@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.
@@ -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 profile sets the minimum: the
7
- * server refuses a binding that lacks a claim its profile requires, from the
8
- * UI, the CLI or the API alike. Say a service is bound to `deploy.yml`,
9
- * pushed to `main` of `acme/api`:
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/deploy.yml@refs/heads/main',
14
- * ref: 'refs/heads/main', event_name: 'push' } }
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
- * Events triggered by someone with write access, at a ref the binding names
50
- * exactly. `pull_request`, `pull_request_target`, `workflow_run` and the
51
- * rest are refused, even when their ref happens to match: their runs can
52
- * carry a stranger's code.
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 GITHUB_EVENTS = ['push', 'workflow_dispatch', 'schedule', 'release'] as 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
- export type BindingClaims = Record<string, string>;
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 (typeof value !== 'string' || value.length === 0) throw new BindingInvalid(`claim ${name} must be a nonempty string`);
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
- if (claims[id] !== undefined && !DIGITS.test(claims[id])) throw new BindingInvalid(`${id} is GitHub's numeric ID, not a name`);
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 ref = claims.ref;
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
- if (event === 'release' && !ref.startsWith('refs/tags/')) throw new BindingInvalid('a release runs at its tag: refs/tags/<tag>');
153
- if (event === 'schedule' && !ref.startsWith('refs/heads/')) throw new BindingInvalid('a schedule runs on the default branch: refs/heads/<branch>');
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 it ran from, which for these events is the run's ref.
156
- if (!WORKFLOW.test(claims.workflow_ref) || !claims.workflow_ref.endsWith(`@${ref}`)) {
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 (claims.workflow_ref !== undefined && !WORKFLOW.test(claims.workflow_ref)) {
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
- if (!(GITLAB_PIPELINE_SOURCES as readonly string[]).includes(claims.pipeline_source)) {
178
- throw new BindingInvalid(`pipeline_source must be one of ${GITLAB_PIPELINE_SOURCES.join(', ')}: merge-request pipelines name another project`);
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
- /** A workflow of the repository, run by `event` at `ref`. */
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
- event: string;
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.event,
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.event,
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 `source`, at a branch or tag. */
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
- source: string;
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.source,
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(['user', 'owner']);
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 whether they are an
68
- * owner. `generation` is theirs now: sessions and tokens of an earlier
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; owner: boolean; generation: number }>>;
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 owners.
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 owner token. */
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
- export type UnwrapInput = Correlation & {
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), every project, or one environment slug in every
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
- /** Root admins and users admitted as owners, while active. */
277
- isOwner: boolean;
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; `environmentSlug` left out
288
- * is null.
307
+ * An environment's place names its project too.
289
308
  */
290
- export type GrantChange = Omit<GrantPlace, 'environmentSlug'> & {
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
- /** Left out, an existing member keeps their role and a new one is not an owner. */
308
- owner?: boolean;
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