@coffre/db 0.10.2 → 0.11.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.
Files changed (38) hide show
  1. package/dist/connect.d.ts +1 -1
  2. package/dist/{database-DBtn3jqp.d.ts → database-Dh4W2pAj.d.ts} +1 -1
  3. package/dist/dialect.d.ts +1 -1
  4. package/dist/grants.d.ts +12 -5
  5. package/dist/grants.js +13 -8
  6. package/dist/hyperdrive.d.ts +9 -2
  7. package/dist/hyperdrive.js +16 -2
  8. package/dist/index.d.ts +2 -2
  9. package/dist/log.d.ts +1 -1
  10. package/dist/migrations/postgres/0015_workload_access.sql +43 -0
  11. package/dist/migrations/postgres/0016_spent_tokens.sql +12 -0
  12. package/dist/migrations/postgres/meta/0015_snapshot.json +3256 -0
  13. package/dist/migrations/postgres/meta/0016_snapshot.json +3256 -0
  14. package/dist/migrations/postgres/meta/_journal.json +14 -0
  15. package/dist/migrations/sqlite/0015_workload_access.sql +70 -0
  16. package/dist/migrations/sqlite/meta/0015_snapshot.json +2959 -0
  17. package/dist/migrations/sqlite/meta/_journal.json +7 -0
  18. package/dist/portable.d.ts +2 -2
  19. package/dist/{schema-NBVA9q-s.d.ts → schema-DG2t57Kf.d.ts} +327 -2
  20. package/dist/schema-sqlite.d.ts +346 -1
  21. package/dist/schema-sqlite.js +53 -5
  22. package/dist/schema-version.d.ts +1 -1
  23. package/dist/schema-version.js +21 -0
  24. package/dist/schema.d.ts +2 -2
  25. package/dist/schema.js +54 -5
  26. package/package.json +2 -2
  27. package/src/grants.ts +18 -9
  28. package/src/hyperdrive.ts +16 -2
  29. package/src/migrations/postgres/0015_workload_access.sql +43 -0
  30. package/src/migrations/postgres/0016_spent_tokens.sql +12 -0
  31. package/src/migrations/postgres/meta/0015_snapshot.json +3256 -0
  32. package/src/migrations/postgres/meta/0016_snapshot.json +3256 -0
  33. package/src/migrations/postgres/meta/_journal.json +14 -0
  34. package/src/migrations/sqlite/0015_workload_access.sql +70 -0
  35. package/src/migrations/sqlite/meta/0015_snapshot.json +2959 -0
  36. package/src/migrations/sqlite/meta/_journal.json +7 -0
  37. package/src/schema.sqlite.ts +73 -2
  38. package/src/schema.ts +73 -2
package/dist/connect.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as Database } from "./database-DBtn3jqp.js";
1
+ import { t as Database } from "./database-Dh4W2pAj.js";
2
2
  import { Engine } from "./dialect.js";
3
3
  import { ClientConfig } from "pg";
4
4
  //#region src/postgres.d.ts
@@ -1,4 +1,4 @@
1
- import { m as schema_d_exports } from "./schema-NBVA9q-s.js";
1
+ import { h as schema_d_exports } from "./schema-DG2t57Kf.js";
2
2
  import { Query, Table } from "drizzle-orm";
3
3
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
  import pg from "pg";
package/dist/dialect.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { i as Queryable } from "./database-DBtn3jqp.js";
1
+ import { i as Queryable } from "./database-Dh4W2pAj.js";
2
2
  import { SQL, SQLWrapper } from "drizzle-orm";
3
3
  import { PgTable } from "drizzle-orm/pg-core";
4
4
  //#region src/dialect.d.ts
package/dist/grants.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { i as Queryable } from "./database-DBtn3jqp.js";
1
+ import { i as Queryable } from "./database-Dh4W2pAj.js";
2
2
  import { Authenticity, Newest } from "./log.js";
3
3
  import { StoredEntry } from "@coffre/core/audit";
4
4
  import { Conversion, ConversionInput, InstanceRole, Scope } from "@coffre/core/access";
