@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/src/access.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Who may do what: seven permissions and the six roles that bundle them.
2
+ * Who may do what: seven permissions, the six project roles a grant names,
3
+ * and the five instance roles a person holds across every project.
3
4
  *
4
5
  * Deliberately constants and not tables or a policy language. A grant names
5
- * one role for one member on one place, and a check is a lookup in this file,
6
- * so authorisation stays something you can read end to end.
6
+ * one role for one member on one place, a person's instance role names one
7
+ * role and its scope, and a check is a lookup in this file, so authorisation
8
+ * stays something you can read end to end.
7
9
  */
8
10
 
9
11
  export const PERMISSIONS = [
@@ -85,50 +87,221 @@ export function assignableToEnvironment(role: Role): boolean {
85
87
  );
86
88
  }
87
89
 
90
+ /**
91
+ * A person's role on the whole instance: what they hold in every project
92
+ * their scope covers, before any grant. Service accounts are always
93
+ * members, and hold only what their grants give. Admins and owners also
94
+ * run the instance (`runsInstance`) when nothing narrows their scope.
95
+ */
96
+ export const INSTANCE_ROLES = {
97
+ member: {
98
+ name: 'Member',
99
+ description: 'Only what projects grant.',
100
+ permissions: [],
101
+ },
102
+ auditor: {
103
+ name: 'Auditor',
104
+ description: 'Read the audit log. No secret values.',
105
+ permissions: ['audit.read'],
106
+ },
107
+ developer: {
108
+ name: 'Developer',
109
+ description: 'Read and write secrets.',
110
+ permissions: ['secret.read', 'secret.write'],
111
+ },
112
+ admin: {
113
+ name: 'Admin',
114
+ description: 'Manage people, service accounts, projects and access. No secret values.',
115
+ permissions: ['audit.read', 'environment.manage', 'grant.manage', 'project.manage'],
116
+ },
117
+ owner: {
118
+ name: 'Owner',
119
+ description: 'Everything an admin does, and read every secret.',
120
+ permissions: [...PERMISSIONS],
121
+ },
122
+ } as const satisfies Record<string, {
123
+ name: string;
124
+ description: string;
125
+ permissions: readonly Permission[];
126
+ }>;
127
+
128
+ export type InstanceRole = keyof typeof INSTANCE_ROLES;
129
+
130
+ export const INSTANCE_ROLE_NAMES = Object.keys(INSTANCE_ROLES) as InstanceRole[];
131
+
132
+ export function isInstanceRole(value: string): value is InstanceRole {
133
+ return Object.hasOwn(INSTANCE_ROLES, value);
134
+ }
135
+
136
+ export function instanceRoleGrants(role: InstanceRole, permission: Permission): boolean {
137
+ return (INSTANCE_ROLES[role].permissions as readonly Permission[]).includes(permission);
138
+ }
139
+
140
+ /** The roles that manage people and access: Admin, and Owner, which adds every secret. */
141
+ export function administers(role: InstanceRole): boolean {
142
+ return role === 'admin' || role === 'owner';
143
+ }
144
+
145
+ /**
146
+ * Which projects, or environments, a scope takes in: all of them, only
147
+ * those listed, or all but those listed. Projects are listed by id, so a
148
+ * rename keeps them; environments by slug, so `dev` is the `dev` of every
149
+ * project, the ones made later too.
150
+ */
151
+ export type Filter = 'all' | { only: string[] } | { except: string[] };
152
+
153
+ /**
154
+ * Where a person's instance role applies: the projects it reaches, and the
155
+ * environments in them, by slug. A grant reaches past it; nothing else does.
156
+ *
157
+ * { projects: 'all', environments: { only: ['dev'] } } every project's dev
158
+ * { projects: { except: [billing] }, environments: 'all' } all but billing
159
+ */
160
+ export type Scope = { projects: Filter; environments: Filter };
161
+
162
+ /** Every project and every environment. */
163
+ export const EVERYWHERE: Scope = { projects: 'all', environments: 'all' };
164
+
165
+ /** Whether a scope narrows nothing. */
166
+ export function unscoped(scope: Scope): boolean {
167
+ return scope.projects === 'all' && scope.environments === 'all';
168
+ }
169
+
170
+ /** Whether a filter takes in a project's id or an environment's slug; one not read (null) is in none that lists. */
171
+ export function admits(filter: Filter, value: string | null | undefined): boolean {
172
+ if (filter === 'all') return true;
173
+ if (value == null) return false;
174
+ return 'only' in filter ? filter.only.includes(value) : !filter.except.includes(value);
175
+ }
176
+
177
+ /**
178
+ * One way to write each scope, which the vault seals and the log replays:
179
+ * lists sorted, without repeats; an empty `except` is `all`. An empty
180
+ * `only` stays: it takes in nothing.
181
+ */
182
+ export function normalScope(scope: Scope): Scope {
183
+ const normal = (filter: Filter): Filter => {
184
+ if (filter === 'all') return 'all';
185
+ if ('only' in filter) return { only: [...new Set(filter.only)].sort() };
186
+ return filter.except.length === 0 ? 'all' : { except: [...new Set(filter.except)].sort() };
187
+ };
188
+ return { projects: normal(scope.projects), environments: normal(scope.environments) };
189
+ }
190
+
191
+ /** Whether `value` is a scope as `normalScope` writes it, and as the vault stores it. */
192
+ export function isScope(value: unknown): value is Scope {
193
+ const isFilter = (filter: unknown): filter is Filter => {
194
+ if (filter === 'all') return true;
195
+ if (typeof filter !== 'object' || filter === null || Array.isArray(filter)) return false;
196
+ const keys = Object.keys(filter);
197
+ if (keys.length !== 1 || (keys[0] !== 'only' && keys[0] !== 'except')) return false;
198
+ const list = (filter as Record<string, unknown>)[keys[0]];
199
+ return Array.isArray(list) && list.every((item) => typeof item === 'string' && item.length > 0);
200
+ };
201
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
202
+ const { projects, environments, ...rest } = value as Record<string, unknown>;
203
+ return Object.keys(rest).length === 0 && isFilter(projects) && isFilter(environments);
204
+ }
205
+
206
+ /**
207
+ * A scope in words, its projects named as the caller has them (slugs, at
208
+ * the API's edges): `All projects`, `All projects · dev only`,
209
+ * `web, api`, `All projects but billing · all but prod`.
210
+ */
211
+ export function scopeInWords(scope: Scope): string {
212
+ const list = (names: string[]) => names.join(', ');
213
+ const { projects, environments } = scope;
214
+ const where = projects === 'all'
215
+ ? 'All projects'
216
+ : 'only' in projects
217
+ ? (projects.only.length === 0 ? 'No projects' : list(projects.only))
218
+ : `All projects but ${list(projects.except)}`;
219
+ if (environments === 'all') return where;
220
+ if ('only' in environments) return `${where} · ${environments.only.length === 0 ? 'no environments' : `${list(environments.only)} only`}`;
221
+ return `${where} · all but ${list(environments.except)}`;
222
+ }
223
+
88
224
  /**
89
225
  * A place a permission applies to: a project, or one of its environments,
90
- * named by its slug too, which grants on every project match by. A slug
91
- * not read (null) matches no grant on a slug.
226
+ * named by its slug too, which a scope matches environments by. A slug not
227
+ * read (null) is in no scope that names environments.
92
228
  */
