Product documentation
Security & compliance dossier
The trust model, threat model, and shared-responsibility split a security or compliance reviewer needs to assess Thoryn — grounded in code and ADRs.
Security & compliance dossier
Draft. This dossier is the annex a security or compliance reviewer reads to assess trust. It goes deeper than the white paper's Security & trust model section: every claim below is grounded in the code or ADR named in each section's source anchor. Where the platform deliberately does not do something, this dossier says so — there are no aspirational claims.
Thoryn is a multi-tenant SaaS OAuth 2.0 / OIDC identity-broker platform, shipped both as a managed sovereign service and as a self-managed distribution built from the identical artefacts. This page frames the trust model, states the threat model, and draws the shared-responsibility line between the two delivery models. The focused pages that follow carry the mechanism-level detail:
| Topic | What it covers |
|---|---|
| Tenant isolation | The tnt claim, cross-tenant 404 (not 403), the trusted-issuer SSRF boundary, per-tenant crypto version-prefixing, tenant-never-in-URL |
| Signing & cryptographic custody | ES256 in Vault/OpenBao Transit (keys never leave the backend), per-tenant HMAC, version-prefixed identifiers, the verifiable hash-chained audit trail |
| Token security | Refresh-token rotation & replay/family revocation, fingerprint binding, single-use codes + PKCE, DPoP/mTLS sender-constrained tokens, RFC 9457 error hygiene |
| SSRF & outbound HTTP | OutboundUrlGuard / ReactiveOutboundUrlGuard, the older UrlSafetyValidator, and the SSRF-safe per-tenant JWKS transport |
| Privacy & compliance | GDPR access (Art. 15) & erasure (Art. 17), DSAR fan-out, per-tenant retention, NIS2 incident logging |
| CI security guards | The build-time scripts/check-*.sh family that makes these invariants un-regressable |
Trust model
The identity-broker pattern — the hub owns no user data
The authorization hub (servers/authorization-hub/) is a pure identity broker. It issues
and verifies tokens and speaks the OAuth/OIDC protocols; it never stores user profiles,
passwords, or organisation records. All identity comes from federation members — Thoryn's
own Identity Service (servers/federation-members/identity-service/), or a customer's own
enterprise IdP (Entra ID, Okta, Google Workspace, a SAML provider, …).
This is a structural security property, not a convention:
- The hub module contains no
User,Organisation, or credential entity. Claim enrichment reads claims from the federation member's ID token — it never queries a user table. - A compromise of the hub yields protocol state and signing-authority (see signing custody), but not a directory of user PII.
- Personal data — and therefore the GDPR obligations attached to it — lives in the identity member that owns it, which is where export and erasure are implemented.
Source: CLAUDE.md → "Identity broker pattern"; servers/authorization-hub/.
The four planes and their boundaries
| Plane | Module | Exposure | Auth |
|---|---|---|---|
| Broker | authorization-hub | Public (OAuth/OIDC endpoints) | Per-flow (PKCE, PAR, client auth, DPoP/mTLS) |
| Identity | federation-members/* | Internal + federated callbacks | OIDC / SAML; PAR + OIDC to the hub |
| Customer | product-api + api-gateway | Public self-service API | Bearer JWT, tnt claim, tenant:* scopes |
| Operator | admin/** scope namespace | VPN-gated, no public ingress | admin:* scopes, separate surface |
The operator plane (admin:*) is a separate scope namespace that structurally cannot
reach the customer plane: no admin:* scope is ever present in a tenant-admin token, so a
tenant admin cannot grant themselves cross-tenant reach. Conversely the customer plane's
tenant:* scopes never authorise cross-tenant operations. The scope-namespace split is the
privilege boundary between "a customer administering their own tenant" and "a Thoryn operator
(or, self-managed, the customer's own operator) administering the platform".
Source: CLAUDE.md → "Customer plane"; ADR 2026-04-25-customer-plane-product-api.md.
Multi-tenancy and per-tenant issuers
Each tenant is its own OIDC issuer (https://{slug}.hub.<platformDomain>), signed by a
tenant-scoped key (tenant-{slug}); the default tenant issues as https://hub.<platformDomain>
signed by sas-ecdsa-jwt-key. A tnt claim minted at the hub rides every token and is
re-checked in every downstream service. This gives each tenant the cryptographic isolation of
a dedicated issuer while thousands of tenants share one deployment. The mechanics — and the
SSRF boundary that keeps a forged issuer from steering an outbound fetch — are on the
tenant isolation page.
Threat model
The controls below are the ones a reviewer will ask about. Each row links to the page and the code that implements it. This is not an exhaustive risk register; it is the set of adversaries the architecture is explicitly built to resist.
| Adversary / risk | Control | Where |
|---|---|---|
| Cross-tenant access — tenant A reads or mutates tenant B's data | tnt-claim re-check on every request; cross-tenant returns 404 (no existence leak); tenant is taken from the token, never the URL path | Tenant isolation |
Forged issuer / SSRF via token — attacker mints a token with an iss pointing at an internal host | Trusted-issuer allowlist rejects unknown iss before any JWKS fetch; JWKS is fetched only from a fixed in-cluster Service with the Host header varying | Tenant isolation, SSRF |
| Outbound SSRF — tenant/admin-supplied URL (federation discovery, webhook, SCIM base URL) aimed at cloud metadata or an internal service | OutboundUrlGuard / UrlSafetyValidator reject private / loopback / link-local / metadata / CGNAT / IPv6-ULA targets before dispatch | SSRF |
| Signing-key theft — heap dump or memory-disclosure bug leaks the private key | Signing and audit HMAC/JWS happen in Vault/OpenBao Transit; the JVM never holds private key material | Signing & crypto |
| Refresh-token theft / replay | Rotation with replay detection; a replayed (rotated) token triggers family revocation (REVOKE_AUTHORIZATION, RFC 9700 §2.2.2); optional device-fingerprint binding | Token security |
| Token export / bearer lift | Optional sender-constrained tokens — DPoP (cnf.jkt, RFC 9449) and mTLS (cnf.x5t#S256, RFC 8705) | Token security |
| Information leak via errors | One RFC 9457 problem-details error shape platform-wide; detail strings are sanitised by construction — no tenant ids or token contents in responses | Token security |
| Audit-log tampering — an attacker with DB access alters or reorders audit history | Per-tenant hash-chained, HMAC + ES256-signed audit rows; a tamper is detected on verify as a HASH_MISMATCH / BROKEN_LINK / BAD_SIGNATURE | Signing & crypto, Privacy & compliance |
| Cross-tenant linkability — correlating the same subject/credential across tenants | Per-tenant HMAC (not plain hash) on audit content; per-tenant signing/encryption keys | Signing & crypto |
| Log injection / CRLF forging | MdcSafe allow-list + length cap on every MDC write; a CI guard bans raw MDC.put | CI guards |
| Container escape / lateral movement | Pods run runAsNonRoot + seccomp RuntimeDefault; automountServiceAccountToken: false on public-facing deployments | CI guards |
| Silent security regression | Every invariant above has a build-time guard that fails the PR; a regression cannot merge | CI guards |
Explicitly out of scope (stated honestly)
- Audit-row deletion detection. The audit chain is tamper-evident against mutation and
reordering, and detects a deleted row as a sequence gap when the chain is walked
contiguously. A Merkle-anchor scheme that would independently prove no row was ever excised
is a documented future option, not shipped (ADR
2026-05-09-verifiable-audit-chain.md). - Redirect re-validation after an SSRF check.
OutboundUrlGuardvalidates the target URL at submission time; callers whose target is tenant-controlled must configure the HTTP client to refuse redirects (see SSRF).
Shared responsibility (managed vs. self-managed)
Thoryn runs the identical artefacts in both delivery models; the entire deployed state is rebuildable from git with zero manual steps (Helm charts, Flyway migrations, config as code). What differs is who operates the substrate.
| Concern | Managed (sovereign SaaS) | Self-managed |
|---|---|---|
| Application security (token flows, tenant isolation, SSRF guards, error hygiene) | Thoryn — same code | You run the same code |
| Cryptographic backend (Vault/OpenBao Transit) | Thoryn operates it; keys never leave it | You operate it; keys never leave it |
| Production seal for the secrets backend | KMS/HSM auto-unseal (no operator-readable unseal key) | Your responsibility — do not ship the Secret-stored-key model to production; use a KMS/HSM seal |
| Secrets (DB, Redis ACL, client secrets) | Thoryn-provisioned per-service Secrets | Your cluster Secrets, same chart pattern |
| Network policy, TLS termination, ingress | Thoryn-operated | Yours |
| Tenant configuration (clients, federation, RBAC/FGA, policy) | You — self-service via console / CLI | You — same self-service surface |
| GDPR data-subject workflows (export, erasure, DSAR) | You initiate; the platform executes | You initiate; the platform executes |
| Incident response & regulator reporting | Shared — Thoryn operates, you own the tenant relationship | Yours |
The one production-hardening step a self-managed operator must perform themselves is the
secrets-backend seal: staging stores the OpenBao unseal key in a Kubernetes Secret, which is
explicitly not promotable to production without a KMS/HSM auto-unseal seal
(seal "awskms" / seal "pkcs11"). This is called out in ADR 2026-06-07-vault-to-openbao.md
and on the signing & crypto page.
Sources: CLAUDE.md → "Reproducible deployments"; white-paper "Delivery models";
ADR 2026-06-07-vault-to-openbao.md.