diff --git a/.changeset/remove-grants-boot-migrations.md b/.changeset/remove-grants-boot-migrations.md new file mode 100644 index 00000000..1c1d90b2 --- /dev/null +++ b/.changeset/remove-grants-boot-migrations.md @@ -0,0 +1,4 @@ +--- +--- + +Internal: remove the one-shot typed-grants boot migrations now that production is migrated. No user-facing or API change. diff --git a/ornn-api/src/bootstrap.ts b/ornn-api/src/bootstrap.ts index 034a26ce..7a8f6f53 100644 --- a/ornn-api/src/bootstrap.ts +++ b/ornn-api/src/bootstrap.ts @@ -146,7 +146,6 @@ import { wireAdmin } from "./domains/admin/bootstrap"; // per-provider arrays). One-time, idempotent, runs before any // LlmProvidersService consumer reads from disk. import { migrateModelCatalogIntoProviders } from "./domains/settings/llmProviders/migration"; -import { backfillTypedGrants, renameReadWriteGrantsToWrite } from "./domains/skills/crud/grants.migration"; import { createLlmPickerRoutes } from "./domains/settings/llmProviders/routes"; // OpenAPI spec @@ -675,33 +674,6 @@ export async function bootstrap( ), ); - // ---- Typed-grants backfill (#1123) ---- - // Fold the legacy read-only `sharedWithUsers` / `sharedWithOrgs` lists into - // the typed `grants` array (every legacy grant → `read` level). One-time, - // idempotent, non-disruptive (legacy lists preserved, nobody escalated to - // write). Runs before any skill/skillset read so the authz gates + scope - // filters can rely on `grants`. Failure is non-fatal: the read-time - // fallback in `effectiveGrants` keeps un-migrated docs authorizing - // correctly off the legacy lists. - await backfillTypedGrants(db).catch((err) => - logger.error( - { err: err instanceof Error ? err.message : String(err) }, - "typed-grants backfill failed — gates fall back to legacy read lists via effectiveGrants, no data loss", - ), - ); - - // ---- read_write → write grant-level rename (#1127) ---- - // The combined `read_write` level was renamed to `write`. Rewrite any - // existing grant carrying the legacy value. Idempotent + non-disruptive - // (write confers what read_write did); `coerceStoredGrants` covers any doc - // not yet rewritten, so failure is non-fatal. - await renameReadWriteGrantsToWrite(db).catch((err) => - logger.error( - { err: err instanceof Error ? err.message : String(err) }, - "read_write→write rename failed — coerceStoredGrants maps legacy values at read time, no data loss", - ), - ); - // The picker route — `GET /me/models?surface=...` — reads from the // per-provider arrays via `LlmProvidersService` (already constructed // upstream as part of `domains/settings/...`). The section-default diff --git a/ornn-api/src/domains/skills/crud/grants.migration.test.ts b/ornn-api/src/domains/skills/crud/grants.migration.test.ts deleted file mode 100644 index c1efe1d7..00000000 --- a/ornn-api/src/domains/skills/crud/grants.migration.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Integration tests for the typed-grants boot migration (#1123). - * - * Verifies the NON-DISRUPTION invariant end-to-end against a real Mongo: - * legacy read lists become read-level grants, public/private flags and the - * legacy lists are preserved, nobody is escalated to write, and reruns are - * no-ops. - * - * @module domains/skills/crud/grants.migration.test - */ - -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; -import { MongoMemoryServer } from "mongodb-memory-server"; -import { MongoClient, type Db } from "mongodb"; -import { backfillTypedGrants, renameReadWriteGrantsToWrite } from "./grants.migration"; - -let mongo: MongoMemoryServer; -let client: MongoClient; -let db: Db; - -beforeAll(async () => { - mongo = await MongoMemoryServer.create(); - client = new MongoClient(mongo.getUri()); - await client.connect(); - db = client.db("grants_migration_test"); -}); - -afterAll(async () => { - await client.close(); - await mongo.stop(); -}); - -beforeEach(async () => { - await db.collection("skills").deleteMany({}); - await db.collection("skillsets").deleteMany({}); -}); - -describe("backfillTypedGrants", () => { - it("derives read-level grants from the legacy lists without touching anything else", async () => { - await db.collection("skills").insertOne({ - _id: "s1" as never, - name: "legacy-private", - isPrivate: true, - sharedWithUsers: ["u1", "u2"], - sharedWithOrgs: ["o1"], - }); - - const res = await backfillTypedGrants(db); - expect(res.skillsBackfilled).toBe(1); - - const doc = await db.collection("skills").findOne({ _id: "s1" as never }); - expect(doc?.grants).toEqual([ - { type: "user", id: "u1", level: "read" }, - { type: "user", id: "u2", level: "read" }, - { type: "org", id: "o1", level: "read" }, - ]); - // Legacy lists + privacy flag preserved (non-disruptive). - expect(doc?.sharedWithUsers).toEqual(["u1", "u2"]); - expect(doc?.sharedWithOrgs).toEqual(["o1"]); - expect(doc?.isPrivate).toBe(true); - // Nobody escalated to write. - expect((doc?.grants as Array<{ level: string }>).every((g) => g.level === "read")).toBe(true); - }); - - it("backfills an empty grants array for a public skill with no shares", async () => { - await db.collection("skills").insertOne({ - _id: "s2" as never, - name: "public-no-shares", - isPrivate: false, - sharedWithUsers: [], - sharedWithOrgs: [], - }); - - await backfillTypedGrants(db); - - const doc = await db.collection("skills").findOne({ _id: "s2" as never }); - expect(doc?.grants).toEqual([]); - expect(doc?.isPrivate).toBe(false); - }); - - it("tolerates docs predating even the legacy lists", async () => { - await db.collection("skills").insertOne({ _id: "s3" as never, name: "ancient" }); - await backfillTypedGrants(db); - const doc = await db.collection("skills").findOne({ _id: "s3" as never }); - expect(doc?.grants).toEqual([]); - }); - - it("does not touch docs that already carry grants (idempotent)", async () => { - await db.collection("skills").insertOne({ - _id: "s4" as never, - name: "already-migrated", - isPrivate: true, - sharedWithUsers: ["u1"], - sharedWithOrgs: [], - grants: [{ type: "user", id: "u1", level: "write" }], - }); - - const res = await backfillTypedGrants(db); - expect(res.skillsBackfilled).toBe(0); - - const doc = await db.collection("skills").findOne({ _id: "s4" as never }); - // write grant preserved — migration must not clobber a richer ACL. - expect(doc?.grants).toEqual([{ type: "user", id: "u1", level: "write" }]); - }); - - it("is a no-op on a second run", async () => { - await db.collection("skills").insertOne({ - _id: "s5" as never, - name: "x", - isPrivate: true, - sharedWithUsers: ["u1"], - sharedWithOrgs: [], - }); - const first = await backfillTypedGrants(db); - expect(first.skillsBackfilled).toBe(1); - const second = await backfillTypedGrants(db); - expect(second.skillsBackfilled).toBe(0); - }); - - it("migrates skillsets the same way", async () => { - await db.collection("skillsets").insertOne({ - _id: "ss1" as never, - name: "set", - isPrivate: true, - sharedWithUsers: [], - sharedWithOrgs: ["o9"], - }); - const res = await backfillTypedGrants(db); - expect(res.skillsetsBackfilled).toBe(1); - const doc = await db.collection("skillsets").findOne({ _id: "ss1" as never }); - expect(doc?.grants).toEqual([{ type: "org", id: "o9", level: "read" }]); - }); -}); - -describe("renameReadWriteGrantsToWrite (#1127)", () => { - it("renames legacy read_write grants to write, leaving read grants + other fields intact", async () => { - await db.collection("skills").insertOne({ - _id: "r1" as never, - name: "legacy-rw", - isPrivate: true, - sharedWithUsers: ["u1", "u2"], - sharedWithOrgs: ["o1"], - grants: [ - { type: "user", id: "u1", level: "read_write" }, - { type: "user", id: "u2", level: "read" }, - { type: "org", id: "o1", level: "read_write" }, - ], - }); - - const res = await renameReadWriteGrantsToWrite(db); - expect(res.skillsRenamed).toBe(1); - - const doc = await db.collection("skills").findOne({ _id: "r1" as never }); - expect(doc?.grants).toEqual([ - { type: "user", id: "u1", level: "write" }, - { type: "user", id: "u2", level: "read" }, - { type: "org", id: "o1", level: "write" }, - ]); - // Non-destructive: legacy lists + privacy untouched. - expect(doc?.sharedWithUsers).toEqual(["u1", "u2"]); - expect(doc?.isPrivate).toBe(true); - }); - - it("is idempotent — a second run renames nothing", async () => { - await db.collection("skills").insertOne({ - _id: "r2" as never, - name: "rw-once", - grants: [{ type: "user", id: "u1", level: "read_write" }], - }); - const first = await renameReadWriteGrantsToWrite(db); - expect(first.skillsRenamed).toBe(1); - const second = await renameReadWriteGrantsToWrite(db); - expect(second.skillsRenamed).toBe(0); - }); - - it("renames skillsets too", async () => { - await db.collection("skillsets").insertOne({ - _id: "rs1" as never, - name: "set-rw", - grants: [{ type: "org", id: "o9", level: "read_write" }], - }); - const res = await renameReadWriteGrantsToWrite(db); - expect(res.skillsetsRenamed).toBe(1); - const doc = await db.collection("skillsets").findOne({ _id: "rs1" as never }); - expect(doc?.grants).toEqual([{ type: "org", id: "o9", level: "write" }]); - }); -}); diff --git a/ornn-api/src/domains/skills/crud/grants.migration.ts b/ornn-api/src/domains/skills/crud/grants.migration.ts deleted file mode 100644 index 52d820f2..00000000 --- a/ornn-api/src/domains/skills/crud/grants.migration.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Boot migration (#1123) — backfill the typed `grants` array on skills and - * skillsets created before the typed-grant model existed. - * - * Non-disruptive by construction, which is the hard requirement for this - * feature: every legacy read-only grant (`sharedWithUsers` / - * `sharedWithOrgs`) becomes a `read`-level typed grant, and the legacy lists - * are LEFT IN PLACE (the repository dual-writes them during the rolling - * deploy). Nobody is escalated to write; public skills are untouched (public - * was, and stays, read-only — visibility is governed by `isPrivate`, not by - * grants). After this runs, every doc carries `grants`, so the read/write - * gates and scope filters can rely on it. - * - * Idempotent: only docs MISSING `grants` are matched, so reruns are no-ops. - * Runs entirely server-side as one `updateMany` + aggregation pipeline per - * collection — `$map` over each doc's own legacy lists, so it scales to the - * whole collection in a single round-trip without pulling docs into the app. - * - * @module domains/skills/crud/grants.migration - */ - -import type { Db } from "mongodb"; -import { createLogger } from "../../../shared/logger"; - -const logger = createLogger("skillGrantsMigration"); - -/** The collections that carry the shared ownership/grant shape. */ -const GRANTED_COLLECTIONS = ["skills", "skillsets"] as const; - -/** - * Aggregation `$set` stage that derives `grants` from the doc's own legacy - * read lists — every entry at `read` level. `$ifNull` tolerates docs that - * predate even the legacy lists. - */ -const BUILD_GRANTS_STAGE = { - $set: { - grants: { - $concatArrays: [ - { - $map: { - input: { $ifNull: ["$sharedWithUsers", []] }, - as: "u", - in: { type: "user", id: "$$u", level: "read" }, - }, - }, - { - $map: { - input: { $ifNull: ["$sharedWithOrgs", []] }, - as: "o", - in: { type: "org", id: "$$o", level: "read" }, - }, - }, - ], - }, - }, -} as const; - -export interface GrantsMigrationResult { - skillsBackfilled: number; - skillsetsBackfilled: number; -} - -/** - * Backfill typed `grants` on every skill / skillset missing the field. - * Safe to call on every boot — see module doc. - */ -export async function backfillTypedGrants(db: Db): Promise { - const counts: Record = {}; - for (const coll of GRANTED_COLLECTIONS) { - const res = await db - .collection(coll) - .updateMany({ grants: { $exists: false } }, [BUILD_GRANTS_STAGE]); - counts[coll] = res.modifiedCount; - } - const result: GrantsMigrationResult = { - skillsBackfilled: counts.skills ?? 0, - skillsetsBackfilled: counts.skillsets ?? 0, - }; - if (result.skillsBackfilled > 0 || result.skillsetsBackfilled > 0) { - logger.info({ ...result }, "Typed-grants backfill complete (#1123)"); - } - return result; -} - -/** - * Aggregation `$set` stage that renames any grant with the legacy - * `read_write` level to `write` (#1127), leaving `read` grants untouched. - */ -const RENAME_LEVEL_STAGE = { - $set: { - grants: { - $map: { - input: { $ifNull: ["$grants", []] }, - as: "g", - in: { - $cond: [ - { $eq: ["$$g.level", "read_write"] }, - { $mergeObjects: ["$$g", { level: "write" }] }, - "$$g", - ], - }, - }, - }, - }, -} as const; - -export interface LevelRenameResult { - skillsRenamed: number; - skillsetsRenamed: number; -} - -/** - * Rename the legacy `read_write` grant level to `write` (#1127) on every - * skill / skillset that still carries one. Idempotent — only docs with a - * `read_write` grant are matched, so reruns are no-ops. Non-destructive: - * `read` grants and all other fields are untouched, and a `write` grant - * confers exactly what `read_write` did (write implies read). Runs at boot - * after the typed-grants backfill; `coerceStoredGrants` covers any doc not - * yet rewritten. - */ -export async function renameReadWriteGrantsToWrite(db: Db): Promise { - const counts: Record = {}; - for (const coll of GRANTED_COLLECTIONS) { - const res = await db - .collection(coll) - .updateMany({ "grants.level": "read_write" }, [RENAME_LEVEL_STAGE]); - counts[coll] = res.modifiedCount; - } - const result: LevelRenameResult = { - skillsRenamed: counts.skills ?? 0, - skillsetsRenamed: counts.skillsets ?? 0, - }; - if (result.skillsRenamed > 0 || result.skillsetsRenamed > 0) { - logger.info({ ...result }, "read_write → write grant-level rename complete (#1127)"); - } - return result; -}