@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "coffre's vault: keys, grants and members, in the database the app uses, as a Cloudflare Worker or on Node.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"drizzle-orm": "0.45.2",
|
|
39
|
-
"@coffre/core": "0.10.
|
|
40
|
-
"@coffre/db": "0.10.
|
|
39
|
+
"@coffre/core": "0.10.2",
|
|
40
|
+
"@coffre/db": "0.10.2"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "26.1.1",
|
package/src/config.ts
CHANGED
|
@@ -2,13 +2,14 @@ import { hkdfSync } from 'node:crypto';
|
|
|
2
2
|
|
|
3
3
|
import { KekRegistry, LocalKekProvider, type KekProvider } from '@coffre/core/kek';
|
|
4
4
|
|
|
5
|
-
/** At most `count` data keys unwrapped per principal in any `windowMs`. */
|
|
5
|
+
/** At most `count` distinct data keys unwrapped per principal in any `windowMs`. */
|
|
6
6
|
export type BulkLimit = { count: number; windowMs: number };
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* 1000 keys in 15 minutes
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* 1000 different keys in 15 minutes, which no person or deploy pipeline
|
|
10
|
+
* reads, while a script pulling every secret it can reach stops within a
|
|
11
|
+
* few seconds. A key read again counts once: a 50-key `coffre run` is 50
|
|
12
|
+
* however often it runs.
|
|
12
13
|
*/
|
|
13
14
|
export const DEFAULT_BULK_LIMIT: BulkLimit = { count: 1000, windowMs: 15 * 60_000 };
|
|
14
15
|
|
|
@@ -62,7 +63,7 @@ export type VaultConfig = {
|
|
|
62
63
|
* service holds the KEK, as AWS KMS does: the vault never sees that key.
|
|
63
64
|
*/
|
|
64
65
|
signingKey?: string;
|
|
65
|
-
/** At most `count` data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
|
|
66
|
+
/** At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
|
|
66
67
|
bulkLimit?: { count: number; windowMinutes: number };
|
|
67
68
|
/**
|
|
68
69
|
* @deprecated Gone in 0.10, and refused: each key says who sees it now. Mark
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { TOMBSTONE } from '@coffre/core/schemas';
|
|
2
|
+
import { ACCESS_ACTIONS } from '@coffre/core/vault';
|
|
3
|
+
import { sql, type SQL } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
import type { Asked } from './snapshot.ts';
|
|
6
|
+
|
|
7
|
+
/** An entry of `audit_log` aliased `e`, as JSON (`RawEntry`): whole numbers past 2^53 as text, bytes as hex. */
|
|
8
|
+
const ENTRY = sql.raw(`json_build_object(
|
|
9
|
+
'seq', e.seq::text, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
|
|
10
|
+
'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
|
|
11
|
+
'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
|
|
12
|
+
'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq::text,
|
|
13
|
+
'metadata', e.metadata, 'prevHash', encode(e.prev_hash, 'hex'), 'mac', encode(e.mac, 'hex'), 'hash', encode(e.hash, 'hex'))`);
|
|
14
|
+
|
|
15
|
+
const NOW = sql.raw('(extract(epoch from clock_timestamp()) * 1000)::bigint');
|
|
16
|
+
|
|
17
|
+
/** `snapshot`'s statement on Postgres: one row, one JSON column. Each part reads one index range. */
|
|
18
|
+
export function snapshotQuery(asked: Asked): SQL {
|
|
19
|
+
const principals = sql`${sql.param([...asked.principals])}::text[]`;
|
|
20
|
+
const environments = sql`${sql.param([...(asked.environments ?? [])])}::uuid[]`;
|
|
21
|
+
const references = sql`${sql.param((asked.references ?? []).map(String))}::bigint[]`;
|
|
22
|
+
const actions = sql`${sql.param([...ACCESS_ACTIONS])}::text[]`;
|
|
23
|
+
const tomb = `%${TOMBSTONE}%`;
|
|
24
|
+
const released = asked.released;
|
|
25
|
+
// The head only if nobody holds it: a statement never waits for it, so all it reads is as of when it took it (`snapshot`).
|
|
26
|
+
const head = asked.head === true
|
|
27
|
+
? sql`, head AS (SELECT next_seq, head_hash FROM audit_chain_head WHERE only_row FOR UPDATE SKIP LOCKED)`
|
|
28
|
+
: sql``;
|
|
29
|
+
return sql`
|
|
30
|
+
WITH policy AS (
|
|
31
|
+
SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
|
|
32
|
+
)${head}
|
|
33
|
+
SELECT json_build_object(
|
|
34
|
+
'at', ${NOW},
|
|
35
|
+
'head', ${asked.head === true ? sql`(SELECT json_build_object('nextSeq', next_seq::text, 'hash', encode(head_hash, 'hex')) FROM head)` : sql`NULL`},
|
|
36
|
+
'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`},
|
|
37
|
+
'rotation', ${asked.head === true
|
|
38
|
+
? sql`(SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1)`
|
|
39
|
+
: sql`NULL`},
|
|
40
|
+
'members', ${asked.members === true
|
|
41
|
+
? sql`(SELECT coalesce(json_agg(json_build_object(
|
|
42
|
+
'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
|
|
43
|
+
'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
|
|
44
|
+
'accessSeq', access_seq::text, 'mac', encode(mac, 'hex'))), '[]') FROM vault_members WHERE principal = ANY(${principals}))`
|
|
45
|
+
: sql`'[]'::json`},
|
|
46
|
+
'grants', (
|
|
47
|
+
SELECT coalesce(json_agg(json_build_object(
|
|
48
|
+
'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
|
|
49
|
+
'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by)), '[]')
|
|
50
|
+
FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
|
|
51
|
+
WHERE g.principal = ANY(${principals})
|
|
52
|
+
),
|
|
53
|
+
'access', (
|
|
54
|
+
SELECT coalesce(json_agg(${ENTRY}), '[]')
|
|
55
|
+
FROM unnest(${principals}) AS asked (principal)
|
|
56
|
+
CROSS JOIN LATERAL (
|
|
57
|
+
SELECT * FROM audit_log
|
|
58
|
+
WHERE author = 'vault' AND subject_principal = asked.principal AND action = ANY(${actions}) AND decision = 'allow'
|
|
59
|
+
ORDER BY seq DESC LIMIT 1
|
|
60
|
+
) e
|
|
61
|
+
),
|
|
62
|
+
'policy', (SELECT ${ENTRY} FROM policy e),
|
|
63
|
+
'environments', (
|
|
64
|
+
SELECT coalesce(json_agg(json_build_object(
|
|
65
|
+
'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
|
|
66
|
+
'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
|
|
67
|
+
'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL)), '[]')
|
|
68
|
+
FROM environments env JOIN projects project ON project.id = env.project_id
|
|
69
|
+
-- One list of ids, each found by the primary key.
|
|
70
|
+
WHERE env.id = ANY(ARRAY(
|
|
71
|
+
SELECT unnest(${environments})
|
|
72
|
+
UNION SELECT parent_id FROM environments WHERE id = ANY(${environments}) AND owner IS NOT NULL
|
|
73
|
+
-- What the newest policy protects, by the ids its JSON names; one that is no UUID names nothing.
|
|
74
|
+
UNION SELECT (place #>> '{}')::uuid FROM policy, jsonb_path_query(policy.metadata::jsonb, '$.policy.protected[*].environmentId') AS place
|
|
75
|
+
WHERE (place #>> '{}') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
76
|
+
))
|
|
77
|
+
),
|
|
78
|
+
'ended', (
|
|
79
|
+
SELECT coalesce(json_agg(related_seq::text), '[]') FROM audit_log
|
|
80
|
+
WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
|
|
81
|
+
),
|
|
82
|
+
'released', ${released === undefined
|
|
83
|
+
? sql`0`
|
|
84
|
+
: sql`(
|
|
85
|
+
SELECT count(DISTINCT key) FROM (
|
|
86
|
+
-- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
|
|
87
|
+
-- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
|
|
88
|
+
SELECT coalesce(e.secret_id::text, CASE jsonb_typeof(one -> 'secretId') WHEN 'string' THEN one ->> 'secretId' END, 'entry:' || e.seq) FROM audit_log e
|
|
89
|
+
-- Each item of a list, never what lax paths would make of an object; silent, as strict paths fail on what is missing.
|
|
90
|
+
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
|
|
91
|
+
WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
|
|
92
|
+
AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
|
|
93
|
+
UNION ALL SELECT unnest(${sql.param([...released.keys])}::text[])
|
|
94
|
+
) AS released (key)
|
|
95
|
+
)`}
|
|
96
|
+
) AS snapshot`;
|
|
97
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { TOMBSTONE } from '@coffre/core/schemas';
|
|
2
|
+
import { ACCESS_ACTIONS } from '@coffre/core/vault';
|
|
3
|
+
import { sql, type SQL } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
import type { Asked } from './snapshot.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An entry of `audit_log` aliased `e`, as JSON (`RawEntry`), bytes as hex.
|
|
9
|
+
* A JSON value loses its type leaving a subquery, so whoever takes one
|
|
10
|
+
* from a subquery wraps it in `json(…)`.
|
|
11
|
+
*/
|
|
12
|
+
const ENTRY = sql.raw(`json_object(
|
|
13
|
+
'seq', e.seq, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
|
|
14
|
+
'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
|
|
15
|
+
'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
|
|
16
|
+
'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq,
|
|
17
|
+
'metadata', e.metadata, 'prevHash', hex(e.prev_hash), 'mac', hex(e.mac), 'hash', hex(e.hash))`);
|
|
18
|
+
|
|
19
|
+
const NOW = sql.raw(`CAST(unixepoch('subsec') * 1000 AS INTEGER)`);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `snapshot`'s statement on SQLite, the same as on Postgres. Nothing is
|
|
23
|
+
* locked: one transaction runs at a time (connect.ts), so the head is the
|
|
24
|
+
* last entry's.
|
|
25
|
+
*/
|
|
26
|
+
export function snapshotQuery(asked: Asked): SQL {
|
|
27
|
+
const principals = JSON.stringify(asked.principals);
|
|
28
|
+
const environments = JSON.stringify(asked.environments ?? []);
|
|
29
|
+
const references = JSON.stringify((asked.references ?? []).map(Number));
|
|
30
|
+
const actions = JSON.stringify(ACCESS_ACTIONS);
|
|
31
|
+
const tomb = `%${TOMBSTONE}%`;
|
|
32
|
+
const released = asked.released;
|
|
33
|
+
const head = asked.head === true;
|
|
34
|
+
return sql`
|
|
35
|
+
WITH policy AS (
|
|
36
|
+
SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
|
|
37
|
+
)
|
|
38
|
+
SELECT json_object(
|
|
39
|
+
'at', ${NOW},
|
|
40
|
+
'head', ${head ? sql`json((SELECT json_object('nextSeq', next_seq, 'hash', hex(head_hash)) FROM audit_chain_head WHERE only_row))` : sql`NULL`},
|
|
41
|
+
'last', ${head ? sql`json((SELECT json_object('seq', seq, 'hash', hex(hash)) FROM audit_log ORDER BY seq DESC LIMIT 1))` : sql`NULL`},
|
|
42
|
+
'rotation', ${head
|
|
43
|
+
? sql`json((SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1))`
|
|
44
|
+
: sql`NULL`},
|
|
45
|
+
'members', json(${asked.members === true
|
|
46
|
+
? sql`(SELECT json_group_array(json_object(
|
|
47
|
+
'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
|
|
48
|
+
'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
|
|
49
|
+
'accessSeq', access_seq, 'mac', hex(mac))) FROM vault_members WHERE principal IN (SELECT value FROM json_each(${principals})))`
|
|
50
|
+
: sql`'[]'`}),
|
|
51
|
+
'grants', json((
|
|
52
|
+
SELECT json_group_array(json_object(
|
|
53
|
+
'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
|
|
54
|
+
'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by))
|
|
55
|
+
FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
|
|
56
|
+
WHERE g.principal IN (SELECT value FROM json_each(${principals}))
|
|
57
|
+
)),
|
|
58
|
+
'access', json((
|
|
59
|
+
SELECT json_group_array(json(newest)) FROM (
|
|
60
|
+
SELECT (
|
|
61
|
+
SELECT ${ENTRY} FROM audit_log e
|
|
62
|
+
WHERE author = 'vault' AND subject_principal = asked.value AND action IN (SELECT value FROM json_each(${actions})) AND decision = 'allow'
|
|
63
|
+
ORDER BY seq DESC LIMIT 1
|
|
64
|
+
) AS newest
|
|
65
|
+
FROM json_each(${principals}) AS asked
|
|
66
|
+
) WHERE newest IS NOT NULL
|
|
67
|
+
)),
|
|
68
|
+
'policy', json((SELECT ${ENTRY} FROM policy e)),
|
|
69
|
+
'environments', json((
|
|
70
|
+
SELECT json_group_array(json_object(
|
|
71
|
+
'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
|
|
72
|
+
'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
|
|
73
|
+
'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL))
|
|
74
|
+
FROM environments env JOIN projects project ON project.id = env.project_id
|
|
75
|
+
WHERE env.id IN (SELECT value FROM json_each(${environments}))
|
|
76
|
+
OR env.id IN (SELECT parent_id FROM environments WHERE id IN (SELECT value FROM json_each(${environments})) AND owner IS NOT NULL)
|
|
77
|
+
OR env.id IN (
|
|
78
|
+
SELECT json_extract(place.value, '$.environmentId')
|
|
79
|
+
FROM policy, json_each(policy.metadata, '$.policy.protected') AS place
|
|
80
|
+
-- Of a forged entry's, an element that is no object names nothing: json_extract would fail on a string.
|
|
81
|
+
WHERE json_valid(policy.metadata) AND json_type(policy.metadata, '$.policy.protected') = 'array' AND place.type = 'object'
|
|
82
|
+
)
|
|
83
|
+
)),
|
|
84
|
+
'ended', json((
|
|
85
|
+
SELECT json_group_array(related_seq) FROM audit_log
|
|
86
|
+
WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
|
|
87
|
+
)),
|
|
88
|
+
'released', ${released === undefined
|
|
89
|
+
? sql`0`
|
|
90
|
+
: sql`(
|
|
91
|
+
SELECT count(DISTINCT key) FROM (
|
|
92
|
+
-- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
|
|
93
|
+
-- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
|
|
94
|
+
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
|
|
95
|
+
FROM audit_log e
|
|
96
|
+
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
|
|
97
|
+
WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
|
|
98
|
+
AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
|
|
99
|
+
UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
|
|
100
|
+
)
|
|
101
|
+
)`}
|
|
102
|
+
) AS snapshot`;
|
|
103
|
+
}
|
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { StoredEntry } from '@coffre/core/audit';
|
|
2
|
+
import type { Queryable, Transaction } from '@coffre/db';
|
|
3
|
+
import { LogHeadMismatch, lockLogHead } from '@coffre/db/log';
|
|
4
|
+
import { engineOf, rowsOf } from '@coffre/db/dialect';
|
|
5
|
+
import type { GrantRow } from '@coffre/db/grants';
|
|
6
|
+
|
|
7
|
+
import { snapshotQuery as postgres } from './snapshot.postgres.ts';
|
|
8
|
+
import { snapshotQuery as sqlite } from './snapshot.sqlite.ts';
|
|
9
|
+
import * as store from './store.ts';
|
|
10
|
+
import type { EnvironmentRow, Member } from './store.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What a decision reads, or `access`, in one statement: one round trip, and
|
|
14
|
+
* one snapshot of the database, so its parts agree with each other.
|
|
15
|
+
*
|
|
16
|
+
* In a decision, the same statement locks the log's head, but only if it
|
|
17
|
+
* is free (`SKIP LOCKED`). Postgres takes a statement's snapshot when it
|
|
18
|
+
* starts, so one that waited for the lock would have read everything but
|
|
19
|
+
* the head as it was before the wait: a policy changed, a place deleted, a
|
|
20
|
+
* grant written around the vault while it waited would be missed. A
|
|
21
|
+
* statement that never waits reads all of it as of the lock. When another
|
|
22
|
+
* transaction holds the head, the decision waits for it in a statement of
|
|
23
|
+
* its own, then reads again, holding it. The read checks that the head names
|
|
24
|
+
* the last entry it saw, too (`current`): one taken between the snapshot and
|
|
25
|
+
* the lock, by an append that committed in that instant, is read again. That
|
|
26
|
+
* check sees a commit only through the head it moved, which holds because
|
|
27
|
+
* every change a decision reads commits with an entry under the head: the
|
|
28
|
+
* vault's own decisions, and the app's writes to places, each `audited`
|
|
29
|
+
* with its entry.
|
|
30
|
+
*/
|
|
31
|
+
export type Snapshot = {
|
|
32
|
+
/** The database's clock, milliseconds since the epoch. */
|
|
33
|
+
at: number;
|
|
34
|
+
/** The rows of `asked.members`, by principal; those with none are not in the map. */
|
|
35
|
+
members: Map<string, Member>;
|
|
36
|
+
/** Every grant of each asked principal, lapsed ones too. */
|
|
37
|
+
grants: Map<string, GrantRow[]>;
|
|
38
|
+
/** The newest allowed access entry in the vault's name about each asked principal, unchecked. */
|
|
39
|
+
newestAccess: Map<string, StoredEntry>;
|
|
40
|
+
/** The newest allowed `policy.change` in the vault's name, unchecked. */
|
|
41
|
+
newestPolicy: StoredEntry | null;
|
|
42
|
+
/**
|
|
43
|
+
* The asked environments, the environments each personal config among
|
|
44
|
+
* them is a config of, and those the newest `policy.change` names, by id.
|
|
45
|
+
*/
|
|
46
|
+
environments: Map<string, EnvironmentRow>;
|
|
47
|
+
/** Of the asked `reference.create` entries, those a `reference.end` of the vault's names, allowed. */
|
|
48
|
+
ended: Set<bigint>;
|
|
49
|
+
/**
|
|
50
|
+
* How many distinct keys `asked.released.principal` holds released within
|
|
51
|
+
* its window once it is given `asked.released.keys` too: a key released
|
|
52
|
+
* again, or asked for again, counts once.
|
|
53
|
+
*/
|
|
54
|
+
released: number;
|
|
55
|
+
/** The log's head, locked, with the last entry and the newest `key.rotate`; null unless asked, or held by another. */
|
|
56
|
+
head: LockedHead | null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type LockedHead = {
|
|
60
|
+
nextSeq: bigint;
|
|
61
|
+
headHash: Buffer;
|
|
62
|
+
/** The log's last entry, as this statement's snapshot has it. */
|
|
63
|
+
last: { seq: bigint; hash: Buffer } | null;
|
|
64
|
+
/** The vault's newest `key.rotate`, allowed, unchecked. */
|
|
65
|
+
rotation: StoredEntry | null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** What to read. */
|
|
69
|
+
export type Asked = {
|
|
70
|
+
/** Whose grants and newest access entry; with `members`, their rows too, unlocked. */
|
|
71
|
+
principals: readonly string[];
|
|
72
|
+
members?: boolean;
|
|
73
|
+
environments?: readonly string[];
|
|
74
|
+
references?: readonly bigint[];
|
|
75
|
+
/**
|
|
76
|
+
* One principal's releases within the last `windowMs` of the clock, moved
|
|
77
|
+
* on by `clockOffsetMs` as the vault's is, with `keys`, secret ids, besides.
|
|
78
|
+
*/
|
|
79
|
+
released?: { principal: string; keys: readonly string[]; windowMs: number; clockOffsetMs: number };
|
|
80
|
+
/** Lock the log's head first. */
|
|
81
|
+
head?: boolean;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** One statement's read, as `Snapshot` says; with the head, two more when another holds it. */
|
|
85
|
+
export async function snapshot(tx: Transaction, asked: Asked): Promise<Snapshot>;
|
|
86
|
+
export async function snapshot(db: Queryable, asked: Asked & { head?: false }): Promise<Snapshot>;
|
|
87
|
+
export async function snapshot(db: Queryable, asked: Asked): Promise<Snapshot> {
|
|
88
|
+
const read = () => readOnce(db, asked);
|
|
89
|
+
const first = await read();
|
|
90
|
+
if (asked.head !== true || (first.head !== null && current(first.head))) return first;
|
|
91
|
+
// Held by another, or moved in the instant before the lock: everything again, once the head is this transaction's.
|
|
92
|
+
// Asked for the head, `db` is a transaction (the overloads above).
|
|
93
|
+
if (first.head === null) await lockLogHead(db as Transaction);
|
|
94
|
+
const again = await read();
|
|
95
|
+
if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
|
|
96
|
+
// Held, nothing appends: a head that names another last entry was moved, or the log written, around it (`appendEntries`).
|
|
97
|
+
if (!current(again.head)) {
|
|
98
|
+
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`);
|
|
99
|
+
}
|
|
100
|
+
return again;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether the head names the last entry the same statement saw: nothing was appended between its snapshot and its lock. */
|
|
104
|
+
export function current(head: LockedHead): boolean {
|
|
105
|
+
if (head.last === null) return head.nextSeq === 0n;
|
|
106
|
+
return head.nextSeq === head.last.seq + 1n && head.headHash.equals(head.last.hash);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function readOnce(db: Queryable, asked: Asked): Promise<Snapshot> {
|
|
110
|
+
const query = (engineOf(db) === 'postgres' ? postgres : sqlite)(asked);
|
|
111
|
+
const [row] = await rowsOf<{ snapshot: string | Raw }>(db, query);
|
|
112
|
+
const raw: Raw = typeof row!.snapshot === 'string' ? (JSON.parse(row!.snapshot) as Raw) : row!.snapshot;
|
|
113
|
+
return {
|
|
114
|
+
at: Number(raw.at),
|
|
115
|
+
members: new Map(raw.members.map((member) => [member.principal, memberOf(member)])),
|
|
116
|
+
grants: new Map(asked.principals.map((principal) => [principal, raw.grants.filter((grant) => grant.principal === principal).map(grantOf)])),
|
|
117
|
+
newestAccess: new Map(raw.access.map((entry) => [entry.subjectPrincipal!, entryOf(entry)])),
|
|
118
|
+
newestPolicy: raw.policy === null ? null : entryOf(raw.policy),
|
|
119
|
+
environments: new Map(raw.environments.map(({ id, ...environment }) => [id, {
|
|
120
|
+
...environment,
|
|
121
|
+
deleted: Boolean(environment.deleted),
|
|
122
|
+
archived: Boolean(environment.archived),
|
|
123
|
+
}])),
|
|
124
|
+
ended: new Set(raw.ended.map((seq) => BigInt(seq))),
|
|
125
|
+
released: Number(raw.released ?? 0),
|
|
126
|
+
head: raw.head === null ? null : {
|
|
127
|
+
nextSeq: BigInt(raw.head.nextSeq),
|
|
128
|
+
headHash: Buffer.from(raw.head.hash, 'hex'),
|
|
129
|
+
last: raw.last === null ? null : { seq: BigInt(raw.last.seq), hash: Buffer.from(raw.last.hash, 'hex') },
|
|
130
|
+
rotation: raw.rotation === null ? null : entryOf(raw.rotation),
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whole numbers come as JSON numbers or text, bytes as hex, as each dialect's query writes them. */
|
|
136
|
+
type Int = number | string;
|
|
137
|
+
|
|
138
|
+
/** An entry's columns, as both dialects' queries write them (`entryJson`). */
|
|
139
|
+
export type RawEntry = {
|
|
140
|
+
seq: Int; author: string; keyId: string; occurredAt: Int; actor: string; action: string; decision: string; code: string | null;
|
|
141
|
+
subjectPrincipal: string | null; projectId: string | null; environmentId: string | null; secretId: string | null;
|
|
142
|
+
secretVersionId: string | null; operationId: string | null; requestId: string | null; sourceIp: string | null;
|
|
143
|
+
relatedSeq: Int | null; metadata: string; prevHash: string; mac: string; hash: string;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
type Raw = {
|
|
147
|
+
at: Int;
|
|
148
|
+
head: { nextSeq: Int; hash: string } | null;
|
|
149
|
+
last: { seq: Int; hash: string } | null;
|
|
150
|
+
rotation: RawEntry | null;
|
|
151
|
+
members: {
|
|
152
|
+
principal: string; status: 'active' | 'removed'; owner: boolean | number; role: string | null; scope: string | null; generation: Int;
|
|
153
|
+
createdAt: Int; createdBy: string; statusChangedAt: Int; statusChangedBy: string; accessSeq: Int; mac: string;
|
|
154
|
+
}[];
|
|
155
|
+
grants: { principal: string; projectId: string | null; environmentId: string | null; environmentSlug: string | null; role: string; expiresAt: Int | null; grantedAt: Int; grantedBy: string }[];
|
|
156
|
+
access: RawEntry[];
|
|
157
|
+
policy: RawEntry | null;
|
|
158
|
+
environments: { id: string; projectId: string; slug: string; owner: string | null; parentId: string | null; deleted: boolean | number; archived: boolean | number }[];
|
|
159
|
+
ended: Int[];
|
|
160
|
+
released: Int | null;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** An entry as the chain's codec reads it, from what `entryJson` wrote. */
|
|
164
|
+
export function entryOf(raw: RawEntry): StoredEntry {
|
|
165
|
+
return {
|
|
166
|
+
...raw,
|
|
167
|
+
seq: BigInt(raw.seq),
|
|
168
|
+
author: raw.author as StoredEntry['author'],
|
|
169
|
+
occurredAt: Number(raw.occurredAt),
|
|
170
|
+
relatedSeq: raw.relatedSeq === null ? null : BigInt(raw.relatedSeq),
|
|
171
|
+
prevHash: Buffer.from(raw.prevHash, 'hex'),
|
|
172
|
+
mac: Buffer.from(raw.mac, 'hex'),
|
|
173
|
+
hash: Buffer.from(raw.hash, 'hex'),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function grantOf(raw: Raw['grants'][number]): GrantRow {
|
|
178
|
+
return { ...raw, expiresAt: raw.expiresAt === null ? null : Number(raw.expiresAt), grantedAt: Number(raw.grantedAt) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function memberOf({ owner, ...raw }: Raw['members'][number]): Member {
|
|
182
|
+
return store.memberOf({
|
|
183
|
+
...raw,
|
|
184
|
+
owner: owner === true || owner === 1,
|
|
185
|
+
generation: Number(raw.generation),
|
|
186
|
+
createdAt: Number(raw.createdAt),
|
|
187
|
+
statusChangedAt: Number(raw.statusChangedAt),
|
|
188
|
+
accessSeq: BigInt(raw.accessSeq),
|
|
189
|
+
mac: Buffer.from(raw.mac, 'hex'),
|
|
190
|
+
});
|
|
191
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -88,10 +88,10 @@ function memberColumns(db: Queryable) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/** A row as stored: `role` and `scope` as their columns hold them. */
|
|
91
|
-
type StoredMember = Omit<Member, 'role' | 'scope'> & { owner: boolean; role: string | null; scope: string | null };
|
|
91
|
+
export type StoredMember = Omit<Member, 'role' | 'scope'> & { owner: boolean; role: string | null; scope: string | null };
|
|
92
92
|
|
|
93
93
|
/** A member as their row says (`storedRole`). */
|
|
94
|
-
function memberOf({ owner, role, scope, ...row }: StoredMember): Member {
|
|
94
|
+
export function memberOf({ owner, role, scope, ...row }: StoredMember): Member {
|
|
95
95
|
return { ...row, ...storedRole({ owner, role, scope }) };
|
|
96
96
|
}
|
|
97
97
|
|
|
@@ -308,38 +308,6 @@ export async function everyProjectHolders(db: Queryable): Promise<string[]> {
|
|
|
308
308
|
|
|
309
309
|
// --- the log --------------------------------------------------------------------
|
|
310
310
|
|
|
311
|
-
/** The action of an entry that releases a key, which the bulk limit counts. */
|
|
312
|
-
export const RELEASE = 'secret.read';
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* How many keys the vault has released to `principal` since `after`: one
|
|
316
|
-
* for an entry about one secret, and as many as it lists for one about
|
|
317
|
-
* several (`secrets`), a read logged once per place.
|
|
318
|
-
*/
|
|
319
|
-
export async function releasesSince(db: Queryable, principal: string, after: number): Promise<number> {
|
|
320
|
-
const [{ n }] = await released(db, { principal, after });
|
|
321
|
-
return Number(n ?? 0);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const released = prepared((db) => {
|
|
325
|
-
const { auditLog } = tablesOf(db);
|
|
326
|
-
const listed = engineOf(db) === 'postgres'
|
|
327
|
-
? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')`
|
|
328
|
-
: sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
|
|
329
|
-
return db
|
|
330
|
-
.select({ n: sql<string | number | null>`SUM(COALESCE(${listed}, 1))` })
|
|
331
|
-
.from(auditLog)
|
|
332
|
-
.where(
|
|
333
|
-
and(
|
|
334
|
-
eq(auditLog.author, 'vault'),
|
|
335
|
-
eq(auditLog.actor, sql.placeholder('principal')),
|
|
336
|
-
eq(auditLog.action, RELEASE),
|
|
337
|
-
eq(auditLog.decision, 'allow'),
|
|
338
|
-
gt(auditLog.occurredAt, sql.placeholder('after')),
|
|
339
|
-
),
|
|
340
|
-
);
|
|
341
|
-
});
|
|
342
|
-
|
|
343
311
|
/** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
|
|
344
312
|
function stored(rows: object[]): StoredEntry[] {
|
|
345
313
|
return rows as StoredEntry[];
|