Skip to main content

Security model

What the extension enforces, how, what it deliberately does not, and what a client may rely on in an error. Every statement here is taken from a spec or from the source; where a behaviour was verified by running it, the section says so. Specs are cited as spec NNN and live in specs/.

1. The model in one page​

The trust boundary is the connection. The extension enforces nothing on a statement nobody prefixed: "an unprefixed query runs natively and unrestricted in every mode, which is why only the gateway may connect" (spec 017). The deployment invariant from CLAUDE.md is therefore the whole perimeter: only the gateway connects to DuckDB - or, when clients are served directly, only the doors (section 5), which prefix every statement themselves.

The prefix is the principal. A statement arrives as ACL ROLE "<role>" <sql>, ACL TOKEN '<jwt>' <sql>, ACL SESSION '<handle>' <sql>, ACL INGEST '<handle>' <insert> (a door's own composition, never a client's) or the anonymous ACL ADMIN <sql>. "Exactly one prefix is stripped; the remainder is parsed natively; role/claims apply to the whole ; batch" (spec 001). ROLE trusts the gateway; TOKEN verifies offline; SESSION and INGEST resolve a handle a door already exchanged a token for. A second ACL … inside the text is text: "Exactly one prefix is stripped (spec 001) and the inner parse runs with the override disabled, so ACL ROLE "r" ACL NATIVE ACL ADMIN CREATE VIRTUAL CATALOG x reaches duckdb's own parser as ACL ADMIN CREATE … and fails there - no re-entry, no mode laundering" (spec 009).

The rewrite happens before bind. The extension is a parser_override: it rewrites the parsed AST and "returns real SQLStatements to the normal execution path" (spec 001). Enforcement is structural, not a filter: "a denied column is absent from the safe subquery, so the binder rejects it; RLS claim values are baked as constants (pool-safe, no session state)". Two replacement forms exist - RENAME (virtual name to physical name in place, writable) and SUBQUERY (a wrapped SELECT carrying projection, masks, computed columns, RLS or view SQL, read-only) - and the resolver picks per object.

Fail-closed defaults.

  • "An unknown/denied name is refused (never left for the binder to hit the real catalog). A direct physical name is not a mapped virtual name ⇒ denied" (spec 001).
  • The markers acl_claim('<name>') and acl_arg(n) "are not real functions ⇒ a missed marker fails closed at bind" (spec 001).
  • The statement gate, the query-node gate and the table-reference gate each end in default: Deny (src/acl_rewriter.cpp): an AST form the rewriter does not enumerate is refused, which is what makes a co-loaded extension's opaque ExtensionStatement refused under the prefix (spec 067).
  • Denials are thrown, never returned: "Refusals are security output; they do not get a channel a neighbour can mute" (spec 017). The extension sets allow_parser_override_extension='STRICT' on load unless a value was set explicitly before loading; at DEFAULT "no ACL … statement parses" - loud, not silent - and acl_use_db / acl_use_functions refuse to configure a policy that cannot be enforced (spec 017).

The golden rule. "The rewriter adds no query parameters - a user's $1/? is the only parameter and binds normally" (spec 001). User-supplied values "are inserted as AST nodes (no text injection)"; the door-composed ingest binds its own $1..$3 in a statement that "carries no client SQL and therefore no client parameters to renumber" (spec 049).

State is per instance. The PolicyStore - policy, sessions, drain flag - is reached through AclParserInfo / AclScalarInfo, "no process globals, so two instances in one process never share sessions" (spec 040).

2. What is enforced, per surface​

Reads​

  • select gates every read position: "Every read position routes through RewriteTableRef (subqueries, EXISTS/IN, CTE bodies, INSERT … SELECT sources, DML FROM/USING), so one gate covers them all" (spec 003). A write-only object "cannot be read anywhere in a statement - including from inside the statement that legitimately writes it".
  • A virtual function call is a read too: a virtual table function or scalar needs select before its template is expanded, "so a denied call never reaches bind - its template is not even parsed" (spec 012).
  • A grant only narrows: "a grant may narrow, never widen" (spec 011/012). An object grant's column list may hide or mask a column the object exposes and nothing else - "naming, computing and ordering belong to the virtual catalog" (spec 037). A catalog-wide list intersects by bare name and refuses where a mask cannot be applied: "an unapplied mask is an error, never a skipped protection" (spec 038). Two roles masking one column differently are refused (spec 011/026).
  • A CTE name never resolves as a catalog object (spec 001).

Writes​

  • The per-verb capability (insert/update/delete/merge) gates every DML target, and a target must be RENAME-form: "Declaring COLUMNS on the object makes it a projection, and a projection is read-only by construction" (spec 042).
  • A grant's predicate confines what is written (spec 024): it is AND-ed into the WHERE and checked per row where the value is written - CASE WHEN <predicate over the new row> THEN <value> ELSE error('… cannot be written') END. "A violating row fails the whole statement rather than being dropped from it". An insert under a predicate "must name its columns, and must supply every column the predicate reads - or be given one by an injection, which counts". ON CONFLICT … DO UPDATE and both MERGE branches carry the same policy. Injected columns override what the writer supplied.
  • With a second relation in scope (UPDATE … FROM, DELETE … USING, MERGE) the predicate is qualified with the target's alias so "a same-named source column cannot capture it"; a MERGE carries it on the ON clause and on every WHEN NOT MATCHED BY SOURCE branch (spec 020). A predicate containing a subquery that was never checked where it was written is refused on those four paths (spec 027) - "Fail closed on the unknown".

DDL​

  • The statement gate admits CREATE and DROP and the rewriter decides: "only tables and views can be created through the ACL"; a table lands in a granted physical home under the explicit create capability, DROP needs drop, and CREATE OR REPLACE is priced at create+drop because "REPLACE is a drop" (specs 016/051). A plain CREATE refuses an existing name (spec 051).
  • The policy record a principal's DDL writes "can only add a record for the object just created, and its shape is fixed (alias form, the schema's origin) - a principal cannot choose a form, a projection or an RLS clause" (spec 016). create "is a data capability, not a manage scope, and no amount of DDL produces one".
  • A principal's CREATE VIEW is "resolved once, with its author's rights, and reading it is decided by the grant on the view" (spec 018).
  • CREATE TEMP TABLE needs the explicit temp capability; a temp name is resolved "virtual-first" and a miss "keeps today's Deny("no access")"; a temp over a granted virtual name is refused (spec 050).

Functions (PolicyStore::ResolveFunction, src/acl_function_categories.cpp, spec 072)​

