@owlmeans/iam 0.1.18-rc.9 → 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/README.md CHANGED
@@ -1,32 +1,202 @@
1
1
  # @owlmeans/iam
2
2
 
3
- Provider-agnostic IAM abstraction for the OwlMeans framework — `IamService` interface, client/realm types, and permission helpers.
4
-
5
- ## Overview
6
-
7
- - `IamService` — unified interface for provisioning clients, realms, and permissions across IAM backends (e.g. Keycloak, built-in)
8
- - `IamClient` / `IamCredentialsPair` / `IamResourceSpec` — shared contract types used by server-side IAM implementations
9
- - `IamPermissionArgs` — describes a named permission including resource-scope and human-readable title
10
- - `hasPermission` — utility to check whether a set of grants covers a given permission/resource pair
11
- - Errors: `IamError` and subtypes for client-provisioning and permission failures
3
+ Provider-agnostic IAM contracts: the `IamService` interface (client provisioning, permission
4
+ definitions and grants, users of a client, organization/group/subject facets), the permission-name
5
+ and gate-param grammars, `hasPermission`, the IAM errors, and the protocol declarations of the
6
+ provider's runtime IAM API. An app imports it to type the IAM service it resolves by alias, to
7
+ declare and grant permissions, to check an `Authorization` against a permission, and to build gate
8
+ params. It contains no implementation and talks to no provider — a concrete backend (a Keycloak
9
+ proxy, or the integrated backend built on `@owlmeans/server-oidc-provider`) ships separately, the
10
+ server-side gate and runtime client are `@owlmeans/server-iam`, and the browser half (organization
11
+ switch, `hasPermission` re-export) is `@owlmeans/client-iam`. Permission decisions belong on the
12
+ server; the `iam` skill holds the full model.
12
13
 
13
14
  ## Installation
14
15
 
15
16
  ```bash
16
- bun add @owlmeans/iam
17
+ bun add @owlmeans/iam@^0.1.18
17
18
  ```
18
19
 
20
+ `ajv` is a peer dependency (the runtime API schemas are `JSONSchemaType`).
21
+
22
+ ## Concepts
23
+
24
+ - **Owning organization entity vs. acting organization** — every `IamService` method takes
25
+ `entityId` first: the organization entity that **owns** the client, resolved server-side. The
26
+ organizations a client's subjects act in are named by `entitySlug` inside the facets and on the
27
+ wire.
28
+ - **Client** — an OIDC client (`IamClient`) of one organization entity. Its id is globally unique,
29
+ because a provider resolves the bare `client_id` with no tenant context.
30
+ - **Permission name** — `<resource>--<action>` with **two** hyphens (`PERMISSION_ACTION_SEPARATOR`);
31
+ a bare `<resource>` is unscoped. A name never carries `@`.
32
+ - **Permission kinds** — a definition is unbound, `entityScoped` (grants bound to one organization),
33
+ `resourceScoped` (grants may list resource ids), or both. The kind is a property of the definition.
34
+ - **Grant form** — `IamGrantMode.Blanket` (no `resources`, covers every id), `Resources` (an explicit
35
+ id list), `All` (revoke-only). The two grantable forms are stored separately.
36
+ - **Default class** — `IamDefaultClass.User` / `Member` / `Owner` / `None`: who holds a definition
37
+ without an explicit grant, evaluated when claims are built.
38
+ - **Facets** — `organizations`, `groups`, `subjects` on `IamService`; a backend without one answers
39
+ `unsupportedFacet(what)`.
40
+ - **Gate param** — `<permission>[@<selector>]` on an entrypoint guard; the selector says where the
41
+ resource id is read at request time: bare `@enquiryId` (flat key, params then query) or qualified
42
+ `@body:order.id` (one source, nested path).
43
+
19
44
  ## Usage
20
45
 
46
+ ### Check a permission
47
+
21
48
  ```typescript
22
- import type { IamService, IamClient } from '@owlmeans/iam'
23
49
  import { hasPermission } from '@owlmeans/iam'
24
50
 
25
- // Check whether an auth payload includes a required permission
26
- const allowed = hasPermission(auth, { permission: 'manage-users', resourceScoped: false })
51
+ // A blanket set satisfies a resourceId check; a bound set counts only for the named organization
52
+ const allowed = hasPermission(auth, 'order--edit', { scope: clientId, resourceId: 'ord-1', entitySlug: 'acme' })
27
53
  ```
28
54
 
