sqlib Docs HomeGet startedGitHub
Explanation

Security model

This page describes what sqlib actually enforces, what it deliberately does not, and why the defaults are where they are. Where a protection is designed but not shipped, it says so.

For reporting a vulnerability and for the short deployment summary, see SECURITY.md.

The two authentication legs

sqlib sits between callers and triplestores, so there are two separate questions and they have separate answers.

sqlib to a triplestore. Service credentials, supplied by environment variable, resolved per backend. Shipped and in use.

A caller to sqlib. Identity and authorization. OIDC bearer token validation with per-library and per-backend grants is enforced at the routes. A backend's graph policy then scopes which of its graphs a caller may read (see Backend graph policies). A per-query ASK gate is designed and not shipped.

What SQLIB_AUTH_MODE does

Four values. The default is disabled.

Mode Token required Effect
disabled (default) no Every request gets a synthetic full-access context. Every check exists and passes.
dry-run no A token, if presented, is verified and grants resolved; a request that would be denied is logged as would-deny and served anyway.
required yes A request without a valid token gets 401 (or 503 if the JWKS endpoint cannot be fetched, because that failure is the server's, not the caller's). A token whose kid is not in the published key set is a 401; an unknown kid refetches the key set at most once per 30-second cooldown.
anonymous no, and none is read Every request is the one principal urn:sqlib:principal:anonymous, with the grants on that principal and nothing else. Enforced like required: a check that fails is refused, a graph policy narrows, a listing hides. No issuer is needed, nobody is an administrator, and nothing is recorded at request time.

anonymous is the mode for a public deployment: beside SQLIB_READ_ONLY=true, a grant of execute on a library and query:saved on its backends lets every visitor run the seeded queries and refuses any SPARQL they typed themselves, on /sparql, in a draft, or through MCP. A grant of query:adhoc on one backend opens that backend to ad-hoc text and no other. Grants are seeded with SQLIB_AUTH_SEED_GRANTS or written by an administrator on a writable instance of the same auth store. Because every caller is one principal, SQLIB_AUTH_ALLOW_LIBRARY_CREATE=all would let anyone create a library on a writable deployment; set it to admin unless that is intended. The anonymous principal is not the authenticated sentinel, so a grant to "everyone signed in" does not reach a visitor.

Setting dry-run or required without configuring an issuer is a startup error, not a silent fallback. So is an issuer without an audience, unless SQLIB_AUTH_AUDIENCE_UNCHECKED=true says it is deliberate: with no audience the aud claim goes unchecked and a token the issuer minted for any other application is accepted.

The design decision behind all four is that the request path is the same shape in every mode. The auth plugin is registered as an onRequest hook before any route, and disabled mode decorates a full-access AuthContext rather than skipping the decoration. Nothing downstream branches on "is auth on", so an enforcement point cannot be accidentally absent in one mode and present in another.

/health, / and the protected-resource metadata (/.well-known/oauth-protected-resource and its path-suffixed forms) are public in every mode. Every 401 names that metadata in WWW-Authenticate: Bearer resource_metadata="…", which is how a client finds the authorization server (RFC 9728). /docs and the routes under it are public unless SQLIB_AUTH_PROTECT_DOCS=true. Public routes are matched by the route pattern Fastify registered (after APP_BASE_PATH), not by the raw URL, so a route such as /backends/:id/health is not public because of its last segment.

What is verified

tokenVerifier.ts validates JWTs against issuers you configure. sqlib is a resource server only: it never issues, stores or resets a credential. Only asymmetric algorithms are accepted (RS*, PS*, ES256, ES384, EdDSA); HS* is excluded so that a leaked public key cannot mint tokens. The key set comes from SQLIB_AUTH_JWKS_URI, or else from the issuer's OIDC discovery document, which must name the same issuer.

Claims become principals: the subject, each value of the configured group claim, and a sentinel matching every authenticated caller.

A person is always their issuer and sub, which never change. For display only, sqlib also keeps a label from the token: email, else preferred_username, else name, and the Access screen shows it in place of the subject. These are standard OIDC claims, but an access token carries them only when the identity provider puts them there, usually when the client asks for the email or profile scope. With none of them, the screen shows the subject. A label never decides access: emails and usernames can change or be reused, so grants stay on the subject.

What is authorized

