@owlmeans/iam 0.1.18-rc.9 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +185 -15
  2. package/agent-meta/manifest.json +92 -4
  3. package/agent-meta/skills/iam/SKILL.md +91 -177
  4. package/agent-meta/skills/iam/parts/clients.md +93 -0
  5. package/agent-meta/skills/iam/parts/permissions.md +106 -0
  6. package/agent-meta/skills/iam/parts/service.md +115 -0
  7. package/agent-meta/skills/iam/parts/tenancy.md +72 -0
  8. package/build/consts.d.ts +170 -1
  9. package/build/consts.d.ts.map +1 -1
  10. package/build/consts.js +221 -0
  11. package/build/consts.js.map +1 -1
  12. package/build/consts.local.d.ts +58 -0
  13. package/build/consts.local.d.ts.map +1 -0
  14. package/build/consts.local.js +21 -0
  15. package/build/consts.local.js.map +1 -0
  16. package/build/errors.d.ts +26 -0
  17. package/build/errors.d.ts.map +1 -1
  18. package/build/errors.js +38 -0
  19. package/build/errors.js.map +1 -1
  20. package/build/facet.d.ts +10 -0
  21. package/build/facet.d.ts.map +1 -0
  22. package/build/facet.js +16 -0
  23. package/build/facet.js.map +1 -0
  24. package/build/gate/consts.local.d.ts +2 -0
  25. package/build/gate/consts.local.d.ts.map +1 -0
  26. package/build/gate/consts.local.js +3 -0
  27. package/build/gate/consts.local.js.map +1 -0
  28. package/build/gate/index.d.ts +4 -0
  29. package/build/gate/index.d.ts.map +1 -0
  30. package/build/gate/index.js +4 -0
  31. package/build/gate/index.js.map +1 -0
  32. package/build/gate/param.d.ts +6 -0
  33. package/build/gate/param.d.ts.map +1 -0
  34. package/build/gate/param.js +83 -0
  35. package/build/gate/param.js.map +1 -0
  36. package/build/gate/resolve.d.ts +10 -0
  37. package/build/gate/resolve.d.ts.map +1 -0
  38. package/build/gate/resolve.js +93 -0
  39. package/build/gate/resolve.js.map +1 -0
  40. package/build/gate/types.d.ts +109 -0
  41. package/build/gate/types.d.ts.map +1 -0
  42. package/build/gate/types.js +2 -0
  43. package/build/gate/types.js.map +1 -0
  44. package/build/gate/types.local.d.ts +5 -0
  45. package/build/gate/types.local.d.ts.map +1 -0
  46. package/build/gate/types.local.js +2 -0
  47. package/build/gate/types.local.js.map +1 -0
  48. package/build/gate/validate.d.ts +4 -0
  49. package/build/gate/validate.d.ts.map +1 -0
  50. package/build/gate/validate.js +100 -0
  51. package/build/gate/validate.js.map +1 -0
  52. package/build/index.d.ts +9 -0
  53. package/build/index.d.ts.map +1 -1
  54. package/build/index.js +5 -0
  55. package/build/index.js.map +1 -1
  56. package/build/permission/types.d.ts +35 -0
  57. package/build/permission/types.d.ts.map +1 -0
  58. package/build/permission/types.js +2 -0
  59. package/build/permission/types.js.map +1 -0
  60. package/build/permission.d.ts +8 -0
  61. package/build/permission.d.ts.map +1 -0
  62. package/build/permission.js +34 -0
  63. package/build/permission.js.map +1 -0
  64. package/build/runtime/types.d.ts +90 -0
  65. package/build/runtime/types.d.ts.map +1 -0
  66. package/build/runtime/types.js +2 -0
  67. package/build/runtime/types.js.map +1 -0
  68. package/build/runtime.d.ts +75 -0
  69. package/build/runtime.d.ts.map +1 -0
  70. package/build/runtime.js +75 -0
  71. package/build/runtime.js.map +1 -0
  72. package/build/schemas.d.ts +13 -0
  73. package/build/schemas.d.ts.map +1 -0
  74. package/build/schemas.js +108 -0
  75. package/build/schemas.js.map +1 -0
  76. package/build/types.d.ts +429 -30
  77. package/build/types.d.ts.map +1 -1
  78. package/build/utils/types.d.ts +12 -0
  79. package/build/utils/types.d.ts.map +1 -0
  80. package/build/utils/types.js +2 -0
  81. package/build/utils/types.js.map +1 -0
  82. package/build/utils.d.ts +7 -6
  83. package/build/utils.d.ts.map +1 -1
  84. package/build/utils.js +10 -1
  85. package/build/utils.js.map +1 -1
  86. package/package.json +12 -7
  87. package/src/consts.local.ts +26 -0
  88. package/src/consts.ts +241 -1
  89. package/src/errors.ts +44 -0
  90. package/src/facet.ts +17 -0
  91. package/src/gate/consts.local.ts +3 -0
  92. package/src/gate/index.ts +3 -0
  93. package/src/gate/param.ts +108 -0
  94. package/src/gate/resolve.ts +114 -0
  95. package/src/gate/types.local.ts +4 -0
  96. package/src/gate/types.ts +117 -0
  97. package/src/gate/validate.ts +123 -0
  98. package/src/index.ts +9 -0
  99. package/src/permission/types.ts +33 -0
  100. package/src/permission.ts +48 -0
  101. package/src/runtime/types.ts +107 -0
  102. package/src/runtime.ts +142 -0
  103. package/src/schemas.ts +122 -0
  104. package/src/types.ts +492 -31
  105. package/src/utils/types.ts +11 -0
  106. package/src/utils.ts +12 -7
  107. package/tests/gate-param.spec.ts +233 -0
  108. package/tests/runtime.spec.ts +101 -0
  109. package/tests/tsconfig.json +12 -0
  110. package/tests/utils.spec.ts +47 -1
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: clients
3
+ description: Provisioning OIDC clients — globally unique client ids, the realm field, issuer and redirect-URI rules — and selecting the IAM backend.
4
+ metadata:
5
+ purposes: auth, config
6
+ mentions: claimClient, deleteClient, getIssuerUrl, redirectUris, IamClientError, IAM_MODE_INTEGRATED, IAM_MODE_KEYCLOAK, iamMode, IamClient
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/iam — Clients, issuers and backend selection
11
+
12
+ ## A client id is a global name
13
+
14
+ A provider resolves a client from the bare `client_id` a relying party sends — the adapter gets no
15
+ tenant context — so the id is unique across the whole deployment, not per entity, and the store
16
+ cannot namespace it on a consumer's behalf. Uniqueness has to be *in the id*.
17
+
18
+ - Compose it with the owning entity in it. A name derived from a project's own name alone lets two
19
+ organizations share one registration: one secret, one redirect-URI list, one permission-definition
20
+ set, and one grant namespace, because `PermissionSet.scope` is that same string.
21
+ - `ensureClient` refuses a record belonging to another entity rather than returning it. The failure
22
+ is deliberate — a loud provisioning error is the only alternative to a silent cross-tenant handover.
23
+ - Assign an id **once** and reserve it with `claimClient`. Whatever a consumer derives ids from
24
+ (a project alias, a slug) may later be released and re-used by a sibling, so only the registry can
25
+ say whether an id is free. `deleteClient` gives it back, or a deleted project's name is burned.
26
+ - Backends whose clients are already per-tenant (keycloak realms) satisfy this by construction:
27
+ `claimClient` returns true and `deleteClient` may be `IamUnsupported`.
28
+
29
+ ## `IamClient.realm` field
30
+
31
+ Every implementation sets `realm` to the entity id, so read `client.realm ?? fallbackEntityId` and
32
+ never reach into an adapter's own internals (`(client as any)._realm`) for the same value.
33
+
34
+ ## Issuer & redirect URIs
35
+
36
+ **`getIssuerUrl(entityId)` is the only place an issuer URL may be composed.** Consumers pass the
37
+ result straight through as `OidcProviderDescriptor.discoveryUrl`; they must never rebuild it from a
38
+ host plus a base path. Each backend owns its own shape (keycloak `{iam-host}/realms/{entityId}`,
39
+ integrated `{provider-host}/{basePath}`), and an implementation must:
40
+
41
+ - resolve it from configuration only — no admin round-trip, so a consumer that just needs the issuer
42
+ never pays for or fails on a token grant;
43
+ - return exactly what the provider advertises as `issuer` (`openid-client` compares the two and fails
44
+ discovery on any difference — build it with the same `makeUrl(..., { base: true })` call
45
+ `@owlmeans/server-oidc-provider` uses);
46
+ - throw `IamClientError` when the provider's service route is missing, never fall back silently.
47
+
48
+ `getEntityAdminConfig` additionally carries the same value as `discoveryUrl`, so the admin provider
49
+ config is self-sufficient too.
50
+
51
+ **Always pass `redirectUris`.** Omitting them is a keycloak-only legacy default (`['*']`, which
52
+ Keycloak expands per-origin). The integrated provider does exact `redirect_uri` matching and
53
+ `oidc-provider` refuses to load a client whose `redirect_uris` are not absolute URIs — so an
54
+ integrated backend's `ensureClient` throws `IamClientError('redirect-uris')` on creation rather than
55
+ registering a client that can never complete a callback. An omitted list never widens an existing
56
+ hardened client.
57
+
58
+ ## Selecting the IAM backend
59
+
60
+ The choice is made **once, in a service factory**, and nowhere else. Feature code depends on
61
+ `IamService` and never learns which implementation answers it. `IAM_MODE_INTEGRATED` /
62
+ `IAM_MODE_KEYCLOAK` and `OidcIamConfig.iamMode` (`@owlmeans/oidc`) exist so a deployment can carry
63
+ that choice as ordinary configuration rather than as a branch scattered through handlers. No OwlMeans
64
+ package reads `iamMode`; it is declared for the consumer's own factory to switch on, which is the
65
+ only thing that may branch.
66
+
67
+ ```ts
68
+ import { DEFAULT_ALIAS, IAM_MODE_INTEGRATED, IAM_MODE_KEYCLOAK } from '@owlmeans/iam'
69
+ import type { IamMode, IamService } from '@owlmeans/iam'
70
+
71
+ // makeKeycloakAdapter / makeIntegratedAdapter are PLACEHOLDER names standing in for whichever
72
+ // adapter packages the deployment installs — this factory is the one place either is named, and
73
+ // every consumer resolves IamService by alias instead. An adapter exposes a
74
+ // `(alias?: string, options?: object) => IamService` factory whose alias defaults to DEFAULT_ALIAS.
75
+ export const makeIamService = (mode: IamMode = IAM_MODE_INTEGRATED): IamService =>
76
+ mode === IAM_MODE_KEYCLOAK
77
+ ? makeKeycloakAdapter(DEFAULT_ALIAS, /* adapter options */)
78
+ : makeIntegratedAdapter(DEFAULT_ALIAS, /* adapter options */)
79
+
80
+ // cfg.oidc typed as OidcSharedConfig & OidcIamConfig is what carries the mode
81
+ context.registerService(makeIamService(cfg.oidc?.iamMode))
82
+ ```
83
+
84
+ An adapter that reassembles the issuer from a registered service alias plus a base path must be given
85
+ the **consumer's own** alias and path. Whatever an adapter defaults to is a placeholder, and a
86
+ mismatch makes every service lookup behind `getIssuerUrl` miss.
87
+
88
+ An integrated backend — one built on `@owlmeans/server-oidc-provider`, so the deployment hosts its
89
+ own provider — is the default choice; a Keycloak-backed adapter exists for deployments that already
90
+ run one.
91
+
92
+ When a context hosts **more than one** `IamService` instance, every instance must be constructed with
93
+ the same options — otherwise they disagree about the issuer, and only one of them can be right.
@@ -0,0 +1,106 @@
1
+ ---
2
+ name: permissions
3
+ description: Permission names and grant forms, IamGrantMode, the four permission kinds, default classes and restrictions.
4
+ metadata:
5
+ purposes: auth
6
+ mentions: hasPermission, grantPermission, revokePermission, ensurePermission, IamGrantMode, IamDefaultClass, composePermissionName, parsePermissionName, entityScoped, resourceScoped, defaultClass
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/iam — Permission model
11
+
12
+ ## Permission model (kinds and grant forms)
13
+
14
+ Permissions are declared per entity client (project) with `ensurePermission` and granted to end-user
15
+ subjects with `grantPermission`:
16
+
17
+ - **Unscoped (project-wide)**: `grantPermission(entityId, clientId, profileId, 'article--modify')`
18
+ - **Resource-scoped**: `grantPermission(entityId, clientId, profileId, 'department--modify', { resources: ['dep-123'] })` —
19
+ the grant only applies to the listed resource ids.
20
+
21
+ Grants materialize as OwlMeans `PermissionSet[]` (`scope` = clientId; resource-scoped grants live in a
22
+ dedicated set per permission carrying `resources[]`, because `resources` applies to all keys of a set).
23
+
24
+ A **name** is `<resource>--<action>`, with two hyphens. One hyphen is not a separator: `enquiry-view`
25
+ is a resource named `enquiry-view` with no action, and it must keep parsing that way, because a name
26
+ already granted somewhere cannot be reinterpreted without orphaning the grant. Build and read names
27
+ through `composePermissionName` / `parsePermissionName` rather than splitting strings.
28
+
29
+ `resourceScoped: true` says a permission *may* be bound to resource ids, not that it must be. The
30
+ same name is grantable in both forms and they are stored separately, which is what `IamGrantMode`
31
+ addresses: `Blanket` is the set carrying no `resources` (and `hasPermission` reads it as covering
32
+ every id), `Resources` is an explicit id list, `All` (revoke-only) is every form, and `Inherit`
33
+ (revoke-only) is the subject's own blanket value — see "Restrictions". A grant in `All` or `Inherit`
34
+ is refused (`IamGrantError('mode:all-is-revoke-only')`, `'mode:inherit-is-revoke-only'`). The
35
+ defaults are asymmetric on purpose — `grantPermission` defaults to `Blanket`, `revokePermission` to
36
+ `All`, because a bare revoke has always meant "remove it everywhere". Pass `mode` explicitly rather
37
+ than relying on either.
38
+
39
+ `area` is a presentational grouping tag (`IAM_AREAS`, or any string a deployment defines) and decides
40
+ nothing at request time. `managed: true` marks a definition the platform owns: an operator cannot
41
+ revoke it from an undifferentiated list, and a repair that deletes "definitions no declaration names"
42
+ must not reach one.
43
+
44
+ ### Permission kinds
45
+
46
+ What a grant binds to is a property of the DEFINITION, and grants follow it:
47
+
48
+ | Kind | Definition flags | Grant stored on | Applies |
49
+ |---|---|---|---|
50
+ | unbound | none | the subject's primary row, or a group | everywhere |
51
+ | entity-bound | `entityScoped` | the subject's row of THAT organization, or a group of it | only while acting in that organization |
52
+ | resource-bound | `resourceScoped` | the primary row, a titled set with `resources` | to those resource ids |
53
+ | both | both | that organization's row, a titled set with `resources` | both checks |
54
+
55
+ - Granting an `entityScoped` definition REQUIRES `args.entitySlug`, which names the subject's row
56
+ there — the subject must be a member. Any other definition refuses `args.entitySlug`. Turning
57
+ `entityScoped` off while grants exist is refused: every bound grant would silently widen.
58
+ - Stored sets carry the organization's id; CLAIM sets (provider → relying party only) carry its
59
+ `entitySlug` instead (`OidcPermissionSetClaim`). The relying party keeps the unbound sets and the
60
+ acting organization's, strips the binding, and drops the rest — a browser token never carries a
61
+ bound set of another organization.
62
+ - `hasPermission(auth, name, { entitySlug })` applies a set without `entitySlug` everywhere and a
63
+ bound one only when `opts.entitySlug` names its organization — fail-closed, so a bound set that
64
+ leaked into a token never counts where the caller named no organization.
65
+
66
+ ### Default classes
67
+
68
+ `defaultClass` says who holds a definition without an explicit grant. It is evaluated when claims and
69
+ grant listings are built — never written onto a row:
70
+
71
+ - `User` — every signed-in subject of the client. Requires an unbound, unmanaged `user`-area definition.
72
+ - `Member` / `Owner` — every member / owner of the organization the subject acts in. Requires
73
+ `entityScoped`.
74
+ - `None` — nobody.
75
+
76
+ A class default is a BLANKET set, so `Owner` on a resource-scoped definition is its all-records form.
77
+ A listing marks each grant's `origin`: `Direct`, `Default`, or `Group` with `through` naming the
78
+ group. A backend that cannot evaluate classes (Keycloak) refuses any `defaultClass` other than
79
+ `None` with `IamUnsupported`.
80
+
81
+ ### Restrictions
82
+
83
+ Every subject — a person or a group — has ONE blanket value of its own per binding: `true` grants,
84
+ `false` RESTRICTS, absent inherits. A subject holds a name when its own value is `true`, or when it is
85
+ not `false` and something it inherits grants it.
86
+
87
+ | Subject | Inherits from | Its own value lives on |
88
+ |---|---|---|
89
+ | person | class defaults, its groups' grants and its groups' bundles | the primary row (unbound definition); the row of the binding's organization (bound — for a group-derived name, the row that names the group) |
90
+ | group | its bundles | the group's own blanket set |
91
+
92
+ - A restriction withholds every INHERITED form of the name in that binding — a class default, a
93
+ group's blanket grant, a group's per-record grant, a bundle's selection. The subject's OWN grants,
94
+ per-record ones included, are never cut. A group's restriction cuts only its own bundles, for all
95
+ its members; another group granting the name still grants it.
96
+ - A `Blanket` or `All` revoke of a DECLARED definition writes the restriction (an undeclared name gets
97
+ none). `Inherit` drops the subject's own value — grant or restriction — writes nothing in its
98
+ place, leaves resource-scoped sets alone and ignores `resources`; a bound definition needs
99
+ `entitySlug` for it as for `Blanket`. A later `Blanket` grant overwrites a restriction with `true`.
100
+ - Tokens carry only what is held: a relying party never sees a restriction, only fewer permissions.
101
+ - Listings (`listGrants`, `groups.grants`) answer what is held; `IamGrantListArgs.restricted` adds
102
+ the inherited grants the subject's own restriction withholds as `restricted: true` (same `origin` /
103
+ `through` as the held form), so a console can show them and lift them with `Inherit`. A user's
104
+ `grantCount` counts held grants only.
105
+ - A backend without inheritance (Keycloak) has nothing to restrict: every revoke, `Inherit`
106
+ included, removes the role, and a listing ignores `restricted`.
@@ -0,0 +1,115 @@
1
+ ---
2
+ name: service
3
+ description: The full IamService interface with its per-method contracts, and the per-client user operations.
4
+ metadata:
5
+ purposes: auth, server-api
6
+ mentions: IamService, getEntityAdminConfig, ensureClient, getClient, grantBundle, listGrants, listUsers, inviteUser, updateUser, removeUser, syncStaff, normalizePermissions, deletePermission
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/iam — The IamService interface
11
+
12
+ ## `IamService` interface
13
+
14
+ ```ts
15
+ interface IamService extends InitializedService {
16
+ // The tenant's admin OIDC provider config, and a raw admin { token, realm } pair.
17
+ getEntityAdminConfig: (entityId: string) => Promise<OidcProviderConfig>
18
+ getCredentialsPair: (entityId: string) => Promise<IamCredentialsPair>
19
+
20
+ // The tenant's public issuer URL — see "Issuer & redirect URIs" (the clients part).
21
+ getIssuerUrl: (entityId: string) => Promise<string>
22
+
23
+ // Provision an OIDC client for a tenant — see "Issuer & redirect URIs" and
24
+ // "A client id is a global name" (the clients part) for the two rules it has to obey.
25
+ ensureClient: (entityId: string, clientId: string, options?: IamClientOptions) => Promise<IamClient>
26
+
27
+ // The entity's client without its secret, or null. Never provisions.
28
+ getClient: (entityId: string, clientId: string) => Promise<IamClientInfo | null>
29
+
30
+ // Reserve a free client id without provisioning it; false when anyone else holds it.
31
+ claimClient: (entityId: string, clientId: string) => Promise<boolean>
32
+
33
+ // Release a client id and everything keyed by it — its subjects' rows, its groups, its
34
+ // definitions (called when a project/slot is deleted).
35
+ deleteClient: (entityId: string, clientId: string) => Promise<void>
36
+
37
+ // Declare a permission. Returns the canonical name. MERGES into an existing definition —
38
+ // resource/action re-derived, omitted flags kept — and NEVER rewrites the name. Refuses a
39
+ // `resource` carrying an `@` selector.
40
+ ensurePermission: (entityId: string, clientId: string, resource?: string, args?: IamPermissionArgs) => Promise<string>
41
+
42
+ // Remove definitions BY NAME, and by default their grants. Idempotent ({ found: false } when
43
+ // absent). Grants go first: a crash between the stores must leave a definition with no grants,
44
+ // never grants with no definition. A `managed` definition needs args.managed === true.
45
+ deletePermission: (entityId: string, clientId: string, permission: string | string[], args?: IamPermissionDeleteArgs) => Promise<IamPermissionRemoval[]>
46
+
47
+ // Repair definitions whose stored name leaked a gate selector, moving grants in the same write.
48
+ // A rename and a merge — never a delete, never an invention.
49
+ normalizePermissions: (entityId: string, clientId: string, args?: IamNormalizeArgs) => Promise<IamNormalizationReport>
50
+
51
+ // Provision a resource and assign it to the tenant's owner role (keycloak). A no-op on the
52
+ // integrated backend, which has no owner role to assign to.
53
+ ensureResourceOwnership: (entityId: string, clientId: string, resource: IamResourceSpec) => Promise<void>
54
+
55
+ // --- Authorization (permission definitions & grants) ---
56
+
57
+ listPermissions: (entityId: string, clientId: string, filter?: IamPermissionFilter) => Promise<IamPermissionDefinition[]>
58
+
59
+ // args.resources selects the resource-scoped form, args.entitySlug the binding of an entityScoped
60
+ // definition; see "Permission model (kinds and grant forms)" in the permissions part. A
61
+ // blanket/all revoke of a declared definition RESTRICTS; mode Inherit takes the own value
62
+ // back — see "Restrictions" there
63
+ grantPermission: (entityId: string, clientId: string, profileId: string, permission: string, args?: IamGrantArgs) => Promise<IamGrant>
64
+ revokePermission: (entityId: string, clientId: string, profileId: string, permission: string, args?: IamGrantArgs) => Promise<void>
65
+
66
+ // Grant everything a bundle selects to each subject, idempotently, in ONE read and write per
67
+ // subject. Returns what each subject now holds, not what changed. Use it instead of looping over
68
+ // grantPermission, which costs N·M writes and N·M lost-update windows on the same records.
69
+ grantBundle: (entityId: string, clientId: string, profileIds: string[], bundle: IamGrantBundle) => Promise<IamGrant[]>
70
+
71
+ // Direct, default-class and group grants, each with its `origin` — what is HELD; args.restricted
72
+ // adds what the subject's own restrictions withhold (`restricted: true`).
73
+ listGrants: (entityId: string, clientId: string, profileId?: string, args?: IamGrantListArgs) => Promise<IamGrant[]>
74
+
75
+ // --- Users of one client — see "Users are per client" ---
76
+
77
+ listUsers: (entityId: string, clientId?: string) => Promise<IamUser[]>
78
+ getUser: (entityId: string, clientId: string, profileId: string) => Promise<IamUser | null>
79
+ inviteUser: (entityId: string, clientId: string, invite: IamUserInvite) => Promise<IamUser>
80
+ updateUser: (entityId: string, clientId: string, profileId: string, update: IamUserUpdate) => Promise<IamUser>
81
+ removeUser: (entityId: string, clientId: string, profileId: string) => Promise<void>
82
+
83
+ // The owning organization's staff, admitted to the client through the managed `members` group.
84
+ syncStaff: (entityId: string, clientId: string, args: IamStaffSyncArgs) => Promise<IamStaffSyncReport>
85
+
86
+ // --- Tenancy facets — see "Organizations, groups and subjects" (the tenancy part) ---
87
+ organizations: IamOrganizationFacet
88
+ groups: IamGroupFacet
89
+ subjects: IamSubjectFacet
90
+ }
91
+ ```
92
+
93
+ The first parameter of every method is `entityId` — the organization that OWNS the client — and
94
+ every `(entityId, clientId)` method verifies that ownership before it reads or writes anything. The
95
+ organizations a client's subjects act in are a different thing, named by `entitySlug` inside the
96
+ facets.
97
+
98
+ ## Users are per client
99
+
100
+ A person is ONE account (one per e-mail address); what a client sees of it are its rows of that app —
101
+ one per (account, app, organization), the PRIMARY one in the account's own organization. The
102
+ `profileId` a client knows is the pairwise subject, the same on every row of one (account, app) and
103
+ different for every app, so two apps of one customer cannot correlate their users by it.
104
+
105
+ - `listUsers(entityId, clientId)` lists every account holding a row of the client, in whichever
106
+ organization. Without `clientId` it is a read-only aggregate over the entity's clients, for a
107
+ console — nothing is written through it.
108
+ - `getUser` / `inviteUser` / `updateUser` / `removeUser` take the client. `removeUser` deletes this
109
+ account's rows of THAT app only — the account, and its rows of other apps, stay. `updateUser`
110
+ writes `disabled` on the primary row, which is what refuses the subject at the provider.
111
+ - `inviteUser` is idempotent by email, which is what lets an invitation and a first sign-in converge on
112
+ one record instead of accumulating twins.
113
+
114
+ A backend with no user store of its own — one where the customer manages users in the provider's own
115
+ console — throws `IamUnsupported('user-management')` from all five methods.
@@ -0,0 +1,72 @@
1
+ ---
2
+ name: tenancy
3
+ description: The organizations, groups and subjects facets of IamService, and the runtime IAM API a tenanted client's server calls.
4
+ metadata:
5
+ purposes: auth, server-api
6
+ mentions: IamOrganizationFacet, IamGroupFacet, IamSubjectFacet, unsupportedFacet, IAM_MEMBERS_GROUP, makeIamRuntimeProtocols, IAM_RUNTIME_GUARD, IAM_RUNTIME_ROUTES, IAM_RUNTIME_PATH, iamRuntime
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/iam — Organizations, groups, subjects and the runtime API
11
+
12
+ ## Organizations, groups and subjects
13
+
14
+ The three facets of `IamService` carry tenancy. A backend that cannot provide one answers
15
+ `unsupportedFacet<IamOrganizationFacet>('organizations')` (and so on): every method of it throws
16
+ `IamUnsupported(what)` SYNCHRONOUSLY, because `subjects.identify` is synchronous and a rejected
17
+ promise would hand it the wrong type — an awaited call rejects either way.
18
+
19
+ - **Tenancy is the client's configuration.** `IamClientConfig.users` / `.operators` turn it on: with
20
+ neither flag, every subject acts in the client's owning organization (a single-tenant app); with
21
+ either, each subject acts in organizations of its own and the provider claims them under the
22
+ `organizations` scope (`@owlmeans/oidc`). The flags also gate the runtime API's writes by area.
23
+ - **`organizations`** — `list`, `get`, `create({ title?, profileId? })` (that subject becomes the
24
+ owner), `update({ title })`, `members`, `addMember` (find-or-create by e-mail, idempotent, refused
25
+ past the membership cap), `updateMember`, `removeMember`. The last owner cannot be demoted or
26
+ removed (`IamOrganizationError`).
27
+ - **`groups`** — a group is (organization, client, key), kept inside the organization record; one
28
+ client never reads or writes another's. `list`, `ensure`, `remove`, `members`, `addMembers`,
29
+ `removeMembers`, `grant` / `revoke` (bound to the group's organization; a revoke restricts, as for
30
+ a person), `grants(…, key, args?)` (its own grants as `Direct`, the names its bundles select as
31
+ `Bundle`, minus what it restricts; `args.restricted` adds the cut bundle names), and `setBundles` —
32
+ a bundle (`{ filter?, permissions? }`) grants every definition it selects to the group's members at
33
+ claim time. `IAM_MEMBERS_GROUP` is the managed group `syncStaff` keeps in the owning organization:
34
+ its existence, key, membership and bundles are the sync's (`ensure`, `remove`, `addMembers`,
35
+ `removeMembers`, `setBundles` → `IamGroupError('managed:members')`), while its grants and
36
+ restrictions are the operator's (`grant` / `revoke`) and survive every sync.
37
+ - **`subjects`** — the provider's half, keyed by the ACCOUNT, which never leaves the provider.
38
+ `signIn(clientId, { email })` finds or creates the account and its rows for the client;
39
+ `resolve(clientId, accountId)` builds the `IamSubject` the claims come from (`null` = not admitted:
40
+ no row, or a disabled primary row); `identify(clientId, accountId)` is the pairwise `sub`.
41
+
42
+ ## Runtime IAM API
43
+
44
+ `makeIamRuntimeProtocols({ service, path? })` declares the provider-side API a tenanted client's
45
+ server calls on behalf of its signed-in subject. It is declarations only: the provider binds it, and
46
+ a relying party calls it through `iamRuntime` (`@owlmeans/server-iam`) over plain `fetch`.
47
+
48
+ | Route (under `path`, default `IAM_RUNTIME_PATH`) | Tree | Who |
49
+ |---|---|---|
50
+ | `GET /organizations` | `organizations.list` | any subject — its own |
51
+ | `POST /organizations` `{ title? }` | `organizations.create` | any subject of a tenanted client; it becomes owner |
52
+ | `POST /organizations/:entitySlug` `{ title }` | `organizations.update` | owner |
53
+ | `GET /organizations/:entitySlug/members` | `members.list` | member |
54
+ | `POST /organizations/:entitySlug/members` `{ email, name?, owner? }` | `members.add` | owner; idempotent by e-mail |
55
+ | `POST …/members/:profileId` `{ owner?, groups? }` | `members.update` | owner |
56
+ | `POST …/members/:profileId/remove` | `members.remove` | owner, or the member itself |
57
+ | `GET …/permissions` | `permissions.list` | member |
58
+ | `GET …/grants` `?profileId` | `grants.list` | owner |
59
+ | `POST …/grants`, `POST …/grants/revoke` `{ profileId, permission, resources?, mode? }` | `grants.assign` / `grants.revoke` (a revoke restricts; `mode: inherit` takes the own value back) | owner |
60
+
61
+ - **Every route, the base included, is pinned to `service`** — a route's service is never inherited
62
+ from its parent — and every leaf hangs under ONE base carrying `IAM_RUNTIME_GUARD`. The serving
63
+ process registers that guard: it admits only a bearer access token the provider issued, and takes
64
+ the client and the account from it, never from the request.
65
+ - **The wire names organizations by `entitySlug` and subjects by the calling client's pairwise
66
+ `profileId`** — no organization or account id appears in any shape. Every schema is closed, since
67
+ the server's AJV strips an undeclared key silently. `IAM_RUNTIME_ROUTES` is the one leaf-path table
68
+ the declarations and the fetch client share.
69
+ - The serving process enforces the write rules: only `entityScoped`, unmanaged definitions of the
70
+ CALLING client, binding forced to the path's organization, the last owner protected, the
71
+ membership cap, and only for an area the client's tenancy config opens (`users` → `user`,
72
+ `operators` → `operator`). A non-owner write is `AuthForbidden(ORGANIZATION_OWNER_REFUSAL)`.
package/build/consts.d.ts CHANGED
@@ -1,5 +1,174 @@
1
+ import type { IamRuntimeAck, IamRuntimeGrantQuery, IamRuntimeMemberInvite, IamRuntimeMemberParams, IamRuntimeOrganizationCreate, IamRuntimeOrganizationParams, IamRuntimeOrganizationUpdate } from './runtime/types.js';
2
+ import type { JSONSchemaType } from 'ajv';
1
3
  export declare const DEFAULT_ALIAS = "iam-service";
