@coffre/vault 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/cloudflare.d.ts +7 -2
- package/dist/cloudflare.js +7 -1
- package/dist/node.js +3 -1
- package/dist/{vault-CeKwkzSV.js → vault-CP8yU8xq.js} +486 -129
- package/package.json +3 -3
- package/src/cloudflare.ts +3 -0
- package/src/local.ts +2 -0
- package/src/replay.ts +20 -7
- package/src/rows.ts +17 -3
- package/src/store.ts +65 -48
- package/src/vault.ts +373 -94
|
@@ -2,13 +2,13 @@ import { ACCESS_ACTIONS, checkpointMessage, checkpointVerifier, describeAccessFa
|
|
|
2
2
|
import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
|
|
3
3
|
import { createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { tablesOf } from "@coffre/db";
|
|
5
|
-
import { allows, assignableToEnvironment,
|
|
5
|
+
import { EVERYWHERE, NOTHING, allows, assignableToEnvironment, convertEveryProjectGrants, givesService, grantKind, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalScope, runsInstance, setsUpServices, unscoped } from "@coffre/core/access";
|
|
6
6
|
import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
|
|
7
7
|
import { checkContext } from "@coffre/core/envelope";
|
|
8
8
|
import { shownMember } from "@coffre/core/schemas";
|
|
9
9
|
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, tombstone, truth } from "@coffre/db/dialect";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { accessEntriesAbout, insertGrant, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
|
|
11
|
+
import { LOOK_BACK, appendEntries, entryColumns, lockLogHead, newestAuthentic } from "@coffre/db/log";
|
|
12
12
|
import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm";
|
|
13
13
|
//#region src/accounting.ts
|
|
14
14
|
const KEY_ACTIONS = /* @__PURE__ */ new Set([
|
|
@@ -188,6 +188,8 @@ function memberColumns(db) {
|
|
|
188
188
|
principal: vaultMembers.principal,
|
|
189
189
|
status: vaultMembers.status,
|
|
190
190
|
owner: vaultMembers.owner,
|
|
191
|
+
role: vaultMembers.role,
|
|
192
|
+
scope: vaultMembers.scope,
|
|
191
193
|
generation: vaultMembers.generation,
|
|
192
194
|
createdAt: vaultMembers.createdAt,
|
|
193
195
|
createdBy: vaultMembers.createdBy,
|
|
@@ -197,14 +199,33 @@ function memberColumns(db) {
|
|
|
197
199
|
mac: vaultMembers.mac
|
|
198
200
|
};
|
|
199
201
|
}
|
|
202
|
+
/** A member as their row says (`storedRole`). */
|
|
203
|
+
function memberOf({ owner, role, scope, ...row }) {
|
|
204
|
+
return {
|
|
205
|
+
...row,
|
|
206
|
+
...storedRole({
|
|
207
|
+
owner,
|
|
208
|
+
role,
|
|
209
|
+
scope
|
|
210
|
+
})
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function storedChange(change) {
|
|
214
|
+
const { role, scope, ...rest } = change;
|
|
215
|
+
if (role === void 0) return rest;
|
|
216
|
+
return {
|
|
217
|
+
...rest,
|
|
218
|
+
...roleColumns(role, scope ?? EVERYWHERE)
|
|
219
|
+
};
|
|
220
|
+
}
|
|
200
221
|
async function member(db, principal) {
|
|
201
222
|
const { vaultMembers } = tablesOf(db);
|
|
202
223
|
const [row] = await db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, principal));
|
|
203
|
-
return row;
|
|
224
|
+
return row === void 0 ? void 0 : memberOf(row);
|
|
204
225
|
}
|
|
205
226
|
async function allMembers(db) {
|
|
206
227
|
const { vaultMembers } = tablesOf(db);
|
|
207
|
-
return await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal));
|
|
228
|
+
return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf);
|
|
208
229
|
}
|
|
209
230
|
/**
|
|
210
231
|
* Lock these members' rows for the rest of the transaction, in one order
|
|
@@ -215,7 +236,7 @@ async function allMembers(db) {
|
|
|
215
236
|
async function lockMembers(tx, principals) {
|
|
216
237
|
const { vaultMembers } = tablesOf(tx);
|
|
217
238
|
const rows = await forUpdate(tx, tx.select(memberColumns(tx)).from(vaultMembers).where(inArray(vaultMembers.principal, [...new Set(principals)])).orderBy(asc(vaultMembers.principal)), "no key update");
|
|
218
|
-
return new Map(rows.map((row) => [row.principal, row]));
|
|
239
|
+
return new Map(rows.map((row) => [row.principal, memberOf(row)]));
|
|
219
240
|
}
|
|
220
241
|
/**
|
|
221
242
|
* Add `row`. A principal has one row: when two decisions admit the same
|
|
@@ -223,11 +244,11 @@ async function lockMembers(tx, principals) {
|
|
|
223
244
|
*/
|
|
224
245
|
async function insertMember(tx, row) {
|
|
225
246
|
const { vaultMembers } = tablesOf(tx);
|
|
226
|
-
await tx.insert(vaultMembers).values(row);
|
|
247
|
+
await tx.insert(vaultMembers).values(storedChange(row));
|
|
227
248
|
}
|
|
228
249
|
async function updateMember(tx, principal, change) {
|
|
229
250
|
const { vaultMembers } = tablesOf(tx);
|
|
230
|
-
await tx.update(vaultMembers).set(change).where(eq(vaultMembers.principal, principal));
|
|
251
|
+
await tx.update(vaultMembers).set(storedChange(change)).where(eq(vaultMembers.principal, principal));
|
|
231
252
|
}
|
|
232
253
|
/** Grants, lapsed ones too, each with its project, which an environment's grant finds through `environments`. */
|
|
233
254
|
function grants(db, principal) {
|
|
@@ -289,7 +310,7 @@ async function wrappedUnder(db, provider, keyId, limit) {
|
|
|
289
310
|
bytes: Buffer.from(row.bytes)
|
|
290
311
|
}));
|
|
291
312
|
}
|
|
292
|
-
/** Of these projects and environments, the ones that exist, each environment with its project. */
|
|
313
|
+
/** Of these projects and environments, the ones that exist, each environment with its project and slug. */
|
|
293
314
|
async function places(db, projectIds, environmentIds) {
|
|
294
315
|
const { projects, environments } = tablesOf(db);
|
|
295
316
|
const [foundProjects, foundEnvironments] = await Promise.all([projectIds.length === 0 ? [] : db.select({
|
|
@@ -298,14 +319,43 @@ async function places(db, projectIds, environmentIds) {
|
|
|
298
319
|
}).from(projects).where(inArray(projects.id, [...projectIds])), environmentIds.length === 0 ? [] : db.select({
|
|
299
320
|
id: environments.id,
|
|
300
321
|
projectId: environments.projectId,
|
|
322
|
+
slug: environments.slug,
|
|
301
323
|
deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug)))
|
|
302
324
|
}).from(environments).innerJoin(projects, eq(projects.id, environments.projectId)).where(inArray(environments.id, [...environmentIds]))]);
|
|
303
325
|
return {
|
|
304
326
|
projects: new Set(foundProjects.map((row) => row.id)),
|
|
305
|
-
environments: new Map(foundEnvironments.map((row) => [row.id,
|
|
327
|
+
environments: new Map(foundEnvironments.map((row) => [row.id, {
|
|
328
|
+
projectId: row.projectId,
|
|
329
|
+
slug: row.slug
|
|
330
|
+
}])),
|
|
306
331
|
deleted: new Set([...foundProjects, ...foundEnvironments].filter((row) => row.deleted).map((row) => row.id))
|
|
307
332
|
};
|
|
308
333
|
}
|
|
334
|
+
/** Every project not deleted, with its environments not deleted: what a grant on every project of 0.4 reached. */
|
|
335
|
+
async function liveProjects(db) {
|
|
336
|
+
const { projects, environments } = tablesOf(db);
|
|
337
|
+
const [found, inside] = await Promise.all([db.select({
|
|
338
|
+
id: projects.id,
|
|
339
|
+
deleted: truth(tombstone(projects.slug))
|
|
340
|
+
}).from(projects).orderBy(asc(projects.id)), db.select({
|
|
341
|
+
id: environments.id,
|
|
342
|
+
projectId: environments.projectId,
|
|
343
|
+
slug: environments.slug,
|
|
344
|
+
deleted: truth(tombstone(environments.slug))
|
|
345
|
+
}).from(environments).orderBy(asc(environments.id))]);
|
|
346
|
+
return found.filter((project) => !project.deleted).map((project) => ({
|
|
347
|
+
id: project.id,
|
|
348
|
+
environments: inside.filter((environment) => environment.projectId === project.id && !environment.deleted).map(({ id, slug }) => ({
|
|
349
|
+
id,
|
|
350
|
+
slug
|
|
351
|
+
}))
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
/** Who holds a grant on every project of 0.4, lapsed or not, which the vault replaces (`#convert`). */
|
|
355
|
+
async function everyProjectHolders(db) {
|
|
356
|
+
const { vaultGrants } = tablesOf(db);
|
|
357
|
+
return (await db.selectDistinct({ principal: vaultGrants.principal }).from(vaultGrants).where(and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId))).orderBy(asc(vaultGrants.principal))).map((row) => row.principal);
|
|
358
|
+
}
|
|
309
359
|
/** The action of an entry that releases a key, which the bulk limit counts. */
|
|
310
360
|
const RELEASE = "secret.read";
|
|
311
361
|
/** How many keys the vault has released to `principal` since `after`. */
|
|
@@ -314,32 +364,6 @@ async function releasesSince(db, principal, after) {
|
|
|
314
364
|
const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, principal), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, after)));
|
|
315
365
|
return Number(n);
|
|
316
366
|
}
|
|
317
|
-
function entryColumns(db) {
|
|
318
|
-
const { auditLog } = tablesOf(db);
|
|
319
|
-
return {
|
|
320
|
-
seq: auditLog.seq,
|
|
321
|
-
author: auditLog.author,
|
|
322
|
-
keyId: auditLog.keyId,
|
|
323
|
-
occurredAt: auditLog.occurredAt,
|
|
324
|
-
actor: auditLog.actor,
|
|
325
|
-
action: auditLog.action,
|
|
326
|
-
decision: auditLog.decision,
|
|
327
|
-
code: auditLog.code,
|
|
328
|
-
subjectPrincipal: auditLog.subjectPrincipal,
|
|
329
|
-
projectId: auditLog.projectId,
|
|
330
|
-
environmentId: auditLog.environmentId,
|
|
331
|
-
secretId: auditLog.secretId,
|
|
332
|
-
secretVersionId: auditLog.secretVersionId,
|
|
333
|
-
operationId: auditLog.operationId,
|
|
334
|
-
requestId: auditLog.requestId,
|
|
335
|
-
sourceIp: auditLog.sourceIp,
|
|
336
|
-
relatedSeq: auditLog.relatedSeq,
|
|
337
|
-
metadata: auditLog.metadata,
|
|
338
|
-
prevHash: auditLog.prevHash,
|
|
339
|
-
mac: auditLog.mac,
|
|
340
|
-
hash: auditLog.hash
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
367
|
/** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
|
|
344
368
|
function stored(rows) {
|
|
345
369
|
return rows;
|
|
@@ -452,10 +476,10 @@ async function latestVaultEntry(db, actions) {
|
|
|
452
476
|
const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...actions]), eq(auditLog.decision, "allow"))).orderBy(desc(auditLog.seq)).limit(1);
|
|
453
477
|
return row === void 0 ? void 0 : stored([row])[0];
|
|
454
478
|
}
|
|
455
|
-
/** Up to `limit` of the vault's allowed
|
|
456
|
-
async function
|
|
479
|
+
/** Up to `limit` of the vault's allowed entries of `action` before `before` (the newest when undefined), newest first. */
|
|
480
|
+
async function newestVaultEntries(db, action, limit, before) {
|
|
457
481
|
const { auditLog } = tablesOf(db);
|
|
458
|
-
return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.
|
|
482
|
+
return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, action), eq(auditLog.decision, "allow"), before === void 0 ? void 0 : lt(auditLog.seq, before))).orderBy(desc(auditLog.seq)).limit(limit));
|
|
459
483
|
}
|
|
460
484
|
/** The vault's newest allowed access entry about each member who has one. */
|
|
461
485
|
async function newestAccessEntries(db) {
|
|
@@ -681,7 +705,8 @@ function replayFault(state, rows, storedGrants, at) {
|
|
|
681
705
|
kind: "missing-member",
|
|
682
706
|
principal
|
|
683
707
|
};
|
|
684
|
-
const
|
|
708
|
+
const same = (field) => field === "scope" ? JSON.stringify(inStore.scope) === JSON.stringify(inLog.scope) : inStore[field] === inLog[field];
|
|
709
|
+
const fields = MEMBER_FIELDS.filter(([field]) => !same(field)).map(([, column]) => column);
|
|
685
710
|
if (fields.length > 0) return {
|
|
686
711
|
kind: "member-differs",
|
|
687
712
|
principal,
|
|
@@ -721,7 +746,7 @@ function apply(state, row) {
|
|
|
721
746
|
state.members.set(principal, {
|
|
722
747
|
principal,
|
|
723
748
|
status: "active",
|
|
724
|
-
|
|
749
|
+
...standingIn(detail),
|
|
725
750
|
generation: before?.generation ?? 0,
|
|
726
751
|
createdAt: before?.createdAt ?? row.occurredAt,
|
|
727
752
|
createdBy: before?.createdBy ?? row.actor,
|
|
@@ -730,6 +755,7 @@ function apply(state, row) {
|
|
|
730
755
|
});
|
|
731
756
|
return null;
|
|
732
757
|
case "member.owner":
|
|
758
|
+
case "member.role":
|
|
733
759
|
if (before === void 0) return {
|
|
734
760
|
kind: "unadmitted-change",
|
|
735
761
|
seq: Number(row.seq),
|
|
@@ -737,7 +763,7 @@ function apply(state, row) {
|
|
|
737
763
|
};
|
|
738
764
|
state.members.set(principal, {
|
|
739
765
|
...before,
|
|
740
|
-
|
|
766
|
+
...standingIn(detail),
|
|
741
767
|
accessSeq: row.seq
|
|
742
768
|
});
|
|
743
769
|
return null;
|
|
@@ -751,7 +777,8 @@ function apply(state, row) {
|
|
|
751
777
|
state.members.set(principal, {
|
|
752
778
|
...before,
|
|
753
779
|
status: "removed",
|
|
754
|
-
|
|
780
|
+
role: "member",
|
|
781
|
+
scope: EVERYWHERE,
|
|
755
782
|
generation,
|
|
756
783
|
accessSeq: row.seq,
|
|
757
784
|
...changed
|
|
@@ -783,10 +810,26 @@ function apply(state, row) {
|
|
|
783
810
|
}
|
|
784
811
|
return null;
|
|
785
812
|
}
|
|
813
|
+
/**
|
|
814
|
+
* The instance role an entry gives: its `role` and `scope`, or, in one
|
|
815
|
+
* 0.4 wrote, whether it made them an owner, an Admin everywhere.
|
|
816
|
+
*/
|
|
817
|
+
function standingIn(detail) {
|
|
818
|
+
if (typeof detail.role !== "string") return {
|
|
819
|
+
role: detail.owner === true ? "admin" : "member",
|
|
820
|
+
scope: EVERYWHERE
|
|
821
|
+
};
|
|
822
|
+
const role = isInstanceRole(detail.role) ? detail.role : "member";
|
|
823
|
+
return {
|
|
824
|
+
role,
|
|
825
|
+
scope: role !== "member" && isScope(detail.scope) ? normalScope(detail.scope) : EVERYWHERE
|
|
826
|
+
};
|
|
827
|
+
}
|
|
786
828
|
/** A member's fields, and the columns a fault names them by. */
|
|
787
829
|
const MEMBER_FIELDS = [
|
|
788
830
|
["status", "status"],
|
|
789
|
-
["
|
|
831
|
+
["role", "role"],
|
|
832
|
+
["scope", "scope"],
|
|
790
833
|
["generation", "generation"],
|
|
791
834
|
["createdAt", "created_at"],
|
|
792
835
|
["createdBy", "created_by"],
|
|
@@ -812,7 +855,7 @@ function placeOf(row, detail) {
|
|
|
812
855
|
}
|
|
813
856
|
/** A grant's place: its environment, or its project when it has none, or every project's path. */
|
|
814
857
|
function placeKey$1(place) {
|
|
815
|
-
return place.environmentId ?? place.projectId ??
|
|
858
|
+
return place.environmentId ?? place.projectId ?? (place.environmentSlug === null ? "*" : `*/${place.environmentSlug}`);
|
|
816
859
|
}
|
|
817
860
|
function grantKey(grant) {
|
|
818
861
|
return JSON.stringify([
|
|
@@ -858,8 +901,8 @@ function rowKey(signingKey) {
|
|
|
858
901
|
/**
|
|
859
902
|
* A member's grants as the MAC covers them: each one's place, role, end and
|
|
860
903
|
* grant, as a sorted set. A grant is kept by what its row names first: its
|
|
861
|
-
* environment, its project, or neither, which is a grant on every project
|
|
862
|
-
* with the slug it covers, or null. A member who holds none of those has the
|
|
904
|
+
* environment, its project, or neither, which is a grant on every project
|
|
905
|
+
* of 0.4, with the slug it covers, or null, until the vault replaces it. A member who holds none of those has the
|
|
863
906
|
* set, and so the MAC, they had before there were any.
|
|
864
907
|
*/
|
|
865
908
|
function grantSet(grants) {
|
|
@@ -877,13 +920,24 @@ function sameGrants(a, b) {
|
|
|
877
920
|
const [x, y] = [grantSet(a), grantSet(b)];
|
|
878
921
|
return x.length === y.length && x.every((tuple, i) => tuple === y[i]);
|
|
879
922
|
}
|
|
923
|
+
/**
|
|
924
|
+
* A member's instance role and its scope, as the MAC covers them: as 0.4
|
|
925
|
+
* did, whether they are an owner, for a Member or an Admin everywhere, so
|
|
926
|
+
* that every row 0.4 sealed holds as it is; otherwise the role and the
|
|
927
|
+
* scope, which no row of 0.4 can match.
|
|
928
|
+
*/
|
|
929
|
+
function standing(member) {
|
|
930
|
+
const scope = normalScope(member.scope);
|
|
931
|
+
if (unscoped(scope) && (member.role === "member" || member.role === "admin")) return member.role === "admin";
|
|
932
|
+
return [member.role, scope];
|
|
933
|
+
}
|
|
880
934
|
function memberMac(key, member, grants) {
|
|
881
935
|
const held = grantSet(grants);
|
|
882
936
|
const tuple = [
|
|
883
937
|
"coffre.vault.member.v1",
|
|
884
938
|
member.principal,
|
|
885
939
|
member.status,
|
|
886
|
-
member
|
|
940
|
+
standing(member),
|
|
887
941
|
member.generation,
|
|
888
942
|
member.accessSeq.toString(),
|
|
889
943
|
member.createdAt,
|
|
@@ -915,7 +969,8 @@ async function prepareVault(config, options = {}) {
|
|
|
915
969
|
since: null,
|
|
916
970
|
superseded: null,
|
|
917
971
|
kekChecked: false,
|
|
918
|
-
wrongKek: null
|
|
972
|
+
wrongKek: null,
|
|
973
|
+
converted: false
|
|
919
974
|
};
|
|
920
975
|
}
|
|
921
976
|
/** The vault's own keys, from each of its signing keys, the one it signs with first. */
|
|
@@ -942,6 +997,9 @@ const KEY_BUDGET_MS = 5e3;
|
|
|
942
997
|
const LOCK_TIMEOUT_MS = 15e3;
|
|
943
998
|
/** A member: `user:<email>` or `token:<name>`. */
|
|
944
999
|
const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
1000
|
+
/** A person, and a service account: only a person sets one up (`givesService`). */
|
|
1001
|
+
const PERSON = /^user:/;
|
|
1002
|
+
const SERVICE = /^token:/;
|
|
945
1003
|
/** Who acts for the vault itself, as when it gives a root admin a member row. */
|
|
946
1004
|
const VAULT_ACTOR = "system:vault";
|
|
947
1005
|
/** How many stored keys a KEK with no check yet is tried on: one that opens proves it. */
|
|
@@ -957,6 +1015,17 @@ const UNSEALED = {
|
|
|
957
1015
|
const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
|
|
958
1016
|
/** The action of the vault's entry that signs a prefix of the log. */
|
|
959
1017
|
const CHECKPOINT = "audit.checkpoint";
|
|
1018
|
+
/** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
|
|
1019
|
+
const SETTINGS = "settings.change";
|
|
1020
|
+
/** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
|
|
1021
|
+
const DEFAULT_SETTINGS = { serviceAccounts: EVERYWHERE };
|
|
1022
|
+
/** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
|
|
1023
|
+
const CLOSED_SETTINGS = { serviceAccounts: {
|
|
1024
|
+
projects: { only: [] },
|
|
1025
|
+
environments: "all"
|
|
1026
|
+
} };
|
|
1027
|
+
/** Why a person's change to a service account is refused. */
|
|
1028
|
+
const NOT_THEIRS = "a person gives a service account at most what they hold, where the instance lets them set one up, and only to one whose every grant they hold";
|
|
960
1029
|
/** Who asks for checkpoints: the app's scheduled job. */
|
|
961
1030
|
const SCHEDULER = "system:coffre-scheduler";
|
|
962
1031
|
/**
|
|
@@ -1009,6 +1078,26 @@ const MESSAGES = {
|
|
|
1009
1078
|
wrong_kek: "this vault's key does not open the data it holds",
|
|
1010
1079
|
tampered: "this member's record failed the vault's integrity check"
|
|
1011
1080
|
};
|
|
1081
|
+
/**
|
|
1082
|
+
* What the one asking holds through the credential their request came in
|
|
1083
|
+
* on: no more than its scope (`allows`). A scope that is not one is a bug
|
|
1084
|
+
* of the app's, which fails the call.
|
|
1085
|
+
*/
|
|
1086
|
+
function through(standing, { credentialScope }) {
|
|
1087
|
+
if (credentialScope == null) return standing;
|
|
1088
|
+
if (!isCredentialScope(credentialScope)) throw new Error("credentialScope must be a list of grants, one a place");
|
|
1089
|
+
return {
|
|
1090
|
+
...standing,
|
|
1091
|
+
live: {
|
|
1092
|
+
...standing.live,
|
|
1093
|
+
credentialScope
|
|
1094
|
+
},
|
|
1095
|
+
all: {
|
|
1096
|
+
...standing.all,
|
|
1097
|
+
credentialScope
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1012
1101
|
/** The credential a CI run's request came in on, as its entries record it: copied, never decided on. */
|
|
1013
1102
|
function traced(correlation) {
|
|
1014
1103
|
return correlation.credentialId == null ? {} : { credentialId: correlation.credentialId };
|
|
@@ -1053,6 +1142,10 @@ var VaultService = class {
|
|
|
1053
1142
|
ok: false,
|
|
1054
1143
|
refusal: refusal("wrong_kek", superseded)
|
|
1055
1144
|
};
|
|
1145
|
+
return this.#decideNow(principals, decide);
|
|
1146
|
+
}
|
|
1147
|
+
/** `#decide`, once the vault is settled. */
|
|
1148
|
+
async #decideNow(principals, decide) {
|
|
1056
1149
|
const reports = [];
|
|
1057
1150
|
for (let attempt = 1;; attempt += 1) try {
|
|
1058
1151
|
return await this.#decideOnce(principals, decide, reports);
|
|
@@ -1152,19 +1245,23 @@ var VaultService = class {
|
|
|
1152
1245
|
this.#report(reports, principal, "mac", row.mac.toString("hex"));
|
|
1153
1246
|
return "mac";
|
|
1154
1247
|
}
|
|
1155
|
-
const newest = await this.#
|
|
1156
|
-
|
|
1248
|
+
const newest = await this.#newestAccess(db, principal, reports);
|
|
1249
|
+
const seq = typeof newest !== "string" ? newest.seq : newest === "unreached" ? void 0 : null;
|
|
1250
|
+
if (row === void 0 ? seq === null : seq === row.accessSeq) return null;
|
|
1157
1251
|
if (row === void 0 && await member(db, principal) !== void 0) throw new Retry();
|
|
1158
|
-
this.#report(reports, principal, "stale", `${row?.accessSeq ?? "none"}<${newest}`);
|
|
1252
|
+
this.#report(reports, principal, "stale", `${row?.accessSeq ?? "none"}<${seq ?? newest}`);
|
|
1159
1253
|
return "stale";
|
|
1160
1254
|
}
|
|
1161
|
-
/** The
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
return
|
|
1255
|
+
/** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
|
|
1256
|
+
#newestAccess(db, principal, reports) {
|
|
1257
|
+
return newestAccess(db, principal, this.#check(reports, principal));
|
|
1258
|
+
}
|
|
1259
|
+
/** Entries in the vault's name told apart by their MACs, each it did not write reported, about `principal`. */
|
|
1260
|
+
#check(reports, principal) {
|
|
1261
|
+
return {
|
|
1262
|
+
authentic: (entry) => this.#authentic(entry),
|
|
1263
|
+
forged: (entry) => this.#report(reports, principal, "forged_entry", String(entry.seq), entry.seq)
|
|
1264
|
+
};
|
|
1168
1265
|
}
|
|
1169
1266
|
/** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
|
|
1170
1267
|
#authentic(entry) {
|
|
@@ -1222,9 +1319,127 @@ var VaultService = class {
|
|
|
1222
1319
|
async #settled() {
|
|
1223
1320
|
const prepared = this.#prepared;
|
|
1224
1321
|
if (prepared.since === null && prepared.superseded === null) await this.#settle();
|
|
1322
|
+
if (prepared.superseded === null && !prepared.converted) await this.#convert();
|
|
1225
1323
|
return prepared.superseded;
|
|
1226
1324
|
}
|
|
1227
1325
|
/**
|
|
1326
|
+
* Replace every grant on every project of 0.4 with what 0.5 has instead
|
|
1327
|
+
* (`convertEveryProjectGrants` in @coffre/core/access): a person's
|
|
1328
|
+
* instance role and its scope, and project grants on the projects there
|
|
1329
|
+
* are now. Once per process, before its first call does anything else:
|
|
1330
|
+
* one decision per member who holds any, as `system:vault`, each grant it
|
|
1331
|
+
* replaces an `access.revoke` that says what became of it. Another
|
|
1332
|
+
* process that converts the same member at once finds nothing left under
|
|
1333
|
+
* their lock. A member whose row fails its check is left as they are, and
|
|
1334
|
+
* so is one the vault has reported changed around it since the last
|
|
1335
|
+
* change to what they hold, even if their row checks out again now (a
|
|
1336
|
+
* genuine row put back). `reportStands` is the rule, which `coffre
|
|
1337
|
+
* migrate` applies too, holding no key, to every entry in the vault's
|
|
1338
|
+
* name: it lists and marks a member so reported, forged or not.
|
|
1339
|
+
* Removing them clears both.
|
|
1340
|
+
*/
|
|
1341
|
+
async #convert() {
|
|
1342
|
+
for (const principal of await everyProjectHolders(this.#db)) {
|
|
1343
|
+
if (this.#isRootAdmin(principal)) continue;
|
|
1344
|
+
await this.#decideNow([principal], (d) => this.#replaceEveryProject(d, principal));
|
|
1345
|
+
}
|
|
1346
|
+
this.#prepared.converted = true;
|
|
1347
|
+
}
|
|
1348
|
+
async #replaceEveryProject(d, principal) {
|
|
1349
|
+
const row = d.members.get(principal);
|
|
1350
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1351
|
+
if (row === void 0 || subject.status !== "active") return;
|
|
1352
|
+
const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
|
|
1353
|
+
if (everyProject.length === 0) return;
|
|
1354
|
+
if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
|
|
1355
|
+
await lockLogHead(d.tx);
|
|
1356
|
+
const before = roleOf(principal, row);
|
|
1357
|
+
const conversion = convertEveryProjectGrants({
|
|
1358
|
+
person: principal.startsWith("user:"),
|
|
1359
|
+
role: before.role,
|
|
1360
|
+
everyProject: everyProject.filter((grant) => live(grant, d.at)).map((grant) => ({
|
|
1361
|
+
environmentSlug: grant.environmentSlug,
|
|
1362
|
+
role: grant.role,
|
|
1363
|
+
expiresAt: grant.expiresAt
|
|
1364
|
+
})),
|
|
1365
|
+
grants: subject.stored.filter((grant) => grantKind(grant) !== null && live(grant, d.at)).map((grant) => ({
|
|
1366
|
+
projectId: grant.projectId,
|
|
1367
|
+
environmentId: grant.environmentId,
|
|
1368
|
+
role: grant.role,
|
|
1369
|
+
expiresAt: grant.expiresAt
|
|
1370
|
+
})),
|
|
1371
|
+
projects: await liveProjects(d.tx)
|
|
1372
|
+
});
|
|
1373
|
+
const after = {
|
|
1374
|
+
role: conversion.role,
|
|
1375
|
+
scope: conversion.scope
|
|
1376
|
+
};
|
|
1377
|
+
const replacedBy = {
|
|
1378
|
+
role: after.role,
|
|
1379
|
+
scope: after.scope,
|
|
1380
|
+
grants: conversion.grants.length
|
|
1381
|
+
};
|
|
1382
|
+
d.grants.set(principal, [...subject.stored]);
|
|
1383
|
+
d.touched.add(principal);
|
|
1384
|
+
const correlation = { operationId: randomUUID() };
|
|
1385
|
+
const drop = (grant) => d.writes.push(async () => {
|
|
1386
|
+
await deleteGrant(d.tx, principal, grant);
|
|
1387
|
+
d.grants.set(principal, d.grants.get(principal).filter((held) => held !== grant));
|
|
1388
|
+
});
|
|
1389
|
+
for (const grant of everyProject) {
|
|
1390
|
+
drop(grant);
|
|
1391
|
+
if (!live(grant, d.at)) continue;
|
|
1392
|
+
const narrowed = conversion.narrowed.filter((loss) => loss.grant.environmentSlug === grant.environmentSlug);
|
|
1393
|
+
d.log.push(accessEntry(VAULT_ACTOR, "access.revoke", principal, "allow", correlation, {
|
|
1394
|
+
role: null,
|
|
1395
|
+
expiresAt: null,
|
|
1396
|
+
previousRole: grant.role,
|
|
1397
|
+
...located(grant).detail,
|
|
1398
|
+
replacedBy,
|
|
1399
|
+
...narrowed.length === 0 ? {} : { narrowed: narrowed.map(({ grant: _, ...loss }) => loss) }
|
|
1400
|
+
}));
|
|
1401
|
+
}
|
|
1402
|
+
if (JSON.stringify(standing(after)) !== JSON.stringify(standing(before))) {
|
|
1403
|
+
d.log.push(accessEntry(VAULT_ACTOR, "member.role", principal, "allow", correlation, {
|
|
1404
|
+
...after,
|
|
1405
|
+
previousRole: before.role,
|
|
1406
|
+
previousScope: before.scope,
|
|
1407
|
+
reason: "every-project"
|
|
1408
|
+
}));
|
|
1409
|
+
d.writes.push(() => updateMember(d.tx, principal, after));
|
|
1410
|
+
}
|
|
1411
|
+
for (const given of conversion.grants) {
|
|
1412
|
+
const place = {
|
|
1413
|
+
projectId: given.projectId,
|
|
1414
|
+
environmentId: given.environmentId,
|
|
1415
|
+
environmentSlug: null
|
|
1416
|
+
};
|
|
1417
|
+
const there = subject.stored.find((grant) => grantKind(grant) !== null && placeKey(grant) === placeKey(place));
|
|
1418
|
+
if (there !== void 0) drop(there);
|
|
1419
|
+
d.writes.push(async (at) => {
|
|
1420
|
+
const grant = {
|
|
1421
|
+
principal,
|
|
1422
|
+
...place,
|
|
1423
|
+
role: given.role,
|
|
1424
|
+
expiresAt: given.expiresAt,
|
|
1425
|
+
grantedAt: at,
|
|
1426
|
+
grantedBy: VAULT_ACTOR
|
|
1427
|
+
};
|
|
1428
|
+
await insertGrant(d.tx, grant);
|
|
1429
|
+
d.grants.get(principal).push(grant);
|
|
1430
|
+
});
|
|
1431
|
+
d.log.push({
|
|
1432
|
+
...accessEntry(VAULT_ACTOR, "access.grant", principal, "allow", correlation, {
|
|
1433
|
+
role: given.role,
|
|
1434
|
+
expiresAt: given.expiresAt === null ? null : iso(given.expiresAt),
|
|
1435
|
+
previousRole: given.replaces,
|
|
1436
|
+
reason: "every-project"
|
|
1437
|
+
}),
|
|
1438
|
+
...located(place).ids
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1228
1443
|
* Which of its keys count where. Those it replaced, from the KEKs in
|
|
1229
1444
|
* previousKeks, verify only what came before its first entry under its
|
|
1230
1445
|
* current key, `since`, and vouch for no member row after it. The first
|
|
@@ -1516,7 +1731,7 @@ var VaultService = class {
|
|
|
1516
1731
|
});
|
|
1517
1732
|
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1518
1733
|
const check = async (d) => {
|
|
1519
|
-
const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
|
|
1734
|
+
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
|
|
1520
1735
|
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
1521
1736
|
const environments = await environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
|
|
1522
1737
|
const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
|
|
@@ -1662,7 +1877,7 @@ var VaultService = class {
|
|
|
1662
1877
|
};
|
|
1663
1878
|
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1664
1879
|
return this.#decide([principal], async (d) => {
|
|
1665
|
-
const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
|
|
1880
|
+
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1666
1881
|
const environments = await environmentsById(d.tx, items.flatMap((item) => {
|
|
1667
1882
|
const source = sources.get(item.source.secretId);
|
|
1668
1883
|
return source === void 0 ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
|
|
@@ -1743,7 +1958,7 @@ var VaultService = class {
|
|
|
1743
1958
|
return seals.map((seal) => done.has(seal.seq) ? "ended" : null);
|
|
1744
1959
|
};
|
|
1745
1960
|
return this.#decide([principal], async (d) => {
|
|
1746
|
-
const actor = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
|
|
1961
|
+
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1747
1962
|
const environments = await environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
|
|
1748
1963
|
const codes = seals.map((seal) => {
|
|
1749
1964
|
const holder = refuses(actor, "secret.write", placeIn(seal.holder, environments), false);
|
|
@@ -1809,14 +2024,15 @@ var VaultService = class {
|
|
|
1809
2024
|
decision: "allow",
|
|
1810
2025
|
subjectPrincipal: principal,
|
|
1811
2026
|
metadata: JSON.stringify({
|
|
1812
|
-
|
|
2027
|
+
role: "member",
|
|
1813
2028
|
rootAdmin: true
|
|
1814
2029
|
})
|
|
1815
2030
|
}]);
|
|
1816
2031
|
const row = {
|
|
1817
2032
|
principal,
|
|
1818
2033
|
status: "active",
|
|
1819
|
-
|
|
2034
|
+
role: "member",
|
|
2035
|
+
scope: EVERYWHERE,
|
|
1820
2036
|
generation: 0,
|
|
1821
2037
|
createdAt: at,
|
|
1822
2038
|
createdBy: VAULT_ACTOR,
|
|
@@ -1837,15 +2053,12 @@ var VaultService = class {
|
|
|
1837
2053
|
* configuration, which no row can change.
|
|
1838
2054
|
*/
|
|
1839
2055
|
async #standing(db, principal, row, at, reports) {
|
|
1840
|
-
const none =
|
|
1841
|
-
isRootAdmin: false,
|
|
1842
|
-
isOwner: false,
|
|
1843
|
-
grants: []
|
|
1844
|
-
};
|
|
2056
|
+
const none = NOTHING;
|
|
1845
2057
|
if (this.#isRootAdmin(principal)) {
|
|
1846
2058
|
const root = {
|
|
1847
2059
|
isRootAdmin: true,
|
|
1848
|
-
|
|
2060
|
+
role: "owner",
|
|
2061
|
+
scope: EVERYWHERE,
|
|
1849
2062
|
grants: []
|
|
1850
2063
|
};
|
|
1851
2064
|
return {
|
|
@@ -1887,18 +2100,20 @@ var VaultService = class {
|
|
|
1887
2100
|
...grant,
|
|
1888
2101
|
role: grant.role
|
|
1889
2102
|
}));
|
|
1890
|
-
const
|
|
2103
|
+
const { role, scope } = roleOf(principal, row);
|
|
1891
2104
|
return {
|
|
1892
2105
|
principal,
|
|
1893
2106
|
status: "active",
|
|
1894
2107
|
live: {
|
|
1895
2108
|
isRootAdmin: false,
|
|
1896
|
-
|
|
2109
|
+
role,
|
|
2110
|
+
scope,
|
|
1897
2111
|
grants: held.filter((grant) => live(grant, at))
|
|
1898
2112
|
},
|
|
1899
2113
|
all: {
|
|
1900
2114
|
isRootAdmin: false,
|
|
1901
|
-
|
|
2115
|
+
role,
|
|
2116
|
+
scope,
|
|
1902
2117
|
grants: held
|
|
1903
2118
|
},
|
|
1904
2119
|
fault,
|
|
@@ -1911,7 +2126,8 @@ var VaultService = class {
|
|
|
1911
2126
|
status: "active",
|
|
1912
2127
|
generation: row?.generation ?? 0,
|
|
1913
2128
|
isRootAdmin: true,
|
|
1914
|
-
|
|
2129
|
+
role: "owner",
|
|
2130
|
+
scope: EVERYWHERE,
|
|
1915
2131
|
grants: [],
|
|
1916
2132
|
since: null,
|
|
1917
2133
|
by: null
|
|
@@ -1922,8 +2138,11 @@ var VaultService = class {
|
|
|
1922
2138
|
status: tampered ? "tampered" : !PRINCIPAL.test(principal) ? "unknown" : row?.status ?? "unknown",
|
|
1923
2139
|
generation: row?.generation ?? 0,
|
|
1924
2140
|
isRootAdmin: false,
|
|
1925
|
-
|
|
1926
|
-
|
|
2141
|
+
...active ? roleOf(principal, row) : {
|
|
2142
|
+
role: "member",
|
|
2143
|
+
scope: EVERYWHERE
|
|
2144
|
+
},
|
|
2145
|
+
grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
|
|
1927
2146
|
since: row ? iso(row.statusChangedAt) : null,
|
|
1928
2147
|
by: row?.statusChangedBy ?? null
|
|
1929
2148
|
};
|
|
@@ -1983,9 +2202,12 @@ var VaultService = class {
|
|
|
1983
2202
|
}
|
|
1984
2203
|
setAccess(input) {
|
|
1985
2204
|
const { actor, principal } = input;
|
|
1986
|
-
const changes = input.changes.map((
|
|
1987
|
-
|
|
1988
|
-
|
|
2205
|
+
const changes = input.changes.map(({ projectId, environmentId, role, expiresAt }) => ({
|
|
2206
|
+
projectId,
|
|
2207
|
+
environmentId,
|
|
2208
|
+
environmentSlug: null,
|
|
2209
|
+
role,
|
|
2210
|
+
expiresAt
|
|
1989
2211
|
}));
|
|
1990
2212
|
const action = changes.every((change) => change.role === null) ? "access.revoke" : "access.grant";
|
|
1991
2213
|
const [only] = changes.length === 1 ? changes : [];
|
|
@@ -2002,41 +2224,54 @@ var VaultService = class {
|
|
|
2002
2224
|
...where.ids
|
|
2003
2225
|
}]);
|
|
2004
2226
|
};
|
|
2005
|
-
const onProjects = changes.flatMap(({ projectId, environmentId, role }) => projectId === null ? [] : [{
|
|
2006
|
-
projectId,
|
|
2007
|
-
environmentId,
|
|
2008
|
-
role
|
|
2009
|
-
}]);
|
|
2010
2227
|
return this.#decide([actor, principal], async (d) => {
|
|
2011
2228
|
validateCorrelation(input);
|
|
2012
2229
|
if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a principal: ${principal}`);
|
|
2013
2230
|
if (this.#isRootAdmin(principal)) throw refused("root_admin");
|
|
2014
2231
|
const places$1 = /* @__PURE__ */ new Set();
|
|
2015
2232
|
for (const change of changes) {
|
|
2016
|
-
|
|
2017
|
-
if (shape !== null) throw refused("invalid", shape);
|
|
2233
|
+
if (grantKind(change) === null) throw refused("invalid", change.projectId === null && change.environmentId === null ? "grants are on a project or an environment: a person reaches every project by their instance role" : "an environment is named in its project");
|
|
2018
2234
|
const key = placeKey(change);
|
|
2019
2235
|
if (places$1.has(key)) throw refused("invalid", "each place may be changed once per call");
|
|
2020
2236
|
places$1.add(key);
|
|
2021
2237
|
if (change.role !== null && !isRole(change.role)) throw refused("invalid", `no such role: ${change.role}`);
|
|
2022
|
-
if (change.role !== null &&
|
|
2238
|
+
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) throw refused("invalid", `${change.role} can only be granted on a project`);
|
|
2023
2239
|
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
2024
2240
|
if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
|
|
2025
2241
|
}
|
|
2026
|
-
|
|
2027
|
-
const known = await places(d.tx,
|
|
2028
|
-
|
|
2029
|
-
|
|
2242
|
+
await lockLogHead(d.tx);
|
|
2243
|
+
const known = await places(d.tx, changes.map((change) => change.projectId), changes.flatMap((change) => change.environmentId === null ? [] : [change.environmentId]));
|
|
2244
|
+
const where = changes.map(({ projectId, environmentId, role }) => {
|
|
2245
|
+
const environment = environmentId === null ? void 0 : known.environments.get(environmentId);
|
|
2246
|
+
if (!known.projects.has(projectId) || environmentId !== null && environment?.projectId !== projectId) throw refused("invalid", `no such place: ${environmentId === null ? projectId : `${projectId}/${environmentId}`}`);
|
|
2030
2247
|
if (role !== null && (known.deleted.has(projectId) || environmentId !== null && known.deleted.has(environmentId))) throw refused("deleted");
|
|
2031
|
-
|
|
2032
|
-
|
|
2248
|
+
return environmentId === null ? { projectId } : {
|
|
2249
|
+
projectId,
|
|
2250
|
+
environmentId,
|
|
2251
|
+
environmentSlug: environment.slug
|
|
2252
|
+
};
|
|
2253
|
+
});
|
|
2254
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2033
2255
|
if (acting.status === "tampered") throw refused("tampered");
|
|
2034
|
-
|
|
2256
|
+
const manages = where.every((place) => mayManageAccess(acting.live, place));
|
|
2257
|
+
if (!manages && !(SERVICE.test(principal) && PERSON.test(actor))) throw refused("not_allowed");
|
|
2258
|
+
if (actor === principal && !acting.live.isRootAdmin && changes.some((change) => change.role !== null)) throw refused("not_allowed", "nobody grants themselves a role: ask another admin");
|
|
2035
2259
|
const row = d.members.get(principal);
|
|
2036
2260
|
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
2037
2261
|
if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
|
|
2038
2262
|
if (row?.status === "removed") throw refused("removed");
|
|
2039
2263
|
if (row === void 0) throw refused("not_a_member");
|
|
2264
|
+
if (!manages) {
|
|
2265
|
+
const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
|
|
2266
|
+
const account = await this.#account(d.tx, subject, row);
|
|
2267
|
+
if (!changes.every((change, i) => {
|
|
2268
|
+
const before = subject.live.grants.find((grant) => placeKey({
|
|
2269
|
+
...grant,
|
|
2270
|
+
environmentSlug: null
|
|
2271
|
+
}) === placeKey(change));
|
|
2272
|
+
return [change.role, before?.role].every((role) => role == null || givesService(acting.live, setting, role, where[i]));
|
|
2273
|
+
}) || !managesService(acting.live, setting, actor, account)) throw refused("not_allowed", NOT_THEIRS);
|
|
2274
|
+
}
|
|
2040
2275
|
const held = subject.stored;
|
|
2041
2276
|
d.grants.set(principal, [...held]);
|
|
2042
2277
|
const outcomes = changes.map((change) => this.#apply(d, actor, principal, held, change, input));
|
|
@@ -2097,29 +2332,52 @@ var VaultService = class {
|
|
|
2097
2332
|
}
|
|
2098
2333
|
admit(input) {
|
|
2099
2334
|
const { actor, principal } = input;
|
|
2100
|
-
const
|
|
2335
|
+
const asked = input.role === void 0 ? {} : {
|
|
2336
|
+
role: input.role,
|
|
2337
|
+
scope: input.scope ?? EVERYWHERE
|
|
2338
|
+
};
|
|
2339
|
+
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.add", principal, "deny", input, asked, code)]);
|
|
2101
2340
|
return this.#decide([actor, principal], async (d) => {
|
|
2102
2341
|
validateCorrelation(input);
|
|
2103
|
-
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
2342
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2104
2343
|
if (acting.status === "tampered") throw refused("tampered");
|
|
2105
|
-
if (!acting.live
|
|
2344
|
+
if (!runsInstance(acting.live)) {
|
|
2345
|
+
const service = SERVICE.test(principal) && (input.role ?? "member") === "member" && input.scope === void 0;
|
|
2346
|
+
const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
|
|
2347
|
+
if (!service || !PERSON.test(actor) || !setsUpServices(acting.live, setting, placesOf(await liveProjects(d.tx)))) throw refused("not_allowed", "only admins and owners of the whole instance may add people or set their roles, and a service account takes access somewhere the instance lets people set one up");
|
|
2348
|
+
const removed = d.members.get(principal);
|
|
2349
|
+
if (removed?.status === "removed" && removed.statusChangedBy !== actor) throw refused("not_allowed", `${shownMember(removed.statusChangedBy)} removed this service account: only they, or someone who runs the whole instance, may bring it back`);
|
|
2350
|
+
}
|
|
2106
2351
|
if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
|
|
2107
2352
|
if (this.#isRootAdmin(principal)) throw refused("root_admin");
|
|
2108
|
-
if (input.
|
|
2353
|
+
if (input.role !== void 0 && !isInstanceRole(input.role)) throw refused("invalid", `no such instance role: ${input.role}`);
|
|
2354
|
+
if (input.scope !== void 0 && (input.role === void 0 || !isScope(input.scope))) throw refused("invalid", "a scope is a role's: { projects, environments }, each all, only or except");
|
|
2355
|
+
const wanted = input.role === void 0 ? void 0 : {
|
|
2356
|
+
role: input.role,
|
|
2357
|
+
scope: input.role === "member" ? EVERYWHERE : normalScope(input.scope ?? EVERYWHERE)
|
|
2358
|
+
};
|
|
2359
|
+
if (wanted !== void 0 && wanted.role !== "member" && !principal.startsWith("user:")) throw refused("invalid", "service accounts hold project grants only, never an instance role");
|
|
2109
2360
|
const row = d.members.get(principal);
|
|
2110
2361
|
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
2111
2362
|
if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
|
|
2112
2363
|
d.touched.add(principal);
|
|
2113
2364
|
d.grants.set(principal, [...subject.stored]);
|
|
2114
|
-
const entry = (action,
|
|
2365
|
+
const entry = (action, detail) => d.log.push(accessEntry(actor, action, principal, "allow", input, detail));
|
|
2115
2366
|
if (row === void 0 || row.status === "removed") {
|
|
2116
|
-
const
|
|
2117
|
-
|
|
2367
|
+
const { role, scope } = wanted ?? {
|
|
2368
|
+
role: "member",
|
|
2369
|
+
scope: EVERYWHERE
|
|
2370
|
+
};
|
|
2371
|
+
entry(row === void 0 ? "member.add" : "member.restore", {
|
|
2372
|
+
role,
|
|
2373
|
+
scope
|
|
2374
|
+
});
|
|
2118
2375
|
d.writes.push(async (at) => {
|
|
2119
2376
|
if (row === void 0) await insertMember(d.tx, {
|
|
2120
2377
|
principal,
|
|
2121
2378
|
status: "active",
|
|
2122
|
-
|
|
2379
|
+
role,
|
|
2380
|
+
scope,
|
|
2123
2381
|
generation: 0,
|
|
2124
2382
|
createdAt: at,
|
|
2125
2383
|
createdBy: actor,
|
|
@@ -2129,25 +2387,42 @@ var VaultService = class {
|
|
|
2129
2387
|
});
|
|
2130
2388
|
else await updateMember(d.tx, principal, {
|
|
2131
2389
|
status: "active",
|
|
2132
|
-
|
|
2390
|
+
role,
|
|
2391
|
+
scope,
|
|
2133
2392
|
statusChangedAt: at,
|
|
2134
2393
|
statusChangedBy: actor
|
|
2135
2394
|
});
|
|
2136
2395
|
});
|
|
2137
2396
|
return {
|
|
2138
2397
|
created: true,
|
|
2139
|
-
|
|
2398
|
+
role,
|
|
2399
|
+
scope,
|
|
2140
2400
|
generation: row?.generation ?? 0
|
|
2141
2401
|
};
|
|
2142
2402
|
}
|
|
2143
|
-
const
|
|
2144
|
-
if (
|
|
2145
|
-
|
|
2146
|
-
|
|
2403
|
+
const changed = wanted !== void 0 && JSON.stringify(standing(wanted)) !== JSON.stringify(standing(row));
|
|
2404
|
+
if (changed && actor === principal) throw refused("not_allowed", "nobody changes their own instance role: ask another admin");
|
|
2405
|
+
if (changed) {
|
|
2406
|
+
entry("member.role", {
|
|
2407
|
+
role: wanted.role,
|
|
2408
|
+
scope: wanted.scope,
|
|
2409
|
+
previousRole: row.role,
|
|
2410
|
+
previousScope: row.scope
|
|
2411
|
+
});
|
|
2412
|
+
d.writes.push(() => updateMember(d.tx, principal, {
|
|
2413
|
+
role: wanted.role,
|
|
2414
|
+
scope: wanted.scope
|
|
2415
|
+
}));
|
|
2416
|
+
return {
|
|
2417
|
+
created: false,
|
|
2418
|
+
...wanted,
|
|
2419
|
+
generation: row.generation
|
|
2420
|
+
};
|
|
2147
2421
|
}
|
|
2148
2422
|
return {
|
|
2149
2423
|
created: false,
|
|
2150
|
-
|
|
2424
|
+
role: row.role,
|
|
2425
|
+
scope: row.scope,
|
|
2151
2426
|
generation: row.generation
|
|
2152
2427
|
};
|
|
2153
2428
|
});
|
|
@@ -2160,16 +2435,18 @@ var VaultService = class {
|
|
|
2160
2435
|
if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
|
|
2161
2436
|
if (this.#isRootAdmin(principal)) throw refused("root_admin");
|
|
2162
2437
|
const row = d.members.get(principal);
|
|
2163
|
-
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
2438
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2164
2439
|
if (acting.status === "tampered") throw refused("tampered");
|
|
2165
2440
|
const holder = acting.live;
|
|
2166
2441
|
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
2167
2442
|
const held = subject.stored;
|
|
2168
2443
|
if (subject.status === "tampered") {
|
|
2169
|
-
if (!holder
|
|
2444
|
+
if (!runsInstance(holder)) throw refused("not_allowed", "only admins and owners of the whole instance may remove a member whose record failed its check");
|
|
2170
2445
|
return this.#startOver(d, actor, principal, row, subject.fault, input, refused);
|
|
2171
2446
|
}
|
|
2172
|
-
if (!holder
|
|
2447
|
+
if (!runsInstance(holder)) {
|
|
2448
|
+
if (!(SERVICE.test(principal) && PERSON.test(actor) && row?.status === "active" && managesService(holder, (await this.#settingsAt(d.tx, d.reports)).serviceAccounts, actor, await this.#account(d.tx, subject, row)))) throw refused("not_allowed", "only admins and owners of the whole instance may remove members, and a service account whoever manages it");
|
|
2449
|
+
}
|
|
2173
2450
|
if (row?.status !== "active") throw refused(row === void 0 ? "not_a_member" : "removed");
|
|
2174
2451
|
const revoked = held.filter((grant) => live(grant, d.at));
|
|
2175
2452
|
for (const grant of revoked) {
|
|
@@ -2195,7 +2472,8 @@ var VaultService = class {
|
|
|
2195
2472
|
d.grants.set(principal, []);
|
|
2196
2473
|
await updateMember(d.tx, principal, {
|
|
2197
2474
|
status: "removed",
|
|
2198
|
-
|
|
2475
|
+
role: "member",
|
|
2476
|
+
scope: EVERYWHERE,
|
|
2199
2477
|
generation,
|
|
2200
2478
|
statusChangedAt: at,
|
|
2201
2479
|
statusChangedBy: actor
|
|
@@ -2208,6 +2486,76 @@ var VaultService = class {
|
|
|
2208
2486
|
});
|
|
2209
2487
|
}
|
|
2210
2488
|
/**
|
|
2489
|
+
* A service account as `managesService` weighs it: its live grants, each
|
|
2490
|
+
* environment by its slug, which the setting's scope matches, and who
|
|
2491
|
+
* admitted it, as its row says.
|
|
2492
|
+
*/
|
|
2493
|
+
async #account(db, subject, row) {
|
|
2494
|
+
const slugs = await environmentsById(db, subject.live.grants.flatMap((grant) => grant.environmentId === null ? [] : [grant.environmentId]));
|
|
2495
|
+
return {
|
|
2496
|
+
grants: subject.live.grants.map((grant) => ({
|
|
2497
|
+
role: grant.role,
|
|
2498
|
+
place: grant.environmentId === null ? { projectId: grant.projectId } : {
|
|
2499
|
+
projectId: grant.projectId,
|
|
2500
|
+
environmentId: grant.environmentId,
|
|
2501
|
+
environmentSlug: slugs.get(grant.environmentId)?.slug ?? null
|
|
2502
|
+
}
|
|
2503
|
+
})),
|
|
2504
|
+
admittedBy: row.statusChangedBy
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
async settings() {
|
|
2508
|
+
await this.#settled();
|
|
2509
|
+
const reports = [];
|
|
2510
|
+
const settings = await this.#settingsAt(this.#db, reports);
|
|
2511
|
+
await this.#record(reports);
|
|
2512
|
+
return settings;
|
|
2513
|
+
}
|
|
2514
|
+
/**
|
|
2515
|
+
* The settings the vault's newest `settings.change` says, by its MAC: one
|
|
2516
|
+
* in its name it did not write is passed over, and reported. With such
|
|
2517
|
+
* entries and none of its own within reach, the setting is closed, never
|
|
2518
|
+
* the default: otherwise whoever can insert rows could open it again.
|
|
2519
|
+
*/
|
|
2520
|
+
async #settingsAt(db, reports) {
|
|
2521
|
+
const entry = await newestAuthentic((before) => newestVaultEntries(db, SETTINGS, LOOK_BACK.page, before), this.#check(reports, null));
|
|
2522
|
+
if (entry === "none") return DEFAULT_SETTINGS;
|
|
2523
|
+
if (typeof entry === "string") return CLOSED_SETTINGS;
|
|
2524
|
+
const { serviceAccounts } = JSON.parse(entry.metadata);
|
|
2525
|
+
return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
|
|
2526
|
+
}
|
|
2527
|
+
setSettings(input) {
|
|
2528
|
+
const { actor } = input;
|
|
2529
|
+
const entry = (decision, detail, code = null) => ({
|
|
2530
|
+
actor,
|
|
2531
|
+
action: SETTINGS,
|
|
2532
|
+
decision,
|
|
2533
|
+
code,
|
|
2534
|
+
operationId: input.operationId ?? null,
|
|
2535
|
+
requestId: input.requestId ?? null,
|
|
2536
|
+
metadata: JSON.stringify({
|
|
2537
|
+
...detail,
|
|
2538
|
+
...traced(input)
|
|
2539
|
+
})
|
|
2540
|
+
});
|
|
2541
|
+
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
|
|
2542
|
+
return this.#decide([actor], async (d) => {
|
|
2543
|
+
validateCorrelation(input);
|
|
2544
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2545
|
+
if (acting.status === "tampered") throw refused("tampered");
|
|
2546
|
+
if (!runsInstance(acting.live)) throw refused("not_allowed", "only admins and owners of the whole instance change its settings");
|
|
2547
|
+
if (!isScope(input.settings?.serviceAccounts)) throw refused("invalid", "who sets up service accounts is a scope: { projects, environments }, each all, only or except");
|
|
2548
|
+
await lockLogHead(d.tx);
|
|
2549
|
+
const previous = await this.#settingsAt(d.tx, d.reports);
|
|
2550
|
+
const settings = { serviceAccounts: normalScope(input.settings.serviceAccounts) };
|
|
2551
|
+
if (JSON.stringify(settings) !== JSON.stringify(previous)) d.log.push(entry("allow", {
|
|
2552
|
+
...settings,
|
|
2553
|
+
previous
|
|
2554
|
+
}));
|
|
2555
|
+
return { settings };
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
/**
|
|
2211
2559
|
* Remove a member whose row failed its check, from what the log says of
|
|
2212
2560
|
* them rather than what the row does: their grants go, whatever they were,
|
|
2213
2561
|
* and their generation moves past both the row's and the log's, so no
|
|
@@ -2229,7 +2577,8 @@ var VaultService = class {
|
|
|
2229
2577
|
d.grants.set(principal, []);
|
|
2230
2578
|
const fresh = {
|
|
2231
2579
|
status: "removed",
|
|
2232
|
-
|
|
2580
|
+
role: "member",
|
|
2581
|
+
scope: EVERYWHERE,
|
|
2233
2582
|
generation,
|
|
2234
2583
|
createdAt: logged.createdAt,
|
|
2235
2584
|
createdBy: logged.createdBy,
|
|
@@ -2908,13 +3257,13 @@ function live(grant, at) {
|
|
|
2908
3257
|
}
|
|
2909
3258
|
/**
|
|
2910
3259
|
* Where an entry about a grant says it is: its project and environment; or,
|
|
2911
|
-
* on every project, neither, and the place as a path in
|
|
2912
|
-
*
|
|
3260
|
+
* for a grant on every project of 0.4, neither, and the place as a path in
|
|
3261
|
+
* its payload, `*`, or `*` and the slug, as 0.4 wrote it.
|
|
2913
3262
|
*/
|
|
2914
3263
|
function located(place) {
|
|
2915
|
-
return
|
|
3264
|
+
return place.projectId === null && place.environmentId === null ? {
|
|
2916
3265
|
ids: {},
|
|
2917
|
-
detail: { place:
|
|
3266
|
+
detail: { place: place.environmentSlug === null ? "*" : `*/${place.environmentSlug}` }
|
|
2918
3267
|
} : {
|
|
2919
3268
|
ids: {
|
|
2920
3269
|
projectId: place.projectId,
|
|
@@ -2923,25 +3272,33 @@ function located(place) {
|
|
|
2923
3272
|
detail: {}
|
|
2924
3273
|
};
|
|
2925
3274
|
}
|
|
2926
|
-
/** One key per place a member can hold a grant at, by what the grant names first: its environment, its project, or neither. */
|
|
3275
|
+
/** One key per place a member can hold a grant at, by what the grant names first: its environment, its project, or neither, on every project of 0.4. */
|
|
3276
|
+
/** Every project there is now and each of its environments, as a permission check names them. */
|
|
3277
|
+
function placesOf(projects) {
|
|
3278
|
+
return projects.flatMap((project) => [{ projectId: project.id }, ...project.environments.map((environment) => ({
|
|
3279
|
+
projectId: project.id,
|
|
3280
|
+
environmentId: environment.id,
|
|
3281
|
+
environmentSlug: environment.slug
|
|
3282
|
+
}))]);
|
|
3283
|
+
}
|
|
2927
3284
|
function placeKey(place) {
|
|
2928
3285
|
if (place.environmentId !== null) return `environment:${place.environmentId}`;
|
|
2929
|
-
return place.projectId !== null ? `project:${place.projectId}` :
|
|
3286
|
+
return place.projectId !== null ? `project:${place.projectId}` : `every-project:${place.environmentSlug ?? ""}`;
|
|
2930
3287
|
}
|
|
2931
|
-
/**
|
|
2932
|
-
function
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
3288
|
+
/** A member's instance role, as their row has it: service accounts are members, whatever a row says. */
|
|
3289
|
+
function roleOf(principal, row) {
|
|
3290
|
+
return principal.startsWith("user:") ? {
|
|
3291
|
+
role: row.role,
|
|
3292
|
+
scope: row.scope
|
|
3293
|
+
} : {
|
|
3294
|
+
role: "member",
|
|
3295
|
+
scope: EVERYWHERE
|
|
3296
|
+
};
|
|
2937
3297
|
}
|
|
2938
|
-
/** An environment's slug, as the schema has it. */
|
|
2939
|
-
const SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
2940
3298
|
function view(grant) {
|
|
2941
3299
|
return {
|
|
2942
3300
|
projectId: grant.projectId,
|
|
2943
3301
|
environmentId: grant.environmentId,
|
|
2944
|
-
environmentSlug: grant.environmentSlug,
|
|
2945
3302
|
role: grant.role,
|
|
2946
3303
|
expiresAt: grant.expiresAt === null ? null : iso(grant.expiresAt),
|
|
2947
3304
|
grantedAt: iso(grant.grantedAt),
|