@coffre/vault 0.10.1 → 0.10.2
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 +1 -1
- package/dist/cloudflare.js +2 -2
- package/dist/{index-CByXzyi-.d.ts → index-zkW6DEm9.d.ts} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +2 -2
- package/dist/{src-D82jyprb.js → src-B0d-W-SI.js} +4 -3
- package/dist/{vault-B0YiPBgi.js → vault-66Xf0Hw1.js} +381 -103
- package/package.json +3 -3
- package/src/config.ts +6 -5
- package/src/snapshot.postgres.ts +97 -0
- package/src/snapshot.sqlite.ts +103 -0
- package/src/snapshot.ts +191 -0
- package/src/store.ts +2 -34
- package/src/vault.ts +134 -72
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { ACCESS_ACTIONS, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
|
|
1
|
+
import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, 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 { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { prepared, tablesOf } from "@coffre/db";
|
|
5
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
|
-
import { shownMember } from "@coffre/core/schemas";
|
|
9
|
-
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, isUnreachable, tombstone, truth } from "@coffre/db/dialect";
|
|
8
|
+
import { TOMBSTONE, shownMember } from "@coffre/core/schemas";
|
|
9
|
+
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, isUnreachable, rowsOf, tombstone, truth } from "@coffre/db/dialect";
|
|
10
10
|
import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
|
|
11
|
-
import { appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries,
|
|
11
|
+
import { LogHeadMismatch, appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
|
|
12
12
|
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
|
|
13
13
|
//#region src/checkpoint.ts
|
|
14
14
|
const PKCS8_ED25519 = [
|
|
@@ -75,7 +75,7 @@ function memberColumns(db) {
|
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
/** A member as their row says (`storedRole`). */
|
|
78
|
-
function memberOf({ owner, role, scope, ...row }) {
|
|
78
|
+
function memberOf$1({ owner, role, scope, ...row }) {
|
|
79
79
|
return {
|
|
80
80
|
...row,
|
|
81
81
|
...storedRole({
|
|
@@ -95,7 +95,7 @@ function storedChange(change) {
|
|
|
95
95
|
}
|
|
96
96
|
async function member(db, principal) {
|
|
97
97
|
const [row] = await memberRow(db, { principal });
|
|
98
|
-
return row === void 0 ? void 0 : memberOf(row);
|
|
98
|
+
return row === void 0 ? void 0 : memberOf$1(row);
|
|
99
99
|
}
|
|
100
100
|
const memberRow = prepared((db) => {
|
|
101
101
|
const { vaultMembers } = tablesOf(db);
|
|
@@ -103,7 +103,7 @@ const memberRow = prepared((db) => {
|
|
|
103
103
|
});
|
|
104
104
|
async function allMembers(db) {
|
|
105
105
|
const { vaultMembers } = tablesOf(db);
|
|
106
|
-
return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf);
|
|
106
|
+
return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf$1);
|
|
107
107
|
}
|
|
108
108
|
/**
|
|
109
109
|
* The rows that say their member is an active Owner of the whole instance
|
|
@@ -113,7 +113,7 @@ async function allMembers(db) {
|
|
|
113
113
|
*/
|
|
114
114
|
async function instanceOwners(db) {
|
|
115
115
|
const { vaultMembers } = tablesOf(db);
|
|
116
|
-
return (await db.select(memberColumns(db)).from(vaultMembers).where(and(eq(vaultMembers.status, "active"), eq(vaultMembers.role, "owner"))).orderBy(asc(vaultMembers.principal))).map(memberOf).filter(ownsInstance);
|
|
116
|
+
return (await db.select(memberColumns(db)).from(vaultMembers).where(and(eq(vaultMembers.status, "active"), eq(vaultMembers.role, "owner"))).orderBy(asc(vaultMembers.principal))).map(memberOf$1).filter(ownsInstance);
|
|
117
117
|
}
|
|
118
118
|
/**
|
|
119
119
|
* Lock these members' rows for the rest of the transaction, in one order
|
|
@@ -124,7 +124,7 @@ async function instanceOwners(db) {
|
|
|
124
124
|
async function lockMembers(tx, principals) {
|
|
125
125
|
const { vaultMembers } = tablesOf(tx);
|
|
126
126
|
const rows = await forUpdate(tx, tx.select(memberColumns(tx)).from(vaultMembers).where(inArray(vaultMembers.principal, [...new Set(principals)])).orderBy(asc(vaultMembers.principal)), "no key update");
|
|
127
|
-
return new Map(rows.map((row) => [row.principal, memberOf(row)]));
|
|
127
|
+
return new Map(rows.map((row) => [row.principal, memberOf$1(row)]));
|
|
128
128
|
}
|
|
129
129
|
/**
|
|
130
130
|
* Add `row`. A principal has one row: when two decisions admit the same
|
|
@@ -221,25 +221,6 @@ async function everyProjectHolders(db) {
|
|
|
221
221
|
const { vaultGrants } = tablesOf(db);
|
|
222
222
|
return (await db.selectDistinct({ principal: vaultGrants.principal }).from(vaultGrants).where(and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId))).orderBy(asc(vaultGrants.principal))).map((row) => row.principal);
|
|
223
223
|
}
|
|
224
|
-
/** The action of an entry that releases a key, which the bulk limit counts. */
|
|
225
|
-
const RELEASE = "secret.read";
|
|
226
|
-
/**
|
|
227
|
-
* How many keys the vault has released to `principal` since `after`: one
|
|
228
|
-
* for an entry about one secret, and as many as it lists for one about
|
|
229
|
-
* several (`secrets`), a read logged once per place.
|
|
230
|
-
*/
|
|
231
|
-
async function releasesSince(db, principal, after) {
|
|
232
|
-
const [{ n }] = await released(db, {
|
|
233
|
-
principal,
|
|
234
|
-
after
|
|
235
|
-
});
|
|
236
|
-
return Number(n ?? 0);
|
|
237
|
-
}
|
|
238
|
-
const released = prepared((db) => {
|
|
239
|
-
const { auditLog } = tablesOf(db);
|
|
240
|
-
const listed = engineOf(db) === "postgres" ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')` : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
|
|
241
|
-
return db.select({ n: sql`SUM(COALESCE(${listed}, 1))` }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, sql.placeholder("principal")), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, sql.placeholder("after"))));
|
|
242
|
-
});
|
|
243
224
|
/** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
|
|
244
225
|
function stored(rows) {
|
|
245
226
|
return rows;
|
|
@@ -436,7 +417,7 @@ async function talliedAfter(db, after, limit) {
|
|
|
436
417
|
/** The vault's newest allowed access entry about each member who has one. */
|
|
437
418
|
async function newestAccessEntries(db) {
|
|
438
419
|
const { auditLog } = tablesOf(db);
|
|
439
|
-
const newest = db.select({ seq: sql`max(${auditLog.seq})` }).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...ACCESS_ACTIONS]), eq(auditLog.decision, "allow"))).groupBy(auditLog.subjectPrincipal);
|
|
420
|
+
const newest = db.select({ seq: sql`max(${auditLog.seq})` }).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...ACCESS_ACTIONS$1]), eq(auditLog.decision, "allow"))).groupBy(auditLog.subjectPrincipal);
|
|
440
421
|
const rows = stored(await db.select(entryColumns(db)).from(auditLog).where(inArray(auditLog.seq, newest)));
|
|
441
422
|
return new Map(rows.flatMap((row) => row.subjectPrincipal === null ? [] : [[row.subjectPrincipal, row]]));
|
|
442
423
|
}
|
|
@@ -994,6 +975,255 @@ function sealed(key, member, grants) {
|
|
|
994
975
|
return member.mac.length === expected.length && timingSafeEqual(member.mac, expected);
|
|
995
976
|
}
|
|
996
977
|
//#endregion
|
|
978
|
+
//#region src/snapshot.postgres.ts
|
|
979
|
+
/** An entry of `audit_log` aliased `e`, as JSON (`RawEntry`): whole numbers past 2^53 as text, bytes as hex. */
|
|
980
|
+
const ENTRY$1 = sql.raw(`json_build_object(
|
|
981
|
+
'seq', e.seq::text, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
|
|
982
|
+
'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
|
|
983
|
+
'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
|
|
984
|
+
'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq::text,
|
|
985
|
+
'metadata', e.metadata, 'prevHash', encode(e.prev_hash, 'hex'), 'mac', encode(e.mac, 'hex'), 'hash', encode(e.hash, 'hex'))`);
|
|
986
|
+
const NOW$1 = sql.raw("(extract(epoch from clock_timestamp()) * 1000)::bigint");
|
|
987
|
+
/** `snapshot`'s statement on Postgres: one row, one JSON column. Each part reads one index range. */
|
|
988
|
+
function snapshotQuery$1(asked) {
|
|
989
|
+
const principals = sql`${sql.param([...asked.principals])}::text[]`;
|
|
990
|
+
const environments = sql`${sql.param([...asked.environments ?? []])}::uuid[]`;
|
|
991
|
+
const references = sql`${sql.param((asked.references ?? []).map(String))}::bigint[]`;
|
|
992
|
+
const actions = sql`${sql.param([...ACCESS_ACTIONS])}::text[]`;
|
|
993
|
+
const tomb = `%${TOMBSTONE}%`;
|
|
994
|
+
const released = asked.released;
|
|
995
|
+
const head = asked.head === true ? sql`, head AS (SELECT next_seq, head_hash FROM audit_chain_head WHERE only_row FOR UPDATE SKIP LOCKED)` : sql``;
|
|
996
|
+
return sql`
|
|
997
|
+
WITH policy AS (
|
|
998
|
+
SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
|
|
999
|
+
)${head}
|
|
1000
|
+
SELECT json_build_object(
|
|
1001
|
+
'at', ${NOW$1},
|
|
1002
|
+
'head', ${asked.head === true ? sql`(SELECT json_build_object('nextSeq', next_seq::text, 'hash', encode(head_hash, 'hex')) FROM head)` : sql`NULL`},
|
|
1003
|
+
'last', ${asked.head === true ? sql`(SELECT json_build_object('seq', seq::text, 'hash', encode(hash, 'hex')) FROM audit_log ORDER BY seq DESC LIMIT 1)` : sql`NULL`},
|
|
1004
|
+
'rotation', ${asked.head === true ? sql`(SELECT ${ENTRY$1} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1)` : sql`NULL`},
|
|
1005
|
+
'members', ${asked.members === true ? sql`(SELECT coalesce(json_agg(json_build_object(
|
|
1006
|
+
'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
|
|
1007
|
+
'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
|
|
1008
|
+
'accessSeq', access_seq::text, 'mac', encode(mac, 'hex'))), '[]') FROM vault_members WHERE principal = ANY(${principals}))` : sql`'[]'::json`},
|
|
1009
|
+
'grants', (
|
|
1010
|
+
SELECT coalesce(json_agg(json_build_object(
|
|
1011
|
+
'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
|
|
1012
|
+
'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by)), '[]')
|
|
1013
|
+
FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
|
|
1014
|
+
WHERE g.principal = ANY(${principals})
|
|
1015
|
+
),
|
|
1016
|
+
'access', (
|
|
1017
|
+
SELECT coalesce(json_agg(${ENTRY$1}), '[]')
|
|
1018
|
+
FROM unnest(${principals}) AS asked (principal)
|
|
1019
|
+
CROSS JOIN LATERAL (
|
|
1020
|
+
SELECT * FROM audit_log
|
|
1021
|
+
WHERE author = 'vault' AND subject_principal = asked.principal AND action = ANY(${actions}) AND decision = 'allow'
|
|
1022
|
+
ORDER BY seq DESC LIMIT 1
|
|
1023
|
+
) e
|
|
1024
|
+
),
|
|
1025
|
+
'policy', (SELECT ${ENTRY$1} FROM policy e),
|
|
1026
|
+
'environments', (
|
|
1027
|
+
SELECT coalesce(json_agg(json_build_object(
|
|
1028
|
+
'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
|
|
1029
|
+
'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
|
|
1030
|
+
'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL)), '[]')
|
|
1031
|
+
FROM environments env JOIN projects project ON project.id = env.project_id
|
|
1032
|
+
-- One list of ids, each found by the primary key.
|
|
1033
|
+
WHERE env.id = ANY(ARRAY(
|
|
1034
|
+
SELECT unnest(${environments})
|
|
1035
|
+
UNION SELECT parent_id FROM environments WHERE id = ANY(${environments}) AND owner IS NOT NULL
|
|
1036
|
+
-- What the newest policy protects, by the ids its JSON names; one that is no UUID names nothing.
|
|
1037
|
+
UNION SELECT (place #>> '{}')::uuid FROM policy, jsonb_path_query(policy.metadata::jsonb, '$.policy.protected[*].environmentId') AS place
|
|
1038
|
+
WHERE (place #>> '{}') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
1039
|
+
))
|
|
1040
|
+
),
|
|
1041
|
+
'ended', (
|
|
1042
|
+
SELECT coalesce(json_agg(related_seq::text), '[]') FROM audit_log
|
|
1043
|
+
WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
|
|
1044
|
+
),
|
|
1045
|
+
'released', ${released === void 0 ? sql`0` : sql`(
|
|
1046
|
+
SELECT count(DISTINCT key) FROM (
|
|
1047
|
+
-- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
|
|
1048
|
+
-- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
|
|
1049
|
+
SELECT coalesce(e.secret_id::text, CASE jsonb_typeof(one -> 'secretId') WHEN 'string' THEN one ->> 'secretId' END, 'entry:' || e.seq) FROM audit_log e
|
|
1050
|
+
-- Each item of a list, never what lax paths would make of an object; silent, as strict paths fail on what is missing.
|
|
1051
|
+
LEFT JOIN LATERAL jsonb_path_query(CASE WHEN e.secret_id IS NULL THEN e.metadata::jsonb END, 'strict $.secrets ? (@.type() == "array")[*]', '{}', true) AS one ON true
|
|
1052
|
+
WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
|
|
1053
|
+
AND e.occurred_at > ${NOW$1} + ${released.clockOffsetMs} - ${released.windowMs}
|
|
1054
|
+
UNION ALL SELECT unnest(${sql.param([...released.keys])}::text[])
|
|
1055
|
+
) AS released (key)
|
|
1056
|
+
)`}
|
|
1057
|
+
) AS snapshot`;
|
|
1058
|
+
}
|
|
1059
|
+
//#endregion
|
|
1060
|
+
//#region src/snapshot.sqlite.ts
|
|
1061
|
+
/**
|
|
1062
|
+
* An entry of `audit_log` aliased `e`, as JSON (`RawEntry`), bytes as hex.
|
|
1063
|
+
* A JSON value loses its type leaving a subquery, so whoever takes one
|
|
1064
|
+
* from a subquery wraps it in `json(…)`.
|
|
1065
|
+
*/
|
|
1066
|
+
const ENTRY = sql.raw(`json_object(
|
|
1067
|
+
'seq', e.seq, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
|
|
1068
|
+
'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
|
|
1069
|
+
'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
|
|
1070
|
+
'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq,
|
|
1071
|
+
'metadata', e.metadata, 'prevHash', hex(e.prev_hash), 'mac', hex(e.mac), 'hash', hex(e.hash))`);
|
|
1072
|
+
const NOW = sql.raw(`CAST(unixepoch('subsec') * 1000 AS INTEGER)`);
|
|
1073
|
+
/**
|
|
1074
|
+
* `snapshot`'s statement on SQLite, the same as on Postgres. Nothing is
|
|
1075
|
+
* locked: one transaction runs at a time (connect.ts), so the head is the
|
|
1076
|
+
* last entry's.
|
|
1077
|
+
*/
|
|
1078
|
+
function snapshotQuery(asked) {
|
|
1079
|
+
const principals = JSON.stringify(asked.principals);
|
|
1080
|
+
const environments = JSON.stringify(asked.environments ?? []);
|
|
1081
|
+
const references = JSON.stringify((asked.references ?? []).map(Number));
|
|
1082
|
+
const actions = JSON.stringify(ACCESS_ACTIONS);
|
|
1083
|
+
const tomb = `%${TOMBSTONE}%`;
|
|
1084
|
+
const released = asked.released;
|
|
1085
|
+
const head = asked.head === true;
|
|
1086
|
+
return sql`
|
|
1087
|
+
WITH policy AS (
|
|
1088
|
+
SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
|
|
1089
|
+
)
|
|
1090
|
+
SELECT json_object(
|
|
1091
|
+
'at', ${NOW},
|
|
1092
|
+
'head', ${head ? sql`json((SELECT json_object('nextSeq', next_seq, 'hash', hex(head_hash)) FROM audit_chain_head WHERE only_row))` : sql`NULL`},
|
|
1093
|
+
'last', ${head ? sql`json((SELECT json_object('seq', seq, 'hash', hex(hash)) FROM audit_log ORDER BY seq DESC LIMIT 1))` : sql`NULL`},
|
|
1094
|
+
'rotation', ${head ? sql`json((SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1))` : sql`NULL`},
|
|
1095
|
+
'members', json(${asked.members === true ? sql`(SELECT json_group_array(json_object(
|
|
1096
|
+
'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
|
|
1097
|
+
'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
|
|
1098
|
+
'accessSeq', access_seq, 'mac', hex(mac))) FROM vault_members WHERE principal IN (SELECT value FROM json_each(${principals})))` : sql`'[]'`}),
|
|
1099
|
+
'grants', json((
|
|
1100
|
+
SELECT json_group_array(json_object(
|
|
1101
|
+
'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
|
|
1102
|
+
'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by))
|
|
1103
|
+
FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
|
|
1104
|
+
WHERE g.principal IN (SELECT value FROM json_each(${principals}))
|
|
1105
|
+
)),
|
|
1106
|
+
'access', json((
|
|
1107
|
+
SELECT json_group_array(json(newest)) FROM (
|
|
1108
|
+
SELECT (
|
|
1109
|
+
SELECT ${ENTRY} FROM audit_log e
|
|
1110
|
+
WHERE author = 'vault' AND subject_principal = asked.value AND action IN (SELECT value FROM json_each(${actions})) AND decision = 'allow'
|
|
1111
|
+
ORDER BY seq DESC LIMIT 1
|
|
1112
|
+
) AS newest
|
|
1113
|
+
FROM json_each(${principals}) AS asked
|
|
1114
|
+
) WHERE newest IS NOT NULL
|
|
1115
|
+
)),
|
|
1116
|
+
'policy', json((SELECT ${ENTRY} FROM policy e)),
|
|
1117
|
+
'environments', json((
|
|
1118
|
+
SELECT json_group_array(json_object(
|
|
1119
|
+
'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
|
|
1120
|
+
'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
|
|
1121
|
+
'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL))
|
|
1122
|
+
FROM environments env JOIN projects project ON project.id = env.project_id
|
|
1123
|
+
WHERE env.id IN (SELECT value FROM json_each(${environments}))
|
|
1124
|
+
OR env.id IN (SELECT parent_id FROM environments WHERE id IN (SELECT value FROM json_each(${environments})) AND owner IS NOT NULL)
|
|
1125
|
+
OR env.id IN (
|
|
1126
|
+
SELECT json_extract(place.value, '$.environmentId')
|
|
1127
|
+
FROM policy, json_each(policy.metadata, '$.policy.protected') AS place
|
|
1128
|
+
-- Of a forged entry's, an element that is no object names nothing: json_extract would fail on a string.
|
|
1129
|
+
WHERE json_valid(policy.metadata) AND json_type(policy.metadata, '$.policy.protected') = 'array' AND place.type = 'object'
|
|
1130
|
+
)
|
|
1131
|
+
)),
|
|
1132
|
+
'ended', json((
|
|
1133
|
+
SELECT json_group_array(related_seq) FROM audit_log
|
|
1134
|
+
WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
|
|
1135
|
+
)),
|
|
1136
|
+
'released', ${released === void 0 ? sql`0` : sql`(
|
|
1137
|
+
SELECT count(DISTINCT key) FROM (
|
|
1138
|
+
-- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
|
|
1139
|
+
-- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
|
|
1140
|
+
SELECT coalesce(e.secret_id, CASE WHEN one.type = 'object' AND json_type(one.value, '$.secretId') = 'text' THEN json_extract(one.value, '$.secretId') END, 'entry:' || e.seq) AS key
|
|
1141
|
+
FROM audit_log e
|
|
1142
|
+
LEFT JOIN json_each(CASE WHEN e.secret_id IS NULL AND json_type(e.metadata, '$.secrets') = 'array' THEN e.metadata END, '$.secrets') AS one
|
|
1143
|
+
WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
|
|
1144
|
+
AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
|
|
1145
|
+
UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
|
|
1146
|
+
)
|
|
1147
|
+
)`}
|
|
1148
|
+
) AS snapshot`;
|
|
1149
|
+
}
|
|
1150
|
+
//#endregion
|
|
1151
|
+
//#region src/snapshot.ts
|
|
1152
|
+
async function snapshot(db, asked) {
|
|
1153
|
+
const read = () => readOnce(db, asked);
|
|
1154
|
+
const first = await read();
|
|
1155
|
+
if (asked.head !== true || first.head !== null && current(first.head)) return first;
|
|
1156
|
+
if (first.head === null) await lockLogHead(db);
|
|
1157
|
+
const again = await read();
|
|
1158
|
+
if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
|
|
1159
|
+
if (!current(again.head)) throw new LogHeadMismatch(`the log's head says entry ${again.head.nextSeq} comes next, but the log ends ${again.head.last === null ? "empty" : `at entry ${again.head.last.seq}`}, or with another hash`);
|
|
1160
|
+
return again;
|
|
1161
|
+
}
|
|
1162
|
+
/** Whether the head names the last entry the same statement saw: nothing was appended between its snapshot and its lock. */
|
|
1163
|
+
function current(head) {
|
|
1164
|
+
if (head.last === null) return head.nextSeq === 0n;
|
|
1165
|
+
return head.nextSeq === head.last.seq + 1n && head.headHash.equals(head.last.hash);
|
|
1166
|
+
}
|
|
1167
|
+
async function readOnce(db, asked) {
|
|
1168
|
+
const query = (engineOf(db) === "postgres" ? snapshotQuery$1 : snapshotQuery)(asked);
|
|
1169
|
+
const [row] = await rowsOf(db, query);
|
|
1170
|
+
const raw = typeof row.snapshot === "string" ? JSON.parse(row.snapshot) : row.snapshot;
|
|
1171
|
+
return {
|
|
1172
|
+
at: Number(raw.at),
|
|
1173
|
+
members: new Map(raw.members.map((member) => [member.principal, memberOf(member)])),
|
|
1174
|
+
grants: new Map(asked.principals.map((principal) => [principal, raw.grants.filter((grant) => grant.principal === principal).map(grantOf)])),
|
|
1175
|
+
newestAccess: new Map(raw.access.map((entry) => [entry.subjectPrincipal, entryOf(entry)])),
|
|
1176
|
+
newestPolicy: raw.policy === null ? null : entryOf(raw.policy),
|
|
1177
|
+
environments: new Map(raw.environments.map(({ id, ...environment }) => [id, {
|
|
1178
|
+
...environment,
|
|
1179
|
+
deleted: Boolean(environment.deleted),
|
|
1180
|
+
archived: Boolean(environment.archived)
|
|
1181
|
+
}])),
|
|
1182
|
+
ended: new Set(raw.ended.map((seq) => BigInt(seq))),
|
|
1183
|
+
released: Number(raw.released ?? 0),
|
|
1184
|
+
head: raw.head === null ? null : {
|
|
1185
|
+
nextSeq: BigInt(raw.head.nextSeq),
|
|
1186
|
+
headHash: Buffer.from(raw.head.hash, "hex"),
|
|
1187
|
+
last: raw.last === null ? null : {
|
|
1188
|
+
seq: BigInt(raw.last.seq),
|
|
1189
|
+
hash: Buffer.from(raw.last.hash, "hex")
|
|
1190
|
+
},
|
|
1191
|
+
rotation: raw.rotation === null ? null : entryOf(raw.rotation)
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
/** An entry as the chain's codec reads it, from what `entryJson` wrote. */
|
|
1196
|
+
function entryOf(raw) {
|
|
1197
|
+
return {
|
|
1198
|
+
...raw,
|
|
1199
|
+
seq: BigInt(raw.seq),
|
|
1200
|
+
author: raw.author,
|
|
1201
|
+
occurredAt: Number(raw.occurredAt),
|
|
1202
|
+
relatedSeq: raw.relatedSeq === null ? null : BigInt(raw.relatedSeq),
|
|
1203
|
+
prevHash: Buffer.from(raw.prevHash, "hex"),
|
|
1204
|
+
mac: Buffer.from(raw.mac, "hex"),
|
|
1205
|
+
hash: Buffer.from(raw.hash, "hex")
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
function grantOf(raw) {
|
|
1209
|
+
return {
|
|
1210
|
+
...raw,
|
|
1211
|
+
expiresAt: raw.expiresAt === null ? null : Number(raw.expiresAt),
|
|
1212
|
+
grantedAt: Number(raw.grantedAt)
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
function memberOf({ owner, ...raw }) {
|
|
1216
|
+
return memberOf$1({
|
|
1217
|
+
...raw,
|
|
1218
|
+
owner: owner === true || owner === 1,
|
|
1219
|
+
generation: Number(raw.generation),
|
|
1220
|
+
createdAt: Number(raw.createdAt),
|
|
1221
|
+
statusChangedAt: Number(raw.statusChangedAt),
|
|
1222
|
+
accessSeq: BigInt(raw.accessSeq),
|
|
1223
|
+
mac: Buffer.from(raw.mac, "hex")
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
//#endregion
|
|
997
1227
|
//#region src/accounting.ts
|
|
998
1228
|
const KEY_ACTIONS = /* @__PURE__ */ new Set([
|
|
999
1229
|
"secret.read",
|
|
@@ -1358,7 +1588,7 @@ var Walk = class {
|
|
|
1358
1588
|
};
|
|
1359
1589
|
/** The action of the vault's entry that signs a prefix of the log. */
|
|
1360
1590
|
const CHECKPOINT = "audit.checkpoint";
|
|
1361
|
-
const ACCESS = new Set(ACCESS_ACTIONS);
|
|
1591
|
+
const ACCESS = new Set(ACCESS_ACTIONS$1);
|
|
1362
1592
|
/** JSON with every object's keys sorted: one text for one value, however it was built. */
|
|
1363
1593
|
function canonical(value) {
|
|
1364
1594
|
return JSON.stringify(value, (_, inner) => inner !== null && typeof inner === "object" && !Array.isArray(inner) ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : inner);
|
|
@@ -1571,6 +1801,24 @@ var VaultService = class {
|
|
|
1571
1801
|
async #now(db) {
|
|
1572
1802
|
return await now(db) + this.#prepared.options.clockOffset();
|
|
1573
1803
|
}
|
|
1804
|
+
/** A snapshot's clock, as the vault keeps time (`#now`). */
|
|
1805
|
+
#at(reads) {
|
|
1806
|
+
return reads.at + this.#prepared.options.clockOffset();
|
|
1807
|
+
}
|
|
1808
|
+
/** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
|
|
1809
|
+
#snapshot(tx, principals, asked, head) {
|
|
1810
|
+
const { released, ...rest } = asked;
|
|
1811
|
+
return snapshot(tx, {
|
|
1812
|
+
...rest,
|
|
1813
|
+
principals,
|
|
1814
|
+
head,
|
|
1815
|
+
...released === void 0 ? {} : { released: {
|
|
1816
|
+
...released,
|
|
1817
|
+
windowMs: this.#config.bulkLimit.windowMs,
|
|
1818
|
+
clockOffsetMs: this.#prepared.options.clockOffset()
|
|
1819
|
+
} }
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1574
1822
|
/**
|
|
1575
1823
|
* Decide in one transaction, with `principals`' rows locked first, then
|
|
1576
1824
|
* the log's head. A `Refused` or an `Outage` rolls back all but its own
|
|
@@ -1583,34 +1831,35 @@ var VaultService = class {
|
|
|
1583
1831
|
* alone takes the head later (`late`), once its key service has answered,
|
|
1584
1832
|
* which it must not wait on holding it, and decides again then (`#keys`).
|
|
1585
1833
|
*/
|
|
1586
|
-
async #decide(principals, decide, late = false) {
|
|
1834
|
+
async #decide(principals, decide, late = false, asked = {}) {
|
|
1587
1835
|
const superseded = await this.#settled();
|
|
1588
1836
|
if (superseded !== null) return {
|
|
1589
1837
|
ok: false,
|
|
1590
1838
|
refusal: refusal("wrong_kek", superseded)
|
|
1591
1839
|
};
|
|
1592
|
-
return this.#decideNow(principals, decide, late);
|
|
1840
|
+
return this.#decideNow(principals, decide, late, asked);
|
|
1593
1841
|
}
|
|
1594
1842
|
/** `#decide`, once the vault is settled. */
|
|
1595
|
-
async #decideNow(principals, decide, late = false) {
|
|
1843
|
+
async #decideNow(principals, decide, late = false, asked = {}) {
|
|
1596
1844
|
const reports = [];
|
|
1597
1845
|
for (let attempt = 1;; attempt += 1) try {
|
|
1598
|
-
return await this.#decideOnce(principals, decide, reports, late);
|
|
1846
|
+
return await this.#decideOnce(principals, decide, reports, late, asked);
|
|
1599
1847
|
} catch (error) {
|
|
1600
1848
|
if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
|
|
1601
1849
|
throw error;
|
|
1602
1850
|
}
|
|
1603
1851
|
}
|
|
1604
|
-
async #decideOnce(principals, decide, reports, late) {
|
|
1852
|
+
async #decideOnce(principals, decide, reports, late, asked) {
|
|
1605
1853
|
try {
|
|
1606
1854
|
const result = await this.#db.transaction(async (tx) => {
|
|
1607
1855
|
await boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
1608
1856
|
const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals);
|
|
1609
|
-
|
|
1857
|
+
const reads = await this.#snapshot(tx, principals, asked, !late);
|
|
1610
1858
|
const d = {
|
|
1611
1859
|
tx,
|
|
1612
1860
|
members,
|
|
1613
|
-
at:
|
|
1861
|
+
at: this.#at(reads),
|
|
1862
|
+
reads,
|
|
1614
1863
|
log: [],
|
|
1615
1864
|
writes: [],
|
|
1616
1865
|
touched: /* @__PURE__ */ new Set(),
|
|
@@ -1624,7 +1873,8 @@ var VaultService = class {
|
|
|
1624
1873
|
let at = d.at;
|
|
1625
1874
|
const accessSeq = /* @__PURE__ */ new Map();
|
|
1626
1875
|
if (entries.length > 0) {
|
|
1627
|
-
|
|
1876
|
+
if (d.reads.head === null) throw new Error("a decision appends only under the head it read with");
|
|
1877
|
+
const appended = await this.#append(tx, entries, d.underLock, d.reads);
|
|
1628
1878
|
at = appended.occurredAt;
|
|
1629
1879
|
entries.forEach((entry, i) => {
|
|
1630
1880
|
if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal, appended.seqStart + BigInt(i));
|
|
@@ -1689,12 +1939,12 @@ var VaultService = class {
|
|
|
1689
1939
|
* MAC are passed over, and reported: otherwise whoever can insert a row
|
|
1690
1940
|
* could lock any member out.
|
|
1691
1941
|
*/
|
|
1692
|
-
async #integrity(db, principal, row, grants, reports) {
|
|
1942
|
+
async #integrity(db, principal, row, grants, reports, reads) {
|
|
1693
1943
|
if (row !== void 0 && !this.#sealed(row, grants)) {
|
|
1694
1944
|
this.#report(reports, principal, "mac", row.mac.toString("hex"));
|
|
1695
1945
|
return "mac";
|
|
1696
1946
|
}
|
|
1697
|
-
const newest = await this.#newestAccess(db, principal, reports);
|
|
1947
|
+
const newest = await this.#newestAccess(db, principal, reports, reads);
|
|
1698
1948
|
const seq = typeof newest !== "string" ? newest.seq : newest === "unreached" ? void 0 : null;
|
|
1699
1949
|
if (row === void 0 ? seq === null : seq === row.accessSeq) return null;
|
|
1700
1950
|
if (row === void 0 && await member(db, principal) !== void 0) throw new Retry();
|
|
@@ -1702,8 +1952,10 @@ var VaultService = class {
|
|
|
1702
1952
|
return "stale";
|
|
1703
1953
|
}
|
|
1704
1954
|
/** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
|
|
1705
|
-
#newestAccess(db, principal, reports) {
|
|
1706
|
-
return newestAccess(db, principal, this.#check(reports, principal));
|
|
1955
|
+
#newestAccess(db, principal, reports, reads) {
|
|
1956
|
+
if (reads === void 0 || !reads.grants.has(principal)) return newestAccess(db, principal, this.#check(reports, principal));
|
|
1957
|
+
const newest = reads.newestAccess.get(principal);
|
|
1958
|
+
return newestAuthentic((limit, before) => before === void 0 ? Promise.resolve(newest === void 0 ? [] : [newest]) : accessEntriesAbout(db, principal, limit, before), this.#check(reports, principal));
|
|
1707
1959
|
}
|
|
1708
1960
|
/** Entries in the vault's name told apart by their MACs, each it did not write reported, about `principal`. */
|
|
1709
1961
|
#check(reports, principal) {
|
|
@@ -1801,7 +2053,7 @@ var VaultService = class {
|
|
|
1801
2053
|
}
|
|
1802
2054
|
async #replaceEveryProject(d, principal) {
|
|
1803
2055
|
const row = d.members.get(principal);
|
|
1804
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
2056
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
1805
2057
|
if (row === void 0 || subject.status !== "active") return;
|
|
1806
2058
|
const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
|
|
1807
2059
|
if (everyProject.length === 0) return;
|
|
@@ -1953,16 +2205,31 @@ var VaultService = class {
|
|
|
1953
2205
|
* that an instance still running with the replaced KEK, as during a
|
|
1954
2206
|
* deploy, cannot write after the rotation.
|
|
1955
2207
|
*/
|
|
1956
|
-
#append(tx, entries, underLock = []) {
|
|
2208
|
+
async #append(tx, entries, underLock = [], held) {
|
|
1957
2209
|
const prepared = this.#prepared;
|
|
1958
|
-
|
|
1959
|
-
|
|
2210
|
+
const head = held?.head ?? void 0;
|
|
2211
|
+
const appended = await appendEntries(tx, prepared.logKey, entries, async (locked) => {
|
|
2212
|
+
const rotation = head === void 0 ? await latestVaultEntry(locked, [KEY_ROTATE]) : head.rotation ?? void 0;
|
|
1960
2213
|
if (rotation !== void 0 && !prepared.logKeys.some((key) => key.keyId === rotation.keyId)) {
|
|
1961
2214
|
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`;
|
|
1962
2215
|
throw new Error(prepared.superseded);
|
|
1963
2216
|
}
|
|
1964
2217
|
for (const check of underLock) await check(locked);
|
|
2218
|
+
}, head === void 0 ? void 0 : {
|
|
2219
|
+
head,
|
|
2220
|
+
last: head.last,
|
|
2221
|
+
at: held.at
|
|
1965
2222
|
});
|
|
2223
|
+
if (head !== void 0) held.head = {
|
|
2224
|
+
...head,
|
|
2225
|
+
nextSeq: appended.nextSeq,
|
|
2226
|
+
headHash: appended.headHash,
|
|
2227
|
+
last: {
|
|
2228
|
+
seq: appended.nextSeq - 1n,
|
|
2229
|
+
hash: appended.headHash
|
|
2230
|
+
}
|
|
2231
|
+
};
|
|
2232
|
+
return appended;
|
|
1966
2233
|
}
|
|
1967
2234
|
async unwrap(input) {
|
|
1968
2235
|
const { principal } = input;
|
|
@@ -2019,10 +2286,8 @@ var VaultService = class {
|
|
|
2019
2286
|
permission: input.purpose === "use" ? "secret.use" : "secret.read",
|
|
2020
2287
|
secrets: items.map((item) => item.secret),
|
|
2021
2288
|
places: sealed.map((seal, i) => seal?.holder ?? items[i].secret),
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
return sealed.map((seal) => seal !== null && ended.has(seal.seq) ? "ended" : null);
|
|
2025
|
-
},
|
|
2289
|
+
references: ends,
|
|
2290
|
+
vet: ends.length === 0 ? void 0 : async (_, reads) => sealed.map((seal) => seal !== null && reads.ended.has(seal.seq) ? "ended" : null),
|
|
2026
2291
|
remote,
|
|
2027
2292
|
entry,
|
|
2028
2293
|
input
|
|
@@ -2203,11 +2468,9 @@ var VaultService = class {
|
|
|
2203
2468
|
});
|
|
2204
2469
|
const owners = /* @__PURE__ */ new Map();
|
|
2205
2470
|
const check = async (d) => {
|
|
2206
|
-
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
|
|
2471
|
+
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), call.input);
|
|
2207
2472
|
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
2208
|
-
const environments =
|
|
2209
|
-
const parents = [...environments.values()].flatMap((environment) => environment.owner === null || environment.parentId === null ? [] : [environment.parentId]);
|
|
2210
|
-
for (const [id, parent] of await environmentsById(d.tx, parents)) environments.set(id, parent);
|
|
2473
|
+
const environments = d.reads.environments;
|
|
2211
2474
|
const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
|
|
2212
2475
|
owners.clear();
|
|
2213
2476
|
for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
|
|
@@ -2223,7 +2486,7 @@ var VaultService = class {
|
|
|
2223
2486
|
}
|
|
2224
2487
|
return refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i]));
|
|
2225
2488
|
});
|
|
2226
|
-
if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx)).forEach((code, i) => codes[i] = code);
|
|
2489
|
+
if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx, d.reads)).forEach((code, i) => codes[i] = code);
|
|
2227
2490
|
const copying = action === "secret.read" && call.input.purpose === "copy";
|
|
2228
2491
|
if (action === "secret.read" && principal.startsWith("user:") && handsBack({
|
|
2229
2492
|
purpose: call.input.purpose ?? "",
|
|
@@ -2232,9 +2495,17 @@ var VaultService = class {
|
|
|
2232
2495
|
if (codes[i] === null && secret.sensitive === true) codes[i] = "sensitive";
|
|
2233
2496
|
});
|
|
2234
2497
|
let first = codes.find((code) => code !== null) ?? null;
|
|
2235
|
-
if (first === null && action === "secret.read" &&
|
|
2498
|
+
if (first === null && action === "secret.read" && this.#overBulkLimit(d)) first = "bulk_limit";
|
|
2236
2499
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, i) => entry(secret, "deny", codes[i] ?? first)));
|
|
2237
2500
|
};
|
|
2501
|
+
const reads = {
|
|
2502
|
+
environments: [...secrets, ...secrets.map((secret, i) => call.places?.[i] ?? secret)].map((place) => place.environmentId),
|
|
2503
|
+
references: call.references ?? [],
|
|
2504
|
+
...action === "secret.read" ? { released: {
|
|
2505
|
+
principal,
|
|
2506
|
+
keys: secrets.map((secret) => secret.secretId)
|
|
2507
|
+
} } : {}
|
|
2508
|
+
};
|
|
2238
2509
|
const intentId = call.remote ? randomUUID() : null;
|
|
2239
2510
|
let intentSeq = null;
|
|
2240
2511
|
const outcomeEntry = (secret, item, decision, code) => {
|
|
@@ -2253,7 +2524,7 @@ var VaultService = class {
|
|
|
2253
2524
|
if (call.remote) {
|
|
2254
2525
|
const intent = await this.#decide([principal], async (d) => {
|
|
2255
2526
|
await check(d);
|
|
2256
|
-
const at =
|
|
2527
|
+
const at = d.at;
|
|
2257
2528
|
return { seq: (await this.#append(d.tx, [{
|
|
2258
2529
|
actor: principal,
|
|
2259
2530
|
action: "key.intent",
|
|
@@ -2273,8 +2544,8 @@ var VaultService = class {
|
|
|
2273
2544
|
version: secret.version
|
|
2274
2545
|
}))
|
|
2275
2546
|
})
|
|
2276
|
-
}])).seqStart };
|
|
2277
|
-
});
|
|
2547
|
+
}], [], d.reads)).seqStart };
|
|
2548
|
+
}, false, reads);
|
|
2278
2549
|
if (!intent.ok) return intent;
|
|
2279
2550
|
intentSeq = intent.seq;
|
|
2280
2551
|
}
|
|
@@ -2291,7 +2562,7 @@ var VaultService = class {
|
|
|
2291
2562
|
await decided(d);
|
|
2292
2563
|
const { vet } = call;
|
|
2293
2564
|
if (late && vet !== void 0) d.underLock.push(async (locked) => {
|
|
2294
|
-
const codes = await vet(locked);
|
|
2565
|
+
const codes = await vet(locked, d.reads);
|
|
2295
2566
|
const first = codes.find((code) => code !== null);
|
|
2296
2567
|
if (first === void 0 || first === null) return;
|
|
2297
2568
|
throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, "deny", codes[item] ?? first)));
|
|
@@ -2307,11 +2578,10 @@ var VaultService = class {
|
|
|
2307
2578
|
if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
|
|
2308
2579
|
if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
|
|
2309
2580
|
if (late) {
|
|
2310
|
-
await
|
|
2581
|
+
d.reads = await this.#snapshot(d.tx, [principal], reads, true);
|
|
2582
|
+
d.at = this.#at(d.reads);
|
|
2311
2583
|
await decided(d);
|
|
2312
|
-
|
|
2313
|
-
const now = await environmentsById(d.tx, places.map((place) => place.environmentId));
|
|
2314
|
-
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")));
|
|
2584
|
+
if (secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]).some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
|
|
2315
2585
|
}
|
|
2316
2586
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
|
|
2317
2587
|
d.log.push(...released);
|
|
@@ -2321,12 +2591,16 @@ var VaultService = class {
|
|
|
2321
2591
|
} finally {
|
|
2322
2592
|
for (const value of done) value.wipe();
|
|
2323
2593
|
}
|
|
2324
|
-
}, late);
|
|
2594
|
+
}, late, reads);
|
|
2325
2595
|
}
|
|
2326
|
-
/**
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2596
|
+
/**
|
|
2597
|
+
* Whether this read's keys would take the reader past the bulk limit: the
|
|
2598
|
+
* distinct keys released to them in the window, these among them. A key
|
|
2599
|
+
* read again exposes nothing new, so it counts once, whatever its version.
|
|
2600
|
+
* Counted under their row's lock, so exactly (`Reads`).
|
|
2601
|
+
*/
|
|
2602
|
+
#overBulkLimit(d) {
|
|
2603
|
+
return d.reads.released > this.#config.bulkLimit.count;
|
|
2330
2604
|
}
|
|
2331
2605
|
/**
|
|
2332
2606
|
* Make each holder a reference to its source: the actor writes the holder
|
|
@@ -2376,7 +2650,7 @@ var VaultService = class {
|
|
|
2376
2650
|
};
|
|
2377
2651
|
};
|
|
2378
2652
|
return this.#decide([principal], async (d) => {
|
|
2379
|
-
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
2653
|
+
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
|
|
2380
2654
|
const natures$1 = await natures(d.tx, [...sources.values()].map((source) => source.secretId));
|
|
2381
2655
|
const environments = await environmentsById(d.tx, items.flatMap((item) => {
|
|
2382
2656
|
const source = sources.get(item.source.secretId);
|
|
@@ -2455,7 +2729,7 @@ var VaultService = class {
|
|
|
2455
2729
|
return seals.map((seal) => done.has(seal.seq) ? "ended" : null);
|
|
2456
2730
|
};
|
|
2457
2731
|
return this.#decide([principal], async (d) => {
|
|
2458
|
-
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
2732
|
+
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
|
|
2459
2733
|
const environments = await environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
|
|
2460
2734
|
const codes = seals.map((seal) => {
|
|
2461
2735
|
const holder = refuses(actor, "secret.write", placeIn(seal.holder, environments), false);
|
|
@@ -2556,7 +2830,7 @@ var VaultService = class {
|
|
|
2556
2830
|
*/
|
|
2557
2831
|
async #makeOwner(d, principal, reason) {
|
|
2558
2832
|
const row = d.members.get(principal);
|
|
2559
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
2833
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
2560
2834
|
if (subject.status === "active" && ownsInstance(subject.live)) return false;
|
|
2561
2835
|
const tampered = subject.status === "tampered";
|
|
2562
2836
|
if (tampered && await this.#logged(d.tx, principal) === void 0) return false;
|
|
@@ -2610,10 +2884,10 @@ var VaultService = class {
|
|
|
2610
2884
|
* What `principal` holds, their row checked first (`#integrity`): a row
|
|
2611
2885
|
* that fails holds nothing, and is `tampered`.
|
|
2612
2886
|
*/
|
|
2613
|
-
async #standing(db, principal, row, at, reports) {
|
|
2887
|
+
async #standing(db, principal, row, at, reports, reads) {
|
|
2614
2888
|
const none = NOTHING;
|
|
2615
|
-
const grants$1 = row === void 0 ? [] : await grants(db, principal);
|
|
2616
|
-
const fault = await this.#integrity(db, principal, row, grants$1, reports);
|
|
2889
|
+
const grants$1 = row === void 0 ? [] : reads?.grants.get(principal) ?? await grants(db, principal);
|
|
2890
|
+
const fault = await this.#integrity(db, principal, row, grants$1, reports, reads);
|
|
2617
2891
|
if (fault !== null) return {
|
|
2618
2892
|
principal,
|
|
2619
2893
|
status: "tampered",
|
|
@@ -2643,7 +2917,7 @@ var VaultService = class {
|
|
|
2643
2917
|
role: grant.role
|
|
2644
2918
|
}));
|
|
2645
2919
|
const { role, scope } = roleOf(principal, row);
|
|
2646
|
-
const { policy } = await this.#policyAt(db, reports, at);
|
|
2920
|
+
const { policy } = await this.#policyAt(db, reports, at, reads);
|
|
2647
2921
|
const service = !principal.startsWith("user:");
|
|
2648
2922
|
return {
|
|
2649
2923
|
principal,
|
|
@@ -2703,13 +2977,17 @@ var VaultService = class {
|
|
|
2703
2977
|
/** `access`, as the rows are now. */
|
|
2704
2978
|
async #read(principal, superseded) {
|
|
2705
2979
|
const read = async (db, reports) => {
|
|
2706
|
-
const
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
]
|
|
2711
|
-
|
|
2712
|
-
|
|
2980
|
+
const reads = await snapshot(db, {
|
|
2981
|
+
principals: [principal],
|
|
2982
|
+
members: true
|
|
2983
|
+
});
|
|
2984
|
+
const [row, held, at] = [
|
|
2985
|
+
reads.members.get(principal),
|
|
2986
|
+
reads.grants.get(principal),
|
|
2987
|
+
this.#at(reads)
|
|
2988
|
+
];
|
|
2989
|
+
const fault = await this.#integrity(db, principal, row, held, reports, reads);
|
|
2990
|
+
const { policy } = await this.#policyAt(db, reports, at, reads);
|
|
2713
2991
|
return this.#access(principal, row, held, at, fault !== null, policy);
|
|
2714
2992
|
};
|
|
2715
2993
|
const found = [];
|
|
@@ -2784,7 +3062,7 @@ var VaultService = class {
|
|
|
2784
3062
|
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
2785
3063
|
if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
|
|
2786
3064
|
}
|
|
2787
|
-
const { policy } = await this.#policyAt(d.tx, d.reports, d.at);
|
|
3065
|
+
const { policy } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
|
|
2788
3066
|
for (const change of changes) {
|
|
2789
3067
|
if (change.role !== null && !isRole(change.role, policy)) throw refused("invalid", `no such role: ${change.role}`);
|
|
2790
3068
|
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) throw refused("invalid", `${change.role} can only be granted on a project`);
|
|
@@ -2800,13 +3078,13 @@ var VaultService = class {
|
|
|
2800
3078
|
environmentSlug: environment.slug
|
|
2801
3079
|
};
|
|
2802
3080
|
});
|
|
2803
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
3081
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
2804
3082
|
if (acting.status === "tampered") throw refused("tampered");
|
|
2805
3083
|
const manages = where.every((place) => mayManageAccess(acting.live, place));
|
|
2806
3084
|
if (!manages && !(SERVICE.test(principal) && PERSON.test(actor))) throw refused("not_allowed");
|
|
2807
3085
|
if (actor === principal && changes.some((change) => change.role !== null)) throw refused("not_allowed", "nobody grants themselves a role: ask another admin");
|
|
2808
3086
|
const row = d.members.get(principal);
|
|
2809
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
3087
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
2810
3088
|
if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
|
|
2811
3089
|
const admitted = input.admit === true && row?.status !== "active" ? await this.#admitWith(d, acting, principal, row, input) : void 0;
|
|
2812
3090
|
if (admitted === void 0 && row?.status === "removed") throw refused("removed");
|
|
@@ -2906,7 +3184,7 @@ var VaultService = class {
|
|
|
2906
3184
|
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.add", principal, "deny", input, asked, code)]);
|
|
2907
3185
|
return this.#decide([actor, principal], async (d) => {
|
|
2908
3186
|
validateCorrelation(input);
|
|
2909
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
3187
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
2910
3188
|
if (acting.status === "tampered") throw refused("tampered");
|
|
2911
3189
|
const service = SERVICE.test(principal) && (input.role ?? "member") === "member" && input.scope === void 0;
|
|
2912
3190
|
if (!runsInstance(acting.live)) await this.#setsUp(d, acting, service, refused);
|
|
@@ -2919,7 +3197,7 @@ var VaultService = class {
|
|
|
2919
3197
|
};
|
|
2920
3198
|
if (wanted !== void 0 && wanted.role !== "member" && !principal.startsWith("user:")) throw refused("invalid", "service accounts hold project grants only, never an instance role");
|
|
2921
3199
|
const row = d.members.get(principal);
|
|
2922
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
3200
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
2923
3201
|
if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
|
|
2924
3202
|
d.touched.add(principal);
|
|
2925
3203
|
d.grants.set(principal, [...subject.stored]);
|
|
@@ -3020,10 +3298,10 @@ var VaultService = class {
|
|
|
3020
3298
|
validateCorrelation(input);
|
|
3021
3299
|
if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
|
|
3022
3300
|
const row = d.members.get(principal);
|
|
3023
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
3301
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
3024
3302
|
if (acting.status === "tampered") throw refused("tampered");
|
|
3025
3303
|
const holder = acting.live;
|
|
3026
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
3304
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
3027
3305
|
const held = subject.stored;
|
|
3028
3306
|
if (this.#listed(principal)) throw refused("not_allowed", declaredOwner(principal));
|
|
3029
3307
|
if (subject.status === "tampered") {
|
|
@@ -3127,20 +3405,21 @@ var VaultService = class {
|
|
|
3127
3405
|
* no role holds anything but Owner, otherwise whoever can insert rows
|
|
3128
3406
|
* could unprotect them. Its edits are then unknown, not none.
|
|
3129
3407
|
*/
|
|
3130
|
-
async #policyAt(db, reports, at) {
|
|
3131
|
-
const entry = await newestAuthentic((limit, before) => newestVaultEntries(db, POLICY, limit, before), this.#check(reports, null));
|
|
3408
|
+
async #policyAt(db, reports, at, reads) {
|
|
3409
|
+
const entry = await newestAuthentic((limit, before) => reads !== void 0 && before === void 0 ? Promise.resolve(reads.newestPolicy === null ? [] : [reads.newestPolicy]) : newestVaultEntries(db, POLICY, limit, before), this.#check(reports, null));
|
|
3132
3410
|
if (entry === "none") return {
|
|
3133
3411
|
policy: DEFAULT_POLICY,
|
|
3134
3412
|
edits: NO_EDITS,
|
|
3135
3413
|
readable: true
|
|
3136
3414
|
};
|
|
3137
3415
|
const edits = typeof entry === "string" ? null : readEdits(JSON.parse(entry.metadata));
|
|
3138
|
-
|
|
3416
|
+
if (edits === null) return {
|
|
3139
3417
|
policy: CLOSED_POLICY,
|
|
3140
3418
|
edits: NO_EDITS,
|
|
3141
3419
|
readable: false
|
|
3142
|
-
}
|
|
3143
|
-
|
|
3420
|
+
};
|
|
3421
|
+
return {
|
|
3422
|
+
policy: livePolicy(entry === reads?.newestPolicy ? reads.environments : await environmentsById(db, edits.protected.map((place) => place.environmentId)), edits, at),
|
|
3144
3423
|
edits,
|
|
3145
3424
|
readable: true
|
|
3146
3425
|
};
|
|
@@ -3165,11 +3444,11 @@ var VaultService = class {
|
|
|
3165
3444
|
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { change: describeChange(change) }, code)]);
|
|
3166
3445
|
return this.#decide([actor], async (d) => {
|
|
3167
3446
|
validateCorrelation(input);
|
|
3168
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
3447
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
3169
3448
|
if (acting.status === "tampered") throw refused("tampered");
|
|
3170
3449
|
if (acting.status !== "active") throw refused(acting.status === "removed" ? "removed" : "not_a_member");
|
|
3171
|
-
const newest =
|
|
3172
|
-
const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
|
|
3450
|
+
const newest = d.reads.newestPolicy?.seq ?? null;
|
|
3451
|
+
const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
|
|
3173
3452
|
if (revision !== void 0 && policyRevision(newest, before) !== revision) throw refused("policy_changed");
|
|
3174
3453
|
const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
|
|
3175
3454
|
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");
|
|
@@ -3225,7 +3504,7 @@ var VaultService = class {
|
|
|
3225
3504
|
}
|
|
3226
3505
|
} else throw refused("invalid", "a change is to a role, an instance role, or protection");
|
|
3227
3506
|
const answer = {
|
|
3228
|
-
policy: await
|
|
3507
|
+
policy: livePolicy(await environmentsById(d.tx, next.protected.map((place) => place.environmentId)), next, d.at),
|
|
3229
3508
|
seq: newest === null ? null : Number(newest)
|
|
3230
3509
|
};
|
|
3231
3510
|
if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
|
|
@@ -3257,7 +3536,7 @@ var VaultService = class {
|
|
|
3257
3536
|
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
|
|
3258
3537
|
return this.#decide([actor], async (d) => {
|
|
3259
3538
|
validateCorrelation(input);
|
|
3260
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
3539
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
3261
3540
|
if (acting.status === "tampered") throw refused("tampered");
|
|
3262
3541
|
if (!runsInstance(acting.live)) throw refused("not_allowed", "only admins and owners of the whole instance change its settings");
|
|
3263
3542
|
if (!isScope(input.settings?.serviceAccounts)) throw refused("invalid", "who sets up service accounts is a scope: { projects, environments }, each all, only or except");
|
|
@@ -4382,7 +4661,7 @@ function oneRead(group) {
|
|
|
4382
4661
|
}
|
|
4383
4662
|
/** Whether `entry` changes a member's access: what their row's `access_seq` names. */
|
|
4384
4663
|
function isAccessEntry(entry) {
|
|
4385
|
-
return entry.decision === "allow" && entry.subjectPrincipal !== void 0 && entry.subjectPrincipal !== null && ACCESS_ACTIONS.includes(entry.action);
|
|
4664
|
+
return entry.decision === "allow" && entry.subjectPrincipal !== void 0 && entry.subjectPrincipal !== null && ACCESS_ACTIONS$1.includes(entry.action);
|
|
4386
4665
|
}
|
|
4387
4666
|
/** An entry about a member's access. */
|
|
4388
4667
|
function accessEntry(actor, action, principal, decision, correlation, detail, code = null) {
|
|
@@ -4486,8 +4765,7 @@ function readEdits(stored) {
|
|
|
4486
4765
|
* keeps its slug, which a scoped instance role's filters read. The edits
|
|
4487
4766
|
* keep them all: no write is needed.
|
|
4488
4767
|
*/
|
|
4489
|
-
|
|
4490
|
-
const rows = await environmentsById(db, edits.protected.map((place) => place.environmentId));
|
|
4768
|
+
function livePolicy(rows, edits, at) {
|
|
4491
4769
|
const live = edits.protected.filter((place) => {
|
|
4492
4770
|
const row = rows.get(place.environmentId);
|
|
4493
4771
|
return row === void 0 ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
|