@coffre/core 0.4.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-CwsFLfTz.d.ts +420 -0
- package/dist/access-pnJ-u4x2.js +522 -0
- package/dist/access.d.ts +2 -136
- package/dist/access.js +2 -135
- package/dist/identity.d.ts +2 -2
- package/dist/identity.js +2 -2
- package/dist/schemas.d.ts +23 -1
- package/dist/schemas.js +14 -2
- package/dist/vault.d.ts +66 -28
- package/dist/vault.js +2 -2
- package/dist/workloads.d.ts +60 -20
- package/dist/workloads.js +141 -26
- package/package.json +2 -2
- package/src/access-conversion.ts +202 -0
- package/src/access-fault.ts +6 -4
- package/src/access.ts +346 -49
- package/src/identity/workloads.ts +154 -42
- package/src/schemas.ts +12 -1
- package/src/vault.ts +61 -27
package/src/access.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Who may do what: seven permissions
|
|
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,
|
|
6
|
-
*
|
|
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
|
|
91
|
-
*
|
|
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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
117
|
-
*
|
|
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' |
|
|
121
|
-
if (grant.
|
|
122
|
-
|
|
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
|
-
/**
|
|
127
|
-
|
|
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
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
/**
|
|
144
|
-
|
|
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
|
|
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`:
|
|
175
|
-
* gives
|
|
176
|
-
*
|
|
177
|
-
* `
|
|
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 (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
189
|
-
*
|
|
190
|
-
*
|
|
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:
|
|
193
|
-
|
|
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';
|