@owlmeans/iam 0.1.18-rc.8 → 0.1.18

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.
Files changed (110) hide show
  1. package/README.md +185 -15
  2. package/agent-meta/manifest.json +92 -4
  3. package/agent-meta/skills/iam/SKILL.md +91 -177
  4. package/agent-meta/skills/iam/parts/clients.md +93 -0
  5. package/agent-meta/skills/iam/parts/permissions.md +106 -0
  6. package/agent-meta/skills/iam/parts/service.md +115 -0
  7. package/agent-meta/skills/iam/parts/tenancy.md +72 -0
  8. package/build/consts.d.ts +170 -1
  9. package/build/consts.d.ts.map +1 -1
  10. package/build/consts.js +221 -0
  11. package/build/consts.js.map +1 -1
  12. package/build/consts.local.d.ts +58 -0
  13. package/build/consts.local.d.ts.map +1 -0
  14. package/build/consts.local.js +21 -0
  15. package/build/consts.local.js.map +1 -0
  16. package/build/errors.d.ts +26 -0
  17. package/build/errors.d.ts.map +1 -1
  18. package/build/errors.js +38 -0
  19. package/build/errors.js.map +1 -1
  20. package/build/facet.d.ts +10 -0
  21. package/build/facet.d.ts.map +1 -0
  22. package/build/facet.js +16 -0
  23. package/build/facet.js.map +1 -0
  24. package/build/gate/consts.local.d.ts +2 -0
  25. package/build/gate/consts.local.d.ts.map +1 -0
  26. package/build/gate/consts.local.js +3 -0
  27. package/build/gate/consts.local.js.map +1 -0
  28. package/build/gate/index.d.ts +4 -0
  29. package/build/gate/index.d.ts.map +1 -0
  30. package/build/gate/index.js +4 -0
  31. package/build/gate/index.js.map +1 -0
  32. package/build/gate/param.d.ts +6 -0
  33. package/build/gate/param.d.ts.map +1 -0
  34. package/build/gate/param.js +83 -0
  35. package/build/gate/param.js.map +1 -0
  36. package/build/gate/resolve.d.ts +10 -0
  37. package/build/gate/resolve.d.ts.map +1 -0
  38. package/build/gate/resolve.js +93 -0
  39. package/build/gate/resolve.js.map +1 -0
  40. package/build/gate/types.d.ts +109 -0
  41. package/build/gate/types.d.ts.map +1 -0
  42. package/build/gate/types.js +2 -0
  43. package/build/gate/types.js.map +1 -0
  44. package/build/gate/types.local.d.ts +5 -0
  45. package/build/gate/types.local.d.ts.map +1 -0
  46. package/build/gate/types.local.js +2 -0
  47. package/build/gate/types.local.js.map +1 -0
  48. package/build/gate/validate.d.ts +4 -0
  49. package/build/gate/validate.d.ts.map +1 -0
  50. package/build/gate/validate.js +100 -0
  51. package/build/gate/validate.js.map +1 -0
  52. package/build/index.d.ts +9 -0
  53. package/build/index.d.ts.map +1 -1
  54. package/build/index.js +5 -0
  55. package/build/index.js.map +1 -1
  56. package/build/permission/types.d.ts +35 -0
  57. package/build/permission/types.d.ts.map +1 -0
  58. package/build/permission/types.js +2 -0
  59. package/build/permission/types.js.map +1 -0
  60. package/build/permission.d.ts +8 -0
  61. package/build/permission.d.ts.map +1 -0
  62. package/build/permission.js +34 -0
  63. package/build/permission.js.map +1 -0
  64. package/build/runtime/types.d.ts +90 -0
  65. package/build/runtime/types.d.ts.map +1 -0
  66. package/build/runtime/types.js +2 -0
  67. package/build/runtime/types.js.map +1 -0
  68. package/build/runtime.d.ts +75 -0
  69. package/build/runtime.d.ts.map +1 -0
  70. package/build/runtime.js +75 -0
  71. package/build/runtime.js.map +1 -0
  72. package/build/schemas.d.ts +13 -0
  73. package/build/schemas.d.ts.map +1 -0
  74. package/build/schemas.js +108 -0
  75. package/build/schemas.js.map +1 -0
  76. package/build/types.d.ts +429 -30
  77. package/build/types.d.ts.map +1 -1
  78. package/build/utils/types.d.ts +12 -0
  79. package/build/utils/types.d.ts.map +1 -0
  80. package/build/utils/types.js +2 -0
  81. package/build/utils/types.js.map +1 -0
  82. package/build/utils.d.ts +7 -6
  83. package/build/utils.d.ts.map +1 -1
  84. package/build/utils.js +10 -1
  85. package/build/utils.js.map +1 -1
  86. package/package.json +12 -7
  87. package/src/consts.local.ts +26 -0
  88. package/src/consts.ts +241 -1
  89. package/src/errors.ts +44 -0
  90. package/src/facet.ts +17 -0
  91. package/src/gate/consts.local.ts +3 -0
  92. package/src/gate/index.ts +3 -0
  93. package/src/gate/param.ts +108 -0
  94. package/src/gate/resolve.ts +114 -0
  95. package/src/gate/types.local.ts +4 -0
  96. package/src/gate/types.ts +117 -0
  97. package/src/gate/validate.ts +123 -0
  98. package/src/index.ts +9 -0
  99. package/src/permission/types.ts +33 -0
  100. package/src/permission.ts +48 -0
  101. package/src/runtime/types.ts +107 -0
  102. package/src/runtime.ts +142 -0
  103. package/src/schemas.ts +122 -0
  104. package/src/types.ts +492 -31
  105. package/src/utils/types.ts +11 -0
  106. package/src/utils.ts +12 -7
  107. package/tests/gate-param.spec.ts +233 -0
  108. package/tests/runtime.spec.ts +101 -0
  109. package/tests/tsconfig.json +12 -0
  110. package/tests/utils.spec.ts +47 -1
