@coffre/db 0.4.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/connect.d.ts +1 -1
- package/dist/{database-edkMkG02.d.ts → database-C63WjCrC.d.ts} +1 -1
- package/dist/dialect.d.ts +7 -1
- package/dist/dialect.js +12 -1
- package/dist/grants.d.ts +105 -10
- package/dist/grants.js +207 -7
- package/dist/hyperdrive.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/log.d.ts +392 -2
- package/dist/log.js +53 -1
- package/dist/migrations/postgres/0002_instance_roles.sql +4 -0
- package/dist/migrations/postgres/0003_credential_scopes.sql +4 -0
- package/dist/migrations/postgres/meta/0002_snapshot.json +2860 -0
- package/dist/migrations/postgres/meta/0003_snapshot.json +2872 -0
- package/dist/migrations/postgres/meta/_journal.json +14 -0
- package/dist/migrations/sqlite/0002_instance_roles.sql +2 -0
- package/dist/migrations/sqlite/0003_credential_scopes.sql +2 -0
- package/dist/migrations/sqlite/meta/0002_snapshot.json +2594 -0
- package/dist/migrations/sqlite/meta/0003_snapshot.json +2608 -0
- package/dist/migrations/sqlite/meta/_journal.json +14 -0
- package/dist/portable.d.ts +2 -2
- package/dist/{schema-BqCB3XFm.d.ts → schema-a7PkFgea.d.ts} +68 -0
- package/dist/schema-sqlite.d.ts +76 -0
- package/dist/schema-sqlite.js +4 -0
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +60 -26
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +4 -0
- package/package.json +2 -2
- package/src/dialect.ts +14 -0
- package/src/grants.ts +272 -11
- package/src/log.ts +66 -2
- package/src/migrations/postgres/0002_instance_roles.sql +4 -0
- package/src/migrations/postgres/0003_credential_scopes.sql +4 -0
- package/src/migrations/postgres/meta/0002_snapshot.json +2860 -0
- package/src/migrations/postgres/meta/0003_snapshot.json +2872 -0
- package/src/migrations/postgres/meta/_journal.json +14 -0
- package/src/migrations/sqlite/0002_instance_roles.sql +2 -0
- package/src/migrations/sqlite/0003_credential_scopes.sql +2 -0
- package/src/migrations/sqlite/meta/0002_snapshot.json +2594 -0
- package/src/migrations/sqlite/meta/0003_snapshot.json +2608 -0
- package/src/migrations/sqlite/meta/_journal.json +14 -0
- package/src/schema.sqlite.ts +6 -0
- package/src/schema.ts +11 -1
package/src/grants.ts
CHANGED
|
@@ -1,13 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
administers,
|
|
3
|
+
convertEveryProjectGrants,
|
|
4
|
+
EVERYWHERE,
|
|
5
|
+
isInstanceRole,
|
|
6
|
+
isScope,
|
|
7
|
+
normalScope,
|
|
8
|
+
unscoped,
|
|
9
|
+
type Conversion,
|
|
10
|
+
type InstanceRole,
|
|
11
|
+
type Role,
|
|
12
|
+
type Scope,
|
|
13
|
+
} from '@coffre/core/access';
|
|
14
|
+
import type { StoredEntry } from '@coffre/core/audit';
|
|
15
|
+
import { isTombstone } from '@coffre/core/schemas';
|
|
16
|
+
import { ACCESS_ACTIONS } from '@coffre/core/vault';
|
|
17
|
+
import { and, desc, eq, gt, inArray, isNull, lt, or, sql, type SQL } from 'drizzle-orm';
|
|
2
18
|
|
|
3
19
|
import { tablesOf, type Queryable } from './database.ts';
|
|
20
|
+
import { entryColumns, LOOK_BACK, newestAuthentic, type Authenticity, type Newest } from './log.ts';
|
|
4
21
|
|
|
5
22
|
/**
|
|
6
23
|
* One grant, as `vault_grants` holds it: on a project (`environmentId`
|
|
7
|
-
* null), one of its environments (`projectId` is the environment's
|
|
8
|
-
* project)
|
|
9
|
-
*
|
|
10
|
-
* them for
|
|
24
|
+
* null), or one of its environments (`projectId` is the environment's
|
|
25
|
+
* project). A grant on every project of 0.4 names neither, and
|
|
26
|
+
* `environmentSlug` the one environment slug it covers in each, until the
|
|
27
|
+
* vault replaces it. Only the vault writes grants; the app reads them for
|
|
28
|
+
* its lists.
|
|
11
29
|
*/
|
|
12
30
|
export type GrantRow = {
|
|
13
31
|
principal: string;
|
|
@@ -21,13 +39,10 @@ export type GrantRow = {
|
|
|
21
39
|
grantedBy: string;
|
|
22
40
|
};
|
|
23
41
|
|
|
24
|
-
/**
|
|
25
|
-
* Grants, lapsed ones too unless `liveAt` says when to judge them: one
|
|
26
|
-
* member's, or everyone's; `everyProject`, only those on every project.
|
|
27
|
-
*/
|
|
42
|
+
/** Grants, lapsed ones too unless `liveAt` says when to judge them: one member's, or everyone's. */
|
|
28
43
|
export async function readGrants(
|
|
29
44
|
db: Queryable,
|
|
30
|
-
filter: { principal?: string; liveAt?: number
|
|
45
|
+
filter: { principal?: string; liveAt?: number } = {},
|
|
31
46
|
): Promise<GrantRow[]> {
|
|
32
47
|
const { vaultGrants, environments } = tablesOf(db);
|
|
33
48
|
const rows = await db
|
|
@@ -38,7 +53,6 @@ export async function readGrants(
|
|
|
38
53
|
and(
|
|
39
54
|
filter.principal === undefined ? undefined : eq(vaultGrants.principal, filter.principal),
|
|
40
55
|
filter.liveAt === undefined ? undefined : or(isNull(vaultGrants.expiresAt), gt(vaultGrants.expiresAt, filter.liveAt)),
|
|
41
|
-
filter.everyProject === true ? and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId)) : undefined,
|
|
42
56
|
),
|
|
43
57
|
);
|
|
44
58
|
return rows.map(({ grant, environmentProjectId }) => ({ ...grant, projectId: grant.projectId ?? environmentProjectId }));
|
|
@@ -49,3 +63,250 @@ export async function insertGrant(db: Queryable, grant: GrantRow): Promise<void>
|
|
|
49
63
|
const { vaultGrants } = tablesOf(db);
|
|
50
64
|
await db.insert(vaultGrants).values({ ...grant, projectId: grant.environmentId === null ? grant.projectId : null });
|
|
51
65
|
}
|
|
66
|
+
|
|
67
|
+
/** A member's instance role and its scope, as `vault_members` stores them. */
|
|
68
|
+
export type StoredRole = { owner: boolean; role: string | null; scope: string | null };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A member's instance role and its scope, as their row says: a row no vault
|
|
72
|
+
* of 0.5 wrote has no role, and its `owner` says whether they are an Admin.
|
|
73
|
+
* A role or a scope the vault would never have written reads as holding
|
|
74
|
+
* nothing (the vault then refuses the row by its MAC); a member's scope is
|
|
75
|
+
* everywhere, since a member's role reaches nothing.
|
|
76
|
+
*/
|
|
77
|
+
export function storedRole({ owner, role, scope }: StoredRole): { role: InstanceRole; scope: Scope } {
|
|
78
|
+
const named: InstanceRole = role === null ? (owner ? 'admin' : 'member') : isInstanceRole(role) ? role : 'member';
|
|
79
|
+
if (named === 'member' || scope === null) return { role: named, scope: EVERYWHERE };
|
|
80
|
+
let parsed: unknown = null;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(scope);
|
|
83
|
+
} catch {}
|
|
84
|
+
return { role: named, scope: isScope(parsed) ? normalScope(parsed) : NOWHERE };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A scope that takes in nothing: what a stored scope nobody can read means. */
|
|
88
|
+
const NOWHERE: Scope = { projects: { only: [] }, environments: { only: [] } };
|
|
89
|
+
|
|
90
|
+
/** The columns that store a member's instance role and scope: `owner` too, for a vault of 0.4 to read. */
|
|
91
|
+
export function roleColumns(role: InstanceRole, scope: Scope): StoredRole {
|
|
92
|
+
const normal = normalScope(scope);
|
|
93
|
+
return { owner: administers(role), role, scope: role === 'member' || unscoped(normal) ? null : JSON.stringify(normal) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What the vault does, when this version first runs, to one member's grants on every project of 0.4. */
|
|
97
|
+
export type EveryProjectPreview = {
|
|
98
|
+
principal: string;
|
|
99
|
+
/** Each, as a path (`*`, or `*` and the slug it covers), its role and its end. */
|
|
100
|
+
before: { place: string; role: string; expiresAt: number | null }[];
|
|
101
|
+
conversion: Conversion;
|
|
102
|
+
/** The places the conversion names, by id: `market`, `market/dev`. */
|
|
103
|
+
paths: Record<string, string>;
|
|
104
|
+
/**
|
|
105
|
+
* Whether the vault has reported their row changed around it since the
|
|
106
|
+
* last change to what they hold (`tamperedMembers`), by the entries in its
|
|
107
|
+
* name: then it leaves them as they are, unless the report is forged.
|
|
108
|
+
*/
|
|
109
|
+
reported: boolean;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What the vault will make of the grants on every project of 0.4 that live
|
|
114
|
+
* members hold, by the rule it converts them with
|
|
115
|
+
* (`convertEveryProjectGrants`), from the rows as they are: what
|
|
116
|
+
* `coffre migrate` says before the vault does it. A display: the vault
|
|
117
|
+
* reads again, under its locks, when it converts, and checks what this
|
|
118
|
+
* cannot, holding no key: each row's MAC, and each entry's in its name. So
|
|
119
|
+
* a member it has reported changed around it is listed, and marked
|
|
120
|
+
* `reported`, by the vault's own rule (`tamperedMembers`) over the entries as
|
|
121
|
+
* they read: whether that report is the vault's own, or forged so as to
|
|
122
|
+
* hide what it will do, only the vault can tell. Any member the vault
|
|
123
|
+
* converts is listed. A root admin's it leaves while they are one, until
|
|
124
|
+
* they are not; which they are, the vault's configuration says and the
|
|
125
|
+
* database does not, so they are listed too.
|
|
126
|
+
*/
|
|
127
|
+
export async function everyProjectPreview(db: Queryable, at: number): Promise<EveryProjectPreview[]> {
|
|
128
|
+
const { vaultMembers, projects, environments } = tablesOf(db);
|
|
129
|
+
const held = (await readGrants(db, { liveAt: at })).filter((grant) => grant.projectId === null && grant.environmentId === null);
|
|
130
|
+
if (held.length === 0) return [];
|
|
131
|
+
const holders = [...new Set(held.map((grant) => grant.principal))].sort();
|
|
132
|
+
const [members, grants, places, inside, tampered] = await Promise.all([
|
|
133
|
+
db.select({ principal: vaultMembers.principal, status: vaultMembers.status, owner: vaultMembers.owner, role: vaultMembers.role, scope: vaultMembers.scope }).from(vaultMembers),
|
|
134
|
+
readGrants(db, { liveAt: at }),
|
|
135
|
+
db.select({ id: projects.id, slug: projects.slug }).from(projects),
|
|
136
|
+
db.select({ id: environments.id, projectId: environments.projectId, slug: environments.slug }).from(environments),
|
|
137
|
+
// The vault's own rule (`reportStands`), over every entry in its name, for everyone in two queries.
|
|
138
|
+
tamperedMembers(db),
|
|
139
|
+
]);
|
|
140
|
+
// A deleted place keeps its row under a tombstone's slug, its own or its project's: no grant reaches it.
|
|
141
|
+
const live = places.filter((project) => !isTombstone(project.slug)).map((project) => ({
|
|
142
|
+
id: project.id,
|
|
143
|
+
environments: inside.filter((environment) => environment.projectId === project.id && !isTombstone(environment.slug)).map(({ id, slug }) => ({ id, slug })),
|
|
144
|
+
}));
|
|
145
|
+
const paths = Object.fromEntries(places.flatMap((project) => [
|
|
146
|
+
[project.id, project.slug],
|
|
147
|
+
...inside.filter((environment) => environment.projectId === project.id).map((environment) => [environment.id, `${project.slug}/${environment.slug}`]),
|
|
148
|
+
]));
|
|
149
|
+
return holders.flatMap((principal) => {
|
|
150
|
+
const member = members.find((row) => row.principal === principal);
|
|
151
|
+
if (member?.status !== 'active') return [];
|
|
152
|
+
const theirs = held.filter((grant) => grant.principal === principal);
|
|
153
|
+
return [{
|
|
154
|
+
principal,
|
|
155
|
+
before: theirs.map((grant) => ({ place: grant.environmentSlug === null ? '*' : `*/${grant.environmentSlug}`, role: grant.role, expiresAt: grant.expiresAt })),
|
|
156
|
+
conversion: convertEveryProjectGrants({
|
|
157
|
+
person: principal.startsWith('user:'),
|
|
158
|
+
role: principal.startsWith('user:') ? storedRole(member).role : 'member',
|
|
159
|
+
everyProject: theirs.map((grant) => ({ environmentSlug: grant.environmentSlug, role: grant.role as Role, expiresAt: grant.expiresAt })),
|
|
160
|
+
grants: grants
|
|
161
|
+
.filter((grant) => grant.principal === principal && grant.projectId !== null)
|
|
162
|
+
.map((grant) => ({ projectId: grant.projectId, environmentId: grant.environmentId, role: grant.role as Role, expiresAt: grant.expiresAt })),
|
|
163
|
+
projects: live,
|
|
164
|
+
}),
|
|
165
|
+
paths,
|
|
166
|
+
reported: tampered.has(principal),
|
|
167
|
+
}];
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A service token or trust binding of 0.4, which carries no scope of its own yet. */
|
|
172
|
+
export type Unsealed = { kind: 'token' | 'binding'; id: string; principal: string };
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The live service tokens and trust bindings of active service accounts
|
|
176
|
+
* that carry no scope yet, as 0.4 made them: what the app seals with each
|
|
177
|
+
* account's grants once a process (`sealScopes`), and what `coffre migrate`
|
|
178
|
+
* says it will. A credential a binding issued carries its binding's.
|
|
179
|
+
*/
|
|
180
|
+
export async function unsealedCredentials(db: Queryable, at: Date): Promise<Unsealed[]> {
|
|
181
|
+
const { credentials, serviceBindings, vaultMembers } = tablesOf(db);
|
|
182
|
+
const [tokens, bindings] = await Promise.all([
|
|
183
|
+
db
|
|
184
|
+
.select({ id: credentials.id, principal: credentials.principal })
|
|
185
|
+
.from(credentials)
|
|
186
|
+
.innerJoin(vaultMembers, and(eq(vaultMembers.principal, credentials.principal), eq(vaultMembers.generation, credentials.generation)))
|
|
187
|
+
.where(and(
|
|
188
|
+
eq(credentials.kind, 'service'),
|
|
189
|
+
isNull(credentials.scope),
|
|
190
|
+
sql`${credentials.createdBy} NOT LIKE 'binding:%'`,
|
|
191
|
+
isNull(credentials.revokedAt),
|
|
192
|
+
gt(credentials.expiresAt, at),
|
|
193
|
+
eq(vaultMembers.status, 'active'),
|
|
194
|
+
)),
|
|
195
|
+
db
|
|
196
|
+
.select({ id: serviceBindings.id, principal: serviceBindings.principal })
|
|
197
|
+
.from(serviceBindings)
|
|
198
|
+
.innerJoin(vaultMembers, and(eq(vaultMembers.principal, serviceBindings.principal), eq(vaultMembers.generation, serviceBindings.generation)))
|
|
199
|
+
.where(and(isNull(serviceBindings.scope), isNull(serviceBindings.revokedAt), eq(vaultMembers.status, 'active'))),
|
|
200
|
+
]);
|
|
201
|
+
return [
|
|
202
|
+
...tokens.map((row) => ({ kind: 'token' as const, ...row })),
|
|
203
|
+
...bindings.map((row) => ({ kind: 'binding' as const, ...row })),
|
|
204
|
+
].sort((a, b) => a.principal.localeCompare(b.principal) || a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The members the vault has found changed around it, and not started over
|
|
209
|
+
* since, as a reader without its key can tell: `reportStands`' rule, taking
|
|
210
|
+
* every entry in the vault's name as its own, for everyone at once, where
|
|
211
|
+
* the app lists members and `coffre migrate` marks them. Their newest
|
|
212
|
+
* `vault.tampered`, for their row (`mac`) or an older one put back
|
|
213
|
+
* (`stale`), is newer than the newest entry changing what they hold. Both
|
|
214
|
+
* reads go by the log's (author, action, seq) and (author, subject, seq)
|
|
215
|
+
* indexes, and findings are few.
|
|
216
|
+
*/
|
|
217
|
+
export async function tamperedMembers(db: Queryable, principal?: string): Promise<Set<string>> {
|
|
218
|
+
const { auditLog } = tablesOf(db);
|
|
219
|
+
const newest = (actions: readonly string[], extra?: SQL) =>
|
|
220
|
+
db
|
|
221
|
+
.select({ principal: auditLog.subjectPrincipal, seq: sql<string>`max(${auditLog.seq})`.mapWith(BigInt) })
|
|
222
|
+
.from(auditLog)
|
|
223
|
+
.where(
|
|
224
|
+
and(
|
|
225
|
+
eq(auditLog.author, 'vault'),
|
|
226
|
+
inArray(auditLog.action, [...actions]),
|
|
227
|
+
principal === undefined ? undefined : eq(auditLog.subjectPrincipal, principal),
|
|
228
|
+
extra,
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
.groupBy(auditLog.subjectPrincipal);
|
|
232
|
+
const found = await newest(['vault.tampered'], inArray(auditLog.code, [...TAMPER_CODES]));
|
|
233
|
+
if (found.length === 0) return new Set();
|
|
234
|
+
const changed = new Map(
|
|
235
|
+
(await newest(ACCESS_ACTIONS, and(eq(auditLog.decision, 'allow'), inArray(auditLog.subjectPrincipal, found.map((row) => row.principal!)))))
|
|
236
|
+
.map((row) => [row.principal!, row.seq]),
|
|
237
|
+
);
|
|
238
|
+
return new Set(found.filter((row) => row.seq > (changed.get(row.principal!) ?? -1n)).map((row) => row.principal!));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The codes of the vault's reports that a member's row was changed around it: their row's MAC fails (`mac`), or an older one was put back (`stale`). */
|
|
242
|
+
const TAMPER_CODES = ['mac', 'stale'] as const;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Whether the vault's report that `principal`'s row was changed around it
|
|
246
|
+
* stands: one of its own is newer than its newest entry changing what they
|
|
247
|
+
* hold, and only starting them over, or another change, settles it. While
|
|
248
|
+
* one stands the vault leaves their grants on every project of 0.4 as they
|
|
249
|
+
* are. Both entries as `check`, the vault's MACs, tells them apart,
|
|
250
|
+
* passing over those forged in its name; a reader without its key takes
|
|
251
|
+
* them all as the vault's (`tamperedMembers`). Only reports after that
|
|
252
|
+
* entry are read, so older ones, forged or not, never hide one. From
|
|
253
|
+
* LOOK_BACK forged entries on, of either kind, one may stand, so it is
|
|
254
|
+
* taken to.
|
|
255
|
+
*/
|
|
256
|
+
export async function reportStands(db: Queryable, principal: string, check: Authenticity): Promise<boolean> {
|
|
257
|
+
const access = await newestAccess(db, principal, check);
|
|
258
|
+
if (access === 'unreached') return true;
|
|
259
|
+
const after = typeof access === 'string' ? -1n : access.seq;
|
|
260
|
+
const report = await newestAuthentic((before) => tamperReportsAbout(db, principal, LOOK_BACK.page, after, before), check);
|
|
261
|
+
return report === 'unreached' || typeof report !== 'string';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The newest access entry about `principal` that `check` finds the vault's own. */
|
|
265
|
+
export function newestAccess(db: Queryable, principal: string, check: Authenticity): Promise<Newest> {
|
|
266
|
+
return newestAuthentic((before) => accessEntriesAbout(db, principal, LOOK_BACK.page, before), check);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Up to `limit` of the vault's allowed access entries about `principal` before `before` (the newest when undefined), newest first. */
|
|
270
|
+
export async function accessEntriesAbout(db: Queryable, principal: string, limit: number, before?: bigint): Promise<StoredEntry[]> {
|
|
271
|
+
const { auditLog } = tablesOf(db);
|
|
272
|
+
const rows = await db
|
|
273
|
+
.select(entryColumns(db))
|
|
274
|
+
.from(auditLog)
|
|
275
|
+
.where(
|
|
276
|
+
and(
|
|
277
|
+
eq(auditLog.author, 'vault'),
|
|
278
|
+
eq(auditLog.subjectPrincipal, principal),
|
|
279
|
+
inArray(auditLog.action, [...ACCESS_ACTIONS]),
|
|
280
|
+
eq(auditLog.decision, 'allow'),
|
|
281
|
+
before === undefined ? undefined : lt(auditLog.seq, before),
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
.orderBy(desc(auditLog.seq))
|
|
285
|
+
.limit(limit);
|
|
286
|
+
return rows as StoredEntry[];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Up to `limit` of the vault's reports that `principal`'s row was changed
|
|
291
|
+
* around it (`TAMPER_CODES`), after `after` and before `before` (the newest
|
|
292
|
+
* when undefined), newest first.
|
|
293
|
+
*/
|
|
294
|
+
async function tamperReportsAbout(db: Queryable, principal: string, limit: number, after: bigint, before?: bigint): Promise<StoredEntry[]> {
|
|
295
|
+
const { auditLog } = tablesOf(db);
|
|
296
|
+
const rows = await db
|
|
297
|
+
.select(entryColumns(db))
|
|
298
|
+
.from(auditLog)
|
|
299
|
+
.where(
|
|
300
|
+
and(
|
|
301
|
+
eq(auditLog.author, 'vault'),
|
|
302
|
+
eq(auditLog.subjectPrincipal, principal),
|
|
303
|
+
eq(auditLog.action, 'vault.tampered'),
|
|
304
|
+
inArray(auditLog.code, [...TAMPER_CODES]),
|
|
305
|
+
gt(auditLog.seq, after),
|
|
306
|
+
before === undefined ? undefined : lt(auditLog.seq, before),
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
.orderBy(desc(auditLog.seq))
|
|
310
|
+
.limit(limit);
|
|
311
|
+
return rows as StoredEntry[];
|
|
312
|
+
}
|
package/src/log.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { GENESIS_HASH, sealEntry, type LogFields, type LogKey } from '@coffre/core/audit';
|
|
1
|
+
import { GENESIS_HASH, sealEntry, type LogFields, type LogKey, type StoredEntry } from '@coffre/core/audit';
|
|
2
2
|
import { desc, eq } from 'drizzle-orm';
|
|
3
3
|
|
|
4
4
|
import { afterCommit } from './commits.ts';
|
|
5
|
-
import { tablesOf, type Transaction } from './database.ts';
|
|
5
|
+
import { tablesOf, type Queryable, type Transaction } from './database.ts';
|
|
6
6
|
import { clockMillis, forUpdate } from './dialect.ts';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -187,3 +187,67 @@ export async function appendEntries(
|
|
|
187
187
|
afterCommit(tx, () => remember(key, written));
|
|
188
188
|
return { seqStart: head.nextSeq, ...written, occurredAt: now };
|
|
189
189
|
}
|
|
190
|
+
|
|
191
|
+
// --- reading the vault's entries back ---------------------------------------------
|
|
192
|
+
|
|
193
|
+
/** An entry's columns, as the chain's codec reads them (`StoredEntry`). */
|
|
194
|
+
export function entryColumns(db: Queryable) {
|
|
195
|
+
const { auditLog } = tablesOf(db);
|
|
196
|
+
return {
|
|
197
|
+
seq: auditLog.seq,
|
|
198
|
+
author: auditLog.author,
|
|
199
|
+
keyId: auditLog.keyId,
|
|
200
|
+
occurredAt: auditLog.occurredAt,
|
|
201
|
+
actor: auditLog.actor,
|
|
202
|
+
action: auditLog.action,
|
|
203
|
+
decision: auditLog.decision,
|
|
204
|
+
code: auditLog.code,
|
|
205
|
+
subjectPrincipal: auditLog.subjectPrincipal,
|
|
206
|
+
projectId: auditLog.projectId,
|
|
207
|
+
environmentId: auditLog.environmentId,
|
|
208
|
+
secretId: auditLog.secretId,
|
|
209
|
+
secretVersionId: auditLog.secretVersionId,
|
|
210
|
+
operationId: auditLog.operationId,
|
|
211
|
+
requestId: auditLog.requestId,
|
|
212
|
+
sourceIp: auditLog.sourceIp,
|
|
213
|
+
relatedSeq: auditLog.relatedSeq,
|
|
214
|
+
metadata: auditLog.metadata,
|
|
215
|
+
prevHash: auditLog.prevHash,
|
|
216
|
+
mac: auditLog.mac,
|
|
217
|
+
hash: auditLog.hash,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* How a reader tells the vault's own entries from those forged in its name:
|
|
223
|
+
* by their MAC, which only the vault holds the key to check. Each it finds
|
|
224
|
+
* forged goes to `forged`.
|
|
225
|
+
*/
|
|
226
|
+
export type Authenticity = { authentic: (entry: StoredEntry) => boolean; forged: (entry: StoredEntry) => void };
|
|
227
|
+
|
|
228
|
+
/** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
|
|
229
|
+
export const LOOK_BACK = { page: 32, pages: 32 };
|
|
230
|
+
|
|
231
|
+
/** What `newestAuthentic` finds: the entry, or why there is none. */
|
|
232
|
+
export type Newest = StoredEntry | 'none' | 'forged' | 'unreached';
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The newest of the entries `page` reads, newest first and before a seq,
|
|
236
|
+
* that `check` finds authentic, read back a page at a time: 'none' when
|
|
237
|
+
* there are none at all, 'forged' when every one is in the vault's name but
|
|
238
|
+
* not its own, and 'unreached' once it has read LOOK_BACK such entries
|
|
239
|
+
* (1024), whether or not more are left.
|
|
240
|
+
*/
|
|
241
|
+
export async function newestAuthentic(page: (before: bigint | undefined) => Promise<StoredEntry[]>, check: Authenticity): Promise<Newest> {
|
|
242
|
+
let before: bigint | undefined;
|
|
243
|
+
for (let read = 0; read < LOOK_BACK.pages; read += 1) {
|
|
244
|
+
const entries = await page(before);
|
|
245
|
+
for (const entry of entries) {
|
|
246
|
+
if (check.authentic(entry)) return entry;
|
|
247
|
+
check.forged(entry);
|
|
248
|
+
}
|
|
249
|
+
if (entries.length < LOOK_BACK.page) return before === undefined && entries.length === 0 ? 'none' : 'forged';
|
|
250
|
+
before = entries.at(-1)!.seq;
|
|
251
|
+
}
|
|
252
|
+
return 'unreached';
|
|
253
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
ALTER TABLE "vault_members" ADD COLUMN "role" text;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE "vault_members" ADD COLUMN "scope" text;--> statement-breakpoint
|
|
3
|
+
-- The vault sets a person's instance role and its scope, as it sets the rest of their row.
|
|
4
|
+
GRANT UPDATE (role, scope) ON vault_members TO coffre_vault;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
ALTER TABLE "credentials" ADD COLUMN "scope" text;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE "service_bindings" ADD COLUMN "scope" text;--> statement-breakpoint
|
|
3
|
+
-- The app seals each token and binding of 0.4 with its account's grants, once, as it seals the rest of their rows.
|
|
4
|
+
GRANT UPDATE (scope) ON credentials, service_bindings TO coffre_app;
|