@fabrika/app 0.3.2 → 0.4.0
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 +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +309 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +808 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +749 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- package/src/testing.ts +0 -119
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
// The test kit's stand-in for IAM's membership transport, which every kit boots with unless a test passes its own. It
|
|
2
|
+
// answers a member directory (`ctx.members.list()` and `get()`) from the kit's callers, as IAM answers it from its
|
|
3
|
+
// grants, and the writes under IAM's rules for the caller's grants: the delegation grant, `members.manage`,
|
|
4
|
+
// `assignableBy`, the role's actions, a link mount's `issuedBy`, the last creator and IAM's input bounds; and its answers
|
|
5
|
+
// to an invitation: the open one again, a resend's new lifetime, an organization's mailbox refused for an external role.
|
|
6
|
+
// It records each write it allowed. A role it assigns or revokes is live, as in IAM: the directory, the stand-in's own
|
|
7
|
+
// checks and the kit's next token of that principal read it.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
API_KEY_PREFIX,
|
|
11
|
+
type AppMemberItem,
|
|
12
|
+
type AssignMemberInput,
|
|
13
|
+
type AssignMemberResult,
|
|
14
|
+
type ContractArtifact,
|
|
15
|
+
type CreateScopeInput,
|
|
16
|
+
type CreateScopeResult,
|
|
17
|
+
DELEGATION_GRANT_TTL_SECONDS,
|
|
18
|
+
directoryLookups,
|
|
19
|
+
type EndpointDelegation,
|
|
20
|
+
endpointDelegation,
|
|
21
|
+
type FindMemberInput,
|
|
22
|
+
type FindMemberResult,
|
|
23
|
+
type IamMembershipRpc,
|
|
24
|
+
invitationOptionsProblem,
|
|
25
|
+
invitedAddress,
|
|
26
|
+
type IssueLinkInput,
|
|
27
|
+
type IssueLinkResult,
|
|
28
|
+
type ListAppMembersInput,
|
|
29
|
+
type ListAppMembersResult,
|
|
30
|
+
type ListMembersInput,
|
|
31
|
+
type ListMembersResult,
|
|
32
|
+
matchAction,
|
|
33
|
+
MAX_APP_MEMBERS_PAGE,
|
|
34
|
+
MAX_LINK_CHAIN_LENGTH,
|
|
35
|
+
MAX_MEMBERSHIP_SCOPE_PART_LENGTH,
|
|
36
|
+
type MemberItem,
|
|
37
|
+
type MembershipCaller,
|
|
38
|
+
type MembershipFailure,
|
|
39
|
+
membershipScopeProblem,
|
|
40
|
+
type MemberTarget,
|
|
41
|
+
normalizeEmailIdentity,
|
|
42
|
+
type PermissionEntry,
|
|
43
|
+
permits,
|
|
44
|
+
type RequestDelegationInput,
|
|
45
|
+
type RequestDelegationResult,
|
|
46
|
+
type RevokeLinkInput,
|
|
47
|
+
type RevokeLinkResult,
|
|
48
|
+
type RevokeMemberInput,
|
|
49
|
+
type RevokeMemberResult,
|
|
50
|
+
type Scope,
|
|
51
|
+
} from '@fabrika/auth-core'
|
|
52
|
+
import type { LabelledTestCaller, TestCaller, TestScope } from './caller'
|
|
53
|
+
|
|
54
|
+
/** A role a test caller holds in one scope value, or globally (`null`). */
|
|
55
|
+
export interface TestMembership {
|
|
56
|
+
readonly role: string
|
|
57
|
+
readonly scope: TestScope | null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DirectoryCallers {
|
|
61
|
+
/**
|
|
62
|
+
* The caller a membership request's credential (its token or `px_` key) belongs to, as IAM verifies it for `app`:
|
|
63
|
+
* none for a credential of another application or an expired token.
|
|
64
|
+
*/
|
|
65
|
+
byCredential(app: string, credential: string): TestCaller | undefined
|
|
66
|
+
/** Every caller with its role grants. */
|
|
67
|
+
all(): readonly { readonly caller: TestCaller; readonly memberships: readonly TestMembership[] }[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A write `kitMembership` allowed, by the principal that asked; the credential and the delegation grant are left out. */
|
|
71
|
+
export type AskedWrite =
|
|
72
|
+
| { readonly operation: 'createScope'; readonly principalId: string; readonly scope: Scope; readonly role: string }
|
|
73
|
+
| {
|
|
74
|
+
readonly operation: 'assignMember'
|
|
75
|
+
readonly principalId: string
|
|
76
|
+
readonly scope: Scope
|
|
77
|
+
readonly role: string
|
|
78
|
+
readonly member: MemberTarget
|
|
79
|
+
/** The invitation it answered for an address; `null` for an existing principal. */
|
|
80
|
+
readonly invitationId: string | null
|
|
81
|
+
}
|
|
82
|
+
| { readonly operation: 'revokeMember'; readonly principalId: string; readonly scope: Scope; readonly member: RevokeMemberInput['member'] }
|
|
83
|
+
| { readonly operation: 'issueLink'; readonly principalId: string; readonly id: string; readonly mount: string; readonly chain: readonly Scope[] }
|
|
84
|
+
| { readonly operation: 'revokeLink'; readonly principalId: string; readonly id: string }
|
|
85
|
+
|
|
86
|
+
/** An invitation its addressee accepted: the role it gave, in its scope, to the invited address. */
|
|
87
|
+
export interface AcceptedInvitation {
|
|
88
|
+
readonly scope: Scope
|
|
89
|
+
readonly role: string
|
|
90
|
+
readonly email: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface KitMembership {
|
|
94
|
+
readonly rpc: IamMembershipRpc
|
|
95
|
+
/** Every write it allowed, in order. */
|
|
96
|
+
readonly asked: readonly AskedWrite[]
|
|
97
|
+
/** The permissions IAM signs for `caller` now: the test's grants, with the roles this stand-in assigned or revoked. */
|
|
98
|
+
permsOf(caller: TestCaller): readonly PermissionEntry[]
|
|
99
|
+
/**
|
|
100
|
+
* Closes the open invitation `invitationId` as accepted, as IAM's onboarding does, and answers what it gives. With
|
|
101
|
+
* `recipient`, that person holds the role from now on; without one the kit makes the new person who holds it. As IAM,
|
|
102
|
+
* it refuses an external recipient of a role not declared external (`not_eligible`) and an invitation whose inviter
|
|
103
|
+
* could no longer give the role (`not_allowed`), and then leaves the invitation open.
|
|
104
|
+
*/
|
|
105
|
+
accept(invitationId: string, recipient?: TestCaller): AcceptedInvitation
|
|
106
|
+
/** An open invitation of `role` in `scope` to `email`, as an earlier request opened it; answers its id. */
|
|
107
|
+
seedInvitation(scope: Scope, role: string, email: string): string
|
|
108
|
+
/** A share link of `mount` bound to `chain`, as `issuer` issued it in an earlier request; answers its id. */
|
|
109
|
+
seedLink(issuer: string, mount: string, chain: readonly Scope[]): string
|
|
110
|
+
/** The link a key it issued is bound to, as IAM resolves a link key; `undefined` for any other key and a revoked link. */
|
|
111
|
+
linkOfKey(key: string): IssuedLinkKey | undefined
|
|
112
|
+
/** The roles `caller` holds now: the test's grants, with the ones this stand-in assigned or revoked. */
|
|
113
|
+
membershipsOf(caller: TestCaller): readonly TestMembership[]
|
|
114
|
+
/** Every invitation it opened, the seeded ones included, in the order they were first opened. */
|
|
115
|
+
invitations(): readonly SentInvitation[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A share link the stand-in issued, as its key resolves. */
|
|
119
|
+
export interface IssuedLinkKey {
|
|
120
|
+
readonly id: string
|
|
121
|
+
readonly mount: string
|
|
122
|
+
readonly chain: readonly Scope[]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The invitation e-mail IAM would have sent: to whom, for which role in which scope, and where it stands now. */
|
|
126
|
+
export interface SentInvitation {
|
|
127
|
+
readonly id: string
|
|
128
|
+
readonly email: string
|
|
129
|
+
readonly role: string
|
|
130
|
+
readonly scope: Scope
|
|
131
|
+
readonly expiresAt: number
|
|
132
|
+
readonly status: 'open' | 'accepted' | 'cancelled' | 'expired'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The addresses the organization names, as IAM's configuration does (its human e-mails, its e-mail domains): IAM never
|
|
137
|
+
* invites one to an external role (`internal_mailbox`).
|
|
138
|
+
*/
|
|
139
|
+
export interface OrganizationMailboxes {
|
|
140
|
+
readonly emails: readonly string[]
|
|
141
|
+
readonly domains: readonly string[]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const NO_ORGANIZATION: OrganizationMailboxes = { emails: [], domains: [] }
|
|
145
|
+
|
|
146
|
+
/** An invitation the stand-in opened, open until it is cancelled, accepted or its lifetime ends. */
|
|
147
|
+
interface KitInvitation {
|
|
148
|
+
readonly id: string
|
|
149
|
+
readonly scope: Scope
|
|
150
|
+
readonly role: string
|
|
151
|
+
readonly email: string
|
|
152
|
+
readonly expiresAt: number
|
|
153
|
+
readonly closed: 'cancelled' | 'accepted' | null
|
|
154
|
+
/** The principal that opened it, whose authority IAM checks again at acceptance; `null` for a seeded one. */
|
|
155
|
+
readonly inviter: string | null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A share link the stand-in issued; a revoked one is still found, so a repeated revoke answers the same. */
|
|
159
|
+
interface KitLink {
|
|
160
|
+
readonly id: string
|
|
161
|
+
readonly issuer: string
|
|
162
|
+
readonly mount: string
|
|
163
|
+
readonly chain: readonly Scope[]
|
|
164
|
+
/** The key it answered the issuer; `null` for a seeded link, which no one holds. */
|
|
165
|
+
readonly key: string | null
|
|
166
|
+
readonly revoked: boolean
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A token the kit's IAM signed, for the caller it was minted for, until its `exp`. */
|
|
170
|
+
export interface MintedToken {
|
|
171
|
+
readonly caller: TestCaller
|
|
172
|
+
readonly expiresAt: number
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The kit's callers as IAM resolves a membership request's credential for `app`: a `px_` key the kit issued, or a token
|
|
177
|
+
* it signed that has not expired; nothing for another application.
|
|
178
|
+
*/
|
|
179
|
+
export function credentialCallers(
|
|
180
|
+
app: string,
|
|
181
|
+
keys: ReadonlyMap<string, TestCaller>,
|
|
182
|
+
tokens: ReadonlyMap<string, MintedToken>,
|
|
183
|
+
memberships: () => ReturnType<DirectoryCallers['all']>,
|
|
184
|
+
): DirectoryCallers {
|
|
185
|
+
return {
|
|
186
|
+
byCredential(audience, credential) {
|
|
187
|
+
if (audience !== app) return undefined
|
|
188
|
+
const minted = tokens.get(credential)
|
|
189
|
+
if (minted === undefined) return keys.get(credential)
|
|
190
|
+
return minted.expiresAt > now() ? minted.caller : undefined
|
|
191
|
+
},
|
|
192
|
+
all: memberships,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const NOT_ALLOWED: MembershipFailure = { ok: false, reason: 'not_allowed' }
|
|
197
|
+
const NOT_FOUND: MembershipFailure = { ok: false, reason: 'not_found' }
|
|
198
|
+
const INVALID_DELEGATION: MembershipFailure = { ok: false, reason: 'invalid_delegation' }
|
|
199
|
+
const DAY_SECONDS = 24 * 60 * 60
|
|
200
|
+
const INVITATION_TTL_SECONDS = 7 * DAY_SECONDS
|
|
201
|
+
|
|
202
|
+
function invalid(message: string): MembershipFailure {
|
|
203
|
+
return { ok: false, reason: 'invalid_request', message }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const randomId = (): string => crypto.randomUUID().replaceAll('-', '')
|
|
207
|
+
const now = (): number => Math.floor(Date.now() / 1000)
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The caller of a membership operation, as IAM resolves it: another application's connector calls none, a link holder
|
|
211
|
+
* is never a member caller, and `userOnly` refuses a script, which creates no scope and manages no member.
|
|
212
|
+
*/
|
|
213
|
+
function memberCaller(callers: DirectoryCallers, input: MembershipCaller, userOnly: boolean): LabelledTestCaller | MembershipFailure {
|
|
214
|
+
if (input.credential === null) return { ok: false, reason: 'missing_token' }
|
|
215
|
+
const caller = callers.byCredential(input.app, input.credential)
|
|
216
|
+
if (caller === undefined || caller.kind === 'app') return { ok: false, reason: 'invalid_token' }
|
|
217
|
+
if (caller.disabled) return { ok: false, reason: 'disabled' }
|
|
218
|
+
if (caller.kind === 'link' || (userOnly && caller.kind !== 'human')) return NOT_ALLOWED
|
|
219
|
+
return caller
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const isFailure = (outcome: LabelledTestCaller | MembershipFailure): outcome is MembershipFailure => 'ok' in outcome
|
|
223
|
+
|
|
224
|
+
function dimensionOf(artifact: ContractArtifact, type: string) {
|
|
225
|
+
return Object.hasOwn(artifact.dimensions, type) ? artifact.dimensions[type] : undefined
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The membership roles held in exactly `scope`, as IAM counts a member of its dimension. */
|
|
229
|
+
function rolesIn(artifact: ContractArtifact, memberships: readonly TestMembership[], scope: Scope): string[] {
|
|
230
|
+
return memberships
|
|
231
|
+
.filter((held) => held.scope !== null && sameScope(held.scope, scope) && artifact.roles[held.role]?.member?.scope === scope.type)
|
|
232
|
+
.map(({ role }) => role)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function membershipsOf(callers: DirectoryCallers, principalId: string): readonly TestMembership[] {
|
|
236
|
+
return callers.all().find(({ caller }) => caller.id === principalId)?.memberships ?? []
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
type PermsOf = (caller: TestCaller) => readonly PermissionEntry[]
|
|
240
|
+
|
|
241
|
+
function listMembers(artifact: ContractArtifact, callers: DirectoryCallers, permsOf: PermsOf, input: ListMembersInput): ListMembersResult {
|
|
242
|
+
const caller = memberCaller(callers, input, false)
|
|
243
|
+
if (isFailure(caller)) return caller
|
|
244
|
+
const perms = [...permsOf(caller)]
|
|
245
|
+
const problem = membershipScopeProblem(input.scope)
|
|
246
|
+
if (problem !== null) return invalid(problem)
|
|
247
|
+
const dimension = dimensionOf(artifact, input.scope.type)
|
|
248
|
+
if (dimension?.members === null || dimension?.members === undefined) {
|
|
249
|
+
return invalid(`scope '${input.scope.type}' declares no members`)
|
|
250
|
+
}
|
|
251
|
+
if (!permits(perms, dimension.members.list, input.scope)) return NOT_ALLOWED
|
|
252
|
+
const manager = permits(perms, dimension.members.manage, input.scope)
|
|
253
|
+
const members: MemberItem[] = []
|
|
254
|
+
for (const { caller: member, memberships } of callers.all()) {
|
|
255
|
+
const roles = rolesIn(artifact, memberships, input.scope)
|
|
256
|
+
if (roles.length === 0) continue
|
|
257
|
+
members.push({
|
|
258
|
+
principalId: member.id,
|
|
259
|
+
label: manager || dimension.directory === 'labels' ? member.label : null,
|
|
260
|
+
email: manager ? member.email : null,
|
|
261
|
+
lastSignInAt: manager ? member.lastSignInAt : null,
|
|
262
|
+
roles,
|
|
263
|
+
disabled: member.disabled,
|
|
264
|
+
affiliation: member.affiliation,
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
return { ok: true, members, invitations: [] }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The application's own people, as IAM answers them: every caller holding a role of the contract, a membership role
|
|
272
|
+
* only in a value of its dimension, paged by id; labels under `directory: 'labels'` or to the application's
|
|
273
|
+
* `members.manage` held globally, which alone sees addresses and last sign-ins.
|
|
274
|
+
*/
|
|
275
|
+
function listAppMembers(artifact: ContractArtifact, callers: DirectoryCallers, permsOf: PermsOf, input: ListAppMembersInput): ListAppMembersResult {
|
|
276
|
+
const caller = memberCaller(callers, input, false)
|
|
277
|
+
if (isFailure(caller)) return caller
|
|
278
|
+
const perms = [...permsOf(caller)]
|
|
279
|
+
const people = artifact.members
|
|
280
|
+
if (people === undefined) return invalid('the application declares no members')
|
|
281
|
+
if (!permits(perms, people.list)) return NOT_ALLOWED
|
|
282
|
+
const limit = input.limit ?? MAX_APP_MEMBERS_PAGE
|
|
283
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_APP_MEMBERS_PAGE) return invalid(`a page holds 1 to ${MAX_APP_MEMBERS_PAGE} members`)
|
|
284
|
+
const manager = permits(perms, people.manage)
|
|
285
|
+
const members: AppMemberItem[] = []
|
|
286
|
+
for (const { caller: member, memberships } of callers.all()) {
|
|
287
|
+
const roles = memberships.filter(({ role, scope }) => {
|
|
288
|
+
const declared = Object.hasOwn(artifact.roles, role) ? artifact.roles[role] : undefined
|
|
289
|
+
return declared !== undefined && (declared.member === null || declared.member.scope === scope?.type)
|
|
290
|
+
})
|
|
291
|
+
if (roles.length === 0 || member.kind === 'link' || (input.after !== undefined && member.id <= input.after)) continue
|
|
292
|
+
members.push({
|
|
293
|
+
principalId: member.id,
|
|
294
|
+
label: manager || artifact.directory === 'labels' ? member.label : null,
|
|
295
|
+
email: manager ? member.email : null,
|
|
296
|
+
lastSignInAt: manager ? member.lastSignInAt : null,
|
|
297
|
+
roles: roles.map(({ role, scope }) => ({ role, scope: scope === null ? null : { type: scope.type, value: scope.value } })),
|
|
298
|
+
disabled: member.disabled,
|
|
299
|
+
affiliation: member.affiliation,
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
members.sort((a, b) => (a.principalId < b.principalId ? -1 : a.principalId > b.principalId ? 1 : 0))
|
|
303
|
+
const page = members.slice(0, limit)
|
|
304
|
+
return { ok: true, members: page, next: members.length > limit ? (page.at(-1)?.principalId ?? null) : null }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function findMember(artifact: ContractArtifact, callers: DirectoryCallers, permsOf: PermsOf, input: FindMemberInput): FindMemberResult {
|
|
308
|
+
const caller = memberCaller(callers, input, false)
|
|
309
|
+
if (isFailure(caller)) return caller
|
|
310
|
+
const perms = [...permsOf(caller)]
|
|
311
|
+
const problem = membershipScopeProblem(input.scope)
|
|
312
|
+
if (problem !== null) return invalid(problem)
|
|
313
|
+
const dimension = dimensionOf(artifact, input.scope.type)
|
|
314
|
+
if (dimension?.members === null || dimension?.members === undefined) {
|
|
315
|
+
return invalid(`scope '${input.scope.type}' declares no members`)
|
|
316
|
+
}
|
|
317
|
+
// As IAM: one of the dimension's lookup actions, held on the directory's value itself.
|
|
318
|
+
if (!directoryLookups(artifact, input.scope.type).includes(input.action) || !permits(perms, input.action, input.scope)) {
|
|
319
|
+
return NOT_ALLOWED
|
|
320
|
+
}
|
|
321
|
+
if (input.principalId === '' || input.principalId.length > MAX_MEMBERSHIP_SCOPE_PART_LENGTH) return { ok: true, member: null }
|
|
322
|
+
const found = callers.all().find(({ caller: member, memberships }) =>
|
|
323
|
+
member.id === input.principalId && rolesIn(artifact, memberships, input.scope).length > 0
|
|
324
|
+
)
|
|
325
|
+
if (found === undefined) return { ok: true, member: null }
|
|
326
|
+
const manager = permits(perms, dimension.members.manage, input.scope)
|
|
327
|
+
return {
|
|
328
|
+
ok: true,
|
|
329
|
+
member: {
|
|
330
|
+
principalId: found.caller.id,
|
|
331
|
+
label: manager || dimension.directory === 'labels' ? found.caller.label : null,
|
|
332
|
+
disabled: found.caller.disabled,
|
|
333
|
+
affiliation: found.caller.affiliation,
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The membership roles held after the stand-in's own scope creations, assignments and revocations, over the roles the
|
|
340
|
+
* test gave its callers: what IAM reads live for the assigner's roles, what a revoke removes, whether a value keeps a
|
|
341
|
+
* creator, the directory, and the permissions of a principal's next token.
|
|
342
|
+
*/
|
|
343
|
+
function membershipChanges(artifact: ContractArtifact, callers: DirectoryCallers) {
|
|
344
|
+
const key = (principalId: string, role: string, scope: Scope) => JSON.stringify([principalId, role, scope.type, scope.value])
|
|
345
|
+
const added = new Map<string, { readonly principalId: string; readonly role: string; readonly scope: Scope }>()
|
|
346
|
+
const removed = new Set<string>()
|
|
347
|
+
const touched = new Set<string>()
|
|
348
|
+
const rolesOf = (principalId: string, scope: Scope): string[] => {
|
|
349
|
+
const held = [
|
|
350
|
+
...rolesIn(artifact, membershipsOf(callers, principalId), scope),
|
|
351
|
+
...[...added.values()].filter((grant) => grant.principalId === principalId && sameScope(grant.scope, scope)).map((grant) => grant.role),
|
|
352
|
+
]
|
|
353
|
+
return [...new Set(held)].filter((role) => !removed.has(key(principalId, role, scope)))
|
|
354
|
+
}
|
|
355
|
+
/** Every role `principalId` holds now: the test's grants it was not revoked, and the ones the stand-in assigned. */
|
|
356
|
+
const heldBy = (principalId: string, given: readonly TestMembership[]): readonly TestMembership[] => {
|
|
357
|
+
if (!touched.has(principalId)) return given
|
|
358
|
+
const kept = given.filter((held) => held.scope === null || !removed.has(key(principalId, held.role, held.scope)))
|
|
359
|
+
const gained = [...added.entries()].filter(([entry, grant]) =>
|
|
360
|
+
grant.principalId === principalId && !removed.has(entry)
|
|
361
|
+
&& !kept.some((held) => held.role === grant.role && held.scope !== null && sameScope(held.scope, grant.scope))
|
|
362
|
+
).map(([, grant]) => ({ role: grant.role, scope: grant.scope }))
|
|
363
|
+
return [...kept, ...gained]
|
|
364
|
+
}
|
|
365
|
+
const principals = () => [...new Set([...callers.all().map(({ caller }) => caller.id), ...[...added.values()].map((grant) => grant.principalId)])]
|
|
366
|
+
return {
|
|
367
|
+
rolesOf,
|
|
368
|
+
heldBy,
|
|
369
|
+
touched: (principalId: string): boolean => touched.has(principalId),
|
|
370
|
+
othersHold: (principalId: string, role: string, scope: Scope): boolean =>
|
|
371
|
+
principals().some((other) => other !== principalId && rolesOf(other, scope).includes(role)),
|
|
372
|
+
assign(principalId: string, role: string, scope: Scope): void {
|
|
373
|
+
touched.add(principalId)
|
|
374
|
+
removed.delete(key(principalId, role, scope))
|
|
375
|
+
added.set(key(principalId, role, scope), { principalId, role, scope })
|
|
376
|
+
},
|
|
377
|
+
revoke(principalId: string, role: string, scope: Scope): void {
|
|
378
|
+
touched.add(principalId)
|
|
379
|
+
removed.add(key(principalId, role, scope))
|
|
380
|
+
},
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** The permissions a role grants in `scope`, as IAM resolves a membership. */
|
|
385
|
+
function roleEntries(artifact: ContractArtifact, held: TestMembership): PermissionEntry[] {
|
|
386
|
+
const scope = held.scope === null ? null : { type: held.scope.type, value: held.scope.value }
|
|
387
|
+
return (artifact.roles[held.role]?.grants ?? []).map((action) => ({ action, scope, source: 'grant' }))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const samePermission = (left: PermissionEntry, right: PermissionEntry): boolean =>
|
|
391
|
+
left.action === right.action && left.source === right.source
|
|
392
|
+
&& (left.scope === null ? right.scope === null : right.scope !== null && sameScope(left.scope, right.scope))
|
|
393
|
+
|
|
394
|
+
const sameScope = (left: Scope, right: Scope): boolean => left.type === right.type && left.value === right.value
|
|
395
|
+
|
|
396
|
+
interface DelegationGrant {
|
|
397
|
+
readonly principalId: string
|
|
398
|
+
readonly endpoint: string
|
|
399
|
+
readonly requestId: string
|
|
400
|
+
readonly purpose: EndpointDelegation
|
|
401
|
+
/** The dimension the endpoint creates a value of or manages the members of. */
|
|
402
|
+
readonly dimension: string
|
|
403
|
+
readonly action: string
|
|
404
|
+
readonly expiresAt: number
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The directory of the kit's callers, and every write checked against the caller's grants as IAM checks it.
|
|
409
|
+
* `organization` names the mailboxes IAM's configuration would; by default none.
|
|
410
|
+
*/
|
|
411
|
+
export function kitMembership(artifact: ContractArtifact, callers: DirectoryCallers, organization = NO_ORGANIZATION): KitMembership {
|
|
412
|
+
const asked: AskedWrite[] = []
|
|
413
|
+
const grants = new Map<string, DelegationGrant>()
|
|
414
|
+
const changes = membershipChanges(artifact, callers)
|
|
415
|
+
const invitations = new Map<string, KitInvitation>()
|
|
416
|
+
const links = new Map<string, KitLink>()
|
|
417
|
+
const live: DirectoryCallers = {
|
|
418
|
+
byCredential: (app, credential) => callers.byCredential(app, credential),
|
|
419
|
+
all: () => callers.all().map(({ caller, memberships }) => ({ caller, memberships: changes.heldBy(caller.id, memberships) })),
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The test's permissions of `caller`, less one entry for each action of a role it lost, plus those of each role it gained. */
|
|
423
|
+
const permsOf = (caller: TestCaller): readonly PermissionEntry[] => {
|
|
424
|
+
if (!changes.touched(caller.id)) return caller.perms
|
|
425
|
+
const given = membershipsOf(callers, caller.id)
|
|
426
|
+
const held = changes.heldBy(caller.id, given)
|
|
427
|
+
const sameMembership = (left: TestMembership) => (right: TestMembership) =>
|
|
428
|
+
left.role === right.role && (left.scope === null ? right.scope === null : right.scope !== null && sameScope(left.scope, right.scope))
|
|
429
|
+
const perms = [...caller.perms]
|
|
430
|
+
for (const lost of given.filter((membership) => !held.some(sameMembership(membership)))) {
|
|
431
|
+
for (const entry of roleEntries(artifact, lost)) {
|
|
432
|
+
const at = perms.findIndex((candidate) => samePermission(candidate, entry))
|
|
433
|
+
if (at >= 0) perms.splice(at, 1)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const gained = held.filter((membership) => !given.some(sameMembership(membership)))
|
|
437
|
+
return Object.freeze([...perms, ...gained.flatMap((membership) => roleEntries(artifact, membership))])
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const organizationMailbox = (email: string): boolean => {
|
|
441
|
+
const domain = email.slice(email.lastIndexOf('@') + 1)
|
|
442
|
+
return organization.emails.some((named) => normalizeEmailIdentity(named) === email)
|
|
443
|
+
|| organization.domains.some((named) => named.toLowerCase() === domain)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The open invitation of `role` in `scope` to `email`; as IAM, an expired one is closed first and never answered. */
|
|
447
|
+
const openInvitation = (scope: Scope, role: string, email: string): KitInvitation | undefined =>
|
|
448
|
+
[...invitations.values()].find((invitation) =>
|
|
449
|
+
invitation.closed === null && invitation.expiresAt > now() && sameScope(invitation.scope, scope) && invitation.role === role
|
|
450
|
+
&& invitation.email === email
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
const openNew = (scope: Scope, role: string, email: string, inviter: string | null): KitInvitation => {
|
|
454
|
+
const invitation = { id: `invitation-${randomId()}`, scope, role, email, expiresAt: now() + INVITATION_TTL_SECONDS, closed: null, inviter }
|
|
455
|
+
invitations.set(invitation.id, invitation)
|
|
456
|
+
return invitation
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Spends the request's grant before its binding is compared, as IAM does, so each attempt is single use. */
|
|
460
|
+
const spend = (caller: TestCaller, input: { delegation: string; requestId: string }, purpose: EndpointDelegation, dimension: string) => {
|
|
461
|
+
const grant = grants.get(input.delegation)
|
|
462
|
+
grants.delete(input.delegation)
|
|
463
|
+
if (
|
|
464
|
+
grant === undefined || grant.expiresAt <= now() || grant.principalId !== caller.id || grant.requestId !== input.requestId
|
|
465
|
+
|| grant.purpose !== purpose || grant.dimension !== dimension
|
|
466
|
+
) {
|
|
467
|
+
return INVALID_DELEGATION
|
|
468
|
+
}
|
|
469
|
+
return grant
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** `members.manage` of the scope's dimension, held there: a value is checked as itself, flat. */
|
|
473
|
+
const manageProblem = (caller: TestCaller, scope: Scope): MembershipFailure | null => {
|
|
474
|
+
const members = dimensionOf(artifact, scope.type)?.members
|
|
475
|
+
if (members === null || members === undefined) return invalid(`scope '${scope.type}' declares no members`)
|
|
476
|
+
return permits([...permsOf(caller)], members.manage, scope) ? null : NOT_ALLOWED
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** As IAM (ADR-0051): an external principal holds only a role the contract declares external. */
|
|
480
|
+
const externalProblem = (principal: TestCaller, role: string): MembershipFailure | null => {
|
|
481
|
+
if (principal.affiliation !== 'external' || artifact.roles[role]?.external === true) return null
|
|
482
|
+
return invalid(`an external principal holds only a role its application declares external, and '${role}' is not one`)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Could `caller` give `role` in `scope`: it holds an assigning role — there, or an application-wide role globally —
|
|
487
|
+
* and every action the role grants there.
|
|
488
|
+
*/
|
|
489
|
+
const roleProblem = (caller: TestCaller, scope: Scope, role: string): MembershipFailure | null => {
|
|
490
|
+
const declared = Object.hasOwn(artifact.roles, role) ? artifact.roles[role] : undefined
|
|
491
|
+
if (declared?.member === null || declared?.member === undefined || declared.member.scope !== scope.type) {
|
|
492
|
+
return invalid(`role '${role}' is not a membership role of scope '${scope.type}'`)
|
|
493
|
+
}
|
|
494
|
+
// IAM reads the assigner's live roles: its membership roles there after this stand-in's own changes, and the
|
|
495
|
+
// application-wide roles the test gave it there or globally, which no membership write removes.
|
|
496
|
+
const appWide = membershipsOf(callers, caller.id).filter((held) =>
|
|
497
|
+
artifact.roles[held.role]?.member === null && (held.scope === null || sameScope(held.scope, scope))
|
|
498
|
+
)
|
|
499
|
+
const held = [...changes.rolesOf(caller.id, scope), ...appWide.map((grant) => grant.role)]
|
|
500
|
+
if (!declared.member.assignableBy.some((assigner) => held.includes(assigner))) return NOT_ALLOWED
|
|
501
|
+
const perms = [...permsOf(caller)]
|
|
502
|
+
return declared.grants.every((action) => permits(perms, action, scope)) ? null : NOT_ALLOWED
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const requestDelegation = (input: RequestDelegationInput): RequestDelegationResult => {
|
|
506
|
+
const caller = memberCaller(callers, input, true)
|
|
507
|
+
if (isFailure(caller)) return caller
|
|
508
|
+
const row = artifact.endpoints.find((candidate) => candidate.id === input.endpoint)
|
|
509
|
+
const purpose = row === undefined ? null : endpointDelegation(artifact, row)
|
|
510
|
+
if (row?.requirement?.kind !== 'action' || purpose === null || row.requirement.access.form === 'app') {
|
|
511
|
+
return invalid(`endpoint '${input.endpoint}' declares no membership operation`)
|
|
512
|
+
}
|
|
513
|
+
const { action, access } = row.requirement
|
|
514
|
+
if (!permsOf(caller).some((entry) => matchAction(entry.action, action))) return NOT_ALLOWED
|
|
515
|
+
const grant = `grant_${randomId()}`
|
|
516
|
+
const expiresAt = now() + DELEGATION_GRANT_TTL_SECONDS
|
|
517
|
+
grants.set(grant, { principalId: caller.id, endpoint: row.id, requestId: input.requestId, purpose, dimension: access.dimension, action, expiresAt })
|
|
518
|
+
return { ok: true, grant, expiresAt }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** A value of a top-level dimension, for a holder of the creating endpoint's action as a global grant. */
|
|
522
|
+
const createScope = (input: CreateScopeInput): CreateScopeResult => {
|
|
523
|
+
const caller = memberCaller(callers, input, true)
|
|
524
|
+
if (isFailure(caller)) return caller
|
|
525
|
+
const spent = spend(caller, input, 'creates', input.dimension)
|
|
526
|
+
if ('ok' in spent) return spent
|
|
527
|
+
const dimension = dimensionOf(artifact, input.dimension)
|
|
528
|
+
if (dimension === undefined || dimension.creator === null || dimension.in !== null) {
|
|
529
|
+
return invalid(`scope '${input.dimension}' is not created by IAM: it needs a creator role and no containing scope`)
|
|
530
|
+
}
|
|
531
|
+
if (!permits([...permsOf(caller)], spent.action)) return NOT_ALLOWED
|
|
532
|
+
const external = externalProblem(caller, dimension.creator)
|
|
533
|
+
if (external !== null) return external
|
|
534
|
+
const scope: Scope = { type: input.dimension, value: crypto.randomUUID() }
|
|
535
|
+
changes.assign(caller.id, dimension.creator, scope)
|
|
536
|
+
asked.push({ operation: 'createScope', principalId: caller.id, scope, role: dimension.creator })
|
|
537
|
+
return { ok: true, scope, role: dimension.creator }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const assignMember = (input: AssignMemberInput): AssignMemberResult => {
|
|
541
|
+
const caller = memberCaller(callers, input, true)
|
|
542
|
+
if (isFailure(caller)) return caller
|
|
543
|
+
const spent = spend(caller, input, 'members', input.scope.type)
|
|
544
|
+
if ('ok' in spent) return spent
|
|
545
|
+
const scopeProblem = membershipScopeProblem(input.scope)
|
|
546
|
+
if (scopeProblem !== null) return invalid(scopeProblem)
|
|
547
|
+
const problem = manageProblem(caller, input.scope) ?? roleProblem(caller, input.scope, input.role)
|
|
548
|
+
if (problem !== null) return problem
|
|
549
|
+
if ('principalId' in input.member) {
|
|
550
|
+
if (input.invitation !== undefined) return invalid('a label, a message and a resend go with an invitation by address')
|
|
551
|
+
const { principalId } = input.member
|
|
552
|
+
const target = callers.all().find(({ caller: candidate }) => candidate.id === principalId && candidate.kind === 'human')?.caller
|
|
553
|
+
if (target === undefined) return NOT_FOUND
|
|
554
|
+
const external = externalProblem(target, input.role)
|
|
555
|
+
if (external !== null) return external
|
|
556
|
+
changes.assign(principalId, input.role, input.scope)
|
|
557
|
+
asked.push({ operation: 'assignMember', principalId: caller.id, scope: input.scope, role: input.role, member: input.member, invitationId: null })
|
|
558
|
+
return { ok: true, assigned: { principalId, role: input.role } }
|
|
559
|
+
}
|
|
560
|
+
const email = invitedAddress(input.member.email)
|
|
561
|
+
if (email === null) return invalid('the member email is not an address')
|
|
562
|
+
const wording = invitationOptionsProblem(input.invitation ?? {})
|
|
563
|
+
if (wording !== null) return invalid(wording)
|
|
564
|
+
if (artifact.roles[input.role]?.external === true && organizationMailbox(email)) return { ok: false, reason: 'internal_mailbox' }
|
|
565
|
+
// As IAM: a repeat answers the open invitation and sends nothing; a resend gives it a new link and 7 days.
|
|
566
|
+
const open = openInvitation(input.scope, input.role, email)
|
|
567
|
+
const invitation = open === undefined
|
|
568
|
+
? openNew(input.scope, input.role, email, caller.id)
|
|
569
|
+
: input.invitation?.resend === true
|
|
570
|
+
? { ...open, expiresAt: now() + INVITATION_TTL_SECONDS }
|
|
571
|
+
: open
|
|
572
|
+
invitations.set(invitation.id, invitation)
|
|
573
|
+
asked.push({
|
|
574
|
+
operation: 'assignMember',
|
|
575
|
+
principalId: caller.id,
|
|
576
|
+
scope: input.scope,
|
|
577
|
+
role: input.role,
|
|
578
|
+
member: { email },
|
|
579
|
+
invitationId: invitation.id,
|
|
580
|
+
})
|
|
581
|
+
return { ok: true, invited: { invitationId: invitation.id, role: invitation.role, expiresAt: invitation.expiresAt } }
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** An invitation this stand-in opened in `scope`, cancelled ones included: IAM knows no other, so any other id is `not_found`. */
|
|
585
|
+
const invitationOf = (id: string, scope: Scope): KitInvitation | undefined => {
|
|
586
|
+
const invitation = invitations.get(id)
|
|
587
|
+
return invitation !== undefined && sameScope(invitation.scope, scope) ? invitation : undefined
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const revokeMember = (input: RevokeMemberInput): RevokeMemberResult => {
|
|
591
|
+
const caller = memberCaller(callers, input, true)
|
|
592
|
+
if (isFailure(caller)) return caller
|
|
593
|
+
const spent = spend(caller, input, 'members', input.scope.type)
|
|
594
|
+
if ('ok' in spent) return spent
|
|
595
|
+
const scopeProblem = membershipScopeProblem(input.scope)
|
|
596
|
+
if (scopeProblem !== null) return invalid(scopeProblem)
|
|
597
|
+
const manage = manageProblem(caller, input.scope)
|
|
598
|
+
if (manage !== null) return manage
|
|
599
|
+
if ('invitationId' in input.member) {
|
|
600
|
+
const invitation = invitationOf(input.member.invitationId, input.scope)
|
|
601
|
+
if (invitation === undefined) return NOT_FOUND
|
|
602
|
+
const problem = roleProblem(caller, input.scope, invitation.role)
|
|
603
|
+
if (problem !== null) return problem
|
|
604
|
+
asked.push({ operation: 'revokeMember', principalId: caller.id, scope: input.scope, member: input.member })
|
|
605
|
+
invitations.set(invitation.id, { ...invitation, closed: 'cancelled' })
|
|
606
|
+
return { ok: true }
|
|
607
|
+
}
|
|
608
|
+
const only = input.member.role ?? null
|
|
609
|
+
if (only !== null && artifact.roles[only]?.member?.scope !== input.scope.type) {
|
|
610
|
+
return invalid(`role '${only}' is not a membership role of scope '${input.scope.type}'`)
|
|
611
|
+
}
|
|
612
|
+
const roles = changes.rolesOf(input.member.principalId, input.scope).filter((role) => only === null || role === only)
|
|
613
|
+
for (const role of roles) {
|
|
614
|
+
const problem = roleProblem(caller, input.scope, role)
|
|
615
|
+
if (problem !== null) return problem
|
|
616
|
+
}
|
|
617
|
+
asked.push({ operation: 'revokeMember', principalId: caller.id, scope: input.scope, member: input.member })
|
|
618
|
+
// As IAM: every other role goes, and the creator role only while another principal still holds it there.
|
|
619
|
+
const target = input.member.principalId
|
|
620
|
+
const creator = dimensionOf(artifact, input.scope.type)?.creator ?? null
|
|
621
|
+
const lastCreator = creator !== null && roles.includes(creator) && !changes.othersHold(target, creator, input.scope)
|
|
622
|
+
for (const role of roles) {
|
|
623
|
+
if (!lastCreator || role !== creator) changes.revoke(target, role, input.scope)
|
|
624
|
+
}
|
|
625
|
+
return lastCreator ? { ok: false, reason: 'last_creator' } : { ok: true }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Why `chain` is not the object of the mount's dimension followed by each value containing it, or `null`. */
|
|
629
|
+
const chainProblem = (dimension: string, chain: readonly Scope[]): string | null => {
|
|
630
|
+
if (chain.length === 0 || chain.length > MAX_LINK_CHAIN_LENGTH) return `a link chain names 1 to ${MAX_LINK_CHAIN_LENGTH} values`
|
|
631
|
+
let expected: string | null = dimension
|
|
632
|
+
for (const scope of chain) {
|
|
633
|
+
const problem = membershipScopeProblem(scope)
|
|
634
|
+
if (problem !== null) return problem
|
|
635
|
+
if (scope.type !== expected) return `the link chain names '${scope.type}' where the schema contains '${expected ?? 'nothing'}'`
|
|
636
|
+
expected = dimensionOf(artifact, scope.type)?.in ?? null
|
|
637
|
+
}
|
|
638
|
+
return expected === null ? null : `the link chain stops before '${expected}'`
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** A share link, for a holder of the mount's `issuedBy` and of every action it grants on some value of the chain. */
|
|
642
|
+
const issueLink = (input: IssueLinkInput): IssueLinkResult => {
|
|
643
|
+
const caller = memberCaller(callers, input, false)
|
|
644
|
+
if (isFailure(caller)) return caller
|
|
645
|
+
const mount = Object.hasOwn(artifact.mounts, input.mount) ? artifact.mounts[input.mount] : undefined
|
|
646
|
+
if (mount?.kind !== 'link') return invalid(`unknown link mount '${input.mount}'`)
|
|
647
|
+
const problem = chainProblem(mount.dimension, input.chain)
|
|
648
|
+
if (problem !== null) return invalid(problem)
|
|
649
|
+
if (!Number.isSafeInteger(input.days) || input.days < 1 || input.days > mount.maxDays) {
|
|
650
|
+
return invalid(`a link of '${input.mount}' lives 1 to ${mount.maxDays} days`)
|
|
651
|
+
}
|
|
652
|
+
const perms = [...permsOf(caller)]
|
|
653
|
+
const heldOnChain = (action: string): boolean => input.chain.some((scope) => permits(perms, action, scope))
|
|
654
|
+
if (!heldOnChain(mount.issuedBy) || !mount.grants.every(heldOnChain)) return NOT_ALLOWED
|
|
655
|
+
const id = `link-${randomId()}`
|
|
656
|
+
const key = `${API_KEY_PREFIX}link_${randomId()}`
|
|
657
|
+
links.set(id, { id, issuer: caller.id, mount: input.mount, chain: input.chain, key, revoked: false })
|
|
658
|
+
asked.push({ operation: 'issueLink', principalId: caller.id, id, mount: input.mount, chain: input.chain })
|
|
659
|
+
return { ok: true, key, id, expiresAt: now() + input.days * DAY_SECONDS }
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** A link this stand-in issued, by its issuer or a holder of `members.manage` on a value of its chain. */
|
|
663
|
+
const revokeLink = (input: RevokeLinkInput): RevokeLinkResult => {
|
|
664
|
+
const caller = memberCaller(callers, input, false)
|
|
665
|
+
if (isFailure(caller)) return caller
|
|
666
|
+
const link = links.get(input.id)
|
|
667
|
+
if (link === undefined) return NOT_FOUND
|
|
668
|
+
const manager = link.chain.some((scope) => {
|
|
669
|
+
const members = dimensionOf(artifact, scope.type)?.members
|
|
670
|
+
return members !== null && members !== undefined && permits([...permsOf(caller)], members.manage, scope)
|
|
671
|
+
})
|
|
672
|
+
if (link.issuer !== caller.id && !manager) return NOT_ALLOWED
|
|
673
|
+
links.set(link.id, { ...link, revoked: true })
|
|
674
|
+
asked.push({ operation: 'revokeLink', principalId: caller.id, id: input.id })
|
|
675
|
+
return { ok: true }
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const rpc: IamMembershipRpc = {
|
|
679
|
+
listMembers: (input) => Promise.resolve(listMembers(artifact, live, permsOf, input)),
|
|
680
|
+
listAppMembers: (input) => Promise.resolve(listAppMembers(artifact, live, permsOf, input)),
|
|
681
|
+
findMember: (input) => Promise.resolve(findMember(artifact, live, permsOf, input)),
|
|
682
|
+
requestDelegation: (input) => Promise.resolve(requestDelegation(input)),
|
|
683
|
+
createScope: (input) => Promise.resolve(createScope(input)),
|
|
684
|
+
assignMember: (input) => Promise.resolve(assignMember(input)),
|
|
685
|
+
revokeMember: (input) => Promise.resolve(revokeMember(input)),
|
|
686
|
+
issueLink: (input) => Promise.resolve(issueLink(input)),
|
|
687
|
+
revokeLink: (input) => Promise.resolve(revokeLink(input)),
|
|
688
|
+
}
|
|
689
|
+
const seedInvitation = (scope: Scope, role: string, email: string): string => {
|
|
690
|
+
const address = invitedAddress(email)
|
|
691
|
+
if (address === null) throw new Error(`a seeded invitation goes to an address, not '${email}'`)
|
|
692
|
+
return (openInvitation(scope, role, address) ?? openNew(scope, role, address, null)).id
|
|
693
|
+
}
|
|
694
|
+
const seedLink = (issuer: string, mount: string, chain: readonly Scope[]): string => {
|
|
695
|
+
const id = `link-${randomId()}`
|
|
696
|
+
links.set(id, { id, issuer, mount, chain, key: null, revoked: false })
|
|
697
|
+
return id
|
|
698
|
+
}
|
|
699
|
+
const linkOfKey = (key: string): IssuedLinkKey | undefined => {
|
|
700
|
+
const link = [...links.values()].find((candidate) => candidate.key === key)
|
|
701
|
+
return link === undefined || link.revoked ? undefined : { id: link.id, mount: link.mount, chain: link.chain }
|
|
702
|
+
}
|
|
703
|
+
const invitationsSent = (): readonly SentInvitation[] =>
|
|
704
|
+
[...invitations.values()].map(({ id, email, role, scope, expiresAt, closed }) => ({
|
|
705
|
+
id,
|
|
706
|
+
email,
|
|
707
|
+
role,
|
|
708
|
+
scope,
|
|
709
|
+
expiresAt,
|
|
710
|
+
status: closed ?? (expiresAt > now() ? 'open' : 'expired'),
|
|
711
|
+
}))
|
|
712
|
+
const accept = (invitationId: string, recipient?: TestCaller): AcceptedInvitation => {
|
|
713
|
+
const invitation = invitations.get(invitationId)
|
|
714
|
+
if (invitation === undefined || invitation.closed !== null || invitation.expiresAt <= now()) {
|
|
715
|
+
throw new Error(`no open invitation ${invitationId}: the kit's IAM opened none by that id, or it was cancelled, accepted or expired`)
|
|
716
|
+
}
|
|
717
|
+
if (recipient !== undefined && externalProblem(recipient, invitation.role) !== null) {
|
|
718
|
+
throw new Error(`not_eligible: an external person accepts only a role the contract declares external, and '${invitation.role}' is not one`)
|
|
719
|
+
}
|
|
720
|
+
// As IAM: the inviter's authority now, its live roles and permissions, not what it held when it invited.
|
|
721
|
+
const inviter = invitation.inviter === null ? undefined : callers.all().find(({ caller }) => caller.id === invitation.inviter)?.caller
|
|
722
|
+
const authority = inviter === undefined || inviter.disabled
|
|
723
|
+
? NOT_ALLOWED
|
|
724
|
+
: (manageProblem(inviter, invitation.scope) ?? roleProblem(inviter, invitation.scope, invitation.role))
|
|
725
|
+
if (authority !== null) {
|
|
726
|
+
throw new Error(
|
|
727
|
+
invitation.inviter === null
|
|
728
|
+
? `not_allowed: ${invitationId} is a seeded invitation, which has no inviter to accept it under`
|
|
729
|
+
: `not_allowed: the inviter can no longer give '${invitation.role}' in that ${invitation.scope.type}`,
|
|
730
|
+
)
|
|
731
|
+
}
|
|
732
|
+
invitations.set(invitation.id, { ...invitation, closed: 'accepted' })
|
|
733
|
+
if (recipient !== undefined) changes.assign(recipient.id, invitation.role, invitation.scope)
|
|
734
|
+
return { scope: invitation.scope, role: invitation.role, email: invitation.email }
|
|
735
|
+
}
|
|
736
|
+
return Object.freeze({
|
|
737
|
+
rpc,
|
|
738
|
+
asked,
|
|
739
|
+
permsOf,
|
|
740
|
+
accept,
|
|
741
|
+
seedInvitation,
|
|
742
|
+
seedLink,
|
|
743
|
+
linkOfKey,
|
|
744
|
+
membershipsOf: (caller: TestCaller) => changes.heldBy(caller.id, membershipsOf(callers, caller.id)),
|
|
745
|
+
invitations: invitationsSent,
|
|
746
|
+
})
|
|
747
|
+
}
|