SoliDB 1.1.0: the collections you can no longer write by name
A handful of SoliDB’s own collections are not data: they hold credentials, the rules that decide who is an admin, and Lua code the server runs. Until 1.1.0 most of them could be written like any other collection by anyone with Write. 1.1.0 puts them in three tiers behind one check, makes Lua scripts write as the person who called them, and puts a login in front of the admin console. Each of these closes a real finding.
The responses on this page were captured on a throwaway server. src/storage/protected.rs, where the tiers and their error messages live, has not changed since 1.1.0.
Collections the server reads back
SoliDB keeps its own state in ordinary collections. _admins holds argon2 password hashes, _env is where the docs tell you to put provider API keys, _scripts holds the Lua source that /api/{db}/{service}/{path} executes, and _user_roles says who holds which role. Being ordinary collections, they were reachable from every path that takes a collection name from the caller: the document API, SDBQL, the driver, import.
That was found in stages. 0.34.0 stopped _env, _admins and _api_keys from being read with plain Read. 1.0.0 made creating or changing a script Admin on the dedicated endpoints, because a collection editor could publish unauthenticated handlers. But the generic document API still wrote those collections by name, so the Admin gate on the script endpoints could be walked around, and a row in _scripts was Lua running as _system. Worse, _roles and _user_roles are the authorization decision: the server trusts every row of _user_roles for the username it matches, and prefers a stored _roles definition to the built-in one. With only Write on _system, inserting one document into either made the caller an admin.
Three tiers, one decision point
1.1.0 sorts these collections by what the server does with them after they are stored:
| Tier | Collections | By name | Why |
|---|---|---|---|
| Protected | _env, _admins, _api_keys, _roles, _user_roles | Not read, not written | Credentials, and the authorization state the server reads back to decide permissions. |
| Write-protected | _scripts, _services, _triggers, _views, _graphs, _config, _rag_pipelines | Read, never written | The server executes or schedules what is in them. Writes go through the Admin-gated endpoints, which validate their input. |
| Admin-write | _jobs | Read; written with Admin only | The trigger dispatcher runs its pending rows as _system, but it is also the Soli framework’s job store. |
The second tier is readable on purpose: listing them is a documented feature — the admin UI browses _scripts. Each entry is there for what the server does with it: _services decides which scripts are routable, _triggers schedules work that runs with the _system admin identity, and a _views row names the collection a REFRESH truncates.
_jobs gets a tier of its own because denying everyone would break every Soli app: perform_later inserts into _jobs by name and the worker updates rows in place, always with an admin credential. An admin can already do anything the dispatcher can, so admitting admins gives nothing away. Plain Write stays out.
_jobs can be written by name, and only with Admin; the second tier stays shut even to admins, who use the dedicated endpoints.The enforcement matters as much as the lists. Rather than each handler re-deriving the rules, there is one function, check_write_access(name, WriteActor), and it is called by the storage getters that hand out a collection for writing. Those getters take the actor as an argument, so a handler cannot ask for a writable collection without saying who is writing: a client, with or without Admin, or the server itself. That covers the document API, SDBQL, the driver, import, truncate, blob uploads and the Lua bindings. Names are matched with or without their database prefix, so tenant:_scripts is caught too.
What a Write user sees
Take a user with the built-in editor role, which reads and writes every database but is not Admin (see the auth API for creating users and assigning roles). Its token writes an ordinary collection as before. Pointing the same request at _scripts is refused, and so is the SDBQL route to the same place:
# ana holds the editor role; $T is her JWT from /auth/login
curl -s -X POST localhost:6745/_api/database/blog/document/_scripts \
-H "authorization: Bearer $T" -H 'content-type: application/json' \
-d '{"name":"pwn","path":"pwn","methods":["GET"],"code":"return 1"}'HTTP 403
{"code": 403,
"error": "Access denied: '_scripts' is managed by the server and is not writable through this API; use the dedicated admin endpoints",
"type": "Forbidden"}INSERT {name: "pwn", code: "return 1"} INTO _scripts
HTTP 403
{"code": 403,
"error": "Access denied: '_scripts' is managed by the server and is not writable through this API; use the dedicated admin endpoints",
"type": "Forbidden"}The other two tiers answer with their own messages, so an operator reading a log can tell which rule fired:
FOR e IN _env RETURN e
HTTP 403
{"code": 403,
"error": "Access denied: '_env' stores credentials and is not readable or writable through this API; use the admin-only endpoints",
"type": "Forbidden"}curl -s -X POST localhost:6745/_api/database/blog/document/_jobs \
-H "authorization: Bearer $T" -H 'content-type: application/json' \
-d '{"status":"pending"}'HTTP 403
{"code": 403,
"error": "Access denied: '_jobs' is executed by the server and is writable only with an Admin credential",
"type": "Forbidden"}These are 403 Forbidden, not 404. The names are fixed and documented, so acknowledging them leaks nothing, and a 403 tells you what happened. An admin writing _scripts by name gets the same 403 as ana: scripts are created with POST /_api/database/:db/scripts (scripting API).
Scripts write as their caller
Closing the HTTP paths was not enough while Lua kept a side door. Script code is fixed by an admin, but a public route can be called by anyone, and a script that writes to whatever collection the request names would let that anyone pick _scripts. From 1.1.0 a script’s by-name writes are attributed to its caller, never to the server. The caller is kept in the Lua state’s app data rather than a global a script could overwrite, and it is read at call time, because a pooled state serves requests from different callers. If nothing is installed, it fails closed as an anonymous caller.
A deliberately careless public endpoint, on a service created with require_auth: false:
local c = db:collection(request.body.collection) return c:insert(request.body.doc)
# no credentials at all
curl -s -X POST localhost:6745/api/blog/public/save -H 'content-type: application/json' \
-d '{"collection":"_scripts","doc":{"name":"pwn","path":"pwn","methods":["GET"],"service":"public","code":"return os"}}'HTTP 403
{"code": 403,
"error": "Access denied: '_scripts' is managed by the server and is not writable through this API; use the dedicated admin endpoints",
"type": "Forbidden"}With "collection": "notes" the same call inserts the document, as it should. Refusals reach the client as a 403 rather than a generic script error. db:query runs under the caller’s principal too, the same one the caller would get on /cursor, and the Lua REPL carries the identity of the admin who opened it. Trigger and job scripts are the exception by design: the queue worker runs them as _system with the admin role, which is what lets them write _jobs. Scripting: database access has the full rules.
A login in front of the admin console
The admin/ console signs in to SoliDB with an administrator account and attaches that credential to every upstream call on behalf of whoever is browsing. Its routes file used to say access protection happens at the reverse proxy, but the repository ships no such proxy and the app binds 0.0.0.0. Anyone who could reach the port had the Lua REPL, user creation and database drops.
From 1.1.0 every route goes through a middleware that fails closed. Set ADMIN_UI_PASSWORD to require a login, or set ADMIN_UI_ALLOW_NO_AUTH=1 to declare that something in front of the app really does authenticate. With neither, the console answers 503 with an explanation instead of serving.
The switches, and their defaults
Everything below is off unless you turn it on. The first four arrived in 1.1.0; the rest were already in place from 1.0.0.
| Variable | Since | Default, and what setting it does |
|---|---|---|
ADMIN_UI_PASSWORD / ADMIN_UI_ALLOW_NO_AUTH | 1.1.0 | Unset: the admin console refuses to serve. One of the two must be set. |
SOLIDB_ENABLE_AI_VALIDATION | 1.1.0 | Off. Enables /_api/ai/validate, which shells out to cargo on the server host. Development hosts only; also requires a global admin. |
SOLIDB_ALLOW_GLOBAL_WEBHOOK_SECRET | 1.1.0 | Off: a job with no webhook_secret is delivered unsigned. On, it is signed with SOLI_WEBHOOK_SECRET — but the target URL is tenant-chosen, which makes it a signing oracle for the instance secret. |
SOLIDB_MAX_INTERMEDIATE_ROWS | 1.1.0 | 5,000,000. A ceiling rather than a switch: the rows one query may materialise, which bounds nested-FOR cartesian products no LIMIT applies to. |
SOLIDB_ALLOW_WEBHOOK_LOOPBACK, SOLIDB_ALLOW_INSECURE_WEBHOOK_TLS | 1.0.0 | Off: webhook URLs are SSRF-checked (loopback, RFC1918, link-local, metadata hosts), and permissive TLS for *.test / *.local is refused. Dev-only escape hatches. |
SOLIDB_DB_AUTHZ_ALLOW_WARN | 1.0.0 | Off: SOLIDB_DB_AUTHZ_MODE=warn, the per-database authorization dry run, is ignored unless this is also set. |
SOLIDB_METRICS_PUBLIC | 1.0.0 | Off: /metrics requires authentication. |
SOLIDB_LUA_FAST_MODE_UNSAFE | 1.0.0 | Off: SOLIDB_LUA_FAST_MODE is ignored, because it leaks Lua state across requests. |
SOLIDB_ALLOW_UNAUTHENTICATED_SYNC | 1.0.0 | Off: replication TCP is refused without a keyfile. For local tests. |
Flipping any of these back is a decision about your deployment, not a default to reach for when something stops working. If a 1.1.0 upgrade breaks a tool that wrote _scripts, _services or _triggers through the document API, move it to the dedicated endpoints; if it wrote _jobs, give it an admin credential, as the Soli framework already does. Security covers roles and production configuration, and every entry is in the changelog.