@@ -92,17 +92,24 @@ export type EveryProjectPreview = {
92
92
  * converts is listed.
93
93
  */
94
94
  export declare function everyProjectPreview(db: Queryable, at: number): Promise<EveryProjectPreview[]>;
95
- /** A service token or trust binding of 0.4, which carries no scope of its own yet. */
95
+ /**
96
+ * A service token or trust binding whose scope this release has not sealed
97
+ * yet: none of its own, as 0.4 made it (`scope` null), or one that does not
98
+ * say where it reads sensitive values, as a release before 0.11 made it. By
99
+ * whom it was issued, `createdBy`, a person's email.
100
+ */
96
101
  export type Unsealed = {
97
102
  kind: 'token' | 'binding';
98
103
  id: string;
99
104
  principal: string;
105
+ createdBy: string;
106
+ scope: string | null;
100
107
  };
101
108
  /**
102
109
  * The live service tokens and trust bindings of active service accounts
103
- * that carry no scope yet, as 0.4 made them: what the app seals with each
104
- * account's grants once a process (`sealScopes`), and what `coffre migrate`
105
- * says it will. A credential a binding issued carries its binding's.
110
+ * whose scope is not sealed yet (`Unsealed`): what the app seals once a
111
+ * process (`sealScopes`), and what `coffre migrate` says it will. A
112
+ * credential a binding issued carries its binding's.
106
113
  */
107
114
  export declare function unsealedCredentials(db: Queryable, at: Date): Promise<Unsealed[]>;
108
115
  /**
package/dist/grants.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { tombstone, truth } from "./dialect.js";
2
2
  import { a as prepared, i as tablesOf } from "./database-BadNeeN-.js";
3
3
  import { entryColumns, newestAuthentic } from "./log.js";
4
- import { and, asc, desc, eq, gt, inArray, isNull, lt, or, sql } from "drizzle-orm";
4
+ import { and, asc, desc, eq, gt, inArray, isNull, like, lt, or, sql } from "drizzle-orm";
5
5
  import { EVERYWHERE, administers, convertEveryProjectGrants, isInstanceRole, isScope, normalScope, unscoped } from "@coffre/core/access";
6
6
  import { ACCESS_ACTIONS } from "@coffre/core/vault";
7
7
  //#region src/grants.ts
@@ -170,19 +170,24 @@ async function everyProjectPreview(db, at) {
170
170
  }
171
171
  /**
172
172
  * The live service tokens and trust bindings of active service accounts
173
- * that carry no scope yet, as 0.4 made them: what the app seals with each
174
- * account's grants once a process (`sealScopes`), and what `coffre migrate`
175
- * says it will. A credential a binding issued carries its binding's.
173
+ * whose scope is not sealed yet (`Unsealed`): what the app seals once a
174
+ * process (`sealScopes`), and what `coffre migrate` says it will. A
175
+ * credential a binding issued carries its binding's.
176
176
  */
177
177
  async function unsealedCredentials(db, at) {
178
178
  const { credentials, serviceBindings, vaultMembers } = tablesOf(db);
179
+ const unsealed = (scope) => or(isNull(scope), and(like(scope, "%\"projectId\"%"), sql`${scope} NOT LIKE '%"sensitive":%'`));
179
180
  const [tokens, bindings] = await Promise.all([db.select({
180
181
  id: credentials.id,
181
- principal: credentials.principal
182
- }).from(credentials).innerJoin(vaultMembers, and(eq(vaultMembers.principal, credentials.principal), eq(vaultMembers.generation, credentials.generation))).where(and(eq(credentials.kind, "service"), isNull(credentials.scope), sql`${credentials.createdBy} NOT LIKE 'binding:%'`, isNull(credentials.revokedAt), gt(credentials.expiresAt, at), eq(vaultMembers.status, "active"))), db.select({
182
+ principal: credentials.principal,
183
+ createdBy: credentials.createdBy,
184
+ scope: credentials.scope
185
+ }).from(credentials).innerJoin(vaultMembers, and(eq(vaultMembers.principal, credentials.principal), eq(vaultMembers.generation, credentials.generation))).where(and(eq(credentials.kind, "service"), unsealed(credentials.scope), sql`${credentials.createdBy} NOT LIKE 'binding:%'`, isNull(credentials.revokedAt), gt(credentials.expiresAt, at), eq(vaultMembers.status, "active"))), db.select({
183
186
  id: serviceBindings.id,
184
- principal: serviceBindings.principal
185
- }).from(serviceBindings).innerJoin(vaultMembers, and(eq(vaultMembers.principal, serviceBindings.principal), eq(vaultMembers.generation, serviceBindings.generation))).where(and(isNull(serviceBindings.scope), isNull(serviceBindings.revokedAt), eq(vaultMembers.status, "active")))]);
187
+ principal: serviceBindings.principal,
188
+ createdBy: serviceBindings.createdBy,
189
+ scope: serviceBindings.scope
190
+ }).from(serviceBindings).innerJoin(vaultMembers, and(eq(vaultMembers.principal, serviceBindings.principal), eq(vaultMembers.generation, serviceBindings.generation))).where(and(unsealed(serviceBindings.scope), isNull(serviceBindings.revokedAt), eq(vaultMembers.status, "active")))]);
186
191
  return [...tokens.map((row) => ({
187
192
  kind: "token",
188
193
  ...row
@@ -1,4 +1,4 @@
1
- import { n as PoolLike } from "./database-DBtn3jqp.js";
1
+ import { n as PoolLike } from "./database-Dh4W2pAj.js";
2
2
  import pg from "pg";
3
3
  //#region src/hyperdrive.d.ts
4
4
  /** Postgres through a Hyperdrive binding: `postgres(env.HYPERDRIVE)`. */
@@ -36,7 +36,14 @@ export declare function postgres(hyperdrive: {
36
36
  */
37
37
  export declare class HyperdrivePool implements Exclude<PoolLike, pg.Pool> {
38
38
  #private;
39
- constructor(connectionString: string);
39
+ /**
40
+ * `closeIdleMs`: for what outlives a call, a Durable Object, which queries
41
+ * now and then: the client closes once it has been idle that long, and
42
+ * the next query connects again.
43
+ */
44
+ constructor(connectionString: string, options?: {
45
+ closeIdleMs?: number;
46
+ });
40
47
  readonly query: pg.Pool['query'];
41
48
  /** The client, for a transaction: held from now until `release`. */
42
49
  connect(): Promise<Pick<pg.PoolClient, 'query'> & {
@@ -43,12 +43,22 @@ var HyperdrivePool = class {
43
43
  #busy = 0;
44
44
  /** What waits for the client to be idle: `end`. */
45
45
  #idle = [];
46
- constructor(connectionString) {
46
+ /** How long the client may sit idle before it closes, or null: closed by `end` alone. */
47
+ #closeIdleMs;
48
+ #idleTimer;
49
+ /**
50
+ * `closeIdleMs`: for what outlives a call, a Durable Object, which queries
51
+ * now and then: the client closes once it has been idle that long, and
52
+ * the next query connects again.
53
+ */
54
+ constructor(connectionString, options = {}) {
47
55
  if (connectionString.trim().length === 0) throw new Error("Hyperdrive returned an empty connection string");
48
56
  this.#connectionString = connectionString;
57
+ this.#closeIdleMs = options.closeIdleMs ?? null;
49
58
  }
50
59
  query = (async (...args) => {
51
60
  this.#busy += 1;
61
+ clearTimeout(this.#idleTimer);
52
62
  try {
53
63
  const client = await this.#connected();
54
64
  return await new Promise((resolve, reject) => {
@@ -61,6 +71,7 @@ var HyperdrivePool = class {
61
71
  /** The client, for a transaction: held from now until `release`. */
62
72
  async connect() {
63
73
  this.#busy += 1;
74
+ clearTimeout(this.#idleTimer);
64
75
  let client;
65
76
  try {
66
77
  client = await this.#connected();
@@ -86,6 +97,7 @@ var HyperdrivePool = class {
86
97
  }
87
98
  /** Close the client once nothing waits on it: what a Worker hands `waitUntil` when the call is done. */
88
99
  async end() {
100
+ clearTimeout(this.#idleTimer);
89
101
  while (this.#busy > 0) await new Promise((resolve) => this.#idle.push(resolve));
90
102
  const client = this.#client;
91
103
  this.#client = null;
@@ -104,7 +116,9 @@ var HyperdrivePool = class {
104
116
  /** One query or transaction done: once none is left, what waits for the client to be idle goes on. */
105
117
  #done() {
106
118
  this.#busy -= 1;
107
- if (this.#busy === 0) for (const resolve of this.#idle.splice(0)) resolve();
119
+ if (this.#busy > 0) return;
120
+ for (const resolve of this.#idle.splice(0)) resolve();
121
+ if (this.#closeIdleMs !== null && this.#client !== null) this.#idleTimer = setTimeout(() => void this.end(), this.#closeIdleMs);
108
122
  }
109
123
  /** The call's client, connecting it the first time. One that fails is dropped: the next query connects again. */
110
124
  #connected() {
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import { m as schema_d_exports } from "./schema-NBVA9q-s.js";
2
- import { a as Transaction, c as tablesOf, i as Queryable, l as prepared, n as PoolLike, o as createDatabase, r as QueryOutsideTransaction, s as own, t as Database } from "./database-DBtn3jqp.js";
1
+ import { h as schema_d_exports } from "./schema-DG2t57Kf.js";
2
+ import { a as Transaction, c as tablesOf, i as Queryable, l as prepared, n as PoolLike, o as createDatabase, r as QueryOutsideTransaction, s as own, t as Database } from "./database-Dh4W2pAj.js";
3
3
  export { Database, PoolLike, QueryOutsideTransaction, Queryable, Transaction, createDatabase, own, prepared, schema_d_exports as schema, tablesOf };
package/dist/log.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as Transaction, i as Queryable } from "./database-DBtn3jqp.js";
1
+ import { a as Transaction, i as Queryable } from "./database-Dh4W2pAj.js";
2
2
  import { Placeholder, SQL } from "drizzle-orm";
3
3
  import { LogKey, StoredEntry } from "@coffre/core/audit";
4
4
  //#region src/log.d.ts
@@ -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;