1
15import { Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query, Req, Res, UseFilters, UseGuards } from "@nestjs/common";
16import type { Request, Response } from "express";
17import { ZodValidate } from "../../common/pipes/zod-validation.pipe.js";
18import { ScimExceptionsFilter, ScimError } from "./scim-errors.js";
19import { ScimBearerGuard } from "./scim-auth.guard.js";
20import { ScimGroupsService } from "./groups.service.js";
21import { ScimUsersService } from "./users.service.js";
22import { ScimPatchSchema, ScimGroupCreateSchema, ScimUserCreateSchema } from "./schemas.js";
23import { ifMatchMatches } from "./etag.js";
24import type { ScimGroupCreate, ScimPatch, ScimUserCreate } from "./schemas.js";
25
26
28interface ScimReq extends Request {
29 scimOrg?: { id: string; scimConfig: unknown };
30}
31
32@UseFilters(ScimExceptionsFilter)
33@UseGuards(ScimBearerGuard)
34@Controller("scim/v2")
35export class ScimUsersController {
36 constructor(@Inject(ScimUsersService) private readonly users: ScimUsersService) {}
37
38 @Post("Users")
39 async create(@Req() req: ScimReq, @Body(ZodValidate(ScimUserCreateSchema)) body: ScimUserCreate, @Res() res: Response) {
40 const { rep, etag, location } = await this.users.create(req, this.org(req), body);
41 res.setHeader("content-type", "application/scim+json; charset=utf-8");
42 res.setHeader("ETag", etag);
43 res.setHeader("Location", location);
44 res.status(201).json(rep);
45 }
46
47 @Get("Users")
48 async list(@Req() req: ScimReq, @Query() query: { filter?: string; startIndex?: string; count?: string }, @Res() res: Response) {
49 const result = await this.users.list(this.org(req), query);
50 res.setHeader("content-type", "application/scim+json; charset=utf-8");
51 if (result.etag) res.setHeader("ETag", result.etag);
52 res.status(200).json({
53 schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
54 totalResults: result.totalResults,
55 startIndex: result.startIndex,
56 itemsPerPage: result.itemsPerPage,
57 Resources: result.resources
58 });
59 }
60
61 @Get("Users/:id")
62 async get(@Req() req: ScimReq, @Param("id") id: string, @Res() res: Response) {
63 const { rep, etag } = await this.users.get(this.org(req), id);
64 res.setHeader("content-type", "application/scim+json; charset=utf-8");
65 res.setHeader("ETag", etag);
66 res.status(200).json(rep);
67 }
68
69 @Patch("Users/:id")
70 async patch(
71 @Req() req: ScimReq,
72 @Param("id") id: string,
73 @Body(ZodValidate(ScimPatchSchema)) body: ScimPatch,
74 @Res() res: Response
75 ) {
76 const org = this.org(req);
77
78 const current = await this.users.get(org, id);
79 if (!ifMatchMatches(req.headers["if-match"] as string | undefined, current.etag)) {
80 throw ScimError.preconditionFailed();
81 }
82 const { rep, etag } = await this.users.patch(req, org, id, body);
83 res.setHeader("content-type", "application/scim+json; charset=utf-8");
84 res.setHeader("ETag", etag);
85 res.status(200).json(rep);
86 }
87
88 @Delete("Users/:id")
89 async delete(@Req() req: ScimReq, @Param("id") id: string, @Res() res: Response) {
90 const org = this.org(req);
91 const current = await this.users.get(org, id);
92 if (!ifMatchMatches(req.headers["if-match"] as string | undefined, current.etag)) {
93 throw ScimError.preconditionFailed();
94 }
95 const { rep } = await this.users.delete(req, org, id);
96 res.setHeader("content-type", "application/scim+json; charset=utf-8");
97 res.status(200).json(rep);
98 }
99
100 private org(req: ScimReq): { id: string; scimConfig: unknown } {
101 if (!req.scimOrg) throw ScimError.unauthorized();
102 return req.scimOrg as { id: string; scimConfig: unknown };
103 }
104}
105
106@UseFilters(ScimExceptionsFilter)
107@UseGuards(ScimBearerGuard)
108@Controller("scim/v2")
109export class ScimGroupsController {
110 constructor(@Inject(ScimGroupsService) private readonly groups: ScimGroupsService) {}
111
112 @Post("Groups")
113 async create(@Req() req: ScimReq, @Body(ZodValidate(ScimGroupCreateSchema)) body: ScimGroupCreate, @Res() res: Response) {
114 const { rep, etag, location } = await this.groups.create(req, this.org(req), body);
115 res.setHeader("content-type", "application/scim+json; charset=utf-8");
116 res.setHeader("ETag", etag);
117 res.setHeader("Location", location);
118 res.status(201).json(rep);
119 }
120
121 @Get("Groups")
122 async list(@Req() req: ScimReq, @Query() query: { filter?: string; startIndex?: string; count?: string }, @Res() res: Response) {
123 const result = await this.groups.list(this.org(req), query);
124 res.setHeader("content-type", "application/scim+json; charset=utf-8");
125 res.status(200).json({
126 schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
127 totalResults: result.totalResults,
128 startIndex: result.startIndex,
129 itemsPerPage: result.itemsPerPage,
130 Resources: result.resources
131 });
132 }
133
134 @Get("Groups/:id")
135 async get(@Req() req: ScimReq, @Param("id") id: string, @Res() res: Response) {
136 const { rep, etag } = await this.groups.get(this.org(req), id);
137 res.setHeader("content-type", "application/scim+json; charset=utf-8");
138 res.setHeader("ETag", etag);
139 res.status(200).json(rep);
140 }
141
142 @Patch("Groups/:id")
143 async patch(
144 @Req() req: ScimReq,
145 @Param("id") id: string,
146 @Body(ZodValidate(ScimPatchSchema)) body: ScimPatch,
147 @Res() res: Response
148 ) {
149 const org = this.org(req);
150 const current = await this.groups.get(org, id);
151 if (!ifMatchMatches(req.headers["if-match"] as string | undefined, current.etag)) {
152 throw ScimError.preconditionFailed();
153 }
154 const { rep, etag } = await this.groups.patch(req, org, id, body.Operations ?? body.operations!);
155 res.setHeader("content-type", "application/scim+json; charset=utf-8");
156 res.setHeader("ETag", etag);
157 res.status(200).json(rep);
158 }
159
160 @Delete("Groups/:id")
161 async delete(@Req() req: ScimReq, @Param("id") id: string, @Res() res: Response) {
162 const org = this.org(req);
163 const current = await this.groups.get(org, id);
164 if (!ifMatchMatches(req.headers["if-match"] as string | undefined, current.etag)) {
165 throw ScimError.preconditionFailed();
166 }
167 const { rep } = await this.groups.delete(req, org, id);
168 res.setHeader("content-type", "application/scim+json; charset=utf-8");
169 res.status(200).json(rep);
170 }
171
172 private org(req: ScimReq): { id: string; scimConfig: unknown } {
173 if (!req.scimOrg) throw ScimError.unauthorized();
174 return req.scimOrg as { id: string; scimConfig: unknown };
175 }
176}
177