93
229
  export type Place =
94
230
  | { projectId: string; environmentId?: null }
95
231
  | { projectId: string; environmentId: string; environmentSlug: string | null };
96
232
 
97
233
  /**
98
- * Where a grant applies, by the fields it fills (`grantKind`):
99
- *
100
- * market projectId
101
- * market/dev projectId and environmentId
102
- * * neither: every project, the ones created later too
103
- * *, on dev environmentSlug only: the environment of that slug in every project
234
+ * Whether a scope takes in a place. A project as a whole is in it only when
235
+ * the scope keeps all of its environments: a role scoped to `dev` reaches
236
+ * every `dev`, never a project around one.
237
+ */
238
+ export function inScope(scope: Scope, place: Place): boolean {
239
+ if (!admits(scope.projects, place.projectId)) return false;
240
+ if (place.environmentId == null) return scope.environments === 'all';
241
+ return admits(scope.environments, place.environmentSlug);
242
+ }
243
+
244
+ /**
245
+ * Where a grant applies: a project (`environmentId` null), or one of its
246
+ * environments, which names its project too.
104
247
  */
105
248
  export type GrantPlace = {
106
- /** The project, also of an environment's grant; null only on every project. */
107
249
  projectId: string | null;
108
250
  environmentId: string | null;
109
- /** On every project, the one environment slug it covers; null for all of them, and on a project's grant. */
110
- environmentSlug: string | null;
111
251
  };
