@owlmeans/iam 0.1.18-rc.53 → 0.1.18-rc.55

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.
package/README.md CHANGED
@@ -14,7 +14,7 @@ server; the `iam` skill holds the full model.
14
14
  ## Installation
15
15
 
16
16
  ```bash
17
- bun add @owlmeans/iam@^0.1.18-rc.53
17
+ bun add @owlmeans/iam@^0.1.18-rc.55
18
18
  ```
19
19
 
20
20
  `ajv` is a peer dependency (the runtime API schemas are `JSONSchemaType`).
@@ -206,7 +206,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
206
206
  your project's skill store (`.agents/skills/`):
207
207
 
208
208
  ```sh
209
- npx @owlmeans/agent-skills@^0.1.18-rc.54
209
+ npx @owlmeans/agent-skills@^0.1.18-rc.57
210
210
  ```
211
211
 
212
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.53",
5
- "generatedAt": "2026-10-15T10:20:01.295Z",
4
+ "version": "0.1.18-rc.55",
5
+ "generatedAt": "2026-10-20T23:19:58.617Z",
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
  }
@@ -8,7 +8,7 @@ metadata:
8
8
 
9
9
  # Using `@owlmeans/iam`
10
10
 
11
- **Install:** `"@owlmeans/iam": "^0.1.18-rc.53"` in `dependencies`
11
+ **Install:** `"@owlmeans/iam": "^0.1.18-rc.55"` in `dependencies`
12
12
 
13
13
  Provider-agnostic IAM abstraction: the `IamService` interface, the permission and grant shapes, the
14
14
  gate-param grammar, `hasPermission`, and the declarations of the provider's runtime IAM API. It
@@ -32,13 +32,13 @@ the browser.
32
32
  | `IamClient` | type | Provisioned OIDC client `{ id?, clientId, secret?, name?, realm?, config? }` |
33
33
  | `IamClientInfo` | type | `IamClient` without `secret` — what `getClient` answers |
34
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 below); `config` merges shallowly |
35
+ | `IamClientOptions` | type | `{ redirectUris?, config? }` — explicit `ensureClient` hardening (omitting `redirectUris` is a keycloak-only legacy shape, see the `clients` part); `config` merges shallowly |
36
36
  | `IamCredentialsPair` | type | `{ token: string; realm: string }` |
37
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 |
38
38
  | `IamResourceSpec` | type | `{ name: string; displayName?: string }` |
39
39
  | `IamPermissionDefinition` | type | Declared permission `{ name, resource, action?, resourceScoped?, entityScoped?, title?, area?, managed?, defaultClass? }` |
40
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") |
41
+ | `IamDefaultClass` | enum | `None` / `User` / `Member` / `Owner` — who holds a definition without a grant (see "Permission kinds" in the `permissions` part) |
42
42
  | `IamGrantArgs` | type | `{ resources?, mode?, entitySlug? }` — `entitySlug` binds an `entityScoped` grant |
43
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
44
  | `IamGrantListArgs` | type | `{ restricted? }` — `listGrants` / `groups.grants` also list what the subject's own restriction withholds |
@@ -68,7 +68,7 @@ the browser.
68
68
  | `IAM_EMAIL_PROOF` | const | `'proof:email'` — the `profileId` of a credential that proves an address and identifies nobody yet |
69
69
  | `hasPermission` | fn | `(auth, permission, { scope?, resourceId?, entitySlug? }?)` — checks `Authorization.permissions`; an unscoped set satisfies a resourceId check, an unbound set any organization |
70
70
  | `HasPermissionOptions` | type | `{ scope?, resourceId?, entitySlug? }` |
71
- | `makeIamRuntimeProtocols({ service, path? })` | fn | The runtime IAM API's protocol tree — see "Runtime IAM API" |
71
+ | `makeIamRuntimeProtocols({ service, path? })` | fn | The runtime IAM API's protocol tree — see "Runtime IAM API" in the `tenancy` part |
72
72
  | `IAM_RUNTIME_GUARD`, `IAM_RUNTIME_PATH`, `IAM_RUNTIME_ROUTES` | const | Its guard alias, default base path (`'/runtime'`) and leaf-path table |
73
73
  | `IamRuntime*` | type/schema | Its wire shapes and closed schemas (`IamRuntimeOrganization`, `IamRuntimeMember`, `IamRuntimePermission`, `IamRuntimeGrant`, `IamRuntimeGrantRequest`, … and `*Schema`) |
74
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 |
@@ -86,351 +86,6 @@ the browser.
86
86
  | `IamGroupError` | class | A group refusal — unknown key, an operator writing a managed group's structure, another app's group |
87
87
  | `IamUnsupported` | class | Operation not supported by the active backend (e.g. `resource-scoped-grant`, `user-management`) |
88
88
 
