@twinfinity/permission 6.0.0 → 6.0.1-ci.28952

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,13 @@
1
1
  import { TwinfinityHttpClient, HttpMethod } from '@twinfinity/authentication';
2
- import type { PaginatedResponse, User, Group, PutGroupRequest, DeleteGroupRequest, PermissionErrorBody } from './types';
2
+ import type {
3
+ PaginatedResponse,
4
+ User,
5
+ Group,
6
+ GroupMember,
7
+ PutGroupRequest,
8
+ DeleteGroupRequest,
9
+ PermissionErrorBody
10
+ } from './types';
3
11
  import {
4
12
  PermissionValidationError,
5
13
  PermissionError,
@@ -9,10 +17,24 @@ import {
9
17
  } from './errors';
10
18
 
11
19
  export interface IPermissionClient {
12
- listUsers(page?: string, limit?: number, signal?: AbortSignal): Promise<PaginatedResponse<User>>;
13
- listGroups(page?: string, limit?: number, signal?: AbortSignal): Promise<PaginatedResponse<Group>>;
20
+ listUsers(page?: string, limit?: number, q?: string, signal?: AbortSignal): Promise<PaginatedResponse<User>>;
21
+ listGroups(page?: string, limit?: number, q?: string, signal?: AbortSignal): Promise<PaginatedResponse<Group>>;
14
22
  putGroup(id: string, request: PutGroupRequest, signal?: AbortSignal): Promise<Group>;
15
23
  deleteGroup(id: string, request: DeleteGroupRequest, signal?: AbortSignal): Promise<Group>;
24
+ listGroupMembers(
25
+ groupId: string,
26
+ page?: string,
27
+ limit?: number,
28
+ signal?: AbortSignal
29
+ ): Promise<PaginatedResponse<User>>;
30
+ addGroupMember(groupId: string, userId: string, signal?: AbortSignal): Promise<GroupMember>;
31
+ removeGroupMember(groupId: string, userId: string, signal?: AbortSignal): Promise<GroupMember>;
32
+ listUserGroups(
33
+ userId: string,
34
+ page?: string,
35
+ limit?: number,
36
+ signal?: AbortSignal
37
+ ): Promise<PaginatedResponse<Group>>;
16
38
  }
17
39
 
18
40
  function isErrorBody(value: unknown): value is PermissionErrorBody {
@@ -23,11 +45,21 @@ async function readErrorBody(response: Response): Promise<PermissionErrorBody |
23
45
  try {
24
46
  const json = await response.json();
25
47
  return isErrorBody(json) ? json : undefined;
26
- } catch {
48
+ } catch (err) {
49
+ if (err instanceof Error && err.name === 'AbortError') throw err;
27
50
  return undefined;
28
51
  }
29
52
  }
30
53
 
54
+ async function readBodyText(response: Response): Promise<string> {
55
+ try {
56
+ return await response.text();
57
+ } catch (err) {
58
+ if (err instanceof Error && err.name === 'AbortError') throw err;
59
+ return '';
60
+ }
61
+ }
62
+
31
63
  export class PermissionClient implements IPermissionClient {
32
64
  private readonly _usersUrl: string;
33
65
  private readonly _groupsUrl: string;
@@ -44,11 +76,13 @@ export class PermissionClient implements IPermissionClient {
44
76
  async listUsers(
45
77
  page: string | undefined = undefined,
46
78
  limit = 500,
79
+ q?: string,
47
80
  signal?: AbortSignal
48
81
  ): Promise<PaginatedResponse<User>> {
49
82
  const params = new URLSearchParams();
50
83
  if (page !== undefined) params.set('page', page);
51
84
  params.set('limit', limit.toString());
85
+ if (q) params.set('q', q);
52
86
 
53
87
  const url = `${this._usersUrl}?${params}`;
54
88
 
@@ -70,7 +104,7 @@ export class PermissionClient implements IPermissionClient {
70
104
  throw new PermissionForbiddenError(body?.detail ?? 'Insufficient permissions to list users', body);
71
105
  }
72
106
 
73
- const errorText = await response.text();
107
+ const errorText = await readBodyText(response);
74
108
  throw new PermissionError(
75
109
  `Failed to list users: ${response.status} ${response.statusText} - ${errorText}`,
76
110
  response.status,
@@ -81,11 +115,13 @@ export class PermissionClient implements IPermissionClient {
81
115
  async listGroups(
82
116
  page: string | undefined = undefined,
83
117
  limit = 500,
118
+ q?: string,
84
119
  signal?: AbortSignal
85
120
  ): Promise<PaginatedResponse<Group>> {
86
121
  const params = new URLSearchParams();
87
122
  if (page !== undefined) params.set('page', page);
88
123
  params.set('limit', limit.toString());
124
+ if (q) params.set('q', q);
89
125
 
90
126
  const url = `${this._groupsUrl}?${params}`;
91
127
 
@@ -107,7 +143,7 @@ export class PermissionClient implements IPermissionClient {
107
143
  throw new PermissionForbiddenError(body?.detail ?? 'Insufficient permissions to list groups', body);
108
144
  }
109
145
 
110
- const errorText = await response.text();
146
+ const errorText = await readBodyText(response);
111
147
  throw new PermissionError(
112
148
  `Failed to list groups: ${response.status} ${response.statusText} - ${errorText}`,
113
149
  response.status,
@@ -136,7 +172,8 @@ export class PermissionClient implements IPermissionClient {
136
172
  let json: unknown;
137
173
  try {
138
174
  json = await response.json();
139
- } catch {
175
+ } catch (err) {
176
+ if (err instanceof Error && err.name === 'AbortError') throw err;
140
177
  throw new PermissionConflictError('Group conflict', undefined);
141
178
  }
142
179
  const body = isErrorBody(json) ? json : undefined;
@@ -160,7 +197,7 @@ export class PermissionClient implements IPermissionClient {
160
197
  throw new PermissionValidationError(body?.detail ?? 'Group validation failed', body);
161
198
  }
162
199
 
163
- const errorText = await response.text();
200
+ const errorText = await readBodyText(response);
164
201
  throw new PermissionError(
165
202
  `Failed to put group: ${response.status} ${response.statusText} - ${errorText}`,
166
203
  response.status,
@@ -168,6 +205,170 @@ export class PermissionClient implements IPermissionClient {
168
205
  );
169
206
  }
170
207
 
208
+ async listGroupMembers(
209
+ groupId: string,
210
+ page: string | undefined = undefined,
211
+ limit = 500,
212
+ signal?: AbortSignal
213
+ ): Promise<PaginatedResponse<User>> {
214
+ const params = new URLSearchParams();
215
+ if (page !== undefined) params.set('page', page);
216
+ params.set('limit', limit.toString());
217
+
218
+ const url = `${this._groupsUrl}/${encodeURIComponent(groupId)}/members?${params}`;
219
+
220
+ const response = await this._httpClient.fetch(HttpMethod.Get, url, { signal });
221
+
222
+ if (response.ok) {
223
+ return (await response.json()) as PaginatedResponse<User>;
224
+ }
225
+
226
+ if (response.status === 404) {
227
+ const body = await readErrorBody(response);
228
+ throw new PermissionNotFoundError(body?.detail ?? `Group '${groupId}' not found`, body);
229
+ }
230
+
231
+ if (response.status === 400) {
232
+ const body = await readErrorBody(response);
233
+ throw new PermissionValidationError(body?.detail ?? 'Group member listing validation failed', body);
234
+ }
235
+
236
+ if (response.status === 403) {
237
+ const body = await readErrorBody(response);
238
+ throw new PermissionForbiddenError(body?.detail ?? 'Insufficient permissions to list group members', body);
239
+ }
240
+
241
+ const errorText = await readBodyText(response);
242
+ throw new PermissionError(
243
+ `Failed to list group members: ${response.status} ${response.statusText} - ${errorText}`,
244
+ response.status,
245
+ response.statusText
246
+ );
247
+ }
248
+
249
+ async addGroupMember(groupId: string, userId: string, signal?: AbortSignal): Promise<GroupMember> {
250
+ const url = `${this._groupsUrl}/${encodeURIComponent(groupId)}/members/${encodeURIComponent(userId)}`;
251
+
252
+ const response = await this._httpClient.fetch(HttpMethod.Put, url, { signal });
253
+
254
+ if (response.ok) {
255
+ return (await response.json()) as GroupMember;
256
+ }
257
+
258
+ if (response.status === 404) {
259
+ const body = await readErrorBody(response);
260
+ throw new PermissionNotFoundError(body?.detail ?? 'Group or user not found', body);
261
+ }
262
+
263
+ if (response.status === 400) {
264
+ const body = await readErrorBody(response);
265
+ throw new PermissionValidationError(body?.detail ?? 'Cannot modify this group', body);
266
+ }
267
+
268
+ if (response.status === 403) {
269
+ const body = await readErrorBody(response);
270
+ throw new PermissionForbiddenError(body?.detail ?? 'Insufficient permissions to add group member', body);
271
+ }
272
+
273
+ if (response.status === 409) {
274
+ const body = await readErrorBody(response);
275
+ throw new PermissionConflictError(
276
+ body?.detail ?? 'Group membership was modified concurrently, please retry',
277
+ undefined,
278
+ body
279
+ );
280
+ }
281
+
282
+ const errorText = await readBodyText(response);
283
+ throw new PermissionError(
284
+ `Failed to add group member: ${response.status} ${response.statusText} - ${errorText}`,
285
+ response.status,
286
+ response.statusText
287
+ );
288
+ }
289
+
290
+ async removeGroupMember(groupId: string, userId: string, signal?: AbortSignal): Promise<GroupMember> {
291
+ const url = `${this._groupsUrl}/${encodeURIComponent(groupId)}/members/${encodeURIComponent(userId)}`;
292
+
293
+ const response = await this._httpClient.fetch(HttpMethod.Delete, url, { signal });
294
+
295
+ if (response.ok) {
296
+ return (await response.json()) as GroupMember;
297
+ }
298
+
299
+ if (response.status === 404) {
300
+ const body = await readErrorBody(response);
301
+ throw new PermissionNotFoundError(body?.detail ?? 'Group, user, or membership not found', body);
302
+ }
303
+
304
+ if (response.status === 400) {
305
+ const body = await readErrorBody(response);
306
+ throw new PermissionValidationError(body?.detail ?? 'Cannot modify this group', body);
307
+ }
308
+
309
+ if (response.status === 403) {
310
+ const body = await readErrorBody(response);
311
+ throw new PermissionForbiddenError(body?.detail ?? 'Insufficient permissions to remove group member', body);
312
+ }
313
+
314
+ if (response.status === 409) {
315
+ const body = await readErrorBody(response);
316
+ throw new PermissionConflictError(
317
+ body?.detail ?? 'Group membership was modified concurrently, please retry',
318
+ undefined,
319
+ body
320
+ );
321
+ }
322
+
323
+ const errorText = await readBodyText(response);
324
+ throw new PermissionError(
325
+ `Failed to remove group member: ${response.status} ${response.statusText} - ${errorText}`,
326
+ response.status,
327
+ response.statusText
328
+ );
329
+ }
330
+
331
+ async listUserGroups(
332
+ userId: string,
333
+ page: string | undefined = undefined,
334
+ limit = 500,
335
+ signal?: AbortSignal
336
+ ): Promise<PaginatedResponse<Group>> {
337
+ const params = new URLSearchParams();
338
+ if (page !== undefined) params.set('page', page);
339
+ params.set('limit', limit.toString());
340
+
341
+ const url = `${this._usersUrl}/${encodeURIComponent(userId)}/groups?${params}`;
342
+
343
+ const response = await this._httpClient.fetch(HttpMethod.Get, url, { signal });
344
+
345
+ if (response.ok) {
346
+ return (await response.json()) as PaginatedResponse<Group>;
347
+ }
348
+
349
+ if (response.status === 404) {
350
+ const body = await readErrorBody(response);
351
+ throw new PermissionNotFoundError(body?.detail ?? `User '${userId}' not found`, body);
352
+ }
353
+
354
+ if (response.status === 400) {
355
+ const body = await readErrorBody(response);
356
+ throw new PermissionValidationError(body?.detail ?? 'User group listing validation failed', body);
357
+ }
358
+
359
+ if (response.status === 403) {
360
+ const body = await readErrorBody(response);
361
+ throw new PermissionForbiddenError(body?.detail ?? 'Insufficient permissions to list user groups', body);
362
+ }
363
+
364
+ const errorText = await readBodyText(response);
365
+ throw new PermissionError(
366
+ `Failed to list user groups: ${response.status} ${response.statusText} - ${errorText}`,
367
+ response.status,
368
+ response.statusText
369
+ );
370
+ }
371
+
171
372
  async deleteGroup(id: string, request: DeleteGroupRequest, signal?: AbortSignal): Promise<Group> {
172
373
  const url = `${this._groupsUrl}/${encodeURIComponent(id)}`;
173
374
  const response = await this._httpClient.fetch(HttpMethod.Delete, url, {
@@ -188,7 +389,8 @@ export class PermissionClient implements IPermissionClient {
188
389
  let json: unknown;
189
390
  try {
190
391
  json = await response.json();
191
- } catch {
392
+ } catch (err) {
393
+ if (err instanceof Error && err.name === 'AbortError') throw err;
192
394
  throw new PermissionConflictError('Group was modified by another user', undefined);
193
395
  }
194
396
  const body = isErrorBody(json) ? json : undefined;
@@ -209,7 +411,7 @@ export class PermissionClient implements IPermissionClient {
209
411
  throw new PermissionValidationError(body?.detail ?? 'Group deletion validation failed', body);
210
412
  }
211
413
 
212
- const errorText = await response.text();
414
+ const errorText = await readBodyText(response);
213
415
  throw new PermissionError(
214
416
  `Failed to delete group: ${response.status} ${response.statusText} - ${errorText}`,
215
417
  response.status,
package/src/types.ts CHANGED
@@ -7,6 +7,14 @@ export interface Pagination {
7
7
  export interface PaginatedResponse<T> {
8
8
  data: T[];
9
9
  pagination: Pagination;
10
+ /**
11
+ * The caller's effective permissions on the parent resource that governs every item in the
12
+ * page — populated by endpoints where the authorization subject is shared across all rows
13
+ * (currently `GET /groups/{id}/members`, where this reflects the caller's mask on the
14
+ * group's management scope). Absent for endpoints whose items each have their own
15
+ * authorization subject.
16
+ */
17
+ callerPermissions?: PrincipalPermission[];
10
18
  }
11
19
 
12
20
  export interface User {
@@ -17,26 +25,54 @@ export interface User {
17
25
  lastName: string | null;
18
26
  }
19
27
 
28
+ /**
29
+ * A user returned from group-membership endpoints. Extends {@link User} with the target user's
30
+ * effective permissions on the containing group's management scope, reflecting the post-mutation
31
+ * state after an add/remove.
32
+ */
33
+ export interface GroupMember extends User {
34
+ permissions: PrincipalPermission[];
35
+ }
36
+
20
37
  export enum PrincipalType {
21
38
  Unknown = 'Unknown',
22
39
  DomainGroup = 'DomainGroup',
23
40
  Group = 'Group'
24
41
  }
25
42
 
43
+ /**
44
+ * The permission bits a subject can hold on a group's management scope.
45
+ *
46
+ * - `View` — can see the group and its basic metadata (title, description, display name).
47
+ * - `Edit` — can create and modify groups.
48
+ * - `Delete` — can delete the group.
49
+ * - `ViewMembers` — can list the group's members.
50
+ * - `ManagePermissions` — can update permissions connected to the group (e.g. grant another user Edit on it).
51
+ */
52
+ export type PrincipalPermission = 'View' | 'Edit' | 'Delete' | 'ViewMembers' | 'ManagePermissions';
53
+
26
54
  export interface Group {
27
55
  id: string;
28
56
  ownerSystem: string;
29
57
  principalType: PrincipalType;
30
58
  title: string;
31
59
  displayName: string | null;
60
+ description: string | null;
32
61
  etag: string;
33
62
  isEditable: boolean;
63
+ /**
64
+ * The effective permission bits the relevant subject holds on this group's management scope.
65
+ * The subject is the caller on most endpoints, and the target user on `GET /users/{id}/groups`.
66
+ * An empty array means no permissions are granted.
67
+ */
68
+ permissions: PrincipalPermission[];
34
69
  }
35
70
 
36
71
  export interface PutGroupRequest {
37
72
  /** Title for the group. Used on create; ignored on update (title is immutable). */
38
73
  title: string;
39
74
  displayName?: string | null;
75
+ description?: string | null;
40
76
  etag?: string | null;
41
77
  }
42
78