@sanoma-ai/workflows 0.11.12 → 0.11.14
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/CLOUD-IDENTITY.md +12 -4
- package/KMS-SECRETS.md +3 -1
- package/README.md +4 -2
- package/dist/bundled.d.ts +2 -2
- package/dist/chats.d.ts +3 -3
- package/dist/chats.js +34 -20
- package/dist/chats.js.map +1 -1
- package/dist/client.d.ts +17 -4
- package/dist/client.js +74 -63
- package/dist/client.js.map +1 -1
- package/dist/cloud-identity.js +14 -12
- package/dist/cloud-identity.js.map +1 -1
- package/dist/collection.d.ts +2 -2
- package/dist/collection.js +53 -42
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +11 -6
- package/dist/config.js +15 -13
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +2 -0
- package/dist/connection.js +112 -40
- package/dist/connection.js.map +1 -1
- package/dist/credentials.d.ts +1 -1
- package/dist/credentials.js +33 -29
- package/dist/credentials.js.map +1 -1
- package/dist/db/company.d.ts +1152 -0
- package/dist/db/company.js +118 -0
- package/dist/db/company.js.map +1 -0
- package/dist/db/dbos.d.ts +111 -0
- package/dist/db/dbos.js +15 -0
- package/dist/db/dbos.js.map +1 -0
- package/dist/db/identity.d.ts +367 -0
- package/dist/db/identity.js +48 -0
- package/dist/db/identity.js.map +1 -0
- package/dist/db/index.d.ts +7 -0
- package/dist/db/index.js +12 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/kms.d.ts +224 -0
- package/dist/db/kms.js +29 -0
- package/dist/db/kms.js.map +1 -0
- package/dist/db/migrate.d.ts +66 -0
- package/dist/db/migrate.js +39 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/wakeup.d.ts +271 -0
- package/dist/db/wakeup.js +34 -0
- package/dist/db/wakeup.js.map +1 -0
- package/dist/db.d.ts +42 -39
- package/dist/db.js +129 -169
- package/dist/db.js.map +1 -1
- package/dist/deadlines.js +8 -4
- package/dist/deadlines.js.map +1 -1
- package/dist/deployment-key.d.ts +4 -1
- package/dist/deployment-key.js +10 -3
- package/dist/deployment-key.js.map +1 -1
- package/dist/host-derive.d.ts +12 -3
- package/dist/host-derive.js +21 -8
- package/dist/host-derive.js.map +1 -1
- package/dist/identity-provisioning.d.ts +2 -2
- package/dist/identity-provisioning.js +75 -42
- package/dist/identity-provisioning.js.map +1 -1
- package/dist/identity-service.js +18 -15
- package/dist/identity-service.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/kms-secrets.d.ts +9 -0
- package/dist/kms-secrets.js +59 -27
- package/dist/kms-secrets.js.map +1 -1
- package/dist/ledger.d.ts +1 -1
- package/dist/ledger.js +22 -14
- package/dist/ledger.js.map +1 -1
- package/dist/op.d.ts +27 -6
- package/dist/op.js.map +1 -1
- package/dist/run-status.js +7 -3
- package/dist/run-status.js.map +1 -1
- package/dist/schedules.d.ts +1 -1
- package/dist/schedules.js +5 -7
- package/dist/schedules.js.map +1 -1
- package/dist/schema.d.ts +6 -5
- package/dist/schema.js +11 -7
- package/dist/schema.js.map +1 -1
- package/dist/shared.d.ts +6 -23
- package/dist/shared.js +6 -5
- package/dist/shared.js.map +1 -1
- package/dist/storage-provisioning.d.ts +2 -0
- package/dist/storage-provisioning.js +51 -50
- package/dist/storage-provisioning.js.map +1 -1
- package/dist/tokens.d.ts +2 -2
- package/dist/tokens.js +31 -20
- package/dist/tokens.js.map +1 -1
- package/dist/version.d.ts +4 -4
- package/dist/version.js +5 -5
- package/dist/version.js.map +1 -1
- package/dist/wakeup-service.d.ts +0 -4
- package/dist/wakeup-service.js +102 -93
- package/dist/wakeup-service.js.map +1 -1
- package/dist/worker.d.ts +7 -0
- package/dist/worker.js +26 -21
- package/dist/worker.js.map +1 -1
- package/drizzle/company/0000_tables.sql +104 -0
- package/drizzle/company/0001_wakeup_row.sql +2 -0
- package/drizzle/company/meta/0000_snapshot.json +652 -0
- package/drizzle/company/meta/0001_snapshot.json +652 -0
- package/drizzle/company/meta/_journal.json +20 -0
- package/drizzle/wakeup/0000_tables.sql +26 -0
- package/drizzle/wakeup/meta/0000_snapshot.json +200 -0
- package/drizzle/wakeup/meta/_journal.json +13 -0
- package/oxlint.json +2 -1
- package/package.json +11 -2
- package/src/bundled.ts +2 -2
- package/src/chats.ts +41 -42
- package/src/client.ts +81 -64
- package/src/cloud-identity.ts +23 -24
- package/src/collection.ts +58 -50
- package/src/config.ts +29 -19
- package/src/connection.ts +132 -102
- package/src/credentials.ts +37 -33
- package/src/db/company.ts +173 -0
- package/src/db/dbos.ts +17 -0
- package/src/db/identity.ts +61 -0
- package/src/db/index.ts +12 -0
- package/src/db/kms.ts +41 -0
- package/src/db/migrate.ts +71 -0
- package/src/db/wakeup.ts +41 -0
- package/src/db.ts +151 -182
- package/src/deadlines.ts +10 -4
- package/src/deployment-key.ts +11 -3
- package/src/host-derive.ts +26 -9
- package/src/identity-provisioning.ts +85 -57
- package/src/identity-service.ts +19 -19
- package/src/index.ts +3 -0
- package/src/kms-secrets.ts +64 -56
- package/src/ledger.ts +23 -18
- package/src/op.ts +28 -6
- package/src/run-status.ts +7 -6
- package/src/schedules.ts +6 -8
- package/src/schema.ts +11 -8
- package/src/shared.ts +6 -17
- package/src/storage-provisioning.ts +47 -52
- package/src/tokens.ts +43 -33
- package/src/version.ts +5 -5
- package/src/wakeup-service.ts +117 -131
- package/src/worker.ts +38 -21
package/src/run-status.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { WorkflowStatusString } from "@dbos-inc/dbos-sdk";
|
|
2
|
+
import { and, eq } from "drizzle-orm";
|
|
2
3
|
import type { Db } from "./db.ts";
|
|
4
|
+
import { workflowStatus } from "./db/dbos.ts";
|
|
3
5
|
|
|
4
6
|
/** Metadata only: never read a run's inputs, outputs, error or authentication data. */
|
|
5
7
|
export type RunObservation = {
|
|
@@ -24,15 +26,14 @@ export const inspectRun =
|
|
|
24
26
|
(database: () => Promise<Db>, appName: string): InspectRun =>
|
|
25
27
|
async (runId) => {
|
|
26
28
|
const db = await database();
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const row = rows[0];
|
|
29
|
+
const [row] = await db
|
|
30
|
+
.select({ name: workflowStatus.name, status: workflowStatus.status })
|
|
31
|
+
.from(workflowStatus)
|
|
32
|
+
.where(and(eq(workflowStatus.workflowUuid, runId), eq(workflowStatus.applicationName, appName)));
|
|
32
33
|
if (!row) return { runId, status: "missing" };
|
|
33
34
|
return {
|
|
34
35
|
runId,
|
|
35
|
-
workflow: row.name,
|
|
36
|
+
...(row.name !== null && { workflow: row.name }),
|
|
36
37
|
status: Object.hasOwn(statuses, row.status) ? statuses[row.status as WorkflowStatusString] : "active",
|
|
37
38
|
};
|
|
38
39
|
};
|
package/src/schedules.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { DBOS, type ScheduledWorkflowFn, type WorkflowSchedule } from "@dbos-inc
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import type { ResolvedConfig } from "./config.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { eq } from "drizzle-orm";
|
|
6
|
+
import type { Db } from "./db.ts";
|
|
7
|
+
import { wakeup } from "./db/company.ts";
|
|
6
8
|
import type { Principal, WorkflowDefinition } from "./define.ts";
|
|
7
9
|
import { readPeople, admit } from "./resources.ts";
|
|
8
10
|
import type { WorkerState } from "./run.ts";
|
|
@@ -77,7 +79,9 @@ export async function applyScheduled(
|
|
|
77
79
|
await Promise.all([...before.keys()].filter((name) => !names.has(name)).map((name) => DBOS.deleteSchedule(name)));
|
|
78
80
|
const unfired = declared.filter((d) => before.get(d.schedule.scheduleName)?.lastFiredAt === null);
|
|
79
81
|
if (!unfired.length) return;
|
|
80
|
-
|
|
82
|
+
// When the instance's worker last stopped itself, from the wakeup row; never, and nothing was missed.
|
|
83
|
+
const [row] = await (await db()).select({ stoppedAt: wakeup.stoppedAt }).from(wakeup).where(eq(wakeup.id, 1));
|
|
84
|
+
const stoppedAt = row?.stoppedAt;
|
|
81
85
|
if (!stoppedAt) return;
|
|
82
86
|
for (const { schedule, since } of unfired) {
|
|
83
87
|
const from = stoppedAt > since ? stoppedAt : since;
|
|
@@ -104,12 +108,6 @@ function sinceOf(schedule: WorkflowSchedule | undefined): Date | undefined {
|
|
|
104
108
|
return at && !Number.isNaN(at.getTime()) ? at : undefined;
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
/** When the instance's worker last stopped, from the wakeup's row; undefined while it never has. */
|
|
108
|
-
async function lastStopped(db: Db): Promise<Date | undefined> {
|
|
109
|
-
const { rows } = await db.query<{ stopped_at: Date | null }>(`SELECT stopped_at FROM ${WAKEUP_TABLE} WHERE id = 1`);
|
|
110
|
-
return rows[0]?.stopped_at ?? undefined;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
111
|
/** Who starts a scheduled run, as `scheduledStarter` admitted them, or why they were not. */
|
|
114
112
|
export interface ScheduledStarter {
|
|
115
113
|
startedBy: Principal;
|
package/src/schema.ts
CHANGED
|
@@ -22,20 +22,23 @@ import { CHANGE_MERGE, DATA_VENDOR, EFFECTS, groupNamesOf, type PeopleResource,
|
|
|
22
22
|
/** Cedar's bindings: its builds (`/nodejs`, `/web`) export the same functions. */
|
|
23
23
|
export type CedarWasm = typeof import("@cedar-policy/cedar-wasm/nodejs");
|
|
24
24
|
let wasm: CedarWasm | undefined;
|
|
25
|
+
/** How Cedar is loaded on first use: its Node build, unless `useCedarWasm` gave another. */
|
|
26
|
+
let load = () => createRequire(import.meta.url)("@cedar-policy/cedar-wasm/nodejs") as CedarWasm;
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
+
* Cedar's bindings, loaded (synchronously, once) on first use, so a process that never decides
|
|
30
|
+
* with Cedar never loads it: `@cedar-policy/cedar-wasm`'s Node build, or what `useCedarWasm`'s loader gives.
|
|
29
31
|
*/
|
|
30
|
-
export const cedarWasm = (): CedarWasm =>
|
|
31
|
-
(wasm ??= createRequire(import.meta.url)("@cedar-policy/cedar-wasm/nodejs") as CedarWasm);
|
|
32
|
+
export const cedarWasm = (): CedarWasm => (wasm ??= load());
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
|
-
* From now on, Cedar is `
|
|
35
|
-
*
|
|
35
|
+
* From now on, Cedar is what `loader` gives on first use: its web build, initialised, where the
|
|
36
|
+
* Node build cannot load (workerd, which bundles the runtime), and instantiated only once a
|
|
37
|
+
* request decides with it.
|
|
36
38
|
*/
|
|
37
|
-
export function useCedarWasm(
|
|
38
|
-
wasm =
|
|
39
|
+
export function useCedarWasm(loader: () => CedarWasm): void {
|
|
40
|
+
wasm = undefined;
|
|
41
|
+
load = loader;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
/** The schema as Cedar's JSON format has it. */
|
package/src/shared.ts
CHANGED
|
@@ -142,24 +142,13 @@ export const domainsText = (domains: Readonly<Record<string, string>>): string =
|
|
|
142
142
|
.join(",");
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
|
-
* Where a hosted
|
|
146
|
-
*
|
|
145
|
+
* Where a hosted worker's port answers, with its instance token (`SANOMA_WAKEUP_TOKEN`) and
|
|
146
|
+
* `?revision=<commit>`, the app bundle (`AppBundle` in `@sanoma-ai/app`) a host's Worker script runs
|
|
147
|
+
* at that revision, built in a checkout of its own: the Cloudflare host asks for it at a deploy.
|
|
147
148
|
*/
|
|
148
|
-
export const
|
|
149
|
-
/** The header every answer of that route carries
|
|
150
|
-
export const
|
|
151
|
-
|
|
152
|
-
/** What that route answers: what a host's Worker runs a company's public sites from (`buildWorker` in `@sanoma-ai/app/build`). */
|
|
153
|
-
export interface SitesBundle {
|
|
154
|
-
/** The sites it serves, by key: public, with a domain and no login. */
|
|
155
|
-
sites: string[];
|
|
156
|
-
/** The main module, among `modules`. */
|
|
157
|
-
mainModule: string;
|
|
158
|
-
/** The Worker's modules, by name relative to the main module, each base64. */
|
|
159
|
-
modules: { name: string; type: "esm" | "wasm" | "text" | "data"; content: string }[];
|
|
160
|
-
/** The Worker's static assets, by path from `/`, each base64: the client build and the prerendered pages. */
|
|
161
|
-
assets: { path: string; content: string }[];
|
|
162
|
-
}
|
|
149
|
+
export const APP_BUNDLE_PATH = "/__sanoma/app-bundle";
|
|
150
|
+
/** The header every answer of that route carries, so a host tells them from its own errors. */
|
|
151
|
+
export const APP_BUNDLE_HEADER = "x-sanoma-app-bundle";
|
|
163
152
|
|
|
164
153
|
/** Where a host with a `hostname` serves the app (`HostSpec`'s `hostname`): `https://<hostname>`. */
|
|
165
154
|
export const hostnameUrl = (hostname: string): string => `https://${hostname}`;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { eq, sql } from "drizzle-orm";
|
|
3
|
+
import { Client } from "pg";
|
|
4
|
+
import { databaseOn, type Db, pgCode } from "./db.ts";
|
|
5
|
+
import { kmsDeployment, kmsSecrets } from "./db/kms.ts";
|
|
3
6
|
import { KMS_SECRETS_SQL } from "./kms-secrets.ts";
|
|
4
7
|
import { generateSecret, type Secrets } from "./secret.ts";
|
|
5
8
|
|
|
@@ -47,7 +50,8 @@ const fail = (code: StorageProvisioningError["code"]): never => {
|
|
|
47
50
|
};
|
|
48
51
|
const digest = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
49
52
|
const ROLE_PREFIX = "sanoma_runtime_";
|
|
50
|
-
|
|
53
|
+
/** Installs the deployment binding table beside `KMS_SECRETS_SQL`; an administrator creates it, the runtime never does. */
|
|
54
|
+
export const BINDING_SQL = `CREATE TABLE IF NOT EXISTS sanoma_kms.deployment (
|
|
51
55
|
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
52
56
|
company text NOT NULL, environment text NOT NULL, runtime_role text NOT NULL,
|
|
53
57
|
credential_hash text NOT NULL
|
|
@@ -128,7 +132,7 @@ async function credentials(input: PrepareSecretStore, secrets: Secrets) {
|
|
|
128
132
|
};
|
|
129
133
|
}
|
|
130
134
|
|
|
131
|
-
async function connected<T>(url: string, run: (db:
|
|
135
|
+
async function connected<T>(url: string, run: (db: Db) => Promise<T>): Promise<T> {
|
|
132
136
|
const db = new Client({
|
|
133
137
|
connectionString: url,
|
|
134
138
|
connectionTimeoutMillis: 10_000,
|
|
@@ -140,7 +144,7 @@ async function connected<T>(url: string, run: (db: Client) => Promise<T>): Promi
|
|
|
140
144
|
});
|
|
141
145
|
try {
|
|
142
146
|
await db.connect();
|
|
143
|
-
return await run(db);
|
|
147
|
+
return await run(databaseOn(db));
|
|
144
148
|
} finally {
|
|
145
149
|
await db.end().catch(() => {});
|
|
146
150
|
}
|
|
@@ -152,72 +156,65 @@ export const prepareSecretStore: PrepareStorage = async (input, secrets) => {
|
|
|
152
156
|
const prepared = await credentials(input, secrets);
|
|
153
157
|
const { company, environment } = input;
|
|
154
158
|
const hash = digest(prepared.password),
|
|
155
|
-
role =
|
|
156
|
-
await connected(prepared.admin,
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
await
|
|
160
|
-
await
|
|
161
|
-
await
|
|
162
|
-
const existing = (
|
|
163
|
-
await db.query<{ company: string; environment: string; runtime_role: string; credential_hash: string }>(
|
|
164
|
-
"SELECT company,environment,runtime_role,credential_hash FROM sanoma_kms.deployment WHERE singleton=true FOR UPDATE",
|
|
165
|
-
)
|
|
166
|
-
).rows[0];
|
|
159
|
+
role = sql.identifier(prepared.role);
|
|
160
|
+
await connected(prepared.admin, (db) =>
|
|
161
|
+
db.transaction(async (tx) => {
|
|
162
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('sanoma.prepareSecretStore'))`);
|
|
163
|
+
await tx.execute(sql.raw(KMS_SECRETS_SQL));
|
|
164
|
+
await tx.execute(sql.raw(BINDING_SQL));
|
|
165
|
+
const [existing] = await tx.select().from(kmsDeployment).where(eq(kmsDeployment.singleton, true)).for("update");
|
|
167
166
|
if (existing) {
|
|
168
167
|
if (
|
|
169
168
|
existing.company !== company ||
|
|
170
169
|
existing.environment !== environment ||
|
|
171
|
-
existing.
|
|
172
|
-
existing.
|
|
170
|
+
existing.runtimeRole !== prepared.role ||
|
|
171
|
+
existing.credentialHash !== hash
|
|
173
172
|
)
|
|
174
173
|
fail("conflict");
|
|
175
174
|
} else {
|
|
176
175
|
if (
|
|
177
|
-
(await
|
|
178
|
-
(
|
|
176
|
+
(await tx.execute(sql`SELECT 1 FROM pg_roles WHERE rolname = ${prepared.role}`)).rowCount ||
|
|
177
|
+
(
|
|
178
|
+
await tx
|
|
179
|
+
.select({ one: sql`1` })
|
|
180
|
+
.from(kmsSecrets)
|
|
181
|
+
.limit(1)
|
|
182
|
+
).length
|
|
179
183
|
)
|
|
180
184
|
fail("conflict");
|
|
181
|
-
await
|
|
182
|
-
await
|
|
183
|
-
await
|
|
184
|
-
await
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
);
|
|
185
|
+
await tx.execute(sql`SET LOCAL password_encryption = 'scram-sha-256'`);
|
|
186
|
+
await tx.execute(sql.raw(CREATE_RUNTIME_SQL));
|
|
187
|
+
await tx.execute(sql`SELECT pg_temp.sanoma_create_runtime(${prepared.role}, ${prepared.password})`);
|
|
188
|
+
await tx
|
|
189
|
+
.insert(kmsDeployment)
|
|
190
|
+
.values({ company, environment, runtimeRole: prepared.role, credentialHash: hash });
|
|
188
191
|
}
|
|
189
192
|
// Never silently repair/adopt an unexpectedly privileged role on a retry.
|
|
190
193
|
const authority = (
|
|
191
|
-
await
|
|
192
|
-
`SELECT (
|
|
194
|
+
await tx.execute<{ safe: boolean }>(sql`SELECT (
|
|
193
195
|
NOT r.rolsuper AND NOT r.rolcreatedb AND NOT r.rolcreaterole AND NOT r.rolreplication AND NOT r.rolbypassrls
|
|
194
196
|
AND r.rolcanlogin AND NOT r.rolinherit
|
|
195
197
|
AND NOT EXISTS(SELECT 1 FROM pg_auth_members WHERE member=r.oid)
|
|
196
198
|
AND NOT EXISTS(SELECT 1 FROM pg_database WHERE datdba=r.oid)
|
|
197
199
|
AND NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname='sanoma_kms' AND nspowner=r.oid)
|
|
198
|
-
) AS safe FROM pg_roles r WHERE rolname
|
|
199
|
-
[prepared.role],
|
|
200
|
-
)
|
|
200
|
+
) AS safe FROM pg_roles r WHERE rolname = ${prepared.role}`)
|
|
201
201
|
).rows[0];
|
|
202
202
|
if (!authority?.safe) fail("denied");
|
|
203
|
-
const database =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
await
|
|
207
|
-
await
|
|
208
|
-
await
|
|
209
|
-
await
|
|
210
|
-
await
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
});
|
|
203
|
+
const database = sql.identifier(
|
|
204
|
+
(await tx.execute<{ name: string }>(sql`SELECT current_database() AS name`)).rows[0]!.name,
|
|
205
|
+
);
|
|
206
|
+
await tx.execute(sql`REVOKE ALL ON DATABASE ${database} FROM PUBLIC`);
|
|
207
|
+
await tx.execute(sql`GRANT CONNECT, CREATE, TEMPORARY ON DATABASE ${database} TO ${role}`);
|
|
208
|
+
await tx.execute(sql`REVOKE ALL ON SCHEMA public FROM PUBLIC`);
|
|
209
|
+
await tx.execute(sql`GRANT USAGE, CREATE ON SCHEMA public TO ${role}`);
|
|
210
|
+
await tx.execute(sql`GRANT USAGE ON SCHEMA sanoma_kms TO ${role}`);
|
|
211
|
+
await tx.execute(sql`GRANT SELECT, INSERT, UPDATE, DELETE ON sanoma_kms.secrets TO ${role}`);
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
216
214
|
// A committed schema is not enough: the stored login must work with the promised authority.
|
|
217
215
|
await connected(prepared.runtime, async (db) => {
|
|
218
|
-
const { rows } = await db.
|
|
219
|
-
|
|
220
|
-
current_user=$1 AND has_schema_privilege(current_user,'sanoma_kms','USAGE')
|
|
216
|
+
const { rows } = await db.execute<{ safe: boolean }>(sql`SELECT (
|
|
217
|
+
current_user = ${prepared.role} AND has_schema_privilege(current_user,'sanoma_kms','USAGE')
|
|
221
218
|
AND NOT has_schema_privilege(current_user,'sanoma_kms','CREATE')
|
|
222
219
|
AND has_table_privilege(current_user,'sanoma_kms.secrets','SELECT')
|
|
223
220
|
AND has_table_privilege(current_user,'sanoma_kms.secrets','INSERT')
|
|
@@ -225,16 +222,14 @@ export const prepareSecretStore: PrepareStorage = async (input, secrets) => {
|
|
|
225
222
|
AND has_table_privilege(current_user,'sanoma_kms.secrets','DELETE')
|
|
226
223
|
AND NOT has_table_privilege(current_user,'sanoma_kms.secrets','TRUNCATE,TRIGGER,REFERENCES')
|
|
227
224
|
AND NOT has_table_privilege(current_user,'sanoma_kms.deployment','SELECT,INSERT,UPDATE,DELETE,TRUNCATE,TRIGGER,REFERENCES')
|
|
228
|
-
) AS safe
|
|
229
|
-
[prepared.role],
|
|
230
|
-
);
|
|
225
|
+
) AS safe`);
|
|
231
226
|
if (!rows[0]?.safe) fail("denied");
|
|
232
227
|
});
|
|
233
228
|
return prepared.receipt;
|
|
234
229
|
} catch (error) {
|
|
235
230
|
const known = storageProvisioningCode(error);
|
|
236
231
|
if (known) throw new StorageProvisioningError(known);
|
|
237
|
-
const code = (error
|
|
232
|
+
const code = pgCode(error);
|
|
238
233
|
throw new StorageProvisioningError(
|
|
239
234
|
code === "42501" ? "denied" : code === "23505" || code === "42710" ? "conflict" : "unavailable",
|
|
240
235
|
);
|
package/src/tokens.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import type
|
|
3
|
-
import {
|
|
2
|
+
import { and, eq, isNull, type SQL, sql } from "drizzle-orm";
|
|
3
|
+
import { pgCode, type Db } from "./db.ts";
|
|
4
|
+
import { apiTokens } from "./db/company.ts";
|
|
4
5
|
import type { Principal } from "./define.ts";
|
|
5
6
|
import { generateSecret, tokenHash as hashOf } from "./secret.ts";
|
|
6
7
|
|
|
@@ -48,62 +49,71 @@ export interface Tokens {
|
|
|
48
49
|
subjectOf(id: string): Promise<string | undefined>;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
const active = (where: SQL | undefined) => and(where, isNull(apiTokens.revokedAt));
|
|
53
|
+
|
|
54
|
+
export function tokenStore(db: () => Promise<Db>): Tokens {
|
|
52
55
|
return {
|
|
53
56
|
async mint({ id = randomUUID(), subject, groups }) {
|
|
54
57
|
const token = newApiToken();
|
|
55
|
-
await (
|
|
56
|
-
await db()
|
|
57
|
-
).query(`INSERT INTO ${TABLE} (id, hash, subject, groups) VALUES ($1, $2, $3, $4::jsonb)`, [
|
|
58
|
-
id,
|
|
59
|
-
hashOf(token),
|
|
60
|
-
subject,
|
|
61
|
-
JSON.stringify(groups),
|
|
62
|
-
]);
|
|
58
|
+
await (await db()).insert(apiTokens).values({ id, hash: hashOf(token), subject, groups: [...groups] });
|
|
63
59
|
return { id, token };
|
|
64
60
|
},
|
|
65
61
|
async register(input) {
|
|
66
62
|
const { id, hash, subject, groups } = registrationOf(input);
|
|
67
63
|
try {
|
|
68
|
-
|
|
64
|
+
// A duplicate id changes nothing unless it is this same active registration (the no-op
|
|
65
|
+
// update makes the row come back).
|
|
66
|
+
const registered = await (
|
|
69
67
|
await db()
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
68
|
+
)
|
|
69
|
+
.insert(apiTokens)
|
|
70
|
+
.values({ id, hash, subject, groups })
|
|
71
|
+
.onConflictDoUpdate({
|
|
72
|
+
target: apiTokens.id,
|
|
73
|
+
set: { id },
|
|
74
|
+
setWhere: and(
|
|
75
|
+
eq(apiTokens.hash, hash),
|
|
76
|
+
eq(apiTokens.subject, subject),
|
|
77
|
+
sql`${apiTokens.groups} @> ${sql.param(groups, apiTokens.groups)} AND ${apiTokens.groups} <@ ${sql.param(groups, apiTokens.groups)}`,
|
|
78
|
+
isNull(apiTokens.revokedAt),
|
|
79
|
+
),
|
|
80
|
+
})
|
|
81
|
+
.returning({ id: apiTokens.id });
|
|
82
|
+
return registered.length === 1;
|
|
80
83
|
} catch (error) {
|
|
81
84
|
// A bearer already registered under another ID cannot acquire a second identity.
|
|
82
|
-
if ((error
|
|
85
|
+
if (pgCode(error) === "23505") return false;
|
|
83
86
|
throw error;
|
|
84
87
|
}
|
|
85
88
|
},
|
|
86
89
|
async revoke({ id }) {
|
|
87
|
-
await (
|
|
90
|
+
await (
|
|
91
|
+
await db()
|
|
92
|
+
)
|
|
93
|
+
.update(apiTokens)
|
|
94
|
+
.set({ revokedAt: sql`now()` })
|
|
95
|
+
.where(active(eq(apiTokens.id, id)));
|
|
88
96
|
},
|
|
89
97
|
async resolve(token) {
|
|
90
98
|
if (!token.startsWith(PREFIX)) return undefined;
|
|
91
99
|
// Looked up by its hash: the token itself is never stored, so there is nothing to compare it to.
|
|
92
|
-
const
|
|
100
|
+
const [row] = await (
|
|
93
101
|
await db()
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const row = rows[0];
|
|
102
|
+
)
|
|
103
|
+
.select({ id: apiTokens.id, subject: apiTokens.subject, groups: apiTokens.groups })
|
|
104
|
+
.from(apiTokens)
|
|
105
|
+
.where(active(eq(apiTokens.hash, hashOf(token))));
|
|
99
106
|
if (!row) return undefined;
|
|
100
107
|
return { principal: { id: `token:${row.id}`, groups: row.groups }, subject: row.subject };
|
|
101
108
|
},
|
|
102
109
|
async subjectOf(id) {
|
|
103
|
-
const
|
|
110
|
+
const [row] = await (
|
|
104
111
|
await db()
|
|
105
|
-
)
|
|
106
|
-
|
|
112
|
+
)
|
|
113
|
+
.select({ subject: apiTokens.subject })
|
|
114
|
+
.from(apiTokens)
|
|
115
|
+
.where(active(eq(apiTokens.id, id)));
|
|
116
|
+
return row?.subject;
|
|
107
117
|
},
|
|
108
118
|
};
|
|
109
119
|
}
|
package/src/version.ts
CHANGED
|
@@ -69,14 +69,14 @@ export function computeVersion({
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
* A hash of
|
|
73
|
-
* `
|
|
74
|
-
* (`
|
|
72
|
+
* A hash of a built-in workflow's own code (`apply`'s, `change`'s, …), which each of its runs
|
|
73
|
+
* carries (its `builtInCode` attribute) so a worker on another version of the app may take it
|
|
74
|
+
* over (`carryBuiltInRuns` in worker.ts): what `computeVersion` hashes of that one workflow, with
|
|
75
75
|
* `RUNTIME_VERSION`, `STEP_LAYOUT` and the installed DBOS version. Never the company's workflows
|
|
76
76
|
* nor `DBOS__APPVERSION`, so a merge that changes only the company's code leaves it as it is.
|
|
77
77
|
*/
|
|
78
|
-
export function
|
|
79
|
-
return sha256([RUNTIME_VERSION, STEP_LAYOUT, installedDbosVersion(), codeOf(
|
|
78
|
+
export function builtInCodeOf(builtIn: WorkflowDefinition<any, any>): string {
|
|
79
|
+
return sha256([RUNTIME_VERSION, STEP_LAYOUT, installedDbosVersion(), codeOf(builtIn)]);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
const sha256 = (parts: unknown[]) => createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|