@coffre/core 0.5.1 → 0.6.1

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.
@@ -17,6 +17,7 @@
17
17
  * Each returns a `SigninProvider`, and a provider that speaks neither can be
18
18
  * one of the deployment's own, in the same list.
19
19
  */
20
+ import { GitHubApp, pkcs8Of, type GitHubAppOptions } from '../github-app.ts';
20
21
  import { GitHubSigninProvider } from './github.ts';
21
22
  import { OidcSigninProvider } from './oidc.ts';
22
23
  import type { SigninBrand, SigninProvider } from './types.ts';
@@ -95,6 +96,15 @@ export type WorkloadsOptions = {
95
96
  * loopback too, so no instance anyone else can reach ever trusts one.
96
97
  */
97
98
  allowLoopbackIssuersForDevelopment?: boolean;
99
+ /**
100
+ * The deployment's own GitHub App, `githubApp(…)`, installed on its
101
+ * organizations: where an owner trusts a workflow, the pages list the
102
+ * repositories it is installed on, private ones too, and a repository
103
+ * typed by name resolves through it (docs/github-app.md). The same value
104
+ * may sign people in, `github({ app })`. Left out, repositories are typed,
105
+ * and only public ones' IDs are looked up.
106
+ */
107
+ github?: GitHubApp;
98
108
  };
99
109
 
100
110
  /** Cloudflare's rate-limiting binding, as coffre calls it; `processLimits()` has the same shape. */
@@ -102,7 +112,7 @@ export type RateLimiter = { limit(options: { key: string }): Promise<{ success:
102
112
 
103
113
  export type WorkloadLimits = { perSource: RateLimiter; total: RateLimiter };
104
114
 
105
- export type WorkloadsConfig = { allowLoopback: boolean; limits: WorkloadLimits };
115
+ export type WorkloadsConfig = { allowLoopback: boolean; limits: WorkloadLimits; github: GitHubApp | null };
106
116
 
107
117
  /**
108
118
  * MCP clients, Claude among them, acting as the people who connect them,
@@ -241,28 +251,63 @@ export function microsoft(
241
251
  });
242
252
  }
243
253
 
244
- /** GitHub accounts; with `organization`, only that organization's members. */
254
+ /**
255
+ * GitHub accounts; with `organization`, only that organization's members.
256
+ * Through an OAuth app, or a GitHub App's OAuth client, by its credentials;
257
+ * or the deployment's GitHub App, `github({ app })`, the same value its
258
+ * `workloads.github` lists repositories with:
259
+ *
260
+ * const app = githubApp({ appId, privateKey, clientId, clientSecret });
261
+ * signin({ providers: [github({ app, organization: 'acme' })], workloads: { limits, github: app } })
262
+ */
245
263
  export function github(
246
- options: Credentials & {
247
- id?: string;
248
- label?: string;
249
- organization?: string;
250
- webUrl?: string;
251
- apiUrl?: string;
252
- },
264
+ options: { id?: string; label?: string; organization?: string } & (
265
+ | (Credentials & { webUrl?: string; apiUrl?: string })
266
+ | { app: GitHubApp }
267
+ ),
253
268
  ): GitHubSigninProvider {
254
269
  const id = checkId(options.id ?? 'github');
270
+ const how = ((): Credentials & { webUrl?: string; apiUrl?: string } => {
271
+ if (!('app' in options)) return options;
272
+ const client = options.app instanceof GitHubApp ? options.app.signinClient() : null;
273
+ if (client === null) {
274
+ throw new Error(`sign-in provider ${id} signs in with the GitHub App's OAuth client: githubApp({ appId, privateKey, clientId, clientSecret })`);
275
+ }
276
+ return { ...client, webUrl: options.app.webUrl, apiUrl: options.app.apiUrl };
277
+ })();
255
278
  return new GitHubSigninProvider({
256
279
  id,
257
280
  label: options.label ?? 'GitHub',
258
281
  brand: 'github',
259
- ...credentials(id, options),
260
- webUrl: baseUrl(options.webUrl ?? 'https://github.com', `sign-in provider ${id} web URL`),
261
- apiUrl: baseUrl(options.apiUrl ?? 'https://api.github.com', `sign-in provider ${id} API URL`),
282
+ ...credentials(id, how),
283
+ webUrl: baseUrl(how.webUrl ?? 'https://github.com', `sign-in provider ${id} web URL`),
284
+ apiUrl: baseUrl(how.apiUrl ?? 'https://api.github.com', `sign-in provider ${id} API URL`),
262
285
  organization: options.organization?.trim() || null,
263
286
  });
264
287
  }
265
288
 
289
+ /**
290
+ * The deployment's GitHub App, checked where it is written: its numeric ID
291
+ * and private key, and its OAuth client when it signs people in too. One
292
+ * value, passed to each side that uses it: `github({ app })` and
293
+ * `workloads.github` (`../github-app.ts`).
294
+ */
295
+ export function githubApp(options: GitHubAppOptions): GitHubApp {
296
+ const appId = String(options.appId ?? '').trim();
297
+ if (!/^[1-9][0-9]{0,19}$/.test(appId)) throw new Error('githubApp(…).appId must be the app\'s numeric ID (its settings page, "App ID"), not its client ID');
298
+ const [clientId, clientSecret] = [options.clientId?.trim() || null, options.clientSecret || null];
299
+ if ((clientId === null) !== (clientSecret === null)) {
300
+ throw new Error('githubApp(…) takes its OAuth client whole, clientId and clientSecret, or neither');
301
+ }
302
+ return new GitHubApp({
303
+ appId,
304
+ pkcs8: pkcs8Of(options.privateKey),
305
+ webUrl: baseUrl(options.webUrl ?? 'https://github.com', 'githubApp(…).webUrl'),
306
+ apiUrl: baseUrl(options.apiUrl ?? 'https://api.github.com', 'githubApp(…).apiUrl'),
307
+ ...(clientId === null || clientSecret === null ? {} : { client: { clientId, clientSecret } }),
308
+ });
309
+ }
310
+
266
311
  function isLoopback(url: URL): boolean {
267
312
  return url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '[::1]';
268
313
  }
@@ -370,7 +415,15 @@ function workloadsConfig(options: WorkloadsOptions, publicUrl: string): Workload
370
415
  `workloads.allowLoopbackIssuersForDevelopment is for an instance on loopback, and ${publicUrl} is not one: turn it off`,
371
416
  );
372
417
  }