2
4
  export declare const IAM_MODE_KEYCLOAK = "keycloak";
3
5
  export declare const IAM_MODE_INTEGRATED = "integrated";
4
- export type IamMode = typeof IAM_MODE_KEYCLOAK | typeof IAM_MODE_INTEGRATED;
6
+ /**
7
+ * Gate-param syntax: `<permission>[@<selector>]`.
8
+ *
9
+ * The `@` belongs to the GATE and to nothing else. It says where a resource id is read from at
10
+ * request time; it is never part of a permission's stored NAME. A definition or a grant whose name
11
+ * carries one is a key nothing ever looks up, so every grant against it is a silent no-op.
12
+ */
13
+ export declare const RESOURCE_PARAM_SEPARATOR = "@";
14
+ /**
15
+ * Separates an explicit source from its path: `@body:order.id`.
16
+ *
17
+ * Deliberately `:` rather than a dot. Under a dotted-only form the meaning of a selector changes the
18
+ * moment a route gains a param named like a source keyword — `/report/:query` with `@query.id` flips
19
+ * from "the route param literally named `query.id`" to "the query string's `id`", redirecting an
20
+ * authorization lookup from a router-matched value to attacker-supplied input, caused by an
21
+ * unrelated route rename. `:` is decidable in one character, and no gate-param string written before
22
+ * this existed can contain one.
23
+ */
24
+ export declare const RESOURCE_SOURCE_SEPARATOR = ":";
25
+ /** Separates path segments inside a qualified selector: `@body:order.id`. */
26
+ export declare const RESOURCE_PATH_SEPARATOR = ".";
27
+ /** Where a qualified selector may read a resource id from. */
28
+ export declare enum GateParamSource {
29
+ Params = "params",
30
+ Query = "query",
31
+ Body = "body",
32
+ Headers = "headers",
33
+ Auth = "auth"
34
+ }
35
+ /**
36
+ * What the bare form (`@enquiryId`) searches, in order.
37
+ *
38
+ * Params first, then query. The query fallback is legacy and deliberately kept: removing it would
39
+ * silently deny already-deployed applications whose selector happens to name a query key, and a
40
+ * silent denial inside a customer's running app is the worst available outcome.
41
+ */
42
+ export declare const DEFAULT_GATE_PARAM_SOURCES: GateParamSource[];
43
+ /** Why a gate param is structurally wrong — a misconfiguration, not a failed authorization. */
44
+ export declare enum GateParamErrorCode {
45
+ UnknownSource = "gate-param:unknown-source",
46
+ EmptySelector = "gate-param:empty-selector",
47
+ EmptySegment = "gate-param:empty-segment",
48
+ /** Named a request key the entrypoint's filter does not declare — AJV strips it before the gate. */
49
+ UnreachableKey = "gate-param:unreachable-key",
50
+ /** Named a route param the entrypoint's own path does not carry. */
51
+ UnknownRouteParam = "gate-param:unknown-route-param"
52
+ }
53
+ /** Why a selector produced no resource id. */
54
+ export declare enum GateResolutionFailure {
55
+ /** The source exists, the path does not. */
56
+ NotProvided = "not-provided",
57
+ /** The request carries no such source at all. */
58
+ SourceMissing = "source-missing",
59
+ /** Resolved to an object or a multi-element array — refused rather than stringified. */
60
+ NotScalar = "not-scalar",
61
+ /** The selector itself did not parse. */
62
+ Malformed = "malformed"
63
+ }
64
+ /**
65
+ * Which FORM of a grant an operation addresses.
66
+ *
67
+ * A resource-scoped permission is grantable two ways, under one name: `Blanket` (a PermissionSet
68
+ * carrying no `resources`, which `hasPermission` reads as covering every id) and `Resources` (an
69
+ * explicit id list). They are stored separately and must be revoked separately, or a UI offering
70
+ * both controls would have one silently wipe the other.
71
+ *
72
+ * Every subject, person or group, also has ONE blanket value of its own per binding: `true` grants,
73
+ * `false` RESTRICTS (it cuts what the subject would inherit from a default class, a group or a
74
+ * bundle), absent inherits. A `Blanket` or `All` revoke of a declared definition writes the
75
+ * restriction; `Inherit` takes the subject's own value back.
76
+ */
77
+ export declare enum IamGrantMode {
78
+ Blanket = "blanket",
79
+ Resources = "resources",
80
+ /** Revoke-only: every form. Illegal on a grant. */
81
+ All = "all",
82
+ /**
83
+ * Revoke-only: drop the subject's OWN blanket value in the binding — a grant or a restriction —
84
+ * so it holds exactly what it inherits. Resource-scoped sets are untouched. Illegal on a grant.
85
+ */
86
+ Inherit = "inherit"
87
+ }
88
+ /** What to do about grants that still reference a definition being removed. */
89
+ export declare enum IamRemovalPolicy {
90
+ /** Strip the permission from every subject of the entity, then drop the definition. */
91
+ Cascade = "cascade",
92
+ /** Refuse while any subject still holds it; the holders travel in the error. */
93
+ Refuse = "refuse"
94
+ }
95
+ /**
96
+ * The grouping tiers a permission definition may be tagged with.
97
+ *
98
+ * A tag decides NOTHING at request time — `hasPermission` and every gate treat a permission name as
99
+ * one opaque string — and only groups the permission in an administrator's screen. `guest` is
100
+ * deliberately absent: a public surface declares no permissions, and adding an area later would
101
+ * re-classify names already tagged.
102
+ */
103
+ export declare const IAM_AREAS: readonly ['user', 'operator', 'admin'];
104
+ /** Separates the resource from the action in a permission name. TWO hyphens, never one. */
105
+ export declare const PERMISSION_ACTION_SEPARATOR = "--";
106
+ /**
107
+ * Who holds a permission without an explicit grant — a property of the DEFINITION, evaluated when
108
+ * claims are built and never written onto a profile.
109
+ *
110
+ * `User` is every signed-in subject of the client (an unbound, unmanaged `user`-area definition
111
+ * only); `Member` and `Owner` are every member / owner of the organization the subject acts in, so
112
+ * they require an `entityScoped` definition. A class default is a BLANKET set: `Owner` on a
113
+ * resource-scoped definition is its all-records form.
114
+ */
115
+ export declare enum IamDefaultClass {
116
+ None = "none",
117
+ User = "user",
118
+ Member = "member",
119
+ Owner = "owner"
120
+ }
121
+ /** Why a subject holds a grant, as a listing reports it. */
122
+ export declare enum IamGrantOrigin {
123
+ /** Written on the subject's own row. */
124
+ Direct = "direct",
125
+ /** The definition's `defaultClass` covers the subject. */
126
+ Default = "default",
127
+ /** Held by a group the subject is a member of — `IamGrant.through` names it. */
128
+ Group = "group",
129
+ /** A group's own listing only: the names its bundles select; its own grants are `Direct`. */
130
+ Bundle = "bundle"
131
+ }
132
+ /**
133
+ * The managed group `syncStaff` keeps in a client's owning organization: the owner's own staff,
134
+ * admitted to the client with the bundles the sync hands it. Its key, membership, bundles and
135
+ * existence are the synchronization's alone; its grants and restrictions are the operator's and
136
+ * survive every sync.
137
+ */
138
+ export declare const IAM_MEMBERS_GROUP = "members";
139
+ /**
140
+ * The `profileId` of a credential that proves an e-mail address and identifies nobody yet: what the
141
+ * end-user sign-in hands the provider's finalizer, which then resolves the account itself. It carries
142
+ * no `entitySlug` — a proof bound to an organization is something else.
143
+ */
144
+ export declare const IAM_EMAIL_PROOF = "proof:email";
145
+ /**
146
+ * The guard of the runtime IAM API (`makeIamRuntimeProtocols`). The serving process registers it: it
147
+ * admits a bearer access token the provider itself issued, and takes the client and the account from
148
+ * that token, never from the request.
149
+ */
150
+ export declare const IAM_RUNTIME_GUARD = "iam-runtime-guard";
151
+ /** Where the runtime IAM API mounts under its service unless the declaration names another path. */
152
+ export declare const IAM_RUNTIME_PATH = "/runtime";
153
+ /**
154
+ * The runtime API's leaf paths under its base. One table so the declarations and a fetch-only client
155
+ * (a target's server, which binds no entrypoint of the provider's tree) cannot disagree on a segment.
156
+ */
157
+ export declare const IAM_RUNTIME_ROUTES: {
158
+ readonly organizations: '/organizations';
159
+ readonly organization: '/organizations/:entitySlug';
160
+ readonly members: '/organizations/:entitySlug/members';
161
+ readonly member: '/organizations/:entitySlug/members/:profileId';
162
+ readonly memberRemove: '/organizations/:entitySlug/members/:profileId/remove';
163
+ readonly permissions: '/organizations/:entitySlug/permissions';
164
+ readonly grants: '/organizations/:entitySlug/grants';
165
+ readonly grantsRevoke: '/organizations/:entitySlug/grants/revoke';
166
+ };
167
+ export declare const IamRuntimeOrganizationCreateSchema: JSONSchemaType<IamRuntimeOrganizationCreate>;
168
+ export declare const IamRuntimeOrganizationUpdateSchema: JSONSchemaType<IamRuntimeOrganizationUpdate>;
169
+ export declare const IamRuntimeOrganizationParamsSchema: JSONSchemaType<IamRuntimeOrganizationParams>;
170
+ export declare const IamRuntimeMemberInviteSchema: JSONSchemaType<IamRuntimeMemberInvite>;
171
+ export declare const IamRuntimeMemberParamsSchema: JSONSchemaType<IamRuntimeMemberParams>;
172
+ export declare const IamRuntimeGrantQuerySchema: JSONSchemaType<IamRuntimeGrantQuery>;
173
+ export declare const IamRuntimeAckSchema: JSONSchemaType<IamRuntimeAck>;
5
174
  //# sourceMappingURL=consts.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,aAAa,gBAAgB,CAAA;AAE1C,eAAO,MAAM,iBAAiB,aAAa,CAAA;AAC3C,eAAO,MAAM,mBAAmB,eAAe,CAAA;AAE/C,MAAM,MAAM,OAAO,GAAG,OAAO,iBAAiB,GAAG,OAAO,mBAAmB,CAAA"}
1
+ {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"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"}