@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/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 profile sets the minimum: the
8
- * server refuses a binding that lacks a claim its profile requires, from the
9
- * UI, the CLI or the API alike. Say a service is bound to `deploy.yml`,
10
- * pushed to `main` of `acme/api`:
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/deploy.yml@refs/heads/main',
15
- * ref: 'refs/heads/main', event_name: 'push' } }
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
- * Events triggered by someone with write access, at a ref the binding names
61
- * exactly. `pull_request`, `pull_request_target`, `workflow_run` and the
62
- * rest are refused, even when their ref happens to match: their runs can
63
- * carry a stranger's code.
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 (typeof value !== "string" || value.length === 0) throw new BindingInvalid(`claim ${name} must be a nonempty string`);
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"]) if (claims[id] !== void 0 && !DIGITS.test(claims[id])) throw new BindingInvalid(`${id} is GitHub's numeric ID, not a name`);
157
- const event = claims.event_name;
158
- if (!GITHUB_EVENTS.includes(event)) throw new BindingInvalid(`event_name must be one of ${GITHUB_EVENTS.join(", ")}: other events can run a stranger's code`);
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
- if (event === "release" && !ref.startsWith("refs/tags/")) throw new BindingInvalid("a release runs at its tag: refs/tags/<tag>");
162
- if (event === "schedule" && !ref.startsWith("refs/heads/")) throw new BindingInvalid("a schedule runs on the default branch: refs/heads/<branch>");
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(claims.workflow_ref) || !claims.workflow_ref.endsWith(`@${ref}`)) throw new BindingInvalid("workflow_ref is <owner>/<repository>/.github/workflows/<file>@<ref>, at the binding's ref");
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 (claims.workflow_ref !== void 0 && !WORKFLOW.test(claims.workflow_ref)) throw new BindingInvalid("workflow_ref, which narrows the binding to one caller, is <owner>/<repository>/.github/workflows/<file>@<ref>");
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
- if (!GITLAB_PIPELINE_SOURCES.includes(claims.pipeline_source)) throw new BindingInvalid(`pipeline_source must be one of ${GITLAB_PIPELINE_SOURCES.join(", ")}: merge-request pipelines name another project`);
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
- /** A workflow of the repository, run by `event` at `ref`. */
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.event
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.event,
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 `source`, at a branch or tag. */
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.source
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.4.6",
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.4.6"
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
+ }
@@ -1,4 +1,4 @@
1
- import { everyProjectPath, grantKind, type GrantPlace } from './access.ts';
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
- export type FaultGrant = GrantPlace & { principal: string; role: string };
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 place = grantKind(held) === 'every-project' ? everyProjectPath(environmentSlug) : where(projectId ?? 'no project', environmentId);
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) {