373
- return { allowLoopback: loopback, limits: options.limits };
418
+ const app = options.github;
419
+ if (app !== undefined && !(app instanceof GitHubApp)) {
420
+ throw new Error('workloads.github is the deployment\'s GitHub App: githubApp({ appId, privateKey })');
421
+ }
422
+ // Its repositories' IDs are trusted under github.com's issuer, the one the lookup and the pages bind to.
423
+ if (app !== undefined && app.apiUrl !== 'https://api.github.com' && !isLoopback(new URL(app.apiUrl))) {
424
+ throw new Error('workloads.github is for github.com: GitHub Enterprise Server is not supported yet; sign in with its app, and leave workloads.github out');
425
+ }
426
+ return { allowLoopback: loopback, limits: options.limits, github: app ?? null };
374
427
  }
375
428
 
376
429
  /** A deployment's own provider is checked like coffre's: it is only typed, not trusted. */
@@ -86,12 +86,16 @@ export class GitHubSigninProvider implements SigninProvider {
86
86
  }
87
87
 
88
88
  const organization = this.config.organization;
89
- if (organization !== null && !(await this.#isMember(token, organization))) {
89
+ const standing = organization === null ? 'member' : await this.#membership(token, organization);
90
+ if (standing === 'unseen') {
90
91
  throw new SigninError(
91
92
  'not_in_organization',
92
- `only members of the ${organization} organization may sign in with ${label}`,
93
+ `${label} would not show ${organization}'s members to coffre: a GitHub App needs the Members organization permission, read, and to be installed on ${organization}; an OAuth app, the organization's approval`,
93
94
  );
94
95
  }
96
+ if (standing === 'outside') {
97
+ throw new SigninError('not_in_organization', `only members of the ${organization} organization may sign in with ${label}`);
98
+ }
95
99
 
96
100
  const listed = await this.#api<GitHubEmail[]>(token, '/user/emails');
97
101
  const emails = (Array.isArray(listed) ? listed : [])
@@ -136,17 +140,25 @@ export class GitHubSigninProvider implements SigninProvider {
136
140
  return body.access_token;
137
141
  }
138
142
 
139
- async #isMember(token: string, organization: string): Promise<boolean> {
143
+ /**
144
+ * Whether the person is an active member: `outside` when GitHub says they
145
+ * are not (404), `unseen` when it will not say (403), which a GitHub App's
146
+ * user token gets without the Members permission or an installation on
147
+ * the organization, and an OAuth app's where the organization restricts
148
+ * it. Either way, no sign-in.
149
+ */
150
+ async #membership(token: string, organization: string): Promise<'member' | 'outside' | 'unseen'> {
140
151
  const response = await this.#send(
141
152
  `${this.config.apiUrl}/user/memberships/orgs/${encodeURIComponent(organization)}`,
142
153
  { headers: apiHeaders(token) },
143
154
  );
144
- if (response.status === 404 || response.status === 403) return false;
155
+ if (response.status === 404) return 'outside';
156
+ if (response.status === 403) return 'unseen';
145
157
  const body = (await readJson(response)) as { state?: unknown } | null;
146
158
  if (!response.ok || body === null) {
147
159
  throw new SigninError('invalid_response', `${this.config.label} answered the membership check with ${response.status}`);
148
160
  }
149
- return body.state === 'active';
161
+ return body.state === 'active' ? 'member' : 'outside';
150
162
  }