89
- ## Permission model (kinds and grant forms)
90
-
91
- Permissions are declared per entity client (project) with `ensurePermission` and granted to end-user
92
- subjects with `grantPermission`:
93
-
94
- - **Unscoped (project-wide)**: `grantPermission(entityId, clientId, profileId, 'article--modify')`
95
- - **Resource-scoped**: `grantPermission(entityId, clientId, profileId, 'department--modify', { resources: ['dep-123'] })` —
96
- the grant only applies to the listed resource ids.
97
-
98
- Grants materialize as OwlMeans `PermissionSet[]` (`scope` = clientId; resource-scoped grants live in a
99
- dedicated set per permission carrying `resources[]`, because `resources` applies to all keys of a set).
100
-
101
- A **name** is `<resource>--<action>`, with two hyphens. One hyphen is not a separator: `enquiry-view`
102
- is a resource named `enquiry-view` with no action, and it must keep parsing that way, because a name
103
- already granted somewhere cannot be reinterpreted without orphaning the grant. Build and read names
104
- through `composePermissionName` / `parsePermissionName` rather than splitting strings.
105
-
106
- `resourceScoped: true` says a permission *may* be bound to resource ids, not that it must be. The
107
- same name is grantable in both forms and they are stored separately, which is what `IamGrantMode`
108
- addresses: `Blanket` is the set carrying no `resources` (and `hasPermission` reads it as covering
109
- every id), `Resources` is an explicit id list, `All` (revoke-only) is every form, and `Inherit`
110
- (revoke-only) is the subject's own blanket value — see "Restrictions". A grant in `All` or `Inherit`
111
- is refused (`IamGrantError('mode:all-is-revoke-only')`, `'mode:inherit-is-revoke-only'`). The
112
- defaults are asymmetric on purpose — `grantPermission` defaults to `Blanket`, `revokePermission` to
113
- `All`, because a bare revoke has always meant "remove it everywhere". Pass `mode` explicitly rather
114
- than relying on either.
115
-
116
- `area` is a presentational grouping tag (`IAM_AREAS`, or any string a deployment defines) and decides
117
- nothing at request time. `managed: true` marks a definition the platform owns: an operator cannot
118
- revoke it from an undifferentiated list, and a repair that deletes "definitions no declaration names"
119
- must not reach one.
120
-
121
- ### Permission kinds
122
-
123
- What a grant binds to is a property of the DEFINITION, and grants follow it:
124
-
125
- | Kind | Definition flags | Grant stored on | Applies |
126
- |---|---|---|---|
127
- | unbound | none | the subject's primary row, or a group | everywhere |
128
- | entity-bound | `entityScoped` | the subject's row of THAT organization, or a group of it | only while acting in that organization |
129
- | resource-bound | `resourceScoped` | the primary row, a titled set with `resources` | to those resource ids |
130
- | both | both | that organization's row, a titled set with `resources` | both checks |
131
-
132
- - Granting an `entityScoped` definition REQUIRES `args.entitySlug`, which names the subject's row
133
- there — the subject must be a member. Any other definition refuses `args.entitySlug`. Turning
134
- `entityScoped` off while grants exist is refused: every bound grant would silently widen.
135
- - Stored sets carry the organization's id; CLAIM sets (provider → relying party only) carry its
136
- `entitySlug` instead (`OidcPermissionSetClaim`). The relying party keeps the unbound sets and the
137
- acting organization's, strips the binding, and drops the rest — a browser token never carries a
138
- bound set of another organization.
139
- - `hasPermission(auth, name, { entitySlug })` applies a set without `entitySlug` everywhere and a
140
- bound one only when `opts.entitySlug` names its organization — fail-closed, so a bound set that
141
- leaked into a token never counts where the caller named no organization.
142
-
143
- ### Default classes
144
-
145
- `defaultClass` says who holds a definition without an explicit grant. It is evaluated when claims and
146
- grant listings are built — never written onto a row:
147
-
148
- - `User` — every signed-in subject of the client. Requires an unbound, unmanaged `user`-area definition.
149
- - `Member` / `Owner` — every member / owner of the organization the subject acts in. Requires
150
- `entityScoped`.
151
- - `None` — nobody.
152
-
153
- A class default is a BLANKET set, so `Owner` on a resource-scoped definition is its all-records form.
154
- A listing marks each grant's `origin`: `Direct`, `Default`, or `Group` with `through` naming the
155
- group. A backend that cannot evaluate classes (Keycloak) refuses any `defaultClass` other than
156
- `None` with `IamUnsupported`.
157
-
158
- ### Restrictions
159
-
160
- Every subject — a person or a group — has ONE blanket value of its own per binding: `true` grants,
161
- `false` RESTRICTS, absent inherits. A subject holds a name when its own value is `true`, or when it is
162
- not `false` and something it inherits grants it.
163
-
164
- | Subject | Inherits from | Its own value lives on |
165
- |---|---|---|
166
- | person | class defaults, its groups' grants and its groups' bundles | the primary row (unbound definition); the row of the binding's organization (bound — for a group-derived name, the row that names the group) |
167
- | group | its bundles | the group's own blanket set |
168
-
169
- - A restriction withholds every INHERITED form of the name in that binding — a class default, a
170
- group's blanket grant, a group's per-record grant, a bundle's selection. The subject's OWN grants,
171
- per-record ones included, are never cut. A group's restriction cuts only its own bundles, for all
172
- its members; another group granting the name still grants it.
173
- - A `Blanket` or `All` revoke of a DECLARED definition writes the restriction (an undeclared name gets
174
- none). `Inherit` drops the subject's own value — grant or restriction — writes nothing in its
175
- place, leaves resource-scoped sets alone and ignores `resources`; a bound definition needs
176
- `entitySlug` for it as for `Blanket`. A later `Blanket` grant overwrites a restriction with `true`.
177
- - Tokens carry only what is held: a relying party never sees a restriction, only fewer permissions.
178
- - Listings (`listGrants`, `groups.grants`) answer what is held; `IamGrantListArgs.restricted` adds
179
- the inherited grants the subject's own restriction withholds as `restricted: true` (same `origin` /
180
- `through` as the held form), so a console can show them and lift them with `Inherit`. A user's
181
- `grantCount` counts held grants only.
182
- - A backend without inheritance (Keycloak) has nothing to restrict: every revoke, `Inherit`
183
- included, removes the role, and a listing ignores `restricted`.
184
-
185
- ## `IamService` interface
186
-
187
- ```ts
188
- interface IamService extends InitializedService {
189
- // The tenant's admin OIDC provider config, and a raw admin { token, realm } pair.
190
- getEntityAdminConfig: (entityId: string) => Promise<OidcProviderConfig>
191
- getCredentialsPair: (entityId: string) => Promise<IamCredentialsPair>
192
-
193
- // The tenant's public issuer URL — see "Issuer & redirect URIs".
194
- getIssuerUrl: (entityId: string) => Promise<string>
195
-
196
- // Provision an OIDC client for a tenant — see "Issuer & redirect URIs" and
197
- // "A client id is a global name" for the two rules it has to obey.
198
- ensureClient: (entityId: string, clientId: string, options?: IamClientOptions) => Promise<IamClient>
199
-
200
- // The entity's client without its secret, or null. Never provisions.
201
- getClient: (entityId: string, clientId: string) => Promise<IamClientInfo | null>
202
-
203
- // Reserve a free client id without provisioning it; false when anyone else holds it.
204
- claimClient: (entityId: string, clientId: string) => Promise<boolean>
205
-
206
- // Release a client id and everything keyed by it — its subjects' rows, its groups, its
207
- // definitions (called when a project/slot is deleted).
208
- deleteClient: (entityId: string, clientId: string) => Promise<void>
209
-
210
- // Declare a permission. Returns the canonical name. MERGES into an existing definition —
211
- // resource/action re-derived, omitted flags kept — and NEVER rewrites the name. Refuses a
212
- // `resource` carrying an `@` selector.
213
- ensurePermission: (entityId: string, clientId: string, resource?: string, args?: IamPermissionArgs) => Promise<string>
214
-
215
- // Remove definitions BY NAME, and by default their grants. Idempotent ({ found: false } when
216
- // absent). Grants go first: a crash between the stores must leave a definition with no grants,
217
- // never grants with no definition. A `managed` definition needs args.managed === true.
218
- deletePermission: (entityId: string, clientId: string, permission: string | string[], args?: IamPermissionDeleteArgs) => Promise<IamPermissionRemoval[]>
219
-
220
- // Repair definitions whose stored name leaked a gate selector, moving grants in the same write.
221
- // A rename and a merge — never a delete, never an invention.
222
- normalizePermissions: (entityId: string, clientId: string, args?: IamNormalizeArgs) => Promise<IamNormalizationReport>
223
-
224
- // Provision a resource and assign it to the tenant's owner role (keycloak). A no-op on the
225
- // integrated backend, which has no owner role to assign to.
226
- ensureResourceOwnership: (entityId: string, clientId: string, resource: IamResourceSpec) => Promise<void>
227
-
228
- // --- Authorization (permission definitions & grants) ---
229
-
230
- listPermissions: (entityId: string, clientId: string, filter?: IamPermissionFilter) => Promise<IamPermissionDefinition[]>
231
-
232
- // args.resources selects the resource-scoped form, args.entitySlug the binding of an entityScoped
233
- // definition; see "Permission model (kinds and grant forms)". A blanket/all revoke of a declared
234
- // definition RESTRICTS; mode Inherit takes the own value back — see "Restrictions"
235
- grantPermission: (entityId: string, clientId: string, profileId: string, permission: string, args?: IamGrantArgs) => Promise<IamGrant>
236
- revokePermission: (entityId: string, clientId: string, profileId: string, permission: string, args?: IamGrantArgs) => Promise<void>
237
-
238
- // Grant everything a bundle selects to each subject, idempotently, in ONE read and write per
239
- // subject. Returns what each subject now holds, not what changed. Use it instead of looping over
240
- // grantPermission, which costs N·M writes and N·M lost-update windows on the same records.
241
- grantBundle: (entityId: string, clientId: string, profileIds: string[], bundle: IamGrantBundle) => Promise<IamGrant[]>
242
-
243
- // Direct, default-class and group grants, each with its `origin` — what is HELD; args.restricted
244
- // adds what the subject's own restrictions withhold (`restricted: true`).
245
- listGrants: (entityId: string, clientId: string, profileId?: string, args?: IamGrantListArgs) => Promise<IamGrant[]>
246
-
247
- // --- Users of one client — see "Users are per client" ---
248
-
249
- listUsers: (entityId: string, clientId?: string) => Promise<IamUser[]>
250
- getUser: (entityId: string, clientId: string, profileId: string) => Promise<IamUser | null>
251
- inviteUser: (entityId: string, clientId: string, invite: IamUserInvite) => Promise<IamUser>
252
- updateUser: (entityId: string, clientId: string, profileId: string, update: IamUserUpdate) => Promise<IamUser>
253
- removeUser: (entityId: string, clientId: string, profileId: string) => Promise<void>
254
-
255
- // The owning organization's staff, admitted to the client through the managed `members` group.
256
- syncStaff: (entityId: string, clientId: string, args: IamStaffSyncArgs) => Promise<IamStaffSyncReport>
257
-
258
- // --- Tenancy facets — see "Organizations, groups and subjects" ---
259
- organizations: IamOrganizationFacet
260
- groups: IamGroupFacet
261
- subjects: IamSubjectFacet
262
- }
263
- ```
264
-
265
- The first parameter of every method is `entityId` — the organization that OWNS the client — and
266
- every `(entityId, clientId)` method verifies that ownership before it reads or writes anything. The
267
- organizations a client's subjects act in are a different thing, named by `entitySlug` inside the
268
- facets.
269
-
270
- ## Users are per client
271
-
272
- A person is ONE account (one per e-mail address); what a client sees of it are its rows of that app —
273
- one per (account, app, organization), the PRIMARY one in the account's own organization. The
274
- `profileId` a client knows is the pairwise subject, the same on every row of one (account, app) and
275
- different for every app, so two apps of one customer cannot correlate their users by it.
276
-
277
- - `listUsers(entityId, clientId)` lists every account holding a row of the client, in whichever
278
- organization. Without `clientId` it is a read-only aggregate over the entity's clients, for a
279
- console — nothing is written through it.
280
- - `getUser` / `inviteUser` / `updateUser` / `removeUser` take the client. `removeUser` deletes this
281
- account's rows of THAT app only — the account, and its rows of other apps, stay. `updateUser`
282
- writes `disabled` on the primary row, which is what refuses the subject at the provider.
283
- - `inviteUser` is idempotent by email, which is what lets an invitation and a first sign-in converge on
284
- one record instead of accumulating twins.
285
-
286
- A backend with no user store of its own — one where the customer manages users in the provider's own
287
- console — throws `IamUnsupported('user-management')` from all five methods.
288
-
289
- ## Organizations, groups and subjects
290
-
291
- The three facets of `IamService` carry tenancy. A backend that cannot provide one answers
292
- `unsupportedFacet<IamOrganizationFacet>('organizations')` (and so on): every method of it throws
293
- `IamUnsupported(what)` SYNCHRONOUSLY, because `subjects.identify` is synchronous and a rejected
294
- promise would hand it the wrong type — an awaited call rejects either way.
295
-
296
- - **Tenancy is the client's configuration.** `IamClientConfig.users` / `.operators` turn it on: with
297
- neither flag, every subject acts in the client's owning organization (a single-tenant app); with
298
- either, each subject acts in organizations of its own and the provider claims them under the
299
- `organizations` scope (`@owlmeans/oidc`). The flags also gate the runtime API's writes by area.
300
- - **`organizations`** — `list`, `get`, `create({ title?, profileId? })` (that subject becomes the
301
- owner), `update({ title })`, `members`, `addMember` (find-or-create by e-mail, idempotent, refused
302
- past the membership cap), `updateMember`, `removeMember`. The last owner cannot be demoted or
303
- removed (`IamOrganizationError`).
304
- - **`groups`** — a group is (organization, client, key), kept inside the organization record; one
305
- client never reads or writes another's. `list`, `ensure`, `remove`, `members`, `addMembers`,
306
- `removeMembers`, `grant` / `revoke` (bound to the group's organization; a revoke restricts, as for
307
- a person), `grants(…, key, args?)` (its own grants as `Direct`, the names its bundles select as
308
- `Bundle`, minus what it restricts; `args.restricted` adds the cut bundle names), and `setBundles` —
309
- a bundle (`{ filter?, permissions? }`) grants every definition it selects to the group's members at
310
- claim time. `IAM_MEMBERS_GROUP` is the managed group `syncStaff` keeps in the owning organization:
311
- its existence, key, membership and bundles are the sync's (`ensure`, `remove`, `addMembers`,
312
- `removeMembers`, `setBundles` → `IamGroupError('managed:members')`), while its grants and
313
- restrictions are the operator's (`grant` / `revoke`) and survive every sync.
314
- - **`subjects`** — the provider's half, keyed by the ACCOUNT, which never leaves the provider.
315
- `signIn(clientId, { email })` finds or creates the account and its rows for the client;
316
- `resolve(clientId, accountId)` builds the `IamSubject` the claims come from (`null` = not admitted:
317
- no row, or a disabled primary row); `identify(clientId, accountId)` is the pairwise `sub`.
318
-
319
- ## Runtime IAM API
320
-
321
- `makeIamRuntimeProtocols({ service, path? })` declares the provider-side API a tenanted client's
322
- server calls on behalf of its signed-in subject. It is declarations only: the provider binds it, and
323
- a relying party calls it through `iamRuntime` (`@owlmeans/server-iam`) over plain `fetch`.
324
-
325
- | Route (under `path`, default `IAM_RUNTIME_PATH`) | Tree | Who |
326
- |---|---|---|
327
- | `GET /organizations` | `organizations.list` | any subject — its own |
328
- | `POST /organizations` `{ title? }` | `organizations.create` | any subject of a tenanted client; it becomes owner |
329
- | `POST /organizations/:entitySlug` `{ title }` | `organizations.update` | owner |
330
- | `GET /organizations/:entitySlug/members` | `members.list` | member |
331
- | `POST /organizations/:entitySlug/members` `{ email, name?, owner? }` | `members.add` | owner; idempotent by e-mail |
332
- | `POST …/members/:profileId` `{ owner?, groups? }` | `members.update` | owner |
333
- | `POST …/members/:profileId/remove` | `members.remove` | owner, or the member itself |
334
- | `GET …/permissions` | `permissions.list` | member |
335
- | `GET …/grants` `?profileId` | `grants.list` | owner |
336
- | `POST …/grants`, `POST …/grants/revoke` `{ profileId, permission, resources?, mode? }` | `grants.assign` / `grants.revoke` (a revoke restricts; `mode: inherit` takes the own value back) | owner |
337
-
338
- - **Every route, the base included, is pinned to `service`** — a route's service is never inherited
339
- from its parent — and every leaf hangs under ONE base carrying `IAM_RUNTIME_GUARD`. The serving
340
- process registers that guard: it admits only a bearer access token the provider issued, and takes
341
- the client and the account from it, never from the request.
342
- - **The wire names organizations by `entitySlug` and subjects by the calling client's pairwise
343
- `profileId`** — no organization or account id appears in any shape. Every schema is closed, since
344
- the server's AJV strips an undeclared key silently. `IAM_RUNTIME_ROUTES` is the one leaf-path table
345
- the declarations and the fetch client share.
346
- - The serving process enforces the write rules: only `entityScoped`, unmanaged definitions of the
347
- CALLING client, binding forced to the path's organization, the last owner protected, the
348
- membership cap, and only for an area the client's tenancy config opens (`users` → `user`,
349
- `operators` → `operator`). A non-owner write is `AuthForbidden(ORGANIZATION_OWNER_REFUSAL)`.
350
-
351
- ## A client id is a global name
352
-
353
- A provider resolves a client from the bare `client_id` a relying party sends — the adapter gets no
354
- tenant context — so the id is unique across the whole deployment, not per entity, and the store
355
- cannot namespace it on a consumer's behalf. Uniqueness has to be *in the id*.
356
-
357
- - Compose it with the owning entity in it. A name derived from a project's own name alone lets two
358
- organizations share one registration: one secret, one redirect-URI list, one permission-definition
359
- set, and one grant namespace, because `PermissionSet.scope` is that same string.
360
- - `ensureClient` refuses a record belonging to another entity rather than returning it. The failure
361
- is deliberate — a loud provisioning error is the only alternative to a silent cross-tenant handover.
362
- - Assign an id **once** and reserve it with `claimClient`. Whatever a consumer derives ids from
363
- (a project alias, a slug) may later be released and re-used by a sibling, so only the registry can
364
- say whether an id is free. `deleteClient` gives it back, or a deleted project's name is burned.
365
- - Backends whose clients are already per-tenant (keycloak realms) satisfy this by construction:
366
- `claimClient` returns true and `deleteClient` may be `IamUnsupported`.
367
-
368
- ## `IamClient.realm` field
369
-
370
- Every implementation sets `realm` to the entity id, so read `client.realm ?? fallbackEntityId` and
371
- never reach into an adapter's own internals (`(client as any)._realm`) for the same value.
372
-
373
- ## Issuer & redirect URIs
374
-
375
- **`getIssuerUrl(entityId)` is the only place an issuer URL may be composed.** Consumers pass the
376
- result straight through as `OidcProviderDescriptor.discoveryUrl`; they must never rebuild it from a
377
- host plus a base path. Each backend owns its own shape (keycloak `{iam-host}/realms/{entityId}`,
378
- integrated `{provider-host}/{basePath}`), and an implementation must:
379
-
380
- - resolve it from configuration only — no admin round-trip, so a consumer that just needs the issuer
381
- never pays for or fails on a token grant;
382
- - return exactly what the provider advertises as `issuer` (`openid-client` compares the two and fails
383
- discovery on any difference — build it with the same `makeUrl(..., { base: true })` call
384
- `@owlmeans/server-oidc-provider` uses);
385
- - throw `IamClientError` when the provider's service route is missing, never fall back silently.
386
-
387
- `getEntityAdminConfig` additionally carries the same value as `discoveryUrl`, so the admin provider
388
- config is self-sufficient too.
389
-
390
- **Always pass `redirectUris`.** Omitting them is a keycloak-only legacy default (`['*']`, which
391
- Keycloak expands per-origin). The integrated provider does exact `redirect_uri` matching and
392
- `oidc-provider` refuses to load a client whose `redirect_uris` are not absolute URIs — so an
393
- integrated backend's `ensureClient` throws `IamClientError('redirect-uris')` on creation rather than
394
- registering a client that can never complete a callback. An omitted list never widens an existing
395
- hardened client.
396
-
397
- ## Selecting the IAM backend
398
-
399
- The choice is made **once, in a service factory**, and nowhere else. Feature code depends on
400
- `IamService` and never learns which implementation answers it. `IAM_MODE_INTEGRATED` /
401
- `IAM_MODE_KEYCLOAK` and `OidcIamConfig.iamMode` (`@owlmeans/oidc`) exist so a deployment can carry
402
- that choice as ordinary configuration rather than as a branch scattered through handlers. No OwlMeans
403
- package reads `iamMode`; it is declared for the consumer's own factory to switch on, which is the
404
- only thing that may branch.
405
-
406
- ```ts
407
- import { DEFAULT_ALIAS, IAM_MODE_INTEGRATED, IAM_MODE_KEYCLOAK } from '@owlmeans/iam'
408
- import type { IamMode, IamService } from '@owlmeans/iam'
409
-
410
- // makeKeycloakAdapter / makeIntegratedAdapter are PLACEHOLDER names standing in for whichever
411
- // adapter packages the deployment installs — this factory is the one place either is named, and
412
- // every consumer resolves IamService by alias instead. An adapter exposes a
413
- // `(alias?: string, options?: object) => IamService` factory whose alias defaults to DEFAULT_ALIAS.
414
- export const makeIamService = (mode: IamMode = IAM_MODE_INTEGRATED): IamService =>
415
- mode === IAM_MODE_KEYCLOAK
416
- ? makeKeycloakAdapter(DEFAULT_ALIAS, /* adapter options */)
417
- : makeIntegratedAdapter(DEFAULT_ALIAS, /* adapter options */)
418
-
419
- // cfg.oidc typed as OidcSharedConfig & OidcIamConfig is what carries the mode
420
- context.registerService(makeIamService(cfg.oidc?.iamMode))
421
- ```
422
-
423
- An adapter that reassembles the issuer from a registered service alias plus a base path must be given
424
- the **consumer's own** alias and path. Whatever an adapter defaults to is a placeholder, and a
425
- mismatch makes every service lookup behind `getIssuerUrl` miss.
426
-
427
- An integrated backend — one built on `@owlmeans/server-oidc-provider`, so the deployment hosts its
428
- own provider — is the default choice; a Keycloak-backed adapter exists for deployments that already
429
- run one.
430
-
431
- When a context hosts **more than one** `IamService` instance, every instance must be constructed with
432
- the same options — otherwise they disagree about the issuer, and only one of them can be right.
433
-
434
89
  ## Rules
