/** * TASK-0132 — SCIM 2.0 body contracts (zod, single source of truth). * * IdP-facing (protocol) bodies: SCIM User (RFC 7643 §4.3), the PATCH * operations envelope (RFC 7644 §3.5.2), SCIM Group (RFC 7643 §4.3 with the * Group extension). Admin bodies: the `Organization.scimConfig` shape (a * JSON field — no migration) + the admin enable/update/test inputs. * * Untrusted input discipline: the IdP is a third party — every field is * bounded, the `password` is consumed immediately (bcrypt) and NEVER kept * on the parsed object, `emails`/`userName` are normalized to lowercase * emails. */ import { z } from "zod"; export const SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"; export const SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"; export const SCIM_PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp"; export const SCIM_LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; // --------------------------------------------------------------------------- // SCIM User // --------------------------------------------------------------------------- const ScimNameSchema = z .object({ formatted: z.string().min(1).max(256).optional(), givenName: z.string().min(1).max(128).optional(), familyName: z.string().min(1).max(128).optional() }); /** Group member (declared before the patch op, which may reference it). */ const ScimMemberSchema = z .object({ value: z.string().min(1).max(256), $ref: z.string().max(64).optional() }); const ScimEmailSchema = z .object({ value: z.string().min(3).max(320), type: z.string().max(64).optional(), primary: z.boolean().optional() }); /** The `schemas` array (we only require it to be an array; unknown schemas are * ignored — forward-compatible). */ const schemasArr = z.array(z.string()).optional(); export const ScimUserCreateSchema = z .object({ schemas: schemasArr, id: z.string().max(64).optional(), userName: z.string().min(1).max(320), name: ScimNameSchema.optional(), displayName: z.string().min(1).max(256).optional(), emails: z.array(ScimEmailSchema).max(20).optional(), active: z.boolean().optional(), /** IdPs that push an initial password (rotated by the IdP afterwards). * Consumed (bcrypt) and dropped — never returned/audited. */ password: z.string().min(8).max(512).optional(), externalId: z.string().max(256).optional(), meta: z.record(z.unknown()).optional() }) .passthrough(); // IdPs send forward-compat fields — ignore unknown keys export type ScimUserCreate = z.infer; /** A single SCIM patch operation (RFC 7644 §3.5.2). `path` is a bare * attribute in the supported subset (no dotted paths). */ export const ScimOpSchema = z .object({ op: z.enum(["add", "replace", "remove"]), // The supported attribute subset (Users: active/name/displayName/emails/ // userName; Groups: displayName/members). path: z.enum(["active", "name", "displayName", "emails", "userName", "members"]).optional(), // `value` is POLYMORPHIC in SCIM (a boolean for `active`, a string, a // Name object, an Email object/array, or a member object/array for // Groups). The Email and member shapes are ambiguous in a shared union // (both carry `value: string`), so we keep `value` untyped here and let // the per-`path` service logic disambiguate (users/groups services cast // to the shape they expect). Bounded by the op count (≤50) + the // service's own field caps. value: z.unknown().optional(), value2: z.record(z.unknown()).optional() }) .strict(); export type ScimOp = z.infer; export const ScimPatchSchema = z .object({ schemas: schemasArr, // SCIM spells it "Operations" (capital O) in the wire examples — accept // both for tolerance. Operations: z.array(ScimOpSchema).min(1).max(50).optional(), operations: z.array(ScimOpSchema).min(1).max(50).optional() }) .strict() .refine((b) => (b.Operations ?? b.operations) !== undefined, { message: "missing Operations" }); export type ScimPatch = z.infer; // --------------------------------------------------------------------------- // SCIM Group // --------------------------------------------------------------------------- export const ScimGroupCreateSchema = z .object({ schemas: schemasArr, id: z.string().max(64).optional(), displayName: z.string().min(1).max(128), description: z.string().max(1024).optional(), members: z.array(ScimMemberSchema).max(500).optional(), meta: z.record(z.unknown()).optional() }) .passthrough(); export type ScimGroupCreate = z.infer; // --------------------------------------------------------------------------- // Organization.scimConfig (JSON field) // --------------------------------------------------------------------------- /** The stored config — `bearerTokenHash` is the SHA-256 of the raw * `scim_…` token (the raw is shown ONCE at enable, never stored). * `tokenHint` = the trailing 4 chars, for the admin badge display only. */ export const ScimConfigSchema = z.object({ bearerTokenHash: z.string().regex(/^[a-f0-9]{64}$/), tokenHint: z.string().max(8).default(""), userEmailDomain: z.string().min(3).max(254).optional(), defaultRole: z.enum(["GUEST", "MEMBER"]).default("MEMBER"), autoJoinOrgWorkspaces: z.boolean().default(true), enabledAt: z.string() }); export type ScimConfig = z.infer; // --------------------------------------------------------------------------- // Admin inputs // --------------------------------------------------------------------------- export const ScimEnableSchema = z.object({ /** Domain for `@`-less `userName`s (→ `@`). Required to * accept `@`-less userNames; if absent, only full-email userNames work. */ userEmailDomain: z.string().min(3).max(254).optional(), defaultRole: z.enum(["GUEST", "MEMBER"]).optional(), autoJoinOrgWorkspaces: z.boolean().optional() }); export type ScimEnable = z.infer; export const ScimAdminUpdateSchema = z .object({ /** Rotate the token (the new raw token is returned once). */ rotate: z.boolean().optional(), /** Disable SCIM (token invalidated immediately). */ disable: z.boolean().optional(), userEmailDomain: z.string().min(3).max(254).nullable().optional(), defaultRole: z.enum(["GUEST", "MEMBER"]).optional(), autoJoinOrgWorkspaces: z.boolean().optional() }) .refine((b) => Object.keys(b).length > 0, { message: "no fields to update" }); export type ScimAdminUpdate = z.infer; /** The test-connection probe — the browser supplies the token (reveal-once: * the server never stores the raw, so the admin pastes it back). */ export const ScimTestSchema = z.object({ token: z.string().min(10).max(256) });