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 implements ResourceAccessibleInterface: #[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.

On this page

Last updated on 16/04/2026 by Anonymous