Grants are (principal, resource, modes) over two resource kinds. A library grants read, write, execute, delete and control. Execute is deliberately independent of read, so "may run the library's saved queries" and "may read their text" are separable.

A backend grants two operations at two levels each:

Mode Allows
query:saved Run saved queries (a stored version) against the backend, and read its metadata.
query:adhoc Also run query text the caller writes, on /sparql or as a draft.
update:saved Run saved updates, and revert a patch.
update:adhoc Also run update text the caller writes, upload data, apply a patch and clear the store.

adhoc includes saved, so a grant of query:adhoc satisfies a check for query:saved. The Access screen shows the two operations as two toggles per backend: none, saved or ad hoc.

An environment grants promote (move its version pointers) and execute (run exactly the versions it points to). Library modes never imply them. See environments.md.

Library grants and backend grants are separate. execute on a library runs its saved entities, and every backend they run on needs a backend grant of its own. A defaultBackend, on a query or a library, is a UI convenience: it picks the backend a client offers first, and it grants nothing. /execute never falls back to it. A query run must name its backendId. Every check happens in one place, GuardedSparqlExecutor, which wraps every executor a caller's request obtains, so a saved update reached through /execute needs update:saved like any other.

Resolution is additive: the effective grant set is the union over all of a caller's principals. There is no ordering, no precedence and no deny rule. Composition can only widen access, which is what keeps a grant set auditable.

Enforcement is a plugin-scoped preHandler registered on twelve entity route plugins rather than a check written into each handler. The comment on entityGuard.ts gives the reason: a per-handler check is only as good as the next handler somebody adds, whereas a plugin-scoped guard covers routes that do not exist yet. Handlers still add explicit checks where the rule is not "the entity named in the path" — creation from a request body, execution, and anything reaching a second entity.

Every allow, deny and would-deny is logged as an audit event with the decision, the resource, the mode and the grant that produced it. Token claims are never serialised into the log.

What is not authenticated

