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>')andacl_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 opaqueExtensionStatementrefused 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; atDEFAULT"noACL …statement parses" - loud, not silent - andacl_use_db/acl_use_functionsrefuse 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
selectgates every read position: "Every read position routes throughRewriteTableRef(subqueries,EXISTS/IN, CTE bodies,INSERT … SELECTsources, DMLFROM/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
selectbefore 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: "DeclaringCOLUMNSon 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
WHEREand 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 UPDATEand bothMERGEbranches 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"; aMERGEcarries it on theONclause and on everyWHEN NOT MATCHED BY SOURCEbranch (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
CREATEandDROPand the rewriter decides: "only tables and views can be created through the ACL"; a table lands in a granted physical home under the explicitcreatecapability,DROPneedsdrop, andCREATE OR REPLACEis priced atcreate+dropbecause "REPLACE is a drop" (specs 016/051). A plainCREATErefuses 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 (
aliasform, 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 VIEWis "resolved once, with its author's rights, and reading it is decided by the grant on the view" (spec 018). CREATE TEMP TABLEneeds the explicittempcapability; a temp name is resolved "virtual-first" and a miss "keeps today'sDeny("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:
- the never set, in code:
acl_*("otherwise one statementSELECT 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 ownACL INGESTstatement),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; - the key: a bare name resolves among the members of that name and kind -
system.mainfirst, thensystem.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; - 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;
- the admitted call is emitted qualified to its key -
lower(x)reaches the binder assystem.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 098duckdb_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.testpins that an ungranted table "is in none of them, under any name" and thatduckdb_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 (withselect; 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'sTABLE(…)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 thesystemcatalog 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 holdsmeta(whereacl_function_statusstill listsduckdb_functionsas a member); before spec 098 ametagrant 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>becomesDESCRIBE (SELECT * FROM <name>), so it describes the rewritten relation (spec 025);SHOW TABLES,SHOW DATABASES,SHOW SCHEMAS,SHOW ALL TABLESanswer from the listing;SHOW VARIABLESis refused;PRAGMA table_info/PRAGMA show_tablesare answered throughDESCRIBEand 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 spellingscurrent_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 -
VALUESrows 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/UNPIVOTgo 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'sCREATE 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, "soinformation_schema.columnsdescribes what the role reads" (spec 026).duckdb_tables().sqlis never NULL andis_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 patha.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 answeringOrdersandorders), 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_version20: 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 itsDEFAULT, generated column andCHECKgated like any expression). A type of another database - a userCREATE TYPE, an ENUM whose labels are the operator's - is refused before bind withtype "<db>.<schema>.<t>" is not available: only the system catalog's types can be named(reasontype_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_strictforms) take a constant structure only, every type in it gated the same way. A bare name resolves insystem.mainonly, so a same-named type of an attached database never shadows a built-in. The built-ins, their aliases,JSONand the anonymous constructors (STRUCT,LIST,MAP,UNION, an inlineENUM('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 withoutmeta.- 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;keepexposes 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 asVARCHAR(varchar). An ENUM's type carries its whole domain: whoever sees the column sees every label, whatever RLS leaves visible -varcharis the control, andacl_check_catalogreportsenum_domain_exposedwhere 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, andacl_check_catalognames 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).
- an extension's alias type (
Fields of structured columns (spec 102)
- A grant may narrow a
STRUCTcolumn 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. RETURNINGrefuses a narrowed column: the stored struct still holds the hidden fields.
- A hidden field is kept (
- 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,WHEREandONsee 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 anUPDATE … SET visible = hiddencopied a hidden column out. - A
MERGE'sUPDATE SET */UPDATE BY NAMEis refused under a column policy (it bypassed the policy before).
Statements (AclRewriter::RewriteStatement, src/acl_rewriter.cpp)
| statement type | under a principal |
|---|---|
SELECT, INSERT, UPDATE, DELETE, MERGE INTO | rewritten (sections above) |
EXPLAIN [ANALYZE] | needs the explicit explain capability on the MAIN grant, then the inner statement is rewritten (spec 052) |
CREATE, DROP | tables 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 |
PRAGMA | table_info, show_tables answered; the rest denied by name (spec 031) |
SET / RESET | TimeZone, Calendar only, session scope, constant value, only on an ACL SESSION connection (spec 068) |
BEGIN / COMMIT / ROLLBACK | pass through: "they are session control, not access" |
| anything else | acl_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 globalmanage/passthroughrow inacl.admins. "Amanagescope never leaves the virtual catalog (ACL NATIVEispassthroughonly) and never hands out scopes." - The client writes the marker after the principal:
ACL <mgmt>needsmanageorpassthrough;ACL NATIVE <sql>- "plain SQL outside the virtual catalog - god mode" - needspassthrough. "Choosing a marker grants nothing by itself." - "A catalog-scoped
manageedits its catalogs' content but may not runGRANT|REVOKE CATALOGorALTER GRANT" - handing out access is privilege administration;acl_grant_admin/acl_revoke_adminrequirepassthrough: "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 ADMINhatch "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
managereally is: "may expose, through C, anything the DuckDB instance can reach - the grammar'sphystargets 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,policyandpassthrough(spec 009's globalmanagereads aspolicy+observe; a catalog'smanagecapability is that catalog's administration) are held side by side; every check asks the set ("may administer" = a bundle that writes, a catalog'smanageor a point grant on a platform function - neverobservealone), 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). Onlypassthroughgrants onplatformand grants the bundles - a grantor that can grant everything is everything; never to every role, never ongrant_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) andconsole_info(every holder's). A deny wins over every bundle butpassthrough. - A metadata answer is bounded, never cut (spec 119): above
acl_metadata_max_rowsthe 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 SOURCEof its role - handed out bypolicyorpassthrough; the physical tree it sees underplatform.attachedis 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
passthroughmay 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_catalognames 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'smanagefor a role a mapping reaches arepassthrough's. - A view as another role sees no more than that role, and reads no rows (spec 120):
SET SESSION VIEW AS ROLEispolicy's orpassthrough'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.DESCRIBEbinds 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 sameacl_*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, orCALL 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 oneadminaudit 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 isno 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
adminsrow. And mapping a claim to a role that administers ispassthrough's alone, as granting the bundle is.
- otherwise whoever may write the object could write their own
- 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 platformis refused, and the v21 migration refuses while a catalog carries it. - Absent on the quack door: quack loads a catalog whole at
ATTACH;platformis 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
GRANTthere can only mean ours. Data DDL (CREATE TABLE,DROP TABLE,ALTER … RENAME) and duckdb'sCOMMENT ON TABLE/ANALYZE/CREATE FUNCTIONare 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
| capability | where it is granted | in the unstated default? | gates |
|---|---|---|---|
select | catalog, schema, object grant | yes | every read of a relation; every virtual function call (specs 003/012) |
insert, update, delete, merge | catalog, schema, object grant | yes | the DML verb on a RENAME-form target; predicate-checked per row (spec 024) |
manage | catalog grant ({"manage": true}) | no | ACL <mgmt> over that catalog; never GRANT CATALOG / ALTER GRANT / admin scopes (spec 009) |
create, drop | schema (or catalog) grant | no | `CREATE TABLE |
temp | MAIN catalog grant | no | CREATE TEMP TABLE and temp-name resolution on a session connection (spec 050) |
explain | MAIN catalog grant | no | EXPLAIN [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 passthrough | acl.admins via acl_grant_admin | - | everything, including ACL NATIVE; the only scope that grants bundles and grants on platform |
bundle observe | acl.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.<view | function>` | platform_grants via `GRANT VIEW | FUNCTION 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 onplatform(grant or deny) or themanagecapability on a catalog (spec 117) - is reached only through the client's own explicit mapping, never an issuer-wide mapping,UNMAPPED AS ROLEorROLES CONSTANT, judged at use; aCONSTANTattribute 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):
SessionOpennow merges role-default claims exactly asACL TOKENdoes, so the same token answers the same slice through a door and through a gateway.acl_jwt_clock_skewis GLOBAL, like every sibling setting. acl_session_token_binding='connect'(default) judgesexpat 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 aconnection_changedpolicy 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_closemint, compose and end; all three areacl_*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_stateis the client's own context, not a credential. It carries the session'sUSE, 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
USEof a catalog the principal does not hold is refused, and a setting goes through the same allowlist and validators as aSET. - 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.
- On a node that does not know the session, every item is judged again as if the client sent it now: a
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 intest/sql/acl_listing_truth.test), and "declaring the shape upgrades any given object to clean refusals". A mask whose column is gone surfaces duckdb'sColumn "ssn" in REPLACE list not found(spec 038). The named compensating control isacl_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/UPDATEnaming a nonexistent column gets duckdb'sTable "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).
EXPLAINunder theexplaincapability showsphys.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 withexplain(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'sstats(<column>)reports the physical table's min/max and distinct count. It is in the grant-onlymetacategory (spec 072): never in a role's default, and a grant ofmetais 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
MERGEmay 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 grantmergeon 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 isSIGKILLed 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 byid, never by the computedtenant. 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 underACL 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 anExtensionStatementis refused as above.- The grammar is the connection owner's choice (gateway, door, operator), never the principal's:
SET active_grammar_extensionsandSET current_dialectare 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 grammar is the connection owner's choice (gateway, door, operator), never the principal's:
- 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_scanare 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 whileinformation_schema.columnslists 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). manageis 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,manageadministers but is refusedACL NATIVEand cannot grant itselfpassthrough, andpassthroughruns 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 = varcharhides 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 itsRETURNINGreads 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 astypes_stale, andANALYZE VIRTUAL TABLEprobes 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.RETURNINGon 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 form | grant | column added to the source | column dropped from the source |
|---|---|---|---|
| plain alias | whole-table (no COLUMNS) | appears, ungranted | the object adapts (the rest is readable) |
| plain alias | COLUMNS (id, tenant, ssn = NULL) | invisible - not listed | a bare name intersects away (spec 038); a masked name that vanished refuses with duckdb's Column "ssn" in REPLACE list not found |
| plain alias | COLUMNS (ssn = NULL) (mask only) | invisible - the list is the masked column alone | refuses, as above |
declared object (COLUMNS (id = pk, tenant = internal_tenant, ssn = ssn_raw)) | any | invisible until declared | the 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-table | a whole new table appears and is listed | the table is gone |
expansion schema (FROM) | whole-table | invisible (no access to object "cur.newtab") until ALTER VIRTUAL SCHEMA c.cur REFRESH, which reported 1 and made it readable | REFRESH PRUNE removes the record (spec 014) |
Three consequences, stated as the decision:
- A whole-table grant on a live alias is a grant on the source's future. A catalog-wide mask
such as
ssn = NULLdoes not cover anssn_backupadded later; with a whole-table grant that column is readable in the clear. A schema alias likewise exposes every table the source grows. - For sensitive objects, declare the shape. Give the object an explicit
COLUMNS (…)list (or grant with aCOLUMNSlist): 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 CATALOGfinds the dead entry,REPAIR VIRTUAL TABLE … REMAP (…)mends a column that moved,… DROP MISSING COLUMNSremoves one that is gone - and refuses while an object grant masks it, unless toldAND MASKS, which removes the mask by name and counts it. - Expansions record objects; aliases do not.
CREATE VIRTUAL SCHEMA … FROMis "a snapshot plus edits"; new source tables needREFRESH, 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:
| prefix | raised by | what it marks |
|---|---|---|
acl_rewrite: | the prefix scanner (ParserException), principal resolution (… token rejected: …, … role verification failed, `… session unknown | expired |
acl admin: | the management grammar and its authorization in acl_parser_override.cpp, the admin functions, the catalog writers | a management statement or admin function was malformed or not authorized (a bare ACL ADMIN is disabled - …) |
acl catalog: | acl_policy_catalog.cpp | the 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 function | its own argument validation |
acl oidc secret: | src/oidc/acl_oidc_secret.cpp | the client-side OIDC secret provider (spec 061), not the node |
acl_quack_scan_data: | the embedded door's drain | internal (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 beginningacl admin:is a management refusal, one beginningacl: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 (
Unavailablefor 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:
| class | printed as | when |
|---|---|---|
BinderException | Binder 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 |
InvalidInputException | Invalid Input Error: | the shape of an argument or a token: a NULL where a value is required, malformed JSON, the per-row write check |
IOException | IO 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: …) |
ParserException | Parser 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_extensionisSTRICT(set on load; verify nobody set it before loading, and nobody runsSET GLOBAL … = 'DEFAULT'). - A policy catalog is configured (
acl_use_db); memory mode is the dev stub. -
acl_allow_anonymous_adminis off once the firstpassthroughadmin 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) andacl.audit.droppedis watched; the load report and/metricsanswer only anobservetoken (spec 097) -acl_observe_unauthenticatedstays off unless the listener's address is private. - Admin scopes are minimal: catalog-scoped
managefor operators of one catalog,passthroughfor the platform only - it is "the actual god mode" and the only scope that runsACL 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 withFROMandREFRESHdeliberately. - Explicit-only capabilities are granted by name and reviewed:
create,drop,temp,explain,manage.EXPLAINshows physical names to whoever holdsexplain. -
mergeis 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
trueverdict (acl_relations(),acl_object_grants()showrls_checked); runacl_refresh_schemawith 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)), ormode := '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(never0underconnectbinding),acl_session_token_binding(every_usewhere revocation latency matters),acl_max_ingest_rowswhere 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 mappedFROM CLIENTonly;acl_jwks_max_staleis0if a failed key read must be fatal at once;acl_jwks_locationsnames 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_killfor 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.