@owlmeans/iam 0.1.18-rc.5 → 0.1.18-rc.50

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 (106) hide show
  1. package/README.md +9 -7
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/iam/SKILL.md +326 -61
  4. package/build/consts.d.ts +156 -1
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +207 -0
  7. package/build/consts.js.map +1 -1
  8. package/build/consts.local.d.ts +58 -0
  9. package/build/consts.local.d.ts.map +1 -0
  10. package/build/consts.local.js +21 -0
  11. package/build/consts.local.js.map +1 -0
  12. package/build/errors.d.ts +26 -0
  13. package/build/errors.d.ts.map +1 -1
  14. package/build/errors.js +38 -0
  15. package/build/errors.js.map +1 -1
  16. package/build/facet.d.ts +10 -0
  17. package/build/facet.d.ts.map +1 -0
  18. package/build/facet.js +16 -0
  19. package/build/facet.js.map +1 -0
  20. package/build/gate/consts.local.d.ts +2 -0
  21. package/build/gate/consts.local.d.ts.map +1 -0
  22. package/build/gate/consts.local.js +3 -0
  23. package/build/gate/consts.local.js.map +1 -0
  24. package/build/gate/index.d.ts +4 -0
  25. package/build/gate/index.d.ts.map +1 -0
  26. package/build/gate/index.js +4 -0
  27. package/build/gate/index.js.map +1 -0
  28. package/build/gate/param.d.ts +6 -0
  29. package/build/gate/param.d.ts.map +1 -0
  30. package/build/gate/param.js +83 -0
  31. package/build/gate/param.js.map +1 -0
  32. package/build/gate/resolve.d.ts +10 -0
  33. package/build/gate/resolve.d.ts.map +1 -0
  34. package/build/gate/resolve.js +93 -0
  35. package/build/gate/resolve.js.map +1 -0
  36. package/build/gate/types.d.ts +109 -0
  37. package/build/gate/types.d.ts.map +1 -0
  38. package/build/gate/types.js +2 -0
  39. package/build/gate/types.js.map +1 -0
  40. package/build/gate/types.local.d.ts +5 -0
  41. package/build/gate/types.local.d.ts.map +1 -0
  42. package/build/gate/types.local.js +2 -0
  43. package/build/gate/types.local.js.map +1 -0
  44. package/build/gate/validate.d.ts +4 -0
  45. package/build/gate/validate.d.ts.map +1 -0
  46. package/build/gate/validate.js +100 -0
  47. package/build/gate/validate.js.map +1 -0
  48. package/build/index.d.ts +9 -0
  49. package/build/index.d.ts.map +1 -1
  50. package/build/index.js +5 -0
  51. package/build/index.js.map +1 -1
  52. package/build/permission/types.d.ts +35 -0
  53. package/build/permission/types.d.ts.map +1 -0
  54. package/build/permission/types.js +2 -0
  55. package/build/permission/types.js.map +1 -0
  56. package/build/permission.d.ts +8 -0
  57. package/build/permission.d.ts.map +1 -0
  58. package/build/permission.js +34 -0
  59. package/build/permission.js.map +1 -0
  60. package/build/runtime/types.d.ts +90 -0
  61. package/build/runtime/types.d.ts.map +1 -0
  62. package/build/runtime/types.js +2 -0
  63. package/build/runtime/types.js.map +1 -0
  64. package/build/runtime.d.ts +75 -0
  65. package/build/runtime.d.ts.map +1 -0
  66. package/build/runtime.js +75 -0
  67. package/build/runtime.js.map +1 -0
  68. package/build/schemas.d.ts +13 -0
  69. package/build/schemas.d.ts.map +1 -0
  70. package/build/schemas.js +108 -0
  71. package/build/schemas.js.map +1 -0
  72. package/build/types.d.ts +423 -23
  73. package/build/types.d.ts.map +1 -1
  74. package/build/utils/types.d.ts +12 -0
  75. package/build/utils/types.d.ts.map +1 -0
  76. package/build/utils/types.js +2 -0
  77. package/build/utils/types.js.map +1 -0
  78. package/build/utils.d.ts +7 -6
  79. package/build/utils.d.ts.map +1 -1
  80. package/build/utils.js +10 -1
  81. package/build/utils.js.map +1 -1
  82. package/package.json +11 -6
  83. package/src/consts.local.ts +26 -0
  84. package/src/consts.ts +227 -1
  85. package/src/errors.ts +44 -0
  86. package/src/facet.ts +17 -0
  87. package/src/gate/consts.local.ts +3 -0
  88. package/src/gate/index.ts +3 -0
  89. package/src/gate/param.ts +108 -0
  90. package/src/gate/resolve.ts +114 -0
  91. package/src/gate/types.local.ts +4 -0
  92. package/src/gate/types.ts +117 -0
  93. package/src/gate/validate.ts +123 -0
  94. package/src/index.ts +9 -0
  95. package/src/permission/types.ts +33 -0
  96. package/src/permission.ts +48 -0
  97. package/src/runtime/types.ts +107 -0
  98. package/src/runtime.ts +142 -0
  99. package/src/schemas.ts +122 -0
  100. package/src/types.ts +484 -24
  101. package/src/utils/types.ts +11 -0
  102. package/src/utils.ts +12 -7
  103. package/tests/gate-param.spec.ts +233 -0
  104. package/tests/runtime.spec.ts +101 -0
  105. package/tests/tsconfig.json +12 -0
  106. package/tests/utils.spec.ts +47 -1