151
163
 
152
164
  async #api<T>(token: string, path: string): Promise<T | null> {
@@ -15,6 +15,12 @@
15
15
  * workflow_ref: 'acme/api/.github/workflows/ci.yml@refs/heads/main',
16
16
  * ref: 'refs/heads/main',
17
17
  * event_name: ['pull_request', 'push', 'workflow_dispatch'] } }
18
+ *
19
+ * A GitHub binding may also name `environment`, the GitHub environment its
20
+ * job runs in, so that the environment's protection rules (its required
21
+ * reviewers, the branches it deploys) gate every run that signs in. GitHub
22
+ * changes `sub` for such a job, `repo:acme/api:environment:production`, but
23
+ * no GitHub binding matches `sub`: `ref` and `workflow_ref` stay as they were.
18
24
  */
19
25
 
20
26
  export const GITHUB_ISSUER = 'https://token.actions.githubusercontent.com';
@@ -106,6 +112,13 @@ const DIGITS = /^[1-9][0-9]{0,19}$/;
106
112
  const SHA = /^[0-9a-f]{40}$/;
107
113
  const REF = /^refs\/(heads|tags)\/\S{1,200}$/;
108
114
  const WORKFLOW = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/\.github\/workflows\/[^@\s]+\.ya?ml@\S+$/;
115
+ /**
116
+ * A GitHub environment's name, as GitHub takes one: at most 255 characters,
117
+ * none a control character, and no space at either end, which GitHub would
118
+ * not keep. GitHub documents no narrower set: `production`, `Staging EU`
119
+ * and `prod/eu:1` are all names.
120
+ */
121
+ const ENVIRONMENT = /^(?! )[^\p{Cc}]{1,255}(?<! )$/u;
109
122
 
110
123
  /** A claim a binding names: one value, or, for what started the run, any of several. */
111
124
  export type ClaimValue = string | string[];
@@ -209,6 +222,9 @@ function checkGitHub(profile: WorkloadProfile, claims: BindingClaims): void {
209
222
  const value = claims[id];
210
223
  if (value !== undefined && !DIGITS.test(value as string)) throw new BindingInvalid(`${id} is GitHub's numeric ID, not a name`);
211
224
  }