112
252
 
113
253
  /**
114
254
  * What a grant's fields say it is on, or null when they say nothing
115
255
  * coherent: an environment without its project, as a grant whose
116
- * environment row is gone reads, or a slug beside an id. A grant is on
117
- * every project only when it names no place at all; anything else that
118
- * lacks a field covers nothing.
256
+ * environment row is gone reads, or neither, as the grants on every project
257
+ * of 0.4 read until the vault replaces them. Null covers nothing.
119
258
  */
120
- export function grantKind(grant: GrantPlace): 'project' | 'environment' | 'every-project' | null {
121
- if (grant.environmentId !== null) return grant.projectId !== null && grant.environmentSlug === null ? 'environment' : null;
122
- if (grant.projectId !== null) return grant.environmentSlug === null ? 'project' : null;
123
- return 'every-project';
259
+ export function grantKind(grant: GrantPlace): 'project' | 'environment' | null {
260
+ if (grant.projectId === null) return null;
261
+ return grant.environmentId === null ? 'project' : 'environment';
124
262
  }
125
263
 
126
- /** Every project, as a path names it. */
127
- export const EVERY_PROJECT = '*';
264
+ /**
265
+ * What a service account's token or trust binding carries, its scope:
266
+ * grants as the account's are, a role on a project or an environment, each
267
+ * one the account held and its issuer could give when it was issued.
268
+ * Nothing changes it after. A request made with it holds what both it and
269
+ * the account hold now (`allows`), so narrowing the account narrows it, and
270
+ * a grant the account gets later reaches no credential issued before:
271
+ *
272
+ * account developer on market/dev, then viewer on market/prod too
273
+ * token developer on market/dev, issued before market/prod
274
+ * request reads and writes market/dev, never market/prod
275
+ */
276
+ export type CredentialScope = readonly (GrantPlace & { role: Role })[];
277
+
278
+ /** The most places a scope asked for names: far more than any credential needs, and a bound on what a request makes the app check. */
279
+ export const MAX_SCOPE_PLACES = 1000;
128
280
 
129
- /** A grant on every project as a path: `*`, or `*` and the environment slug it covers in each. */
130
- export function everyProjectPath(environmentSlug: string | null): string {
131
- return environmentSlug === null ? EVERY_PROJECT : `${EVERY_PROJECT}/${environmentSlug}`;
281
+ /**
282
+ * A credential's scope, by path as the API names it, in words, each role
283
+ * with its places: `developer on market/dev, api/dev; viewer on market/prod`.
284
+ */
285
+ export function credentialScopeInWords(scope: Readonly<Record<string, string>>): string {
286
+ const byRole = new Map<string, string[]>();
287
+ for (const [path, role] of Object.entries(scope)) byRole.set(role, [...(byRole.get(role) ?? []), path]);
288
+ return [...byRole].map(([role, paths]) => `${role} on ${paths.join(', ')}`).join('; ') || 'nothing';
289
+ }
290
+
291
+ /** Whether `value` is a credential's scope as the app stores it and the vault reads it: one grant a place, each role one that place takes. */
292
+ export function isCredentialScope(value: unknown): value is CredentialScope {
293
+ if (!Array.isArray(value)) return false;
294
+ const places = new Set<string>();
295
+ return value.every((grant: unknown) => {
296
+ if (typeof grant !== 'object' || grant === null || Array.isArray(grant)) return false;
297
+ const { projectId, environmentId, role, ...rest } = grant as Record<string, unknown>;
298
+ if (Object.keys(rest).length > 0 || typeof projectId !== 'string' || typeof role !== 'string' || !isRole(role)) return false;
299
+ if (environmentId !== null && (typeof environmentId !== 'string' || !assignableToEnvironment(role))) return false;
300
+ const place = `${projectId}/${environmentId ?? ''}`;
301
+ if (places.has(place)) return false;
302
+ places.add(place);
303
+ return true;
304
+ });
132
305
  }
133
306
 
134
307
  /**
@@ -140,22 +313,25 @@ export function everyProjectPath(environmentSlug: string | null): string {
140
313
  export type Holdings = {
141
314
  /** Named in the vault's configuration: everything, everywhere. */
142
315
  isRootAdmin: boolean;
143
- /** Root admins and active users with the instance `owner` role. */
144
- isOwner: boolean;
316
+ /** `member` for service accounts, and for anyone not active. */
317
+ role: InstanceRole;
318
+ scope: Scope;
145
319
  /** Live grants only. */
146
320
  grants: readonly (GrantPlace & { role: Role })[];
321
+ /** The scope of the credential a request came in on, past which nothing reaches; absent or null for a session's. */
322
+ credentialScope?: CredentialScope | null;
147
323
  };
148
324
 
325
+ /** Someone who holds nothing: a stranger, a removed member, a tampered row. */
326
+ export const NOTHING: Holdings = { isRootAdmin: false, role: 'member', scope: EVERYWHERE, grants: [] };
327
+
149
328
  /**
150
329
  * Whether a grant at `grant` reaches `place`:
151
330
  *
152
331
  * market market and each of its environments
153
332
  * market/dev market/dev
154
- * * every project and every environment
155
- * *, on dev each environment whose slug is dev, in every project
156
333
  *
157
- * An environment's grant never reaches up to its project, whether it names
158
- * the environment or its slug.
334
+ * An environment's grant never reaches up to its project.
159
335
  */
160
336
  export function covers(grant: GrantPlace, place: Place): boolean {
161
337
  switch (grantKind(grant)) {
@@ -163,34 +339,155 @@ export function covers(grant: GrantPlace, place: Place): boolean {
163
339
  return grant.projectId === place.projectId && grant.environmentId === (place.environmentId ?? null);
164
340
  case 'project':
165
341
  return grant.projectId === place.projectId;
166
- case 'every-project':
167
- return grant.environmentSlug === null || (place.environmentId != null && place.environmentSlug !== null && place.environmentSlug === grant.environmentSlug);
168
342
  case null:
169
343
  return false;
170
344
  }
171
345
  }
172
346
 
173
347
  /**
174
- * Whether `holder` may do `permission` at `place`: a grant that covers it
175
- * gives the role's permissions there, and grants add up. Owners also manage
176
- * every project. `developer` on `market/prod` does not let anyone rename
177
- * `market`.
348
+ * Whether `holder` may do `permission` at `place`: their instance role
349
+ * gives its permissions inside its scope, a grant that covers the place
350
+ * gives its role's there, and they add up. A Developer scoped to `dev` with
351
+ * `viewer` on `billing` reads and writes every `dev`, and reads all of
352
+ * `billing`.
178
353
  */
179
354
  export function allows(holder: Holdings, permission: Permission, place: Place): boolean {
355
+ if (holder.credentialScope != null && !holdsAt(holder.credentialScope, permission, place)) return false;
180
356
  if (holder.isRootAdmin) return true;
181
- if (place.environmentId == null && holder.isOwner && PROJECT_ONLY_PERMISSIONS.includes(permission)) {
182
- return true;
183
- }
184
- return holder.grants.some((grant) => covers(grant, place) && roleGrants(grant.role, permission));
357
+ if (instanceRoleGrants(holder.role, permission) && inScope(holder.scope, place)) return true;
358
+ return holdsAt(holder.grants, permission, place);
359
+ }
360
+
361
+ /** Whether a grant among `grants` covers `place` with a role that holds `permission`. */
362
+ function holdsAt(grants: readonly (GrantPlace & { role: Role })[], permission: Permission, place: Place): boolean {
363
+ return grants.some((grant) => covers(grant, place) && roleGrants(grant.role, permission));
185
364
  }
186
365
 
187
366
  /**
188
- * Whether the actor may manage grants at this place: on a project or its
189
- * environments, with `grant.manage` on the project; on every project, as an
190
- * instance owner or a root admin only.
367
+ * Whether the actor may give or take grants at this place: with
368
+ * `grant.manage` there, from a project grant or an instance role whose
369
+ * scope takes it in. A grant on a project reaches all its environments, so
370
+ * an admin scoped to `dev` manages `market/dev`'s grants, not `market`'s.
191
371
  */
192
- export function mayManageAccess(actor: Holdings, place: GrantPlace): boolean {
193
- const kind = grantKind(place);
194
- if (kind === 'every-project') return actor.isOwner || actor.isRootAdmin;
195
- return kind !== null && place.projectId !== null && allows(actor, 'grant.manage', { projectId: place.projectId });
372
+ export function mayManageAccess(actor: Holdings, place: Place): boolean {
373
+ return allows(actor, 'grant.manage', place);
196
374
  }
375
+
376
+ /**
377
+ * Whether the holder runs the instance: a root admin, or an Admin or Owner
378
+ * whose scope narrows nothing. They add and remove people, set instance
379
+ * roles, and change the instance's settings; they read its whole log too
380
+ * (`readsWholeLog`). A scoped Admin manages only inside its scope, so it
381
+ * does none of these.
382
+ */
383
+ export function runsInstance(holder: Holdings): boolean {
384
+ return holder.isRootAdmin || (administers(holder.role) && unscoped(holder.scope));
385
+ }
386
+
387
+ /**
388
+ * Who may set up service accounts themselves, the instance's setting: a
389
+ * scope as a role's is, projects by id and environments by slug, which
390
+ * only those who run the instance change. Inside it, any person gives a
391
+ * service account what they hold, never more (`givesService`). Everywhere,
392
+ * unless it says otherwise:
393
+ *
394
+ * { projects: 'all', environments: { except: ['prod'] } } CI for dev and staging; prod's takes an Admin
395
+ */
396
+ export type ServiceSetting = Scope;
397
+
398
+ /**
399
+ * Whether `holder` may give a service account `role` at `place`: where
400
+ * they manage access, as anyone may; or inside the setting, where they hold
401
+ * everything `role` does. A Developer on `market/dev` gives viewer or
402
+ * developer there, never maintainer; a grant on `market` takes holding it
403
+ * on all of `market`. `setting` is null for a service account, which sets
404
+ * up none.
405
+ */
406
+ export function givesService(holder: Holdings, setting: ServiceSetting | null, role: Role, place: Place): boolean {
407
+ if (mayManageAccess(holder, place)) return true;
408
+ return setting !== null && inScope(setting, place) && ROLES[role].permissions.every((permission) => allows(holder, permission, place));
409
+ }
410
+
411
+ /** Whether `holder` gives a service account anything anywhere among `places`: whether they set any up. */
412
+ export function setsUpServices(holder: Holdings, setting: ServiceSetting | null, places: readonly Place[]): boolean {
413
+ if (runsInstance(holder)) return true;
414
+ return places.some((place) => ROLE_NAMES.some((role) => givesService(holder, setting, role, place)));
415
+ }
416
+
417
+ /** A service account as `managesService` weighs it: its live grants, where they are, and who admitted it. */
418
+ export type ServiceAccount = {
419
+ grants: readonly { role: Role; place: Place }[];
420
+ /** The member who admitted it last (`user:…`), as its row says. */
421
+ admittedBy: string | null;
422
+ };
423
+
424
+ /**
425
+ * Whether `holder`, the member `actor`, manages a service account: issues
426
+ * and revokes its tokens, adds and removes its trust bindings, changes its
427
+ * grants and removes it. A credential for it reads everything it holds, so
428
+ * they must reach every grant it holds, as if giving it (`givesService`):
429
+ * a Developer on `dev` manages an account on `market/dev`, and not one that
430
+ * holds `market/prod` too. One that holds nothing, only the person who
431
+ * admitted it: anyone else's token for it would read whatever it is given
432
+ * next. Those who run the instance manage every one.
433
+ */
434
+ export function managesService(holder: Holdings, setting: ServiceSetting | null, actor: string, account: ServiceAccount): boolean {
435
+ if (runsInstance(holder)) return true;
436
+ if (account.grants.length === 0) return setting !== null && account.admittedBy === actor;
437
+ return account.grants.every((grant) => givesService(holder, setting, grant.role, grant.place));
438
+ }
439
+
440
+ /**
441
+ * Whether `holder`, the member `actor`, may issue a token or a trust
442
+ * binding of `account` whose scope is `scope`: they manage the account
443
+ * (`managesService`), and each grant of the scope is one they could give it
444
+ * (`givesService`) and one it holds, every permission of its role there.
445
+ * For an account that holds `developer` on `market/dev`, a Developer of
446
+ * `dev` issues `developer` or `viewer` there; nobody issues `market/prod`
447
+ * for it, or `maintainer` on `market/dev`, until the account holds them.
448
+ */
449
+ export function issuesCredential(
450
+ holder: Holdings,
451
+ setting: ServiceSetting | null,
452
+ actor: string,
453
+ account: ServiceAccount,
454
+ scope: ServiceAccount['grants'],
455
+ ): boolean {
456
+ if (!managesService(holder, setting, actor, account)) return false;
457
+ const held: Holdings = { ...NOTHING, grants: account.grants.map(({ role, place }) => ({ projectId: place.projectId, environmentId: place.environmentId ?? null, role })) };
458
+ return scope.every(({ role, place }) =>
459
+ givesService(holder, setting, role, place) && ROLES[role].permissions.every((permission) => allows(held, permission, place)));
460
+ }
461
+
462
+ /**
463
+ * Whether the holder reads the whole log, the instance's own entries
464
+ * (sign-ins, people, service accounts, settings) with every project's, and
465
+ * verifies it: whoever holds `audit.read` by an instance role whose scope
466
+ * narrows nothing, an Auditor's included, and a root admin. Reading, not
467
+ * running: an Auditor of the whole instance changes nothing it reads about.
468
+ * Anyone else reads the places they hold `audit.read` in.
469
+ */
470
+ export function readsWholeLog(holder: Holdings): boolean {
471
+ return holder.isRootAdmin || (instanceRoleGrants(holder.role, 'audit.read') && unscoped(holder.scope));
472
+ }
473
+
474
+ /**
475
+ * Whether the holder may make a project: an Admin or Owner whose scope
476
+ * takes the new one in as a whole. A scope listing only some projects does
477
+ * not list the new one; one that keeps only some environments does not
478
+ * hold the project around them.
479
+ */
480
+ export function makesProjects(holder: Holdings): boolean {
481
+ if (holder.isRootAdmin) return true;
482
+ const { projects, environments } = holder.scope;
483
+ return administers(holder.role) && environments === 'all' && (projects === 'all' || 'except' in projects);
484
+ }
485
+
486
+ export {
487
+ convertEveryProjectGrants,
488
+ type Conversion,
489
+ type ConversionInput,
490
+ type ConvertedGrant,
491
+ type EveryProjectGrant,
492
+ type Narrowed,
493
+ } from './access-conversion.ts';