@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.
- package/README.md +9 -7
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/iam/SKILL.md +326 -61
- package/build/consts.d.ts +156 -1
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +207 -0
- package/build/consts.js.map +1 -1
- package/build/consts.local.d.ts +58 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +21 -0
- package/build/consts.local.js.map +1 -0
- package/build/errors.d.ts +26 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +38 -0
- package/build/errors.js.map +1 -1
- package/build/facet.d.ts +10 -0
- package/build/facet.d.ts.map +1 -0
- package/build/facet.js +16 -0
- package/build/facet.js.map +1 -0
- package/build/gate/consts.local.d.ts +2 -0
- package/build/gate/consts.local.d.ts.map +1 -0
- package/build/gate/consts.local.js +3 -0
- package/build/gate/consts.local.js.map +1 -0
- package/build/gate/index.d.ts +4 -0
- package/build/gate/index.d.ts.map +1 -0
- package/build/gate/index.js +4 -0
- package/build/gate/index.js.map +1 -0
- package/build/gate/param.d.ts +6 -0
- package/build/gate/param.d.ts.map +1 -0
- package/build/gate/param.js +83 -0
- package/build/gate/param.js.map +1 -0
- package/build/gate/resolve.d.ts +10 -0
- package/build/gate/resolve.d.ts.map +1 -0
- package/build/gate/resolve.js +93 -0
- package/build/gate/resolve.js.map +1 -0
- package/build/gate/types.d.ts +109 -0
- package/build/gate/types.d.ts.map +1 -0
- package/build/gate/types.js +2 -0
- package/build/gate/types.js.map +1 -0
- package/build/gate/types.local.d.ts +5 -0
- package/build/gate/types.local.d.ts.map +1 -0
- package/build/gate/types.local.js +2 -0
- package/build/gate/types.local.js.map +1 -0
- package/build/gate/validate.d.ts +4 -0
- package/build/gate/validate.d.ts.map +1 -0
- package/build/gate/validate.js +100 -0
- package/build/gate/validate.js.map +1 -0
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +5 -0
- package/build/index.js.map +1 -1
- package/build/permission/types.d.ts +35 -0
- package/build/permission/types.d.ts.map +1 -0
- package/build/permission/types.js +2 -0
- package/build/permission/types.js.map +1 -0
- package/build/permission.d.ts +8 -0
- package/build/permission.d.ts.map +1 -0
- package/build/permission.js +34 -0
- package/build/permission.js.map +1 -0
- package/build/runtime/types.d.ts +90 -0
- package/build/runtime/types.d.ts.map +1 -0
- package/build/runtime/types.js +2 -0
- package/build/runtime/types.js.map +1 -0
- package/build/runtime.d.ts +75 -0
- package/build/runtime.d.ts.map +1 -0
- package/build/runtime.js +75 -0
- package/build/runtime.js.map +1 -0
- package/build/schemas.d.ts +13 -0
- package/build/schemas.d.ts.map +1 -0
- package/build/schemas.js +108 -0
- package/build/schemas.js.map +1 -0
- package/build/types.d.ts +423 -23
- package/build/types.d.ts.map +1 -1
- package/build/utils/types.d.ts +12 -0
- package/build/utils/types.d.ts.map +1 -0
- package/build/utils/types.js +2 -0
- package/build/utils/types.js.map +1 -0
- package/build/utils.d.ts +7 -6
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +10 -1
- package/build/utils.js.map +1 -1
- package/package.json +11 -6
- package/src/consts.local.ts +26 -0
- package/src/consts.ts +227 -1
- package/src/errors.ts +44 -0
- package/src/facet.ts +17 -0
- package/src/gate/consts.local.ts +3 -0
- package/src/gate/index.ts +3 -0
- package/src/gate/param.ts +108 -0
- package/src/gate/resolve.ts +114 -0
- package/src/gate/types.local.ts +4 -0
- package/src/gate/types.ts +117 -0
- package/src/gate/validate.ts +123 -0
- package/src/index.ts +9 -0
- package/src/permission/types.ts +33 -0
- package/src/permission.ts +48 -0
- package/src/runtime/types.ts +107 -0
- package/src/runtime.ts +142 -0
- package/src/schemas.ts +122 -0
- package/src/types.ts +484 -24
- package/src/utils/types.ts +11 -0
- package/src/utils.ts +12 -7
- package/tests/gate-param.spec.ts +233 -0
- package/tests/runtime.spec.ts +101 -0
- package/tests/tsconfig.json +12 -0
- 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
|
|
10
|
-
- `
|
|
11
|
-
-
|
|
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
|
|
26
|
-
const allowed = hasPermission(auth, {
|
|
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
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/iam",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
| `
|
|
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
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
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 |
|
|
33
|
-
| `IamResourceError` | class |
|
|
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
|
-
| `
|
|
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 (
|
|
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
|
-
//
|
|
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
|
|
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
|
|
64
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
76
|
-
listPermissions: (entityId: string, clientId: string) => Promise<IamPermissionDefinition[]>
|
|
201
|
+
listPermissions: (entityId: string, clientId: string, filter?: IamPermissionFilter) => Promise<IamPermissionDefinition[]>
|
|
77
202
|
|
|
78
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
216
|
+
// --- Users of one client — see "Users are per client" ---
|
|
90
217
|
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
//
|
|
98
|
-
|
|
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
|
-
`
|
|
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
|
|
362
|
+
## Selecting the IAM backend
|
|
126
363
|
|
|
127
|
-
The
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
##
|
|
399
|
+
## Rules
|
|
152
400
|
|
|
153
|
-
-
|
|
154
|
-
-
|
|
155
|
-
-
|
|
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`.
|