@coffre/vault 0.8.2 → 0.10.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 +10 -11
- package/dist/cloudflare.js +8 -2
- package/dist/{index-BkHJR9Gt.d.ts → index-CByXzyi-.d.ts} +7 -12
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/node.d.ts +2 -2
- package/dist/node.js +4 -2
- package/dist/{src-BcttALFQ.js → src-D82jyprb.js} +2 -6
- package/dist/{vault-lBbyZ3kQ.js → vault-COqZ12ny.js} +389 -53
- package/package.json +3 -3
- package/src/cloudflare.ts +3 -0
- package/src/config.ts +12 -19
- package/src/index.ts +1 -1
- package/src/local.ts +2 -0
- package/src/store.ts +84 -1
- package/src/vault.ts +389 -57
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { ACCESS_ACTIONS,
|
|
1
|
+
import { ACCESS_ACTIONS, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
|
|
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 { EVERYWHERE, NOTHING, allows, assignableToEnvironment, givesService, grantKind, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped, withinCredential } from "@coffre/core/access";
|
|
5
|
+
import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_ROLES, NOTHING, PROJECT_ONLY_PERMISSIONS, ROLES, ROLE_ID, UNWRITTEN_FORK_MS, allows, assignableToEnvironment, definitionProblem, givesService, grantKind, isBuiltinRole, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalDefinition, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped, withinCredential } from "@coffre/core/access";
|
|
6
6
|
import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
|
|
7
7
|
import { checkContext, ownerKey } 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
10
|
import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
|
|
11
|
-
import { LOOK_BACK, appendEntries, entryColumns, lockLogHead, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
|
|
11
|
+
import { LOOK_BACK, appendEntries, entryColumns, lockLogHead, newestAuthentic, newestVaultEntries, newestVaultSeq, withinBytes } from "@coffre/db/log";
|
|
12
12
|
import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
|
|
13
13
|
//#region src/accounting.ts
|
|
14
14
|
const KEY_ACTIONS = /* @__PURE__ */ new Set([
|
|
@@ -456,6 +456,12 @@ async function endedReferences(db, seqs) {
|
|
|
456
456
|
return new Set(rows.map((row) => row.relatedSeq));
|
|
457
457
|
}
|
|
458
458
|
/** The project each environment is in: a holder's ids, checked against each other. */
|
|
459
|
+
/** Whether a project of this id is there, and not deleted. */
|
|
460
|
+
async function projectLive(db, id) {
|
|
461
|
+
const { projects } = tablesOf(db);
|
|
462
|
+
const rows = await db.select({ deleted: truth(tombstone(projects.slug)) }).from(projects).where(eq(projects.id, id));
|
|
463
|
+
return rows.length > 0 && !rows[0].deleted;
|
|
464
|
+
}
|
|
459
465
|
async function projectsOfEnvironments(db, ids) {
|
|
460
466
|
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
461
467
|
const { environments } = tablesOf(db);
|
|
@@ -482,6 +488,41 @@ async function secretsByIds(db, ids) {
|
|
|
482
488
|
path: `${project}/${environment}/${key}`
|
|
483
489
|
}]));
|
|
484
490
|
}
|
|
491
|
+
/**
|
|
492
|
+
* Each secret's nature, as its current version has it: plain where its
|
|
493
|
+
* value is stored as it is, sensitive where it is bound so, and otherwise
|
|
494
|
+
* secret, as is one with no value of its own.
|
|
495
|
+
*/
|
|
496
|
+
async function natures(db, secretIds) {
|
|
497
|
+
if (secretIds.length === 0) return /* @__PURE__ */ new Map();
|
|
498
|
+
const { secrets, secretVersions } = tablesOf(db);
|
|
499
|
+
const rows = await db.select({
|
|
500
|
+
id: secrets.id,
|
|
501
|
+
sensitive: secretVersions.sensitive,
|
|
502
|
+
plain: truth(isNotNull(secretVersions.plaintext))
|
|
503
|
+
}).from(secrets).innerJoin(secretVersions, eq(secretVersions.id, secrets.currentVersionId)).where(inArray(secrets.id, [...new Set(secretIds)]));
|
|
504
|
+
return new Map(rows.map((row) => [row.id, Boolean(row.sensitive) ? "sensitive" : Boolean(row.plain) ? "plain" : "secret"]));
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* For each version a rewrap would bring back into its secret, its nature
|
|
508
|
+
* as stored when it is an old value, not the secret's current one; null
|
|
509
|
+
* for the current one, which a mark wraps again with no value changing.
|
|
510
|
+
*/
|
|
511
|
+
async function restoredNatures(db, items) {
|
|
512
|
+
if (items.length === 0) return [];
|
|
513
|
+
const { secrets, secretVersions } = tablesOf(db);
|
|
514
|
+
const [current, versions] = await Promise.all([db.select({
|
|
515
|
+
id: secrets.id,
|
|
516
|
+
version: secrets.currentVersionId
|
|
517
|
+
}).from(secrets).where(inArray(secrets.id, [...new Set(items.map((item) => item.secretId))])), db.select({
|
|
518
|
+
id: secretVersions.id,
|
|
519
|
+
sensitive: secretVersions.sensitive,
|
|
520
|
+
plain: truth(isNotNull(secretVersions.plaintext))
|
|
521
|
+
}).from(secretVersions).where(inArray(secretVersions.id, [...new Set(items.map((item) => item.from))]))]);
|
|
522
|
+
const currentOf = new Map(current.map((row) => [row.id, row.version]));
|
|
523
|
+
const natureOf = new Map(versions.map((row) => [row.id, Boolean(row.sensitive) ? "sensitive" : Boolean(row.plain) ? "plain" : "secret"]));
|
|
524
|
+
return items.map(({ secretId, from }) => currentOf.get(secretId) === from ? null : natureOf.get(from) ?? "secret");
|
|
525
|
+
}
|
|
485
526
|
/** The vault's newest entry of any of these actions, allowed, or undefined. */
|
|
486
527
|
async function latestVaultEntry(db, actions) {
|
|
487
528
|
const { auditLog } = tablesOf(db);
|
|
@@ -500,6 +541,19 @@ async function vaultEntriesOf(db, actions, afterSeq, limit) {
|
|
|
500
541
|
const { auditLog } = tablesOf(db);
|
|
501
542
|
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));
|
|
502
543
|
}
|
|
544
|
+
/** Which of these secrets' newest versions are sensitive, by the versions' own order. */
|
|
545
|
+
async function newestSensitive(db, secretIds) {
|
|
546
|
+
if (secretIds.length === 0) return /* @__PURE__ */ new Set();
|
|
547
|
+
const { secretVersions } = tablesOf(db);
|
|
548
|
+
const rows = await db.select({ secretId: secretVersions.secretId }).from(secretVersions).where(and(inArray(secretVersions.secretId, [...new Set(secretIds)]), eq(secretVersions.sensitive, true), sql`${secretVersions.version} = (SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`));
|
|
549
|
+
return new Set(rows.map((row) => row.secretId));
|
|
550
|
+
}
|
|
551
|
+
/** How many grants name `role`: all of them, or those on environments. */
|
|
552
|
+
async function grantsNaming(db, role, filter = {}) {
|
|
553
|
+
const { vaultGrants } = tablesOf(db);
|
|
554
|
+
const rows = await db.select({ count: sql`count(*)` }).from(vaultGrants).where(and(eq(vaultGrants.role, role), filter.onEnvironments === true ? or(isNotNull(vaultGrants.environmentId), isNotNull(vaultGrants.environmentSlug)) : void 0));
|
|
555
|
+
return Number(rows[0]?.count ?? 0);
|
|
556
|
+
}
|
|
503
557
|
/**
|
|
504
558
|
* A version that still holds its wrapped key. A deleted place's versions are
|
|
505
559
|
* erased, their keys emptied: there is nothing left to open, or to try a
|
|
@@ -526,7 +580,9 @@ async function versions(db, ids) {
|
|
|
526
580
|
wrappedDek: secretVersions.wrappedDek,
|
|
527
581
|
deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))),
|
|
528
582
|
currentVersionId: secrets.currentVersionId,
|
|
529
|
-
|
|
583
|
+
sensitive: secretVersions.sensitive,
|
|
584
|
+
newest: sql`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`,
|
|
585
|
+
newestSensitive: sql`(SELECT newest.sensitive FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId} ORDER BY newest.version DESC LIMIT 1)`
|
|
530
586
|
}).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) => ({
|
|
531
587
|
id: row.id,
|
|
532
588
|
deleted: row.deleted,
|
|
@@ -535,7 +591,8 @@ async function versions(db, ids) {
|
|
|
535
591
|
environmentId: row.environmentId,
|
|
536
592
|
secretId: row.secretId,
|
|
537
593
|
version: row.version,
|
|
538
|
-
path: `${row.project}/${row.environment}/${row.key}
|
|
594
|
+
path: `${row.project}/${row.environment}/${row.key}`,
|
|
595
|
+
sensitive: row.sensitive
|
|
539
596
|
},
|
|
540
597
|
wrapped: {
|
|
541
598
|
kekProvider: row.kekProvider,
|
|
@@ -543,7 +600,8 @@ async function versions(db, ids) {
|
|
|
543
600
|
kekVersion: row.kekVersion,
|
|
544
601
|
bytes: row.wrappedDek.toString("base64")
|
|
545
602
|
},
|
|
546
|
-
current: row.currentVersionId === row.id && Number(row.newest) === row.version
|
|
603
|
+
current: row.currentVersionId === row.id && Number(row.newest) === row.version,
|
|
604
|
+
newestSensitive: Boolean(row.newestSensitive)
|
|
547
605
|
}));
|
|
548
606
|
}
|
|
549
607
|
//#endregion
|
|
@@ -1024,6 +1082,31 @@ const CHECKPOINT = "audit.checkpoint";
|
|
|
1024
1082
|
const SETTINGS = "settings.change";
|
|
1025
1083
|
/** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
|
|
1026
1084
|
const DEFAULT_SETTINGS = { serviceAccounts: EVERYWHERE };
|
|
1085
|
+
/** The action of the vault's entry that holds the instance's access policy, and changes it (`policy`). */
|
|
1086
|
+
const POLICY = "policy.change";
|
|
1087
|
+
const NO_EDITS = {
|
|
1088
|
+
roles: {},
|
|
1089
|
+
instanceRoles: {},
|
|
1090
|
+
protected: []
|
|
1091
|
+
};
|
|
1092
|
+
/** A role holding nothing, which every role but Owner is under the closed policy. */
|
|
1093
|
+
const NOTHING_HELD = (definition) => ({
|
|
1094
|
+
...definition,
|
|
1095
|
+
permissions: [],
|
|
1096
|
+
protected: []
|
|
1097
|
+
});
|
|
1098
|
+
/**
|
|
1099
|
+
* The policy when the log has some, but none the vault wrote within reach:
|
|
1100
|
+
* whoever inserted rows may have hidden an Owner's narrowing, so no role
|
|
1101
|
+
* holds anything, a service account's neither, but Owner, which holds
|
|
1102
|
+
* everything always and so may write the policy again.
|
|
1103
|
+
*/
|
|
1104
|
+
const CLOSED_POLICY = {
|
|
1105
|
+
roles: Object.fromEntries(Object.entries(ROLES).map(([id, definition]) => [id, id === "owner" ? definition : NOTHING_HELD(definition)])),
|
|
1106
|
+
instanceRoles: Object.fromEntries(Object.entries(INSTANCE_ROLES).map(([id, definition]) => [id, id === "owner" ? definition : NOTHING_HELD(definition)])),
|
|
1107
|
+
protected: [EVERY_ENVIRONMENT],
|
|
1108
|
+
protectedIn: {}
|
|
1109
|
+
};
|
|
1027
1110
|
/** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
|
|
1028
1111
|
const CLOSED_SETTINGS = { serviceAccounts: {
|
|
1029
1112
|
projects: { only: [] },
|
|
@@ -1067,6 +1150,8 @@ var Outage = class {
|
|
|
1067
1150
|
this.entries = entries;
|
|
1068
1151
|
}
|
|
1069
1152
|
};
|
|
1153
|
+
/** A rewrap that would make a value less sensitive than it is. */
|
|
1154
|
+
const LOOSER = "a value is made less sensitive only by writing it again: an old version comes back, and a key is made stricter, as sensitive as its newest version or more";
|
|
1070
1155
|
function refusal(code, message) {
|
|
1071
1156
|
return {
|
|
1072
1157
|
code,
|
|
@@ -1083,10 +1168,11 @@ const MESSAGES = {
|
|
|
1083
1168
|
deleted: "this project or environment was deleted",
|
|
1084
1169
|
ended: "the reference read through was ended",
|
|
1085
1170
|
personal: "a personal config's values are its owner's alone",
|
|
1086
|
-
|
|
1171
|
+
sensitive: "a sensitive value is never shown to anyone, Owners included: `coffre run` gives your app a placeholder for it through the credential proxy, and a service account reads it",
|
|
1087
1172
|
not_allowed: "not allowed to change this",
|
|
1088
1173
|
last_owner: "the last Owner of the whole instance stays one: make someone else an Owner first",
|
|
1089
1174
|
invalid: "not something the rules allow",
|
|
1175
|
+
policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
|
|
1090
1176
|
log_broken: "the vault log does not hold from the last checkpoint",
|
|
1091
1177
|
wrong_kek: "this vault's key does not open the data it holds",
|
|
1092
1178
|
tampered: "this member's record failed the vault's integrity check"
|
|
@@ -1144,36 +1230,44 @@ var VaultService = class {
|
|
|
1144
1230
|
return await now(db) + this.#prepared.options.clockOffset();
|
|
1145
1231
|
}
|
|
1146
1232
|
/**
|
|
1147
|
-
* Decide in one transaction, with `principals`' rows locked first
|
|
1148
|
-
* `Refused` or an `Outage` rolls back all but its own
|
|
1149
|
-
* commit on their own: then the refusal is the answer, and
|
|
1150
|
-
* fails the call.
|
|
1233
|
+
* Decide in one transaction, with `principals`' rows locked first, then
|
|
1234
|
+
* the log's head. A `Refused` or an `Outage` rolls back all but its own
|
|
1235
|
+
* entries, which commit on their own: then the refusal is the answer, and
|
|
1236
|
+
* the outage fails the call.
|
|
1237
|
+
*
|
|
1238
|
+
* Every change to the policy, the settings, a member or a place appends
|
|
1239
|
+
* under the head, so whatever the decision reads of them stands until it
|
|
1240
|
+
* commits: no decision acts on a policy read before it. A key operation
|
|
1241
|
+
* alone takes the head later (`late`), once its key service has answered,
|
|
1242
|
+
* which it must not wait on holding it, and decides again then (`#keys`).
|
|
1151
1243
|
*/
|
|
1152
|
-
async #decide(principals, decide) {
|
|
1244
|
+
async #decide(principals, decide, late = false) {
|
|
1153
1245
|
const superseded = await this.#settled();
|
|
1154
1246
|
if (superseded !== null) return {
|
|
1155
1247
|
ok: false,
|
|
1156
1248
|
refusal: refusal("wrong_kek", superseded)
|
|
1157
1249
|
};
|
|
1158
|
-
return this.#decideNow(principals, decide);
|
|
1250
|
+
return this.#decideNow(principals, decide, late);
|
|
1159
1251
|
}
|
|
1160
1252
|
/** `#decide`, once the vault is settled. */
|
|
1161
|
-
async #decideNow(principals, decide) {
|
|
1253
|
+
async #decideNow(principals, decide, late = false) {
|
|
1162
1254
|
const reports = [];
|
|
1163
1255
|
for (let attempt = 1;; attempt += 1) try {
|
|
1164
|
-
return await this.#decideOnce(principals, decide, reports);
|
|
1256
|
+
return await this.#decideOnce(principals, decide, reports, late);
|
|
1165
1257
|
} catch (error) {
|
|
1166
1258
|
if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
|
|
1167
1259
|
throw error;
|
|
1168
1260
|
}
|
|
1169
1261
|
}
|
|
1170
|
-
async #decideOnce(principals, decide, reports) {
|
|
1262
|
+
async #decideOnce(principals, decide, reports, late) {
|
|
1171
1263
|
try {
|
|
1172
1264
|
const result = await this.#db.transaction(async (tx) => {
|
|
1173
1265
|
await boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
1266
|
+
const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals);
|
|
1267
|
+
if (!late) await lockLogHead(tx);
|
|
1174
1268
|
const d = {
|
|
1175
1269
|
tx,
|
|
1176
|
-
members
|
|
1270
|
+
members,
|
|
1177
1271
|
at: await this.#now(tx),
|
|
1178
1272
|
log: [],
|
|
1179
1273
|
writes: [],
|
|
@@ -1367,7 +1461,6 @@ var VaultService = class {
|
|
|
1367
1461
|
const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
|
|
1368
1462
|
if (everyProject.length === 0) return;
|
|
1369
1463
|
if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
|
|
1370
|
-
await lockLogHead(d.tx);
|
|
1371
1464
|
const before = roleOf(principal, row);
|
|
1372
1465
|
const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await liveProjects(d.tx));
|
|
1373
1466
|
const after = {
|
|
@@ -1529,6 +1622,7 @@ var VaultService = class {
|
|
|
1529
1622
|
async unwrap(input) {
|
|
1530
1623
|
const { principal } = input;
|
|
1531
1624
|
validateText(input.purpose);
|
|
1625
|
+
if (input.keepsSensitive !== void 0 && typeof input.keepsSensitive !== "boolean") throw new Error("keepsSensitive is true, false or absent");
|
|
1532
1626
|
const vias = input.items.map((item) => validateVia(item.via));
|
|
1533
1627
|
const throughs = input.items.map((item) => validateThrough(item.through));
|
|
1534
1628
|
const loaded = await this.#versions(input, "secret.read", { purpose: input.purpose });
|
|
@@ -1577,7 +1671,7 @@ var VaultService = class {
|
|
|
1577
1671
|
return this.#keys({
|
|
1578
1672
|
action: "secret.read",
|
|
1579
1673
|
principal,
|
|
1580
|
-
permission: "secret.read",
|
|
1674
|
+
permission: input.purpose === "use" ? "secret.use" : "secret.read",
|
|
1581
1675
|
secrets: items.map((item) => item.secret),
|
|
1582
1676
|
places: sealed.map((seal, i) => seal?.holder ?? items[i].secret),
|
|
1583
1677
|
vet: ends.length === 0 ? void 0 : async (db) => {
|
|
@@ -1627,7 +1721,8 @@ var VaultService = class {
|
|
|
1627
1721
|
}
|
|
1628
1722
|
}), (done) => ({
|
|
1629
1723
|
wrapped: done.map(({ wrapped }) => wrapped),
|
|
1630
|
-
seqs: []
|
|
1724
|
+
seqs: [],
|
|
1725
|
+
sensitive: items.map(({ secret }) => secret.sensitive === true)
|
|
1631
1726
|
}));
|
|
1632
1727
|
}
|
|
1633
1728
|
async rewrap(input) {
|
|
@@ -1644,6 +1739,10 @@ var VaultService = class {
|
|
|
1644
1739
|
...keyEntry("key.rewrap", principal, source.secret, "deny", "bad_claim", input),
|
|
1645
1740
|
secretVersionId: source.id
|
|
1646
1741
|
})));
|
|
1742
|
+
if (items.some((item, i) => item.secret.sensitive !== true && (loaded.versions[i].secret.sensitive || loaded.versions[i].newestSensitive))) return this.#refuseVersions(principal, "invalid", loaded.versions.map((source) => ({
|
|
1743
|
+
...keyEntry("key.rewrap", principal, source.secret, "deny", "invalid", input),
|
|
1744
|
+
secretVersionId: source.id
|
|
1745
|
+
})), LOOSER);
|
|
1647
1746
|
const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
|
|
1648
1747
|
const entry = (secret, decision, code) => ({
|
|
1649
1748
|
...keyEntry("key.rewrap", principal, secret, decision, code, input, { from: sources.get(secret).secret.version }),
|
|
@@ -1657,9 +1756,19 @@ var VaultService = class {
|
|
|
1657
1756
|
secrets: items.map((item) => item.secret),
|
|
1658
1757
|
remote,
|
|
1659
1758
|
entry,
|
|
1660
|
-
input
|
|
1759
|
+
input,
|
|
1760
|
+
vet: async (db) => {
|
|
1761
|
+
const newest = await newestSensitive(db, items.map(({ secret }) => secret.secretId));
|
|
1762
|
+
return items.map(({ secret }) => secret.sensitive !== true && newest.has(secret.secretId) ? "invalid" : null);
|
|
1763
|
+
},
|
|
1764
|
+
needs: async (db) => {
|
|
1765
|
+
return (await restoredNatures(db, items.map(({ secret }) => ({
|
|
1766
|
+
secretId: secret.secretId,
|
|
1767
|
+
from: sources.get(secret).id
|
|
1768
|
+
})))).map((nature) => nature === "secret" ? ["secret.read"] : nature === "sensitive" ? ["secret.use"] : null);
|
|
1769
|
+
}
|
|
1661
1770
|
}, (contextOf) => items.map(({ secret, wrapped }) => async (operation) => {
|
|
1662
|
-
const key = await this.#open(wrapped, contextOf(secret), operation);
|
|
1771
|
+
const key = await this.#open(wrapped, contextOf(sources.get(secret).secret), operation);
|
|
1663
1772
|
if (key === null) return null;
|
|
1664
1773
|
try {
|
|
1665
1774
|
return {
|
|
@@ -1671,7 +1780,8 @@ var VaultService = class {
|
|
|
1671
1780
|
}
|
|
1672
1781
|
}), (done) => ({
|
|
1673
1782
|
wrapped: done.map(({ wrapped }) => wrapped),
|
|
1674
|
-
seqs: []
|
|
1783
|
+
seqs: [],
|
|
1784
|
+
sensitive: items.map(({ secret }) => secret.sensitive === true)
|
|
1675
1785
|
}));
|
|
1676
1786
|
}
|
|
1677
1787
|
/** 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. */
|
|
@@ -1710,9 +1820,9 @@ var VaultService = class {
|
|
|
1710
1820
|
versions: ids.map((id) => found.get(id))
|
|
1711
1821
|
};
|
|
1712
1822
|
}
|
|
1713
|
-
async #refuseVersions(principal, code, entries) {
|
|
1823
|
+
async #refuseVersions(principal, code, entries, message = MESSAGES[code]) {
|
|
1714
1824
|
return this.#decide([principal], async () => {
|
|
1715
|
-
throw new Refused(refusal(code,
|
|
1825
|
+
throw new Refused(refusal(code, message), entries);
|
|
1716
1826
|
});
|
|
1717
1827
|
}
|
|
1718
1828
|
/**
|
|
@@ -1756,14 +1866,27 @@ var VaultService = class {
|
|
|
1756
1866
|
const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1757
1867
|
owners.clear();
|
|
1758
1868
|
for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
|
|
1869
|
+
const needs = call.needs === void 0 ? [] : await call.needs(d.tx);
|
|
1759
1870
|
const codes = secrets.map((secret, i) => {
|
|
1760
1871
|
const personal = personalRefusal(reader, principal, call.permission, secret, at[i], environments);
|
|
1761
1872
|
if (personal !== void 0) return personal;
|
|
1873
|
+
const also = needs[i];
|
|
1874
|
+
if (also != null) {
|
|
1875
|
+
const place = placeIn(at[i], environments);
|
|
1876
|
+
const refused = also.map((permission) => refuses(reader, permission, place, gone(secret) || gone(at[i])));
|
|
1877
|
+
if (refused.every((code) => code !== null)) return refused[0];
|
|
1878
|
+
}
|
|
1762
1879
|
return refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i]));
|
|
1763
1880
|
});
|
|
1764
1881
|
if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx)).forEach((code, i) => codes[i] = code);
|
|
1882
|
+
const copying = action === "secret.read" && call.input.purpose === "copy";
|
|
1883
|
+
if (action === "secret.read" && principal.startsWith("user:") && handsBack({
|
|
1884
|
+
purpose: call.input.purpose ?? "",
|
|
1885
|
+
keepsSensitive: call.input.keepsSensitive === true
|
|
1886
|
+
}) || copying && call.input.keepsSensitive !== true) secrets.forEach((secret, i) => {
|
|
1887
|
+
if (codes[i] === null && secret.sensitive === true) codes[i] = "sensitive";
|
|
1888
|
+
});
|
|
1765
1889
|
let first = codes.find((code) => code !== null) ?? null;
|
|
1766
|
-
if (first === null && action === "secret.read" && this.#config.people === "stand-ins" && principal.startsWith("user:") && HANDED_BACK.includes(call.input.purpose)) first = "stand_ins";
|
|
1767
1890
|
if (first === null && action === "secret.read" && await this.#overBulkLimit(d, principal, secrets.length)) first = "bulk_limit";
|
|
1768
1891
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, i) => entry(secret, "deny", codes[i] ?? first)));
|
|
1769
1892
|
};
|
|
@@ -1785,7 +1908,6 @@ var VaultService = class {
|
|
|
1785
1908
|
if (call.remote) {
|
|
1786
1909
|
const intent = await this.#decide([principal], async (d) => {
|
|
1787
1910
|
await check(d);
|
|
1788
|
-
await lockLogHead(d.tx);
|
|
1789
1911
|
const at = await this.#now(d.tx);
|
|
1790
1912
|
return { seq: (await this.#append(d.tx, [{
|
|
1791
1913
|
actor: principal,
|
|
@@ -1836,6 +1958,12 @@ var VaultService = class {
|
|
|
1836
1958
|
if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
|
|
1837
1959
|
if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
|
|
1838
1960
|
await lockLogHead(d.tx);
|
|
1961
|
+
try {
|
|
1962
|
+
await check(d);
|
|
1963
|
+
} catch (error) {
|
|
1964
|
+
if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item].code ?? error.refusal.code)));
|
|
1965
|
+
throw error;
|
|
1966
|
+
}
|
|
1839
1967
|
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
1840
1968
|
const now = await environmentsById(d.tx, places.map((place) => place.environmentId));
|
|
1841
1969
|
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")));
|
|
@@ -1847,7 +1975,7 @@ var VaultService = class {
|
|
|
1847
1975
|
} finally {
|
|
1848
1976
|
for (const value of done) value.wipe();
|
|
1849
1977
|
}
|
|
1850
|
-
});
|
|
1978
|
+
}, true);
|
|
1851
1979
|
}
|
|
1852
1980
|
/** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
|
|
1853
1981
|
async #overBulkLimit(d, principal, n) {
|
|
@@ -1903,6 +2031,7 @@ var VaultService = class {
|
|
|
1903
2031
|
};
|
|
1904
2032
|
return this.#decide([principal], async (d) => {
|
|
1905
2033
|
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
2034
|
+
const natures$1 = await natures(d.tx, [...sources.values()].map((source) => source.secretId));
|
|
1906
2035
|
const environments = await environmentsById(d.tx, items.flatMap((item) => {
|
|
1907
2036
|
const source = sources.get(item.source.secretId);
|
|
1908
2037
|
return source === void 0 ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
|
|
@@ -1913,14 +2042,11 @@ var VaultService = class {
|
|
|
1913
2042
|
if (source === void 0 || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return "bad_claim";
|
|
1914
2043
|
if (source.secretId === item.holder.secretId) return "invalid";
|
|
1915
2044
|
if (environments.get(item.holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return "personal";
|
|
1916
|
-
|
|
2045
|
+
const at = placeIn(source, environments);
|
|
2046
|
+
return refuses(reader, "secret.write", placeIn(item.holder, environments), gone(item.holder)) ?? refuses(reader, "secret.read", at, gone(source)) ?? (natures$1.get(source.secretId) === "sensitive" ? refuses(reader, "secret.use", at, gone(source)) : null);
|
|
1917
2047
|
});
|
|
1918
2048
|
const first = codes.find((code) => code !== null) ?? null;
|
|
1919
2049
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, "deny", codes[i] ?? first)));
|
|
1920
|
-
d.underLock.push(async (locked) => {
|
|
1921
|
-
if (![...(await environmentsById(locked, [...environments.keys()])).values()].some((environment) => environment.deleted)) return;
|
|
1922
|
-
throw new Refused(refusal("deleted", MESSAGES.deleted), items.map((item) => entry(item, "deny", "deleted")));
|
|
1923
|
-
});
|
|
1924
2050
|
const entries = items.map((item) => entry(item, "allow", null));
|
|
1925
2051
|
d.log.push(...entries);
|
|
1926
2052
|
const answer = { seqs: [] };
|
|
@@ -1994,10 +2120,6 @@ var VaultService = class {
|
|
|
1994
2120
|
if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => codes[i] = code);
|
|
1995
2121
|
const first = codes.find((code) => code !== null) ?? null;
|
|
1996
2122
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), vias.map((via, i) => entry(via, seals[i], "deny", codes[i] ?? first)));
|
|
1997
|
-
d.underLock.push(async (locked) => {
|
|
1998
|
-
if ((await ended(locked)).every((code) => code === null)) return;
|
|
1999
|
-
throw new Refused(refusal("ended", MESSAGES.ended), vias.map((via, i) => entry(via, seals[i], "deny", "ended")));
|
|
2000
|
-
});
|
|
2001
2123
|
const entries = vias.map((via, i) => entry(via, seals[i], "allow", null));
|
|
2002
2124
|
d.log.push(...entries);
|
|
2003
2125
|
const answer = { seqs: [] };
|
|
@@ -2071,11 +2193,11 @@ var VaultService = class {
|
|
|
2071
2193
|
/**
|
|
2072
2194
|
* Refuse a change that would leave `principal`, an Owner of the whole
|
|
2073
2195
|
* instance, no longer one, unless another is. Counted under the log's
|
|
2074
|
-
* head: two changes at once, each demoting or
|
|
2075
|
-
* Owner, lock different rows, and only the head puts
|
|
2196
|
+
* head, which every decision holds: two changes at once, each demoting or
|
|
2197
|
+
* removing a different Owner, lock different rows, and only the head puts
|
|
2198
|
+
* one after the other.
|
|
2076
2199
|
*/
|
|
2077
2200
|
async #keepAnOwner(d, actor, principal, refused) {
|
|
2078
|
-
await lockLogHead(d.tx);
|
|
2079
2201
|
if (await this.#anOwner(d.tx, d.reports, principal) !== null) return;
|
|
2080
2202
|
throw refused("last_owner", actor === principal ? "you're the last Owner of the whole instance: make someone else an Owner first" : `${principal.slice(5)} is the last Owner of the whole instance: make someone else an Owner first`);
|
|
2081
2203
|
}
|
|
@@ -2175,24 +2297,30 @@ var VaultService = class {
|
|
|
2175
2297
|
role: grant.role
|
|
2176
2298
|
}));
|
|
2177
2299
|
const { role, scope } = roleOf(principal, row);
|
|
2300
|
+
const { policy } = await this.#policyAt(db, reports, at);
|
|
2301
|
+
const service = !principal.startsWith("user:");
|
|
2178
2302
|
return {
|
|
2179
2303
|
principal,
|
|
2180
2304
|
status: "active",
|
|
2181
2305
|
live: {
|
|
2182
2306
|
role,
|
|
2183
2307
|
scope,
|
|
2184
|
-
grants: held.filter((grant) => live(grant, at))
|
|
2308
|
+
grants: held.filter((grant) => live(grant, at)),
|
|
2309
|
+
policy,
|
|
2310
|
+
service
|
|
2185
2311
|
},
|
|
2186
2312
|
all: {
|
|
2187
2313
|
role,
|
|
2188
2314
|
scope,
|
|
2189
|
-
grants: held
|
|
2315
|
+
grants: held,
|
|
2316
|
+
policy,
|
|
2317
|
+
service
|
|
2190
2318
|
},
|
|
2191
2319
|
fault,
|
|
2192
2320
|
stored: grants$1
|
|
2193
2321
|
};
|
|
2194
2322
|
}
|
|
2195
|
-
#access(principal, row, held, at, tampered) {
|
|
2323
|
+
#access(principal, row, held, at, tampered, policy) {
|
|
2196
2324
|
const active = PRINCIPAL.test(principal) && !tampered && row?.status === "active";
|
|
2197
2325
|
return {
|
|
2198
2326
|
principal,
|
|
@@ -2204,7 +2332,8 @@ var VaultService = class {
|
|
|
2204
2332
|
},
|
|
2205
2333
|
grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
|
|
2206
2334
|
since: row ? iso(row.statusChangedAt) : null,
|
|
2207
|
-
by: row?.statusChangedBy ?? null
|
|
2335
|
+
by: row?.statusChangedBy ?? null,
|
|
2336
|
+
policy
|
|
2208
2337
|
};
|
|
2209
2338
|
}
|
|
2210
2339
|
/**
|
|
@@ -2234,7 +2363,8 @@ var VaultService = class {
|
|
|
2234
2363
|
this.#now(db)
|
|
2235
2364
|
]);
|
|
2236
2365
|
const fault = await this.#integrity(db, principal, row, held, reports);
|
|
2237
|
-
|
|
2366
|
+
const { policy } = await this.#policyAt(db, reports, at);
|
|
2367
|
+
return this.#access(principal, row, held, at, fault !== null, policy);
|
|
2238
2368
|
};
|
|
2239
2369
|
const found = [];
|
|
2240
2370
|
const quick = await read(this.#db, found).catch((error) => {
|
|
@@ -2304,12 +2434,15 @@ var VaultService = class {
|
|
|
2304
2434
|
const key = placeKey(change);
|
|
2305
2435
|
if (places$1.has(key)) throw refused("invalid", "each place may be changed once per call");
|
|
2306
2436
|
places$1.add(key);
|
|
2307
|
-
if (change.role !== null &&
|
|
2308
|
-
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) throw refused("invalid", `${change.role} can only be granted on a project`);
|
|
2437
|
+
if (change.role !== null && typeof change.role !== "string") throw refused("invalid", `no such role: ${String(change.role)}`);
|
|
2309
2438
|
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
2310
2439
|
if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
|
|
2311
2440
|
}
|
|
2312
|
-
await
|
|
2441
|
+
const { policy } = await this.#policyAt(d.tx, d.reports, d.at);
|
|
2442
|
+
for (const change of changes) {
|
|
2443
|
+
if (change.role !== null && !isRole(change.role, policy)) throw refused("invalid", `no such role: ${change.role}`);
|
|
2444
|
+
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) throw refused("invalid", `${change.role} can only be granted on a project`);
|
|
2445
|
+
}
|
|
2313
2446
|
const known = await places(d.tx, changes.map((change) => change.projectId), changes.flatMap((change) => change.environmentId === null ? [] : [change.environmentId]));
|
|
2314
2447
|
const where = changes.map(({ projectId, environmentId, role }) => {
|
|
2315
2448
|
const environment = environmentId === null ? void 0 : known.environments.get(environmentId);
|
|
@@ -2633,6 +2766,134 @@ var VaultService = class {
|
|
|
2633
2766
|
const { serviceAccounts } = JSON.parse(entry.metadata);
|
|
2634
2767
|
return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
|
|
2635
2768
|
}
|
|
2769
|
+
async policy() {
|
|
2770
|
+
await this.#settled();
|
|
2771
|
+
const reports = [];
|
|
2772
|
+
const { policy } = await this.#policyAt(this.#db, reports, await this.#now(this.#db));
|
|
2773
|
+
await this.#record(reports);
|
|
2774
|
+
return policy;
|
|
2775
|
+
}
|
|
2776
|
+
/**
|
|
2777
|
+
* The policy the vault's newest `policy.change` says, by its MAC, and its
|
|
2778
|
+
* edits, as of `at`: one in its name it did not write is passed over, and
|
|
2779
|
+
* reported. With such entries and none of its own within reach, the
|
|
2780
|
+
* policy is unreadable, and closed: every environment is protected, and
|
|
2781
|
+
* no role holds anything but Owner, otherwise whoever can insert rows
|
|
2782
|
+
* could unprotect them. Its edits are then unknown, not none.
|
|
2783
|
+
*/
|
|
2784
|
+
async #policyAt(db, reports, at) {
|
|
2785
|
+
const entry = await newestAuthentic((before) => newestVaultEntries(db, POLICY, LOOK_BACK.page, before), this.#check(reports, null));
|
|
2786
|
+
if (entry === "none") return {
|
|
2787
|
+
policy: DEFAULT_POLICY,
|
|
2788
|
+
edits: NO_EDITS,
|
|
2789
|
+
readable: true
|
|
2790
|
+
};
|
|
2791
|
+
const edits = typeof entry === "string" ? null : readEdits(JSON.parse(entry.metadata));
|
|
2792
|
+
return edits === null ? {
|
|
2793
|
+
policy: CLOSED_POLICY,
|
|
2794
|
+
edits: NO_EDITS,
|
|
2795
|
+
readable: false
|
|
2796
|
+
} : {
|
|
2797
|
+
policy: await livePolicy(db, edits, at),
|
|
2798
|
+
edits,
|
|
2799
|
+
readable: true
|
|
2800
|
+
};
|
|
2801
|
+
}
|
|
2802
|
+
changePolicy(input) {
|
|
2803
|
+
const { actor, change, revision } = input;
|
|
2804
|
+
if (revision !== void 0 && (typeof revision !== "string" || !POLICY_REVISION.test(revision))) throw new Error("revision names a policy as policyRevision writes it");
|
|
2805
|
+
const at = {};
|
|
2806
|
+
const entry = (decision, detail, code = null) => ({
|
|
2807
|
+
actor,
|
|
2808
|
+
action: POLICY,
|
|
2809
|
+
decision,
|
|
2810
|
+
code,
|
|
2811
|
+
operationId: input.operationId ?? null,
|
|
2812
|
+
requestId: input.requestId ?? null,
|
|
2813
|
+
...at,
|
|
2814
|
+
metadata: JSON.stringify({
|
|
2815
|
+
...detail,
|
|
2816
|
+
...traced(input)
|
|
2817
|
+
})
|
|
2818
|
+
});
|
|
2819
|
+
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { change: describeChange(change) }, code)]);
|
|
2820
|
+
return this.#decide([actor], async (d) => {
|
|
2821
|
+
validateCorrelation(input);
|
|
2822
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2823
|
+
if (acting.status === "tampered") throw refused("tampered");
|
|
2824
|
+
if (acting.status !== "active") throw refused(acting.status === "removed" ? "removed" : "not_a_member");
|
|
2825
|
+
const newest = await newestVaultSeq(d.tx, POLICY);
|
|
2826
|
+
const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
|
|
2827
|
+
if (revision !== void 0 && policyRevision(newest, before) !== revision) throw refused("policy_changed");
|
|
2828
|
+
const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
|
|
2829
|
+
if (!readable && !owner) throw refused("not_allowed", "the vault cannot read its own policy: only an Owner of the whole instance writes it again, from the roles as they ship");
|
|
2830
|
+
const next = {
|
|
2831
|
+
roles: { ...edits.roles },
|
|
2832
|
+
instanceRoles: { ...edits.instanceRoles },
|
|
2833
|
+
protected: edits.protected.filter((place) => before.protected.includes(place.environmentId)).map((place) => ({ ...place }))
|
|
2834
|
+
};
|
|
2835
|
+
if (change?.kind === "role" || change?.kind === "instance-role") {
|
|
2836
|
+
if (!owner) throw refused("not_allowed", "only an Owner of the whole instance edits roles");
|
|
2837
|
+
const instance = change.kind === "instance-role";
|
|
2838
|
+
const fixed = instance ? FIXED_ROLES.instanceRoles.includes(change.role) : FIXED_ROLES.roles.includes(change.role);
|
|
2839
|
+
if (typeof change.role !== "string" || fixed) throw refused("invalid", `${change.role} is not edited: it holds ${change.role === "member" ? "nothing" : "everything"}, always`);
|
|
2840
|
+
if (instance && !isInstanceRole(change.role)) throw refused("invalid", `no instance role ${change.role}: an Owner makes project roles`);
|
|
2841
|
+
if (!instance && !isBuiltinRole(change.role) && !ROLE_ID.test(change.role)) throw refused("invalid", "a role is named by lowercase letters, digits and dashes, 2 to 32, starting with a letter");
|
|
2842
|
+
const table = instance ? next.instanceRoles : next.roles;
|
|
2843
|
+
if (change.definition === null) {
|
|
2844
|
+
if (!instance && !isBuiltinRole(change.role)) {
|
|
2845
|
+
if (table[change.role] === void 0) throw refused("invalid", `no role ${change.role}`);
|
|
2846
|
+
const held = await grantsNaming(d.tx, change.role);
|
|
2847
|
+
if (held > 0) throw refused("invalid", `${held === 1 ? "1 grant names" : `${held} grants name`} ${change.role}: revoke ${held === 1 ? "it" : "them"} first`);
|
|
2848
|
+
}
|
|
2849
|
+
const shipped = instance ? void 0 : isBuiltinRole(change.role) ? ROLES[change.role] : void 0;
|
|
2850
|
+
if (shipped !== void 0 && shipped.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && await grantsNaming(d.tx, change.role, { onEnvironments: true }) > 0) throw refused("invalid", `${change.role} is granted on environments: as it ships, it holds a project-wide permission, which would make those grants incoherent`);
|
|
2851
|
+
delete table[change.role];
|
|
2852
|
+
} else {
|
|
2853
|
+
const problem = definitionProblem(change.definition);
|
|
2854
|
+
if (problem !== null) throw refused("invalid", problem);
|
|
2855
|
+
const definition = normalDefinition(change.definition);
|
|
2856
|
+
if (!instance && definition.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && await grantsNaming(d.tx, change.role, { onEnvironments: true }) > 0) throw refused("invalid", `${change.role} is granted on environments: a project-wide permission would make those grants incoherent`);
|
|
2857
|
+
const shipped = instance ? INSTANCE_ROLES[change.role] : isBuiltinRole(change.role) ? ROLES[change.role] : void 0;
|
|
2858
|
+
if (shipped !== void 0 && JSON.stringify(normalDefinition(shipped)) === JSON.stringify(definition)) delete table[change.role];
|
|
2859
|
+
else table[change.role] = definition;
|
|
2860
|
+
}
|
|
2861
|
+
} else if (change?.kind === "protect") {
|
|
2862
|
+
if (typeof change.projectId !== "string" || !UUID.test(change.projectId) || !Array.isArray(change.environmentIds) || change.environmentIds.length === 0 || change.environmentIds.some((id) => typeof id !== "string" || !UUID.test(id)) || typeof change.protected !== "boolean") throw refused("invalid", "protection names a project and some of its environments, by id, and whether they are protected");
|
|
2863
|
+
if (!await projectLive(d.tx, change.projectId)) throw refused("invalid", "no such project");
|
|
2864
|
+
at.projectId = change.projectId;
|
|
2865
|
+
const found = await environmentsById(d.tx, change.environmentIds);
|
|
2866
|
+
if ([...found.values()].some((environment) => environment.projectId !== change.projectId)) throw refused("invalid", "each environment is one of the project's");
|
|
2867
|
+
if (change.environmentIds.length === 1 && found.has(change.environmentIds[0])) at.environmentId = change.environmentIds[0];
|
|
2868
|
+
if (change.protected) {
|
|
2869
|
+
if (!allows(acting.live, "environment.manage", { projectId: change.projectId })) throw refused("not_allowed", "protecting an environment takes managing its project's environments");
|
|
2870
|
+
const known = new Set(next.protected.map((place) => place.environmentId));
|
|
2871
|
+
next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({
|
|
2872
|
+
projectId: change.projectId,
|
|
2873
|
+
environmentId,
|
|
2874
|
+
at: d.at
|
|
2875
|
+
}))].sort((a, b) => a.environmentId < b.environmentId ? -1 : 1);
|
|
2876
|
+
} else {
|
|
2877
|
+
if (!owner) throw refused("not_allowed", "only an Owner of the whole instance unprotects an environment");
|
|
2878
|
+
next.protected = next.protected.filter((place) => !change.environmentIds.includes(place.environmentId));
|
|
2879
|
+
}
|
|
2880
|
+
} else throw refused("invalid", "a change is to a role, an instance role, or protection");
|
|
2881
|
+
const answer = {
|
|
2882
|
+
policy: await livePolicy(d.tx, next, d.at),
|
|
2883
|
+
seq: newest === null ? null : Number(newest)
|
|
2884
|
+
};
|
|
2885
|
+
if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
|
|
2886
|
+
const made = entry("allow", {
|
|
2887
|
+
policy: next,
|
|
2888
|
+
change: describeChange(change),
|
|
2889
|
+
before: describeBefore(change, before)
|
|
2890
|
+
});
|
|
2891
|
+
d.log.push(made);
|
|
2892
|
+
d.after.push((seqOf) => answer.seq = seqOf(made));
|
|
2893
|
+
}
|
|
2894
|
+
return answer;
|
|
2895
|
+
});
|
|
2896
|
+
}
|
|
2636
2897
|
setSettings(input) {
|
|
2637
2898
|
const { actor } = input;
|
|
2638
2899
|
const entry = (decision, detail, code = null) => ({
|
|
@@ -2654,7 +2915,6 @@ var VaultService = class {
|
|
|
2654
2915
|
if (acting.status === "tampered") throw refused("tampered");
|
|
2655
2916
|
if (!runsInstance(acting.live)) throw refused("not_allowed", "only admins and owners of the whole instance change its settings");
|
|
2656
2917
|
if (!isScope(input.settings?.serviceAccounts)) throw refused("invalid", "who sets up service accounts is a scope: { projects, environments }, each all, only or except");
|
|
2657
|
-
await lockLogHead(d.tx);
|
|
2658
2918
|
const previous = await this.#settingsAt(d.tx, d.reports);
|
|
2659
2919
|
const settings = { serviceAccounts: normalScope(input.settings.serviceAccounts) };
|
|
2660
2920
|
if (JSON.stringify(settings) !== JSON.stringify(previous)) d.log.push(entry("allow", {
|
|
@@ -3199,6 +3459,7 @@ function validateItems(items, field) {
|
|
|
3199
3459
|
if (field === "key" && typeof key !== "string") throw new Error("DEK must be base64");
|
|
3200
3460
|
if (field === "wrapped" && (wrapped === void 0 || wrapped === null)) throw new Error("wrapped key is required");
|
|
3201
3461
|
checkContext(context(secret));
|
|
3462
|
+
if (secret.sensitive !== void 0 && typeof secret.sensitive !== "boolean") throw new Error("a secret is sensitive or not: true, false or absent");
|
|
3202
3463
|
if (!Number.isSafeInteger(secret.version) || secret.version < 1) throw new Error("secret version must be a positive integer");
|
|
3203
3464
|
if (typeof secret.path !== "string" || /[\uD800-\uDFFF]/u.test(secret.path)) throw new Error("secret path must be a well-formed string");
|
|
3204
3465
|
if (key !== void 0) {
|
|
@@ -3346,9 +3607,10 @@ function personalRefusal(reader, principal, permission, secret, at, environments
|
|
|
3346
3607
|
projectId: secret.projectId,
|
|
3347
3608
|
environmentId: own.parentId
|
|
3348
3609
|
}, environments);
|
|
3349
|
-
const
|
|
3610
|
+
const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
|
|
3611
|
+
const refused = refuses(reader, "secret.use", parent, gone) === null ? null : refuses(reader, "secret.read", parent, gone);
|
|
3350
3612
|
if (refused !== null) return refused;
|
|
3351
|
-
return withinCredential(reader.live, permission, parent) ? null : "no_grant";
|
|
3613
|
+
return withinCredential(reader.live, permission === "secret.read" ? "secret.use" : permission, parent) ? null : "no_grant";
|
|
3352
3614
|
}
|
|
3353
3615
|
function refuses(reader, permission, where, deleted) {
|
|
3354
3616
|
if (reader.status === "tampered") return "tampered";
|
|
@@ -3545,13 +3807,87 @@ function view(grant) {
|
|
|
3545
3807
|
function iso(ms) {
|
|
3546
3808
|
return new Date(ms).toISOString();
|
|
3547
3809
|
}
|
|
3548
|
-
/** A
|
|
3810
|
+
/** A policy entry's edits, checked as the vault wrote them, or null for any that does not hold. */
|
|
3811
|
+
function readEdits(stored) {
|
|
3812
|
+
const edits = stored.policy;
|
|
3813
|
+
if (typeof edits !== "object" || edits === null) return null;
|
|
3814
|
+
const { roles, instanceRoles, protected: protectedIds } = edits;
|
|
3815
|
+
if (typeof roles !== "object" || roles === null || typeof instanceRoles !== "object" || instanceRoles === null || !Array.isArray(protectedIds)) return null;
|
|
3816
|
+
const defined = (table) => Object.values(table).every((definition) => definitionProblem(definition) === null);
|
|
3817
|
+
if (!defined(roles) || !defined(instanceRoles) || Object.keys(instanceRoles).some((role) => !isInstanceRole(role))) return null;
|
|
3818
|
+
if (protectedIds.some((place) => typeof place !== "object" || place === null || !UUID.test(place.projectId) || !UUID.test(place.environmentId) || !Number.isSafeInteger(place.at))) return null;
|
|
3819
|
+
return {
|
|
3820
|
+
roles,
|
|
3821
|
+
instanceRoles,
|
|
3822
|
+
protected: protectedIds
|
|
3823
|
+
};
|
|
3824
|
+
}
|
|
3825
|
+
/**
|
|
3826
|
+
* The policy `edits` say as of `at`: the roles as they ship, each edited
|
|
3827
|
+
* one replaced and each made one added; and the protected environments, but
|
|
3828
|
+
* for those deleted since, the project around one's included: a deleted
|
|
3829
|
+
* prod protects nothing, and holds nobody back from giving a service
|
|
3830
|
+
* account a role on all of its project (`givesService`). A fork's id with
|
|
3831
|
+
* no row yet stays, protected before it is written, ten minutes: one with
|
|
3832
|
+
* none after that is a fork refused or abandoned, never written. Each
|
|
3833
|
+
* keeps its slug, which a scoped instance role's filters read. The edits
|
|
3834
|
+
* keep them all: no write is needed.
|
|
3835
|
+
*/
|
|
3836
|
+
async function livePolicy(db, edits, at) {
|
|
3837
|
+
const rows = await environmentsById(db, edits.protected.map((place) => place.environmentId));
|
|
3838
|
+
const live = edits.protected.filter((place) => {
|
|
3839
|
+
const row = rows.get(place.environmentId);
|
|
3840
|
+
return row === void 0 ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
|
|
3841
|
+
});
|
|
3842
|
+
const slugOf = (place) => rows.get(place.environmentId)?.slug ?? null;
|
|
3843
|
+
return {
|
|
3844
|
+
roles: {
|
|
3845
|
+
...ROLES,
|
|
3846
|
+
...edits.roles
|
|
3847
|
+
},
|
|
3848
|
+
instanceRoles: {
|
|
3849
|
+
...INSTANCE_ROLES,
|
|
3850
|
+
...edits.instanceRoles
|
|
3851
|
+
},
|
|
3852
|
+
protected: live.map((place) => place.environmentId),
|
|
3853
|
+
protectedIn: live.reduce((byProject, place) => ({
|
|
3854
|
+
...byProject,
|
|
3855
|
+
[place.projectId]: [...byProject[place.projectId] ?? [], {
|
|
3856
|
+
environmentId: place.environmentId,
|
|
3857
|
+
slug: slugOf(place)
|
|
3858
|
+
}]
|
|
3859
|
+
}), {})
|
|
3860
|
+
};
|
|
3861
|
+
}
|
|
3862
|
+
/** A change, as its entry names it. */
|
|
3863
|
+
function describeChange(change) {
|
|
3864
|
+
if (change === void 0 || change === null || typeof change !== "object") return null;
|
|
3865
|
+
if (change.kind === "protect") return {
|
|
3866
|
+
kind: "protect",
|
|
3867
|
+
projectId: change.projectId,
|
|
3868
|
+
environmentIds: change.environmentIds,
|
|
3869
|
+
protected: change.protected
|
|
3870
|
+
};
|
|
3871
|
+
return {
|
|
3872
|
+
kind: change.kind,
|
|
3873
|
+
role: change.role,
|
|
3874
|
+
definition: change.definition
|
|
3875
|
+
};
|
|
3876
|
+
}
|
|
3877
|
+
/** What a change changed, as it was: the role's definition, or which environments were protected. */
|
|
3878
|
+
function describeBefore(change, before) {
|
|
3879
|
+
if (change.kind === "protect") return { protected: change.environmentIds.filter((id) => before.protected.includes(id)) };
|
|
3880
|
+
const table = change.kind === "instance-role" ? before.instanceRoles : before.roles;
|
|
3881
|
+
return { definition: Object.hasOwn(table, change.role) ? table[change.role] : null };
|
|
3882
|
+
}
|
|
3883
|
+
/** A secret's binding context: its ids, a personal config's owner, hashed, as the decision read it, and whether it is sensitive. */
|
|
3549
3884
|
function context(secret, owner) {
|
|
3550
3885
|
return {
|
|
3551
3886
|
projectId: secret.projectId,
|
|
3552
3887
|
environmentId: secret.environmentId,
|
|
3553
3888
|
secretId: secret.secretId,
|
|
3554
|
-
...owner === void 0 ? {} : { owner }
|
|
3889
|
+
...owner === void 0 ? {} : { owner },
|
|
3890
|
+
...secret.sensitive === true ? { sensitive: true } : {}
|
|
3555
3891
|
};
|
|
3556
3892
|
}
|
|
3557
3893
|
function unwrappable(wrapped) {
|