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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `COUCH_PRELOAD_DB_INCLUDE`: warm ACL caches at boot by matching exact names or
`/regex/flags` against admin `GET /_all_dbs` (same pattern syntax as
`ACL_DB_INCLUDE`). Union with `COUCH_PRELOAD_DBS` when both are set; system
DBs are skipped from pattern matches; results still honour
`ACL_DB_INCLUDE` / `ACL_DB_EXCLUDE`. Empty/unset keeps lazy ensure-on-first-request.

## [1.5.0] - 2026-07-25

### Added
Expand Down
47 changes: 24 additions & 23 deletions README.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ A normal application DB (e.g. `acldemo`) is ACL-enabled when it has `_design/acl

Without a usable ACL map, the DB is **`noacl`**: the proxy only applies `restrict` (if any) and otherwise passes through to Couch `_security`.

**Warming at boot:** by default ACL ensure is lazy (first ACL-scoped request). Set `COUCH_PRELOAD_DBS` for an explicit name list, and/or `COUCH_PRELOAD_DB_INCLUDE` (exact names or `/regex/flags`, same syntax as `ACL_DB_INCLUDE`) to select DBs from Couch’s `/_all_dbs` at startup. When both are set, the preload set is their **union**. System DBs are never picked by the include patterns; `ACL_DB_INCLUDE` / `ACL_DB_EXCLUDE` still apply so preload cannot install ACL outside the intended scope. Useful for fleets of `data-*` project DBs without enumerating each name in compose/CDK.

**`/_all_dbs`** is filtered: DBs with `restrict.*` only appear for principals who match that list. Operators may also set process-wide `ACL_DB_INCLUDE` / `ACL_DB_EXCLUDE` (and `ACL_ROUTE_*`) env lists to further hide databases or API surfaces for non-admins — see the README “Env access policy” section.

### Couch `_security` vs proxy ACL
Expand Down
14 changes: 14 additions & 0 deletions src/acl/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ export class AclCache {
private readonly inflight = new Map<string, Promise<DbAclState>>();
/** Coalesce concurrent single-doc refreshes (`db\\0docId`). */
private readonly refreshInflight = new Map<string, Promise<void>>();
/**
* DBs resolved for boot preload (`COUCH_PRELOAD_DBS` ∪ include patterns).
* Readiness gates on these when non-empty; null means preload was never run.
*/
private criticalPreloadDbs: string[] | null = null;
private stopped = false;

constructor(private readonly config: AppConfig) {
Expand All @@ -100,6 +105,14 @@ export class AclCache {
return this.admin;
}

/**
* Boot-critical DB names for `/_couch-auth-proxy/ready`.
* Empty array / null → probe does not require a fixed preload inventory.
*/
getCriticalPreloadDbs(): string[] | null {
return this.criticalPreloadDbs;
}

/** Return cached state if present (may be incomplete / not ready). */
get(db: string): DbAclState | undefined {
return this.dbs.get(db);
Expand Down Expand Up @@ -234,6 +247,7 @@ export class AclCache {

/** Warm caches for configured DBs at boot (errors logged, not thrown). */
async preload(dbs: string[]): Promise<void> {
this.criticalPreloadDbs = [...dbs];
await Promise.all(
dbs.map(async (db) => {
try {
Expand Down
48 changes: 48 additions & 0 deletions src/acl/preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Resolve the set of databases to warm at process boot.
*
* Operators may name DBs explicitly (`COUCH_PRELOAD_DBS`) and/or select them
* via patterns against Couch `GET /_all_dbs` (`COUCH_PRELOAD_DB_INCLUDE`).
* When both are set, the preload set is their **union**. Empty/unset keeps
* historical lazy ensure-on-first-request behaviour.
*
* Guards:
* - System DBs (`_users`, `_replicator`, `_global_changes`) never come from
* the include patterns (explicit names still warm as noacl pass-through).
* - `ACL_DB_INCLUDE` / `ACL_DB_EXCLUDE` still apply so preload cannot widen
* ACL install beyond the intended DB scope.
*/
import type { AppConfig } from "../config.js";
import type { AdminClient } from "../couch/adminClient.js";
import { allowedByIncludeExclude, compileMatchList, matchListHits } from "./matchList.js";
import { isDatabaseName, isSystemDatabase } from "./names.js";

/**
* Build the ordered preload DB list from config + live `/_all_dbs`.
*
* Throws when `COUCH_PRELOAD_DB_INCLUDE` is set and `/_all_dbs` fails — boot
* should fail closed rather than silently skip warm-up.
*/
export async function resolvePreloadDbs(admin: AdminClient, config: AppConfig): Promise<string[]> {
const explicit = config.couch.preloadDbs;
const includeEntries = config.couch.preloadDbInclude;
const names = new Set<string>(explicit);

if (includeEntries.length) {
const listed = await admin.json<string[]>("/_all_dbs");
if (!listed.ok) {
throw new Error(`COUCH_PRELOAD_DB_INCLUDE: GET /_all_dbs failed (${listed.status})`);
}
const include = compileMatchList(includeEntries);
for (const db of listed.body) {
if (!isDatabaseName(db) || isSystemDatabase(db)) continue;
if (matchListHits(include, db)) names.add(db);
}
}

const aclInclude = compileMatchList(config.access.dbInclude);
const aclExclude = compileMatchList(config.access.dbExclude);
const scoped = [...names].filter((db) => allowedByIncludeExclude(aclInclude, aclExclude, db));

return scoped.sort((a, b) => a.localeCompare(b));
}
4 changes: 3 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ export function createApp(services: AppServices): Hono<AppEnv> {
const config = c.get("config");
const couchOk = await cache.adminClient.ping();
const states = [...cache.all()];
const preload = config.couch.preloadDbs;
// Prefer the resolved boot preload set (explicit ∪ include patterns).
// Fall back to configured explicit names when preload() was never called.
const preload = cache.getCriticalPreloadDbs() ?? config.couch.preloadDbs;
// Gate on preloaded DBs (ops-critical). One-off ensures must not flap readiness.
const critical = preload.length
? preload.map((name) => states.find((s) => s.name === name)).filter(Boolean)
Expand Down
16 changes: 16 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* - `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
* - `COUCH_PRELOAD_DBS` / `COUCH_PRELOAD_DB_INCLUDE` — warm ACL caches at boot
* - `AUTH_RESOLVE_VIA_COUCH_SESSION` — forward creds to Couch `/_session` (preferred)
* - `TRUST_PROXY_HOPS` — how many reverse-proxy hops to trust for client IP
*/
Expand Down Expand Up @@ -62,6 +63,11 @@ const ConfigSchema = z
/** Max hashed session-cache entries (LRU). */
sessionCacheMaxEntries: z.coerce.number().int().positive().default(10_000),
preloadDbs: z.array(z.string()).default([]),
/**
* Opt-in DB name patterns (exact or `/regex/flags`) matched against
* Couch `GET /_all_dbs` at boot. Unioned with `preloadDbs` when both set.
*/
preloadDbInclude: z.array(z.string()).default([]),
/**
* When true, missing `_design/acl` on app DBs is auto-installed.
* System DBs (`_users`, etc.) are never auto-installed.
Expand Down Expand Up @@ -136,6 +142,15 @@ const ConfigSchema = z
message: "JWT_HMAC_SECRET is required when JWT_LOCAL_VERIFY is enabled",
});
}
try {
assertDbPatterns(config.couch.preloadDbInclude, "COUCH_PRELOAD_DB_INCLUDE");
} catch (err) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["couch", "preloadDbInclude"],
message: err instanceof Error ? err.message : String(err),
});
}
try {
assertDbPatterns(config.access.dbInclude, "ACL_DB_INCLUDE");
} catch (err) {
Expand Down Expand Up @@ -219,6 +234,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
sessionCacheTtlMs: env.SESSION_CACHE_TTL_MS ?? 5000,
sessionCacheMaxEntries: env.SESSION_CACHE_MAX ?? 10_000,
preloadDbs: splitCsv(env.COUCH_PRELOAD_DBS),
preloadDbInclude: splitCsv(env.COUCH_PRELOAD_DB_INCLUDE),
aclAutoInstall: env.ACL_AUTO_INSTALL ?? true,
aclRequireCreator: env.ACL_REQUIRE_CREATOR ?? false,
},
Expand Down
16 changes: 12 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
* Process entrypoint for couch-auth-proxy.
*
* Loads config, builds the Hono app + ACL services, optionally preloads ACL
* caches for configured databases, then serves HTTP until SIGINT/SIGTERM.
* caches for configured databases (explicit names and/or `/_all_dbs` patterns),
* then serves HTTP until SIGINT/SIGTERM.
* On shutdown the ACL changes followers are stopped and the server is closed
* with a forced exit after `shutdownTimeoutMs`.
*/
import { serve } from "@hono/node-server";
import { loadConfig } from "./config.js";
import { createApp, createServices } from "./app.js";
import { resolvePreloadDbs } from "./acl/preload.js";
import { createLogger, getLogLevel } from "./util/log.js";

