The manifest¶
The manifest is blastit's routing table and its whole permission model, in one
file. Default /app/blastit.toml or BLASTIT_MANIFEST; parsed strictly at boot,
so an unknown key is an error.
Top-level keys¶
| Key | Required | Meaning |
|---|---|---|
version |
yes | must be 1 |
groups |
no | group names attached to every route |
[proxy.<name>] |
no | a named broker for one external service; a grant on an undeclared proxy aborts the boot |
[group.<name>] |
no | grants several routes share; a group grants nothing until attached |
[[route]] |
yes | one sandbox: one process, one uid |
version and groups come before any [table].
Routes¶
[[route]]
name = "product-detail"
path = ['/products/[0-9]+/'] # full match against PATH_INFO
methods = ["GET", "HEAD"] # optional
groups = ["catalog"] # shared grants
prefork = true # fork at startup
egress = true # may open TCP connections
| Key | Required | Meaning |
|---|---|---|
name |
yes | the route's name, in the log and in environ["blastit.route"]; must match [a-z0-9][a-z0-9_-]*, and duplicates abort the boot |
path |
yes | a list of regexes matched against the whole of PATH_INFO, in order; one regex may be a bare string (path = ".*" is path = ['.*']) |
methods |
no | the methods it answers; without it, any method. HEAD is never implied by GET |
groups |
no | groups whose grants this route takes |
egress |
no | true lets the process open outbound TCP connections |
prefork |
no | true forks this view at startup instead of on first use |
Proxies¶
A proxy declares its protocol, the variables it owns, and the ones the app sees instead.
[proxy.analytics]
protocol = "postgresql"
input = { url = "ANALYTICS_URL" }
[proxy.analytics.output]
ANALYTICS_DB_HOST = "{{ socket_dir }}"
ANALYTICS_DB_PORT = "{{ port }}"
- Inputs —
inputmaps input slots to variable names. A missing input variable aborts the boot. - Outputs —
outputmaps names to templates. Without anoutputtable, each input variable is rewritten in place:DATABASE_URLin,DATABASE_URLout, pointing at the local endpoint. - Templates are tiny:
{{ field }}inserts a field and{{ field | urlencode }}percent-encodes it. They render once, at boot; an unknown field or filter aborts the boot. - Secrets cannot be templated: templates see the local endpoint and a few non-secret facts.
- An output may not overwrite a variable it does not own, and no two proxies may produce the same variable.
blastit.toml holds no secrets and is safe to commit.
Protocol postgresql¶
Input slots: url (a libpq URI), or the same split into host, port, user,
password, database, sslmode — not both — plus ca_cert (PEM for the
upstream TLS leg).
Template fields: the default url is
postgresql://{{ user }}@/{{ database }}?host={{ socket_dir }}&port={{ port }} —
the socket directory in a query parameter, the one form libpq,
dj-database-url and SQLAlchemy all resolve — where socket_dir is
/proc/self/cwd, each view's own socket directory, and port is 5432 + k,
k = index among postgresql proxies sorted by name. user is blastit,
which the proxy ignores.
Default outputs: every input variable is rewritten in place; sslmode becomes
disable; password and ca_cert are removed. The local leg is a Unix socket,
so libpq connects to <host>/.s.PGSQL.<port>.
Grant keys for this protocol: queries, tables, functions,
transaction_control, unrestricted.
Grants¶
A grant is per proxy; for postgresql it says which statements a route may run.
[group.session.db] # grants several routes share
transaction_control = true
queries = ["SET TIME ZONE 'UTC'"]
[[route]]
name = "restaurants"
path = ['/restaurants/.*']
[route.db.tables] # instead of listing every statement
restaurants_restaurant = { select = ["id", "slug", "name"] }
orders_order = { select = "*", update = ["status"], delete = true }
functions = ["st_distance"] # beyond the built-in safe set
| Key | Type | Meaning |
|---|---|---|
queries |
list of strings | the statements this grant allows, compared after normalisation |
tables |
table | which tables and columns the route may read and write |
functions |
list of strings | function calls allowed beyond the built-in safe set |
transaction_control |
bool | allow BEGIN/COMMIT/ROLLBACK/SAVEPOINT and friends |
unrestricted |
bool | allow every statement on this proxy. Inline on a route only |
An exact queries entry is trusted as it is: the text is compared and run,
without looking at what it calls.
Table and column grants¶
[route.<db>.tables] has one entry per table; select, insert and update
take columns or "*", and delete takes true.
A statement no exact query lists is parsed with PostgreSQL's own parser
(libpg_query) and allowed when every table, column and function it touches is
granted.
What decides real deployments:
- reading a column anywhere — a
WHERE, anORDER BY, a join condition — is reading it; - every base table a statement names must be granted, even when no column of it is read;
count(*)reads no column;pg_catalogandinformation_schemaare never readable this way: statements on the catalogue stay exactqueries;- a row lock (
FOR UPDATE,FOR SHARE, …) needs anupdateentry on every base table it locks, on any one column; - a function call must be in the built-in safe set
or in
functions(pg_catalog.xisx); casts toreg*and other types need an entry.
unrestricted¶
That route may run any statement on that proxy, transaction control included.
- Inline on a route only:
unrestrictedin a group is a boot error. - Every unrestricted (route, proxy) is a
manifest.warningat boot. - The sandbox stays: the listener, the uid check, the other proxies' grants and the credential barrier.
unrestrictedwithqueries,tablesorfunctionsis a boot error.
Effective grants¶
A route's allowlist for a proxy is the union of its own grant, its groups'
grants and the global groups'; transaction_control is on if any turns it on.
A route with no grant for a proxy gets no listener, so a catch-all fallback
cannot reach the database.
How a statement matches¶
Matching is exact, on normalised text: trimmed, a trailing ; removed,
whitespace collapsed outside quotes. The shape is normalised too:
$1,$2… become$?, so one template is one grant;- a list of nothing but placeholders after
INbecomesIN ($?, ...)at any length, so__inmatches one grant; - identical rows after
VALUEScollapse to one row and..., sobulk_creatematches one grant.
A matching Parse authorises the Bind/Execute that follow; simple query
messages get the same check. COPY, LISTEN and the function-call
sub-protocol are denied unless listed.
Two things cannot be granted: a savepoint's name (use transaction_control) and
a named cursor (QuerySet.iterator()).
Routing¶
- Routes are tried in order; the first match of
path(andmethods) wins. - A regex must match the whole of
PATH_INFO. - They use Rust
regexsyntax, matched in linear time. BLASTIT_SCRIPT_NAMEstrips a mount prefix first.- Nothing matches: the scheduler answers
404, no Python runs. A catch-all last lets the app render its own 404s. - Every decision is a
requestevent, withrouteandrule.
Validation at boot¶
Aborts the boot: an unknown key or group, a version that is not 1, a grant
on an undeclared proxy, a duplicate route name, an invalid regex or method, a
missing proxy input variable, and unrestricted in a group or next to
queries/tables/functions.
Warns and runs: a group attached nowhere, a proxy no grant uses, and an unrestricted route.
Writing one¶
Two tools write grants from evidence, instead of from memory:
- the pytest plugin — Django: it diffs what your test suite ran against the manifest and prints the table grant to paste: the plugin;
blastit-manifest fold— anything, fromsql.deniedevents: fold the log.
e2e/demo/blastit.toml is a working example.
The built-in safe function set¶
A tables grant accepts these names; anything else must be named in the grant's
functions.
Aggregates: array_agg, avg, bool_and, bool_or, count, json_agg,
json_build_object, jsonb_agg, jsonb_build_object, max, min,
string_agg, sum. Conditions: coalesce, greatest, least, nullif.
Strings: btrim, concat, left, length, lower, ltrim, overlay,
position, replace, right, rtrim, similar_to_escape, substr,
substring, trim, upper. Numbers: abs, ceil, floor, round. Dates
and times: age, date_trunc, extract, make_interval, now, timezone,
to_char. Sets and arrays: array_length, cardinality, generate_series,
unnest. Window functions: dense_rank, rank, row_number, cume_dist,
first_value, lag, last_value, lead, mode, nth_value, ntile,
percent_rank, percentile_cont, percentile_disc, stddev, stddev_pop,
stddev_samp, var_pop, var_samp, variance. JSON and full text:
json_extract_path_text, jsonb_array_length, jsonb_extract_path_text,
plainto_tsquery, to_tsvector, ts_rank, websearch_to_tsquery.
pg_catalog. is dropped before the lookup. unaccent reads a rule file, so a
grant must name it. The list is SAFE_FUNCTIONS in
crates/blastit-sqlpolicy/src/analysis.rs.