package/README.md CHANGED
@@ -6,14 +6,16 @@ Provider-agnostic IAM abstraction for the OwlMeans framework — `IamService` in
6
6
 
7
7
  - `IamService` — unified interface for provisioning clients, realms, and permissions across IAM backends (e.g. Keycloak, built-in)
8
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
9
+ - `IamPermissionArgs` — describes a named permission: resource and organization binding (`resourceScoped`, `entityScoped`), title, area and `defaultClass`
10
+ - Tenancy facets on `IamService` — `organizations`, `groups`, `subjects` — and `unsupportedFacet` for a backend without them
11
+ - `makeIamRuntimeProtocols` — declarations of the provider's runtime IAM API (organizations, members, permissions, grants)
12
+ - `hasPermission` — utility to check whether a set of grants covers a given permission, resource and acting organization
13
+ - Errors: `IamError` and subtypes for client, permission, user, organization and group failures
12
14
 
13
15
  ## Installation
14
16
 
15
17
  ```bash
16
- bun add @owlmeans/iam
18
+ bun add @owlmeans/iam@^0.1.18-rc.50
17
19
  ```
18
20
 
19
21
  ## Usage
@@ -22,8 +24,8 @@ bun add @owlmeans/iam
22
24
  import type { IamService, IamClient } from '@owlmeans/iam'
23
25
  import { hasPermission } from '@owlmeans/iam'
24
26
 
25
- // Check whether an auth payload includes a required permission
26
- const allowed = hasPermission(auth, { permission: 'manage-users', resourceScoped: false })
27
+ // Check whether an auth payload grants a permission for one record, in the organization the request acts in
28
+ const allowed = hasPermission(auth, 'order--edit', { resourceId: 'ord-1', entitySlug: 'acme' })
27
29
  ```
28
30
 
29
31
  Typically consumed via `@owlmeans/server-iam` which wires the concrete OIDC-backed implementation.
@@ -36,7 +38,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
36
38
  your project's skill store (`.agents/skills/`):
37
39
 
38
40
  ```sh