const log = createLogger("main");
Expand All @@ -21,15 +23,21 @@ async function boot() {
log.info("boot", {
logLevel: getLogLevel(),
preloadDbs: config.couch.preloadDbs,
preloadDbInclude: config.couch.preloadDbInclude,
aclAutoInstall: config.couch.aclAutoInstall,
aclRequireCreator: config.couch.aclRequireCreator,
resolveViaCouchSession: config.auth.resolveViaCouchSession,
profile: config.server.profile,
});

if (config.couch.preloadDbs.length) {
log.info("preloading ACL caches", { dbs: config.couch.preloadDbs });
await services.aclCache.preload(config.couch.preloadDbs);
if (config.couch.preloadDbs.length || config.couch.preloadDbInclude.length) {
const dbs = await resolvePreloadDbs(services.aclCache.adminClient, config);
log.info("preloading ACL caches", {
dbs,
explicit: config.couch.preloadDbs,
include: config.couch.preloadDbInclude,
});
await services.aclCache.preload(dbs);
}

const server = serve(
Expand Down
109 changes: 109 additions & 0 deletions test/unit/preload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Boot preload resolution: explicit names ∪ `/_all_dbs` include patterns.
*/
import { describe, expect, it, vi } from "vitest";
import { loadConfig } from "../../src/config.js";
import { resolvePreloadDbs } from "../../src/acl/preload.js";
import type { AdminClient } from "../../src/couch/adminClient.js";

function config(env: Record<string, string>) {
return loadConfig({
COUCH_URL: "http://127.0.0.1:5984",
RATE_LIMIT_ENABLED: "false",
...env,
});
}

function adminListing(dbs: string[]): AdminClient {
return {
json: vi.fn(async () => ({ ok: true as const, status: 200, body: dbs })),
} as unknown as AdminClient;
}

describe("loadConfig COUCH_PRELOAD_DB_INCLUDE", () => {
it("defaults to empty (lazy ensure)", () => {
const cfg = config({});
expect(cfg.couch.preloadDbs).toEqual([]);
expect(cfg.couch.preloadDbInclude).toEqual([]);
});

it("parses CSV patterns", () => {
const cfg = config({
COUCH_PRELOAD_DB_INCLUDE: "/^data-/,shared",
});
expect(cfg.couch.preloadDbInclude).toEqual(["/^data-/", "shared"]);
});

it("rejects invalid regexes at config load", () => {
expect(() =>
config({
COUCH_PRELOAD_DB_INCLUDE: "/(/",
}),
).toThrow(/COUCH_PRELOAD_DB_INCLUDE/);
});
});

describe("resolvePreloadDbs", () => {
it("returns only explicit names when include is unset", async () => {
const admin = adminListing(["data-a", "acldemo"]);
const dbs = await resolvePreloadDbs(admin, config({ COUCH_PRELOAD_DBS: "acldemo,extra" }));
expect(dbs).toEqual(["acldemo", "extra"]);
expect(admin.json).not.toHaveBeenCalled();
});

it("unions explicit names with /_all_dbs include matches", async () => {
const admin = adminListing([
"_users",
"_replicator",
"_global_changes",
"acldemo",
"data-alpha",
"data-beta",
"meta",
]);
const dbs = await resolvePreloadDbs(
admin,
config({
COUCH_PRELOAD_DBS: "acldemo",
COUCH_PRELOAD_DB_INCLUDE: "/^data-/",
}),
);
expect(dbs).toEqual(["acldemo", "data-alpha", "data-beta"]);
});

it("skips system DBs from include matches", async () => {
const admin = adminListing(["_users", "_replicator", "data-1"]);
const dbs = await resolvePreloadDbs(admin, config({ COUCH_PRELOAD_DB_INCLUDE: "/.*/" }));
expect(dbs).toEqual(["data-1"]);
});

it("honours ACL_DB_INCLUDE / ACL_DB_EXCLUDE so preload cannot widen scope", async () => {
const admin = adminListing(["data-ok", "data-secret", "acldemo", "other"]);
const dbs = await resolvePreloadDbs(
admin,
config({
COUCH_PRELOAD_DBS: "acldemo,other",
COUCH_PRELOAD_DB_INCLUDE: "/^data-/",
ACL_DB_INCLUDE: "/^data-/",
ACL_DB_EXCLUDE: "data-secret",
}),
);
expect(dbs).toEqual(["data-ok"]);
});

it("returns empty when nothing is configured", async () => {
const admin = adminListing(["data-a"]);
const dbs = await resolvePreloadDbs(admin, config({}));
expect(dbs).toEqual([]);
expect(admin.json).not.toHaveBeenCalled();
});

it("fails closed when /_all_dbs errors and include is set", async () => {
const admin = {
json: vi.fn(async () => ({ ok: false as const, status: 503, text: "down" })),
} as unknown as AdminClient;
await expect(
resolvePreloadDbs(admin, config({ COUCH_PRELOAD_DB_INCLUDE: "/^data-/" })),
).rejects.toThrow(/_all_dbs failed/);
});
});
Loading