Phase 14 — Enterprise
Purpose: Task specs for Phase 14 — enterprise features: SSO (SAML 2.0 + OIDC), SCIM user/group provisioning, and the audit + org policies + data retention + org admin console cluster.
Phase exit criteria (ROADMAP.md, Phase 14):
- SSO round-trip E2E.
- SCIM sync tested.
- Retention job purges per policy (integration test).
- Audit log covers all mandatory events.
Task range: TASK-0131 … TASK-0133 (3 tasks — this agent owns exactly TASK-0131..TASK-0133; TASK-0096..0120 are owned by other agents).
Note on section references: the brief cites PRODUCT-SPEC "§36–46"; the actual PRODUCT-SPEC.md sections are §21 (AI), §22 (API/Webhooks), §23 (Audit Logging), §24 (Security Requirements), §26 (Performance Targets). This file cites the real section numbers.
Task Table
| ID | Title | Estimate |
|---|---|---|
| TASK-0131 | SSO: SAML 2.0 SP + OIDC (JIT provisioning, enforce mode, admin UI) | L |
| TASK-0132 | SCIM 2.0 user/group provisioning | M |
| TASK-0133 | Audit viewer, org policies, data retention, org admin console | L |
Phase Completion Checklist
- [ ] SAML 2.0 SP per org (
SsoProviderconfig: entityId, SSO url, certificate ref, nameId/email mapping);/saml/metadata+/saml/acswith assertion validation (signature, audience, expiry, nameId) → JIT match/create user → workspace membership per policy → session; auditsso.login.saml. - [ ] OIDC (authorization code + PKCE, ID token via JWKS, claims mapping) at
/sso/oidc/:orgId/authorize→ same provisioning path; auditsso.login.oidc. - [ ] Enforce mode:
ssoEnforcedpolicy blocks password login with a redirect to SSO (E2E-asserted). - [ ] SSO E2E green against a samlify-based SAML IdP mock and an OIDC mock server (round trip: login → session → workspace access).
- [ ] Admin UI SSO tabs: provider config form, test-connection button (fetches metadata / JWKS + reports), enforce toggle.
- [ ] SCIM 2.0:
POST/GET/PATCH/DELETE /scim/v2/Users+/scim/v2/Groupswith org bearer token fromscimConfig(hashed); deactivate = disable (not delete); groups → teams;userName/activefilters + pagination; lifecycle E2E (create → can login; deactivate → login blocked); auditscim.user.created/scim.user.deactivated. - [ ] Audit viewer at
/org/[id]/admin/audit: filters (actor, action, resource type, date range, workspace), detail drawer (full metadata JSON, ip, ua), async CSV export for >10k rows; mandatory-event completeness test green (every event in PRODUCT-SPEC §23 + phase-added security events fires in integration tests; missing emitters added). - [ ] Org policies (zod JSON schema:
require2FA,ssoEnforced,disableGuestPublicShare,maxFileSizeMB,sessionLifetimeHours,allowExternalEmbeds) enforced at their five enforcement points (login 2FA, guest public share blocked with 403, upload size, session lifetime, embed rendering) + admin policy editor with live validation. - [ ] Retention:
retention_policiesper org+scope (trash 30 d, comments 90 d, audit per legal hold, files per policy); worker daily job with dry-run (log only) and execute (purge +retention.purgedaudit with counts); integration test proves purge-per-policy. - [ ] Org admin console at
/org/[id]/admin(tabbed: overview, members, integrations, policies, retention, API keys, webhooks, audit) with org-admin role gate; E2E: SAML login → workspace access; SCIM lifecycle; policy blocks guest public share (403); retention dry-run → execute.
Test Fixtures & Mocks (shared across Phase 14 tasks)
The three tasks share one fixture directory, tests/e2e/mocks/, so the E2E
suites never depend on external IdPs:
| Fixture | Stack | What it provides |
|---|---|---|
tests/e2e/mocks/saml-idp.ts |
samlify (IdP mode) | Full SAML 2.0 IdP on a local HTTP port (test-only): login page with auto-approve profile (and a manual-approve mode for the E2E), AuthnResponse builder with signature (RSA-SHA256), NameID, attribute statements (email, name); certificate generated at fixture startup (per-run keypair — values match the *_test_* gitleaks allowlist from TASK-0134); also serves IdP metadata XML so the admin "test-connection" path has a real endpoint to fetch. |
tests/e2e/mocks/oidc-issuer.ts |
custom (node:http + node:crypto) | Minimal OIDC provider: /.well-known/openid-configuration, /.well-known/jwks.json (per-run RS256 keypair), /authorize (auto-approve + a manual consent page for the E2E), /token (code + refresh grants), ID-token issuer with configurable claims; in-memory code store (single-use, 10 min) mirroring the contract under test. |
tests/e2e/mocks/scim-client.ts |
typed fetch wrapper | SCIM 2.0 client for the E2E: createUser, listUsers(filter, page), patchUser, deleteUser, createGroup, …; carries the bearer token captured from the SCIM enable response; asserts the application/scim+json content type and error envelope on every call. |
tests/e2e/mocks/saml-goldens/ |
static XML | Golden SP-metadata document + a valid AuthnResponse + the tampered variants (bad signature, expired, wrong audience, wrong destination, replayed nonce, non-success status) — one file per variant, referenced by name from the assertion-matrix test so each rejection is reproducible and reviewable. |
Fixture lifecycle: each Playwright worker run boots the fixtures it needs
(beforeAll), tears them down (afterAll); ports are fixed in
tests/e2e/mocks/ports.ts (no collisions with the compose stack). The fixtures
are contract sources: if the admin UI or the API changes a SAML/OIDC/SCIM
behavior, the fixture golden is updated in the same PR and the diff is reviewed
as part of the security review.
TASK-0131
Title
SSO: SAML 2.0 service provider + OIDC, JIT provisioning, enforce mode, admin UI
Objective
Let organizations log in through their identity provider: SAML 2.0 SP (metadata, ACS with full assertion validation, JIT provisioning, auto workspace membership, session) and OIDC (authorization code + PKCE, JWKS ID-token validation, claims mapping) through the same provisioning path — with an ssoEnforced org policy that blocks password login, audited logins, and an admin configuration UI with test-connection.
Background
Models exist: SsoProvider (per org: type SAML|OIDC, name, config Json, enforce Boolean), SsoIdentity (providerId, externalId, userId, unique per provider), Organization.policy Json (holds ssoEnforced), Organization.ssoConfig Json?. Phase 1 provides sessions (JWT access + rotating refresh), login/logout, and audit. Phase 13's OAuth2 module provides PKCE/JWT machinery to reuse for OIDC (claims mapping differs: SSO signs the user in — type:"sso" JWTs, not third-party grants). Admin config surface lands in the org admin console built by TASK-0133 (the SSO tabs component is built here, mounted there; if TASK-0133 lands first, the tabs are dropped into its layout — either way the route /org/[id]/admin/integrations?tab=sso renders them).
Requirements
- Config contract (zod,
packages/enterprise/src/sso/schemas.ts— newpackages/enterprisepackage? Decision: no new package — SSO/SCIM/policy code lives inapps/api/src/modules/sso/,apps/api/src/modules/scim/,apps/api/src/modules/org/per the modular-monolith invariant;packages/sharedonly gains audit event names):- SAML
SsoProvider.config:{entityId, ssoUrl, acsUrl (our ACS, default<origin>/saml/acs), x509CertificateRef (a ref into the org's encrypted secrets store — cert PEM itself is stored AES-GCM encrypted inOrganization.ssoConfig.certificateEncrypted, same pattern as TASK-0130 webhook secrets), nameIdAttribute, emailAttribute, emailIsVerified: true (assertion emails are treated as verified), attributeMapping? (extra claim→field map)}. - OIDC
SsoProvider.config:{issuer, clientId, clientSecretEncrypted (reveal-once at config save), authorizationEndpoint, tokenEndpoint, jwksUri (or derived from issuer well-known), redirectUri (<origin>/sso/oidc/:orgId/callback), scope: "openid email profile", claimsMapping? {sub, email, name, emailVerified}, attributeMapping?}.
- SAML
- SAML SP:
GET /saml/metadata/:orgSlug(SP metadata XML for the org — entityId<origin>/saml/metadata/<orgSlug>, ACS binding URL, supported bindings HTTP-Redirect + HTTP-POST, nameId format);GET /saml/acsis not a GET target — the IdP POSTs to/saml/acs(AuthnResponse): validation order — signature (against the org's current + previous certificate, cert rotation grace: keepcertificateEncrypted+previousCertificateEncrypted), Conditions NotOnOrAfter (expiry), AudienceRestriction = our entityId, Destination = ACS, Replay (InResponseTo + nonce dedupe in Redis 10 min), Status = Success; extract nameId (pernameIdAttributemapping) + email (peremailAttribute). On valid assertion: match or create — existingSsoIdentity {providerId, externalId: nameId}→ that user; else match by verified email → link identity; else JIT create (user with email from assertion,passwordHash: null— passwordless account, display name from assertion, email considered verified) +SsoIdentityrow. Then workspace membership: auto-join all workspaces of the user's org (org membership derived from the SsoProvider's org; the JIT user becomes anOrganizationMemberwith roleMEMBERby default, configurable viapolicy.ssoDefaultRole), and open a session (same session creation as password login: access JWT + refresh token, device fingerprinted per Phase 1). Auditsso.login.saml {orgId, userId, nameId, ip, ua}; failures audited too (sso.login_failed {reason}— bad signature, expiry, audience, unknown email when JIT disabled bypolicy.ssoJitProvisioning:false). - OIDC:
GET /sso/oidc/:orgId/authorize→ build authorization URL (code + PKCE S256, state signed + Redis-bound to the starting session context, 10 min) → redirect to IdP;GET /sso/oidc/:orgId/callback→ exchange code (client secret from encrypted config) → validate ID token (JWKS fromjwksUriwith cache 1 h; signature,iss= configured issuer,aud= clientId,exp/nbf,noncematch) → claims perclaimsMapping→ same match-or-create + membership + session path (shared servicesso-provisioning.service.ts— one code path for both protocols, selected by provider type). Auditsso.login.oidc. - Enforce mode: org policy
ssoEnforced: true(mirrored intoSsoProvider.enforceas the source of truth — the policy editor writes both, tested together): the login endpoint (POST /auth/login) and password-reset-confirm check the org of the email's known workspaces (org resolution: email → users → orgs; no org → normal login); if any org is ssoEnforced for that user →403 FORBIDDEN {reason:"sso_required", orgs: [slugs]}and the web login page redirects to the org's SSO entry (SAML: redirect to IdP login URL with RelayState; OIDC: authorize URL) instead of showing the password form. A password login via SSO-created user with no password is impossible anyway (passwordless account). - Admin UI (tabs component
SsoTabs.tsx, mounted by TASK-0133's Integrations tab; standalone route/org/[id]/admin/integrationsworks even before the console lands): SAML card + OIDC card (org has one active provider per type; create/edit/activate). Form fields per the config contract (cert paste → stored encrypted; secret reveal-once). Test connection: SAML → fetches the IdP metadata URL (optional field) or validates the pasted certificate parses + entityId non-empty + ACS reachable (self-check) → green/red result panel; OIDC → fetches/.well-known/openid-configurationfrom the issuer (validates endpoints present + JWKS fetches + a key parses) → result panel. Enforce toggle (confirm dialog: "Password login will be blocked for this org's users"). Danger zone: deactivate provider (keeps identities; logins fall back to password for users who have one). - Security defaults: SSO emails are treated as verified (no verification email); JIT-created users cannot set a password unless an admin enables
policy.ssoAllowPasswordSetup(default false); SSO login respects the orgrequire2FApolicy (TASK-0133) — 2FA prompt after SSO assertion when required.
Wire examples (canonical shapes the fixtures and tests assert against):
SP metadata (abridged, GET /saml/metadata/acme):
<md:EntityDescriptor entityID="https://factory.example.com/saml/metadata/acme">
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://factory.example.com/saml/acs" index="0"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>
Accepted AuthnResponse (what the samlify IdP mock POSTs to /saml/acs; the
golden tests/e2e/mocks/saml-goldens/valid-authn-response.xml is the full
signed document):
<samlp:Response Id="_test_resp_001" InResponseTo="_test_authnreq_001"
Destination="https://factory.example.com/saml/acs"
Issuer="urn:mock-idp:acme" Status="Success">
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
<saml:Assertion ID="_test_assert_001" Issuer="urn:mock-idp:acme">
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">jane@acme.test</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData NotOnOrAfter="…+10min" InResponseTo="_test_authnreq_001"
Recipient="https://factory.example.com/saml/acs"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="…−1min" NotOnOrAfter="…+10min">
<saml:AudienceRestriction><saml:Audience>https://factory.example.com/saml/metadata/acme</saml:Audience></saml:AudienceRestriction>
</saml:Conditions>
<saml:AttributeStatement>
<saml:Attribute Name="mail"><saml:AttributeValue>jane@acme.test</saml:AttributeValue></saml:Attribute>
<saml:Attribute Name="displayName"><saml:AttributeValue>Jane Doe</saml:AttributeValue></saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
OIDC authorize redirect (step 1 of the flow):
GET /sso/oidc/org_01J9…/authorize → 302
Location: https://mock-oidc.test/authorize?response_type=code
&client_id=cli_sso_01J9…&redirect_uri=https://factory.example.com/sso/oidc/org_01J9…/callback
&scope=openid%20email%20profile&state=<signed, Redis-bound>&nonce=<128-bit>
&code_challenge=<S256>&code_challenge_method=S256
Token exchange (step 3):
POST /token (mock-oidc.test; client secret from the encrypted org config)
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=oc_…&redirect_uri=…&code_verifier=<original verifier>
→ 200 { "access_token": "eyJ…", "id_token": "eyJ…", "token_type": "Bearer",
"expires_in": 900, "scope": "openid email profile" }
(The id_token is validated per the Requirements — JWKS, iss/aud/exp/nbf,
nonce — then the claims-mapping + provisioning path runs; the access_token
from the IdP is discarded after validation, never stored.)
Acceptance Criteria
- E2E (SAML, the exit "SSO round-trip"): test IdP (samlify in
tests/e2e/mocks/saml-idp.ts, spun up per run) with a signed AuthnResponse → POST to/saml/acs(Playwright-driven: user clicks "Sign in with SSO" on the login page → browser follows to IdP → IdP auto-approves and POSTs back) → session cookie set → user lands in the org's workspace (assert workspace content renders); auditsso.login.samlpresent; second login reuses the same user (no duplicate user rows). - Integration (assertion validation matrix): each tampered assertion is rejected with the right
sso.login_failedreason — bad signature, expired (NotOnOrAfter past), wrong Audience, wrong Destination, replayed InResponseTo (second use), non-Success status. - Integration (OIDC): mock OIDC server (
tests/e2e/mocks/oidc-server.ts, JWKS with RS256) → full authorize → callback → session; ID token with wrongaud/expired/unsigned rejected; refresh of the SSO session works like any session (Phase 1 rotation). - Integration (JIT + membership): assertion email not in system → user created (email verified flag true, no password),
OrganizationMemberrow exists for the provider's org with role frompolicy.ssoDefaultRole; assertion email matching an existing user with 3 workspaces in 2 orgs → only the SSO org's workspaces auto-joined (assert no membership in the other org). - E2E (enforce): set
ssoEnforced→ password login for an org user →403 {reason:"sso_required"}+ login page redirects to the IdP (assert the redirect target is the SAML IdP URL with RelayState); a user with no org membership can still password-login (policy only applies per-org). - UI: admin SSO tabs render both provider forms; test-connection returns a visible green/red panel (SAML with a bad cert → red with parse error; OIDC against the mock → green); enforce toggle persists and is visible as a badge.
- Audit: both protocols emit their login event with ip + ua (asserted rows in
audit_logs).
Backend Changes
apps/api/src/modules/sso/:saml.controller.ts(metadata + ACS),saml.service.ts(assertion validation — usesamlifyin SP mode for parsing/validation to avoid hand-rolled XML crypto; our value-add: replay/nonce, audience strictness, audit hooks),oidc.controller.ts+oidc.service.ts(authorize/callback, JWKS cache, ID token validation),provisioning.service.ts(shared match-or-create + membership + session),sso-admin.controller.ts(provider CRUD + test-connection + enforce),schemas.ts(zod config contracts).- Session middleware:
type:"sso"JWTs accepted (same shape as user JWTs,authMethod:"sso|password"claim); login controller: org enforcement check before password verification (fast path: no org → skip). - Crypto: cert/secret storage reuses
packages/shared/src/crypto.tsAES-GCM (TASK-0130) intoOrganization.ssoConfig(certificateEncrypted,previousCertificateEncrypted,clientSecretEncrypted). - Audit events added to
packages/sharedevent names:sso.login.saml,sso.login.oidc,sso.login_failed,sso.provider_configured,sso.enforce_changed.
Frontend Changes
- Login page: "Sign in with SSO" button per org detected from the entered email (live lookup endpoint
GET /auth/sso/options?email=…→ org slugs with SSO enabled; anonymous-safe — reveals only that an SSO exists, not provider identity) → redirect to the SSO start URL. apps/web/components/admin/SsoTabs.tsx: SAML + OIDC config forms, test-connection panel, enforce toggle + confirm, certificate/secret reveal-once dialogs, deactivate danger zone; wired into the org admin Integrations tab (route works standalone).- Error state on login when
sso_required: message + "Continue with SSO" button.
Database Changes
- No new tables/models.
SsoProvider.config,SsoIdentity,Organization.policy/ssoConfigcarry the data (zod contracts documented in comments). If certificate storage needs a column beyond thessoConfigJson (e.g. size), keep it in the Json — documented decision: certs ≤ 16 KB inssoConfig, no column.
API Changes
GET /saml/metadata/:orgSlug(XML),POST /saml/acs(IdP form post),GET /sso/oidc/:orgId/authorize,GET /sso/oidc/:orgId/callback,GET /auth/sso/options?email=, admin:POST/GET/PATCH/DELETE /api/v1/orgs/:orgId/sso(provider per type),POST /api/v1/orgs/:orgId/sso/test(SAML/OIDC probe),PATCH /api/v1/orgs/:orgId/sso/enforce. Admin routes require org-admin role (TASK-0133 role gate; interim: org owner).
Tests
- E2E: SAML round trip (samlify IdP mock), OIDC round trip (mock server), enforce-mode redirect (both protocols' entry points).
- Integration: the assertion matrix, JIT/membership scenarios, test-connection probes (bad cert, good OIDC discovery), enforce check unit (org resolution from email).
- Unit: zod config contracts (every field, cert parse), nonce dedupe, JWKS cache expiry, RelayState round-trip, claims mapping (nested claim paths).
Security
- Assertion/ID-token validation is defense-in-depth: signature + audience + destination + expiry + nonce/replay (all tested individually — a bypass of any single check must still be caught by another where applicable).
- Certificates/secret at rest AES-GCM (never in
SsoProvider.configplaintext — schema test asserts no PEM in that Json); reveal-once for the OIDC client secret. GET /auth/sso/optionsis minimal-info (org slug + "sso available" only — no provider type/urls to unauthenticated callers).- Enforce mode cannot lock out the org owner: owner with password is also blocked (documented + test: admin recovers via API with an elevated service credential — documented runbook in
docs/development/deployment.md, tested as the "owner locked out → support flow" integration test using the service account). - JIT provisioning cannot grant memberships outside the SSO org (tested); role from
policy.ssoDefaultRolecapped atMEMBER(owner/admin must be granted by an org admin — schema enforces the cap, tested).
Documentation
docs/security/security-architecture.md: SSO threat model (assertion replay, cert rotation, enforce lockout runbook);docs/api/SSO endpoints (admin + public routes);docs/development/deployment.md: SSO setup guide (SAML + OIDC, with mock IdP instructions for self-hosters);docs/ui/admin SSO tabs.
Dependencies
- Phase 1 (sessions, audit, login), TASK-0133's org-admin role gate (interim: org owner check — the gate function
requireOrgAdminis defined in TASK-0133'sorgmodule; if that task lands later, this task defines it and TASK-0133 reuses it), TASK-0130's crypto utility (AES-GCM; if not yet available, a localsso/crypto.tswith the same interface, consolidated later — recorded).
Definition of Done
- [x] SAML SP (metadata, ACS, full validation matrix, replay protection) + OIDC (authorize/callback, JWKS, claims mapping) both provisioning through one shared service.
- [x] JIT create + email-match + identity-link + org membership per
policy.ssoDefaultRole; sessions withauthMethod:"sso". - [x]
ssoEnforcedblocks password login (403 + redirect) per org; lockout recovery runbook documented + tested. - [x] Admin SSO tabs (config, test-connection, enforce toggle) with a11y; audit events for all paths.
- [x] E2E SAML + OIDC round trips green against mocks; integration matrix green; docs updated.
Requirement conflicts & recorded decisions (rule 29)
- samlify SP-mode vs per-check rejection reasons. The Backend Changes
say "use samlify in SP mode for parsing/validation to avoid
hand-rolled XML crypto", but the Acceptance Criteria require distinct,
per-check
sso.login_failedreasons (bad signature / expired / audience / destination / replay / status) — samlify'svalidateSamlResponsecollapses all of those into one rejection and exposes no per-check hook. Decision: parse with@xmldom/xmldom, verify withxml-crypto(SignedXml, enveloped + exc-C14N, RSA-SHA256) in the exact documented check order, and use samlify only for what it does uniquely (the mock IdP's signing intests/e2e/mocks/saml-idp.ts—SamlLib.constructSAMLSignature). The "no hand-rolled XML crypto" intent is preserved: all signature crypto is xml-crypto's. require2FApolicy field has no 2FA implementation.Organization.policyincludesrequire2FA(Phase 2 schema) but no 2FA exists in the codebase yet (later task). SSO does not gate on it; recorded so TASK-0133's policy editor doesn't advertise it as enforceable.- PRODUCT-SPEC "§36–46" citations vs the real spec. The task text references product-spec sections that don't exist under those numbers (the SSO/SCIM/audit requirements actually live in the real §23/§24/§26 enterprise sections). Implemented against the real sections; the task file's citations left as-is (historical).
@xmldom/xmldomships no types and pollutes the global DOM namespace. Addedapps/api/src/types/xmldom.d.ts(shim for the two classes we use) + a tsconfigpathspin.@xmldom/xmldomandxml-cryptowere samlify's transitive deps and are now direct deps ofapps/api(justified per rule 17: they ARE the validation engine, per conflict #1).- SSO protocol routes outside
api/v1vs the reference nginx's/api/-only proxying. The protocol surface is browser binding (IdP form-POST + browser GETs), so it must be reachable without theapi/v1prefix. Decision:main.tsexcludes/saml/*+/sso/*from the global prefix,AuthGuardwhitelists them, and the reference nginx carrieslocation /saml/+location /sso/→ api. Two-origin deployments additionally requireSSO_ORIGIN(the API's public origin) — documented indocs/development/deployment.md§10 +.env.example. Single-origin deployments need nothing (WEB_ORIGIN serves both). - No API endpoint sets
Workspace.organizationId. The workspace create schema is{name, slug?, description}(.strict()) and no org↔workspace route exists, yet the DoD requires "auto-join all non-personal workspaces of the org". Decision (interim): the E2E seeds the link via direct Prisma (tests/e2e/specs/sso.spec.ts,setupOrg). Owner: TASK-0133 — the org admin console should add the "attach workspace to org" route and own the invariant; SSO provisioning is correct-by-construction againstWorkspace.organizationId. - JIT emails are invisible to anonymous login-page discovery.
GET /auth/sso/optionsresolves by existing membership (minimal-info anti-enumeration, per the Security section), so a brand-new JIT email gets{orgs: []}and no "Sign in with SSO" button on the login page. Decision: the entry path for first-time SSO users is the org-provided link<SSO_ORIGIN>/sso/start/<slug>(public dispatcher — it only redirects to the org's own IdP). Documented indocs/api/sso.md+docs/ui/sso.md§1; NOT a defect. - Enforce lockout recovery has no "elevated service credential".
The Security section presumes "admin recovers via API with an
elevated service credential — documented runbook". No service-account
concept exists in the schema or auth module (searched; none).
Decision: the SSO path is never blocked by enforcement, and the
enforcement state is plain data — recovery = (a) sign in via SSO once
the IdP is reachable, or (b) flip enforce off with a
pre-enforcement session. Runbook:
docs/development/deployment.md§10.4; tested:sso.spec.ts"enforce mode…" (owner 403 → SSO path open → un-enforce → password login again).
TASK-0132
Title
SCIM 2.0 user/group provisioning
Objective
Provision users and groups from an identity system via SCIM 2.0: POST/GET/PATCH/DELETE /scim/v2/Users and /scim/v2/Groups with org bearer-token auth, create → User + workspace memberships, groups → teams, deactivation = disable (not delete), standard filters and pagination, audited, with a full lifecycle E2E (mock SCIM client creates a user who can log in, deactivation blocks login).
Background
Organization.scimConfig Json? holds the SCIM setup: {bearerTokenHash (SHA-256 of the raw scim_… token, raw shown once at enable), provisioningBase? (unused, we are the target), autoJoinOrgWorkspaces: true (default), defaultRole: "MEMBER"}. User.status enum (ACTIVE, SUSPENDED, …) exists (Phase 1/2); teams exist (Team, TeamMember). The modular monolith gets apps/api/src/modules/scim/. SCIM 2.0 (RFC 7643/7644) subset: Users schema (userName, name, emails, active, metadata), Groups schema (displayName, members), filters userName eq "…" / active eq true|false (and combined with and), pagination via startIndex/count (SCIM ListResponse totalResults/startIndex/itemsPerPage), ETag support (If-Match on PATCH/DELETE → 412 on mismatch), standard SCIM errors (400 invalidFilter, 409 duplicate, 403 forbidden, 401 invalidCredentials).
Requirements
- Auth:
Authorization: Bearer scim_…on/scim/v2/*only → SHA-256 lookup againstscimConfig.bearerTokenHashacross orgs (single token per org; lookup is a Redis-cached hash map refreshed on config change — the hash→orgId index is maintained in Redis, not a DB scan). Bad/unknown token →401 {schemas:["urn:ietf:params:scim:api:errors:2.0"], statusCode:401, detail:"Invalid authentication credentials"}. The token acts as the org (actorTypeINTEGRATION, actorId = org id — audited). - Users:
POST /scim/v2/Users(body per SCIM User schema,userNamerequired — mapped to email: ifuserNamecontains@it is the email, else<userName>@<org domain from scimConfig.userEmailDomain>;emails[0].valuewins when present), createUser(status ACTIVE ifactive !== false, display name fromname, no password unless the SCIM payload carriespassword(then bcrypt-hashed — supports IdPs that push passwords; the user can then log in with password or SSO), email-verified true) +OrganizationMember(role fromscimConfig.defaultRole, capped MEMBER) + auto-join org workspaces (same policy as SSO JIT:autoJoinOrgWorkspaces). Response 201 with the SCIM representation +Locationheader. DuplicateuserName/email →409 duplicate.GET /scim/v2/Users(list, filters, pagination),GET /scim/v2/Users/:id(SCIM id = the User id),PATCH /scim/v2/Users/:id(SCIM operations:add,replace,removeonactive,name,emails,userName—replace active:false= deactivate →User.status SUSPENDED(login blocked at the session + login layer, Phase 1's status check), memberships kept),DELETE /scim/v2/Users/:id→ same as deactivate (SCIM contract: DELETE deprovisions; we disable, never hard-delete — recorded decision, surfaced in the responsemetadata+ docs) — auditscim.user.deactivated {userId, via:"delete"}. - Groups:
POST/GET/PATCH/DELETE /scim/v2/Groups— a Group maps to aTeamin the org's primary workspace (team name =displayName, unique per workspace — rename on PATCH updates the team; members:value= SCIM user id (ours) or email — resolved to User;removeoperations dropTeamMember). DELETE group → delete the team (members keep their workspace membership — only team membership is removed, auditedscim.group.deleted). - Filters/pagination:
?filter=userName eq "jane@example.com",active eq false, combineduserName eq "x" and active eq true(SCIM filter grammar subset — a small parser inscim/filters.ts, unit-tested against the grammar; unsupported filter →400 invalidFilter);startIndex(1-based) +count(≤ 100, default 50); responsetotalResults. - ETag: each resource carries a versioned ETag (hash of the representation);
If-Matchmismatch on PATCH/DELETE →412 {statusCode:412, detail:"Precondition Failed"}. - Audit:
scim.user.created {userId, source:"scim"},scim.user.updated,scim.user.deactivated,scim.group.created/updated/deleted— all actorTypeINTEGRATION(org id), with the operation summary in metadata (never the full payload secrets — no passwords in audit, ever). - Admin UI: Integrations tab → SCIM card (enable → generates
scim_…token shown once with copy + warning, like API keys; disable (token invalidated immediately); test connection =GET /scim/v2/Users?count=1with the token from the browser → 200/401 result panel; shows the endpoint URL for the IdP to configure + a curl example).
Wire examples (canonical SCIM shapes):
Create user (IdP → us):
POST /scim/v2/Users
Authorization: Bearer scim_live_01J9…
Content-Type: application/scim+json
{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "jane.doe", "name": { "givenName": "Jane", "familyName": "Doe" },
"emails": [{ "value": "jane@acme.test", "primary": true }],
"password": "initial-rotated-by-idp", "active": true }
→ 201 Location: /scim/v2/Users/usr_01J9…
{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "id": "usr_01J9…",
"userName": "jane@acme.test", "active": true,
"meta": { "resourceType": "User", "created": "…", "location": "…",
"version": "W/\"etag-a1b2\"" } }
(No @ in userName + no emails[0] → mapped to jane.doe@<userEmailDomain>.)
Deactivate (replace active):
PATCH /scim/v2/Users/usr_01J9…
If-Match: W/"etag-a1b2"
Content-Type: application/scim+json
{ "schemas": ["urn:ietf:params:scim:patch:2.0"],
"Operations": [{ "op": "replace", "path": "active", "value": "false" }] }
→ 200 (User.status → SUSPENDED; login + in-flight sessions blocked; membership kept)
List with filter + pagination:
GET /scim/v2/Users?filter=active eq false&startIndex=1&count=50
→ 200 { "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 7, "startIndex": 1, "itemsPerPage": 50,
"Resources": [ … ] }
SCIM error envelope (every non-2xx):
{ "schemas": ["urn:ietf:params:scim:api:errors:2.0"],
"status": "409", "detail": "A User with userName 'jane@acme.test' already exists." }
(status is the HTTP status as a string per RFC 7644; codes used: 400
invalidFilter/invalidSyntax, 401 invalidCredentials, 403 forbidden,
404, 409 duplicate, 412 on ETag mismatch.)
Acceptance Criteria
- E2E (lifecycle, the exit "SCIM sync tested"): mock SCIM client (a scripted HTTP client in
tests/e2e/mocks/scim-client.ts) POSTs a user → 201; the user logs in via the web UI (password pushed in the SCIM payload — or via SSO if configured; test uses password) → reaches the org workspace (auto-join worked); client PATCHesactive:false→ the user's existing session is rejected on next API call (401/403 per Phase 1 status semantics — assert the login and an in-flight session both fail) and a fresh login attempt → blocked (403account_suspended); client DELETEs → same state, user row still exists (assertstatus SUSPENDED, not deleted). - Integration: token auth — wrong token → 401 SCIM error shape; disabled token → 401 immediately (cache invalidation test); two orgs with tokens → each scoped to its own org (org A token cannot read org B users — 404, not a leak).
- Integration: user CRUD matrix — create (201 + Location + email mapping for
@-less userName withuserEmailDomain), duplicate → 409, GET by id, list withactive eq falsereturns only deactivated (assert exact set), pagination (105 users,count=50→ 3 pages,totalResults=105), PATCHreplace emailsupdates + ETag changes, PATCH with staleIf-Match→ 412, DELETE deactivates (not deletes). - Integration: groups — create group with 2 members → team exists in the primary workspace with both members; PATCH adds a member (email resolution); DELETE removes the team, users keep workspace membership.
- Integration: bad filters —
filter=userName neq "x"→ 400invalidFilter;filter=(empty) → 400. - UI: SCIM card enable → token reveal-once dialog; test-connection panel green against the running API; disable invalidates the token (subsequent curl → 401, asserted in the same E2E).
- Audit:
scim.user.created+scim.user.deactivatedrows with actorTypeINTEGRATION(asserted).
Backend Changes
apps/api/src/modules/scim/:scim.controller.ts(users + groups routes, SCIM envelope middleware:Content-Type: application/scim+json, error shape, ETag headers),scim-auth.guard.ts(token → org via Redis hash index),users.service.ts(SCIM representation ↔User/OrganizationMembermapping, password push),groups.service.ts(↔Team/TeamMember),filters.ts(SCIM filter grammar subset parser),schemas.ts(zod for SCIM User/Group bodies + operations),scim-admin.controller.ts(token enable/disable/rotate + test).Organization.scimConfigzod contract + migration-free (Json field); Redis:scim:token:{hash} → orgId(maintained on enable/rotate/disable).- Login/session layer: confirm
SUSPENDEDstatus blocks login and in-flight sessions (Phase 1 behavior — if the in-flight check is missing, add it to the JWT middleware status validation; this is a prerequisite fix, auditedauth.suspended_blocked). - Event names added to
packages/shared:scim.user.created|updated|deactivated,scim.group.created|updated|deleted,scim.token.created|disabled.
Frontend Changes
apps/web/components/admin/ScimCard.tsx(Integrations tab, mounted by TASK-0133): enable flow (token reveal-once + copy + warning + "I have stored it"), endpoint URL + curl example (for the IdP admin), test-connection panel, disable confirm; status badge (Enabled/Disabled + last used).
Database Changes
- No new tables. Reuses
User,OrganizationMember,Team,TeamMember,Organization.scimConfig. (ETag is computed, not stored —updatedAt+ content hash.)
API Changes
POST/GET /scim/v2/Users,GET/PATCH/DELETE /scim/v2/Users/:id,POST/GET /scim/v2/Groups,GET/PATCH/DELETE /scim/v2/Groups/:id(SCIM 2.0 envelope — these are the only routes outside the/api/v1prefix; excluded from the TASK-0127 OpenAPI contract by explicit allowlist, documented indocs/api/versioning.mdas a protocol surface with its own spec section indocs/api/scim.md).- Admin:
POST/GET/PATCH /api/v1/orgs/:orgId/scim(enable/disable/rotate + test), under the org-admin gate.
Tests
- E2E: the lifecycle scenario (create → login → workspace → deactivate → session + login blocked → delete → still exists suspended) + SCIM card enable/disable/test.
- Integration: the five matrix groups in Acceptance Criteria (auth, user CRUD/filters/pagination/ETag, groups, bad filters, audit rows).
- Unit: SCIM filter grammar (each operator,
and, unsupported → error), email mapping rules, ETag stability (same representation → same tag), representation mapping (every SCIM field → model field table).
Security
- Bearer token: hashed at rest (raw shown once), Redis index for lookup (no DB scan per request), disable = immediate invalidation (tested).
- SCIM payloads are untrusted input: full zod validation, email normalization + verification flag, password field hashed immediately and never logged/audited;
userName/namepass the same sanitization as profile updates. - Privilege cap: SCIM can never set a role above
MEMBER(schema + service double-check, tested); no SCIM endpoint touches permissions, API keys, or other orgs' data. - Enumeration: cross-org access returns 404 (not 403) per the existence-hiding convention.
Documentation
docs/api/scim.md(the protocol contract: auth, endpoints, filters, errors, ETag, deactivation semantics);docs/security/security-architecture.md(SCIM token threat model);docs/development/deployment.md(connecting an IdP: Okta/Entra examples with the endpoint + token); OpenAPI unaffected (allowlisted).
Dependencies
- Phase 1 (User status enforcement, sessions, audit), Phase 2 (orgs, teams, workspace membership), TASK-0131's org-admin gate + Integrations tab layout (the card mounts there; works standalone if TASK-0133 not yet landed), TASK-0130 crypto (not needed — token is hashed, not encrypted).
Definition of Done
- [x] SCIM Users + Groups endpoints live (auth, CRUD, filters, pagination, ETag, deactivation-not-deletion) with SCIM error shapes.
- [x] Groups → teams mapping; auto-join workspace policy; role cap enforced.
- [x] Login + in-flight session blocked on deactivation (prerequisite session check landed if missing).
- [x] Lifecycle E2E green; integration matrix green; audit events asserted; SCIM card UI done; docs/api/scim.md written.
Requirement conflicts & recorded decisions (rule 29)
- The in-flight
SUSPENDEDcheck was missing (prerequisite fix). The task says "confirmSUSPENDEDblocks login and in-flight sessions — if the in-flight check is missing, add it to the JWT middleware status validation." It WAS missing: login/refresh blocked suspended users (Phase 1) but a previously-issued access token kept working until it expired. Decision:AuthGuard.resolveJwtnow re-checksUser.statusper request (point PK query, fail-open on DB error) for user-JWTs; a suspended user gets403 { reason: "account_suspended" }and the block is auditedauth.suspended_blocked(user-scoped, best-effort). Placed after the OAuth 401, before both the OAuth and user-JWT returns. Tested in the SCIM integration (2g) and E2E lifecycle (in-flightpage.requestcall). application/scim+jsonneeds a dedicated body-parser. IdPs sendContent-Type: application/scim+json(RFC 7644), but body-parser's defaultjsontype matches ONLYapplication/json— so SCIM bodies arrived unparsed (req.bodyundefined) and every create 400'd. Decision: mount a secondjsonparser inmain.ts+ the integration helper withtype: "application/scim+json"(a second json parser only parses its own content type; the default one is untouched). The SCIM controller also acceptsapplication/json(tolerant) and always respondsapplication/scim+json.- The SCIM exceptions filter must handle
ApiError, not justHttpException. The sharedZodValidatepipe throwsApiError(which extends plainError, notHttpException), so an invalid SCIM body hit the filter's 500 fallthrough instead of a 400. Decision:ScimExceptionsFiltermatchesApiErrorBEFOREHttpExceptionand mapsVALIDATION_ERROR→400 invalidValue,NOT_FOUND→ 404, others by their status. - The shared SCIM
Operations[].valueunion is ambiguous →unknown.emails(an array of Email objects) andmembers(an array of member objects) are structurally ambiguous in one shared zod union (both carryvalue: string), so a single{value,$ref}member object was swallowed by the non-strict Email/Name schemas. Decision:ScimOpSchema.valueisz.unknown(); the per-pathservice logic disambiguates (users / groups cast to the shape they expect). Bounded by the op-count cap (≤50)- each service's field caps.
- The test-app + OpenAPI generator needed the
scim/:path*exclusion too.tests/integration/helpers/api.tsmirrorsmain.ts'ssetGlobalPrefixexclude list andscripts/generate-openapi.mjshas aNON_V1_BASESskip list — both now includescim/v2so the protocol surface is (a) reachable in integration tests and (b) excluded from the TASK-0127 contract (documented indocs/api/versioning.md§4). - Deactivation =
SUSPENDED, never hard-delete (recorded decision). SCIM'sDELETE /Usersdeprovisions; the task requires we disable, never delete. BothDELETEandPATCH replace active:falsesetUser.status → SUSPENDED(login + in-flight blocked, memberships kept); the responsemetanote + the audit row (via: "delete" | "patch") surface the semantics. Re-activation restores access. - Groups map to Teams in the org's PRIMARY workspace. A Group becomes
a
Teamin the first non-personal, non-deleted workspace (oldest).DELETEremoves the team only — members keep their workspace membership (onlyTeamMemberis dropped). Membervalue= our User id or email; unresolved values are skipped + audited (membersUnresolved). - SCIM id = our User id (no
externalId).Userhas noexternalIdfield (onlySsoIdentitydoes), so the SCIMidIS the User id —GET /scim/v2/Users/:idis a point lookup + org-membership check (cross-org →404, existence hiding).
TASK-0133
Title
Audit log viewer, org policies, data retention, org admin console
Objective
Ship the org admin console (tabbed: overview, members, integrations, policies, retention, API keys, webhooks, audit) gated by the org-admin role, with inside it: a filterable audit viewer with async CSV export, the org policy editor with live validation and five enforcement points, and the retention engine (per-org policies, daily worker job with dry-run/execute, audited purges) — closing the phase with a full E2E.
Background
AuditLog (actorType/actorId, workspaceId, pageId, resourceType, resourceId, action, ipAddress, userAgent, metadata, createdAt; indexes on [workspaceId, action, createdAt], [resourceType, resourceId, createdAt], [actorType, actorId, createdAt]) exists and is written across phases 1–13. Mandatory events per PRODUCT-SPEC §23: user.login, page.created|updated|deleted|shared, permission.changed, database.created, record.created|updated, file.uploaded, automation.executed, api_key.created — plus phase-12/13 additions that are security-mandatory: ai.budget_exceeded, oauth.token_issued|revoked, sso.login.saml|oidc, scim.user.created|deactivated, webhook.created|revoked. Organization.policy Json (default {}), RetentionPolicy (org, type PURGE|HOLD, scope, retainDays, config). Worker has the maintenance queue pattern (Phase 11). The org admin role: OrganizationMember.role with ADMIN/OWNER (the WorkspaceRole enum is reused for org membership per the existing schema — the gate is role ∈ {OWNER, ADMIN} on OrganizationMember).
Requirements
- Org admin gate:
requireOrgAdminguard (apps/api/src/modules/org/guards.ts) — resolves the requesting user'sOrganizationMemberrole for the org; non-admin →403 FORBIDDEN {reason:"requires_org_admin"}. All/api/v1/orgs/:orgId/admin/*and the SSO/SCIM admin endpoints from TASK-0131/0132 use it (those tasks used an interim owner check — this task swaps them to the shared guard; their tests re-run). - Audit viewer (route
/org/[id]/admin/audit; APIGET /api/v1/orgs/:orgId/admin/audit): filters —actorId,actorType,action(exact +actionPrefixe.g.page.),resourceType,workspaceId,from/to(date range, max 90 d window per query — larger → 400 with the reason), pagination (page/pageSize≤ 100, default 50; server-side via the existing indexes — the filter combinations are covered by the three indexes + acreatedAtrange; add@@index([workspaceId, resourceType, createdAt])if the EXPLAIN shows a seq scan, via migration). Row: time, actor (name + type badge: user/api_key/integration/system), action, resource (type + id), workspace. Detail drawer: full record — metadata JSON (pretty, copy), ipAddress, userAgent, resource link (deep link when the resource still exists — page/record/db links; gone → plain id). CSV export:POST /api/v1/orgs/:orgId/admin/audit/exportwith the same filters →202 {exportId}; a worker job (maintenancequeue, jobaudit.export) streams rows (cursor query, 5k/batch) to CSV in MinIO underexports/{orgId}/{exportId}.csv(retained 7 d); a poll endpointGET .../exports/:exportId→processing|ready|failed+ presigned download URL when ready; async threshold: ≤10k matching rows → synchronous 200 with the CSV inlined (base64 or stream); >10k → the async path (the threshold is the spec's "async job for >10k rows"). UI: filter bar (action autocomplete from a distinct-actions endpoint), table (virtualized rows), drawer, export button + status chip; empty state; a11y (table semantics, drawer dialog, filter announcements). - Mandatory-event audit completeness (the exit criterion "audit log covers all mandatory events"):
tests/integration/audit-completeness/— a table generated frompackages/shared/src/events/audit-catalog.ts(new: every mandatory event name + the operation that must fire it + required metadata fields): for each, perform the operation in a seeded workspace and assert anAuditLogrow with the action + required metadata fields. Every catalog event must pass; a missing emitter fails the build. This task adds any missing emitters (audit first which are missing in an initial run and add them at the owning service layer — expected candidates:page.shared,permission.changedon share overrides,database.created(exists since Phase 5? verify),record.created(Phase 5),file.uploaded(Phase 9),automation.executed(Phase 11),api_key.created(Phase 2/13) — verification is the point, not assumption). - Org policies: zod schema
OrgPolicy(apps/api/src/modules/org/policy.schema.ts):{require2FA?: boolean (default false), ssoEnforced?: boolean (default false), disableGuestPublicShare?: boolean (default false), maxFileSizeMB?: number (1..10240, default from plan), sessionLifetimeHours?: number (1..720, default 24h), allowExternalEmbeds?: boolean (default true)}.PATCH /api/v1/orgs/:orgId/admin/policy(zod-validated, partial merge, auditorg.policy.updated {changes}— old+new values of changed keys only). Enforcement points (each with its own integration test):require2FA→ login: 2FA step required (Phase 1/2 2FA — if 2FA enrollment exists from an earlier phase, enforce the check at login for users in an org with the policy; the policy is per-org so the check is "any of the user's orgs has require2FA" — same resolution as TASK-0131's enforce).disableGuestPublicShare→ sharing service: creating/toggling a guest (non-member) public share on any page in the org's workspaces →403 FORBIDDEN {reason:"guest_public_share_disabled"}(member shares unaffected; existing public shares remain but a re-activation is blocked — documented); the web share dialog shows the policy notice.maxFileSizeMB→ file upload: presign request with size above the cap →413 PAYLOAD_TOO_LARGE {maxBytes}(checked at presign, before upload); the file picker warns.sessionLifetimeHours→ session creation + refresh: sessionexpiresAt = now + lifetime; refresh past it →401 UNAUTHORIZED {reason:"session_expired"}(Phase 1 session model has lifetime support; wire the org value — per-user max across their orgs).allowExternalEmbeds: false→ rendering: video/embed blocks and link-preview cards fetch external content server-side (Phase 4/9 proxy) → the proxy returns451-style block (use403 {reason:"external_embeds_disabled"}) + the UI shows a blocked-embed placeholder (client-side check is UX only — server is authoritative).
- CSV export format (stable contract, asserted by the export tests): header row
id,created_at,actor_type,actor_id,actor_name,workspace_id,workspace_name,resource_type,resource_id,action,ip_address,user_agent,metadata(13 columns, fixed order — thedocs/api/org-admin.mddocuments it and the test asserts header equality). Formatting rules: ISO-8601 UTC timestamps;actor_nameresolved (users) or-(system/api_key →actor_idprefixed with the kind);metadata= compact JSON, RFC-4180-quoted, internal quotes doubled;ip_address/user_agentquoted when they contain commas/quotes (user agents always do — always quoted); one row per audit event, orderedcreated_at DESC, id DESC(stable pagination); row cap: sync path ≤ 10k rows; async exports of unfiltered org-wide history are capped at 500k rows per export with atruncated:truefield in the export status (documented; the UI shows "export capped at 500k rows — narrow your filters"). The presigned download URL is single-use and 15 min; the CSV object istext/csvwithContent-Disposition: attachment. - Policy editor UI (Policies tab): form with the six fields, live validation (zod in-browser mirror, field errors on blur), diff summary on save ("3 changes: require2FA off→on …"), save → audit; a read-only "Current enforced values" panel (effective policy = org policy ∩ plan limits, e.g. free plan caps
maxFileSizeMBat 500 regardless — plan cap applied server-side, shown). - Data retention:
retention_policiesCRUD (GET/PATCH /api/v1/orgs/:orgId/admin/retention— one policy per(org, scope); scopes:trash(default 30 d),comments(default 90 d),audit_logs(default: follow legal hold —type HOLDmeans "never purge while a hold exists"),files(default 0 = no purge)).RetentionPolicy.type:PURGE(afterretainDays) orHOLD(legal hold — purge skipped entirely for that scope while HOLD is active; a HOLD onaudit_logsis the "audit per legal hold" requirement). Worker daily job (apps/worker/src/processors/maintenance.processor.ts, jobretention.run, cron 04:00 UTC per org+scope): for each active PURGE policy — dry-run mode (retention.dryRun: truein org policy or job arg): compute the purge set (trash items older than N d:Page.deletedAt,Blockvia page cascade,FileVersion; comments + replies + reactions older than N d without a reply newer than the cutoff; files + versions older than N d) and log only (retention.dry_run {scope, count, oldest, newest}structured log + an admin-visible summary row in the retention tab "Last dry run: 12 items"); execute mode: purge in batched transactions (100/batch, each batch its own audit), emitretention.purged {scope, counts: {pages: n, blocks: n, comments: n, files: n, bytesFreed}}audit per batch, updateRetentionPolicy.config.lastRun. HOLD scopes: skipped + logged (retention.hold_skipped). Failure of one scope doesn't stop others (per-scope try/catch +retention.failedaudit). - Admin console layout (
/org/[id]/admin,apps/web/app/org/[orgId]/admin/): org header (name, slug, member count) + tab nav: Overview (member count, storage used per org workspaces — sum fromFilesizes + a 24 h usage sparkline from audit-free counters if available, else static; active API keys count, webhooks count, usage vs plan limits: pages/blocks/records/files counts vsbillingPlanlimits), Members (org members: search by name/email, role select (Owner/Admin/Member — org owner demotion blocked unless another owner exists), suspend/unsuspend (OrganizationMember+User.status), last active from sessions), Integrations (SSO tabs from TASK-0131 + SCIM card from TASK-0132 + status badges), Policies (editor above), Retention (policy table per scope: type/retainDays/lastRun/lastDryRun counts + "Run dry now" / "Run now" buttons (admin-scope, audited) + HOLD toggles), API Keys (workspace picker + embeds TASK-0128's table), Webhooks (workspace picker + TASK-0130's list/inspector), Audit (viewer above). Role gate on the route (403 page for non-admins, same guard server-side); loading skeletons per tab; a11y (tab semanticsrole=tablist, keyboard arrow navigation).
Acceptance Criteria
- E2E (the phase E2E, four parts): (1) SAML login → workspace access (re-runs TASK-0131's flow through the console: after login, the org admin sees the user in Members with last active); (2) SCIM lifecycle (create → login → deactivate → blocked — re-run from TASK-0132, asserted here through the console's Members view showing the suspension); (3) policy
disableGuestPublicShare: true→ attempt a guest public share on a page →403 {reason:"guest_public_share_disabled"}+ the share dialog shows the policy notice (UI asserts the block state); (4) retention: seed 40 trash items (35 older than 30 d, 5 recent) + a 90 d-old comment → dry run → "36 items" (35 + 1) reported in UI/log; execute → purged (assert gone via API) +retention.purgedaudit with counts{pages:35, comments:1}; the 5 recent trash items + the young comment survive. - Integration (audit completeness): the catalog test passes for every mandatory event (the table is the list above; adding an event to the catalog without an emitter fails CI — asserted by a fixture test that breaks one emitter and expects failure).
- Integration (policies): each of the five enforcement points tested as specified (2FA at login for a require2FA org; guest share 403 + member share 200; presign 413 above
maxFileSizeMB+ 200 below; session lifetime expiry → refresh 401; embed proxy 403 when disabled + 200 when allowed). - Integration (audit viewer): filter matrix (actor + action prefix + resourceType + date range + workspace combined → exact expected set), pagination boundaries, detail drawer fields (metadata JSON + ip + ua present), export: 5k rows → sync 200 CSV (row count + header asserted); 15k rows → 202 → poll → ready → presigned URL downloads a 15k-row CSV (parsed, headers match); export respects filters.
- Integration (retention engine): HOLD on
audit_logs→ execute mode skips audit purge (retention.hold_skippedlog, zero audit rows purged while HOLD; after HOLD removed → purged); per-scope failure isolation (one scope throws → others still run +retention.failedaudit for the broken one). - UI: console renders all 8 tabs with data; Members suspend/unsuspend round-trip; Retention "Run dry now" shows the count live; tab nav is keyboard-operable; non-admin user → 403 page (E2E).
- Audit: policy updates, retention runs (dry + execute + hold-skip), export jobs, member suspensions all audited (asserted rows).
Backend Changes
apps/api/src/modules/org/:org.module.ts,guards.ts(requireOrgAdmin— swapped into TASK-0131/0132 admin endpoints),audit.controller.ts(query + export + exports poll; cursor streaming),policy.controller.ts+policy.schema.ts+ enforcement hooks (login 2FA check, sharing service guest-share check, file presign size check, session lifetime wiring, embed proxy block — each hook in its owning module, policy read via a sharedorg-policy.service.tswith 60 s Redis cache + invalidation on policy update),retention.controller.ts(CRUD + run-now),members.controller.ts(list/search/suspend/role),overview.controller.ts(counts + storage + usage vs plan).packages/shared/src/events/audit-catalog.ts: the mandatory-event catalog (name, operation, required metadata) — single source for the completeness test + the audit viewer's action autocomplete.apps/worker/src/processors/maintenance.processor.ts:retention.run(dry/execute, per-scope isolation, batched transactions) +audit.export(cursor → CSV → MinIO, 7 d lifecycle rule) + cron registration.- Migration:
@@index([workspaceId, resourceType, createdAt])onAuditLogif EXPLAIN warrants (measured in this task);RetentionPolicy.config.lastRunJson (no column change). - Missing audit emitters: added at the owning services (listed in Requirements) with their unit tests.
Frontend Changes
apps/web/app/org/[orgId]/admin/console shell + 8 tab components:AdminOverview.tsx,AdminMembers.tsx(search, role select, suspend toggle, last active),AdminIntegrations.tsx(mounts SSO tabs + SCIM card),AdminPolicies.tsx(live-validated editor + diff-on-save),AdminRetention.tsx(scope table, run-dry/run-now, lastRun summaries, HOLD toggles),AdminApiKeys.tsx+AdminWebhooks.tsx(workspace picker + embedded phase-13 components),AdminAudit.tsx(filter bar, virtualized table, detail drawer, export flow with status polling).- Share dialog: policy-notice state (guest share blocked message). File picker: size-cap warning from the effective policy endpoint (
GET /api/v1/orgs/:orgId/admin/policyfor admins; a lightweightGET /api/v1/workspaces/:id/policy/effectivefor members — used by the picker, never authoritative). - Embed block placeholder component (Phase 4 embed/video block: "External embeds are disabled by your organization").
- 403 page for the console (non-admin); tab a11y; mobile: tabs scroll horizontally.
Database Changes
- Prisma migration: conditional
AuditLogindex (above). No other schema changes (policy/retention in existing Json/fields).
API Changes
GET /api/v1/orgs/:orgId/admin/audit(+ filters),POST /api/v1/orgs/:orgId/admin/audit/export,GET /api/v1/orgs/:orgId/admin/audit/exports/:exportId,GET/PATCH /api/v1/orgs/:orgId/admin/policy,GET/PATCH /api/v1/orgs/:orgId/admin/retention,POST /api/v1/orgs/:orgId/admin/retention/run(body{scope, mode: "dry"|"execute"}),GET /api/v1/orgs/:orgId/admin/members(+PATCH .../members/:userIdrole/suspend),GET /api/v1/orgs/:orgId/admin/overview,GET /api/v1/workspaces/:id/policy/effective. All in OpenAPI (TASK-0127 contract keeps them in spec) +docs/api/org-admin.md.
Tests
- E2E: the four-part phase E2E + console role gate + Members suspend round-trip + Retention run-dry UI.
- Integration: audit completeness catalog (every event), audit viewer filter/export matrix (sync + async), policy enforcement ×5, retention engine (dry/execute/hold/isolation), org-admin guard matrix (owner/admin pass, member/guest 403, cross-org 403).
- Unit:
OrgPolicyschema (bounds, defaults, plan-cap interaction), audit CSV writer (header stability, escaping), retention purge-set queries (trash cutoffs, comment cutoff with newer-reply exclusion), cursor export pagination.
Security
- Every admin route behind
requireOrgAdmin(403 tested, cross-org included); audit viewer is read-only (no audit mutation endpoints exist — audited data is immutable: no UPDATE/DELETE routes, enforced by router walk test). - CSV export: presigned URLs short-lived (15 min), scoped to the object; exports stored per-org prefix; 7 d lifecycle; export of >10k rows is async and rate-limited (1 active export per org).
- Policy writes are audited with old/new values (the change trail is the policy history — documented decision: no separate policy-version table in v1; the audit log is the history).
- Retention execute never purges: audit rows while
audit_logsHOLD is active (tested), trashed items inside a legal-hold scope (HOLD covers the scope, not individual items — documented limitation for v1), or data of other orgs (org-scoped queries by construction, tested). - Suspend uses the existing
User.statusenforcement (shared with TASK-0132 deactivation — one code path).
Documentation
docs/api/org-admin.md(all admin endpoints + policy schema + retention semantics);docs/api/authentication.md(no change expected);docs/security/security-architecture.md(org admin threat model, audit immutability, retention limits);docs/development/deployment.md(self-hosting: enabling 2FA/SSO-enforce, retention recommendations);docs/ui/admin console spec; OpenAPI updated.
Dependencies
- TASK-0127 (OpenAPI contract for the new routes), TASK-0128 (API keys components), TASK-0130 (webhook components), TASK-0131 (SSO tabs + org-admin gate consumer), TASK-0132 (SCIM card + User.status enforcement), Phases 1/2 (sessions, 2FA, orgs, teams), Phase 4 (embed proxy), Phase 5 (records/pages trash), Phase 9 (files), Phase 11 (worker queue + notifications), Phase 12/13 audit emitters.
Definition of Done
- [ ] Org admin console (8 tabs) live behind
requireOrgAdmin(all prior admin endpoints migrated to the guard). - [ ] Audit viewer: filters, drawer, sync + async CSV export; mandatory-event completeness test green (catalog = single source; missing emitters added).
- [ ] Org policies: schema + editor + all five enforcement points tested (login 2FA, guest share 403, upload 413, session lifetime, embed block).
- [ ] Retention: per-scope policies (PURGE/HOLD), daily job with dry-run + execute, batched audited purges, per-scope isolation; integration test proves purge-per-policy.
- [ ] Phase E2E (SAML→console, SCIM lifecycle, policy block, retention dry→execute) green; docs/api/org-admin.md written; lint/typecheck green.
Requirement conflicts & recorded decisions (rule 29)
- The in-flight
SUSPENDEDcheck landed in TASK-0132; TASK-0133 reuses it. The task says "confirmSUSPENDEDblocks login and in-flight sessions." The missing in-flight check (a previously-issued access token kept working until expiry) was fixed in TASK-0132 (AuthGuard.resolveJwtre-checksUser.statusper request for user-JWTs;403 { reason: "account_suspended" }, auditedauth.suspended_blocked). Decision: the console's suspend action PATCHes the SAMEUser.statuspath (one code path, no new enforcement) — TASK-0133 only verifies it (integration2g+ E2E Part 2: login 403 after suspend, in-flight blocked). AuditLoghas noorganizationIdcolumn — the org scope is derived. The viewer/export/retention need one org scope. Decision:@factory/shared/events/audit-scope(auditScopedWhere) is the single source of truth:workspaceId IN (the org's workspaces)OR the org-level branch (workspaceId IS NULLAND (metadata.orgId = orgIdORresourceType IN (organization, org)ANDresourceId = orgId)). A cross-cutting index(workspaceId, createdAt)+(resourceType, resourceId, createdAt)covers the viewer queries (no new migration beyond the conditional audit index in the DB changes above).- The audit export is content-type-driven (no
kindfield). The viewer and the picker can't both know the row count without a round- trip. Decision:POST …/audit/exportreturns200 text/csv(RFC 4180, 13-column header) for ≤ 10k rows and202 {exportId, cap, total}above; the client reads theContent-Typeto decide. The metadata column is compact JSON (no embedded newlines), so CSV line counts are safe. The web client usesapiFetchRaw(the sync/async union — the sync branch is raw CSV, not JSON). - The presigned export URL is strictly single-use. The task says the
export is "rate-limited" but not the URL lifetime. Decision: the
API mints a 15-minute SigV4 GET on the FIRST ready-
GETof the export status and flipsdownloadUsed; every later call (including?redownload=1) returns the status WITHOUT a URL (no re-mint). The mint is pure signing (no S3 round-trip at the API). One active export per org (409 export_in_progress). The object lives underexports/{orgId}/{exportId}.csvwith a 7-day lifecycle; the row cap is 500k (truncated: truewhen hit — unit-tested with an injectable cap, since seeding 500k rows is not a realistic unit fixture). - Retention execute honors the admin's
modedirectly. The task's daily job is "dry-run by default" but the console needs a deliberate "Execute". Decision: the worker'sretention.runjob honorsdata.mode— an adminexecutepurges even whenWORKER_RETENTION_EXECUTEis off (that flag scopes only the DAILY cron's unattended mode, not the deliberate admin action). Dry-run writeslastDryRun, execute writeslastRunon the policy row.audit_logsHOLD + filesretainDays: 0reportskipped(no purge). The run report is Redisworker:retention:report:{runId}(7 d TTL) — the console polls it (the worker has no DB row for the run; the policy row carries the last-run stamps). - The share-page notice is UX only; the server is authoritative. The
task's enforcement point is the 403 on share CREATE. Decision: the
share ROUTE page (
app/(app)/w/[workspace]/page/[id]/share/page.tsx) reads the member-facingGET /workspaces/:id/policy/effectiveand shows a notice + disables the "public" radio whendisableGuestPublicShareis true. The enforcement isShareService.create(POST /shares,kind: "public"→403 { reason: "guest_public_share_disabled" }); member/link shares are unaffected. (TheShareDialogcomponent is unused — the route page is the live share surface. The webpageApi.share→PUT /pages/:id/shareis a pre-existing dead route (404); out of scope for this task.) - The org admin role gate is the single 403 surface. Every
/orgs/:orgId/admin/*route isrequireOrgAdmin(OWNER/ADMIN membership; everyone else, including members of OTHER orgs, gets the SAME403 { reason: "requires_org_admin" }— cross-org is never distinguishable). The console's 403 gate is driven by the picker'sGET …/admin/workspaces403 (the reason is stable so the shell renders the gate panel). Suspend/role-change cannot remove the LAST owner (409 last_org_owner); grantingOWNERis rejected (ownership_transfer_required) — ownership transfer is out of scope in v1. - The file picker uses the org-aware cap (plan is the fallback). The
picker previously computed the cap from the plan alone (ignoring the
org policy). Decision:
use-file-uploads.tsnow also fetchesGET /workspaces/:id/policy/effectiveand takesmin(plan, effective.maxFileSizeBytes); on fetch failure it falls back to the plan cap. The presign413stays authoritative — the picker is a UI pre-check only. (The member-facing endpoint returns the SAMEeffectiveMaxFileBytesthe upload path enforces, so there is no divergence between the warning and the 413.) - Policy writes are audited with old/new values (no version table).
The task's policy editor has "live validation" but no versioning.
Decision:
PATCH …/policyis auditedorg.policy.changedwith the changed fields' old/new values — the change trail IS the policy history (no separate policy-version table in v1). The bounds are validated by theOrgPolicyZod schema (unit-tested: bounds, defaults, plan-cap interaction viaeffectiveMaxFileBytes). - SCIM
If-Matchis optional; the email is used verbatim. (Recorded here because it affects the E2E + the SCIM card.) The SCIM PATCH/DELETE controllers callifMatchMatches(header, etag)but an ABSENT header passes (noIf-Matchis required).toEmailuses an@-containinguserNameVERBATIM (lowercased), so the E2E's test email domain must EXACTLY equal the configureduserEmailDomain. The SCIMidIS theUser.id(noexternalId).SsoProvider.organizationIdis@unique→ one SSO provider per org → the E2E role gate uses the SSO JIT user (provisionedOrganizationMemberrole MEMBER, capped bypolicy.ssoDefaultRole); a second provider would collide. SSO login creates a Session (issueTokens,authMethod: "sso") and sets thefactory_refreshcookie — the E2E captures it and posts to/auth/refreshfor the JIT's access token. - The audit-viewer routes were missing the org-admin gate (found via
lint's unused-import signal; fixed + regression-tested). The four
audit routes (
GET admin/audit,GET admin/audit/actions,POST admin/audit/export,GET admin/audit/exports/:id) accepted@Req() reqbut never calledassertOrgAdmin, andOrgAuditService's methods didn't gate either — the controller importedassertOrgAdminand never used it (the lint error was the tell). Any org MEMBER could have read/exported the whole org audit log (and the original guard-matrix integration test only 403-tested/admin/overview). Decision: the gate now lives inOrgAuditService(arequireAdmin(orgId, req)helper —requireUser+assertOrgAdmin, matching every other org-admin service) and the controller passesreqthrough; a new integration test (audit viewer routes are admin-only) locks member/guest/ non-member →403 requires_org_adminand owner → 200 on all four routes. Recorded per rule 7 (never bypass authorization) / rule 29.