39
- npx @owlmeans/agent-skills
41
+ npx @owlmeans/agent-skills@^0.1.18-rc.49
40
42
  ```
41
43
 
42
44
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/iam",
4
- "version": "0.1.18-rc.0",
5
- "generatedAt": "2026-08-16T22:20:50.504Z",
4
+ "version": "0.1.18-rc.50",
5
+ "generatedAt": "2026-10-05T21:03:06.386Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -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,33 +8,84 @@ 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-rc.50"` 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 below); `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
- | `hasPermission` | fn | `(auth, permission, { scope?, resourceId? }?)` — checks `Authorization.permissions` PermissionSet[]; an unscoped set satisfies a resourceId check |
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") |
42
+ | `IamGrantArgs` | type | `{ resources?, mode?, entitySlug? }` — `entitySlug` binds an `entityScoped` grant |
43
+ | `IamGrant` | type | `{ profileId? \| group?, clientId, permission, resources?, mode?, entitySlug?, origin?, through? }` — exactly one subject |
44
+ | `IamGrantOrigin` | enum | `Direct` / `Default` / `Group` — why a listed grant is held; `through` names the group |
45
+ | `IamGroupRef` | type | `{ entitySlug, key }` — a group by its organization's slug and its key |
46
+ | `IamGrantMode` | enum | `Blanket` / `Resources` / `All` — which FORM of a grant an operation addresses |
47
+ | `IamGrantBundle` | type | `{ filter?, permissions?, mode?, resources?, entitySlug? }` — what `grantBundle` hands out |
48
+ | `IamRemovalPolicy` | enum | `Cascade` / `Refuse` — what to do about grants of a definition being deleted |
49
+ | `IamPermissionDeleteArgs`, `IamPermissionRemoval` | type | `{ policy?, managed? }` and `{ permission, clientId, found, revoked }` |
50
+ | `IamNormalizeArgs`, `IamNormalizationReport` | type | `{ dryRun? }` and the rename/merge plan `normalizePermissions` reports |
51
+ | `IAM_AREAS`, `IamArea` | const/type | `['user', 'operator', 'admin']` — presentational grouping tags, read by no gate |
52
+ | `PERMISSION_ACTION_SEPARATOR` | const | `'--'` — TWO hyphens, never one |
53
+ | `parsePermissionName` / `composePermissionName` / `isPermissionName` | fn | Read, build and validate a permission NAME |
54
+ | `ParsedPermissionName` | type | `{ name, resource, action?, problem? }` |
55
+ | `IamUser` | type | Subject of one client `{ profileId, email?, name?, role, disabled?, grantCount?, home?, memberships? }` |
56
+ | `IamMembershipInfo` | type | `{ entitySlug, title?, owner, groups, home? }` — one organization of an `IamUser` |
57
+ | `IamUserInvite` | type | `{ email, name?, role? }` — find-or-create args |
58
+ | `IamUserUpdate` | type | `{ name?, role?, disabled? }` — `disabled` lands on the primary row |
59
+ | `IamOrganization`, `IamOrganizationArgs`, `IamOrganizationUpdate` | type | `{ entitySlug, title?, members? }`; create `{ title?, profileId? }` (the new owner); update `{ title }` |
60
+ | `IamMember`, `IamMemberInvite`, `IamMemberUpdate` | type | `{ profileId, email?, name?, owner, groups, managed? }`; `{ email, name?, owner? }`; `{ owner?, groups? }` |
61
+ | `IamGroup`, `IamGroupArgs`, `IamGroupBundle` | type | `{ entitySlug, key, title?, managed?, members?, bundles }`; `{ title?, bundles? }`; `{ filter?, permissions? }` |
62
+ | `IamStaffSyncArgs`, `IamStaffSyncReport` | type | `{ bundles }` and `{ added, removed }` (profile ids) of `syncStaff` |
63
+ | `IamSubject`, `IamSignInArgs`, `IamSignInResult` | type | `{ profileId, email?, name?, organizations, permissions }` (claims input); `{ email, name? }`; `{ accountId, profileId }` |
64
+ | `IamOrganizationFacet`, `IamGroupFacet`, `IamSubjectFacet` | type | The three facets of `IamService` |
65
+ | `unsupportedFacet<T>(what)` | fn | A facet whose every method throws `IamUnsupported(what)` — synchronously |
66
+ | `IAM_MEMBERS_GROUP` | const | `'members'` — the managed staff group `syncStaff` keeps |
67
+ | `IAM_EMAIL_PROOF` | const | `'proof:email'` — the `profileId` of a credential that proves an address and identifies nobody yet |
68
+ | `hasPermission` | fn | `(auth, permission, { scope?, resourceId?, entitySlug? }?)` — checks `Authorization.permissions`; an unscoped set satisfies a resourceId check, an unbound set any organization |
69
+ | `HasPermissionOptions` | type | `{ scope?, resourceId?, entitySlug? }` |
70
+ | `makeIamRuntimeProtocols({ service, path? })` | fn | The runtime IAM API's protocol tree — see "Runtime IAM API" |
71
+ | `IAM_RUNTIME_GUARD`, `IAM_RUNTIME_PATH`, `IAM_RUNTIME_ROUTES` | const | Its guard alias, default base path (`'/runtime'`) and leaf-path table |
72
+ | `IamRuntime*` | type/schema | Its wire shapes and closed schemas (`IamRuntimeOrganization`, `IamRuntimeMember`, `IamRuntimePermission`, `IamRuntimeGrant`, `IamRuntimeGrantRequest`, … and `*Schema`) |
73
+ | 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 |
27
74
  | `DEFAULT_ALIAS` | const | Default service alias `'iam-service'` |
28
75
  | `IAM_MODE_KEYCLOAK` | const | `'keycloak'` |
29
76
  | `IAM_MODE_INTEGRATED` | const | `'integrated'` |
30
77
  | `IamMode` | type | `'keycloak' \| 'integrated'` |
31
78
  | `IamError` | class | Base IAM error |
32
- | `IamClientError` | class | Thrown when an OIDC client is missing a required field (e.g. `secret`) |
33
- | `IamResourceError` | class | Thrown when a KC resource returns with no name |
79
+ | `IamClientError` | class | An OIDC client is invalid or unusable — missing `secret`, unusable `redirect_uris`, `client:entity-mismatch`, an unconfigured provider route |
80
+ | `IamResourceError` | class | A provider resource returned with no name |
34
81
  | `IamGrantError` | class | Grant subject missing or entity mismatch |
35
- | `IamUnsupported` | class | Operation not supported by the active backend (e.g. resource-scoped grants on Keycloak) |
82
+ | `IamPermissionError` | class | A refusal about a DEFINITION: `permission:held:<name>`, `permission:managed:<name>` |
83
+ | `IamUserError` | class | An end-user operation was refused |
84
+ | `IamOrganizationError` | class | An organization or membership refusal — unknown organization, last owner, membership cap |
85
+ | `IamGroupError` | class | A group refusal — unknown key, a managed group written by an operator, another app's group |
86
+ | `IamUnsupported` | class | Operation not supported by the active backend (e.g. `resource-scoped-grant`, `user-management`) |
36
87
 
37
- ## Permission model (two grant forms)
88
+ ## Permission model (kinds and grant forms)
38
89
 
39
90
  Permissions are declared per entity client (project) with `ensurePermission` and granted to end-user
40
91
  subjects with `grantPermission`:
@@ -46,58 +97,244 @@ subjects with `grantPermission`:
46
97
  Grants materialize as OwlMeans `PermissionSet[]` (`scope` = clientId; resource-scoped grants live in a
47
98
  dedicated set per permission carrying `resources[]`, because `resources` applies to all keys of a set).
48
99
 
100
+ A **name** is `<resource>--<action>`, with two hyphens. One hyphen is not a separator: `enquiry-view`
101
+ is a resource named `enquiry-view` with no action, and it must keep parsing that way, because a name
102
+ already granted somewhere cannot be reinterpreted without orphaning the grant. Build and read names
103
+ through `composePermissionName` / `parsePermissionName` rather than splitting strings.
104
+
105
+ `resourceScoped: true` says a permission *may* be bound to resource ids, not that it must be. The
106
+ same name is grantable in both forms and they are stored separately, which is what `IamGrantMode`
107
+ addresses: `Blanket` is the set carrying no `resources` (and `hasPermission` reads it as covering
108
+ every id), `Resources` is an explicit id list, and `All` is revoke-only. The defaults are asymmetric
109
+ on purpose — `grantPermission` defaults to `Blanket`, `revokePermission` to `All`, because a bare
110
+ revoke has always meant "remove it everywhere". Pass `mode` explicitly rather than relying on either.
111
+
112
+ `area` is a presentational grouping tag (`IAM_AREAS`, or any string a deployment defines) and decides
113
+ nothing at request time. `managed: true` marks a definition the platform owns: an operator cannot
114
+ revoke it from an undifferentiated list, and a repair that deletes "definitions no declaration names"
115
+ must not reach one.
116
+
117
+ ### Permission kinds
118
+
119
+ What a grant binds to is a property of the DEFINITION, and grants follow it:
120
+
121
+ | Kind | Definition flags | Grant stored on | Applies |
122
+ |---|---|---|---|
123
+ | unbound | none | the subject's primary row, or a group | everywhere |
124
+ | entity-bound | `entityScoped` | the subject's row of THAT organization, or a group of it | only while acting in that organization |
125
+ | resource-bound | `resourceScoped` | the primary row, a titled set with `resources` | to those resource ids |
126
+ | both | both | that organization's row, a titled set with `resources` | both checks |
127
+
128
+ - Granting an `entityScoped` definition REQUIRES `args.entitySlug`, which names the subject's row
129
+ there — the subject must be a member. Any other definition refuses `args.entitySlug`. Turning
130
+ `entityScoped` off while grants exist is refused: every bound grant would silently widen.
131
+ - Stored sets carry the organization's id; CLAIM sets (provider → relying party only) carry its
132
+ `entitySlug` instead (`OidcPermissionSetClaim`). The relying party keeps the unbound sets and the
133
+ acting organization's, strips the binding, and drops the rest — a browser token never carries a
134
+ bound set of another organization.
135
+ - `hasPermission(auth, name, { entitySlug })` applies a set without `entitySlug` everywhere and a
136
+ bound one only when `opts.entitySlug` names its organization — fail-closed, so a bound set that
137
+ leaked into a token never counts where the caller named no organization.
138
+
139
+ ### Default classes
140
+
141
+ `defaultClass` says who holds a definition without an explicit grant. It is evaluated when claims and
142
+ grant listings are built — never written onto a row:
143
+
144
+ - `User` — every signed-in subject of the client. Requires an unbound, unmanaged `user`-area definition.
145
+ - `Member` / `Owner` — every member / owner of the organization the subject acts in. Requires
146
+ `entityScoped`.
147
+ - `None` — nobody.
148
+
149
+ A class default is a BLANKET set, so `Owner` on a resource-scoped definition is its all-records form.
150
+ An explicit `false` on a row suppresses only the class default of that binding (an unbound `false`
151
+ suppresses `User`; a `false` on the row of an organization suppresses `Member`/`Owner` there).
152
+ Grants are additive — there is no deny rule over a group's grant. A listing marks each grant's
153
+ `origin`: `Direct`, `Default`, or `Group` with `through` naming the group. A backend that cannot
154
+ evaluate classes (Keycloak) refuses any `defaultClass` other than `None` with `IamUnsupported`.
155
+
49
156
  ## `IamService` interface
50
157
 
51
158
  ```ts
