| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386 |
- <?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);
- }
- }
- }
|