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,
|
BIGINT_COUNTERS_VERSION,
|
||||||
TASK_VERIFICATION_REQUEST_VERSION,
|
TASK_VERIFICATION_REQUEST_VERSION,
|
||||||
TASK_DECLARED_SYMBOLS_VERSION,
|
TASK_DECLARED_SYMBOLS_VERSION,
|
||||||
|
PLANNING_ACTIVE_TIMING_VERSION,
|
||||||
|
SQLITE_MIGRATION_RUNTIME_READ_VERSION,
|
||||||
} from "../../postgres/schema-applier.js";
|
} from "../../postgres/schema-applier.js";
|
||||||
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
|
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
|
||||||
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js";
|
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js";
|
||||||
@@ -1576,6 +1578,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
|||||||
BIGINT_COUNTERS_VERSION,
|
BIGINT_COUNTERS_VERSION,
|
||||||
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
||||||
TASK_DECLARED_SYMBOLS_VERSION,
|
TASK_DECLARED_SYMBOLS_VERSION,
|
||||||
|
PLANNING_ACTIVE_TIMING_VERSION,
|
||||||
|
SQLITE_MIGRATION_RUNTIME_READ_VERSION,
|
||||||
]);
|
]);
|
||||||
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
|
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -1630,6 +1634,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
|||||||
BIGINT_COUNTERS_VERSION,
|
BIGINT_COUNTERS_VERSION,
|
||||||
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
||||||
TASK_DECLARED_SYMBOLS_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,
|
BIGINT_COUNTERS_VERSION,
|
||||||
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
||||||
TASK_DECLARED_SYMBOLS_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,
|
BIGINT_COUNTERS_VERSION,
|
||||||
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
||||||
TASK_DECLARED_SYMBOLS_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,
|
BIGINT_COUNTERS_VERSION,
|
||||||
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION,
|
||||||
TASK_DECLARED_SYMBOLS_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 { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { createTaskStoreForBackend } from "../../postgres/startup-factory.js";
|
import { createTaskStoreForBackend } from "../../postgres/startup-factory.js";
|
||||||
|
import { getSqliteMigrationState } from "../../postgres/sqlite-migrator.js";
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
import { DatabaseSync } from "../../sqlite-adapter.js";
|
import { DatabaseSync } from "../../sqlite-adapter.js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
@@ -178,6 +179,92 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => {
|
|||||||
await second!.shutdown();
|
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:
|
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".
|
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:
|
FNXC:PostgresBigintCounters 2026-07-19-12:00:
|
||||||
SCHEMA_BASELINE_VERSION advances to 0026 for the bigint counters migration.
|
SCHEMA_BASELINE_VERSION advances to 0026 for the bigint counters migration.
|
||||||
Per-migration identities above stay fixed; only this latest-version marker moves.
|
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. */
|
/** FNXC:SymbolLock 2026-07-31-10:00: upgrades need durable task declarations before admission resolves symbols. */
|
||||||
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
|
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
|
||||||
const INITIAL_SCHEMA_VERSION = "0000";
|
const INITIAL_SCHEMA_VERSION = "0000";
|
||||||
@@ -126,6 +130,8 @@ export const SYMBOL_LOCKS_SCHEMA_VERSION = "0025";
|
|||||||
export const BIGINT_COUNTERS_VERSION = "0026";
|
export const BIGINT_COUNTERS_VERSION = "0026";
|
||||||
/** FNXC:TaskTiming 2026-08-01-10:00: existing clusters need planning-session timing columns. */
|
/** FNXC:TaskTiming 2026-08-01-10:00: existing clusters need planning-session timing columns. */
|
||||||
export const PLANNING_ACTIVE_TIMING_VERSION = "0029";
|
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
|
* 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 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 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
|
* 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 bigintCountersAlreadyApplied = applied.includes(BIGINT_COUNTERS_VERSION);
|
||||||
const workflowIrPinAndLegacyAdoptionAlreadyApplied = applied.includes(WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION);
|
const workflowIrPinAndLegacyAdoptionAlreadyApplied = applied.includes(WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION);
|
||||||
const planningActiveTimingAlreadyApplied = applied.includes(PLANNING_ACTIVE_TIMING_VERSION);
|
const planningActiveTimingAlreadyApplied = applied.includes(PLANNING_ACTIVE_TIMING_VERSION);
|
||||||
|
const sqliteMigrationRuntimeReadAlreadyApplied = applied.includes(SQLITE_MIGRATION_RUNTIME_READ_VERSION);
|
||||||
assertBinaryNotOlderThanDatabase(applied);
|
assertBinaryNotOlderThanDatabase(applied);
|
||||||
let schemaChanged = false;
|
let schemaChanged = false;
|
||||||
|
|
||||||
@@ -856,6 +864,13 @@ export async function applySchemaBaseline(
|
|||||||
schemaChanged = true;
|
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 };
|
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,6 +457,34 @@ async function ensureMigrationStateTable(db: PostgresJsDatabase<Record<string, n
|
|||||||
last_error text,
|
last_error text,
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
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 {
|
export interface SqliteMigrationState {
|
||||||
|
|||||||
Reference in New Issue
Block a user