225
+ if (claims.environment !== undefined && !ENVIRONMENT.test(claims.environment as string)) {
226
+ throw new BindingInvalid('environment is a GitHub environment\'s name: at most 255 characters, no control character, no space at either end');
227
+ }
212
228
  const events = claimValues(claims.event_name);
213
229
  const ref = claims.ref as string;
214
230
  if (!REF.test(ref)) throw new BindingInvalid('ref is a full ref: refs/heads/<branch> or refs/tags/<tag>');
@@ -245,17 +261,24 @@ function checkGitLab(claims: BindingClaims): void {
245
261
  /**
246
262
  * The claims of a verified token that differ from what a binding expects,
247
263
  * 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>`.
264
+ * value, or one of its values. On GitHub's profiles, two readings:
265
+ *
266
+ * - A `pull_request` run, at `refs/pull/<n>/merge`, counts as a run at the
267
+ * branch it merges into: its `ref`, and the ref its `workflow_ref` ends
268
+ * in, are read as `refs/heads/<base_ref>`.
269
+ * - `environment` matches whatever its case: GitHub's environment names are
270
+ * case-insensitive, so `Production` and `production` are one environment,
271
+ * under the same protection rules.
252
272
  */
253
273
  export function differingClaims(profile: WorkloadProfile, expected: BindingClaims, token: Record<string, unknown>): string[] {
254
- const claims = profile.startsWith('github') ? atBaseBranch(token) : token;
274
+ const github = profile.startsWith('github');
275
+ const claims = github ? atBaseBranch(token) : token;
255
276
  return Object.entries(expected)
256
277
  .filter(([name, value]) => {
257
278
  const actual = claims[name];
258
- return typeof actual !== 'string' || !claimValues(value).includes(actual);
279
+ if (typeof actual !== 'string') return true;
280
+ if (github && name === 'environment') return actual.toLowerCase() !== (value as string).toLowerCase();
281
+ return !claimValues(value).includes(actual);
259
282
  })
260
283
  .map(([name]) => name);
261
284
  }
@@ -351,13 +374,18 @@ function started(input: Record<string, unknown>, one: string, many: string): Cla
351
374
  return list as string[];
352
375
  }
353
376
 
