@owlmeans/iam 0.1.18-rc.2 → 0.1.18-rc.21
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 +2 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/iam/SKILL.md +193 -56
- package/build/consts.d.ts +91 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +97 -0
- package/build/consts.js.map +1 -1
- package/build/errors.d.ts +13 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +17 -0
- package/build/errors.js.map +1 -1
- 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 +31 -0
- package/build/gate/param.d.ts.map +1 -0
- package/build/gate/param.js +99 -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/validate.d.ts +15 -0
- package/build/gate/validate.d.ts.map +1 -0
- package/build/gate/validate.js +107 -0
- package/build/gate/validate.js.map +1 -0
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/permission.d.ts +21 -0
- package/build/permission.d.ts.map +1 -0
- package/build/permission.js +39 -0
- package/build/permission.js.map +1 -0
- package/build/types.d.ts +251 -6
- package/build/types.d.ts.map +1 -1
- package/package.json +7 -6
- package/src/consts.ts +105 -0
- package/src/errors.ts +19 -0
- package/src/gate/index.ts +3 -0
- package/src/gate/param.ts +122 -0
- package/src/gate/resolve.ts +114 -0
- package/src/gate/validate.ts +132 -0
- package/src/index.ts +2 -0
- package/src/permission.ts +48 -0
- package/src/types.ts +283 -6
- package/tests/gate-param.spec.ts +234 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { normalizePath, SEP, PARAM } from '@owlmeans/route'
|
|
2
|
+
import { GateParamSource, GateParamErrorCode } from '../consts.js'
|
|
3
|
+
import type { GateParamAudit, GateParamIssue, GateResourceSelector } from '../types.js'
|
|
4
|
+
import { parseGateParam } from './param.js'
|
|
5
|
+
|
|
6
|
+
/** The `:name` segments of a route template. */
|
|
7
|
+
export const routeParamsOf = (path: string): string[] =>
|
|
8
|
+
normalizePath(path).split(SEP)
|
|
9
|
+
.filter(segment => segment.startsWith(PARAM))
|
|
10
|
+
.map(segment => segment.slice(PARAM.length))
|
|
11
|
+
|
|
12
|
+
interface SchemaLike {
|
|
13
|
+
properties?: Record<string, unknown>
|
|
14
|
+
additionalProperties?: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether a key can survive validation to reach the gate.
|
|
19
|
+
*
|
|
20
|
+
* The server compiles entrypoint filters with AJV's `removeAdditional`, which strips an undeclared
|
|
21
|
+
* key BEFORE the handler — and therefore before the gate — runs. So a selector naming a key the
|
|
22
|
+
* filter does not declare denies every request, with a clean build and nothing logged.
|
|
23
|
+
*
|
|
24
|
+
* The `additionalProperties === false` qualifier is what makes this check usable rather than noise:
|
|
25
|
+
* AJV only strips when a schema says so. An entrypoint that declares no filter for a source strips
|
|
26
|
+
* nothing, and reporting those would fire on nearly every endpoint and be ignored within a week.
|
|
27
|
+
*/
|
|
28
|
+
const unreachable = (schema: object | undefined, key: string): boolean => {
|
|
29
|
+
if (schema == null) {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { properties, additionalProperties } = schema as SchemaLike
|
|
34
|
+
if (additionalProperties !== false) {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return properties == null || !(key in properties)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const filterFor = (audit: GateParamAudit, source: GateParamSource): object | undefined => {
|
|
42
|
+
switch (source) {
|
|
43
|
+
case GateParamSource.Query: return audit.filter?.query
|
|
44
|
+
case GateParamSource.Body: return audit.filter?.body
|
|
45
|
+
case GateParamSource.Headers: return audit.filter?.headers
|
|
46
|
+
case GateParamSource.Params: return audit.filter?.params
|
|
47
|
+
case GateParamSource.Auth: return undefined
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const auditSelector = (
|
|
52
|
+
audit: GateParamAudit, selector: GateResourceSelector
|
|
53
|
+
): Omit<GateParamIssue, 'param'> | null => {
|
|
54
|
+
const key = selector.path[0]
|
|
55
|
+
if (key == null) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The bare form searches route params first and the query second, so it is only provably wrong
|
|
60
|
+
// when BOTH are impossible. A declared route param settles it; otherwise fall through to the
|
|
61
|
+
// query check below, which only fires on an explicitly closed schema.
|
|
62
|
+
const routeParams = audit.routeParams
|
|
63
|
+
?? (audit.routePath != null ? routeParamsOf(audit.routePath) : undefined)
|
|
64
|
+
|
|
65
|
+
if (selector.source == null) {
|
|
66
|
+
if (routeParams?.includes(key) === true) {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
if (unreachable(audit.filter?.query, key) && unreachable(audit.filter?.params, key)) {
|
|
70
|
+
return {
|
|
71
|
+
code: GateParamErrorCode.UnknownRouteParam,
|
|
72
|
+
detail: `"${key}" is not a route param of "${audit.routePath ?? '?'}"`
|
|
73
|
+
+ ' and is declared by neither the params nor the query filter'
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (selector.source === GateParamSource.Params) {
|
|
80
|
+
if (routeParams != null && !routeParams.includes(key) && unreachable(audit.filter?.params, key)) {
|
|
81
|
+
return {
|
|
82
|
+
code: GateParamErrorCode.UnknownRouteParam,
|
|
83
|
+
detail: `"${key}" is not a route param of "${audit.routePath ?? '?'}"`
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (unreachable(filterFor(audit, selector.source), key)) {
|
|
90
|
+
return {
|
|
91
|
+
code: GateParamErrorCode.UnreachableKey,
|
|
92
|
+
detail: `the ${selector.source} filter declares no "${key}" and forbids additional properties,`
|
|
93
|
+
+ ' so it is removed by validation before the gate can read it'
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check gate params against what their entrypoint declares.
|
|
102
|
+
*
|
|
103
|
+
* Reports only what is provable from the declaration itself. Anything that would need a guess is
|
|
104
|
+
* left alone: a check that fires on correct code stops being read.
|
|
105
|
+
*
|
|
106
|
+
* Meant to run where a mistake can still be fixed — while code is being generated, or once per alias
|
|
107
|
+
* as a diagnostic. It must never decide a request: with `some()` semantics across params, one
|
|
108
|
+
* malformed sibling must not be able to refuse a legitimately passing one.
|
|
109
|
+
*/
|
|
110
|
+
export const validateGateParams = (params: string[], audit?: GateParamAudit): GateParamIssue[] => {
|
|
111
|
+
const issues: GateParamIssue[] = []
|
|
112
|
+
|
|
113
|
+
for (const param of params) {
|
|
114
|
+
const parsed = parseGateParam(param)
|
|
115
|
+
|
|
116
|
+
if (parsed.error != null) {
|
|
117
|
+
issues.push({ param, code: parsed.error.code, detail: parsed.error.detail })
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (parsed.resource == null || audit == null) {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const issue = auditSelector(audit, parsed.resource)
|
|
126
|
+
if (issue != null) {
|
|
127
|
+
issues.push({ param, ...issue })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return issues
|
|
132
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { PERMISSION_ACTION_SEPARATOR, RESOURCE_PARAM_SEPARATOR, GateParamErrorCode } from './consts.js'
|
|
2
|
+
import type { ParsedPermissionName } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read a permission NAME.
|
|
6
|
+
*
|
|
7
|
+
* The name is `<resource>--<action>`, with TWO hyphens. One hyphen is not a separator: `enquiry-view`
|
|
8
|
+
* is a resource called `enquiry-view` carrying no action, and it must keep parsing that way — a name
|
|
9
|
+
* already granted somewhere cannot be re-interpreted without orphaning the grant.
|
|
10
|
+
*
|
|
11
|
+
* A name never carries a gate selector. `@` is the gate's syntax, and a name containing one is
|
|
12
|
+
* exactly the corruption this function exists to make visible, so it is reported through `problem`
|
|
13
|
+
* rather than quietly split.
|
|
14
|
+
*/
|
|
15
|
+
export const parsePermissionName = (name: string): ParsedPermissionName => {
|
|
16
|
+
const problem = name.includes(RESOURCE_PARAM_SEPARATOR)
|
|
17
|
+
? {
|
|
18
|
+
code: GateParamErrorCode.UnreachableKey,
|
|
19
|
+
detail: `"${name}" carries a gate selector; "${RESOURCE_PARAM_SEPARATOR}" is never part of a`
|
|
20
|
+
+ ' permission name, and nothing ever looks up a name that contains one'
|
|
21
|
+
}
|
|
22
|
+
: undefined
|
|
23
|
+
|
|
24
|
+
const idx = name.indexOf(PERMISSION_ACTION_SEPARATOR)
|
|
25
|
+
if (idx < 0) {
|
|
26
|
+
return { name, resource: name, ...(problem != null ? { problem } : {}) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const action = name.slice(idx + PERMISSION_ACTION_SEPARATOR.length)
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
resource: name.slice(0, idx),
|
|
34
|
+
...(action !== '' ? { action } : {}),
|
|
35
|
+
...(problem != null ? { problem } : {})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Compose a permission name. The inverse of `parsePermissionName`. */
|
|
40
|
+
export const composePermissionName = (
|
|
41
|
+
parts: { resource: string, action?: string }
|
|
42
|
+
): string => parts.action == null || parts.action === ''
|
|
43
|
+
? parts.resource
|
|
44
|
+
: `${parts.resource}${PERMISSION_ACTION_SEPARATOR}${parts.action}`
|
|
45
|
+
|
|
46
|
+
/** True when the value is a usable permission name — well-formed and carrying no gate selector. */
|
|
47
|
+
export const isPermissionName = (value: string): boolean =>
|
|
48
|
+
value !== '' && !value.includes(RESOURCE_PARAM_SEPARATOR)
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,87 @@
|
|
|
1
1
|
import type { InitializedService } from '@owlmeans/context'
|
|
2
2
|
import type { OidcProviderConfig } from '@owlmeans/oidc'
|
|
3
|
+
import type {
|
|
4
|
+
GateParamSource, GateParamErrorCode, GateResolutionFailure, IamGrantMode, IamRemovalPolicy
|
|
5
|
+
} from './consts.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The parts of a request a gate selector may read.
|
|
9
|
+
*
|
|
10
|
+
* Structural on purpose: `AbstractRequest` is assignable to it, so `@owlmeans/server-iam` passes its
|
|
11
|
+
* request straight through, while tooling outside the server stack can resolve a selector against a
|
|
12
|
+
* plain object without taking a dependency on `@owlmeans/entrypoint`.
|
|
13
|
+
*/
|
|
14
|
+
export interface GateRequestLike {
|
|
15
|
+
params?: unknown
|
|
16
|
+
query?: unknown
|
|
17
|
+
body?: unknown
|
|
18
|
+
headers?: unknown
|
|
19
|
+
auth?: unknown
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Where one gate param reads its resource id from. */
|
|
23
|
+
export interface GateResourceSelector {
|
|
24
|
+
/** The selector text exactly as written, for diagnostics. */
|
|
25
|
+
readonly selector: string
|
|
26
|
+
/** Undefined for the bare form, which searches `sources` in order. */
|
|
27
|
+
readonly source?: GateParamSource
|
|
28
|
+
/** Length 1 and UNSPLIT for the bare form — a bare key may legally contain dots. */
|
|
29
|
+
readonly path: string[]
|
|
30
|
+
/** The explicit source, or `DEFAULT_GATE_PARAM_SOURCES`. */
|
|
31
|
+
readonly sources: GateParamSource[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface GateParamProblem {
|
|
35
|
+
code: GateParamErrorCode
|
|
36
|
+
detail: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ParsedGateParam {
|
|
40
|
+
permission: string
|
|
41
|
+
/**
|
|
42
|
+
* @deprecated The bare-form flat key, kept so existing readers keep compiling. Read `resource`,
|
|
43
|
+
* which carries the source and the path for both forms.
|
|
44
|
+
*/
|
|
45
|
+
resourceParam?: string
|
|
46
|
+
resource?: GateResourceSelector
|
|
47
|
+
error?: GateParamProblem
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface GateResourceResolution {
|
|
51
|
+
id?: string
|
|
52
|
+
from?: GateParamSource
|
|
53
|
+
reason?: GateResolutionFailure
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What an entrypoint declares, so a selector can be checked against it before it is ever deployed. */
|
|
57
|
+
export interface GateParamAudit {
|
|
58
|
+
routePath?: string
|
|
59
|
+
routeParams?: string[]
|
|
60
|
+
filter?: {
|
|
61
|
+
query?: object
|
|
62
|
+
params?: object
|
|
63
|
+
body?: object
|
|
64
|
+
headers?: object
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface GateParamIssue {
|
|
69
|
+
param: string
|
|
70
|
+
code: GateParamErrorCode
|
|
71
|
+
detail: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A permission NAME taken apart. Never carries a selector — `@` is the gate's syntax alone. */
|
|
75
|
+
export interface ParsedPermissionName {
|
|
76
|
+
/** The whole name, exactly as it must be stored and granted. */
|
|
77
|
+
name: string
|
|
78
|
+
/** What IAM stores as `resource`. The whole name when there is no action separator. */
|
|
79
|
+
resource: string
|
|
80
|
+
/** What IAM stores as `action`. Absent for a bare, unsplittable legacy name. */
|
|
81
|
+
action?: string
|
|
82
|
+
/** Set when the argument carried an `@` — which a NAME never legally does. */
|
|
83
|
+
problem?: GateParamProblem
|
|
84
|
+
}
|
|
3
85
|
|
|
4
86
|
export interface IamClient {
|
|
5
87
|
id?: string
|
|
@@ -33,10 +115,32 @@ export interface IamClientOptions {
|
|
|
33
115
|
export interface IamPermissionArgs {
|
|
34
116
|
/** Action name. When absent the permission is unscoped (project-wide). */
|
|
35
117
|
permission?: string
|
|
36
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* Declares that grants of this permission may be bound to specific resource ids.
|
|
120
|
+
*
|
|
121
|
+
* It does NOT mean grants must be: the same name is grantable in either form. A grant carrying no
|
|
122
|
+
* resource ids covers every resource, which is what `hasPermission` already implements.
|
|
123
|
+
*/
|
|
37
124
|
resourceScoped?: boolean
|
|
38
125
|
/** Optional human-readable title for the permission definition. */
|
|
39
126
|
title?: string
|
|
127
|
+
/**
|
|
128
|
+
* Grouping tag — one of `IAM_AREAS`, or any string a deployment defines.
|
|
129
|
+
*
|
|
130
|
+
* Purely presentational: it groups the permission in an administrator's screen and is never read
|
|
131
|
+
* when a request is authorized. It is a TAG rather than a name segment precisely so an existing
|
|
132
|
+
* permission can be grouped without renaming it, which would orphan every grant already made.
|
|
133
|
+
*/
|
|
134
|
+
area?: string
|
|
135
|
+
/**
|
|
136
|
+
* Marks a permission the PLATFORM owns rather than one the application declared for itself.
|
|
137
|
+
*
|
|
138
|
+
* The distinction is not cosmetic: an operator must not be able to revoke a platform marker from
|
|
139
|
+
* an undifferentiated list, and a repair that deletes "definitions no declaration names" must not
|
|
140
|
+
* reach one — a marker can be enforced by a runtime decoration rather than by a gate parameter,
|
|
141
|
+
* so its absence from the declarations is not evidence that it is unused.
|
|
142
|
+
*/
|
|
143
|
+
managed?: boolean
|
|
40
144
|
}
|
|
41
145
|
|
|
42
146
|
export interface IamResourceSpec {
|
|
@@ -46,17 +150,42 @@ export interface IamResourceSpec {
|
|
|
46
150
|
|
|
47
151
|
/** A permission declared for an entity's client (project). */
|
|
48
152
|
export interface IamPermissionDefinition {
|
|
49
|
-
/** Canonical name: "res--action" or bare "res" when unscoped. */
|
|
153
|
+
/** Canonical name: "res--action" or bare "res" when unscoped. NEVER carries an `@` selector. */
|
|
50
154
|
name: string
|
|
51
155
|
resource: string
|
|
52
156
|
action?: string
|
|
53
157
|
resourceScoped?: boolean
|
|
54
158
|
title?: string
|
|
159
|
+
/** Grouping tag; presentational only. See `IamPermissionArgs.area`. */
|
|
160
|
+
area?: string
|
|
161
|
+
/** Owned by the platform rather than declared by the application. See `IamPermissionArgs.managed`. */
|
|
162
|
+
managed?: boolean
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Narrows a definition listing. An `areas` entry of `null` matches definitions carrying no tag. */
|
|
166
|
+
export interface IamPermissionFilter {
|
|
167
|
+
areas?: (string | null)[]
|
|
168
|
+
managed?: boolean
|
|
169
|
+
resourceScoped?: boolean
|
|
55
170
|
}
|
|
56
171
|
|
|
57
172
|
export interface IamGrantArgs {
|
|
58
173
|
/** Resource ids for the resource-scoped grant form; omit for an unscoped (project-wide) grant. */
|
|
59
174
|
resources?: string[]
|
|
175
|
+
/**
|
|
176
|
+
* Which form to address. THE DEFAULTS ARE ASYMMETRIC ON PURPOSE, to preserve the semantics every
|
|
177
|
+
* existing caller already relies on:
|
|
178
|
+
*
|
|
179
|
+
* grantPermission — `resources != null` ? Resources : Blanket
|
|
180
|
+
* revokePermission — `resources != null` ? Resources : All
|
|
181
|
+
*
|
|
182
|
+
* Revoke defaults to `All` because a bare `revokePermission(...)` has always meant "remove it
|
|
183
|
+
* everywhere". Narrowing that to `Blanket` would leave the resource-scoped set standing for every
|
|
184
|
+
* existing caller — a live grant surviving a revoke, which is the failure direction nobody detects.
|
|
185
|
+
*
|
|
186
|
+
* Pass `mode` explicitly rather than depending on the default.
|
|
187
|
+
*/
|
|
188
|
+
mode?: IamGrantMode
|
|
60
189
|
}
|
|
61
190
|
|
|
62
191
|
/** A permission granted to an end-user subject of an entity's client. */
|
|
@@ -68,6 +197,48 @@ export interface IamGrant {
|
|
|
68
197
|
permission: string
|
|
69
198
|
/** Present only for resource-scoped grants. */
|
|
70
199
|
resources?: string[]
|
|
200
|
+
/** Which form this record is. Derived from `resources`, not new information. */
|
|
201
|
+
mode?: IamGrantMode
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Grants every definition a filter selects, plus any named outright. */
|
|
205
|
+
export interface IamGrantBundle {
|
|
206
|
+
filter?: IamPermissionFilter
|
|
207
|
+
/** Extra names included regardless of the filter — a platform marker, for instance. */
|
|
208
|
+
permissions?: string[]
|
|
209
|
+
/** Defaults to `Blanket`: a tier of access is a role, not a resource ACL. */
|
|
210
|
+
mode?: IamGrantMode
|
|
211
|
+
/** Only meaningful with `mode: Resources`. */
|
|
212
|
+
resources?: string[]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface IamPermissionDeleteArgs {
|
|
216
|
+
/** Defaults to `Cascade`. */
|
|
217
|
+
policy?: IamRemovalPolicy
|
|
218
|
+
/** Required to delete a `managed` definition. Defaults to false. */
|
|
219
|
+
managed?: boolean
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface IamPermissionRemoval {
|
|
223
|
+
permission: string
|
|
224
|
+
clientId: string
|
|
225
|
+
/** The definition existed and was removed. False is a normal, non-error outcome. */
|
|
226
|
+
found: boolean
|
|
227
|
+
/** Grants stripped as part of the removal. */
|
|
228
|
+
revoked: IamGrant[]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface IamNormalizeArgs {
|
|
232
|
+
/** Compute and report the plan without writing anything. */
|
|
233
|
+
dryRun?: boolean
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface IamNormalizationReport {
|
|
237
|
+
clientId: string
|
|
238
|
+
renamed: { from: string, to: string, grantsMoved: number }[]
|
|
239
|
+
merged: { from: string, into: string, grantsMoved: number }[]
|
|
240
|
+
/** Well-formed definitions deliberately left alone. */
|
|
241
|
+
untouched: string[]
|
|
71
242
|
}
|
|
72
243
|
|
|
73
244
|
/** An end-user of an entity (customer-wide; shared across that entity's projects). */
|
|
@@ -120,11 +291,50 @@ export interface IamService extends InitializedService {
|
|
|
120
291
|
getIssuerUrl: (entityId: string) => Promise<string>
|
|
121
292
|
|
|
122
293
|
// --- Provisioning (agent → story development) ---
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Find-or-create the entity's OIDC client.
|
|
297
|
+
*
|
|
298
|
+
* A client id is globally unique — a provider resolves it from the bare `client_id` a relying
|
|
299
|
+
* party sends, with no tenant context — so an implementation MUST refuse an existing record
|
|
300
|
+
* that belongs to a different entity (`IamClientError('client:entity-mismatch')`) rather than
|
|
301
|
+
* returning it. Silently adopting one hands the caller another tenant's secret and overwrites
|
|
302
|
+
* that tenant's redirect URIs.
|
|
303
|
+
*/
|
|
123
304
|
ensureClient: (entityId: string, clientId: string, options?: IamClientOptions) => Promise<IamClient>
|
|
124
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Reserve a client id for the entity without provisioning it, so a caller can find a free name
|
|
308
|
+
* before committing to it. Returns false when the id is already taken — by this entity or any
|
|
309
|
+
* other — and true when the reservation is now held.
|
|
310
|
+
*
|
|
311
|
+
* This exists because the id is minted from a name that may later be released (a project alias
|
|
312
|
+
* moves when its hostname is refused), so the naming index cannot answer whether an id is free.
|
|
313
|
+
* The registry is the only authority.
|
|
314
|
+
*/
|
|
315
|
+
claimClient: (entityId: string, clientId: string) => Promise<boolean>
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Release a client id and everything keyed by it. Called when a project or slot is deleted —
|
|
319
|
+
* without it a recreated project can inherit a stale registration.
|
|
320
|
+
*/
|
|
321
|
+
deleteClient: (entityId: string, clientId: string) => Promise<void>
|
|
322
|
+
|
|
125
323
|
/**
|
|
126
324
|
* Ensures a permission/resource exists in the entity's client.
|
|
127
325
|
* Returns the canonical resource name (e.g. "res--action" or "res").
|
|
326
|
+
*
|
|
327
|
+
* MERGES into an existing definition rather than replacing it: `resource` and `action` are
|
|
328
|
+
* re-derived and overwrite, while `title`, `resourceScoped`, `area` and `managed` are set when
|
|
329
|
+
* provided — including when provided as `false` — and KEPT when omitted. Replacing wholesale made
|
|
330
|
+
* every flag last-write-wins, so a caller that happened not to pass one erased it.
|
|
331
|
+
*
|
|
332
|
+
* It NEVER rewrites the name. Callers round-trip a definition's `resource` + `action` back through
|
|
333
|
+
* this method to copy a definition set between clients, so a rename here would silently orphan
|
|
334
|
+
* every grant made against the old name.
|
|
335
|
+
*
|
|
336
|
+
* The `resource` argument must not carry a gate selector: `@` is the gate's syntax and is never
|
|
337
|
+
* part of a stored name. Implementations reject one rather than storing a key no gate looks up.
|
|
128
338
|
*/
|
|
129
339
|
ensurePermission: (
|
|
130
340
|
entityId: string,
|
|
@@ -133,6 +343,49 @@ export interface IamService extends InitializedService {
|
|
|
133
343
|
args?: IamPermissionArgs
|
|
134
344
|
) => Promise<string>
|
|
135
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Removes permission definitions, and by default every grant that referenced them.
|
|
348
|
+
*
|
|
349
|
+
* Takes NAMES: the definition record is keyed by name, and a malformed definition's stored
|
|
350
|
+
* `resource`/`action` cannot be trusted to recompose that key. Accepts an array so a repair costs
|
|
351
|
+
* one sweep of the entity's subjects rather than one per name.
|
|
352
|
+
*
|
|
353
|
+
* Idempotent — a name with no definition yields `{ found: false }` and writes nothing. Every other
|
|
354
|
+
* lifecycle operation here converges rather than erroring, and two repairs may run concurrently on
|
|
355
|
+
* one project.
|
|
356
|
+
*
|
|
357
|
+
* Implementations MUST revoke the grants before dropping the definition. There is no transaction
|
|
358
|
+
* across the two stores, and a crash between them must leave a definition with no grants — visible
|
|
359
|
+
* and re-runnable — rather than grants with no definition, which are invisible to any screen that
|
|
360
|
+
* renders the definition list yet still satisfy a gate.
|
|
361
|
+
*
|
|
362
|
+
* A `managed` definition is refused unless `args.managed` is explicitly true.
|
|
363
|
+
*/
|
|
364
|
+
deletePermission: (
|
|
365
|
+
entityId: string,
|
|
366
|
+
clientId: string,
|
|
367
|
+
permission: string | string[],
|
|
368
|
+
args?: IamPermissionDeleteArgs
|
|
369
|
+
) => Promise<IamPermissionRemoval[]>
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Repairs definitions whose stored name carries a gate selector, moving grants with them.
|
|
373
|
+
*
|
|
374
|
+
* Strictly a rename and merge — never a delete, never an invention. Where the stripped name
|
|
375
|
+
* already exists the two are merged, because one entrypoint scoping a permission by `:id` and
|
|
376
|
+
* another by `:enquiryId` describe ONE permission. The target is marked resource-scoped: a leaked
|
|
377
|
+
* selector is positive evidence that it is, and that evidence is otherwise discarded.
|
|
378
|
+
*
|
|
379
|
+
* Grants move in the same write that renames the definition, which is why this exists as its own
|
|
380
|
+
* operation: reconstructing it as delete + re-declare + re-grant drops real access on any failure
|
|
381
|
+
* between the three steps.
|
|
382
|
+
*/
|
|
383
|
+
normalizePermissions: (
|
|
384
|
+
entityId: string,
|
|
385
|
+
clientId: string,
|
|
386
|
+
args?: IamNormalizeArgs
|
|
387
|
+
) => Promise<IamNormalizationReport>
|
|
388
|
+
|
|
136
389
|
/**
|
|
137
390
|
* Idempotent: creates a resource, a client role for it, and assigns that role to
|
|
138
391
|
* the entity-owner role. Used by payment provisioning for account/project/wl resources.
|
|
@@ -145,8 +398,12 @@ export interface IamService extends InitializedService {
|
|
|
145
398
|
|
|
146
399
|
// --- Authorization (permission definitions & grants) ---
|
|
147
400
|
|
|
148
|
-
/** Lists permission definitions registered for the entity's client. */
|
|
149
|
-
listPermissions: (
|
|
401
|
+
/** Lists permission definitions registered for the entity's client, optionally narrowed. */
|
|
402
|
+
listPermissions: (
|
|
403
|
+
entityId: string,
|
|
404
|
+
clientId: string,
|
|
405
|
+
filter?: IamPermissionFilter
|
|
406
|
+
) => Promise<IamPermissionDefinition[]>
|
|
150
407
|
|
|
151
408
|
/**
|
|
152
409
|
* Grants a permission to an end-user subject. With args.resources the grant is
|
|
@@ -172,14 +429,34 @@ export interface IamService extends InitializedService {
|
|
|
172
429
|
args?: IamGrantArgs
|
|
173
430
|
) => Promise<void>
|
|
174
431
|
|
|
432
|
+
/**
|
|
433
|
+
* Grants every permission a bundle selects to each subject, idempotently.
|
|
434
|
+
*
|
|
435
|
+
* Exists as its own operation rather than a loop over `grantPermission` because a backend that
|
|
436
|
+
* loads-mutates-saves a subject per call turns N permissions across M subjects into N·M writes and
|
|
437
|
+
* N·M lost-update windows on the same records. Collapsing to one read and one write per subject is
|
|
438
|
+
* also what makes re-running it genuinely idempotent.
|
|
439
|
+
*
|
|
440
|
+
* Returns what each subject now holds, not what changed.
|
|
441
|
+
*/
|
|
442
|
+
grantBundle: (
|
|
443
|
+
entityId: string,
|
|
444
|
+
clientId: string,
|
|
445
|
+
profileIds: string[],
|
|
446
|
+
bundle: IamGrantBundle
|
|
447
|
+
) => Promise<IamGrant[]>
|
|
448
|
+
|
|
175
449
|
/** Lists grants for the entity's client, optionally for a single subject. */
|
|
176
450
|
listGrants: (entityId: string, clientId: string, profileId?: string) => Promise<IamGrant[]>
|
|
177
451
|
|
|
178
452
|
// --- End-user management (customer-wide users, shared per entityId) ---
|
|
179
453
|
|
|
180
454
|
/**
|
|
181
|
-
* Lists the entity's end-users.
|
|
182
|
-
*
|
|
455
|
+
* Lists the entity's end-users. End-users are customer-wide — shared across every project of
|
|
456
|
+
* the entity — so `clientId` scopes the reported `grantCount` to one project's client and does
|
|
457
|
+
* **not** filter the set: a user who has authenticated against a project but holds no grant
|
|
458
|
+
* there is still that project's user, and the screen that manages grants is exactly where they
|
|
459
|
+
* must be visible.
|
|
183
460
|
*/
|
|
184
461
|
listUsers: (entityId: string, clientId?: string) => Promise<IamUser[]>
|
|
185
462
|
|