52
159
  interface IamService extends InitializedService {
53
- // Get the admin OIDC provider config for a tenant (used in payment provisioning, RP config)
160
+ // The tenant's admin OIDC provider config, and a raw admin { token, realm } pair.
54
161
  getEntityAdminConfig: (entityId: string) => Promise<OidcProviderConfig>
55
-
56
- // Get an admin { token, realm } pair for raw low-level calls (avoid unless necessary)
57
162
  getCredentialsPair: (entityId: string) => Promise<IamCredentialsPair>
58
163
 
59
- // The tenant's public, fully-qualified OIDC issuer URL — the single value a relying party needs.
60
- // Config-only (no remote call); throws IamClientError when the provider route is unconfigured.
164
+ // The tenant's public issuer URL — see "Issuer & redirect URIs".
61
165
  getIssuerUrl: (entityId: string) => Promise<string>
62
166
 
63
- // Provision an OIDC client for a tenant and return its credentials.
64
- // options.redirectUris hardens the client; always pass them (see "Issuer & redirect URIs").
167
+ // Provision an OIDC client for a tenant — see "Issuer & redirect URIs" and
168
+ // "A client id is a global name" for the two rules it has to obey.
65
169
  ensureClient: (entityId: string, clientId: string, options?: IamClientOptions) => Promise<IamClient>
66
170
 
67
- // Provision a named permission on a client resource; returns the resource name
171
+ // The entity's client without its secret, or null. Never provisions.
172
+ getClient: (entityId: string, clientId: string) => Promise<IamClientInfo | null>
173
+
174
+ // Reserve a free client id without provisioning it; false when anyone else holds it.
175
+ claimClient: (entityId: string, clientId: string) => Promise<boolean>
176
+
177
+ // Release a client id and everything keyed by it — its subjects' rows, its groups, its
178
+ // definitions (called when a project/slot is deleted).
179
+ deleteClient: (entityId: string, clientId: string) => Promise<void>
180
+
181
+ // Declare a permission. Returns the canonical name. MERGES into an existing definition —
182
+ // resource/action re-derived, omitted flags kept — and NEVER rewrites the name. Refuses a
183
+ // `resource` carrying an `@` selector.
68
184
  ensurePermission: (entityId: string, clientId: string, resource?: string, args?: IamPermissionArgs) => Promise<string>
69
185
 
70
- // Provision a resource and assign it to the tenant's owner role (used in payment provisioning)
186
+ // Remove definitions BY NAME, and by default their grants. Idempotent ({ found: false } when
187
+ // absent). Grants go first: a crash between the stores must leave a definition with no grants,
188
+ // never grants with no definition. A `managed` definition needs args.managed === true.
189
+ deletePermission: (entityId: string, clientId: string, permission: string | string[], args?: IamPermissionDeleteArgs) => Promise<IamPermissionRemoval[]>
190
+
191
+ // Repair definitions whose stored name leaked a gate selector, moving grants in the same write.
192
+ // A rename and a merge — never a delete, never an invention.
193
+ normalizePermissions: (entityId: string, clientId: string, args?: IamNormalizeArgs) => Promise<IamNormalizationReport>
194
+
195
+ // Provision a resource and assign it to the tenant's owner role (keycloak). A no-op on the
196
+ // integrated backend, which has no owner role to assign to.
71
197
  ensureResourceOwnership: (entityId: string, clientId: string, resource: IamResourceSpec) => Promise<void>
72
198
 
73
199
  // --- Authorization (permission definitions & grants) ---
74
200
 
75
- // List permission definitions registered for the entity's client
76
- listPermissions: (entityId: string, clientId: string) => Promise<IamPermissionDefinition[]>
201
+ listPermissions: (entityId: string, clientId: string, filter?: IamPermissionFilter) => Promise<IamPermissionDefinition[]>
77
202
 
78
- // Grant a permission to an end-user subject; args.resources = resource-scoped form
203
+ // args.resources selects the resource-scoped form, args.entitySlug the binding of an entityScoped
204
+ // definition; see "Permission model (kinds and grant forms)"
79
205
  grantPermission: (entityId: string, clientId: string, profileId: string, permission: string, args?: IamGrantArgs) => Promise<IamGrant>
80
-
81
- // Revoke; with args.resources only those ids are removed, else the whole grant
82
206
  revokePermission: (entityId: string, clientId: string, profileId: string, permission: string, args?: IamGrantArgs) => Promise<void>
83
207
 
84
- // List grants for the client, optionally for one subject
208
+ // Grant everything a bundle selects to each subject, idempotently, in ONE read and write per
209
+ // subject. Returns what each subject now holds, not what changed. Use it instead of looping over
210
+ // grantPermission, which costs N·M writes and N·M lost-update windows on the same records.
211
+ grantBundle: (entityId: string, clientId: string, profileIds: string[], bundle: IamGrantBundle) => Promise<IamGrant[]>
212
+
213
+ // Direct, default-class and group grants, each with its `origin`.
85
214
  listGrants: (entityId: string, clientId: string, profileId?: string) => Promise<IamGrant[]>
86
- }
87
- ```
88
215
 
89
- ## `IamClient.realm` field
216
+ // --- Users of one client — see "Users are per client" ---
90
217
 
91
- `realm` is set by the provider to the entity ID. Use it instead of accessing internal properties:
218
+ listUsers: (entityId: string, clientId?: string) => Promise<IamUser[]>
219
+ getUser: (entityId: string, clientId: string, profileId: string) => Promise<IamUser | null>
220
+ inviteUser: (entityId: string, clientId: string, invite: IamUserInvite) => Promise<IamUser>
221
+ updateUser: (entityId: string, clientId: string, profileId: string, update: IamUserUpdate) => Promise<IamUser>
222
+ removeUser: (entityId: string, clientId: string, profileId: string) => Promise<void>
92
223
 
93
- ```ts
94
- // ✓ correct
95
- await meta.update('oidcRealm', client.realm ?? fallbackEntityId)
224
+ // The owning organization's staff, admitted to the client through the managed `members` group.
225
+ syncStaff: (entityId: string, clientId: string, args: IamStaffSyncArgs) => Promise<IamStaffSyncReport>
96
226
 
