@coffre/vault 0.5.0 → 0.6.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/README.md +1 -1
- package/dist/cloudflare.d.ts +5 -8
- package/dist/cloudflare.js +2 -2
- package/dist/{index-CGVAd1mz.d.ts → index-CwsWOH6j.d.ts} +27 -5
- 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-1yVV2L0_.js → src-CQJS3OZZ.js} +10 -10
- package/dist/{vault-CP8yU8xq.js → vault-ihE3Yc4a.js} +247 -189
- package/package.json +3 -3
- package/src/cloudflare.ts +1 -1
- package/src/config.ts +45 -19
- package/src/store.ts +20 -35
- package/src/vault.ts +244 -138
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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/
|
|
40
|
-
"@coffre/
|
|
39
|
+
"@coffre/db": "0.6.0",
|
|
40
|
+
"@coffre/core": "0.6.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "26.1.1",
|
package/src/cloudflare.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* export default vault((env: Env) => ({
|
|
10
10
|
* database: postgres(env.HYPERDRIVE),
|
|
11
11
|
* kek: { id: env.VAULT_KEY_ID, key: env.VAULT_KEY }, // or awsKms({ keyArn, credentials }), with a signingKey
|
|
12
|
-
*
|
|
12
|
+
* owners: ['admin@acme.example'],
|
|
13
13
|
* }));
|
|
14
14
|
*
|
|
15
15
|
* Any number of isolates run it side by side: every decision locks what it
|
package/src/config.ts
CHANGED
|
@@ -15,8 +15,12 @@ export const DEFAULT_BULK_LIMIT: BulkLimit = { count: 1000, windowMs: 15 * 60_00
|
|
|
15
15
|
/** The vault as it runs: keys decoded, emails checked. */
|
|
16
16
|
export type ResolvedVaultConfig = {
|
|
17
17
|
keks: KekRegistry;
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Emails, lowercased: the Owners of the whole instance its deployment
|
|
20
|
+
* names, made one at each start and at sign-in (`#declare` in vault.ts),
|
|
21
|
+
* and never demoted or removed while named.
|
|
22
|
+
*/
|
|
23
|
+
owners: readonly string[];
|
|
20
24
|
/**
|
|
21
25
|
* The seeds of the vault's own keys, which MAC its log entries, seal member
|
|
22
26
|
* rows and sign checkpoints: the first signs; every one verifies what it
|
|
@@ -33,13 +37,13 @@ export type ResolvedVaultConfig = {
|
|
|
33
37
|
export type Kek = { id: string; key: string } | KekProvider;
|
|
34
38
|
|
|
35
39
|
/**
|
|
36
|
-
* What a deployment writes: every key coffre has, and who
|
|
37
|
-
*
|
|
40
|
+
* What a deployment writes: every key coffre has, and who owns the
|
|
41
|
+
* instance whatever its database says. The app holds none of it.
|
|
38
42
|
*
|
|
39
43
|
* {
|
|
40
44
|
* kek: awsKms({ keyArn: env.KMS_KEY_ARN, credentials: { … } }),
|
|
41
45
|
* previousKeks: [{ id: 'vault-2025-01-10-k7q2xm', key: env.OLD_VAULT_KEY }],
|
|
42
|
-
*
|
|
46
|
+
* owners: ['admin@acme.example'],
|
|
43
47
|
* signingKey: env.SIGNING_KEY, // required with a key service; derived from a local KEK otherwise
|
|
44
48
|
* }
|
|
45
49
|
*/
|
|
@@ -51,8 +55,6 @@ export type VaultConfig = {
|
|
|
51
55
|
* on, and verifying what the vault wrote under them before the rotation.
|
|
52
56
|
*/
|
|
53
57
|
previousKeks?: readonly Kek[];
|
|
54
|
-
/** At least one email: the only way into a fresh instance, and the only members nobody can remove. */
|
|
55
|
-
rootAdmins: readonly string[];
|
|
56
58
|
/**
|
|
57
59
|
* 32 random bytes, base64: the seed of the vault's own keys, which MAC its
|
|
58
60
|
* log entries, seal member rows and sign checkpoints. Leave it out with a
|
|
@@ -62,7 +64,31 @@ export type VaultConfig = {
|
|
|
62
64
|
signingKey?: string;
|
|
63
65
|
/** At most `count` data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
|
|
64
66
|
bulkLimit?: { count: number; windowMinutes: number };
|
|
65
|
-
};
|
|
67
|
+
} & Owners;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* At least one email, each an Owner of the whole instance: made one when
|
|
71
|
+
* the vault starts, and when they sign in, whatever their row says, and
|
|
72
|
+
* refused to anyone who would demote or remove them, themselves included.
|
|
73
|
+
* The way into a fresh instance, and back into one whose Owners are all
|
|
74
|
+
* locked out: add an email, deploy, sign in. Taking an email off makes
|
|
75
|
+
* them an Owner like any other, whom an Owner may demote. `rootAdmins`, as
|
|
76
|
+
* 0.5 named it, and `bootstrapOwners` are read until 0.7.
|
|
77
|
+
*/
|
|
78
|
+
type Owners =
|
|
79
|
+
| { owners: readonly string[]; bootstrapOwners?: never; rootAdmins?: never }
|
|
80
|
+
| {
|
|
81
|
+
/** @deprecated `owners`, read until 0.7. */
|
|
82
|
+
bootstrapOwners: readonly string[];
|
|
83
|
+
owners?: never;
|
|
84
|
+
rootAdmins?: never;
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
/** @deprecated 0.5's name for `owners`, read until 0.7: a root admin is an Owner now. */
|
|
88
|
+
rootAdmins: readonly string[];
|
|
89
|
+
owners?: never;
|
|
90
|
+
bootstrapOwners?: never;
|
|
91
|
+
};
|
|
66
92
|
|
|
67
93
|
function key32(value: string, what: string): Buffer {
|
|
68
94
|
const raw = Buffer.from(value, 'base64');
|
|
@@ -113,7 +139,7 @@ export function resolveVaultConfig(config: VaultConfig): ResolvedVaultConfig {
|
|
|
113
139
|
if (twice !== undefined) throw new Error(`two vault keys share an id: ${twice}`);
|
|
114
140
|
return {
|
|
115
141
|
keks: new KekRegistry(current, previous),
|
|
116
|
-
|
|
142
|
+
owners: checkOwners(config.owners ?? config.bootstrapOwners ?? config.rootAdmins ?? []),
|
|
117
143
|
signingKeys: signingKeys(config.signingKey, keks),
|
|
118
144
|
bulkLimit: config.bulkLimit === undefined ? DEFAULT_BULK_LIMIT : checkBulkLimit(config.bulkLimit),
|
|
119
145
|
};
|
|
@@ -178,18 +204,18 @@ function isHumanEmail(value: string): boolean {
|
|
|
178
204
|
}
|
|
179
205
|
|
|
180
206
|
/**
|
|
181
|
-
*
|
|
182
|
-
* mode: they are the only way into a fresh
|
|
183
|
-
*
|
|
207
|
+
* The Owners the deployment names, lowercased the way they are compared.
|
|
208
|
+
* At least one, in every mode: they are the only way into a fresh
|
|
209
|
+
* instance, and back into one whose Owners are locked out.
|
|
184
210
|
*/
|
|
185
|
-
export function
|
|
186
|
-
const
|
|
187
|
-
if (
|
|
188
|
-
throw new Error('
|
|
211
|
+
export function checkOwners(emails: readonly string[]): string[] {
|
|
212
|
+
const owners = [...new Set(emails.map((entry) => entry.trim()).filter((entry) => entry.length > 0))];
|
|
213
|
+
if (owners.length === 0) {
|
|
214
|
+
throw new Error('owners must name at least one email, or nobody can manage coffre');
|
|
189
215
|
}
|
|
190
|
-
const invalid =
|
|
216
|
+
const invalid = owners.find((entry) => !isHumanEmail(entry));
|
|
191
217
|
if (invalid) {
|
|
192
|
-
throw new Error(`
|
|
218
|
+
throw new Error(`owners must be human email identities; invalid: ${invalid}`);
|
|
193
219
|
}
|
|
194
|
-
return
|
|
220
|
+
return [...new Set(owners.map((entry) => entry.toLowerCase()))];
|
|
195
221
|
}
|
package/src/store.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { EVERYWHERE, type InstanceRole, type Scope } from '@coffre/core/access';
|
|
1
|
+
import { EVERYWHERE, ownsInstance, type InstanceRole, type Scope } from '@coffre/core/access';
|
|
2
2
|
import type { Author, StoredEntry } from '@coffre/core/audit';
|
|
3
3
|
import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/vault';
|
|
4
4
|
import { tablesOf, type Queryable, type Transaction } from '@coffre/db';
|
|
5
5
|
import { clockMillis, engineOf, forUpdate, tombstone, truth } from '@coffre/db/dialect';
|
|
6
6
|
import { readGrants, roleColumns, storedRole, type GrantRow } from '@coffre/db/grants';
|
|
7
7
|
import { entryColumns } from '@coffre/db/log';
|
|
8
|
-
import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
|
|
8
|
+
import { and, asc, count, desc, eq, gt, gte, inArray, isNull, like, lt, lte, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Every query the vault makes, and the only code in it that holds them.
|
|
@@ -48,7 +48,8 @@ export type Place = Pick<GrantRow, 'projectId' | 'environmentId' | 'environmentS
|
|
|
48
48
|
|
|
49
49
|
/** A grant as stored, which `@coffre/db/grants` reads and writes. */
|
|
50
50
|
export type { GrantRow };
|
|
51
|
-
export { accessEntriesAbout, insertGrant } from '@coffre/db/grants';
|
|
51
|
+
export { accessEntriesAbout, insertGrant, liveProjects } from '@coffre/db/grants';
|
|
52
|
+
export { newestVaultEntries } from '@coffre/db/log';
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
55
|
* On Postgres, fail a lock wait in this transaction after `ms`, rather than
|
|
@@ -110,6 +111,22 @@ export async function allMembers(db: Queryable): Promise<Member[]> {
|
|
|
110
111
|
return ((await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))) as StoredMember[]).map(memberOf);
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The rows that say their member is an active Owner of the whole instance
|
|
116
|
+
* (`ownsInstance` in @coffre/core/access), as stored: a row changed around
|
|
117
|
+
* the vault says so too, which the caller checks. The scope is read as the
|
|
118
|
+
* row's MAC reads it, parsed: any way of writing everywhere is everywhere.
|
|
119
|
+
*/
|
|
120
|
+
export async function instanceOwners(db: Queryable): Promise<Member[]> {
|
|
121
|
+
const { vaultMembers } = tablesOf(db);
|
|
122
|
+
const rows = await db
|
|
123
|
+
.select(memberColumns(db))
|
|
124
|
+
.from(vaultMembers)
|
|
125
|
+
.where(and(eq(vaultMembers.status, 'active'), eq(vaultMembers.role, 'owner')))
|
|
126
|
+
.orderBy(asc(vaultMembers.principal));
|
|
127
|
+
return (rows as StoredMember[]).map(memberOf).filter(ownsInstance);
|
|
128
|
+
}
|
|
129
|
+
|
|
113
130
|
/**
|
|
114
131
|
* Lock these members' rows for the rest of the transaction, in one order
|
|
115
132
|
* everywhere, and read them: those that have none are not in the map. Every
|
|
@@ -258,19 +275,6 @@ export async function places(
|
|
|
258
275
|
};
|
|
259
276
|
}
|
|
260
277
|
|
|
261
|
-
/** Every project not deleted, with its environments not deleted: what a grant on every project of 0.4 reached. */
|
|
262
|
-
export async function liveProjects(db: Queryable): Promise<{ id: string; environments: { id: string; slug: string }[] }[]> {
|
|
263
|
-
const { projects, environments } = tablesOf(db);
|
|
264
|
-
const [found, inside] = await Promise.all([
|
|
265
|
-
db.select({ id: projects.id, deleted: truth(tombstone(projects.slug)) }).from(projects).orderBy(asc(projects.id)),
|
|
266
|
-
db.select({ id: environments.id, projectId: environments.projectId, slug: environments.slug, deleted: truth(tombstone(environments.slug)) }).from(environments).orderBy(asc(environments.id)),
|
|
267
|
-
]);
|
|
268
|
-
return found.filter((project) => !project.deleted).map((project) => ({
|
|
269
|
-
id: project.id,
|
|
270
|
-
environments: inside.filter((environment) => environment.projectId === project.id && !environment.deleted).map(({ id, slug }) => ({ id, slug })),
|
|
271
|
-
}));
|
|
272
|
-
}
|
|
273
|
-
|
|
274
278
|
/** Who holds a grant on every project of 0.4, lapsed or not, which the vault replaces (`#convert`). */
|
|
275
279
|
export async function everyProjectHolders(db: Queryable): Promise<string[]> {
|
|
276
280
|
const { vaultGrants } = tablesOf(db);
|
|
@@ -464,25 +468,6 @@ export async function latestVaultEntry(db: Queryable, actions: readonly string[]
|
|
|
464
468
|
return row === undefined ? undefined : stored([row])[0];
|
|
465
469
|
}
|
|
466
470
|
|
|
467
|
-
/** Up to `limit` of the vault's allowed entries of `action` before `before` (the newest when undefined), newest first. */
|
|
468
|
-
export async function newestVaultEntries(db: Queryable, action: string, limit: number, before?: bigint): Promise<StoredEntry[]> {
|
|
469
|
-
const { auditLog } = tablesOf(db);
|
|
470
|
-
const rows = await db
|
|
471
|
-
.select(entryColumns(db))
|
|
472
|
-
.from(auditLog)
|
|
473
|
-
.where(
|
|
474
|
-
and(
|
|
475
|
-
eq(auditLog.author, 'vault'),
|
|
476
|
-
eq(auditLog.action, action),
|
|
477
|
-
eq(auditLog.decision, 'allow'),
|
|
478
|
-
before === undefined ? undefined : lt(auditLog.seq, before),
|
|
479
|
-
),
|
|
480
|
-
)
|
|
481
|
-
.orderBy(desc(auditLog.seq))
|
|
482
|
-
.limit(limit);
|
|
483
|
-
return stored(rows);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
471
|
/** The vault's newest allowed access entry about each member who has one. */
|
|
487
472
|
export async function newestAccessEntries(db: Queryable): Promise<Map<string, StoredEntry>> {
|
|
488
473
|
const { auditLog } = tablesOf(db);
|