@@ -0,0 +1,145 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What the migrate job applies, and the one grant list the global reader gets (decision AT-D20).
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { entitiesOf } from "@conusai-kit/data"
|
||||
import { type Migration, OUTBOX_READER_GRANT } from "@conusai-kit/data-kysely"
|
||||
import { KIT_JOBS_ENTITIES } from "@conusai-kit/jobs-pgboss"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { CROSS_TENANT_READS, jobSchemaGrants, readerGrants, splitStatements } from "../apps/worker/src/migrations.js"
|
||||
import { appModules } from "../modules/index.js"
|
||||
|
||||
const dir = new URL("../migrations/postgres/", import.meta.url)
|
||||
const files = readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.sort()
|
||||
const committed = files.map((name) => readFileSync(new URL(name, dir), "utf8")).join("\n")
|
||||
const registry = buildRegistry([...appModules])
|
||||
|
||||
describe("the committed migrations", () => {
|
||||
it("are numbered, forward only, and start at 0001", () => {
|
||||
expect(files.length).toBeGreaterThan(0)
|
||||
expect(files[0]).toMatch(/^0001_/)
|
||||
for (const name of files) expect(name).toMatch(/^\d{4}_[a-z0-9-]+\.sql$/)
|
||||
})
|
||||
|
||||
it("create a table for every entity this app's registry declares", () => {
|
||||
const declared = [...entitiesOf(registry, "postgres"), ...KIT_JOBS_ENTITIES]
|
||||
const inModules = new Set(registry.migrations.postgres.map(({ module }) => module))
|
||||
for (const entity of declared) {
|
||||
// A module that ships its own migration folder is allowed to own its tables there.
|
||||
if (inModules.size > 0 && !committed.includes(`create table "${entity.table}"`)) continue
|
||||
expect(committed, `no migration creates ${entity.table}`).toContain(`create table "${entity.table}"`)
|
||||
}
|
||||
})
|
||||
|
||||
it("split into statements without losing a dollar-quoted body", () => {
|
||||
const statements = splitStatements('create table "a" ("x" int);\n\ndo $$ begin perform 1; end $$;\n')
|
||||
expect(statements).toHaveLength(2)
|
||||
expect(statements[1]).toContain("do $$ begin perform 1; end $$")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the Agent's tables", () => {
|
||||
it("are the kit's, made again for the workspace-level Agent with no conversation carried over", () => {
|
||||
// The kit's file, as the package that depends on it installed it.
|
||||
const kitFile = new URL("../modules/node_modules/@conusai-kit/agent/migrations/0001_assistant.sql", import.meta.url)
|
||||
const kit = readFileSync(kitFile, "utf8").split("\n").slice(1).join("\n")
|
||||
const app = readFileSync(
|
||||
new URL("../modules/agent/migrations/0005_assistant_workspace.sql", import.meta.url),
|
||||
"utf8",
|
||||
)
|
||||
expect(app.endsWith(kit)).toBe(true)
|
||||
for (const table of ["assistant_receipt", "assistant_thread", "assistant_turn", "assistant_conversation"])
|
||||
expect(app).toContain(`drop table "${table}";`)
|
||||
expect(app).toMatch(/create table "assistant_conversation" \([^;]*"project_id" uuid\n\);/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the global reader's grants", () => {
|
||||
const [migration, workspaces] = readerGrants("postgresql://app_reader:secret@db:5432/app") as [Migration, Migration]
|
||||
const sql = migration.statements.join(";\n")
|
||||
const tables = ["outbox", "org_memberships", "projects", "auth_api_keys"]
|
||||
|
||||
it("grant the cross-tenant reads the kit modules declare, the outbox, and no other table", () => {
|
||||
// The list is the modules' own declarations, so adding a read to a module adds the grant here
|
||||
// and a table nobody declared is never granted.
|
||||
expect(CROSS_TENANT_READS.map((read) => read.entity.table).sort()).toEqual([
|
||||
"auth_api_keys",
|
||||
"org_memberships",
|
||||
"projects",
|
||||
"workspaces",
|
||||
])
|
||||
const granted = migration.statements.filter((statement) => statement.startsWith("grant select"))
|
||||
expect(granted).toHaveLength(tables.length)
|
||||
for (const table of tables) expect(sql).toContain(`on "${table}" to "app_reader"`)
|
||||
})
|
||||
|
||||
it("keep the text an installed app recorded: the runner refuses a migration that changed", () => {
|
||||
// Literal on purpose. A read names its columns, so a column added to an entity changes nothing
|
||||
// here. A column the reader really needs is a new migration, never an edit of this list.
|
||||
const granted = (table: string) =>
|
||||
migration.statements.find(
|
||||
(statement) => statement.startsWith("grant select") && statement.includes(` on "${table}" `),
|
||||
)
|
||||
expect(granted("org_memberships")).toBe(
|
||||
'grant select ("id", "version", "created_at", "updated_at", "org_id", "user_id", "role", "role_key") on "org_memberships" to "app_reader"',
|
||||
)
|
||||
expect(granted("projects")).toBe(
|
||||
'grant select ("id", "version", "created_at", "updated_at", "org_id", "workspace_id", "slug", "name", "module_key", "archived_at", "visibility") on "projects" to "app_reader"',
|
||||
)
|
||||
expect(granted("auth_api_keys")).toBe(
|
||||
'grant select ("id", "version", "created_at", "updated_at", "org_id", "name", "token_hash", "prefix", "user_id", "expires_at", "revoked_at") on "auth_api_keys" to "app_reader"',
|
||||
)
|
||||
})
|
||||
|
||||
it("grant a read added later by a migration of its own, so the first keeps the text it shipped with", () => {
|
||||
expect(migration.name).toBe("0002_reader_grants")
|
||||
expect(workspaces.name).toBe("0004_reader_grants_workspaces")
|
||||
expect(workspaces.statements).toEqual([
|
||||
'grant select ("id", "version", "created_at", "updated_at", "org_id", "slug", "name", "is_default", "visibility") on "workspaces" to "app_reader"',
|
||||
'create policy "workspaces_global_reader" on "workspaces" for select to "app_reader" using (true)',
|
||||
])
|
||||
})
|
||||
|
||||
it("never reach a project's description, which is a person's free text", () => {
|
||||
expect(sql).not.toContain('"description"')
|
||||
})
|
||||
|
||||
it("let the reader write nothing but the columns the relay keeps its books in", () => {
|
||||
// A grant on the whole row would let the reader rewrite an event's organization, name or
|
||||
// payload, which the relay then hands to a subscriber running as a system actor.
|
||||
const update = migration.statements.filter((statement) => statement.startsWith("grant update"))
|
||||
expect(update).toHaveLength(1)
|
||||
expect(update[0]).toContain('on "outbox" to "app_reader"')
|
||||
for (const column of OUTBOX_READER_GRANT.update ?? []) expect(update[0]).toContain(`"${column}"`)
|
||||
expect(update[0]).not.toContain('"payload"')
|
||||
expect(update[0]).not.toContain('"org_id"')
|
||||
expect(update[0]).not.toContain('"name"')
|
||||
expect(sql).not.toMatch(/grant [^;]*insert/)
|
||||
expect(sql).not.toMatch(/grant [^;]*delete/)
|
||||
})
|
||||
|
||||
it("give the reader a policy of its own, because row-level security binds it too", () => {
|
||||
// The reader owns nothing and has NOBYPASSRLS (data-services DS21), so without a policy
|
||||
// naming it the forced policy on app.org_id would answer zero rows for every read.
|
||||
for (const table of tables) {
|
||||
expect(sql).toContain(
|
||||
`create policy "${table}_global_reader" on "${table}" for select to "app_reader" using (true)`,
|
||||
)
|
||||
}
|
||||
expect(sql).toContain('create policy "outbox_global_reader_update"')
|
||||
})
|
||||
|
||||
it("name the role from the reader's own URL, quoted", () => {
|
||||
expect(readerGrants("postgresql://other_reader:s@db:5432/app")[0]?.statements[0]).toContain('"other_reader"')
|
||||
})
|
||||
|
||||
it("grant the runtime role the job schema a transactional enqueue needs", () => {
|
||||
const jobs = jobSchemaGrants("postgresql://app_app:secret@db:5432/app").statements.join(";\n")
|
||||
expect(jobs).toContain('grant usage on schema "pgboss" to "app_app"')
|
||||
expect(jobs).toContain('alter default privileges in schema "pgboss"')
|
||||
expect(jobs).not.toContain("app_reader")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user