29
- Typically consumed via `@owlmeans/server-iam` which wires the concrete OIDC-backed implementation.
55
+ ### Declare and grant permissions
56
+
57
+ ```typescript
58
+ import { DEFAULT_ALIAS, IamDefaultClass, IamGrantMode, permissionNameHelper } from '@owlmeans/iam'
59
+ import type { IamService } from '@owlmeans/iam'
60
+
61
+ const iam = context.service<IamService>(DEFAULT_ALIAS)
62
+
63
+ const name = await iam.ensurePermission(entityId, clientId, 'order', {
64
+ permission: 'edit', resourceScoped: true, entityScoped: true, area: 'user',
65
+ defaultClass: IamDefaultClass.Owner,
66
+ }) // 'order--edit'
67
+
68
+ await iam.grantPermission(entityId, clientId, profileId, name, {
69
+ mode: IamGrantMode.Resources, resources: ['ord-1'], entitySlug: 'acme',
70
+ })
71
+ await iam.revokePermission(entityId, clientId, profileId, name, { mode: IamGrantMode.All, entitySlug: 'acme' })
72
+
73
+ permissionNameHelper.parsePermissionName('order--edit') // { name, resource: 'order', action: 'edit' }
74
+ permissionNameHelper.composePermissionName({ resource: 'order', action: 'edit' })
75
+ ```
76
+
77
+ ### Provision a client
78
+
79
+ ```typescript
80
+ const client = await iam.ensureClient(entityId, clientId, {
81
+ redirectUris: ['https://acme.example.com/callback'],
82
+ config: { users: true }, // tenanted: subjects get organizations of their own
83
+ })
84
+ const issuer = await iam.getIssuerUrl(entityId) // pass through as the discovery URL, never rebuild it
85
+ ```
86
+
87
+ ### Work with gate params
88
+
89
+ ```typescript
90
+ import { GateParamSource, gateParamHelper, gateValidationHelper, resolveGateResource } from '@owlmeans/iam'
91
+
92
+ const param = gateParamHelper.formatGateParam('order--edit', { source: GateParamSource.Body, path: ['order', 'id'] })
93
+ // 'order--edit@body:order.id'
94
+
95
+ const { permission, resource, error } = gateParamHelper.parseGateParam(param)
96
+ const resolved = resource != null ? resolveGateResource(req, resource) : undefined // { id?, from?, reason? }
97
+
98
+ const issues = gateValidationHelper.validateGateParams(['order--view@orderId'], { routePath: '/orders/:orderId' })
99
+ ```
100
+
101
+ ### A backend without a facet
102
+
103
+ ```typescript
104
+ import { unsupportedFacet } from '@owlmeans/iam'
105
+ import type { IamGroupFacet } from '@owlmeans/iam'
106
+
107
+ const groups = unsupportedFacet<IamGroupFacet>('groups') // every method throws IamUnsupported('groups') synchronously
108
+ ```
109
+
110
+ ## API
111
+
112
+ ### `IamService` (alias `DEFAULT_ALIAS` = `'iam-service'`)
113
+
114
+ | Group | Methods |
115
+ |---|---|
116
+ | Admin config | `getEntityAdminConfig`, `getCredentialsPair`, `getIssuerUrl` |
117
+ | Clients | `ensureClient(entityId, clientId, { redirectUris?, config? }?)`, `getClient`, `claimClient`, `deleteClient` |
118
+ | Definitions | `ensurePermission`, `deletePermission(…, names, { policy?, managed? }?)`, `normalizePermissions`, `ensureResourceOwnership`, `listPermissions(…, filter?)` |
119
+ | Grants | `grantPermission`, `revokePermission`, `grantBundle`, `listGrants` |
120
+ | Users of a client | `listUsers`, `getUser`, `inviteUser`, `updateUser`, `removeUser`, `syncStaff` |
121
+ | Facets | `organizations` (`IamOrganizationFacet`), `groups` (`IamGroupFacet`), `subjects` (`IamSubjectFacet`) |
122
+
123
+ ### Functions and helpers
124
+
125
+ | Symbol | Purpose |
126
+ |---|---|
127
+ | `hasPermission(auth, permission, { scope?, resourceId?, entitySlug? }?)` | Does an `Authorization`'s permission sets grant it |
128
+ | `permissionNameHelper` (`createPermissionNameHelper()`) | `parsePermissionName`, `composePermissionName`, `isPermissionName` |
129
+ | `gateParamHelper` (`createGateParamHelper()`) | `parseGateParam`, `parseGateSelector`, `formatGateParam` |
130
+ | `gateValidationHelper` (`createGateValidationHelper()`) | `validateGateParams(params, audit?)`, `routeParamsOf(path)` |
131
+ | `resolveGateResource(req, selector)` | Read the resource id a selector points at → `{ id?, from?, reason? }` |
132
+ | `unsupportedFacet<T>(what)` | A facet whose every method throws `IamUnsupported(what)` |
133
+ | `makeIamRuntimeProtocols({ service, path? })` | Protocol tree of the runtime IAM API: `organizations.{list,create,update}`, `members.{list,add,update,remove}`, `permissions.list`, `grants.{list,assign,revoke}` |
134
+ | `parsePermissionName`, `isPermissionName`, `parseGateParam` | Deprecated standalone forms of the helper members |
135
+
136
+ ### Constants and enums
137
+
138
+ | Symbol | Value / members |
139
+ |---|---|
140
+ | `IAM_MODE_KEYCLOAK`, `IAM_MODE_INTEGRATED`, `IamMode` | `'keycloak'`, `'integrated'` — a config seam for the app's own service factory |
141
+ | `IAM_AREAS`, `IamArea` | `['user', 'operator', 'admin']` — presentational grouping tags |
142
+ | `PERMISSION_ACTION_SEPARATOR` | `'--'` |
143
+ | `RESOURCE_PARAM_SEPARATOR`, `RESOURCE_SOURCE_SEPARATOR`, `RESOURCE_PATH_SEPARATOR` | `'@'`, `':'`, `'.'` |
144
+ | `GateParamSource`, `DEFAULT_GATE_PARAM_SOURCES` | `Params` `Query` `Body` `Headers` `Auth`; bare form searches `[Params, Query]` |
145
+ | `GateParamErrorCode`, `GateResolutionFailure` | structural selector problems; why a selector yielded no id |
146
+ | `IamGrantMode`, `IamRemovalPolicy`, `IamDefaultClass`, `IamGrantOrigin` | `Blanket`/`Resources`/`All`; `Cascade`/`Refuse`; `None`/`User`/`Member`/`Owner`; `Direct`/`Default`/`Group` |
147
+ | `IAM_MEMBERS_GROUP`, `IAM_EMAIL_PROOF` | `'members'` (managed staff group), `'proof:email'` |
148
+ | `IAM_RUNTIME_GUARD`, `IAM_RUNTIME_PATH`, `IAM_RUNTIME_ROUTES` | runtime API guard alias, default base `'/runtime'`, leaf-path table |
149
+ | `IamRuntime*Schema` | closed AJV schemas of the runtime API's wire shapes |
150
+
151
+ ### Types
152
+
153
+ `IamClient`, `IamClientInfo`, `IamClientConfig`, `IamClientOptions`, `IamCredentialsPair`,
154
+ `IamPermissionArgs`, `IamPermissionDefinition`, `IamPermissionFilter`, `IamResourceSpec`,
155
+ `IamGrant`, `IamGrantArgs`, `IamGrantBundle`, `IamGroupRef`, `IamPermissionDeleteArgs`,
156
+ `IamPermissionRemoval`, `IamNormalizeArgs`, `IamNormalizationReport`, `IamUser`, `IamUserInvite`,
157
+ `IamUserUpdate`, `IamMembershipInfo`, `IamOrganization*`, `IamMember*`, `IamGroup*`,
158
+ `IamStaffSync*`, `IamSubject`, `IamSignIn*`, the three facet interfaces, `HasPermissionOptions`,
159
+ `ParsedPermissionName`, `PermissionNameHelper`, the `Gate*` types and the `IamRuntime*` wire types.
160
+
161
+ ### Errors
162
+
163
+ All extend `IamError` (a `ResilientError`) and are registered for marshalling.
164
+
165
+ | Class | Meaning |
166
+ |---|---|
167
+ | `IamClientError` | client invalid or unusable — `client:entity-mismatch`, `redirect-uris`, unconfigured provider route |
168
+ | `IamResourceError` | a provider resource came back without a name |
169
+ | `IamGrantError` | grant subject missing or entity mismatch |
170
+ | `IamPermissionError` | definition refusal — `permission:held:<name>`, `permission:managed:<name>`, a disallowed `defaultClass` |
171
+ | `IamUserError` | an end-user operation was refused |
172
+ | `IamOrganizationError` | unknown organization, last owner, membership cap |
173
+ | `IamGroupError` | unknown group key, managed group written by an operator, another client's group |
174
+ | `IamUnsupported` | the active backend cannot do it (`user-management`, `resource-scoped-grant`, a facet) |
175
+
176
+ ## Common pitfalls
177
+
178
+ - `revokePermission` without `mode` defaults to `All`, `grantPermission` to `Blanket`; pass `mode`
179
+ explicitly.
180
+ - Granting an `entityScoped` definition requires `args.entitySlug`; any other definition refuses it.
181
+ - One hyphen is not a separator: `enquiry-view` is a resource with no action. Build names with
182
+ `permissionNameHelper`, never by splitting strings.
183
+ - `@` belongs to the gate param, never to a stored name — `ensurePermission` refuses it.
184
+ - Always pass `redirectUris` to `ensureClient`; the integrated provider refuses the legacy wildcard.
185
+ - Never rebuild an issuer URL from a host and base path — use `getIssuerUrl`.
186
+ - A client id must include the owning organization entity and be reserved with `claimClient`;
187
+ `ensureClient` refuses another entity's record.
188
+ - Use `grantBundle` instead of looping over `grantPermission`.
189
+ - `getCredentialsPair` returns a raw admin token — never hand it to a request handler.
190
+ - `entityId` never comes from a token or request; the wire names organizations by `entitySlug` only.
191
+
192
+ ## Related packages
193
+
194
+ - [`@owlmeans/server-iam`](../server-iam) — the IAM gate, the session's organizations on the server, the runtime IAM client
195
+ - [`@owlmeans/client-iam`](../client-iam) — browser wiring, organization switch, `hasPermission` re-export
196
+ - [`@owlmeans/oidc`](../oidc) — provider descriptors, claim shapes, the `iamMode` config field
197
+ - [`@owlmeans/auth`](../auth) — `Authorization` and `PermissionSet`, which `hasPermission` reads
198
+ - [`@owlmeans/server-oidc-provider`](../server-oidc-provider) — the provider the integrated backend is built on
199
+ - [`@owlmeans/entrypoint`](../entrypoint) — the protocol builders behind `makeIamRuntimeProtocols`
30
200
 