354
- /** A workflow of the repository, run at `ref` by any of `events`; a pull request, by the branch it merges into. */
377
+ /**
378
+ * A workflow of the repository, run at `ref` by any of `events`; a pull
379
+ * request, by the branch it merges into. An `environment` narrows it to the
380
+ * jobs that run in that GitHub environment.
381
+ */
355
382
  export function githubWorkflow(input: {
356
383
  repository: string;
357
384
  repositoryId: string;
358
385
  ownerId: string;
359
386
  workflow: string;
360
387
  ref: string;
388
+ environment?: string;
361
389
  } & Starts<'event', 'events'>): Omit<BindingPolicy, 'issuer'> {
362
390
  return {
363
391
  profile: 'github',
@@ -367,14 +395,21 @@ export function githubWorkflow(input: {
367
395
  workflow_ref: workflowRef(input.repository, input.workflow, input.ref),
368
396
  ref: input.ref,
369
397
  event_name: started(input, 'event', 'events'),
398
+ ...inEnvironment(input.environment),
370
399
  },
371
400
  };
372
401
  }
373
402
 
403
+ /** The `environment` claim, when one is named. */
404
+ function inEnvironment(environment: string | undefined): BindingClaims {
405
+ return environment === undefined ? {} : { environment };
406
+ }
407
+
374
408
  /**
375
409
  * A reusable workflow at one commit, called at `ref`: from one repository,
376
410
  * or, with `repositoryId` null, from any repository of the organization. A
377
- * `caller` workflow narrows it to one calling workflow.
411
+ * `caller` workflow narrows it to one calling workflow, and an
412
+ * `environment` to the jobs that run in that GitHub environment.
378
413
  */
379
414
  export function githubReusable(input: {
380
415
  repositoryId: string | null;
@@ -383,6 +418,7 @@ export function githubReusable(input: {
383
418
  called: string;
384
419
  sha: string;
385
420
  caller?: { repository: string; workflow: string };
421
+ environment?: string;
386
422
  } & Starts<'event', 'events'>): Omit<BindingPolicy, 'issuer'> {
387
423
  const claims: BindingClaims = {
388
424
  repository_owner_id: input.ownerId,
@@ -391,6 +427,7 @@ export function githubReusable(input: {
391
427
  event_name: started(input, 'event', 'events'),
392
428
  job_workflow_ref: input.called,
393
429
  job_workflow_sha: input.sha,
430
+ ...inEnvironment(input.environment),
394
431
  };
395
432
  if (input.caller !== undefined) claims.workflow_ref = workflowRef(input.caller.repository, input.caller.workflow, input.ref);
396
433
  return { profile: input.repositoryId === null ? 'github-reusable-organization' : 'github-reusable', claims };
package/src/vault.ts CHANGED
@@ -56,20 +56,30 @@ export interface Vault {
56
56
  endReferences(input: EndReferencesInput): Promise<Outcome<{ seqs: number[] }>>;
57
57
 
58
58
  /**
59
- * What one principal holds right now: the app asks once per request. A
60
- * decision, its rows checked: lists of who holds what read the rows
61
- * themselves, and need not ask.
59
+ * What one principal holds right now: the app asks once per request,
60
+ * and at sign-in for each verified email. A decision, its rows checked:
61
+ * lists of who holds what read the rows themselves, and need not ask.
62
+ * A person the vault's `owners` names who is no Owner of all of it
63
+ * becomes one here, logged (`reason: 'owners'`).
62
64
  */
63
65
  access(principal: string): Promise<Access>;
64
- /** Set roles at several places for one principal: all of it, or none. */
65
- setAccess(input: SetAccessInput): Promise<Outcome<{ changes: AccessChange[] }>>;
66
+ /**
67
+ * Set roles at several places for one principal: all of it, or none.
68
+ * With `admit`, a service account that is not a member is admitted
69
+ * first, in the same decision, as `admit` would: `admitted` says so.
70
+ */
71
+ setAccess(input: SetAccessInput): Promise<Outcome<{ changes: AccessChange[]; admitted?: Admitted }>>;
66
72
  /**
67
73
  * Add a member, bring back a removed one, or change a person's instance
68
74
  * role and its scope. `generation` is theirs now: sessions and tokens of
69
75
  * an earlier one are dead.
70
76
  */
71
- admit(input: AdmitInput): Promise<Outcome<{ created: boolean; role: InstanceRole; scope: Scope; generation: number }>>;
72
- /** Remove a member: revoke every grant and refuse them until admitted again, from `generation` on. */
77
+ admit(input: AdmitInput): Promise<Outcome<Admitted>>;
78
+ /**
79
+ * Remove a member: revoke every grant and refuse them until admitted
80
+ * again, from `generation` on. An Owner, only an Owner of the whole
81
+ * instance removes, and never the last.
82
+ */
73
83
  remove(input: RemoveInput): Promise<Outcome<{ revoked: Grant[]; generation: number }>>;
74
84
  /**
75
85
  * The instance's settings, as the vault's newest `settings.change` entry
@@ -96,10 +106,22 @@ export interface Vault {
96
106
  /**
97
107
  * What the vault's configuration says, which the app shows: the keys its
98
108
  * checkpoints verify under, by the key id each checkpoint names (the one
99
- * it signs with now, and those of the KEKs it replaced), and the root
100
- * admins, as principals.
109
+ * it signs with now, and those of the KEKs it replaced).
101
110
  */
102
- about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; rootAdmins: string[] }>;
111
+ about(): Promise<{
112
+ checkpointKeys: Record<string, CheckpointKey>;
113
+ /**
114
+ * The emails of those the deployment names Owners of the whole instance
115
+ * (`owners`), lowercased: the vault makes them Owners, and refuses to
116
+ * demote or remove them. Absent from a vault of 0.5, which names none.
117
+ */
118
+ owners?: string[];
119
+ /**
120
+ * @deprecated Always empty: an app of 0.5, deployed after this vault,
121
+ * lists and checks root admins here until it is. Gone in 0.7.
122
+ */
123
+ rootAdmins: [];
124
+ }>;
103
125
  /**
104
126
  * What an escrowed vault key is checked against: the vault key it wraps
105
127
  * under now, and each one's check, as the vault wrote it. None of it is
@@ -150,8 +172,12 @@ export type RefusalCode =
150
172
  | 'ended'
151
173
  /** Not allowed to manage access, members or the log. */
152
174
  | 'not_allowed'
153
- /** Root admins come from the vault's configuration and cannot be changed. */
154
- | 'root_admin'
175
+ /**
176
+ * The change would leave the instance without an Owner of all of it: the
177
+ * last one is not demoted, narrowed or removed, by anyone, until another
178
+ * is made.
179
+ */
180
+ | 'last_owner'
155
181
  /** Well-formed, but not something the rules allow: a project role on an environment, a service account with an instance role. */
156
182
  | 'invalid'
157
183
  /** The vault's own log no longer carries the head the last checkpoint signed, or does not rehash since. */
@@ -282,22 +308,19 @@ export type Access = {
282
308
  /**
283
309
  * `unknown` for someone never admitted, `tampered` for a member whose row
284
310
  * fails the vault's integrity check: refused everything, as `removed` is.
285
- * Root admins are always active.
286
311
  */
287
312
  status: 'active' | 'removed' | 'unknown' | 'tampered';
288
313
  /** Advanced by removal, even if the app cannot commit its credential revocations. */
289
314
  generation: number;
290
- isRootAdmin: boolean;
291
315
  /**
292
316
  * 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.
317
+ * for service accounts and anyone not active. Scopes name projects by id.
295
318
  */
296
319
  role: InstanceRole;
297
320
  scope: Scope;
298
321
  /** Live grants only, and none unless active. */
299
322
  grants: Grant[];
300
- /** When the status last changed, and who changed it; null for root admins and strangers. */
323
+ /** When the status last changed, and who changed it; null for strangers. */
301
324
  since: string | null;
302
325
  by: string | null;
303
326
  };
@@ -317,6 +340,12 @@ export type SetAccessInput = Correlation & Through & {
317
340
  actor: string;
318
341
  principal: string;
319
342
  changes: GrantChange[];
343
+ /**
344
+ * Admit the principal, a service account, unless it is a member: a
345
+ * grant refused admits nobody. A vault older than this refuses one that
346
+ * is not a member, `not_a_member`, and admits nobody either.
347
+ */
348
+ admit?: boolean;
320
349
  };
321
350
 
322
351
  export type AdmitInput = Correlation & Through & {
@@ -325,12 +354,19 @@ export type AdmitInput = Correlation & Through & {
325
354
  /**
326
355
  * A person's instance role, and where it applies (everywhere, left out).
327
356
  * 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.
357
+ * Changing it takes someone who runs the instance, about someone else;
358
+ * making someone an Owner, or changing an Owner's, an Owner of the whole
359
+ * instance, who may change their own too. It never leaves the instance
360
+ * without an Owner of all of it (`last_owner`), nor demotes one the
361
+ * deployment names (`owners`; `not_allowed`).
329
362
  */
330
363
  role?: InstanceRole;
331
364
  scope?: Scope;
332
365
  };
333
366
 
367
+ /** A member as `admit` left them; `created` when they were not one before. */
368
+ export type Admitted = { created: boolean; role: InstanceRole; scope: Scope; generation: number };
369
+
334
370
  export type RemoveInput = Correlation & Through & {
335
371
  actor: string;
336
372
  principal: string;