Outbound provisioning security
Outbound provisioning security
Outbound provisioning makes authenticated, server-side HTTP calls to operator-configured URLs carrying an organization's user data. Its controls mirror the platform's other egress paths (webhooks, federation, SIEM streaming).
SSRF-guarded egress
Every outbound URL — a connection's SCIM base URL and an OAuth token URL — is
validated by the shared, independently-tested cboxdk/laravel-ssrf guard before a
request is made:
- At registration,
SafeScimUrl::assert()rejects a base URL that resolves to a loopback, private, link-local or reserved address (e.g. the cloud-metadata endpoint169.254.169.254), so an unsafe target is never stored. - At delivery, the connection is pinned to the exact IPs resolved by the
guard immediately before connecting (
pinnedOptions), and redirects are refused (withoutRedirecting). A DNS rebind between check and connect, or a 30x toward an internal host, cannot redirect the request — the TOCTOU window is closed.
config('cbox-id.provisioning.verify_url') (default true) is operator-only.
Never expose it to a tenant/org admin: a tenant that could disable the guard could
point a connection at an internal address. A single-tenant/on-prem install
delivering to an internal SCIM endpoint may set it false.
TLS
Delivery runs over the platform HTTP client with certificate verification on; the guard's pinning does not disable TLS verification. There is no per-connection toggle to turn it off.
Secrets at rest (reveal-once)
A connection's bearer token / OAuth client secret is sealed with the Crypto
SecretBox (XChaCha20-Poly1305 AEAD), bound as additional authenticated data to
that connection's id — a ciphertext sealed for one connection cannot be opened
against another. The raw secret is:
- never stored in plaintext (only the sealed column
auth_secret_encrypted); - never returned again after registration (there is no reveal endpoint);
- opened only at delivery time to build the
Authorizationheader; - never written into an operation's
last_erroror a dead-letter row — a failed response is reduced to its status and SCIMdetail, and the header is never logged.
Deny-by-default isolation
ProvisioningConnection, ProvisionedResource and ProvisioningOperation are
environment-owned. The hard environment scope is deny-by-default (no ambient
environment ⇒ zero rows), so:
- with no connection in an environment, a change enqueues nothing and makes no outbound call;
- a connection in env-A can only ever be enqueued against, and delivered from, within env-A — proven by cross-environment tests at both the dispatch and drain stages;
- the drain job, running in a worker with no ambient environment, reconstructs the
connection's environment (
withoutScopesingle-id read →runAs) rather than trusting one.
Resilience limits
- Bounded retries. Transient failures (transport error, 429, 5xx) retry with
exponential backoff + jitter up to
max_attempts, then dead-letter (exhausted). A permanent 4xx is dead-lettered immediately — it will not self-correct. - Per-connection circuit breaker. After
failure_thresholdconsecutive transient failures a connection's breaker opens forcooldown_seconds; a failing downstream app pauses only itself and never blocks another connection.
Honest scope: at-least-once
Delivery is at-least-once. The transactional outbox and per-connection
ShouldBeUnique drain keep the normal case to a single delivery, and SCIM PATCH
replace operations are idempotent, so a redelivery converges. It is not
exactly-once: a downstream app that acknowledges after a network cut may see a
repeat. Receivers should key on externalId. We do not claim delivery guarantees
we cannot keep, nor SCIM conformance beyond what the tests exercise (RFC 7643/7644
payload shapes against an in-memory server and the real HTTP client).