-
Notifications
You must be signed in to change notification settings - Fork 0
feat(security): ROLE_DOMAIN_MANAGER, role hierarchy, domain-scoped voter (#84) #87
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
martinydeAI
wants to merge
6
commits into
develop
Choose a base branch
from
feature/issue-84-roles-and-voter
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
979a51b
feat(security): ROLE_DOMAIN_MANAGER, role hierarchy, domain-scoped voter
martinydeAI 6fee033
Update ManageUserVoter.php
martinyde d4cef2a
Update EmailDomain.php
martinyde 6540545
Fix comment formatting in Roles.php
martinyde 7a8f45b
Refactor CHANGELOG to streamline content
martinyde 6d2162e
chore(tests): add one-line intent comments to security unit tests
martinydeAI File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Security; | ||
|
|
||
| use App\Entity\User; | ||
|
|
||
| /** | ||
| * Pure helpers for extracting an organisation-identifying domain from | ||
| * a {@see User}'s email address. | ||
| * | ||
| * 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 | ||
| * user-management repository need th same normalisation, so it lives here in | ||
| * one place. | ||
| */ | ||
| final class EmailDomain | ||
| { | ||
| /** | ||
| * Extract the lowercased domain from a user's email. | ||
| * | ||
| * Returns `null` when the user has no email set or when the email | ||
| * has no `@` (defensive — both cases should be unreachable for a | ||
| * persisted user, but the caller's authorisation decision must | ||
| * still fail closed if they happen). | ||
| * | ||
| * @param User $user the user whose email to inspect | ||
| * | ||
| * @return string|null lowercased domain ("aarhus.dk") or `null` when undeterminable | ||
| */ | ||
| public static function of(User $user): ?string | ||
| { | ||
| $email = $user->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() | ||
| { | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Security; | ||
|
|
||
| /** | ||
| * Symfony Security role identifiers used by this application. | ||
| * | ||
| * `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 | ||
| * to remove magic strings from `IsGranted` attributes and voter | ||
| * call-sites. | ||
| */ | ||
| final class Roles | ||
| { | ||
| /** | ||
| * The implicit floor every authenticated user holds. | ||
| */ | ||
| public const string USER = 'ROLE_USER'; | ||
|
|
||
| /** | ||
| * Authority to approve / block accounts within the manager's own | ||
| * email domain. Holders can also see the scoped admin user list. | ||
| */ | ||
| public const string DOMAIN_MANAGER = 'ROLE_DOMAIN_MANAGER'; | ||
|
|
||
| /** | ||
| * Site-wide admin. Implies `ROLE_DOMAIN_MANAGER` across every | ||
| * email domain via the role hierarchy. | ||
| */ | ||
| public const string ADMIN = 'ROLE_ADMIN'; | ||
|
|
||
| /** | ||
| * @codeCoverageIgnore | ||
| */ | ||
| private function __construct() | ||
| { | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Security\Voter; | ||
|
|
||
| use App\Entity\User; | ||
| use App\Security\EmailDomain; | ||
| use App\Security\Roles; | ||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||
| use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; | ||
| use Symfony\Component\Security\Core\Authorization\Voter\Vote; | ||
| use Symfony\Component\Security\Core\Authorization\Voter\Voter; | ||
|
|
||
| /** | ||
| * Authorises a `User` action ("approve", "block", or the umbrella | ||
| * "manage") on a target {@see User}, scoped by email domain. | ||
| * | ||
| * 1. The acting user must hold {@see Roles::DOMAIN_MANAGER}. | ||
| * 2. If they hold {@see Roles::ADMIN}, allow across all domains | ||
| * (admins manage everyone). | ||
| * 3. Otherwise allow iff the actor and subject share the lowercased | ||
| * email domain returned by {@see EmailDomain::of()}. | ||
| * | ||
| * The voter never reads the subject's `status` — identity state | ||
| * (signed-in or not) is a separate concern handled by the | ||
| * `UserCheckerInterface` (#63). Authorisation only answers "what may | ||
| * this signed-in actor do to that target?". | ||
| */ | ||
| final class ManageUserVoter extends Voter | ||
| { | ||
| public const string MANAGE = 'MANAGE_USER'; | ||
| public const string APPROVE = 'APPROVE_USER'; | ||
| public const string BLOCK = 'BLOCK_USER'; | ||
|
|
||
| private const array SUPPORTED = [self::MANAGE, self::APPROVE, self::BLOCK]; | ||
|
|
||
| /** | ||
| * @param AccessDecisionManagerInterface $accessDecisionManager used to evaluate the actor's roles via the configured role hierarchy | ||
| */ | ||
| public function __construct( | ||
| private readonly AccessDecisionManagerInterface $accessDecisionManager, | ||
| ) { | ||
| } | ||
|
|
||
| /** | ||
| * Vote only on `MANAGE_USER`, `APPROVE_USER`, `BLOCK_USER` with a | ||
| * `User` subject. Any other combination defers. | ||
| * | ||
| * @param string $attribute the attribute being checked | ||
| * @param mixed $subject the object the attribute is checked against | ||
| * | ||
| * @return bool true when this voter has an opinion to give | ||
| */ | ||
| protected function supports(string $attribute, mixed $subject): bool | ||
| { | ||
| return $subject instanceof User && \in_array($attribute, self::SUPPORTED, true); | ||
| } | ||
|
|
||
| /** | ||
| * 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 | ||
| * @param TokenInterface $token the acting user's authentication token | ||
| * @param Vote|null $vote Symfony 8 vote-explanation slot; unused here | ||
| * | ||
| * @return bool true to grant, false to deny | ||
| */ | ||
| protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool | ||
| { | ||
| $actor = $token->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; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Tests\Unit\Security; | ||
|
|
||
| use App\Entity\User; | ||
| use App\Security\EmailDomain; | ||
| use PHPUnit\Framework\TestCase; | ||
|
|
||
| final class EmailDomainTest extends TestCase | ||
| { | ||
| // Tests that the domain part is returned in lowercase. | ||
| public function testReturnsLowercasedDomain(): void | ||
| { | ||
| $user = new User(); | ||
| $user->setEmail('Alice@Aarhus.DK'); | ||
|
|
||
| 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(); | ||
| $user->setEmail('not-an-email'); | ||
|
|
||
| self::assertNull(EmailDomain::of($user)); | ||
| } | ||
|
|
||
| // Ensures null is returned for input with an empty domain part (trailing '@'). | ||
| public function testReturnsNullWhenEmailEndsWithAtSign(): void | ||
| { | ||
| $user = new User(); | ||
| $user->setEmail('orphan@'); | ||
|
|
||
| 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. | ||
| $user = new User(); | ||
| $user->setEmail('alice+sub@aarhus.dk'); | ||
|
|
||
| self::assertSame('aarhus.dk', EmailDomain::of($user)); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.