auth-scim-role-maps.ts182 lines · main
1/**
2 * SCIM group displayName → Briven org + role (Phase 9.2).
3 *
4 * When a SCIM Group is created/updated with members, any role map whose
5 * display_name matches (case-insensitive) adds those users to the org.
6 */
7
8import { NotFoundError, ValidationError, newId } from '@briven/shared';
9
10import { runInProjectDatabase } from '../db/data-plane.js';
11import { addOrgMember } from './auth-orgs.js';
12import { ensureScimTables } from './auth-scim.js';
13import { log } from '../lib/logger.js';
14
15export interface ScimRoleMap {
16 id: string;
17 displayName: string;
18 orgId: string;
19 role: string;
20 createdAt: string;
21 updatedAt: string;
22}
23
24export async function listScimRoleMaps(projectId: string): Promise<ScimRoleMap[]> {
25 await ensureScimTables(projectId);
26 // ensure role map table (self-heal)
27 await ensureRoleMapTable(projectId);
28 const rows = await runInProjectDatabase<
29 Array<{
30 id: string;
31 display_name: string;
32 org_id: string;
33 role: string;
34 created_at: Date;
35 updated_at: Date;
36 }>
37 >(projectId, async (tx) =>
38 tx.unsafe(
39 `SELECT id, display_name, org_id, role, created_at, updated_at
40 FROM "_briven_auth_scim_role_maps"
41 ORDER BY display_name ASC`,
42 ),
43 );
44 return rows.map((r) => ({
45 id: r.id,
46 displayName: r.display_name,
47 orgId: r.org_id,
48 role: r.role,
49 createdAt: r.created_at.toISOString(),
50 updatedAt: r.updated_at.toISOString(),
51 }));
52}
53
54export async function upsertScimRoleMap(
55 projectId: string,
56 input: { displayName: string; orgId: string; role?: string },
57): Promise<ScimRoleMap> {
58 await ensureScimTables(projectId);
59 await ensureRoleMapTable(projectId);
60 const displayName = input.displayName.trim();
61 if (!displayName) throw new ValidationError('displayName required');
62 if (!input.orgId.trim()) throw new ValidationError('orgId required');
63 const role = (input.role ?? 'member').trim() || 'member';
64 const id = newId('scimm');
65 const now = new Date().toISOString();
66
67 await runInProjectDatabase(projectId, async (tx) => {
68 // Store + match display names case-insensitively by normalizing to lower
69 // on write. DoltGres rejects expression indexes on lower(col).
70 const nameKey = displayName.toLowerCase();
71 const existing = (await tx.unsafe(
72 `SELECT id FROM "_briven_auth_scim_role_maps" WHERE display_name = $1 LIMIT 1`,
73 [nameKey] as never[],
74 )) as Array<{ id: string }>;
75 if (existing[0]) {
76 await tx.unsafe(
77 `UPDATE "_briven_auth_scim_role_maps"
78 SET org_id = $2, role = $3, updated_at = $4::timestamptz
79 WHERE id = $1`,
80 [existing[0].id, input.orgId, role, now] as never[],
81 );
82 } else {
83 await tx.unsafe(
84 `INSERT INTO "_briven_auth_scim_role_maps"
85 (id, display_name, org_id, role, created_at, updated_at)
86 VALUES ($1, $2, $3, $4, $5::timestamptz, $5::timestamptz)`,
87 [id, nameKey, input.orgId, role, now] as never[],
88 );
89 }
90 });
91
92 const maps = await listScimRoleMaps(projectId);
93 const found = maps.find((m) => m.displayName.toLowerCase() === displayName.toLowerCase());
94 if (!found) throw new Error('role map upsert failed');
95 return found;
96}
97
98export async function deleteScimRoleMap(projectId: string, mapId: string): Promise<void> {
99 await ensureRoleMapTable(projectId);
100 const deleted = await runInProjectDatabase<Array<{ id: string }>>(projectId, async (tx) =>
101 tx.unsafe(
102 `DELETE FROM "_briven_auth_scim_role_maps" WHERE id = $1 RETURNING id`,
103 [mapId] as never[],
104 ),
105 );
106 if (deleted.length === 0) throw new NotFoundError('role map not found');
107}
108
109/**
110 * Apply maps for a SCIM group: for each member SCIM user id, resolve
111 * platform user_id and add to mapped org with role.
112 */
113export async function applyScimGroupRoleMaps(
114 projectId: string,
115 groupDisplayName: string,
116 memberScimUserIds: string[],
117): Promise<{ applied: number; errors: string[] }> {
118 await ensureRoleMapTable(projectId);
119 const maps = await listScimRoleMaps(projectId);
120 const map = maps.find((m) => m.displayName.toLowerCase() === groupDisplayName.toLowerCase());
121 if (!map) return { applied: 0, errors: [] };
122
123 let applied = 0;
124 const errors: string[] = [];
125
126 for (const scimUserId of memberScimUserIds) {
127 try {
128 const rows = await runInProjectDatabase<Array<{ user_id: string }>>(projectId, async (tx) =>
129 tx.unsafe(
130 `SELECT user_id FROM "_briven_auth_scim_users" WHERE id = $1 LIMIT 1`,
131 [scimUserId] as never[],
132 ),
133 );
134 const userId = rows[0]?.user_id;
135 if (!userId) {
136 errors.push(`no user for scim id ${scimUserId}`);
137 continue;
138 }
139 const role = map.role === 'admin' ? 'admin' : 'member';
140 try {
141 await addOrgMember(projectId, map.orgId, userId, role);
142 } catch (err) {
143 const msg = err instanceof Error ? err.message : String(err);
144 // Already a member is success for IdP re-sync.
145 if (!/already a member/i.test(msg)) throw err;
146 }
147 applied += 1;
148 } catch (err) {
149 errors.push(err instanceof Error ? err.message : String(err));
150 }
151 }
152
153 log.info('scim_group_role_map_applied', {
154 projectId,
155 groupDisplayName,
156 orgId: map.orgId,
157 role: map.role,
158 applied,
159 errors: errors.length,
160 });
161
162 return { applied, errors };
163}
164
165async function ensureRoleMapTable(projectId: string): Promise<void> {
166 await runInProjectDatabase(projectId, async (tx) => {
167 await tx.unsafe(
168 `CREATE TABLE IF NOT EXISTS "_briven_auth_scim_role_maps" (
169 id text PRIMARY KEY,
170 display_name text NOT NULL,
171 org_id text NOT NULL,
172 role text NOT NULL DEFAULT 'member',
173 created_at timestamptz NOT NULL DEFAULT now(),
174 updated_at timestamptz NOT NULL DEFAULT now()
175 )`.replace(/\s+/g, ' '),
176 );
177 await tx.unsafe(
178 `CREATE UNIQUE INDEX IF NOT EXISTS "_briven_auth_scim_role_maps_name_uniq"
179 ON "_briven_auth_scim_role_maps" (display_name)`.replace(/\s+/g, ' '),
180 );
181 });
182}