The gate is default-deny by category. A function's key is (database, schema, name, kind); a category is a named set of keys, a row of the policy catalog; a call is admitted when its key is in a category granted to one of the principal's roles (or to '', every role) or is granted by name; a deny anywhere among the roles - by name or on a category - wins; a key in no category is refused. Evaluated in this order:

  1. the never set, in code: acl_* ("otherwise one statement SELECT acl_grant_admin('me','passthrough') defeats the whole model"), ducklake_*, quack_*, uc_*, arrow_scan / arrow_scan_dumb / seq_scan (pointers, spec 049; the one exemption is the door's own ACL INGEST statement), query / query_table / json_execute_serialized_sql (SQL past the rewriter), a scanner's *_query / *_execute / *_attach (SQL under the node's credentials on another server), the engine's __internal_* helpers. No grant re-opens one: the write refuses the name;
  2. the key: a bare name resolves among the members of that name and kind - system.main first, then system.pg_catalog, then any other system schema, then a physical catalog's; two physical candidates refuse as ambiguous; a qualified name is its own key; a qualified name that is no member is duckdb's method-call spelling (x.lower()) and becomes the call over the column;
  3. the verdict, flat: any deny by name or on a category holding the key → refused; a grant by name, or a grant on any category holding the key → admitted; else refused, and the text says whether the key is in no category at all;
  4. the admitted call is emitted qualified to its key - lower(x) reaches the binder as system.main.lower(x) - so a macro named like a builtin in a physical catalog cannot take a principal's call; result columns keep the names duckdb gives them.

The shipped categories (schema/function_categories/, seeded once when a catalog is created and never touched by the extension again): every role's from the start - base (core compute, the operators, the names the parser rewrites syntax into), generators, node_facts, json, icu, spatial, inet, h3, hashfuncs, a5, geosilo; nobody's until granted - readers (files, URLs, object stores, other databases: unconfined by construction), meta (the duckdb_* listings the rewriter does not substitute, pragma_*, the pg-compatibility readers such as pg_get_viewdef, histogram_values, stats), environment (getenv, current_setting, getvariable, which_secret, duckdb_secrets, …), node (checkpoints, logging, profiling, sequences, external resources, servers), plan (json_serialize_plan, substrait). A function of a newly loaded extension, a builtin a pin bump adds, an admin's macro, a macro in a ducklake catalog: in no category, refused, until the operator puts it somewhere. acl_function_status([role]) is the screen that shows them (status = 'uncategorized').

What spec 041 said of the denylist - "loading an extension extends the function surface, and this gate is a denylist - so its failure mode is the thing nobody named" - no longer holds: the failure mode of default-deny is a refusal, and the refusal names the function.

Metadata​

  • The catalog surfaces are replaced by the principal's own listing, in duckdb's own shapes (specs 010/035): information_schema.tables|columns|schemata, duckdb_tables, duckdb_views, duckdb_columns, duckdb_schemas, duckdb_databases (function and view forms alike), and since spec 098 duckdb_functions() - the function, bare or qualified (system.main.duckdb_functions()), and the bare name in FROM. "The physical world does not appear in any of them" (spec 031). test/sql/acl_metadata_leak.test pins that an ungranted table "is in none of them, under any name" and that duckdb_databases() does not list the physical database.
  • duckdb_functions() answers what the principal may call (spec 098): the engine's functions the function gate admits for its roles - enumerated from the gate's own model - and the virtual functions a call of the principal reaches (with select; since spec 115 in every catalog it holds - a flat name of the MAIN catalog by its bare name, any other qualified: c.f, c.a.b.f), with their parameters, a table function's TABLE(…) result type and comment. A virtual row names only virtual things (its catalog, its schema path, its comment) and never a definition; an engine row outside the system catalog carries no definition, database, schema or the operator's comment - a macro of an attached catalog granted by name is listed by name only, and only when that bare name reaches it (not when a builtin or a virtual function of that name takes the call). It answers whether or not the role holds meta (where acl_function_status still lists duckdb_functions as a member); before spec 098 a meta grant handed out the engine's whole listing, macro definitions included.
  • Surfaces with no filtered form stay denied by the function gate: duckdb_secrets(), duckdb_constraints(), pragma_table_info(...) as a function, duckdb_settings() (spec 052).
  • DESCRIBE <name> becomes DESCRIBE (SELECT * FROM <name>), so it describes the rewritten relation (spec 025); SHOW TABLES, SHOW DATABASES, SHOW SCHEMAS, SHOW ALL TABLES answer from the listing; SHOW VARIABLES is refused; PRAGMA table_info / PRAGMA show_tables are answered through DESCRIBE and every other PRAGMA "stays denied, because a PRAGMA is otherwise a setting" (spec 031).
  • The session-identity functions answer as the principal: current_database(), current_catalog(), current_schema(), current_schemas() return the MAIN virtual catalog - and so do the keyword spellings current_catalog / current_schema, which the parser hands over as column references (test/sql/acl_metadata_leak.test, spec 010 part 3, spec 052 addendum 2026-09-17).
  • The walker visits every position an expression can sit in - VALUES rows included, in FROM and in INSERT - so the function gate and the unknown-object refusal hold there as in a select list (test/sql/acl_expression_positions.test, spec 052 addendum 2026-09-17).
  • PIVOT / UNPIVOT go through the same gate (spec 075): the source is resolved and confined like any relation, the aggregates and pivot expressions are gated, and the implicit form's column discovery (the parser's CREATE TEMP TYPE ... AS ENUM (SELECT DISTINCT ...)) runs under the same RLS, so a value only a hidden row carries never becomes a column (test/sql/acl_pivot.test).
  • A grant's projection is probed where it is written and stored in grant_columns, "so information_schema.columns describes what the role reads" (spec 026). duckdb_tables().sql is never NULL and is_insertable_into "follows the grant, not the physical row" (spec 065, test/sql/acl_listing_truth.test).
  • References (spec 022) are "a hint an agent reads, never enforced and granting nothing; visible only when both ends and every column it names are".

Names (spec 116)​

  • A virtual catalog, schema, object and function - and the physical name it points at - may be named the way SQL allows (any case, spaces, a . or a " inside a part). Every stored name is the canonical key of its parts: joined by ., a part quoted only when it holds . or ", so a key reads back as the same parts and a part is never read as two ("a.b" is one schema, beside the path a.b). Prefix matching on keys stays sound: a quoted part delimits itself.
  • Names compare case-insensitively, as duckdb's catalog does - in the resolver, the DDL homes, the listings and the manage-scope check. A name is never two things: every write takes the spelling the policy already stores (the parent schemas, the object itself), and a NEW name that differs from any stored name of its catalog only by case is refused where it is written. Joins between the policy's own tables then stay exact. The fold is ASCII only, as duckdb's catalog folds - one fold in SQL and in C++ - so "Ä" and "ä" are two names, as in duckdb. When a name nonetheless matches more than one object (a function-driver source answering Orders and orders), the read is refused as ambiguous, never served from the union of their grants; a schema alias's tail is taken by parts, never by bytes.
  • Roles, groups, issuers and clients are values (claims, mapping targets), not SQL identifiers: they may be written quoted, and compare exactly.
  • Schema v20 declares min_reader_version 20: a build before spec 116 refuses such a catalog rather than split a quoted part as a path; the migration refuses while one name is spelled several ways and lists them (test/sql/acl_quoted_names.test).

Types (spec 099)​

  • Only the system catalog's types can be named under a principal: a cast, a NULL::t, a column type of a table the principal creates (with its DEFAULT, generated column and CHECK gated like any expression). A type of another database - a user CREATE TYPE, an ENUM whose labels are the operator's - is refused before bind with type "<db>.<schema>.<t>" is not available: only the system catalog's types can be named (reason type_denied), the same text whether the type exists or not - the refusal is ours, so duckdb's "Did you mean" never names the database that has it. A type written as text is a type too: from_json / json_transform (and their _strict forms) take a constant structure only, every type in it gated the same way. A bare name resolves in system.main only, so a same-named type of an attached database never shadows a built-in. The built-ins, their aliases, JSON and the anonymous constructors (STRUCT, LIST, MAP, UNION, an inline ENUM('a', 'b')) stay.
  • duckdb_types() answers the system catalog's types, once - never another database's, a user type, or an ENUM's labels, with or without meta.
  • What type a column is exposed as is the operator's choice, and the read, the listings, the DDL a quack client binds and the Flight schema always agree (the rewriter casts where the source is read; every listing describes the cast type):
    • an extension's alias type (MSSQL_VARCHAR(n)) is read as its base type by default (acl_alias_types = 'base') - a quack client without that extension cannot ATTACH a catalog that names it; keep exposes it as it is;
    • an ENUM (at any depth of a STRUCT / LIST / MAP / ARRAY / UNION) is kept by default (acl_enum_types = 'keep') or read as VARCHAR (varchar). An ENUM's type carries its whole domain: whoever sees the column sees every label, whatever RLS leaves visible - varchar is the control, and acl_check_catalog reports enum_domain_exposed where a predicate narrows a table whose ENUMs are kept.
    • The virtual table overrides the node: ALTER VIRTUAL TABLE c.t SET TYPES (aliases = …, enums = …). A cast changes only the read - the relation stays writable, a string still reaches the ENUM column. The cast sits above the row filter: a predicate (the object's or a grant's) reads the physical values - an ENUM orders by its labels' positions, a VARCHAR lexically, so a predicate over the cast value would change which rows a role sees; the projection and masks read the exposed values, and acl_check_catalog names an entry or a mask that no longer binds (types_incompatible, e.g. enum_code(...)) or that makes a type of its own (types_mismatch).

Fields of structured columns (spec 102)​

  • A grant may narrow a STRUCT column to some of its fields, through lists of structs too, and mask single fields. A hidden field is absent from the projection, so nothing that reads the relation reaches it: SELECT *, the row as a value, struct_extract, DESCRIBE, the listings, the DDL a quack client binds, the Arrow schema.
  • A path is parsed, never spliced: every step is a name, emitted quoted inside struct_extract. Only the part after = is an expression, as with a column mask.
  • Writes keep what the role cannot see.
    • A hidden field is kept (UPDATE) or left NULL (INSERT).
    • A masked field is assigned.
    • A written value that carries a hidden field is refused with an error(). The message names no field, so it is no oracle for field names.
    • RETURNING refuses a narrowed column: the stored struct still holds the hidden fields.
  • A grant's predicate reads the physical row, hidden fields included (address.country = acl_claim('country')). A hidden column works the same way. A principal's own expressions in a write statement do not: SET, WHERE and ON see the target as the principal reads it. A hidden field or column is refused there, and a masked one reads as its mask. Before spec 102's review an UPDATE … SET visible = hidden copied a hidden column out.
  • A MERGE's UPDATE SET * / UPDATE BY NAME is refused under a column policy (it bypassed the policy before).

Statements (AclRewriter::RewriteStatement, src/acl_rewriter.cpp)​

statement typeunder a principal
SELECT, INSERT, UPDATE, DELETE, MERGE INTOrewritten (sections above)
EXPLAIN [ANALYZE]needs the explicit explain capability on the MAIN grant, then the inner statement is rewritten (spec 052)
CREATE, DROPtables and views under create/drop/temp (above); SECRET under the explicit secrets capability, constant parameters only - kept in the attached secrets service, never TEMPORARY there (spec 082); with no service attached (a standalone node, spec 118) kept by the node itself, PERSISTENT in its secret files on disk - there secrets administers every secret of the node, the operator's included; on a platform node (hugr-node's hugr_node, a cluster node by construction) only in the service
PRAGMAtable_info, show_tables answered; the rest denied by name (spec 031)
SET / RESETTimeZone, Calendar only, session scope, constant value, only on an ACL SESSION connection (spec 068)
BEGIN / COMMIT / ROLLBACKpass through: "they are session control, not access"
anything elseacl_rewrite: statement type <T> is not permitted under ACL

test/sql/acl_statement_gate.test pins CONNECT and DISCONNECT refused ("CONNECT hands every following statement's text to another executor, stepping around the rewriter"); spec 067 pins statement type EXTENSION is not permitted under ACL for a co-loaded extension's peeled syntax. Below the statement gate, an unknown query-node type or table-reference form is refused the same way.

Settings​

A principal sets nothing that changes "what a name resolves to (search_path), what is read (file_search_path, enable_external_access), or what a statement costs (threads, memory_limit)" (spec 068). SET GLOBAL is refused; SET VARIABLE is refused; a listed setting without a session of the client's own is refused, so "a prefix-per-statement deployment never carries state between principals". The extension's own settings are SetScope::GLOBAL, read through the DatabaseInstance, so a session-scoped SET cannot silently change nothing (spec 009, spec 040 addendum for acl_jwt_clock_skew).

Doors​

RefuseUnlessServable (src/acl_door_common.cpp) refuses to open either door when: no policy source is configured; acl_allow_anonymous_admin is on ("a served client could administer the ACL with a bare ACL ADMIN"); the parser override is not STRICT ("a served statement that failed to parse as ACL would fall through to plain SQL"); or there is no TLS certificate and the address is not loopback ("without a TLS certificate the door serves in the clear, so it binds only localhost"). Only the last is lifted by a certificate or by quack's explicit mode := 'plain'; "the three above stand regardless". The quack door additionally demands a server token ("a default-configured quack accepts whatever a caller sends, and this is the outer fence around that") and refuses plain mode combined with a certificate. Both doors require cert and key together.

Administration (spec 009)​

  • Administering the ACL is a granted capability: {"manage": true} on a catalog grant (per catalog), or a global manage / passthrough row in acl.admins. "A manage scope never leaves the virtual catalog (ACL NATIVE is passthrough only) and never hands out scopes."
  • The client writes the marker after the principal: ACL <mgmt> needs manage or passthrough; ACL NATIVE <sql> - "plain SQL outside the virtual catalog - god mode" - needs passthrough. "Choosing a marker grants nothing by itself."
  • "A catalog-scoped manage edits its catalogs' content but may not run GRANT|REVOKE CATALOG or ALTER GRANT" - handing out access is privilege administration; acl_grant_admin / acl_revoke_admin require passthrough: "no self-escalation".
  • A management batch "is authorized statement by statement at parse time, so a refusal anywhere in it executes nothing"; execution itself is not atomic.
  • The anonymous ACL ADMIN hatch "is off by default the moment a real policy source exists" (acl_allow_anonymous_admin), and a door refuses to open while it is on.
  • What manage really is: "may expose, through C, anything the DuckDB instance can reach - the grammar's phys targets are not themselves restricted … manage scopes belong to trusted operators".

The platform catalog (spec 117)​

  • The scopes are a set of bundles, never an order. observe, operate, cluster, policy and passthrough (spec 009's global manage reads as policy + observe; a catalog's manage capability is that catalog's administration) are held side by side; every check asks the set ("may administer" = a bundle that writes, a catalog's manage or a point grant on a platform function - never observe alone), so a scope added later cannot be compared into rights it does not carry.
  • Rights on administration are grants on platform's objects: a bundle, or a point grant / deny on one view or function (platform_grants, its own table - never a catalog grant, a function grant or a category, which could spread it into every listing or hand it to every role). Only passthrough grants on platform and grants the bundles - a grantor that can grant everything is everything; never to every role, never on grant_admin, revoke_admin, grant_platform, revoke_platform, migrate_catalog (the passthrough scope's own), cluster_extension, cluster_attach, cluster_detach, cluster_setting (the cluster bundle's alone), view_as (the policy bundle's - granted by name it would grant nothing; a DENY on it still works) and console_info (every holder's). A deny wins over every bundle but passthrough.
  • A metadata answer is bounded, never cut (spec 119): above acl_metadata_max_rows the statement is refused; the narrowing a listing takes from its WHERE only chooses which physical sources are read - it never changes the answer (the WHERE stays as written) and reads only sources behind objects the principal sees.
  • A catalog admin builds only over its granted sources (spec 118.3): every physical name it stores and every table or view a body of its names lies inside a GRANT SOURCE of its role - handed out by policy or passthrough; the physical tree it sees under platform.attached is the same set. Delegating a catalog no longer delegates every attached database. A body's names are judged by the object each one resolves to, as duckdb binds it (spec 138): a physical view by its own database, a one-part name in the node's default database, a name of no object (a file) refused. What a granted view or function reads inside is the constructor's - the virtual catalog is a facade.
  • A stored body holds only what its author may call (the review of spec 117): a view, a macro template, an alias target, an RLS, a column or mask expression, a grant's policy runs as the node when read, so an author who is not passthrough may store only calls its own function gate admits (its roles' categories and grants by name; the never set always refused) - judged where it is written. acl_check_catalog names a body stored before (body_function_denied). No definition names the policy catalog's own tables, under any reading of the name. A change to an issuer or a client behind a mapping to an administering role, a mapping to such a role, and a catalog's manage for a role a mapping reaches are passthrough's.
  • A view as another role sees no more than that role, and reads no rows (spec 120): SET SESSION VIEW AS ROLE is policy's or passthrough's, acts only on the session the statement runs under (the call takes no session argument, so no session can be put into another role's view by someone else), rewrites the session's statements as that role, and refuses every path that returns rows or changes anything - a read of an object, a table function, a virtual scalar, SUMMARIZE, DML, DDL, management, ACL NATIVE, secrets. DESCRIBE binds what it describes and runs nothing. A role holding administration is viewed by passthrough only, the viewer's right is judged on every statement, a function of an attached catalog (whose body may read) is refused, and a statement the session prepared outside the mode does not run inside it. The administrator is what the audit records.
  • One authorizer. The grammar and a direct platform.<op>(…) compile to the same acl_* call and are judged by the same table of rights per operation (CATALOG / POLICY / HANDS_OUT / ESCALATES / INFRASTRUCTURE), before anything runs; an operation the table does not know is refused.
  • Management calls run at the top level only - SELECT platform.f(…) with one item and nothing else, or CALL platform.f(…); in FROM, a subquery, a WHERE, a CTE or per row of another selection they are refused at rewrite, and a batch mixing management and queries is refused. One call is one change and one admin audit event.
  • Arguments that carry authorization are constants (a catalog, a role, an object, a scope): they are judged before the call runs, which a ? parameter cannot be. Other arguments may be parameters, and the compiled call carries exactly the user's parameters (the golden rule).
  • The views widen nothing: they answer what the acl_* listings answered a passthrough operator, narrowed by scope - a catalog admin reads its catalogs' rows and the names of the roles holding them, nothing of identity, categories, resource groups, the cluster or the node. No view carries a secret or a handle (an issuer / client shows the secret's name). A missing grant fails closed: the object is not listed and is no access.
  • No definition reads the policy store itself: a virtual table, schema alias, expansion, view or macro body, RLS or column expression naming the policy catalog's tables is refused where it is written
    • otherwise whoever may write the object could write their own admins row. And mapping a claim to a role that administers is passthrough's alone, as granting the bundle is.
  • The name is reserved and synthesized: no virtual catalog takes it, no management call names it, a function-driver source answering it is refused on every read, USE platform is refused, and the v21 migration refuses while a catalog carries it.
  • Absent on the quack door: quack loads a catalog whole at ATTACH; platform is listed and read only on the Flight door and under the gateway's prefixes.
  • The grammar needs no marker under a principal prefix, recognized by its leading phrase only; a principal holds no physical rights, so a GRANT there can only mean ours. Data DDL (CREATE TABLE, DROP TABLE, ALTER … RENAME) and duckdb's COMMENT ON TABLE / ANALYZE / CREATE FUNCTION are not captured.