/mcp has no authentication of its own. The MCP endpoint is registered on the same Fastify instance as the API, so the auth plugin's onRequest hook does cover it: under required, a request with no bearer token is refused before the transport sees it, and a token that is present is forwarded onto every app.inject call so tools run under that caller's grants. Each MCP session is bound to the caller that created it (a digest of the Authorization header, or of the verified token's issuer and subject), and a request on that session from anyone else gets 403, so a leaked session id does not hand over the session. What does not exist is per-tool authorization, and under the default disabled mode no token is checked at all: callers are told apart only by whatever Authorization header they send. packages/mcp-server/test/http-server.test.ts pins that /mcp refuses a request with no bearer under required.

The in-app assistant has no authorization of its own. Its route module registers no entity guard and calls no enforcement helper. Its own header states the position:

Unauthenticated, like /mcp beside it. That is not an oversight, it is the gap the plan names: both doors are blocked on the caller-authorization model, and until then this is a single-tenant, local-or-trusted-network feature.

The practical reading for an operator: in a default deployment, anyone who can reach the port can use both doors, and network reachability is the only access control they have. Put the server behind something that enforces your own.

The assistant does have a capability boundary of a different kind. Its tool allowlist is read-only by construction: every catalogue tool that mutates server state is absent rather than disabled, its writes go through draft tools that stage into the session for a human to save, and sparql.proxyQuery is excluded at any setting because /sparql executes UPDATEs and that would be a write channel to the user's triplestore around the draft gate. A list of forbidden name patterns exists so a newly added catalogue tool cannot quietly become assistant-reachable. execute.run is allowed, on the argument that it runs an artifact a human already saved.

Note the interaction with prompt injection: the assistant's context is fed by user-writable entity content — query text, descriptions, results — so the allowlist is what bounds the damage an instruction hidden in that content can do.

Backend graph policies

A backend can carry a graph policy: a saved SELECT that decides, per caller, which graphs of that backend the caller may read. It is the second of the three tiers the original design named. Tier 1, a grant on the backend, decides whether a caller may run anything at all; the policy then narrows what a query reads. Tier 3, a policy graph with an ASK gate per query, is not implemented.

How a policy is set

An administrator sets two fields on the backend, together:

Saving checks the policy once, so a policy that could never run is refused with a 400 instead of failing every request later. The policy must be a SELECT. It may project only ?graph, ?defaultGraph and ?namedGraph. It may declare only the two single-variable slots VALUES ?authUser { UNDEF } and VALUES ?authGroup { UNDEF }, and no LIMIT or OFFSET parameters. A backend cannot be its own policy backend, and an auth backend cannot have a policy.

An auth backend holds access data. Only administrators may query it, through any route, so the rules that decide access are not themselves readable.

How a policy is applied

For each read, sqlib runs the policy on the auth backend with the caller bound in: ?authUser to the caller's user principal IRI, and ?authGroup to one row per group principal IRI the token carried (zero rows for no groups, so a group rule matches nothing). The result becomes a dataset:

That dataset is intersected with any dataset the caller asked for (the graph picker, defaultGraphs/namedGraphs on /sparql and /execute) and with dataset edges in a query group. The intersection is sent as the SPARQL protocol parameters default-graph-uri and named-graph-uri, or as Oxigraph's dataset options, which override FROM and FROM NAMED in the query text. An empty part is sent as urn:sqlib:empty-dataset, a graph that names nothing, because no parameter would mean "the endpoint's whole dataset".

Results are cached per backend and caller for SQLIB_AUTH_POLICY_CACHE_SECONDS (default 60). A policy change on the backend clears the cache, and so does any update to an auth backend through sqlib. Any other change to the access data, such as an edit to a data graph the auth backend is hydrated from, takes effect within the cache time.

Who is not policed

Fail closed

In required mode every failure to evaluate a policy refuses the request with a 503 and a generic message: an unknown version, an unreachable auth backend, a policy that no longer passes the checks, or a result that is not SPARQL JSON. In dry-run mode the failure and the narrowing are logged as would-deny audit events, and the query runs unnarrowed.

What stays the deployment's job

To try all of this on one machine, with Rauthy issuing the groups, run just demo-graph-access (see Deploying).

The caller in saved queries

Grants decide which queries a caller may run. They cannot decide for whom a query runs: a query that takes the caller as a parameter trusts whatever value the caller sends. So any saved query may declare the slots a graph policy reads, VALUES ?authUser { UNDEF } and VALUES ?authGroup { UNDEF }, and sqlib fills them from the sign-in. The rules are in guides/publishing-tools.md ("The caller").

The fill happens in completeArgumentSets (packages/runtime), the one step that every substitution takes, on the template path and the AST path alike. It takes a three-valued caller option:

The third case is how the design fails closed. A route that forgets the caller does not run the slot open, which would match every caller; it fails. The API's own wrappers (applyExecutionArguments, invokeCallable) take the caller as a required field, so the compiler lists every call site.

No one is signed in for a full-access context (disabled mode, dry-run without a valid token), for anonymous mode, whose one principal identifies nobody, and for sqlib's own work. A door refuses such a run (401 or 403) before it starts; the zero rows are the safety net behind that refusal.

Ad-hoc text on POST /sparql is filled too. That gives nothing away: a caller who may run ad-hoc text can type any IRI into it.

Backend credentials

A Backend entity stores an authEnvKey — a name, constrained to ^[A-Z0-9_]+$. It stores no secret. The legacy username and password fields were removed from the data model, and a request carrying them is rejected by schema validation.

At execution, backendAuth.ts reads from the server's own environment:

SQLIB_BACKEND_<KEY>_USERNAME + SQLIB_BACKEND_<KEY>_PASSWORD   → Basic
SQLIB_BACKEND_<KEY>_AUTH_HEADER                               → used verbatim

Basic takes precedence over a header; absent both, the request goes out unauthenticated. One function builds the header for every server-side caller — the executor, the capability probe, the prefix service — because a second copy is a second place for a store to start answering 401.

Three properties follow. Exporting a library, copying its .nq snapshot or dumping its triplestore carries no credentials. Rotating a credential is an environment change and a restart, with no entity edit. And anyone who can execute against a backend is using those credentials, which is exactly why the unauthenticated doors above matter: the library does not hold the secret, but it holds the ability to spend it.

Two adjacent notes. The library storage backend is reserved for administrators even when a caller holds grants elsewhere, because reading it directly would bypass the entity layer.

Grants and principals are not in the library store. They live in the urn:sqlib:auth graph of an auth store of their own: one SQLite file by default, one N-Quads file, or a SPARQL dataset of its own (SQLIB_AUTH_STORE_TYPE). The SQLite store also keeps an audit trail: each change's added and removed quads, with the time, in auth_events. A backup, restore or reset of the library store leaves access control alone, and no export or library-store query reaches it. Deleting a library or a backend removes the grants that name it. And the static export bundle carries no authorization of its own: an exported bundle grants whatever the endpoint grants its caller, so a query whose safety depended on server-side scoping should not be exported.

Why ETL and the assistant default off

Both defaults are capability grants, not maturity judgements. The flag comments state it in those terms.

ETL. FEATURE_ETL and FEATURE_PLAYGROUND_ETL default false. ETL accepts arbitrary DuckDB SQL, which is a host filesystem read primitive and, with the httpfs extension, an outbound request primitive. That is the feature — a pipeline that cannot express its own extraction is not an ETL tool — and it is the risk. A capability with that blast radius should not appear because somebody upgraded.

Underneath the flag, the DuckDB instance is configured with every capability off and locked. What actually enforces something, measured against the binding in use rather than taken from documentation:

A directory allowlist is deliberately not offered, because it does not work: allowed_directories is rejected by the binding's instance config, and when set over SQL it is accepted and enforces nothing — with it set and the configuration locked, read_csv('/etc/passwd') still returns rows. A knob that appears to confine ETL to a directory but does not is worse than no knob, so filesystem access is all-or-nothing. ETL_DUCKDB_ALLOW_HTTP implies filesystem access, because DuckDB gates both on one setting. Deadlines and a memory limit are on by default.

Under required, every route that stores or runs ETL SQL needs administrator access rather than a library grant: POST /etl-jobs/preview through adminSuffixes on its route plugin, and POST /etl-jobs/:id/versions, POST /etl-jobs/:id/execute and POST /playground/etl/execute through an explicit requireAdmin in the handler (a suffix would match every method on the path, reads included). Storing a version counts, because a stored version is SQL that a later run executes. A library grant answers the wrong question — "may do anything to my own queries" is not "may read the host filesystem", and the blast radius of submitted SQL is the process and its host, not an entity. Those routes are checked by a test that mounts the real route plugins and requires a principal holding every library mode, and not administrator, to be refused — both a route added without a guard and a guard deleted from a route fail it.

Under the default disabled mode there is no principal and every request carries full access, so those checks are no-ops. The flag, not the authorization check, is what protects a default deployment.

No etl.* tools exist in the MCP catalogue, so neither an MCP client nor the assistant can reach ETL at all.

The assistant. FEATURE_ASSISTANT defaults false for three reasons stated together: it is unauthenticated, it calls a paid third-party provider, and it can reach a configured backend. The provider key is supplied by the caller per request precisely so the server does not hold one.

How caller values reach SPARQL

Argument values are never concatenated into query text.

Two paths exist and both go through the same validation. The AST path parses the query, finds the VALUES clauses whose only row is all UNDEF, and replaces that row with term nodes built from the supplied bindings. The template path, shared with the exported runtime, splices serialised terms into recorded offsets in the query text.

The security property is provided by packages/runtime/src/sparql-terms.ts, and its own header states the contract:

The security contract is validation, not sanitisation. Every value is either provably inside its SPARQL terminal production or rejected. We never try to make a dangerous value safe by rewriting it, except for the one production where the grammar defines an escape mechanism (string literals).

Concretely: an IRI is rejected if it contains any character IRIREF forbids between the angle brackets (< > " { } | ^ \ and everything below U+0021); a language tag must match LANGTAG exactly, because language tags have no escape mechanism; a string literal's four reserved characters are escaped, and nothing else is touched, so the round trip is byte-exact.

The AST path validates through the same serialiser and discards the string, so there is one definition of "is this value representable" and the two paths cannot drift into accepting different inputs.

This is a fix for a real hole rather than a precaution. The parser's generator escapes string literals but emits IRIs and language tags verbatim, so < + rawIri + > was enough to add terms to a VALUES block:

{ type: 'uri', value: 'http://e/a> <http://e/b' }
→ VALUES ?l { <http://e/a> <http://e/b> }     // two rows, valid SPARQL

The caller chose which rows a parameterised filter matched. packages/compiler/test/parser.injection.test.ts pins the fix in two ways: a table of hostile values that must be rejected, and a property-based test asserting that every string surviving validation yields exactly one row in the resulting query, counted by re-parsing the output with an independent parser.

What this prevents. A supplied argument cannot terminate its own production and reach the surrounding query structure. It cannot add rows, add clauses, close a graph pattern or append an update.

What it does not prevent. It says nothing about what the query itself is allowed to do. A saved query containing SERVICE federates; a saved update writes. POST /sparql takes arbitrary query text, subject only to a backend grant. Parameter values are safe; the query that consumes them is whatever somebody with write access to the library put there. An update goes over POST only: GET /sparql answers an UPDATE with 405, because any page a visitor opens can make their browser send a GET (a link, an image, a prefetch), and CORS stops the page reading the answer, not the request running.

A read-only deployment

SQLIB_READ_ONLY=true refuses every write to sqlib's own state: the catalogue changes by redeployment, and anything a visitor authors is kept in their browser. It is a separate axis from SQLIB_AUTH_MODE and needs no principal — see configuration for the gate and its exceptions.

It is worth stating what it is and is not, because "read-only" invites a reading it does not support. It stops sqlib storing what a visitor sends. It does not authenticate anybody, it does not stop a visitor reading the whole catalogue, and it does not decide whether a backend accepts a write — POST /sparql still passes an update through, and the store refuses it or does not, on its own terms. A deployment that wants its own stores immutable configures those stores read-only; the flag is not a substitute for that.

Browser backends. The SPA lets a visitor register a SPARQL endpoint in their own browser (localStorage), and runs those queries from the tab rather than through sqlib. Neither the endpoint, the query text, any header the visitor attached, nor the results reach the server. This is a stronger position than the inline-endpoint field it replaces, which posted the endpoint to sqlib and made the server fetch it — on a public site, a server that fetches a caller-supplied URL is a request forgery primitive pointed at whatever the host can reach. It is also why POST /backends/probes is absent from the read-only exceptions: it is that primitive by another name.

The cost is CORS. A browser can only read an endpoint that sends Access-Control-Allow-Origin, so an endpoint that answers curl can still be unreachable from the page; the SPA says so specifically rather than reporting a generic network failure.

Before you expose this

  1. Decide what SQLIB_AUTH_MODE is. The default is disabled, which means no authentication. Set an issuer and use dry-run to see what would be denied before switching to required.
  2. Do not expose /mcp or the assistant to a network you do not control. Neither has authorization of its own. If the API is reachable, so are they.
  3. If it is a demo, set SQLIB_READ_ONLY=true. Authorization is not what keeps a public deployment's catalogue intact; refusing writes is. Under disabled mode every visitor holds full access, so without this flag a public site is one where anybody may edit or delete anything.
  4. Make sqlib the only path to your stores. Firewall the SPARQL endpoints to sqlib. Any bypass path voids every authorization decision sqlib makes.
  5. Leave FEATURE_ETL, FEATURE_PLAYGROUND_ETL and FEATURE_ASSISTANT off unless you have decided the deployment can carry them. If you turn ETL on, grant no DuckDB capability you do not need, and confine the process with the container rather than with a directory allowlist that does not work.
  6. Turn off the sections you are not using. A feature flag that is off means the routes are never registered, which is a stronger statement than a 403.
  7. Check SQLIB_AUTH_PROTECT_DOCS and SQLIB_AUTH_ALLOW_LIBRARY_CREATE. /docs is public by default, and library creation is open to any authenticated principal by default.
  8. Keep backend credentials in the environment. Never put a secret in an entity field. Confirm authEnvKey names the key you think it does.
  9. Do not run two sqlib processes against one library store. Each holds its own cache and neither is told when the other writes. Back up the auth store (SQLIB_AUTH_STORE_DIR, or its SPARQL dataset) as well as the library store: they are separate. Copy a live SQLite store with sqlite3 auth.sqlite ".backup copy.sqlite", not cp, so the copy includes its WAL.
  10. Review what you export. A static bundle carries no authorization, and updates are not exportable — read queries only.
  11. List the browser origins that may call the API in SQLIB_CORS_ORIGINS. Unset, a production deployment allows none, which is right when the SPA is served from the API's own origin. A listed origin gets credentialed access; * allows any origin without credentials. See configuration.
  12. A hosted multi-tenant deployment cannot enable ETL on shared infrastructure. There is no setting that makes arbitrary SQL safe to offer to tenants who do not trust one another.