@coffre/core 0.4.6 → 0.5.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/dist/access-CwsFLfTz.d.ts +420 -0
- package/dist/access-pnJ-u4x2.js +522 -0
- package/dist/access.d.ts +2 -136
- package/dist/access.js +2 -135
- package/dist/identity.d.ts +2 -2
- package/dist/identity.js +2 -2
- package/dist/schemas.d.ts +23 -1
- package/dist/schemas.js +14 -2
- package/dist/vault.d.ts +66 -28
- package/dist/vault.js +2 -2
- package/dist/workloads.d.ts +60 -20
- package/dist/workloads.js +141 -26
- package/package.json +2 -2
- package/src/access-conversion.ts +202 -0
- package/src/access-fault.ts +6 -4
- package/src/access.ts +346 -49
- package/src/identity/workloads.ts +154 -42
- package/src/schemas.ts +12 -1
- package/src/vault.ts +61 -27
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
* their platform signs for them (docs/design/oidc.md).
|
|
4
4
|
*
|
|
5
5
|
* A binding names a profile, an issuer, and claims a token must carry, each
|
|
6
|
-
* a top-level string matched exactly. The
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* a top-level string matched exactly. The event that started the run may
|
|
7
|
+
* be any of several: a list. The profile sets the minimum: the server
|
|
8
|
+
* refuses a binding that lacks a claim its profile requires, from the UI,
|
|
9
|
+
* the CLI or the API alike. Say a service is bound to `ci.yml` of
|
|
10
|
+
* `acme/api`, pushed or dispatched on `main`, or run for a pull request
|
|
11
|
+
* into `main`:
|
|
10
12
|
*
|
|
11
13
|
* { profile: 'github', issuer: 'https://token.actions.githubusercontent.com',
|
|
12
14
|
* claims: { repository_owner_id: '9919', repository_id: '41532',
|
|
13
|
-
* workflow_ref: 'acme/api/.github/workflows/
|
|
14
|
-
* ref: 'refs/heads/main',
|
|
15
|
+
* workflow_ref: 'acme/api/.github/workflows/ci.yml@refs/heads/main',
|
|
16
|
+
* ref: 'refs/heads/main',
|
|
17
|
+
* event_name: ['pull_request', 'push', 'workflow_dispatch'] } }
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
export const GITHUB_ISSUER = 'https://token.actions.githubusercontent.com';
|
|
@@ -46,16 +49,50 @@ export const REQUIRED_CLAIMS: Record<WorkloadProfile, readonly string[]> = {
|
|
|
46
49
|
};
|
|
47
50
|
|
|
48
51
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
52
|
+
* The events a GitHub binding may trust. The first four are started by
|
|
53
|
+
* someone with write access, at the ref the binding names. The last two
|
|
54
|
+
* can run code nobody reviewed, so the owner opts in to each:
|
|
55
|
+
*
|
|
56
|
+
* - `pull_request` runs the pull request's own code and workflow file, at
|
|
57
|
+
* `refs/pull/<n>/merge`; a binding matches it by the branch it merges
|
|
58
|
+
* into. Forks get no ID token, unless the repository sends write tokens
|
|
59
|
+
* to their pull requests (a setting of private repositories), so this
|
|
60
|
+
* trusts whoever can push a branch to the repository.
|
|
61
|
+
* - `workflow_run` runs the default branch's workflow, but a fork's pull
|
|
62
|
+
* request can start it: the run is as safe as what it does with the
|
|
63
|
+
* code and artifacts of the run that started it.
|
|
64
|
+
*
|
|
65
|
+
* `pull_request_target` and the rest are refused, even when their ref
|
|
66
|
+
* happens to match: they exist to act on anyone's pull request, with the
|
|
67
|
+
* base repository's tokens.
|
|
68
|
+
*/
|
|
69
|
+
export const GITHUB_EVENTS = ['push', 'workflow_dispatch', 'schedule', 'release', 'pull_request', 'workflow_run'] as const;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What trusting each of the last two events exposes, as the UI, the CLI and
|
|
73
|
+
* the approval page say it when one is chosen.
|
|
53
74
|
*/
|
|
54
|
-
export const
|
|
75
|
+
export const EVENT_EXPOSURE: Readonly<Record<string, string>> = {
|
|
76
|
+
pull_request:
|
|
77
|
+
'Anyone who can push a branch to the repository can open a pull request and run their own code as this service account, unreviewed. ' +
|
|
78
|
+
'Pull requests from forks get no ID token, unless the repository sends them write tokens.',
|
|
79
|
+
workflow_run:
|
|
80
|
+
"A fork's pull request can start workflow_run. The workflow comes from the default branch, so this is safe unless it runs code or artifacts from the run that started it.",
|
|
81
|
+
};
|
|
55
82
|
|
|
56
83
|
/** Pipelines that run the project's own code, at its own ref: never a merge request's. */
|
|
57
84
|
export const GITLAB_PIPELINE_SOURCES = ['push', 'web', 'schedule'] as const;
|
|
58
85
|
|
|
86
|
+
/** The claim that says what started the run, which a binding may name several values of, and why the others are refused. */
|
|
87
|
+
const GITHUB_EVENT = { name: 'event_name', values: GITHUB_EVENTS, why: "other events can run a stranger's code" };
|
|
88
|
+
const EVENT_CLAIM: Record<WorkloadProfile, { name: string; values: readonly string[]; why: string } | null> = {
|
|
89
|
+
github: GITHUB_EVENT,
|
|
90
|
+
'github-reusable': GITHUB_EVENT,
|
|
91
|
+
'github-reusable-organization': GITHUB_EVENT,
|
|
92
|
+
gitlab: { name: 'pipeline_source', values: GITLAB_PIPELINE_SOURCES, why: 'merge-request pipelines name another project' },
|
|
93
|
+
custom: null,
|
|
94
|
+
};
|
|
95
|
+
|
|
59
96
|
/** At most this many live bindings per service, and claims per binding. */
|
|
60
97
|
export const MAX_BINDINGS = 16;
|
|
61
98
|
export const MAX_CLAIMS = 16;
|
|
@@ -70,7 +107,9 @@ const SHA = /^[0-9a-f]{40}$/;
|
|
|
70
107
|
const REF = /^refs\/(heads|tags)\/\S{1,200}$/;
|
|
71
108
|
const WORKFLOW = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/\.github\/workflows\/[^@\s]+\.ya?ml@\S+$/;
|
|
72
109
|
|
|
73
|
-
|
|
110
|
+
/** A claim a binding names: one value, or, for what started the run, any of several. */
|
|
111
|
+
export type ClaimValue = string | string[];
|
|
112
|
+
export type BindingClaims = Record<string, ClaimValue>;
|
|
74
113
|
|
|
75
114
|
/** What makes a binding, checked: its profile, its issuer as a URL, and its claims, sorted. */
|
|
76
115
|
export type BindingPolicy = { profile: WorkloadProfile; issuer: string; claims: BindingClaims };
|
|
@@ -118,11 +157,24 @@ export function checkBinding(
|
|
|
118
157
|
|
|
119
158
|
const entries = Object.entries(input.claims);
|
|
120
159
|
if (entries.length > MAX_CLAIMS) throw new BindingInvalid(`a binding names at most ${MAX_CLAIMS} claims`);
|
|
160
|
+
const event = EVENT_CLAIM[profile];
|
|
121
161
|
const claims: BindingClaims = {};
|
|
122
162
|
for (const [name, value] of entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
123
163
|
if (!CLAIM_NAME.test(name)) throw new BindingInvalid(`"${name}" is not a claim name a binding can match`);
|
|
124
164
|
if (RESERVED.has(name)) throw new BindingInvalid(`a binding never names "${name}": the exchange checks it`);
|
|
125
|
-
if (
|
|
165
|
+
if (name === event?.name) {
|
|
166
|
+
// Stored once each, in the profile's order, and a single one as a plain value.
|
|
167
|
+
const given: unknown[] = Array.isArray(value) ? value : [value];
|
|
168
|
+
if (given.length === 0 || given.some((each) => !event.values.includes(each as string))) {
|
|
169
|
+
throw new BindingInvalid(`${name} must be one or more of ${event.values.join(', ')}: ${event.why}`);
|
|
170
|
+
}
|
|
171
|
+
const values = event.values.filter((each) => given.includes(each));
|
|
172
|
+
claims[name] = values.length === 1 ? values[0]! : values;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
176
|
+
throw new BindingInvalid(`claim ${name} must be a nonempty string${Array.isArray(value) && event !== null ? `: only ${event.name} lists several` : ''}`);
|
|
177
|
+
}
|
|
126
178
|
if (new TextEncoder().encode(value).length > MAX_CLAIM_BYTES) {
|
|
127
179
|
throw new BindingInvalid(`claim ${name} is longer than ${MAX_CLAIM_BYTES} bytes`);
|
|
128
180
|
}
|
|
@@ -139,44 +191,81 @@ export function checkBinding(
|
|
|
139
191
|
return { profile, issuer, claims };
|
|
140
192
|
}
|
|
141
193
|
|
|
194
|
+
/** A claim's values, one or several. */
|
|
195
|
+
export function claimValues(value: ClaimValue | undefined): string[] {
|
|
196
|
+
return value === undefined ? [] : typeof value === 'string' ? [value] : value;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** What a ref must be for each event: the branch or tag it runs at, or for a pull request, the branch it merges into. */
|
|
200
|
+
const EVENT_REFS: Record<string, { prefix: string; why: string }> = {
|
|
201
|
+
release: { prefix: 'refs/tags/', why: 'a release runs at its tag: refs/tags/<tag>' },
|
|
202
|
+
schedule: { prefix: 'refs/heads/', why: 'a schedule runs on the default branch: refs/heads/<branch>' },
|
|
203
|
+
workflow_run: { prefix: 'refs/heads/', why: 'a workflow_run runs on the default branch: refs/heads/<branch>' },
|
|
204
|
+
pull_request: { prefix: 'refs/heads/', why: 'a pull request matches by the branch it merges into: refs/heads/<branch>' },
|
|
205
|
+
};
|
|
206
|
+
|
|
142
207
|
function checkGitHub(profile: WorkloadProfile, claims: BindingClaims): void {
|
|
143
208
|
for (const id of ['repository_owner_id', 'repository_id']) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const event = claims.event_name;
|
|
147
|
-
if (!(GITHUB_EVENTS as readonly string[]).includes(event)) {
|
|
148
|
-
throw new BindingInvalid(`event_name must be one of ${GITHUB_EVENTS.join(', ')}: other events can run a stranger's code`);
|
|
209
|
+
const value = claims[id];
|
|
210
|
+
if (value !== undefined && !DIGITS.test(value as string)) throw new BindingInvalid(`${id} is GitHub's numeric ID, not a name`);
|
|
149
211
|
}
|
|
150
|
-
const
|
|
212
|
+
const events = claimValues(claims.event_name);
|
|
213
|
+
const ref = claims.ref as string;
|
|
151
214
|
if (!REF.test(ref)) throw new BindingInvalid('ref is a full ref: refs/heads/<branch> or refs/tags/<tag>');
|
|
152
|
-
|
|
153
|
-
|
|
215
|
+
for (const event of events) {
|
|
216
|
+
const rule = EVENT_REFS[event];
|
|
217
|
+
if (rule !== undefined && !ref.startsWith(rule.prefix)) throw new BindingInvalid(rule.why);
|
|
218
|
+
}
|
|
219
|
+
const workflow = claims.workflow_ref as string | undefined;
|
|
154
220
|
if (profile === 'github') {
|
|
155
|
-
// The workflow file at the ref
|
|
156
|
-
if (!WORKFLOW.test(
|
|
221
|
+
// The workflow file at the binding's ref, which a pull request's run is matched at too.
|
|
222
|
+
if (!WORKFLOW.test(workflow!) || !workflow!.endsWith(`@${ref}`)) {
|
|
157
223
|
throw new BindingInvalid('workflow_ref is <owner>/<repository>/.github/workflows/<file>@<ref>, at the binding\'s ref');
|
|
158
224
|
}
|
|
159
225
|
return;
|
|
160
226
|
}
|
|
161
227
|
// A reusable workflow, pinned to one commit: its path can be reused and its branch changed.
|
|
162
|
-
if (!WORKFLOW.test(claims.job_workflow_ref)) {
|
|
228
|
+
if (!WORKFLOW.test(claims.job_workflow_ref as string)) {
|
|
163
229
|
throw new BindingInvalid('job_workflow_ref is <owner>/<repository>/.github/workflows/<file>@<ref>');
|
|
164
230
|
}
|
|
165
|
-
if (!SHA.test(claims.job_workflow_sha)) throw new BindingInvalid('job_workflow_sha is the called workflow\'s full commit SHA');
|
|
166
|
-
if (
|
|
231
|
+
if (!SHA.test(claims.job_workflow_sha as string)) throw new BindingInvalid('job_workflow_sha is the called workflow\'s full commit SHA');
|
|
232
|
+
if (workflow !== undefined && !WORKFLOW.test(workflow)) {
|
|
167
233
|
throw new BindingInvalid('workflow_ref, which narrows the binding to one caller, is <owner>/<repository>/.github/workflows/<file>@<ref>');
|
|
168
234
|
}
|
|
169
235
|
}
|
|
170
236
|
|
|
171
237
|
function checkGitLab(claims: BindingClaims): void {
|
|
172
238
|
for (const id of ['namespace_id', 'project_id']) {
|
|
173
|
-
if (!DIGITS.test(claims[id])) throw new BindingInvalid(`${id} is GitLab's numeric ID, not a path`);
|
|
239
|
+
if (!DIGITS.test(claims[id] as string)) throw new BindingInvalid(`${id} is GitLab's numeric ID, not a path`);
|
|
174
240
|
}
|
|
175
241
|
if (claims.ref_type !== 'branch' && claims.ref_type !== 'tag') throw new BindingInvalid('ref_type is branch or tag');
|
|
176
|
-
if (claims.ref.startsWith('refs/')) throw new BindingInvalid('GitLab\'s ref is the branch or tag name, without refs/');
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
242
|
+
if ((claims.ref as string).startsWith('refs/')) throw new BindingInvalid('GitLab\'s ref is the branch or tag name, without refs/');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The claims of a verified token that differ from what a binding expects,
|
|
247
|
+
* by name: none when it matches. A claim matches when the token carries its
|
|
248
|
+
* value, or one of its values. A GitHub `pull_request` run, at
|
|
249
|
+
* `refs/pull/<n>/merge`, counts as a run at the branch it merges into: its
|
|
250
|
+
* `ref`, and the ref its `workflow_ref` ends in, are read as
|
|
251
|
+
* `refs/heads/<base_ref>`.
|
|
252
|
+
*/
|
|
253
|
+
export function differingClaims(profile: WorkloadProfile, expected: BindingClaims, token: Record<string, unknown>): string[] {
|
|
254
|
+
const claims = profile.startsWith('github') ? atBaseBranch(token) : token;
|
|
255
|
+
return Object.entries(expected)
|
|
256
|
+
.filter(([name, value]) => {
|
|
257
|
+
const actual = claims[name];
|
|
258
|
+
return typeof actual !== 'string' || !claimValues(value).includes(actual);
|
|
259
|
+
})
|
|
260
|
+
.map(([name]) => name);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function atBaseBranch(claims: Record<string, unknown>): Record<string, unknown> {
|
|
264
|
+
const { event_name: event, ref, base_ref: base, workflow_ref: workflow } = claims;
|
|
265
|
+
if (event !== 'pull_request' || typeof ref !== 'string' || typeof base !== 'string' || base === '') return claims;
|
|
266
|
+
const at = `refs/heads/${base}`;
|
|
267
|
+
const file = typeof workflow === 'string' && workflow.endsWith(`@${ref}`) ? `${workflow.slice(0, -ref.length)}${at}` : workflow;
|
|
268
|
+
return { ...claims, ref: at, workflow_ref: file };
|
|
180
269
|
}
|
|
181
270
|
|
|
182
271
|
/** The claims as the MAC and the database hold them: JSON, sorted by name. */
|
|
@@ -231,20 +320,45 @@ export function fullRef(kind: 'branch' | 'tag', name: string): string {
|
|
|
231
320
|
return `refs/${kind === 'branch' ? 'heads' : 'tags'}/${name}`;
|
|
232
321
|
}
|
|
233
322
|
|
|
323
|
+
/** The GitHub events a binding at a branch, or at a tag, may name. */
|
|
324
|
+
export function githubEventsAt(kind: 'branch' | 'tag'): string[] {
|
|
325
|
+
return GITHUB_EVENTS.filter((event) => EVENT_REFS[event] === undefined || EVENT_REFS[event].prefix === fullRef(kind, ''));
|
|
326
|
+
}
|
|
327
|
+
|
|
234
328
|
/** A workflow file at a ref, as GitHub's tokens name it: `acme/api/.github/workflows/deploy.yml@refs/heads/main`. */
|
|
235
329
|
export function workflowRef(repository: string, file: string, ref: string): string {
|
|
236
330
|
return `${repository}/.github/workflows/${file.replace(/^\.github\/workflows\//, '')}@${ref}`;
|
|
237
331
|
}
|
|
238
332
|
|
|
239
|
-
/**
|
|
333
|
+
/**
|
|
334
|
+
* What started the run, as the presets take it: `event` (`source`, for
|
|
335
|
+
* GitLab), the one value 0.4.6's presets took, or `events` (`sources`), any
|
|
336
|
+
* of several. Naming both, or neither, is a type error and, for a caller the
|
|
337
|
+
* types do not reach, a `BindingInvalid` at run time: a preset never guesses
|
|
338
|
+
* which one was meant.
|
|
339
|
+
*/
|
|
340
|
+
export type Starts<One extends string, Many extends string> =
|
|
341
|
+
| ({ [K in One]: string } & { [K in Many]?: never })
|
|
342
|
+
| ({ [K in Many]: string[] } & { [K in One]?: never });
|
|
343
|
+
|
|
344
|
+
function started(input: Record<string, unknown>, one: string, many: string): ClaimValue {
|
|
345
|
+
const single = input[one];
|
|
346
|
+
const list = input[many];
|
|
347
|
+
if (single !== undefined && list !== undefined) throw new BindingInvalid(`name ${one} or ${many}, not both`);
|
|
348
|
+
// As 0.4.6 made it: the plain value, so a one-event binding is the same claim either way.
|
|
349
|
+
if (single !== undefined) return single as string;
|
|
350
|
+
if (list === undefined) throw new BindingInvalid(`name ${one} or ${many}`);
|
|
351
|
+
return list as string[];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** A workflow of the repository, run at `ref` by any of `events`; a pull request, by the branch it merges into. */
|
|
240
355
|
export function githubWorkflow(input: {
|
|
241
356
|
repository: string;
|
|
242
357
|
repositoryId: string;
|
|
243
358
|
ownerId: string;
|
|
244
359
|
workflow: string;
|
|
245
360
|
ref: string;
|
|
246
|
-
|
|
247
|
-
}): Omit<BindingPolicy, 'issuer'> {
|
|
361
|
+
} & Starts<'event', 'events'>): Omit<BindingPolicy, 'issuer'> {
|
|
248
362
|
return {
|
|
249
363
|
profile: 'github',
|
|
250
364
|
claims: {
|
|
@@ -252,7 +366,7 @@ export function githubWorkflow(input: {
|
|
|
252
366
|
repository_id: input.repositoryId,
|
|
253
367
|
workflow_ref: workflowRef(input.repository, input.workflow, input.ref),
|
|
254
368
|
ref: input.ref,
|
|
255
|
-
event_name: input
|
|
369
|
+
event_name: started(input, 'event', 'events'),
|
|
256
370
|
},
|
|
257
371
|
};
|
|
258
372
|
}
|
|
@@ -266,16 +380,15 @@ export function githubReusable(input: {
|
|
|
266
380
|
repositoryId: string | null;
|
|
267
381
|
ownerId: string;
|
|
268
382
|
ref: string;
|
|
269
|
-
event: string;
|
|
270
383
|
called: string;
|
|
271
384
|
sha: string;
|
|
272
385
|
caller?: { repository: string; workflow: string };
|
|
273
|
-
}): Omit<BindingPolicy, 'issuer'> {
|
|
386
|
+
} & Starts<'event', 'events'>): Omit<BindingPolicy, 'issuer'> {
|
|
274
387
|
const claims: BindingClaims = {
|
|
275
388
|
repository_owner_id: input.ownerId,
|
|
276
389
|
...(input.repositoryId === null ? {} : { repository_id: input.repositoryId }),
|
|
277
390
|
ref: input.ref,
|
|
278
|
-
event_name: input
|
|
391
|
+
event_name: started(input, 'event', 'events'),
|
|
279
392
|
job_workflow_ref: input.called,
|
|
280
393
|
job_workflow_sha: input.sha,
|
|
281
394
|
};
|
|
@@ -283,14 +396,13 @@ export function githubReusable(input: {
|
|
|
283
396
|
return { profile: input.repositoryId === null ? 'github-reusable-organization' : 'github-reusable', claims };
|
|
284
397
|
}
|
|
285
398
|
|
|
286
|
-
/** A GitLab project's pipelines from `
|
|
399
|
+
/** A GitLab project's pipelines from any of `sources`, at a branch or tag. */
|
|
287
400
|
export function gitlabProject(input: {
|
|
288
401
|
namespaceId: string;
|
|
289
402
|
projectId: string;
|
|
290
403
|
refType: 'branch' | 'tag';
|
|
291
404
|
ref: string;
|
|
292
|
-
|
|
293
|
-
}): Omit<BindingPolicy, 'issuer'> {
|
|
405
|
+
} & Starts<'source', 'sources'>): Omit<BindingPolicy, 'issuer'> {
|
|
294
406
|
return {
|
|
295
407
|
profile: 'gitlab',
|
|
296
408
|
claims: {
|
|
@@ -298,7 +410,7 @@ export function gitlabProject(input: {
|
|
|
298
410
|
project_id: input.projectId,
|
|
299
411
|
ref_type: input.refType,
|
|
300
412
|
ref: input.ref,
|
|
301
|
-
pipeline_source: input
|
|
413
|
+
pipeline_source: started(input, 'source', 'sources'),
|
|
302
414
|
},
|
|
303
415
|
};
|
|
304
416
|
}
|
package/src/schemas.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
+
import { INSTANCE_ROLE_NAMES, type InstanceRole } from './access.ts';
|
|
3
4
|
import { PROJECT_PAGES } from './pages.ts';
|
|
4
5
|
|
|
5
6
|
export const slug = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
|
|
@@ -42,6 +43,16 @@ export function shownMember(member: string): string {
|
|
|
42
43
|
|
|
43
44
|
export const principalType = z.enum(['user', 'service']);
|
|
44
45
|
export const principalId = z.string().trim().min(1).max(320);
|
|
45
|
-
export const instanceRole = z.enum([
|
|
46
|
+
export const instanceRole = z.enum(INSTANCE_ROLE_NAMES as [InstanceRole, ...InstanceRole[]]);
|
|
47
|
+
|
|
48
|
+
/** Which projects or environments a scope takes in, by slug: `all`, `{ only: [...] }` or `{ except: [...] }`. */
|
|
49
|
+
export const scopeFilter = z.union([
|
|
50
|
+
z.literal('all'),
|
|
51
|
+
z.object({ only: z.array(slug).max(200) }).strict(),
|
|
52
|
+
z.object({ except: z.array(slug).min(1).max(200) }).strict(),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/** Where an instance role applies, as the API takes it: either filter left out is `all`. */
|
|
56
|
+
export const scopeInput = z.object({ projects: scopeFilter.optional(), environments: scopeFilter.optional() }).strict();
|
|
46
57
|
export const grantId = z.string().uuid();
|
|
47
58
|
export const emailAddress = z.string().email().max(320);
|
package/src/vault.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AccessFault } from './access-fault.ts';
|
|
2
|
-
import type { GrantPlace, Role } from './access.ts';
|
|
2
|
+
import type { CredentialScope, GrantPlace, InstanceRole, Role, Scope, ServiceSetting } from './access.ts';
|
|
3
3
|
|
|
4
4
|
export { checkpointMessage, checkpointVerifier, verifyCheckpoint } from './checkpoint.ts';
|
|
5
5
|
export { describeAccessFault, type AccessFault, type FaultGrant, type FaultNames } from './access-fault.ts';
|
|
@@ -64,13 +64,21 @@ export interface Vault {
|
|
|
64
64
|
/** Set roles at several places for one principal: all of it, or none. */
|
|
65
65
|
setAccess(input: SetAccessInput): Promise<Outcome<{ changes: AccessChange[] }>>;
|
|
66
66
|
/**
|
|
67
|
-
* Add a member, bring back a removed one, or change
|
|
68
|
-
*
|
|
69
|
-
* one are dead.
|
|
67
|
+
* Add a member, bring back a removed one, or change a person's instance
|
|
68
|
+
* role and its scope. `generation` is theirs now: sessions and tokens of
|
|
69
|
+
* an earlier one are dead.
|
|
70
70
|
*/
|
|
71
|
-
admit(input: AdmitInput): Promise<Outcome<{ created: boolean;
|
|
71
|
+
admit(input: AdmitInput): Promise<Outcome<{ created: boolean; role: InstanceRole; scope: Scope; generation: number }>>;
|
|
72
72
|
/** Remove a member: revoke every grant and refuse them until admitted again, from `generation` on. */
|
|
73
73
|
remove(input: RemoveInput): Promise<Outcome<{ revoked: Grant[]; generation: number }>>;
|
|
74
|
+
/**
|
|
75
|
+
* The instance's settings, as the vault's newest `settings.change` entry
|
|
76
|
+
* says, by its MAC: an entry is where they live, so each change is logged
|
|
77
|
+
* by being made. Everywhere, until someone narrows it.
|
|
78
|
+
*/
|
|
79
|
+
settings(): Promise<Settings>;
|
|
80
|
+
/** Change the instance's settings: those who run it, never anyone else. */
|
|
81
|
+
setSettings(input: SetSettingsInput): Promise<Outcome<{ settings: Settings }>>;
|
|
74
82
|
|
|
75
83
|
/**
|
|
76
84
|
* Sign the log up to its last entry, in an `audit.checkpoint` entry of
|
|
@@ -105,7 +113,7 @@ export interface Vault {
|
|
|
105
113
|
* from it. The links and hashes of the chain are the caller's to
|
|
106
114
|
* recompute through `upTo`, as the app does first; with none, the vault
|
|
107
115
|
* recomputes them all itself. A verdict and nothing else, so anyone may
|
|
108
|
-
* ask; the app asks for
|
|
116
|
+
* ask; the app asks for those who read the whole log (`readsWholeLog`).
|
|
109
117
|
*/
|
|
110
118
|
verifyLog(input: VerifyLogInput): Promise<LogVerification>;
|
|
111
119
|
}
|
|
@@ -115,7 +123,7 @@ export interface Vault {
|
|
|
115
123
|
* `access_seq` names, and what a finding about them is newer than while it
|
|
116
124
|
* stands.
|
|
117
125
|
*/
|
|
118
|
-
export const ACCESS_ACTIONS = ['member.add', 'member.restore', 'member.owner', 'member.remove', 'access.grant', 'access.revoke'] as const;
|
|
126
|
+
export const ACCESS_ACTIONS = ['member.add', 'member.restore', 'member.owner', 'member.role', 'member.remove', 'access.grant', 'access.revoke'] as const;
|
|
119
127
|
|
|
120
128
|
/** A refusal, already logged by the time the app sees it. */
|
|
121
129
|
export type Refusal = { code: RefusalCode; message: string };
|
|
@@ -144,7 +152,7 @@ export type RefusalCode =
|
|
|
144
152
|
| 'not_allowed'
|
|
145
153
|
/** Root admins come from the vault's configuration and cannot be changed. */
|
|
146
154
|
| 'root_admin'
|
|
147
|
-
/** Well-formed, but not something the rules allow: a project role on an environment, an
|
|
155
|
+
/** Well-formed, but not something the rules allow: a project role on an environment, a service account with an instance role. */
|
|
148
156
|
| 'invalid'
|
|
149
157
|
/** The vault's own log no longer carries the head the last checkpoint signed, or does not rehash since. */
|
|
150
158
|
| 'log_broken'
|
|
@@ -207,7 +215,15 @@ type Correlation = {
|
|
|
207
215
|
credentialId?: string | null;
|
|
208
216
|
};
|
|
209
217
|
|
|
210
|
-
|
|
218
|
+
/**
|
|
219
|
+
* The scope of the credential the request came in on, a service token's or
|
|
220
|
+
* a trust binding's: unlike the correlation, the vault decides on it, and
|
|
221
|
+
* its principal holds nothing past it (`allows`). Null, or left out, for a
|
|
222
|
+
* session's, which carries all its member holds.
|
|
223
|
+
*/
|
|
224
|
+
export type Through = { credentialScope?: CredentialScope | null };
|
|
225
|
+
|
|
226
|
+
export type UnwrapInput = Correlation & Through & {
|
|
211
227
|
principal: string;
|
|
212
228
|
purpose: Purpose;
|
|
213
229
|
/**
|
|
@@ -224,35 +240,34 @@ export type Via = { reference: string; seq: number };
|
|
|
224
240
|
/** Where a reference is held: the holder's ids, and its path for the log. A new holder's row may not exist yet. */
|
|
225
241
|
export type HolderRef = { projectId: string; environmentId: string; secretId: string; path: string };
|
|
226
242
|
|
|
227
|
-
export type ReferenceInput = Correlation & {
|
|
243
|
+
export type ReferenceInput = Correlation & Through & {
|
|
228
244
|
principal: string;
|
|
229
245
|
/** `id` is the reference's, a fresh UUID; the vault reads the source by its id. */
|
|
230
246
|
items: { id: string; holder: HolderRef; source: { secretId: string } }[];
|
|
231
247
|
};
|
|
232
248
|
|
|
233
|
-
export type EndReferencesInput = Correlation & {
|
|
249
|
+
export type EndReferencesInput = Correlation & Through & {
|
|
234
250
|
principal: string;
|
|
235
251
|
/** `abandoned`: sealed for a write that stored nothing, so that no row written later revives it. */
|
|
236
252
|
reason: 'replaced' | 'broken' | 'abandoned';
|
|
237
253
|
items: Via[];
|
|
238
254
|
};
|
|
239
255
|
|
|
240
|
-
export type WrapInput = Correlation & {
|
|
256
|
+
export type WrapInput = Correlation & Through & {
|
|
241
257
|
principal: string;
|
|
242
258
|
/** `key` is a fresh 32-byte data key, base64. */
|
|
243
259
|
items: { secret: SecretRef; key: string }[];
|
|
244
260
|
};
|
|
245
261
|
|
|
246
|
-
export type RewrapInput = Correlation & {
|
|
262
|
+
export type RewrapInput = Correlation & Through & {
|
|
247
263
|
principal: string;
|
|
248
264
|
/** `secret` is the proposed new version; `secretVersionId` is the stored source. */
|
|
249
265
|
items: { secret: SecretRef; secretVersionId: string }[];
|
|
250
266
|
};
|
|
251
267
|
|
|
252
268
|
/**
|
|
253
|
-
* A grant: on a project, one of its environments (`projectId` is the
|
|
254
|
-
* environment's project
|
|
255
|
-
* project (`GrantPlace` in @coffre/core/access).
|
|
269
|
+
* A grant: on a project, or one of its environments (`projectId` is the
|
|
270
|
+
* environment's project; `GrantPlace` in @coffre/core/access).
|
|
256
271
|
*/
|
|
257
272
|
export type Grant = GrantPlace & {
|
|
258
273
|
role: Role;
|
|
@@ -273,8 +288,13 @@ export type Access = {
|
|
|
273
288
|
/** Advanced by removal, even if the app cannot commit its credential revocations. */
|
|
274
289
|
generation: number;
|
|
275
290
|
isRootAdmin: boolean;
|
|
276
|
-
/**
|
|
277
|
-
|
|
291
|
+
/**
|
|
292
|
+
* Their instance role and its scope, while active: `member` everywhere
|
|
293
|
+
* for service accounts and anyone not active, `owner` everywhere for
|
|
294
|
+
* root admins. Scopes name projects by id.
|
|
295
|
+
*/
|
|
296
|
+
role: InstanceRole;
|
|
297
|
+
scope: Scope;
|
|
278
298
|
/** Live grants only, and none unless active. */
|
|
279
299
|
grants: Grant[];
|
|
280
300
|
/** When the status last changed, and who changed it; null for root admins and strangers. */
|
|
@@ -284,36 +304,50 @@ export type Access = {
|
|
|
284
304
|
|
|
285
305
|
/**
|
|
286
306
|
* One place: a role to hold there, with an optional end, or null for none.
|
|
287
|
-
* An environment's place names its project too
|
|
288
|
-
* is null.
|
|
307
|
+
* An environment's place names its project too.
|
|
289
308
|
*/
|
|
290
|
-
export type GrantChange =
|
|
291
|
-
environmentSlug?: string | null;
|
|
309
|
+
export type GrantChange = GrantPlace & {
|
|
292
310
|
role: Role | null;
|
|
293
311
|
expiresAt: string | null;
|
|
294
312
|
};
|
|
295
313
|
|
|
296
314
|
export type AccessChange = 'created' | 'updated' | 'revoked' | 'unchanged';
|
|
297
315
|
|
|
298
|
-
export type SetAccessInput = Correlation & {
|
|
316
|
+
export type SetAccessInput = Correlation & Through & {
|
|
299
317
|
actor: string;
|
|
300
318
|
principal: string;
|
|
301
319
|
changes: GrantChange[];
|
|
302
320
|
};
|
|
303
321
|
|
|
304
|
-
export type AdmitInput = Correlation & {
|
|
322
|
+
export type AdmitInput = Correlation & Through & {
|
|
305
323
|
actor: string;
|
|
306
324
|
principal: string;
|
|
307
|
-
/**
|
|
308
|
-
|
|
325
|
+
/**
|
|
326
|
+
* A person's instance role, and where it applies (everywhere, left out).
|
|
327
|
+
* Left out, an existing member keeps theirs and a new one is a `member`.
|
|
328
|
+
* Changing it takes someone who runs the instance, about someone else.
|
|
329
|
+
*/
|
|
330
|
+
role?: InstanceRole;
|
|
331
|
+
scope?: Scope;
|
|
309
332
|
};
|
|
310
333
|
|
|
311
|
-
export type RemoveInput = Correlation & {
|
|
334
|
+
export type RemoveInput = Correlation & Through & {
|
|
312
335
|
actor: string;
|
|
313
336
|
principal: string;
|
|
314
337
|
|
|
315
338
|
};
|
|
316
339
|
|
|
340
|
+
/** What the instance's settings hold. */
|
|
341
|
+
export type Settings = {
|
|
342
|
+
/** Where people set up service accounts themselves (`ServiceSetting` in @coffre/core/access), projects by id. */
|
|
343
|
+
serviceAccounts: ServiceSetting;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
export type SetSettingsInput = Correlation & Through & {
|
|
347
|
+
actor: string;
|
|
348
|
+
settings: Settings;
|
|
349
|
+
};
|
|
350
|
+
|
|
317
351
|
/**
|
|
318
352
|
* A prefix of the log the vault signed: every entry up to `seq`, the last
|
|
319
353
|
* of which has `hash`, which pins every byte before it. Kept as the vault's
|