package/src/types.ts CHANGED
@@ -1,5 +1,21 @@
1
1
  import type { InitializedService } from '@owlmeans/context'
2
- import type { OidcProviderConfig } from '@owlmeans/oidc'
2
+ import type { OidcOrganizationClaim, OidcPermissionSetClaim, OidcProviderConfig } from '@owlmeans/oidc'
3
+ import { type IamDefaultClass, type IamGrantMode, type IamGrantOrigin, type IamRemovalPolicy, IAM_AREAS, IAM_MODE_INTEGRATED, IAM_MODE_KEYCLOAK } from './consts.js'
4
+
5
+ /**
6
+ * A client's own configuration, stored with its registration.
7
+ *
8
+ * The two tenancy flags decide which organizations the client's subjects act in. With NEITHER, every
9
+ * subject acts in the client's owning organization (a single-tenant app). With either, every subject
10
+ * acts in organizations of its own, and the runtime IAM API lets their owners manage them — `users`
11
+ * opens the `user` area to those owners, `operators` the `operator` area. Anything else is the
12
+ * client's arbitrary configuration, carried through untouched.
13
+ */
14
+ export interface IamClientConfig {
15
+ operators?: boolean
16
+ users?: boolean
17
+ [key: string]: unknown
18
+ }
3
19
 