435
90
 
436
91
  - Always depend on `IamService`, never on a backend's own admin client.
@@ -453,3 +108,12 @@ the same options — otherwise they disagree about the issuer, and only one of t
453
108
  - The concrete adapters (a Keycloak proxy, and the integrated backend built on
454
109
  `@owlmeans/server-oidc-provider` plus `@owlmeans/server-auth-identity`) ship separately and are
455
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 |
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: clients
3
+ description: Provisioning OIDC clients — globally unique client ids, the realm field, issuer and redirect-URI rules — and selecting the IAM backend.
4
+ metadata:
5
+ purposes: auth, config
6
+ mentions: claimClient, deleteClient, getIssuerUrl, redirectUris, IamClientError, IAM_MODE_INTEGRATED, IAM_MODE_KEYCLOAK, iamMode, IamClient
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/iam — Clients, issuers and backend selection
11
+
12
+ ## A client id is a global name
13
+
14
+ A provider resolves a client from the bare `client_id` a relying party sends — the adapter gets no
15
+ tenant context — so the id is unique across the whole deployment, not per entity, and the store
16
+ cannot namespace it on a consumer's behalf. Uniqueness has to be *in the id*.
17
+
18
+ - Compose it with the owning entity in it. A name derived from a project's own name alone lets two
19
+ organizations share one registration: one secret, one redirect-URI list, one permission-definition
20
+ set, and one grant namespace, because `PermissionSet.scope` is that same string.
21
+ - `ensureClient` refuses a record belonging to another entity rather than returning it. The failure
22
+ is deliberate — a loud provisioning error is the only alternative to a silent cross-tenant handover.
23
+ - Assign an id **once** and reserve it with `claimClient`. Whatever a consumer derives ids from
24
+ (a project alias, a slug) may later be released and re-used by a sibling, so only the registry can
25
+ say whether an id is free. `deleteClient` gives it back, or a deleted project's name is burned.
26
+ - Backends whose clients are already per-tenant (keycloak realms) satisfy this by construction:
27
+ `claimClient` returns true and `deleteClient` may be `IamUnsupported`.
28
+
29
+ ## `IamClient.realm` field
30
+
31
+ Every implementation sets `realm` to the entity id, so read `client.realm ?? fallbackEntityId` and
32
+ never reach into an adapter's own internals (`(client as any)._realm`) for the same value.
33
+
34
+ ## Issuer & redirect URIs
35
+
36
+ **`getIssuerUrl(entityId)` is the only place an issuer URL may be composed.** Consumers pass the
37
+ result straight through as `OidcProviderDescriptor.discoveryUrl`; they must never rebuild it from a
38
+ host plus a base path. Each backend owns its own shape (keycloak `{iam-host}/realms/{entityId}`,
39
+ integrated `{provider-host}/{basePath}`), and an implementation must:
40
+
41
+ - resolve it from configuration only — no admin round-trip, so a consumer that just needs the issuer
42
+ never pays for or fails on a token grant;
43
+ - return exactly what the provider advertises as `issuer` (`openid-client` compares the two and fails
44
+ discovery on any difference — build it with the same `makeUrl(..., { base: true })` call
45
+ `@owlmeans/server-oidc-provider` uses);
46
+ - throw `IamClientError` when the provider's service route is missing, never fall back silently.
47
+
48
+ `getEntityAdminConfig` additionally carries the same value as `discoveryUrl`, so the admin provider
49
+ config is self-sufficient too.
50
+
51
+ **Always pass `redirectUris`.** Omitting them is a keycloak-only legacy default (`['*']`, which
52
+ Keycloak expands per-origin). The integrated provider does exact `redirect_uri` matching and
53
+ `oidc-provider` refuses to load a client whose `redirect_uris` are not absolute URIs — so an
54
+ integrated backend's `ensureClient` throws `IamClientError('redirect-uris')` on creation rather than
55
+ registering a client that can never complete a callback. An omitted list never widens an existing
56
+ hardened client.
57
+
58
+ ## Selecting the IAM backend
59
+
60
+ The choice is made **once, in a service factory**, and nowhere else. Feature code depends on
61
+ `IamService` and never learns which implementation answers it. `IAM_MODE_INTEGRATED` /
62
+ `IAM_MODE_KEYCLOAK` and `OidcIamConfig.iamMode` (`@owlmeans/oidc`) exist so a deployment can carry
63
+ that choice as ordinary configuration rather than as a branch scattered through handlers. No OwlMeans
64
+ package reads `iamMode`; it is declared for the consumer's own factory to switch on, which is the
65
+ only thing that may branch.
66
+
67
+ ```ts
68
+ import { DEFAULT_ALIAS, IAM_MODE_INTEGRATED, IAM_MODE_KEYCLOAK } from '@owlmeans/iam'
69
+ import type { IamMode, IamService } from '@owlmeans/iam'
70
+
71
+ // makeKeycloakAdapter / makeIntegratedAdapter are PLACEHOLDER names standing in for whichever
72
+ // adapter packages the deployment installs — this factory is the one place either is named, and
73
+ // every consumer resolves IamService by alias instead. An adapter exposes a
74
+ // `(alias?: string, options?: object) => IamService` factory whose alias defaults to DEFAULT_ALIAS.
75
+ export const makeIamService = (mode: IamMode = IAM_MODE_INTEGRATED): IamService =>
76
+ mode === IAM_MODE_KEYCLOAK
77
+ ? makeKeycloakAdapter(DEFAULT_ALIAS, /* adapter options */)
78
+ : makeIntegratedAdapter(DEFAULT_ALIAS, /* adapter options */)
79
+
80
+ // cfg.oidc typed as OidcSharedConfig & OidcIamConfig is what carries the mode
81
+ context.registerService(makeIamService(cfg.oidc?.iamMode))
82
+ ```
83
+
84
+ An adapter that reassembles the issuer from a registered service alias plus a base path must be given
85
+ the **consumer's own** alias and path. Whatever an adapter defaults to is a placeholder, and a
86
+ mismatch makes every service lookup behind `getIssuerUrl` miss.
87
+
88
+ An integrated backend — one built on `@owlmeans/server-oidc-provider`, so the deployment hosts its
89
+ own provider — is the default choice; a Keycloak-backed adapter exists for deployments that already
90
+ run one.
91
+
92
+ When a context hosts **more than one** `IamService` instance, every instance must be constructed with
93
+ the same options — otherwise they disagree about the issuer, and only one of them can be right.
@@ -0,0 +1,106 @@
1
+ ---
2
+ name: permissions
3
+ description: Permission names and grant forms, IamGrantMode, the four permission kinds, default classes and restrictions.
4
+ metadata:
5
+ purposes: auth
6
+ mentions: hasPermission, grantPermission, revokePermission, ensurePermission, IamGrantMode, IamDefaultClass, composePermissionName, parsePermissionName, entityScoped, resourceScoped, defaultClass
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/iam — Permission model
11
+
12
+ ## Permission model (kinds and grant forms)
13
+
14
+ Permissions are declared per entity client (project) with `ensurePermission` and granted to end-user
15
+ subjects with `grantPermission`:
16
+
17
+ - **Unscoped (project-wide)**: `grantPermission(entityId, clientId, profileId, 'article--modify')`
18
+ - **Resource-scoped**: `grantPermission(entityId, clientId, profileId, 'department--modify', { resources: ['dep-123'] })` —
19
+ the grant only applies to the listed resource ids.
20
+
21
+ Grants materialize as OwlMeans `PermissionSet[]` (`scope` = clientId; resource-scoped grants live in a
22
+ dedicated set per permission carrying `resources[]`, because `resources` applies to all keys of a set).
23
+
24
+ A **name** is `<resource>--<action>`, with two hyphens. One hyphen is not a separator: `enquiry-view`
25
+ is a resource named `enquiry-view` with no action, and it must keep parsing that way, because a name
26
+ already granted somewhere cannot be reinterpreted without orphaning the grant. Build and read names
27
+ through `composePermissionName` / `parsePermissionName` rather than splitting strings.
28
+
29
+ `resourceScoped: true` says a permission *may* be bound to resource ids, not that it must be. The
30
+ same name is grantable in both forms and they are stored separately, which is what `IamGrantMode`
31
+ addresses: `Blanket` is the set carrying no `resources` (and `hasPermission` reads it as covering
32
+ every id), `Resources` is an explicit id list, `All` (revoke-only) is every form, and `Inherit`
33
+ (revoke-only) is the subject's own blanket value — see "Restrictions". A grant in `All` or `Inherit`
34
+ is refused (`IamGrantError('mode:all-is-revoke-only')`, `'mode:inherit-is-revoke-only'`). The
35
+ defaults are asymmetric on purpose — `grantPermission` defaults to `Blanket`, `revokePermission` to
36
+ `All`, because a bare revoke has always meant "remove it everywhere". Pass `mode` explicitly rather
37
+ than relying on either.
38
+
39
+ `area` is a presentational grouping tag (`IAM_AREAS`, or any string a deployment defines) and decides
40
+ nothing at request time. `managed: true` marks a definition the platform owns: an operator cannot
41
+ revoke it from an undifferentiated list, and a repair that deletes "definitions no declaration names"
42
+ must not reach one.
43
+
44
+ ### Permission kinds
45
+
46
+ What a grant binds to is a property of the DEFINITION, and grants follow it:
47
+
48
+ | Kind | Definition flags | Grant stored on | Applies |
49
+ |---|---|---|---|
50
+ | unbound | none | the subject's primary row, or a group | everywhere |
51
+ | entity-bound | `entityScoped` | the subject's row of THAT organization, or a group of it | only while acting in that organization |
52
+ | resource-bound | `resourceScoped` | the primary row, a titled set with `resources` | to those resource ids |
53
+ | both | both | that organization's row, a titled set with `resources` | both checks |
54
+
55
+ - Granting an `entityScoped` definition REQUIRES `args.entitySlug`, which names the subject's row
56
+ there — the subject must be a member. Any other definition refuses `args.entitySlug`. Turning
57
+ `entityScoped` off while grants exist is refused: every bound grant would silently widen.
58
+ - Stored sets carry the organization's id; CLAIM sets (provider → relying party only) carry its
59
+ `entitySlug` instead (`OidcPermissionSetClaim`). The relying party keeps the unbound sets and the
60
+ acting organization's, strips the binding, and drops the rest — a browser token never carries a
61
+ bound set of another organization.
62
+ - `hasPermission(auth, name, { entitySlug })` applies a set without `entitySlug` everywhere and a
63
+ bound one only when `opts.entitySlug` names its organization — fail-closed, so a bound set that
64
+ leaked into a token never counts where the caller named no organization.
65
+
66
+ ### Default classes
67
+
68
+ `defaultClass` says who holds a definition without an explicit grant. It is evaluated when claims and
69
+ grant listings are built — never written onto a row:
70
+
71
+ - `User` — every signed-in subject of the client. Requires an unbound, unmanaged `user`-area definition.
72
+ - `Member` / `Owner` — every member / owner of the organization the subject acts in. Requires
73
+ `entityScoped`.
74
+ - `None` — nobody.
75
+
76
+ A class default is a BLANKET set, so `Owner` on a resource-scoped definition is its all-records form.
77
+ A listing marks each grant's `origin`: `Direct`, `Default`, or `Group` with `through` naming the
78
+ group. A backend that cannot evaluate classes (Keycloak) refuses any `defaultClass` other than
79
+ `None` with `IamUnsupported`.
80
+
81
+ ### Restrictions
82
+
83
+ Every subject — a person or a group — has ONE blanket value of its own per binding: `true` grants,
84
+ `false` RESTRICTS, absent inherits. A subject holds a name when its own value is `true`, or when it is
85
+ not `false` and something it inherits grants it.
86
+
87
+ | Subject | Inherits from | Its own value lives on |
88
+ |---|---|---|
89
+ | person | class defaults, its groups' grants and its groups' bundles | the primary row (unbound definition); the row of the binding's organization (bound — for a group-derived name, the row that names the group) |
90
+ | group | its bundles | the group's own blanket set |
91
+
92
+ - A restriction withholds every INHERITED form of the name in that binding — a class default, a
93
+ group's blanket grant, a group's per-record grant, a bundle's selection. The subject's OWN grants,
94
+ per-record ones included, are never cut. A group's restriction cuts only its own bundles, for all
95
+ its members; another group granting the name still grants it.
96
+ - A `Blanket` or `All` revoke of a DECLARED definition writes the restriction (an undeclared name gets
97
+ none). `Inherit` drops the subject's own value — grant or restriction — writes nothing in its
98
+ place, leaves resource-scoped sets alone and ignores `resources`; a bound definition needs
99
+ `entitySlug` for it as for `Blanket`. A later `Blanket` grant overwrites a restriction with `true`.
100
+ - Tokens carry only what is held: a relying party never sees a restriction, only fewer permissions.
101
+ - Listings (`listGrants`, `groups.grants`) answer what is held; `IamGrantListArgs.restricted` adds
102
+ the inherited grants the subject's own restriction withholds as `restricted: true` (same `origin` /
103
+ `through` as the held form), so a console can show them and lift them with `Inherit`. A user's
104
+ `grantCount` counts held grants only.
105
+ - A backend without inheritance (Keycloak) has nothing to restrict: every revoke, `Inherit`
106
+ included, removes the role, and a listing ignores `restricted`.
@@ -0,0 +1,115 @@
1
+ ---
2
+ name: service
3
+ description: The full IamService interface with its per-method contracts, and the per-client user operations.
4
+ metadata:
5
+ purposes: auth, server-api
6
+ mentions: IamService, getEntityAdminConfig, ensureClient, getClient, grantBundle, listGrants, listUsers, inviteUser, updateUser, removeUser, syncStaff, normalizePermissions, deletePermission
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/iam — The IamService interface
11
+
12
+ ## `IamService` interface
13
+
14
+ ```ts
15
+ interface IamService extends InitializedService {
16
+ // The tenant's admin OIDC provider config, and a raw admin { token, realm } pair.
17
+ getEntityAdminConfig: (entityId: string) => Promise<OidcProviderConfig>
18
+ getCredentialsPair: (entityId: string) => Promise<IamCredentialsPair>
19
+
20
+ // The tenant's public issuer URL — see "Issuer & redirect URIs" (the clients part).
21
+ getIssuerUrl: (entityId: string) => Promise<string>
22
+
23
+ // Provision an OIDC client for a tenant — see "Issuer & redirect URIs" and
24
+ // "A client id is a global name" (the clients part) for the two rules it has to obey.
25
+ ensureClient: (entityId: string, clientId: string, options?: IamClientOptions) => Promise<IamClient>
26
+
27
+ // The entity's client without its secret, or null. Never provisions.
28
+ getClient: (entityId: string, clientId: string) => Promise<IamClientInfo | null>
29
+
30
+ // Reserve a free client id without provisioning it; false when anyone else holds it.
31
+ claimClient: (entityId: string, clientId: string) => Promise<boolean>
32
+
33
+ // Release a client id and everything keyed by it — its subjects' rows, its groups, its
34
+ // definitions (called when a project/slot is deleted).
35
+ deleteClient: (entityId: string, clientId: string) => Promise<void>
36
+
37
+ // Declare a permission. Returns the canonical name. MERGES into an existing definition —
38
+ // resource/action re-derived, omitted flags kept — and NEVER rewrites the name. Refuses a
39
+ // `resource` carrying an `@` selector.
40
+ ensurePermission: (entityId: string, clientId: string, resource?: string, args?: IamPermissionArgs) => Promise<string>
41
+
42
+ // Remove definitions BY NAME, and by default their grants. Idempotent ({ found: false } when
43
+ // absent). Grants go first: a crash between the stores must leave a definition with no grants,
44
+ // never grants with no definition. A `managed` definition needs args.managed === true.
45
+ deletePermission: (entityId: string, clientId: string, permission: string | string[], args?: IamPermissionDeleteArgs) => Promise<IamPermissionRemoval[]>
46
+
47
+ // Repair definitions whose stored name leaked a gate selector, moving grants in the same write.
48
+ // A rename and a merge — never a delete, never an invention.
49
+ normalizePermissions: (entityId: string, clientId: string, args?: IamNormalizeArgs) => Promise<IamNormalizationReport>
50
+
51
+ // Provision a resource and assign it to the tenant's owner role (keycloak). A no-op on the
52
+ // integrated backend, which has no owner role to assign to.
53
+ ensureResourceOwnership: (entityId: string, clientId: string, resource: IamResourceSpec) => Promise<void>
54
+
55
+ // --- Authorization (permission definitions & grants) ---
56
+
57
+ listPermissions: (entityId: string, clientId: string, filter?: IamPermissionFilter) => Promise<IamPermissionDefinition[]>
58
+
59
+ // args.resources selects the resource-scoped form, args.entitySlug the binding of an entityScoped
60
+ // definition; see "Permission model (kinds and grant forms)" in the permissions part. A
61
+ // blanket/all revoke of a declared definition RESTRICTS; mode Inherit takes the own value
62
+ // back — see "Restrictions" there
63
+ grantPermission: (entityId: string, clientId: string, profileId: string, permission: string, args?: IamGrantArgs) => Promise<IamGrant>
64
+ revokePermission: (entityId: string, clientId: string, profileId: string, permission: string, args?: IamGrantArgs) => Promise<void>
65
+
66
+ // Grant everything a bundle selects to each subject, idempotently, in ONE read and write per
67
+ // subject. Returns what each subject now holds, not what changed. Use it instead of looping over
68
+ // grantPermission, which costs N·M writes and N·M lost-update windows on the same records.
69
+ grantBundle: (entityId: string, clientId: string, profileIds: string[], bundle: IamGrantBundle) => Promise<IamGrant[]>
70
+
71
+ // Direct, default-class and group grants, each with its `origin` — what is HELD; args.restricted
72
+ // adds what the subject's own restrictions withhold (`restricted: true`).
73
+ listGrants: (entityId: string, clientId: string, profileId?: string, args?: IamGrantListArgs) => Promise<IamGrant[]>
74
+
75
+ // --- Users of one client — see "Users are per client" ---
76
+
77
+ listUsers: (entityId: string, clientId?: string) => Promise<IamUser[]>
78
+ getUser: (entityId: string, clientId: string, profileId: string) => Promise<IamUser | null>
79
+ inviteUser: (entityId: string, clientId: string, invite: IamUserInvite) => Promise<IamUser>
80
+ updateUser: (entityId: string, clientId: string, profileId: string, update: IamUserUpdate) => Promise<IamUser>
81
+ removeUser: (entityId: string, clientId: string, profileId: string) => Promise<void>
82
+
83
+ // The owning organization's staff, admitted to the client through the managed `members` group.
84
+ syncStaff: (entityId: string, clientId: string, args: IamStaffSyncArgs) => Promise<IamStaffSyncReport>
85
+
86
+ // --- Tenancy facets — see "Organizations, groups and subjects" (the tenancy part) ---
87
+ organizations: IamOrganizationFacet
88
+ groups: IamGroupFacet
89
+ subjects: IamSubjectFacet
90
+ }
91
+ ```
92
+
93
+ The first parameter of every method is `entityId` — the organization that OWNS the client — and
94
+ every `(entityId, clientId)` method verifies that ownership before it reads or writes anything. The
95
+ organizations a client's subjects act in are a different thing, named by `entitySlug` inside the
96
+ facets.
97
+
98
+ ## Users are per client
99
+
100
+ A person is ONE account (one per e-mail address); what a client sees of it are its rows of that app —
101
+ one per (account, app, organization), the PRIMARY one in the account's own organization. The
102
+ `profileId` a client knows is the pairwise subject, the same on every row of one (account, app) and
103
+ different for every app, so two apps of one customer cannot correlate their users by it.
104
+
105
+ - `listUsers(entityId, clientId)` lists every account holding a row of the client, in whichever
106
+ organization. Without `clientId` it is a read-only aggregate over the entity's clients, for a
107
+ console — nothing is written through it.
108
+ - `getUser` / `inviteUser` / `updateUser` / `removeUser` take the client. `removeUser` deletes this
109
+ account's rows of THAT app only — the account, and its rows of other apps, stay. `updateUser`
110
+ writes `disabled` on the primary row, which is what refuses the subject at the provider.
111
+ - `inviteUser` is idempotent by email, which is what lets an invitation and a first sign-in converge on
112
+ one record instead of accumulating twins.
113
+
114
+ A backend with no user store of its own — one where the customer manages users in the provider's own
115
+ console — throws `IamUnsupported('user-management')` from all five methods.
@@ -0,0 +1,72 @@
1
+ ---
2
+ name: tenancy
3
+ description: The organizations, groups and subjects facets of IamService, and the runtime IAM API a tenanted client's server calls.
4
+ metadata:
5
+ purposes: auth, server-api
6
+ mentions: IamOrganizationFacet, IamGroupFacet, IamSubjectFacet, unsupportedFacet, IAM_MEMBERS_GROUP, makeIamRuntimeProtocols, IAM_RUNTIME_GUARD, IAM_RUNTIME_ROUTES, IAM_RUNTIME_PATH, iamRuntime
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/iam — Organizations, groups, subjects and the runtime API
11
+
12
+ ## Organizations, groups and subjects
13
+
14
+ The three facets of `IamService` carry tenancy. A backend that cannot provide one answers
15
+ `unsupportedFacet<IamOrganizationFacet>('organizations')` (and so on): every method of it throws
16
+ `IamUnsupported(what)` SYNCHRONOUSLY, because `subjects.identify` is synchronous and a rejected
17
+ promise would hand it the wrong type — an awaited call rejects either way.
18
+
19
+ - **Tenancy is the client's configuration.** `IamClientConfig.users` / `.operators` turn it on: with
20
+ neither flag, every subject acts in the client's owning organization (a single-tenant app); with
21
+ either, each subject acts in organizations of its own and the provider claims them under the
22
+ `organizations` scope (`@owlmeans/oidc`). The flags also gate the runtime API's writes by area.
23
+ - **`organizations`** — `list`, `get`, `create({ title?, profileId? })` (that subject becomes the
24
+ owner), `update({ title })`, `members`, `addMember` (find-or-create by e-mail, idempotent, refused
25
+ past the membership cap), `updateMember`, `removeMember`. The last owner cannot be demoted or
26
+ removed (`IamOrganizationError`).
27
+ - **`groups`** — a group is (organization, client, key), kept inside the organization record; one
28
+ client never reads or writes another's. `list`, `ensure`, `remove`, `members`, `addMembers`,
29
+ `removeMembers`, `grant` / `revoke` (bound to the group's organization; a revoke restricts, as for
30
+ a person), `grants(…, key, args?)` (its own grants as `Direct`, the names its bundles select as
31
+ `Bundle`, minus what it restricts; `args.restricted` adds the cut bundle names), and `setBundles` —
32
+ a bundle (`{ filter?, permissions? }`) grants every definition it selects to the group's members at
33
+ claim time. `IAM_MEMBERS_GROUP` is the managed group `syncStaff` keeps in the owning organization:
34
+ its existence, key, membership and bundles are the sync's (`ensure`, `remove`, `addMembers`,
35
+ `removeMembers`, `setBundles` → `IamGroupError('managed:members')`), while its grants and
36
+ restrictions are the operator's (`grant` / `revoke`) and survive every sync.
37
+ - **`subjects`** — the provider's half, keyed by the ACCOUNT, which never leaves the provider.
38
+ `signIn(clientId, { email })` finds or creates the account and its rows for the client;
39
+ `resolve(clientId, accountId)` builds the `IamSubject` the claims come from (`null` = not admitted:
40
+ no row, or a disabled primary row); `identify(clientId, accountId)` is the pairwise `sub`.
41
+
42
+ ## Runtime IAM API
43
+
44
+ `makeIamRuntimeProtocols({ service, path? })` declares the provider-side API a tenanted client's
45
+ server calls on behalf of its signed-in subject. It is declarations only: the provider binds it, and
46
+ a relying party calls it through `iamRuntime` (`@owlmeans/server-iam`) over plain `fetch`.
47
+
48
+ | Route (under `path`, default `IAM_RUNTIME_PATH`) | Tree | Who |
49
+ |---|---|---|
50
+ | `GET /organizations` | `organizations.list` | any subject — its own |
51
+ | `POST /organizations` `{ title? }` | `organizations.create` | any subject of a tenanted client; it becomes owner |
52
+ | `POST /organizations/:entitySlug` `{ title }` | `organizations.update` | owner |
53
+ | `GET /organizations/:entitySlug/members` | `members.list` | member |
54
+ | `POST /organizations/:entitySlug/members` `{ email, name?, owner? }` | `members.add` | owner; idempotent by e-mail |
55
+ | `POST …/members/:profileId` `{ owner?, groups? }` | `members.update` | owner |
56
+ | `POST …/members/:profileId/remove` | `members.remove` | owner, or the member itself |
57
+ | `GET …/permissions` | `permissions.list` | member |
58
+ | `GET …/grants` `?profileId` | `grants.list` | owner |
59
+ | `POST …/grants`, `POST …/grants/revoke` `{ profileId, permission, resources?, mode? }` | `grants.assign` / `grants.revoke` (a revoke restricts; `mode: inherit` takes the own value back) | owner |
60
+
61
+ - **Every route, the base included, is pinned to `service`** — a route's service is never inherited
62
+ from its parent — and every leaf hangs under ONE base carrying `IAM_RUNTIME_GUARD`. The serving
63
+ process registers that guard: it admits only a bearer access token the provider issued, and takes
64
+ the client and the account from it, never from the request.
65
+ - **The wire names organizations by `entitySlug` and subjects by the calling client's pairwise
66
+ `profileId`** — no organization or account id appears in any shape. Every schema is closed, since
67
+ the server's AJV strips an undeclared key silently. `IAM_RUNTIME_ROUTES` is the one leaf-path table
68
+ the declarations and the fetch client share.
69
+ - The serving process enforces the write rules: only `entityScoped`, unmanaged definitions of the
70
+ CALLING client, binding forced to the path's organization, the last owner protected, the
71
+ membership cap, and only for an area the client's tenancy config opens (`users` → `user`,
72
+ `operators` → `operator`). A non-owner write is `AuthForbidden(ORGANIZATION_OWNER_REFUSAL)`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/iam",
3
- "version": "0.1.18-rc.53",
3
+ "version": "0.1.18-rc.55",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -22,12 +22,12 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
25
- "@owlmeans/auth": "^0.1.18-rc.41",
26
- "@owlmeans/context": "^0.1.18-rc.34",
27
- "@owlmeans/entrypoint": "^0.1.18-rc.41",
28
- "@owlmeans/error": "^0.1.18-rc.38",
29
- "@owlmeans/oidc": "^0.1.18-rc.58",
30
- "@owlmeans/route": "^0.1.18-rc.35"
25
+ "@owlmeans/auth": "^0.1.18-rc.42",
26
+ "@owlmeans/context": "^0.1.18-rc.35",
27
+ "@owlmeans/entrypoint": "^0.1.18-rc.42",
28
+ "@owlmeans/error": "^0.1.18-rc.39",
29
+ "@owlmeans/oidc": "^0.1.18-rc.60",
30
+ "@owlmeans/route": "^0.1.18-rc.36"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "ajv": "*"