|
|
@@ -0,0 +1,386 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace App\Tests\Integration\Auth;
|
|
|
+
|
|
|
+use App\Domain\Auth\Role;
|
|
|
+use App\Domain\Auth\TokenKind;
|
|
|
+use App\Tests\Integration\Support\AppTestCase;
|
|
|
+
|
|
|
+/**
|
|
|
+ * SEC_REVIEW F11: a disabled `users` row is unimpersonatable, refuses
|
|
|
+ * OIDC re-login (no role recompute, no audit drift), and refuses local
|
|
|
+ * sign-in. The admin user-CRUD endpoint is the only path that toggles
|
|
|
+ * `disabled_at` — it audits both directions and refuses self-disable
|
|
|
+ * and local-admin-disable.
|
|
|
+ */
|
|
|
+final class DisabledUserTest extends AppTestCase
|
|
|
+{
|
|
|
+ public function testServiceTokenImpersonatingDisabledUserReturns403(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
+ $userId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-disabled', disabled: true);
|
|
|
+
|
|
|
+ $response = $this->request('GET', '/api/v1/admin/me', [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'X-Acting-User-Id' => (string) $userId,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ self::assertSame(403, $response->getStatusCode());
|
|
|
+ self::assertSame('user_disabled', $this->decode($response)['error']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testEnabledUserOfSameKindStillWorksWhenAnotherIsDisabled(): void
|
|
|
+ {
|
|
|
+ // Sanity: disabling user A must not break impersonation for user B.
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
+ $disabledId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-A', disabled: true);
|
|
|
+ $activeId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-B', disabled: false);
|
|
|
+
|
|
|
+ $disabled = $this->request('GET', '/api/v1/admin/me', [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'X-Acting-User-Id' => (string) $disabledId,
|
|
|
+ ]);
|
|
|
+ self::assertSame(403, $disabled->getStatusCode());
|
|
|
+
|
|
|
+ $active = $this->request('GET', '/api/v1/admin/me', [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'X-Acting-User-Id' => (string) $activeId,
|
|
|
+ ]);
|
|
|
+ self::assertSame(200, $active->getStatusCode());
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testUpsertOidcRefusesDisabledUserAndDoesNotRecomputeRole(): void
|
|
|
+ {
|
|
|
+ // A returning OIDC subject whose row was disabled must 403,
|
|
|
+ // and the role on the (still disabled) row must remain
|
|
|
+ // whatever it was — we must NOT have applied the new groups.
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
+ $this->db->insert('users', [
|
|
|
+ 'subject' => 'churn-disabled',
|
|
|
+ 'email' => 'old@example.com',
|
|
|
+ 'display_name' => 'Churned',
|
|
|
+ 'role' => Role::Viewer->value,
|
|
|
+ 'is_local' => 0,
|
|
|
+ 'disabled_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $this->db->insert('oidc_role_mappings', [
|
|
|
+ 'group_id' => 'admin-grp',
|
|
|
+ 'role' => Role::Admin->value,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $response = $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/auth/users/upsert-oidc',
|
|
|
+ [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ],
|
|
|
+ (string) json_encode([
|
|
|
+ 'subject' => 'churn-disabled',
|
|
|
+ 'email' => 'new@example.com',
|
|
|
+ 'display_name' => 'Renamed',
|
|
|
+ 'groups' => ['admin-grp'],
|
|
|
+ ])
|
|
|
+ );
|
|
|
+
|
|
|
+ self::assertSame(403, $response->getStatusCode());
|
|
|
+ self::assertSame('user_disabled', $this->decode($response)['error']);
|
|
|
+
|
|
|
+ // Role must not have been recomputed.
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
+ "SELECT role, email, display_name FROM users WHERE subject = 'churn-disabled'"
|
|
|
+ );
|
|
|
+ self::assertIsArray($row);
|
|
|
+ self::assertSame('viewer', $row['role'], 'disabled user role must not be recomputed from new groups');
|
|
|
+ self::assertSame('old@example.com', $row['email']);
|
|
|
+ self::assertSame('Churned', $row['display_name']);
|
|
|
+
|
|
|
+ // No user.role_changed audit row from this denied attempt.
|
|
|
+ $count = (int) $this->db->fetchOne(
|
|
|
+ "SELECT COUNT(*) FROM audit_log WHERE action = 'user.role_changed'"
|
|
|
+ );
|
|
|
+ self::assertSame(0, $count, 'denied login must not emit role_changed audit');
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testUpsertLocalRefusesWhenLocalAdminDisabled(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
+ // Seed the local admin row pre-disabled.
|
|
|
+ $this->createUser(Role::Admin, isLocal: true, disabled: true);
|
|
|
+
|
|
|
+ $response = $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/auth/users/upsert-local',
|
|
|
+ [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ],
|
|
|
+ (string) json_encode(['username' => 'admin'])
|
|
|
+ );
|
|
|
+
|
|
|
+ self::assertSame(403, $response->getStatusCode());
|
|
|
+ self::assertSame('user_disabled', $this->decode($response)['error']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testActorViaIsLocalForServiceImpersonationOfLocalAdmin(): void
|
|
|
+ {
|
|
|
+ // Layer B2: any audit emitted while impersonating a local user
|
|
|
+ // carries actor_via='local'; an OIDC user yields 'oidc'.
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
+ $localAdmin = $this->createUser(Role::Admin, isLocal: true);
|
|
|
+ $oidcAdmin = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-via-oidc');
|
|
|
+
|
|
|
+ // Drive an audit-emitting write under the local admin.
|
|
|
+ $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/admin/manual-blocks',
|
|
|
+ [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'X-Acting-User-Id' => (string) $localAdmin,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ],
|
|
|
+ (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.1', 'reason' => 'via=local'])
|
|
|
+ );
|
|
|
+
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
+ "SELECT actor_kind, actor_id, actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
|
|
|
+ );
|
|
|
+ self::assertIsArray($row);
|
|
|
+ self::assertSame('user', $row['actor_kind']);
|
|
|
+ self::assertSame((string) $localAdmin, $row['actor_id']);
|
|
|
+ self::assertSame('local', $row['actor_via']);
|
|
|
+
|
|
|
+ // Same write under the OIDC admin → actor_via='oidc'.
|
|
|
+ $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/admin/manual-blocks',
|
|
|
+ [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'X-Acting-User-Id' => (string) $oidcAdmin,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ],
|
|
|
+ (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.2', 'reason' => 'via=oidc'])
|
|
|
+ );
|
|
|
+
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
+ "SELECT actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
|
|
|
+ );
|
|
|
+ self::assertIsArray($row);
|
|
|
+ self::assertSame('oidc', $row['actor_via']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testActorViaIsAdminTokenForBareAdminToken(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
|
|
|
+
|
|
|
+ $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/admin/manual-blocks',
|
|
|
+ [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ],
|
|
|
+ (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.3', 'reason' => 'via=admin-token'])
|
|
|
+ );
|
|
|
+
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
+ "SELECT actor_kind, actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
|
|
|
+ );
|
|
|
+ self::assertIsArray($row);
|
|
|
+ self::assertSame('admin-token', $row['actor_kind']);
|
|
|
+ self::assertSame('admin-token', $row['actor_via']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testAdminUsersListIncludesDisabledFlag(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
|
|
|
+ $active = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-active');
|
|
|
+ $disabled = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-off', disabled: true);
|
|
|
+
|
|
|
+ $response = $this->request('GET', '/api/v1/admin/users', [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ ]);
|
|
|
+ self::assertSame(200, $response->getStatusCode());
|
|
|
+
|
|
|
+ $body = $this->decode($response);
|
|
|
+ $byId = [];
|
|
|
+ foreach ($body['items'] as $item) {
|
|
|
+ $byId[(int) $item['id']] = $item;
|
|
|
+ }
|
|
|
+ self::assertArrayHasKey($active, $byId);
|
|
|
+ self::assertArrayHasKey($disabled, $byId);
|
|
|
+ self::assertFalse($byId[$active]['disabled']);
|
|
|
+ self::assertNull($byId[$active]['disabled_at']);
|
|
|
+ self::assertTrue($byId[$disabled]['disabled']);
|
|
|
+ self::assertNotNull($byId[$disabled]['disabled_at']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testAdminPatchDisablesUserAndEmitsAudit(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
|
|
|
+ $target = $this->createUser(Role::Viewer, isLocal: false, subject: 'patch-target');
|
|
|
+
|
|
|
+ $response = $this->request(
|
|
|
+ 'PATCH',
|
|
|
+ '/api/v1/admin/users/' . $target,
|
|
|
+ [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ],
|
|
|
+ (string) json_encode(['disabled' => true])
|
|
|
+ );
|
|
|
+ self::assertSame(200, $response->getStatusCode());
|
|
|
+ $body = $this->decode($response);
|
|
|
+ self::assertTrue($body['disabled']);
|
|
|
+ self::assertNotNull($body['disabled_at']);
|
|
|
+
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
+ "SELECT action, target_id, actor_kind FROM audit_log WHERE action = 'user.disabled' ORDER BY id DESC LIMIT 1"
|
|
|
+ );
|
|
|
+ self::assertIsArray($row);
|
|
|
+ self::assertSame((string) $target, $row['target_id']);
|
|
|
+
|
|
|
+ // Reverse — flipping back emits user.enabled.
|
|
|
+ $back = $this->request(
|
|
|
+ 'PATCH',
|
|
|
+ '/api/v1/admin/users/' . $target,
|
|
|
+ [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ],
|
|
|
+ (string) json_encode(['disabled' => false])
|
|
|
+ );
|
|
|
+ self::assertSame(200, $back->getStatusCode());
|
|
|
+ self::assertFalse($this->decode($back)['disabled']);
|
|
|
+
|
|
|
+ $enabledRow = $this->db->fetchAssociative(
|
|
|
+ "SELECT action FROM audit_log WHERE action = 'user.enabled' ORDER BY id DESC LIMIT 1"
|
|
|
+ );
|
|
|
+ self::assertIsArray($enabledRow);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testAdminPatchRefusesSelfDisable(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
+ $self = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-self');
|
|
|
+
|
|
|
+ $response = $this->request(
|
|
|
+ 'PATCH',
|
|
|
+ '/api/v1/admin/users/' . $self,
|
|
|
+ [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'X-Acting-User-Id' => (string) $self,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ],
|
|
|
+ (string) json_encode(['disabled' => true])
|
|
|
+ );
|
|
|
+ self::assertSame(409, $response->getStatusCode());
|
|
|
+ self::assertSame('cannot_disable_self', $this->decode($response)['error']);
|
|
|
+
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
+ 'SELECT disabled_at FROM users WHERE id = :id',
|
|
|
+ ['id' => $self]
|
|
|
+ );
|
|
|
+ self::assertIsArray($row);
|
|
|
+ self::assertNull($row['disabled_at']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testAdminPatchRefusesDisablingLocalAdmin(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
|
|
|
+ $local = $this->createUser(Role::Admin, isLocal: true);
|
|
|
+
|
|
|
+ $response = $this->request(
|
|
|
+ 'PATCH',
|
|
|
+ '/api/v1/admin/users/' . $local,
|
|
|
+ [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ],
|
|
|
+ (string) json_encode(['disabled' => true])
|
|
|
+ );
|
|
|
+ self::assertSame(409, $response->getStatusCode());
|
|
|
+ self::assertSame('cannot_disable_local_admin', $this->decode($response)['error']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testAdminPatchIsIdempotentWithNoAuditOnNoOp(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
|
|
|
+ $target = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-noop');
|
|
|
+
|
|
|
+ // Already enabled → patch enabled=false (no, we want already-active, set enabled): set disabled=false (no-op).
|
|
|
+ $beforeAuditCount = (int) $this->db->fetchOne(
|
|
|
+ "SELECT COUNT(*) FROM audit_log WHERE action IN ('user.disabled', 'user.enabled')"
|
|
|
+ );
|
|
|
+
|
|
|
+ $response = $this->request(
|
|
|
+ 'PATCH',
|
|
|
+ '/api/v1/admin/users/' . $target,
|
|
|
+ [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ],
|
|
|
+ (string) json_encode(['disabled' => false])
|
|
|
+ );
|
|
|
+ self::assertSame(200, $response->getStatusCode());
|
|
|
+
|
|
|
+ $afterAuditCount = (int) $this->db->fetchOne(
|
|
|
+ "SELECT COUNT(*) FROM audit_log WHERE action IN ('user.disabled', 'user.enabled')"
|
|
|
+ );
|
|
|
+ self::assertSame($beforeAuditCount, $afterAuditCount, 'no-op patch must not emit audit');
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testAdminUsersRequiresAdminRole(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
|
|
|
+
|
|
|
+ $response = $this->request('GET', '/api/v1/admin/users', [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ ]);
|
|
|
+ self::assertSame(403, $response->getStatusCode());
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testAuditLogActorViaFilter(): void
|
|
|
+ {
|
|
|
+ // Filter on actor_via='local' returns only impersonated-local rows.
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
+ $local = $this->createUser(Role::Admin, isLocal: true);
|
|
|
+ $oidc = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-filter');
|
|
|
+
|
|
|
+ $localToken = $this->createToken(TokenKind::Admin, role: Role::Admin);
|
|
|
+
|
|
|
+ // Three writes: two impersonated (one local, one oidc) + one admin token.
|
|
|
+ $this->request('POST', '/api/v1/admin/manual-blocks', [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'X-Acting-User-Id' => (string) $local,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.1', 'reason' => 'l']));
|
|
|
+ $this->request('POST', '/api/v1/admin/manual-blocks', [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'X-Acting-User-Id' => (string) $oidc,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.2', 'reason' => 'o']));
|
|
|
+ $this->request('POST', '/api/v1/admin/manual-blocks', [
|
|
|
+ 'Authorization' => 'Bearer ' . $localToken,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.3', 'reason' => 'a']));
|
|
|
+
|
|
|
+ // Admin-list filtered to actor_via=local.
|
|
|
+ $resp = $this->request(
|
|
|
+ 'GET',
|
|
|
+ '/api/v1/admin/audit-log?actor_via=local',
|
|
|
+ ['Authorization' => 'Bearer ' . $localToken],
|
|
|
+ );
|
|
|
+ self::assertSame(200, $resp->getStatusCode());
|
|
|
+
|
|
|
+ $body = $this->decode($resp);
|
|
|
+ $vias = array_map(static fn (array $row) => $row['actor_via'], $body['items']);
|
|
|
+ self::assertNotEmpty($vias);
|
|
|
+ foreach ($vias as $via) {
|
|
|
+ self::assertSame('local', $via);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|