97
- // ✗ never do this — internal Keycloak implementation detail
98
- await meta.update('oidcRealm', (client as any)._realm)
227
+ // --- Tenancy facets — see "Organizations, groups and subjects" ---
228
+ organizations: IamOrganizationFacet
229
+ groups: IamGroupFacet
230
+ subjects: IamSubjectFacet
231
+ }
99
232
  ```
100
233
 
234
+ The first parameter of every method is `entityId` — the organization that OWNS the client — and
235
+ every `(entityId, clientId)` method verifies that ownership before it reads or writes anything. The
236
+ organizations a client's subjects act in are a different thing, named by `entitySlug` inside the
237
+ facets.
238
+
239
+ ## Users are per client
240
+
241
+ A person is ONE account (one per e-mail address); what a client sees of it are its rows of that app —
242
+ one per (account, app, organization), the PRIMARY one in the account's own organization. The
243
+ `profileId` a client knows is the pairwise subject, the same on every row of one (account, app) and
244
+ different for every app, so two apps of one customer cannot correlate their users by it.
245
+
246
+ - `listUsers(entityId, clientId)` lists every account holding a row of the client, in whichever
247
+ organization. Without `clientId` it is a read-only aggregate over the entity's clients, for a
248
+ console — nothing is written through it.
249
+ - `getUser` / `inviteUser` / `updateUser` / `removeUser` take the client. `removeUser` deletes this
250
+ account's rows of THAT app only — the account, and its rows of other apps, stay. `updateUser`
251
+ writes `disabled` on the primary row, which is what refuses the subject at the provider.
252
+ - `inviteUser` is idempotent by email, which is what lets an invitation and a first sign-in converge on
253
+ one record instead of accumulating twins.
254
+
255
+ A backend with no user store of its own — one where the customer manages users in the provider's own
256
+ console — throws `IamUnsupported('user-management')` from all five methods.
257
+
258
+ ## Organizations, groups and subjects
259
+
260
+ The three facets of `IamService` carry tenancy. A backend that cannot provide one answers
261
+ `unsupportedFacet<IamOrganizationFacet>('organizations')` (and so on): every method of it throws
262
+ `IamUnsupported(what)` SYNCHRONOUSLY, because `subjects.identify` is synchronous and a rejected
263
+ promise would hand it the wrong type — an awaited call rejects either way.
264
+
265
+ - **Tenancy is the client's configuration.** `IamClientConfig.users` / `.operators` turn it on: with
266
+ neither flag, every subject acts in the client's owning organization (a single-tenant app); with
267
+ either, each subject acts in organizations of its own and the provider claims them under the
268
+ `organizations` scope (`@owlmeans/oidc`). The flags also gate the runtime API's writes by area.
269
+ - **`organizations`** — `list`, `get`, `create({ title?, profileId? })` (that subject becomes the
270
+ owner), `update({ title })`, `members`, `addMember` (find-or-create by e-mail, idempotent, refused
271
+ past the membership cap), `updateMember`, `removeMember`. The last owner cannot be demoted or
272
+ removed (`IamOrganizationError`).
273
+ - **`groups`** — a group is (organization, client, key), kept inside the organization record; one
274
+ client never reads or writes another's. `list`, `ensure`, `remove`, `members`, `addMembers`,
275
+ `removeMembers`, `grant` / `revoke` (bound to the group's organization), `grants`, and
276
+ `setBundles` — a bundle (`{ filter?, permissions? }`) grants every definition it selects to the
277
+ group's members at claim time. `IAM_MEMBERS_GROUP` is the managed group `syncStaff` keeps in the
278
+ owning organization; it is read-only to an operator (`IamGroupError`).
279
+ - **`subjects`** — the provider's half, keyed by the ACCOUNT, which never leaves the provider.
280
+ `signIn(clientId, { email })` finds or creates the account and its rows for the client;
281
+ `resolve(clientId, accountId)` builds the `IamSubject` the claims come from (`null` = not admitted:
282
+ no row, or a disabled primary row); `identify(clientId, accountId)` is the pairwise `sub`.
283
+
284
+ ## Runtime IAM API
285
+
286
+ `makeIamRuntimeProtocols({ service, path? })` declares the provider-side API a tenanted client's
287
+ server calls on behalf of its signed-in subject. It is declarations only: the provider binds it, and
288
+ a relying party calls it through `iamRuntime` (`@owlmeans/server-iam`) over plain `fetch`.
289
+
290
+ | Route (under `path`, default `IAM_RUNTIME_PATH`) | Tree | Who |
291
+ |---|---|---|
292
+ | `GET /organizations` | `organizations.list` | any subject — its own |
293
+ | `POST /organizations` `{ title? }` | `organizations.create` | any subject of a tenanted client; it becomes owner |
294
+ | `POST /organizations/:entitySlug` `{ title }` | `organizations.update` | owner |
295
+ | `GET /organizations/:entitySlug/members` | `members.list` | member |
296
+ | `POST /organizations/:entitySlug/members` `{ email, name?, owner? }` | `members.add` | owner; idempotent by e-mail |
297
+ | `POST …/members/:profileId` `{ owner?, groups? }` | `members.update` | owner |
298
+ | `POST …/members/:profileId/remove` | `members.remove` | owner, or the member itself |
299
+ | `GET …/permissions` | `permissions.list` | member |
300
+ | `GET …/grants` `?profileId` | `grants.list` | owner |
301
+ | `POST …/grants`, `POST …/grants/revoke` `{ profileId, permission, resources?, mode? }` | `grants.assign` / `grants.revoke` | owner |
302
+
303
+ - **Every route, the base included, is pinned to `service`** — a route's service is never inherited
304
+ from its parent — and every leaf hangs under ONE base carrying `IAM_RUNTIME_GUARD`. The serving
305
+ process registers that guard: it admits only a bearer access token the provider issued, and takes
306
+ the client and the account from it, never from the request.
307
+ - **The wire names organizations by `entitySlug` and subjects by the calling client's pairwise
308
+ `profileId`** — no organization or account id appears in any shape. Every schema is closed, since
309
+ the server's AJV strips an undeclared key silently. `IAM_RUNTIME_ROUTES` is the one leaf-path table
310
+ the declarations and the fetch client share.
311
+ - The serving process enforces the write rules: only `entityScoped`, unmanaged definitions of the
312
+ CALLING client, binding forced to the path's organization, the last owner protected, the
313
+ membership cap, and only for an area the client's tenancy config opens (`users` → `user`,
314
+ `operators` → `operator`). A non-owner write is `AuthForbidden(ORGANIZATION_OWNER_REFUSAL)`.
315
+
316
+ ## A client id is a global name
317
+
318
+ A provider resolves a client from the bare `client_id` a relying party sends — the adapter gets no
319
+ tenant context — so the id is unique across the whole deployment, not per entity, and the store
320
+ cannot namespace it on a consumer's behalf. Uniqueness has to be *in the id*.
321
+
322
+ - Compose it with the owning entity in it. A name derived from a project's own name alone lets two
323
+ organizations share one registration: one secret, one redirect-URI list, one permission-definition
324
+ set, and one grant namespace, because `PermissionSet.scope` is that same string.
325
+ - `ensureClient` refuses a record belonging to another entity rather than returning it. The failure
326
+ is deliberate — a loud provisioning error is the only alternative to a silent cross-tenant handover.
327
+ - Assign an id **once** and reserve it with `claimClient`. Whatever a consumer derives ids from
328
+ (a project alias, a slug) may later be released and re-used by a sibling, so only the registry can
329
+ say whether an id is free. `deleteClient` gives it back, or a deleted project's name is burned.
330
+ - Backends whose clients are already per-tenant (keycloak realms) satisfy this by construction:
331
+ `claimClient` returns true and `deleteClient` may be `IamUnsupported`.
332
+
333
+ ## `IamClient.realm` field
334
+
335
+ Every implementation sets `realm` to the entity id, so read `client.realm ?? fallbackEntityId` and
336
+ never reach into an adapter's own internals (`(client as any)._realm`) for the same value.
337
+
101
338
  ## Issuer & redirect URIs
102
339
 
103
340
  **`getIssuerUrl(entityId)` is the only place an issuer URL may be composed.** Consumers pass the
@@ -117,39 +354,67 @@ config is self-sufficient too.
117
354
 
118
355
  **Always pass `redirectUris`.** Omitting them is a keycloak-only legacy default (`['*']`, which
119
356
  Keycloak expands per-origin). The integrated provider does exact `redirect_uri` matching and
120
- `oidc-provider` refuses to load a client whose `redirect_uris` are not absolute URIs — so
121
- `iam-integrated.ensureClient` throws `IamClientError('redirect-uris')` on creation rather than
357
+ `oidc-provider` refuses to load a client whose `redirect_uris` are not absolute URIs — so an
358
+ integrated backend's `ensureClient` throws `IamClientError('redirect-uris')` on creation rather than
122
359
  registering a client that can never complete a callback. An omitted list never widens an existing
123
360
  hardened client.
124
361
 
125
- ## Selecting the IAM provider
362
+ ## Selecting the IAM backend
126
363
 
127
- 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`):
364
+ The choice is made **once, in a service factory**, and nowhere else. Feature code depends on
365
+ `IamService` and never learns which implementation answers it. `IAM_MODE_INTEGRATED` /
366
+ `IAM_MODE_KEYCLOAK` and `OidcIamConfig.iamMode` (`@owlmeans/oidc`) exist so a deployment can carry
367
+ that choice as ordinary configuration rather than as a branch scattered through handlers. No OwlMeans
368
+ package reads `iamMode`; it is declared for the consumer's own factory to switch on, which is the
369
+ only thing that may branch.
128
370
 
