@owlmeans/iam 0.1.18-rc.2 → 0.1.18-rc.21

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 (48) hide show
  1. package/README.md +2 -2
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/iam/SKILL.md +193 -56
  4. package/build/consts.d.ts +91 -0
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +97 -0
  7. package/build/consts.js.map +1 -1
  8. package/build/errors.d.ts +13 -0
  9. package/build/errors.d.ts.map +1 -1
  10. package/build/errors.js +17 -0
  11. package/build/errors.js.map +1 -1
  12. package/build/gate/index.d.ts +4 -0
  13. package/build/gate/index.d.ts.map +1 -0
  14. package/build/gate/index.js +4 -0
  15. package/build/gate/index.js.map +1 -0
  16. package/build/gate/param.d.ts +31 -0
  17. package/build/gate/param.d.ts.map +1 -0
  18. package/build/gate/param.js +99 -0
  19. package/build/gate/param.js.map +1 -0
  20. package/build/gate/resolve.d.ts +10 -0
  21. package/build/gate/resolve.d.ts.map +1 -0
  22. package/build/gate/resolve.js +93 -0
  23. package/build/gate/resolve.js.map +1 -0
  24. package/build/gate/validate.d.ts +15 -0
  25. package/build/gate/validate.d.ts.map +1 -0
  26. package/build/gate/validate.js +107 -0
  27. package/build/gate/validate.js.map +1 -0
  28. package/build/index.d.ts +2 -0
  29. package/build/index.d.ts.map +1 -1
  30. package/build/index.js +2 -0
  31. package/build/index.js.map +1 -1
  32. package/build/permission.d.ts +21 -0
  33. package/build/permission.d.ts.map +1 -0
  34. package/build/permission.js +39 -0
  35. package/build/permission.js.map +1 -0
  36. package/build/types.d.ts +251 -6
  37. package/build/types.d.ts.map +1 -1
  38. package/package.json +7 -6
  39. package/src/consts.ts +105 -0
  40. package/src/errors.ts +19 -0
  41. package/src/gate/index.ts +3 -0
  42. package/src/gate/param.ts +122 -0
  43. package/src/gate/resolve.ts +114 -0
  44. package/src/gate/validate.ts +132 -0
  45. package/src/index.ts +2 -0
  46. package/src/permission.ts +48 -0
  47. package/src/types.ts +283 -6
  48. package/tests/gate-param.spec.ts +234 -0