31
201
  <!-- owlmeans:agent-guidance:start -->
32
202
  ## Agent guidance
@@ -36,7 +206,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
36
206
  your project's skill store (`.agents/skills/`):
37
207
 
38
208
  ```sh
39
- npx @owlmeans/agent-skills
209
+ npx @owlmeans/agent-skills@^0.1.18
40
210
  ```
41
211
 
42
212
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
- "schemaVersion": 2,
2
+ "schemaVersion": 3,
3
3
  "package": "@owlmeans/iam",
4
- "version": "0.1.18-rc.8",
5
- "generatedAt": "2026-08-24T21:14:00.118Z",
4
+ "version": "0.1.18",
5
+ "generatedAt": "2026-10-28T00:31:34.724Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -10,7 +10,95 @@
10
10
  "name": "iam",
11
11
  "category": "package-specific",
12
12
  "file": "skills/iam/SKILL.md",
13
- "canonicalPath": ".agents/skills/iam/SKILL.md"
13
+ "canonicalPath": ".agents/skills/iam/SKILL.md",
14
+ "parts": [
15
+ {
16
+ "name": "clients",
17
+ "file": "skills/iam/parts/clients.md",
18
+ "description": "Provisioning OIDC clients — globally unique client ids, the realm field, issuer and redirect-URI rules — and selecting the IAM backend.",
19
+ "purposes": [
20
+ "auth",
21
+ "config"
22
+ ],
23
+ "mentions": [
24
+ "claimClient",
25
+ "deleteClient",
26
+ "getIssuerUrl",
27
+ "redirectUris",
28
+ "IamClientError",
29
+ "IAM_MODE_INTEGRATED",
30
+ "IAM_MODE_KEYCLOAK",
31
+ "iamMode",
32
+ "IamClient"
33
+ ]
34
+ },
35
+ {
36
+ "name": "permissions",
37
+ "file": "skills/iam/parts/permissions.md",
38
+ "description": "Permission names and grant forms, IamGrantMode, the four permission kinds, default classes and restrictions.",
39
+ "purposes": [
40
+ "auth"
41
+ ],
42
+ "mentions": [
43
+ "hasPermission",
44
+ "grantPermission",
45
+ "revokePermission",
46
+ "ensurePermission",
47
+ "IamGrantMode",
48
+ "IamDefaultClass",
49
+ "composePermissionName",
50
+ "parsePermissionName",
51
+ "entityScoped",
52
+ "resourceScoped",
53
+ "defaultClass"
54
+ ]
55
+ },
56
+ {
57
+ "name": "service",
58
+ "file": "skills/iam/parts/service.md",
59
+ "description": "The full IamService interface with its per-method contracts, and the per-client user operations.",
60
+ "purposes": [
61
+ "auth",
62
+ "server-api"
63
+ ],
64
+ "mentions": [
65
+ "IamService",
66
+ "getEntityAdminConfig",
67
+ "ensureClient",
68
+ "getClient",
69
+ "grantBundle",
70
+ "listGrants",
71
+ "listUsers",
72
+ "inviteUser",
73
+ "updateUser",
74
+ "removeUser",
75
+ "syncStaff",
76
+ "normalizePermissions",
77
+ "deletePermission"
78
+ ]
79
+ },
80
+ {
81
+ "name": "tenancy",
82
+ "file": "skills/iam/parts/tenancy.md",
83
+ "description": "The organizations, groups and subjects facets of IamService, and the runtime IAM API a tenanted client's server calls.",
84
+ "purposes": [
85
+ "auth",
86
+ "server-api"
87
+ ],
88
+ "mentions": [
89
+ "IamOrganizationFacet",
90
+ "IamGroupFacet",
91
+ "IamSubjectFacet",
92
+ "unsupportedFacet",
93
+ "IAM_MEMBERS_GROUP",
94
+ "makeIamRuntimeProtocols",
95
+ "IAM_RUNTIME_GUARD",
96
+ "IAM_RUNTIME_ROUTES",
97
+ "IAM_RUNTIME_PATH",
98
+ "iamRuntime"
99
+ ]
100
+ }
101
+ ]
14
102
  }
