@coffre/db 0.9.0 → 0.10.1
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/connect.js +1 -1
- package/dist/database-BadNeeN-.js +146 -0
- package/dist/{database-hd1hAll-.d.ts → database-DBtn3jqp.d.ts} +27 -3
- package/dist/dialect.d.ts +1 -1
- package/dist/grants.d.ts +2 -2
- package/dist/grants.js +26 -10
- package/dist/hyperdrive.d.ts +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -89
- package/dist/log.d.ts +26 -6
- package/dist/log.js +86 -21
- package/dist/migrations/postgres/0012_sensitive_values.sql +18 -0
- package/dist/migrations/postgres/0013_contributor_role.sql +5 -0
- package/dist/migrations/postgres/0014_custom_roles.sql +4 -0
- package/dist/migrations/postgres/meta/0012_snapshot.json +3004 -0
- package/dist/migrations/postgres/meta/0013_snapshot.json +3004 -0
- package/dist/migrations/postgres/meta/0014_snapshot.json +3000 -0
- package/dist/migrations/postgres/meta/_journal.json +21 -0
- package/dist/migrations/sqlite/0012_sensitive_values.sql +15 -0
- package/dist/migrations/sqlite/0013_contributor_role.sql +28 -0
- package/dist/migrations/sqlite/0014_custom_roles.sql +27 -0
- package/dist/migrations/sqlite/meta/0012_snapshot.json +2726 -0
- package/dist/migrations/sqlite/meta/0013_snapshot.json +2726 -0
- package/dist/migrations/sqlite/meta/0014_snapshot.json +2722 -0
- package/dist/migrations/sqlite/meta/_journal.json +21 -0
- package/dist/portable.d.ts +2 -2
- package/dist/{schema-CCYKTG-E.d.ts → schema-NBVA9q-s.d.ts} +17 -0
- package/dist/schema-sqlite.d.ts +17 -0
- package/dist/schema-sqlite.js +3 -6
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +42 -0
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +3 -7
- package/package.json +2 -2
- package/src/database.ts +35 -3
- package/src/grants.ts +32 -18
- package/src/log.ts +90 -34
- package/src/migrations/postgres/0012_sensitive_values.sql +18 -0
- package/src/migrations/postgres/0013_contributor_role.sql +5 -0
- package/src/migrations/postgres/0014_custom_roles.sql +4 -0
- package/src/migrations/postgres/meta/0012_snapshot.json +3004 -0
- package/src/migrations/postgres/meta/0013_snapshot.json +3004 -0
- package/src/migrations/postgres/meta/0014_snapshot.json +3000 -0
- package/src/migrations/postgres/meta/_journal.json +21 -0
- package/src/migrations/sqlite/0012_sensitive_values.sql +15 -0
- package/src/migrations/sqlite/0013_contributor_role.sql +28 -0
- package/src/migrations/sqlite/0014_custom_roles.sql +27 -0
- package/src/migrations/sqlite/meta/0012_snapshot.json +2726 -0
- package/src/migrations/sqlite/meta/0013_snapshot.json +2726 -0
- package/src/migrations/sqlite/meta/0014_snapshot.json +2722 -0
- package/src/migrations/sqlite/meta/_journal.json +21 -0
- package/src/prepared.ts +60 -0
- package/src/schema.sqlite.ts +8 -9
- package/src/schema.ts +8 -10
|
@@ -85,6 +85,27 @@
|
|
|
85
85
|
"when": 1792317461299,
|
|
86
86
|
"tag": "0011_plain_values",
|
|
87
87
|
"breakpoints": true
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"idx": 12,
|
|
91
|
+
"version": "6",
|
|
92
|
+
"when": 1792340920098,
|
|
93
|
+
"tag": "0012_sensitive_values",
|
|
94
|
+
"breakpoints": true
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"idx": 13,
|
|
98
|
+
"version": "6",
|
|
99
|
+
"when": 1792343374795,
|
|
100
|
+
"tag": "0013_contributor_role",
|
|
101
|
+
"breakpoints": true
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"idx": 14,
|
|
105
|
+
"version": "6",
|
|
106
|
+
"when": 1792345777255,
|
|
107
|
+
"tag": "0014_custom_roles",
|
|
108
|
+
"breakpoints": true
|
|
88
109
|
}
|
|
89
110
|
]
|
|
90
111
|
}
|
package/dist/portable.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { m as schema_d_exports } from "./schema-
|
|
2
|
-
import { t as Database } from "./database-
|
|
1
|
+
import { m as schema_d_exports } from "./schema-NBVA9q-s.js";
|
|
2
|
+
import { t as Database } from "./database-DBtn3jqp.js";
|
|
3
3
|
import { t as schema_sqlite_d_exports } from "./schema-sqlite.js";
|
|
4
4
|
import { LibSQLDatabase } from "drizzle-orm/libsql";
|
|
5
5
|
//#region src/portable.d.ts
|
|
@@ -731,6 +731,23 @@ declare const secretVersions: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
|
731
731
|
identity: undefined;
|
|
732
732
|
generated: undefined;
|
|
733
733
|
}, {}, {}>;
|
|
734
|
+
sensitive: import("drizzle-orm/pg-core").PgColumn<{
|
|
735
|
+
name: "sensitive";
|
|
736
|
+
tableName: "secret_versions";
|
|
737
|
+
dataType: "boolean";
|
|
738
|
+
columnType: "PgBoolean";
|
|
739
|
+
data: boolean;
|
|
740
|
+
driverParam: boolean;
|
|
741
|
+
notNull: true;
|
|
742
|
+
hasDefault: true;
|
|
743
|
+
isPrimaryKey: false;
|
|
744
|
+
isAutoincrement: false;
|
|
745
|
+
hasRuntimeDefault: false;
|
|
746
|
+
enumValues: undefined;
|
|
747
|
+
baseColumn: never;
|
|
748
|
+
identity: undefined;
|
|
749
|
+
generated: undefined;
|
|
750
|
+
}, {}, {}>;
|
|
734
751
|
};
|
|
735
752
|
dialect: "pg";
|
|
736
753
|
}>;
|
package/dist/schema-sqlite.d.ts
CHANGED
|
@@ -775,6 +775,23 @@ export declare const secretVersions: import("drizzle-orm/sqlite-core").SQLiteTab
|
|
|
775
775
|
}, {}, {
|
|
776
776
|
length: undefined;
|
|
777
777
|
}>;
|
|
778
|
+
sensitive: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
779
|
+
name: string;
|
|
780
|
+
tableName: "secret_versions";
|
|
781
|
+
dataType: "boolean";
|
|
782
|
+
columnType: "SQLiteBoolean";
|
|
783
|
+
data: boolean;
|
|
784
|
+
driverParam: number;
|
|
785
|
+
notNull: true;
|
|
786
|
+
hasDefault: true;
|
|
787
|
+
isPrimaryKey: false;
|
|
788
|
+
isAutoincrement: false;
|
|
789
|
+
hasRuntimeDefault: false;
|
|
790
|
+
enumValues: undefined;
|
|
791
|
+
baseColumn: never;
|
|
792
|
+
identity: undefined;
|
|
793
|
+
generated: undefined;
|
|
794
|
+
}, {}, {}>;
|
|
778
795
|
};
|
|
779
796
|
dialect: "sqlite";
|
|
780
797
|
}>;
|
package/dist/schema-sqlite.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { n as relationsOf, t as ACTIVE_SUBJECT } from "./relations-BfR3JjT1.js";
|
|
2
2
|
import { asPostgres } from "./portable.js";
|
|
3
3
|
import { sql } from "drizzle-orm";
|
|
4
|
-
import { ROLE_NAMES, assignableToEnvironment } from "@coffre/core/access";
|
|
5
4
|
import { blob, check, customType, foreignKey, index, integer, primaryKey, sqliteTable, text, unique, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
6
5
|
//#region src/schema.sqlite.ts
|
|
7
6
|
/**
|
|
@@ -125,7 +124,8 @@ const secretVersions = sqliteTable("secret_versions", {
|
|
|
125
124
|
recipe: text(),
|
|
126
125
|
pairId: text("pair_id"),
|
|
127
126
|
pairHalf: text("pair_half"),
|
|
128
|
-
plaintext: text()
|
|
127
|
+
plaintext: text(),
|
|
128
|
+
sensitive: flag("sensitive").notNull().default(false)
|
|
129
129
|
}, (table) => [
|
|
130
130
|
unique("secret_versions_secret_id_version_key").on(table.secretId, table.version),
|
|
131
131
|
check("secret_versions_version_check", sql`${table.version} > 0`),
|
|
@@ -157,8 +157,6 @@ const dismissedKeys = sqliteTable("dismissed_keys", {
|
|
|
157
157
|
foreignColumns: [environments.id]
|
|
158
158
|
}).onDelete("restrict")
|
|
159
159
|
]);
|
|
160
|
-
const ROLES = sql.raw(ROLE_NAMES.map((role) => `'${role}'`).join(", "));
|
|
161
|
-
const ENVIRONMENT_ROLES = sql.raw(ROLE_NAMES.filter(assignableToEnvironment).map((role) => `'${role}'`).join(", "));
|
|
162
160
|
const vaultMembers = sqliteTable("vault_members", {
|
|
163
161
|
principal: text().primaryKey(),
|
|
164
162
|
status: text().notNull(),
|
|
@@ -195,8 +193,7 @@ const vaultGrants = sqliteTable("vault_grants", {
|
|
|
195
193
|
}, (table) => [
|
|
196
194
|
check("vault_grants_one_place", sql`((${table.projectId} IS NULL) <> (${table.environmentId} IS NULL) AND ${table.environmentSlug} IS NULL) OR (${table.projectId} IS NULL AND ${table.environmentId} IS NULL)`),
|
|
197
195
|
check("vault_grants_environment_slug_check", sql`${table.environmentSlug} IS NULL OR (${isSlug(table.environmentSlug, 63)})`),
|
|
198
|
-
check("vault_grants_role_check", sql
|
|
199
|
-
check("vault_grants_environment_role_check", sql`(${table.environmentId} IS NULL AND ${table.environmentSlug} IS NULL) OR ${table.role} IN (${ENVIRONMENT_ROLES})`),
|
|
196
|
+
check("vault_grants_role_check", sql`length(${table.role}) BETWEEN 2 AND 32 AND ${table.role} GLOB '[a-z]*' AND ${table.role} NOT GLOB '*[^a-z0-9-]*'`),
|
|
200
197
|
unique("vault_grants_on_project").on(table.principal, table.projectId),
|
|
201
198
|
unique("vault_grants_on_environment").on(table.principal, table.environmentId),
|
|
202
199
|
unique("vault_grants_on_environment_slug").on(table.principal, table.environmentSlug),
|
package/dist/schema-version.d.ts
CHANGED
package/dist/schema-version.js
CHANGED
|
@@ -92,6 +92,27 @@ const KNOWN_MIGRATIONS = {
|
|
|
92
92
|
"when": 1792317460399,
|
|
93
93
|
"tag": "0011_plain_values",
|
|
94
94
|
"breakpoints": true
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"idx": 12,
|
|
98
|
+
"version": "7",
|
|
99
|
+
"when": 1792340919452,
|
|
100
|
+
"tag": "0012_sensitive_values",
|
|
101
|
+
"breakpoints": true
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"idx": 13,
|
|
105
|
+
"version": "7",
|
|
106
|
+
"when": 1792343374226,
|
|
107
|
+
"tag": "0013_contributor_role",
|
|
108
|
+
"breakpoints": true
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"idx": 14,
|
|
112
|
+
"version": "7",
|
|
113
|
+
"when": 1792345776262,
|
|
114
|
+
"tag": "0014_custom_roles",
|
|
115
|
+
"breakpoints": true
|
|
95
116
|
}
|
|
96
117
|
].map((entry) => entry.tag),
|
|
97
118
|
sqlite: [
|
|
@@ -178,6 +199,27 @@ const KNOWN_MIGRATIONS = {
|
|
|
178
199
|
"when": 1792317461299,
|
|
179
200
|
"tag": "0011_plain_values",
|
|
180
201
|
"breakpoints": true
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"idx": 12,
|
|
205
|
+
"version": "6",
|
|
206
|
+
"when": 1792340920098,
|
|
207
|
+
"tag": "0012_sensitive_values",
|
|
208
|
+
"breakpoints": true
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"idx": 13,
|
|
212
|
+
"version": "6",
|
|
213
|
+
"when": 1792343374795,
|
|
214
|
+
"tag": "0013_contributor_role",
|
|
215
|
+
"breakpoints": true
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"idx": 14,
|
|
219
|
+
"version": "6",
|
|
220
|
+
"when": 1792345777255,
|
|
221
|
+
"tag": "0014_custom_roles",
|
|
222
|
+
"breakpoints": true
|
|
181
223
|
}
|
|
182
224
|
].map((entry) => entry.tag)
|
|
183
225
|
};
|
package/dist/schema.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { _ as secrets, a as deviceAuthorizations, b as vaultGrants, c as environmentsRelations, d as mcpConnections, f as oauthClients, g as secretVersions, h as secretReferences, i as credentials, l as identities, n as auditLog, o as dismissedKeys, p as projects, r as consumedTokens, s as environments, t as auditChainHead, u as mcpApprovals, v as secretsRelations, x as vaultMembers, y as serviceBindings } from "./schema-
|
|
1
|
+
import { _ as secrets, a as deviceAuthorizations, b as vaultGrants, c as environmentsRelations, d as mcpConnections, f as oauthClients, g as secretVersions, h as secretReferences, i as credentials, l as identities, n as auditLog, o as dismissedKeys, p as projects, r as consumedTokens, s as environments, t as auditChainHead, u as mcpApprovals, v as secretsRelations, x as vaultMembers, y as serviceBindings } from "./schema-NBVA9q-s.js";
|
|
2
2
|
export { auditChainHead, auditLog, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, vaultGrants, vaultMembers };
|
package/dist/schema.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
2
2
|
import { n as relationsOf, t as ACTIVE_SUBJECT } from "./relations-BfR3JjT1.js";
|
|
3
3
|
import { sql } from "drizzle-orm";
|
|
4
|
-
import { ROLE_NAMES, assignableToEnvironment } from "@coffre/core/access";
|
|
5
4
|
import { bigint, boolean, check, customType, foreignKey, index, integer, pgTable, primaryKey, text, timestamp, unique, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
|
6
5
|
//#region src/schema.ts
|
|
7
6
|
var schema_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -130,7 +129,8 @@ const secretVersions = pgTable("secret_versions", {
|
|
|
130
129
|
recipe: text(),
|
|
131
130
|
pairId: uuid("pair_id"),
|
|
132
131
|
pairHalf: text("pair_half"),
|
|
133
|
-
plaintext: text()
|
|
132
|
+
plaintext: text(),
|
|
133
|
+
sensitive: boolean().notNull().default(false)
|
|
134
134
|
}, (table) => [
|
|
135
135
|
unique("secret_versions_secret_id_version_key").on(table.secretId, table.version),
|
|
136
136
|
check("secret_versions_version_check", sql`${table.version} > 0`),
|
|
@@ -169,9 +169,6 @@ const dismissedKeys = pgTable("dismissed_keys", {
|
|
|
169
169
|
foreignColumns: [environments.id]
|
|
170
170
|
}).onDelete("restrict")
|
|
171
171
|
]);
|
|
172
|
-
/** The role catalogue, as SQL: every role, and those an environment may be granted. */
|
|
173
|
-
const ROLES = sql.raw(ROLE_NAMES.map((role) => `'${role}'`).join(", "));
|
|
174
|
-
const ENVIRONMENT_ROLES = sql.raw(ROLE_NAMES.filter(assignableToEnvironment).map((role) => `'${role}'`).join(", "));
|
|
175
172
|
/**
|
|
176
173
|
* The vault's member directory: everyone it has admitted, written by the
|
|
177
174
|
* vault alone, read by both. A principal with no row is no member.
|
|
@@ -220,8 +217,7 @@ const vaultGrants = pgTable("vault_grants", {
|
|
|
220
217
|
}, (table) => [
|
|
221
218
|
check("vault_grants_one_place", sql`((${table.projectId} IS NULL) <> (${table.environmentId} IS NULL) AND ${table.environmentSlug} IS NULL) OR (${table.projectId} IS NULL AND ${table.environmentId} IS NULL)`),
|
|
222
219
|
check("vault_grants_environment_slug_check", sql`${table.environmentSlug} IS NULL OR ${table.environmentSlug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
|
|
223
|
-
check("vault_grants_role_check", sql`${table.role}
|
|
224
|
-
check("vault_grants_environment_role_check", sql`(${table.environmentId} IS NULL AND ${table.environmentSlug} IS NULL) OR ${table.role} IN (${ENVIRONMENT_ROLES})`),
|
|
220
|
+
check("vault_grants_role_check", sql`${table.role} ~ '^[a-z][a-z0-9-]{1,31}$'`),
|
|
225
221
|
unique("vault_grants_on_project").on(table.principal, table.projectId),
|
|
226
222
|
unique("vault_grants_on_environment").on(table.principal, table.environmentId),
|
|
227
223
|
unique("vault_grants_on_environment_slug").on(table.principal, table.environmentSlug),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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.10.1"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@types/node": "26.1.1",
|
package/src/database.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
createTableRelationsHelpers,
|
|
5
|
+
extractTablesRelationalConfig,
|
|
6
|
+
getTableName,
|
|
7
|
+
is,
|
|
8
|
+
Table,
|
|
9
|
+
type ExtractTablesWithRelations,
|
|
10
|
+
type RelationalSchemaConfig,
|
|
11
|
+
} from 'drizzle-orm';
|
|
12
|
+
import { NodePgDatabase, NodePgDriver } from 'drizzle-orm/node-postgres';
|
|
13
|
+
import { PgDialect } from 'drizzle-orm/pg-core';
|
|
5
14
|
import type pg from 'pg';
|
|
6
15
|
|
|
7
16
|
import { trackCommits } from './commits.ts';
|
|
@@ -74,8 +83,29 @@ function brokenRule(error: unknown): QueryOutsideTransaction | undefined {
|
|
|
74
83
|
return undefined;
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
/**
|
|
87
|
+
* What Drizzle makes of the schema, which `drizzle(pool, { schema })` works
|
|
88
|
+
* out again from every table at each call: on Workers, a database is made
|
|
89
|
+
* per call, and this was a fifth of a vault call's CPU. The schema never
|
|
90
|
+
* changes, so it is worked out once per process or isolate, a settled value.
|
|
91
|
+
*/
|
|
92
|
+
type Relational = RelationalSchemaConfig<ExtractTablesWithRelations<typeof schema>>;
|
|
93
|
+
let relational: Relational | undefined;
|
|
94
|
+
|
|
95
|
+
function relationalSchema(): Relational {
|
|
96
|
+
if (relational === undefined) {
|
|
97
|
+
const { tables, tableNamesMap } = extractTablesRelationalConfig(schema, createTableRelationsHelpers);
|
|
98
|
+
// Drizzle's own construct passes the same untyped tables on as these.
|
|
99
|
+
relational = { fullSchema: schema, schema: tables as Relational['schema'], tableNamesMap };
|
|
100
|
+
}
|
|
101
|
+
return relational;
|
|
102
|
+
}
|
|
103
|
+
|
|
77
104
|
export function createDatabase(pool: PoolLike): Database {
|
|
78
|
-
|
|
105
|
+
// What `drizzle(pool, { schema })` builds, but for the schema's config, made once.
|
|
106
|
+
const dialect = new PgDialect();
|
|
107
|
+
const config = relationalSchema();
|
|
108
|
+
const db: Database = new NodePgDatabase(dialect, new NodePgDriver(guarded(pool) as pg.Pool, dialect).createSession(config), config);
|
|
79
109
|
// Each transaction's work runs knowing which one it is in, until that work is done. Not until
|
|
80
110
|
// the COMMIT comes back: the transaction waits on nothing then, so what the work left running
|
|
81
111
|
// may ask for a query, which waits for the transaction to end rather than for itself.
|
|
@@ -132,3 +162,5 @@ export function own<T extends Table>(db: Queryable, table: T): T {
|
|
|
132
162
|
if (found === undefined) throw new Error(`no table ${getTableName(table)} in this database`);
|
|
133
163
|
return found as T;
|
|
134
164
|
}
|
|
165
|
+
|
|
166
|
+
export { prepared } from './prepared.ts';
|
package/src/grants.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type Conversion,
|
|
10
10
|
type ConversionInput,
|
|
11
11
|
type InstanceRole,
|
|
12
|
+
type BuiltinRole,
|
|
12
13
|
type Role,
|
|
13
14
|
type Scope,
|
|
14
15
|
} from '@coffre/core/access';
|
|
@@ -17,8 +18,9 @@ import { ACCESS_ACTIONS } from '@coffre/core/vault';
|
|
|
17
18
|
import { and, asc, desc, eq, gt, inArray, isNull, like, lt, or, sql, type SQL } from 'drizzle-orm';
|
|
18
19
|
|
|
19
20
|
import { tablesOf, type Queryable } from './database.ts';
|
|
21
|
+
import { prepared } from './prepared.ts';
|
|
20
22
|
import { tombstone, truth } from './dialect.ts';
|
|
21
|
-
import { entryColumns,
|
|
23
|
+
import { entryColumns, newestAuthentic, newestVaultEntries, type Authenticity, type Newest } from './log.ts';
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* One grant, as `vault_grants` holds it: on a project (`environmentId`
|
|
@@ -45,20 +47,24 @@ export async function readGrants(
|
|
|
45
47
|
db: Queryable,
|
|
46
48
|
filter: { principal?: string; liveAt?: number } = {},
|
|
47
49
|
): Promise<GrantRow[]> {
|
|
50
|
+
// A member's grants, which every check reads, built once (`prepared`); any other filter, as asked.
|
|
51
|
+
const rows = filter.principal !== undefined && filter.liveAt === undefined
|
|
52
|
+
? await grantsOf(db, { principal: filter.principal })
|
|
53
|
+
: await grantRows(db, filter.principal === undefined ? undefined : eq(tablesOf(db).vaultGrants.principal, filter.principal), filter.liveAt);
|
|
54
|
+
return rows.map(({ grant, environmentProjectId }) => ({ ...grant, projectId: grant.projectId ?? environmentProjectId }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function grantRows(db: Queryable, principal: SQL | undefined, liveAt?: number) {
|
|
48
58
|
const { vaultGrants, environments } = tablesOf(db);
|
|
49
|
-
|
|
59
|
+
return db
|
|
50
60
|
.select({ grant: vaultGrants, environmentProjectId: environments.projectId })
|
|
51
61
|
.from(vaultGrants)
|
|
52
62
|
.leftJoin(environments, eq(environments.id, vaultGrants.environmentId))
|
|
53
|
-
.where(
|
|
54
|
-
and(
|
|
55
|
-
filter.principal === undefined ? undefined : eq(vaultGrants.principal, filter.principal),
|
|
56
|
-
filter.liveAt === undefined ? undefined : or(isNull(vaultGrants.expiresAt), gt(vaultGrants.expiresAt, filter.liveAt)),
|
|
57
|
-
),
|
|
58
|
-
);
|
|
59
|
-
return rows.map(({ grant, environmentProjectId }) => ({ ...grant, projectId: grant.projectId ?? environmentProjectId }));
|
|
63
|
+
.where(and(principal, liveAt === undefined ? undefined : or(isNull(vaultGrants.expiresAt), gt(vaultGrants.expiresAt, liveAt))));
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
const grantsOf = prepared((db) => grantRows(db, eq(tablesOf(db).vaultGrants.principal, sql.placeholder('principal'))));
|
|
67
|
+
|
|
62
68
|
/** Add a grant; an environment's row names only the environment. */
|
|
63
69
|
export async function insertGrant(db: Queryable, grant: GrantRow): Promise<void> {
|
|
64
70
|
const { vaultGrants } = tablesOf(db);
|
|
@@ -129,10 +135,10 @@ export function everyProjectConversion(
|
|
|
129
135
|
role: person ? role : 'member',
|
|
130
136
|
everyProject: live
|
|
131
137
|
.filter((grant) => grant.projectId === null && grant.environmentId === null)
|
|
132
|
-
.map((grant) => ({ environmentSlug: grant.environmentSlug, role: grant.role as
|
|
138
|
+
.map((grant) => ({ environmentSlug: grant.environmentSlug, role: grant.role as BuiltinRole, expiresAt: grant.expiresAt })),
|
|
133
139
|
grants: live
|
|
134
140
|
.filter((grant) => grant.projectId !== null)
|
|
135
|
-
.map((grant) => ({ projectId: grant.projectId, environmentId: grant.environmentId, role: grant.role as
|
|
141
|
+
.map((grant) => ({ projectId: grant.projectId, environmentId: grant.environmentId, role: grant.role as BuiltinRole, expiresAt: grant.expiresAt })),
|
|
136
142
|
projects,
|
|
137
143
|
});
|
|
138
144
|
}
|
|
@@ -288,35 +294,43 @@ export async function reportStands(db: Queryable, principal: string, check: Auth
|
|
|
288
294
|
const access = await newestAccess(db, principal, check);
|
|
289
295
|
if (access === 'unreached') return true;
|
|
290
296
|
const after = typeof access === 'string' ? -1n : access.seq;
|
|
291
|
-
const report = await newestAuthentic((before) => tamperReportsAbout(db, principal,
|
|
297
|
+
const report = await newestAuthentic((limit, before) => tamperReportsAbout(db, principal, limit, after, before), check);
|
|
292
298
|
return report === 'unreached' || typeof report !== 'string';
|
|
293
299
|
}
|
|
294
300
|
|
|
295
301
|
/** The newest access entry about `principal` that `check` finds the vault's own. */
|
|
296
302
|
export function newestAccess(db: Queryable, principal: string, check: Authenticity): Promise<Newest> {
|
|
297
|
-
return newestAuthentic((before) => accessEntriesAbout(db, principal,
|
|
303
|
+
return newestAuthentic((limit, before) => accessEntriesAbout(db, principal, limit, before), check);
|
|
298
304
|
}
|
|
299
305
|
|
|
300
306
|
/** Up to `limit` of the vault's allowed access entries about `principal` before `before` (the newest when undefined), newest first. */
|
|
301
307
|
export async function accessEntriesAbout(db: Queryable, principal: string, limit: number, before?: bigint): Promise<StoredEntry[]> {
|
|
308
|
+
const rows = before === undefined ? await newestAccessEntries(db, { principal, limit }) : await accessEntriesBefore(db, { principal, limit, before });
|
|
309
|
+
return rows as StoredEntry[];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function accessEntries(db: Queryable, before?: SQL) {
|
|
302
313
|
const { auditLog } = tablesOf(db);
|
|
303
|
-
|
|
314
|
+
return db
|
|
304
315
|
.select(entryColumns(db))
|
|
305
316
|
.from(auditLog)
|
|
306
317
|
.where(
|
|
307
318
|
and(
|
|
308
319
|
eq(auditLog.author, 'vault'),
|
|
309
|
-
eq(auditLog.subjectPrincipal, principal),
|
|
320
|
+
eq(auditLog.subjectPrincipal, sql.placeholder('principal')),
|
|
310
321
|
inArray(auditLog.action, [...ACCESS_ACTIONS]),
|
|
311
322
|
eq(auditLog.decision, 'allow'),
|
|
312
|
-
before
|
|
323
|
+
before,
|
|
313
324
|
),
|
|
314
325
|
)
|
|
315
326
|
.orderBy(desc(auditLog.seq))
|
|
316
|
-
.limit(limit);
|
|
317
|
-
return rows as StoredEntry[];
|
|
327
|
+
.limit(sql.placeholder('limit'));
|
|
318
328
|
}
|
|
319
329
|
|
|
330
|
+
// What every check of a member reads first, built once (`prepared`).
|
|
331
|
+
const newestAccessEntries = prepared((db) => accessEntries(db));
|
|
332
|
+
const accessEntriesBefore = prepared((db) => accessEntries(db, lt(tablesOf(db).auditLog.seq, sql.placeholder('before'))));
|
|
333
|
+
|
|
320
334
|
/**
|
|
321
335
|
* Up to `limit` of the vault's reports that `principal`'s row was changed
|
|
322
336
|
* around it (`TAMPER_CODES`), after `after` and before `before` (the newest
|
package/src/log.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { GENESIS_HASH, sealEntry, type LogFields, type LogKey, type StoredEntry } from '@coffre/core/audit';
|
|
2
|
-
import { and, asc, desc, eq, lt, sql, type SQL } from 'drizzle-orm';
|
|
2
|
+
import { and, asc, count, desc, eq, gt, gte, lt, lte, sql, type SQL } from 'drizzle-orm';
|
|
3
3
|
|
|
4
4
|
import { afterCommit } from './commits.ts';
|
|
5
5
|
import { tablesOf, type Queryable, type Transaction } from './database.ts';
|
|
6
|
+
import { prepared } from './prepared.ts';
|
|
6
7
|
import { clockMillis, forUpdate } from './dialect.ts';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -99,18 +100,19 @@ export function forgetLogHeads(): void {
|
|
|
99
100
|
* again in the same transaction is free.
|
|
100
101
|
*/
|
|
101
102
|
export async function lockLogHead(tx: Transaction): Promise<{ nextSeq: bigint; headHash: Buffer }> {
|
|
102
|
-
const
|
|
103
|
-
const [head] = await forUpdate(
|
|
104
|
-
tx,
|
|
105
|
-
tx
|
|
106
|
-
.select({ nextSeq: auditChainHead.nextSeq, headHash: auditChainHead.headHash })
|
|
107
|
-
.from(auditChainHead)
|
|
108
|
-
.where(eq(auditChainHead.onlyRow, true)),
|
|
109
|
-
);
|
|
103
|
+
const [head] = await lockedHead(tx);
|
|
110
104
|
if (head === undefined) throw new Error('audit_chain_head is missing; refusing to write an unchained entry');
|
|
111
105
|
return head;
|
|
112
106
|
}
|
|
113
107
|
|
|
108
|
+
const lockedHead = prepared((db) => {
|
|
109
|
+
const { auditChainHead } = tablesOf(db);
|
|
110
|
+
return forUpdate(
|
|
111
|
+
db,
|
|
112
|
+
db.select({ nextSeq: auditChainHead.nextSeq, headHash: auditChainHead.headHash }).from(auditChainHead).where(eq(auditChainHead.onlyRow, true)),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
114
116
|
/**
|
|
115
117
|
* Append `entries` under `key`. `check`, when given, runs once the head is
|
|
116
118
|
* locked and before anything is written: a writer's own condition on the
|
|
@@ -129,12 +131,8 @@ export async function appendEntries(
|
|
|
129
131
|
// Every append queues here, if its transaction has not already.
|
|
130
132
|
const head = await lockLogHead(tx);
|
|
131
133
|
await check?.(tx);
|
|
132
|
-
const [{ now }] = await
|
|
133
|
-
const [last] = await tx
|
|
134
|
-
.select({ seq: auditLog.seq, hash: auditLog.hash })
|
|
135
|
-
.from(auditLog)
|
|
136
|
-
.orderBy(desc(auditLog.seq))
|
|
137
|
-
.limit(1);
|
|
134
|
+
const [{ now }] = await clockNow(tx);
|
|
135
|
+
const [last] = await lastEntry(tx);
|
|
138
136
|
|
|
139
137
|
const expected = last === undefined ? { nextSeq: 0n, headHash: GENESIS_HASH } : { nextSeq: last.seq + 1n, headHash: last.hash };
|
|
140
138
|
if (head.nextSeq !== expected.nextSeq || !head.headHash.equals(expected.headHash)) {
|
|
@@ -188,6 +186,13 @@ export async function appendEntries(
|
|
|
188
186
|
return { seqStart: head.nextSeq, ...written, occurredAt: now };
|
|
189
187
|
}
|
|
190
188
|
|
|
189
|
+
const clockNow = prepared((db) => db.select({ now: clockMillis(db) }).from(tablesOf(db).auditChainHead));
|
|
190
|
+
|
|
191
|
+
const lastEntry = prepared((db) => {
|
|
192
|
+
const { auditLog } = tablesOf(db);
|
|
193
|
+
return db.select({ seq: auditLog.seq, hash: auditLog.hash }).from(auditLog).orderBy(desc(auditLog.seq)).limit(1);
|
|
194
|
+
});
|
|
195
|
+
|
|
191
196
|
// --- reading the vault's entries back ---------------------------------------------
|
|
192
197
|
|
|
193
198
|
/** An entry's columns, as the chain's codec reads them (`StoredEntry`). */
|
|
@@ -257,6 +262,33 @@ export async function withinBytes(
|
|
|
257
262
|
return { from: sizes[0]!.seq, to: sizes[count - 1]!.seq, cut: count < sizes.length, count };
|
|
258
263
|
}
|
|
259
264
|
|
|
265
|
+
/**
|
|
266
|
+
* The first entry missing from the log up to `seq`, and the one found in
|
|
267
|
+
* its place, or null when every one is there. Seqs are the primary key, so
|
|
268
|
+
* a stretch is whole exactly when its count is its length: one pass over
|
|
269
|
+
* the key's index, never the entries. Only a log with a gap pays for more,
|
|
270
|
+
* a count per halving to find where.
|
|
271
|
+
*
|
|
272
|
+
* entries 0..9, 4 cut: { expected: 4n, found: 5n }
|
|
273
|
+
*/
|
|
274
|
+
export async function missingEntry(db: Queryable, seq: bigint): Promise<{ expected: bigint; found: bigint | null } | null> {
|
|
275
|
+
const { auditLog } = tablesOf(db);
|
|
276
|
+
const whole = async (to: bigint) => {
|
|
277
|
+
const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, to)));
|
|
278
|
+
return BigInt(n) === to + 1n;
|
|
279
|
+
};
|
|
280
|
+
if (seq < 0n || (await whole(seq))) return null;
|
|
281
|
+
// The first stretch from 0 that is not whole ends at the missing entry.
|
|
282
|
+
let [low, high] = [0n, seq];
|
|
283
|
+
while (low < high) {
|
|
284
|
+
const middle = (low + high) / 2n;
|
|
285
|
+
if (await whole(middle)) low = middle + 1n;
|
|
286
|
+
else high = middle;
|
|
287
|
+
}
|
|
288
|
+
const [next] = await db.select({ seq: auditLog.seq }).from(auditLog).where(gt(auditLog.seq, low)).orderBy(asc(auditLog.seq)).limit(1);
|
|
289
|
+
return { expected: low, found: (next?.seq as bigint | undefined) ?? null };
|
|
290
|
+
}
|
|
291
|
+
|
|
260
292
|
/**
|
|
261
293
|
* How a reader tells the vault's own entries from those forged in its name:
|
|
262
294
|
* by their MAC, which only the vault holds the key to check. Each it finds
|
|
@@ -266,23 +298,43 @@ export type Authenticity = { authentic: (entry: StoredEntry) => boolean; forged:
|
|
|
266
298
|
|
|
267
299
|
/** Up to `limit` of the entries in the vault's name, allowed, of one `action`, before `before` (the newest when undefined), newest first. */
|
|
268
300
|
export async function newestVaultEntries(db: Queryable, action: string, limit: number, before?: bigint): Promise<StoredEntry[]> {
|
|
301
|
+
const rows = before === undefined ? await newestEntries(db, { action, limit }) : await entriesBefore(db, { action, limit, before });
|
|
302
|
+
return rows as StoredEntry[];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function vaultEntries(db: Queryable, before?: SQL) {
|
|
269
306
|
const { auditLog } = tablesOf(db);
|
|
270
|
-
|
|
307
|
+
return db
|
|
271
308
|
.select(entryColumns(db))
|
|
272
309
|
.from(auditLog)
|
|
273
|
-
.where(
|
|
274
|
-
and(
|
|
275
|
-
eq(auditLog.author, 'vault'),
|
|
276
|
-
eq(auditLog.action, action),
|
|
277
|
-
eq(auditLog.decision, 'allow'),
|
|
278
|
-
before === undefined ? undefined : lt(auditLog.seq, before),
|
|
279
|
-
),
|
|
280
|
-
)
|
|
310
|
+
.where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, sql.placeholder('action')), eq(auditLog.decision, 'allow'), before))
|
|
281
311
|
.orderBy(desc(auditLog.seq))
|
|
282
|
-
.limit(limit);
|
|
283
|
-
|
|
312
|
+
.limit(sql.placeholder('limit'));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const newestEntries = prepared((db) => vaultEntries(db));
|
|
316
|
+
const entriesBefore = prepared((db) => vaultEntries(db, lt(tablesOf(db).auditLog.seq, sql.placeholder('before'))));
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The number of the newest entry in the vault's name, allowed, of one
|
|
320
|
+
* `action`, or null for none: whether one was appended since, and nothing
|
|
321
|
+
* of what it says, so its MAC is not checked. One indexed read.
|
|
322
|
+
*/
|
|
323
|
+
export async function newestVaultSeq(db: Queryable, action: string): Promise<bigint | null> {
|
|
324
|
+
const [row] = await newestSeq(db, { action });
|
|
325
|
+
return (row?.seq as bigint | undefined) ?? null;
|
|
284
326
|
}
|
|
285
327
|
|
|
328
|
+
const newestSeq = prepared((db) => {
|
|
329
|
+
const { auditLog } = tablesOf(db);
|
|
330
|
+
return db
|
|
331
|
+
.select({ seq: auditLog.seq })
|
|
332
|
+
.from(auditLog)
|
|
333
|
+
.where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, sql.placeholder('action')), eq(auditLog.decision, 'allow')))
|
|
334
|
+
.orderBy(desc(auditLog.seq))
|
|
335
|
+
.limit(1);
|
|
336
|
+
});
|
|
337
|
+
|
|
286
338
|
/** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
|
|
287
339
|
export const LOOK_BACK = { page: 32, pages: 32 };
|
|
288
340
|
|
|
@@ -291,20 +343,24 @@ export type Newest = StoredEntry | 'none' | 'forged' | 'unreached';
|
|
|
291
343
|
|
|
292
344
|
/**
|
|
293
345
|
* The newest of the entries `page` reads, newest first and before a seq,
|
|
294
|
-
* that `check` finds authentic
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
346
|
+
* that `check` finds authentic: the newest alone first, which it is unless
|
|
347
|
+
* entries were forged in the vault's name since, then back a page at a
|
|
348
|
+
* time. 'none' when there are none at all, 'forged' when every one is in
|
|
349
|
+
* the vault's name but not its own, and 'unreached' once it has read
|
|
350
|
+
* LOOK_BACK such entries (1024), whether or not more are left.
|
|
298
351
|
*/
|
|
299
|
-
export async function newestAuthentic(page: (before: bigint | undefined) => Promise<StoredEntry[]>, check: Authenticity): Promise<Newest> {
|
|
352
|
+
export async function newestAuthentic(page: (limit: number, before: bigint | undefined) => Promise<StoredEntry[]>, check: Authenticity): Promise<Newest> {
|
|
353
|
+
const most = LOOK_BACK.page * LOOK_BACK.pages;
|
|
354
|
+
let read = 0;
|
|
300
355
|
let before: bigint | undefined;
|
|
301
|
-
for (let
|
|
302
|
-
const entries = await page(before);
|
|
356
|
+
for (let limit = 1; read < most; limit = Math.min(LOOK_BACK.page, most - read)) {
|
|
357
|
+
const entries = await page(limit, before);
|
|
303
358
|
for (const entry of entries) {
|
|
304
359
|
if (check.authentic(entry)) return entry;
|
|
305
360
|
check.forged(entry);
|
|
306
361
|
}
|
|
307
|
-
|
|
362
|
+
read += entries.length;
|
|
363
|
+
if (entries.length < limit) return read === 0 ? 'none' : 'forged';
|
|
308
364
|
before = entries.at(-1)!.seq;
|
|
309
365
|
}
|
|
310
366
|
return 'unreached';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Sensitive values (0.10): a version never handed back to a person, which the vault's wrap of its data key binds. Defaults to false, so the release before writes on as it did.
|
|
2
|
+
ALTER TABLE "secret_versions" ADD COLUMN "sensitive" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
|
3
|
+
-- A version is still only ever erased: its sensitivity with the rest of it.
|
|
4
|
+
CREATE OR REPLACE FUNCTION secret_versions_erase_only() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
5
|
+
BEGIN
|
|
6
|
+
IF octet_length(NEW.ciphertext) <> 0 OR octet_length(NEW.wrapped_dek) <> 0 OR NEW.plaintext IS NOT NULL
|
|
7
|
+
OR (NEW.id, NEW.secret_id, NEW.version, NEW.envelope_version, NEW.iv, NEW.auth_tag,
|
|
8
|
+
NEW.kek_provider, NEW.kek_id, NEW.kek_version, NEW.created_at, NEW.created_by,
|
|
9
|
+
NEW.recipe, NEW.pair_id, NEW.pair_half, NEW.sensitive)
|
|
10
|
+
IS DISTINCT FROM
|
|
11
|
+
(OLD.id, OLD.secret_id, OLD.version, OLD.envelope_version, OLD.iv, OLD.auth_tag,
|
|
12
|
+
OLD.kek_provider, OLD.kek_id, OLD.kek_version, OLD.created_at, OLD.created_by,
|
|
13
|
+
OLD.recipe, OLD.pair_id, OLD.pair_half, OLD.sensitive) THEN
|
|
14
|
+
RAISE EXCEPTION 'a secret version is only ever erased' USING ERRCODE = 'insufficient_privilege';
|
|
15
|
+
END IF;
|
|
16
|
+
RETURN NEW;
|
|
17
|
+
END
|
|
18
|
+
$$;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- The Contributor role (0.10): use and write, never read. The role checks widen to it, which the release before never grants.
|
|
2
|
+
ALTER TABLE "vault_grants" DROP CONSTRAINT "vault_grants_role_check";--> statement-breakpoint
|
|
3
|
+
ALTER TABLE "vault_grants" DROP CONSTRAINT "vault_grants_environment_role_check";--> statement-breakpoint
|
|
4
|
+
ALTER TABLE "vault_grants" ADD CONSTRAINT "vault_grants_role_check" CHECK ("vault_grants"."role" IN ('viewer', 'contributor', 'developer', 'maintainer', 'access-manager', 'auditor', 'owner'));--> statement-breakpoint
|
|
5
|
+
ALTER TABLE "vault_grants" ADD CONSTRAINT "vault_grants_environment_role_check" CHECK (("vault_grants"."environment_id" IS NULL AND "vault_grants"."environment_slug" IS NULL) OR "vault_grants"."role" IN ('viewer', 'contributor', 'developer', 'auditor'));
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
-- Roles an Owner makes (0.10): a grant names a role by its id, which the vault's policy defines; which of them an environment may be granted, the vault decides. The checks widen: the release before grants only the roles it ships.
|
|
2
|
+
ALTER TABLE "vault_grants" DROP CONSTRAINT "vault_grants_environment_role_check";--> statement-breakpoint
|
|
3
|
+
ALTER TABLE "vault_grants" DROP CONSTRAINT "vault_grants_role_check";--> statement-breakpoint
|
|
4
|
+
ALTER TABLE "vault_grants" ADD CONSTRAINT "vault_grants_role_check" CHECK ("vault_grants"."role" ~ '^[a-z][a-z0-9-]{1,31}$');
|