Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions api/src/changeRequests/aclValidation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,45 @@ describe("validateAcl", () => {
expect(result[0].permission).toContain(AclPermission.View);
});

// Share is app-facing and assignable on the content-bearing doc types only.
it.each([DocType.Post, DocType.Tag])("should accept Share on doc type %s", (docType) => {
const acl = [createEntry(docType, "g1", [AclPermission.View, AclPermission.Share])];
const result = validateAcl(acl);

expect(result[0].permission).toContain(AclPermission.Share);
expect(result[0].permission).toContain(AclPermission.View);
});

it.each([
DocType.Group,
DocType.Language,
DocType.User,
DocType.Redirect,
DocType.Storage,
DocType.AuthProvider,
DocType.AutoGroupMappings,
])("should strip Share from doc type %s", (docType) => {
const acl = [createEntry(docType, "g1", [AclPermission.View, AclPermission.Share])];
const result = validateAcl(acl);

expect(result[0].permission).not.toContain(AclPermission.Share);
expect(result[0].permission).toContain(AclPermission.View);
});

it("should not auto-add CmsView for Share", () => {
const acl = [createEntry(DocType.Post, "g1", [AclPermission.View, AclPermission.Share])];
const result = validateAcl(acl);

expect(result[0].permission).not.toContain(AclPermission.CmsView);
});

it("should remove a Share-only entry", () => {
const acl = [createEntry(DocType.Post, "g1", [AclPermission.Share])];
const result = validateAcl(acl);

expect(result).toHaveLength(0);
});

