Security hardening
Quack on Demand runs tenant-supplied SQL on shared infrastructure. The defaults are tuned for first-run smoke tests, not for exposing the gateway to untrusted tenants. This page collects the isolation controls you turn on before that: node lockdown, pod security, network policy, and catalog-reader bounding. All of them are opt-in or defaulted-safe, and none change behavior for a single-tenant local deployment.
Read this alongside TLS (encrypt the FlightSQL edge), Authentication (require credentials), and RBAC (scope table access). Hardening assumes those are already in place: it protects the node process and the network, not the front door.
Threat model
The controls here address a tenant who authenticates legitimately and then sends hostile SQL: reading node-local files, attaching arbitrary databases, installing extensions, exhausting the control-plane database, or reaching the network from a node pod. They do not replace a runtime sandbox. If you run genuinely untrusted code and need a hard boundary, put each tenant in its own Kubernetes namespace and consider a sandboxed runtime (gVisor, Kata); the controls below are defense-in-depth layered under that, not a substitute for it.
Node lockdown
Node lockdown stops tenant SQL from touching the machine the node runs on. It has two layers behind one switch.
Enable it with the environment variable:
QOD_NODE_LOCKDOWN=true
(config key quack-flightsql.nodeLockdown.enabled, default false). One flag
drives both layers; the manager logs node lockdown: enabled at boot.
Edge layer (statement screen). Before a statement is routed, the edge denies these for non-superuser sessions and audits them as denials:
ATTACH/DETACH(no attaching arbitrary databases).INSTALL/LOAD(no pulling extensions at query time).SET/RESET/PRAGMAagainst protected settings (disabled_filesystems, theallow_*_extensionsandautoinstall/autoloadknobs,enable_external_access,lock_configuration,temp_directory,extension_directory,secret_directory, and the rest of the lockdown set). Benign session settings such asSET memory_limitandSET threadsstay allowed.- Local-file reads:
read_text,read_blob,read_csv,read_parquet,read_jsonand friends,glob,getenv, plusCOPY ... FROM/TO '<path>'and bare-pathFROM '<file>'replacement scans. Object-store URL literals (s3://,gs://,az://,r2://,http://,https://) stay allowed, so reading remote data throughhttpfsstill works; reading node-local paths does not.
Denied statements return a lockdown: ... error and appear in the
audit log. Superuser sessions bypass the edge
screen (operators use the REST surface, not raw node SQL).
Engine layer (settings). Before a node starts serving, Quack on Demand runs
a lockdown block that disables the settings a tenant could otherwise use to pull
in untrusted code: SET autoinstall_known_extensions = false (no fetching
extensions over the network), SET allow_community_extensions = false, and
SET allow_unsigned_extensions = false. When the tenant-db data path is an
object store, it also sets SET disabled_filesystems = 'LocalFileSystem', so the
engine itself refuses every local-file read. These are the enforcement layer
underneath the edge screen: even a statement the edge parser does not recognize
cannot install or load untrusted code, and on object-store deployments cannot
read the local filesystem.
The block deliberately does not use SET lock_configuration = true and does not
disable autoload_known_extensions: the node's query server needs to autoload
its signed built-in extensions to handle connections, and freezing configuration
outright makes nodes unable to serve. It is not a gap: the edge screen already
denies every protected-setting SET/RESET/PRAGMA for tenant sessions, so a
tenant cannot re-enable any of the disabled values in the first place.
Local vs object-store data paths. With an object-store data path both
layers hold: the engine disabled_filesystems restriction blocks local-file
access outright. With a local data path the engine must keep the filesystem
enabled (the DuckLake data lives there), so the edge screen is the guard, and it
is best-effort: it covers the common
read_* / COPY / bare-path FROM forms, but exotic quoting (dollar-quoted or
identifier-quoted paths) can still reach a local file. For any multi-tenant or
untrusted deployment, use an object-store data path so the engine layer is the
hard guard.
Lockdown is a per-deployment switch, not per-tenant. Turn it on wherever tenants
are untrusted; leave it off for a trusted single-tenant install where local-file
reads and ATTACH are legitimate.
Pod security context (Kubernetes)
On the Kubernetes backend, every spawned node pod gets a hardened security context by default (no flag needed):
- Pod:
runAsNonRoot: true,runAsUser/fsGroup=1000(envQOD_K8S_RUN_AS_USER),seccompProfile: RuntimeDefault. - Container:
allowPrivilegeEscalation: false, all Linux capabilities dropped,readOnlyRootFilesystem: true. - Writable temp:
emptyDirvolumes at/tmpand/duckdb-tmpso the JVM and DuckDB still have scratch space despite the read-only root filesystem.
The kubelet refuses to start a pod that would run as root, so this is enforced,
not advisory. If you supply a pod template, its own
securityContext fields win field-by-field: set only what you need to relax and
the rest of the hardened defaults still apply.
Change the UID fleet-wide with QOD_K8S_RUN_AS_USER. Note that all node pods
share this UID; per-tenant UIDs are not a v1 feature (put tenants in separate
namespaces if you need that separation).
The Helm chart applies an equivalent posture to the manager deployment; both are values-overridable.
Network policy (Kubernetes, opt-in)
The Helm chart ships two NetworkPolicy templates, off by default. Enable them:
networkPolicy:
enabled: true
When enabled you get default-deny ingress and egress with only the necessary holes:
- Node pods: ingress only from manager pods on the node port range; egress to DNS, the control-plane Postgres, and the object store. A tenant node cannot reach the rest of the cluster or the internet beyond those.
- Manager: ingress on the REST/UI port (20900) and the FlightSQL edge (31338); egress to node pods, Postgres, DNS, and optionally SMTP.
Tune the reachable targets through the values block:
networkPolicy:
enabled: true
nodePortRange: { from: 21900, to: 22500 }
postgres: { cidr: "10.0.0.0/8", port: 5432 } # empty cidr = any host on the port
objectStore: { cidrs: [] } # empty = 443 anywhere; tighten in prod
ingressFrom: [] # manager ingress peers; empty = anywhere
smtp: { enabled: false, cidr: "", port: 587 }
extraEgress: [] # extra egress rules for federation targets
An empty cidr/list means "allow on that port" (the safe default for a policy you
are turning on); set real CIDRs before calling it locked down. Add federation
egress targets through extraEgress.
The node-pod selector matches the managed-by: quack-on-demand label. If you
override the node pod label (QOD_K8S_POD_LABEL), update the policy selector to
match, or the node policy will not select any pods.
Catalog-reader bounding
The manager caches one DuckLake catalog reader per browsed tenant-db, each holding a small Postgres connection pool. Without bounds, a deployment with many self-serve tenant-dbs could accumulate enough idle readers to exhaust the control-plane database's connections.
Two controls keep that in check, both on by default:
- Each cached reader's pool holds zero idle connections (
minimumIdle 0, 60-second idle timeout, max 2), so an idle reader releases its Postgres connections on its own. - A background sweep evicts readers unused for 30 minutes, closing their pools. A later browse simply rebuilds the reader.
Tune the cadence and threshold:
QOD_CATALOG_READER_SWEEP_MIN=10 # how often the sweep runs (minutes)
QOD_CATALOG_READER_IDLE_EVICT_MIN=30 # idle age before a reader is evicted (minutes)
The sweep runs on every replica (the cache is per-process), independently of the HA leader. No action is needed; the defaults suit most deployments.
Recommended posture for untrusted tenants
For a gateway exposed to tenants you do not fully trust:
QOD_NODE_LOCKDOWN=true, with an object-store data path so both lockdown layers hold.QOD_ACL_ENABLED=true(RBAC) so table access is scoped.- TLS on the edge (TLS) and a real
QOD_API_KEY/QOD_SESSION_JWT_SECRET(Authentication). - On Kubernetes:
networkPolicy.enabled=truewith real CIDRs, the default pod security context left on, and one namespace per tenant if you need hard isolation.
Leave all of this off for a trusted single-tenant local install, where the defaults keep first-run smoke tests frictionless.