15
103
  ]
16
104
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: iam
3
- description: "IamService abstraction for provider-agnostic IAM provisioning. Load when wiring IAM operations, switching IAM mode, or implementing a new IAM provider. Applies to files matching **/services/iam*.ts, **/context.ts, **/types.ts."
3
+ description: "How to use @owlmeans/iam — the provider-agnostic IamService, the permission-definition model with its four kinds (unbound, entity-bound, resource-bound, both) and default classes, the organization/group/subject facets, the runtime IAM API declarations, client-id uniqueness, issuer and redirect-URI rules, and hasPermission. Load when wiring IAM operations, granting or revoking permissions, working with tenanted clients, selecting the IAM backend, or implementing a new IAM provider. Applies to files matching **/services/iam*.ts, **/context.ts, **/types.ts."
4
4
  metadata:
5
5
  applyTo: "**/services/iam*.ts, **/context.ts, **/types.ts"
6
6
  ---
@@ -8,198 +8,112 @@ metadata:
8
8
 
9
9
  # Using `@owlmeans/iam`
10
10
 
11
- Provider-agnostic IAM abstraction. Defines `IamService` interface and related types. Used by both the platform backend (`viable-backend`) and the agent library (`@owlmeans/viable`). Concrete implementations live in `@owlmeans/iam-keycloak` (full proxy) and `@owlmeans/iam-integrated`.
11
+ **Install:** `"@owlmeans/iam": "^0.1.18"` in `dependencies`
12
+
13
+ Provider-agnostic IAM abstraction: the `IamService` interface, the permission and grant shapes, the
14
+ gate-param grammar, `hasPermission`, and the declarations of the provider's runtime IAM API. It
15
+ contains no implementation and talks to no provider —
16
+ concrete backends (a Keycloak proxy, an integrated backend built on the OwlMeans OIDC provider) are
17
+ separate packages, and every consumer depends on this interface rather than on one of them.
18
+
19
+ ## Where the halves live
20
+
21
+ This package is contracts only. The browser half — `appendIam`, the session's organizations and the
22
+ switch, `hasPermission` re-exported — is `@owlmeans/client-iam` (`client-iam` skill); the server half
23
+ — the IAM gate, the session's organizations on the server and the runtime IAM client — is
24
+ `@owlmeans/server-iam` (`server-iam` skill). Permission decisions are made on the server, never in
25
+ the browser.
12
26
 
13
27
  ## Public API surface
14
28
 
15
29
  | Symbol | Kind | Purpose |
16
30
  |--------|------|---------|
17
31
  | `IamService` | type | Unified IAM interface — all provisioning + authorization operations |
