fix(core): allow scoped migration health reads
Grant the restricted runtime role read-only access to its own SQLite cutover marker. Repair existing databases with migration 0030 and apply the same row-scoped policy when first-boot migration creates the ledger.
This commit is contained in:
7
.changeset/read-sqlite-migration-health.md
Normal file
7
.changeset/read-sqlite-migration-health.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Stop PostgreSQL permission errors when the dashboard reads SQLite migration health.
|
||||
category: fix
|
||||
dev: Grants the project-bound runtime role row-scoped read access to the SQLite migration ledger.
|
||||
@@ -72,6 +72,8 @@ import {
|
||||
BIGINT_COUNTERS_VERSION,
|
||||
TASK_VERIFICATION_REQUEST_VERSION,
|
||||
TASK_DECLARED_SYMBOLS_VERSION,
|
||||
PLANNING_ACTIVE_TIMING_VERSION,
|
||||
SQLITE_MIGRATION_RUNTIME_READ_VERSION,
|
||||
} from "../../postgres/schema-applier.js";
|
||||
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
|
||||
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js";
|
||||
@@ -1576,6 +1578,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
BIGINT_COUNTERS_VERSION,
|
||||
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
||||
TASK_DECLARED_SYMBOLS_VERSION,
|
||||
PLANNING_ACTIVE_TIMING_VERSION,
|
||||
SQLITE_MIGRATION_RUNTIME_READ_VERSION,
|
||||
]);
|
||||
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
|
||||
});
|
||||
@@ -1630,6 +1634,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
BIGINT_COUNTERS_VERSION,
|
||||
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
||||
TASK_DECLARED_SYMBOLS_VERSION,
|
||||
PLANNING_ACTIVE_TIMING_VERSION,
|
||||
SQLITE_MIGRATION_RUNTIME_READ_VERSION,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1817,6 +1823,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
BIGINT_COUNTERS_VERSION,
|
||||
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
||||
TASK_DECLARED_SYMBOLS_VERSION,
|
||||
PLANNING_ACTIVE_TIMING_VERSION,
|
||||
SQLITE_MIGRATION_RUNTIME_READ_VERSION,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1885,6 +1893,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
BIGINT_COUNTERS_VERSION,
|
||||
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
||||
TASK_DECLARED_SYMBOLS_VERSION,
|
||||
PLANNING_ACTIVE_TIMING_VERSION,
|
||||
SQLITE_MIGRATION_RUNTIME_READ_VERSION,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1953,6 +1963,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
BIGINT_COUNTERS_VERSION,
|
||||
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
||||
TASK_DECLARED_SYMBOLS_VERSION,
|
||||
PLANNING_ACTIVE_TIMING_VERSION,
|
||||
SQLITE_MIGRATION_RUNTIME_READ_VERSION,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createTaskStoreForBackend } from "../../postgres/startup-factory.js";
|
||||
import { getSqliteMigrationState } from "../../postgres/sqlite-migrator.js";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { DatabaseSync } from "../../sqlite-adapter.js";
|
||||
import postgres from "postgres";
|
||||
@@ -178,6 +179,92 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => {
|
||||
await second!.shutdown();
|
||||
});
|
||||
|
||||
it("lets the restricted runtime role read an existing SQLite migration marker", async () => {
|
||||
rootDir = await mkdtemp(join(tmpdir(), "startup-factory-migration-marker-role-"));
|
||||
dbName = uniqueDbName();
|
||||
adminExec(`CREATE DATABASE "${dbName}"`);
|
||||
const testUrl = `${PG_TEST_URL_BASE}/${dbName}`;
|
||||
|
||||
const first = await createTaskStoreForBackend({
|
||||
rootDir,
|
||||
env: { DATABASE_URL: testUrl },
|
||||
poolMax: 1,
|
||||
});
|
||||
const projectId = first.taskStore.getAsyncLayer()!.projectId!;
|
||||
await first.shutdown();
|
||||
|
||||
const admin = postgres(testUrl, { max: 1 });
|
||||
try {
|
||||
await admin`CREATE TABLE public.fusion_sqlite_migrations (
|
||||
migration_key text PRIMARY KEY,
|
||||
project_id text,
|
||||
status text NOT NULL CHECK (status IN ('running', 'complete', 'failed')),
|
||||
last_error text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)`;
|
||||
await admin`
|
||||
INSERT INTO public.fusion_sqlite_migrations
|
||||
(migration_key, project_id, status, last_error, updated_at)
|
||||
VALUES
|
||||
(${`project:${projectId}`}, ${projectId}, 'failed', 'copy failed', now()),
|
||||
('project:other-project', 'other-project', 'failed', 'other copy failed', now())
|
||||
`;
|
||||
await admin`REVOKE ALL ON public.fusion_sqlite_migrations FROM fusion_runtime`;
|
||||
await admin`DELETE FROM public.fusion_schema_migrations WHERE version = '0030'`;
|
||||
} finally {
|
||||
await admin.end();
|
||||
}
|
||||
|
||||
const second = await createTaskStoreForBackend({
|
||||
rootDir,
|
||||
env: { DATABASE_URL: testUrl },
|
||||
poolMax: 1,
|
||||
});
|
||||
try {
|
||||
await expect(getSqliteMigrationState(
|
||||
second.taskStore.getAsyncLayer()!.db,
|
||||
`project:${projectId}`,
|
||||
)).resolves.toMatchObject({
|
||||
migrationKey: `project:${projectId}`,
|
||||
projectId,
|
||||
status: "failed",
|
||||
});
|
||||
await expect(getSqliteMigrationState(
|
||||
second.taskStore.getAsyncLayer()!.db,
|
||||
"project:other-project",
|
||||
)).resolves.toBeNull();
|
||||
} finally {
|
||||
await second.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("grants migration-marker reads when first-boot SQLite migration creates the table", async () => {
|
||||
rootDir = await mkdtemp(join(tmpdir(), "startup-factory-new-migration-marker-role-"));
|
||||
dbName = uniqueDbName();
|
||||
adminExec(`CREATE DATABASE "${dbName}"`);
|
||||
const testUrl = `${PG_TEST_URL_BASE}/${dbName}`;
|
||||
seedLegacyTask(rootDir, "FN-MARKER-1", "Migration marker grant");
|
||||
|
||||
const result = await createTaskStoreForBackend({
|
||||
rootDir,
|
||||
env: { DATABASE_URL: testUrl },
|
||||
poolMax: 1,
|
||||
});
|
||||
try {
|
||||
const projectId = result.taskStore.getAsyncLayer()!.projectId!;
|
||||
await expect(getSqliteMigrationState(
|
||||
result.taskStore.getAsyncLayer()!.db,
|
||||
`project:${projectId}`,
|
||||
)).resolves.toMatchObject({
|
||||
migrationKey: `project:${projectId}`,
|
||||
projectId,
|
||||
status: "complete",
|
||||
});
|
||||
} finally {
|
||||
await result.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLegacyMigration 2026-07-15-02:09:
|
||||
Steady-state startup must finish the retained-SQLite plugin bridge through the privileged migration connection before returning a project-scoped runtime store. Dashboard, serve, desktop, and engine startup all initialize PluginStore after the runtime role is active, so PluginStore.init must remain DDL-free and must not crash with "permission denied for schema public".
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
FNXC:MigrationStatusRuntimeRead 2026-07-20:
|
||||
Dashboard migration health runs through the project-bound fusion_runtime role.
|
||||
Grant that role read-only access to the SQLite cutover ledger while row-level
|
||||
security limits each session to its own project marker. Existing databases need
|
||||
this forward migration because the ledger is created outside the schema baseline.
|
||||
*/
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.fusion_sqlite_migrations') IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fusion_runtime') THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
ALTER TABLE public.fusion_sqlite_migrations ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_policy
|
||||
WHERE polrelid = 'public.fusion_sqlite_migrations'::regclass
|
||||
AND polname = 'fusion_sqlite_migrations_project_read'
|
||||
) THEN
|
||||
CREATE POLICY fusion_sqlite_migrations_project_read
|
||||
ON public.fusion_sqlite_migrations
|
||||
FOR SELECT
|
||||
TO fusion_runtime
|
||||
USING (
|
||||
current_setting('fusion.project_bypass', true) = 'on'
|
||||
OR project_id = NULLIF(current_setting('fusion.project_id', true), '')
|
||||
);
|
||||
END IF;
|
||||
|
||||
GRANT SELECT ON public.fusion_sqlite_migrations TO fusion_runtime;
|
||||
END $$;
|
||||
@@ -36,8 +36,12 @@ Advances to 0019 for the import-translation legacy-partition backfill. Per-migra
|
||||
FNXC:PostgresBigintCounters 2026-07-19-12:00:
|
||||
SCHEMA_BASELINE_VERSION advances to 0026 for the bigint counters migration.
|
||||
Per-migration identities above stay fixed; only this latest-version marker moves.
|
||||
|
||||
FNXC:MigrationStatusRuntimeRead 2026-07-20:
|
||||
SCHEMA_BASELINE_VERSION advances to 0030 for project-scoped runtime reads of
|
||||
the SQLite cutover ledger.
|
||||
*/
|
||||
export const SCHEMA_BASELINE_VERSION = "0029";
|
||||
export const SCHEMA_BASELINE_VERSION = "0030";
|
||||
/** FNXC:SymbolLock 2026-07-31-10:00: upgrades need durable task declarations before admission resolves symbols. */
|
||||
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
|
||||
const INITIAL_SCHEMA_VERSION = "0000";
|
||||
@@ -126,6 +130,8 @@ export const SYMBOL_LOCKS_SCHEMA_VERSION = "0025";
|
||||
export const BIGINT_COUNTERS_VERSION = "0026";
|
||||
/** FNXC:TaskTiming 2026-08-01-10:00: existing clusters need planning-session timing columns. */
|
||||
export const PLANNING_ACTIVE_TIMING_VERSION = "0029";
|
||||
/** Dashboard health needs project-scoped, read-only runtime access to the SQLite cutover ledger. */
|
||||
export const SQLITE_MIGRATION_RUNTIME_READ_VERSION = "0030";
|
||||
|
||||
/**
|
||||
* Thrown when the database was migrated by a NEWER Fusion binary than the one now
|
||||
@@ -317,6 +323,7 @@ const WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_MIGRATION_PATH = join(
|
||||
|
||||
const PLANNING_ACTIVE_TIMING_MIGRATION_PATH = join(MIGRATIONS_DIR, "0029_planning_active_timing.sql");
|
||||
const TASK_DECLARED_SYMBOLS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0028_task_declared_symbols.sql");
|
||||
const SQLITE_MIGRATION_RUNTIME_READ_PATH = join(MIGRATIONS_DIR, "0030_sqlite_migration_runtime_read.sql");
|
||||
|
||||
/**
|
||||
* Ensure the migration bookkeeping table exists. Lives in the public schema so
|
||||
@@ -414,6 +421,7 @@ export async function applySchemaBaseline(
|
||||
const bigintCountersAlreadyApplied = applied.includes(BIGINT_COUNTERS_VERSION);
|
||||
const workflowIrPinAndLegacyAdoptionAlreadyApplied = applied.includes(WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION);
|
||||
const planningActiveTimingAlreadyApplied = applied.includes(PLANNING_ACTIVE_TIMING_VERSION);
|
||||
const sqliteMigrationRuntimeReadAlreadyApplied = applied.includes(SQLITE_MIGRATION_RUNTIME_READ_VERSION);
|
||||
assertBinaryNotOlderThanDatabase(applied);
|
||||
let schemaChanged = false;
|
||||
|
||||
@@ -856,6 +864,13 @@ export async function applySchemaBaseline(
|
||||
schemaChanged = true;
|
||||
}
|
||||
|
||||
if (!sqliteMigrationRuntimeReadAlreadyApplied) {
|
||||
const migrationSql = await readFile(SQLITE_MIGRATION_RUNTIME_READ_PATH, "utf8");
|
||||
await tx.execute(sql.raw(migrationSql));
|
||||
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SQLITE_MIGRATION_RUNTIME_READ_VERSION}) ON CONFLICT (version) DO NOTHING`);
|
||||
schemaChanged = true;
|
||||
}
|
||||
|
||||
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -457,6 +457,34 @@ async function ensureMigrationStateTable(db: PostgresJsDatabase<Record<string, n
|
||||
last_error text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)`));
|
||||
await db.execute(sql.raw(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fusion_runtime') THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
ALTER TABLE public.${SQLITE_MIGRATION_STATE_TABLE} ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_policy
|
||||
WHERE polrelid = 'public.${SQLITE_MIGRATION_STATE_TABLE}'::regclass
|
||||
AND polname = 'fusion_sqlite_migrations_project_read'
|
||||
) THEN
|
||||
CREATE POLICY fusion_sqlite_migrations_project_read
|
||||
ON public.${SQLITE_MIGRATION_STATE_TABLE}
|
||||
FOR SELECT
|
||||
TO fusion_runtime
|
||||
USING (
|
||||
current_setting('fusion.project_bypass', true) = 'on'
|
||||
OR project_id = NULLIF(current_setting('fusion.project_id', true), '')
|
||||
);
|
||||
END IF;
|
||||
|
||||
GRANT SELECT ON public.${SQLITE_MIGRATION_STATE_TABLE} TO fusion_runtime;
|
||||
END $$;
|
||||
`));
|
||||
}
|
||||
|
||||
export interface SqliteMigrationState {
|
||||
|
||||
Reference in New Issue
Block a user