Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ 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).
([#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`,
Expand Down
5 changes: 5 additions & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
54 changes: 54 additions & 0 deletions src/Security/EmailDomain.php
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()
{
}
}
42 changes: 42 additions & 0 deletions src/Security/Roles.php
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()
{
}
}
93 changes: 93 additions & 0 deletions src/Security/Voter/ManageUserVoter.php
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;
}
}
55 changes: 55 additions & 0 deletions tests/Unit/Security/EmailDomainTest.php
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
{
Comment thread
martinyde marked this conversation as resolved.
// 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));
}
}
Loading