18
- | `IamClient` | type | Provisioned OIDC client `{ id?, clientId, secret?, name?, realm? }` |
19
- | `IamClientOptions` | type | `{ redirectUris? }` — explicit `ensureClient` hardening; omitting is a keycloak-only legacy shape (see below) |
32
+ | `IamClient` | type | Provisioned OIDC client `{ id?, clientId, secret?, name?, realm?, config? }` |
33
+ | `IamClientInfo` | type | `IamClient` without `secret` — what `getClient` answers |
34
+ | `IamClientConfig` | type | `{ operators?, users?, [key]: unknown }` — the two tenancy flags plus the client's arbitrary configuration |
35
+ | `IamClientOptions` | type | `{ redirectUris?, config? }` — explicit `ensureClient` hardening (omitting `redirectUris` is a keycloak-only legacy shape, see the `clients` part); `config` merges shallowly |
20
36
  | `IamCredentialsPair` | type | `{ token: string; realm: string }` |
21
- | `IamPermissionArgs` | type | `{ permission?, resourceScoped?, title? }` — `permission` absent means unscoped resource name |
37
+ | `IamPermissionArgs` | type | `{ permission?, resourceScoped?, entityScoped?, title?, area?, managed?, defaultClass? }` — `permission` absent means unscoped resource name; omitted flags are kept, passed ones (`false` / `None` included) written |
22
38
  | `IamResourceSpec` | type | `{ name: string; displayName?: string }` |
23
- | `IamPermissionDefinition` | type | Declared permission `{ name, resource, action?, resourceScoped?, title? }` |
24
- | `IamGrantArgs` | type | `{ resources?: string[] }` — present = resource-scoped grant form |
25
- | `IamGrant` | type | `{ profileId, clientId, permission, resources? }` |
26
- | `IamUser` | type | End-user of an entity `{ profileId, email?, name?, role, disabled?, grantCount? }` |
39
+ | `IamPermissionDefinition` | type | Declared permission `{ name, resource, action?, resourceScoped?, entityScoped?, title?, area?, managed?, defaultClass? }` |
40
+ | `IamPermissionFilter` | type | `{ areas?, managed?, resourceScoped?, entityScoped? }` — narrows `listPermissions`; an `areas` entry of `null` matches untagged definitions |
41
+ | `IamDefaultClass` | enum | `None` / `User` / `Member` / `Owner` — who holds a definition without a grant (see "Permission kinds" in the `permissions` part) |
42
+ | `IamGrantArgs` | type | `{ resources?, mode?, entitySlug? }` — `entitySlug` binds an `entityScoped` grant |
43
+ | `IamGrant` | type | `{ profileId? \| group?, clientId, permission, resources?, mode?, entitySlug?, origin?, through?, restricted? }` — exactly one subject; `restricted: true` only in a listing that asked for it (withheld, not held) |
44
+ | `IamGrantListArgs` | type | `{ restricted? }` — `listGrants` / `groups.grants` also list what the subject's own restriction withholds |
45
+ | `IamGrantOrigin` | enum | `Direct` / `Default` / `Group` / `Bundle` — why a listed grant is held; `through` names the group; `Bundle` only in a group's own listing |
46
+ | `IamGroupRef` | type | `{ entitySlug, key }` — a group by its organization's slug and its key |
47
+ | `IamGrantMode` | enum | `Blanket` / `Resources` / `All` / `Inherit` — which FORM of a grant an operation addresses; `All` and `Inherit` are revoke-only |
48
+ | `IamGrantBundle` | type | `{ filter?, permissions?, mode?, resources?, entitySlug? }` — what `grantBundle` hands out |
49
+ | `IamRemovalPolicy` | enum | `Cascade` / `Refuse` — what to do about grants of a definition being deleted |
50
+ | `IamPermissionDeleteArgs`, `IamPermissionRemoval` | type | `{ policy?, managed? }` and `{ permission, clientId, found, revoked }` |
51
+ | `IamNormalizeArgs`, `IamNormalizationReport` | type | `{ dryRun? }` and the rename/merge plan `normalizePermissions` reports |
52
+ | `IAM_AREAS`, `IamArea` | const/type | `['user', 'operator', 'admin']` — presentational grouping tags, read by no gate |
53
+ | `PERMISSION_ACTION_SEPARATOR` | const | `'--'` — TWO hyphens, never one |
54
+ | `parsePermissionName` / `composePermissionName` / `isPermissionName` | fn | Read, build and validate a permission NAME |
55
+ | `ParsedPermissionName` | type | `{ name, resource, action?, problem? }` |
56
+ | `IamUser` | type | Subject of one client `{ profileId, email?, name?, role, disabled?, grantCount?, home?, memberships? }` |
57
+ | `IamMembershipInfo` | type | `{ entitySlug, title?, owner, groups, home? }` — one organization of an `IamUser` |
27
58
  | `IamUserInvite` | type | `{ email, name?, role? }` — find-or-create args |
