Skip to content

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 at PUT /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 via CapabilitiesSyncService. Responses: 200 with the sync result (status, counts), 400 malformed manifest, 401 invalid/missing credentials, 403 cross-client push, 405 wrong 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 as location_id), and client_manifest (the last-synced MD5 hash). Created by the current-date migration 2026_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's app.php features array. The rule is fail-open: a directory whose name is not a registered framework feature — and the always-on core feature — still runs unconditionally, so nothing outside the known feature set is affected. Implemented as Application::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. Serves GET /api/internal/permissions?user_id={id} — a resource server authenticates with its own Client Credentials and receives the PermissionResolver result for that user within its own audience (the authenticated client determines the app_id; an explicit client_id query, if present, must match). Responses: 200 with the effective grants, 400 missing/invalid user_id, 401 invalid/missing credentials, 403 cross-client request, 405 wrong method. The resource server caches the result and refreshes on a permissions_changed webhook — 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 (the Capabilities controller 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-lib dependency. The framework now requires web-auth/webauthn-lib ^5.3 (Spomky-Labs — the same ecosystem as the existing web-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_credentials table. 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, an is_active revocation flag, and created / last-used timestamps. Added by the current-date migration 2026_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 (and PasskeyServiceInterface) — the public passkey API: beginRegistration() / finishRegistration() and beginAuthentication() / 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 boundaryWebAuthnAdapterInterface with the default WebAuthnLibAdapter (backed by web-auth/webauthn-lib 5.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, and list / 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 to auth(). 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 reads authserver.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 the effective_permissions view'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. PermissionResolverInterface is an extension seam: an app layer can decorate it to intersect the result with a licensing/entitlement gate. The resolver is independent of the legacy Pramnos\Auth\Permissions class. The internal HTTP endpoint that serves this to resource servers lands next.

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. NULL means 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. NULL means 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_changed webhook on RBAC changes. PermissionsController::save() and delete() now queue a permissions_changed event (via WebhookService) after writing to authserver.permissions. For a user-subject the event targets that user; for role/application subjects it targets user 0 and 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.


First-party applications can now be marked trusted so the authorization-code flow issues a code without showing the consent screen.

Added

  • trusted flag on applications. A new trusted column on the applications table (SMALLINT NOT NULL DEFAULT 0) marks first-party / internal clients. When trusted = 1, Oauth::authorize() skips the consent screen entirely and issues the authorization code silently (clientSkipsConsent() gates the branch). Every existing application defaults to 0 (untrusted), so third-party clients keep seeing the consent screen exactly as before. Added by the current-date migration 2026_07_15_000001_add_trusted_to_applications, which is strictly additive and idempotent (guarded by hasColumn(), no foreign key).

Fixed

  • Oauth::getLoggedInUser() visibility. The method was private, which silently prevented test doubles (and any subclass) from overriding the logged-in-user lookup. It is now protected — a backward-compatible visibility widening that makes the authorization flow properly testable.