Features
Everything implemented in the bundle, grouped by capability.
Identity core
User entity with UUID and email login
Base user with uuid (v7, index-friendly), email (unique, used as
Symfony login identifier), password (hashed), active flag,
emailVerifiedAt timestamp, and a free-form config JSON column for
per-app data. Implements Symfony’s UserInterface and
PasswordAuthenticatedUserInterface out of the box.
Roles and permissions (RBAC)
A reusable Role catalog identified by unique code strings (e.g.
org.admin, contribuyente.read). Roles carry Permission entries
(atomic capabilities like invoice.create). No scope column — the
context is defined by the relation that assigns the role, not by the
role itself. Works for system-level, organization-level, and
resource-level RBAC with the same data model.
System-level role assignments
UserRole pivot for global roles (super-admin, auditor). Separate from
organization memberships — these are rare, system-wide grants.
JWT token management
TokenManager wraps Lexik’s JWTTokenManagerInterface behind a stable
bundle boundary. Controllers depend on the bundle’s service rather than
Lexik’s API directly — if the JWT library is ever swapped, only this
service changes.
User lifecycle
Registration with email verification
Registrar service creates a user, hashes the password, issues a
single-use email_verify token, and dispatches UserRegistered with
the plaintext token for the app’s mailer. The user is created
unverified; the EmailVerifier service consumes the token and stamps
emailVerifiedAt.
Password reset
PasswordResetter implements the “I forgot my password” flow. Request
phase issues a token and dispatches PasswordResetRequested; reset
phase consumes the token, rehashes, and dispatches
UserPasswordChanged. Silent no-op when the email is not registered
(anti-enumeration).
Email change with confirmation
EmailChanger issues a token bound to the new address (stored in the
token’s JSON payload). The current email is NOT touched until the token
is consumed — prevents lockout if an attacker has temporary session
access. Dispatches EmailChangeRequested (to the new address) and
UserEmailChanged (carries both old and new, so the app can notify the
old inbox).
Magic links (passwordless login)
MagicLinkManager issues a short-lived token (default 15 min) and
dispatches MagicLinkRequested. The MagicLinkAuthenticator (Symfony
Security authenticator) consumes the _magic_link_token query
parameter and authenticates the user without a password. Fires only on
dik_-free bearer tokens — no conflict with API keys or JWT. Opt-in
via security.yaml.
Secure one-use tokens
All lifecycle tokens (email verify, password reset, email change, magic
link) share the same security model: 256 bits of randomness, SHA-256
hash stored in the database (never the plaintext), hash_equals for
timing-safe validation, configurable per-type TTL. The
identity:tokens:purge command cleans up expired rows.
Organizations & multi-tenancy
GitHub-style organizations
Organization entity with uuid, name, active, config. No
hardcoded owner column — the owner is the membership carrying
role.code = 'org.owner', which lets ownership be transferred with a
role swap instead of a FK update.
Organization memberships
OrganizationMembership pivot (user + organization + role) with
surrogate id and unique constraint on (user, organization). Supports
different roles per org. The day-to-day RBAC pivot for multi-tenant
apps.
Ownership transfer
OrganizationManager::transferOwnership() atomically swaps the owner
role to a new member and demotes the previous owner to a caller-chosen
role. Dispatches OrganizationOwnershipTransferred.
Organization invitations
InvitationManager issues email-bound invitations with SHA-256 hashed
tokens. Accept validates email match (case-insensitive) and creates the
membership transactionally. Revoke marks the invitation inactive.
Accepted and revoked invitations are kept as audit trail; only
pending-expired ones are pruned. Events: InvitationCreated,
InvitationAccepted, InvitationRevoked.
Tenant contexts (Organization & User)
TenantContextInterface is the generic base for “who is the current
tenant?”. The bundle provides two typed implementations:
OrganizationContextInterface (org-as-tenant) and
UserContextInterface (user-as-tenant), each with an in-memory default.
The app’s middleware sets the current tenant from a header, subdomain,
route, or session; services, voters, and the Doctrine tenant filter
read it downstream. Pluggable — swap the implementation for custom
resolution strategies. Apps that need resource-level tenancy (e.g.
Contribuyente) create their own context following the same pattern.
Organization teams
GitHub-style teams within organizations. A team is a named group of org
members (with a slug for URL-friendly identification). Teams simplify
resource access: instead of granting 20 users individually, the admin
assigns a team to a resource with a role, and all members inherit that
access. TeamManager creates teams, adds/removes members (validates
org membership), and dispatches events. Resources implement
TeamAccessibleInterface to opt-in. BaseTeamResourceAccess
MappedSuperclass for the pivot — app extends per resource type. The
PermissionChecker cascade now includes team access:
direct → teams → org → global.
Authorization
Four-level cascading permission checker
PermissionChecker resolves permissions at four scopes with automatic
fallback: resource-access → team-access → organization-membership →
global system-roles. A global super-admin passes every check without explicit
per-org or per-resource grants.
Symfony Security voters (auto-registered)
Three voters, each fires based on the subject type:
PermissionVoter— global, no subject:#[IsGranted('system.admin')].OrganizationPermissionVoter— subject is an Organization:#[IsGranted('org.invite', subject: 'organization')].ResourcePermissionVoter— subject implementsResourceAccessibleInterface:#[IsGranted('write', subject: 'contribuyente')].
All voters add human-readable deny reasons to Symfony’s Vote object
for Web Profiler debugging.
Security
Sudo mode
Session-based re-authentication window (default 15 min). The
#[RequiresSudo] attribute on controllers throws
SudoRequiredException when the window expires — the app catches it to
show a password-confirmation form. SudoManager::confirmPassword()
verifies and refreshes. Auto-granted on every login via
SudoOnLoginListener. No database column — timestamp lives in the
session.
API keys with scopes
Long-lived tokens for scripts and integrations. Format: dik_ + 40 hex
chars (160 bits entropy), SHA-256 hashed, display prefix stored for UI.
Scopes are app-defined strings in a JSON column — empty means
unrestricted. ApiKeyAuthenticator fires on Authorization: Bearer dik_..., #[RequiresScope('write:invoices')] enforces per-endpoint
scope checks. Session users bypass scopes entirely (scopes restrict
tokens, not users).
Account lockout
Automatic lock after N consecutive failed login attempts (default 5).
Locked accounts cannot authenticate even with the correct password.
Lock expires automatically after a configurable duration (default 30
min). Counter resets on successful login. Admin-initiated unlock via
AccountLockManager::unlock(). Fully automatic — the
AccountLockListener handles CheckPassportEvent, LoginFailureEvent,
and LoginSuccessEvent with no app-side wiring. Events:
AccountLocked, AccountUnlocked.
Login history
Every successful authentication is recorded: IP address, User-Agent, and
the authenticator that succeeded (e.g. FormLoginAuthenticator,
MagicLinkAuthenticator, ApiKeyAuthenticator). The
LoginHistoryListener hooks into LoginSuccessEvent automatically —
zero wiring. LoginHistoryManager::getRecentForUser() returns the N
most recent entries for a “security activity” page. The
identity:login-history:purge --days=90 command prunes old records.
App integration
Organization-owned resources
OrganizationOwnedInterface + OrganizationOwnedTrait — one line of
code to give any app entity a $organization ManyToOne. Nullable by
default (GitHub personal-account pattern). The ResourcePermissionVoter
cascades to org-level permission when no direct resource access is
found.
User-owned resources
UserOwnedInterface + UserOwnedTrait — same pattern for resources
that belong directly to a user. Composable with org ownership.
Resource-level collaborators
ResourceAccessibleInterface + BaseResourceAccess MappedSuperclass
for per-resource RBAC. The app extends BaseResourceAccess per
resource type with a typed FK — one table per resource, real FK
integrity, no polymorphic hacks. The ResourcePermissionVoter
iterates access entries automatically.
Multi-tenancy & row-level security
Automatic Doctrine filtering by tenant
IdentityTenantFilter (Doctrine SQLFilter) auto-appends WHERE organization_id = :id and/or WHERE owner_id = :id to every query on
entities that implement OrganizationOwnedInterface or
UserOwnedInterface. The TenantFilterListener enables the filter
per-request when at least one tenant context has a value. Supports
organization-as-tenant, user-as-tenant, or both simultaneously (AND).
For resource-level tenancy (e.g. Contribuyente), the app creates its
own filter following the documented pattern.
Tenant contexts
TenantContextInterface is the generic base.
OrganizationContextInterface and UserContextInterface extend it with
typed get/set methods. In-memory implementations provided for both. The
app can create custom contexts (e.g. ContribuyenteContext) following
the same pattern.
API Platform compatibility
The voters and the Doctrine tenant filter work transparently with API
Platform. Per-object authorization via the security attribute
(is_granted('read', object)) triggers the bundle’s voters with the
full cascade. Listing operations run through the Doctrine filter
automatically — no custom state providers needed.
Architecture
MappedSuperclass + ResolveTargetEntityListener
Every entity is an abstract Base* MappedSuperclass. Every ORM
relation targets an interface. Doctrine resolves interfaces to the app’s
concrete classes at runtime. The bundle has zero knowledge of any
App\Entity\* class.
Contract-driven design
All public interfaces live under Contract/{Entity,Service,Integration}/.
Services are final with companion interfaces for testability and
decoration. Entity interfaces define the shape the app must provide;
service interfaces define what the bundle provides.
Event-driven lifecycle
19 domain events dispatched at key lifecycle moments. The bundle never sends emails, renders templates, or writes logs — it emits events, and the app’s listeners handle transport. Events that carry plaintext tokens document that the plaintext must not be logged or persisted.
Zero-config defaults
All entity-class config nodes default to App\Entity\Auth\*. All TTLs
have sensible defaults. The app needs no derafu_identity.yaml unless
it deviates from the convention.
CLI commands
Two maintenance commands for periodic cleanup:
identity:tokens:purge (expired verification tokens) and
identity:login-history:purge --days=90 (old login records). Intended
for cron.
Headless
No controllers, no templates, no forms, no routes. The bundle is a service layer — the app owns the UX.