@coffre/db 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +14 -2
- package/dist/commits-BuW-NL3n.js +30 -0
- package/dist/connect.d.ts +21 -0
- package/dist/connect.js +155 -0
- package/dist/database-CWpo5aF0.d.ts +39 -0
- package/dist/dialect.d.ts +88 -0
- package/dist/dialect.js +128 -0
- package/dist/hyperdrive.d.ts +32 -0
- package/dist/hyperdrive.js +53 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +34 -0
- package/dist/log.d.ts +72 -0
- package/dist/log.js +133 -0
- package/dist/migrate.d.ts +10 -0
- package/dist/migrate.js +129 -0
- package/dist/migrations/postgres/0000_baseline.sql +502 -0
- package/dist/migrations/postgres/meta/0000_snapshot.json +1939 -0
- package/dist/migrations/postgres/meta/_journal.json +13 -0
- package/dist/migrations/sqlite/0000_baseline.sql +293 -0
- package/dist/migrations/sqlite/meta/0000_snapshot.json +1811 -0
- package/dist/migrations/sqlite/meta/_journal.json +13 -0
- package/dist/portable.d.ts +33 -0
- package/dist/portable.js +12 -0
- package/dist/relations-DqgxtlKu.js +58 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/schema-Cd4WOnB5.d.ts +2503 -0
- package/dist/schema-sqlite.d.ts +2587 -0
- package/dist/schema-sqlite.js +401 -0
- package/dist/schema-version.d.ts +11 -0
- package/dist/schema-version.js +16 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +448 -0
- package/package.json +91 -3
- package/src/baseline/postgres.sql +238 -0
- package/src/baseline/sqlite.sql +27 -0
- package/src/baseline.ts +104 -0
- package/src/commits.ts +47 -0
- package/src/connect.ts +168 -0
- package/src/database.ts +63 -0
- package/src/dialect.ts +174 -0
- package/src/drizzle.postgres.config.ts +7 -0
- package/src/drizzle.sqlite.config.ts +7 -0
- package/src/hyperdrive.ts +62 -0
- package/src/log.ts +177 -0
- package/src/migrate-main.ts +8 -0
- package/src/migrate.ts +206 -0
- package/src/migrations/postgres/0000_baseline.sql +502 -0
- package/src/migrations/postgres/meta/0000_snapshot.json +1939 -0
- package/src/migrations/postgres/meta/_journal.json +13 -0
- package/src/migrations/sqlite/0000_baseline.sql +293 -0
- package/src/migrations/sqlite/meta/0000_snapshot.json +1811 -0
- package/src/migrations/sqlite/meta/_journal.json +13 -0
- package/src/portable.ts +54 -0
- package/src/relations.ts +53 -0
- package/src/schema-version.ts +13 -0
- package/src/schema.sqlite.ts +511 -0
- package/src/schema.ts +548 -0
- package/src/verify-runtime.ts +120 -0
package/src/portable.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
|
2
|
+
|
|
3
|
+
import { trackCommits } from './commits.ts';
|
|
4
|
+
import type { Database } from './database.ts';
|
|
5
|
+
import type * as postgres from './schema.ts';
|
|
6
|
+
import type * as sqlite from './schema.sqlite.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The one place a SQLite database becomes a Postgres one.
|
|
10
|
+
*
|
|
11
|
+
* Drizzle has no type that spans dialects, so the queries are written once,
|
|
12
|
+
* against the Postgres schema, and a SQLite database is handed to
|
|
13
|
+
* them under the Postgres type. That holds because both schemas have the
|
|
14
|
+
* same tables with the same row types (checked below, at compile time, and
|
|
15
|
+
* in schema-parity.test.ts, at run time), and because every query runs on
|
|
16
|
+
* the database's own tables: queries.ts reads them from the database it is
|
|
17
|
+
* given (`tablesOf`), never from an import. A Postgres column on a SQLite
|
|
18
|
+
* database would encode its values the Postgres way.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type PostgresSchema = typeof postgres;
|
|
22
|
+
|
|
23
|
+
/** Some of a schema's tables, read as their Postgres twins. */
|
|
24
|
+
export function asPostgres<Tables extends Partial<Record<keyof PostgresSchema, unknown>>>(
|
|
25
|
+
tables: Tables,
|
|
26
|
+
): Pick<PostgresSchema, keyof Tables & keyof PostgresSchema> {
|
|
27
|
+
return tables as never;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A SQLite database, read as a Postgres one. */
|
|
31
|
+
export function asPostgresDatabase(
|
|
32
|
+
db: LibSQLDatabase<typeof sqlite>,
|
|
33
|
+
): Database {
|
|
34
|
+
return trackCommits(db as unknown as Database);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- the row types match ---------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
type TableName = {
|
|
40
|
+
[K in keyof PostgresSchema]: PostgresSchema[K] extends { $inferSelect: object } ? K : never;
|
|
41
|
+
}[keyof PostgresSchema];
|
|
42
|
+
|
|
43
|
+
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
44
|
+
|
|
45
|
+
/** The tables whose rows differ from their Postgres twin's; never if none. */
|
|
46
|
+
type Mismatched<Schema extends Record<TableName, { $inferSelect: object }>> = {
|
|
47
|
+
[K in TableName]: Equal<Schema[K]['$inferSelect'], PostgresSchema[K]['$inferSelect']> extends true ? never : K;
|
|
48
|
+
}[TableName];
|
|
49
|
+
|
|
50
|
+
type None<T extends never> = T;
|
|
51
|
+
|
|
52
|
+
// A column that differs in type, nullability or name breaks the typecheck
|
|
53
|
+
// here, naming its table.
|
|
54
|
+
export type SqliteRowsMatch = None<Mismatched<typeof sqlite>>;
|
package/src/relations.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { relations, sql } from 'drizzle-orm';
|
|
2
|
+
|
|
3
|
+
import type * as schema from './schema.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What both schemas share beyond their columns: the relations and the
|
|
7
|
+
* one generated column.
|
|
8
|
+
*
|
|
9
|
+
* Relations are for Drizzle's relational queries (`db.query.syncs
|
|
10
|
+
* .findMany({ with })`), which read a row and what hangs off it in one
|
|
11
|
+
* statement on every dialect. They add no constraints; the foreign keys in
|
|
12
|
+
* each schema do that. Each schema calls this with its own tables, so a
|
|
13
|
+
* relational query on SQLite joins SQLite tables.
|
|
14
|
+
*/
|
|
15
|
+
export function relationsOf(t: Pick<
|
|
16
|
+
typeof schema,
|
|
17
|
+
| 'projects'
|
|
18
|
+
| 'environments'
|
|
19
|
+
| 'secrets'
|
|
20
|
+
| 'secretVersions'
|
|
21
|
+
| 'syncs'
|
|
22
|
+
| 'syncKeys'
|
|
23
|
+
>) {
|
|
24
|
+
return {
|
|
25
|
+
environmentsRelations: relations(t.environments, ({ one }) => ({
|
|
26
|
+
project: one(t.projects, { fields: [t.environments.projectId], references: [t.projects.id] }),
|
|
27
|
+
})),
|
|
28
|
+
|
|
29
|
+
secretsRelations: relations(t.secrets, ({ one }) => ({
|
|
30
|
+
project: one(t.projects, { fields: [t.secrets.projectId], references: [t.projects.id] }),
|
|
31
|
+
environment: one(t.environments, { fields: [t.secrets.environmentId], references: [t.environments.id] }),
|
|
32
|
+
})),
|
|
33
|
+
|
|
34
|
+
syncsRelations: relations(t.syncs, ({ one, many }) => ({
|
|
35
|
+
project: one(t.projects, { fields: [t.syncs.projectId], references: [t.projects.id] }),
|
|
36
|
+
environment: one(t.environments, { fields: [t.syncs.environmentId], references: [t.environments.id] }),
|
|
37
|
+
credential: one(t.secrets, { fields: [t.syncs.credentialSecretId], references: [t.secrets.id] }),
|
|
38
|
+
keys: many(t.syncKeys),
|
|
39
|
+
})),
|
|
40
|
+
|
|
41
|
+
syncKeysRelations: relations(t.syncKeys, ({ one }) => ({
|
|
42
|
+
sync: one(t.syncs, { fields: [t.syncKeys.syncId], references: [t.syncs.id] }),
|
|
43
|
+
})),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `identities.active_subject`: the subject while the account is bound, null
|
|
49
|
+
* once revoked. A unique index on (provider, issuer_hash, active_subject)
|
|
50
|
+
* binds an account to one person at a time. Plain column names, so the
|
|
51
|
+
* expression reads the same on both databases.
|
|
52
|
+
*/
|
|
53
|
+
export const ACTIVE_SUBJECT = sql`CASE WHEN revoked_at IS NULL THEN subject END`;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Queryable } from './database.ts';
|
|
2
|
+
import { engineOf, type Engine } from './dialect.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimum migration prefix required by this application image, per engine:
|
|
6
|
+
* each tree has its own history, all ending at the same schema. Until the
|
|
7
|
+
* first deployment, each is its baseline alone (see baseline.ts).
|
|
8
|
+
*/
|
|
9
|
+
export const REQUIRED_MIGRATIONS: Record<Engine, number> = { postgres: 1, sqlite: 1 };
|
|
10
|
+
|
|
11
|
+
export function requiredMigrations(db: Queryable): number {
|
|
12
|
+
return REQUIRED_MIGRATIONS[engineOf(db)];
|
|
13
|
+
}
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { assignableToEnvironment, ROLE_NAMES } from '@coffre/core/access';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
import {
|
|
4
|
+
blob,
|
|
5
|
+
check,
|
|
6
|
+
customType,
|
|
7
|
+
foreignKey,
|
|
8
|
+
index,
|
|
9
|
+
integer,
|
|
10
|
+
primaryKey,
|
|
11
|
+
sqliteTable,
|
|
12
|
+
text,
|
|
13
|
+
unique,
|
|
14
|
+
uniqueIndex,
|
|
15
|
+
type AnySQLiteColumn,
|
|
16
|
+
} from 'drizzle-orm/sqlite-core';
|
|
17
|
+
|
|
18
|
+
import { asPostgres } from './portable.ts';
|
|
19
|
+
import { ACTIVE_SUBJECT, relationsOf } from './relations.ts';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The schema in schema.ts, for SQLite (through libsql). Same tables,
|
|
23
|
+
* columns, keys and row types (portable.ts checks); only the storage
|
|
24
|
+
* differs:
|
|
25
|
+
*
|
|
26
|
+
* - Ids and strings are `text`, with no default for ids: the application
|
|
27
|
+
* makes every id.
|
|
28
|
+
* - Bytes are `blob`.
|
|
29
|
+
* - Times are integer milliseconds since the epoch.
|
|
30
|
+
* - Patterns are checked with GLOB, which is case-sensitive and needs no
|
|
31
|
+
* extension, rather than a regular expression.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const bytes = (name: string) => blob(name, { mode: 'buffer' });
|
|
35
|
+
|
|
36
|
+
/** A 64-bit integer read as a bigint, like Postgres's bigint in 'bigint' mode. */
|
|
37
|
+
const int64 = customType<{ data: bigint; driverData: number | bigint }>({
|
|
38
|
+
dataType: () => 'integer',
|
|
39
|
+
fromDriver: (value) => BigInt(value),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const time = (name: string) => integer(name, { mode: 'timestamp_ms' });
|
|
43
|
+
const now = sql`(CAST(unixepoch('subsec') * 1000 AS INTEGER))`;
|
|
44
|
+
const createdAt = () => time('created_at').notNull().default(now);
|
|
45
|
+
const flag = (name: string) => integer(name, { mode: 'boolean' });
|
|
46
|
+
|
|
47
|
+
/** `^[a-z0-9][a-z0-9-]{0,max-1}$`, in GLOB. */
|
|
48
|
+
const isSlug = (column: AnySQLiteColumn, max: number) =>
|
|
49
|
+
sql`length(${column}) BETWEEN 1 AND ${sql.raw(String(max))} AND ${column} GLOB '[a-z0-9]*' AND ${column} NOT GLOB '*[^a-z0-9-]*'`;
|
|
50
|
+
|
|
51
|
+
export const projects = sqliteTable(
|
|
52
|
+
'projects',
|
|
53
|
+
{
|
|
54
|
+
id: text().primaryKey(),
|
|
55
|
+
slug: text().notNull(),
|
|
56
|
+
name: text().notNull(),
|
|
57
|
+
createdAt: createdAt(),
|
|
58
|
+
archivedAt: time('archived_at'),
|
|
59
|
+
},
|
|
60
|
+
(table) => [
|
|
61
|
+
unique('projects_slug_key').on(table.slug),
|
|
62
|
+
check('projects_slug_check', isSlug(table.slug, 63)),
|
|
63
|
+
],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
export const environments = sqliteTable(
|
|
67
|
+
'environments',
|
|
68
|
+
{
|
|
69
|
+
id: text().primaryKey(),
|
|
70
|
+
projectId: text('project_id').notNull(),
|
|
71
|
+
slug: text().notNull(),
|
|
72
|
+
name: text().notNull(),
|
|
73
|
+
createdAt: createdAt(),
|
|
74
|
+
archivedAt: time('archived_at'),
|
|
75
|
+
},
|
|
76
|
+
(table) => [
|
|
77
|
+
unique('environments_project_id_slug_key').on(table.projectId, table.slug),
|
|
78
|
+
unique('environments_project_scoped').on(table.id, table.projectId),
|
|
79
|
+
check('environments_slug_check', isSlug(table.slug, 63)),
|
|
80
|
+
foreignKey({
|
|
81
|
+
name: 'environments_project_id_fkey',
|
|
82
|
+
columns: [table.projectId],
|
|
83
|
+
foreignColumns: [projects.id],
|
|
84
|
+
}).onDelete('restrict'),
|
|
85
|
+
],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
export const secrets = sqliteTable(
|
|
89
|
+
'secrets',
|
|
90
|
+
{
|
|
91
|
+
id: text().primaryKey(),
|
|
92
|
+
projectId: text('project_id').notNull(),
|
|
93
|
+
environmentId: text('environment_id').notNull(),
|
|
94
|
+
key: text().notNull(),
|
|
95
|
+
createdAt: createdAt(),
|
|
96
|
+
updatedAt: time('updated_at').notNull().default(now),
|
|
97
|
+
currentVersionId: text('current_version_id').references((): AnySQLiteColumn => secretVersions.id, {
|
|
98
|
+
onDelete: 'no action',
|
|
99
|
+
}),
|
|
100
|
+
currentVersion: integer('current_version').notNull().default(0),
|
|
101
|
+
archivedAt: time('archived_at'),
|
|
102
|
+
},
|
|
103
|
+
(table) => [
|
|
104
|
+
unique('secrets_project_id_environment_id_key_key').on(table.projectId, table.environmentId, table.key),
|
|
105
|
+
check(
|
|
106
|
+
'secrets_key_check',
|
|
107
|
+
sql`length(${table.key}) BETWEEN 1 AND 128 AND ${table.key} GLOB '[A-Za-z_]*' AND ${table.key} NOT GLOB '*[^A-Za-z0-9_]*'`,
|
|
108
|
+
),
|
|
109
|
+
foreignKey({
|
|
110
|
+
name: 'secrets_project_id_fkey',
|
|
111
|
+
columns: [table.projectId],
|
|
112
|
+
foreignColumns: [projects.id],
|
|
113
|
+
}).onDelete('restrict'),
|
|
114
|
+
foreignKey({
|
|
115
|
+
name: 'secrets_environment_id_fkey',
|
|
116
|
+
columns: [table.environmentId],
|
|
117
|
+
foreignColumns: [environments.id],
|
|
118
|
+
}).onDelete('restrict'),
|
|
119
|
+
foreignKey({
|
|
120
|
+
name: 'secrets_environment_in_project',
|
|
121
|
+
columns: [table.environmentId, table.projectId],
|
|
122
|
+
foreignColumns: [environments.id, environments.projectId],
|
|
123
|
+
}),
|
|
124
|
+
index('secrets_lookup_idx').on(table.projectId, table.environmentId, table.key),
|
|
125
|
+
],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
export const secretVersions = sqliteTable(
|
|
129
|
+
'secret_versions',
|
|
130
|
+
{
|
|
131
|
+
id: text().primaryKey(),
|
|
132
|
+
secretId: text('secret_id').notNull(),
|
|
133
|
+
version: integer().notNull(),
|
|
134
|
+
envelopeVersion: integer('envelope_version').notNull(),
|
|
135
|
+
ciphertext: bytes('ciphertext').notNull(),
|
|
136
|
+
iv: bytes('iv').notNull(),
|
|
137
|
+
authTag: bytes('auth_tag').notNull(),
|
|
138
|
+
wrappedDek: bytes('wrapped_dek').notNull(),
|
|
139
|
+
kekProvider: text('kek_provider').notNull(),
|
|
140
|
+
kekId: text('kek_id').notNull(),
|
|
141
|
+
kekVersion: text('kek_version').notNull(),
|
|
142
|
+
createdAt: createdAt(),
|
|
143
|
+
createdBy: text('created_by').notNull(),
|
|
144
|
+
},
|
|
145
|
+
(table) => [
|
|
146
|
+
unique('secret_versions_secret_id_version_key').on(table.secretId, table.version),
|
|
147
|
+
check('secret_versions_version_check', sql`${table.version} > 0`),
|
|
148
|
+
check('secret_versions_iv_check', sql`octet_length(${table.iv}) = 12`),
|
|
149
|
+
check('secret_versions_auth_tag_check', sql`octet_length(${table.authTag}) = 16`),
|
|
150
|
+
foreignKey({
|
|
151
|
+
name: 'secret_versions_secret_id_fkey',
|
|
152
|
+
columns: [table.secretId],
|
|
153
|
+
foreignColumns: [secrets.id],
|
|
154
|
+
}).onDelete('restrict'),
|
|
155
|
+
index('secret_versions_secret_idx').on(table.secretId, table.version),
|
|
156
|
+
],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const ROLES = sql.raw(ROLE_NAMES.map((role) => `'${role}'`).join(', '));
|
|
160
|
+
const ENVIRONMENT_ROLES = sql.raw(ROLE_NAMES.filter(assignableToEnvironment).map((role) => `'${role}'`).join(', '));
|
|
161
|
+
|
|
162
|
+
export const vaultMembers = sqliteTable(
|
|
163
|
+
'vault_members',
|
|
164
|
+
{
|
|
165
|
+
principal: text().primaryKey(),
|
|
166
|
+
status: text().notNull(),
|
|
167
|
+
owner: flag('owner').notNull().default(false),
|
|
168
|
+
generation: integer().notNull().default(0),
|
|
169
|
+
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
|
170
|
+
createdBy: text('created_by').notNull(),
|
|
171
|
+
statusChangedAt: integer('status_changed_at', { mode: 'number' }).notNull(),
|
|
172
|
+
statusChangedBy: text('status_changed_by').notNull(),
|
|
173
|
+
accessSeq: int64('access_seq').notNull(),
|
|
174
|
+
mac: bytes('mac').notNull(),
|
|
175
|
+
},
|
|
176
|
+
(table) => [
|
|
177
|
+
check('vault_members_mac_check', sql`octet_length(${table.mac}) = 32`),
|
|
178
|
+
// `^(user|token|sync):[^[:space:]:][^[:space:]]*$`, without regular expressions.
|
|
179
|
+
check(
|
|
180
|
+
'vault_members_principal_check',
|
|
181
|
+
sql`(${table.principal} GLOB 'user:?*' OR ${table.principal} GLOB 'token:?*' OR ${table.principal} GLOB 'sync:?*')
|
|
182
|
+
AND substr(${table.principal}, instr(${table.principal}, ':') + 1, 1) <> ':'
|
|
183
|
+
AND instr(${table.principal}, ' ') = 0 AND instr(${table.principal}, char(9)) = 0
|
|
184
|
+
AND instr(${table.principal}, char(10)) = 0 AND instr(${table.principal}, char(13)) = 0`,
|
|
185
|
+
),
|
|
186
|
+
check('vault_members_user_lowercase', sql`${table.principal} NOT LIKE 'user:%' OR ${table.principal} = lower(${table.principal})`),
|
|
187
|
+
check('vault_members_status_check', sql`${table.status} IN ('active', 'removed')`),
|
|
188
|
+
check('vault_members_owner_check', sql`NOT ${table.owner} OR (${table.status} = 'active' AND ${table.principal} LIKE 'user:%')`),
|
|
189
|
+
check('vault_members_generation_check', sql`${table.generation} >= 0`),
|
|
190
|
+
],
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
export const vaultGrants = sqliteTable(
|
|
194
|
+
'vault_grants',
|
|
195
|
+
{
|
|
196
|
+
principal: text().notNull(),
|
|
197
|
+
projectId: text('project_id'),
|
|
198
|
+
environmentId: text('environment_id'),
|
|
199
|
+
role: text().notNull(),
|
|
200
|
+
expiresAt: integer('expires_at', { mode: 'number' }),
|
|
201
|
+
grantedAt: integer('granted_at', { mode: 'number' }).notNull(),
|
|
202
|
+
grantedBy: text('granted_by').notNull(),
|
|
203
|
+
},
|
|
204
|
+
(table) => [
|
|
205
|
+
check('vault_grants_one_place', sql`(${table.projectId} IS NULL) <> (${table.environmentId} IS NULL)`),
|
|
206
|
+
check('vault_grants_role_check', sql`${table.role} IN (${ROLES})`),
|
|
207
|
+
check('vault_grants_environment_role_check', sql`${table.environmentId} IS NULL OR ${table.role} IN (${ENVIRONMENT_ROLES})`),
|
|
208
|
+
unique('vault_grants_on_project').on(table.principal, table.projectId),
|
|
209
|
+
unique('vault_grants_on_environment').on(table.principal, table.environmentId),
|
|
210
|
+
foreignKey({
|
|
211
|
+
name: 'vault_grants_principal_fkey',
|
|
212
|
+
columns: [table.principal],
|
|
213
|
+
foreignColumns: [vaultMembers.principal],
|
|
214
|
+
}).onDelete('restrict'),
|
|
215
|
+
foreignKey({
|
|
216
|
+
name: 'vault_grants_project_id_fkey',
|
|
217
|
+
columns: [table.projectId],
|
|
218
|
+
foreignColumns: [projects.id],
|
|
219
|
+
}).onDelete('restrict'),
|
|
220
|
+
foreignKey({
|
|
221
|
+
name: 'vault_grants_environment_id_fkey',
|
|
222
|
+
columns: [table.environmentId],
|
|
223
|
+
foreignColumns: [environments.id],
|
|
224
|
+
}).onDelete('restrict'),
|
|
225
|
+
],
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
export const auditLog = sqliteTable(
|
|
229
|
+
'audit_log',
|
|
230
|
+
{
|
|
231
|
+
seq: int64('seq').primaryKey(),
|
|
232
|
+
// Which component wrote the entry, under which of its keys; see
|
|
233
|
+
// @coffre/core/audit for what its MAC and hash cover.
|
|
234
|
+
author: text().notNull(),
|
|
235
|
+
keyId: text('key_id').notNull(),
|
|
236
|
+
// Milliseconds since the epoch, from the database's clock, read after the
|
|
237
|
+
// append took the head's lock.
|
|
238
|
+
occurredAt: integer('occurred_at', { mode: 'number' }).notNull(),
|
|
239
|
+
// Who acted: `user:<email>`, `token:<id>`, `sync:<id>` or `system:<name>`.
|
|
240
|
+
actor: text().notNull(),
|
|
241
|
+
action: text().notNull(),
|
|
242
|
+
decision: text().notNull(),
|
|
243
|
+
code: text(),
|
|
244
|
+
// The member an access change is about.
|
|
245
|
+
subjectPrincipal: text('subject_principal'),
|
|
246
|
+
projectId: text('project_id'),
|
|
247
|
+
environmentId: text('environment_id'),
|
|
248
|
+
secretId: text('secret_id'),
|
|
249
|
+
secretVersionId: text('secret_version_id'),
|
|
250
|
+
// One id for everything one action did: a reveal's keys, a write's versions.
|
|
251
|
+
operationId: text('operation_id'),
|
|
252
|
+
requestId: text('request_id'),
|
|
253
|
+
sourceIp: text('source_ip'),
|
|
254
|
+
// An earlier entry this one follows from, such as the wrap behind a write.
|
|
255
|
+
relatedSeq: int64('related_seq'),
|
|
256
|
+
metadata: text().notNull().default('{}'),
|
|
257
|
+
prevHash: bytes('prev_hash').notNull(),
|
|
258
|
+
mac: bytes('mac').notNull(),
|
|
259
|
+
hash: bytes('hash').notNull(),
|
|
260
|
+
},
|
|
261
|
+
(table) => [
|
|
262
|
+
check('audit_log_author_check', sql`${table.author} IN ('app', 'vault')`),
|
|
263
|
+
check(
|
|
264
|
+
'audit_log_actor_check',
|
|
265
|
+
sql`${table.actor} GLOB 'user:?*' OR ${table.actor} GLOB 'token:?*' OR ${table.actor} GLOB 'sync:?*' OR ${table.actor} GLOB 'system:?*'`,
|
|
266
|
+
),
|
|
267
|
+
check('audit_log_decision_check', sql`${table.decision} IN ('allow', 'deny')`),
|
|
268
|
+
check('audit_log_metadata_check', sql`json_valid(${table.metadata})`),
|
|
269
|
+
check('audit_log_prev_hash_check', sql`octet_length(${table.prevHash}) = 32`),
|
|
270
|
+
check('audit_log_mac_check', sql`octet_length(${table.mac}) = 32`),
|
|
271
|
+
check('audit_log_hash_check', sql`octet_length(${table.hash}) = 32`),
|
|
272
|
+
foreignKey({
|
|
273
|
+
name: 'audit_log_project_id_fkey',
|
|
274
|
+
columns: [table.projectId],
|
|
275
|
+
foreignColumns: [projects.id],
|
|
276
|
+
}).onDelete('restrict'),
|
|
277
|
+
foreignKey({
|
|
278
|
+
name: 'audit_log_environment_id_fkey',
|
|
279
|
+
columns: [table.environmentId],
|
|
280
|
+
foreignColumns: [environments.id],
|
|
281
|
+
}).onDelete('restrict'),
|
|
282
|
+
foreignKey({
|
|
283
|
+
name: 'audit_log_secret_id_fkey',
|
|
284
|
+
columns: [table.secretId],
|
|
285
|
+
foreignColumns: [secrets.id],
|
|
286
|
+
}).onDelete('restrict'),
|
|
287
|
+
foreignKey({
|
|
288
|
+
name: 'audit_log_secret_version_id_fkey',
|
|
289
|
+
columns: [table.secretVersionId],
|
|
290
|
+
foreignColumns: [secretVersions.id],
|
|
291
|
+
}).onDelete('restrict'),
|
|
292
|
+
foreignKey({
|
|
293
|
+
name: 'audit_log_related_seq_fkey',
|
|
294
|
+
columns: [table.relatedSeq],
|
|
295
|
+
foreignColumns: [table.seq],
|
|
296
|
+
}).onDelete('restrict'),
|
|
297
|
+
// Pages read backwards by seq, within a place or an actor.
|
|
298
|
+
index('audit_log_project_idx').on(table.projectId, table.seq),
|
|
299
|
+
index('audit_log_environment_idx').on(table.environmentId, table.seq),
|
|
300
|
+
index('audit_log_secret_idx').on(table.secretId, table.seq),
|
|
301
|
+
index('audit_log_actor_idx').on(table.actor, table.seq),
|
|
302
|
+
index('audit_log_operation_idx').on(table.operationId, table.seq),
|
|
303
|
+
index('audit_log_action_idx').on(table.author, table.action, table.seq),
|
|
304
|
+
index('audit_log_releases_idx').on(table.author, table.actor, table.action, table.decision, table.occurredAt),
|
|
305
|
+
index('audit_log_subject_idx').on(table.author, table.subjectPrincipal, table.seq),
|
|
306
|
+
],
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
export const auditChainHead = sqliteTable(
|
|
310
|
+
'audit_chain_head',
|
|
311
|
+
{
|
|
312
|
+
onlyRow: flag('only_row').primaryKey().default(true),
|
|
313
|
+
nextSeq: int64('next_seq').notNull().default(sql`0`),
|
|
314
|
+
headHash: bytes('head_hash').notNull(),
|
|
315
|
+
},
|
|
316
|
+
(table) => [
|
|
317
|
+
check('audit_chain_head_only_row_check', sql`${table.onlyRow}`),
|
|
318
|
+
check('audit_chain_head_next_seq_check', sql`${table.nextSeq} >= 0`),
|
|
319
|
+
check('audit_chain_head_head_hash_check', sql`octet_length(${table.headHash}) = 32`),
|
|
320
|
+
],
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
export const identities = sqliteTable(
|
|
324
|
+
'identities',
|
|
325
|
+
{
|
|
326
|
+
id: text().primaryKey(),
|
|
327
|
+
provider: text().notNull(),
|
|
328
|
+
authMac: bytes('auth_mac').notNull(),
|
|
329
|
+
subject: text().notNull(),
|
|
330
|
+
issuerHash: text('issuer_hash').notNull(),
|
|
331
|
+
generation: integer().notNull(),
|
|
332
|
+
// The member it signs in as, `user:<email>`, under the generation it was bound in.
|
|
333
|
+
principal: text().notNull(),
|
|
334
|
+
email: text(),
|
|
335
|
+
createdAt: createdAt(),
|
|
336
|
+
createdBy: text('created_by').notNull(),
|
|
337
|
+
lastSignInAt: time('last_sign_in_at'),
|
|
338
|
+
revokedAt: time('revoked_at'),
|
|
339
|
+
revokedBy: text('revoked_by'),
|
|
340
|
+
activeSubject: text('active_subject').generatedAlwaysAs(ACTIVE_SUBJECT, { mode: 'stored' }),
|
|
341
|
+
},
|
|
342
|
+
(table) => [
|
|
343
|
+
check('identities_principal_check', sql`${table.principal} LIKE 'user:%'`),
|
|
344
|
+
check('identities_provider_check', isSlug(table.provider, 32)),
|
|
345
|
+
check('identities_auth_mac_check', sql`octet_length(${table.authMac}) = 32`),
|
|
346
|
+
unique('identities_member_generation_key').on(table.id, table.principal, table.generation),
|
|
347
|
+
foreignKey({
|
|
348
|
+
name: 'identities_principal_fkey',
|
|
349
|
+
columns: [table.principal],
|
|
350
|
+
foreignColumns: [vaultMembers.principal],
|
|
351
|
+
}).onDelete('restrict'),
|
|
352
|
+
uniqueIndex('identities_active_subject').on(table.provider, table.issuerHash, table.activeSubject),
|
|
353
|
+
index('identities_principal_idx').on(table.principal, table.generation),
|
|
354
|
+
],
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
export const credentials = sqliteTable(
|
|
358
|
+
'credentials',
|
|
359
|
+
{
|
|
360
|
+
id: text().primaryKey(),
|
|
361
|
+
kind: text().notNull(),
|
|
362
|
+
authMac: bytes('auth_mac').notNull(),
|
|
363
|
+
tokenHash: bytes('token_hash').notNull(),
|
|
364
|
+
tokenHint: text('token_hint').notNull(),
|
|
365
|
+
generation: integer().notNull(),
|
|
366
|
+
// The member it acts as, `user:<email>` or `token:<id>`, under the generation it was issued in.
|
|
367
|
+
principal: text().notNull(),
|
|
368
|
+
identityId: text('identity_id'),
|
|
369
|
+
label: text(),
|
|
370
|
+
createdAt: createdAt(),
|
|
371
|
+
createdBy: text('created_by').notNull(),
|
|
372
|
+
expiresAt: time('expires_at').notNull(),
|
|
373
|
+
lastUsedAt: time('last_used_at'),
|
|
374
|
+
lastUsedIp: text('last_used_ip'),
|
|
375
|
+
revokedAt: time('revoked_at'),
|
|
376
|
+
revokedBy: text('revoked_by'),
|
|
377
|
+
},
|
|
378
|
+
(table) => [
|
|
379
|
+
unique('credentials_token_hash_key').on(table.tokenHash),
|
|
380
|
+
check('credentials_kind_check', sql`${table.kind} IN ('browser', 'cli', 'service')`),
|
|
381
|
+
check('credentials_principal_check', sql`${table.principal} LIKE 'user:%' OR ${table.principal} LIKE 'token:%'`),
|
|
382
|
+
check('credentials_kind_matches_principal', sql`(${table.kind} = 'service') = (${table.principal} LIKE 'token:%')`),
|
|
383
|
+
check('credentials_token_hash_check', sql`octet_length(${table.tokenHash}) = 32`),
|
|
384
|
+
check('credentials_auth_mac_check', sql`octet_length(${table.authMac}) = 32`),
|
|
385
|
+
foreignKey({
|
|
386
|
+
name: 'credentials_principal_fkey',
|
|
387
|
+
columns: [table.principal],
|
|
388
|
+
foreignColumns: [vaultMembers.principal],
|
|
389
|
+
}).onDelete('restrict'),
|
|
390
|
+
// A session's account is its own member's, bound in the same generation.
|
|
391
|
+
foreignKey({
|
|
392
|
+
name: 'credentials_identity_id_fkey',
|
|
393
|
+
columns: [table.identityId, table.principal, table.generation],
|
|
394
|
+
foreignColumns: [identities.id, identities.principal, identities.generation],
|
|
395
|
+
}).onDelete('restrict'),
|
|
396
|
+
index('credentials_principal_idx').on(table.principal, table.generation),
|
|
397
|
+
],
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
export const deviceAuthorizations = sqliteTable(
|
|
401
|
+
'device_authorizations',
|
|
402
|
+
{
|
|
403
|
+
id: text().primaryKey(),
|
|
404
|
+
deviceCodeHash: bytes('device_code_hash').notNull(),
|
|
405
|
+
authMac: bytes('auth_mac').notNull(),
|
|
406
|
+
userCode: text('user_code').notNull(),
|
|
407
|
+
clientLabel: text('client_label'),
|
|
408
|
+
clientIp: text('client_ip'),
|
|
409
|
+
createdAt: createdAt(),
|
|
410
|
+
expiresAt: time('expires_at').notNull(),
|
|
411
|
+
decidedAt: time('decided_at'),
|
|
412
|
+
decision: text(),
|
|
413
|
+
generation: integer().notNull().default(0),
|
|
414
|
+
// Who approved it, `user:<email>`, once approved.
|
|
415
|
+
principal: text(),
|
|
416
|
+
consumedAt: time('consumed_at'),
|
|
417
|
+
},
|
|
418
|
+
(table) => [
|
|
419
|
+
unique('device_authorizations_device_code_hash_key').on(table.deviceCodeHash),
|
|
420
|
+
unique('device_authorizations_user_code_key').on(table.userCode),
|
|
421
|
+
check('device_authorizations_auth_mac_check', sql`octet_length(${table.authMac}) = 32`),
|
|
422
|
+
check(
|
|
423
|
+
'device_authorizations_decision_check',
|
|
424
|
+
sql`${table.decision} IS NULL OR ${table.decision} IN ('approved', 'denied')`,
|
|
425
|
+
),
|
|
426
|
+
check(
|
|
427
|
+
'device_authorizations_approval_names_principal',
|
|
428
|
+
sql`(${table.decision} IS NULL AND ${table.decidedAt} IS NULL AND ${table.principal} IS NULL AND ${table.generation} = 0 AND ${table.consumedAt} IS NULL)
|
|
429
|
+
OR (${table.decision} IS NOT NULL AND ${table.decidedAt} IS NOT NULL AND (
|
|
430
|
+
(${table.decision} = 'denied' AND ${table.principal} IS NULL AND ${table.generation} = 0 AND ${table.consumedAt} IS NULL)
|
|
431
|
+
OR (${table.decision} = 'approved' AND ${table.principal} IS NOT NULL AND ${table.principal} LIKE 'user:%')
|
|
432
|
+
))`,
|
|
433
|
+
),
|
|
434
|
+
foreignKey({
|
|
435
|
+
name: 'device_authorizations_principal_fkey',
|
|
436
|
+
columns: [table.principal],
|
|
437
|
+
foreignColumns: [vaultMembers.principal],
|
|
438
|
+
}).onDelete('restrict'),
|
|
439
|
+
],
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
export const syncs = sqliteTable(
|
|
443
|
+
'syncs',
|
|
444
|
+
{
|
|
445
|
+
id: text().primaryKey(),
|
|
446
|
+
projectId: text('project_id').notNull(),
|
|
447
|
+
environmentId: text('environment_id').notNull(),
|
|
448
|
+
provider: text().notNull(),
|
|
449
|
+
config: text().notNull(),
|
|
450
|
+
credentialSecretId: text('credential_secret_id').notNull(),
|
|
451
|
+
createdAt: createdAt(),
|
|
452
|
+
createdBy: text('created_by').notNull(),
|
|
453
|
+
pausedAt: time('paused_at'),
|
|
454
|
+
archivedAt: time('archived_at'),
|
|
455
|
+
leaseUntil: time('lease_until'),
|
|
456
|
+
lastRunAt: time('last_run_at'),
|
|
457
|
+
lastStatus: text('last_status'),
|
|
458
|
+
lastError: text('last_error'),
|
|
459
|
+
},
|
|
460
|
+
(table) => [
|
|
461
|
+
check('syncs_config_check', sql`json_valid(${table.config})`),
|
|
462
|
+
check(
|
|
463
|
+
'syncs_last_status_check',
|
|
464
|
+
sql`${table.lastStatus} IS NULL OR ${table.lastStatus} IN ('ok', 'partial', 'failed')`,
|
|
465
|
+
),
|
|
466
|
+
foreignKey({
|
|
467
|
+
name: 'syncs_environment_in_project',
|
|
468
|
+
columns: [table.environmentId, table.projectId],
|
|
469
|
+
foreignColumns: [environments.id, environments.projectId],
|
|
470
|
+
}).onDelete('restrict'),
|
|
471
|
+
foreignKey({
|
|
472
|
+
name: 'syncs_credential_secret_id_fkey',
|
|
473
|
+
columns: [table.credentialSecretId],
|
|
474
|
+
foreignColumns: [secrets.id],
|
|
475
|
+
}).onDelete('restrict'),
|
|
476
|
+
index('syncs_environment_idx').on(table.environmentId),
|
|
477
|
+
],
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
export const syncKeys = sqliteTable(
|
|
481
|
+
'sync_keys',
|
|
482
|
+
{
|
|
483
|
+
syncId: text('sync_id').notNull(),
|
|
484
|
+
key: text().notNull(),
|
|
485
|
+
secretVersionId: text('secret_version_id'),
|
|
486
|
+
pushedAt: time('pushed_at').notNull().default(now),
|
|
487
|
+
removedAt: time('removed_at'),
|
|
488
|
+
},
|
|
489
|
+
(table) => [
|
|
490
|
+
primaryKey({ name: 'sync_keys_pkey', columns: [table.syncId, table.key] }),
|
|
491
|
+
foreignKey({
|
|
492
|
+
name: 'sync_keys_sync_id_fkey',
|
|
493
|
+
columns: [table.syncId],
|
|
494
|
+
foreignColumns: [syncs.id],
|
|
495
|
+
}).onDelete('restrict'),
|
|
496
|
+
foreignKey({
|
|
497
|
+
name: 'sync_keys_secret_version_id_fkey',
|
|
498
|
+
columns: [table.secretVersionId],
|
|
499
|
+
foreignColumns: [secretVersions.id],
|
|
500
|
+
}).onDelete('restrict'),
|
|
501
|
+
],
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
export const {
|
|
505
|
+
environmentsRelations,
|
|
506
|
+
secretsRelations,
|
|
507
|
+
syncsRelations,
|
|
508
|
+
syncKeysRelations,
|
|
509
|
+
} = relationsOf(
|
|
510
|
+
asPostgres({ projects, environments, secrets, secretVersions, syncs, syncKeys }),
|
|
511
|
+
);
|