@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/src/vault.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
|
3
3
|
import {
|
|
4
4
|
allows,
|
|
5
5
|
assignableToEnvironment,
|
|
6
|
-
convertEveryProjectGrants,
|
|
7
6
|
EVERYWHERE,
|
|
8
7
|
givesService,
|
|
9
8
|
grantKind,
|
|
@@ -15,6 +14,7 @@ import {
|
|
|
15
14
|
mayManageAccess,
|
|
16
15
|
normalScope,
|
|
17
16
|
NOTHING,
|
|
17
|
+
ownsInstance,
|
|
18
18
|
runsInstance,
|
|
19
19
|
setsUpServices,
|
|
20
20
|
type Holdings,
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
type AccessChange,
|
|
50
50
|
type AccessFault,
|
|
51
51
|
type AdmitInput,
|
|
52
|
+
type Admitted,
|
|
52
53
|
type Checkpoint,
|
|
53
54
|
type CheckpointKey,
|
|
54
55
|
type EndReferencesInput,
|
|
@@ -77,7 +78,7 @@ import {
|
|
|
77
78
|
} from '@coffre/core/vault';
|
|
78
79
|
import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
79
80
|
import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
|
|
80
|
-
import { newestAccess, reportStands } from '@coffre/db/grants';
|
|
81
|
+
import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
|
|
81
82
|
import { appendEntries, LOOK_BACK, lockLogHead, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
82
83
|
|
|
83
84
|
import { KeyAccounting } from './accounting.ts';
|
|
@@ -120,8 +121,6 @@ export type PreparedVault = {
|
|
|
120
121
|
options: Required<VaultOptions>;
|
|
121
122
|
/** How far the log is verified; `verifyChain` in log.ts. The furthest any call got to. */
|
|
122
123
|
verified: Anchor;
|
|
123
|
-
/** Root admins known to have a member row; rows are never deleted. */
|
|
124
|
-
rooted: Set<string>;
|
|
125
124
|
/** What member rows are sealed under now; rows.ts. */
|
|
126
125
|
rowKey: Buffer;
|
|
127
126
|
/** Each signing key's row key: those after the first vouch for rows only until `since`, when the rows are sealed again. */
|
|
@@ -136,7 +135,11 @@ export type PreparedVault = {
|
|
|
136
135
|
kekChecked: boolean;
|
|
137
136
|
/** Why not, once decided that they do not. */
|
|
138
137
|
wrongKek: string | null;
|
|
139
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* Whether this process has done what it does once, at its start
|
|
140
|
+
* (`#convert`): the grants on every project of 0.4 there were replaced,
|
|
141
|
+
* then everyone the deployment names made an Owner (`#declare`).
|
|
142
|
+
*/
|
|
140
143
|
converted: boolean;
|
|
141
144
|
};
|
|
142
145
|
|
|
@@ -146,7 +149,6 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
|
|
|
146
149
|
...(await keysOf(config.signingKeys)),
|
|
147
150
|
options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0) },
|
|
148
151
|
verified: UNVERIFIED,
|
|
149
|
-
rooted: new Set(),
|
|
150
152
|
reported: new Set(),
|
|
151
153
|
since: null,
|
|
152
154
|
superseded: null,
|
|
@@ -182,7 +184,7 @@ const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
|
182
184
|
const PERSON = /^user:/;
|
|
183
185
|
const SERVICE = /^token:/;
|
|
184
186
|
|
|
185
|
-
/** Who acts for the vault itself, as when it
|
|
187
|
+
/** Who acts for the vault itself, as when it makes an Owner its deployment names. */
|
|
186
188
|
const VAULT_ACTOR = 'system:vault';
|
|
187
189
|
|
|
188
190
|
// A KEK's check: a known value, the size of a data key, wrapped under it in
|
|
@@ -215,6 +217,14 @@ const DEFAULT_SETTINGS: Settings = { serviceAccounts: EVERYWHERE };
|
|
|
215
217
|
/** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
|
|
216
218
|
const CLOSED_SETTINGS: Settings = { serviceAccounts: { projects: { only: [] }, environments: 'all' } };
|
|
217
219
|
|
|
220
|
+
/** Why a change to who is an Owner, scoped or not, is refused to anyone else: Owners make and unmake Owners. */
|
|
221
|
+
const OWNERS_ONLY = 'only an Owner of the whole instance makes someone an Owner, or changes or removes one';
|
|
222
|
+
|
|
223
|
+
/** Why nobody demotes or removes a person the deployment names (`owners`), themselves included. */
|
|
224
|
+
function declaredOwner(principal: string): string {
|
|
225
|
+
return `the deployment names ${principal.slice('user:'.length)} an Owner (owners, in the vault's configuration): take them off that list and deploy first`;
|
|
226
|
+
}
|
|
227
|
+
|
|
218
228
|
/** Why a person's change to a service account is refused. */
|
|
219
229
|
const NOT_THEIRS = 'a person gives a service account at most what they hold, where the instance lets them set one up, and only to one whose every grant they hold';
|
|
220
230
|
|
|
@@ -270,7 +280,7 @@ const MESSAGES: Record<RefusalCode, string> = {
|
|
|
270
280
|
deleted: 'this project or environment was deleted',
|
|
271
281
|
ended: 'the reference read through was ended',
|
|
272
282
|
not_allowed: 'not allowed to change this',
|
|
273
|
-
|
|
283
|
+
last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
|
|
274
284
|
invalid: 'not something the rules allow',
|
|
275
285
|
log_broken: 'the vault log does not hold from the last checkpoint',
|
|
276
286
|
wrong_kek: "this vault's key does not open the data it holds",
|
|
@@ -573,7 +583,8 @@ class VaultService implements Vault {
|
|
|
573
583
|
// database; calls that race settle it alike, and a rotation they race
|
|
574
584
|
// to write goes in once (`#rotate`).
|
|
575
585
|
if (prepared.since === null && prepared.superseded === null) await this.#settle();
|
|
576
|
-
|
|
586
|
+
// A log with no entry of the vault's holds nothing to convert, and stays empty until someone acts.
|
|
587
|
+
if (prepared.superseded === null && prepared.since !== null && !prepared.converted) await this.#convert();
|
|
577
588
|
return prepared.superseded;
|
|
578
589
|
}
|
|
579
590
|
|
|
@@ -591,14 +602,18 @@ class VaultService implements Vault {
|
|
|
591
602
|
* genuine row put back). `reportStands` is the rule, which `coffre
|
|
592
603
|
* migrate` applies too, holding no key, to every entry in the vault's
|
|
593
604
|
* name: it lists and marks a member so reported, forged or not.
|
|
594
|
-
* Removing them clears both.
|
|
605
|
+
* Removing them clears both. Then everyone the deployment names becomes
|
|
606
|
+
* an Owner (`#declare`): after, so that what their grants become is what
|
|
607
|
+
* `coffre migrate` says, by the same rule over the same rows
|
|
608
|
+
* (`everyProjectConversion`), which cannot tell who the configuration
|
|
609
|
+
* names. Until both finish, each call does again what is left, a few
|
|
610
|
+
* reads once nothing is.
|
|
595
611
|
*/
|
|
596
612
|
async #convert(): Promise<void> {
|
|
597
613
|
for (const principal of await store.everyProjectHolders(this.#db)) {
|
|
598
|
-
if (this.#isRootAdmin(principal)) continue;
|
|
599
614
|
await this.#decideNow([principal], (d) => this.#replaceEveryProject(d, principal));
|
|
600
615
|
}
|
|
601
|
-
this.#prepared.converted =
|
|
616
|
+
this.#prepared.converted = await this.#declare(this.#config.owners.map((email) => `user:${email}`));
|
|
602
617
|
}
|
|
603
618
|
|
|
604
619
|
async #replaceEveryProject(d: Decision, principal: string): Promise<void> {
|
|
@@ -611,15 +626,7 @@ class VaultService implements Vault {
|
|
|
611
626
|
// The projects there are now, read under the log's head, which a deletion holds.
|
|
612
627
|
await lockLogHead(d.tx);
|
|
613
628
|
const before = roleOf(principal, row);
|
|
614
|
-
const conversion =
|
|
615
|
-
person: principal.startsWith('user:'),
|
|
616
|
-
role: before.role,
|
|
617
|
-
everyProject: everyProject.filter((grant) => live(grant, d.at)).map((grant) => ({ environmentSlug: grant.environmentSlug, role: grant.role as Role, expiresAt: grant.expiresAt })),
|
|
618
|
-
grants: subject.stored
|
|
619
|
-
.filter((grant) => grantKind(grant) !== null && live(grant, d.at))
|
|
620
|
-
.map((grant) => ({ projectId: grant.projectId, environmentId: grant.environmentId, role: grant.role as Role, expiresAt: grant.expiresAt })),
|
|
621
|
-
projects: await store.liveProjects(d.tx),
|
|
622
|
-
});
|
|
629
|
+
const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await store.liveProjects(d.tx));
|
|
623
630
|
const after = { role: conversion.role, scope: conversion.scope };
|
|
624
631
|
const replacedBy = { role: after.role, scope: after.scope, grants: conversion.grants.length };
|
|
625
632
|
d.grants.set(principal, [...subject.stored]);
|
|
@@ -934,7 +941,6 @@ class VaultService implements Vault {
|
|
|
934
941
|
}
|
|
935
942
|
|
|
936
943
|
async #refuseVersions(principal: string, code: 'bad_claim' | 'deleted', entries: NewEntry[]): Promise<Outcome<never>> {
|
|
937
|
-
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
938
944
|
return this.#decide([principal], async () => {
|
|
939
945
|
throw new Refused(refusal(code, MESSAGES[code]), entries);
|
|
940
946
|
});
|
|
@@ -995,7 +1001,6 @@ class VaultService implements Vault {
|
|
|
995
1001
|
throw new Refused(refusal('wrong_kek', wrongKek), secrets.map((secret) => entry(secret, 'deny', 'wrong_kek')));
|
|
996
1002
|
});
|
|
997
1003
|
}
|
|
998
|
-
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
999
1004
|
const check = async (d: Decision) => {
|
|
1000
1005
|
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
|
|
1001
1006
|
// One read of the environments the decision is about, as the store has them, not as the app sent them:
|
|
@@ -1175,7 +1180,6 @@ class VaultService implements Vault {
|
|
|
1175
1180
|
}),
|
|
1176
1181
|
};
|
|
1177
1182
|
};
|
|
1178
|
-
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1179
1183
|
return this.#decide([principal], async (d) => {
|
|
1180
1184
|
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1181
1185
|
// Both sides as the store has them: a grant on a slug matches there, and neither side may be deleted.
|
|
@@ -1250,7 +1254,6 @@ class VaultService implements Vault {
|
|
|
1250
1254
|
...traced(input),
|
|
1251
1255
|
}),
|
|
1252
1256
|
});
|
|
1253
|
-
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1254
1257
|
if (sealed.some((seal) => seal === null)) {
|
|
1255
1258
|
return this.#decide([principal], async () => {
|
|
1256
1259
|
throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), vias.map((via, i) => entry(via, sealed[i]!, 'deny', 'bad_claim')));
|
|
@@ -1307,69 +1310,116 @@ class VaultService implements Vault {
|
|
|
1307
1310
|
});
|
|
1308
1311
|
}
|
|
1309
1312
|
|
|
1310
|
-
// ---
|
|
1313
|
+
// --- Owners of the whole instance -------------------------------------------------
|
|
1311
1314
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1315
|
+
/** Whether the deployment names `principal` an Owner of the whole instance (`owners`). */
|
|
1316
|
+
#listed(principal: string): boolean {
|
|
1317
|
+
return principal.startsWith('user:') && this.#config.owners.includes(principal.slice('user:'.length));
|
|
1314
1318
|
}
|
|
1315
1319
|
|
|
1316
1320
|
/**
|
|
1317
|
-
*
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
1321
|
-
*
|
|
1322
|
-
* anyone a root admin; the configuration does. Rows are never deleted, so
|
|
1323
|
-
* one read finds it made already, as on every fresh isolate but the first,
|
|
1324
|
-
* without queueing on the log's lock.
|
|
1321
|
+
* An Owner of the whole instance other than `except`, as their row says
|
|
1322
|
+
* and its check holds (`#integrity`), or null when there is none. A row
|
|
1323
|
+
* changed around the vault owns nothing, and is reported. Read under the
|
|
1324
|
+
* log's head, which every change to a member takes before it writes, the
|
|
1325
|
+
* answer stands until the reader commits.
|
|
1325
1326
|
*/
|
|
1326
|
-
async #
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
this.#
|
|
1330
|
-
return;
|
|
1327
|
+
async #anOwner(db: Queryable, reports: NewEntry[], except: string | null = null): Promise<string | null> {
|
|
1328
|
+
for (const row of await store.instanceOwners(db)) {
|
|
1329
|
+
if (row.principal === except) continue;
|
|
1330
|
+
if ((await this.#integrity(db, row.principal, row, await store.grants(db, row.principal), reports)) === null) return row.principal;
|
|
1331
1331
|
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
};
|
|
1357
|
-
|
|
1332
|
+
return null;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* Make each of `principals`, people the deployment names (`owners`), an
|
|
1337
|
+
* Owner of the whole instance, whoever else is one: asked about all of
|
|
1338
|
+
* them at the start of each process (`#convert`), and at `access` about
|
|
1339
|
+
* one who owns nothing, which is a new instance's first sign-in, before
|
|
1340
|
+
* the vault has written to its log, and the way back for someone whose
|
|
1341
|
+
* row was changed around the vault since. An unlocked read of each finds
|
|
1342
|
+
* who is one already, the quick way out: a hint, never a report, and
|
|
1343
|
+
* never `#record`, which would settle the vault, and so convert, again;
|
|
1344
|
+
* one it cannot tell, a row changing under it as another process makes
|
|
1345
|
+
* them an Owner, is due. Then one decision, under the others' rows, reads
|
|
1346
|
+
* again (`#makeOwner`). One whose row the log never admitted stays as it is,
|
|
1347
|
+
* and is asked about again at each of their requests. Whether it ran.
|
|
1348
|
+
*/
|
|
1349
|
+
async #declare(principals: readonly string[]): Promise<boolean> {
|
|
1350
|
+
const at = await this.#now(this.#db);
|
|
1351
|
+
const due: string[] = [];
|
|
1352
|
+
for (const principal of principals) {
|
|
1353
|
+
const subject = await this.#standing(this.#db, principal, await store.member(this.#db, principal), at, []).catch((error: unknown) => {
|
|
1354
|
+
if (error instanceof Retry) return null;
|
|
1355
|
+
throw error;
|
|
1356
|
+
});
|
|
1357
|
+
if (!(subject?.status === 'active' && ownsInstance(subject.live))) due.push(principal);
|
|
1358
|
+
}
|
|
1359
|
+
if (due.length === 0) return true;
|
|
1360
|
+
const decided = await this.#decideNow(due, async (d) => {
|
|
1361
|
+
for (const principal of due) await this.#makeOwner(d, principal, 'owners');
|
|
1358
1362
|
});
|
|
1359
|
-
|
|
1363
|
+
return decided.ok;
|
|
1360
1364
|
}
|
|
1361
1365
|
|
|
1366
|
+
/**
|
|
1367
|
+
* Refuse a change that would leave `principal`, an Owner of the whole
|
|
1368
|
+
* instance, no longer one, unless another is. Counted under the log's
|
|
1369
|
+
* head: two changes at once, each demoting or removing a different
|
|
1370
|
+
* Owner, lock different rows, and only the head puts one after the other.
|
|
1371
|
+
*/
|
|
1372
|
+
async #keepAnOwner(d: Decision, actor: string, principal: string, refused: (code: RefusalCode, message?: string) => Refused): Promise<void> {
|
|
1373
|
+
await lockLogHead(d.tx);
|
|
1374
|
+
if ((await this.#anOwner(d.tx, d.reports, principal)) !== null) return;
|
|
1375
|
+
throw refused('last_owner', actor === principal
|
|
1376
|
+
? "you're the last Owner of the whole instance: make someone else an Owner first"
|
|
1377
|
+
: `${principal.slice('user:'.length)} is the last Owner of the whole instance: make someone else an Owner first`);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* Make `principal`, a person, an Owner of the whole instance, as the
|
|
1382
|
+
* vault, for `reason`: admitted, brought back, or given the role, the
|
|
1383
|
+
* grants they hold kept. One whose row fails its check is started over
|
|
1384
|
+
* first, as removing them would; one the log never admitted, whose row was
|
|
1385
|
+
* written around the vault, is left as it is. Whether it made them one.
|
|
1386
|
+
*/
|
|
1387
|
+
async #makeOwner(d: Decision, principal: string, reason: 'owners'): Promise<boolean> {
|
|
1388
|
+
const row = d.members.get(principal);
|
|
1389
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1390
|
+
if (subject.status === 'active' && ownsInstance(subject.live)) return false;
|
|
1391
|
+
const tampered = subject.status === 'tampered';
|
|
1392
|
+
if (tampered && (await this.#logged(d.tx, principal)) === undefined) return false;
|
|
1393
|
+
const correlation = { operationId: randomUUID() };
|
|
1394
|
+
const owner = { role: 'owner' as const, scope: EVERYWHERE };
|
|
1395
|
+
d.grants.set(principal, [...subject.stored]);
|
|
1396
|
+
d.touched.add(principal);
|
|
1397
|
+
if (tampered) await this.#startOver(d, VAULT_ACTOR, principal, row, subject.fault!, correlation, (code, message) => new Refused(refusal(code, message ?? MESSAGES[code]), []));
|
|
1398
|
+
if (row === undefined && !tampered) {
|
|
1399
|
+
d.log.push(accessEntry(VAULT_ACTOR, 'member.add', principal, 'allow', correlation, { ...owner, reason }));
|
|
1400
|
+
d.writes.push((at) => store.insertMember(d.tx, {
|
|
1401
|
+
principal, status: 'active', ...owner, generation: 0, createdAt: at, createdBy: VAULT_ACTOR, statusChangedAt: at, statusChangedBy: VAULT_ACTOR, ...UNSEALED,
|
|
1402
|
+
}));
|
|
1403
|
+
} else if (tampered || row?.status === 'removed') {
|
|
1404
|
+
// Coming back keeps the generation a removal, or the start over, moved on to.
|
|
1405
|
+
d.log.push(accessEntry(VAULT_ACTOR, 'member.restore', principal, 'allow', correlation, { ...owner, reason }));
|
|
1406
|
+
d.writes.push((at) => store.updateMember(d.tx, principal, { status: 'active', ...owner, statusChangedAt: at, statusChangedBy: VAULT_ACTOR }));
|
|
1407
|
+
} else {
|
|
1408
|
+
d.log.push(accessEntry(VAULT_ACTOR, 'member.role', principal, 'allow', correlation, { ...owner, previousRole: row!.role, previousScope: row!.scope, reason }));
|
|
1409
|
+
d.writes.push(() => store.updateMember(d.tx, principal, owner));
|
|
1410
|
+
}
|
|
1411
|
+
return true;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
// --- who holds what -----------------------------------------------------------
|
|
1416
|
+
|
|
1362
1417
|
/**
|
|
1363
1418
|
* What `principal` holds, their row checked first (`#integrity`): a row
|
|
1364
|
-
* that fails holds nothing, and is `tampered`.
|
|
1365
|
-
* configuration, which no row can change.
|
|
1419
|
+
* that fails holds nothing, and is `tampered`.
|
|
1366
1420
|
*/
|
|
1367
1421
|
async #standing(db: Queryable, principal: string, row: Member | undefined, at: number, reports: NewEntry[]): Promise<Standing> {
|
|
1368
1422
|
const none = NOTHING;
|
|
1369
|
-
if (this.#isRootAdmin(principal)) {
|
|
1370
|
-
const root: Holdings = { isRootAdmin: true, role: 'owner', scope: EVERYWHERE, grants: [] };
|
|
1371
|
-
return { principal, status: 'active', live: root, all: root, fault: null, stored: [] };
|
|
1372
|
-
}
|
|
1373
1423
|
const grants = row === undefined ? [] : await store.grants(db, principal);
|
|
1374
1424
|
const fault = await this.#integrity(db, principal, row, grants, reports);
|
|
1375
1425
|
if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
|
|
@@ -1380,23 +1430,19 @@ class VaultService implements Vault {
|
|
|
1380
1430
|
return {
|
|
1381
1431
|
principal,
|
|
1382
1432
|
status: 'active',
|
|
1383
|
-
live: {
|
|
1384
|
-
all: {
|
|
1433
|
+
live: { role, scope, grants: held.filter((grant) => live(grant, at)) },
|
|
1434
|
+
all: { role, scope, grants: held },
|
|
1385
1435
|
fault,
|
|
1386
1436
|
stored: grants,
|
|
1387
1437
|
};
|
|
1388
1438
|
}
|
|
1389
1439
|
|
|
1390
1440
|
#access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean): Access {
|
|
1391
|
-
if (this.#isRootAdmin(principal)) {
|
|
1392
|
-
return { principal, status: 'active', generation: row?.generation ?? 0, isRootAdmin: true, role: 'owner', scope: EVERYWHERE, grants: [], since: null, by: null };
|
|
1393
|
-
}
|
|
1394
1441
|
const active = PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
|
|
1395
1442
|
return {
|
|
1396
1443
|
principal,
|
|
1397
1444
|
status: tampered ? 'tampered' : !PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
|
|
1398
1445
|
generation: row?.generation ?? 0,
|
|
1399
|
-
isRootAdmin: false,
|
|
1400
1446
|
...(active ? roleOf(principal, row) : { role: 'member', scope: EVERYWHERE }),
|
|
1401
1447
|
grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
|
|
1402
1448
|
since: row ? iso(row.statusChangedAt) : null,
|
|
@@ -1408,14 +1454,26 @@ class VaultService implements Vault {
|
|
|
1408
1454
|
* What `principal` holds now. Read without locks, the fast way; a row that
|
|
1409
1455
|
* seems to fail its check is read again in one snapshot before anyone is
|
|
1410
1456
|
* called tampered, since a change committed between two of the reads
|
|
1411
|
-
* looks like one.
|
|
1457
|
+
* looks like one. A person the deployment names (`owners`) who owns
|
|
1458
|
+
* nothing becomes an Owner of the whole instance first (`#declare`). A
|
|
1459
|
+
* vault that holds no key the log's newest entries are under, given the
|
|
1460
|
+
* wrong vault key, cannot tell a row it cannot check from one changed
|
|
1461
|
+
* around it: it says so, an outage, rather than call the member tampered.
|
|
1462
|
+
* A row it can check still answers, so a forged entry that stops it
|
|
1463
|
+
* writing leaves the Owners able to sign in and find it.
|
|
1412
1464
|
*/
|
|
1413
1465
|
async access(principal: string): Promise<Access> {
|
|
1414
|
-
await this.#settled();
|
|
1415
|
-
|
|
1466
|
+
const superseded = await this.#settled();
|
|
1467
|
+
const access = await this.#read(principal, superseded);
|
|
1468
|
+
if (superseded !== null || !this.#listed(principal) || (access.status === 'active' && ownsInstance(access))) return access;
|
|
1469
|
+
return (await this.#declare([principal])) ? this.#read(principal, superseded) : access;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** `access`, as the rows are now. */
|
|
1473
|
+
async #read(principal: string, superseded: string | null): Promise<Access> {
|
|
1416
1474
|
const read = async (db: Queryable, reports: NewEntry[]) => {
|
|
1417
1475
|
const [row, held, at] = await Promise.all([store.member(db, principal), store.grants(db, principal), this.#now(db)]);
|
|
1418
|
-
const fault =
|
|
1476
|
+
const fault = await this.#integrity(db, principal, row, held, reports);
|
|
1419
1477
|
return this.#access(principal, row, held, at, fault !== null);
|
|
1420
1478
|
};
|
|
1421
1479
|
const found: NewEntry[] = [];
|
|
@@ -1429,6 +1487,7 @@ class VaultService implements Vault {
|
|
|
1429
1487
|
}
|
|
1430
1488
|
const reports: NewEntry[] = [];
|
|
1431
1489
|
const access = await this.#db.transaction((tx) => read(tx, reports), SNAPSHOT);
|
|
1490
|
+
if (access.status === 'tampered' && superseded !== null) throw new Error(superseded);
|
|
1432
1491
|
await this.#record(reports);
|
|
1433
1492
|
return access;
|
|
1434
1493
|
}
|
|
@@ -1448,7 +1507,6 @@ class VaultService implements Vault {
|
|
|
1448
1507
|
const held = await store.grants(db);
|
|
1449
1508
|
const newest = await store.newestAccessEntries(db);
|
|
1450
1509
|
for (const row of rows) {
|
|
1451
|
-
if (this.#isRootAdmin(row.principal)) continue;
|
|
1452
1510
|
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
1453
1511
|
const entry = newest.get(row.principal);
|
|
1454
1512
|
const holds = entry !== undefined && this.#authentic(entry) && this.#sealed(row, grants) && entry.seq === row.accessSeq;
|
|
@@ -1458,7 +1516,7 @@ class VaultService implements Vault {
|
|
|
1458
1516
|
|
|
1459
1517
|
// --- changing access ----------------------------------------------------------
|
|
1460
1518
|
|
|
1461
|
-
setAccess(input: SetAccessInput): Promise<Outcome<{ changes: AccessChange[] }>> {
|
|
1519
|
+
setAccess(input: SetAccessInput): Promise<Outcome<{ changes: AccessChange[]; admitted?: Admitted }>> {
|
|
1462
1520
|
const { actor, principal } = input;
|
|
1463
1521
|
const changes = input.changes.map(({ projectId, environmentId, role, expiresAt }) => ({ projectId, environmentId, environmentSlug: null, role, expiresAt }));
|
|
1464
1522
|
const action = changes.every((change) => change.role === null) ? 'access.revoke' : 'access.grant';
|
|
@@ -1474,7 +1532,6 @@ class VaultService implements Vault {
|
|
|
1474
1532
|
return this.#decide([actor, principal], async (d) => {
|
|
1475
1533
|
validateCorrelation(input);
|
|
1476
1534
|
if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a principal: ${principal}`);
|
|
1477
|
-
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
1478
1535
|
const places = new Set<string>();
|
|
1479
1536
|
for (const change of changes) {
|
|
1480
1537
|
if (grantKind(change) === null) {
|
|
@@ -1521,19 +1578,22 @@ class VaultService implements Vault {
|
|
|
1521
1578
|
const manages = where.every((place) => mayManageAccess(acting.live, place));
|
|
1522
1579
|
if (!manages && !(SERVICE.test(principal) && PERSON.test(actor))) throw refused('not_allowed');
|
|
1523
1580
|
// Managing access never reads a value: nobody gives themselves a role, though they may give one up.
|
|
1524
|
-
if (actor === principal &&
|
|
1581
|
+
if (actor === principal && changes.some((change) => change.role !== null)) {
|
|
1525
1582
|
throw refused('not_allowed', 'nobody grants themselves a role: ask another admin');
|
|
1526
1583
|
}
|
|
1527
1584
|
|
|
1528
1585
|
const row = d.members.get(principal);
|
|
1529
1586
|
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1530
1587
|
if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
|
|
1531
|
-
|
|
1532
|
-
|
|
1588
|
+
// A service account admitted with its first grants, as `admit` would: all of it, or none.
|
|
1589
|
+
const admitted = input.admit === true && row?.status !== 'active' ? await this.#admitWith(d, acting, principal, row, input) : undefined;
|
|
1590
|
+
if (admitted === undefined && row?.status === 'removed') throw refused('removed');
|
|
1591
|
+
if (admitted === undefined && row === undefined) throw refused('not_a_member');
|
|
1533
1592
|
if (!manages) {
|
|
1534
1593
|
// The setting as of the log's head, which a change to it takes too.
|
|
1535
1594
|
const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
|
|
1536
|
-
|
|
1595
|
+
// One just admitted holds nothing yet, and is theirs.
|
|
1596
|
+
const account = admitted !== undefined || row === undefined ? { grants: [], admittedBy: actor } : await this.#account(d.tx, subject, row);
|
|
1537
1597
|
// Each place's new role they must hold, and what it held before: taking a grant away is changing it.
|
|
1538
1598
|
const gives = changes.every((change, i) => {
|
|
1539
1599
|
const before = subject.live.grants.find((grant) => placeKey({ ...grant, environmentSlug: null }) === placeKey(change));
|
|
@@ -1546,10 +1606,23 @@ class VaultService implements Vault {
|
|
|
1546
1606
|
d.grants.set(principal, [...held]);
|
|
1547
1607
|
const outcomes = changes.map((change) => this.#apply(d, actor, principal, held, change, input));
|
|
1548
1608
|
if (d.writes.length > 0) d.touched.add(principal);
|
|
1549
|
-
return { changes: outcomes };
|
|
1609
|
+
return { changes: outcomes, ...(admitted === undefined ? {} : { admitted }) };
|
|
1550
1610
|
});
|
|
1551
1611
|
}
|
|
1552
1612
|
|
|
1613
|
+
/**
|
|
1614
|
+
* Admit a service account that `setAccess` gives its first grants, as
|
|
1615
|
+
* `admit` would, its refusals logged as `member.add`'s: someone who does
|
|
1616
|
+
* not run the instance sets one up as they would through `admit`.
|
|
1617
|
+
*/
|
|
1618
|
+
async #admitWith(d: Decision, acting: Standing, principal: string, row: Member | undefined, input: SetAccessInput): Promise<Admitted> {
|
|
1619
|
+
const refused = (code: RefusalCode, message = MESSAGES[code]) =>
|
|
1620
|
+
new Refused(refusal(code, message), [accessEntry(acting.principal, 'member.add', principal, 'deny', input, { changes: input.changes }, code)]);
|
|
1621
|
+
if (!SERVICE.test(principal)) throw refused('invalid', 'only a service account is admitted with its grants: a person, by an instance role');
|
|
1622
|
+
if (!runsInstance(acting.live)) await this.#setsUp(d, acting, true, refused);
|
|
1623
|
+
return this.#enlist(d, acting, principal, row, undefined, input, refused);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1553
1626
|
/** One place's change, logged when it changes what is live. */
|
|
1554
1627
|
#apply(
|
|
1555
1628
|
d: Decision,
|
|
@@ -1601,7 +1674,7 @@ class VaultService implements Vault {
|
|
|
1601
1674
|
return current === undefined ? 'created' : 'updated';
|
|
1602
1675
|
}
|
|
1603
1676
|
|
|
1604
|
-
admit(input: AdmitInput): Promise<Outcome<
|
|
1677
|
+
admit(input: AdmitInput): Promise<Outcome<Admitted>> {
|
|
1605
1678
|
const { actor, principal } = input;
|
|
1606
1679
|
const asked = input.role === undefined ? {} : { role: input.role, scope: input.scope ?? EVERYWHERE };
|
|
1607
1680
|
const refused = (code: RefusalCode, message = MESSAGES[code]) =>
|
|
@@ -1612,21 +1685,10 @@ class VaultService implements Vault {
|
|
|
1612
1685
|
validateCorrelation(input);
|
|
1613
1686
|
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
1614
1687
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
|
|
1619
|
-
if (!service || !PERSON.test(actor) || !setsUpServices(acting.live, setting, placesOf(await store.liveProjects(d.tx)))) {
|
|
1620
|
-
throw refused('not_allowed', 'only admins and owners of the whole instance may add people or set their roles, and a service account takes access somewhere the instance lets people set one up');
|
|
1621
|
-
}
|
|
1622
|
-
// One removed they bring back only if they removed it themselves, never one someone else took away.
|
|
1623
|
-
const removed = d.members.get(principal);
|
|
1624
|
-
if (removed?.status === 'removed' && removed.statusChangedBy !== actor) {
|
|
1625
|
-
throw refused('not_allowed', `${shownMember(removed.statusChangedBy)} removed this service account: only they, or someone who runs the whole instance, may bring it back`);
|
|
1626
|
-
}
|
|
1627
|
-
}
|
|
1688
|
+
// A service account, with no grant yet, any person who could give it one sets up (`setsUpServices`).
|
|
1689
|
+
const service = SERVICE.test(principal) && (input.role ?? 'member') === 'member' && input.scope === undefined;
|
|
1690
|
+
if (!runsInstance(acting.live)) await this.#setsUp(d, acting, service, refused);
|
|
1628
1691
|
if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
|
|
1629
|
-
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
1630
1692
|
if (input.role !== undefined && !isInstanceRole(input.role)) throw refused('invalid', `no such instance role: ${input.role}`);
|
|
1631
1693
|
if (input.scope !== undefined && (input.role === undefined || !isScope(input.scope))) throw refused('invalid', 'a scope is a role\'s: { projects, environments }, each all, only or except');
|
|
1632
1694
|
const wanted = input.role === undefined ? undefined : { role: input.role, scope: input.role === 'member' ? EVERYWHERE : normalScope(input.scope ?? EVERYWHERE) };
|
|
@@ -1638,39 +1700,22 @@ class VaultService implements Vault {
|
|
|
1638
1700
|
if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
|
|
1639
1701
|
d.touched.add(principal);
|
|
1640
1702
|
d.grants.set(principal, [...subject.stored]);
|
|
1641
|
-
const entry = (action: string, detail: Record<string, unknown>) =>
|
|
1642
|
-
d.log.push(accessEntry(actor, action, principal, 'allow', input, detail));
|
|
1643
1703
|
|
|
1644
1704
|
if (row === undefined || row.status === 'removed') {
|
|
1645
|
-
//
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
d.writes.push(async (at) => {
|
|
1649
|
-
if (row === undefined) {
|
|
1650
|
-
await store.insertMember(d.tx, {
|
|
1651
|
-
principal,
|
|
1652
|
-
status: 'active',
|
|
1653
|
-
role,
|
|
1654
|
-
scope,
|
|
1655
|
-
generation: 0,
|
|
1656
|
-
createdAt: at,
|
|
1657
|
-
createdBy: actor,
|
|
1658
|
-
statusChangedAt: at,
|
|
1659
|
-
statusChangedBy: actor,
|
|
1660
|
-
...UNSEALED,
|
|
1661
|
-
});
|
|
1662
|
-
} else {
|
|
1663
|
-
await store.updateMember(d.tx, principal, { status: 'active', role, scope, statusChangedAt: at, statusChangedBy: actor });
|
|
1664
|
-
}
|
|
1665
|
-
});
|
|
1666
|
-
// A removal moved the generation on already; coming back keeps it.
|
|
1667
|
-
return { created: true, role, scope, generation: row?.generation ?? 0 };
|
|
1705
|
+
// Added or brought back, as anything but an Owner of the whole instance, one the deployment names would be a listed Member.
|
|
1706
|
+
if (this.#listed(principal) && !(wanted !== undefined && ownsInstance(wanted))) throw refused('not_allowed', declaredOwner(principal));
|
|
1707
|
+
return this.#enlist(d, acting, principal, row, wanted, input, refused);
|
|
1668
1708
|
}
|
|
1669
1709
|
const changed = wanted !== undefined && JSON.stringify(standing(wanted)) !== JSON.stringify(standing(row));
|
|
1670
|
-
//
|
|
1671
|
-
if (changed &&
|
|
1710
|
+
// One the deployment names stays an Owner of the whole instance, whoever asks: the list says where to change that.
|
|
1711
|
+
if (changed && this.#listed(principal) && !ownsInstance(wanted)) throw refused('not_allowed', declaredOwner(principal));
|
|
1712
|
+
// Read under both rows' locks: neither the actor's role nor the subject's changes before this commits.
|
|
1713
|
+
if (changed && (wanted.role === 'owner' || subject.live.role === 'owner') && !ownsInstance(acting.live)) throw refused('not_allowed', OWNERS_ONLY);
|
|
1714
|
+
// Nobody sets their own role, but an Owner of the whole instance, who holds all there is: running the instance never lets anyone widen what they hold themselves.
|
|
1715
|
+
if (changed && actor === principal && !ownsInstance(acting.live)) throw refused('not_allowed', 'nobody changes their own instance role: ask another admin');
|
|
1716
|
+
if (changed && ownsInstance(subject.live) && !ownsInstance(wanted)) await this.#keepAnOwner(d, actor, principal, refused);
|
|
1672
1717
|
if (changed) {
|
|
1673
|
-
|
|
1718
|
+
d.log.push(accessEntry(actor, 'member.role', principal, 'allow', input, { role: wanted.role, scope: wanted.scope, previousRole: row.role, previousScope: row.scope }));
|
|
1674
1719
|
d.writes.push(() => store.updateMember(d.tx, principal, { role: wanted.role, scope: wanted.scope }));
|
|
1675
1720
|
return { created: false, ...wanted, generation: row.generation };
|
|
1676
1721
|
}
|
|
@@ -1678,6 +1723,63 @@ class VaultService implements Vault {
|
|
|
1678
1723
|
});
|
|
1679
1724
|
}
|
|
1680
1725
|
|
|
1726
|
+
/**
|
|
1727
|
+
* Whether `acting`, who does not run the instance, may admit `principal`:
|
|
1728
|
+
* only a service account (`service`: one asked for with no role), as a
|
|
1729
|
+
* person who could give it a grant somewhere (`setsUpServices`), and one
|
|
1730
|
+
* removed only if they removed it themselves, never one someone else
|
|
1731
|
+
* took away.
|
|
1732
|
+
*/
|
|
1733
|
+
async #setsUp(d: Decision, acting: Standing, service: boolean, refused: (code: RefusalCode, message?: string) => Refused): Promise<void> {
|
|
1734
|
+
const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
|
|
1735
|
+
if (!service || !PERSON.test(acting.principal) || !setsUpServices(acting.live, setting, placesOf(await store.liveProjects(d.tx)))) {
|
|
1736
|
+
throw refused('not_allowed', 'only admins and owners of the whole instance may add people or set their roles, and a service account takes access somewhere the instance lets people set one up');
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/**
|
|
1741
|
+
* Admit `principal`, who has no row or a removed one: a fresh start, as a
|
|
1742
|
+
* member unless given a role (`wanted`), at the generation a removal
|
|
1743
|
+
* moved on. The caller has checked who may, and locked the row.
|
|
1744
|
+
*/
|
|
1745
|
+
#enlist(
|
|
1746
|
+
d: Decision,
|
|
1747
|
+
acting: Standing,
|
|
1748
|
+
principal: string,
|
|
1749
|
+
row: Member | undefined,
|
|
1750
|
+
wanted: { role: InstanceRole; scope: Scope } | undefined,
|
|
1751
|
+
correlation: Correlation,
|
|
1752
|
+
refused: (code: RefusalCode, message?: string) => Refused,
|
|
1753
|
+
): Admitted {
|
|
1754
|
+
const actor = acting.principal;
|
|
1755
|
+
if (row?.status === 'removed' && row.statusChangedBy !== actor && !runsInstance(acting.live)) {
|
|
1756
|
+
throw refused('not_allowed', `${shownMember(row.statusChangedBy)} removed this service account: only they, or someone who runs the whole instance, may bring it back`);
|
|
1757
|
+
}
|
|
1758
|
+
if (wanted?.role === 'owner' && !ownsInstance(acting.live)) throw refused('not_allowed', OWNERS_ONLY);
|
|
1759
|
+
const { role, scope } = wanted ?? { role: 'member' as const, scope: EVERYWHERE };
|
|
1760
|
+
d.log.push(accessEntry(actor, row === undefined ? 'member.add' : 'member.restore', principal, 'allow', correlation, { role, scope }));
|
|
1761
|
+
d.writes.push(async (at) => {
|
|
1762
|
+
if (row === undefined) {
|
|
1763
|
+
await store.insertMember(d.tx, {
|
|
1764
|
+
principal,
|
|
1765
|
+
status: 'active',
|
|
1766
|
+
role,
|
|
1767
|
+
scope,
|
|
1768
|
+
generation: 0,
|
|
1769
|
+
createdAt: at,
|
|
1770
|
+
createdBy: actor,
|
|
1771
|
+
statusChangedAt: at,
|
|
1772
|
+
statusChangedBy: actor,
|
|
1773
|
+
...UNSEALED,
|
|
1774
|
+
});
|
|
1775
|
+
} else {
|
|
1776
|
+
await store.updateMember(d.tx, principal, { status: 'active', role, scope, statusChangedAt: at, statusChangedBy: actor });
|
|
1777
|
+
}
|
|
1778
|
+
});
|
|
1779
|
+
// A removal moved the generation on already; coming back keeps it.
|
|
1780
|
+
return { created: true, role, scope, generation: row?.generation ?? 0 };
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1681
1783
|
remove(input: RemoveInput): Promise<Outcome<{ revoked: Grant[]; generation: number }>> {
|
|
1682
1784
|
const { actor, principal } = input;
|
|
1683
1785
|
const refused = (code: RefusalCode, message = MESSAGES[code]) =>
|
|
@@ -1685,13 +1787,13 @@ class VaultService implements Vault {
|
|
|
1685
1787
|
return this.#decide([actor, principal], async (d) => {
|
|
1686
1788
|
validateCorrelation(input);
|
|
1687
1789
|
if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
|
|
1688
|
-
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
1689
1790
|
const row = d.members.get(principal);
|
|
1690
1791
|
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
1691
1792
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1692
1793
|
const holder = acting.live;
|
|
1693
1794
|
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1694
1795
|
const held = subject.stored;
|
|
1796
|
+
if (this.#listed(principal)) throw refused('not_allowed', declaredOwner(principal));
|
|
1695
1797
|
if (subject.status === 'tampered') {
|
|
1696
1798
|
if (!runsInstance(holder)) throw refused('not_allowed', 'only admins and owners of the whole instance may remove a member whose record failed its check');
|
|
1697
1799
|
return this.#startOver(d, actor, principal, row, subject.fault!, input, refused);
|
|
@@ -1703,6 +1805,8 @@ class VaultService implements Vault {
|
|
|
1703
1805
|
if (!theirs) throw refused('not_allowed', 'only admins and owners of the whole instance may remove members, and a service account whoever manages it');
|
|
1704
1806
|
}
|
|
1705
1807
|
if (row?.status !== 'active') throw refused(row === undefined ? 'not_a_member' : 'removed');
|
|
1808
|
+
if (subject.live.role === 'owner' && !ownsInstance(holder)) throw refused('not_allowed', OWNERS_ONLY);
|
|
1809
|
+
if (ownsInstance(subject.live)) await this.#keepAnOwner(d, actor, principal, refused);
|
|
1706
1810
|
|
|
1707
1811
|
const revoked = held.filter((grant) => live(grant, d.at));
|
|
1708
1812
|
for (const grant of revoked) {
|
|
@@ -2048,14 +2152,16 @@ class VaultService implements Vault {
|
|
|
2048
2152
|
});
|
|
2049
2153
|
}
|
|
2050
2154
|
|
|
2051
|
-
async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>;
|
|
2155
|
+
async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; owners: string[]; rootAdmins: [] }> {
|
|
2052
2156
|
await this.#settled();
|
|
2053
2157
|
const { signers, since } = this.#prepared;
|
|
2054
2158
|
// A key it replaced vouches only for prefixes before its first entry under the current one: none, before it has one.
|
|
2055
2159
|
const until = Number(since ?? 0n);
|
|
2056
2160
|
return {
|
|
2057
2161
|
checkpointKeys: Object.fromEntries(signers.map(({ keyId, publicKey }, i) => [keyId, { publicKey, until: i === 0 ? null : until }])),
|
|
2058
|
-
|
|
2162
|
+
owners: [...this.#config.owners],
|
|
2163
|
+
// For an app of 0.5 while it runs beside this vault: there are none, and its lists and checks need the field.
|
|
2164
|
+
rootAdmins: [],
|
|
2059
2165
|
};
|
|
2060
2166
|
}
|
|
2061
2167
|
|