/** * TASK-0132 — SCIM 2.0 PROTOCOL controller (the IdP-facing surface). * * /scim/v2/Users POST create · GET list(filter, startIndex, count) * /scim/v2/Users/:id GET · PATCH(If-Match) · DELETE(If-Match) * /scim/v2/Groups POST create · GET list * /scim/v2/Groups/:id GET · PATCH(If-Match) · DELETE(If-Match) * * Mounted OUTSIDE the `api/v1` prefix (main.ts excludes `scim/v2`; the * AuthGuard whitelists it — auth is the ScimBearerGuard, token → org). * Every response is `application/scim+json`; non-2xx is the RFC 7644 error * envelope (ScimExceptionsFilter). `Content-Type` on requests is NOT * enforced to scim+json (IdPs sometimes send application/json — tolerant). */ import { Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query, Req, Res, UseFilters, UseGuards } from "@nestjs/common"; import type { Request, Response } from "express"; import { ZodValidate } from "../../common/pipes/zod-validation.pipe.js"; import { ScimExceptionsFilter, ScimError } from "./scim-errors.js"; import { ScimBearerGuard } from "./scim-auth.guard.js"; import { ScimGroupsService } from "./groups.service.js"; import { ScimUsersService } from "./users.service.js"; import { ScimPatchSchema, ScimGroupCreateSchema, ScimUserCreateSchema } from "./schemas.js"; import { ifMatchMatches } from "./etag.js"; import type { ScimGroupCreate, ScimPatch, ScimUserCreate } from "./schemas.js"; /** The org the bearer token resolved to — set by `ScimBearerGuard` (the * token acts as the org; actorType INTEGRATION, actorId = orgId). */ interface ScimReq extends Request { scimOrg?: { id: string; scimConfig: unknown }; } @UseFilters(ScimExceptionsFilter) @UseGuards(ScimBearerGuard) @Controller("scim/v2") export class ScimUsersController { constructor(@Inject(ScimUsersService) private readonly users: ScimUsersService) {} @Post("Users") async create(@Req() req: ScimReq, @Body(ZodValidate(ScimUserCreateSchema)) body: ScimUserCreate, @Res() res: Response) { const { rep, etag, location } = await this.users.create(req, this.org(req), body); res.setHeader("content-type", "application/scim+json; charset=utf-8"); res.setHeader("ETag", etag); res.setHeader("Location", location); res.status(201).json(rep); } @Get("Users") async list(@Req() req: ScimReq, @Query() query: { filter?: string; startIndex?: string; count?: string }, @Res() res: Response) { const result = await this.users.list(this.org(req), query); res.setHeader("content-type", "application/scim+json; charset=utf-8"); if (result.etag) res.setHeader("ETag", result.etag); res.status(200).json({ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], totalResults: result.totalResults, startIndex: result.startIndex, itemsPerPage: result.itemsPerPage, Resources: result.resources }); } @Get("Users/:id") async get(@Req() req: ScimReq, @Param("id") id: string, @Res() res: Response) { const { rep, etag } = await this.users.get(this.org(req), id); res.setHeader("content-type", "application/scim+json; charset=utf-8"); res.setHeader("ETag", etag); res.status(200).json(rep); } @Patch("Users/:id") async patch( @Req() req: ScimReq, @Param("id") id: string, @Body(ZodValidate(ScimPatchSchema)) body: ScimPatch, @Res() res: Response ) { const org = this.org(req); // ETag precondition: resolve current + check If-Match BEFORE mutating. const current = await this.users.get(org, id); if (!ifMatchMatches(req.headers["if-match"] as string | undefined, current.etag)) { throw ScimError.preconditionFailed(); } const { rep, etag } = await this.users.patch(req, org, id, body); res.setHeader("content-type", "application/scim+json; charset=utf-8"); res.setHeader("ETag", etag); res.status(200).json(rep); } @Delete("Users/:id") async delete(@Req() req: ScimReq, @Param("id") id: string, @Res() res: Response) { const org = this.org(req); const current = await this.users.get(org, id); if (!ifMatchMatches(req.headers["if-match"] as string | undefined, current.etag)) { throw ScimError.preconditionFailed(); } const { rep } = await this.users.delete(req, org, id); res.setHeader("content-type", "application/scim+json; charset=utf-8"); res.status(200).json(rep); } private org(req: ScimReq): { id: string; scimConfig: unknown } { if (!req.scimOrg) throw ScimError.unauthorized(); return req.scimOrg as { id: string; scimConfig: unknown }; } } @UseFilters(ScimExceptionsFilter) @UseGuards(ScimBearerGuard) @Controller("scim/v2") export class ScimGroupsController { constructor(@Inject(ScimGroupsService) private readonly groups: ScimGroupsService) {} @Post("Groups") async create(@Req() req: ScimReq, @Body(ZodValidate(ScimGroupCreateSchema)) body: ScimGroupCreate, @Res() res: Response) { const { rep, etag, location } = await this.groups.create(req, this.org(req), body); res.setHeader("content-type", "application/scim+json; charset=utf-8"); res.setHeader("ETag", etag); res.setHeader("Location", location); res.status(201).json(rep); } @Get("Groups") async list(@Req() req: ScimReq, @Query() query: { filter?: string; startIndex?: string; count?: string }, @Res() res: Response) { const result = await this.groups.list(this.org(req), query); res.setHeader("content-type", "application/scim+json; charset=utf-8"); res.status(200).json({ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], totalResults: result.totalResults, startIndex: result.startIndex, itemsPerPage: result.itemsPerPage, Resources: result.resources }); } @Get("Groups/:id") async get(@Req() req: ScimReq, @Param("id") id: string, @Res() res: Response) { const { rep, etag } = await this.groups.get(this.org(req), id); res.setHeader("content-type", "application/scim+json; charset=utf-8"); res.setHeader("ETag", etag); res.status(200).json(rep); } @Patch("Groups/:id") async patch( @Req() req: ScimReq, @Param("id") id: string, @Body(ZodValidate(ScimPatchSchema)) body: ScimPatch, @Res() res: Response ) { const org = this.org(req); const current = await this.groups.get(org, id); if (!ifMatchMatches(req.headers["if-match"] as string | undefined, current.etag)) { throw ScimError.preconditionFailed(); } const { rep, etag } = await this.groups.patch(req, org, id, body.Operations ?? body.operations!); res.setHeader("content-type", "application/scim+json; charset=utf-8"); res.setHeader("ETag", etag); res.status(200).json(rep); } @Delete("Groups/:id") async delete(@Req() req: ScimReq, @Param("id") id: string, @Res() res: Response) { const org = this.org(req); const current = await this.groups.get(org, id); if (!ifMatchMatches(req.headers["if-match"] as string | undefined, current.etag)) { throw ScimError.preconditionFailed(); } const { rep } = await this.groups.delete(req, org, id); res.setHeader("content-type", "application/scim+json; charset=utf-8"); res.status(200).json(rep); } private org(req: ScimReq): { id: string; scimConfig: unknown } { if (!req.scimOrg) throw ScimError.unauthorized(); return req.scimOrg as { id: string; scimConfig: unknown }; } }