Credential Broker
The credential broker lets a user store a long-lived external secret (a GitHub token, a Doffin subscription key, …) ONCE in OpenRegister and lets allowed apps use it without ever holding it. Apps never receive the secret: the broker performs the outbound call itself and injects the secret server-side.
How a brokered call works
Given a credential id, a calling app id, and a method + path, the broker
(lib/Service/Credential/CredentialBrokerService.php) enforces four ordered
guards, failing closed with a static 403:
- Owner — the acting user owns the credential object (per-object IDOR guard).
- Allowed apps — the calling app id is in the credential's
allowedApps[]. - Allow-rules — method + query-stripped path match one of the provider's
allowRules[](exact method, glob path). - Host-lock — the resolved URL host equals the provider
baseUrlhost; callers supply only a path, never a URL.
Only then is the secret read from the store, injected per the provider's
authScheme (the broker owns that header — caller-supplied values for it are
discarded), and the single outbound call performed. The secret never appears in
a response, log line, or error.
Provider catalogue (runtime-immutable)
lib/Settings/credential-providers.json is a read-only security control
shipped with the app: it defines every reachable provider, its host-locked
baseUrl, auth header template, and allow-rules. There is NO API that writes
it — new providers/rules ship only through a reviewed release. Current entries:
github, gitlab, and doffin (Doffin Norway public-procurement Public API —
GET-only, /notices search, Ocp-Apim-Subscription-Key header carrying the
bare key).
Secret custody: resolved store leaves
The secret itself lives behind the CredentialStore interface (put/get/
delete, keyed by the credential object's UUID). Which leaf holds it is
decided at runtime by CredentialStoreResolver (DI factory in
lib/AppInfo/Application.php):
- Doriath leaf (
DoriathCredentialStore) — preferred when thedoriathsecrets app is enabled, its service classes and application-scoped seam methods (SecretService::getByNameForApplication/deleteByApplication, landing via Doriath'sapplication-secret-deletechange) exist, AND OpenRegister has self-registered with Doriath. Secrets are stored as application-owned ciphertext in OpenRegister's single Doriath vault (secret name = credential UUID, root folder), envelope-encrypted with Doriath'srsa-oaep-sha256-chunked-v1scheme against OpenRegister's own public key. Only OpenRegister's system-scoped private key can decrypt them; Doriath adds at-rest encryption, audit events, and rotation metadata. - Nextcloud-vault leaf (
NextcloudVaultCredentialStore) — the fallback: per-user rows in Nextcloud's encrypted credentials vault. Used whenever any Doriath condition fails, so instances without Doriath behave exactly as before.
Lazy migration: with the Doriath leaf active, a read that misses in Doriath but hits in the legacy per-user vault re-puts the secret into Doriath, deletes the vault row, and returns it. This only happens in a user-session context (the vault is session-scoped); a background read of an un-migrated secret fails closed until its owner uses it once interactively. Deletes always clear both stores.
Operator note: disabling Doriath after secrets have migrated makes those secrets unreachable (broker fails closed with 403) until Doriath is re-enabled; nothing is lost or exposed. If OpenRegister's private key is ever lost, affected credentials fail closed and owners simply re-enter the secret.
Doriath self-registration (repair step)
lib/Repair/RegisterOpenRegisterWithDoriath.php (install + post-migration)
makes OpenRegister a first-class Doriath application, idempotently:
- Skips fast when the persisted application UUID still matches a live Doriath row; warns and completes (never throws) when Doriath is unavailable.
- Otherwise: generates an RSA-4096 keypair, stores the private key
SYSTEM-scoped in
ICredentialsManager(openregister/doriath/private-key), self-generates a PKCS#10 CSR (CNopenregister), and calls Doriath'sApplicationService::register(..., isAdmin: true)in-process — the registration auto-approves and provisions the EncryptionSuite from the CSR. - Persists the Doriath-assigned application UUID and OpenRegister's public key
PEM (public material only) in
IAppConfig.
App onboarding and identity
- HTTP / cross-runtime callers authenticate per app with a short-lived
HMAC token (
X-Credential-Token, minted from a per-app signing secret registered viaPOST /api/credentials/apps/{appId}/register, admin-only). The app id checked againstallowedApps[]comes ONLY from verified token claims. - Trusted in-process callers (same-instance PHP: openconnector, background
jobs) call
CredentialBrokerService::requestdirectly, passing their ownappId— no token. The token authenticates claims across the HTTP trust boundary, which an in-process call does not cross; all four guards run identically. - No-code onboarding: when an AppHost leaf (or generated virtual app)
initialises and its bundled
src/manifest.jsondeclares a non-emptycredentials[],GenericInitializeSettingsauto-registers the app with the broker — guarded byisRegistered()so an auto-run never rotates an existing signing secret. Rotation stays an explicit admin action.
Background jobs: actingUserId
CredentialBrokerService::request accepts an optional actingUserId for
sessionless in-process callers only (e.g. a cron job running a scheduled
ingestion for the user who configured it). Semantics:
- Honored ONLY when no user session exists; the owner guard then evaluates against it. All other guards are unchanged.
- When a session exists, the session identity wins unconditionally — a
session caller cannot impersonate anyone by passing
actingUserId. - The HTTP controller never forwards an acting user: on the HTTP path identity is session-only, and any request-supplied acting-user value is ignored entirely.
- The value is an assertion by trusted same-instance code — derive it from durable job context (the configuring user), never from request input.