@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
package/dist/workloads.js
CHANGED
|
@@ -4,15 +4,18 @@
|
|
|
4
4
|
* their platform signs for them (docs/design/oidc.md).
|
|
5
5
|
*
|
|
6
6
|
* A binding names a profile, an issuer, and claims a token must carry, each
|
|
7
|
-
* a top-level string matched exactly. The
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* a top-level string matched exactly. The event that started the run may
|
|
8
|
+
* be any of several: a list. The profile sets the minimum: the server
|
|
9
|
+
* refuses a binding that lacks a claim its profile requires, from the UI,
|
|
10
|
+
* the CLI or the API alike. Say a service is bound to `ci.yml` of
|
|
11
|
+
* `acme/api`, pushed or dispatched on `main`, or run for a pull request
|
|
12
|
+
* into `main`:
|
|
11
13
|
*
|
|
12
14
|
* { profile: 'github', issuer: 'https://token.actions.githubusercontent.com',
|
|
13
15
|
* claims: { repository_owner_id: '9919', repository_id: '41532',
|
|
14
|
-
* workflow_ref: 'acme/api/.github/workflows/
|
|
15
|
-
* ref: 'refs/heads/main',
|
|
16
|
+
* workflow_ref: 'acme/api/.github/workflows/ci.yml@refs/heads/main',
|
|
17
|
+
* ref: 'refs/heads/main',
|
|
18
|
+
* event_name: ['pull_request', 'push', 'workflow_dispatch'] } }
|
|
16
19
|
*/
|
|
17
20
|
const GITHUB_ISSUER = "https://token.actions.githubusercontent.com";
|
|
18
21
|
const GITLAB_ISSUER = "https://gitlab.com";
|
|
@@ -57,23 +60,62 @@ const REQUIRED_CLAIMS = {
|
|
|
57
60
|
custom: ["sub"]
|
|
58
61
|
};
|
|
59
62
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
63
|
+
* The events a GitHub binding may trust. The first four are started by
|
|
64
|
+
* someone with write access, at the ref the binding names. The last two
|
|
65
|
+
* can run code nobody reviewed, so the owner opts in to each:
|
|
66
|
+
*
|
|
67
|
+
* - `pull_request` runs the pull request's own code and workflow file, at
|
|
68
|
+
* `refs/pull/<n>/merge`; a binding matches it by the branch it merges
|
|
69
|
+
* into. Forks get no ID token, unless the repository sends write tokens
|
|
70
|
+
* to their pull requests (a setting of private repositories), so this
|
|
71
|
+
* trusts whoever can push a branch to the repository.
|
|
72
|
+
* - `workflow_run` runs the default branch's workflow, but a fork's pull
|
|
73
|
+
* request can start it: the run is as safe as what it does with the
|
|
74
|
+
* code and artifacts of the run that started it.
|
|
75
|
+
*
|
|
76
|
+
* `pull_request_target` and the rest are refused, even when their ref
|
|
77
|
+
* happens to match: they exist to act on anyone's pull request, with the
|
|
78
|
+
* base repository's tokens.
|
|
64
79
|
*/
|
|
65
80
|
const GITHUB_EVENTS = [
|
|
66
81
|
"push",
|
|
67
82
|
"workflow_dispatch",
|
|
68
83
|
"schedule",
|
|
69
|
-
"release"
|
|
84
|
+
"release",
|
|
85
|
+
"pull_request",
|
|
86
|
+
"workflow_run"
|
|
70
87
|
];
|
|
88
|
+
/**
|
|
89
|
+
* What trusting each of the last two events exposes, as the UI, the CLI and
|
|
90
|
+
* the approval page say it when one is chosen.
|
|
91
|
+
*/
|
|
92
|
+
const EVENT_EXPOSURE = {
|
|
93
|
+
pull_request: "Anyone who can push a branch to the repository can open a pull request and run their own code as this service account, unreviewed. Pull requests from forks get no ID token, unless the repository sends them write tokens.",
|
|
94
|
+
workflow_run: "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."
|
|
95
|
+
};
|
|
71
96
|
/** Pipelines that run the project's own code, at its own ref: never a merge request's. */
|
|
72
97
|
const GITLAB_PIPELINE_SOURCES = [
|
|
73
98
|
"push",
|
|
74
99
|
"web",
|
|
75
100
|
"schedule"
|
|
76
101
|
];
|
|
102
|
+
/** The claim that says what started the run, which a binding may name several values of, and why the others are refused. */
|
|
103
|
+
const GITHUB_EVENT = {
|
|
104
|
+
name: "event_name",
|
|
105
|
+
values: GITHUB_EVENTS,
|
|
106
|
+
why: "other events can run a stranger's code"
|
|
107
|
+
};
|
|
108
|
+
const EVENT_CLAIM = {
|
|
109
|
+
github: GITHUB_EVENT,
|
|
110
|
+
"github-reusable": GITHUB_EVENT,
|
|
111
|
+
"github-reusable-organization": GITHUB_EVENT,
|
|
112
|
+
gitlab: {
|
|
113
|
+
name: "pipeline_source",
|
|
114
|
+
values: GITLAB_PIPELINE_SOURCES,
|
|
115
|
+
why: "merge-request pipelines name another project"
|
|
116
|
+
},
|
|
117
|
+
custom: null
|
|
118
|
+
};
|
|
77
119
|
/** At most this many live bindings per service, and claims per binding. */
|
|
78
120
|
const MAX_BINDINGS = 16;
|
|
79
121
|
const MAX_CLAIMS = 16;
|
|
@@ -133,11 +175,19 @@ function checkBinding(input, options = {}) {
|
|
|
133
175
|
if (forced !== null && !forced.includes(profile)) throw new BindingInvalid(`${issuer} signs only for the ${forced[0]} profile${forced.length > 1 ? "s" : ""}`);
|
|
134
176
|
const entries = Object.entries(input.claims);
|
|
135
177
|
if (entries.length > 16) throw new BindingInvalid(`a binding names at most 16 claims`);
|
|
178
|
+
const event = EVENT_CLAIM[profile];
|
|
136
179
|
const claims = {};
|
|
137
180
|
for (const [name, value] of entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
138
181
|
if (!CLAIM_NAME.test(name)) throw new BindingInvalid(`"${name}" is not a claim name a binding can match`);
|
|
139
182
|
if (RESERVED.has(name)) throw new BindingInvalid(`a binding never names "${name}": the exchange checks it`);
|
|
140
|
-
if (
|
|
183
|
+
if (name === event?.name) {
|
|
184
|
+
const given = Array.isArray(value) ? value : [value];
|
|
185
|
+
if (given.length === 0 || given.some((each) => !event.values.includes(each))) throw new BindingInvalid(`${name} must be one or more of ${event.values.join(", ")}: ${event.why}`);
|
|
186
|
+
const values = event.values.filter((each) => given.includes(each));
|
|
187
|
+
claims[name] = values.length === 1 ? values[0] : values;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (typeof value !== "string" || value.length === 0) throw new BindingInvalid(`claim ${name} must be a nonempty string${Array.isArray(value) && event !== null ? `: only ${event.name} lists several` : ""}`);
|
|
141
191
|
if (new TextEncoder().encode(value).length > 256) throw new BindingInvalid(`claim ${name} is longer than 256 bytes`);
|
|
142
192
|
claims[name] = value;
|
|
143
193
|
}
|
|
@@ -152,27 +202,80 @@ function checkBinding(input, options = {}) {
|
|
|
152
202
|
claims
|
|
153
203
|
};
|
|
154
204
|
}
|
|
205
|
+
/** A claim's values, one or several. */
|
|
206
|
+
function claimValues(value) {
|
|
207
|
+
return value === void 0 ? [] : typeof value === "string" ? [value] : value;
|
|
208
|
+
}
|
|
209
|
+
/** 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. */
|
|
210
|
+
const EVENT_REFS = {
|
|
211
|
+
release: {
|
|
212
|
+
prefix: "refs/tags/",
|
|
213
|
+
why: "a release runs at its tag: refs/tags/<tag>"
|
|
214
|
+
},
|
|
215
|
+
schedule: {
|
|
216
|
+
prefix: "refs/heads/",
|
|
217
|
+
why: "a schedule runs on the default branch: refs/heads/<branch>"
|
|
218
|
+
},
|
|
219
|
+
workflow_run: {
|
|
220
|
+
prefix: "refs/heads/",
|
|
221
|
+
why: "a workflow_run runs on the default branch: refs/heads/<branch>"
|
|
222
|
+
},
|
|
223
|
+
pull_request: {
|
|
224
|
+
prefix: "refs/heads/",
|
|
225
|
+
why: "a pull request matches by the branch it merges into: refs/heads/<branch>"
|
|
226
|
+
}
|
|
227
|
+
};
|
|
155
228
|
function checkGitHub(profile, claims) {
|
|
156
|
-
for (const id of ["repository_owner_id", "repository_id"])
|
|
157
|
-
|
|
158
|
-
|
|
229
|
+
for (const id of ["repository_owner_id", "repository_id"]) {
|
|
230
|
+
const value = claims[id];
|
|
231
|
+
if (value !== void 0 && !DIGITS.test(value)) throw new BindingInvalid(`${id} is GitHub's numeric ID, not a name`);
|
|
232
|
+
}
|
|
233
|
+
const events = claimValues(claims.event_name);
|
|
159
234
|
const ref = claims.ref;
|
|
160
235
|
if (!REF.test(ref)) throw new BindingInvalid("ref is a full ref: refs/heads/<branch> or refs/tags/<tag>");
|
|
161
|
-
|
|
162
|
-
|
|
236
|
+
for (const event of events) {
|
|
237
|
+
const rule = EVENT_REFS[event];
|
|
238
|
+
if (rule !== void 0 && !ref.startsWith(rule.prefix)) throw new BindingInvalid(rule.why);
|
|
239
|
+
}
|
|
240
|
+
const workflow = claims.workflow_ref;
|
|
163
241
|
if (profile === "github") {
|
|
164
|
-
if (!WORKFLOW.test(
|
|
242
|
+
if (!WORKFLOW.test(workflow) || !workflow.endsWith(`@${ref}`)) throw new BindingInvalid("workflow_ref is <owner>/<repository>/.github/workflows/<file>@<ref>, at the binding's ref");
|
|
165
243
|
return;
|
|
166
244
|
}
|
|
167
245
|
if (!WORKFLOW.test(claims.job_workflow_ref)) throw new BindingInvalid("job_workflow_ref is <owner>/<repository>/.github/workflows/<file>@<ref>");
|
|
168
246
|
if (!SHA.test(claims.job_workflow_sha)) throw new BindingInvalid("job_workflow_sha is the called workflow's full commit SHA");
|
|
169
|
-
if (
|
|
247
|
+
if (workflow !== void 0 && !WORKFLOW.test(workflow)) throw new BindingInvalid("workflow_ref, which narrows the binding to one caller, is <owner>/<repository>/.github/workflows/<file>@<ref>");
|
|
170
248
|
}
|
|
171
249
|
function checkGitLab(claims) {
|
|
172
250
|
for (const id of ["namespace_id", "project_id"]) if (!DIGITS.test(claims[id])) throw new BindingInvalid(`${id} is GitLab's numeric ID, not a path`);
|
|
173
251
|
if (claims.ref_type !== "branch" && claims.ref_type !== "tag") throw new BindingInvalid("ref_type is branch or tag");
|
|
174
252
|
if (claims.ref.startsWith("refs/")) throw new BindingInvalid("GitLab's ref is the branch or tag name, without refs/");
|
|
175
|
-
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The claims of a verified token that differ from what a binding expects,
|
|
256
|
+
* by name: none when it matches. A claim matches when the token carries its
|
|
257
|
+
* value, or one of its values. A GitHub `pull_request` run, at
|
|
258
|
+
* `refs/pull/<n>/merge`, counts as a run at the branch it merges into: its
|
|
259
|
+
* `ref`, and the ref its `workflow_ref` ends in, are read as
|
|
260
|
+
* `refs/heads/<base_ref>`.
|
|
261
|
+
*/
|
|
262
|
+
function differingClaims(profile, expected, token) {
|
|
263
|
+
const claims = profile.startsWith("github") ? atBaseBranch(token) : token;
|
|
264
|
+
return Object.entries(expected).filter(([name, value]) => {
|
|
265
|
+
const actual = claims[name];
|
|
266
|
+
return typeof actual !== "string" || !claimValues(value).includes(actual);
|
|
267
|
+
}).map(([name]) => name);
|
|
268
|
+
}
|
|
269
|
+
function atBaseBranch(claims) {
|
|
270
|
+
const { event_name: event, ref, base_ref: base, workflow_ref: workflow } = claims;
|
|
271
|
+
if (event !== "pull_request" || typeof ref !== "string" || typeof base !== "string" || base === "") return claims;
|
|
272
|
+
const at = `refs/heads/${base}`;
|
|
273
|
+
const file = typeof workflow === "string" && workflow.endsWith(`@${ref}`) ? `${workflow.slice(0, -ref.length)}${at}` : workflow;
|
|
274
|
+
return {
|
|
275
|
+
...claims,
|
|
276
|
+
ref: at,
|
|
277
|
+
workflow_ref: file
|
|
278
|
+
};
|
|
176
279
|
}
|
|
177
280
|
/** The claims as the MAC and the database hold them: JSON, sorted by name. */
|
|
178
281
|
function canonicalClaims(claims) {
|
|
@@ -210,11 +313,23 @@ function checkFetchUrl(value, what, options) {
|
|
|
210
313
|
function fullRef(kind, name) {
|
|
211
314
|
return `refs/${kind === "branch" ? "heads" : "tags"}/${name}`;
|
|
212
315
|
}
|
|
316
|
+
/** The GitHub events a binding at a branch, or at a tag, may name. */
|
|
317
|
+
function githubEventsAt(kind) {
|
|
318
|
+
return GITHUB_EVENTS.filter((event) => EVENT_REFS[event] === void 0 || EVENT_REFS[event].prefix === fullRef(kind, ""));
|
|
319
|
+
}
|
|
213
320
|
/** A workflow file at a ref, as GitHub's tokens name it: `acme/api/.github/workflows/deploy.yml@refs/heads/main`. */
|
|
214
321
|
function workflowRef(repository, file, ref) {
|
|
215
322
|
return `${repository}/.github/workflows/${file.replace(/^\.github\/workflows\//, "")}@${ref}`;
|
|
216
323
|
}
|
|
217
|
-
|
|
324
|
+
function started(input, one, many) {
|
|
325
|
+
const single = input[one];
|
|
326
|
+
const list = input[many];
|
|
327
|
+
if (single !== void 0 && list !== void 0) throw new BindingInvalid(`name ${one} or ${many}, not both`);
|
|
328
|
+
if (single !== void 0) return single;
|
|
329
|
+
if (list === void 0) throw new BindingInvalid(`name ${one} or ${many}`);
|
|
330
|
+
return list;
|
|
331
|
+
}
|
|
332
|
+
/** A workflow of the repository, run at `ref` by any of `events`; a pull request, by the branch it merges into. */
|
|
218
333
|
function githubWorkflow(input) {
|
|
219
334
|
return {
|
|
220
335
|
profile: "github",
|
|
@@ -223,7 +338,7 @@ function githubWorkflow(input) {
|
|
|
223
338
|
repository_id: input.repositoryId,
|
|
224
339
|
workflow_ref: workflowRef(input.repository, input.workflow, input.ref),
|
|
225
340
|
ref: input.ref,
|
|
226
|
-
event_name: input
|
|
341
|
+
event_name: started(input, "event", "events")
|
|
227
342
|
}
|
|
228
343
|
};
|
|
229
344
|
}
|
|
@@ -237,7 +352,7 @@ function githubReusable(input) {
|
|
|
237
352
|
repository_owner_id: input.ownerId,
|
|
238
353
|
...input.repositoryId === null ? {} : { repository_id: input.repositoryId },
|
|
239
354
|
ref: input.ref,
|
|
240
|
-
event_name: input
|
|
355
|
+
event_name: started(input, "event", "events"),
|
|
241
356
|
job_workflow_ref: input.called,
|
|
242
357
|
job_workflow_sha: input.sha
|
|
243
358
|
};
|
|
@@ -247,7 +362,7 @@ function githubReusable(input) {
|
|
|
247
362
|
claims
|
|
248
363
|
};
|
|
249
364
|
}
|
|
250
|
-
/** A GitLab project's pipelines from `
|
|
365
|
+
/** A GitLab project's pipelines from any of `sources`, at a branch or tag. */
|
|
251
366
|
function gitlabProject(input) {
|
|
252
367
|
return {
|
|
253
368
|
profile: "gitlab",
|
|
@@ -256,9 +371,9 @@ function gitlabProject(input) {
|
|
|
256
371
|
project_id: input.projectId,
|
|
257
372
|
ref_type: input.refType,
|
|
258
373
|
ref: input.ref,
|
|
259
|
-
pipeline_source: input
|
|
374
|
+
pipeline_source: started(input, "source", "sources")
|
|
260
375
|
}
|
|
261
376
|
};
|
|
262
377
|
}
|
|
263
378
|
//#endregion
|
|
264
|
-
export { BindingInvalid, GITHUB_EVENTS, GITHUB_ISSUER, GITLAB_ISSUER, GITLAB_PIPELINE_SOURCES, MAX_BINDINGS, MAX_CLAIMS, MAX_CLAIM_BYTES, REQUIRED_CLAIMS, WORKLOAD_PROFILES, canonicalClaims, checkBinding, checkFetchUrl, defaultIssuer, fullRef, githubReusable, githubWorkflow, gitlabProject, workflowRef };
|
|
379
|
+
export { BindingInvalid, EVENT_EXPOSURE, GITHUB_EVENTS, GITHUB_ISSUER, GITLAB_ISSUER, GITLAB_PIPELINE_SOURCES, MAX_BINDINGS, MAX_CLAIMS, MAX_CLAIM_BYTES, REQUIRED_CLAIMS, WORKLOAD_PROFILES, canonicalClaims, checkBinding, checkFetchUrl, claimValues, defaultIssuer, differingClaims, fullRef, githubEventsAt, githubReusable, githubWorkflow, gitlabProject, workflowRef };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "What coffre's packages share: roles, envelopes, identity, the audit chain and the vault's contract.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@types/node": "26.1.1",
|
|
85
85
|
"tsdown": "0.23.0",
|
|
86
86
|
"typescript": "5.9.3",
|
|
87
|
-
"@coffre/conformance": "0.
|
|
87
|
+
"@coffre/conformance": "0.5.0"
|
|
88
88
|
},
|
|
89
89
|
"scripts": {
|
|
90
90
|
"build": "tsdown",
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {
|
|
2
|
+
admits,
|
|
3
|
+
allows,
|
|
4
|
+
assignableToEnvironment,
|
|
5
|
+
EVERYWHERE,
|
|
6
|
+
INSTANCE_ROLES,
|
|
7
|
+
instanceRoleGrants,
|
|
8
|
+
normalScope,
|
|
9
|
+
roleGrants,
|
|
10
|
+
ROLES,
|
|
11
|
+
type GrantPlace,
|
|
12
|
+
type InstanceRole,
|
|
13
|
+
type Place,
|
|
14
|
+
type Role,
|
|
15
|
+
type Scope,
|
|
16
|
+
} from './access.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How the vault replaces the grants on every project of 0.4 (`*`, and `*`
|
|
20
|
+
* on one environment slug) with what 0.5 has instead: a person's instance
|
|
21
|
+
* role and its scope, and project grants. Pure, so the vault converts with
|
|
22
|
+
* it and anyone can read what it does to each case.
|
|
23
|
+
*
|
|
24
|
+
* Nobody ends up holding more than before, anywhere, at any time. Where the
|
|
25
|
+
* new model cannot say exactly what a grant said, it says less:
|
|
26
|
+
*
|
|
27
|
+
* developer on * Developer, everywhere
|
|
28
|
+
* developer on dev in each Developer, environments only dev
|
|
29
|
+
* auditor on * Auditor, everywhere
|
|
30
|
+
* viewer on * viewer on each project there is now
|
|
31
|
+
* maintainer on * Developer, everywhere, and maintainer on each project there is now
|
|
32
|
+
* owner on *, as an owner Owner (an owner of 0.4 is an Admin)
|
|
33
|
+
* any, for a service the same role on each project, or each environment of the slug, there is now
|
|
34
|
+
*
|
|
35
|
+
* What a grant leaves to project grants reaches the projects there are
|
|
36
|
+
* now, not those made later (`narrowed`, `later-projects`). A place holds
|
|
37
|
+
* one grant: where they hold one already that neither it nor the new one
|
|
38
|
+
* holds all of, for as long, a project's goes on to each of its
|
|
39
|
+
* environments instead, the one that does not read the log first
|
|
40
|
+
* (`environments`: not environments made later, nor, for an auditor, the
|
|
41
|
+
* project's own entries). Only where neither can, or on an environment, is
|
|
42
|
+
* something lost, the one that reads kept (`lost`):
|
|
43
|
+
*
|
|
44
|
+
* viewer on *, auditor on billing auditor on billing, viewer on each of billing's environments
|
|
45
|
+
* viewer on *, developer on billing developer on billing
|
|
46
|
+
* maintainer on *, access-manager on billing, as a service
|
|
47
|
+
* maintainer on billing: access-manager there is lost
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/** A grant on every project of 0.4: on all of each (`environmentSlug` null), or on the environment of one slug in each. */
|
|
51
|
+
export type EveryProjectGrant = { environmentSlug: string | null; role: Role; expiresAt: number | null };
|
|
52
|
+
|
|
53
|
+
export type ConversionInput = {
|
|
54
|
+
/** A person may hold an instance role; a service account holds grants only. */
|
|
55
|
+
person: boolean;
|
|
56
|
+
/** Their instance role before: `admin` for an owner of 0.4, `member` for anyone else. */
|
|
57
|
+
role: InstanceRole;
|
|
58
|
+
/** Their live grants on every project. */
|
|
59
|
+
everyProject: readonly EveryProjectGrant[];
|
|
60
|
+
/** Their live grants on projects and environments. */
|
|
61
|
+
grants: readonly (GrantPlace & { role: Role; expiresAt: number | null })[];
|
|
62
|
+
/** The projects there are now, deleted ones left out, with their environments. */
|
|
63
|
+
projects: readonly { id: string; environments: readonly { id: string; slug: string }[] }[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** A project grant the conversion gives: new, or in place of the one they held there (`replaces`). */
|
|
67
|
+
export type ConvertedGrant = { projectId: string; environmentId: string | null; role: Role; expiresAt: number | null; replaces: Role | null };
|
|
68
|
+
|
|
69
|
+
/** Where the conversion gives less than a grant on every project did, each naming the grant it comes of. */
|
|
70
|
+
export type Narrowed =
|
|
71
|
+
/** It reaches the projects there are now as project grants, and none made later. */
|
|
72
|
+
| { kind: 'later-projects'; grant: EveryProjectGrant }
|
|
73
|
+
/**
|
|
74
|
+
* `role` on this project, the grant's or the one they held there, is on
|
|
75
|
+
* each of its environments instead: not on environments made later, nor
|
|
76
|
+
* on the project's own log entries.
|
|
77
|
+
*/
|
|
78
|
+
| { kind: 'environments'; grant: EveryProjectGrant; projectId: string; role: Role }
|
|
79
|
+
/** No grant here holds both: `kept` stays (until `keptUntil`), and what `lost` held beyond it is gone. */
|
|
80
|
+
| { kind: 'lost'; grant: EveryProjectGrant; projectId: string; environmentId: string | null; lost: Role; kept: Role; keptUntil: number | null };
|
|
81
|
+
|
|
82
|
+
export type Conversion = { role: InstanceRole; scope: Scope; grants: ConvertedGrant[]; narrowed: Narrowed[] };
|
|
83
|
+
|
|
84
|
+
type Held = GrantPlace & { role: Role; expiresAt: number | null };
|
|
85
|
+
type Wanted = { role: Role; expiresAt: number | null };
|
|
86
|
+
type At = { project: ConversionInput['projects'][number]; environment: { id: string; slug: string } | null };
|
|
87
|
+
|
|
88
|
+
export function convertEveryProjectGrants(input: ConversionInput): Conversion {
|
|
89
|
+
const { role, scope } = input.person ? instanceRoleFor(input.role, input.everyProject) : { role: input.role, scope: EVERYWHERE };
|
|
90
|
+
const before = new Map(input.grants.map((grant) => [placeKey(grant), grant]));
|
|
91
|
+
const held = new Map<string, Held>(before);
|
|
92
|
+
const given = new Map<string, ConvertedGrant>();
|
|
93
|
+
const narrowed: Narrowed[] = [];
|
|
94
|
+
|
|
95
|
+
const give = (at: At, wanted: Wanted) => {
|
|
96
|
+
const place = { projectId: at.project.id, environmentId: at.environment?.id ?? null, role: wanted.role, expiresAt: wanted.expiresAt };
|
|
97
|
+
held.set(placeKey(place), place);
|
|
98
|
+
given.set(placeKey(place), { ...place, replaces: before.get(placeKey(place))?.role ?? null });
|
|
99
|
+
};
|
|
100
|
+
// What `grant` held at `at`, as `wanted`, added to what they hold there already.
|
|
101
|
+
const settle = (grant: EveryProjectGrant, at: At, wanted: Wanted) => {
|
|
102
|
+
const place: Place = at.environment === null
|
|
103
|
+
? { projectId: at.project.id }
|
|
104
|
+
: { projectId: at.project.id, environmentId: at.environment.id, environmentSlug: at.environment.slug };
|
|
105
|
+
if (holdsAlready(role, scope, [...held.values()], wanted, place)) return;
|
|
106
|
+
const existing = held.get(at.environment?.id ?? at.project.id);
|
|
107
|
+
if (existing === undefined || (within(existing.role, wanted.role) && outlasts(wanted.expiresAt, existing.expiresAt))) {
|
|
108
|
+
give(at, wanted);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// One place, one grant, and neither holds the other for as long: on a
|
|
112
|
+
// project, one of them goes on to its environments, as far as it reached
|
|
113
|
+
// but for environments made later; the one that reads no log first, as
|
|
114
|
+
// an environment's grant reads none of its project's entries.
|
|
115
|
+
if (at.environment === null) {
|
|
116
|
+
const down = [wanted, existing]
|
|
117
|
+
.filter((candidate) => assignableToEnvironment(candidate.role))
|
|
118
|
+
.sort((a, b) => Number(roleGrants(a.role, 'audit.read')) - Number(roleGrants(b.role, 'audit.read')))[0];
|
|
119
|
+
if (down !== undefined) {
|
|
120
|
+
if (down === existing) give(at, wanted);
|
|
121
|
+
narrowed.push({ kind: 'environments', grant, projectId: at.project.id, role: down.role });
|
|
122
|
+
for (const environment of at.project.environments) settle(grant, { project: at.project, environment }, { role: down.role, expiresAt: down.expiresAt });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Nothing holds both: the one that reads stays, else the one they had.
|
|
127
|
+
const keep = !roleGrants(wanted.role, 'secret.read') || roleGrants(existing.role, 'secret.read') ? existing : wanted;
|
|
128
|
+
const lose = keep === existing ? wanted : existing;
|
|
129
|
+
if (keep === wanted) give(at, wanted);
|
|
130
|
+
narrowed.push({ kind: 'lost', grant, projectId: at.project.id, environmentId: at.environment?.id ?? null, lost: lose.role, kept: keep.role, keptUntil: keep.expiresAt });
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
for (const grant of input.everyProject) {
|
|
134
|
+
if (byRole(role, scope, grant)) continue;
|
|
135
|
+
narrowed.push({ kind: 'later-projects', grant });
|
|
136
|
+
for (const project of input.projects) {
|
|
137
|
+
const reached = grant.environmentSlug === null
|
|
138
|
+
? [null]
|
|
139
|
+
: project.environments.filter((environment) => environment.slug === grant.environmentSlug);
|
|
140
|
+
for (const environment of reached) settle(grant, { project, environment }, grant);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { role, scope, grants: [...given.values()], narrowed };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The strongest instance role a person's lasting grants on every project
|
|
148
|
+
* add up to, never more than they held: an owner of 0.4 (an Admin) who
|
|
149
|
+
* read, wrote and retired secrets everywhere is an Owner; anyone else is a
|
|
150
|
+
* Developer or an Auditor where those grants were on all of every project,
|
|
151
|
+
* or on some environment slugs (Developer first), or stays as they were.
|
|
152
|
+
*/
|
|
153
|
+
function instanceRoleFor(previous: InstanceRole, everyProject: readonly EveryProjectGrant[]): { role: InstanceRole; scope: Scope } {
|
|
154
|
+
const lasting = everyProject.filter((grant) => grant.expiresAt === null);
|
|
155
|
+
const all = lasting.find((grant) => grant.environmentSlug === null)?.role;
|
|
156
|
+
if (previous === 'admin') {
|
|
157
|
+
const adds = all === undefined ? [] : ROLES[all].permissions;
|
|
158
|
+
const owner = INSTANCE_ROLES.owner.permissions.every((permission) => instanceRoleGrants('admin', permission) || (adds as readonly string[]).includes(permission));
|
|
159
|
+
return { role: owner ? 'owner' : 'admin', scope: EVERYWHERE };
|
|
160
|
+
}
|
|
161
|
+
if (previous !== 'member') return { role: previous, scope: EVERYWHERE };
|
|
162
|
+
const fits = (role: InstanceRole, held: Role) => INSTANCE_ROLES[role].permissions.every((permission) => roleGrants(held, permission));
|
|
163
|
+
const candidates = ['developer', 'auditor'] as const;
|
|
164
|
+
for (const role of candidates) if (all !== undefined && fits(role, all)) return { role, scope: EVERYWHERE };
|
|
165
|
+
for (const role of candidates) {
|
|
166
|
+
const only = lasting.filter((grant) => grant.environmentSlug !== null && fits(role, grant.role)).map((grant) => grant.environmentSlug!);
|
|
167
|
+
if (only.length > 0) return { role, scope: normalScope({ projects: 'all', environments: { only } }) };
|
|
168
|
+
}
|
|
169
|
+
return { role: 'member', scope: EVERYWHERE };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Whether the instance role, in its scope, holds all a grant on every project did, wherever it did, for good. */
|
|
173
|
+
function byRole(role: InstanceRole, scope: Scope, grant: EveryProjectGrant): boolean {
|
|
174
|
+
const covered = scope.projects === 'all' && (grant.environmentSlug === null ? scope.environments === 'all' : admits(scope.environments, grant.environmentSlug));
|
|
175
|
+
return covered && ROLES[grant.role].permissions.every((permission) => instanceRoleGrants(role, permission));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Whether every permission of the grant's role is held at `at` already, by the role or by grants lasting as long. */
|
|
179
|
+
function holdsAlready(
|
|
180
|
+
role: InstanceRole,
|
|
181
|
+
scope: Scope,
|
|
182
|
+
held: readonly Held[],
|
|
183
|
+
grant: Wanted,
|
|
184
|
+
at: Place,
|
|
185
|
+
): boolean {
|
|
186
|
+
const lasting = held.filter((other) => outlasts(other.expiresAt, grant.expiresAt));
|
|
187
|
+
return ROLES[grant.role].permissions.every((permission) => allows({ isRootAdmin: false, role, scope, grants: lasting }, permission, at));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Whether `role` holds nothing `wider` does not. */
|
|
191
|
+
function within(role: Role, wider: Role): boolean {
|
|
192
|
+
return ROLES[role].permissions.every((permission) => roleGrants(wider, permission));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Whether an end of `a` comes no sooner than one of `b`; null is never. */
|
|
196
|
+
function outlasts(a: number | null, b: number | null): boolean {
|
|
197
|
+
return a === null || (b !== null && a >= b);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function placeKey(place: GrantPlace): string {
|
|
201
|
+
return place.environmentId ?? place.projectId!;
|
|
202
|
+
}
|
package/src/access-fault.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { GrantPlace } from './access.ts';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Why the vault's members and grants do not follow from its log, as facts
|
|
@@ -25,12 +25,13 @@ export type AccessFault =
|
|
|
25
25
|
*/
|
|
26
26
|
| { kind: 'tampered-member'; principal: string; why: 'mac' | 'stale' };
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
/** A grant a fault names; `environmentSlug` only on a grant on every project of 0.4, which names no project. */
|
|
29
|
+
export type FaultGrant = GrantPlace & { environmentSlug?: string | null; principal: string; role: string };
|
|
29
30
|
|
|
30
31
|
/** How to name a principal (`user:ada@…`) and a place; ids by default. */
|
|
31
32
|
export type FaultNames = {
|
|
32
33
|
principal?: (principal: string) => string;
|
|
33
|
-
/** Asked only of a project's or an environment's place: a grant on every project is named by its path. */
|
|
34
|
+
/** Asked only of a project's or an environment's place: a grant on every project of 0.4 is named by its path. */
|
|
34
35
|
place?: (projectId: string, environmentId: string | null) => string;
|
|
35
36
|
};
|
|
36
37
|
|
|
@@ -43,7 +44,8 @@ export function describeAccessFault(fault: AccessFault, names: FaultNames = {}):
|
|
|
43
44
|
environmentId === null ? projectId : `${projectId}/${environmentId}`);
|
|
44
45
|
const grant = (held: FaultGrant) => {
|
|
45
46
|
const { principal, projectId, environmentId, environmentSlug, role } = held;
|
|
46
|
-
const
|
|
47
|
+
const everyProject = projectId === null && environmentId === null;
|
|
48
|
+
const place = everyProject ? (environmentSlug == null ? '*' : `*/${environmentSlug}`) : where(projectId ?? 'no project', environmentId);
|
|
47
49
|
return `${who(principal)} as ${role} on ${place}`;
|
|
48
50
|
};
|
|
49
51
|
switch (fault.kind) {
|