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