diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..34460e6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `ACL_REQUIRE_CREATOR` (default `false`): when `true`, the generated `_design/acl` + `validate_doc_update` rejects non-admin, non-`_design` creates that omit a + non-empty `creator`. Existing unstamped docs remain `r-*` on the ACL map; the + flag only blocks new open holes. Flipping the flag bumps the ddoc version + (`2.3.0` ↔ `2.4.0`) so ensure/migrate rewrites the VDU. diff --git a/README.md b/README.md index 7fff817..4079d9b 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ Unmapped endpoints return **404** for non-admins (default-deny). `_list`, `_show | `COUCH_ADMIN_USER` / `COUCH_ADMIN_PASSWORD` or `COUCH_ADMIN_URL` | Admin for ACL maintenance + `_changes` follow | | `COUCH_PRELOAD_DBS` | Comma-separated DBs to warm on boot | | `ACL_AUTO_INSTALL` | Auto-PUT `_design/acl` when missing on app DBs (default `true`). Never installs into `_users` / `_replicator` / `_global_changes`. Prefer `false` in production when ddocs are provisioned out-of-band. | +| `ACL_REQUIRE_CREATOR` | When `true`, installed/migrated `_design/acl` `validate_doc_update` rejects non-admin creates that omit a non-empty `creator` (`_design/*` exempt). Default `false` preserves historical open-create semantics. Flipping the flag bumps the ddoc version so ensure rewrites the VDU. | | `ACL_DB_INCLUDE` / `ACL_DB_EXCLUDE` | Opt-in database allow/deny lists (CSV). Entries are exact names or `/regex/flags`. Empty = historical behaviour. Exclude wins. Non-admins only; hidden DBs are omitted from `/_all_dbs` and return **404**. Example: `ACL_DB_INCLUDE=/^data-/`. | | `ACL_ROUTE_INCLUDE` / `ACL_ROUTE_EXCLUDE` | Opt-in API surface allow/deny lists (CSV). Entries are feature/bundle names (`pouch-sync`, `session`, `changes`, …), `METHOD /restmap-path` templates, or `/regex/flags` over `METHOD pathname`. Empty = all restmap routes. Exclude wins. Non-admins get **403**. | | `AUTH_RESOLVE_VIA_COUCH_SESSION` | Default `true` | diff --git a/USER-GUIDE.md b/USER-GUIDE.md index da14064..e03d838 100644 --- a/USER-GUIDE.md +++ b/USER-GUIDE.md @@ -153,6 +153,15 @@ These are ordinary JSON fields—not a separate doc type. The `_design/acl` map ACL fields are type-checked: `creator` must be a non-empty string, `acl` / `owners` must be arrays of non-empty strings, and `parent` must be a string. Malformed present fields are rejected and index fail-closed. Non-admins cannot add a creator or an empty grant field to an existing open document. +#### Opt-in: require `creator` on create (`ACL_REQUIRE_CREATOR`) + +Set `ACL_REQUIRE_CREATOR=true` when clients must not create unstamped docs (docs that become world-open to every DB member via `r-*`). The proxy bakes this into Couch `_design/acl` `validate_doc_update` on ensure/install: + +- Non-admin creates of non-`_design/*` docs without a non-empty `creator` are **forbidden**. +- `_admin` and `_design/*` are exempt. +- Already-written unstamped docs stay `r-*` on the map; the flag only blocks **new** holes. +- Default is `false` (historical create semantics). Flipping the flag bumps the ddoc version so the next ensure rewrites the VDU. + ### Design documents | Doc | Role | @@ -185,7 +194,7 @@ For a non-admin principal matching the listed grant: | via `parent` only | union of parent’s grants | same | same | — | — | | via `dbacl` overlay | extra flags on **every** doc | same | same | — | — | -Server admins always pass. Couch `validate_doc_update` on `_design/acl` also blocks forging `creator` on create and illegal ownership/acl edits. Delete authorization remains in the proxy because Couch's validation function cannot load a parent ACL or the ddoc's `dbacl` overlay. +Server admins always pass. Couch `validate_doc_update` on `_design/acl` also blocks forging `creator` on create and illegal ownership/acl edits. With `ACL_REQUIRE_CREATOR=true`, it additionally rejects creates that omit `creator`. Delete authorization remains in the proxy because Couch's validation function cannot load a parent ACL or the ddoc's `dbacl` overlay. --- @@ -268,7 +277,7 @@ On create (PUT/POST/`_bulk_docs`/Pouch `put`): - Shared edit: `"owners": ["u-bob"]` (still cannot delete; only creator can). - Hierarchy: child docs with `"parent": "folder-1"` inherit the folder’s grants (union). -Omit `creator`/`owners`/`acl` only when you intentionally want **any authenticated member** to fully control the doc. +Omit `creator`/`owners`/`acl` only when you intentionally want **any authenticated member** to fully control the doc. If your app relies on per-document ownership, set `ACL_REQUIRE_CREATOR=true` so unstamped creates are rejected at the Couch VDU. ### 4. Share by updating grants, not by copying data diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f633bea..baa6552 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -15,6 +15,8 @@ services: TRUST_PROXY_HOPS: "1" # Provision `_design/acl` out-of-band (or via preload with ACL_AUTO_INSTALL=true). ACL_AUTO_INSTALL: "false" + # Opt-in: reject non-admin creates that omit `creator` (baked into Couch VDU). + # ACL_REQUIRE_CREATOR: "true" # Set explicitly in deploy env, e.g.: # CORS_ORIGINS: https://app.example.com # COUCH_ADMIN_PASSWORD: diff --git a/src/acl/cache.ts b/src/acl/cache.ts index 850ec8b..59e6526 100644 --- a/src/acl/cache.ts +++ b/src/acl/cache.ts @@ -15,7 +15,7 @@ */ import type { AppConfig } from "../config.js"; import type { AclRow, DbAclOverlay, RestrictMap } from "./types.js"; -import { ACL_MAP_SOURCE, buildAclDesignDoc } from "./ddoc.js"; +import { ACL_MAP_SOURCE, REQUIRE_CREATOR_FORBIDDEN, buildAclDesignDoc } from "./ddoc.js"; import { aclRowFromDoc } from "./resolve.js"; import { AdminClient } from "../couch/adminClient.js"; import { ChangesFollower, fetchAclRow, fetchAclRows, fetchUpdateSeq } from "./changesFollower.js"; @@ -547,13 +547,18 @@ export class AclCache { const put = await this.admin.fetch(`/${encodeURIComponent(db)}/_design/acl`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(buildAclDesignDoc()), + body: JSON.stringify( + buildAclDesignDoc({ requireCreator: this.config.couch.aclRequireCreator }), + ), }); if (!put.ok) { const text = await put.text(); throw new Error(`Failed to install _design/acl in ${db}: ${put.status} ${text}`); } - log.info("installed _design/acl", { db }); + log.info("installed _design/acl", { + db, + requireCreator: this.config.couch.aclRequireCreator, + }); return { kind: "present" }; } if (get.status === 401 || get.status === 403) { @@ -568,6 +573,8 @@ export class AclCache { * grants from parent/dbacl; older versions also used `_local_seq`. v2.1 did * not recognize role owners and allowed non-creators to retarget `parent`. * v2.2 allowed a writer to claim `creator` on an existing creator-less doc. + * v2.4 bakes optional `ACL_REQUIRE_CREATOR` into the VDU; flipping the flag + * rewrites generated VDUs so creates match the process config. */ private async maybeMigrateStamp(db: string, getRes: Response): Promise { const ddoc = (await getRes.json()) as { @@ -610,17 +617,28 @@ export class AclCache { version.startsWith("2.2.") && !looksLikeGeneratedAclMap && /if \(odc && odc != ndc\)/.test(validateSrc); + const looksLikeGeneratedVdu = + /Creator can not be changed\./.test(validateSrc) && + /Can't create doc on behalf of other user\./.test(validateSrc); + const hasRequireCreatorRule = validateSrc.includes(REQUIRE_CREATOR_FORBIDDEN); + const needsRequireCreatorRewrite = + generatedShape && + looksLikeGeneratedVdu && + this.config.couch.aclRequireCreator !== hasRequireCreatorRule; if ( !needsLegacyRewrite && !needsOwnerPolicyRewrite && !needsCreatorPolicyRewrite && !needsV22FullPolicyRewrite && + !needsRequireCreatorRewrite && !needsGlobalViewOption ) { return; } - const generated = buildAclDesignDoc(); + const generated = buildAclDesignDoc({ + requireCreator: this.config.couch.aclRequireCreator, + }); const next = needsLegacyRewrite || needsV21FullPolicyRewrite || needsV22FullPolicyRewrite ? { @@ -635,7 +653,7 @@ export class AclCache { views: { ...ddoc.views, acl: generated.views.acl }, validate_doc_update: generated.validate_doc_update, } - : needsOwnerPolicyRewrite || needsCreatorPolicyRewrite + : needsOwnerPolicyRewrite || needsCreatorPolicyRewrite || needsRequireCreatorRewrite ? { ...ddoc, _id: ddoc._id ?? generated._id, diff --git a/src/acl/ddoc.ts b/src/acl/ddoc.ts index e4817f5..54c8767 100644 --- a/src/acl/ddoc.ts +++ b/src/acl/ddoc.ts @@ -14,8 +14,27 @@ * Map / VDU sources are kept as strings so they upload to CouchDB unchanged. */ +/** Ddoc version when `ACL_REQUIRE_CREATOR` is off (historical create semantics). */ +export const ACL_DDOC_VERSION_DEFAULT = "2.3.0"; +/** + * Ddoc version when `ACL_REQUIRE_CREATOR` is on. Bumped so ensure/migrate + * rewrites the VDU when the flag flips. + */ +export const ACL_DDOC_VERSION_REQUIRE_CREATOR = "2.4.0"; + +export type BuildAclDesignDocOptions = { + /** When true, VDU rejects non-admin creates that omit `creator`. */ + requireCreator?: boolean; + /** Override generated `version` (tests / migrations). */ + version?: string; +}; + /** Build a fresh `_design/acl` document (no `_rev`; caller supplies on update). */ -export function buildAclDesignDoc(version = "2.3.0") { +export function buildAclDesignDoc(options: BuildAclDesignDocOptions = {}) { + const requireCreator = options.requireCreator === true; + const version = + options.version ?? + (requireCreator ? ACL_DDOC_VERSION_REQUIRE_CREATOR : ACL_DDOC_VERSION_DEFAULT); return { _id: "_design/acl", language: "javascript", @@ -34,7 +53,7 @@ export function buildAclDesignDoc(version = "2.3.0") { map: ACL_MAP_SOURCE, }, }, - validate_doc_update: VALIDATE_DOC_UPDATE_SOURCE, + validate_doc_update: buildValidateDocUpdateSource(requireCreator), }; } @@ -92,12 +111,27 @@ export const ACL_MAP_SOURCE = `function (doc) { emit(doc._id, r); }`; +/** Marker string present only in require-creator VDU bodies (migration sniff). */ +export const REQUIRE_CREATOR_FORBIDDEN = "Document must have a creator."; + /** - * Couch `validate_doc_update` source: non-admins cannot forge creator or - * change owners/acl without standing. Delete authorization belongs to the - * proxy because parent and dbacl grants are unavailable to Couch's VDU. + * Build Couch `validate_doc_update` source. + * + * When `requireCreator` is true, non-admin creates of non-`_design` docs must + * include a non-empty `creator`. Existing creator-less docs remain readable as + * `r-*` via the map; this flag only blocks new unstamped creates. */ -export const VALIDATE_DOC_UPDATE_SOURCE = `function (nd, od, userCtx, secObj) { +export function buildValidateDocUpdateSource(requireCreator = false): string { + const requireCreatorCheck = requireCreator + ? ` + if (!/^_design/.test(nd._id || "")) { + if (typeof nd.creator != S || !nd.creator) + throw { forbidden: "${REQUIRE_CREATOR_FORBIDDEN}" }; + } +` + : ""; + + return `function (nd, od, userCtx, secObj) { var roles = userCtx.roles || []; var adm = !!(roles.indexOf("_admin") >= 0); var u = userCtx.name; @@ -138,7 +172,7 @@ export const VALIDATE_DOC_UPDATE_SOURCE = `function (nd, od, userCtx, secObj) { } if (!od) { - if (has(nd, "creator") && nd.creator != u && nd.creator != uu) +${requireCreatorCheck} if (has(nd, "creator") && nd.creator != u && nd.creator != uu) throw { forbidden: "Can't create doc on behalf of other user." }; } else { var odc = od.creator; @@ -171,3 +205,10 @@ export const VALIDATE_DOC_UPDATE_SOURCE = `function (nd, od, userCtx, secObj) { } } }`; +} + +/** + * Default VDU source (`ACL_REQUIRE_CREATOR` off). Kept as a constant so unit + * tests can assert the historical create semantics are unchanged. + */ +export const VALIDATE_DOC_UPDATE_SOURCE = buildValidateDocUpdateSource(false); diff --git a/src/config.ts b/src/config.ts index bb3ce90..7022550 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ * * Notable knobs: * - `ACL_AUTO_INSTALL` — whether missing `_design/acl` is installed on app DBs + * - `ACL_REQUIRE_CREATOR` — bake require-creator into installed `_design/acl` VDU * - `ACL_DB_INCLUDE` / `ACL_DB_EXCLUDE` — opt-in database allow/deny lists * - `ACL_ROUTE_INCLUDE` / `ACL_ROUTE_EXCLUDE` — opt-in API surface allow/deny lists * - `AUTH_RESOLVE_VIA_COUCH_SESSION` — forward creds to Couch `/_session` (preferred) @@ -67,6 +68,13 @@ const ConfigSchema = z * Set false in production if ddocs are provisioned out-of-band. */ aclAutoInstall: boolFromEnv.default(true), + /** + * When true, installed/migrated `_design/acl` `validate_doc_update` + * rejects non-admin creates that omit a non-empty `creator` + * (`_design/*` exempt). Default false preserves historical open-create + * semantics. Flipping the flag bumps the ddoc version so ensure rewrites. + */ + aclRequireCreator: boolFromEnv.default(false), }), auth: z.object({ /** @@ -212,6 +220,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { sessionCacheMaxEntries: env.SESSION_CACHE_MAX ?? 10_000, preloadDbs: splitCsv(env.COUCH_PRELOAD_DBS), aclAutoInstall: env.ACL_AUTO_INSTALL ?? true, + aclRequireCreator: env.ACL_REQUIRE_CREATOR ?? false, }, auth: { resolveViaCouchSession: env.AUTH_RESOLVE_VIA_COUCH_SESSION ?? true, diff --git a/src/index.ts b/src/index.ts index e6566e2..7ae3e3f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ async function boot() { logLevel: getLogLevel(), preloadDbs: config.couch.preloadDbs, aclAutoInstall: config.couch.aclAutoInstall, + aclRequireCreator: config.couch.aclRequireCreator, resolveViaCouchSession: config.auth.resolveViaCouchSession, profile: config.server.profile, }); diff --git a/test/integration/require-creator.test.ts b/test/integration/require-creator.test.ts new file mode 100644 index 0000000..c37d053 --- /dev/null +++ b/test/integration/require-creator.test.ts @@ -0,0 +1,244 @@ +/** + * Integration tests for ACL_REQUIRE_CREATOR. + * + * Default compose proxy keeps the flag off (historical create semantics). + * A second proxy container with ACL_REQUIRE_CREATOR=true exercises the + * require-creator VDU against a dedicated DB so acldemo stays untouched. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { + ACL_DDOC_VERSION_DEFAULT, + ACL_DDOC_VERSION_REQUIRE_CREATOR, + REQUIRE_CREATOR_FORBIDDEN, +} from "../../src/acl/ddoc.js"; +import { + ADMIN_PASS, + ADMIN_USER, + PROXY, + authHeaders, + ensureDbOpenForDemoUsers, + mintJwt, + putDoc, + waitForReady, + waitUntil, +} from "./helpers.js"; + +const execFileAsync = promisify(execFile); + +const REQUIRE_PROXY = process.env.COUCH_AUTH_REQUIRE_CREATOR_PROXY_URL ?? "http://127.0.0.1:8002"; +const CONTAINER = `couch-auth-proxy-require-creator-${process.pid}`; +const DB = `reqcreator-${process.pid}`; + +async function docker(args: string[]): Promise<{ stdout: string; stderr: string }> { + return execFileAsync("docker", args, { maxBuffer: 10 * 1024 * 1024 }); +} + +async function waitForUrlReady(url: string, timeoutMs = 120_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`${url}/_couch-auth-proxy/ready`); + if (res.ok) return; + } catch { + // retry + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`require-creator proxy not ready at ${url}`); +} + +async function proxyContainerId(): Promise { + const { stdout } = await docker(["compose", "ps", "-q", "couch-auth-proxy"]); + const id = stdout + .trim() + .split("\n") + .map((s) => s.trim()) + .find(Boolean); + if (!id) throw new Error("couch-auth-proxy container not running; start compose first"); + return id; +} + +async function resolveComposeNetwork(): Promise { + const id = await proxyContainerId(); + const { stdout: nets } = await docker([ + "inspect", + "-f", + "{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}", + id, + ]); + const network = nets.trim(); + if (!network) throw new Error("could not resolve compose network for couch-auth-proxy"); + return network; +} + +async function readAclDdoc(proxyBase: string): Promise<{ + version?: string; + validate_doc_update?: string; +}> { + const res = await fetch(`${proxyBase}/${DB}/_design/acl`, { + headers: authHeaders("basic", ADMIN_USER, ADMIN_PASS), + }); + expect(res.status).toBe(200); + return (await res.json()) as { version?: string; validate_doc_update?: string }; +} + +describe("ACL_REQUIRE_CREATOR integration", () => { + let aliceJwt: string; + let bobJwt: string; + let startedContainer = false; + + beforeAll(async () => { + await waitForReady(); + aliceJwt = await mintJwt("alice", ["readers"]); + bobJwt = await mintJwt("bob", ["writers"]); + + // Install historical (flag-off) ddoc via the default proxy first. + await ensureDbOpenForDemoUsers(DB); + const before = await readAclDdoc(PROXY); + expect(before.version).toBe(ACL_DDOC_VERSION_DEFAULT); + expect(before.validate_doc_update ?? "").not.toContain(REQUIRE_CREATOR_FORBIDDEN); + + const network = await resolveComposeNetwork(); + const proxyId = await proxyContainerId(); + const { stdout: imageOut } = await docker(["inspect", "-f", "{{.Config.Image}}", proxyId]); + const image = imageOut.trim(); + + await docker(["rm", "-f", CONTAINER]).catch(() => undefined); + + await docker([ + "run", + "-d", + "--name", + CONTAINER, + "--network", + network, + "-p", + "8002:8000", + "-e", + "HOST=0.0.0.0", + "-e", + "PORT=8000", + "-e", + "COUCH_URL=http://couchdb:5984", + "-e", + "COUCH_ADMIN_USER=admin", + "-e", + "COUCH_ADMIN_PASSWORD=password", + "-e", + "JWT_HMAC_SECRET=couch-auth-proxy-dev-secret", + "-e", + "AUTH_RESOLVE_VIA_COUCH_SESSION=true", + "-e", + "RATE_LIMIT_ENABLED=false", + "-e", + "ACL_REQUIRE_CREATOR=true", + image, + ]); + startedContainer = true; + await waitForUrlReady(REQUIRE_PROXY); + + // Touch DB so ensure/migrate upgrades the VDU for require-creator. + const admin = authHeaders("basic", ADMIN_USER, ADMIN_PASS); + await waitUntil( + `require-creator proxy acl ready ${DB}`, + async () => { + const touch = await fetch(`${REQUIRE_PROXY}/${DB}`, { headers: admin }); + return touch.ok; + }, + 60_000, + ); + + await waitUntil( + "ddoc upgraded to require-creator", + async () => { + const ddoc = await readAclDdoc(REQUIRE_PROXY); + return ( + ddoc.version === ACL_DDOC_VERSION_REQUIRE_CREATOR && + (ddoc.validate_doc_update ?? "").includes(REQUIRE_CREATOR_FORBIDDEN) + ); + }, + 30_000, + ); + }, 180_000); + + afterAll(async () => { + if (startedContainer) { + await docker(["rm", "-f", CONTAINER]).catch(() => undefined); + } + // Best-effort cleanup of the dedicated DB. + await fetch(`${PROXY}/${DB}`, { + method: "DELETE", + headers: authHeaders("basic", ADMIN_USER, ADMIN_PASS), + }).catch(() => undefined); + }); + + it("flag off (default proxy): unstamped create still allowed on acldemo", async () => { + const id = `open-default-${Date.now()}`; + const res = await putDoc( + "acldemo", + id, + { body: "intentionally open" }, + authHeaders("jwt", bobJwt), + ); + expect(res.status).toBe(201); + }); + + it("flag on: create without creator is forbidden", async () => { + const id = `no-creator-${Date.now()}`; + const res = await fetch(`${REQUIRE_PROXY}/${DB}/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...authHeaders("jwt", bobJwt), + }, + body: JSON.stringify({ _id: id, body: "hole" }), + }); + expect(res.status).toBe(403); + const body = (await res.json()) as { reason?: string; error?: string }; + expect(body.error).toBe("forbidden"); + expect(body.reason).toContain(REQUIRE_CREATOR_FORBIDDEN); + }); + + it("flag on: create with own creator succeeds", async () => { + const id = `own-creator-${Date.now()}`; + const res = await fetch(`${REQUIRE_PROXY}/${DB}/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...authHeaders("jwt", aliceJwt), + }, + body: JSON.stringify({ _id: id, creator: "alice", body: "private" }), + }); + expect(res.status).toBe(201); + }); + + it("flag on: forging creator is still forbidden", async () => { + const id = `forge-${Date.now()}`; + const res = await fetch(`${REQUIRE_PROXY}/${DB}/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...authHeaders("jwt", bobJwt), + }, + body: JSON.stringify({ _id: id, creator: "alice", body: "spoof" }), + }); + expect(res.status).toBe(403); + const body = (await res.json()) as { reason?: string }; + expect(body.reason).toMatch(/behalf of other user/i); + }); + + it("flag on: admin may still create unstamped docs", async () => { + const id = `admin-open-${Date.now()}`; + const res = await fetch(`${REQUIRE_PROXY}/${DB}/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...authHeaders("basic", ADMIN_USER, ADMIN_PASS), + }, + body: JSON.stringify({ _id: id, body: "admin hole" }), + }); + expect(res.status).toBe(201); + }); +}); diff --git a/test/unit/ddoc.test.ts b/test/unit/ddoc.test.ts index 0cbadd8..3df4ae3 100644 --- a/test/unit/ddoc.test.ts +++ b/test/unit/ddoc.test.ts @@ -1,24 +1,40 @@ import { describe, expect, it, vi } from "vitest"; import { AclCache } from "../../src/acl/cache.js"; -import { buildAclDesignDoc, VALIDATE_DOC_UPDATE_SOURCE } from "../../src/acl/ddoc.js"; +import { + ACL_DDOC_VERSION_DEFAULT, + ACL_DDOC_VERSION_REQUIRE_CREATOR, + REQUIRE_CREATOR_FORBIDDEN, + VALIDATE_DOC_UPDATE_SOURCE, + buildAclDesignDoc, + buildValidateDocUpdateSource, +} from "../../src/acl/ddoc.js"; import { loadConfig } from "../../src/config.js"; +type ValidateFn = ( + next: Record, + old: Record | null, + user: { name: string; roles: string[] }, + security: Record, +) => void; + +function loadValidate(source: string): ValidateFn { + return Function(`return (${source});`)() as ValidateFn; +} + describe("generated ACL design document", () => { it("leaves delete authorization to proxy r/w/d resolution", () => { const ddoc = buildAclDesignDoc(); + expect(ddoc.version).toBe(ACL_DDOC_VERSION_DEFAULT); expect(ddoc.version).toBe("2.3.0"); expect(ddoc.options.partitioned).toBe(false); expect(VALIDATE_DOC_UPDATE_SOURCE).not.toContain("You can't delete doc"); expect(VALIDATE_DOC_UPDATE_SOURCE).toContain("Creator can not be changed"); + expect(VALIDATE_DOC_UPDATE_SOURCE).not.toContain(REQUIRE_CREATOR_FORBIDDEN); + expect(buildValidateDocUpdateSource(false)).toBe(VALIDATE_DOC_UPDATE_SOURCE); }); it("prevents writers from claiming creator on an existing open document", () => { - const validate = Function(`return (${VALIDATE_DOC_UPDATE_SOURCE});`)() as ( - next: Record, - old: Record | null, - user: { name: string; roles: string[] }, - security: Record, - ) => void; + const validate = loadValidate(VALIDATE_DOC_UPDATE_SOURCE); const old = { _id: "open", body: "shared", @@ -34,12 +50,7 @@ describe("generated ACL design document", () => { }); it("lets role owners change readers but not retarget parent inheritance", () => { - const validate = Function(`return (${VALIDATE_DOC_UPDATE_SOURCE});`)() as ( - next: Record, - old: Record | null, - user: { name: string; roles: string[] }, - security: Record, - ) => void; + const validate = loadValidate(VALIDATE_DOC_UPDATE_SOURCE); const old = { _id: "shared", creator: "alice", @@ -66,12 +77,7 @@ describe("generated ACL design document", () => { }); it("prevents claiming creator-less documents and rejects malformed ACL metadata", () => { - const validate = Function(`return (${VALIDATE_DOC_UPDATE_SOURCE});`)() as ( - next: Record, - old: Record | null, - user: { name: string; roles: string[] }, - security: Record, - ) => void; + const validate = loadValidate(VALIDATE_DOC_UPDATE_SOURCE); const open = { _id: "open", body: "before" }; expect(() => @@ -86,13 +92,112 @@ describe("generated ACL design document", () => { ).toThrow(); }); + it("allows unstamped creates when ACL_REQUIRE_CREATOR is off", () => { + const validate = loadValidate(buildValidateDocUpdateSource(false)); + expect(() => + validate({ _id: "open-create", body: "shared" }, null, { name: "bob", roles: [] }, {}), + ).not.toThrow(); + expect(buildAclDesignDoc({ requireCreator: false }).version).toBe(ACL_DDOC_VERSION_DEFAULT); + expect(buildAclDesignDoc({ requireCreator: false }).validate_doc_update).toBe( + VALIDATE_DOC_UPDATE_SOURCE, + ); + }); + + describe("ACL_REQUIRE_CREATOR=true VDU", () => { + const source = buildValidateDocUpdateSource(true); + const validate = loadValidate(source); + + it("bumps ddoc version and embeds require-creator rule", () => { + const ddoc = buildAclDesignDoc({ requireCreator: true }); + expect(ddoc.version).toBe(ACL_DDOC_VERSION_REQUIRE_CREATOR); + expect(ddoc.version).toBe("2.4.0"); + expect(ddoc.validate_doc_update).toContain(REQUIRE_CREATOR_FORBIDDEN); + expect(ddoc.views.acl.map).toBe(buildAclDesignDoc().views.acl.map); + }); + + it("forbids missing or empty creator on non-admin creates", () => { + let missing: unknown; + try { + validate({ _id: "no-creator", body: "x" }, null, { name: "bob", roles: [] }, {}); + } catch (err) { + missing = err; + } + expect(missing).toEqual({ forbidden: REQUIRE_CREATOR_FORBIDDEN }); + + let empty: unknown; + try { + validate( + { _id: "empty-creator", creator: "", body: "x" }, + null, + { + name: "bob", + roles: [], + }, + {}, + ); + } catch (err) { + empty = err; + } + // Present-but-empty hits the type check first. + expect(empty).toEqual({ forbidden: "Creator must be a non-empty string." }); + }); + + it("allows create with own creator and rejects forge", () => { + expect(() => + validate({ _id: "mine", creator: "bob", body: "ok" }, null, { name: "bob", roles: [] }, {}), + ).not.toThrow(); + expect(() => + validate( + { _id: "mine", creator: "u-bob", body: "ok" }, + null, + { name: "bob", roles: [] }, + {}, + ), + ).not.toThrow(); + + let forged: unknown; + try { + validate( + { _id: "spoof", creator: "alice", body: "nope" }, + null, + { name: "bob", roles: [] }, + {}, + ); + } catch (err) { + forged = err; + } + expect(forged).toEqual({ forbidden: "Can't create doc on behalf of other user." }); + }); + + it("exempts _design docs and _admin from require-creator", () => { + expect(() => + validate({ _id: "_design/app", views: {} }, null, { name: "bob", roles: [] }, {}), + ).not.toThrow(); + expect(() => + validate( + { _id: "admin-open", body: "unstamped" }, + null, + { name: "admin", roles: ["_admin"] }, + {}, + ), + ).not.toThrow(); + }); + + it("still enforces immutable creator on updates", () => { + const old = { _id: "owned", creator: "alice", body: "a" }; + expect(() => + validate({ ...old, creator: "bob" }, old, { name: "bob", roles: [] }, {}), + ).toThrow(); + // Existing open docs remain updatable without adding creator. + const open = { _id: "open", body: "before" }; + expect(() => + validate({ ...open, body: "after" }, open, { name: "bob", roles: [] }, {}), + ).not.toThrow(); + }); + }); + it("compares owner arrays without comma-collision ambiguity", () => { - const validate = Function(`return (${VALIDATE_DOC_UPDATE_SOURCE});`)() as ( - next: Record, - old: Record | null, - user: { name: string; roles: string[] }, - security: Record, - ) => void; + const validate = loadValidate(VALIDATE_DOC_UPDATE_SOURCE); const old = { _id: "shared", creator: "alice", @@ -153,13 +258,14 @@ describe("generated ACL design document", () => { expect(upgraded).toMatchObject({ _id: "_design/acl", _rev: "4-old", - version: "2.3.0", + version: ACL_DDOC_VERSION_DEFAULT, acl: ["u-ops"], dbacl: { _r: ["r-support"] }, restrict: { "*": ["r-members"] }, }); expect((upgraded.views as Record).custom).toEqual(legacy.views.custom); expect(String(upgraded.validate_doc_update)).not.toContain("You can't delete doc"); + expect(String(upgraded.validate_doc_update)).not.toContain(REQUIRE_CREATOR_FORBIDDEN); }); it("upgrades generated v2.1 owner policy without replacing custom views", async () => { @@ -206,7 +312,7 @@ describe("generated ACL design document", () => { expect(written).toMatchObject({ _id: "_design/acl", _rev: "3-old", - version: "2.3.0", + version: ACL_DDOC_VERSION_DEFAULT, }); const views = written?.views as Record; expect(views.custom).toEqual(old.views.custom); @@ -305,7 +411,7 @@ describe("generated ACL design document", () => { expect(written).toMatchObject({ _id: "_design/acl", _rev: "5-old", - version: "2.3.0", + version: ACL_DDOC_VERSION_DEFAULT, dbacl: old.dbacl, restrict: old.restrict, }); @@ -357,10 +463,149 @@ describe("generated ACL design document", () => { expect(written).toMatchObject({ _id: "_design/acl", _rev: "4-old", - version: "2.3.0", + version: ACL_DDOC_VERSION_DEFAULT, dbacl: old.dbacl, views: old.views, }); expect(String(written?.validate_doc_update)).toContain('has(od, "creator")'); }); + + it("rewrites generated VDU when ACL_REQUIRE_CREATOR flips on", async () => { + const cache = new AclCache( + loadConfig({ + COUCH_URL: "http://127.0.0.1:5984", + RATE_LIMIT_ENABLED: "false", + ACL_REQUIRE_CREATOR: "true", + }), + ); + let written: Record | undefined; + cache.adminClient.fetch = vi.fn(async (_path: string, init?: RequestInit) => { + written = JSON.parse(String(init?.body)) as Record; + return new Response("{}", { status: 201 }); + }) as typeof cache.adminClient.fetch; + + const current = buildAclDesignDoc({ requireCreator: false }); + const installed = { + ...current, + _rev: "7-cur", + stamp: 1, + dbacl: { _r: ["r-support"] }, + views: { + ...current.views, + custom: { map: "function (doc) { emit(doc.kind, 1); }" }, + }, + }; + + await ( + cache as unknown as { + maybeMigrateStamp: (db: string, response: Response) => Promise; + } + ).maybeMigrateStamp( + "docs", + new Response(JSON.stringify(installed), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + expect(written).toBeDefined(); + const upgraded = written!; + expect(upgraded).toMatchObject({ + _id: "_design/acl", + _rev: "7-cur", + version: ACL_DDOC_VERSION_REQUIRE_CREATOR, + dbacl: installed.dbacl, + }); + expect(String(upgraded.validate_doc_update)).toContain(REQUIRE_CREATOR_FORBIDDEN); + expect((upgraded.views as Record).custom).toEqual(installed.views.custom); + }); + + it("rewrites generated VDU when ACL_REQUIRE_CREATOR flips off", async () => { + const cache = new AclCache( + loadConfig({ + COUCH_URL: "http://127.0.0.1:5984", + RATE_LIMIT_ENABLED: "false", + ACL_REQUIRE_CREATOR: "false", + }), + ); + let written: Record | undefined; + cache.adminClient.fetch = vi.fn(async (_path: string, init?: RequestInit) => { + written = JSON.parse(String(init?.body)) as Record; + return new Response("{}", { status: 201 }); + }) as typeof cache.adminClient.fetch; + + const required = buildAclDesignDoc({ requireCreator: true }); + const installed = { ...required, _rev: "8-req", stamp: 1 }; + + await ( + cache as unknown as { + maybeMigrateStamp: (db: string, response: Response) => Promise; + } + ).maybeMigrateStamp( + "docs", + new Response(JSON.stringify(installed), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + expect(written).toMatchObject({ + _id: "_design/acl", + _rev: "8-req", + version: ACL_DDOC_VERSION_DEFAULT, + }); + expect(String(written?.validate_doc_update)).not.toContain(REQUIRE_CREATOR_FORBIDDEN); + expect(String(written?.validate_doc_update)).toBe(VALIDATE_DOC_UPDATE_SOURCE); + }); + + it("does not rewrite when require-creator flag already matches the VDU", async () => { + const cache = new AclCache( + loadConfig({ + COUCH_URL: "http://127.0.0.1:5984", + RATE_LIMIT_ENABLED: "false", + ACL_REQUIRE_CREATOR: "true", + }), + ); + const fetchMock = vi.fn(async () => new Response("{}", { status: 201 })); + cache.adminClient.fetch = fetchMock as typeof cache.adminClient.fetch; + + const installed = { ...buildAclDesignDoc({ requireCreator: true }), _rev: "9-ok" }; + await ( + cache as unknown as { + maybeMigrateStamp: (db: string, response: Response) => Promise; + } + ).maybeMigrateStamp( + "docs", + new Response(JSON.stringify(installed), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("loadConfig ACL_REQUIRE_CREATOR", () => { + it("defaults to false and accepts truthy env strings", () => { + expect( + loadConfig({ + COUCH_URL: "http://127.0.0.1:5984", + RATE_LIMIT_ENABLED: "false", + }).couch.aclRequireCreator, + ).toBe(false); + expect( + loadConfig({ + COUCH_URL: "http://127.0.0.1:5984", + RATE_LIMIT_ENABLED: "false", + ACL_REQUIRE_CREATOR: "true", + }).couch.aclRequireCreator, + ).toBe(true); + expect( + loadConfig({ + COUCH_URL: "http://127.0.0.1:5984", + RATE_LIMIT_ENABLED: "false", + ACL_REQUIRE_CREATOR: "false", + }).couch.aclRequireCreator, + ).toBe(false); + }); });