alexandrebulete / ddd-iam-bundle
Identity & Access Management as a reusable DDD Bounded Context — users, roles, credentials, audit log, Sylius admin CRUD.
Package info
github.com/AlexandreBulete/ddd-iam-bundle
Type:symfony-bundle
pkg:composer/alexandrebulete/ddd-iam-bundle
Requires
- php: ^8.4
- alexandrebulete/ddd-doctrine-bridge: ^1.4
- alexandrebulete/ddd-foundation: ^1.5
- alexandrebulete/ddd-sylius-bundle: ^1.0
- alexandrebulete/ddd-symfony-bundle: ^1.5
- doctrine/dbal: ^4.3
- doctrine/doctrine-bundle: ^2.13 || ^3.0
- doctrine/doctrine-migrations-bundle: ^3.7 || ^4.0
- doctrine/migrations: ^3.8
- doctrine/orm: ^3.0
- pagerfanta/core: ^4.0
- sylius/admin-ui: >=0.10
- sylius/grid-bundle: ^1.13
- sylius/resource-bundle: >=1.14
- symfony/clock: ^7.0 || ^8.0
- symfony/config: ^7.0 || ^8.0
- symfony/console: ^7.0 || ^8.0
- symfony/dependency-injection: ^7.0 || ^8.0
- symfony/event-dispatcher: ^7.0 || ^8.0
- symfony/form: ^7.0 || ^8.0
- symfony/framework-bundle: ^7.0 || ^8.0
- symfony/http-foundation: ^7.0 || ^8.0
- symfony/password-hasher: ^7.0 || ^8.0
- symfony/routing: ^7.0 || ^8.0
- symfony/security-bundle: ^7.3 || ^8.0
- symfony/security-core: ^7.3 || ^8.0
- symfony/security-http: ^7.3 || ^8.0
- symfony/translation: ^7.0 || ^8.0
- symfony/uid: ^7.0 || ^8.0
- symfony/validator: ^7.0 || ^8.0
- webmozart/assert: ^1.11
Requires (Dev)
- deptrac/deptrac: ^4.7
- phpstan/phpstan: ^2.1
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.0 || ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Identity & Access Management as a drop-in Bounded Context, for the FrankenPHP / Symfony / Sylius / DDD stack.
composer require alexandrebulete/ddd-iam-bundle gives a project:
- a
Useraggregate — email, password, lifecycle, roles; - authentication against the Sylius back office (
IamUserProvider); - a user CRUD in the admin, with grid, filters and a role editor;
- an append-only audit log of every account change;
iam:create-super-admin, the CLI that solves the chicken-and-egg problem.
No file to copy, no namespace to rewrite. What a project legitimately differs on is configuration.
Install
composer require alexandrebulete/ddd-iam-bundle
Then, in the application:
# config/routes/iam.yaml iam: resource: '@DddIamBundle/config/routes.php'
# config/packages/security.yaml security: password_hashers: AlexandreBulete\DddSymfonyBundle\Security\SecurityUser: 'auto' providers: app_admin_user_provider: id: AlexandreBulete\DddIamBundle\Infrastructure\Security\IamUserProvider firewalls: # Agents, by API token (see *Agents and API tokens*). Only if you have an API. api: pattern: ^/api stateless: true access_token: token_handler: AlexandreBulete\DddIamBundle\Infrastructure\Security\ApiTokenHandler admin: context: admin pattern: '/admin(?:/.*)?$' provider: app_admin_user_provider form_login: login_path: sylius_admin_ui_login check_path: sylius_admin_ui_login_check default_target_path: sylius_admin_ui_dashboard logout: path: sylius_admin_ui_logout target: sylius_admin_ui_login access_control: - { path: ^/admin/login, roles: PUBLIC_ACCESS } - { path: ^/admin/logout, roles: PUBLIC_ACCESS } - { path: ^/admin, roles: backoffice.access } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
backoffice.access is a permission, not a role: it is ticked in a role
like any other (see Roles and permissions below).
Then:
bin/console doctrine:migrations:migrate bin/console iam:create-super-admin --email=you@example.com
The bundle ships its own migrations — never run migrations:diff for IAM
tables, that would copy the bundle's schema into your history. They are
registered as services (the bundle turns on DoctrineMigrationsBundle's
enable_service_migrations) so they receive iam.table_prefix, and they are
built with the Schema API, so the DDL follows your platform. Their versions
live under AlexandreBulete\DddIamBundle\…\Migrations and sort before
DoctrineMigrations\…: IAM tables exist before any project migration that
references them.
Do not set enable_service_migrations: false in your application: the IAM
migrations would silently disappear.
Custom user_class: the bundle migration creates its own columns; your
subclass's extra columns go through your own migrations:diff.
Where the line is
The bundle owns identity, credentials, lifecycle and authorisation. It does not own profile or business data: a phone number, an avatar, a store assignment, a signature block are not IAM.
That is not a limitation, it is the reason the bundle can be shared between
projects that have nothing else in common. Anything else goes in a companion
aggregate in your own Bounded Context, keyed by UserId, with no
cross-context foreign key:
final class StaffMember { private function __construct( private(set) StaffMemberId $id, private(set) UserId $userId, // published language, not an association private(set) ?StoreCode $storeCode, ) {} }
Provision it by listening to the bundle's events:
#[AsEventListener] final readonly class ProvisionStaffMemberOnUserCreated { public function __invoke(UserCreated $event): void { /* … */ } }
firstName / lastName are the deliberate exception: every user list needs a
human label.
Extension seams
1. Roles and permissions — data, not configuration
Authorization is by permission (ddd-symfony-bundle, ADR 0008 of the
project that drove it): every command and query is a permission, discovered
from its handler — iam.create_user, client.find_clients… — plus a few
entry points like backoffice.access.
A role is a named set of permissions, defined in the back office (Identity › Roles): name it, tick its permissions, assign it to users. No YAML, no deploy. A new use case appears in the role screen by itself.
super_adminis the one system role, created by the bundle's migration: every permission, present and future; it cannot be restricted nor removed.- No default role: an account created without a role can do nothing.
- No escalation: whoever acts hands out only what they hold — permissions
on a role, roles on a user (
super_adminonly by asuper_admin). - A role still carried by users or agents cannot be removed.
Providing a PermissionCheckerInterface is what turns authorization on: every
command and query on the buses is then checked (a refusal is a 403 over HTTP),
and the menu shows only what its screens allow. In templates and
access_control, a permission is used like a role:
is_granted('iam.find_users'), roles: backoffice.access.
A role that must exist in every environment is created by a migration of
the project (insert into iam_role); the back office covers the rest.
2. Agents and API tokens — iam.api_tokens
An agent is a non-human account: an AI agent, a CI job, an integration. It has roles and is authorized exactly like a person, but it has no email, no password, and never signs in to the back office: it presents an API token.
- Identity › Agents: create the agent, give it a role.
- Identity › API tokens: issue a token for it — label, lifetime (90 days by default, a year at most). The token is shown once; only its SHA-256 is stored. Several tokens per agent: rotate by issuing the next one, deploying it, revoking the previous one.
- Revoking an agent revokes its tokens; suspending it stops them until it is reactivated. The list flags tokens about to expire.
A token reads <prefix>_<id>_<secret>. The prefix (iam.api_tokens.prefix,
iam by default) makes a leaked token recognisable by secret scanners; the id
is the token's, so everything done with it is one filter away in an activity
journal (ddd-activity-bundle keeps it as the actor's credential).
curl -H "Authorization: Bearer $TOKEN" https://app.example/api/iam/me # {"kind":"agent","id":"…","name":"Security watch","token":"…","permissions":[…]}
GET /api/iam/me says who the token authenticates and what it may do: what a
CI job calls to check its token before relying on it.
An agent running inside the application (a worker) needs no token: run its
work as its account, TraceContext::runAs(Actor::agent($id, $name), 'worker', …),
and the same permissions apply.
3. Password policy — iam.password_policy
iam: password_policy: min_length: 12 require_letters: true require_digits: true require_mixed_case: false require_special_character: false
Rules config cannot express (history, HIBP lookup, per-tenant): implement
PasswordPolicyInterface and alias it.
4. Back office — iam.admin
iam: admin: enabled: true # false → headless, no Sylius service at all grid_limits: [10, 25, 50]
Extending a grid. Re-declare the service id with your own subclass — do not
use #[AsDecorator]:
$services->set(UserGrid::class) ->class(UserGridWithStatusFilter::class) ->args([param('iam.admin.grid_limits')]);
A decorator would leave Sylius's sylius.grid tag on the renamed inner service
and the grid would silently disappear from the registry. Re-declaring the id
keeps every tag and autoconfiguration.
Adding a column backed by your own data is the one case config does not
cover: grid fields are read off UserResource, which is final. You need
either your own resource + grid pair, or iam.user_class.
5. Audit — iam.audit
iam: audit: enabled: true
Disabled means absent — no listener, no write. The iam_audit_log table stays
mapped either way: dropping history because a flag flipped is not a thing a
bundle should do.
Events are published through DomainEventPublisherInterface. The default
adapter dispatches in-process, inside the aggregate's transaction. A project
running a transactional outbox binds its own adapter and the bundle neither
knows nor cares:
$services->alias(DomainEventPublisherInterface::class, OutboxEventPublisher::class);
6. Domain events
UserCreated, UserRenamed, UserEmailChanged, UserPasswordChanged,
UserRolesChanged, UserSuspended, UserReactivated, UserRevoked.
Public and stable. UserPasswordChanged deliberately carries no password;
UserRolesChanged carries both the new and the previous set, because the delta
cannot be recomputed after the fact.
7. Table names — iam.table_prefix
iam: table_prefix: iam_
Applied at mapping-load time by TablePrefixListener — a bundle cannot ship a
configurable table name in static XML.
8. The escape hatch — iam.user_class
Subclassing the aggregate. Properties are protected(set) and the constructor
is protected to make it possible, but overriding this means shipping your own
Doctrine mapping for the subclass, and the bundle then maps only the audit log.
Prefer the companion aggregate. This exists for the case that genuinely cannot be modelled that way.
Design notes worth knowing
Roles are stored as JSON, not a join table. They are read on every request,
always loaded and written whole, and never queried from the role side. A join
table would buy referential integrity against a role table that does not exist
— the catalogue lives in configuration. On PostgreSQL the column is jsonb (binary,
comparable, indexable); other platforms get their JSON type.
Deleting a user revokes it. The back office's delete button dispatches
RevokeUserCommand. Erasing the row would leave the audit trail pointing at
nothing. GDPR erasure is a different use case: scrub the personal data, keep the
identifier.
Account status is checked in the user provider, not in a UserChecker.
A UserChecker is more idiomatic and discloses less, but it has to be wired per
firewall and a firewall that forgets it authenticates suspended accounts
silently. For a bundle, a check that cannot be forgotten wins. Revisit the day
post-authentication checks appear (password expiry, MFA enrolment).
Route names are pinned via AsResource(alias: 'iam.user') →
iam_admin_user_index. Derived names would change under anyone who moved a
class.
Supported databases
PostgreSQL, MySQL / MariaDB and SQLite. Every Doctrine type of the bundle maps onto a portable DBAL declaration, and the migration is written with the Schema API. The integration tests run the migration on all three and check that the result is exactly what the ORM mapping expects.
Development
composer install
composer qa # phpstan (max + strict rules), deptrac, phpunit
Integration tests use SQLite in memory unless DDD_TEST_DATABASE_URL points
at a real database — CI runs them on PostgreSQL and MySQL too.
Upgrading from 1.x
Roles moved from configuration to the database (2.0):
- remove
iam.roles,iam.default_rolesandiam.super_admin_rolefrom your configuration, and anysecurity.role_hierarchythat mirrored them; - in
access_control, replaceROLE_ADMINbybackoffice.access; - run the migrations (the
iam_roletable andsuper_adminare created), then recreate your roles in the back office —ROLE_ADMIN/ROLE_USERon existing accounts are no longer defined roles.
Configuration reference
iam: user_class: AlexandreBulete\DddIamBundle\Domain\Model\User table_prefix: iam_ api_tokens: prefix: iam # 2 to 16 lowercase letters and digits password_policy: min_length: 12 require_letters: true require_digits: true require_mixed_case: false require_special_character: false audit: enabled: true admin: enabled: true grid_limits: [10, 25, 50]
Not configurable (yet), and why
The /admin route prefix. It is fixed in #[AsResource], and it matches
sylius/admin-ui's own prefix, which the application sets in
config/routes/sylius_admin_ui.yaml. Making one side configurable without the
other would produce a half-moved back office. Change both, or neither.