28
- | `IamUserUpdate` | type | `{ name?, role?, disabled? }` |
29
- | `hasPermission` | fn | `(auth, permission, { scope?, resourceId? }?)` — checks `Authorization.permissions` PermissionSet[]; an unscoped set satisfies a resourceId check |
59
+ | `IamUserUpdate` | type | `{ name?, role?, disabled? }` — `disabled` lands on the primary row |
60
+ | `IamOrganization`, `IamOrganizationArgs`, `IamOrganizationUpdate` | type | `{ entitySlug, title?, members? }`; create `{ title?, profileId? }` (the new owner); update `{ title }` |
61
+ | `IamMember`, `IamMemberInvite`, `IamMemberUpdate` | type | `{ profileId, email?, name?, owner, groups, managed? }`; `{ email, name?, owner? }`; `{ owner?, groups? }` |
62
+ | `IamGroup`, `IamGroupArgs`, `IamGroupBundle` | type | `{ entitySlug, key, title?, managed?, members?, bundles }`; `{ title?, bundles? }`; `{ filter?, permissions? }` |
63
+ | `IamStaffSyncArgs`, `IamStaffSyncReport` | type | `{ bundles }` and `{ added, removed }` (profile ids) of `syncStaff` |
64
+ | `IamSubject`, `IamSignInArgs`, `IamSignInResult` | type | `{ profileId, email?, name?, organizations, permissions }` (claims input); `{ email, name? }`; `{ accountId, profileId }` |
65
+ | `IamOrganizationFacet`, `IamGroupFacet`, `IamSubjectFacet` | type | The three facets of `IamService` |
66
+ | `unsupportedFacet<T>(what)` | fn | A facet whose every method throws `IamUnsupported(what)` — synchronously |
67
+ | `IAM_MEMBERS_GROUP` | const | `'members'` — the managed staff group `syncStaff` keeps (structure the sync's, permissions the operator's) |
68
+ | `IAM_EMAIL_PROOF` | const | `'proof:email'` — the `profileId` of a credential that proves an address and identifies nobody yet |
69
+ | `hasPermission` | fn | `(auth, permission, { scope?, resourceId?, entitySlug? }?)` — checks `Authorization.permissions`; an unscoped set satisfies a resourceId check, an unbound set any organization |
70
+ | `HasPermissionOptions` | type | `{ scope?, resourceId?, entitySlug? }` |
71
+ | `makeIamRuntimeProtocols({ service, path? })` | fn | The runtime IAM API's protocol tree — see "Runtime IAM API" in the `tenancy` part |
72
+ | `IAM_RUNTIME_GUARD`, `IAM_RUNTIME_PATH`, `IAM_RUNTIME_ROUTES` | const | Its guard alias, default base path (`'/runtime'`) and leaf-path table |
73
+ | `IamRuntime*` | type/schema | Its wire shapes and closed schemas (`IamRuntimeOrganization`, `IamRuntimeMember`, `IamRuntimePermission`, `IamRuntimeGrant`, `IamRuntimeGrantRequest`, … and `*Schema`) |
74
+ | Gate grammar | fn/type | `parseGateParam`, `parseGateSelector`, `formatGateParam`, `resolveGateResource`, `validateGateParams`, `routeParamsOf`, plus `RESOURCE_*_SEPARATOR`, `GateParamSource`, `DEFAULT_GATE_PARAM_SOURCES`, `GateParamErrorCode`, `GateResolutionFailure` and the `Gate*` types. The syntax itself is documented in the `server-iam` skill |
30
75
  | `DEFAULT_ALIAS` | const | Default service alias `'iam-service'` |
31
76
  | `IAM_MODE_KEYCLOAK` | const | `'keycloak'` |
32
77
  | `IAM_MODE_INTEGRATED` | const | `'integrated'` |
33
78
  | `IamMode` | type | `'keycloak' \| 'integrated'` |
34
79
  | `IamError` | class | Base IAM error |
35
- | `IamClientError` | class | Thrown when an OIDC client is missing a required field (e.g. `secret`) |
36
- | `IamResourceError` | class | Thrown when a KC resource returns with no name |
80
+ | `IamClientError` | class | An OIDC client is invalid or unusable — missing `secret`, unusable `redirect_uris`, `client:entity-mismatch`, an unconfigured provider route |
81
+ | `IamResourceError` | class | A provider resource returned with no name |
37
82
  | `IamGrantError` | class | Grant subject missing or entity mismatch |