129
371
  ```ts
130
- export const makeIamService = (mode = resolveIamMode()) => {
131
- if (mode === IAM_MODE_KEYCLOAK) return makeIamKeycloakService(VIABLE_IAM, { oidcProductAlias: OIDC_PRODUCT })
132
- // Pass the consumer's OWN service alias + base path: the package defaults ('manager-api',
133
- // '/oidc') are placeholders, and a mismatch makes every cfg.services[...] lookup miss.
134
- return makeIamIntegratedService(VIABLE_IAM, {
135
- providerServiceAlias: MANAGER_API, providerBasePath: IAM_PROVIDER_BASE_PATH,
136
- })
137
- }
372
+ import { DEFAULT_ALIAS, IAM_MODE_INTEGRATED, IAM_MODE_KEYCLOAK } from '@owlmeans/iam'
373
+ import type { IamMode, IamService } from '@owlmeans/iam'
374
+
375
+ // makeKeycloakAdapter / makeIntegratedAdapter are PLACEHOLDER names standing in for whichever
376
+ // adapter packages the deployment installs — this factory is the one place either is named, and
377
+ // every consumer resolves IamService by alias instead. An adapter exposes a
378
+ // `(alias?: string, options?: object) => IamService` factory whose alias defaults to DEFAULT_ALIAS.
379
+ export const makeIamService = (mode: IamMode = IAM_MODE_INTEGRATED): IamService =>
380
+ mode === IAM_MODE_KEYCLOAK
381
+ ? makeKeycloakAdapter(DEFAULT_ALIAS, /* adapter options */)
382
+ : makeIntegratedAdapter(DEFAULT_ALIAS, /* adapter options */)
383
+
384
+ // cfg.oidc typed as OidcSharedConfig & OidcIamConfig is what carries the mode
385
+ context.registerService(makeIamService(cfg.oidc?.iamMode))
138
386
  ```
