@coffre/db 0.10.2 → 0.12.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-DBtn3jqp.d.ts → database-Ds1Y79h6.d.ts} +1 -1
- package/dist/dialect.d.ts +1 -1
- package/dist/grants.d.ts +12 -5
- package/dist/grants.js +13 -8
- package/dist/hyperdrive.d.ts +9 -2
- package/dist/hyperdrive.js +16 -2
- package/dist/index.d.ts +2 -2
- package/dist/log.d.ts +1 -1
- package/dist/migrations/postgres/0015_workload_access.sql +43 -0
- package/dist/migrations/postgres/0016_spent_tokens.sql +12 -0
- package/dist/migrations/postgres/0017_capped_alerts.sql +10 -0
- package/dist/migrations/postgres/meta/0015_snapshot.json +3256 -0
- package/dist/migrations/postgres/meta/0016_snapshot.json +3256 -0
- package/dist/migrations/postgres/meta/0017_snapshot.json +3295 -0
- package/dist/migrations/postgres/meta/_journal.json +21 -0
- package/dist/migrations/sqlite/0015_workload_access.sql +70 -0
- package/dist/migrations/sqlite/0016_capped_alerts.sql +7 -0
- package/dist/migrations/sqlite/meta/0015_snapshot.json +2959 -0
- package/dist/migrations/sqlite/meta/0016_snapshot.json +2998 -0
- package/dist/migrations/sqlite/meta/_journal.json +14 -0
- package/dist/portable.d.ts +2 -2
- package/dist/{schema-NBVA9q-s.d.ts → schema-DcVkJhCf.d.ts} +393 -2
- package/dist/schema-sqlite.d.ts +408 -1
- package/dist/schema-sqlite.js +61 -5
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +35 -0
- package/dist/schema.d.ts +2 -2
- package/dist/schema.js +71 -5
- package/package.json +2 -2
- package/src/grants.ts +18 -9
- package/src/hyperdrive.ts +16 -2
- package/src/migrations/postgres/0015_workload_access.sql +43 -0
- package/src/migrations/postgres/0016_spent_tokens.sql +12 -0
- package/src/migrations/postgres/0017_capped_alerts.sql +10 -0
- package/src/migrations/postgres/meta/0015_snapshot.json +3256 -0
- package/src/migrations/postgres/meta/0016_snapshot.json +3256 -0
- package/src/migrations/postgres/meta/0017_snapshot.json +3295 -0
- package/src/migrations/postgres/meta/_journal.json +21 -0
- package/src/migrations/sqlite/0015_workload_access.sql +70 -0
- package/src/migrations/sqlite/0016_capped_alerts.sql +7 -0
- package/src/migrations/sqlite/meta/0015_snapshot.json +2959 -0
- package/src/migrations/sqlite/meta/0016_snapshot.json +2998 -0
- package/src/migrations/sqlite/meta/_journal.json +14 -0
- package/src/schema.sqlite.ts +83 -2
- package/src/schema.ts +91 -2
package/dist/schema.js
CHANGED
|
@@ -6,10 +6,12 @@ import { bigint, boolean, check, customType, foreignKey, index, integer, pgTable
|
|
|
6
6
|
var schema_exports = /* @__PURE__ */ __exportAll({
|
|
7
7
|
auditChainHead: () => auditChainHead,
|
|
8
8
|
auditLog: () => auditLog,
|
|
9
|
+
cappedAlerts: () => cappedAlerts,
|
|
9
10
|
consumedTokens: () => consumedTokens,
|
|
10
11
|
credentials: () => credentials,
|
|
11
12
|
deviceAuthorizations: () => deviceAuthorizations,
|
|
12
13
|
dismissedKeys: () => dismissedKeys,
|
|
14
|
+
enrollments: () => enrollments,
|
|
13
15
|
environments: () => environments,
|
|
14
16
|
environmentsRelations: () => environmentsRelations,
|
|
15
17
|
identities: () => identities,
|
|
@@ -302,6 +304,8 @@ const auditLog = pgTable("audit_log", {
|
|
|
302
304
|
index("audit_log_releases_idx").on(table.author, table.actor, table.action, table.decision, table.occurredAt),
|
|
303
305
|
index("audit_log_subject_idx").on(table.author, table.subjectPrincipal, table.seq),
|
|
304
306
|
index("audit_log_unbind_idx").on(sql`((${table.metadata})::jsonb ->> 'bindingId')`).where(sql`${table.author} = 'app' AND ${table.action} = 'token.unbind' AND ${table.decision} = 'allow'`),
|
|
307
|
+
index("audit_log_freeze_idx").on(sql`((${table.metadata})::jsonb ->> 'target')`, table.seq.desc()).where(sql`${table.author} = 'app' AND ${table.action} IN ('workload.freeze', 'workload.unfreeze') AND ${table.decision} = 'allow'`),
|
|
308
|
+
index("audit_log_supersede_idx").on(sql`((${table.metadata})::jsonb ->> 'enrollmentId')`).where(sql`${table.author} = 'app' AND ${table.action} = 'enrollment.supersede' AND ${table.decision} = 'allow'`),
|
|
305
309
|
index("audit_log_exchange_idx").on(sql`((${table.metadata})::jsonb ->> 'credentialId')`).where(sql`${table.author} = 'app' AND ${table.action} = 'token.exchange' AND ${table.decision} = 'allow'`),
|
|
306
310
|
index("audit_log_token_create_idx").on(sql`((${table.metadata})::jsonb ->> 'targetCredentialId')`).where(sql`${table.author} = 'app' AND ${table.action} = 'token.create' AND ${table.decision} = 'allow'`),
|
|
307
311
|
index("audit_log_reference_end_idx").on(table.relatedSeq).where(sql`${table.author} = 'vault' AND ${table.action} = 'reference.end' AND ${table.decision} = 'allow'`),
|
|
@@ -414,6 +418,7 @@ const serviceBindings = pgTable("service_bindings", {
|
|
|
414
418
|
jwks: text(),
|
|
415
419
|
claims: text().notNull(),
|
|
416
420
|
scope: text(),
|
|
421
|
+
policy: text(),
|
|
417
422
|
label: text(),
|
|
418
423
|
createdAt: createdAt(),
|
|
419
424
|
createdBy: text("created_by").notNull(),
|
|
@@ -443,6 +448,22 @@ const consumedTokens = pgTable("consumed_tokens", {
|
|
|
443
448
|
consumedAt: timestamp("consumed_at", { withTimezone: true }).notNull().defaultNow()
|
|
444
449
|
}, (table) => [check("consumed_tokens_hash_check", sql`octet_length(${table.hash}) = 32`)]);
|
|
445
450
|
/**
|
|
451
|
+
* When a service's capped alert was last raised, by kind: the refusal of a
|
|
452
|
+
* token signed by a key its uploaded keys lack (`unknown_key`) alerts once
|
|
453
|
+
* an hour, however many isolates see it. One conditional write per refusal
|
|
454
|
+
* decides (`claimAlert`), so that the many it caps never reach the log's
|
|
455
|
+
* head. Only a request that a binding of the service on the token's issuer
|
|
456
|
+
* gets as far as its keys writes here: a row per service, at most, per kind.
|
|
457
|
+
*/
|
|
458
|
+
const cappedAlerts = pgTable("capped_alerts", {
|
|
459
|
+
service: text().notNull(),
|
|
460
|
+
kind: text().notNull(),
|
|
461
|
+
raisedAt: timestamp("raised_at", { withTimezone: true }).notNull()
|
|
462
|
+
}, (table) => [primaryKey({
|
|
463
|
+
name: "capped_alerts_pkey",
|
|
464
|
+
columns: [table.service, table.kind]
|
|
465
|
+
})]);
|
|
466
|
+
/**
|
|
446
467
|
* MCP clients that registered themselves (RFC 7591), the fallback to Client
|
|
447
468
|
* ID Metadata Documents, which need no row. The ID is the client's
|
|
448
469
|
* `client_id`. Its redirects are under its MAC: a database writer cannot add
|
|
@@ -533,6 +554,37 @@ const mcpApprovals = pgTable("mcp_approvals", {
|
|
|
533
554
|
index("mcp_approvals_open_idx").on(table.connectionId, table.digest).where(sql`${table.reportedAt} IS NULL`)
|
|
534
555
|
]);
|
|
535
556
|
/**
|
|
557
|
+
* Enrollment codes (docs/design/workload-access.md, section 4.4): what CI
|
|
558
|
+
* mints for a platform that signs no identity, and the app exchanges for a
|
|
559
|
+
* workload's session at `/api/auth/workload`. Never a credential itself, and
|
|
560
|
+
* only ever added: the log says which code a promote superseded. Its scope,
|
|
561
|
+
* fixed at minting, is the binding's within what the minter held; the MAC
|
|
562
|
+
* covers everything but its hint and creation time (auth-rows.ts in the server).
|
|
563
|
+
*/
|
|
564
|
+
const enrollments = pgTable("enrollments", {
|
|
565
|
+
id: uuid().primaryKey(),
|
|
566
|
+
authMac: bytea("auth_mac").notNull(),
|
|
567
|
+
bindingId: uuid("binding_id").notNull(),
|
|
568
|
+
codeHash: bytea("code_hash").notNull(),
|
|
569
|
+
codeHint: text("code_hint").notNull(),
|
|
570
|
+
scope: text().notNull(),
|
|
571
|
+
createdAt: createdAt(),
|
|
572
|
+
createdBy: text("created_by").notNull(),
|
|
573
|
+
createdWith: uuid("created_with"),
|
|
574
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull()
|
|
575
|
+
}, (table) => [
|
|
576
|
+
unique("enrollments_code_hash_key").on(table.codeHash),
|
|
577
|
+
check("enrollments_auth_mac_check", sql`octet_length(${table.authMac}) = 32`),
|
|
578
|
+
check("enrollments_code_hash_check", sql`octet_length(${table.codeHash}) = 32`),
|
|
579
|
+
check("enrollments_scope_check", sql`${table.scope}::jsonb IS NOT NULL`),
|
|
580
|
+
foreignKey({
|
|
581
|
+
name: "enrollments_binding_id_fkey",
|
|
582
|
+
columns: [table.bindingId],
|
|
583
|
+
foreignColumns: [serviceBindings.id]
|
|
584
|
+
}).onDelete("restrict"),
|
|
585
|
+
index("enrollments_binding_idx").on(table.bindingId, table.createdAt.desc())
|
|
586
|
+
]);
|
|
587
|
+
/**
|
|
536
588
|
* Bearer credentials coffre issues itself: browser sessions, CLI sessions and
|
|
537
589
|
* service tokens.
|
|
538
590
|
*
|
|
@@ -558,12 +610,20 @@ const credentials = pgTable("credentials", {
|
|
|
558
610
|
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
|
559
611
|
lastUsedIp: text("last_used_ip"),
|
|
560
612
|
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
|
561
|
-
revokedBy: text("revoked_by")
|
|
613
|
+
revokedBy: text("revoked_by"),
|
|
614
|
+
jkt: text(),
|
|
615
|
+
notAfter: timestamp("not_after", { withTimezone: true }),
|
|
616
|
+
ancestors: text(),
|
|
617
|
+
constraints: text(),
|
|
618
|
+
enrollmentId: uuid("enrollment_id"),
|
|
619
|
+
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
|
620
|
+
approvedBy: text("approved_by")
|
|
562
621
|
}, (table) => [
|
|
563
622
|
unique("credentials_token_hash_key").on(table.tokenHash),
|
|
564
|
-
check("credentials_kind_check", sql`${table.kind} IN ('browser', 'cli', 'service', 'personal', 'handle')`),
|
|
623
|
+
check("credentials_kind_check", sql`${table.kind} IN ('browser', 'cli', 'service', 'personal', 'handle', 'workload')`),
|
|
565
624
|
check("credentials_principal_check", sql`${table.principal} LIKE 'user:%' OR ${table.principal} LIKE 'token:%'`),
|
|
566
|
-
check("credentials_kind_matches_principal", sql`(${table.kind}
|
|
625
|
+
check("credentials_kind_matches_principal", sql`(${table.kind} IN ('service', 'workload')) = (${table.principal} LIKE 'token:%')`),
|
|
626
|
+
check("credentials_workload_jkt_check", sql`${table.kind} <> 'workload' OR ${table.jkt} IS NOT NULL`),
|
|
567
627
|
check("credentials_token_hash_check", sql`octet_length(${table.tokenHash}) = 32`),
|
|
568
628
|
check("credentials_auth_mac_check", sql`octet_length(${table.authMac}) = 32`),
|
|
569
629
|
foreignKey({
|
|
@@ -586,7 +646,13 @@ const credentials = pgTable("credentials", {
|
|
|
586
646
|
}).onDelete("restrict"),
|
|
587
647
|
index("credentials_principal_idx").on(table.principal, table.generation),
|
|
588
648
|
index("credentials_live_idx").on(table.principal, table.expiresAt).where(sql`${table.revokedAt} IS NULL`),
|
|
589
|
-
index("credentials_issued_by_idx").on(table.principal, table.createdBy, table.createdAt)
|
|
649
|
+
index("credentials_issued_by_idx").on(table.principal, table.createdBy, table.createdAt),
|
|
650
|
+
foreignKey({
|
|
651
|
+
name: "credentials_enrollment_id_fkey",
|
|
652
|
+
columns: [table.enrollmentId],
|
|
653
|
+
foreignColumns: [enrollments.id]
|
|
654
|
+
}).onDelete("restrict"),
|
|
655
|
+
index("credentials_enrollment_roots_idx").on(table.enrollmentId, table.createdAt).where(sql`${table.kind} = 'workload' AND ${table.ancestors} IS NULL`)
|
|
590
656
|
]);
|
|
591
657
|
/**
|
|
592
658
|
* A CLI asking to be signed in from a browser (RFC 8628, device flow).
|
|
@@ -632,4 +698,4 @@ const { environmentsRelations, secretsRelations } = relationsOf({
|
|
|
632
698
|
secretVersions
|
|
633
699
|
});
|
|
634
700
|
//#endregion
|
|
635
|
-
export { auditChainHead, auditLog, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, schema_exports as t, vaultGrants, vaultMembers };
|
|
701
|
+
export { auditChainHead, auditLog, cappedAlerts, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, enrollments, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, schema_exports as t, vaultGrants, vaultMembers };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "coffre's database: the schema, its migrations and the connections, on Postgres or SQLite.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"drizzle-orm": "0.45.2",
|
|
80
80
|
"pg": "8.16.3",
|
|
81
81
|
"pg-connection-string": "2.14.0",
|
|
82
|
-
"@coffre/core": "0.
|
|
82
|
+
"@coffre/core": "0.12.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@types/node": "26.1.1",
|
package/src/grants.ts
CHANGED
|
@@ -205,35 +205,44 @@ export async function everyProjectPreview(db: Queryable, at: number): Promise<Ev
|
|
|
205
205
|
});
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
/**
|
|
209
|
-
|
|
208
|
+
/**
|
|
209
|
+
* A service token or trust binding whose scope this release has not sealed
|
|
210
|
+
* yet: none of its own, as 0.4 made it (`scope` null), or one that does not
|
|
211
|
+
* say where it reads sensitive values, as a release before 0.11 made it. By
|
|
212
|
+
* whom it was issued, `createdBy`, a person's email.
|
|
213
|
+
*/
|
|
214
|
+
export type Unsealed = { kind: 'token' | 'binding'; id: string; principal: string; createdBy: string; scope: string | null };
|
|
210
215
|
|
|
211
216
|
/**
|
|
212
217
|
* The live service tokens and trust bindings of active service accounts
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
218
|
+
* whose scope is not sealed yet (`Unsealed`): what the app seals once a
|
|
219
|
+
* process (`sealScopes`), and what `coffre migrate` says it will. A
|
|
220
|
+
* credential a binding issued carries its binding's.
|
|
216
221
|
*/
|
|
217
222
|
export async function unsealedCredentials(db: Queryable, at: Date): Promise<Unsealed[]> {
|
|
218
223
|
const { credentials, serviceBindings, vaultMembers } = tablesOf(db);
|
|
224
|
+
// Every scope this release writes for a service says, at each place it names, whether it reads the sensitive values
|
|
225
|
+
// there; one that names none, sealed while its account held nothing, is sealed (`unsealedScope` in @coffre/server).
|
|
226
|
+
const unsealed = (scope: typeof credentials.scope | typeof serviceBindings.scope) =>
|
|
227
|
+
or(isNull(scope), and(like(scope, '%"projectId"%'), sql`${scope} NOT LIKE '%"sensitive":%'`));
|
|
219
228
|
const [tokens, bindings] = await Promise.all([
|
|
220
229
|
db
|
|
221
|
-
.select({ id: credentials.id, principal: credentials.principal })
|
|
230
|
+
.select({ id: credentials.id, principal: credentials.principal, createdBy: credentials.createdBy, scope: credentials.scope })
|
|
222
231
|
.from(credentials)
|
|
223
232
|
.innerJoin(vaultMembers, and(eq(vaultMembers.principal, credentials.principal), eq(vaultMembers.generation, credentials.generation)))
|
|
224
233
|
.where(and(
|
|
225
234
|
eq(credentials.kind, 'service'),
|
|
226
|
-
|
|
235
|
+
unsealed(credentials.scope),
|
|
227
236
|
sql`${credentials.createdBy} NOT LIKE 'binding:%'`,
|
|
228
237
|
isNull(credentials.revokedAt),
|
|
229
238
|
gt(credentials.expiresAt, at),
|
|
230
239
|
eq(vaultMembers.status, 'active'),
|
|
231
240
|
)),
|
|
232
241
|
db
|
|
233
|
-
.select({ id: serviceBindings.id, principal: serviceBindings.principal })
|
|
242
|
+
.select({ id: serviceBindings.id, principal: serviceBindings.principal, createdBy: serviceBindings.createdBy, scope: serviceBindings.scope })
|
|
234
243
|
.from(serviceBindings)
|
|
235
244
|
.innerJoin(vaultMembers, and(eq(vaultMembers.principal, serviceBindings.principal), eq(vaultMembers.generation, serviceBindings.generation)))
|
|
236
|
-
.where(and(
|
|
245
|
+
.where(and(unsealed(serviceBindings.scope), isNull(serviceBindings.revokedAt), eq(vaultMembers.status, 'active'))),
|
|
237
246
|
]);
|
|
238
247
|
return [
|
|
239
248
|
...tokens.map((row) => ({ kind: 'token' as const, ...row })),
|
package/src/hyperdrive.ts
CHANGED
|
@@ -48,16 +48,26 @@ export class HyperdrivePool implements Exclude<PoolLike, pg.Pool> {
|
|
|
48
48
|
#busy = 0;
|
|
49
49
|
/** What waits for the client to be idle: `end`. */
|
|
50
50
|
readonly #idle: (() => void)[] = [];
|
|
51
|
+
/** How long the client may sit idle before it closes, or null: closed by `end` alone. */
|
|
52
|
+
readonly #closeIdleMs: number | null;
|
|
53
|
+
#idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
51
54
|
|
|
52
|
-
|
|
55
|
+
/**
|
|
56
|
+
* `closeIdleMs`: for what outlives a call, a Durable Object, which queries
|
|
57
|
+
* now and then: the client closes once it has been idle that long, and
|
|
58
|
+
* the next query connects again.
|
|
59
|
+
*/
|
|
60
|
+
constructor(connectionString: string, options: { closeIdleMs?: number } = {}) {
|
|
53
61
|
if (connectionString.trim().length === 0) {
|
|
54
62
|
throw new Error('Hyperdrive returned an empty connection string');
|
|
55
63
|
}
|
|
56
64
|
this.#connectionString = connectionString;
|
|
65
|
+
this.#closeIdleMs = options.closeIdleMs ?? null;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
readonly query: pg.Pool['query'] = (async (...args: unknown[]) => {
|
|
60
69
|
this.#busy += 1;
|
|
70
|
+
clearTimeout(this.#idleTimer);
|
|
61
71
|
try {
|
|
62
72
|
const client = await this.#connected();
|
|
63
73
|
return await new Promise((resolve, reject) => {
|
|
@@ -72,6 +82,7 @@ export class HyperdrivePool implements Exclude<PoolLike, pg.Pool> {
|
|
|
72
82
|
/** The client, for a transaction: held from now until `release`. */
|
|
73
83
|
async connect(): Promise<Pick<pg.PoolClient, 'query'> & { release: () => void }> {
|
|
74
84
|
this.#busy += 1;
|
|
85
|
+
clearTimeout(this.#idleTimer);
|
|
75
86
|
let client: pg.Client;
|
|
76
87
|
try {
|
|
77
88
|
client = await this.#connected();
|
|
@@ -100,6 +111,7 @@ export class HyperdrivePool implements Exclude<PoolLike, pg.Pool> {
|
|
|
100
111
|
|
|
101
112
|
/** Close the client once nothing waits on it: what a Worker hands `waitUntil` when the call is done. */
|
|
102
113
|
async end(): Promise<void> {
|
|
114
|
+
clearTimeout(this.#idleTimer);
|
|
103
115
|
while (this.#busy > 0) {
|
|
104
116
|
await new Promise<void>((resolve) => this.#idle.push(resolve));
|
|
105
117
|
}
|
|
@@ -123,7 +135,9 @@ export class HyperdrivePool implements Exclude<PoolLike, pg.Pool> {
|
|
|
123
135
|
/** One query or transaction done: once none is left, what waits for the client to be idle goes on. */
|
|
124
136
|
#done(): void {
|
|
125
137
|
this.#busy -= 1;
|
|
126
|
-
if (this.#busy
|
|
138
|
+
if (this.#busy > 0) return;
|
|
139
|
+
for (const resolve of this.#idle.splice(0)) resolve();
|
|
140
|
+
if (this.#closeIdleMs !== null && this.#client !== null) this.#idleTimer = setTimeout(() => void this.end(), this.#closeIdleMs);
|
|
127
141
|
}
|
|
128
142
|
|
|
129
143
|
/** The call's client, connecting it the first time. One that fails is dropped: the next query connects again. */
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- Workload access (docs/design/workload-access.md, section 11): key-bound sessions, their enrollment codes, and bindings' policies. It only adds: the release before neither reads nor writes any of it, and every check is only looser for its rows, so it serves on this schema unchanged.
|
|
2
|
+
CREATE TABLE "enrollments" (
|
|
3
|
+
"id" uuid PRIMARY KEY NOT NULL,
|
|
4
|
+
"auth_mac" "bytea" NOT NULL,
|
|
5
|
+
"binding_id" uuid NOT NULL,
|
|
6
|
+
"code_hash" "bytea" NOT NULL,
|
|
7
|
+
"code_hint" text NOT NULL,
|
|
8
|
+
"scope" text NOT NULL,
|
|
9
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
10
|
+
"created_by" text NOT NULL,
|
|
11
|
+
"created_with" uuid,
|
|
12
|
+
"expires_at" timestamp with time zone NOT NULL,
|
|
13
|
+
CONSTRAINT "enrollments_code_hash_key" UNIQUE("code_hash"),
|
|
14
|
+
CONSTRAINT "enrollments_auth_mac_check" CHECK (octet_length("enrollments"."auth_mac") = 32),
|
|
15
|
+
CONSTRAINT "enrollments_code_hash_check" CHECK (octet_length("enrollments"."code_hash") = 32),
|
|
16
|
+
CONSTRAINT "enrollments_scope_check" CHECK ("enrollments"."scope"::jsonb IS NOT NULL)
|
|
17
|
+
);
|
|
18
|
+
--> statement-breakpoint
|
|
19
|
+
-- The checks widen to a workload's session, a service's credential like a service token; a session carries the key it is bound to.
|
|
20
|
+
ALTER TABLE "credentials" DROP CONSTRAINT "credentials_kind_check";--> statement-breakpoint
|
|
21
|
+
ALTER TABLE "credentials" DROP CONSTRAINT "credentials_kind_matches_principal";--> statement-breakpoint
|
|
22
|
+
ALTER TABLE "credentials" ADD COLUMN "jkt" text;--> statement-breakpoint
|
|
23
|
+
ALTER TABLE "credentials" ADD COLUMN "not_after" timestamp with time zone;--> statement-breakpoint
|
|
24
|
+
ALTER TABLE "credentials" ADD COLUMN "ancestors" text;--> statement-breakpoint
|
|
25
|
+
ALTER TABLE "credentials" ADD COLUMN "constraints" text;--> statement-breakpoint
|
|
26
|
+
ALTER TABLE "credentials" ADD COLUMN "enrollment_id" uuid;--> statement-breakpoint
|
|
27
|
+
ALTER TABLE "credentials" ADD COLUMN "approved_at" timestamp with time zone;--> statement-breakpoint
|
|
28
|
+
ALTER TABLE "credentials" ADD COLUMN "approved_by" text;--> statement-breakpoint
|
|
29
|
+
-- A binding's policy, inserted with it under the table-wide INSERT, and named by no UPDATE grant: as immutable as the rest of it.
|
|
30
|
+
ALTER TABLE "service_bindings" ADD COLUMN "policy" text;--> statement-breakpoint
|
|
31
|
+
ALTER TABLE "enrollments" ADD CONSTRAINT "enrollments_binding_id_fkey" FOREIGN KEY ("binding_id") REFERENCES "public"."service_bindings"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
|
32
|
+
CREATE INDEX "enrollments_binding_idx" ON "enrollments" USING btree ("binding_id","created_at" DESC NULLS LAST);--> statement-breakpoint
|
|
33
|
+
ALTER TABLE "credentials" ADD CONSTRAINT "credentials_enrollment_id_fkey" FOREIGN KEY ("enrollment_id") REFERENCES "public"."enrollments"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
|
34
|
+
-- Freezes are the log's: a target's newest allowed freeze or lift, and a code's supersession, its tombstone.
|
|
35
|
+
CREATE INDEX "audit_log_freeze_idx" ON "audit_log" USING btree ((("metadata")::jsonb ->> 'target'),"seq" DESC NULLS LAST) WHERE "audit_log"."author" = 'app' AND "audit_log"."action" IN ('workload.freeze', 'workload.unfreeze') AND "audit_log"."decision" = 'allow';--> statement-breakpoint
|
|
36
|
+
CREATE INDEX "audit_log_supersede_idx" ON "audit_log" USING btree ((("metadata")::jsonb ->> 'enrollmentId')) WHERE "audit_log"."author" = 'app' AND "audit_log"."action" = 'enrollment.supersede' AND "audit_log"."decision" = 'allow';--> statement-breakpoint
|
|
37
|
+
CREATE INDEX "credentials_enrollment_roots_idx" ON "credentials" USING btree ("enrollment_id","created_at") WHERE "credentials"."kind" = 'workload' AND "credentials"."ancestors" IS NULL;--> statement-breakpoint
|
|
38
|
+
ALTER TABLE "credentials" ADD CONSTRAINT "credentials_workload_jkt_check" CHECK ("credentials"."kind" <> 'workload' OR "credentials"."jkt" IS NOT NULL);--> statement-breakpoint
|
|
39
|
+
ALTER TABLE "credentials" ADD CONSTRAINT "credentials_kind_check" CHECK ("credentials"."kind" IN ('browser', 'cli', 'service', 'personal', 'handle', 'workload'));--> statement-breakpoint
|
|
40
|
+
ALTER TABLE "credentials" ADD CONSTRAINT "credentials_kind_matches_principal" CHECK (("credentials"."kind" IN ('service', 'workload')) = ("credentials"."principal" LIKE 'token:%'));--> statement-breakpoint
|
|
41
|
+
-- The app makes codes and never changes one; a session's key, hard end, ancestors, limits and code are set when it is made, and never after: renewal moves its expiry, and an approval is the one other change.
|
|
42
|
+
GRANT SELECT, INSERT ON enrollments TO coffre_app;--> statement-breakpoint
|
|
43
|
+
GRANT UPDATE (expires_at, approved_at, approved_by) ON credentials TO coffre_app;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Spent tokens (W157): a token's record goes once no exchange could take the token again. An exchange refuses one issued over an hour ago (MAX_TOKEN_AGE_SECONDS, 30 s of clock tolerance), so two hours after it was spent its record guards nothing. The runtime login still deletes no row itself: it asks this function, which deletes only such records, at most at_most a call. The release before never calls it.
|
|
2
|
+
CREATE FUNCTION prune_consumed_tokens(at_most integer) RETURNS integer
|
|
3
|
+
LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$
|
|
4
|
+
WITH pruned AS (
|
|
5
|
+
DELETE FROM public.consumed_tokens
|
|
6
|
+
WHERE hash IN (SELECT hash FROM public.consumed_tokens WHERE consumed_at < now() - interval '2 hours' LIMIT at_most)
|
|
7
|
+
RETURNING 1
|
|
8
|
+
)
|
|
9
|
+
SELECT count(*)::integer FROM pruned;
|
|
10
|
+
$$;--> statement-breakpoint
|
|
11
|
+
REVOKE ALL ON FUNCTION prune_consumed_tokens(integer) FROM PUBLIC;--> statement-breakpoint
|
|
12
|
+
GRANT EXECUTE ON FUNCTION prune_consumed_tokens(integer) TO coffre_app;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Capped alerts (T98): when a service's sign-in was last refused `unknown_key` with an alert, so that the next alerts an hour later at the soonest, whichever isolate sees it. It only adds: the release before neither reads nor writes it.
|
|
2
|
+
CREATE TABLE "capped_alerts" (
|
|
3
|
+
"service" text NOT NULL,
|
|
4
|
+
"kind" text NOT NULL,
|
|
5
|
+
"raised_at" timestamp with time zone NOT NULL,
|
|
6
|
+
CONSTRAINT "capped_alerts_pkey" PRIMARY KEY("service","kind")
|
|
7
|
+
);
|
|
8
|
+
--> statement-breakpoint
|
|
9
|
+
-- The app moves a row's time on, and deletes none.
|
|
10
|
+
GRANT SELECT, INSERT, UPDATE (raised_at) ON capped_alerts TO coffre_app;
|