@coffre/vault 0.2.0 → 0.4.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 -1
- package/dist/cloudflare.js +7 -1
- package/dist/node.js +3 -1
- package/dist/{vault-BexBMpZL.js → vault-CeKwkzSV.js} +551 -104
- package/package.json +4 -4
- package/src/cloudflare.ts +4 -0
- package/src/local.ts +2 -0
- package/src/replay.ts +19 -5
- package/src/rows.ts +9 -3
- package/src/store.ts +136 -43
- package/src/vault.ts +435 -52
|
@@ -2,12 +2,14 @@ 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, isRole, mayManageAccess } from "@coffre/core/access";
|
|
5
|
+
import { allows, assignableToEnvironment, everyProjectPath, grantKind, isRole, mayManageAccess } from "@coffre/core/access";
|
|
6
6
|
import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
|
|
7
7
|
import { checkContext } from "@coffre/core/envelope";
|
|
8
|
-
import {
|
|
8
|
+
import { shownMember } from "@coffre/core/schemas";
|
|
9
|
+
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, tombstone, truth } from "@coffre/db/dialect";
|
|
9
10
|
import { appendEntries, lockLogHead } from "@coffre/db/log";
|
|
10
|
-
import {
|
|
11
|
+
import { insertGrant, readGrants } from "@coffre/db/grants";
|
|
12
|
+
import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm";
|
|
11
13
|
//#region src/accounting.ts
|
|
12
14
|
const KEY_ACTIONS = /* @__PURE__ */ new Set([
|
|
13
15
|
"secret.read",
|
|
@@ -228,29 +230,35 @@ async function updateMember(tx, principal, change) {
|
|
|
228
230
|
await tx.update(vaultMembers).set(change).where(eq(vaultMembers.principal, principal));
|
|
229
231
|
}
|
|
230
232
|
/** Grants, lapsed ones too, each with its project, which an environment's grant finds through `environments`. */
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
expiresAt: vaultGrants.expiresAt,
|
|
239
|
-
grantedAt: vaultGrants.grantedAt,
|
|
240
|
-
grantedBy: vaultGrants.grantedBy
|
|
241
|
-
}).from(vaultGrants).leftJoin(environments, eq(environments.id, vaultGrants.environmentId)).where(principal === void 0 ? void 0 : eq(vaultGrants.principal, principal));
|
|
242
|
-
}
|
|
243
|
-
/** A grant names its environment, or its project when it has none: exactly one. */
|
|
233
|
+
function grants(db, principal) {
|
|
234
|
+
return readGrants(db, principal === void 0 ? {} : { principal });
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* A grant's row names its environment, or its project when it has none, or
|
|
238
|
+
* neither on every project, with the slug it covers there, if any.
|
|
239
|
+
*/
|
|
244
240
|
function at(db, place) {
|
|
245
241
|
const { vaultGrants } = tablesOf(db);
|
|
246
|
-
|
|
242
|
+
if (place.environmentId !== null) return eq(vaultGrants.environmentId, place.environmentId);
|
|
243
|
+
if (place.projectId !== null) return and(eq(vaultGrants.projectId, place.projectId), isNull(vaultGrants.environmentId));
|
|
244
|
+
return and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId), place.environmentSlug === null ? isNull(vaultGrants.environmentSlug) : eq(vaultGrants.environmentSlug, place.environmentSlug));
|
|
247
245
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
246
|
+
/**
|
|
247
|
+
* Each of these environments that exists, by id, with its project, its
|
|
248
|
+
* slug, what grants on one slug in every project match, and whether it was
|
|
249
|
+
* deleted, alone or with its project: then no key under it opens, or is
|
|
250
|
+
* wrapped, whatever grant would cover it.
|
|
251
|
+
*/
|
|
252
|
+
async function environmentsById(db, ids) {
|
|
253
|
+
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
254
|
+
const { environments, projects } = tablesOf(db);
|
|
255
|
+
const rows = await db.select({
|
|
256
|
+
id: environments.id,
|
|
257
|
+
projectId: environments.projectId,
|
|
258
|
+
slug: environments.slug,
|
|
259
|
+
deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug)))
|
|
260
|
+
}).from(environments).innerJoin(projects, eq(projects.id, environments.projectId)).where(inArray(environments.id, [...new Set(ids)]));
|
|
261
|
+
return new Map(rows.map(({ id, ...environment }) => [id, environment]));
|
|
254
262
|
}
|
|
255
263
|
async function deleteGrant(tx, principal, place) {
|
|
256
264
|
const { vaultGrants } = tablesOf(tx);
|
|
@@ -276,7 +284,7 @@ async function wrappedUnder(db, provider, keyId, limit) {
|
|
|
276
284
|
kekId: secretVersions.kekId,
|
|
277
285
|
kekVersion: secretVersions.kekVersion,
|
|
278
286
|
bytes: secretVersions.wrappedDek
|
|
279
|
-
}).from(secretVersions).innerJoin(secrets, eq(secrets.id, secretVersions.secretId)).where(and(eq(secretVersions.kekProvider, provider), eq(secretVersions.kekId, keyId))).orderBy(desc(secretVersions.createdAt)).limit(limit)).map((row) => ({
|
|
287
|
+
}).from(secretVersions).innerJoin(secrets, eq(secrets.id, secretVersions.secretId)).where(and(eq(secretVersions.kekProvider, provider), eq(secretVersions.kekId, keyId), sealed$1(secretVersions.wrappedDek))).orderBy(desc(secretVersions.createdAt)).limit(limit)).map((row) => ({
|
|
280
288
|
...row,
|
|
281
289
|
bytes: Buffer.from(row.bytes)
|
|
282
290
|
}));
|
|
@@ -284,13 +292,18 @@ async function wrappedUnder(db, provider, keyId, limit) {
|
|
|
284
292
|
/** Of these projects and environments, the ones that exist, each environment with its project. */
|
|
285
293
|
async function places(db, projectIds, environmentIds) {
|
|
286
294
|
const { projects, environments } = tablesOf(db);
|
|
287
|
-
const [foundProjects, foundEnvironments] = await Promise.all([projectIds.length === 0 ? [] : db.select({
|
|
295
|
+
const [foundProjects, foundEnvironments] = await Promise.all([projectIds.length === 0 ? [] : db.select({
|
|
296
|
+
id: projects.id,
|
|
297
|
+
deleted: truth(tombstone(projects.slug))
|
|
298
|
+
}).from(projects).where(inArray(projects.id, [...projectIds])), environmentIds.length === 0 ? [] : db.select({
|
|
288
299
|
id: environments.id,
|
|
289
|
-
projectId: environments.projectId
|
|
290
|
-
|
|
300
|
+
projectId: environments.projectId,
|
|
301
|
+
deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug)))
|
|
302
|
+
}).from(environments).innerJoin(projects, eq(projects.id, environments.projectId)).where(inArray(environments.id, [...environmentIds]))]);
|
|
291
303
|
return {
|
|
292
304
|
projects: new Set(foundProjects.map((row) => row.id)),
|
|
293
|
-
environments: new Map(foundEnvironments.map((row) => [row.id, row.projectId]))
|
|
305
|
+
environments: new Map(foundEnvironments.map((row) => [row.id, row.projectId])),
|
|
306
|
+
deleted: new Set([...foundProjects, ...foundEnvironments].filter((row) => row.deleted).map((row) => row.id))
|
|
294
307
|
};
|
|
295
308
|
}
|
|
296
309
|
/** The action of an entry that releases a key, which the bulk limit counts. */
|
|
@@ -387,6 +400,52 @@ async function firstVaultEntryUnder(db, keyId) {
|
|
|
387
400
|
const [row] = await db.select({ seq: auditLog.seq }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.keyId, keyId))).orderBy(asc(auditLog.seq)).limit(1);
|
|
388
401
|
return row?.seq;
|
|
389
402
|
}
|
|
403
|
+
/** The entries at these seqs, any author, by seq: what a reference names as its seal. */
|
|
404
|
+
async function entriesAt(db, seqs) {
|
|
405
|
+
if (seqs.length === 0) return /* @__PURE__ */ new Map();
|
|
406
|
+
const { auditLog } = tablesOf(db);
|
|
407
|
+
const rows = stored(await db.select(entryColumns(db)).from(auditLog).where(inArray(auditLog.seq, [...new Set(seqs)])));
|
|
408
|
+
return new Map(rows.map((row) => [row.seq, row]));
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Which of these `reference.create` entries a `reference.end` of the
|
|
412
|
+
* vault's names, allowed: one read through `audit_log_reference_end_idx`.
|
|
413
|
+
* An end that fails its MAC still ends: putting a reference back takes
|
|
414
|
+
* more than a forged end can undo, and refusing is the safe side.
|
|
415
|
+
*/
|
|
416
|
+
async function endedReferences(db, seqs) {
|
|
417
|
+
if (seqs.length === 0) return /* @__PURE__ */ new Set();
|
|
418
|
+
const { auditLog } = tablesOf(db);
|
|
419
|
+
const rows = await db.select({ relatedSeq: auditLog.relatedSeq }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, "reference.end"), eq(auditLog.decision, "allow"), inArray(auditLog.relatedSeq, [...new Set(seqs)])));
|
|
420
|
+
return new Set(rows.map((row) => row.relatedSeq));
|
|
421
|
+
}
|
|
422
|
+
/** The project each environment is in: a holder's ids, checked against each other. */
|
|
423
|
+
async function projectsOfEnvironments(db, ids) {
|
|
424
|
+
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
425
|
+
const { environments } = tablesOf(db);
|
|
426
|
+
const rows = await db.select({
|
|
427
|
+
id: environments.id,
|
|
428
|
+
projectId: environments.projectId
|
|
429
|
+
}).from(environments).where(inArray(environments.id, [...new Set(ids)]));
|
|
430
|
+
return new Map(rows.map((row) => [row.id, row.projectId]));
|
|
431
|
+
}
|
|
432
|
+
async function secretsByIds(db, ids) {
|
|
433
|
+
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
434
|
+
const { secrets, environments, projects } = tablesOf(db);
|
|
435
|
+
const rows = await db.select({
|
|
436
|
+
secretId: secrets.id,
|
|
437
|
+
projectId: secrets.projectId,
|
|
438
|
+
environmentId: secrets.environmentId,
|
|
439
|
+
project: projects.slug,
|
|
440
|
+
environment: environments.slug,
|
|
441
|
+
key: secrets.key,
|
|
442
|
+
currentVersionId: secrets.currentVersionId
|
|
443
|
+
}).from(secrets).innerJoin(environments, eq(environments.id, secrets.environmentId)).innerJoin(projects, eq(projects.id, secrets.projectId)).where(inArray(secrets.id, [...new Set(ids)]));
|
|
444
|
+
return new Map(rows.map(({ project, environment, key, ...row }) => [row.secretId, {
|
|
445
|
+
...row,
|
|
446
|
+
path: `${project}/${environment}/${key}`
|
|
447
|
+
}]));
|
|
448
|
+
}
|
|
390
449
|
/** The vault's newest entry of any of these actions, allowed, or undefined. */
|
|
391
450
|
async function latestVaultEntry(db, actions) {
|
|
392
451
|
const { auditLog } = tablesOf(db);
|
|
@@ -410,6 +469,14 @@ async function vaultEntriesOf(db, actions, afterSeq, limit) {
|
|
|
410
469
|
const { auditLog } = tablesOf(db);
|
|
411
470
|
return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...actions]), eq(auditLog.decision, "allow"), gt(auditLog.seq, afterSeq))).orderBy(asc(auditLog.seq)).limit(limit));
|
|
412
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* A version that still holds its wrapped key. A deleted place's versions are
|
|
474
|
+
* erased, their keys emptied: there is nothing left to open, or to try a
|
|
475
|
+
* vault key on.
|
|
476
|
+
*/
|
|
477
|
+
function sealed$1(wrappedDek) {
|
|
478
|
+
return sql`length(${wrappedDek}) > 0`;
|
|
479
|
+
}
|
|
413
480
|
async function versions(db, ids) {
|
|
414
481
|
if (ids.length === 0) return [];
|
|
415
482
|
const { secretVersions, secrets, environments, projects } = tablesOf(db);
|
|
@@ -425,9 +492,13 @@ async function versions(db, ids) {
|
|
|
425
492
|
kekProvider: secretVersions.kekProvider,
|
|
426
493
|
kekId: secretVersions.kekId,
|
|
427
494
|
kekVersion: secretVersions.kekVersion,
|
|
428
|
-
wrappedDek: secretVersions.wrappedDek
|
|
429
|
-
|
|
495
|
+
wrappedDek: secretVersions.wrappedDek,
|
|
496
|
+
deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))),
|
|
497
|
+
currentVersionId: secrets.currentVersionId,
|
|
498
|
+
newest: sql`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`
|
|
499
|
+
}).from(secretVersions).innerJoin(secrets, eq(secrets.id, secretVersions.secretId)).innerJoin(environments, eq(environments.id, secrets.environmentId)).innerJoin(projects, eq(projects.id, secrets.projectId)).where(and(inArray(secretVersions.id, [...new Set(ids)]), or(sealed$1(secretVersions.wrappedDek), tombstone(environments.slug), tombstone(projects.slug))))).map((row) => ({
|
|
430
500
|
id: row.id,
|
|
501
|
+
deleted: row.deleted,
|
|
431
502
|
secret: {
|
|
432
503
|
projectId: row.projectId,
|
|
433
504
|
environmentId: row.environmentId,
|
|
@@ -440,7 +511,8 @@ async function versions(db, ids) {
|
|
|
440
511
|
kekId: row.kekId,
|
|
441
512
|
kekVersion: row.kekVersion,
|
|
442
513
|
bytes: row.wrappedDek.toString("base64")
|
|
443
|
-
}
|
|
514
|
+
},
|
|
515
|
+
current: row.currentVersionId === row.id && Number(row.newest) === row.version
|
|
444
516
|
}));
|
|
445
517
|
}
|
|
446
518
|
//#endregion
|
|
@@ -635,10 +707,7 @@ function replayFault(state, rows, storedGrants, at) {
|
|
|
635
707
|
function apply(state, row) {
|
|
636
708
|
const principal = row.subjectPrincipal;
|
|
637
709
|
const detail = JSON.parse(row.metadata);
|
|
638
|
-
const place =
|
|
639
|
-
projectId: row.projectId,
|
|
640
|
-
environmentId: row.environmentId
|
|
641
|
-
};
|
|
710
|
+
const place = placeOf(row, detail);
|
|
642
711
|
const grantsOf = state.held.get(principal) ?? /* @__PURE__ */ new Map();
|
|
643
712
|
state.held.set(principal, grantsOf);
|
|
644
713
|
const before = state.members.get(principal);
|
|
@@ -691,7 +760,7 @@ function apply(state, row) {
|
|
|
691
760
|
return null;
|
|
692
761
|
}
|
|
693
762
|
case "access.grant":
|
|
694
|
-
grantsOf.set(placeKey(place), {
|
|
763
|
+
grantsOf.set(placeKey$1(place), {
|
|
695
764
|
principal,
|
|
696
765
|
...place,
|
|
697
766
|
role: detail.role,
|
|
@@ -705,7 +774,7 @@ function apply(state, row) {
|
|
|
705
774
|
});
|
|
706
775
|
return null;
|
|
707
776
|
case "access.revoke":
|
|
708
|
-
grantsOf.delete(placeKey(place));
|
|
777
|
+
grantsOf.delete(placeKey$1(place));
|
|
709
778
|
if (before !== void 0) state.members.set(principal, {
|
|
710
779
|
...before,
|
|
711
780
|
accessSeq: row.seq
|
|
@@ -725,15 +794,32 @@ const MEMBER_FIELDS = [
|
|
|
725
794
|
["statusChangedBy", "status_changed_by"],
|
|
726
795
|
["accessSeq", "access_seq"]
|
|
727
796
|
];
|
|
728
|
-
/**
|
|
729
|
-
|
|
730
|
-
|
|
797
|
+
/**
|
|
798
|
+
* The place an access entry is about: its project and environment, or, with
|
|
799
|
+
* neither, every project, which it names as a path (`*`, or `*` and a slug).
|
|
800
|
+
*/
|
|
801
|
+
function placeOf(row, detail) {
|
|
802
|
+
if (row.projectId !== null || row.environmentId !== null) return {
|
|
803
|
+
projectId: row.projectId,
|
|
804
|
+
environmentId: row.environmentId,
|
|
805
|
+
environmentSlug: null
|
|
806
|
+
};
|
|
807
|
+
return {
|
|
808
|
+
projectId: null,
|
|
809
|
+
environmentId: null,
|
|
810
|
+
environmentSlug: (typeof detail.place === "string" ? detail.place.split("/")[1] : void 0) ?? null
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
/** A grant's place: its environment, or its project when it has none, or every project's path. */
|
|
814
|
+
function placeKey$1(place) {
|
|
815
|
+
return place.environmentId ?? place.projectId ?? everyProjectPath(place.environmentSlug);
|
|
731
816
|
}
|
|
732
817
|
function grantKey(grant) {
|
|
733
818
|
return JSON.stringify([
|
|
734
819
|
grant.principal,
|
|
735
820
|
grant.projectId,
|
|
736
821
|
grant.environmentId,
|
|
822
|
+
grant.environmentSlug,
|
|
737
823
|
grant.role,
|
|
738
824
|
grant.expiresAt,
|
|
739
825
|
grant.grantedAt,
|
|
@@ -741,11 +827,12 @@ function grantKey(grant) {
|
|
|
741
827
|
]);
|
|
742
828
|
}
|
|
743
829
|
function faultGrant(key) {
|
|
744
|
-
const [principal, projectId, environmentId, role] = JSON.parse(key);
|
|
830
|
+
const [principal, projectId, environmentId, environmentSlug, role] = JSON.parse(key);
|
|
745
831
|
return {
|
|
746
832
|
principal,
|
|
747
833
|
projectId,
|
|
748
834
|
environmentId,
|
|
835
|
+
environmentSlug,
|
|
749
836
|
role
|
|
750
837
|
};
|
|
751
838
|
}
|
|
@@ -768,11 +855,17 @@ function faultGrant(key) {
|
|
|
768
855
|
function rowKey(signingKey) {
|
|
769
856
|
return Buffer.from(hkdfSync("sha256", signingKey, /* @__PURE__ */ new Uint8Array(0), "coffre.vault.rows.v1", 32));
|
|
770
857
|
}
|
|
771
|
-
/**
|
|
858
|
+
/**
|
|
859
|
+
* A member's grants as the MAC covers them: each one's place, role, end and
|
|
860
|
+
* 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
|
|
863
|
+
* set, and so the MAC, they had before there were any.
|
|
864
|
+
*/
|
|
772
865
|
function grantSet(grants) {
|
|
773
866
|
return grants.map((grant) => [
|
|
774
|
-
grant.environmentId
|
|
775
|
-
grant.environmentId ?? grant.projectId,
|
|
867
|
+
grant.environmentId !== null ? "environment" : grant.projectId !== null ? "project" : "every-project",
|
|
868
|
+
grant.environmentId ?? grant.projectId ?? grant.environmentSlug,
|
|
776
869
|
grant.role,
|
|
777
870
|
grant.expiresAt,
|
|
778
871
|
grant.grantedAt,
|
|
@@ -847,8 +940,8 @@ function openVault(db, prepared) {
|
|
|
847
940
|
const KEY_BUDGET_MS = 5e3;
|
|
848
941
|
/** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
|
|
849
942
|
const LOCK_TIMEOUT_MS = 15e3;
|
|
850
|
-
|
|
851
|
-
const
|
|
943
|
+
/** A member: `user:<email>` or `token:<name>`. */
|
|
944
|
+
const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
852
945
|
/** Who acts for the vault itself, as when it gives a root admin a member row. */
|
|
853
946
|
const VAULT_ACTOR = "system:vault";
|
|
854
947
|
/** How many stored keys a KEK with no check yet is tried on: one that opens proves it. */
|
|
@@ -907,6 +1000,8 @@ const MESSAGES = {
|
|
|
907
1000
|
expired: "the grant that covered this has expired",
|
|
908
1001
|
bulk_limit: "too many secrets read in too short a time",
|
|
909
1002
|
bad_claim: "the key does not belong to this secret",
|
|
1003
|
+
deleted: "this project or environment was deleted",
|
|
1004
|
+
ended: "the reference read through was ended",
|
|
910
1005
|
not_allowed: "not allowed to change this",
|
|
911
1006
|
root_admin: "root admins are set in the vault configuration",
|
|
912
1007
|
invalid: "not something the rules allow",
|
|
@@ -979,14 +1074,15 @@ var VaultService = class {
|
|
|
979
1074
|
touched: /* @__PURE__ */ new Set(),
|
|
980
1075
|
grants: /* @__PURE__ */ new Map(),
|
|
981
1076
|
reports,
|
|
982
|
-
after: []
|
|
1077
|
+
after: [],
|
|
1078
|
+
underLock: []
|
|
983
1079
|
};
|
|
984
1080
|
const result = await decide(d);
|
|
985
1081
|
const entries = [...reports, ...d.log];
|
|
986
1082
|
let at = d.at;
|
|
987
1083
|
const accessSeq = /* @__PURE__ */ new Map();
|
|
988
1084
|
if (entries.length > 0) {
|
|
989
|
-
const appended = await this.#append(tx, entries);
|
|
1085
|
+
const appended = await this.#append(tx, entries, d.underLock);
|
|
990
1086
|
at = appended.occurredAt;
|
|
991
1087
|
entries.forEach((entry, i) => {
|
|
992
1088
|
if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal, appended.seqStart + BigInt(i));
|
|
@@ -1204,33 +1300,57 @@ var VaultService = class {
|
|
|
1204
1300
|
* that an instance still running with the replaced KEK, as during a
|
|
1205
1301
|
* deploy, cannot write after the rotation.
|
|
1206
1302
|
*/
|
|
1207
|
-
#append(tx, entries) {
|
|
1303
|
+
#append(tx, entries, underLock = []) {
|
|
1208
1304
|
const prepared = this.#prepared;
|
|
1209
1305
|
return appendEntries(tx, prepared.logKey, entries, async (locked) => {
|
|
1210
1306
|
const rotation = await latestVaultEntry(locked, [KEY_ROTATE]);
|
|
1211
|
-
if (rotation
|
|
1212
|
-
|
|
1213
|
-
|
|
1307
|
+
if (rotation !== void 0 && !prepared.logKeys.some((key) => key.keyId === rotation.keyId)) {
|
|
1308
|
+
prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer vault key replaced it, and a replaced key writes nothing more`;
|
|
1309
|
+
throw new Error(prepared.superseded);
|
|
1310
|
+
}
|
|
1311
|
+
for (const check of underLock) await check(locked);
|
|
1214
1312
|
});
|
|
1215
1313
|
}
|
|
1216
1314
|
async unwrap(input) {
|
|
1217
1315
|
const { principal } = input;
|
|
1218
1316
|
validateText(input.purpose);
|
|
1317
|
+
const vias = input.items.map((item) => validateVia(item.via));
|
|
1219
1318
|
const loaded = await this.#versions(input, "secret.read", { purpose: input.purpose });
|
|
1220
1319
|
if (!loaded.ok) return loaded;
|
|
1221
|
-
const items = loaded.versions
|
|
1320
|
+
const items = loaded.versions.map((version) => ({
|
|
1321
|
+
...version,
|
|
1322
|
+
secret: { ...version.secret }
|
|
1323
|
+
}));
|
|
1222
1324
|
validateItems(items, "wrapped");
|
|
1325
|
+
const sealed = await this.#referencesAt(vias);
|
|
1223
1326
|
const versionIds = new Map(items.map((item) => [item.secret, item.id]));
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
});
|
|
1327
|
+
const viaOf = new Map(items.map((item, i) => [item.secret, {
|
|
1328
|
+
via: vias[i],
|
|
1329
|
+
sealed: sealed[i]
|
|
1330
|
+
}]));
|
|
1331
|
+
const entry = (secret, decision, code) => {
|
|
1332
|
+
const { via, sealed: seal } = viaOf.get(secret);
|
|
1333
|
+
return {
|
|
1334
|
+
...keyEntry("secret.read", principal, secret, decision, code, input, {
|
|
1335
|
+
purpose: input.purpose,
|
|
1336
|
+
...via === null ? {} : viaDetail(via, seal)
|
|
1337
|
+
}),
|
|
1338
|
+
secretVersionId: versionIds.get(secret)
|
|
1339
|
+
};
|
|
1340
|
+
};
|
|
1341
|
+
if (items.some((item, i) => vias[i] !== null && (sealed[i] === null || sealed[i].source.secretId !== item.secret.secretId || !item.current))) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")));
|
|
1342
|
+
const ends = sealed.flatMap((seal) => seal === null ? [] : [seal.seq]);
|
|
1228
1343
|
const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
1229
1344
|
return this.#keys({
|
|
1230
1345
|
action: "secret.read",
|
|
1231
1346
|
principal,
|
|
1232
1347
|
permission: "secret.read",
|
|
1233
1348
|
secrets: items.map((item) => item.secret),
|
|
1349
|
+
places: sealed.map((seal, i) => seal?.holder ?? items[i].secret),
|
|
1350
|
+
vet: ends.length === 0 ? void 0 : async (db) => {
|
|
1351
|
+
const ended = await endedReferences(db, ends);
|
|
1352
|
+
return sealed.map((seal) => seal !== null && ended.has(seal.seq) ? "ended" : null);
|
|
1353
|
+
},
|
|
1234
1354
|
remote,
|
|
1235
1355
|
entry,
|
|
1236
1356
|
input
|
|
@@ -1287,7 +1407,7 @@ var VaultService = class {
|
|
|
1287
1407
|
}));
|
|
1288
1408
|
validateItems(items, "wrapped");
|
|
1289
1409
|
const sameSecret = (secret, source) => secret.projectId === source.projectId && secret.environmentId === source.environmentId && secret.secretId === source.secretId;
|
|
1290
|
-
if (items.some((item, i) => !sameSecret(item.secret, loaded.versions[i].secret))) return this.#
|
|
1410
|
+
if (items.some((item, i) => !sameSecret(item.secret, loaded.versions[i].secret))) return this.#refuseVersions(principal, "bad_claim", loaded.versions.map((source) => ({
|
|
1291
1411
|
...keyEntry("key.rewrap", principal, source.secret, "deny", "bad_claim", input),
|
|
1292
1412
|
secretVersionId: source.id
|
|
1293
1413
|
})));
|
|
@@ -1321,14 +1441,18 @@ var VaultService = class {
|
|
|
1321
1441
|
seqs: []
|
|
1322
1442
|
}));
|
|
1323
1443
|
}
|
|
1324
|
-
/**
|
|
1444
|
+
/** Versions need no lock: they change only by being erased, once their place is archived, and their ids determine the whole batch before any key call. */
|
|
1325
1445
|
async #versions(input, action, detail = {}) {
|
|
1326
1446
|
validateText(input.principal);
|
|
1327
1447
|
validateCorrelation(input);
|
|
1328
1448
|
const ids = input.items.map((item) => item.secretVersionId);
|
|
1329
1449
|
for (const id of ids) if (typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) throw new Error("secret version id must be a lowercase UUID");
|
|
1330
1450
|
const found = new Map((await versions(this.#db, ids)).map((version) => [version.id, version]));
|
|
1331
|
-
if (
|
|
1451
|
+
if ([...found.values()].some((version) => version.deleted)) return this.#refuseVersions(input.principal, "deleted", [...found.values()].map((version) => ({
|
|
1452
|
+
...keyEntry(action, input.principal, version.secret, "deny", "deleted", input, detail),
|
|
1453
|
+
secretVersionId: version.id
|
|
1454
|
+
})));
|
|
1455
|
+
if (ids.some((id) => !found.has(id))) return this.#refuseVersions(input.principal, "bad_claim", ids.map((id) => {
|
|
1332
1456
|
const version = found.get(id);
|
|
1333
1457
|
if (version === void 0) return {
|
|
1334
1458
|
actor: input.principal,
|
|
@@ -1353,10 +1477,10 @@ var VaultService = class {
|
|
|
1353
1477
|
versions: ids.map((id) => found.get(id))
|
|
1354
1478
|
};
|
|
1355
1479
|
}
|
|
1356
|
-
async #
|
|
1480
|
+
async #refuseVersions(principal, code, entries) {
|
|
1357
1481
|
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1358
1482
|
return this.#decide([principal], async () => {
|
|
1359
|
-
throw new Refused(refusal(
|
|
1483
|
+
throw new Refused(refusal(code, MESSAGES[code]), entries);
|
|
1360
1484
|
});
|
|
1361
1485
|
}
|
|
1362
1486
|
/**
|
|
@@ -1393,7 +1517,11 @@ var VaultService = class {
|
|
|
1393
1517
|
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1394
1518
|
const check = async (d) => {
|
|
1395
1519
|
const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
|
|
1396
|
-
const
|
|
1520
|
+
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
1521
|
+
const environments = await environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
|
|
1522
|
+
const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1523
|
+
const codes = secrets.map((secret, i) => refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i])));
|
|
1524
|
+
if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx)).forEach((code, i) => codes[i] = code);
|
|
1397
1525
|
let first = codes.find((code) => code !== null) ?? null;
|
|
1398
1526
|
if (first === null && action === "secret.read" && await this.#overBulkLimit(d, principal, secrets.length)) first = "bulk_limit";
|
|
1399
1527
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, i) => entry(secret, "deny", codes[i] ?? first)));
|
|
@@ -1449,6 +1577,13 @@ var VaultService = class {
|
|
|
1449
1577
|
if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item].code ?? error.refusal.code)));
|
|
1450
1578
|
throw error;
|
|
1451
1579
|
}
|
|
1580
|
+
const { vet } = call;
|
|
1581
|
+
if (vet !== void 0) d.underLock.push(async (locked) => {
|
|
1582
|
+
const codes = await vet(locked);
|
|
1583
|
+
const first = codes.find((code) => code !== null);
|
|
1584
|
+
if (first === void 0 || first === null) return;
|
|
1585
|
+
throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, "deny", codes[item] ?? first)));
|
|
1586
|
+
});
|
|
1452
1587
|
const { outcomes, expired } = await settle(operations(), this.#prepared.options.keyBudgetMs);
|
|
1453
1588
|
const done = outcomes.flatMap((outcome) => outcome.ok ? [outcome.value] : []);
|
|
1454
1589
|
try {
|
|
@@ -1459,6 +1594,10 @@ var VaultService = class {
|
|
|
1459
1594
|
if (unanswered > 0) throw new Outage(new KekUnavailableError(`the key service did not answer for ${unanswered} of ${secrets.length} keys`, outcomes.some((outcome) => !outcome.ok && outcome.code === "kms_uncertain")), entries());
|
|
1460
1595
|
if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
|
|
1461
1596
|
if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
|
|
1597
|
+
await lockLogHead(d.tx);
|
|
1598
|
+
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
1599
|
+
const now = await environmentsById(d.tx, places.map((place) => place.environmentId));
|
|
1600
|
+
if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
|
|
1462
1601
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
|
|
1463
1602
|
d.log.push(...released);
|
|
1464
1603
|
const answer = result(done);
|
|
@@ -1474,6 +1613,174 @@ var VaultService = class {
|
|
|
1474
1613
|
const { count, windowMs } = this.#config.bulkLimit;
|
|
1475
1614
|
return await releasesSince(d.tx, principal, d.at - windowMs) + n > count;
|
|
1476
1615
|
}
|
|
1616
|
+
/**
|
|
1617
|
+
* Make each holder a reference to its source: the actor writes the holder
|
|
1618
|
+
* and reads the source, by their own grants, and the vault's
|
|
1619
|
+
* `reference.create` entry is the reference. The app's row only points at
|
|
1620
|
+
* it; a read through one is checked against the entry (`#referencesAt`).
|
|
1621
|
+
*/
|
|
1622
|
+
async reference(input) {
|
|
1623
|
+
const { principal, items } = input;
|
|
1624
|
+
validateText(principal);
|
|
1625
|
+
validateCorrelation(input);
|
|
1626
|
+
for (const item of items) {
|
|
1627
|
+
if (typeof item.id !== "string" || !UUID.test(item.id)) throw new Error("a reference id must be a lowercase UUID");
|
|
1628
|
+
if (typeof item.source?.secretId !== "string" || !UUID.test(item.source.secretId)) throw new Error("a source must be named by its secret id");
|
|
1629
|
+
checkContext(item.holder);
|
|
1630
|
+
validateText(item.holder.path);
|
|
1631
|
+
}
|
|
1632
|
+
if (new Set(items.map((item) => item.id)).size !== items.length) throw new Error("each reference needs its own id");
|
|
1633
|
+
if (items.length === 0) return {
|
|
1634
|
+
ok: true,
|
|
1635
|
+
seqs: []
|
|
1636
|
+
};
|
|
1637
|
+
const [sources, projectOf] = await Promise.all([secretsByIds(this.#db, items.map((item) => item.source.secretId)), projectsOfEnvironments(this.#db, items.map((item) => item.holder.environmentId))]);
|
|
1638
|
+
const entry = (item, decision, code) => {
|
|
1639
|
+
const source = sources.get(item.source.secretId);
|
|
1640
|
+
return {
|
|
1641
|
+
actor: principal,
|
|
1642
|
+
action: "reference.create",
|
|
1643
|
+
decision,
|
|
1644
|
+
code,
|
|
1645
|
+
projectId: projectOf.has(item.holder.environmentId) ? item.holder.projectId : null,
|
|
1646
|
+
environmentId: projectOf.has(item.holder.environmentId) ? item.holder.environmentId : null,
|
|
1647
|
+
operationId: input.operationId ?? null,
|
|
1648
|
+
requestId: input.requestId ?? null,
|
|
1649
|
+
metadata: JSON.stringify({
|
|
1650
|
+
reference: item.id,
|
|
1651
|
+
subject: item.holder.path,
|
|
1652
|
+
secretId: item.holder.secretId,
|
|
1653
|
+
source: source === void 0 ? { secretId: item.source.secretId } : { path: source.path },
|
|
1654
|
+
...source === void 0 ? {} : { also: {
|
|
1655
|
+
projectId: source.projectId,
|
|
1656
|
+
environmentId: source.environmentId,
|
|
1657
|
+
secretId: source.secretId
|
|
1658
|
+
} },
|
|
1659
|
+
...traced(input)
|
|
1660
|
+
})
|
|
1661
|
+
};
|
|
1662
|
+
};
|
|
1663
|
+
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1664
|
+
return this.#decide([principal], async (d) => {
|
|
1665
|
+
const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
|
|
1666
|
+
const environments = await environmentsById(d.tx, items.flatMap((item) => {
|
|
1667
|
+
const source = sources.get(item.source.secretId);
|
|
1668
|
+
return source === void 0 ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
|
|
1669
|
+
}));
|
|
1670
|
+
const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1671
|
+
const codes = items.map((item) => {
|
|
1672
|
+
const source = sources.get(item.source.secretId);
|
|
1673
|
+
if (source === void 0 || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return "bad_claim";
|
|
1674
|
+
if (source.secretId === item.holder.secretId) return "invalid";
|
|
1675
|
+
return refuses(reader, "secret.write", placeIn(item.holder, environments), gone(item.holder)) ?? refuses(reader, "secret.read", placeIn(source, environments), gone(source));
|
|
1676
|
+
});
|
|
1677
|
+
const first = codes.find((code) => code !== null) ?? null;
|
|
1678
|
+
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, "deny", codes[i] ?? first)));
|
|
1679
|
+
d.underLock.push(async (locked) => {
|
|
1680
|
+
if (![...(await environmentsById(locked, [...environments.keys()])).values()].some((environment) => environment.deleted)) return;
|
|
1681
|
+
throw new Refused(refusal("deleted", MESSAGES.deleted), items.map((item) => entry(item, "deny", "deleted")));
|
|
1682
|
+
});
|
|
1683
|
+
const entries = items.map((item) => entry(item, "allow", null));
|
|
1684
|
+
d.log.push(...entries);
|
|
1685
|
+
const answer = { seqs: [] };
|
|
1686
|
+
d.after.push((seqOf) => answer.seqs = entries.map(seqOf));
|
|
1687
|
+
return answer;
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* End references, each with a `reference.end` naming its creation by
|
|
1692
|
+
* `related_seq`, where the next read through it looks. `replaced` takes
|
|
1693
|
+
* write on the holder; `broken`, write on the holder or managing the
|
|
1694
|
+
* source's project's access. Ending one already ended is refused.
|
|
1695
|
+
*/
|
|
1696
|
+
async endReferences(input) {
|
|
1697
|
+
const { principal, reason } = input;
|
|
1698
|
+
validateText(principal);
|
|
1699
|
+
validateCorrelation(input);
|
|
1700
|
+
if (reason !== "replaced" && reason !== "broken" && reason !== "abandoned") throw new Error("a reference ends replaced, broken or abandoned");
|
|
1701
|
+
const vias = input.items.map((item) => validateVia(item));
|
|
1702
|
+
if (vias.some((via) => via === null) || new Set(vias.map((via) => via.seq)).size !== vias.length) throw new Error("each reference to end, once");
|
|
1703
|
+
if (vias.length === 0) return {
|
|
1704
|
+
ok: true,
|
|
1705
|
+
seqs: []
|
|
1706
|
+
};
|
|
1707
|
+
const sealed = await this.#referencesAt(vias);
|
|
1708
|
+
const holders = await secretsByIds(this.#db, sealed.flatMap((seal) => seal === null ? [] : [seal.holder.secretId]));
|
|
1709
|
+
const entry = (via, seal, decision, code) => ({
|
|
1710
|
+
actor: principal,
|
|
1711
|
+
action: "reference.end",
|
|
1712
|
+
decision,
|
|
1713
|
+
code,
|
|
1714
|
+
projectId: seal?.holder.projectId ?? null,
|
|
1715
|
+
environmentId: seal?.holder.environmentId ?? null,
|
|
1716
|
+
secretId: seal !== null && holders.has(seal.holder.secretId) ? seal.holder.secretId : null,
|
|
1717
|
+
relatedSeq: seal === null ? null : seal.seq,
|
|
1718
|
+
operationId: input.operationId ?? null,
|
|
1719
|
+
requestId: input.requestId ?? null,
|
|
1720
|
+
metadata: JSON.stringify({
|
|
1721
|
+
reference: via.reference,
|
|
1722
|
+
reason,
|
|
1723
|
+
...seal === null ? { seq: via.seq } : {
|
|
1724
|
+
subject: seal.holder.path,
|
|
1725
|
+
secretId: seal.holder.secretId,
|
|
1726
|
+
source: { path: seal.source.path },
|
|
1727
|
+
also: {
|
|
1728
|
+
projectId: seal.source.projectId,
|
|
1729
|
+
environmentId: seal.source.environmentId,
|
|
1730
|
+
secretId: seal.source.secretId
|
|
1731
|
+
}
|
|
1732
|
+
},
|
|
1733
|
+
...traced(input)
|
|
1734
|
+
})
|
|
1735
|
+
});
|
|
1736
|
+
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1737
|
+
if (sealed.some((seal) => seal === null)) return this.#decide([principal], async () => {
|
|
1738
|
+
throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), vias.map((via, i) => entry(via, sealed[i], "deny", "bad_claim")));
|
|
1739
|
+
});
|
|
1740
|
+
const seals = sealed;
|
|
1741
|
+
const ended = async (db) => {
|
|
1742
|
+
const done = await endedReferences(db, seals.map((seal) => seal.seq));
|
|
1743
|
+
return seals.map((seal) => done.has(seal.seq) ? "ended" : null);
|
|
1744
|
+
};
|
|
1745
|
+
return this.#decide([principal], async (d) => {
|
|
1746
|
+
const actor = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
|
|
1747
|
+
const environments = await environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
|
|
1748
|
+
const codes = seals.map((seal) => {
|
|
1749
|
+
const holder = refuses(actor, "secret.write", placeIn(seal.holder, environments), false);
|
|
1750
|
+
if (reason === "abandoned" && seal.createdBy !== principal) return "not_allowed";
|
|
1751
|
+
if (holder === null || reason !== "broken") return holder;
|
|
1752
|
+
return refuses(actor, "grant.manage", { projectId: seal.source.projectId }, false) === null ? null : holder;
|
|
1753
|
+
});
|
|
1754
|
+
if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => codes[i] = code);
|
|
1755
|
+
const first = codes.find((code) => code !== null) ?? null;
|
|
1756
|
+
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), vias.map((via, i) => entry(via, seals[i], "deny", codes[i] ?? first)));
|
|
1757
|
+
d.underLock.push(async (locked) => {
|
|
1758
|
+
if ((await ended(locked)).every((code) => code === null)) return;
|
|
1759
|
+
throw new Refused(refusal("ended", MESSAGES.ended), vias.map((via, i) => entry(via, seals[i], "deny", "ended")));
|
|
1760
|
+
});
|
|
1761
|
+
const entries = vias.map((via, i) => entry(via, seals[i], "allow", null));
|
|
1762
|
+
d.log.push(...entries);
|
|
1763
|
+
const answer = { seqs: [] };
|
|
1764
|
+
d.after.push((seqOf) => answer.seqs = entries.map(seqOf));
|
|
1765
|
+
return answer;
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* The reference each read names, as the vault's own `reference.create`
|
|
1770
|
+
* entry at its seq says it is: an entry of the vault's, allowed, under its
|
|
1771
|
+
* MAC, about that reference. Null for an item that names none, and for
|
|
1772
|
+
* one that names something else: a row the vault never sealed.
|
|
1773
|
+
*/
|
|
1774
|
+
async #referencesAt(vias) {
|
|
1775
|
+
const found = await entriesAt(this.#db, vias.flatMap((via) => via === null ? [] : [BigInt(via.seq)]));
|
|
1776
|
+
return vias.map((via) => {
|
|
1777
|
+
if (via === null) return null;
|
|
1778
|
+
const entry = found.get(BigInt(via.seq));
|
|
1779
|
+
if (entry === void 0 || entry.author !== "vault" || entry.action !== "reference.create" || entry.decision !== "allow") return null;
|
|
1780
|
+
if (!this.#authentic(entry)) return null;
|
|
1781
|
+
return sealedReference(entry, via.reference);
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1477
1784
|
#isRootAdmin(principal) {
|
|
1478
1785
|
return principal.startsWith("user:") && this.#config.rootAdmins.includes(principal.slice(5));
|
|
1479
1786
|
}
|
|
@@ -1560,7 +1867,7 @@ var VaultService = class {
|
|
|
1560
1867
|
fault,
|
|
1561
1868
|
stored: grants$1
|
|
1562
1869
|
};
|
|
1563
|
-
if (!
|
|
1870
|
+
if (!PRINCIPAL.test(principal)) return {
|
|
1564
1871
|
principal,
|
|
1565
1872
|
status: "unknown",
|
|
1566
1873
|
live: none,
|
|
@@ -1609,10 +1916,10 @@ var VaultService = class {
|
|
|
1609
1916
|
since: null,
|
|
1610
1917
|
by: null
|
|
1611
1918
|
};
|
|
1612
|
-
const active =
|
|
1919
|
+
const active = PRINCIPAL.test(principal) && !tampered && row?.status === "active";
|
|
1613
1920
|
return {
|
|
1614
1921
|
principal,
|
|
1615
|
-
status: tampered ? "tampered" : !
|
|
1922
|
+
status: tampered ? "tampered" : !PRINCIPAL.test(principal) ? "unknown" : row?.status ?? "unknown",
|
|
1616
1923
|
generation: row?.generation ?? 0,
|
|
1617
1924
|
isRootAdmin: false,
|
|
1618
1925
|
isOwner: active && row.owner && principal.startsWith("user:"),
|
|
@@ -1676,34 +1983,55 @@ var VaultService = class {
|
|
|
1676
1983
|
}
|
|
1677
1984
|
setAccess(input) {
|
|
1678
1985
|
const { actor, principal } = input;
|
|
1679
|
-
const
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1986
|
+
const changes = input.changes.map((change) => ({
|
|
1987
|
+
...change,
|
|
1988
|
+
environmentSlug: change.environmentSlug ?? null
|
|
1989
|
+
}));
|
|
1990
|
+
const action = changes.every((change) => change.role === null) ? "access.revoke" : "access.grant";
|
|
1991
|
+
const [only] = changes.length === 1 ? changes : [];
|
|
1992
|
+
const refused = (code, message = MESSAGES[code]) => {
|
|
1993
|
+
const where = only === void 0 || code === "invalid" ? {
|
|
1994
|
+
ids: {},
|
|
1995
|
+
detail: {}
|
|
1996
|
+
} : located(only);
|
|
1997
|
+
return new Refused(refusal(code, message), [{
|
|
1998
|
+
...accessEntry(actor, action, principal, "deny", input, {
|
|
1999
|
+
changes: input.changes,
|
|
2000
|
+
...where.detail
|
|
2001
|
+
}, code),
|
|
2002
|
+
...where.ids
|
|
2003
|
+
}]);
|
|
2004
|
+
};
|
|
2005
|
+
const onProjects = changes.flatMap(({ projectId, environmentId, role }) => projectId === null ? [] : [{
|
|
2006
|
+
projectId,
|
|
2007
|
+
environmentId,
|
|
2008
|
+
role
|
|
1687
2009
|
}]);
|
|
1688
2010
|
return this.#decide([actor, principal], async (d) => {
|
|
1689
2011
|
validateCorrelation(input);
|
|
1690
|
-
if (!
|
|
2012
|
+
if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a principal: ${principal}`);
|
|
1691
2013
|
if (this.#isRootAdmin(principal)) throw refused("root_admin");
|
|
1692
2014
|
const places$1 = /* @__PURE__ */ new Set();
|
|
1693
|
-
for (const change of
|
|
1694
|
-
const
|
|
2015
|
+
for (const change of changes) {
|
|
2016
|
+
const shape = placeShape(change);
|
|
2017
|
+
if (shape !== null) throw refused("invalid", shape);
|
|
2018
|
+
const key = placeKey(change);
|
|
1695
2019
|
if (places$1.has(key)) throw refused("invalid", "each place may be changed once per call");
|
|
1696
2020
|
places$1.add(key);
|
|
1697
2021
|
if (change.role !== null && !isRole(change.role)) throw refused("invalid", `no such role: ${change.role}`);
|
|
1698
|
-
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) throw refused("invalid", `${change.role} can only be granted on a project`);
|
|
2022
|
+
if (change.role !== null && (change.environmentId !== null || change.environmentSlug !== null) && !assignableToEnvironment(change.role)) throw refused("invalid", `${change.role} can only be granted on a project, or on every project`);
|
|
1699
2023
|
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
1700
2024
|
if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
|
|
1701
2025
|
}
|
|
1702
|
-
|
|
1703
|
-
|
|
2026
|
+
if (onProjects.length > 0) await lockLogHead(d.tx);
|
|
2027
|
+
const known = await places(d.tx, onProjects.map((change) => change.projectId), onProjects.flatMap((change) => change.environmentId === null ? [] : [change.environmentId]));
|
|
2028
|
+
for (const { projectId, environmentId, role } of onProjects) {
|
|
2029
|
+
if (!known.projects.has(projectId) || environmentId !== null && known.environments.get(environmentId) !== projectId) throw refused("invalid", `no such place: ${environmentId === null ? projectId : `${projectId}/${environmentId}`}`);
|
|
2030
|
+
if (role !== null && (known.deleted.has(projectId) || environmentId !== null && known.deleted.has(environmentId))) throw refused("deleted");
|
|
2031
|
+
}
|
|
1704
2032
|
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1705
2033
|
if (acting.status === "tampered") throw refused("tampered");
|
|
1706
|
-
if (!
|
|
2034
|
+
if (!changes.every((change) => mayManageAccess(acting.live, change))) throw refused("not_allowed");
|
|
1707
2035
|
const row = d.members.get(principal);
|
|
1708
2036
|
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1709
2037
|
if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
|
|
@@ -1711,27 +2039,30 @@ var VaultService = class {
|
|
|
1711
2039
|
if (row === void 0) throw refused("not_a_member");
|
|
1712
2040
|
const held = subject.stored;
|
|
1713
2041
|
d.grants.set(principal, [...held]);
|
|
1714
|
-
const
|
|
2042
|
+
const outcomes = changes.map((change) => this.#apply(d, actor, principal, held, change, input));
|
|
1715
2043
|
if (d.writes.length > 0) d.touched.add(principal);
|
|
1716
|
-
return { changes };
|
|
2044
|
+
return { changes: outcomes };
|
|
1717
2045
|
});
|
|
1718
2046
|
}
|
|
1719
2047
|
/** One place's change, logged when it changes what is live. */
|
|
1720
2048
|
#apply(d, actor, principal, held, change, correlation) {
|
|
1721
|
-
const existing = held.find((grant) => grant
|
|
2049
|
+
const existing = held.find((grant) => placeKey(grant) === placeKey(change));
|
|
1722
2050
|
const current = existing !== void 0 && live(existing, d.at) ? existing : void 0;
|
|
1723
2051
|
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
1724
2052
|
const place = {
|
|
1725
2053
|
projectId: change.projectId,
|
|
1726
|
-
environmentId: change.environmentId
|
|
2054
|
+
environmentId: change.environmentId,
|
|
2055
|
+
environmentSlug: change.environmentSlug
|
|
1727
2056
|
};
|
|
2057
|
+
const where = located(place);
|
|
1728
2058
|
const entry = (action, role) => d.log.push({
|
|
1729
2059
|
...accessEntry(actor, action, principal, "allow", correlation, {
|
|
1730
2060
|
role,
|
|
1731
2061
|
expiresAt: expiresAt === null ? null : iso(expiresAt),
|
|
1732
|
-
previousRole: current?.role ?? null
|
|
2062
|
+
previousRole: current?.role ?? null,
|
|
2063
|
+
...where.detail
|
|
1733
2064
|
}),
|
|
1734
|
-
...
|
|
2065
|
+
...where.ids
|
|
1735
2066
|
});
|
|
1736
2067
|
const clear = () => {
|
|
1737
2068
|
if (existing === void 0) return;
|
|
@@ -1772,7 +2103,7 @@ var VaultService = class {
|
|
|
1772
2103
|
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1773
2104
|
if (acting.status === "tampered") throw refused("tampered");
|
|
1774
2105
|
if (!acting.live.isOwner) throw refused("not_allowed", "only owners may add or restore members");
|
|
1775
|
-
if (!
|
|
2106
|
+
if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
|
|
1776
2107
|
if (this.#isRootAdmin(principal)) throw refused("root_admin");
|
|
1777
2108
|
if (input.owner === true && !principal.startsWith("user:")) throw refused("invalid", "service accounts cannot be owners");
|
|
1778
2109
|
const row = d.members.get(principal);
|
|
@@ -1826,7 +2157,7 @@ var VaultService = class {
|
|
|
1826
2157
|
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.remove", principal, "deny", input, {}, code)]);
|
|
1827
2158
|
return this.#decide([actor, principal], async (d) => {
|
|
1828
2159
|
validateCorrelation(input);
|
|
1829
|
-
if (!
|
|
2160
|
+
if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
|
|
1830
2161
|
if (this.#isRootAdmin(principal)) throw refused("root_admin");
|
|
1831
2162
|
const row = d.members.get(principal);
|
|
1832
2163
|
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
@@ -1841,15 +2172,18 @@ var VaultService = class {
|
|
|
1841
2172
|
if (!holder.isOwner) throw refused("not_allowed", "only owners may remove members");
|
|
1842
2173
|
if (row?.status !== "active") throw refused(row === void 0 ? "not_a_member" : "removed");
|
|
1843
2174
|
const revoked = held.filter((grant) => live(grant, d.at));
|
|
1844
|
-
for (const grant of revoked)
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
2175
|
+
for (const grant of revoked) {
|
|
2176
|
+
const where = located(grant);
|
|
2177
|
+
d.log.push({
|
|
2178
|
+
...accessEntry(actor, "access.revoke", principal, "allow", input, {
|
|
2179
|
+
role: null,
|
|
2180
|
+
expiresAt: null,
|
|
2181
|
+
previousRole: grant.role,
|
|
2182
|
+
...where.detail
|
|
2183
|
+
}),
|
|
2184
|
+
...where.ids
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
1853
2187
|
const generation = row.generation + 1;
|
|
1854
2188
|
d.log.push(accessEntry(actor, "member.remove", principal, "allow", input, {
|
|
1855
2189
|
revoked: revoked.length,
|
|
@@ -2428,15 +2762,96 @@ function validateItems(items, field) {
|
|
|
2428
2762
|
}
|
|
2429
2763
|
}
|
|
2430
2764
|
}
|
|
2431
|
-
/**
|
|
2432
|
-
function
|
|
2765
|
+
/** What a genuine `reference.create` entry seals, if it is about `reference`; null if not, or malformed. */
|
|
2766
|
+
function sealedReference(entry, reference) {
|
|
2767
|
+
let detail;
|
|
2768
|
+
try {
|
|
2769
|
+
detail = JSON.parse(entry.metadata);
|
|
2770
|
+
} catch {
|
|
2771
|
+
return null;
|
|
2772
|
+
}
|
|
2773
|
+
const text = (value) => typeof value === "string" && value !== "";
|
|
2774
|
+
const also = detail.also ?? {};
|
|
2775
|
+
if (detail.reference !== reference || entry.projectId === null || entry.environmentId === null) return null;
|
|
2776
|
+
if (!text(detail.subject) || !text(detail.secretId) || !text(detail.source?.path)) return null;
|
|
2777
|
+
if (!text(also.projectId) || !text(also.environmentId) || !text(also.secretId)) return null;
|
|
2778
|
+
return {
|
|
2779
|
+
reference,
|
|
2780
|
+
seq: entry.seq,
|
|
2781
|
+
holder: {
|
|
2782
|
+
projectId: entry.projectId,
|
|
2783
|
+
environmentId: entry.environmentId,
|
|
2784
|
+
secretId: detail.secretId,
|
|
2785
|
+
path: detail.subject
|
|
2786
|
+
},
|
|
2787
|
+
source: {
|
|
2788
|
+
projectId: also.projectId,
|
|
2789
|
+
environmentId: also.environmentId,
|
|
2790
|
+
secretId: also.secretId,
|
|
2791
|
+
path: detail.source.path
|
|
2792
|
+
},
|
|
2793
|
+
createdBy: entry.actor,
|
|
2794
|
+
createdAt: entry.occurredAt
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
/** A read's reference, checked for shape: null for none. */
|
|
2798
|
+
function validateVia(via) {
|
|
2799
|
+
if (via === void 0 || via === null) return null;
|
|
2800
|
+
if (typeof via.reference !== "string" || !UUID.test(via.reference)) throw new Error("a reference id must be a lowercase UUID");
|
|
2801
|
+
if (!Number.isSafeInteger(via.seq) || via.seq < 0) throw new Error("a reference's seq must be a whole number");
|
|
2802
|
+
return {
|
|
2803
|
+
reference: via.reference,
|
|
2804
|
+
seq: via.seq
|
|
2805
|
+
};
|
|
2806
|
+
}
|
|
2807
|
+
/**
|
|
2808
|
+
* How a read through a reference is logged: the way in, and `also`, the
|
|
2809
|
+
* holder's place, which puts the read in the holder's project's log too.
|
|
2810
|
+
*/
|
|
2811
|
+
function viaDetail(via, seal) {
|
|
2812
|
+
if (seal === null) return { via: {
|
|
2813
|
+
reference: via.reference,
|
|
2814
|
+
seq: via.seq
|
|
2815
|
+
} };
|
|
2816
|
+
return {
|
|
2817
|
+
via: {
|
|
2818
|
+
reference: via.reference,
|
|
2819
|
+
seq: via.seq,
|
|
2820
|
+
path: seal.holder.path,
|
|
2821
|
+
createdBy: seal.createdBy,
|
|
2822
|
+
createdAt: iso(seal.createdAt)
|
|
2823
|
+
},
|
|
2824
|
+
also: {
|
|
2825
|
+
projectId: seal.holder.projectId,
|
|
2826
|
+
environmentId: seal.holder.environmentId,
|
|
2827
|
+
secretId: seal.holder.secretId
|
|
2828
|
+
}
|
|
2829
|
+
};
|
|
2830
|
+
}
|
|
2831
|
+
/**
|
|
2832
|
+
* Where a decision checks a permission: an environment, with its slug as
|
|
2833
|
+
* the store has it when it was read and is in the project claimed;
|
|
2834
|
+
* otherwise none, which no grant on a slug matches.
|
|
2835
|
+
*/
|
|
2836
|
+
function placeIn(place, environments) {
|
|
2837
|
+
const environment = environments.get(place.environmentId);
|
|
2838
|
+
const slug = environment?.projectId === place.projectId ? environment.slug : null;
|
|
2839
|
+
return {
|
|
2840
|
+
projectId: place.projectId,
|
|
2841
|
+
environmentId: place.environmentId,
|
|
2842
|
+
environmentSlug: slug
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
/**
|
|
2846
|
+
* Why `reader` may not do `permission` at `where`, or null if they may. A
|
|
2847
|
+
* deleted place is refused before any grant is asked, so no kind of grant,
|
|
2848
|
+
* on the project, the environment or every project, can reach into one.
|
|
2849
|
+
*/
|
|
2850
|
+
function refuses(reader, permission, where, deleted) {
|
|
2433
2851
|
if (reader.status === "tampered") return "tampered";
|
|
2434
2852
|
if (reader.status === "removed") return "removed";
|
|
2435
2853
|
if (reader.status === "unknown") return "not_a_member";
|
|
2436
|
-
|
|
2437
|
-
projectId: secret.projectId,
|
|
2438
|
-
environmentId: secret.environmentId
|
|
2439
|
-
};
|
|
2854
|
+
if (deleted) return "deleted";
|
|
2440
2855
|
if (allows(reader.live, permission, where)) return null;
|
|
2441
2856
|
return allows(reader.all, permission, where) ? "expired" : "no_grant";
|
|
2442
2857
|
}
|
|
@@ -2491,10 +2906,42 @@ function accessEntry(actor, action, principal, decision, correlation, detail, co
|
|
|
2491
2906
|
function live(grant, at) {
|
|
2492
2907
|
return grant.expiresAt === null || grant.expiresAt > at;
|
|
2493
2908
|
}
|
|
2909
|
+
/**
|
|
2910
|
+
* 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 its payload. Only a
|
|
2912
|
+
* grant that names no place at all is on every project (`grantKind`).
|
|
2913
|
+
*/
|
|
2914
|
+
function located(place) {
|
|
2915
|
+
return grantKind(place) === "every-project" ? {
|
|
2916
|
+
ids: {},
|
|
2917
|
+
detail: { place: everyProjectPath(place.environmentSlug) }
|
|
2918
|
+
} : {
|
|
2919
|
+
ids: {
|
|
2920
|
+
projectId: place.projectId,
|
|
2921
|
+
environmentId: place.environmentId
|
|
2922
|
+
},
|
|
2923
|
+
detail: {}
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2926
|
+
/** One key per place a member can hold a grant at, by what the grant names first: its environment, its project, or neither. */
|
|
2927
|
+
function placeKey(place) {
|
|
2928
|
+
if (place.environmentId !== null) return `environment:${place.environmentId}`;
|
|
2929
|
+
return place.projectId !== null ? `project:${place.projectId}` : everyProjectPath(place.environmentSlug);
|
|
2930
|
+
}
|
|
2931
|
+
/** Why a change's place is not one a grant can be at, or null when it is. */
|
|
2932
|
+
function placeShape(place) {
|
|
2933
|
+
const kind = grantKind(place);
|
|
2934
|
+
if (kind === null) return place.projectId === null ? "an environment is named in its project" : "a slug names environments on every project, not in one";
|
|
2935
|
+
if (kind === "every-project" && place.environmentSlug !== null && !SLUG.test(place.environmentSlug)) return `not an environment slug: ${place.environmentSlug}`;
|
|
2936
|
+
return null;
|
|
2937
|
+
}
|
|
2938
|
+
/** An environment's slug, as the schema has it. */
|
|
2939
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
2494
2940
|
function view(grant) {
|
|
2495
2941
|
return {
|
|
2496
2942
|
projectId: grant.projectId,
|
|
2497
2943
|
environmentId: grant.environmentId,
|
|
2944
|
+
environmentSlug: grant.environmentSlug,
|
|
2498
2945
|
role: grant.role,
|
|
2499
2946
|
expiresAt: grant.expiresAt === null ? null : iso(grant.expiresAt),
|
|
2500
2947
|
grantedAt: iso(grant.grantedAt),
|