From 979a51b4cb722401f27b99e6e930a6d50a47e1a6 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Fri, 19 Jun 2026 09:01:47 +0200 Subject: [PATCH 1/6] feat(security): ROLE_DOMAIN_MANAGER, role hierarchy, domain-scoped voter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the authorisation foundation that ADR 006 builds on: - `App\Security\Roles` — three constants (`ROLE_USER`, `ROLE_DOMAIN_MANAGER`, `ROLE_ADMIN`) so controllers and templates stop typing the raw strings. - `role_hierarchy` in `security.yaml`: `ROLE_ADMIN` implies `ROLE_DOMAIN_MANAGER`, so site-wide admins manage every domain from the same surfaces a domain manager uses. - `App\Security\EmailDomain::of(User): ?string` — pure helper that extracts the lowercased domain from a user's email. Reused by the voter today and by the scoped user-management list query in #85. - `App\Security\Voter\ManageUserVoter` — supports the `MANAGE_USER` / `APPROVE_USER` / `BLOCK_USER` attributes against a `User` subject. Grants when the actor (a) holds `ROLE_DOMAIN_MANAGER`, (b) is `ROLE_ADMIN` (short-circuit across every domain), or (c) shares the lowercased email domain with the subject. The voter never reads the subject's `status` — identity state stays orthogonal to authorisation. Sequenced as PR 2 of the User management milestone plan. Independent of PR 1 (#45 + #83, #86) — branched from `develop` in parallel. PR 5 (#64 + #85) consumes the voter and the `EmailDomain` helper. Closes #84. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 10 ++ config/packages/security.yaml | 5 + src/Security/EmailDomain.php | 54 ++++++ src/Security/Roles.php | 42 +++++ src/Security/Voter/ManageUserVoter.php | 95 ++++++++++ tests/Unit/Security/EmailDomainTest.php | 50 ++++++ .../Security/Voter/ManageUserVoterTest.php | 163 ++++++++++++++++++ 7 files changed, 419 insertions(+) create mode 100644 src/Security/EmailDomain.php create mode 100644 src/Security/Roles.php create mode 100644 src/Security/Voter/ManageUserVoter.php create mode 100644 tests/Unit/Security/EmailDomainTest.php create mode 100644 tests/Unit/Security/Voter/ManageUserVoterTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ccda751..822288e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `ROLE_DOMAIN_MANAGER` + `ROLE_ADMIN` role identifiers + (`App\Security\Roles`), `role_hierarchy` wiring in `security.yaml` + so `ROLE_ADMIN` implies `ROLE_DOMAIN_MANAGER`, and a + domain-scoped `ManageUserVoter` that grants the `MANAGE_USER` / + `APPROVE_USER` / `BLOCK_USER` attributes when the acting user is a + domain manager in the subject's email domain (or a site-wide + admin). Lays the authorisation foundation for the admin approval + queue (#64) and the scoped user-management list view (#85) per + ADR 006 + ([#84](https://github.com/itk-dev/ai-lib/issues/84)). - Initial Symfony 8 application scaffold on the ITK Dev Docker `symfony-8` template (phpfpm 8.4, nginx, MariaDB, Mailpit, Traefik), including dev dependencies for coding standards (`php-cs-fixer`, diff --git a/config/packages/security.yaml b/config/packages/security.yaml index bad06f3..9b0a2da 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -3,6 +3,11 @@ security: password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + # ADR 006: ROLE_ADMIN implies ROLE_DOMAIN_MANAGER so the same admin + # surfaces serve both site-wide admins and domain-scoped approvers. + role_hierarchy: + ROLE_ADMIN: [ROLE_DOMAIN_MANAGER] + # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: # used to reload user from session & other features (e.g. switch_user) diff --git a/src/Security/EmailDomain.php b/src/Security/EmailDomain.php new file mode 100644 index 0000000..ecfc697 --- /dev/null +++ b/src/Security/EmailDomain.php @@ -0,0 +1,54 @@ +getEmail(); + if (null === $email) { + return null; + } + + $at = strrpos($email, '@'); + if (false === $at || $at === strlen($email) - 1) { + return null; + } + + return strtolower(substr($email, $at + 1)); + } + + /** + * @codeCoverageIgnore + */ + private function __construct() + { + } +} diff --git a/src/Security/Roles.php b/src/Security/Roles.php new file mode 100644 index 0000000..0a27718 --- /dev/null +++ b/src/Security/Roles.php @@ -0,0 +1,42 @@ +getUser(); + if (!$actor instanceof User) { + return false; + } + + if (!$this->accessDecisionManager->decide($token, [Roles::DOMAIN_MANAGER])) { + return false; + } + + if ($this->accessDecisionManager->decide($token, [Roles::ADMIN])) { + return true; + } + + $actorDomain = EmailDomain::of($actor); + $subjectDomain = EmailDomain::of($subject); + if (null === $actorDomain || null === $subjectDomain) { + return false; + } + + return $actorDomain === $subjectDomain; + } +} diff --git a/tests/Unit/Security/EmailDomainTest.php b/tests/Unit/Security/EmailDomainTest.php new file mode 100644 index 0000000..ce9d1fd --- /dev/null +++ b/tests/Unit/Security/EmailDomainTest.php @@ -0,0 +1,50 @@ +setEmail('Alice@Aarhus.DK'); + + self::assertSame('aarhus.dk', EmailDomain::of($user)); + } + + public function testReturnsNullForUserWithoutEmail(): void + { + self::assertNull(EmailDomain::of(new User())); + } + + public function testReturnsNullWhenEmailHasNoAtSign(): void + { + $user = new User(); + $user->setEmail('not-an-email'); + + self::assertNull(EmailDomain::of($user)); + } + + public function testReturnsNullWhenEmailEndsWithAtSign(): void + { + $user = new User(); + $user->setEmail('orphan@'); + + self::assertNull(EmailDomain::of($user)); + } + + public function testHandlesSubaddressingByKeepingTheDomainOnly(): void + { + // "user+tag@domain" still has exactly one @; the helper splits on the rightmost. + $user = new User(); + $user->setEmail('alice+sub@aarhus.dk'); + + self::assertSame('aarhus.dk', EmailDomain::of($user)); + } +} diff --git a/tests/Unit/Security/Voter/ManageUserVoterTest.php b/tests/Unit/Security/Voter/ManageUserVoterTest.php new file mode 100644 index 0000000..ccf6da2 --- /dev/null +++ b/tests/Unit/Security/Voter/ManageUserVoterTest.php @@ -0,0 +1,163 @@ +voterAllowing(); + $token = $this->tokenFor(null); + + self::assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($token, $this->userWithEmail('alice@aarhus.dk'), [ManageUserVoter::MANAGE]), + ); + } + + public function testDeniesWhenActorLacksDomainManagerRole(): void + { + $voter = $this->voterWithRoles([]); + $token = $this->tokenFor($this->userWithEmail('charlie@aarhus.dk')); + + self::assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($token, $this->userWithEmail('alice@aarhus.dk'), [ManageUserVoter::MANAGE]), + ); + } + + public function testGrantsAdminAcrossDomains(): void + { + $voter = $this->voterWithRoles([Roles::DOMAIN_MANAGER, Roles::ADMIN]); + $token = $this->tokenFor($this->userWithEmail('siteadmin@aarhus.dk')); + + self::assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($token, $this->userWithEmail('subject@aalborg.dk'), [ManageUserVoter::MANAGE]), + ); + } + + public function testGrantsDomainManagerWithinSameDomain(): void + { + $voter = $this->voterWithRoles([Roles::DOMAIN_MANAGER]); + $token = $this->tokenFor($this->userWithEmail('manager@aarhus.dk')); + + self::assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($token, $this->userWithEmail('alice@aarhus.dk'), [ManageUserVoter::APPROVE]), + ); + } + + public function testDeniesDomainManagerAcrossDifferentDomains(): void + { + $voter = $this->voterWithRoles([Roles::DOMAIN_MANAGER]); + $token = $this->tokenFor($this->userWithEmail('manager@aarhus.dk')); + + self::assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($token, $this->userWithEmail('alice@aalborg.dk'), [ManageUserVoter::BLOCK]), + ); + } + + public function testIsCaseInsensitiveOnTheDomainComparison(): void + { + $voter = $this->voterWithRoles([Roles::DOMAIN_MANAGER]); + $token = $this->tokenFor($this->userWithEmail('manager@Aarhus.DK')); + + self::assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($token, $this->userWithEmail('alice@AARHUS.dk'), [ManageUserVoter::MANAGE]), + ); + } + + public function testDeniesWhenSubjectHasNoEmail(): void + { + $voter = $this->voterWithRoles([Roles::DOMAIN_MANAGER]); + $token = $this->tokenFor($this->userWithEmail('manager@aarhus.dk')); + $subject = new User(); // email left null + + self::assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($token, $subject, [ManageUserVoter::MANAGE]), + ); + } + + public function testDeniesWhenActorHasNoEmail(): void + { + $voter = $this->voterWithRoles([Roles::DOMAIN_MANAGER]); + $token = $this->tokenFor(new User()); + + self::assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($token, $this->userWithEmail('alice@aarhus.dk'), [ManageUserVoter::MANAGE]), + ); + } + + public function testAbstainsOnUnsupportedAttribute(): void + { + $voter = $this->voterAllowing(); + $token = $this->tokenFor($this->userWithEmail('alice@aarhus.dk')); + + self::assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($token, $this->userWithEmail('bob@aarhus.dk'), ['SOMETHING_ELSE']), + ); + } + + public function testAbstainsOnUnsupportedSubject(): void + { + $voter = $this->voterAllowing(); + $token = $this->tokenFor($this->userWithEmail('alice@aarhus.dk')); + + self::assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($token, new \stdClass(), [ManageUserVoter::MANAGE]), + ); + } + + private function userWithEmail(string $email): User + { + $user = new User(); + $user->setEmail($email); + + return $user; + } + + /** + * @param list $tokenRoles roles the access-decision manager will consider granted + */ + private function voterWithRoles(array $tokenRoles): ManageUserVoter + { + $adm = $this->createMock(AccessDecisionManagerInterface::class); + $adm->method('decide')->willReturnCallback( + static fn (TokenInterface $token, array $attributes): bool => \in_array($attributes[0] ?? null, $tokenRoles, true), + ); + + return new ManageUserVoter($adm); + } + + private function voterAllowing(): ManageUserVoter + { + // For tests that never reach the role check (unsupported attribute / subject + // and "actor is not a User"), the access-decision manager isn't consulted. + return $this->voterWithRoles([Roles::DOMAIN_MANAGER, Roles::ADMIN]); + } + + private function tokenFor(?User $user): TokenInterface + { + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($user); + + return $token; + } +} From 6fee0335815a154047cdc9c399b948462a39a4a6 Mon Sep 17 00:00:00 2001 From: Martin Yde Granath Date: Fri, 19 Jun 2026 09:44:11 +0200 Subject: [PATCH 2/6] Update ManageUserVoter.php Removed ADR reference --- src/Security/Voter/ManageUserVoter.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Security/Voter/ManageUserVoter.php b/src/Security/Voter/ManageUserVoter.php index c658b53..777edcc 100644 --- a/src/Security/Voter/ManageUserVoter.php +++ b/src/Security/Voter/ManageUserVoter.php @@ -16,8 +16,6 @@ * Authorises a `User` action ("approve", "block", or the umbrella * "manage") on a target {@see User}, scoped by email domain. * - * Decision flow (ADR 006): - * * 1. The acting user must hold {@see Roles::DOMAIN_MANAGER}. * 2. If they hold {@see Roles::ADMIN}, allow across all domains * (admins manage everyone). @@ -60,7 +58,7 @@ protected function supports(string $attribute, mixed $subject): bool } /** - * Apply the ADR-006 decision rules to the actor / subject pair. + * Apply the decision rules to the actor / subject pair. * * @param string $attribute the attribute being checked (already filtered to one of `SUPPORTED`) * @param User $subject the user being acted on From d4cef2a90e49a990366d77d929a5b2feb784548b Mon Sep 17 00:00:00 2001 From: Martin Yde Granath Date: Fri, 19 Jun 2026 09:48:44 +0200 Subject: [PATCH 3/6] Update EmailDomain.php --- src/Security/EmailDomain.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Security/EmailDomain.php b/src/Security/EmailDomain.php index ecfc697..0ffa6b1 100644 --- a/src/Security/EmailDomain.php +++ b/src/Security/EmailDomain.php @@ -10,11 +10,11 @@ * Pure helpers for extracting an organisation-identifying domain from * a {@see User}'s email address. * - * The "domain manager" model in ADR 006 derives a user's organisational + * The "domain manager" model derives a user's organisational * scope from the right-hand side of their email rather than carrying a * separate column. Both the voter ({@see Voter\ManageUserVoter}) and - * the future scoped user-management repository finder (#85) need the - * same normalisation, so it lives here in one place. + * user-management repository need th same normalisation, so it lives here in + * one place. */ final class EmailDomain { From 65405453e3f276a1c5a4a6970b5d10dd69de1f16 Mon Sep 17 00:00:00 2001 From: Martin Yde Granath Date: Fri, 19 Jun 2026 09:50:00 +0200 Subject: [PATCH 4/6] Fix comment formatting in Roles.php Corrected comment formatting in Roles.php. --- src/Security/Roles.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Security/Roles.php b/src/Security/Roles.php index 0a27718..be9b5fd 100644 --- a/src/Security/Roles.php +++ b/src/Security/Roles.php @@ -7,7 +7,7 @@ /** * Symfony Security role identifiers used by this application. * - * Decided in ADR 006. `ROLE_ADMIN` implies `ROLE_DOMAIN_MANAGER` via + * `ROLE_ADMIN` implies `ROLE_DOMAIN_MANAGER` via * the `role_hierarchy` entry in `security.yaml`. `ROLE_USER` is the * implicit floor: every authenticated user holds it via * {@see \App\Entity\User::getRoles()}, so this constant exists only From 7a8f45bf90997f49e3bca69bab17d8e9143d0c01 Mon Sep 17 00:00:00 2001 From: Martin Yde Granath Date: Fri, 19 Jun 2026 09:54:07 +0200 Subject: [PATCH 5/6] Refactor CHANGELOG to streamline content Removed redundant details about authorization foundation and user-management features. --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 822288e..3abf6f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 domain-scoped `ManageUserVoter` that grants the `MANAGE_USER` / `APPROVE_USER` / `BLOCK_USER` attributes when the acting user is a domain manager in the subject's email domain (or a site-wide - admin). Lays the authorisation foundation for the admin approval - queue (#64) and the scoped user-management list view (#85) per - ADR 006 + admin). ([#84](https://github.com/itk-dev/ai-lib/issues/84)). - Initial Symfony 8 application scaffold on the ITK Dev Docker `symfony-8` template (phpfpm 8.4, nginx, MariaDB, Mailpit, Traefik), From 6d2162ea4bd83af2625c7f4b34aa4594e2ce5be4 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Fri, 19 Jun 2026 12:14:49 +0200 Subject: [PATCH 6/6] chore(tests): add one-line intent comments to security unit tests Per review on PR #87 - apply the test-comment convention from CLAUDE.md to the two new test files: each `test...` method now opens with a single-line "Tests ...", "Ensures ...", or "Verifies ..." comment naming what it asserts. Pure documentation change - no test logic touched. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Unit/Security/EmailDomainTest.php | 5 +++++ tests/Unit/Security/Voter/ManageUserVoterTest.php | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/tests/Unit/Security/EmailDomainTest.php b/tests/Unit/Security/EmailDomainTest.php index ce9d1fd..8de40c1 100644 --- a/tests/Unit/Security/EmailDomainTest.php +++ b/tests/Unit/Security/EmailDomainTest.php @@ -10,6 +10,7 @@ final class EmailDomainTest extends TestCase { + // Tests that the domain part is returned in lowercase. public function testReturnsLowercasedDomain(): void { $user = new User(); @@ -18,11 +19,13 @@ public function testReturnsLowercasedDomain(): void self::assertSame('aarhus.dk', EmailDomain::of($user)); } + // Verifies null is returned when the user has no email set. public function testReturnsNullForUserWithoutEmail(): void { self::assertNull(EmailDomain::of(new User())); } + // Ensures null is returned for input that contains no '@'. public function testReturnsNullWhenEmailHasNoAtSign(): void { $user = new User(); @@ -31,6 +34,7 @@ public function testReturnsNullWhenEmailHasNoAtSign(): void self::assertNull(EmailDomain::of($user)); } + // Ensures null is returned for input with an empty domain part (trailing '@'). public function testReturnsNullWhenEmailEndsWithAtSign(): void { $user = new User(); @@ -39,6 +43,7 @@ public function testReturnsNullWhenEmailEndsWithAtSign(): void self::assertNull(EmailDomain::of($user)); } + // Tests that 'user+tag@domain' still resolves to the bare domain. public function testHandlesSubaddressingByKeepingTheDomainOnly(): void { // "user+tag@domain" still has exactly one @; the helper splits on the rightmost. diff --git a/tests/Unit/Security/Voter/ManageUserVoterTest.php b/tests/Unit/Security/Voter/ManageUserVoterTest.php index ccf6da2..95c67a0 100644 --- a/tests/Unit/Security/Voter/ManageUserVoterTest.php +++ b/tests/Unit/Security/Voter/ManageUserVoterTest.php @@ -14,6 +14,7 @@ final class ManageUserVoterTest extends TestCase { + // Tests that the voter denies access when the token has no User actor. public function testDeniesWhenActorIsNotAUser(): void { $voter = $this->voterAllowing(); @@ -25,6 +26,7 @@ public function testDeniesWhenActorIsNotAUser(): void ); } + // Ensures access is denied when the actor lacks ROLE_DOMAIN_MANAGER even within the same domain. public function testDeniesWhenActorLacksDomainManagerRole(): void { $voter = $this->voterWithRoles([]); @@ -36,6 +38,7 @@ public function testDeniesWhenActorLacksDomainManagerRole(): void ); } + // Verifies ROLE_ADMIN short-circuits the same-domain check and grants across any domain. public function testGrantsAdminAcrossDomains(): void { $voter = $this->voterWithRoles([Roles::DOMAIN_MANAGER, Roles::ADMIN]); @@ -47,6 +50,7 @@ public function testGrantsAdminAcrossDomains(): void ); } + // Tests that a domain manager can act on a subject in the same email domain. public function testGrantsDomainManagerWithinSameDomain(): void { $voter = $this->voterWithRoles([Roles::DOMAIN_MANAGER]); @@ -58,6 +62,7 @@ public function testGrantsDomainManagerWithinSameDomain(): void ); } + // Ensures a domain manager cannot act on subjects in a different email domain. public function testDeniesDomainManagerAcrossDifferentDomains(): void { $voter = $this->voterWithRoles([Roles::DOMAIN_MANAGER]); @@ -69,6 +74,7 @@ public function testDeniesDomainManagerAcrossDifferentDomains(): void ); } + // Verifies the domain comparison is case-insensitive on both actor and subject. public function testIsCaseInsensitiveOnTheDomainComparison(): void { $voter = $this->voterWithRoles([Roles::DOMAIN_MANAGER]); @@ -80,6 +86,7 @@ public function testIsCaseInsensitiveOnTheDomainComparison(): void ); } + // Tests that the voter denies when the subject has no email to derive a domain from. public function testDeniesWhenSubjectHasNoEmail(): void { $voter = $this->voterWithRoles([Roles::DOMAIN_MANAGER]); @@ -92,6 +99,7 @@ public function testDeniesWhenSubjectHasNoEmail(): void ); } + // Tests that the voter denies when the actor has no email to derive a domain from. public function testDeniesWhenActorHasNoEmail(): void { $voter = $this->voterWithRoles([Roles::DOMAIN_MANAGER]); @@ -103,6 +111,7 @@ public function testDeniesWhenActorHasNoEmail(): void ); } + // Ensures the voter abstains on attributes it doesn't claim to support. public function testAbstainsOnUnsupportedAttribute(): void { $voter = $this->voterAllowing(); @@ -114,6 +123,7 @@ public function testAbstainsOnUnsupportedAttribute(): void ); } + // Ensures the voter abstains on subjects that aren't User instances. public function testAbstainsOnUnsupportedSubject(): void { $voter = $this->voterAllowing();