38
- | `IamUnsupported` | class | Operation not supported by the active backend (e.g. resource-scoped grants on Keycloak) |
39
-
40
- ## Permission model (two grant forms)
41
-
42
- Permissions are declared per entity client (project) with `ensurePermission` and granted to end-user
43
- subjects with `grantPermission`:
44
-
45
- - **Unscoped (project-wide)**: `grantPermission(entityId, clientId, profileId, 'article--modify')`
46
- - **Resource-scoped**: `grantPermission(entityId, clientId, profileId, 'department--modify', { resources: ['dep-123'] })` —
47
- the grant only applies to the listed resource ids.
48
-
49
- Grants materialize as OwlMeans `PermissionSet[]` (`scope` = clientId; resource-scoped grants live in a
50
- dedicated set per permission carrying `resources[]`, because `resources` applies to all keys of a set).
51
-
52
- ## `IamService` interface
53
-
54
- ```ts
55
- interface IamService extends InitializedService {
56
- // Get the admin OIDC provider config for a tenant (used in payment provisioning, RP config)
57
- getEntityAdminConfig: (entityId: string) => Promise<OidcProviderConfig>
58
-
59
- // Get an admin { token, realm } pair for raw low-level calls (avoid unless necessary)
60
- getCredentialsPair: (entityId: string) => Promise<IamCredentialsPair>
61
-
62
- // The tenant's public, fully-qualified OIDC issuer URL — the single value a relying party needs.
63
- // Config-only (no remote call); throws IamClientError when the provider route is unconfigured.
64
- getIssuerUrl: (entityId: string) => Promise<string>
65
-
66
- // Provision an OIDC client for a tenant and return its credentials.
67
- // options.redirectUris hardens the client; always pass them (see "Issuer & redirect URIs").
68
- // MUST refuse an existing record owned by another entity — see "A client id is a global name".
69
- ensureClient: (entityId: string, clientId: string, options?: IamClientOptions) => Promise<IamClient>
70
-
71
- // Reserve a free client id without provisioning it; false when anyone else holds it.
72
- claimClient: (entityId: string, clientId: string) => Promise<boolean>
73
-
74
- // Release a client id and everything keyed by it (called when a project/slot is deleted).
75
- deleteClient: (entityId: string, clientId: string) => Promise<void>
76
-
77
- // Provision a named permission on a client resource; returns the resource name
78
- ensurePermission: (entityId: string, clientId: string, resource?: string, args?: IamPermissionArgs) => Promise<string>
79
-
80
- // Provision a resource and assign it to the tenant's owner role (used in payment provisioning)
81
- ensureResourceOwnership: (entityId: string, clientId: string, resource: IamResourceSpec) => Promise<void>
82
-
83
- // --- Authorization (permission definitions & grants) ---
84
-
85
- // List permission definitions registered for the entity's client
86
- listPermissions: (entityId: string, clientId: string) => Promise<IamPermissionDefinition[]>
87
-
88
- // Grant a permission to an end-user subject; args.resources = resource-scoped form
89
- grantPermission: (entityId: string, clientId: string, profileId: string, permission: string, args?: IamGrantArgs) => Promise<IamGrant>
90
-
91
- // Revoke; with args.resources only those ids are removed, else the whole grant
92
- revokePermission: (entityId: string, clientId: string, profileId: string, permission: string, args?: IamGrantArgs) => Promise<void>
93
-
94
- // List grants for the client, optionally for one subject
95
- listGrants: (entityId: string, clientId: string, profileId?: string) => Promise<IamGrant[]>
96
-
97
- // --- End-user management (customer-wide users, shared per entityId) ---
98
-
99
- // Every end-user of the entity. clientId scopes the reported grantCount — it does NOT filter.
100
- listUsers: (entityId: string, clientId?: string) => Promise<IamUser[]>
101
- getUser: (entityId: string, profileId: string) => Promise<IamUser | null>
102
- // Find-or-create by email — the one way an end-user record comes into existence.
103
- inviteUser: (entityId: string, invite: IamUserInvite) => Promise<IamUser>
104
- updateUser: (entityId: string, profileId: string, update: IamUserUpdate) => Promise<IamUser>
105
- removeUser: (entityId: string, profileId: string) => Promise<void>
106
- }
107
- ```
108
-
109
- ## End-users are customer-wide
110
-
111
- An end-user belongs to the **entity**, not to a project: one customer's users are shared across every
112
- project they own. `listUsers` therefore returns the entity's whole set and `clientId` only scopes the
113
- reported `grantCount` to one project's client. Do not reintroduce a per-client filter — a person who
114
- has authenticated against a project but holds no grant there is still that project's user, and the
115
- screen that hands out grants is exactly where they have to be visible.
116
-
117
- `inviteUser` is idempotent by email, which is what lets an invitation and a first sign-in converge on
118
- one record instead of accumulating twins. Backends that have no user store of their own (keycloak,
119
- where the customer manages realm users in its console) throw `IamUnsupported('user-management')` from
120
- all five methods; callers turn that into an "external console" fallback rather than an error.
121
-
122
- ## A client id is a global name
123
-
124
- A provider resolves a client from the bare `client_id` a relying party sends — the adapter gets no
125
- tenant context — so the id is unique across the whole deployment, not per entity, and the store
126
- cannot namespace it on a consumer's behalf. Uniqueness has to be *in the id*.
127
-
128
- - Compose it with the owning entity in it. A name derived from a project's own name alone lets two
129
- organizations share one registration: one secret, one redirect-URI list, one permission-definition
130
- set, and one grant namespace, because `PermissionSet.scope` is that same string.
131
- - `ensureClient` refuses a record belonging to another entity rather than returning it. The failure
132
- is deliberate — a loud provisioning error is the only alternative to a silent cross-tenant handover.
133
- - Assign an id **once** and reserve it with `claimClient`. Whatever a consumer derives ids from
134
- (a project alias, a slug) may later be released and re-used by a sibling, so only the registry can
135
- say whether an id is free. `deleteClient` gives it back, or a deleted project's name is burned.
136
- - Backends whose clients are already per-tenant (keycloak realms) satisfy this by construction:
137
- `claimClient` returns true and `deleteClient` may be `IamUnsupported`.
138
-
139
- ## `IamClient.realm` field
140
-
141
- `realm` is set by the provider to the entity ID. Use it instead of accessing internal properties:
142
-
143
- ```ts
144
- // ✓ correct
145
- await meta.update('oidcRealm', client.realm ?? fallbackEntityId)
146
-
147
- // ✗ never do this — internal Keycloak implementation detail
148
- await meta.update('oidcRealm', (client as any)._realm)
149
- ```
150
-
151
- ## Issuer & redirect URIs
152
-
153
- **`getIssuerUrl(entityId)` is the only place an issuer URL may be composed.** Consumers pass the
154
- result straight through as `OidcProviderDescriptor.discoveryUrl`; they must never rebuild it from a
155
- host plus a base path. Each backend owns its own shape (keycloak `{iam-host}/realms/{entityId}`,
156
- integrated `{provider-host}/{basePath}`), and an implementation must:
157
-
158
- - resolve it from configuration only — no admin round-trip, so a consumer that just needs the issuer
159
- never pays for or fails on a token grant;
160
- - return exactly what the provider advertises as `issuer` (`openid-client` compares the two and fails
161
- discovery on any difference — build it with the same `makeUrl(..., { base: true })` call
162
- `@owlmeans/server-oidc-provider` uses);
163
- - throw `IamClientError` when the provider's service route is missing, never fall back silently.
164
-
165
- `getEntityAdminConfig` additionally carries the same value as `discoveryUrl`, so the admin provider
166
- config is self-sufficient too.
167
-
168
- **Always pass `redirectUris`.** Omitting them is a keycloak-only legacy default (`['*']`, which
169
- Keycloak expands per-origin). The integrated provider does exact `redirect_uri` matching and
170
- `oidc-provider` refuses to load a client whose `redirect_uris` are not absolute URIs — so
171
- `iam-integrated.ensureClient` throws `IamClientError('redirect-uris')` on creation rather than
172
- registering a client that can never complete a callback. An omitted list never widens an existing
173
- hardened client.
174
-
175
- ## Selecting the IAM provider
176
-
177
- The platform wires the correct implementation via the `IAM_MODE` environment variable. The default is **`integrated`** (the internal IAM); `IAM_MODE=keycloak` is opt-in, used mainly for custom / standalone customer production setups. Do not read `IAM_MODE` directly from feature code — it belongs in the service-factory only (the platform exposes `resolveIamMode()` / `isIntegratedIam()` from `viable-common`):
178
-
179
- ```ts
180
- export const makeIamService = (mode = resolveIamMode()) => {
181
- if (mode === IAM_MODE_KEYCLOAK) return makeIamKeycloakService(VIABLE_IAM, { oidcProductAlias: OIDC_PRODUCT })
182
- // Pass the consumer's OWN service alias + base path: the package defaults ('manager-api',
183
- // '/oidc') are placeholders, and a mismatch makes every cfg.services[...] lookup miss.
184
- return makeIamIntegratedService(VIABLE_IAM, {
185
- providerServiceAlias: MANAGER_API, providerBasePath: IAM_PROVIDER_BASE_PATH,
186
- })
187
- }
188
- ```
189
-
190
- When a context hosts **more than one** `IamService` instance (the viable agent registers both
191
- `viable-backend`'s and the agent library's), every instance must be constructed with the same
192
- options — otherwise they disagree about the issuer.
83
+ | `IamPermissionError` | class | A refusal about a DEFINITION: `permission:held:<name>`, `permission:managed:<name>` |
84
+ | `IamUserError` | class | An end-user operation was refused |
85
+ | `IamOrganizationError` | class | An organization or membership refusal — unknown organization, last owner, membership cap |
86
+ | `IamGroupError` | class | A group refusal — unknown key, an operator writing a managed group's structure, another app's group |
87
+ | `IamUnsupported` | class | Operation not supported by the active backend (e.g. `resource-scoped-grant`, `user-management`) |
193
88
 
