15 July 2026¶
Authorization core: capabilities, permissions & passkeys groundwork. The authorization stack lands: trusted-client silent consent, a client capabilities registry with manifest sync and push endpoint, the RBAC+ABAC permission grain and resolver with a live-fetch internal endpoint, instant permissions_changed webhook invalidation, feature-gated auto-migrations, and the passkey credential store + WebAuthn ceremonies.
10 changes:
- Capabilities push endpoint
- Client capabilities registry and manifest sync
- Framework auto-migrations are now gated by enabled features
- Internal permissions endpoint (live fetch)
- Passkeys groundwork: credential store and WebAuthn library
- Passkeys: WebAuthn registration & authentication ceremonies
- Permission resolver (RBAC + ABAC, live-fetch read side)
- Permissions grain: audience and ABAC conditions
- Instant invalidation: permissions_changed webhook
- Trusted clients: skip the OAuth2 consent screen (silent flow)
Capabilities push endpoint¶
Resource servers can now push their capabilities manifest to the auth server over HTTP, completing the CI/CD push model (feature 2).
Added¶
Pramnos\Auth\Controllers\Capabilities. Handles the capabilities push atPUT /api/internal/clients/{client_id}/capabilities. The caller authenticates with its own Client Credentials (HTTP Basic or request body) and sends the JSON manifest as the request body; the controller validates the credentials, enforces that a client may only push its own manifest (the authenticated client must match the path{client_id}), parses the manifest, and applies it viaCapabilitiesSyncService. Responses:200with the sync result (status, counts),400malformed manifest,401invalid/missing credentials,403cross-client push,405wrong method.- Credential extraction, manifest reading, authentication, and the sync service are exposed as protected seams, so the whole flow is unit-testable without a live HTTP request. As with the other framework auth controllers, the route itself is wired by the consuming application.
Client capabilities registry and manifest sync¶
Resource servers can now declare what they expose — Resources, the Scopes (action vocabulary) per Resource, and the ABAC Condition keys they support — and have the auth server persist that declaration with smart, non-destructive sync.
Added¶
- Capabilities registry (4 tables). New authserver tables hold each client's
declared capabilities:
client_resources,client_resource_scopes(the action vocabulary per resource),client_supported_conditions(declared ABAC keys such aslocation_id), andclient_manifest(the last-synced MD5 hash). Created by the current-date migration2026_07_15_000002_create_client_capabilities_tables— additive, idempotent, no foreign keys (DB-safe on shared installations). Pramnos\Auth\CapabilitiesSyncService. Applies a client's JSON capabilities manifest to the registry with three guarantees:- MD5 short-circuit — an unchanged manifest is a no-op.
- Upsert — declared resources/scopes/conditions are inserted or refreshed and marked active; re-adding a removed item reactivates the existing row instead of duplicating it.
- Soft delete — anything dropped from a later manifest is flagged
is_active = false, never hard-deleted, so existing user policies that reference it are preserved.hashManifest()is order-independent, so a cosmetically reordered but semantically identical manifest still short-circuits. The service is designed to be subclassed so an app layer can filter which declared capabilities are exposed.
Framework auto-migrations are now gated by enabled features¶
Auto-run framework migrations are now scoped to the features an application has actually enabled, so a project only ever provisions the schema for the subsystems it uses.
Changed¶
- Feature-gated auto-migrations.
Application::runAutoMigrations()(the boot-time runner that applies pending framework migrations automatically) now filters migration directories by feature activation. Each framework migration lives in a per-feature sub-directory (database/migrations/framework/{feature}/); a directory is now applied only when its feature is enabled in the application'sapp.phpfeaturesarray. The rule is fail-open: a directory whose name is not a registered framework feature — and the always-oncorefeature — still runs unconditionally, so nothing outside the known feature set is affected. Implemented asApplication::filterMigrationDirsByEnabledFeatures().
Upgrade note
If your application relies on a framework feature's tables being created automatically
(authserver, auth, queue, messaging, …), make sure that feature is listed in your
app.php features array. Without it, that feature's new migrations will no longer
auto-run. Installations that already have the tables are unaffected for existing schema;
this only governs whether future framework migrations for a feature are applied. The
core feature always runs regardless of configuration.
Internal permissions endpoint (live fetch)¶
Resource servers can now fetch a user's effective permissions over HTTP, completing the live-fetch read path (feature 6).
Added¶
Pramnos\Auth\Controllers\InternalPermissions. ServesGET /api/internal/permissions?user_id={id}— a resource server authenticates with its own Client Credentials and receives thePermissionResolverresult for that user within its own audience (the authenticated client determines theapp_id; an explicitclient_idquery, if present, must match). Responses:200with the effective grants,400missing/invaliduser_id,401invalid/missing credentials,403cross-client request,405wrong method. The resource server caches the result and refreshes on apermissions_changedwebhook — so access tokens stay lightweight (identity only).ClientCredentialsAuthTrait. The Basic/body credential extraction and validation shared by the capabilities-push and internal-permissions endpoints is now a single trait (theCapabilitiescontroller was refactored onto it), keeping the client-authentication behaviour in one place.
Passkeys groundwork: credential store and WebAuthn library¶
The foundation for passkey (WebAuthn / FIDO2) support: the credential store and the WebAuthn library, wired in behind an upcoming anti-corruption wrapper layer.
Added¶
web-auth/webauthn-libdependency. The framework now requiresweb-auth/webauthn-lib ^5.3(Spomky-Labs — the same ecosystem as the existingweb-token/jwt-framework). It will be used only behind a framework-owned wrapper layer, so the public passkey API stays framework-native and the library can be swapped without breaking backward compatibility.passkey_credentialstable. New authserver table storing one row per registered passkey: owner, base64url credential id (unique), COSE public key, signature counter (clone detection), AAGUID, transports, a user label, the backup-eligible / backup-state flags, anis_activerevocation flag, and created / last-used timestamps. Added by the current-date migration2026_07_15_000004_create_passkey_credentials_table— a brand-new table, guarded and portable (binary values stored base64-encoded), no foreign key.
Passkeys: WebAuthn registration & authentication ceremonies¶
The passkey (WebAuthn / FIDO2) ceremony layer lands: register a credential, authenticate with it — including usernameless / discoverable-credential login — and manage passkeys, all behind a framework-owned API that keeps the third-party WebAuthn library fully swappable.
Added¶
Pramnos\Auth\Passkey\PasskeyService(andPasskeyServiceInterface) — the public passkey API:beginRegistration()/finishRegistration()andbeginAuthentication()/finishAuthentication(), plus list / rename / revoke for dashboards. It owns the single-use challenge store (cache, 5-minute TTL), credential persistence, and — crucially — writing back the advanced signature counter so clone/replay is caught across requests.- Anti-corruption boundary —
WebAuthnAdapterInterfacewith the defaultWebAuthnLibAdapter(backed byweb-auth/webauthn-lib5.x). This is the only class that speaks the WebAuthn library's dialect; everything above it uses framework-owned value objects (RegistrationOptions,AuthenticationOptions,PasskeyCredential,VerificationResult,Config). Swapping the library — or hand-rolling an implementation — means writing another adapter, nothing more. Pramnos\Auth\Controllers\Passkey— JSON endpoints for the ceremonies and management:registerOptions/register,loginOptions/login, andlist/rename/revoke. The in-flight ceremony's challenge is correlated through the session, never round-tripped through the client.Pramnos\Auth\Auth::loginById(int $userId, bool $remember = true)— an additive, passwordless counterpart toauth(). It establishes a session for an already-verified user through the same post-login path (triggerLogin()→ user addon or built-in lifecycle → afterLogin callbacks), honouring the same active-status gate. Used by passkey login; the existing password flow is unchanged.
Security¶
- Attestation is
none(consumer passkeys); signatures are verified with ES256 / RS256. The ceremony rejects a non-increasing signature counter (clone/replay), a tampered signature, a wrong origin, and a mismatched credential/user. These rejections are covered by round-trip tests driven by a software authenticator, across MySQL and PostgreSQL.
Permission resolver (RBAC + ABAC, live-fetch read side)¶
A resolver that computes a user's effective permissions for one application — the read side of the live-fetch model (feature 6).
Added¶
Pramnos\Auth\PermissionResolverInterface+PermissionResolver. Given a user and an application, the resolver readsauthserver.permissions— the user's own grants plus those of the active roles they hold (authserver.user_roles) — and returns each effective grant:- Audience scoping — global rows (
app_id IS NULL) always apply; app-scoped rows apply only to the matching application. - Deny-over-allow — resolved per
(object_type, object_id, action), mirroring theeffective_permissionsview's priority semantics. - Active / non-expired only — inactive rows and expired grants (and permissions from expired role assignments) are excluded.
- ABAC pass-through — conditions are not evaluated here; each grant
carries its predicate(s) so the calling app evaluates them against its own
request context. A grant is unconditional when any winning row is.
PermissionResolverInterfaceis an extension seam: an app layer can decorate it to intersect the result with a licensing/entitlement gate. The resolver is independent of the legacyPramnos\Auth\Permissionsclass. The internal HTTP endpoint that serves this to resource servers lands next.
- Audience scoping — global rows (
Permissions grain: audience and ABAC conditions¶
The authserver.permissions table gains two dimensions that turn plain RBAC into
per-application, attribute-aware authorization (feature 4, Hybrid RBAC + ABAC).
Added¶
app_id(audience). A nullable column recording which application a permission applies within.NULLmeans global (every app) — exactly the grain the table had before — so all existing rows keep their meaning.conditions(ABAC). A nullable JSON column holding an attribute predicate such as{"location_id":[1,2]}, evaluated at runtime by the consuming app.NULLmeans unconditional.- A new non-unique lookup index
(subject_type, subject_id, app_id, object_type, action).
Added by the current-date migration
2026_07_15_000003_add_audience_and_conditions_to_permissions — strictly additive
and idempotent (hasColumn guards), no foreign key on app_id, and the
existing unique constraint and effective_permissions view are left untouched.
The runtime resolver that reads these dimensions (per-app permission fetch with
condition pass-through) lands with the internal permissions endpoint in the next
phase.
Instant invalidation: permissions_changed webhook¶
Admin permission changes now emit a permissions_changed webhook so resource
servers drop the affected user's cached permissions and re-fetch — closing the
live-fetch loop (feature 7).
Added¶
permissions_changedwebhook on RBAC changes.PermissionsController::save()anddelete()now queue apermissions_changedevent (viaWebhookService) after writing toauthserver.permissions. For a user-subject the event targets that user; for role/application subjects it targets user0and carries the subject in the payload, so a subscriber can invalidate every affected user's cache. The payload also includes the operation (create/update/delete) and, for saves, the object type and action.- Delivery is best-effort: a webhook/queue failure is swallowed so it can never
break permission administration. The emission is exposed as a protected seam
(
emitPermissionsChanged()/webhookService()) for testing and overriding.
Together with the resolver and the internal permissions endpoint, this completes the "lightweight token + live fetch + event-driven invalidation" model: tokens stay identity-only, apps fetch permissions on demand and cache them, and a change at the auth server invalidates those caches immediately.
Trusted clients: skip the OAuth2 consent screen (silent flow)¶
First-party applications can now be marked trusted so the authorization-code flow issues a code without showing the consent screen.
Added¶
trustedflag on applications. A newtrustedcolumn on the applications table (SMALLINT NOT NULL DEFAULT 0) marks first-party / internal clients. Whentrusted = 1,Oauth::authorize()skips the consent screen entirely and issues the authorization code silently (clientSkipsConsent()gates the branch). Every existing application defaults to0(untrusted), so third-party clients keep seeing the consent screen exactly as before. Added by the current-date migration2026_07_15_000001_add_trusted_to_applications, which is strictly additive and idempotent (guarded byhasColumn(), no foreign key).
Fixed¶
Oauth::getLoggedInUser()visibility. The method wasprivate, which silently prevented test doubles (and any subclass) from overriding the logged-in-user lookup. It is nowprotected— a backward-compatible visibility widening that makes the authorization flow properly testable.