Audit (spec 069)​

Every decision is an event emitted after it is made, off the decision path: the parser override emits one per statement it decides (or one for the refusal that ended the batch), the store one per session opened / closed / refused, the doors one per ingest completed, ticket, and password handshake, the catalog backend one per reload, write and source error. An event never carries a claim value, a session handle, or statement text - the subject and roles, the session's ops id, and the statement class stand in for them - and the JSON-lines file sink writes exactly those fields. A refusal names one code of a bounded taxonomy (the Reason enum in acl_rewriter.hpp; every Deny site names its own, and a failure nobody noted falls to the phase it escaped from - parse, principal, mgmt_unauthorized, policy_error). Metric attributes are drawn from bounded sets, so a metric row can never name a role, a subject or an object (acl_audit.test pins it). A refusal's reason is our own text and names virtual objects only; the three places it could carry more are closed: a parse reason is a fixed sentence (the parser echoes the statement's text), a principal reason has its quoted values blanked (the issuer or algorithm of a token nobody verified), an ingest failure of the physical source keeps only the error's class (the source's text carries the row it refused). Reasons are bounded to 512 bytes and trace ids to 128, so a caller cannot grow the ring. A slow or throwing sink costs dropped events, counted, never a slower statement; a level of off still counts. One source - a session, a principal, a door - has at most acl_audit_denials_per_second refusals recorded per second (all are counted), so a flood of cheap refusals cannot push other principals' records out of the ring, the queue or a sink. The audit surface is refused to a principal like every other operator function; the Prometheus route and the load report are opt-in (acl_metrics_endpoint) and answer only a bearer token whose roles hold the observe scope (spec 097) - acl_observe_unauthenticated is the operator's explicit opt-out for a private address. They publish counts and states, never a principal, a handle or an object name.

3. Capabilities and scopes​

capabilitywhere it is grantedin the unstated default?gates
selectcatalog, schema, object grantyesevery read of a relation; every virtual function call (specs 003/012)
insert, update, delete, mergecatalog, schema, object grantyesthe DML verb on a RENAME-form target; predicate-checked per row (spec 024)
managecatalog grant ({"manage": true})noACL <mgmt> over that catalog; never GRANT CATALOG / ALTER GRANT / admin scopes (spec 009)
create, dropschema (or catalog) grantno`CREATE TABLE
tempMAIN catalog grantnoCREATE TEMP TABLE and temp-name resolution on a session connection (spec 050)
explainMAIN catalog grantnoEXPLAIN [ANALYZE] - "a plan names the physical objects a query resolves to" (spec 052)
bundle policy (global; spec 009's manage = policy + observe)acl.admins via acl_grant_admin-the management grammar and the platform functions over every catalog plus catalog-less statements; the policy views (spec 117)
bundle passthroughacl.admins via acl_grant_admin-everything, including ACL NATIVE; the only scope that grants bundles and grants on platform
bundle observeacl.admins via acl_grant_admin-the node views, the load report and /metrics (spec 097); administers nothing - implied by passthrough and a global manage
bundle operate (spec 118)acl.admins via acl_grant_admin-the node's runtime: kill a session, set a session's audit level (OFF included - its own too) and profile, drain / resume; reads what observe reads; administers no policy - implied by passthrough
bundle cluster (spec 118; a platform node's - hugr-node, absent on an open node)acl.admins via acl_grant_admin-operate + ACL CLUSTER … (settings: resources and tuning only), the cluster views and drift; never a source's data nor ACL NATIVE; a cluster node's - implied by passthrough
point grant on `platform.<viewfunction>`platform_grants via `GRANT VIEWFUNCTION platform.x` (passthrough)

The rules behind the table (spec 012): a grant "written without CAPS - or a driver row with NULL/empty caps - means select, insert, update, delete, merge, never manage; an explicit '{}' means none"; "an object grant that states nothing inherits the catalog grant's caps, so a refinement never widens by omission"; the capabilities outside the default "are explicit-only and never inherited". A schema grant "carries capabilities only - no RLS, no column list" (spec 015). Capabilities and predicates union across a principal's roles; column lists union in the object's order (specs 026/038).

4. Identity​

The token model - who verifies, who acquires, the admin's flow menu, session token binding - is in authentication.md. What matters for enforcement:

  • ACL ROLE "trusts the gateway (role + optional default claims)"; ACL TOKEN "verifies offline → role + claims" (spec 001) - RS256/ES256 (HS256 only from a key pasted into a secret), issuer, exp/nbf, keys found by the issuer's OIDC discovery or named by a secret of the secrets service (specs 007/023/095); then exactly one client of the issuer accepts it (audiences required, azp, conditions - ties refused, never unioned) and makes roles and attributes of it. A role that administers - any bundle, any point grant on platform (grant or deny) or the manage capability on a catalog (spec 117) - is reached only through the client's own explicit mapping, never an issuer-wide mapping, UNMAPPED AS ROLE or ROLES CONSTANT, judged at use; a CONSTANT attribute is a ceiling the token cannot pass. "An unverified token never reaches the scope question" (spec 009).
  • A session "carries exactly the prefix's principal, both ways" (spec 040 addendum, 2026-09-03): SessionOpen now merges role-default claims exactly as ACL TOKEN does, so the same token answers the same slice through a door and through a gateway. acl_jwt_clock_skew is GLOBAL, like every sibling setting.
  • acl_session_token_binding='connect' (default) judges exp at establishment only; 'every_use' re-judges it on every statement (spec 059). "An expired token can never open a session under either setting" (authentication.md).
  • The Flight password handshake runs the IdP's password grant as the node's one client with the password flow - several are refused, so a password never reaches another customer's IdP, and the token endpoint must be on acl_jwks_locations; "the IdP's refusal is the gate"; it is refused on a cleartext door and while draining. No key and no client secret is ever in the policy catalog or a listing: they live in the secrets service and are read at use (specs 064/066/095); a change of an issuer's or a client's connection made there is a connection_changed policy event.

5. Doors and sessions​

Handles, tickets and cookies are bearer credentials, and they come from one minter. MintRandomHex (src/acl_policy.cpp) reads std::random_device - the OS CSPRNG on glibc, libc++ and MSVC - and checks the device is not deterministic (MinGW before GCC 9.2 "returned a fixed sequence"): "Refusing to mint beats minting that" (spec 040). Since the 2026-09-03 addendum to spec 045 the session handle, the Flight ticket id and the Flight session cookie are all minted there. A handle is "never derived from the token, never logged by us, revocable by acl_session_close, and useless outside this instance".

Who holds what.

  • Gateway contract (spec 040): acl_session_open/acl_session_sql/acl_session_close mint, compose and end; all three are acl_* and therefore denied to a principal, so "a client can neither mint a session, nor compose a prefix, nor close somebody else's".
  • quack (spec 041): the connection id is "a bearer credential in quack's model; our handle is separate, minted by us, and only ever appears in SQL we compose". The callbacks "run on a fresh transient connection of quack's own making".
  • Flight (spec 050): the session cookie "only selects a session, and only one of the same principal" - the match is the principal fingerprint including the token's subject; "a cookie presented with a different principal's token closes the old session and opens a fresh one"; the token is verified before the idle clock is touched; "Our handle never leaves the server". A ticket is a single-use reservation redeemable only by the fingerprint that made it, and "a mismatch answers exactly like a handle that never existed: no oracle" (spec 047).

A session is a connection (spec 050): statements execute on the session's own held Connection under a per-session lock; temp tables are "per-connection by construction, so no principal can reach another session's temp through any surface"; duckdb reclaims everything when the session ends. A cookie-less call gets a per-call session closed on the way out.

  • Flight (spec 135): the session-state cookie acl_session_state is the client's own context, not a credential. It carries the session's USE, the client-local settings it SET, why the session cannot move (an open transaction, a temp table, the view-as mode) and a tag of its principal. It is unsigned on purpose: it grants nothing.
    • On a node that does not know the session, every item is judged again as if the client sent it now: a USE of a catalog the principal does not hold is refused, and a setting goes through the same allowlist and validators as a SET.
    • The decoder admits only spec 068's eight settings, each once, within 4 KiB: anything else is refused, never read partly.
    • A state of another principal (a stolen or shared jar) carries nothing.
    • Dropping a pin loses only the client's own transaction or temp tables. A session that was in the view-as mode is not seated afresh, unless the call declares the mode.

Bounds (spec 044): a session dies at idle (acl_session_idle_timeout, default 900 s) or - under every_use - at exp; acl_max_sessions (default 1000) is a cap at which "a new session is refused, never an old one evicted"; SessionOpen sweeps by itself. Arrow's own session store is closed alongside ours so it "cannot grow without bound".

Drain (spec 066): acl_drain() sets one flag on the store and SessionOpen refuses "before verifying anything"; it "never ends a session"; established sessions keep working; ACL TOKEN / ACL ROLE statements keep working; the three drain functions are denied to a principal; quack's discovery document answers 503 draining. Ending a straggler is acl_session_kill, an operator's act.

The unprefixed drain fence (spec 042; src/acl_parser_override.cpp). quack generates its bulk INSERT … SELECT * FROM <scan>(…) unprefixed on the client's connection. While a door of this instance is open (store.DoorOpen()), any unprefixed statement that calls acl_quack_scan_data or scan_data_from_quack_client is either rewritten under the principal recovered from the stream id (connection_id:uuid → session → principal) or refused: "The refusal is the default and the rewrite is the exception". The recovered principal carries ingest_stream set to that exact id, so the never-set scan passes "for that principal and nothing else". With no door open the fence is off - a stock quack's own ingest "is its own business".

The Flight ingest (spec 049): the door composes INSERT … SELECT … FROM arrow_scan() - the client's stream attached to that ref as process-local bind input (duckdb #25726), never as SQL text - under ACL INGEST '<handle>', a prefix "composed only by the door's own C++"; it admits "exactly one INSERT or CREATE TABLE statement" (the CREATE form is spec 050/051's staging and create/replace). A client cannot author it: "every door wraps client text as ACL SESSION '<handle>' <text>, so an embedded ACL INGEST … is mid-statement garbage". The load is one statement, so a refusal on row N stores nothing, and the row count is cross-checked against the rows the stream delivered.

Instance identity (spec 045 addendum): a door holds a weak_ptr<DatabaseInstance> to its opener; a stop from another instance is refused (belongs to another database instance).

6. Accepted risks and known limits​

Each item below is written down in a spec as accepted, pending or open. None is a data leak; two are name leaks bounded to an already-granted principal.

  • Binder errors can name physical columns for an unknowable shape (spec 065, accepted). For "a bare alias, every table function", a misconfigured grant or a drifted source mapping "still surfaces duckdb's binder error, which names physical columns … What leaks is names, never data: RLS and masks are not bypassed, and only a principal already holding a grant on that object can provoke it - an admin-misconfiguration surface, not an outsider's." Declared shapes refuse cleanly (acl_rewrite: object "orders" exposes no readable columns, pinned in test/sql/acl_listing_truth.test), and "declaring the shape upgrades any given object to clean refusals". A mask whose column is gone surfaces duckdb's Column "ssn" in REPLACE list not found (spec 038). The named compensating control is acl_check_catalog (spec 039): run by the administrator, off the query path, it probes every stored fact against the source and names what no longer holds - the dead declared column included - with the repair each finding wants; the tables listings mark a broken object rather than describe a narrower one.
  • A write-path binder error cites the physical leaf table name (spec 052, accepted). An INSERT/UPDATE naming a nonexistent column gets duckdb's Table "orders_physical" does not have column …: the principal "already holds a write capability on this exact object, learns only its unqualified leaf name (never the catalog/schema/database), and only by deliberately naming a column that does not exist".
  • A plan names physical objects - by grant (spec 052). EXPLAIN under the explain capability shows phys.schema.table; "that a principal who may run a query also learns where it lands is acceptable behavior, but it belongs to a role that was granted it". A SECURE view as the physical object (duckdb 2.0) keeps the plan to the view's one name: recommended behind any relation a role reads with explain (spec 052, addendum 2026-09-18). It does not keep the statistics of a view that only projects: those pass through since duckdb #25969, and our row-level predicate is outside the view.
  • stats() answers over rows the role cannot see (spec 052, addendum 2026-09-18). Over a RENAME or an RLS subquery - and over a secure view that only projects - duckdb's stats(<column>) reports the physical table's min/max and distinct count. It is in the grant-only meta category (spec 072): never in a role's default, and a grant of meta is the operator saying that role may read facts about the data. That grant is the control.
  • Upsert against an invisible row (spec 020). Under RLS a MERGE may insert beside a row the principal cannot see; where a unique constraint exists "the failure does disclose that some row with that key exists outside the principal's slice … a deployment that cares should not grant merge on a table whose key space is shared across tenants".
  • Concurrency under load is proven the way spec 043 asked for (implemented 2026-09-04). Isolation is by construction - the override receives no ClientContext, "which is why identity rides in the statement text"; each session executes under its own lock on its own connection - and it is exercised two ways. In process, on every build: four writers of two tenants, an auditor with a hidden column and a thread bumping the policy version under them, then six threads interleaving two principals' prefixes on one shared connection (test/cpp/test_acl_concurrency.cpp, under ASan/UBSan in CI; test/sql/acl_interleave.test). Through the door, with real sources (make test-e2e): on each leg two clients stream 20 000 rows apiece while a reader ticks through the load (zero foreign rows on every tick, and a count that is all or nothing - an ingest is one statement), a fourth client is SIGKILLed mid-load (its rows stored all or not at all, the door answering afterwards), and a postgres × ducklake leg joins across sources under the load. Every assertion is by id, never by the computed tenant. The exercise found and fixed one listing bug (an RLS-only relation listed without its columns, invisible to a client that builds its catalog from them).
  • An ingest is atomic per statement, not per stream (spec 042). A session that expires mid-stream "ends the drain with an error and a partially written table". The fence keys on two scan names; "if quack ever drains a stream by another name, an unprefixed write runs natively again".
  • Foreign syntax is refused under the prefix (spec 067). A co-loaded extension's peeled statement is an opaque ExtensionStatement: "an AST we cannot enumerate is an AST we cannot confine". It works only under ACL NATIVE (passthrough). An unprefixed foreign query "runs without the ACL - the same property every unprefixed query has" (spec 017).
  • A grammar extension's syntax is confined, not refused (spec 132). duckdb's grammar extensions are opted into per connection (SET active_grammar_extensions = [...], LOCAL only). The text after the prefix parses with that connection's grammar. A statement the extension builds from ordinary AST is therefore walked like any other: virtual names resolved, the grant's RLS and projection applied, a physical name refused. One that yields an ExtensionStatement is refused as above.
    • The grammar is the connection owner's choice (gateway, door, operator), never the principal's: SET active_grammar_extensions and SET current_dialect are refused under every prefix, a session's own included.
    • An active extension can change what ordinary SQL means, since a transform may replace a core rule's. Activate only extensions the node trusts, on the connections that need them.
  • The function gate is default-deny by category (spec 072): a newly loaded extension's functions are refused until the operator puts them in a category; quack's functions and arrow_scan are in the never set, which no grant re-opens.
  • The listing narrows while a declared object is dead. Verified today (section 7): after the source drops a mapped column, SELECT * fails with the binder error above while information_schema.columns lists the remaining columns. specs/BACKLOG.md (pre-release): "The listing marks a broken object instead of narrowing it". Likewise "an object whose mask cannot be applied is unreadable but still listed" (spec 038).
  • manage is trusted-operator territory (spec 009): its physical targets are unrestricted; "a physical allowlist per scope is possible future work".
  • Management and native SQL through the doors are the session's scope, exactly (spec 009 addendum, 2026-09-03; test_acl_session.cpp): no scope is refused both, manage administers but is refused ACL NATIVE and cannot grant itself passthrough, and passthrough runs native SQL including the node's control surface - that is the operator path over a door, by decision.
  • No audit trail yet (blocker 3, decided in the release): no event per engine decision exists today.
  • Memory mode (no policy catalog) is the dev stub: the anonymous hatch is unconditionally open there (spec 009), and it cannot serve, list or read a JWKS.
  • enums = varchar hides a domain from readers, not from writers (spec 099). A role that may write the ENUM column is told the labels by duckdb's own conversion error (Could not convert string 'x' to ENUM('a', 'b')) - writing a value is a way to test labels anyway - and its RETURNING reads the physical row, ENUM type included. A role that holds no write capability sees neither. The casts are facts probed where the object is written (nothing on the query path reads the source): a source retyped since is reported as types_stale, and ANALYZE VIRTUAL TABLE probes it again. A live schema alias, a table function and the memory store are not cast: they expose the source's types as they are.
  • RETURNING on a refused row names the grant, not the row - deliberately, "a row identifier in the message … would leak the row to the principal" (spec 024).
  • Sessions are per node. There is no shared session backend; a cluster front routes by cookie.

7. The live-alias decision: what happens when the source grows​

This is policy an operator must read. It was verified on today's build (build/release/duckdb, 2026-09-03) by adding and dropping physical columns and tables under every object form, and it agrees with specs 014/026/029/037/038/065.

A "live" alias is live and total. A virtual table that is a plain alias (CREATE VIRTUAL TABLE c.orders AS phys.main.orders, no COLUMNS) read through a whole-table grant (no COLUMNS on any grant in the chain) shows a column added to the physical source later - ssn_backup appeared in SELECT * and in information_schema.columns for the role the moment it was added, with nobody granting it. That is what live means, and spec 014 states it at the schema level: an alias schema "resolves through the prefix, live: a table added physically is visible at once, and nothing can be excluded". Verified: a table created in the source after CREATE VIRTUAL SCHEMA c.raw AS phys.main was readable as raw.newtab and listed at once.

A mask is by name. ssn = NULL protects the column named ssn and knows nothing about an ssn_backup added later. Whether the new column is visible depends only on whether a column list is in force somewhere in the chain:

object formgrantcolumn added to the sourcecolumn dropped from the source
plain aliaswhole-table (no COLUMNS)appears, ungrantedthe object adapts (the rest is readable)
plain aliasCOLUMNS (id, tenant, ssn = NULL)invisible - not listeda bare name intersects away (spec 038); a masked name that vanished refuses with duckdb's Column "ssn" in REPLACE list not found
plain aliasCOLUMNS (ssn = NULL) (mask only)invisible - the list is the masked column alonerefuses, as above
declared object (COLUMNS (id = pk, tenant = internal_tenant, ssn = ssn_raw))anyinvisible until declaredthe object refuses, it does not narrow - with duckdb's Referenced column "ssn_raw" not found in FROM clause! Candidate bindings: "ssn_backup", "internal_tenant" (spec 065 probe A, the accepted name leak); information_schema.columns meanwhile lists id, tenant
alias schema (AS)whole-tablea whole new table appears and is listedthe table is gone
expansion schema (FROM)whole-tableinvisible (no access to object "cur.newtab") until ALTER VIRTUAL SCHEMA c.cur REFRESH, which reported 1 and made it readableREFRESH PRUNE removes the record (spec 014)

Three consequences, stated as the decision:

  1. A whole-table grant on a live alias is a grant on the source's future. A catalog-wide mask such as ssn = NULL does not cover an ssn_backup added later; with a whole-table grant that column is readable in the clear. A schema alias likewise exposes every table the source grows.
  2. For sensitive objects, declare the shape. Give the object an explicit COLUMNS (…) list (or grant with a COLUMNS list): a new physical column is then invisible until it is declared, and a dropped one makes the object refuse rather than quietly narrow. Note what "refuse" means here: the refusal is duckdb's binder error and names physical columns (the spec 065 accepted risk) - it is fail-closed on data, not name-tight. The name-tight refusal (acl_rewrite: object "orders" exposes no readable columns) is the empty-intersection case on a declared object. Repair is the administrator's, by rewriting the object's list (ALTER VIRTUAL TABLE … SET COLUMNS): a refresh (acl_refresh_schema / ANALYZE VIRTUAL CATALOG, spec 010) re-probes verdicts and derived schemas but does not heal a declared list - healing it automatically would drop a mask silently, which spec 038 refuses. The check-and-repair procedure is spec 039: CHECK VIRTUAL CATALOG finds the dead entry, REPAIR VIRTUAL TABLE … REMAP (…) mends a column that moved, … DROP MISSING COLUMNS removes one that is gone - and refuses while an object grant masks it, unless told AND MASKS, which removes the mask by name and counts it.
  3. Expansions record objects; aliases do not. CREATE VIRTUAL SCHEMA … FROM is "a snapshot plus edits"; new source tables need REFRESH, an excluded object stays excluded, and this is the only way to exclude a single table (spec 014: "nothing about this is dynamic-with-exceptions").

8. The error contract​

Every refusal the extension raises carries a prefix that names its subsystem, then a message that names virtual facts only: "every message a principal can provoke names only what the principal's own catalog shows - virtual names, virtual columns, the acl prefix" (spec 065). The exceptions are the accepted-risk cases in section 6, where duckdb's own binder error - not ours - surfaces physical names to an already-granted principal.

Prefixes in use (grep -rhoE '"acl[a-z_ ]*:' src/ | sort | uniq -c, 2026-09-03), and what each marks:

prefixraised bywhat it marks
acl_rewrite:the prefix scanner (ParserException), principal resolution (… token rejected: …, … role verification failed, `… session unknownexpired
acl admin:the management grammar and its authorization in acl_parser_override.cpp, the admin functions, the catalog writersa management statement or admin function was malformed or not authorized (a bare ACL ADMIN is disabled - …)
acl catalog:acl_policy_catalog.cppthe policy-catalog backend: malformed caps/claim JSON, a failed catalog read/write/transaction, the read-only function driver
acl:the drain fence (a streamed insert carries no principal, so it would be written outside the policy (…)), door runtime refusals returned as arrow::Status (node is draining - not accepting new sessions, transaction and ticket errors, the password handshake), the multi-relation predicate check, join-expression checks, internal errors (this build's std::random_device is deterministic, …)the door or the store refused something that is not one statement's rewrite; also the description text of every acl_* setting, which the grep counts
acl_flight_serve:, acl_quack_serve:, acl_flight_stop:, acl_quack_stop:the serve/stop functions (RefuseUnlessServable substitutes the function name)a door could not be opened or closed: preconditions, TLS arguments, an occupied uri, belongs to another database instance
acl_define_issuer:, acl_use_functions:that functionits own argument validation
acl oidc secret:src/oidc/acl_oidc_secret.cppthe client-side OIDC secret provider (spec 061), not the node
acl_quack_scan_data:the embedded door's draininternal (no active stream)

Two counted matches are not prefixes: acl embedded door: is the description of the embedded server's settings, and acl_references takes at most one argument: … / acl_keys takes … are Deny() bodies that reach a client as acl_rewrite: acl_references takes ….

What a client may rely on:

  • the prefix is stable enough to match on: a message beginning acl_rewrite: is a policy refusal of the statement, one beginning acl admin: is a management refusal, one beginning acl: came from a door or the store;
  • a refusal names the virtual object (no access to object "cur.newtab", select on "audit_log" is not allowed, statement type CONNECT is not permitted under ACL) and never data;
  • through the Flight door a refusal arrives as the gRPC status the door chose (Unavailable for draining, Invalid, KeyError, …) with the same text; through quack, an authentication or authorization refusal is quack's own "Authentication failed" / "Authorization failed" (spec 041).

The rule (release plan 4.4): the prefix is the contract; the exception class says which kind of thing went wrong; the wording after the prefix is not promised. A client matches on the prefix and, if it needs more, on a keyword (no access, not allowed, read-only, draining) - never on the whole sentence. duckdb prints the class in front of every message:

classprinted aswhen
BinderExceptionBinder Error:a refusal or a policy/configuration problem: every acl_rewrite: and acl admin: refusal, acl catalog:, a door precondition (acl_flight_serve: …), an occupied uri
InvalidInputExceptionInvalid Input Error:the shape of an argument or a token: a NULL where a value is required, malformed JSON, the per-row write check
IOExceptionIO Error:the environment, not the policy: a certificate or key that could not be read or parsed, a listen address that could not be bound (acl_quack_serve: failed to bind …, acl_flight_serve: …)
ParserExceptionParser Error:the prefix itself (acl_rewrite: …) or the statement text after it does not parse

A token that does not verify is refused as acl_rewrite: token rejected: <reason> on the prefix path (ACL TOKEN '…'); a door never sees that text - acl_session_open answers NULL and acl_quack_authenticate false, and acl_session_reason says why a session is gone.

9. Hardening checklist​

Before a node serves anyone:

  • Nothing but the gateway or the doors can open a connection to the DuckDB instance. Every unprefixed statement is unrestricted; this is the perimeter.
  • allow_parser_override_extension is STRICT (set on load; verify nobody set it before loading, and nobody runs SET GLOBAL … = 'DEFAULT').
  • A policy catalog is configured (acl_use_db); memory mode is the dev stub.
  • acl_allow_anonymous_admin is off once the first passthrough admin exists; the doors refuse to open while it is on.
  • The audit goes somewhere that outlives the node (acl_audit_sink, or a registered sink) and acl.audit.dropped is watched; the load report and /metrics answer only an observe token (spec 097) - acl_observe_unauthenticated stays off unless the listener's address is private.
  • Admin scopes are minimal: catalog-scoped manage for operators of one catalog, passthrough for the platform only - it is "the actual god mode" and the only scope that runs ACL NATIVE.
  • Sensitive objects declare COLUMNS (…); no whole-table grant sits on a live alias whose source may grow (section 7). Schema aliases are used only where "everything, forever" is intended; otherwise expand with FROM and REFRESH deliberately.
  • Explicit-only capabilities are granted by name and reviewed: create, drop, temp, explain, manage. EXPLAIN shows physical names to whoever holds explain.
  • merge is not granted on tables whose key space is shared across tenants (spec 020).
  • Every loaded extension's functions have been placed in a category or deliberately left out: SELECT * FROM acl_function_status() WHERE status = 'uncategorized' is the review list (spec 072); a name left there is refused, which is the safe side.
  • Grants' predicates and projections carry a true verdict (acl_relations(), acl_object_grants() show rls_checked); run acl_refresh_schema with every source attached (spec 027).
  • Doors leave the machine only over TLS (acl_flight_serve(uri, cert, key); acl_quack_serve(uri, token, cert, key)), or mode := 'plain' sits behind a TLS-terminating proxy on purpose. The quack server token is set explicitly.
  • Session bounds fit the deployment: acl_max_sessions, acl_session_idle_timeout (never 0 under connect binding), acl_session_token_binding (every_use where revocation latency matters), acl_max_ingest_rows where a ceiling is wanted.
  • Issuers and clients (spec 095): every client names the audiences its tokens carry; a client of an IdP you do not run is UNMAPPED IGNORE (the explicit default) and maps what it grants; a role that administers is mapped FROM CLIENT only; acl_jwks_max_stale is 0 if a failed key read must be fatal at once; acl_jwks_locations names the origins keys may be read from (the default admits https only; a local directory is listed by name - spec 071).
  • Stopping a node follows spec 066: acl_drain() → watch the count → acl_session_kill for stragglers → acl_flight_stop / acl_quack_stop → close duckdb. Stop the Flight door before closing the instance.
  • The accepted risks of section 6 are known to whoever owns the policy: physical column names in a binder error reach only an already-granted principal, and only on shapes the catalog does not know.

Unverified​

Listed so nobody takes them for facts:

  • The "drift matrix" that spec 065 rests on is in the local, gitignored design/BACKLOG.md ("probed 2026-08-21"); section 7 re-ran it on today's build and reports what that run showed. Not probed: DML under drift, a catalog-level alias (which does not exist).
  • Spec 043's harness assertions are taken from the spec text; the run itself was not repeated here.
  • The gRPC status classes per Flight refusal are read from src/flight/acl_flight_door.cpp; not every status was enumerated.
  • Spec 028 (DML in a CTE) was not read for this document.