it("should strip CmsView from a doc type not in availablePermissionsPerDocType", () => {
// Crypto is an internal doc type with no ACL config → all permissions stripped, entry removed.
const acl = [
Expand Down
2 changes: 2 additions & 0 deletions api/src/changeRequests/aclValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const availablePermissionsPerDocType = {
AclPermission.Delete,
AclPermission.Translate,
AclPermission.Publish,
AclPermission.Share,
AclPermission.CmsView,
],
[DocType.Tag]: [
Expand All @@ -35,6 +36,7 @@ const availablePermissionsPerDocType = {
AclPermission.Assign,
AclPermission.Translate,
AclPermission.Publish,
AclPermission.Share,
AclPermission.CmsView,
],
[DocType.User]: [
Expand Down
6 changes: 6 additions & 0 deletions api/src/db/db.upgrade.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ jest.mock("./schemaUpgrade/v20", () => ({
__esModule: true,
default: jest.fn().mockResolvedValue(undefined),
}));
jest.mock("./schemaUpgrade/v21", () => ({
__esModule: true,
default: jest.fn().mockResolvedValue(undefined),
}));

import { upgradeDbSchema } from "./db.upgrade";
import initSchemaVersion from "./schemaUpgrade/initSchemaVersion";
Expand All @@ -65,6 +69,7 @@ import v17 from "./schemaUpgrade/v17";
import v18 from "./schemaUpgrade/v18";
import v19 from "./schemaUpgrade/v19";
import v20 from "./schemaUpgrade/v20";
import v21 from "./schemaUpgrade/v21";

describe("upgradeDbSchema", () => {
const mockDb = {} as any;
Expand All @@ -89,6 +94,7 @@ describe("upgradeDbSchema", () => {
expect(v18).toHaveBeenCalledWith(mockDb);
expect(v19).toHaveBeenCalledWith(mockDb);
expect(v20).toHaveBeenCalledWith(mockDb);
expect(v21).toHaveBeenCalledWith(mockDb);
});

it("should re-throw error and log it when an upgrade function fails", async () => {
Expand Down
2 changes: 2 additions & 0 deletions api/src/db/db.upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import v17 from "./schemaUpgrade/v17";
import v18 from "./schemaUpgrade/v18";
import v19 from "./schemaUpgrade/v19";
import v20 from "./schemaUpgrade/v20";
import v21 from "./schemaUpgrade/v21";

// Re-exported for convenience so callers can read the fresh-DB baseline version from this module.
export { FRESH_DB_SCHEMA_VERSION } from "./schemaUpgrade/freshDbSchemaVersion";
Expand All @@ -37,6 +38,7 @@ export async function upgradeDbSchema(db: DbService) {
await v18(db);
await v19(db);
await v20(db);
await v21(db);
} catch (error) {
console.error("Database schema upgrade failed:", error);
throw error; // Re-throw to prevent schema version from being updated
Expand Down
6 changes: 5 additions & 1 deletion api/src/db/schemaUpgrade/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Schema upgrades can be safely removed when:
### Current Baseline


**Current Schema Version**: 20 (as of 2026-08-21)
**Current Schema Version**: 21 (as of 2026-09-04)

All production databases are expected to be at version 10 or higher. Historical upgrades v1-v9 have been removed as they are no longer needed.

Expand Down Expand Up @@ -187,4 +187,8 @@ Backfills the new `CmsView` ACL permission (GitHub #160). CmsView gates CMS-scop

Backfills `CmsView` on existing ACL entries that hold a CMS-only permission (`Edit`, `Delete`, `Assign`, `Translate`, `Publish`), matching the new auto-assign rule in `changeRequests/aclValidation.ts` and its CMS mirror `cms/src/components/groups/permissions.ts`. Those permissions previously auto-assigned `View`, which granted app-facing visibility as a side effect of a CMS-only permission change; `CmsView` is what they actually imply, and `View` is now an independent toggle. Entries holding `View` alone are genuine app-consumer grants and are deliberately left untouched, so `CmsView` stays a real, narrowable permission (ADR 0013). `group-public-users` is skipped entirely — it is effectively the anonymous group, and its broad seeded `edit`/`delete`/`publish` grants would otherwise expose drafts and expired content to anyone opening the CMS; its one intended `CmsView` grant (AuthProvider) was made by v19. Idempotent (only pushes `CmsView` where missing), safe to re-run including on fresh DBs and via `npm run seed`. Uses `insertDoc` to preserve `updatedTimeUtc`: the granted access takes effect via the server-recomputed AccessMap delivered on connect.

### v21 — Share ACL backfill (2026-09-04)

Backfills the new `Share` ACL permission on every Post/Tag ACL entry that already holds `View`, so the audience that can read content in the app keeps being able to share it. `Share` is assignable on Post and Tag only (`changeRequests/aclValidation.ts` and its CMS mirror `cms/src/components/groups/permissions.ts`) and is app-facing — it grants no additional read access, so the broad backfill onto every `View` holder is safe, unlike `CmsView` (v19/v20) which had to stay narrow. Idempotent (only pushes `Share` where missing), safe to re-run including on fresh DBs and via `npm run seed` — the seeded Group fixtures already carry `Share`, making it a no-op there. Uses `insertDoc` to preserve `updatedTimeUtc`: the granted access takes effect via the server-recomputed AccessMap delivered on connect.

The CMS-managed "default affinity" recommendation feature (`DocType.DefaultAffinity`) followed the same ACL-administration path instead of an upgrade script: `group-super-admins`/`group-public-content` get the `DefaultAffinity` ACL entries directly in their seed fixtures (fresh DBs only — existing deployed DBs need it granted via ACL administration), and the singleton doc (`api/src/util/defaultAffinity.ts`) is created lazily by the CMS on first save rather than backfilled (`cms/src/composables/useDefaultAffinity.ts`'s `saveDoc`).
129 changes: 129 additions & 0 deletions api/src/db/schemaUpgrade/v21.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import v21 from "./v21";
import { AclPermission, DocType } from "../../enums";

describe("v21 — Share ACL backfill", () => {
function mockDb(version: number, groups: any[]) {
const inserted: any[] = [];
const db = {
getSchemaVersion: jest.fn().mockResolvedValue(version),
setSchemaVersion: jest.fn().mockResolvedValue(undefined),
processAllDocs: jest.fn(async (_types: DocType[], cb: (doc: any) => Promise<void>) => {
for (const g of groups) await cb(g);
}),
insertDoc: jest.fn(async (doc: any) => {
inserted.push(doc);
}),
} as any;
return { db, inserted };
}

function entry(type: DocType, permission: AclPermission[], groupId = "g") {
return { type, groupId, permission };
}

function group(id: string, acl: any[]) {
return { _id: id, type: DocType.Group, acl };
}

it.each([DocType.Post, DocType.Tag])(
"grants Share on a %s entry holding View",
async (type) => {
const g = group("group-public-content", [entry(type, [AclPermission.View])]);
const { db, inserted } = mockDb(20, [g]);

await v21(db);

expect(inserted).toHaveLength(1);
expect(inserted[0].acl[0].permission).toContain(AclPermission.Share);
expect(inserted[0].acl[0].permission).toContain(AclPermission.View);
expect(db.setSchemaVersion).toHaveBeenCalledWith(21);
},
);

it("leaves entries without View untouched", async () => {
const g = group("group-public-editors", [
entry(DocType.Post, [AclPermission.CmsView, AclPermission.Edit]),
]);
const { db, inserted } = mockDb(20, [g]);

await v21(db);

expect(inserted).toHaveLength(0);
expect(g.acl[0].permission).not.toContain(AclPermission.Share);
expect(db.setSchemaVersion).toHaveBeenCalledWith(21);
});

it("leaves non-shareable doc types untouched", async () => {
const g = group("group-public-users", [
entry(DocType.Language, [AclPermission.View]),
entry(DocType.Redirect, [AclPermission.View]),
entry(DocType.Storage, [AclPermission.View]),
]);
const { db, inserted } = mockDb(20, [g]);

await v21(db);

expect(inserted).toHaveLength(0);
for (const e of g.acl) expect(e.permission).not.toContain(AclPermission.Share);
});

it("backfills only the qualifying entries of a mixed group", async () => {
const g = group("group-private-content", [
entry(DocType.Post, [AclPermission.View, AclPermission.Publish]),
entry(DocType.Language, [AclPermission.View]),
]);
const { db, inserted } = mockDb(20, [g]);

await v21(db);

expect(inserted).toHaveLength(1);
const post = inserted[0].acl.find((e: any) => e.type === DocType.Post);
const language = inserted[0].acl.find((e: any) => e.type === DocType.Language);
expect(post.permission).toContain(AclPermission.Share);
expect(language.permission).not.toContain(AclPermission.Share);
});

it("is idempotent for entries that already hold Share", async () => {
const g = group("group-public-content", [
entry(DocType.Tag, [AclPermission.View, AclPermission.Share]),
]);
const { db, inserted } = mockDb(20, [g]);

await v21(db);

expect(inserted).toHaveLength(0);
expect(g.acl[0].permission).toEqual([AclPermission.View, AclPermission.Share]);
});

it("skips groups with a malformed acl", async () => {
const { db, inserted } = mockDb(20, [
{ _id: "no-acl", type: DocType.Group },
group("bad-permission", [{ type: DocType.Post, groupId: "g" }]),
]);

await v21(db);

expect(inserted).toHaveLength(0);
expect(db.setSchemaVersion).toHaveBeenCalledWith(21);
});

it.each([19, 21])("does not run when the schema version is %s", async (version) => {
const g = group("group-public-content", [entry(DocType.Post, [AclPermission.View])]);
const { db, inserted } = mockDb(version, [g]);

await v21(db);

expect(inserted).toHaveLength(0);
expect(db.processAllDocs).not.toHaveBeenCalled();
expect(db.setSchemaVersion).not.toHaveBeenCalled();
});

it("re-throws and leaves the version alone when a write fails", async () => {
const g = group("group-public-content", [entry(DocType.Post, [AclPermission.View])]);
const { db } = mockDb(20, [g]);
db.insertDoc = jest.fn().mockRejectedValue(new Error("write failed"));

await expect(v21(db)).rejects.toThrow("write failed");
expect(db.setSchemaVersion).not.toHaveBeenCalled();
});
});
69 changes: 69 additions & 0 deletions api/src/db/schemaUpgrade/v21.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { DbService } from "../db.service";
import { AclPermission, DocType } from "../../enums";

/**
* Doc types the Share permission is assignable on, mirroring
* `changeRequests/aclValidation.ts`.
*/
const SHAREABLE_DOC_TYPES = [DocType.Post, DocType.Tag];

/**
* Upgrade the database schema from version 20 to 21.
*
* Backfills the new `Share` ACL permission on every Post/Tag entry that already holds `View`, so
* the audience that can read content in the app keeps being able to share it. Share is app-facing
* and grants no additional read access, so a broad backfill is safe — unlike `CmsView` (v19/v20),
* which had to stay narrow.
*
* Idempotent: only pushes `Share` where missing, so re-running (e.g. `npm run seed` runs the
* upgrade chain) is a no-op. Uses `insertDoc` to preserve `updatedTimeUtc`; the granted access
* takes effect via the server-recomputed AccessMap delivered to clients on connect.
*/
export default async function (db: DbService) {
try {
const schemaVersion = await db.getSchemaVersion();
if (schemaVersion === 20) {
console.info("Upgrading database schema from version 20 to 21");

let updatedCount = 0;
let skippedCount = 0;

await db.processAllDocs([DocType.Group], async (doc: any) => {
if (!doc || !Array.isArray(doc.acl)) return;

let changed = false;

doc.acl.forEach((entry: any) => {
if (!Array.isArray(entry.permission)) return;
if (!SHAREABLE_DOC_TYPES.includes(entry.type)) return;
if (entry.permission.includes(AclPermission.Share)) return;
if (!entry.permission.includes(AclPermission.View)) return;

entry.permission.push(AclPermission.Share);
changed = true;
});

if (changed) {
await db.insertDoc(doc);
updatedCount++;
} else {
skippedCount++;
}
});

console.info(
`Share backfill complete: ${updatedCount} groups updated, ${skippedCount} unchanged`,
);

await db.setSchemaVersion(21);
console.info("Database schema upgrade from version 20 to 21 completed successfully");
} else {
console.info(
`Skipping schema upgrade v21: current version is ${schemaVersion}, expected 20`,
);
}
} catch (error) {
console.error("Database schema upgrade from version 20 to 21 failed:", error);
throw error;
}
}
17 changes: 13 additions & 4 deletions api/src/db/seedingDocs/group-private-content.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
{
"type": "post",
"groupId": "group-private-users",
"permission": ["view"]
"permission": ["view", "share"]
},
{
"type": "tag",
"groupId": "group-private-users",
"permission": ["view"]
"permission": ["view", "share"]
},
{
"type": "language",
Expand All @@ -27,12 +27,21 @@
{
"type": "post",
"groupId": "group-private-editors",
"permission": ["view", "edit", "translate", "publish", "delete", "cmsView"]
"permission": ["view", "share", "edit", "translate", "publish", "delete", "cmsView"]
},
{
"type": "tag",
"groupId": "group-private-editors",
"permission": ["view", "edit", "translate", "publish", "delete", "assign", "cmsView"]
"permission": [
"view",
"share",
"edit",
"translate",
"publish",
"delete",
"assign",
"cmsView"
]
},
{
"type": "group",
Expand Down
13 changes: 11 additions & 2 deletions api/src/db/seedingDocs/group-private-editors.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,21 @@
{
"type": "post",
"groupId": "group-super-admins",
"permission": ["view", "edit", "delete", "translate", "publish", "cmsView"]
"permission": ["view", "share", "edit", "delete", "translate", "publish", "cmsView"]
},
{
"type": "tag",
"groupId": "group-super-admins",
"permission": ["view", "edit", "delete", "assign", "translate", "publish", "cmsView"]
"permission": [
"view",
"share",
"edit",
"delete",
"assign",
"translate",
"publish",
"cmsView"
]
},
{
"type": "group",
Expand Down
Loading
Loading