4
20
  export interface IamClient {
5
21
  id?: string
@@ -8,8 +24,12 @@ export interface IamClient {
8
24
  name?: string
9
25
  /** The entity realm this client belongs to — replaces the old (client as any)._realm hack */
10
26
  realm?: string
27
+ config?: IamClientConfig
11
28
  }
12
29
 
30
+ /** A client as a reader that must never see its secret gets it. */
31
+ export interface IamClientInfo extends Omit<IamClient, 'secret'> {}
32
+
13
33
  export interface IamCredentialsPair {
14
34
  token: string
15
35
  realm: string
@@ -28,15 +48,56 @@ export interface IamClientOptions {
28
48
  * unusable client.
29
49
  */
30
50
  redirectUris?: string[]
51
+ /**
52
+ * Merged SHALLOWLY into the stored configuration: a key passed is written (a flag passed as
53
+ * `false` included), a key omitted is kept. A backend with no tenancy refuses a tenancy flag.
54
+ */
55
+ config?: IamClientConfig
31
56
  }
32
57
 
33
58
  export interface IamPermissionArgs {
34
59
  /** Action name. When absent the permission is unscoped (project-wide). */
35
60
  permission?: string
36
- /** Declares that grants of this permission may be bound to specific resource ids. */
61
+ /**
62
+ * Declares that grants of this permission may be bound to specific resource ids.
63
+ *
64
+ * It does NOT mean grants must be: the same name is grantable in either form. A grant carrying no
65
+ * resource ids covers every resource, which is what `hasPermission` already implements.
66
+ */
37
67
  resourceScoped?: boolean
68
+ /**
69
+ * Declares that grants of this permission are bound to ONE organization: stored on the row of that
70
+ * organization (or a group of it) and effective only while the subject acts in it.
71
+ *
72
+ * Turning it off while grants exist is refused — every bound grant would silently widen to
73
+ * every organization of its subject.
74
+ */
75
+ entityScoped?: boolean
38
76
  /** Optional human-readable title for the permission definition. */
39
77
  title?: string
78
+ /**
79
+ * Grouping tag — one of `IAM_AREAS`, or any string a deployment defines.
80
+ *
81
+ * Purely presentational: it groups the permission in an administrator's screen and is never read
82
+ * when a request is authorized. It is a TAG rather than a name segment precisely so an existing
83
+ * permission can be grouped without renaming it, which would orphan every grant already made.
84
+ */
85
+ area?: string
86
+ /**
87
+ * Marks a permission the PLATFORM owns rather than one the application declared for itself.
88
+ *
89
+ * The distinction is not cosmetic: an operator must not be able to revoke a platform marker from
90
+ * an undifferentiated list, and a repair that deletes "definitions no declaration names" must not
91
+ * reach one — a marker can be enforced by a runtime decoration rather than by a gate parameter,
92
+ * so its absence from the declarations is not evidence that it is unused.
93
+ */
94
+ managed?: boolean
95
+ /**
96
+ * Who holds it without an explicit grant. `User` needs an unbound, unmanaged `user`-area
97
+ * definition; `Member` / `Owner` need an `entityScoped` one. `None` (or omitted on creation) is
98
+ * nobody.
99
+ */
100
+ defaultClass?: IamDefaultClass
40
101
  }
41
102
 
42
103
  export interface IamResourceSpec {
@@ -46,33 +107,156 @@ export interface IamResourceSpec {
46
107
 
47
108
  /** A permission declared for an entity's client (project). */
48
109
  export interface IamPermissionDefinition {
49
- /** Canonical name: "res--action" or bare "res" when unscoped. */
110
+ /** Canonical name: "res--action" or bare "res" when unscoped. NEVER carries an `@` selector. */
50
111
  name: string
51
112
  resource: string
52
113
  action?: string
53
114
  resourceScoped?: boolean
115
+ /** Grants are bound to one organization. See `IamPermissionArgs.entityScoped`. */
116
+ entityScoped?: boolean
54
117
  title?: string
118
+ /** Grouping tag; presentational only. See `IamPermissionArgs.area`. */
119
+ area?: string
120
+ /** Owned by the platform rather than declared by the application. See `IamPermissionArgs.managed`. */
121
+ managed?: boolean
122
+ /** Who holds it by default. See `IamPermissionArgs.defaultClass`. */
123
+ defaultClass?: IamDefaultClass
124
+ }
125
+
126
+ /** Narrows a definition listing. An `areas` entry of `null` matches definitions carrying no tag. */
127
+ export interface IamPermissionFilter {
128
+ areas?: (string | null)[]
129
+ managed?: boolean
130
+ resourceScoped?: boolean
131
+ entityScoped?: boolean
132
+ }
133
+
134
+ /** A group, named the way it is on the wire: its organization's slug and its key in that organization. */
135
+ export interface IamGroupRef {
136
+ entitySlug: string
137
+ key: string
55
138
  }
56
139
 
57
140
  export interface IamGrantArgs {
58
141
  /** Resource ids for the resource-scoped grant form; omit for an unscoped (project-wide) grant. */
59
142
  resources?: string[]
143
+ /**
144
+ * Which form to address. THE DEFAULTS ARE ASYMMETRIC ON PURPOSE, to preserve the semantics every
145
+ * existing caller already relies on:
146
+ *
147
+ * grantPermission — `resources != null` ? Resources : Blanket
148
+ * revokePermission — `resources != null` ? Resources : All
149
+ *
150
+ * Revoke defaults to `All` because a bare `revokePermission(...)` has always meant "remove it
151
+ * everywhere". Narrowing that to `Blanket` would leave the resource-scoped set standing for every
152
+ * existing caller — a live grant surviving a revoke, which is the failure direction nobody detects.
153
+ *
154
+ * A `Blanket` or `All` revoke of a DECLARED definition RESTRICTS the subject: it writes the
155
+ * subject's own `false`, which cuts what it would inherit (class default, group, bundle).
156
+ * `Inherit` (revoke-only) drops that own value instead, grant or restriction.
157
+ *
158
+ * Pass `mode` explicitly rather than depending on the default.
159
+ */
160
+ mode?: IamGrantMode
161
+ /**
162
+ * The organization a grant of an `entityScoped` definition is bound to — REQUIRED for one (it
163
+ * names the subject's row there, which must exist) and refused for any other definition.
164
+ */
165
+ entitySlug?: string
60
166
  }
61
167
 
62
- /** A permission granted to an end-user subject of an entity's client. */
168
+ /**
169
+ * A permission held by ONE subject — a person (`profileId`) or a group (`group`), never both — of an
170
+ * entity's client.
171
+ */
63
172
  export interface IamGrant {
64
- /** Backend-specific subject id: integrated = IdentityProfile.profileId, keycloak = KC user id. */
65
- profileId: string
173
+ /** Backend-specific subject id: integrated = the pairwise `profileId`, keycloak = KC user id. */
174
+ profileId?: string
175
+ /** A group subject. */
176
+ group?: IamGroupRef
66
177
  clientId: string
67
178
  /** Canonical permission name. */
68
179
  permission: string
69
180
  /** Present only for resource-scoped grants. */
70
181
  resources?: string[]
182
+ /** Which form this record is. Derived from `resources`, not new information. */
183
+ mode?: IamGrantMode
184
+ /** The organization the grant is bound to; absent for an unbound one. */
185
+ entitySlug?: string
186
+ /** Why the subject holds it. Listings report it; a write answers `Direct`. */
187
+ origin?: IamGrantOrigin
188
+ /** The group a `Group`-origin grant reaches the person through. */
189
+ through?: IamGroupRef
190
+ /**
191
+ * Present (`true`) only when a listing asked for restrictions (`IamGrantListArgs.restricted`): an
192
+ * inherited grant — by default class, group or bundle — that the subject's OWN restriction
193
+ * withholds. It is NOT held and never reaches a claim; a console shows it so the restriction can
194
+ * be lifted (`IamGrantMode.Inherit`).
195
+ */
196
+ restricted?: boolean
197
+ }
198
+
199
+ /** What a grant listing adds to what is held. */
200
+ export interface IamGrantListArgs {
201
+ /** Also list the inherited grants the subject's own restriction withholds, as `restricted: true`. */
202
+ restricted?: boolean
203
+ }
204
+
205
+ /** Grants every definition a filter selects, plus any named outright. */
206
+ export interface IamGrantBundle {
207
+ filter?: IamPermissionFilter
208
+ /** Extra names included regardless of the filter — a platform marker, for instance. */
209
+ permissions?: string[]
210
+ /** Defaults to `Blanket`: a tier of access is a role, not a resource ACL. */
211
+ mode?: IamGrantMode
212
+ /** Only meaningful with `mode: Resources`. */
213
+ resources?: string[]
214
+ /** The binding of the bundle's `entityScoped` definitions. See `IamGrantArgs.entitySlug`. */
215
+ entitySlug?: string
216
+ }
217
+
218
+ export interface IamPermissionDeleteArgs {
219
+ /** Defaults to `Cascade`. */
220
+ policy?: IamRemovalPolicy
221
+ /** Required to delete a `managed` definition. Defaults to false. */
222
+ managed?: boolean
71
223
  }
72
224
 
73
- /** An end-user of an entity (customer-wide; shared across that entity's projects). */
225
+ export interface IamPermissionRemoval {
226
+ permission: string
227
+ clientId: string
228
+ /** The definition existed and was removed. False is a normal, non-error outcome. */
229
+ found: boolean
230
+ /** Grants stripped as part of the removal. */
231
+ revoked: IamGrant[]
232
+ }
233
+
234
+ export interface IamNormalizeArgs {
235
+ /** Compute and report the plan without writing anything. */
236
+ dryRun?: boolean
237
+ }
238
+
239
+ export interface IamNormalizationReport {
240
+ clientId: string
241
+ renamed: { from: string, to: string, grantsMoved: number }[]
242
+ merged: { from: string, into: string, grantsMoved: number }[]
243
+ /** Well-formed definitions deliberately left alone. */
244
+ untouched: string[]
245
+ }
246
+
247
+ /** One organization a user of a client belongs to there. */
248
+ export interface IamMembershipInfo {
249
+ entitySlug: string
250
+ title?: string
251
+ owner: boolean
252
+ groups: string[]
253
+ /** The organization the user starts in. */
254
+ home?: boolean
255
+ }
256
+
257
+ /** A subject of one client (app): one account, seen through that app's rows. */
74
258
  export interface IamUser {
75
- /** Backend-specific subject id: integrated = IdentityProfile.profileId, keycloak = KC user id. */
259
+ /** Backend-specific subject id: integrated = the pairwise `profileId`, keycloak = KC user id. */
76
260
  profileId: string
77
261
  /** Primary login identifier (email for the integrated OTP path). */
78
262
  email?: string
@@ -82,9 +266,12 @@ export interface IamUser {
82
266
  disabled?: boolean
83
267
  /** Convenience count of permission grants the user holds (across clients, or for one client). */
84
268
  grantCount?: number
269
+ /** The `entitySlug` of the organization the user starts in. */
270
+ home?: string
271
+ memberships?: IamMembershipInfo[]
85
272
  }
86
273
 
87
- /** Args to invite/create an end-user under an entity. */
274
+ /** Args to invite/create an end-user of a client. */
88
275
  export interface IamUserInvite {
89
276
  email: string
90
277
  name?: string
@@ -92,13 +279,180 @@ export interface IamUserInvite {
92
279
  role?: string
93
280
  }
94
281
 
95
- /** Args to update an existing end-user. */
282
+ /** Args to update an existing end-user. `disabled` is written on the user's primary row. */
96
283
  export interface IamUserUpdate {
97
284
  name?: string
98
285
  role?: string
99
286
  disabled?: boolean
100
287
  }
101
288
 
289
+ /** An organization as a client's administration sees it. Named by slug only. */
290
+ export interface IamOrganization {
291
+ entitySlug: string
292
+ title?: string
293
+ /** How many subjects of the client are members. */
294
+ members?: number
295
+ }
296
+
297
+ /** `organizations.create`: the organization's title, and the subject who becomes its owner. */
298
+ export interface IamOrganizationArgs {
299
+ title?: string
300
+ /** A subject of the client — the creator, on the runtime path. */
301
+ profileId?: string
302
+ }
303
+
304
+ export interface IamOrganizationUpdate {
305
+ title: string
306
+ }
307
+
308
+ /** A member of an organization, as one client sees it. */
309
+ export interface IamMember {
310
+ profileId: string
311
+ email?: string
312
+ name?: string
313
+ owner: boolean
314
+ /** Group keys of (this organization, this client). */
315
+ groups: string[]
316
+ /** Written by the staff synchronization; an operator cannot change it. */
317
+ managed?: boolean
318
+ }
319
+
320
+ /** Find-or-create by e-mail: an invitation and a first sign-in converge on one account. */
321
+ export interface IamMemberInvite {
322
+ email: string
323
+ name?: string
324
+ owner?: boolean
325
+ }
326
+
327
+ export interface IamMemberUpdate {
328
+ owner?: boolean
329
+ groups?: string[]
330
+ }
331
+
332
+ /** What a group grants its members: every definition the filter selects, plus names outright. */
333
+ export interface IamGroupBundle {
334
+ filter?: IamPermissionFilter
335
+ permissions?: string[]
336
+ }
337
+
338
+ /** A group of (organization, client) — kept inside the organization record. */
339
+ export interface IamGroup {
340
+ entitySlug: string
341
+ key: string
342
+ title?: string
343
+ /**
344
+ * Kept by the platform (the staff sync's `members`): its membership, bundles and existence are
345
+ * not an operator's to write. Its grants and restrictions are.
346
+ */
347
+ managed?: boolean
348
+ members?: number
349
+ bundles: IamGroupBundle[]
350
+ }
351
+
352
+ export interface IamGroupArgs {
353
+ title?: string
354
+ bundles?: IamGroupBundle[]
355
+ }
356
+
357
+ /** `syncStaff`: what the managed `members` group of the owning organization grants. */
358
+ export interface IamStaffSyncArgs {
359
+ bundles: IamGroupBundle[]
360
+ }
361
+
362
+ /** The subjects whose managed rows the sync created and removed. */
363
+ export interface IamStaffSyncReport {
364
+ added: string[]
365
+ removed: string[]
366
+ }
367
+
368
+ /** Who a subject is to a client, as the provider builds its claims. */
369
+ export interface IamSubject {
370
+ /** The pairwise subject: `subjects.identify(clientId, accountId)`. */
371
+ profileId: string
372
+ email?: string
373
+ name?: string
374
+ organizations: OidcOrganizationClaim[]
375
+ /** The FULL claim sets: a set bound to an organization carries its `entitySlug`. */
376
+ permissions: OidcPermissionSetClaim[]
377
+ }
378
+
379
+ export interface IamSignInArgs {
380
+ email: string
381
+ name?: string
382
+ }
383
+
384
+ export interface IamSignInResult {
385
+ accountId: string
386
+ profileId: string
387
+ }
388
+
389
+ /**
390
+ * The organizations a client's subjects act in. Every method takes `(entityId, clientId, …)`: the
391
+ * entity that OWNS the client, then the client — the organizations themselves are named by slug.
392
+ */
393
+ export interface IamOrganizationFacet {
394
+ /** Every organization with a member of the client. */
395
+ list: (entityId: string, clientId: string) => Promise<IamOrganization[]>
396
+ get: (entityId: string, clientId: string, entitySlug: string) => Promise<IamOrganization | null>
397
+ create: (entityId: string, clientId: string, args?: IamOrganizationArgs) => Promise<IamOrganization>
398
+ update: (entityId: string, clientId: string, entitySlug: string, update: IamOrganizationUpdate) => Promise<IamOrganization>
399
+ members: (entityId: string, clientId: string, entitySlug: string) => Promise<IamMember[]>
400
+ /** Idempotent by e-mail. Refused past the membership cap. */
401
+ addMember: (entityId: string, clientId: string, entitySlug: string, invite: IamMemberInvite) => Promise<IamMember>
402
+ /** Refused when it would leave the organization without an owner. */
403
+ updateMember: (
404
+ entityId: string, clientId: string, entitySlug: string, profileId: string, update: IamMemberUpdate
405
+ ) => Promise<IamMember>
406
+ /** Refused for the last owner. */
407
+ removeMember: (entityId: string, clientId: string, entitySlug: string, profileId: string) => Promise<void>
408
+ }
409
+
410
+ /**
411
+ * Groups of (organization, client). Every method takes `(entityId, clientId, entitySlug, …)` and the
412
+ * group key after it where one is addressed; one client never reads or writes another client's group.
413
+ */
414
+ export interface IamGroupFacet {
415
+ list: (entityId: string, clientId: string, entitySlug: string) => Promise<IamGroup[]>
416
+ ensure: (entityId: string, clientId: string, entitySlug: string, key: string, args?: IamGroupArgs) => Promise<IamGroup>
417
+ /** Also drops the key from every member row. Refused for a managed group. */
418
+ remove: (entityId: string, clientId: string, entitySlug: string, key: string) => Promise<void>
419
+ members: (entityId: string, clientId: string, entitySlug: string, key: string) => Promise<IamMember[]>
420
+ addMembers: (entityId: string, clientId: string, entitySlug: string, key: string, profileIds: string[]) => Promise<void>
421
+ removeMembers: (entityId: string, clientId: string, entitySlug: string, key: string, profileIds: string[]) => Promise<void>
422
+ /** The binding of a group grant is the group's organization; `args.entitySlug` is not taken. */
423
+ grant: (
424
+ entityId: string, clientId: string, entitySlug: string, key: string, permission: string,
425
+ args?: Omit<IamGrantArgs, 'entitySlug'>
426
+ ) => Promise<IamGrant>
427
+ revoke: (
428
+ entityId: string, clientId: string, entitySlug: string, key: string, permission: string,
429
+ args?: Omit<IamGrantArgs, 'entitySlug'>
430
+ ) => Promise<void>
431
+ /**
432
+ * What the group holds: its own grants (`Direct`) and the names its bundles select (`Bundle`),
433
+ * minus the names it restricts — which `args.restricted` lists too, as `restricted: true`.
434
+ */
435
+ grants: (
436
+ entityId: string, clientId: string, entitySlug: string, key: string, args?: IamGrantListArgs
437
+ ) => Promise<IamGrant[]>
438
+ setBundles: (
439
+ entityId: string, clientId: string, entitySlug: string, key: string, bundles: IamGroupBundle[]
440
+ ) => Promise<IamGroup>
441
+ }
442
+
443
+ /**
444
+ * The provider's half: who signs in to a client and what the claims say about them. Keyed by the
445
+ * ACCOUNT, which never leaves the provider; what a client sees is the pairwise subject.
446
+ */
447
+ export interface IamSubjectFacet {
448
+ /** `null` = not admitted: no row of the client, or a disabled primary row. */
449
+ resolve: (clientId: string, accountId: string) => Promise<IamSubject | null>
450
+ /** Find-or-create the account by e-mail and its rows for the client. Race-safe. */
451
+ signIn: (clientId: string, args: IamSignInArgs) => Promise<IamSignInResult>
452
+ /** The pairwise `sub` of the account for the client — computed, the same on every call. */
453
+ identify: (clientId: string, accountId: string) => string
454
+ }
455
+
102
456
  /** Unified IAM provider interface — all platform/agent code calls only this, never a backend directly */
103
457
  export interface IamService extends InitializedService {
104
458
  // --- Admin config (backend → OIDC RP config, payment provisioning) ---
@@ -132,6 +486,9 @@ export interface IamService extends InitializedService {
132
486
  */
133
487
  ensureClient: (entityId: string, clientId: string, options?: IamClientOptions) => Promise<IamClient>
134
488
 
489
+ /** The entity's client without its secret, or null when there is none. Never provisions. */
490
+ getClient: (entityId: string, clientId: string) => Promise<IamClientInfo | null>
491
+
135
492
  /**
136
493
  * Reserve a client id for the entity without provisioning it, so a caller can find a free name
137
494
  * before committing to it. Returns false when the id is already taken — by this entity or any
@@ -144,14 +501,29 @@ export interface IamService extends InitializedService {
144
501
  claimClient: (entityId: string, clientId: string) => Promise<boolean>
145
502
 
146
503
  /**
147
- * Release a client id and everything keyed by it. Called when a project or slot is deleted —
148
- * without it a recreated project can inherit a stale registration.
504
+ * Release a client id and everything keyed by it — its subjects' rows, its groups and its
505
+ * definitions. Called when a project or slot is deleted; without it a recreated project can
506
+ * inherit a stale registration.
149
507
  */
150
508
  deleteClient: (entityId: string, clientId: string) => Promise<void>
151
509
 
152
510
  /**
153
511
  * Ensures a permission/resource exists in the entity's client.
154
512
  * Returns the canonical resource name (e.g. "res--action" or "res").
513
+ *
514
+ * MERGES into an existing definition rather than replacing it: `resource` and `action` are
515
+ * re-derived and overwrite, while `title`, `resourceScoped`, `entityScoped`, `area`, `managed` and
516
+ * `defaultClass` are set when provided — including when provided as `false` / `None` — and KEPT
517
+ * when omitted. Replacing wholesale made every flag last-write-wins, so a caller that happened not
518
+ * to pass one erased it.
519
+ *
520
+ * It NEVER rewrites the name. Callers round-trip a definition's `resource` + `action` back through
521
+ * this method to copy a definition set between clients, so a rename here would silently orphan
522
+ * every grant made against the old name.
523
+ *
524
+ * The `resource` argument must not carry a gate selector: `@` is the gate's syntax and is never
525
+ * part of a stored name. Implementations reject one rather than storing a key no gate looks up.
526
+ * A `defaultClass` the definition's flags do not admit is refused (`IamPermissionError`).
155
527
  */
156
528
  ensurePermission: (
157
529
  entityId: string,
@@ -160,6 +532,49 @@ export interface IamService extends InitializedService {
160
532
  args?: IamPermissionArgs
161
533
  ) => Promise<string>
162
534
 
535
+ /**
536
+ * Removes permission definitions, and by default every grant that referenced them.
537
+ *
538
+ * Takes NAMES: the definition record is keyed by name, and a malformed definition's stored
539
+ * `resource`/`action` cannot be trusted to recompose that key. Accepts an array so a repair costs
540
+ * one sweep of the entity's subjects rather than one per name.
541
+ *
542
+ * Idempotent — a name with no definition yields `{ found: false }` and writes nothing. Every other
543
+ * lifecycle operation here converges rather than erroring, and two repairs may run concurrently on
544
+ * one project.
545
+ *
546
+ * Implementations MUST revoke the grants before dropping the definition. There is no transaction
547
+ * across the two stores, and a crash between them must leave a definition with no grants — visible
548
+ * and re-runnable — rather than grants with no definition, which are invisible to any screen that
549
+ * renders the definition list yet still satisfy a gate.
550
+ *
551
+ * A `managed` definition is refused unless `args.managed` is explicitly true.
552
+ */
553
+ deletePermission: (
554
+ entityId: string,
555
+ clientId: string,
556
+ permission: string | string[],
557
+ args?: IamPermissionDeleteArgs
558
+ ) => Promise<IamPermissionRemoval[]>
559
+
560
+ /**
561
+ * Repairs definitions whose stored name carries a gate selector, moving grants with them.
562
+ *
563
+ * Strictly a rename and merge — never a delete, never an invention. Where the stripped name
564
+ * already exists the two are merged, because one entrypoint scoping a permission by `:id` and
565
+ * another by `:enquiryId` describe ONE permission. The target is marked resource-scoped: a leaked
566
+ * selector is positive evidence that it is, and that evidence is otherwise discarded.
567
+ *
568
+ * Grants move in the same write that renames the definition, which is why this exists as its own
569
+ * operation: reconstructing it as delete + re-declare + re-grant drops real access on any failure
570
+ * between the three steps.
571
+ */
572
+ normalizePermissions: (
573
+ entityId: string,
574
+ clientId: string,
575
+ args?: IamNormalizeArgs
576
+ ) => Promise<IamNormalizationReport>
577
+
163
578
  /**
164
579
  * Idempotent: creates a resource, a client role for it, and assigns that role to
165
580
  * the entity-owner role. Used by payment provisioning for account/project/wl resources.
@@ -172,12 +587,17 @@ export interface IamService extends InitializedService {
172
587
 
173
588
  // --- Authorization (permission definitions & grants) ---
174
589
 
175
- /** Lists permission definitions registered for the entity's client. */
176
- listPermissions: (entityId: string, clientId: string) => Promise<IamPermissionDefinition[]>
590
+ /** Lists permission definitions registered for the entity's client, optionally narrowed. */
591
+ listPermissions: (
592
+ entityId: string,
593
+ clientId: string,
594
+ filter?: IamPermissionFilter
595
+ ) => Promise<IamPermissionDefinition[]>
177
596
 
178
597
  /**
179
598
  * Grants a permission to an end-user subject. With args.resources the grant is
180
599
  * resource-scoped (bound to those resource ids); without it the grant is project-wide.
600
+ * An `entityScoped` definition is granted on the subject's row of `args.entitySlug`.
181
601
  */
182
602
  grantPermission: (
183
603
  entityId: string,
@@ -189,7 +609,8 @@ export interface IamService extends InitializedService {
189
609
 
190
610
  /**
191
611
  * Revokes a grant. With args.resources only those resource ids are removed;
192
- * without it the whole grant is removed.
612
+ * without it the whole grant is removed. A blanket revoke of a declared definition also
613
+ * restricts the subject from what it inherits; `IamGrantMode.Inherit` takes that back.
193
614
  */
194
615
  revokePermission: (
195
616
  entityId: string,
@@ -199,29 +620,69 @@ export interface IamService extends InitializedService {
199
620
  args?: IamGrantArgs
200
621
  ) => Promise<void>
201
622
 
202
- /** Lists grants for the entity's client, optionally for a single subject. */
203
- listGrants: (entityId: string, clientId: string, profileId?: string) => Promise<IamGrant[]>
623
+ /**
624
+ * Grants every permission a bundle selects to each subject, idempotently.
625
+ *
626
+ * Exists as its own operation rather than a loop over `grantPermission` because a backend that
627
+ * loads-mutates-saves a subject per call turns N permissions across M subjects into N·M writes and
628
+ * N·M lost-update windows on the same records. Collapsing to one read and one write per subject is
629
+ * also what makes re-running it genuinely idempotent.
630
+ *
631
+ * Returns what each subject now holds, not what changed.
632
+ */
633
+ grantBundle: (
634
+ entityId: string,
635
+ clientId: string,
636
+ profileIds: string[],
637
+ bundle: IamGrantBundle
638
+ ) => Promise<IamGrant[]>
639
+
640
+ /**
641
+ * Lists grants for the entity's client, optionally for a single subject — what each holds
642
+ * directly, by its definition's default class and through its groups, `origin` telling which.
643
+ * Only what is HELD, unless `args.restricted` adds the inherited grants the subject's own
644
+ * restriction withholds (`restricted: true`).
645
+ */
646
+ listGrants: (
647
+ entityId: string, clientId: string, profileId?: string, args?: IamGrantListArgs
648
+ ) => Promise<IamGrant[]>
204
649
 
205
- // --- End-user management (customer-wide users, shared per entityId) ---
650
+ // --- End-user management (per client: rows of one app) ---
206
651
 
207
652
  /**
208
- * Lists the entity's end-users. End-users are customer-wide — shared across every project of
209
- * the entity — so `clientId` scopes the reported `grantCount` to one project's client and does
210
- * **not** filter the set: a user who has authenticated against a project but holds no grant
211
- * there is still that project's user, and the screen that manages grants is exactly where they
212
- * must be visible.
653
+ * Lists the subjects of the client — every account holding a row of that app, in whichever
654
+ * organization. Without `clientId` it is a read-only aggregate over every client the entity
655
+ * owns, for a console; nothing is written through it.
213
656
  */
214
657
  listUsers: (entityId: string, clientId?: string) => Promise<IamUser[]>
215
658
 
216
- /** Loads a single end-user by subject id, or null when absent. */
217
- getUser: (entityId: string, profileId: string) => Promise<IamUser | null>
659
+ /** Loads a single subject of the client by its id, or null when absent. */
660
+ getUser: (entityId: string, clientId: string, profileId: string) => Promise<IamUser | null>
661
+
662
+ /** Creates (or resolves, idempotently by email) a subject of the client. */
663
+ inviteUser: (entityId: string, clientId: string, invite: IamUserInvite) => Promise<IamUser>
218
664
 
219
- /** Creates (or resolves, idempotently by email) an end-user under the entity. */
220
- inviteUser: (entityId: string, invite: IamUserInvite) => Promise<IamUser>
665
+ /** Updates a subject's mutable fields; `disabled` is written on its primary row. */
666
+ updateUser: (entityId: string, clientId: string, profileId: string, update: IamUserUpdate) => Promise<IamUser>
221
667
 
222
- /** Updates an end-user's mutable fields (name, role, disabled). */
223
- updateUser: (entityId: string, profileId: string, update: IamUserUpdate) => Promise<IamUser>
668
+ /** Deletes this account's rows of the client only — the account and its other apps stay. */
669
+ removeUser: (entityId: string, clientId: string, profileId: string) => Promise<void>
224
670
 
225
- /** Removes an end-user from the entity (their grants go with them). */
226
- removeUser: (entityId: string, profileId: string) => Promise<void>
671
+ /**
672
+ * Admits the owning organization's own staff to the client: keeps the managed `IAM_MEMBERS_GROUP`
673
+ * group there with `bundles`, a managed row per staff account, and removes the managed rows of
674
+ * people no longer on the staff.
675
+ */
676
+ syncStaff: (entityId: string, clientId: string, args: IamStaffSyncArgs) => Promise<IamStaffSyncReport>
677
+
678
+ // --- Tenancy facets — a backend without them answers `unsupportedFacet(...)` ---
679
+
680
+ organizations: IamOrganizationFacet
681
+ groups: IamGroupFacet
682
+ subjects: IamSubjectFacet
227
683
  }
684
+
685
+ // Kept as a type: a union of the mode constants' literal types.
686
+ export type IamMode = typeof IAM_MODE_KEYCLOAK | typeof IAM_MODE_INTEGRATED
687
+
688
+ export type IamArea = typeof IAM_AREAS[number]
@@ -0,0 +1,11 @@
1
+ export interface HasPermissionOptions {
2
+ /** Restrict the check to PermissionSets of this scope (the project's clientId). */
3
+ scope?: string
4
+ /** Resource-scoped check: the set must list this id — unless the set itself is unscoped. */
5
+ resourceId?: string
6
+ /**
7
+ * The organization the request acts in. A set bound to an organization (one carrying
8
+ * `entitySlug`) counts only when this names that same organization.
9
+ */
10
+ entitySlug?: string
11
+ }