Skip to content
Merged
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
13 changes: 11 additions & 2 deletions USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: <from secret>
Expand Down
28 changes: 23 additions & 5 deletions src/acl/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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<void> {
const ddoc = (await getRes.json()) as {
Expand Down Expand Up @@ -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
? {
Expand All @@ -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,
Expand Down
55 changes: 48 additions & 7 deletions src/acl/ddoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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),
};
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
9 changes: 9 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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({
/**
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
Loading
Loading