139
387
 
140
- When a context hosts **more than one** `IamService` instance (the viable agent registers both
141
- `viable-backend`'s and the agent library's), every instance must be constructed with the same
142
- options — otherwise they disagree about the issuer.
388
+ An adapter that reassembles the issuer from a registered service alias plus a base path must be given
389
+ the **consumer's own** alias and path. Whatever an adapter defaults to is a placeholder, and a
390
+ mismatch makes every service lookup behind `getIssuerUrl` miss.
143
391
 
144
- ## Rules
392
+ An integrated backend — one built on `@owlmeans/server-oidc-provider`, so the deployment hosts its
393
+ own provider — is the default choice; a Keycloak-backed adapter exists for deployments that already
394
+ run one.
145
395
 
146
- - Always depend on `IamService` from `@owlmeans/iam`, not on `KeycloakApiService` directly.
147
- - Do not expose `getCredentialsPair` to request handlers — it returns a raw admin token.
148
- - `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.
149
- - The `realm` field on `IamClient` is guaranteed to be set by all concrete implementations.
396
+ When a context hosts **more than one** `IamService` instance, every instance must be constructed with
397
+ the same options — otherwise they disagree about the issuer, and only one of them can be right.
150
398
 
151
- ## Related instructions
399
+ ## Rules
152
400
 
153
- - `@owlmeans/iam-keycloak` (internal) — full proxy over Keycloak; see the `iam-keycloak` skill
154
- - `@owlmeans/keycloak` (internal) — low-level KC admin API; see the `keycloak` skill
155
- - `@owlmeans/server-iam` — IAM gate asserting both grant forms; see the `server-iam` skill
401
+ - Always depend on `IamService`, never on a backend's own admin client.
402
+ - Do not expose `getCredentialsPair` to request handlers — it returns a raw admin token.
403
+ - `IamClientError` means the provisioned client was invalid (missing secret, null token). The adapter
404
+ already throws it; callers handle it rather than re-throwing a wrapped error.
405
+ - An operation a backend cannot support throws `IamUnsupported('<what>')`; a whole facet it cannot
406
+ support is `unsupportedFacet(what)`. Callers turn that into a fallback — an "external console"
407
+ link, a disabled control — rather than an error screen.
408
+ - A wire value names an organization by `entitySlug` only; the `entityId` parameter is the client's
409
+ OWNER, resolved server-side, never taken from a token or a request.
410
+
411
+ ## Related
412
+
413
+ - `@owlmeans/server-iam` — the IAM gate that asserts every permission kind, the gate-param grammar,
414
+ the session's organizations and the runtime IAM client; see the `server-iam` skill
415
+ - `@owlmeans/client-iam` — browser wiring, the organization switch and `hasPermission`; see the
416
+ `client-iam` skill
417
+ - `@owlmeans/oidc` — provider descriptors and the `iamMode` config seam; see the `oidc` skill
418
+ - The concrete adapters (a Keycloak proxy, and the integrated backend built on
419
+ `@owlmeans/server-oidc-provider` plus `@owlmeans/server-auth-identity`) ship separately and are
420
+ reached only through `IamService`.