194
89
  ## Rules
195
90
 
196
- - Always depend on `IamService` from `@owlmeans/iam`, not on `KeycloakApiService` directly.
91
+ - Always depend on `IamService`, never on a backend's own admin client.
197
92
  - Do not expose `getCredentialsPair` to request handlers — it returns a raw admin token.
198
- - `IamClientError` means the provisioned client was invalid (missing secret, null token). The proxy already throws it; callers only need to handle it, not re-throw wrapped errors.
199
- - The `realm` field on `IamClient` is guaranteed to be set by all concrete implementations.
200
-
201
- ## Related instructions
202
-
203
- - `@owlmeans/iam-keycloak` (internal) — full proxy over Keycloak; see the `iam-keycloak` skill
204
- - `@owlmeans/keycloak` (internal) — low-level KC admin API; see the `keycloak` skill
205
- - `@owlmeans/server-iam` — IAM gate asserting both grant forms; see the `server-iam` skill
93
+ - `IamClientError` means the provisioned client was invalid (missing secret, null token). The adapter
94
+ already throws it; callers handle it rather than re-throwing a wrapped error.
95
+ - An operation a backend cannot support throws `IamUnsupported('<what>')`; a whole facet it cannot
96
+ support is `unsupportedFacet(what)`. Callers turn that into a fallback — an "external console"
97
+ link, a disabled control — rather than an error screen.
98
+ - A wire value names an organization by `entitySlug` only; the `entityId` parameter is the client's
99
+ OWNER, resolved server-side, never taken from a token or a request.
100
+
101
+ ## Related
102
+
103
+ - `@owlmeans/server-iam` — the IAM gate that asserts every permission kind, the gate-param grammar,
104
+ the session's organizations and the runtime IAM client; see the `server-iam` skill
105
+ - `@owlmeans/client-iam` — browser wiring, the organization switch and `hasPermission`; see the
106
+ `client-iam` skill
107
+ - `@owlmeans/oidc` — provider descriptors and the `iamMode` config seam; see the `oidc` skill
108
+ - The concrete adapters (a Keycloak proxy, and the integrated backend built on
109
+ `@owlmeans/server-oidc-provider` plus `@owlmeans/server-auth-identity`) ship separately and are
110
+ reached only through `IamService`.
111
+
112
+ ## Parts
113
+
114
+ | Part | Load it when |
115
+ |---|---|
116
+ | [permissions](parts/permissions.md) | declaring, granting, revoking or checking a permission, or reasoning about what a subject holds |
117
+ | [service](parts/service.md) | calling IamService methods, or implementing a new IAM provider |
118
+ | [tenancy](parts/tenancy.md) | working with tenanted clients, organizations, members or groups, or the runtime IAM API |
119
+ | [clients](parts/clients.md) | provisioning a client, composing an issuer URL, or choosing between the integrated and Keycloak backends |