DD-013: Enterprise SSO / SCIM connections — parked until a consumer brings a real IdP¶
id: DD-013
status: FORCED
forced: 2026-10-16
consumer: platform (core requirement, operator ruling 2026-10-16)
plan: docs/plans/2026-10-16-enterprise-auth-restore.md
issues: [1342, 1344, 1345]
adrs: [0057, 0044, 0047, 0033, 0003]
decided: 2026-10-15
decided_at_sha: 23cf59372dc91d2636dbde0e6aceb549cd40efd3
parent: flense-programme
One-line intent¶
Per-org enterprise identity (OIDC and SAML 2.0 sign-in, SCIM 2.0 provisioning,
DNS-verified domains, the connection admin surface and dazzle auth connection)
was gated on [capabilities] enabled = ["auth.enterprise.*"] in dazzle.toml.
No consumer declared the section, and AegisMark's runbook
(docs/enterprise-auth-enablement.md in that repo) records the capability as
inert and names go-live as a reviewed architecture change. Flense ship AD
(ADR-0057, operator decision 2026-10-15) removed it: about 7.4k lines of
src, 11.6k of tests and the saml extra. Social OAuth (sso_routes,
sso_config, sso_views, social_auth, Authlib), magic link, member admin,
org activation and the session provider are a different plane and stay.
Plan (what we already concluded)¶
Restore from the commit this DD was parked at
(23cf59372dc91d2636dbde0e6aceb549cd40efd3, three commits before flense ship AD
229226a88; none of those commits touched an enterprise file, so it equals the true
parent ee5a761be for every path below);
git show 23cf59372:<path> returns every file below. The operator guide was
docs/reference/enterprise-sso.md at the same commit; its substance is here.
Scope agreed 2026-10-16: OIDC and SCIM only. The SAML paragraphs below
describe what existed and stay as reference; they are not restored (see
Agreed plan and Explicitly out of scope).
Gating. Three capabilities in dazzle.core.capabilities.registry:
auth.enterprise.oidc (probe authlib, extra sso), auth.enterprise.saml
(probe onelogin, extra saml = python3-saml, needs native libxmlsec1),
auth.enterprise.scim (no probe, always available once declared). The
registry, [capabilities] manifest parsing, dazzle capability enable|disable
and the cognition gate (bootstrap / spec-analyze / lint suggest
dazzle capability enable … instead of pushing the pattern) were removed in
flense ship AI, since with nothing registered they gated nothing; restore them
from 23cf59372 with the protocol (core/capabilities/, cli/capability.py,
CapabilitiesConfig in core/manifest.py). AuthSubsystem mounted each route group only
when its capability was active (_mount_enterprise_capabilities,
_mount_enterprise_sso, _mount_saml, _mount_scim, _any_enterprise_active)
and a boot guard (auth/capability_guard.py, #1344) logged loudly when
connection rows existed for a protocol whose capability was off.
Connection model (auth/connections.py). A Connection is framework-owned
runtime data, never DSL: id, tenant_id, type (oidc | saml | scim | domain),
provider ('native'), domains, verified_domains, config, encrypted_secret,
previous_encrypted_secret, previous_secret_expires_at, group_mapping, status.
Secrets (OIDC client_secret, SCIM bearer, SAML SP private key) were
AES-256-GCM encrypted at rest under DAZZLE_CONNECTION_SECRET
(auth/connection_crypto.py), with a two-key …_OLD window and
dazzle auth rotate-encryption-key to rewrap. A provider registry keyed by
(type, provider) resolved the native OIDC and SAML providers.
Domain verification (auth/domain_verification.py). add-domain claims a
domain; the admin publishes dazzle-verify=<HMAC-SHA256(key,
"domain-verify:<conn_id>:<domain>")> as a TXT record; verify-domain checks it
and moves the domain to verified_domains under an advisory lock (one verified
owner per domain). Only verified domains route an email to a connection or let
an IdP assert identities: the anti-hijack gate both login and provisioning
depend on. The DNS resolver seam now lives in tenant/aliases.py (custom-domain
host aliases use it).
Login and JIT join (enterprise_routes.py, enterprise_login.py,
sso_session.py). GET /auth/enterprise/login (by ?connection=, verified
?email= domain, or host pin) → IdP → /auth/enterprise/callback. The asserted
identity joins a global identity by verified email and gets a membership in the
connection's org; IdP groups map to roles through group_mapping, default-deny,
matching a group's name or its IdP externalId. Differential trust: id_token
and SAML assertions are trusted as signed; the UserInfo fallback additionally
needs email_verified=true. finish_login_session was the one place for
session-fixation regeneration and cookie minting. Login errors were
/login?error=sso_{no_connection,unavailable,domain_not_verified,unverified_fallback,no_membership}.
SAML SP (saml_provider.py, saml_routes.py, saml_metadata.py,
saml_logout.py, saml_sp_keys.py). All XML parsing and signature checks
delegated to python3-saml in strict mode, never hand-rolled. SP-initiated SSO
with one-time InResponseTo binding; opt-in IdP-initiated SSO with a
saml_consumed_assertions replay cache; assertion signature, audience and
NotOnOrAfter enforced; NameID or a configured attribute → email; encrypted
assertions and SP-signed AuthnRequests on a per-connection RSA-2048 keypair;
GET /auth/saml/metadata; IdP metadata import by URL (SSRF-guarded) or file;
bidirectional SLO (/auth/saml/sls, and /auth/logout redirecting to the IdP
SLO URL). Entra group-overage claims logged loudly.
SCIM 2.0 (scim_routes.py, scim_provisioning.py, scim_discovery.py).
Bearer-authenticated /scim/v2/Users and /Groups with RFC 7644 PATCH, plus
ServiceProviderConfig, ResourceTypes, Schemas publishing only the honoured
attributes. A User is a membership; active:false / DELETE suspend or remove it
and kill that org's sessions only. Identity is externalId-first
(memberships.external_id, unique per tenant) so a re-push under a changed
email updates rather than forks. Groups (scim_groups, scim_group_members)
own group → role; roles are recomputed over the union of a member's groups,
org-fenced. Bearer rotation with an optional SCIM-only grace window, audited in
connection_secret_events.
Admin surface (connection_admin_routes.py, connection_admin_views.py,
connection_create_form.py, connection_doctor.py, connection_probe.py).
/auth/connections, gated on the manage_connections admin capability
(auth/admin_policy.py, removed with it), org-fenced, CSRF-gated: create
OIDC/SCIM/SAML/domain connections, claim and verify domains, readiness
checklist, rotation history; secrets shown once (a minted SCIM bearer) and
never echoed.
CLI (cli/auth_connection.py): dazzle auth connection create | create-saml
| create-scim | list | add-domain | show-verification | verify-domain | doctor
[--probe] | rotate-secret [--grace] | revoke-previous-secret | secret-history |
enable/disable-request-signing | enable/disable-assertion-encryption |
enable/disable-idp-initiated | scaffold | delete, and
dazzle auth rotate-encryption-key.
Schema. Tables connections, connection_secret_events, scim_groups,
scim_group_members, saml_consumed_assertions; dropped by Alembic revision
0023_flense_park_enterprise_auth, whose downgrade holds the exact DDL. Re-add
them with a new revision (ADR-0017), register them in
dazzle.db.artifact_registry (ADR-0047) and regenerate
FRAMEWORK_SCHEMA_SNAPSHOT. memberships.external_id and its unique index
were kept.
Counter-priors (hold even when forced). - Never hand-roll SAML XML or signature validation; python3-saml or an equivalent maintained library only. - Never route or provision on an unverified domain; a connection with no verified domains asserts nobody. - Never let an IdP group grant a role by default; unmapped groups grant nothing. - Never store or echo a secret in plaintext; never put connections in the DSL. - Never rewrite a global identity's email from a SCIM push.
Reopen when (consumer force)¶
Operator ruling (James, 2026-10-16): enterprise auth will be a core
requirement of the platform even though no consumer has declared it yet, and
the retired implementation is the starting point for the design discussion, not
a design to restore verbatim. That ruling is a second route to FORCED: the
operator may move this DD to FORCED with consumer: platform (core
requirement) whenever the work is scheduled, without the consumer evidence in
items 2 and 3; the design questions were settled the same day (see Agreed
plan). Until then the status stays PARKED and
the memberships.external_id drop that plan §10 listed as "open for the
operator" is off the table (SCIM identity is externalId-first).
- Named consumer — AegisMark (school SSO via Entra ID or Google) is the
candidate; its runbook
docs/enterprise-auth-enablement.mdalready treats go-live as a reviewed change. Any consumer qualifies. - Evidence — the consumer declares
[capabilities] enabled = ["auth.enterprise.oidc" | ".saml" | ".scim"]indazzle.tomland has a real IdP tenant to test against (not a mock). - Acceptance that unblocks — in the consumer's CI or staging, a user of a verified domain signs in through the real IdP and lands in the right org with group-mapped roles; for SCIM, the IdP provisions and deprovisions a user and a group end to end.
When a force lands:
- Set
status: FORCEDand date it. - Comment on every linked issue with the consumer + this DD path.
- Restore the protocol the consumer needs from
23cf59372(not all three by default), with its capability registration, tables (new revision) and tests; re-add thesamlextra only for SAML. - When shipped, set
status: DONEand close issues.
Agreed plan (2026-10-16)¶
The discussion between the operator and the session agent on 2026-10-16
settled the questions the first write-up raised. What flense ship AD removed,
as a size: 153 files, 20,757 lines deleted (about 7.4k src, 11.6k tests,
the 262-line operator guide, the saml extra). git show 23cf59372:<path> for
any file; git show --stat 229226a88 for the list.
Kept as designed (the parts that encode hard-won judgement): the
Connection runtime-data model with org fencing; verified-domain routing and
provisioning as the anti-hijack gate; AES-GCM secrets with the two-key
rotation window; default-deny group → role mapping matched on name or IdP
externalId; SCIM identity externalId-first with org-scoped session kill on
deprovision; finish_login_session as the single session-minting point; the
boot guard that logs loudly when connection rows exist for an unmounted
protocol. The counter-priors above hold.
Decisions:
- Gating is
[auth.enterprise]indazzle.toml, not a capability registry.protocols = ["oidc", "scim"]names what mounts. A declared protocol whose extra does not import is a boot error, never a silent skip, so the old "declared but inert" posture cannot recur. The capability framework removed in ship AI is not rebuilt. - OIDC and SCIM only. OIDC covers Google Workspace, Microsoft Entra ID, ADFS (since 2016) and Sign in with Apple, which are every exemplar; SCIM is independent of the sign-in protocol. Restore OIDC first, SCIM second.
- SAML is not restored (operator's view: obsolete for Dazzle's market).
It was the riskiest piece of the old stack (XML signature handling,
python3-samlplus nativelibxmlsec1on every dyno, the most special-case code). If a customer arrives on a SAML-only IdP, the answer is a hosted broker (Keycloak, Auth0, Entra External ID) that terminates SAML and presents OIDC to Dazzle, as a per-customer deployment choice. The known triggers are UK education federations: RM Unify and UK Access Management Federation (Shibboleth). AegisMark's pilot material names neither. require_ssoper org is the gate. New behaviour, not a restore: once an org has a verified domain and an active connection and setsrequire_sso, password and magic-link login for that domain are refused and the user is sent to the IdP; the group mapping is then the only source of roles for those users. With IdP sign-in, MFA is the IdP's job; local two-factor (DD-014) stays parked for orgs without an IdP and ships separately if a consumer's assurance pack names it.- Admin surface is CLI first.
dazzle auth connection …comes back; the/auth/connectionspages and themanage_connectionsadmin capability wait for the first customer whose IT admin self-serves. The one exception isconnection_doctor's readiness checklist, exposed read-only on the existing org admin page because support will ask for it. - Tests. Restore the behaviour tests and the OIDC double verbatim; fold
the ten
test_scim_*_leftover.pyfiles (improve-loop outputs, one per SCIM attribute, about 3.5k of the 11.6k test lines) into one parametrised gate (flense ship AX principle: one gate per invariant). The SAML tests andsaml_idp_double.pyare not restored. - Consumer shape. AegisMark is the design partner. Its ADR-0012
migration onto the framework membership model did ship (AegisMark
CHANGELOG, "Enterprise-auth membership migration, EAM P1–P4": native
tenant_host:routing, login-time membership gating, the custompipeline/tenantgate retired), so the group → role mapping has a membership to write to. Itsdocs/enterprise-auth-enablement.mdrunbook (per-org connections alongside session login; Heroku enablement steps) is the acceptance script, minus the SAML and[saml]extra lines. - Schema.
connections,connection_secret_events,scim_groupsandscim_group_membersreturn by one new Alembic revision built from thedowngradeof0023_flense_park_enterprise_auth(notsaml_consumed_assertions), registered indazzle.db.artifact_registrywith the snapshot regenerated.memberships.external_idand its unique index stay. The connection-secret crypto becomes the single secret-at-rest mechanism, so a later DD-014 restore encryptstotp_secretwith it rather than adding a second.
Consumer (force)¶
platform (core requirement) — the operator's ruling of 2026-10-16, not a named
customer. The work plan is docs/plans/2026-10-16-enterprise-auth-restore.md
(ships EA-0 to EA-6, model placement, the rules every ship obeys). AegisMark is
the design partner and receives the hand-off; its enablement runbook minus the
SAML lines is the acceptance script.
Already shipped (do not re-litigate)¶
- The code, tests and operator guide at
23cf59372dc91d2636dbde0e6aceb549cd40efd3(thearchivebranch holds retired docs, not this code; use the SHA). - Kept in the tree: social OAuth via Authlib, magic link, member admin and
manage_members, org activation, global Identity + Organization + fenced Membership,memberships.external_id, the DNS resolvers (now intenant/aliases.py), and thessoextra (Authlib + dnspython).
Explicitly out of scope until force¶
- SAML 2.0 (decision 3 above): not restored; a SAML-only IdP is bridged by a hosted broker that presents OIDC. Reopen this line only for a signed customer on RM Unify or UK federation Shibboleth who refuses a broker.
- WS-Federation, LDAP, Kerberos; SCIM Bulk; just-in-time org creation from an IdP.
- Re-adding a protocol no consumer asked for because the others came back.
Trail¶
| Date | Event |
|---|---|
| 2026-10-15 | Parked (this DD); enterprise auth removed in flense ship AD, tables dropped by 0023_flense_park_enterprise_auth |
| 2026-10-16 | Operator ruling: enterprise auth is a core requirement without a named consumer; the retired implementation is the discussion starting point; memberships.external_id drop withdrawn; Discussion starter added |
| 2026-10-16 | Discussion resolved (operator + session agent): OIDC and SCIM only, SAML out of scope with the broker route, [auth.enterprise] gating, require_sso per org, CLI-first admin; Agreed plan replaces the discussion starter |
| 2026-10-16 | FORCED (consumer: platform (core requirement)); work plan docs/plans/2026-10-16-enterprise-auth-restore.md; EA-0 inventory (Haiku) and EA-1 substrate (Opus) dispatched |
| 2026-10-16 | Restore shipped on main (EA-1 to EA-6, nine commits a736301fd..b864abdb6); acceptance record in the plan §4; two archive rules changed on evidence: verified-domain ownership is per org, doctor --json is plain stdout. Status stays FORCED until the hand-off is acknowledged, then DONE |