@owlmeans/iam 0.1.18-rc.8 → 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/build/utils.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
|
+
/** The organization a set is bound to, when it is a claim set that kept its binding. */
|
|
2
|
+
const boundTo = (set) => set.entitySlug;
|
|
1
3
|
/**
|
|
2
4
|
* True when any PermissionSet grants `permission`. An unscoped set (no resources)
|
|
3
5
|
* satisfies a resourceId check too — a project-wide grant covers every resource.
|
|
6
|
+
*
|
|
7
|
+
* The organization follows the same rule the other way round, fail-closed: a set without a binding
|
|
8
|
+
* applies everywhere, a bound one only when `opts.entitySlug` names its organization. A relying
|
|
9
|
+
* party strips the binding from the sets of the organization a session acts in and drops every
|
|
10
|
+
* other bound set, so a bound set reaching a check is one that leaked — and it must not count
|
|
11
|
+
* merely because the caller did not say where the request acts.
|
|
4
12
|
*/
|
|
5
13
|
export const hasPermission = (auth, permission, opts) => auth.permissions?.some(set => (opts?.scope == null || set.scope === opts.scope)
|
|
6
14
|
&& set.permissions[permission] === true
|
|
7
|
-
&& (opts?.resourceId == null || set.resources == null || set.resources.includes(opts.resourceId))
|
|
15
|
+
&& (opts?.resourceId == null || set.resources == null || set.resources.includes(opts.resourceId))
|
|
16
|
+
&& (boundTo(set) == null || (opts?.entitySlug != null && boundTo(set) === opts.entitySlug))) ?? false;
|
|
8
17
|
//# sourceMappingURL=utils.js.map
|
package/build/utils.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAIA,wFAAwF;AACxF,MAAM,OAAO,GAAG,CAAC,GAAkB,EAAsB,EAAE,CAAE,GAA8B,CAAC,UAAU,CAAA;AAEtG;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,CAC3B,IAAmB,EACnB,UAAkB,EAClB,IAA2B,EAClB,EAAE,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,CACzC,CAAC,IAAI,EAAE,KAAK,IAAI,IAAI,IAAI,GAAG,CAAC,KAAK,KAAK,IAAI,CAAC,KAAK,CAAC;OAC9C,GAAG,CAAC,WAAW,CAAC,UAAU,CAAC,KAAK,IAAI;OACpC,CAAC,IAAI,EAAE,UAAU,IAAI,IAAI,IAAI,GAAG,CAAC,SAAS,IAAI,IAAI,IAAI,GAAG,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;OAC9F,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,IAAI,EAAE,UAAU,IAAI,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,KAAK,IAAI,CAAC,UAAU,CAAC,CAAC,CAC5F,IAAI,KAAK,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/iam",
|
|
3
|
-
"version": "0.1.18
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -22,14 +22,19 @@
|
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@owlmeans/auth": "^0.1.18
|
|
26
|
-
"@owlmeans/context": "^0.1.18
|
|
27
|
-
"@owlmeans/
|
|
28
|
-
"@owlmeans/
|
|
25
|
+
"@owlmeans/auth": "^0.1.18",
|
|
26
|
+
"@owlmeans/context": "^0.1.18",
|
|
27
|
+
"@owlmeans/entrypoint": "^0.1.18",
|
|
28
|
+
"@owlmeans/error": "^0.1.18",
|
|
29
|
+
"@owlmeans/oidc": "^0.1.18",
|
|
30
|
+
"@owlmeans/route": "^0.1.18"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"ajv": "*"
|
|
29
34
|
},
|
|
30
35
|
"devDependencies": {
|
|
31
|
-
"@owlmeans/dep-config": "
|
|
32
|
-
"@types/bun": "^1.
|
|
36
|
+
"@owlmeans/dep-config": "^0.1.18",
|
|
37
|
+
"@types/bun": "^1.4.2",
|
|
33
38
|
"nodemon": "^3.1.14",
|
|
34
39
|
"typescript": "^7.0.2"
|
|
35
40
|
},
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const TitleSchema = { type: 'string', minLength: 0, maxLength: 256 } as const
|
|
2
|
+
|
|
3
|
+
/** A pairwise subject: `<clientId>:<digest>`. Never a path separator, never whitespace. */
|
|
4
|
+
export const ProfileIdSchema = { type: 'string', minLength: 1, maxLength: 256, pattern: '^[^/\\s]+$' } as const
|
|
5
|
+
|
|
6
|
+
export const PermissionNameSchema = { type: 'string', minLength: 1, maxLength: 128 } as const
|
|
7
|
+
|
|
8
|
+
export const EmailSchema = { type: 'string', format: 'email', minLength: 3, maxLength: 254 } as const
|
|
9
|
+
|
|
10
|
+
export const ResourcesSchema = {
|
|
11
|
+
type: 'array', items: { type: 'string', minLength: 1, maxLength: 128 }, maxItems: 256, nullable: true,
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
export const aliases = {
|
|
15
|
+
base: 'iam-runtime',
|
|
16
|
+
organization: { list: 'iam-runtime:organization:list', create: 'iam-runtime:organization:create', update: 'iam-runtime:organization:update' },
|
|
17
|
+
member: {
|
|
18
|
+
list: 'iam-runtime:member:list', add: 'iam-runtime:member:add',
|
|
19
|
+
update: 'iam-runtime:member:update', remove: 'iam-runtime:member:remove',
|
|
20
|
+
},
|
|
21
|
+
permission: { list: 'iam-runtime:permission:list' },
|
|
22
|
+
grant: { list: 'iam-runtime:grant:list', assign: 'iam-runtime:grant:assign', revoke: 'iam-runtime:grant:revoke' },
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
/** Keys a facet stand-in answers `undefined` for, so it is never mistaken for a thenable or a primitive. */
|
|
26
|
+
export const INERT_KEYS = new Set<PropertyKey>(['then', 'toJSON'])
|
package/src/consts.ts
CHANGED
|
@@ -1,6 +1,246 @@
|
|
|
1
|
+
import { EmailSchema, ProfileIdSchema, TitleSchema } from './consts.local.js'
|
|
2
|
+
import type { IamRuntimeAck, IamRuntimeGrantQuery, IamRuntimeMemberInvite, IamRuntimeMemberParams, IamRuntimeOrganizationCreate, IamRuntimeOrganizationParams, IamRuntimeOrganizationUpdate } from './runtime/types.js'
|
|
3
|
+
import type { JSONSchemaType } from 'ajv'
|
|
4
|
+
import { EntitySlugValueSchema } from '@owlmeans/auth'
|
|
5
|
+
|
|
1
6
|
export const DEFAULT_ALIAS = 'iam-service'
|
|
2
7
|
|
|
3
8
|
export const IAM_MODE_KEYCLOAK = 'keycloak'
|
|
4
9
|
export const IAM_MODE_INTEGRATED = 'integrated'
|
|
5
10
|
|
|
6
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Gate-param syntax: `<permission>[@<selector>]`.
|
|
13
|
+
*
|
|
14
|
+
* The `@` belongs to the GATE and to nothing else. It says where a resource id is read from at
|
|
15
|
+
* request time; it is never part of a permission's stored NAME. A definition or a grant whose name
|
|
16
|
+
* carries one is a key nothing ever looks up, so every grant against it is a silent no-op.
|
|
17
|
+
*/
|
|
18
|
+
export const RESOURCE_PARAM_SEPARATOR = '@'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Separates an explicit source from its path: `@body:order.id`.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately `:` rather than a dot. Under a dotted-only form the meaning of a selector changes the
|
|
24
|
+
* moment a route gains a param named like a source keyword — `/report/:query` with `@query.id` flips
|
|
25
|
+
* from "the route param literally named `query.id`" to "the query string's `id`", redirecting an
|
|
26
|
+
* authorization lookup from a router-matched value to attacker-supplied input, caused by an
|
|
27
|
+
* unrelated route rename. `:` is decidable in one character, and no gate-param string written before
|
|
28
|
+
* this existed can contain one.
|
|
29
|
+
*/
|
|
30
|
+
export const RESOURCE_SOURCE_SEPARATOR = ':'
|
|
31
|
+
|
|
32
|
+
/** Separates path segments inside a qualified selector: `@body:order.id`. */
|
|
33
|
+
export const RESOURCE_PATH_SEPARATOR = '.'
|
|
34
|
+
|
|
35
|
+
/** Where a qualified selector may read a resource id from. */
|
|
36
|
+
export enum GateParamSource {
|
|
37
|
+
Params = 'params',
|
|
38
|
+
Query = 'query',
|
|
39
|
+
Body = 'body',
|
|
40
|
+
Headers = 'headers',
|
|
41
|
+
Auth = 'auth'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What the bare form (`@enquiryId`) searches, in order.
|
|
46
|
+
*
|
|
47
|
+
* Params first, then query. The query fallback is legacy and deliberately kept: removing it would
|
|
48
|
+
* silently deny already-deployed applications whose selector happens to name a query key, and a
|
|
49
|
+
* silent denial inside a customer's running app is the worst available outcome.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_GATE_PARAM_SOURCES: GateParamSource[] = [
|
|
52
|
+
GateParamSource.Params, GateParamSource.Query
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
/** Why a gate param is structurally wrong — a misconfiguration, not a failed authorization. */
|
|
56
|
+
export enum GateParamErrorCode {
|
|
57
|
+
UnknownSource = 'gate-param:unknown-source',
|
|
58
|
+
EmptySelector = 'gate-param:empty-selector',
|
|
59
|
+
EmptySegment = 'gate-param:empty-segment',
|
|
60
|
+
/** Named a request key the entrypoint's filter does not declare — AJV strips it before the gate. */
|
|
61
|
+
UnreachableKey = 'gate-param:unreachable-key',
|
|
62
|
+
/** Named a route param the entrypoint's own path does not carry. */
|
|
63
|
+
UnknownRouteParam = 'gate-param:unknown-route-param'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Why a selector produced no resource id. */
|
|
67
|
+
export enum GateResolutionFailure {
|
|
68
|
+
/** The source exists, the path does not. */
|
|
69
|
+
NotProvided = 'not-provided',
|
|
70
|
+
/** The request carries no such source at all. */
|
|
71
|
+
SourceMissing = 'source-missing',
|
|
72
|
+
/** Resolved to an object or a multi-element array — refused rather than stringified. */
|
|
73
|
+
NotScalar = 'not-scalar',
|
|
74
|
+
/** The selector itself did not parse. */
|
|
75
|
+
Malformed = 'malformed'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Which FORM of a grant an operation addresses.
|
|
80
|
+
*
|
|
81
|
+
* A resource-scoped permission is grantable two ways, under one name: `Blanket` (a PermissionSet
|
|
82
|
+
* carrying no `resources`, which `hasPermission` reads as covering every id) and `Resources` (an
|
|
83
|
+
* explicit id list). They are stored separately and must be revoked separately, or a UI offering
|
|
84
|
+
* both controls would have one silently wipe the other.
|
|
85
|
+
*
|
|
86
|
+
* Every subject, person or group, also has ONE blanket value of its own per binding: `true` grants,
|
|
87
|
+
* `false` RESTRICTS (it cuts what the subject would inherit from a default class, a group or a
|
|
88
|
+
* bundle), absent inherits. A `Blanket` or `All` revoke of a declared definition writes the
|
|
89
|
+
* restriction; `Inherit` takes the subject's own value back.
|
|
90
|
+
*/
|
|
91
|
+
export enum IamGrantMode {
|
|
92
|
+
Blanket = 'blanket',
|
|
93
|
+
Resources = 'resources',
|
|
94
|
+
/** Revoke-only: every form. Illegal on a grant. */
|
|
95
|
+
All = 'all',
|
|
96
|
+
/**
|
|
97
|
+
* Revoke-only: drop the subject's OWN blanket value in the binding — a grant or a restriction —
|
|
98
|
+
* so it holds exactly what it inherits. Resource-scoped sets are untouched. Illegal on a grant.
|
|
99
|
+
*/
|
|
100
|
+
Inherit = 'inherit'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What to do about grants that still reference a definition being removed. */
|
|
104
|
+
export enum IamRemovalPolicy {
|
|
105
|
+
/** Strip the permission from every subject of the entity, then drop the definition. */
|
|
106
|
+
Cascade = 'cascade',
|
|
107
|
+
/** Refuse while any subject still holds it; the holders travel in the error. */
|
|
108
|
+
Refuse = 'refuse'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The grouping tiers a permission definition may be tagged with.
|
|
113
|
+
*
|
|
114
|
+
* A tag decides NOTHING at request time — `hasPermission` and every gate treat a permission name as
|
|
115
|
+
* one opaque string — and only groups the permission in an administrator's screen. `guest` is
|
|
116
|
+
* deliberately absent: a public surface declares no permissions, and adding an area later would
|
|
117
|
+
* re-classify names already tagged.
|
|
118
|
+
*/
|
|
119
|
+
export const IAM_AREAS = ['user', 'operator', 'admin'] as const
|
|
120
|
+
|
|
121
|
+
/** Separates the resource from the action in a permission name. TWO hyphens, never one. */
|
|
122
|
+
export const PERMISSION_ACTION_SEPARATOR = '--'
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Who holds a permission without an explicit grant — a property of the DEFINITION, evaluated when
|
|
126
|
+
* claims are built and never written onto a profile.
|
|
127
|
+
*
|
|
128
|
+
* `User` is every signed-in subject of the client (an unbound, unmanaged `user`-area definition
|
|
129
|
+
* only); `Member` and `Owner` are every member / owner of the organization the subject acts in, so
|
|
130
|
+
* they require an `entityScoped` definition. A class default is a BLANKET set: `Owner` on a
|
|
131
|
+
* resource-scoped definition is its all-records form.
|
|
132
|
+
*/
|
|
133
|
+
export enum IamDefaultClass {
|
|
134
|
+
None = 'none',
|
|
135
|
+
User = 'user',
|
|
136
|
+
Member = 'member',
|
|
137
|
+
Owner = 'owner'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Why a subject holds a grant, as a listing reports it. */
|
|
141
|
+
export enum IamGrantOrigin {
|
|
142
|
+
/** Written on the subject's own row. */
|
|
143
|
+
Direct = 'direct',
|
|
144
|
+
/** The definition's `defaultClass` covers the subject. */
|
|
145
|
+
Default = 'default',
|
|
146
|
+
/** Held by a group the subject is a member of — `IamGrant.through` names it. */
|
|
147
|
+
Group = 'group',
|
|
148
|
+
/** A group's own listing only: the names its bundles select; its own grants are `Direct`. */
|
|
149
|
+
Bundle = 'bundle'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The managed group `syncStaff` keeps in a client's owning organization: the owner's own staff,
|
|
154
|
+
* admitted to the client with the bundles the sync hands it. Its key, membership, bundles and
|
|
155
|
+
* existence are the synchronization's alone; its grants and restrictions are the operator's and
|
|
156
|
+
* survive every sync.
|
|
157
|
+
*/
|
|
158
|
+
export const IAM_MEMBERS_GROUP = 'members'
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The `profileId` of a credential that proves an e-mail address and identifies nobody yet: what the
|
|
162
|
+
* end-user sign-in hands the provider's finalizer, which then resolves the account itself. It carries
|
|
163
|
+
* no `entitySlug` — a proof bound to an organization is something else.
|
|
164
|
+
*/
|
|
165
|
+
export const IAM_EMAIL_PROOF = 'proof:email'
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The guard of the runtime IAM API (`makeIamRuntimeProtocols`). The serving process registers it: it
|
|
169
|
+
* admits a bearer access token the provider itself issued, and takes the client and the account from
|
|
170
|
+
* that token, never from the request.
|
|
171
|
+
*/
|
|
172
|
+
export const IAM_RUNTIME_GUARD = 'iam-runtime-guard'
|
|
173
|
+
|
|
174
|
+
/** Where the runtime IAM API mounts under its service unless the declaration names another path. */
|
|
175
|
+
export const IAM_RUNTIME_PATH = '/runtime'
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The runtime API's leaf paths under its base. One table so the declarations and a fetch-only client
|
|
179
|
+
* (a target's server, which binds no entrypoint of the provider's tree) cannot disagree on a segment.
|
|
180
|
+
*/
|
|
181
|
+
export const IAM_RUNTIME_ROUTES = {
|
|
182
|
+
organizations: '/organizations',
|
|
183
|
+
organization: '/organizations/:entitySlug',
|
|
184
|
+
members: '/organizations/:entitySlug/members',
|
|
185
|
+
member: '/organizations/:entitySlug/members/:profileId',
|
|
186
|
+
memberRemove: '/organizations/:entitySlug/members/:profileId/remove',
|
|
187
|
+
permissions: '/organizations/:entitySlug/permissions',
|
|
188
|
+
grants: '/organizations/:entitySlug/grants',
|
|
189
|
+
grantsRevoke: '/organizations/:entitySlug/grants/revoke',
|
|
190
|
+
} as const
|
|
191
|
+
|
|
192
|
+
export const IamRuntimeOrganizationCreateSchema: JSONSchemaType<IamRuntimeOrganizationCreate> = {
|
|
193
|
+
type: 'object',
|
|
194
|
+
properties: { title: { ...TitleSchema, nullable: true } },
|
|
195
|
+
required: [],
|
|
196
|
+
additionalProperties: false,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const IamRuntimeOrganizationUpdateSchema: JSONSchemaType<IamRuntimeOrganizationUpdate> = {
|
|
200
|
+
type: 'object',
|
|
201
|
+
properties: { title: { ...TitleSchema } },
|
|
202
|
+
required: ['title'],
|
|
203
|
+
additionalProperties: false,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const IamRuntimeOrganizationParamsSchema: JSONSchemaType<IamRuntimeOrganizationParams> = {
|
|
207
|
+
type: 'object',
|
|
208
|
+
properties: { entitySlug: { ...EntitySlugValueSchema } },
|
|
209
|
+
required: ['entitySlug'],
|
|
210
|
+
additionalProperties: false,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const IamRuntimeMemberInviteSchema: JSONSchemaType<IamRuntimeMemberInvite> = {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
email: { ...EmailSchema },
|
|
217
|
+
name: { ...TitleSchema, nullable: true },
|
|
218
|
+
owner: { type: 'boolean', nullable: true },
|
|
219
|
+
},
|
|
220
|
+
required: ['email'],
|
|
221
|
+
additionalProperties: false,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const IamRuntimeMemberParamsSchema: JSONSchemaType<IamRuntimeMemberParams> = {
|
|
225
|
+
type: 'object',
|
|
226
|
+
properties: {
|
|
227
|
+
entitySlug: { ...EntitySlugValueSchema },
|
|
228
|
+
profileId: { ...ProfileIdSchema },
|
|
229
|
+
},
|
|
230
|
+
required: ['entitySlug', 'profileId'],
|
|
231
|
+
additionalProperties: false,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const IamRuntimeGrantQuerySchema: JSONSchemaType<IamRuntimeGrantQuery> = {
|
|
235
|
+
type: 'object',
|
|
236
|
+
properties: { profileId: { ...ProfileIdSchema, nullable: true } },
|
|
237
|
+
required: [],
|
|
238
|
+
additionalProperties: false,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export const IamRuntimeAckSchema: JSONSchemaType<IamRuntimeAck> = {
|
|
242
|
+
type: 'object',
|
|
243
|
+
properties: { ok: { type: 'boolean' } },
|
|
244
|
+
required: ['ok'],
|
|
245
|
+
additionalProperties: false,
|
|
246
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -36,6 +36,24 @@ export class IamGrantError extends IamError {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* A refusal about a permission DEFINITION, as opposed to a grant.
|
|
41
|
+
*
|
|
42
|
+
* Separate from `IamGrantError`, which means a grant's subject is missing or its entity does not
|
|
43
|
+
* match — reusing it for a definition-level refusal is a category error the next reader has to unpick.
|
|
44
|
+
*
|
|
45
|
+
* Codes: `permission:held:<name>` (still granted to someone, under a policy that refuses),
|
|
46
|
+
* `permission:managed:<name>` (platform-owned, deleted only with an explicit second key).
|
|
47
|
+
*/
|
|
48
|
+
export class IamPermissionError extends IamError {
|
|
49
|
+
public static override typeName = `IamPermission${ResilientError.typeName}`
|
|
50
|
+
|
|
51
|
+
constructor(message = 'error') {
|
|
52
|
+
super(`permission:${message}`)
|
|
53
|
+
this.type = IamPermissionError.typeName
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
export class IamUnsupported extends IamError {
|
|
40
58
|
public static override typeName = `IamUnsupported${ResilientError.typeName}`
|
|
41
59
|
|
|
@@ -54,9 +72,35 @@ export class IamUserError extends IamError {
|
|
|
54
72
|
}
|
|
55
73
|
}
|
|
56
74
|
|
|
75
|
+
/**
|
|
76
|
+
* A refusal about an organization or a membership in it — an unknown organization, the last owner,
|
|
77
|
+
* the membership cap.
|
|
78
|
+
*/
|
|
79
|
+
export class IamOrganizationError extends IamError {
|
|
80
|
+
public static override typeName = `IamOrganization${ResilientError.typeName}`
|
|
81
|
+
|
|
82
|
+
constructor(message = 'error') {
|
|
83
|
+
super(`organization:${message}`)
|
|
84
|
+
this.type = IamOrganizationError.typeName
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A refusal about a group: an unknown key, a managed group written by an operator, another app's group. */
|
|
89
|
+
export class IamGroupError extends IamError {
|
|
90
|
+
public static override typeName = `IamGroup${ResilientError.typeName}`
|
|
91
|
+
|
|
92
|
+
constructor(message = 'error') {
|
|
93
|
+
super(`group:${message}`)
|
|
94
|
+
this.type = IamGroupError.typeName
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
57
98
|
ResilientError.registerErrorClass(IamError)
|
|
58
99
|
ResilientError.registerErrorClass(IamClientError)
|
|
59
100
|
ResilientError.registerErrorClass(IamResourceError)
|
|
60
101
|
ResilientError.registerErrorClass(IamGrantError)
|
|
102
|
+
ResilientError.registerErrorClass(IamPermissionError)
|
|
61
103
|
ResilientError.registerErrorClass(IamUnsupported)
|
|
62
104
|
ResilientError.registerErrorClass(IamUserError)
|
|
105
|
+
ResilientError.registerErrorClass(IamOrganizationError)
|
|
106
|
+
ResilientError.registerErrorClass(IamGroupError)
|
package/src/facet.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { IamUnsupported } from './errors.js'
|
|
2
|
+
import { INERT_KEYS } from './consts.local.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A facet a backend cannot provide: every method throws `IamUnsupported(what)`.
|
|
6
|
+
*
|
|
7
|
+
* It throws SYNCHRONOUSLY, because a facet may carry synchronous methods (`subjects.identify`) and a
|
|
8
|
+
* rejected promise would hand those a value of the wrong type; an awaited call rejects the same way
|
|
9
|
+
* either way. Caller code turns the refusal into a fallback, exactly as for any other unsupported
|
|
10
|
+
* operation.
|
|
11
|
+
*/
|
|
12
|
+
export const unsupportedFacet = <T extends object>(what: string): T =>
|
|
13
|
+
new Proxy(Object.freeze({}) as T, {
|
|
14
|
+
get: (_, key) => typeof key === 'symbol' || INERT_KEYS.has(key)
|
|
15
|
+
? undefined
|
|
16
|
+
: () => { throw new IamUnsupported(what) },
|
|
17
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RESOURCE_PARAM_SEPARATOR, RESOURCE_SOURCE_SEPARATOR, RESOURCE_PATH_SEPARATOR,
|
|
3
|
+
GateParamSource, GateParamErrorCode, DEFAULT_GATE_PARAM_SOURCES
|
|
4
|
+
} from '../consts.js'
|
|
5
|
+
import type { GateParamHelper, GateResourceSelector, GateParamProblem, ParsedGateParam } from './types.js'
|
|
6
|
+
import { SOURCES } from './consts.local.js'
|
|
7
|
+
|
|
8
|
+
export const createGateParamHelper = (): GateParamHelper => {
|
|
9
|
+
const parseGateSelector = (selector: string): GateResourceSelector | GateParamProblem => {
|
|
10
|
+
if (selector === '') {
|
|
11
|
+
return { code: GateParamErrorCode.EmptySelector, detail: 'the selector after "@" is empty' }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const idx = selector.indexOf(RESOURCE_SOURCE_SEPARATOR)
|
|
15
|
+
if (idx < 0) {
|
|
16
|
+
return {
|
|
17
|
+
selector,
|
|
18
|
+
path: [selector],
|
|
19
|
+
sources: DEFAULT_GATE_PARAM_SOURCES
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const source = selector.slice(0, idx)
|
|
24
|
+
const rest = selector.slice(idx + 1)
|
|
25
|
+
|
|
26
|
+
if (!SOURCES.includes(source)) {
|
|
27
|
+
return {
|
|
28
|
+
code: GateParamErrorCode.UnknownSource,
|
|
29
|
+
detail: `"${source}" is not one of ${SOURCES.join(', ')}`
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (rest === '') {
|
|
34
|
+
return {
|
|
35
|
+
code: GateParamErrorCode.EmptySelector,
|
|
36
|
+
detail: `"${source}:" names no path`
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const path = rest.split(RESOURCE_PATH_SEPARATOR)
|
|
41
|
+
if (path.some(segment => segment === '')) {
|
|
42
|
+
return {
|
|
43
|
+
code: GateParamErrorCode.EmptySegment,
|
|
44
|
+
detail: `"${rest}" has an empty path segment`
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
selector,
|
|
50
|
+
source: source as GateParamSource,
|
|
51
|
+
path,
|
|
52
|
+
sources: [source as GateParamSource]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const parseGateParam = (param: string): ParsedGateParam => {
|
|
57
|
+
const idx = param.indexOf(RESOURCE_PARAM_SEPARATOR)
|
|
58
|
+
if (idx < 0) {
|
|
59
|
+
return { permission: param }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const permission = param.slice(0, idx)
|
|
63
|
+
const selector = param.slice(idx + 1)
|
|
64
|
+
const parsed = parseGateSelector(selector)
|
|
65
|
+
|
|
66
|
+
if ('code' in parsed) {
|
|
67
|
+
return { permission, resourceParam: selector, error: parsed }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
permission,
|
|
72
|
+
// Kept for readers written against the flat form. Only meaningful when the form IS flat.
|
|
73
|
+
...(parsed.source == null ? { resourceParam: selector } : {}),
|
|
74
|
+
resource: parsed
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const formatGateParam = (
|
|
79
|
+
permission: string,
|
|
80
|
+
selector?: string | { source?: GateParamSource, path: string[] }
|
|
81
|
+
): string => {
|
|
82
|
+
if (selector == null) {
|
|
83
|
+
return permission
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof selector === 'string') {
|
|
87
|
+
return selector === '' ? permission : `${permission}${RESOURCE_PARAM_SEPARATOR}${selector}`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (selector.path.length < 1) {
|
|
91
|
+
return permission
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const path = selector.path.join(RESOURCE_PATH_SEPARATOR)
|
|
95
|
+
const tail = selector.source == null
|
|
96
|
+
? path
|
|
97
|
+
: `${selector.source}${RESOURCE_SOURCE_SEPARATOR}${path}`
|
|
98
|
+
|
|
99
|
+
return `${permission}${RESOURCE_PARAM_SEPARATOR}${tail}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { parseGateSelector, parseGateParam, formatGateParam }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const gateParamHelper = createGateParamHelper()
|
|
106
|
+
|
|
107
|
+
/** @deprecated compat:factory-refactor — use `gateParamHelper.parseGateParam(…)` */
|
|
108
|
+
export const parseGateParam = (param: string): ParsedGateParam => gateParamHelper.parseGateParam(param)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { GateParamSource, GateResolutionFailure } from '../consts.js'
|
|
2
|
+
import type { GateRequestLike, GateResourceSelector, GateResourceResolution } from './types.js'
|
|
3
|
+
|
|
4
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
5
|
+
value != null && typeof value === 'object'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A resource id is a scalar. Anything else is refused rather than coerced.
|
|
9
|
+
*
|
|
10
|
+
* Stringifying an object would produce `"[object Object]"`, which then either matches nothing or —
|
|
11
|
+
* far worse — matches another record that stringified the same way.
|
|
12
|
+
*/
|
|
13
|
+
const scalar = (value: unknown): string | null => {
|
|
14
|
+
switch (typeof value) {
|
|
15
|
+
case 'string':
|
|
16
|
+
return value
|
|
17
|
+
case 'number':
|
|
18
|
+
case 'boolean':
|
|
19
|
+
case 'bigint':
|
|
20
|
+
return `${value}`
|
|
21
|
+
default:
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sourceOf = (req: GateRequestLike, source: GateParamSource): unknown => {
|
|
27
|
+
switch (source) {
|
|
28
|
+
case GateParamSource.Params: return req.params
|
|
29
|
+
case GateParamSource.Query: return req.query
|
|
30
|
+
case GateParamSource.Body: return req.body
|
|
31
|
+
case GateParamSource.Headers: return req.headers
|
|
32
|
+
case GateParamSource.Auth: return req.auth
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Walk one source for the selector's path.
|
|
38
|
+
*
|
|
39
|
+
* Headers get two adjustments they need and nothing else does: the first segment is lowercased,
|
|
40
|
+
* because HTTP header names are case-insensitive and every server normalises them differently; and a
|
|
41
|
+
* single-element array unwraps, because a repeated header arrives as an array and silently picking
|
|
42
|
+
* one of several values would be an authorization decision made by accident.
|
|
43
|
+
*/
|
|
44
|
+
const walk = (
|
|
45
|
+
container: unknown, path: string[], source: GateParamSource
|
|
46
|
+
): { value?: unknown, reason?: GateResolutionFailure } => {
|
|
47
|
+
if (!isRecord(container)) {
|
|
48
|
+
return { reason: GateResolutionFailure.SourceMissing }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let cursor: unknown = container
|
|
52
|
+
for (const [index, rawSegment] of path.entries()) {
|
|
53
|
+
if (!isRecord(cursor)) {
|
|
54
|
+
return { reason: GateResolutionFailure.NotProvided }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const segment = source === GateParamSource.Headers && index === 0
|
|
58
|
+
? rawSegment.toLowerCase()
|
|
59
|
+
: rawSegment
|
|
60
|
+
|
|
61
|
+
cursor = cursor[segment]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (source === GateParamSource.Headers && Array.isArray(cursor)) {
|
|
65
|
+
if (cursor.length !== 1) {
|
|
66
|
+
return { reason: GateResolutionFailure.NotScalar }
|
|
67
|
+
}
|
|
68
|
+
cursor = cursor[0]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (cursor == null) {
|
|
72
|
+
return { reason: GateResolutionFailure.NotProvided }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { value: cursor }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the resource id a selector points at.
|
|
80
|
+
*
|
|
81
|
+
* Sources are tried in the selector's own order — for the bare form, route params before the query —
|
|
82
|
+
* and the first that yields a value wins. When none does, the reason travels back so the caller can
|
|
83
|
+
* tell a MISCONFIGURED gate (the request never carried that key) from a legitimately DENIED one.
|
|
84
|
+
*/
|
|
85
|
+
export const resolveGateResource = (
|
|
86
|
+
req: GateRequestLike, selector: GateResourceSelector
|
|
87
|
+
): GateResourceResolution => {
|
|
88
|
+
let reason: GateResolutionFailure = GateResolutionFailure.SourceMissing
|
|
89
|
+
|
|
90
|
+
for (const source of selector.sources) {
|
|
91
|
+
const result = walk(sourceOf(req, source), selector.path, source)
|
|
92
|
+
|
|
93
|
+
if (result.reason != null) {
|
|
94
|
+
// A hard refusal is more informative than "keep looking" — report it rather than letting a
|
|
95
|
+
// later, emptier source overwrite it with a vaguer reason.
|
|
96
|
+
if (result.reason === GateResolutionFailure.NotScalar) {
|
|
97
|
+
return { reason: result.reason, from: source }
|
|
98
|
+
}
|
|
99
|
+
if (reason === GateResolutionFailure.SourceMissing) {
|
|
100
|
+
reason = result.reason
|
|
101
|
+
}
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const value = scalar(result.value)
|
|
106
|
+
if (value == null) {
|
|
107
|
+
return { reason: GateResolutionFailure.NotScalar, from: source }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { id: value, from: source }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { reason }
|
|
114
|
+
}
|