@@ -0,0 +1,114 @@
1
+ import { GateParamSource, GateResolutionFailure } from '../consts.js'
2
+ import type { GateRequestLike, GateResourceSelector, GateResourceResolution } from '../types.js'
3
+
4
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
5
+ value != null && typeof value === 'object'
6
+
7
+ /**
8
+ * A resource id is a scalar. Anything else is refused rather than coerced.
9
+ *
10
+ * Stringifying an object would produce `"[object Object]"`, which then either matches nothing or —
11
+ * far worse — matches another record that stringified the same way.
12
+ */
13
+ const scalar = (value: unknown): string | null => {
14
+ switch (typeof value) {
15
+ case 'string':
16
+ return value
17
+ case 'number':
18
+ case 'boolean':
19
+ case 'bigint':
20
+ return `${value}`
21
+ default:
22
+ return null
23
+ }
24
+ }
25
+
26
+ const sourceOf = (req: GateRequestLike, source: GateParamSource): unknown => {
27
+ switch (source) {
28
+ case GateParamSource.Params: return req.params
29
+ case GateParamSource.Query: return req.query
30
+ case GateParamSource.Body: return req.body
31
+ case GateParamSource.Headers: return req.headers
32
+ case GateParamSource.Auth: return req.auth
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Walk one source for the selector's path.
38
+ *
39
+ * Headers get two adjustments they need and nothing else does: the first segment is lowercased,
40
+ * because HTTP header names are case-insensitive and every server normalises them differently; and a
41
+ * single-element array unwraps, because a repeated header arrives as an array and silently picking
42
+ * one of several values would be an authorization decision made by accident.
43
+ */
44
+ const walk = (
45
+ container: unknown, path: string[], source: GateParamSource
46
+ ): { value?: unknown, reason?: GateResolutionFailure } => {
47
+ if (!isRecord(container)) {
48
+ return { reason: GateResolutionFailure.SourceMissing }
49
+ }
50
+
51
+ let cursor: unknown = container
52
+ for (const [index, rawSegment] of path.entries()) {
53
+ if (!isRecord(cursor)) {
54
+ return { reason: GateResolutionFailure.NotProvided }
55
+ }
56
+
57
+ const segment = source === GateParamSource.Headers && index === 0
58
+ ? rawSegment.toLowerCase()
59
+ : rawSegment
60
+
61
+ cursor = cursor[segment]
62
+ }
63
+
64
+ if (source === GateParamSource.Headers && Array.isArray(cursor)) {
65
+ if (cursor.length !== 1) {
66
+ return { reason: GateResolutionFailure.NotScalar }
67
+ }
68
+ cursor = cursor[0]
69
+ }
70
+
71
+ if (cursor == null) {
72
+ return { reason: GateResolutionFailure.NotProvided }
73
+ }
74
+
75
+ return { value: cursor }
76
+ }
77
+
78
+ /**
79
+ * Resolve the resource id a selector points at.
80
+ *
81
+ * Sources are tried in the selector's own order — for the bare form, route params before the query —
82
+ * and the first that yields a value wins. When none does, the reason travels back so the caller can
83
+ * tell a MISCONFIGURED gate (the request never carried that key) from a legitimately DENIED one.
84
+ */
85
+ export const resolveGateResource = (
86
+ req: GateRequestLike, selector: GateResourceSelector
87
+ ): GateResourceResolution => {
88
+ let reason: GateResolutionFailure = GateResolutionFailure.SourceMissing
89
+
90
+ for (const source of selector.sources) {
91
+ const result = walk(sourceOf(req, source), selector.path, source)
92
+
93
+ if (result.reason != null) {
94
+ // A hard refusal is more informative than "keep looking" — report it rather than letting a
95
+ // later, emptier source overwrite it with a vaguer reason.
96
+ if (result.reason === GateResolutionFailure.NotScalar) {
97
+ return { reason: result.reason, from: source }
98
+ }
99
+ if (reason === GateResolutionFailure.SourceMissing) {
100
+ reason = result.reason
101
+ }
102
+ continue
103
+ }
104
+
105
+ const value = scalar(result.value)
106
+ if (value == null) {
107
+ return { reason: GateResolutionFailure.NotScalar, from: source }
108
+ }
109
+
110
+ return { id: value, from: source }
111
+ }
112
+
113
+ return { reason }
114
+ }
@@ -0,0 +1,132 @@
1
+ import { normalizePath, SEP, PARAM } from '@owlmeans/route'
2
+ import { GateParamSource, GateParamErrorCode } from '../consts.js'
3
+ import type { GateParamAudit, GateParamIssue, GateResourceSelector } from '../types.js'
4
+ import { parseGateParam } from './param.js'
5
+
6
+ /** The `:name` segments of a route template. */
7
+ export const routeParamsOf = (path: string): string[] =>
8
+ normalizePath(path).split(SEP)
9
+ .filter(segment => segment.startsWith(PARAM))
10
+ .map(segment => segment.slice(PARAM.length))
11
+
12
+ interface SchemaLike {
13
+ properties?: Record<string, unknown>
14
+ additionalProperties?: unknown
15
+ }
16
+
17
+ /**
18
+ * Whether a key can survive validation to reach the gate.
19
+ *
20
+ * The server compiles entrypoint filters with AJV's `removeAdditional`, which strips an undeclared
21
+ * key BEFORE the handler — and therefore before the gate — runs. So a selector naming a key the
22
+ * filter does not declare denies every request, with a clean build and nothing logged.
23
+ *
24
+ * The `additionalProperties === false` qualifier is what makes this check usable rather than noise:
25
+ * AJV only strips when a schema says so. An entrypoint that declares no filter for a source strips
26
+ * nothing, and reporting those would fire on nearly every endpoint and be ignored within a week.
27
+ */
28
+ const unreachable = (schema: object | undefined, key: string): boolean => {
29
+ if (schema == null) {
30
+ return false
31
+ }
32
+
33
+ const { properties, additionalProperties } = schema as SchemaLike
34
+ if (additionalProperties !== false) {
35
+ return false
36
+ }
37
+
38
+ return properties == null || !(key in properties)
39
+ }
40
+
41
+ const filterFor = (audit: GateParamAudit, source: GateParamSource): object | undefined => {
42
+ switch (source) {
43
+ case GateParamSource.Query: return audit.filter?.query
44
+ case GateParamSource.Body: return audit.filter?.body
45
+ case GateParamSource.Headers: return audit.filter?.headers
46
+ case GateParamSource.Params: return audit.filter?.params
47
+ case GateParamSource.Auth: return undefined
48
+ }
49
+ }
50
+
51
+ const auditSelector = (
52
+ audit: GateParamAudit, selector: GateResourceSelector
53
+ ): Omit<GateParamIssue, 'param'> | null => {
54
+ const key = selector.path[0]
55
+ if (key == null) {
56
+ return null
57
+ }
58
+
59
+ // The bare form searches route params first and the query second, so it is only provably wrong
60
+ // when BOTH are impossible. A declared route param settles it; otherwise fall through to the
61
+ // query check below, which only fires on an explicitly closed schema.
62
+ const routeParams = audit.routeParams
63
+ ?? (audit.routePath != null ? routeParamsOf(audit.routePath) : undefined)
64
+
65
+ if (selector.source == null) {
66
+ if (routeParams?.includes(key) === true) {
67
+ return null
68
+ }
69
+ if (unreachable(audit.filter?.query, key) && unreachable(audit.filter?.params, key)) {
70
+ return {
71
+ code: GateParamErrorCode.UnknownRouteParam,
72
+ detail: `"${key}" is not a route param of "${audit.routePath ?? '?'}"`
73
+ + ' and is declared by neither the params nor the query filter'
74
+ }
75
+ }
76
+ return null
77
+ }
78
+
79
+ if (selector.source === GateParamSource.Params) {
80
+ if (routeParams != null && !routeParams.includes(key) && unreachable(audit.filter?.params, key)) {
81
+ return {
82
+ code: GateParamErrorCode.UnknownRouteParam,
83
+ detail: `"${key}" is not a route param of "${audit.routePath ?? '?'}"`
84
+ }
85
+ }
86
+ return null
87
+ }
88
+
89
+ if (unreachable(filterFor(audit, selector.source), key)) {
90
+ return {
91
+ code: GateParamErrorCode.UnreachableKey,
92
+ detail: `the ${selector.source} filter declares no "${key}" and forbids additional properties,`
93
+ + ' so it is removed by validation before the gate can read it'
94
+ }
95
+ }
96
+
97
+ return null
98
+ }
99
+
100
+ /**
101
+ * Check gate params against what their entrypoint declares.
102
+ *
103
+ * Reports only what is provable from the declaration itself. Anything that would need a guess is
104
+ * left alone: a check that fires on correct code stops being read.
105
+ *
106
+ * Meant to run where a mistake can still be fixed — while code is being generated, or once per alias
107
+ * as a diagnostic. It must never decide a request: with `some()` semantics across params, one
108
+ * malformed sibling must not be able to refuse a legitimately passing one.
109
+ */
110
+ export const validateGateParams = (params: string[], audit?: GateParamAudit): GateParamIssue[] => {
111
+ const issues: GateParamIssue[] = []
112
+
113
+ for (const param of params) {
114
+ const parsed = parseGateParam(param)
115
+
116
+ if (parsed.error != null) {
117
+ issues.push({ param, code: parsed.error.code, detail: parsed.error.detail })
118
+ continue
119
+ }
120
+
121
+ if (parsed.resource == null || audit == null) {
122
+ continue
123
+ }
124
+
125
+ const issue = auditSelector(audit, parsed.resource)
126
+ if (issue != null) {
127
+ issues.push({ param, ...issue })
128
+ }
129
+ }
130
+
131
+ return issues
132
+ }
package/src/index.ts CHANGED
@@ -2,3 +2,5 @@ export type * from './types.js'
2
2
  export * from './consts.js'
3
3
  export * from './errors.js'
4
4
  export * from './utils.js'
5
+ export * from './gate/index.js'
6
+ export * from './permission.js'
@@ -0,0 +1,48 @@
1
+ import { PERMISSION_ACTION_SEPARATOR, RESOURCE_PARAM_SEPARATOR, GateParamErrorCode } from './consts.js'
2
+ import type { ParsedPermissionName } from './types.js'
3
+
4
+ /**
5
+ * Read a permission NAME.
6
+ *
7
+ * The name is `<resource>--<action>`, with TWO hyphens. One hyphen is not a separator: `enquiry-view`
8
+ * is a resource called `enquiry-view` carrying no action, and it must keep parsing that way — a name
9
+ * already granted somewhere cannot be re-interpreted without orphaning the grant.
10
+ *
11
+ * A name never carries a gate selector. `@` is the gate's syntax, and a name containing one is
12
+ * exactly the corruption this function exists to make visible, so it is reported through `problem`
13
+ * rather than quietly split.
14
+ */
15
+ export const parsePermissionName = (name: string): ParsedPermissionName => {
16
+ const problem = name.includes(RESOURCE_PARAM_SEPARATOR)
17
+ ? {
18
+ code: GateParamErrorCode.UnreachableKey,
19
+ detail: `"${name}" carries a gate selector; "${RESOURCE_PARAM_SEPARATOR}" is never part of a`
20
+ + ' permission name, and nothing ever looks up a name that contains one'
21
+ }
22
+ : undefined
23
+
24
+ const idx = name.indexOf(PERMISSION_ACTION_SEPARATOR)
25
+ if (idx < 0) {
26
+ return { name, resource: name, ...(problem != null ? { problem } : {}) }
27
+ }
28
+
29
+ const action = name.slice(idx + PERMISSION_ACTION_SEPARATOR.length)
30
+
31
+ return {
32
+ name,
33
+ resource: name.slice(0, idx),
34
+ ...(action !== '' ? { action } : {}),
35
+ ...(problem != null ? { problem } : {})
36
+ }
37
+ }
38
+
39
+ /** Compose a permission name. The inverse of `parsePermissionName`. */
40
+ export const composePermissionName = (
41
+ parts: { resource: string, action?: string }
42
+ ): string => parts.action == null || parts.action === ''
43
+ ? parts.resource
44
+ : `${parts.resource}${PERMISSION_ACTION_SEPARATOR}${parts.action}`
45
+
46
+ /** True when the value is a usable permission name — well-formed and carrying no gate selector. */
47
+ export const isPermissionName = (value: string): boolean =>
48
+ value !== '' && !value.includes(RESOURCE_PARAM_SEPARATOR)
package/src/types.ts CHANGED
@@ -1,5 +1,87 @@
1
1
  import type { InitializedService } from '@owlmeans/context'
2
2
  import type { OidcProviderConfig } from '@owlmeans/oidc'
3
+ import type {
4
+ GateParamSource, GateParamErrorCode, GateResolutionFailure, IamGrantMode, IamRemovalPolicy
5
+ } from './consts.js'
6
+
7
+ /**
8
+ * The parts of a request a gate selector may read.
9
+ *
10
+ * Structural on purpose: `AbstractRequest` is assignable to it, so `@owlmeans/server-iam` passes its
11
+ * request straight through, while tooling outside the server stack can resolve a selector against a
12
+ * plain object without taking a dependency on `@owlmeans/entrypoint`.
13
+ */
14
+ export interface GateRequestLike {
15
+ params?: unknown
16
+ query?: unknown
17
+ body?: unknown
18
+ headers?: unknown
19
+ auth?: unknown
20
+ }
21
+
22
+ /** Where one gate param reads its resource id from. */
23
+ export interface GateResourceSelector {
24
+ /** The selector text exactly as written, for diagnostics. */
25
+ readonly selector: string
26
+ /** Undefined for the bare form, which searches `sources` in order. */
27
+ readonly source?: GateParamSource
28
+ /** Length 1 and UNSPLIT for the bare form — a bare key may legally contain dots. */
29
+ readonly path: string[]
30
+ /** The explicit source, or `DEFAULT_GATE_PARAM_SOURCES`. */
31
+ readonly sources: GateParamSource[]
32
+ }
33
+
34
+ export interface GateParamProblem {
35
+ code: GateParamErrorCode
36
+ detail: string
37
+ }
38
+
39
+ export interface ParsedGateParam {
40
+ permission: string
41
+ /**
42
+ * @deprecated The bare-form flat key, kept so existing readers keep compiling. Read `resource`,
43
+ * which carries the source and the path for both forms.
44
+ */
45
+ resourceParam?: string
46
+ resource?: GateResourceSelector
47
+ error?: GateParamProblem
48
+ }
49
+
50
+ export interface GateResourceResolution {
51
+ id?: string
52
+ from?: GateParamSource
53
+ reason?: GateResolutionFailure
54
+ }
55
+
56
+ /** What an entrypoint declares, so a selector can be checked against it before it is ever deployed. */
57
+ export interface GateParamAudit {
58
+ routePath?: string
59
+ routeParams?: string[]
60
+ filter?: {
61
+ query?: object
62
+ params?: object
63
+ body?: object
64
+ headers?: object
65
+ }
66
+ }
67
+
68
+ export interface GateParamIssue {
69
+ param: string
70
+ code: GateParamErrorCode
71
+ detail: string
72
+ }
73
+
74
+ /** A permission NAME taken apart. Never carries a selector — `@` is the gate's syntax alone. */
75
+ export interface ParsedPermissionName {
76
+ /** The whole name, exactly as it must be stored and granted. */
77
+ name: string
78
+ /** What IAM stores as `resource`. The whole name when there is no action separator. */
79
+ resource: string
80
+ /** What IAM stores as `action`. Absent for a bare, unsplittable legacy name. */
81
+ action?: string
82
+ /** Set when the argument carried an `@` — which a NAME never legally does. */
83
+ problem?: GateParamProblem
84
+ }
3
85
 
4
86
  export interface IamClient {
5
87
  id?: string
@@ -33,10 +115,32 @@ export interface IamClientOptions {
33
115
  export interface IamPermissionArgs {
34
116
  /** Action name. When absent the permission is unscoped (project-wide). */
35
117
  permission?: string
36
- /** Declares that grants of this permission may be bound to specific resource ids. */
118
+ /**
119
+ * Declares that grants of this permission may be bound to specific resource ids.
120
+ *
121
+ * It does NOT mean grants must be: the same name is grantable in either form. A grant carrying no
122
+ * resource ids covers every resource, which is what `hasPermission` already implements.
123
+ */
37
124
  resourceScoped?: boolean
38
125
  /** Optional human-readable title for the permission definition. */
39
126
  title?: string
127
+ /**
128
+ * Grouping tag — one of `IAM_AREAS`, or any string a deployment defines.
129
+ *
130
+ * Purely presentational: it groups the permission in an administrator's screen and is never read
131
+ * when a request is authorized. It is a TAG rather than a name segment precisely so an existing
132
+ * permission can be grouped without renaming it, which would orphan every grant already made.
133
+ */
134
+ area?: string
135
+ /**
136
+ * Marks a permission the PLATFORM owns rather than one the application declared for itself.
137
+ *
138
+ * The distinction is not cosmetic: an operator must not be able to revoke a platform marker from
139
+ * an undifferentiated list, and a repair that deletes "definitions no declaration names" must not
140
+ * reach one — a marker can be enforced by a runtime decoration rather than by a gate parameter,
141
+ * so its absence from the declarations is not evidence that it is unused.
142
+ */
143
+ managed?: boolean
40
144
  }
41
145
 
42
146
  export interface IamResourceSpec {
@@ -46,17 +150,42 @@ export interface IamResourceSpec {
46
150
 
47
151
  /** A permission declared for an entity's client (project). */
48
152
  export interface IamPermissionDefinition {
49
- /** Canonical name: "res--action" or bare "res" when unscoped. */
153
+ /** Canonical name: "res--action" or bare "res" when unscoped. NEVER carries an `@` selector. */
50
154
  name: string
51
155
  resource: string
52
156
  action?: string
53
157
  resourceScoped?: boolean
54
158
  title?: string
159
+ /** Grouping tag; presentational only. See `IamPermissionArgs.area`. */
160
+ area?: string
161
+ /** Owned by the platform rather than declared by the application. See `IamPermissionArgs.managed`. */
162
+ managed?: boolean
163
+ }
164
+
165
+ /** Narrows a definition listing. An `areas` entry of `null` matches definitions carrying no tag. */
166
+ export interface IamPermissionFilter {
167
+ areas?: (string | null)[]
168
+ managed?: boolean
169
+ resourceScoped?: boolean
55
170
  }
56
171
 
57
172
  export interface IamGrantArgs {
58
173
  /** Resource ids for the resource-scoped grant form; omit for an unscoped (project-wide) grant. */
59
174
  resources?: string[]
175
+ /**
176
+ * Which form to address. THE DEFAULTS ARE ASYMMETRIC ON PURPOSE, to preserve the semantics every
177
+ * existing caller already relies on:
178
+ *
179
+ * grantPermission — `resources != null` ? Resources : Blanket
180
+ * revokePermission — `resources != null` ? Resources : All
181
+ *
182
+ * Revoke defaults to `All` because a bare `revokePermission(...)` has always meant "remove it
183
+ * everywhere". Narrowing that to `Blanket` would leave the resource-scoped set standing for every
184
+ * existing caller — a live grant surviving a revoke, which is the failure direction nobody detects.
185
+ *
186
+ * Pass `mode` explicitly rather than depending on the default.
187
+ */
188
+ mode?: IamGrantMode
60
189
  }
61
190
 
62
191
  /** A permission granted to an end-user subject of an entity's client. */
@@ -68,6 +197,48 @@ export interface IamGrant {
68
197
  permission: string
69
198
  /** Present only for resource-scoped grants. */
70
199
  resources?: string[]
200
+ /** Which form this record is. Derived from `resources`, not new information. */
201
+ mode?: IamGrantMode
202
+ }
203
+
204
+ /** Grants every definition a filter selects, plus any named outright. */
205
+ export interface IamGrantBundle {
206
+ filter?: IamPermissionFilter
207
+ /** Extra names included regardless of the filter — a platform marker, for instance. */
208
+ permissions?: string[]
209
+ /** Defaults to `Blanket`: a tier of access is a role, not a resource ACL. */
210
+ mode?: IamGrantMode
211
+ /** Only meaningful with `mode: Resources`. */
212
+ resources?: string[]
213
+ }
214
+
215
+ export interface IamPermissionDeleteArgs {
216
+ /** Defaults to `Cascade`. */
217
+ policy?: IamRemovalPolicy
218
+ /** Required to delete a `managed` definition. Defaults to false. */
219
+ managed?: boolean
220
+ }
221
+
222
+ export interface IamPermissionRemoval {
223
+ permission: string
224
+ clientId: string
225
+ /** The definition existed and was removed. False is a normal, non-error outcome. */
226
+ found: boolean
227
+ /** Grants stripped as part of the removal. */
228
+ revoked: IamGrant[]
229
+ }
230
+
231
+ export interface IamNormalizeArgs {
232
+ /** Compute and report the plan without writing anything. */
233
+ dryRun?: boolean
234
+ }
235
+
236
+ export interface IamNormalizationReport {
237
+ clientId: string
238
+ renamed: { from: string, to: string, grantsMoved: number }[]
239
+ merged: { from: string, into: string, grantsMoved: number }[]
240
+ /** Well-formed definitions deliberately left alone. */
241
+ untouched: string[]
71
242
  }
72
243
 
73
244
  /** An end-user of an entity (customer-wide; shared across that entity's projects). */
@@ -120,11 +291,50 @@ export interface IamService extends InitializedService {
120
291
  getIssuerUrl: (entityId: string) => Promise<string>
121
292
 
122
293
  // --- Provisioning (agent → story development) ---
294
+
295
+ /**
296
+ * Find-or-create the entity's OIDC client.
297
+ *
298
+ * A client id is globally unique — a provider resolves it from the bare `client_id` a relying
299
+ * party sends, with no tenant context — so an implementation MUST refuse an existing record
300
+ * that belongs to a different entity (`IamClientError('client:entity-mismatch')`) rather than
301
+ * returning it. Silently adopting one hands the caller another tenant's secret and overwrites
302
+ * that tenant's redirect URIs.
303
+ */
123
304
  ensureClient: (entityId: string, clientId: string, options?: IamClientOptions) => Promise<IamClient>
124
305
 
306
+ /**
307
+ * Reserve a client id for the entity without provisioning it, so a caller can find a free name
308
+ * before committing to it. Returns false when the id is already taken — by this entity or any
309
+ * other — and true when the reservation is now held.
310
+ *
311
+ * This exists because the id is minted from a name that may later be released (a project alias
312
+ * moves when its hostname is refused), so the naming index cannot answer whether an id is free.
313
+ * The registry is the only authority.
314
+ */
315
+ claimClient: (entityId: string, clientId: string) => Promise<boolean>
316
+
317
+ /**
318
+ * Release a client id and everything keyed by it. Called when a project or slot is deleted —
319
+ * without it a recreated project can inherit a stale registration.
320
+ */
321
+ deleteClient: (entityId: string, clientId: string) => Promise<void>
322
+
125
323
  /**
126
324
  * Ensures a permission/resource exists in the entity's client.
127
325
  * Returns the canonical resource name (e.g. "res--action" or "res").
326
+ *
327
+ * MERGES into an existing definition rather than replacing it: `resource` and `action` are
328
+ * re-derived and overwrite, while `title`, `resourceScoped`, `area` and `managed` are set when
329
+ * provided — including when provided as `false` — and KEPT when omitted. Replacing wholesale made
330
+ * every flag last-write-wins, so a caller that happened not to pass one erased it.
331
+ *
332
+ * It NEVER rewrites the name. Callers round-trip a definition's `resource` + `action` back through
333
+ * this method to copy a definition set between clients, so a rename here would silently orphan
334
+ * every grant made against the old name.
335
+ *
336
+ * The `resource` argument must not carry a gate selector: `@` is the gate's syntax and is never
337
+ * part of a stored name. Implementations reject one rather than storing a key no gate looks up.
128
338
  */
129
339
  ensurePermission: (
130
340
  entityId: string,
@@ -133,6 +343,49 @@ export interface IamService extends InitializedService {
133
343
  args?: IamPermissionArgs
134
344
  ) => Promise<string>
135
345
 
346
+ /**
347
+ * Removes permission definitions, and by default every grant that referenced them.
348
+ *
349
+ * Takes NAMES: the definition record is keyed by name, and a malformed definition's stored
350
+ * `resource`/`action` cannot be trusted to recompose that key. Accepts an array so a repair costs
351
+ * one sweep of the entity's subjects rather than one per name.
352
+ *
353
+ * Idempotent — a name with no definition yields `{ found: false }` and writes nothing. Every other
354
+ * lifecycle operation here converges rather than erroring, and two repairs may run concurrently on
355
+ * one project.
356
+ *
357
+ * Implementations MUST revoke the grants before dropping the definition. There is no transaction
358
+ * across the two stores, and a crash between them must leave a definition with no grants — visible
359
+ * and re-runnable — rather than grants with no definition, which are invisible to any screen that
360
+ * renders the definition list yet still satisfy a gate.
361
+ *
362
+ * A `managed` definition is refused unless `args.managed` is explicitly true.
363
+ */
364
+ deletePermission: (
365
+ entityId: string,
366
+ clientId: string,
367
+ permission: string | string[],
368
+ args?: IamPermissionDeleteArgs
369
+ ) => Promise<IamPermissionRemoval[]>
370
+
371
+ /**
372
+ * Repairs definitions whose stored name carries a gate selector, moving grants with them.
373
+ *
374
+ * Strictly a rename and merge — never a delete, never an invention. Where the stripped name
375
+ * already exists the two are merged, because one entrypoint scoping a permission by `:id` and
376
+ * another by `:enquiryId` describe ONE permission. The target is marked resource-scoped: a leaked
377
+ * selector is positive evidence that it is, and that evidence is otherwise discarded.
378
+ *
379
+ * Grants move in the same write that renames the definition, which is why this exists as its own
380
+ * operation: reconstructing it as delete + re-declare + re-grant drops real access on any failure
381
+ * between the three steps.
382
+ */
383
+ normalizePermissions: (
384
+ entityId: string,
385
+ clientId: string,
386
+ args?: IamNormalizeArgs
387
+ ) => Promise<IamNormalizationReport>
388
+
136
389
  /**
137
390
  * Idempotent: creates a resource, a client role for it, and assigns that role to
138
391
  * the entity-owner role. Used by payment provisioning for account/project/wl resources.
@@ -145,8 +398,12 @@ export interface IamService extends InitializedService {
145
398
 
146
399
  // --- Authorization (permission definitions & grants) ---
147
400
 
148
- /** Lists permission definitions registered for the entity's client. */
149
- listPermissions: (entityId: string, clientId: string) => Promise<IamPermissionDefinition[]>
401
+ /** Lists permission definitions registered for the entity's client, optionally narrowed. */
402
+ listPermissions: (
403
+ entityId: string,
404
+ clientId: string,
405
+ filter?: IamPermissionFilter
406
+ ) => Promise<IamPermissionDefinition[]>
150
407
 
151
408
  /**
152
409
  * Grants a permission to an end-user subject. With args.resources the grant is
@@ -172,14 +429,34 @@ export interface IamService extends InitializedService {
172
429
  args?: IamGrantArgs
173
430
  ) => Promise<void>
174
431
 
432
+ /**
433
+ * Grants every permission a bundle selects to each subject, idempotently.
434
+ *
435
+ * Exists as its own operation rather than a loop over `grantPermission` because a backend that
436
+ * loads-mutates-saves a subject per call turns N permissions across M subjects into N·M writes and
437
+ * N·M lost-update windows on the same records. Collapsing to one read and one write per subject is
438
+ * also what makes re-running it genuinely idempotent.
439
+ *
440
+ * Returns what each subject now holds, not what changed.
441
+ */
442
+ grantBundle: (
443
+ entityId: string,
444
+ clientId: string,
445
+ profileIds: string[],
446
+ bundle: IamGrantBundle
447
+ ) => Promise<IamGrant[]>
448
+
175
449
  /** Lists grants for the entity's client, optionally for a single subject. */
176
450
  listGrants: (entityId: string, clientId: string, profileId?: string) => Promise<IamGrant[]>
177
451
 
178
452
  // --- End-user management (customer-wide users, shared per entityId) ---
179
453
 
180
454
  /**
181
- * Lists the entity's end-users. With clientId, narrows to users holding at least
182
- * one grant for that client (per-project view).
455
+ * Lists the entity's end-users. End-users are customer-wide — shared across every project of
456
+ * the entity — so `clientId` scopes the reported `grantCount` to one project's client and does
457
+ * **not** filter the set: a user who has authenticated against a project but holds no grant
458
+ * there is still that project's user, and the screen that manages grants is exactly where they
459
+ * must be visible.
183
460
  */
184
461
  listUsers: (entityId: string, clientId?: string) => Promise<IamUser[]>
185
462