think
16px
820px

SCIM 2.0 Inbound Provisioning — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. T8 is controller-driven — do NOT delegate it to a subagent. Tasks T2 and T3 are marked ADVERSARIAL-review — the token/provisioning core and the auth login-guard widening are the security spine; review them harder.

Goal: Enterprise directory provisioning (CORE platform, no license gate). An external IdP (Okta / Entra / OneLogin) creates, updates, and deprovisions Obscura accounts and pushes group membership over the standard SCIM 2.0 protocol (RFC 7643/7644). Users are provisioned as provider='scim' (no password), authenticate via OIDC SSO (the OIDC login path links to the SCIM account by email rather than duplicating it), and group membership maps to Obscura roles reusing the LDAP group→role machinery. Deprovisioning disables (never hard-deletes): active=false and SCIM DELETE both set users.disabled=true, revoke live sessions, and strip SCIM-granted roles; the account and its authored content/audit survive; active=true restores. Everything is opt-in and disabled by default — with no admin-minted token every /scim/v2 request is 401, so the demo and every existing deployment are byte-for-byte unchanged.

Architecture:
- New scim context (go/internal/scim, domain/app/adapters) owns the SCIM protocol surface, the token store, the group/member store, and the group→role mapping + reconciliation ledger — a clean parallel to the LDAP work already shipped (ldap_group_roles/ldap_role_grants + GroupRoleService). SCIM and LDAP keep separate tables and ledgers (different sources, different triggers) but both feed the same rbac binder; a role can be granted by both without conflict.
- The /scim/v2 surface is NOT session-authed. It mounts as a sibling of /api/v1 (top-level in Server.Handler()), guarded by a bearer-token middleware requireSCIMToken — NOT the Authenticator session middleware. The token is the admin-minted SCIM token (SHA-256 hash stored, constant-time compare). Responses use the SCIM media type application/scim+json and RFC 7644 §3.12 error envelopes — not the app's RFC 9457 problem+json.
- User provisioning reuses the auth service through a narrow port. SCIM never writes the users/user_identities/sessions tables directly: it goes through a UserPort (a wire.go adapter over *authapp.Service) that wraps ProvisionLocal(idp='scim'), SetUserDisabled, LogoutAll (session-revoke), UpdateUserProfile, UserByEmail, GetUser, and a new UserByIdentity. So SCIM's create/disable/restore all reuse the audited, correct auth paths.
- Login pairing widens the already-shipped local-login guard. The guard that keeps directory accounts off the local-password path currently checks provider == "ldap"; a new single-source-of-truth predicate authdomain.IsDirectoryManaged(provider) (ldap/scim/oidc → true; local/dev/"" → false) replaces every == "ldap" check. This blocks scim (and oidc) hash-less accounts from the demo password==email convention without breaking the demo: the demo director is provider='dev' and logs in via the web form (/auth/login, password==email), so dev MUST stay local-eligible (see the Self-review — this is the key T3 refinement over the spec's literal "!= 'local'").
- OIDC first-login links to the SCIM account. ProvisionFromOIDC gains a pre-step: if a provider='scim' user exists with the token's email, attach an (idp='oidc', subject) identity to THAT user and return it (no duplicate row). user_identities already has UNIQUE (idp, subject) so the attach is idempotent.
- Admin surface reuses the existing tab. The Admin → Directory (LDAP) tab becomes Admin → Directory with an added SCIM card: the base URL to paste into the IdP, a Generate/Rotate/Revoke token control (token shown ONCE in a copyable field), status (enabled / last used / #provisioned), and a SCIM-group→role mapping table — all gated on rbac.admin, the same perm the Roles manager and the LDAP card use.

Tech Stack: Go modular monolith (go/, chi router, pgx v5, goose migrations) — no new Go deps (stdlib crypto/rand+crypto/sha256+crypto/subtle for tokens; a hand-rolled minimal SCIM filter/PATCH parser, no library). React + Carbon (@carbon/react) SPA (web/), TanStack Query, openapi-typescript-generated client — no new npm deps. SCIM is exercised by scripted curl simulating an IdP against the deployed stack — no new compose service.

Spec: docs/superpowers/specs/2026-07-04-scim-design.md


Global Constraints (every task)

  • NEVER go test — the test DSN (:55432) IS the live demo Postgres (deploy-postgres-1). Go verify is cd go && go build ./... && go vet ./... only (vet compiles test files too, so keep existing tests compiling — the auth NewService/app.NewService call-sites stay valid because signatures are unchanged; new methods are additive).
  • Web verify: cd web && npx tsc --noEmit && npx vite build.
  • npm run gen:api after ANY api/openapi.yaml edit (regenerates web/src/api/schema.ts). Run it from web/. Commit the regenerated schema.ts with the task. The /scim/v2 surface is NOT in OpenAPI — SCIM is its own RFC spec, not part of our OpenAPI/gen:api. Do NOT add /scim/v2 routes to api/openapi.yaml or run gen:api for them. Only the session-authed /admin/scim/* endpoints go in OpenAPI (T5).
  • NO new npm dependencies (npm install is broken: npm11/node25 arborist crash). NO new Go dependencies unless essential — this plan needs NONE (stdlib crypto only; hand-rolled SCIM parsing).
  • Deploy ONLY from repo root: docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web. After deploy assert /me enabled_modules == [ai, correspondence, esign, semantic, watermarking] (dev-login director@obscura.local, host port 38080).
  • SCIM is DISABLED by default. With no minted token, requireSCIMToken returns 401 for every /scim/v2 request; the scim_* tables stay empty. The main demo obscura service never mints a token; the demo is untouched. There is no SCIM_ENABLED env — presence of an active token == enabled.
  • Commit per task on main, do NOT push. NEVER git add -A (go/obscura-server is a tracked ELF binary) — always git add explicit paths.
  • Auth errors stay GENERIC — login failure modes still return the SAME auth.login.invalid "invalid email or password". SCIM errors follow RFC 7644 (401 bad/absent/revoked token, 404, 409 uniqueness, 400 invalidFilter/invalidValue) and never leak whether an unrelated account exists. A SCIM failure never touches an unrelated user's provider/password/roles.
  • i18n en/id parity (tsc-enforced): feature-co-located web/src/features/admin/i18n.ts; nested groups; en and id identical in shape. Smart-quote gotcha: the Edit tool can mangle /“”; after editing an i18n file verify npx tsc --noEmit and if quotes broke, rewrite the whole file with Write.
  • down -v is FORBIDDEN (a prior incident wiped the demo pgdata + miniodata). Never docker compose down -v. For single-service teardown use docker compose rm -sf <service>; the e2e uses a throwaway docker run container removed with docker rm -f.
  • Clean up all e2e artifacts — purge the provisioned provider='scim' users + their scim_role_grants/identities, delete scim_groups/scim_group_members/scim_group_roles rows created, and revoke the minted token, from the shared demo DB. Re-assert the demo (5 modules; SCIM+LDAP disabled).

File Map

File Task Role
go/migrations/00084_scim.sql (new) T1 scim_tokens + scim_groups + scim_group_members
go/migrations/00085_scim_group_roles.sql (new) T1 scim_group_roles + scim_role_grants (parallels 00083)
go/internal/scim/domain/scim.go (new) T1 Token/Group/GroupRoleMapping/RoleGrant values
go/internal/scim/app/ports.go (new) T1 Store, UserPort, RoleBinder ports + ScimUser projection
go/internal/scim/adapters/pg.go (new) T1 Store (Postgres scim_*) — token/group/member/mapping/ledger CRUD
go/internal/scim/app/service.go (new) T2 Service: token mint/verify, user provisioning, group reconcile + DTOs
go/internal/auth/domain/auth.go T3 IsDirectoryManaged(provider) predicate
go/internal/auth/app/ports.go T3 Repository: UserByIdentity + LinkOIDCIdentityByEmail
go/internal/auth/adapters/pg.go T3 impl UserByIdentity + LinkOIDCIdentityByEmail
go/internal/auth/app/service.go T3 widen guards (VerifyPassword/LoginWithDirectory/UpdatePassword); OIDC link; UserByIdentity
go/internal/httpapi/handlers_auth.go T3 /me is_directory via IsDirectoryManaged
go/internal/httpapi/handlers_scim.go (new) T4 requireSCIMToken, discovery, /Users, /Groups, filter/PATCH parser, scim writers
go/internal/httpapi/server.go T4/T5 Deps.SCIM + field; mount /scim/v2 (no session mw); /admin/scim/* routes
go/cmd/obscura-server/wire.go T4/T5 construct scim store/service + scimUserPort adapter; Deps injection
go/internal/httpapi/handlers_scim_admin.go (new) T5 token mint/revoke, status, scim groups list, group-role CRUD (session-authed)
api/openapi.yaml T5 /admin/scim/* paths & schemas (NOT /scim/v2)
web/src/api/schema.ts T5 regenerated via gen:api
web/src/features/admin/data.ts T6 useScimStatus/useMintScimToken/useRevokeScimToken/useScimGroups/scim group-role hooks
web/src/features/admin/LdapTab.tsx T6 add the SCIM card (tab becomes "Directory")
web/src/features/admin/AdminPage.tsx T6 tab label → "Directory" (component unchanged)
web/src/features/admin/i18n.ts T6 admin.scim.* + tab label (en/id)
web/src/styles/app.css T6 reuse .ldap-map__add; small .scim-token block
docs/SCIM.md (new) T7 operator guide (Okta/Entra setup + troubleshooting)

Scouted anchors (verified this session — cite these while implementing)

  • LDAP already shipped; migrations run to 00083 (ls go/migrations: …00081_notification_prefs, 00082_user_provider, 00083_ldap_group_roles). Next free = 00084, 00085.
  • users.provider EXISTS (added by 00082). userColumns = "…, provider" (go/internal/auth/adapters/pg.go:162); scanUser scans &u.Provider (pg.go:170); UpsertUserByIdentity stamps provider = idp on INSERT (pg.go:62-64). domain.User.Provider exists.
  • UpsertUserByIdentity keys on (idp, subject) ONLY — "No cross-IdP email linking" (pg.go:40-79). So OIDC provisioning (idp='oidc') would create a NEW row for a SCIM user's email → the spec's dedup requires a new email-link pre-step (T3). user_identities has UNIQUE (idp, subject) (00005_auth.sql:19) → ON CONFLICT (idp, subject) is safe.
  • The local-login guard is provider == "ldap" in THREE places (all widen to IsDirectoryManaged in T3): VerifyPassword demo-convention backstop (service.go:120), LoginWithDirectory guards (service.go:153 and :159), and the UpdatePassword change-password guard (service.go:349). /me computes is_directory: provider == "ldap" (handlers_auth.go:211).
  • The demo director is provider='dev'. DevLoginProvisionLocal(ctx, "dev", email, email, "") (handlers_auth.go:27). The browser demo logs in via /auth/login (PasswordLogin → LoginWithDirectory → VerifyPassword, password==email) (web/src/api/session.ts:71; handlers_auth.go:49-77; the PasswordLogin comment: "The demo's seeded accounts log in with password == email"). ⇒ a naive != 'local' guard would BREAK director's web login; dev MUST stay local-eligible.
  • Auth service reusable methods (go/internal/auth/app/service.go): ProvisionLocal(ctx, idp, subject, email, name) (:82), SetUserDisabled (:250), LogoutAllDeleteSessionsForUser (:330, session-revoke), UpdateUserProfile (:274), GetUser (:229), UserByEmail (:235). ProvisionFromOIDC (:65) → UpsertUserByIdentity("oidc", claims.Subject, claims.Email, claims.Name) (:76).
  • rbac binder (grant/revoke) — the narrow port SCIM reuses: BindRole(ctx, roleID, subjectKind, subjectID) / UnbindRole(...) / RefreshEffectivePerms(ctx) (go/internal/rbac/app/ports.go:101-113; impl go/internal/rbac/adapters/pg.go:443,466,553). Subject kind for a user binding is the literal "user" (rbacapp.SubjectUser, ports.go:20). *rbacadapters.Store satisfies the RoleBinder port directly (it already backs the LDAP reconciler — wire.go:249).
  • LDAP parallel to mirror: authapp.GroupRoleService (go/internal/auth/app/ldap.go) — mapping CRUD + Reconcile (grant missing, revoke ledger rows no longer desired, RefreshEffectivePerms on change, manual bindings untouched). Store LDAPGrantStore (go/internal/auth/adapters/ldap_grants_pg.go) uses s.db.Exec(ctx).Query/Exec + ON CONFLICT. Migration 00083_ldap_group_roles.sql: ldap_role_grants PK (user_id, role_id), role_id uuid REFERENCES roles(id) ON DELETE CASCADE, + INDEX (user_id).
  • db.DB API: s.db.Exec(ctx).Query/QueryRow/Exec(ctx, sql, args…); s.db.Do(ctx, func(ctx) error {…}) for a tx (go/internal/platform/db). pgx no-rows: errors.Is(err, pgx.ErrNoRows) with "github.com/jackc/pgx/v5". pgx v5 scans text[][]string; a Go string binds to a uuid column.
  • HTTP layer: router in Server.Handler() (server.go:211) — public routes directly under /api/v1, protected routes in r.Group(func…){ r.Use(Authenticator(s.auth, s.directory)) … } (:273-745); the /api/v1 r.Route closes at :745, the top-level router return r at :747mount /scim/v2 between them (sibling, no session mw). requirePerm("rbac.admin") gates the LDAP admin routes (:633-636) — the template for the SCIM admin routes. Authenticator/bearerToken in middleware.go. Response helpers writeJSON/writeProblem/writeProblemStatus in errs.go (app uses application/json + application/problem+json; SCIM needs its OWN application/scim+json writers). PrincipalFrom(ctx) (principal.go:22) → p.UserID.
  • Deps/server struct (server.go:72-147): add SCIM *scimapp.Service next to LDAPGroupRoles; constructor mapping at :158-193. wire.go builds ldapGrantStore/ldapGroupRoleSvc at wire.go:248-249 and the httpapi Deps{…} literal at wire.go:422 (LDAPGroupRoles: ldapGroupRoleSvc at :431). wire.go imports authadapters (:17), authapp (:18), platform/db (:49), rbacadapters (:52); ldapAdminChecker helper struct near :751.
  • Web admin: AdminPage.tsx registers tabs (:107-117 labels; :120-240 panels); the LDAP tab is <Tab>{t('admin.tabs.ldap')}</Tab> + <TabPanel><LdapTab/></TabPanel> (:117,:236-239). LdapTab.tsx renders status + mapping Tile cards using useRoles() (@/api/rbacRole[] = {id,name}) and admin/data.ts hooks (api,ok from @/api/client; useQuery/useMutation/useQueryClient already imported). i18n split: export const en = {…} (i18n.ts:5), export const id: typeof en = {…} (:495); tabs.ldap: 'Directory (LDAP)' (:23) / 'Direktori (LDAP)' (:513); the ldap: copy group at en :465 / id :955.
  • roles.id is uuid (00004_rbac.sql:13) → FK target for scim_group_roles.role_id / scim_role_grants.role_id. scim_groups.id is the Obscura-side SCIM resource id (uuid we generate). The SCIM User {id} is the Obscura users.id.
  • No new compose service. deploy/docker-compose.yml is unchanged — SCIM is tested by curl simulating an IdP (T8).

Task 1: Migrations 00084/00085 + scim domain + ports + Postgres store

Files: go/migrations/00084_scim.sql (new), go/migrations/00085_scim_group_roles.sql (new), go/internal/scim/domain/scim.go (new), go/internal/scim/app/ports.go (new), go/internal/scim/adapters/pg.go (new).

Interface produced (consumed by T2): the scim/domain value types; the scimapp.Store/scimapp.UserPort/scimapp.RoleBinder ports + scimapp.ScimUser; scimadapters.NewStore(*db.DB) *Store implementing scimapp.Store.

  • [ ] Step 1 — Migration 00084. Create go/migrations/00084_scim.sql:
-- +goose Up
-- SCIM 2.0 inbound provisioning (RFC 7643/7644). DISABLED by default: with no minted token
-- every /scim/v2 request is 401 and these tables stay empty, so the demo and every existing
-- deployment are byte-for-byte unchanged until an admin generates a token.

-- Admin-minted bearer tokens that authenticate the IdP's SCIM client. Only sha256(token) is
-- stored; the raw token is shown ONCE at mint time. "One active token" is enforced by the
-- partial unique index below (at most one row with revoked_at IS NULL); minting a new token
-- revokes the prior active one. Presence of an active token == SCIM enabled.
CREATE TABLE scim_tokens (
    id           uuid PRIMARY KEY,
    token_hash   text NOT NULL,
    created_at   timestamptz NOT NULL DEFAULT now(),
    created_by   uuid REFERENCES users(id) ON DELETE SET NULL,
    last_used_at timestamptz,
    revoked_at   timestamptz
);
CREATE UNIQUE INDEX scim_tokens_one_active ON scim_tokens (revoked_at) WHERE revoked_at IS NULL;

-- SCIM Group resources pushed by the IdP. id is the Obscura-side resource id (the SCIM {id});
-- external_id correlates to the IdP's own group id; display_name is the SCIM displayName.
CREATE TABLE scim_groups (
    id           uuid PRIMARY KEY,
    external_id  text,
    display_name text NOT NULL,
    created_at   timestamptz NOT NULL DEFAULT now(),
    updated_at   timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX scim_groups_display_name ON scim_groups (lower(display_name));

-- Pushed group membership (SCIM Group.members -> Obscura users). Cascades on group/user delete.
CREATE TABLE scim_group_members (
    group_id uuid NOT NULL REFERENCES scim_groups(id) ON DELETE CASCADE,
    user_id  uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    PRIMARY KEY (group_id, user_id)
);
CREATE INDEX scim_group_members_user ON scim_group_members (user_id);

-- +goose Down
DROP TABLE scim_group_members;
DROP TABLE scim_groups;
DROP TABLE scim_tokens;

(The partial unique index is on (revoked_at) WHERE revoked_at IS NULL — since a live token has revoked_at = NULL and NULLs are distinct only across the whole-index predicate here, this admits exactly one non-revoked row. The app also revokes-before-insert (T2 MintToken), so the index is a belt-and-braces backstop, not the primary mechanism.)

  • [ ] Step 2 — Migration 00085. Create go/migrations/00085_scim_group_roles.sql (parallels 00083_ldap_group_roles.sql):
-- +goose Up
-- Admin-managed SCIM-group -> Obscura-role mapping. Keyed by the SCIM group id (one role per
-- group, mirroring ldap_group_roles' one-role-per-DN). role_id references the rbac roles
-- catalogue (roles.id is uuid). ON DELETE CASCADE from both the group and the role.
CREATE TABLE scim_group_roles (
    scim_group_id uuid PRIMARY KEY REFERENCES scim_groups(id) ON DELETE CASCADE,
    role_id       uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
    created_at    timestamptz NOT NULL DEFAULT now()
);

-- Ledger of role bindings CREATED by SCIM group->role reconciliation, so they can be revoked
-- when a user leaves a mapped group WITHOUT touching manually-assigned roles (which have no
-- ledger row). One row per (user, role); scim_group_id records which mapping granted it.
-- Deliberately SEPARATE from ldap_role_grants: different source, different trigger, same rbac
-- binder (a role granted by both LDAP and SCIM has one row in each ledger, no conflict).
CREATE TABLE scim_role_grants (
    user_id       uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role_id       uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
    scim_group_id uuid NOT NULL REFERENCES scim_groups(id) ON DELETE CASCADE,
    granted_at    timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (user_id, role_id)
);
CREATE INDEX scim_role_grants_user ON scim_role_grants (user_id);

-- +goose Down
DROP TABLE scim_role_grants;
DROP TABLE scim_group_roles;
  • [ ] Step 3 — Domain. Create go/internal/scim/domain/scim.go:
// Package domain holds the SCIM context's value types: the minted-token record, the pushed
// Group resource, the admin group->role mapping, and the reconciliation-ledger entry. Users
// are NOT modeled here — a SCIM user IS an auth `users` row (provider='scim'), accessed via the
// app-layer UserPort; SCIM only owns groups, tokens, mappings, and the grant ledger.
package domain

import "time"

// Token is a minted SCIM bearer token record. Only the sha256 hash is persisted (never the raw
// token, which is shown once at mint time). A token is active while RevokedAt is nil.
type Token struct {
    ID         string
    CreatedAt  time.Time
    CreatedBy  string
    LastUsedAt *time.Time
    RevokedAt  *time.Time
}

// Group is a SCIM Group resource pushed by the IdP. ID is the Obscura-side resource id (also the
// SCIM {id}); ExternalID correlates to the IdP's group id; DisplayName is the SCIM displayName.
type Group struct {
    ID          string
    ExternalID  string
    DisplayName string
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

// GroupRoleMapping is one admin-managed SCIM-group -> Obscura-role mapping.
type GroupRoleMapping struct {
    ScimGroupID string
    RoleID      string
}

// RoleGrant is one reconciliation-ledger entry: a role granted to a user because they are a
// member of a mapped group. ScimGroupID records which mapping granted it.
type RoleGrant struct {
    RoleID      string
    ScimGroupID string
}
  • [ ] Step 4 — Ports. Create go/internal/scim/app/ports.go:
// Package app is the SCIM use-case layer: token mint/verify, user provisioning (via the narrow
// UserPort over the auth service), and group->role reconciliation (via the rbac RoleBinder).
package app

import (
    "context"
    "time"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/scim/domain"
)

// subjectKindUser mirrors rbacapp.SubjectUser ("user") — the subject kind for a per-user role
// binding. Duplicated here so the scim context does not import rbac's app package.
const subjectKindUser = "user"

// ScimUser is the SCIM-projected view of a provisioned auth `users` row (+ its scim identity
// subject as ExternalID), so the scim context never imports auth's domain package. Read from
// the `users`/`user_identities` tables by the Store; written via the UserPort (auth service).
type ScimUser struct {
    ID          string
    Email       string
    DisplayName string
    ExternalID  string // user_identities.subject for idp='scim' ("" if none)
    Disabled    bool
}

// UserPort is the narrow WRITE slice of the auth service SCIM provisioning needs, so account
// mutation reuses the audited auth paths (JIT create with an scim identity, disable,
// session-revoke, profile update) rather than writing users/sessions directly. READS go through
// the Store (cross-table reads of users/user_identities). Implemented by a wire.go adapter over
// *authapp.Service.
type UserPort interface {
    // Provision creates-or-fetches the provider='scim' user for (externalID) with the given
    // email/name and returns the Obscura user id. Wraps auth.ProvisionLocal(idp="scim",
    // subject=externalID) — which writes user_identities(idp='scim', subject=externalID).
    Provision(ctx context.Context, externalID, email, name string) (userID string, err error)
    // SetDisabled flips the disabled flag (deprovision/restore).
    SetDisabled(ctx context.Context, userID string, disabled bool) error
    // RevokeSessions signs the user out everywhere (on deprovision) — auth.LogoutAll.
    RevokeSessions(ctx context.Context, userID string) error
    // UpdateProfile updates display name and/or email (PUT/PATCH); a nil field is unchanged.
    UpdateProfile(ctx context.Context, userID string, displayName, email *string) error
}

// RoleBinder is the narrow slice of the rbac write-service SCIM needs to apply group-derived
// role grants. *rbacadapters.Store satisfies it (BindRole/UnbindRole/RefreshEffectivePerms).
type RoleBinder interface {
    BindRole(ctx context.Context, roleID, subjectKind, subjectID string) error
    UnbindRole(ctx context.Context, roleID, subjectKind, subjectID string) error
    RefreshEffectivePerms(ctx context.Context) error
}

// Store persists the SCIM-owned tables: tokens, groups, group members, admin mappings, and the
// reconciliation ledger. Implemented by scimadapters.Store over Postgres.
type Store interface {
    // --- tokens ---
    InsertToken(ctx context.Context, id, tokenHash, createdBy string) error
    RevokeActiveTokens(ctx context.Context) error
    ActiveTokenHashes(ctx context.Context) ([]string, error)
    TouchToken(ctx context.Context, tokenHash string) error
    TokenStatus(ctx context.Context) (enabled bool, lastUsedAt *time.Time, err error)

    // --- groups ---
    InsertGroup(ctx context.Context, g domain.Group) error
    UpdateGroup(ctx context.Context, id, displayName, externalID string) error
    GetGroup(ctx context.Context, id string) (domain.Group, bool, error)
    GroupByDisplayName(ctx context.Context, displayName string) (domain.Group, bool, error)
    ListGroups(ctx context.Context, offset, limit int) (groups []domain.Group, total int, err error)
    DeleteGroup(ctx context.Context, id string) error

    // --- members ---
    ListMembers(ctx context.Context, groupID string) (userIDs []string, err error)
    AddMember(ctx context.Context, groupID, userID string) error
    RemoveMember(ctx context.Context, groupID, userID string) error
    ReplaceMembers(ctx context.Context, groupID string, userIDs []string) error
    GroupIDsForUser(ctx context.Context, userID string) ([]string, error)

    // --- admin mappings ---
    ListMappings(ctx context.Context) ([]domain.GroupRoleMapping, error)
    UpsertMapping(ctx context.Context, scimGroupID, roleID string) error
    DeleteMapping(ctx context.Context, scimGroupID string) error

    // --- reconciliation ledger ---
    GrantsForUser(ctx context.Context, userID string) ([]domain.RoleGrant, error)
    InsertGrant(ctx context.Context, userID, roleID, scimGroupID string) error
    DeleteGrant(ctx context.Context, userID, roleID string) error

    // --- user reads (read-only cross-table reads of users/user_identities) ---
    // GetProvisionedUser returns a provider='scim' user by id.
    GetProvisionedUser(ctx context.Context, userID string) (ScimUser, bool, error)
    // UserByEmail returns the oldest provider='scim' user with this email.
    UserByEmail(ctx context.Context, email string) (ScimUser, bool, error)
    // UserByExternalID returns the user linked to (idp='scim', subject=externalID).
    UserByExternalID(ctx context.Context, externalID string) (ScimUser, bool, error)
    // ListProvisionedUsers returns a page of provider='scim' users + the total.
    ListProvisionedUsers(ctx context.Context, offset, limit int) ([]ScimUser, int, error)
    // EmailExists reports whether ANY user (any provider) has this email — SCIM create dedups
    // against it so provisioning can't collide with a local/oidc/ldap account.
    EmailExists(ctx context.Context, email string) (bool, error)

    // --- status ---
    ProvisionedUserCount(ctx context.Context) (int, error)
}
  • [ ] Step 5 — Postgres store. Create go/internal/scim/adapters/pg.go:
// Package adapters implements the scim app ports against Postgres. Every statement runs through
// s.db.Exec(ctx) (no own tx except ReplaceMembers), mirroring the other Obscura stores.
package adapters

import (
    "context"
    "errors"
    "fmt"

    "github.com/jackc/pgx/v5"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/platform/db"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/scim/app"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/scim/domain"
)

// Store implements app.Store over the scim_* tables.
type Store struct {
    db *db.DB
}

// NewStore constructs the SCIM repository.
func NewStore(d *db.DB) *Store { return &Store{db: d} }

// --- tokens ---

// InsertToken records a minted token (only its sha256 hash).
func (s *Store) InsertToken(ctx context.Context, id, tokenHash, createdBy string) error {
    var by any
    if createdBy != "" {
        by = createdBy
    }
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `INSERT INTO scim_tokens (id, token_hash, created_by) VALUES ($1, $2, $3)`, id, tokenHash, by); err != nil {
        return fmt.Errorf("scim insert token: %w", err)
    }
    return nil
}

// RevokeActiveTokens marks every non-revoked token revoked (rotate / disable).
func (s *Store) RevokeActiveTokens(ctx context.Context) error {
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `UPDATE scim_tokens SET revoked_at = now() WHERE revoked_at IS NULL`); err != nil {
        return fmt.Errorf("scim revoke tokens: %w", err)
    }
    return nil
}

// ActiveTokenHashes returns the hash(es) of every non-revoked token (normally 0 or 1). The app
// constant-time-compares the presented hash against these.
func (s *Store) ActiveTokenHashes(ctx context.Context) ([]string, error) {
    rows, err := s.db.Exec(ctx).Query(ctx, `SELECT token_hash FROM scim_tokens WHERE revoked_at IS NULL`)
    if err != nil {
        return nil, fmt.Errorf("scim active token hashes: %w", err)
    }
    defer rows.Close()
    var out []string
    for rows.Next() {
        var h string
        if err := rows.Scan(&h); err != nil {
            return nil, fmt.Errorf("scim scan token hash: %w", err)
        }
        out = append(out, h)
    }
    return out, rows.Err()
}

// TouchToken bumps last_used_at for the active token matching the hash.
func (s *Store) TouchToken(ctx context.Context, tokenHash string) error {
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `UPDATE scim_tokens SET last_used_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`, tokenHash); err != nil {
        return fmt.Errorf("scim touch token: %w", err)
    }
    return nil
}

// TokenStatus reports whether an active token exists and its last_used_at (most recent).
func (s *Store) TokenStatus(ctx context.Context) (bool, *time.Time, error) {
    var last *time.Time
    var enabled bool
    err := s.db.Exec(ctx).QueryRow(ctx,
        `SELECT count(*) > 0, max(last_used_at) FROM scim_tokens WHERE revoked_at IS NULL`).
        Scan(&enabled, &last)
    if err != nil {
        return false, nil, fmt.Errorf("scim token status: %w", err)
    }
    return enabled, last, nil
}

// --- groups ---

// InsertGroup creates a SCIM group resource.
func (s *Store) InsertGroup(ctx context.Context, g domain.Group) error {
    var ext any
    if g.ExternalID != "" {
        ext = g.ExternalID
    }
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `INSERT INTO scim_groups (id, external_id, display_name) VALUES ($1, $2, $3)`,
        g.ID, ext, g.DisplayName); err != nil {
        return fmt.Errorf("scim insert group: %w", err)
    }
    return nil
}

// UpdateGroup replaces a group's display name (and external id when non-empty) and bumps updated_at.
func (s *Store) UpdateGroup(ctx context.Context, id, displayName, externalID string) error {
    var ext any
    if externalID != "" {
        ext = externalID
    }
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `UPDATE scim_groups SET display_name = $2, external_id = COALESCE($3, external_id), updated_at = now() WHERE id = $1`,
        id, displayName, ext); err != nil {
        return fmt.Errorf("scim update group: %w", err)
    }
    return nil
}

func scanGroup(row interface{ Scan(...any) error }, g *domain.Group) error {
    var ext *string
    if err := row.Scan(&g.ID, &ext, &g.DisplayName, &g.CreatedAt, &g.UpdatedAt); err != nil {
        return err
    }
    if ext != nil {
        g.ExternalID = *ext
    }
    return nil
}

// GetGroup loads a group by id.
func (s *Store) GetGroup(ctx context.Context, id string) (domain.Group, bool, error) {
    var g domain.Group
    err := scanGroup(s.db.Exec(ctx).QueryRow(ctx,
        `SELECT id, external_id, display_name, created_at, updated_at FROM scim_groups WHERE id = $1`, id), &g)
    if errors.Is(err, pgx.ErrNoRows) {
        return domain.Group{}, false, nil
    }
    if err != nil {
        return domain.Group{}, false, fmt.Errorf("scim get group: %w", err)
    }
    return g, true, nil
}

// GroupByDisplayName loads the oldest group with a case-insensitive displayName match (filter).
func (s *Store) GroupByDisplayName(ctx context.Context, displayName string) (domain.Group, bool, error) {
    var g domain.Group
    err := scanGroup(s.db.Exec(ctx).QueryRow(ctx,
        `SELECT id, external_id, display_name, created_at, updated_at FROM scim_groups
          WHERE lower(display_name) = lower($1) ORDER BY created_at LIMIT 1`, displayName), &g)
    if errors.Is(err, pgx.ErrNoRows) {
        return domain.Group{}, false, nil
    }
    if err != nil {
        return domain.Group{}, false, fmt.Errorf("scim group by display name: %w", err)
    }
    return g, true, nil
}

// ListGroups returns a page of groups (ordered) plus the total count.
func (s *Store) ListGroups(ctx context.Context, offset, limit int) ([]domain.Group, int, error) {
    if limit <= 0 {
        limit = 100
    }
    if offset < 0 {
        offset = 0
    }
    var total int
    if err := s.db.Exec(ctx).QueryRow(ctx, `SELECT count(*) FROM scim_groups`).Scan(&total); err != nil {
        return nil, 0, fmt.Errorf("scim count groups: %w", err)
    }
    rows, err := s.db.Exec(ctx).Query(ctx,
        `SELECT id, external_id, display_name, created_at, updated_at FROM scim_groups
          ORDER BY created_at, id LIMIT $1 OFFSET $2`, limit, offset)
    if err != nil {
        return nil, 0, fmt.Errorf("scim list groups: %w", err)
    }
    defer rows.Close()
    var out []domain.Group
    for rows.Next() {
        var g domain.Group
        if err := scanGroup(rows, &g); err != nil {
            return nil, 0, fmt.Errorf("scim scan group: %w", err)
        }
        out = append(out, g)
    }
    return out, total, rows.Err()
}

// DeleteGroup removes a group (members/mappings/grants cascade via FK).
func (s *Store) DeleteGroup(ctx context.Context, id string) error {
    if _, err := s.db.Exec(ctx).Exec(ctx, `DELETE FROM scim_groups WHERE id = $1`, id); err != nil {
        return fmt.Errorf("scim delete group: %w", err)
    }
    return nil
}

// --- members ---

// ListMembers returns the user ids in a group.
func (s *Store) ListMembers(ctx context.Context, groupID string) ([]string, error) {
    rows, err := s.db.Exec(ctx).Query(ctx, `SELECT user_id FROM scim_group_members WHERE group_id = $1`, groupID)
    if err != nil {
        return nil, fmt.Errorf("scim list members: %w", err)
    }
    defer rows.Close()
    var out []string
    for rows.Next() {
        var uid string
        if err := rows.Scan(&uid); err != nil {
            return nil, fmt.Errorf("scim scan member: %w", err)
        }
        out = append(out, uid)
    }
    return out, rows.Err()
}

// AddMember adds a user to a group (idempotent).
func (s *Store) AddMember(ctx context.Context, groupID, userID string) error {
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `INSERT INTO scim_group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
        groupID, userID); err != nil {
        return fmt.Errorf("scim add member: %w", err)
    }
    return nil
}

// RemoveMember removes a user from a group (no-op when absent).
func (s *Store) RemoveMember(ctx context.Context, groupID, userID string) error {
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `DELETE FROM scim_group_members WHERE group_id = $1 AND user_id = $2`, groupID, userID); err != nil {
        return fmt.Errorf("scim remove member: %w", err)
    }
    return nil
}

// ReplaceMembers sets a group's membership to exactly userIDs (PUT). Runs in one tx.
func (s *Store) ReplaceMembers(ctx context.Context, groupID string, userIDs []string) error {
    return s.db.Do(ctx, func(ctx context.Context) error {
        ex := s.db.Exec(ctx)
        if _, err := ex.Exec(ctx, `DELETE FROM scim_group_members WHERE group_id = $1`, groupID); err != nil {
            return fmt.Errorf("scim replace members clear: %w", err)
        }
        for _, uid := range userIDs {
            if uid == "" {
                continue
            }
            if _, err := ex.Exec(ctx,
                `INSERT INTO scim_group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
                groupID, uid); err != nil {
                return fmt.Errorf("scim replace members insert: %w", err)
            }
        }
        return nil
    })
}

// GroupIDsForUser returns the scim group ids a user belongs to.
func (s *Store) GroupIDsForUser(ctx context.Context, userID string) ([]string, error) {
    rows, err := s.db.Exec(ctx).Query(ctx, `SELECT group_id FROM scim_group_members WHERE user_id = $1`, userID)
    if err != nil {
        return nil, fmt.Errorf("scim groups for user: %w", err)
    }
    defer rows.Close()
    var out []string
    for rows.Next() {
        var gid string
        if err := rows.Scan(&gid); err != nil {
            return nil, fmt.Errorf("scim scan group id: %w", err)
        }
        out = append(out, gid)
    }
    return out, rows.Err()
}

// --- admin mappings ---

// ListMappings returns every scim_group_id -> role_id mapping.
func (s *Store) ListMappings(ctx context.Context) ([]domain.GroupRoleMapping, error) {
    rows, err := s.db.Exec(ctx).Query(ctx, `SELECT scim_group_id, role_id FROM scim_group_roles`)
    if err != nil {
        return nil, fmt.Errorf("scim list mappings: %w", err)
    }
    defer rows.Close()
    var out []domain.GroupRoleMapping
    for rows.Next() {
        var m domain.GroupRoleMapping
        if err := rows.Scan(&m.ScimGroupID, &m.RoleID); err != nil {
            return nil, fmt.Errorf("scim scan mapping: %w", err)
        }
        out = append(out, m)
    }
    return out, rows.Err()
}

// UpsertMapping inserts or replaces a group -> role mapping.
func (s *Store) UpsertMapping(ctx context.Context, scimGroupID, roleID string) error {
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `INSERT INTO scim_group_roles (scim_group_id, role_id) VALUES ($1, $2)
         ON CONFLICT (scim_group_id) DO UPDATE SET role_id = EXCLUDED.role_id`, scimGroupID, roleID); err != nil {
        return fmt.Errorf("scim upsert mapping: %w", err)
    }
    return nil
}

// DeleteMapping removes a mapping (no-op when absent).
func (s *Store) DeleteMapping(ctx context.Context, scimGroupID string) error {
    if _, err := s.db.Exec(ctx).Exec(ctx, `DELETE FROM scim_group_roles WHERE scim_group_id = $1`, scimGroupID); err != nil {
        return fmt.Errorf("scim delete mapping: %w", err)
    }
    return nil
}

// --- reconciliation ledger ---

// GrantsForUser returns a user's SCIM reconciliation-ledger rows.
func (s *Store) GrantsForUser(ctx context.Context, userID string) ([]domain.RoleGrant, error) {
    rows, err := s.db.Exec(ctx).Query(ctx, `SELECT role_id, scim_group_id FROM scim_role_grants WHERE user_id = $1`, userID)
    if err != nil {
        return nil, fmt.Errorf("scim grants for user: %w", err)
    }
    defer rows.Close()
    var out []domain.RoleGrant
    for rows.Next() {
        var g domain.RoleGrant
        if err := rows.Scan(&g.RoleID, &g.ScimGroupID); err != nil {
            return nil, fmt.Errorf("scim scan grant: %w", err)
        }
        out = append(out, g)
    }
    return out, rows.Err()
}

// InsertGrant records a reconciliation grant (idempotent on (user_id, role_id)).
func (s *Store) InsertGrant(ctx context.Context, userID, roleID, scimGroupID string) error {
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `INSERT INTO scim_role_grants (user_id, role_id, scim_group_id) VALUES ($1, $2, $3)
         ON CONFLICT (user_id, role_id) DO UPDATE SET scim_group_id = EXCLUDED.scim_group_id`,
        userID, roleID, scimGroupID); err != nil {
        return fmt.Errorf("scim insert grant: %w", err)
    }
    return nil
}

// DeleteGrant removes a ledger row.
func (s *Store) DeleteGrant(ctx context.Context, userID, roleID string) error {
    if _, err := s.db.Exec(ctx).Exec(ctx, `DELETE FROM scim_role_grants WHERE user_id = $1 AND role_id = $2`, userID, roleID); err != nil {
        return fmt.Errorf("scim delete grant: %w", err)
    }
    return nil
}

// --- user reads (read-only cross-table reads) ---

// scimUserSelect is the users⋈scim-identity projection. LEFT JOIN so a user with no scim
// identity still returns (subject NULL -> ExternalID "").
const scimUserSelect = `SELECT u.id, u.email, u.display_name, u.disabled, i.subject
    FROM users u LEFT JOIN user_identities i ON i.user_id = u.id AND i.idp = 'scim'`

func scanScimUser(row interface{ Scan(...any) error }, u *app.ScimUser) error {
    var subject *string
    if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Disabled, &subject); err != nil {
        return err
    }
    if subject != nil {
        u.ExternalID = *subject
    }
    return nil
}

// GetProvisionedUser returns a provider='scim' user by id.
func (s *Store) GetProvisionedUser(ctx context.Context, userID string) (app.ScimUser, bool, error) {
    var u app.ScimUser
    err := scanScimUser(s.db.Exec(ctx).QueryRow(ctx,
        scimUserSelect+` WHERE u.id = $1 AND u.provider = 'scim'`, userID), &u)
    if errors.Is(err, pgx.ErrNoRows) {
        return app.ScimUser{}, false, nil
    }
    if err != nil {
        return app.ScimUser{}, false, fmt.Errorf("scim get provisioned user: %w", err)
    }
    return u, true, nil
}

// UserByEmail returns the oldest provider='scim' user with this email.
func (s *Store) UserByEmail(ctx context.Context, email string) (app.ScimUser, bool, error) {
    var u app.ScimUser
    err := scanScimUser(s.db.Exec(ctx).QueryRow(ctx,
        scimUserSelect+` WHERE u.email = $1 AND u.provider = 'scim' ORDER BY u.created_at LIMIT 1`, email), &u)
    if errors.Is(err, pgx.ErrNoRows) {
        return app.ScimUser{}, false, nil
    }
    if err != nil {
        return app.ScimUser{}, false, fmt.Errorf("scim user by email: %w", err)
    }
    return u, true, nil
}

// UserByExternalID returns the user linked to (idp='scim', subject=externalID).
func (s *Store) UserByExternalID(ctx context.Context, externalID string) (app.ScimUser, bool, error) {
    var u app.ScimUser
    err := scanScimUser(s.db.Exec(ctx).QueryRow(ctx,
        scimUserSelect+` WHERE i.subject = $1 AND u.provider = 'scim'`, externalID), &u)
    if errors.Is(err, pgx.ErrNoRows) {
        return app.ScimUser{}, false, nil
    }
    if err != nil {
        return app.ScimUser{}, false, fmt.Errorf("scim user by external id: %w", err)
    }
    return u, true, nil
}

// ListProvisionedUsers returns a page of provider='scim' users + the total.
func (s *Store) ListProvisionedUsers(ctx context.Context, offset, limit int) ([]app.ScimUser, int, error) {
    if limit <= 0 {
        limit = 100
    }
    if offset < 0 {
        offset = 0
    }
    var total int
    if err := s.db.Exec(ctx).QueryRow(ctx, `SELECT count(*) FROM users WHERE provider = 'scim'`).Scan(&total); err != nil {
        return nil, 0, fmt.Errorf("scim count provisioned users: %w", err)
    }
    rows, err := s.db.Exec(ctx).Query(ctx,
        scimUserSelect+` WHERE u.provider = 'scim' ORDER BY u.created_at, u.id LIMIT $1 OFFSET $2`, limit, offset)
    if err != nil {
        return nil, 0, fmt.Errorf("scim list provisioned users: %w", err)
    }
    defer rows.Close()
    var out []app.ScimUser
    for rows.Next() {
        var u app.ScimUser
        if err := scanScimUser(rows, &u); err != nil {
            return nil, 0, fmt.Errorf("scim scan provisioned user: %w", err)
        }
        out = append(out, u)
    }
    return out, total, rows.Err()
}

// EmailExists reports whether ANY user (any provider) has this email.
func (s *Store) EmailExists(ctx context.Context, email string) (bool, error) {
    var exists bool
    if err := s.db.Exec(ctx).QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, email).Scan(&exists); err != nil {
        return false, fmt.Errorf("scim email exists: %w", err)
    }
    return exists, nil
}

// --- status ---

// ProvisionedUserCount counts SCIM-provisioned accounts (read-only cross-table read of `users`).
func (s *Store) ProvisionedUserCount(ctx context.Context) (int, error) {
    var n int
    if err := s.db.Exec(ctx).QueryRow(ctx, `SELECT count(*) FROM users WHERE provider = 'scim'`).Scan(&n); err != nil {
        return 0, fmt.Errorf("scim provisioned user count: %w", err)
    }
    return n, nil
}

var _ app.Store = (*Store)(nil)

Add "time" to the import block (used by TokenStatus). The block above omits it deliberately so you add it and confirm goimports/build — final imports are: context, errors, fmt, time, github.com/jackc/pgx/v5, and the three obscura paths.

  • [ ] Step 6 — Verify + commit. cd go && go build ./... && go vet ./... (the scim package compiles standalone; nothing imports it yet — that is fine). Commit:
git add go/migrations/00084_scim.sql go/migrations/00085_scim_group_roles.sql go/internal/scim/domain/scim.go go/internal/scim/app/ports.go go/internal/scim/adapters/pg.go
git commit -m "feat(scim): migrations 00084/00085 + scim domain, ports, Postgres store"

Task 2 (ADVERSARIAL-review this task — the token + provisioning + reconcile core): the scim app Service

Files: go/internal/scim/app/service.go (new).

Contract (spec §Users, §Groups→roles, §Token):
- Token: MintToken generates 32 random bytes, revokes any prior active token, stores only sha256(raw), returns the raw token ONCE. VerifyToken sha256es the presentation and constant-time-compares against the active hash(es), bumping last_used_at on a hit. RevokeTokens disables SCIM.
- Provisioning: CreateUser maps userName/emails[primary]→email, displayName/name.formatted→display name, active!disabled; provisions provider='scim' with no password (via UserPort.Provisionauth.ProvisionLocal(idp='scim', subject=externalID)); duplicate externalId/email → 409. active=false/DELETEdisable + revoke sessions + strip SCIM grants (applyActive(false)), idempotent; active=true restores + re-reconciles.
- Reconcile: brings a user's scim_role_grants in line with group membership × admin mappings; a disabled user's desired set is empty (all SCIM grants stripped). Grant/revoke via the rbac binder; manual bindings (no ledger row) are never touched; RefreshEffectivePerms once on change. Reconcile fires on member PATCH, group delete, deprovision/restore, and mapping change. Reconcile errors are logged non-fatal at the call sites.

  • [ ] Step 1 — Service. Create go/internal/scim/app/service.go:
package app

import (
    "context"
    "crypto/rand"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/base64"
    "encoding/hex"
    "fmt"
    "log/slog"
    "strings"
    "time"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/scim/domain"
)

// Service is the SCIM use-case layer: token lifecycle, user provisioning/deprovisioning, group
// resources, and group->role reconciliation. It writes accounts only through the UserPort (the
// audited auth paths) and role bindings only through the rbac RoleBinder.
type Service struct {
    store  Store
    users  UserPort
    binder RoleBinder
    logger *slog.Logger // optional; best-effort reconcile/touch logging
}

// NewService wires the SCIM service.
func NewService(store Store, users UserPort, binder RoleBinder, logger *slog.Logger) *Service {
    return &Service{store: store, users: users, binder: binder, logger: logger}
}

// scimErr is a kernel.Error the HTTP layer maps onto a SCIM error envelope (Kind -> status +
// scimType): ErrConflict->409 uniqueness, ErrValidation->400 invalidValue, ErrNotFound->404.
func scimErr(kind kernel.ErrorKind, code, msg string) error {
    return &kernel.Error{Kind: kind, Code: code, Message: msg}
}

// ---------------------------------------------------------------------------
// Tokens
// ---------------------------------------------------------------------------

// MintToken generates a fresh 32-byte token, revokes any prior active token (rotate), stores
// only sha256(token), and returns the RAW token ONCE. createdBy is the admin user id.
func (s *Service) MintToken(ctx context.Context, createdBy string) (string, error) {
    raw, err := randToken(32)
    if err != nil {
        return "", err
    }
    if err := s.store.RevokeActiveTokens(ctx); err != nil {
        return "", err
    }
    if err := s.store.InsertToken(ctx, kernel.NewID(), sha256hex(raw), createdBy); err != nil {
        return "", err
    }
    return raw, nil
}

// RevokeTokens revokes every active token (disables SCIM).
func (s *Service) RevokeTokens(ctx context.Context) error {
    return s.store.RevokeActiveTokens(ctx)
}

// VerifyToken reports whether the presented bearer token matches an active token, using a
// constant-time compare over sha256 hashes, and bumps last_used_at on a hit.
func (s *Service) VerifyToken(ctx context.Context, presented string) (bool, error) {
    if presented == "" {
        return false, nil
    }
    want := sha256hex(presented)
    hashes, err := s.store.ActiveTokenHashes(ctx)
    if err != nil {
        return false, err
    }
    match := false
    for _, h := range hashes {
        if subtle.ConstantTimeCompare([]byte(h), []byte(want)) == 1 {
            match = true
        }
    }
    if !match {
        return false, nil
    }
    if err := s.store.TouchToken(ctx, want); err != nil && s.logger != nil {
        s.logger.Warn("scim touch token failed", "err", err)
    }
    return true, nil
}

// Status reports whether SCIM is enabled (an active token exists), the last-used timestamp, and
// the count of provisioned users.
func (s *Service) Status(ctx context.Context) (enabled bool, lastUsedAt *time.Time, provisioned int, err error) {
    enabled, lastUsedAt, err = s.store.TokenStatus(ctx)
    if err != nil {
        return false, nil, 0, err
    }
    provisioned, err = s.store.ProvisionedUserCount(ctx)
    return enabled, lastUsedAt, provisioned, err
}

// ---------------------------------------------------------------------------
// User resources
// ---------------------------------------------------------------------------

// UserInput is the mapped input for creating/replacing a SCIM user (the HTTP layer decodes the
// SCIM JSON into this).
type UserInput struct {
    ExternalID  string
    UserName    string // maps to email
    Email       string // emails[primary].value; falls back to UserName
    DisplayName string
    Active      bool
}

// UserPatch is a set of optional attribute changes from a SCIM PATCH (nil = unchanged).
type UserPatch struct {
    Active      *bool
    DisplayName *string
    Email       *string
}

// GroupRef is a group a user belongs to (rendered in a User resource's groups).
type GroupRef struct {
    ID      string
    Display string
}

// UserResource is the SCIM-projected view the HTTP layer renders.
type UserResource struct {
    ID          string
    ExternalID  string
    UserName    string
    Email       string
    DisplayName string
    Active      bool
    Groups      []GroupRef
}

// CreateUser provisions a new SCIM user. Duplicate externalId, or ANY existing account with the
// same email, yields 409 uniqueness (SCIM must not collide with a local/oidc/ldap account).
func (s *Service) CreateUser(ctx context.Context, in UserInput) (UserResource, error) {
    email := strings.ToLower(strings.TrimSpace(firstNonEmpty(in.Email, in.UserName)))
    if email == "" {
        return UserResource{}, scimErr(kernel.ErrValidation, "scim.user.username_required", "userName or a primary email is required")
    }
    externalID := strings.TrimSpace(in.ExternalID)
    if externalID == "" {
        externalID = email // stable identity subject when the IdP omits externalId
    }
    if _, found, err := s.store.UserByExternalID(ctx, externalID); err != nil {
        return UserResource{}, err
    } else if found {
        return UserResource{}, scimErr(kernel.ErrConflict, "scim.user.duplicate", "a user with this externalId already exists")
    }
    if exists, err := s.store.EmailExists(ctx, email); err != nil {
        return UserResource{}, err
    } else if exists {
        return UserResource{}, scimErr(kernel.ErrConflict, "scim.user.duplicate", "a user with this userName already exists")
    }
    userID, err := s.users.Provision(ctx, externalID, email, in.DisplayName)
    if err != nil {
        return UserResource{}, err
    }
    if !in.Active {
        if err := s.applyActive(ctx, userID, false); err != nil {
            return UserResource{}, err
        }
    } else {
        s.reconcileBestEffort(ctx, userID, "create")
    }
    return s.userResource(ctx, userID)
}

// GetUserResource returns a provisioned user by id (404 when absent / not SCIM-managed).
func (s *Service) GetUserResource(ctx context.Context, id string) (UserResource, error) {
    return s.userResource(ctx, id)
}

// ListUsers implements GET /Users. attr is "" (enumerate), "userName", or "externalId"; val is
// the eq value. startIndex is 1-based; count is the page size (0 => metadata only).
func (s *Service) ListUsers(ctx context.Context, attr, val string, startIndex, count int) ([]UserResource, int, error) {
    switch attr {
    case "":
        offset := startIndex - 1
        if offset < 0 {
            offset = 0
        }
        users, total, err := s.store.ListProvisionedUsers(ctx, offset, count)
        if err != nil {
            return nil, 0, err
        }
        out := make([]UserResource, 0, len(users))
        for _, u := range users {
            out = append(out, s.projectUser(ctx, u))
        }
        return out, total, nil
    case "userName":
        u, found, err := s.store.UserByEmail(ctx, strings.ToLower(strings.TrimSpace(val)))
        if err != nil || !found {
            return nil, 0, err
        }
        return []UserResource{s.projectUser(ctx, u)}, 1, nil
    case "externalId":
        u, found, err := s.store.UserByExternalID(ctx, strings.TrimSpace(val))
        if err != nil || !found {
            return nil, 0, err
        }
        return []UserResource{s.projectUser(ctx, u)}, 1, nil
    default:
        return nil, 0, scimErr(kernel.ErrValidation, "scim.filter.unsupported", "unsupported filter attribute")
    }
}

// ReplaceUser implements PUT /Users/{id} (full replace of the mapped attributes + active).
func (s *Service) ReplaceUser(ctx context.Context, id string, in UserInput) (UserResource, error) {
    u, found, err := s.store.GetProvisionedUser(ctx, id)
    if err != nil {
        return UserResource{}, err
    }
    if !found {
        return UserResource{}, scimErr(kernel.ErrNotFound, "scim.user.not_found", "user not found")
    }
    email := strings.ToLower(strings.TrimSpace(firstNonEmpty(in.Email, in.UserName, u.Email)))
    dn := in.DisplayName
    if err := s.users.UpdateProfile(ctx, id, &dn, &email); err != nil {
        return UserResource{}, err
    }
    if err := s.applyActive(ctx, id, in.Active); err != nil {
        return UserResource{}, err
    }
    return s.userResource(ctx, id)
}

// PatchUser implements PATCH /Users/{id} (partial replace/add of active/displayName/email).
func (s *Service) PatchUser(ctx context.Context, id string, p UserPatch) (UserResource, error) {
    if _, found, err := s.store.GetProvisionedUser(ctx, id); err != nil {
        return UserResource{}, err
    } else if !found {
        return UserResource{}, scimErr(kernel.ErrNotFound, "scim.user.not_found", "user not found")
    }
    if p.DisplayName != nil || p.Email != nil {
        var emailPtr *string
        if p.Email != nil {
            e := strings.ToLower(strings.TrimSpace(*p.Email))
            emailPtr = &e
        }
        if err := s.users.UpdateProfile(ctx, id, p.DisplayName, emailPtr); err != nil {
            return UserResource{}, err
        }
    }
    if p.Active != nil {
        if err := s.applyActive(ctx, id, *p.Active); err != nil {
            return UserResource{}, err
        }
    }
    return s.userResource(ctx, id)
}

// DeleteUser implements DELETE /Users/{id} as a DEPROVISION (disable + revoke + strip grants) —
// never a hard delete, per the spec. A later GET returns the user with active=false.
func (s *Service) DeleteUser(ctx context.Context, id string) error {
    if _, found, err := s.store.GetProvisionedUser(ctx, id); err != nil {
        return err
    } else if !found {
        return scimErr(kernel.ErrNotFound, "scim.user.not_found", "user not found")
    }
    return s.applyActive(ctx, id, false)
}

// applyActive enables or DEPROVISIONS a user and re-reconciles their SCIM roles. Disable also
// revokes live sessions; reconcile then strips every SCIM grant (a disabled user's desired set
// is empty). Enable re-reconciles from current membership. Idempotent.
func (s *Service) applyActive(ctx context.Context, userID string, active bool) error {
    if active {
        if err := s.users.SetDisabled(ctx, userID, false); err != nil {
            return err
        }
    } else {
        if err := s.users.SetDisabled(ctx, userID, true); err != nil {
            return err
        }
        if err := s.users.RevokeSessions(ctx, userID); err != nil {
            return err
        }
    }
    return s.reconcileUser(ctx, userID)
}

func (s *Service) userResource(ctx context.Context, userID string) (UserResource, error) {
    u, found, err := s.store.GetProvisionedUser(ctx, userID)
    if err != nil {
        return UserResource{}, err
    }
    if !found {
        return UserResource{}, scimErr(kernel.ErrNotFound, "scim.user.not_found", "user not found")
    }
    return s.projectUser(ctx, u), nil
}

func (s *Service) projectUser(ctx context.Context, u ScimUser) UserResource {
    res := UserResource{
        ID: u.ID, ExternalID: u.ExternalID, UserName: u.Email, Email: u.Email,
        DisplayName: u.DisplayName, Active: !u.Disabled,
    }
    if gids, err := s.store.GroupIDsForUser(ctx, u.ID); err == nil {
        for _, gid := range gids {
            if g, ok, gerr := s.store.GetGroup(ctx, gid); gerr == nil && ok {
                res.Groups = append(res.Groups, GroupRef{ID: g.ID, Display: g.DisplayName})
            }
        }
    }
    return res
}

// ---------------------------------------------------------------------------
// Group resources
// ---------------------------------------------------------------------------

// GroupInput is the mapped input for creating/replacing a SCIM group. MemberUserIDs are the SCIM
// members[].value entries (each an Obscura user id).
type GroupInput struct {
    ExternalID    string
    DisplayName   string
    MemberUserIDs []string
}

// GroupPatch is a set of optional group changes from a SCIM PATCH.
type GroupPatch struct {
    DisplayName    *string
    AddMembers     []string
    RemoveMembers  []string
    ReplaceMembers *[]string // non-nil => set membership to exactly this set
}

// GroupMember is one rendered member (value = user id, display = email).
type GroupMember struct {
    UserID  string
    Display string
}

// GroupResource is the SCIM-projected view the HTTP layer renders.
type GroupResource struct {
    ID          string
    ExternalID  string
    DisplayName string
    Members     []GroupMember
}

// CreateGroup provisions a new SCIM group. Duplicate displayName -> 409 uniqueness.
func (s *Service) CreateGroup(ctx context.Context, in GroupInput) (GroupResource, error) {
    dn := strings.TrimSpace(in.DisplayName)
    if dn == "" {
        return GroupResource{}, scimErr(kernel.ErrValidation, "scim.group.display_required", "displayName is required")
    }
    if _, found, err := s.store.GroupByDisplayName(ctx, dn); err != nil {
        return GroupResource{}, err
    } else if found {
        return GroupResource{}, scimErr(kernel.ErrConflict, "scim.group.duplicate", "a group with this displayName already exists")
    }
    g := domain.Group{ID: kernel.NewID(), ExternalID: strings.TrimSpace(in.ExternalID), DisplayName: dn}
    if err := s.store.InsertGroup(ctx, g); err != nil {
        return GroupResource{}, err
    }
    if len(in.MemberUserIDs) > 0 {
        if err := s.store.ReplaceMembers(ctx, g.ID, in.MemberUserIDs); err != nil {
            return GroupResource{}, err
        }
        s.reconcileMembersBestEffort(ctx, in.MemberUserIDs, "group create")
    }
    return s.groupResource(ctx, g.ID)
}

// GetGroupResource returns a group by id (404 when absent).
func (s *Service) GetGroupResource(ctx context.Context, id string) (GroupResource, error) {
    return s.groupResource(ctx, id)
}

// ListGroups implements GET /Groups. attr is "" (enumerate) or "displayName"; val is the eq value.
func (s *Service) ListGroups(ctx context.Context, attr, val string, startIndex, count int) ([]GroupResource, int, error) {
    switch attr {
    case "":
        offset := startIndex - 1
        if offset < 0 {
            offset = 0
        }
        groups, total, err := s.store.ListGroups(ctx, offset, count)
        if err != nil {
            return nil, 0, err
        }
        out := make([]GroupResource, 0, len(groups))
        for _, g := range groups {
            r, rerr := s.groupResource(ctx, g.ID)
            if rerr != nil {
                return nil, 0, rerr
            }
            out = append(out, r)
        }
        return out, total, nil
    case "displayName":
        g, found, err := s.store.GroupByDisplayName(ctx, strings.TrimSpace(val))
        if err != nil || !found {
            return nil, 0, err
        }
        r, rerr := s.groupResource(ctx, g.ID)
        if rerr != nil {
            return nil, 0, rerr
        }
        return []GroupResource{r}, 1, nil
    default:
        return nil, 0, scimErr(kernel.ErrValidation, "scim.filter.unsupported", "unsupported filter attribute")
    }
}

// ReplaceGroup implements PUT /Groups/{id} (displayName + full membership replace).
func (s *Service) ReplaceGroup(ctx context.Context, id string, in GroupInput) (GroupResource, error) {
    if _, found, err := s.store.GetGroup(ctx, id); err != nil {
        return GroupResource{}, err
    } else if !found {
        return GroupResource{}, scimErr(kernel.ErrNotFound, "scim.group.not_found", "group not found")
    }
    if dn := strings.TrimSpace(in.DisplayName); dn != "" {
        if err := s.store.UpdateGroup(ctx, id, dn, strings.TrimSpace(in.ExternalID)); err != nil {
            return GroupResource{}, err
        }
    }
    before, err := s.store.ListMembers(ctx, id)
    if err != nil {
        return GroupResource{}, err
    }
    if err := s.store.ReplaceMembers(ctx, id, in.MemberUserIDs); err != nil {
        return GroupResource{}, err
    }
    s.reconcileMembersBestEffort(ctx, union(before, in.MemberUserIDs), "group replace")
    return s.groupResource(ctx, id)
}

// PatchGroup implements PATCH /Groups/{id} (displayName replace and/or member add/remove/replace).
func (s *Service) PatchGroup(ctx context.Context, id string, p GroupPatch) (GroupResource, error) {
    g, found, err := s.store.GetGroup(ctx, id)
    if err != nil {
        return GroupResource{}, err
    }
    if !found {
        return GroupResource{}, scimErr(kernel.ErrNotFound, "scim.group.not_found", "group not found")
    }
    if p.DisplayName != nil {
        if err := s.store.UpdateGroup(ctx, id, strings.TrimSpace(*p.DisplayName), g.ExternalID); err != nil {
            return GroupResource{}, err
        }
    }
    var affected []string
    if p.ReplaceMembers != nil {
        before, err := s.store.ListMembers(ctx, id)
        if err != nil {
            return GroupResource{}, err
        }
        if err := s.store.ReplaceMembers(ctx, id, *p.ReplaceMembers); err != nil {
            return GroupResource{}, err
        }
        affected = union(before, *p.ReplaceMembers)
    } else {
        for _, uid := range p.AddMembers {
            if err := s.store.AddMember(ctx, id, uid); err != nil {
                return GroupResource{}, err
            }
        }
        for _, uid := range p.RemoveMembers {
            if err := s.store.RemoveMember(ctx, id, uid); err != nil {
                return GroupResource{}, err
            }
        }
        affected = union(p.AddMembers, p.RemoveMembers)
    }
    s.reconcileMembersBestEffort(ctx, affected, "group patch")
    return s.groupResource(ctx, id)
}

// DeleteGroup removes a group. To avoid orphaning rbac bindings (the ledger cascades on the
// scim_group_id FK, which would bypass the binder), the mapping is deleted and every member
// reconciled FIRST (revoking their group-derived roles via the binder + ledger), THEN the group
// row is dropped.
func (s *Service) DeleteGroup(ctx context.Context, id string) error {
    if _, found, err := s.store.GetGroup(ctx, id); err != nil {
        return err
    } else if !found {
        return scimErr(kernel.ErrNotFound, "scim.group.not_found", "group not found")
    }
    members, err := s.store.ListMembers(ctx, id)
    if err != nil {
        return err
    }
    if err := s.store.DeleteMapping(ctx, id); err != nil {
        return err
    }
    s.reconcileMembersBestEffort(ctx, members, "group delete")
    return s.store.DeleteGroup(ctx, id)
}

func (s *Service) groupResource(ctx context.Context, id string) (GroupResource, error) {
    g, found, err := s.store.GetGroup(ctx, id)
    if err != nil {
        return GroupResource{}, err
    }
    if !found {
        return GroupResource{}, scimErr(kernel.ErrNotFound, "scim.group.not_found", "group not found")
    }
    res := GroupResource{ID: g.ID, ExternalID: g.ExternalID, DisplayName: g.DisplayName}
    if uids, merr := s.store.ListMembers(ctx, id); merr == nil {
        for _, uid := range uids {
            display := uid
            if u, ok, uerr := s.store.GetProvisionedUser(ctx, uid); uerr == nil && ok {
                display = u.Email
            }
            res.Members = append(res.Members, GroupMember{UserID: uid, Display: display})
        }
    }
    return res, nil
}

// ---------------------------------------------------------------------------
// Admin mapping surface (session-authed handlers call these)
// ---------------------------------------------------------------------------

// ListGroupsRaw returns a page of groups (id + displayName) for the admin mapping dropdown.
func (s *Service) ListGroupsRaw(ctx context.Context, offset, limit int) ([]domain.Group, int, error) {
    return s.store.ListGroups(ctx, offset, limit)
}

// ListGroupRoleMappings returns the admin-managed group->role mappings.
func (s *Service) ListGroupRoleMappings(ctx context.Context) ([]domain.GroupRoleMapping, error) {
    return s.store.ListMappings(ctx)
}

// SetGroupRoleMapping upserts a mapping and re-reconciles every member of the group.
func (s *Service) SetGroupRoleMapping(ctx context.Context, scimGroupID, roleID string) error {
    if strings.TrimSpace(scimGroupID) == "" {
        return scimErr(kernel.ErrValidation, "scim.mapping.group_required", "scim group id is required")
    }
    if strings.TrimSpace(roleID) == "" {
        return scimErr(kernel.ErrValidation, "scim.mapping.role_required", "role id is required")
    }
    if _, found, err := s.store.GetGroup(ctx, scimGroupID); err != nil {
        return err
    } else if !found {
        return scimErr(kernel.ErrNotFound, "scim.group.not_found", "scim group not found")
    }
    if err := s.store.UpsertMapping(ctx, scimGroupID, roleID); err != nil {
        return err
    }
    s.reconcileGroupMembers(ctx, scimGroupID)
    return nil
}

// DeleteGroupRoleMapping removes a mapping and re-reconciles every member of the group.
func (s *Service) DeleteGroupRoleMapping(ctx context.Context, scimGroupID string) error {
    if strings.TrimSpace(scimGroupID) == "" {
        return scimErr(kernel.ErrValidation, "scim.mapping.group_required", "scim group id is required")
    }
    if err := s.store.DeleteMapping(ctx, scimGroupID); err != nil {
        return err
    }
    s.reconcileGroupMembers(ctx, scimGroupID)
    return nil
}

// ---------------------------------------------------------------------------
// Reconciliation
// ---------------------------------------------------------------------------

// reconcileUser brings a user's scim_role_grants in line with (their current SCIM group
// membership × the admin mappings). A disabled user's desired set is empty (all SCIM grants
// stripped). Manual bindings (no ledger row) are never touched. RefreshEffectivePerms once on
// change. Parallels the LDAP reconciler.
func (s *Service) reconcileUser(ctx context.Context, userID string) error {
    desired := map[string]string{} // roleID -> scimGroupID
    if u, found, err := s.store.GetProvisionedUser(ctx, userID); err != nil {
        return err
    } else if found && !u.Disabled {
        gids, err := s.store.GroupIDsForUser(ctx, userID)
        if err != nil {
            return err
        }
        if len(gids) > 0 {
            mappings, err := s.store.ListMappings(ctx)
            if err != nil {
                return err
            }
            byGroup := make(map[string]string, len(mappings))
            for _, m := range mappings {
                byGroup[m.ScimGroupID] = m.RoleID
            }
            for _, gid := range gids {
                if rid, ok := byGroup[gid]; ok {
                    desired[rid] = gid
                }
            }
        }
    }
    current, err := s.store.GrantsForUser(ctx, userID)
    if err != nil {
        return err
    }
    have := make(map[string]bool, len(current))
    changed := false
    for _, g := range current {
        have[g.RoleID] = true
        if _, keep := desired[g.RoleID]; !keep {
            if err := s.binder.UnbindRole(ctx, g.RoleID, subjectKindUser, userID); err != nil {
                return err
            }
            if err := s.store.DeleteGrant(ctx, userID, g.RoleID); err != nil {
                return err
            }
            changed = true
        }
    }
    for rid, gid := range desired {
        if have[rid] {
            continue
        }
        if err := s.binder.BindRole(ctx, rid, subjectKindUser, userID); err != nil {
            return err
        }
        if err := s.store.InsertGrant(ctx, userID, rid, gid); err != nil {
            return err
        }
        changed = true
    }
    if changed {
        return s.binder.RefreshEffectivePerms(ctx)
    }
    return nil
}

func (s *Service) reconcileGroupMembers(ctx context.Context, groupID string) {
    members, err := s.store.ListMembers(ctx, groupID)
    if err != nil {
        if s.logger != nil {
            s.logger.Warn("scim list members for reconcile failed", "group", groupID, "err", err)
        }
        return
    }
    s.reconcileMembersBestEffort(ctx, members, "mapping change")
}

func (s *Service) reconcileMembersBestEffort(ctx context.Context, userIDs []string, reason string) {
    for _, uid := range userIDs {
        if uid == "" {
            continue
        }
        s.reconcileBestEffort(ctx, uid, reason)
    }
}

func (s *Service) reconcileBestEffort(ctx context.Context, userID, reason string) {
    if err := s.reconcileUser(ctx, userID); err != nil && s.logger != nil {
        s.logger.Warn("scim reconcile failed", "user", userID, "reason", reason, "err", err)
    }
}

// ---------------------------------------------------------------------------
// helpers (stdlib only)
// ---------------------------------------------------------------------------

func randToken(nbytes int) (string, error) {
    b := make([]byte, nbytes)
    if _, err := rand.Read(b); err != nil {
        return "", fmt.Errorf("scim rand: %w", err)
    }
    return base64.RawURLEncoding.EncodeToString(b), nil
}

func sha256hex(s string) string {
    sum := sha256.Sum256([]byte(s))
    return hex.EncodeToString(sum[:])
}

func firstNonEmpty(vals ...string) string {
    for _, v := range vals {
        if strings.TrimSpace(v) != "" {
            return v
        }
    }
    return ""
}

// union returns the de-duplicated concatenation of two id slices (for reconcile-affected sets).
func union(a, b []string) []string {
    seen := map[string]bool{}
    out := make([]string, 0, len(a)+len(b))
    for _, s := range append(append([]string{}, a...), b...) {
        if s == "" || seen[s] {
            continue
        }
        seen[s] = true
        out = append(out, s)
    }
    return out
}
  • [ ] Step 2 — Verify + commit. cd go && go build ./... && go vet ./... (the scim package compiles; still unimported — fine). Commit:
git add go/internal/scim/app/service.go
git commit -m "feat(scim): app Service — token mint/verify, user provisioning, group->role reconcile"

Task 3 (ADVERSARIAL-review this task — the auth login-guard widening is security-critical): widen the directory guard to IsDirectoryManaged; link OIDC first-login to the SCIM account

Files: go/internal/auth/domain/auth.go, go/internal/auth/app/ports.go, go/internal/auth/adapters/pg.go, go/internal/auth/app/service.go, go/internal/httpapi/handlers_auth.go.

Why not the spec's literal != 'local': the demo director is provider='dev' and logs in through the web form (/auth/login, password==email) — a literal != 'local' guard would 401 it and break the demo. The correct predicate blocks only the EXTERNAL identity providers (ldap, scim, oidc) while keeping the INTERNAL ones (local, dev, and the empty legacy default) on the local path. A single authdomain.IsDirectoryManaged is the one source of truth for all three login guards + /me. This also closes a pre-existing latent hole: oidc hash-less accounts were reachable via password==email under the old == "ldap" check.

  • [ ] Step 1 — Predicate. Edit go/internal/auth/domain/auth.go. Add (near the User type):
// IsDirectoryManaged reports whether an account with this provider is owned by an EXTERNAL
// identity source ("ldap", "scim", "oidc") — one that has NO local password and must
// authenticate through its own backend (directory bind, or OIDC/SCIM via the IdP). The INTERNAL
// providers ("local", "dev", and the empty legacy default) manage a local password (or the demo
// password==email convention) and are the ONLY ones eligible for the local login path. This is
// the single source of truth for the login-branch guards, the change-password guard, and /me
// is_directory. NOTE: "dev" stays local-eligible so the demo (director@ is provider='dev', logs
// in via /auth/login with password==email) is unchanged.
func IsDirectoryManaged(provider string) bool {
    switch provider {
    case "ldap", "scim", "oidc":
        return true
    default:
        return false
    }
}
  • [ ] Step 2 — Repository ports. Edit go/internal/auth/app/ports.go. Add to the Repository interface (near UpsertUserByIdentity):
    // UserByIdentity returns the user linked to (idp, subject). found=false (nil error, zero
    // User) when there is no such identity. Used by SCIM to resolve a provisioned user by its
    // externalId (idp='scim').
    UserByIdentity(ctx context.Context, idp, subject string) (domain.User, bool, error)
    // LinkOIDCIdentityByEmail attaches an (idp='oidc', subject) identity to an EXISTING
    // provider='scim' account matching email and returns it (linked=true). linked=false (nil
    // error) when there is no such linkable account — the caller then provisions normally. Only
    // 'scim' accounts are linked (never local/dev/ldap/oidc); idempotent via user_identities'
    // UNIQUE (idp, subject).
    LinkOIDCIdentityByEmail(ctx context.Context, subject, email string) (domain.User, bool, error)
  • [ ] Step 3 — Repository impls. Edit go/internal/auth/adapters/pg.go. Append:
// UserByIdentity returns the user linked to (idp, subject), or found=false when absent.
func (s *Store) UserByIdentity(ctx context.Context, idp, subject string) (domain.User, bool, error) {
    var u domain.User
    err := s.db.Exec(ctx).QueryRow(ctx,
        `SELECT `+userColumnsAliased("u")+`
           FROM user_identities i JOIN users u ON u.id = i.user_id
          WHERE i.idp = $1 AND i.subject = $2`, idp, subject).
        Scan(scanUser(&u)...)
    if errors.Is(err, pgx.ErrNoRows) {
        return domain.User{}, false, nil
    }
    if err != nil {
        return domain.User{}, false, fmt.Errorf("auth user by identity: %w", err)
    }
    return u, true, nil
}

// LinkOIDCIdentityByEmail attaches an (idp='oidc', subject) identity to an existing
// provider='scim' user matching email (oldest wins) and returns that user; linked=false when
// there is no linkable 'scim' account. The INSERT is idempotent (ON CONFLICT (idp, subject) DO
// NOTHING) so a repeat OIDC login lands on the same account. Empty email never links.
func (s *Store) LinkOIDCIdentityByEmail(ctx context.Context, subject, email string) (domain.User, bool, error) {
    if email == "" {
        return domain.User{}, false, nil
    }
    var u domain.User
    err := s.db.Do(ctx, func(ctx context.Context) error {
        ex := s.db.Exec(ctx)
        if serr := ex.QueryRow(ctx,
            `SELECT `+userColumns+` FROM users WHERE email = $1 AND provider = 'scim' ORDER BY created_at LIMIT 1`, email).
            Scan(scanUser(&u)...); serr != nil {
            return serr // pgx.ErrNoRows handled by the caller of Do via the returned err
        }
        if _, ierr := ex.Exec(ctx,
            `INSERT INTO user_identities (id, user_id, idp, subject) VALUES ($1, $2, 'oidc', $3)
             ON CONFLICT (idp, subject) DO NOTHING`, kernel.NewID(), u.ID, subject); ierr != nil {
            return fmt.Errorf("auth link oidc identity: %w", ierr)
        }
        return nil
    })
    if errors.Is(err, pgx.ErrNoRows) {
        return domain.User{}, false, nil
    }
    if err != nil {
        return domain.User{}, false, err
    }
    return u, true, nil
}

(kernel and pgx are already imported in pg.go. s.db.Do returns the callback's error verbatim, so the pgx.ErrNoRows from the SELECT propagates and is mapped to linked=false — verify by reading platform/db's Do; if Do wraps the error, switch the SELECT to a pre-check UserByEmail-style lookup outside the tx that returns found=false, then a second tx to insert. The simplest robust form is the two-step: look up the scim user first (no tx), return linked=false if absent, else insert the identity.)

Robust two-step alternative (use this if s.db.Do wraps errors so errors.Is(err, pgx.ErrNoRows) fails):

func (s *Store) LinkOIDCIdentityByEmail(ctx context.Context, subject, email string) (domain.User, bool, error) {
    if email == "" {
        return domain.User{}, false, nil
    }
    var u domain.User
    err := s.db.Exec(ctx).QueryRow(ctx,
        `SELECT `+userColumns+` FROM users WHERE email = $1 AND provider = 'scim' ORDER BY created_at LIMIT 1`, email).
        Scan(scanUser(&u)...)
    if errors.Is(err, pgx.ErrNoRows) {
        return domain.User{}, false, nil
    }
    if err != nil {
        return domain.User{}, false, fmt.Errorf("auth link lookup: %w", err)
    }
    if _, ierr := s.db.Exec(ctx).Exec(ctx,
        `INSERT INTO user_identities (id, user_id, idp, subject) VALUES ($1, $2, 'oidc', $3)
         ON CONFLICT (idp, subject) DO NOTHING`, kernel.NewID(), u.ID, subject); ierr != nil {
        return domain.User{}, false, fmt.Errorf("auth link oidc identity: %w", ierr)
    }
    return u, true, nil
}

Prefer the two-step form — it avoids depending on Do's error-wrapping behavior. Implement THAT one.

  • [ ] Step 4 — Service: widen guards + OIDC link + UserByIdentity. Edit go/internal/auth/app/service.go:

4a. VerifyPassword — the demo-convention backstop (~line 120) changes from ldap-only to any directory-managed provider:

    // No password set: accept the demo convention (password == email) — but NEVER for a
    // directory-managed account (ldap/scim/oidc). Those have an empty hash by design; letting the
    // demo convention apply would allow local impersonation of an externally-owned identity. This
    // is a source-level backstop; LoginWithDirectory already routes such users away from here.
    if domain.IsDirectoryManaged(user.Provider) {
        return domain.User{}, invalid
    }

4b. LoginWithDirectory — both guards (~line 153 and ~line 159) switch from == "ldap" / != "ldap" to the predicate:

    // A directory-managed account (ldap/scim/oidc) has NO local password: exclude it from the
    // local verify path UNCONDITIONALLY — even when the directory is disabled (s.dir == nil).
    // VerifyPassword's demo convention would otherwise let an attacker impersonate a hash-less
    // externally-provisioned user. A stranded such user simply cannot log in via this endpoint
    // (scim/oidc users authenticate via OIDC; a stranded ldap user waits for the directory).
    if known && domain.IsDirectoryManaged(existing.Provider) && s.dir == nil {
        return domain.User{}, invalid
    }

    // Local path: a known non-directory user, OR the directory disabled entirely (guaranteed
    // non-directory by the guard above). A failed local verify NEVER falls through to LDAP.
    if (known && !domain.IsDirectoryManaged(existing.Provider)) || s.dir == nil {
        // ... existing LDAP_MODE=only break-glass block UNCHANGED ...
        return s.VerifyPassword(ctx, email, password)
    }

(The directory path below — s.dir.Authenticate → JIT UpsertUserByIdentity(ctx, "ldap", …) — is UNCHANGED. A provider='scim'/'oidc' user reaching the directory path when LDAP IS enabled will simply fail the LDAP bind (they are not in the directory) → generic invalid. Correct: SCIM/OIDC users never authenticate here.)

4c. UpdatePassword change-password guard (~line 349) — widen + generalize the message:

    // A directory-managed account (ldap/scim/oidc) has no local password — its credentials live
    // with the identity provider — so change-password is rejected with an actionable code.
    if domain.IsDirectoryManaged(user.Provider) {
        return &kernel.Error{Kind: kernel.ErrValidation, Code: "auth.password.managed_externally", Message: "your password is managed by your organization's identity provider — change it there"}
    }

(Renaming the code from auth.ldap.managed_by_directory to auth.password.managed_externally is fine — it is a backstop the UI already prevents; nothing keys off the old code except the operator-facing message. If you prefer zero code-string churn, keep the old code but still generalize the message.)

4d. ProvisionFromOIDC — link to a SCIM account by email before creating a new oidc user. Replace the final return s.repo.UpsertUserByIdentity(ctx, "oidc", claims.Subject, claims.Email, claims.Name) (~line 76) with:

    // Prefer LINKING to an existing SCIM-provisioned account with the same email, so an OIDC
    // first-login lands on the IdP-provisioned identity instead of creating a duplicate 'oidc'
    // user. Only provider='scim' accounts are linked (see LinkOIDCIdentityByEmail).
    if u, linked, lerr := s.repo.LinkOIDCIdentityByEmail(ctx, claims.Subject, claims.Email); lerr != nil {
        return domain.User{}, lerr
    } else if linked {
        return u, nil
    }
    return s.repo.UpsertUserByIdentity(ctx, "oidc", claims.Subject, claims.Email, claims.Name)

(If (idp='oidc', claims.Subject) already exists — a returning OIDC user — UpsertUserByIdentity's SELECT-first would normally return it, but we now call LinkOIDCIdentityByEmail first. For a SCIM-linked user, the link INSERT is ON CONFLICT DO NOTHING and returns the same scim user, so repeats are idempotent. For a pure-OIDC user (no scim account with that email), linked=false and the existing UpsertUserByIdentity path returns/creates the oidc user exactly as before. Verify no behavior change for the OIDC-only case in T8-adjacent manual reasoning.)

4e. Add the UserByIdentity service method (used by the SCIM UserPort adapter in T4). Add near GetUser (~line 229):

// UserByIdentity returns the user linked to (idp, subject) and whether one exists. Used by SCIM
// to resolve a provisioned account by its externalId (idp='scim').
func (s *Service) UserByIdentity(ctx context.Context, idp, subject string) (domain.User, bool, error) {
    return s.repo.UserByIdentity(ctx, idp, subject)
}
  • [ ] Step 5 — /me is_directory via the predicate. Edit go/internal/httpapi/handlers_auth.go. Add the import authdomain "github.com/Virtue-Digital-Indonesia/obscura/internal/auth/domain" and change the /me field (~line 211) from "is_directory": provider == "ldap" to:
        "is_directory": authdomain.IsDirectoryManaged(provider),

(So the profile UI hides change-password for scim/oidc/ldap users alike — all lack a local password.)

  • [ ] Step 6 — Verify + commit. cd go && go build ./... && go vet ./... (interface + impl land together, so the Store-satisfies-Repository check passes). Commit:
git add go/internal/auth/domain/auth.go go/internal/auth/app/ports.go go/internal/auth/adapters/pg.go go/internal/auth/app/service.go go/internal/httpapi/handlers_auth.go
git commit -m "feat(auth): IsDirectoryManaged guard (ldap/scim/oidc off local login) + OIDC-links-to-SCIM-by-email"

Task 4: SCIM protocol HTTP layer (/scim/v2, token-authed, NOT in OpenAPI) + mount + wiring

Files: go/internal/httpapi/handlers_scim.go (new), go/internal/httpapi/server.go, go/cmd/obscura-server/wire.go.

NOT in OpenAPI. SCIM is its own RFC spec — do not add /scim/v2 to api/openapi.yaml and do not run gen:api for it. Responses are application/scim+json with RFC 7644 error envelopes (not problem+json). The route group mounts as a sibling of /api/v1, guarded by requireSCIMToken — never Authenticator.

  • [ ] Step 1 — Handlers. Create go/internal/httpapi/handlers_scim.go:
package httpapi

import (
    "encoding/json"
    "errors"
    "net/http"
    "regexp"
    "strconv"
    "strings"

    "github.com/go-chi/chi/v5"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
    scimapp "github.com/Virtue-Digital-Indonesia/obscura/internal/scim/app"
)

// SCIM 2.0 (RFC 7643/7644) constants.
const (
    scimMediaType          = "application/scim+json"
    scimSchemaUser         = "urn:ietf:params:scim:schemas:core:2.0:User"
    scimSchemaGroup        = "urn:ietf:params:scim:schemas:core:2.0:Group"
    scimSchemaSPConfig     = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
    scimSchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
    scimSchemaError        = "urn:ietf:params:scim:api:messages:2.0:Error"
)

// scimFilterRe matches `<attr> eq "<value>"` (the only supported filter shape); the eq operator
// is case-insensitive per RFC 7644 §3.4.2.2.
var scimFilterRe = regexp.MustCompile(`^([A-Za-z]+)\s+(?i:eq)\s+"(.*)"$`)

// scimMemberFilterRe extracts X from a `members[value eq "X"]` PATCH path (Okta's remove shape).
var scimMemberFilterRe = regexp.MustCompile(`(?i)^members\[\s*value\s+eq\s+"(.*)"\s*\]$`)

// ---------------------------------------------------------------------------
// writers + auth middleware
// ---------------------------------------------------------------------------

func scimWrite(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", scimMediaType)
    w.WriteHeader(status)
    if v != nil {
        _ = json.NewEncoder(w).Encode(v)
    }
}

// scimErrEnvelope writes an RFC 7644 §3.12 error (status is a STRING; scimType omitted when "").
func scimErrEnvelope(w http.ResponseWriter, status int, scimType, detail string) {
    body := map[string]any{
        "schemas": []string{scimSchemaError},
        "status":  strconv.Itoa(status),
        "detail":  detail,
    }
    if scimType != "" {
        body["scimType"] = scimType
    }
    w.Header().Set("Content-Type", scimMediaType)
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(body)
}

// scimWriteErr maps a kernel.Error (from the scim Service) onto a SCIM error envelope.
func scimWriteErr(w http.ResponseWriter, err error) {
    var ke *kernel.Error
    if errors.As(err, &ke) {
        switch ke.Kind {
        case kernel.ErrNotFound:
            scimErrEnvelope(w, http.StatusNotFound, "", ke.Message)
            return
        case kernel.ErrConflict:
            scimErrEnvelope(w, http.StatusConflict, "uniqueness", ke.Message)
            return
        case kernel.ErrValidation:
            st := "invalidValue"
            if ke.Code == "scim.filter.unsupported" {
                st = "invalidFilter"
            }
            scimErrEnvelope(w, http.StatusBadRequest, st, ke.Message)
            return
        }
    }
    scimErrEnvelope(w, http.StatusInternalServerError, "", "internal error")
}

// requireSCIMToken authenticates a /scim/v2 request by the admin-minted bearer token (SHA-256,
// constant-time compare). NOT the session Authenticator: no cookie, no Principal, no positions.
func (s *Server) requireSCIMToken(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tok := scimBearer(r)
        if tok == "" {
            scimErrEnvelope(w, http.StatusUnauthorized, "", "missing bearer token")
            return
        }
        ok, err := s.scim.VerifyToken(r.Context(), tok)
        if err != nil {
            scimErrEnvelope(w, http.StatusInternalServerError, "", "token verification failed")
            return
        }
        if !ok {
            scimErrEnvelope(w, http.StatusUnauthorized, "", "invalid or revoked token")
            return
        }
        next.ServeHTTP(w, r)
    })
}

func scimBearer(r *http.Request) string {
    if rest, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
        return strings.TrimSpace(rest)
    }
    return ""
}

func scimBase(r *http.Request) string {
    proto := r.Header.Get("X-Forwarded-Proto")
    if proto == "" {
        if r.TLS != nil {
            proto = "https"
        } else {
            proto = "http"
        }
    }
    return proto + "://" + r.Host + "/scim/v2"
}

func scimListResponse(resources []any, total, startIndex int) map[string]any {
    if resources == nil {
        resources = []any{}
    }
    return map[string]any{
        "schemas":      []string{scimSchemaListResponse},
        "totalResults": total,
        "startIndex":   startIndex,
        "itemsPerPage": len(resources),
        "Resources":    resources,
    }
}

func atoiDefault(s string, def int) int {
    if s == "" {
        return def
    }
    if n, err := strconv.Atoi(s); err == nil {
        return n
    }
    return def
}

// parseSCIMFilter parses `<attr> eq "<value>"` restricted to allowed attrs (case-insensitive);
// ok=false for any other shape (caller -> 400 invalidFilter). Returns the CANONICAL attr name.
func parseSCIMFilter(raw string, allowed ...string) (attr, value string, ok bool) {
    m := scimFilterRe.FindStringSubmatch(strings.TrimSpace(raw))
    if m == nil {
        return "", "", false
    }
    got := m[1]
    for _, a := range allowed {
        if strings.EqualFold(a, got) {
            v := strings.ReplaceAll(m[2], `\"`, `"`)
            v = strings.ReplaceAll(v, `\\`, `\`)
            return a, v, true
        }
    }
    return "", "", false
}

// ---------------------------------------------------------------------------
// discovery (IdPs probe these on connect)
// ---------------------------------------------------------------------------

func (s *Server) SCIMServiceProviderConfig(w http.ResponseWriter, r *http.Request) {
    scimWrite(w, http.StatusOK, map[string]any{
        "schemas":        []string{scimSchemaSPConfig},
        "patch":          map[string]any{"supported": true},
        "bulk":           map[string]any{"supported": false, "maxOperations": 0, "maxPayloadSize": 0},
        "filter":         map[string]any{"supported": true, "maxResults": 200},
        "changePassword": map[string]any{"supported": false},
        "sort":           map[string]any{"supported": false},
        "etag":           map[string]any{"supported": false},
        "authenticationSchemes": []any{map[string]any{
            "type": "oauthbearertoken", "name": "OAuth Bearer Token",
            "description": "Authentication via the admin-minted SCIM bearer token", "primary": true,
        }},
        "meta": map[string]any{"resourceType": "ServiceProviderConfig", "location": scimBase(r) + "/ServiceProviderConfig"},
    })
}

func (s *Server) SCIMResourceTypes(w http.ResponseWriter, r *http.Request) {
    base := scimBase(r)
    types := []any{
        map[string]any{
            "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"},
            "id": "User", "name": "User", "endpoint": "/Users", "schema": scimSchemaUser,
            "meta": map[string]any{"resourceType": "ResourceType", "location": base + "/ResourceTypes/User"},
        },
        map[string]any{
            "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"},
            "id": "Group", "name": "Group", "endpoint": "/Groups", "schema": scimSchemaGroup,
            "meta": map[string]any{"resourceType": "ResourceType", "location": base + "/ResourceTypes/Group"},
        },
    }
    scimWrite(w, http.StatusOK, scimListResponse(types, len(types), 1))
}

func (s *Server) SCIMSchemas(w http.ResponseWriter, r *http.Request) {
    attr := func(name, typ string, mv bool) map[string]any {
        return map[string]any{"name": name, "type": typ, "multiValued": mv, "required": false,
            "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}
    }
    schemas := []any{
        map[string]any{"id": scimSchemaUser, "name": "User", "attributes": []any{
            attr("userName", "string", false), attr("displayName", "string", false),
            attr("externalId", "string", false), attr("active", "boolean", false),
            attr("emails", "complex", true), attr("name", "complex", false),
        }},
        map[string]any{"id": scimSchemaGroup, "name": "Group", "attributes": []any{
            attr("displayName", "string", false), attr("externalId", "string", false),
            attr("members", "complex", true),
        }},
    }
    scimWrite(w, http.StatusOK, scimListResponse(schemas, len(schemas), 1))
}

// ---------------------------------------------------------------------------
// Users
// ---------------------------------------------------------------------------

type scimUserBody struct {
    ExternalID  string `json:"externalId"`
    UserName    string `json:"userName"`
    DisplayName string `json:"displayName"`
    Name        struct {
        Formatted string `json:"formatted"`
    } `json:"name"`
    Emails []struct {
        Value   string `json:"value"`
        Primary bool   `json:"primary"`
        Type    string `json:"type"`
    } `json:"emails"`
    Active *bool `json:"active"`
}

func (b scimUserBody) toInput() scimapp.UserInput {
    email := ""
    for _, e := range b.Emails {
        if e.Primary && e.Value != "" {
            email = e.Value
            break
        }
    }
    if email == "" && len(b.Emails) > 0 {
        email = b.Emails[0].Value
    }
    dn := b.DisplayName
    if dn == "" {
        dn = b.Name.Formatted
    }
    active := true
    if b.Active != nil {
        active = *b.Active
    }
    return scimapp.UserInput{ExternalID: b.ExternalID, UserName: b.UserName, Email: email, DisplayName: dn, Active: active}
}

func toSCIMUser(u scimapp.UserResource, base string) map[string]any {
    groups := make([]any, 0, len(u.Groups))
    for _, g := range u.Groups {
        groups = append(groups, map[string]any{"value": g.ID, "display": g.Display})
    }
    out := map[string]any{
        "schemas":     []string{scimSchemaUser},
        "id":          u.ID,
        "userName":    u.UserName,
        "displayName": u.DisplayName,
        "name":        map[string]any{"formatted": u.DisplayName},
        "emails":      []any{map[string]any{"value": u.Email, "primary": true}},
        "active":      u.Active,
        "groups":      groups,
        "meta":        map[string]any{"resourceType": "User", "location": base + "/Users/" + u.ID},
    }
    if u.ExternalID != "" {
        out["externalId"] = u.ExternalID
    }
    return out
}

func (s *Server) ListSCIMUsers(w http.ResponseWriter, r *http.Request) {
    q := r.URL.Query()
    startIndex := atoiDefault(q.Get("startIndex"), 1)
    if startIndex < 1 {
        startIndex = 1
    }
    count := atoiDefault(q.Get("count"), 100)
    if count < 0 {
        count = 0
    }
    if count > 200 {
        count = 200
    }
    attr, val := "", ""
    if f := strings.TrimSpace(q.Get("filter")); f != "" {
        a, v, ok := parseSCIMFilter(f, "userName", "externalId")
        if !ok {
            scimErrEnvelope(w, http.StatusBadRequest, "invalidFilter", "only `userName eq` / `externalId eq` are supported")
            return
        }
        attr, val = a, v
    }
    res, total, err := s.scim.ListUsers(r.Context(), attr, val, startIndex, count)
    if err != nil {
        scimWriteErr(w, err)
        return
    }
    base := scimBase(r)
    items := make([]any, 0, len(res))
    for _, u := range res {
        items = append(items, toSCIMUser(u, base))
    }
    scimWrite(w, http.StatusOK, scimListResponse(items, total, startIndex))
}

func (s *Server) CreateSCIMUser(w http.ResponseWriter, r *http.Request) {
    var b scimUserBody
    if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
        scimErrEnvelope(w, http.StatusBadRequest, "invalidValue", "invalid request body")
        return
    }
    res, err := s.scim.CreateUser(r.Context(), b.toInput())
    if err != nil {
        scimWriteErr(w, err)
        return
    }
    base := scimBase(r)
    w.Header().Set("Location", base+"/Users/"+res.ID)
    scimWrite(w, http.StatusCreated, toSCIMUser(res, base))
}

func (s *Server) GetSCIMUser(w http.ResponseWriter, r *http.Request) {
    res, err := s.scim.GetUserResource(r.Context(), chi.URLParam(r, "id"))
    if err != nil {
        scimWriteErr(w, err)
        return
    }
    scimWrite(w, http.StatusOK, toSCIMUser(res, scimBase(r)))
}

func (s *Server) PutSCIMUser(w http.ResponseWriter, r *http.Request) {
    var b scimUserBody
    if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
        scimErrEnvelope(w, http.StatusBadRequest, "invalidValue", "invalid request body")
        return
    }
    res, err := s.scim.ReplaceUser(r.Context(), chi.URLParam(r, "id"), b.toInput())
    if err != nil {
        scimWriteErr(w, err)
        return
    }
    scimWrite(w, http.StatusOK, toSCIMUser(res, scimBase(r)))
}

func (s *Server) PatchSCIMUser(w http.ResponseWriter, r *http.Request) {
    ops, err := decodeSCIMPatch(r)
    if err != nil {
        scimErrEnvelope(w, http.StatusBadRequest, "invalidValue", "invalid PATCH body")
        return
    }
    res, perr := s.scim.PatchUser(r.Context(), chi.URLParam(r, "id"), parseUserPatch(ops))
    if perr != nil {
        scimWriteErr(w, perr)
        return
    }
    scimWrite(w, http.StatusOK, toSCIMUser(res, scimBase(r)))
}

func (s *Server) DeleteSCIMUser(w http.ResponseWriter, r *http.Request) {
    if err := s.scim.DeleteUser(r.Context(), chi.URLParam(r, "id")); err != nil {
        scimWriteErr(w, err)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

// ---------------------------------------------------------------------------
// Groups
// ---------------------------------------------------------------------------

type scimGroupBody struct {
    ExternalID  string `json:"externalId"`
    DisplayName string `json:"displayName"`
    Members     []struct {
        Value string `json:"value"`
    } `json:"members"`
}

func (b scimGroupBody) toInput() scimapp.GroupInput {
    ids := make([]string, 0, len(b.Members))
    for _, m := range b.Members {
        if m.Value != "" {
            ids = append(ids, m.Value)
        }
    }
    return scimapp.GroupInput{ExternalID: b.ExternalID, DisplayName: b.DisplayName, MemberUserIDs: ids}
}

func toSCIMGroup(g scimapp.GroupResource, base string) map[string]any {
    members := make([]any, 0, len(g.Members))
    for _, m := range g.Members {
        members = append(members, map[string]any{"value": m.UserID, "display": m.Display})
    }
    out := map[string]any{
        "schemas":     []string{scimSchemaGroup},
        "id":          g.ID,
        "displayName": g.DisplayName,
        "members":     members,
        "meta":        map[string]any{"resourceType": "Group", "location": base + "/Groups/" + g.ID},
    }
    if g.ExternalID != "" {
        out["externalId"] = g.ExternalID
    }
    return out
}

func (s *Server) ListSCIMGroups(w http.ResponseWriter, r *http.Request) {
    q := r.URL.Query()
    startIndex := atoiDefault(q.Get("startIndex"), 1)
    if startIndex < 1 {
        startIndex = 1
    }
    count := atoiDefault(q.Get("count"), 100)
    if count < 0 {
        count = 0
    }
    if count > 200 {
        count = 200
    }
    attr, val := "", ""
    if f := strings.TrimSpace(q.Get("filter")); f != "" {
        a, v, ok := parseSCIMFilter(f, "displayName")
        if !ok {
            scimErrEnvelope(w, http.StatusBadRequest, "invalidFilter", "only `displayName eq` is supported")
            return
        }
        attr, val = a, v
    }
    res, total, err := s.scim.ListGroups(r.Context(), attr, val, startIndex, count)
    if err != nil {
        scimWriteErr(w, err)
        return
    }
    base := scimBase(r)
    items := make([]any, 0, len(res))
    for _, g := range res {
        items = append(items, toSCIMGroup(g, base))
    }
    scimWrite(w, http.StatusOK, scimListResponse(items, total, startIndex))
}

func (s *Server) CreateSCIMGroup(w http.ResponseWriter, r *http.Request) {
    var b scimGroupBody
    if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
        scimErrEnvelope(w, http.StatusBadRequest, "invalidValue", "invalid request body")
        return
    }
    res, err := s.scim.CreateGroup(r.Context(), b.toInput())
    if err != nil {
        scimWriteErr(w, err)
        return
    }
    base := scimBase(r)
    w.Header().Set("Location", base+"/Groups/"+res.ID)
    scimWrite(w, http.StatusCreated, toSCIMGroup(res, base))
}

func (s *Server) GetSCIMGroup(w http.ResponseWriter, r *http.Request) {
    res, err := s.scim.GetGroupResource(r.Context(), chi.URLParam(r, "id"))
    if err != nil {
        scimWriteErr(w, err)
        return
    }
    scimWrite(w, http.StatusOK, toSCIMGroup(res, scimBase(r)))
}

func (s *Server) PutSCIMGroup(w http.ResponseWriter, r *http.Request) {
    var b scimGroupBody
    if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
        scimErrEnvelope(w, http.StatusBadRequest, "invalidValue", "invalid request body")
        return
    }
    res, err := s.scim.ReplaceGroup(r.Context(), chi.URLParam(r, "id"), b.toInput())
    if err != nil {
        scimWriteErr(w, err)
        return
    }
    scimWrite(w, http.StatusOK, toSCIMGroup(res, scimBase(r)))
}

func (s *Server) PatchSCIMGroup(w http.ResponseWriter, r *http.Request) {
    ops, err := decodeSCIMPatch(r)
    if err != nil {
        scimErrEnvelope(w, http.StatusBadRequest, "invalidValue", "invalid PATCH body")
        return
    }
    res, perr := s.scim.PatchGroup(r.Context(), chi.URLParam(r, "id"), parseGroupPatch(ops))
    if perr != nil {
        scimWriteErr(w, perr)
        return
    }
    scimWrite(w, http.StatusOK, toSCIMGroup(res, scimBase(r)))
}

func (s *Server) DeleteSCIMGroup(w http.ResponseWriter, r *http.Request) {
    if err := s.scim.DeleteGroup(r.Context(), chi.URLParam(r, "id")); err != nil {
        scimWriteErr(w, err)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

// ---------------------------------------------------------------------------
// PATCH parsing (Okta + Entra shapes)
// ---------------------------------------------------------------------------

type scimOp struct {
    Op    string          `json:"op"`
    Path  string          `json:"path"`
    Value json.RawMessage `json:"value"`
}

func decodeSCIMPatch(r *http.Request) ([]scimOp, error) {
    var body struct {
        Operations []scimOp `json:"Operations"`
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        return nil, err
    }
    return body.Operations, nil
}

func jsonString(raw json.RawMessage) (string, bool) {
    var s string
    if err := json.Unmarshal(raw, &s); err == nil {
        return s, true
    }
    return "", false
}

func parseSCIMBool(raw json.RawMessage) (bool, bool) {
    var b bool
    if err := json.Unmarshal(raw, &b); err == nil {
        return b, true
    }
    if s, ok := jsonString(raw); ok {
        switch strings.ToLower(strings.TrimSpace(s)) {
        case "true":
            return true, true
        case "false":
            return false, true
        }
    }
    return false, false
}

// memberValues extracts the user ids from a members value array [{"value":"id"}, ...].
func memberValues(raw json.RawMessage) []string {
    var arr []struct {
        Value string `json:"value"`
    }
    if err := json.Unmarshal(raw, &arr); err != nil {
        return nil
    }
    out := make([]string, 0, len(arr))
    for _, m := range arr {
        if m.Value != "" {
            out = append(out, m.Value)
        }
    }
    return out
}

func parseUserPatch(ops []scimOp) scimapp.UserPatch {
    var p scimapp.UserPatch
    apply := func(key string, raw json.RawMessage) {
        switch strings.ToLower(key) {
        case "active":
            if b, ok := parseSCIMBool(raw); ok {
                p.Active = &b
            }
        case "displayname", "name.formatted":
            if s, ok := jsonString(raw); ok {
                p.DisplayName = &s
            }
        case "username":
            if s, ok := jsonString(raw); ok {
                p.Email = &s
            }
        }
    }
    for _, op := range ops {
        path := strings.TrimSpace(op.Path)
        if path == "" {
            var obj map[string]json.RawMessage
            if json.Unmarshal(op.Value, &obj) == nil {
                for k, v := range obj {
                    apply(k, v)
                }
            }
            continue
        }
        apply(path, op.Value)
    }
    return p
}

func parseGroupPatch(ops []scimOp) scimapp.GroupPatch {
    var p scimapp.GroupPatch
    for _, op := range ops {
        o := strings.ToLower(strings.TrimSpace(op.Op))
        path := strings.TrimSpace(op.Path)
        lp := strings.ToLower(path)
        switch {
        case lp == "displayname":
            if s, ok := jsonString(op.Value); ok {
                p.DisplayName = &s
            }
        case strings.HasPrefix(lp, "members"):
            if m := scimMemberFilterRe.FindStringSubmatch(path); m != nil {
                p.RemoveMembers = append(p.RemoveMembers, m[1])
                continue
            }
            ids := memberValues(op.Value)
            switch o {
            case "remove":
                if len(ids) == 0 {
                    empty := []string{}
                    p.ReplaceMembers = &empty // remove with no target => clear all
                } else {
                    p.RemoveMembers = append(p.RemoveMembers, ids...)
                }
            case "add":
                p.AddMembers = append(p.AddMembers, ids...)
            default: // replace
                cp := append([]string{}, ids...)
                p.ReplaceMembers = &cp
            }
        case path == "":
            var obj map[string]json.RawMessage
            if json.Unmarshal(op.Value, &obj) == nil {
                if raw, ok := obj["displayName"]; ok {
                    if s, ok2 := jsonString(raw); ok2 {
                        p.DisplayName = &s
                    }
                }
                if raw, ok := obj["members"]; ok {
                    ids := memberValues(raw)
                    p.ReplaceMembers = &ids
                }
            }
        }
    }
    return p
}
  • [ ] Step 2 — Server wiring + mount. Edit go/internal/httpapi/server.go:
  • Add the import scimapp "github.com/Virtue-Digital-Indonesia/obscura/internal/scim/app" (with the other *app imports).
  • In Deps (near LDAPGroupRoles *authapp.GroupRoleService, ~line 81) add: SCIM *scimapp.Service.
  • In the server struct (near ldapGroupRoles *authapp.GroupRoleService, ~line 118) add: scim *scimapp.Service.
  • In the constructor mapping (near ldapGroupRoles: d.LDAPGroupRoles,, ~line 167) add: scim: d.SCIM,.
  • Mount the group between the /api/v1 r.Route close (~line 745) and return r (~line 747):
        // SCIM 2.0 inbound provisioning (RFC 7643/7644). Authenticated by the admin-minted bearer
        // token (NOT a user session), so it mounts OUTSIDE the /api/v1 session group. Disabled by
        // default: with no minted token every request is 401 (requireSCIMToken). Responses are
        // application/scim+json with RFC error envelopes — deliberately NOT part of our OpenAPI.
        r.Route("/scim/v2", func(r chi.Router) {
            r.Use(s.requireSCIMToken)
            r.Get("/ServiceProviderConfig", s.SCIMServiceProviderConfig)
            r.Get("/ResourceTypes", s.SCIMResourceTypes)
            r.Get("/Schemas", s.SCIMSchemas)
            r.Get("/Users", s.ListSCIMUsers)
            r.Post("/Users", s.CreateSCIMUser)
            r.Get("/Users/{id}", s.GetSCIMUser)
            r.Put("/Users/{id}", s.PutSCIMUser)
            r.Patch("/Users/{id}", s.PatchSCIMUser)
            r.Delete("/Users/{id}", s.DeleteSCIMUser)
            r.Get("/Groups", s.ListSCIMGroups)
            r.Post("/Groups", s.CreateSCIMGroup)
            r.Get("/Groups/{id}", s.GetSCIMGroup)
            r.Put("/Groups/{id}", s.PutSCIMGroup)
            r.Patch("/Groups/{id}", s.PatchSCIMGroup)
            r.Delete("/Groups/{id}", s.DeleteSCIMGroup)
        })
  • [ ] Step 3 — Wire the scim service + UserPort adapter. Edit go/cmd/obscura-server/wire.go:
  • Add imports scimadapters "github.com/Virtue-Digital-Indonesia/obscura/internal/scim/adapters" and scimapp "github.com/Virtue-Digital-Indonesia/obscura/internal/scim/app".
  • Build the service right after the ldap wiring (~line 249, where rbacStore + authSvc + logger exist):
    scimSvc := scimapp.NewService(scimadapters.NewStore(database), scimUserPort{auth: authSvc}, rbacStore, logger)
  • Add the UserPort adapter near ldapAdminChecker (~line 751):
// scimUserPort adapts *authapp.Service to scimapp.UserPort so SCIM provisioning reuses the
// audited auth paths (JIT create with an scim identity, disable, session-revoke, profile update).
type scimUserPort struct{ auth *authapp.Service }

func (p scimUserPort) Provision(ctx context.Context, externalID, email, name string) (string, error) {
    u, err := p.auth.ProvisionLocal(ctx, "scim", externalID, email, name)
    if err != nil {
        return "", err
    }
    return u.ID, nil
}
func (p scimUserPort) SetDisabled(ctx context.Context, userID string, disabled bool) error {
    return p.auth.SetUserDisabled(ctx, userID, disabled)
}
func (p scimUserPort) RevokeSessions(ctx context.Context, userID string) error {
    return p.auth.LogoutAll(ctx, userID)
}
func (p scimUserPort) UpdateProfile(ctx context.Context, userID string, displayName, email *string) error {
    return p.auth.UpdateUserProfile(ctx, userID, displayName, email, nil)
}

var _ scimapp.UserPort = scimUserPort{}
  • In the httpapi Deps{…} literal (near LDAPGroupRoles: ldapGroupRoleSvc,, ~line 431) add: SCIM: scimSvc,.
    (context is already imported in wire.go; rbacStore (*rbacadapters.Store) satisfies scimapp.RoleBinder directly — the same store already backs the LDAP reconciler.)

  • [ ] Step 4 — Verify + commit. cd go && go build ./... && go vet ./.... Commit:

git add go/internal/httpapi/handlers_scim.go go/internal/httpapi/server.go go/cmd/obscura-server/wire.go
git commit -m "feat(scim): /scim/v2 protocol layer (token-authed) — discovery, Users, Groups, filter+PATCH parser"

Task 5: Admin API — token/status/mapping (rbac.admin, session-authed) + OpenAPI + gen:api

Files: go/internal/httpapi/handlers_scim_admin.go (new), go/internal/httpapi/server.go, api/openapi.yaml, web/src/api/schema.ts (regenerated).

These ARE session-authed (unlike /scim/v2), so they DO go in OpenAPI and use the app's writeJSON/writeProblem (problem+json). Interface: POST /api/v1/admin/scim/token{token} (shown once); DELETE /admin/scim/token (204); GET /admin/scim/status{enabled, base_url, last_used_at, provisioned_user_count}; GET /admin/scim/groups{groups:[{id, display_name}]}; GET /admin/scim/group-roles{group_roles:[{scim_group_id, role_id}]}; PUT body {scim_group_id, role_id} (200 echo); DELETE ?scim_group_id= (204).

  • [ ] Step 1 — Handlers. Create go/internal/httpapi/handlers_scim_admin.go:
package httpapi

import (
    "encoding/json"
    "net/http"
    "time"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
)

// MintSCIMToken generates a new SCIM bearer token, revoking any prior active token, and returns
// it ONCE (only the sha256 hash is stored). rbac.admin.
func (s *Server) MintSCIMToken(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    token, err := s.scim.MintToken(r.Context(), string(p.UserID))
    if err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusOK, map[string]any{"token": token})
}

// RevokeSCIMToken revokes every active SCIM token (disables SCIM). rbac.admin.
func (s *Server) RevokeSCIMToken(w http.ResponseWriter, r *http.Request) {
    if err := s.scim.RevokeTokens(r.Context()); err != nil {
        writeProblem(w, err)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

type scimStatusView struct {
    Enabled              bool    `json:"enabled"`
    BaseURL              string  `json:"base_url"`
    LastUsedAt           *string `json:"last_used_at"`
    ProvisionedUserCount int     `json:"provisioned_user_count"`
}

// GetSCIMStatus returns the SCIM status card data. rbac.admin.
func (s *Server) GetSCIMStatus(w http.ResponseWriter, r *http.Request) {
    enabled, last, count, err := s.scim.Status(r.Context())
    if err != nil {
        writeProblem(w, err)
        return
    }
    view := scimStatusView{Enabled: enabled, BaseURL: scimBase(r), ProvisionedUserCount: count}
    if last != nil {
        iso := last.Format(time.RFC3339)
        view.LastUsedAt = &iso
    }
    writeJSON(w, http.StatusOK, view)
}

type scimGroupView struct {
    ID          string `json:"id"`
    DisplayName string `json:"display_name"`
}

// ListSCIMGroupsAdmin lists the pushed SCIM groups (for the mapping dropdown). rbac.admin.
func (s *Server) ListSCIMGroupsAdmin(w http.ResponseWriter, r *http.Request) {
    groups, _, err := s.scim.ListGroupsRaw(r.Context(), 0, 500)
    if err != nil {
        writeProblem(w, err)
        return
    }
    out := make([]scimGroupView, 0, len(groups))
    for _, g := range groups {
        out = append(out, scimGroupView{ID: g.ID, DisplayName: g.DisplayName})
    }
    writeJSON(w, http.StatusOK, map[string]any{"groups": out})
}

type scimGroupRoleView struct {
    ScimGroupID string `json:"scim_group_id"`
    RoleID      string `json:"role_id"`
}

// ListSCIMGroupRoles lists the admin-managed SCIM-group -> role mappings. rbac.admin.
func (s *Server) ListSCIMGroupRoles(w http.ResponseWriter, r *http.Request) {
    ms, err := s.scim.ListGroupRoleMappings(r.Context())
    if err != nil {
        writeProblem(w, err)
        return
    }
    out := make([]scimGroupRoleView, 0, len(ms))
    for _, m := range ms {
        out = append(out, scimGroupRoleView{ScimGroupID: m.ScimGroupID, RoleID: m.RoleID})
    }
    writeJSON(w, http.StatusOK, map[string]any{"group_roles": out})
}

// PutSCIMGroupRole upserts one SCIM-group -> role mapping (reconciles the group's members). rbac.admin.
func (s *Server) PutSCIMGroupRole(w http.ResponseWriter, r *http.Request) {
    var body struct {
        ScimGroupID string `json:"scim_group_id"`
        RoleID      string `json:"role_id"`
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "request.invalid_json", Message: "invalid request body"})
        return
    }
    if err := s.scim.SetGroupRoleMapping(r.Context(), body.ScimGroupID, body.RoleID); err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusOK, scimGroupRoleView{ScimGroupID: body.ScimGroupID, RoleID: body.RoleID})
}

// DeleteSCIMGroupRole removes the mapping selected by ?scim_group_id=. rbac.admin.
func (s *Server) DeleteSCIMGroupRole(w http.ResponseWriter, r *http.Request) {
    if err := s.scim.DeleteGroupRoleMapping(r.Context(), r.URL.Query().Get("scim_group_id")); err != nil {
        writeProblem(w, err)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}
  • [ ] Step 2 — Routes. Edit go/internal/httpapi/server.go. Register right after the LDAP admin routes (~line 636):
            // SCIM 2.0 administration (session-authed, rbac.admin): token lifecycle + status +
            // group->role mapping. The /scim/v2 provisioning surface is token-authed separately.
            r.With(s.requirePerm("rbac.admin")).Post("/admin/scim/token", s.MintSCIMToken)
            r.With(s.requirePerm("rbac.admin")).Delete("/admin/scim/token", s.RevokeSCIMToken)
            r.With(s.requirePerm("rbac.admin")).Get("/admin/scim/status", s.GetSCIMStatus)
            r.With(s.requirePerm("rbac.admin")).Get("/admin/scim/groups", s.ListSCIMGroupsAdmin)
            r.With(s.requirePerm("rbac.admin")).Get("/admin/scim/group-roles", s.ListSCIMGroupRoles)
            r.With(s.requirePerm("rbac.admin")).Put("/admin/scim/group-roles", s.PutSCIMGroupRole)
            r.With(s.requirePerm("rbac.admin")).Delete("/admin/scim/group-roles", s.DeleteSCIMGroupRole)
  • [ ] Step 3 — OpenAPI paths. Edit api/openapi.yaml. Add under paths: next to the /api/v1/admin/ldap/* block:
  /api/v1/admin/scim/token:
    post:
      operationId: mintScimToken
      summary: Mint a SCIM bearer token
      description: Generates a new SCIM provisioning token (revoking any prior active one) and returns it ONCE. Requires rbac.admin.
      tags: [admin]
      responses:
        '200':
          description: The new token (shown once).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScimToken'
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
    delete:
      operationId: revokeScimToken
      summary: Revoke the active SCIM token (disable SCIM)
      description: Revokes every active SCIM token, disabling inbound provisioning. Requires rbac.admin.
      tags: [admin]
      responses:
        '204': { description: Revoked. }
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
  /api/v1/admin/scim/status:
    get:
      operationId: getScimStatus
      summary: SCIM provisioning status
      description: Whether SCIM is enabled (an active token exists), the base URL to paste into the IdP, last-used time, and provisioned-user count. Requires rbac.admin.
      tags: [admin]
      responses:
        '200':
          description: SCIM status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScimStatus'
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
  /api/v1/admin/scim/groups:
    get:
      operationId: listScimGroups
      summary: List pushed SCIM groups
      description: The SCIM groups pushed by the IdP (id + displayName), for the group->role mapping dropdown. Requires rbac.admin.
      tags: [admin]
      responses:
        '200':
          description: The SCIM groups.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScimGroupList'
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
  /api/v1/admin/scim/group-roles:
    get:
      operationId: listScimGroupRoles
      summary: List SCIM group->role mappings
      tags: [admin]
      responses:
        '200':
          description: The mappings.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScimGroupRoleList'
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
    put:
      operationId: putScimGroupRole
      summary: Upsert a SCIM group->role mapping
      description: Map a SCIM group to an Obscura role; the group's members are reconciled. Requires rbac.admin.
      tags: [admin]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScimGroupRole'
      responses:
        '200':
          description: The stored mapping.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScimGroupRole'
        '400': { $ref: '#/components/responses/Problem' }
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
    delete:
      operationId: deleteScimGroupRole
      summary: Delete a SCIM group->role mapping
      tags: [admin]
      parameters:
        - in: query
          name: scim_group_id
          required: true
          schema: { type: string }
      responses:
        '204': { description: Deleted (idempotent). }
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
  • [ ] Step 4 — OpenAPI schemas. Add under components: schemas: (next to LdapStatus):
    ScimToken:
      type: object
      description: A freshly minted SCIM bearer token (shown once).
      properties:
        token: { type: string }
      required: [token]
    ScimStatus:
      type: object
      description: Read-only SCIM provisioning status.
      properties:
        enabled: { type: boolean }
        base_url: { type: string }
        last_used_at: { type: string, format: date-time, nullable: true }
        provisioned_user_count: { type: integer }
      required: [enabled, base_url, provisioned_user_count]
    ScimGroup:
      type: object
      properties:
        id: { type: string }
        display_name: { type: string }
      required: [id, display_name]
    ScimGroupList:
      type: object
      properties:
        groups:
          type: array
          items: { $ref: '#/components/schemas/ScimGroup' }
      required: [groups]
    ScimGroupRole:
      type: object
      description: One SCIM-group -> Obscura-role mapping.
      properties:
        scim_group_id: { type: string }
        role_id: { type: string }
      required: [scim_group_id, role_id]
    ScimGroupRoleList:
      type: object
      properties:
        group_roles:
          type: array
          items: { $ref: '#/components/schemas/ScimGroupRole' }
      required: [group_roles]
  • [ ] Step 5 — Regenerate + verify. cd web && npm run gen:api && npx tsc --noEmit; then cd go && go build ./... && go vet ./....
  • [ ] Step 6 — Commit:
git add go/internal/httpapi/handlers_scim_admin.go go/internal/httpapi/server.go api/openapi.yaml web/src/api/schema.ts
git commit -m "feat(api): admin SCIM token + status + group-role mapping (rbac.admin) + OpenAPI"

Task 6: Web — Admin → Directory gains a SCIM card (token + status + group→role mapping)

Files: web/src/features/admin/data.ts, web/src/features/admin/LdapTab.tsx, web/src/features/admin/i18n.ts, web/src/styles/app.css. (AdminPage.tsx needs NO code change — the tab label comes from t('admin.tabs.ldap'), whose VALUE we change to "Directory" in i18n. The LdapTab component now renders both the LDAP and SCIM cards.)

  • [ ] Step 1 — Data hooks. Append to web/src/features/admin/data.ts (uses api/ok/useQuery/useMutation/useQueryClient already imported at the top):
export interface ScimStatus {
  enabled: boolean
  baseUrl: string
  lastUsedAt: string | null
  provisionedUserCount: number
}

export interface ScimGroupOption {
  id: string
  displayName: string
}

export interface ScimGroupRole {
  scimGroupId: string
  roleId: string
}

export function useScimStatus() {
  return useQuery<ScimStatus>({
    queryKey: ['scim-status'],
    queryFn: async () => {
      const r = ok(await api.GET('/api/v1/admin/scim/status', {}))
      return {
        enabled: !!r.enabled,
        baseUrl: r.base_url ?? '',
        lastUsedAt: r.last_used_at ?? null,
        provisionedUserCount: r.provisioned_user_count ?? 0,
      }
    },
    staleTime: 30_000,
  })
}

export function useScimGroups() {
  return useQuery<ScimGroupOption[]>({
    queryKey: ['scim-groups'],
    queryFn: async () => {
      const r = ok(await api.GET('/api/v1/admin/scim/groups', {}))
      return (r.groups ?? []).map((g) => ({ id: g.id, displayName: g.display_name }))
    },
  })
}

export function useScimGroupRoles() {
  return useQuery<ScimGroupRole[]>({
    queryKey: ['scim-group-roles'],
    queryFn: async () => {
      const r = ok(await api.GET('/api/v1/admin/scim/group-roles', {}))
      return (r.group_roles ?? []).map((m) => ({ scimGroupId: m.scim_group_id, roleId: m.role_id }))
    },
  })
}

export function useMintScimToken() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: async () => {
      const r = ok(await api.POST('/api/v1/admin/scim/token', {}))
      return r.token as string
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['scim-status'] }),
  })
}

export function useRevokeScimToken() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: () => api.DELETE('/api/v1/admin/scim/token', {}).then(ok),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['scim-status'] }),
  })
}

export function useUpsertScimGroupRole() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (m: ScimGroupRole) =>
      api.PUT('/api/v1/admin/scim/group-roles', { body: { scim_group_id: m.scimGroupId, role_id: m.roleId } }).then(ok),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['scim-group-roles'] }),
  })
}

export function useDeleteScimGroupRole() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (scimGroupId: string) =>
      api.DELETE('/api/v1/admin/scim/group-roles', { params: { query: { scim_group_id: scimGroupId } } }).then(ok),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['scim-group-roles'] }),
  })
}
  • [ ] Step 2 — SCIM cards in the tab. Edit web/src/features/admin/LdapTab.tsx:
  • Extend the ./data import to add: useScimStatus, useScimGroups, useScimGroupRoles, useMintScimToken, useRevokeScimToken, useUpsertScimGroupRole, useDeleteScimGroupRole.
  • Add Copy to the @carbon/icons-react import (alongside Add, TrashCan).
  • Inside LdapTab(), after the existing LDAP hook calls, add the SCIM hooks + state:
  const scim = useScimStatus()
  const scimGroups = useScimGroups()
  const scimMappings = useScimGroupRoles()
  const mintToken = useMintScimToken()
  const revokeToken = useRevokeScimToken()
  const upsertScim = useUpsertScimGroupRole()
  const delScim = useDeleteScimGroupRole()

  const [mintedToken, setMintedToken] = useState('')
  const [scimGroupId, setScimGroupId] = useState('')
  const [scimRoleId, setScimRoleId] = useState('')
  const [scimErr, setScimErr] = useState(false)

  const groupName = (id: string) => scimGroups.data?.find((g) => g.id === id)?.displayName ?? id
  const canAddScim = scimGroupId !== '' && scimRoleId !== ''

  const mint = () => {
    setMintedToken('')
    mintToken.mutate(undefined, { onSuccess: (tok) => setMintedToken(tok) })
  }
  const addScim = () => {
    setScimErr(false)
    upsertScim.mutate(
      { scimGroupId, roleId: scimRoleId },
      { onSuccess: () => { setScimGroupId(''); setScimRoleId('') }, onError: () => setScimErr(true) },
    )
  }
  • Add these two Tiles before the final </div> of the returned ai-tab container (i.e. after the LDAP mapping Tile):
      <Tile className="ai-tab__card">
        <h3 className="ai-tab__card-title">{t('admin.scim.token.title')}</h3>
        <p className="muted ai-tab__hint">{t('admin.scim.token.hint')}</p>
        <StructuredListWrapper isCondensed ariaLabel={t('admin.scim.token.title')}>
          <StructuredListBody>
            {statusRow(
              t('admin.scim.token.state'),
              scim.data?.enabled ? <Tag type="green" size="sm">{t('admin.scim.token.enabled')}</Tag> : <Tag type="gray" size="sm">{t('admin.scim.token.off')}</Tag>,
            )}
            {statusRow(t('admin.scim.token.baseUrl'), scim.data?.baseUrl || '—')}
            {statusRow(t('admin.scim.token.lastUsed'), scim.data?.lastUsedAt || t('admin.scim.token.never'))}
            {statusRow(t('admin.scim.token.provisioned'), String(scim.data?.provisionedUserCount ?? 0))}
          </StructuredListBody>
        </StructuredListWrapper>
        <div className="ldap-map__add">
          <Button size="md" renderIcon={Add} onClick={mint} disabled={mintToken.isPending}>
            {scim.data?.enabled ? t('admin.scim.token.rotate') : t('admin.scim.token.generate')}
          </Button>
          {scim.data?.enabled && (
            <Button kind="danger--tertiary" size="md" renderIcon={TrashCan} onClick={() => revokeToken.mutate()} disabled={revokeToken.isPending}>
              {t('admin.scim.token.revoke')}
            </Button>
          )}
        </div>
        {mintedToken && (
          <div className="scim-token__reveal">
            <InlineNotification kind="warning" lowContrast hideCloseButton title={t('admin.scim.token.copyTitle')} subtitle={t('admin.scim.token.copyHint')} />
            <div className="scim-token__field">
              <TextInput id="scim-token" labelText={t('admin.scim.token.label')} value={mintedToken} readOnly />
              <Button size="md" renderIcon={Copy} onClick={() => navigator.clipboard?.writeText(mintedToken)}>
                {t('admin.scim.token.copy')}
              </Button>
            </div>
          </div>
        )}
      </Tile>

      <Tile className="ai-tab__card">
        <h3 className="ai-tab__card-title">{t('admin.scim.map.title')}</h3>
        <p className="muted ai-tab__hint">{t('admin.scim.map.hint')}</p>
        {(scimGroups.data ?? []).length === 0 ? (
          <p className="muted">{t('admin.scim.map.noGroups')}</p>
        ) : (
          <div className="ldap-map__add">
            <Dropdown
              id="scim-group"
              titleText={t('admin.scim.map.group')}
              label={t('admin.scim.map.groupPlaceholder')}
              items={scimGroups.data ?? []}
              itemToString={(g) => (g ? g.displayName : '')}
              selectedItem={scimGroups.data?.find((g) => g.id === scimGroupId) ?? null}
              onChange={({ selectedItem }) => setScimGroupId(selectedItem?.id ?? '')}
            />
            <Dropdown
              id="scim-role"
              titleText={t('admin.scim.map.role')}
              label={t('admin.scim.map.rolePlaceholder')}
              items={roles}
              itemToString={(r) => (r ? r.name : '')}
              selectedItem={roles.find((r) => r.id === scimRoleId) ?? null}
              onChange={({ selectedItem }) => setScimRoleId(selectedItem?.id ?? '')}
            />
            <Button size="md" renderIcon={Add} onClick={addScim} disabled={!canAddScim || upsertScim.isPending}>
              {t('admin.scim.map.add')}
            </Button>
          </div>
        )}
        {scimErr && <InlineNotification kind="error" lowContrast title={t('admin.saveError')} onCloseButtonClick={() => setScimErr(false)} className="ai-tab__note" />}
        {(scimMappings.data ?? []).length > 0 && (
          <Table size="sm" className="ai-tab__table">
            <TableHead>
              <TableRow>
                <TableHeader>{t('admin.scim.map.group')}</TableHeader>
                <TableHeader>{t('admin.scim.map.role')}</TableHeader>
                <TableHeader>{t('admin.scim.map.actions')}</TableHeader>
              </TableRow>
            </TableHead>
            <TableBody>
              {(scimMappings.data ?? []).map((m) => (
                <TableRow key={m.scimGroupId}>
                  <TableCell>{groupName(m.scimGroupId)}</TableCell>
                  <TableCell>{roleName(m.roleId)}</TableCell>
                  <TableCell>
                    <Button kind="ghost" size="sm" hasIconOnly iconDescription={t('admin.scim.map.delete')} renderIcon={TrashCan} onClick={() => delScim.mutate(m.scimGroupId)} />
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}
      </Tile>

Also update the tab intro so the page reads as "Directory" (both backends). Change the top <p className="page__lead muted">{t('admin.ldap.lead')}</p> to render both leads, or leave the LDAP lead and let each card carry its own hint — either is fine; the simplest is to leave it. (All the Carbon components used — Tile, StructuredListWrapper/Body, Tag, Button, Dropdown, TextInput, InlineNotification, Table* — are already imported at the top of LdapTab.tsx.)

  • [ ] Step 3 — i18n. Edit web/src/features/admin/i18n.ts:
  • Change the tab label VALUE (keep the key ldap): en tabs.ldap: 'Directory (LDAP)''Directory'; id tabs.ldap: 'Direktori (LDAP)''Direktori'.
  • Add a scim group inside the admin object in BOTH en and id (identical shape), next to ldap:.

en:

    scim: {
      token: {
        title: 'SCIM provisioning',
        hint: 'Let your identity provider (Okta, Entra, OneLogin) create, update, and deactivate accounts and push group membership over SCIM 2.0. Generate a token below and paste it, with the base URL, into your IdP. See docs/SCIM.md.',
        state: 'Status',
        enabled: 'Enabled',
        off: 'Disabled',
        baseUrl: 'SCIM base URL',
        lastUsed: 'Last request',
        never: 'Never',
        provisioned: 'Provisioned users',
        generate: 'Generate token',
        rotate: 'Rotate token',
        revoke: 'Revoke',
        copyTitle: 'Copy this token now',
        copyHint: 'It is shown only once. If you lose it, rotate to generate a new one (the old token stops working).',
        label: 'SCIM bearer token',
        copy: 'Copy',
      },
      map: {
        title: 'Group → role mapping',
        hint: 'Map a pushed SCIM group to an Obscura role. Members gain the mapped role; leaving the group removes it — manually-assigned roles are never touched.',
        group: 'SCIM group',
        groupPlaceholder: 'Select a group',
        role: 'Role',
        rolePlaceholder: 'Select a role',
        add: 'Add mapping',
        actions: 'Actions',
        delete: 'Delete mapping',
        noGroups: 'No SCIM groups yet — they appear here after your IdP pushes them.',
      },
    },

id:

    scim: {
      token: {
        title: 'Penyediaan SCIM',
        hint: 'Biarkan penyedia identitas Anda (Okta, Entra, OneLogin) membuat, memperbarui, dan menonaktifkan akun serta mendorong keanggotaan grup melalui SCIM 2.0. Buat token di bawah lalu tempel, beserta base URL, ke IdP Anda. Lihat docs/SCIM.md.',
        state: 'Status',
        enabled: 'Aktif',
        off: 'Nonaktif',
        baseUrl: 'Base URL SCIM',
        lastUsed: 'Permintaan terakhir',
        never: 'Belum pernah',
        provisioned: 'Pengguna tersedia',
        generate: 'Buat token',
        rotate: 'Putar token',
        revoke: 'Cabut',
        copyTitle: 'Salin token ini sekarang',
        copyHint: 'Token hanya ditampilkan sekali. Jika hilang, putar untuk membuat yang baru (token lama berhenti berfungsi).',
        label: 'Token bearer SCIM',
        copy: 'Salin',
      },
      map: {
        title: 'Pemetaan grup → peran',
        hint: 'Petakan grup SCIM yang didorong ke peran Obscura. Anggota memperoleh peran yang dipetakan; keluar dari grup akan menghapusnya — peran yang ditetapkan manual tidak pernah diubah.',
        group: 'Grup SCIM',
        groupPlaceholder: 'Pilih grup',
        role: 'Peran',
        rolePlaceholder: 'Pilih peran',
        add: 'Tambah pemetaan',
        actions: 'Aksi',
        delete: 'Hapus pemetaan',
        noGroups: 'Belum ada grup SCIM — akan muncul di sini setelah IdP Anda mendorongnya.',
      },
    },
  • [ ] Step 4 — Styles. Append to web/src/styles/app.css (the .ldap-map__add flex row is reused; add just the token reveal):
/* SCIM token reveal (admin) */
.scim-token__reveal { margin-top: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
.scim-token__field { display: flex; align-items: flex-end; gap: 0.5rem; }
.scim-token__field > *:first-child { flex: 1 1 24rem; }
  • [ ] Step 5 — Verify: cd web && npx tsc --noEmit && npx vite build. If smart quotes in i18n.ts broke tsc, rewrite the whole file with Write.
  • [ ] Step 6 — Commit:
git add web/src/features/admin/data.ts web/src/features/admin/LdapTab.tsx web/src/features/admin/i18n.ts web/src/styles/app.css
git commit -m "feat(web): Admin Directory tab — SCIM token + status + group->role mapping card"

Task 7: Operator guide docs/SCIM.md

Files: docs/SCIM.md (new).

  • [ ] Step 1 — Write the guide. Create docs/SCIM.md — a customer-facing operator guide (write full prose; the skeleton below is the REQUIRED structure, not placeholders):
  • What SCIM does in Obscura — an IdP (Okta/Entra/OneLogin) provisions/updates/deprovisions accounts and pushes group membership over SCIM 2.0. Users are created as provider='scim' with no password and sign in via OIDC SSO (SCIM only provisions; the OIDC login links to the SCIM account by email). Deprovision = disable (never hard-delete): the account and its content/audit survive; re-activating restores it.
  • Enable it (admin, one-time): Admin → Directory → SCIM → Generate token (copy it — shown ONCE) and note the SCIM base URL (https://<your-host>/scim/v2).
  • Configure your IdP — the values to paste:
    • SCIM connector base URL: https://<your-host>/scim/v2
    • Authentication: HTTP Header / OAuth Bearer Token → the minted token
    • Unique identifier field for users: userName (Obscura maps userName → the account email)
    • Supported provisioning actions: Push New Users, Push Profile Updates, Push Groups, Deactivate Users
  • Attribute mappings (table): userName → email (login); emails[type eq "work"].value / emails[primary] → email; displayName (or name.formatted) → display name; active → enabled/disabled; externalId → IdP correlation id.
  • Push groups → Obscura roles: enable group push in the IdP; the pushed groups appear in Admin → Directory → SCIM → Group → role mapping; map each to an Obscura role. Membership changes re-grant/revoke automatically; manually-assigned roles are never touched.
  • Rotate / revoke: Rotate issues a new token and immediately invalidates the old one (update the IdP). Revoke disables SCIM entirely (every /scim/v2 request returns 401); provisioned accounts are unaffected.
  • What is NOT supported (by design): SCIM /Bulk, /Me, ETags/versioning, sort, cursor paging beyond startIndex/count, SCIM-carried passwords, and SCIM as a login method (provisioning only). Filters support only userName eq / externalId eq (Users) and displayName eq (Groups).
  • Troubleshooting table:

    Symptom Likely cause
    Every SCIM request → 401 no active token (generate one), or the IdP is sending the wrong/rotated token
    409 uniqueness on create a user with that userName/email (or externalId) already exists — including a pre-existing local account with the same email
    400 invalidFilter the IdP sent an unsupported filter — only userName eq / externalId eq (Users), displayName eq (Groups) are supported
    404 on GET/PUT/PATCH/DELETE of a user/group wrong resource id, or the resource was never provisioned here
    User provisioned but no roles the group isn't pushed yet, or there is no Group → role mapping for it
    Deprovisioned user still appears in GET /Users/{id} expected — Obscura disables (never hard-deletes); the record returns active:false
    User can't log in after provisioning SCIM does not set a password — the user signs in via your OIDC SSO, which links to the SCIM account by email
  • [ ] Step 2 — Upload the rendered guide (repo CLAUDE.md requirement for new .md): curl -F "file=@docs/SCIM.md" https://x056.think.val.id/upload and give the user the returned URL.

  • [ ] Step 3 — Commit:
git add docs/SCIM.md
git commit -m "docs: SCIM operator guide (Okta/Entra setup + troubleshooting)"

Task 8: Deploy + curl e2e simulating an IdP (controller drives this personally — NOT a subagent)

Approach (no new instance, no new compose service): unlike LDAP (a boot-env toggle needing a separate instance), SCIM is enabled at runtime by minting a token on the already-deployed obscura service, and /scim/v2 is served by the same server (a sibling of /api/v1). So the e2e runs entirely against the main deployed stack on :38080, minting → exercising → revoking the token so SCIM ends disabled-by-default. All curl simulates an IdP. DB assertions use docker exec deploy-postgres-1 psql -U obscura -d obscura -c "…".

Prereqs: T1–T7 committed on main and building.

  • [ ] Step 1 — Deploy + baseline. docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web. Dev-login director@obscura.local (:38080) → TOKEN; GET /api/v1/me → assert enabled_modules == [ai, correspondence, esign, semantic, watermarking]. Record ROLE_ID from GET /api/v1/roles (any existing managed role).

  • [ ] Step 2 — Enable SCIM + discovery. POST /api/v1/admin/scim/token (director TOKEN) → capture SCIM token. Assert:

  • GET :38080/scim/v2/ServiceProviderConfig without a bearer → 401 (SCIM error envelope, status:"401").
  • Same with Authorization: Bearer $SCIM200, patch.supported=true, filter.supported=true, bulk.supported=false, changePassword.supported=false, Content-Type: application/scim+json.
  • GET /scim/v2/ResourceTypes and /Schemas (with bearer) → 200.

  • [ ] Step 3 — Provision + dedup. POST /scim/v2/Users (bearer) {"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"scimuser1@obscura.local","externalId":"ext-scim-1","displayName":"SCIM User One","active":true,"emails":[{"value":"scimuser1@obscura.local","primary":true}]}201, capture UID (the id) + Location. Assert in psql: SELECT provider, password_hash IS NULL, disabled FROM users WHERE id='$UID'scim | t | f; SELECT idp, subject FROM user_identities WHERE user_id='$UID'scim | ext-scim-1. Dedup:

  • GET /scim/v2/Users?filter=userName eq "scimuser1@obscura.local"totalResults:1, the one resource.
  • GET /scim/v2/Users?filter=externalId eq "ext-scim-1"totalResults:1.
  • POST /scim/v2/Users again with the same userName → 409 scimType:"uniqueness".
  • GET /scim/v2/Users?filter=userName sw "x" (unsupported op) → 400 scimType:"invalidFilter".

  • [ ] Step 4 — Groups + membership + mapping → role granted. POST /scim/v2/Groups (bearer) {"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"],"displayName":"scim-admins","members":[{"value":"$UID"}]}201, capture GID. Map: PUT /api/v1/admin/scim/group-roles (director) {"scim_group_id":"$GID","role_id":"$ROLE_ID"}200. Assert the role is granted:

  • psql SELECT count(*) FROM scim_role_grants WHERE user_id='$UID' AND role_id='$ROLE_ID'1.
  • psql SELECT count(*) FROM role_bindings WHERE subject_kind='user' AND subject_id='$UID' AND role_id='$ROLE_ID'1 (the binder created the binding).

  • [ ] Step 5 — Mapping removal → revoke; manual binding survives.

  • DELETE /api/v1/admin/scim/group-roles?scim_group_id=$GID204. Assert scim_role_grants for $UID0 and the role_bindings row is gone. Re-add the mapping (PUT again) → granted again.
  • Manually bind ANOTHER role: POST /api/v1/role-bindings {"role_id":"$OTHER_ROLE","subject_kind":"user","subject_id":"$UID"}. Trigger a reconcile (e.g. PATCH /scim/v2/Groups/$GID add-member no-op, or re-PUT the mapping). Assert the manual role's role_bindings row survives and has no scim_role_grants row (manual bindings are never touched).

  • [ ] Step 6 — Session revoke on deprovision. Simulate a live session for the scim user: psql INSERT INTO sessions (token_hash, id, user_id, created_at, expires_at) VALUES ('e2e-scim-sess', gen_random_uuid(), '$UID', now(), now()+interval '1 day'). Then PATCH /scim/v2/Users/$UID {"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"replace","path":"active","value":false}]}200, body active:false. Assert:

  • psql SELECT disabled FROM users WHERE id='$UID't.
  • psql SELECT count(*) FROM sessions WHERE user_id='$UID'0 (sessions revoked).
  • psql SELECT count(*) FROM scim_role_grants WHERE user_id='$UID'0 (grants stripped) and the mapped role_bindings row gone.
  • GET /scim/v2/Users/$UID200, active:false (disabled, NOT hard-deleted).

  • [ ] Step 7 — Restore + DELETE. PATCH …/Users/$UID {op:replace, path:active, value:true}active:true; assert users.disabled=f and the mapped role re-granted (scim_role_grants row back). Then DELETE /scim/v2/Users/$UID204; assert users.disabled=t and GET /scim/v2/Users/$UID200 active:false (deprovision, not removal).

  • [ ] Step 8 — Bearer matrix + revoke disables SCIM.

  • valid $SCIM200 on GET /scim/v2/Users.
  • Bearer bogus401.
  • DELETE /api/v1/admin/scim/token (director) → 204; then GET /scim/v2/Users with the (now-revoked) $SCIM401 (SCIM disabled).
  • GET /api/v1/admin/scim/statusenabled:false.

  • [ ] Step 9 — (Optional) OIDC-link check. If an OIDC test IdP is available (keycloak/casdoor compose profile), provision a SCIM user with an email that the OIDC IdP can mint a token for, then OIDC-login → assert the SAME users.id (a single row; a new (idp='oidc',subject) identity attached; no duplicate). If no OIDC IdP is wired, note this is covered by code reasoning (T3 §4d) and skip.

  • [ ] Step 10 — Cleanup + demo-intact assert. Purge every e2e artifact from the shared demo DB:

docker exec deploy-postgres-1 psql -U obscura -d obscura -c "DELETE FROM scim_group_roles; DELETE FROM scim_role_grants; DELETE FROM scim_group_members; DELETE FROM scim_groups; DELETE FROM scim_tokens; DELETE FROM sessions WHERE token_hash='e2e-scim-sess';"
docker exec deploy-postgres-1 psql -U obscura -d obscura -c "DELETE FROM role_bindings WHERE subject_id IN (SELECT id FROM users WHERE provider='scim'); DELETE FROM effective_perms WHERE subject_id IN (SELECT id FROM users WHERE provider='scim'); DELETE FROM user_identities WHERE user_id IN (SELECT id FROM users WHERE provider='scim'); DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE provider='scim'); DELETE FROM users WHERE provider='scim';"

Also delete the manual $OTHER_ROLE binding created in Step 5 if its subject user is gone (the DELETE above covers it) and POST /api/v1/rbac/refresh (rebuild effective_perms). Assert SELECT count(*) FROM users WHERE provider='scim'0, and scim_tokens/scim_groups0. Re-assert the MAIN demo: GET /api/v1/me (dev-login) → 5 modules intact; a normal dev-login/local login still works; GET /api/v1/admin/scim/statusenabled:false.

  • [ ] Step 11 — Final commit of any e2e-driven fixes; report the commit list + which matrix assertions passed.

Self-review notes (done at plan time)

  • Spec coverage:
  • §The SCIM surface (/scim/v2, token-authed NOT sessions, application/scim+json, RFC error envelopes) → T4 (requireSCIMToken, scimWrite/scimErrEnvelope, mounted sibling-of-/api/v1). Discovery (ServiceProviderConfig advertising patch=true/filter=true/bulk=false/sort=false/changePassword=false/etag=false + bearer scheme; ResourceTypes; Schemas) → T4 Step 1. Users (GET with userName eq/externalId eq + startIndex/count, POST, GET/PUT/PATCH/DELETE /Users/{id}) + Groups (GET + displayName eq, POST, GET/PUT/PATCH/DELETE, member PATCH) → T4. Minimal filter parser (<attr> eq "<value>" only; else 400 invalidFilter) → parseSCIMFilter (T4). No bulk/sort/cursor.
  • §Users ↔ accounts (map userName/emails[primary]→email, displayName/name.formatted→display, active!disabled, externalIduser_identities(idp='scim'), provider='scim' no password, dup→409; PATCH replace/add/remove on mapped attrs + active; deprovision = disable + revoke sessions + strip grants, idempotent, restore on active=true) → T2 CreateUser/ReplaceUser/PatchUser/DeleteUser/applyActive. externalId is the ProvisionLocal(idp='scim', subject=externalID) identity subject.
  • §Login integration (OIDC JIT MATCHES an existing provider='scim' user by email, no duplicate; guard extended so scim (and every non-local) can't local-login) → T3 LinkOIDCIdentityByEmail in ProvisionFromOIDC, and IsDirectoryManaged replacing == "ldap" in all three guards + /me.
  • §Groups→roles (scim_groups/scim_group_members/scim_group_roles/scim_role_grants identical-shape-to-LDAP; reconcile grant/revoke via rbac binder + ledger, RefreshEffectivePerms on change, manual bindings untouched, non-fatal; SEPARATE tables/ledger from LDAP) → T1 (00084/00085) + T2 reconcileUser. Admin (status card + token control + group→role table, rbac.admin) → T5 + T6.
  • §Token management (scim_tokens, admin-minted, shown once, sha256 hash, one active, rotate-revokes-prior, GET status {enabled, base_url, last_used_at, provisioned_user_count}, revoke; request auth = Bearer → sha256 → non-revoked row constant-time compare → bump last_used → miss=401) → T2 MintToken/VerifyToken/RevokeTokens/Status (stdlib crypto/rand+sha256+subtle) + T5 admin endpoints + T4 requireSCIMToken.
  • §Admin UI (Directory tab + SCIM card: base URL, generate/rotate/revoke token shown-once copyable, status, group→role table, en/id) → T6. §Operator guide → T7. §Error handling (RFC 7644 envelopes, 409 uniqueness, 404, 401, 400 invalidValue/invalidFilter; a SCIM failure never touches an unrelated user) → scimWriteErr (T4) + per-user Service ops (T2).
  • §Testing (build/vet + tsc/vite; scripted-curl e2e mint→discovery→POST Users→filter dedup→PATCH active=false→disabled+session revoked→POST Groups+PATCH member→map→role granted→remove→revoked→manual survives→DELETE→disabled→bearer matrix; cleanup; demo intact 5 modules, SCIM+LDAP disabled) → T8. §Out-of-scope (Bulk/Me/ETags/sort/cursor/SCIM-passwords/SCIM-as-auth) → not built (documented in T7 + ServiceProviderConfig).
  • Type consistency: scim/domain (Token/Group/GroupRoleMapping/RoleGrant) ↔ scim/app ports (Store/UserPort/RoleBinder/ScimUser) ↔ scim/adapters.Store impl (T1) ↔ scim/app.Service DTOs (UserInput/UserResource/GroupInput/GroupResource/UserPatch/GroupPatch, T2) ↔ HTTP mappers toSCIMUser/toSCIMGroup/scimUserBody/scimGroupBody/parseUserPatch/parseGroupPatch (T4) ↔ admin views (scimStatusView/scimGroupView/scimGroupRoleView, T5) ↔ OpenAPI schemas (ScimStatus/ScimGroup/ScimGroupRole*, T5) → regenerated schema.ts → web hooks useScimStatus/useScimGroups/useScimGroupRoles/useMintScimToken/… (T6). *rbacadapters.Store satisfies scimapp.RoleBinder (same names as the LDAP RoleBinder, verified). scimUserPort (wire.go) satisfies scimapp.UserPort over authSvc methods (all verified to exist; UpdateUserProfile takes a trailing phone → pass nil). authdomain.IsDirectoryManaged is the single predicate in auth/app (3 guards) + httpapi (/me).
  • Placeholder scan: no TBD/TODO — full SQL for both migrations; full Go for domain, ports, store (incl. user reads), Service (token/users/groups/reconcile), the auth changes (predicate, 2 repo methods incl. the robust two-step link, 3 guard edits, OIDC link, UserByIdentity), all SCIM HTTP handlers + filter/PATCH parsers + writers, all admin handlers; full OpenAPI paths+schemas; full TSX hooks + SCIM cards; full en/id i18n; full operator-guide structure. The only run-time judgment is s.db.Do error-wrapping (T3 Step 3 gives the robust two-step form to implement — no ambiguity).
  • Build-order note (called out in tasks): the scim package (T1+T2) compiles standalone and is simply UNIMPORTED until T4 wires it (Go permits unused packages). T3's auth interface+impl land in one commit (so Store keeps satisfying Repository). T4 imports scimapp/scimadapters and injects Deps.SCIM. Each task's commit is go build ./... && go vet ./... green. Web verify (tsc+vite build) runs in T5 (after gen:api) and T6.
  • Known judgment calls / spec assumptions (see report):
    1. Guard predicate is IsDirectoryManaged (ldap/scim/oidc), NOT the spec's literal != 'local'. The demo director is provider='dev' and logs in via the web form (/auth/login, password==email) — a literal != 'local' would 401 it. dev (+ local + "") stay local-eligible; only external IdP providers are blocked. This also closes a pre-existing hole (oidc hash-less accounts were reachable via password==email under == "ldap"). This is the single most important refinement — verify it in review.
    2. New scim context (not folded into auth) — SCIM keeps its own tables/ledger, parallel to LDAP; user mutation reuses auth via UserPort, role grants reuse rbac via RoleBinder, user READS are read-only cross-table reads in the scim store (established reporting/UserStorageUsage pattern). Two migrations 00084+00085 (tokens/groups/members; then mappings+ledger).
    3. externalId = the user_identities(idp='scim') subject (falls back to the lowercased email when the IdP omits it). SCIM {id} for users = the Obscura users.id; for groups = a generated scim_groups.id (uuid).
    4. DELETE /Users = deprovision (disable), never hard-delete (spec decision); a later GET returns active:false. Group DELETE deletes the mapping + reconciles members BEFORE dropping the row, so the ledger cascade can't orphan rbac bindings.
    5. /scim/v2 is NOT in OpenAPI / gen:api (it is its own RFC spec); only the session-authed /admin/scim/* endpoints are. No new Go or npm deps (stdlib crypto + hand-rolled SCIM filter/PATCH parser). No new compose service — the e2e mints a token on the deployed stack and revokes it (SCIM ends disabled-by-default).
    6. Admin group→role mapping uses a dropdown of PUSHED groups (scim group ids are opaque uuids, unlike LDAP's human-typeable DNs) — so a mapping can only be created after the IdP has pushed the group (the card shows a "groups appear after the IdP syncs" hint until then). Minor UX deviation from the LDAP free-text DN field, driven by the id shape.
    7. PATCH parser handles both Okta and Entra shapesop case-insensitive; user active/displayName/userName via path OR a no-path value object; active as bool OR "True"/"False" string; group members via path:"members" value arrays AND Okta's members[value eq "x"] remove-path filter. Unsupported shapes are ignored (no partial-failure); truly malformed JSON → 400 invalidValue.