Skip to content

Security model

MatterData enforces access control, request protection and input validation in the runtime, once, for every application. You declare policy. The engine decides how to apply it.

Every route carries the roles that may open it, and an absent rule is a denial rather than a gap. Four separate locks hold this:

  • A missing rule decides “no”, with the reason no access rule (default deny).
  • The compiler refuses to build a route table for a page that declares no rule.
  • Route registration fails on a route with no rule, so an unguarded route cannot reach the server.
  • One guard runs before every handler. There is no second way in.

A rule that names no role admits nobody. The engine’s own routes carry rules in the same vocabulary, so the access matrix covers /matterdata/login, the administration page, and an image URL exactly as it covers your pages.

A role is held in one application. An organization can hold several, so authenticated admits a login that has a role in your application, and nobody else. A login with a role only in the calendar is anonymous in the club application, for every rule including the bare one.

The organization’s own administration page at /matterdata carries the reserved role tenant_admin, which the engine matches against a flag on the login. That flag grants nothing inside an application: for every route and every role, the decision with the flag set equals the decision with it clear. Read Who administers an organization.

Navigation asks the same question the guard asks. A person does not see a link to a page they cannot open.

A refusal does not tell a stranger what exists

Section titled “A refusal does not tell a stranger what exists”

Two rules decide which status code you get, and they are not interchangeable:

  • A record you may not see answers 404, byte for byte the same as a record that does not exist. The engine never uses 403 here, because 403 would confirm the record.
  • 403 means “signed in, wrong role”, and it applies to a page rather than a record. It does tell the reader the page exists. That is the intended trade, because their colleague uses it.

Exports follow the page. A CSV or calendar file is a parameter on the page’s own address, under the page’s own rule, so there is no separate export permission to forget.

A row rule is not a filter applied to a result. It is part of the WHERE clause of the statement that reads the record, so a record you may not see is never read.

The same holds on the way out. An update or a delete composes four conditions into one statement:

  1. the record’s own id
  2. the actor’s row filter
  3. the region’s own where: condition
  4. the relationship proved by earlier route parameters

/courses/5/students/99 therefore answers 404 when pupil 99 is not in course 5, and it is the same 404 as a pupil who does not exist. A statement that affects no row is a 404, never a 403.

The order below is deliberate, and two steps in it are the reason a rule cannot be raced:

  1. Every submitted value is parsed to its column’s type, with the column’s bounds.
  2. A candidate record is built from the stored row, the defaults and the submission.
  3. set values are resolved into the candidate.
  4. References, unique(...), check and require … when are evaluated against the candidate.
  5. The transaction opens.
  6. deny when is evaluated inside the transaction. Two people signing up for the last place cannot both pass a capacity check.
  7. A before hook runs, still inside the transaction.
  8. Everything the hook changed is validated again, against the same rules and by the same code that checked the submission.
  9. The write runs, and the transaction commits.

Two details worth knowing when you write rules. A required field is checked against the fields this actor was actually offered, not against the table. And an empty value never makes a check fail: a check refuses only when it is false, and a require … when applies only when its condition is true.

The record id, the four audit columns, a slug, an unguessable, a lifecycle state, a one-hop scope column and every part of an img field are engine-owned. The runtime derives each from its own source: the clock, the signed-in actor, a cryptographic random generator, or the session.

A request body that names one of these is dropped and logged at warning level, and the rest of the save proceeds. The name is not refused, because refusing would tell the sender which names exist. The control is structural rather than a check: the set of names a body may carry is built by the same function that draws the controls, so the form and the parser cannot disagree.

Protection Value
Password hashing Argon2id, 64 MiB memory, 2 passes, 4 lanes
Lockout 5 failed sign-ins, then locked for 15 minutes
Sign-in rate limit 20 per minute per tenant and client, 60 per minute per client
Session cookie HttpOnly, SameSite=Lax, Secure and __Host- outside development
Session lifetime 30 days, with a 12 hour idle timeout
CSRF checked in the guard for every mutating route, by HTTP method
Request body 2 MiB, or 64 MiB for an upload, refused with 413
Upload quota 512 MiB per organization, shared by every application it holds
Content Security Policy default-src 'self', no unsafe-inline
Other headers nosniff, Referrer-Policy: same-origin, X-Frame-Options: DENY

CSRF is keyed off the route’s HTTP method rather than off the template, so a new mutating route cannot exist without the check. SQL values are always bound parameters. A column name that comes from a request control, such as ?sort=, is resolved against the region’s own column list and never reaches SQL as text.

Lockout and the rate limits are held in memory. They do not survive a restart and they do not span several machines.

The applications of one organization share a session

Section titled “The applications of one organization share a session”

An organization can hold several applications, each under its own first path segment. They are one host and therefore one origin. Every cookie the engine sets carries Path=/, and the __Host- prefix outside development forces it.

Two consequences are worth planning around:

  • A public site and an internal one belong in two organizations, not in two applications of one. Two tenants are two files, two sessions and two addresses.
  • An application cannot read another application’s tables, and neither can a hook. A table name in ctx.db is resolved against the model of the application the hook belongs to.

Read An organization, its applications and one login.

Marking a column sensitive changes four things at once:

  • It is never in a default column set, so it cannot reach a listing or a CSV file by accident. Showing one takes two deliberate acts.
  • A role sees it only through grants: table.column on its access rule. Without the grant the column is dropped from the query, not refused, because a refusal would announce it.
  • In a form, an ungranted column has no control and no name a request can use. A crafted body naming it is ignored and logged.
  • On an update, a column that no control offered keeps its value. Opening a record without the grant and saving cannot blank a grade.

A calculated column that reads a sensitive one must carry the flag itself, and the compiler refuses one that does not. The compiler follows the chain to a fixed point. Aggregation is not anonymization: an average over one record is that record’s value.

An application scope narrows; it does not authorize

Section titled “An application scope narrows; it does not authorize”

An application-wide scope puts every page of a request in one term, class or season. It is resolved once per request and held in the session. No query can leave it out, because the condition is applied by a type that refuses to produce SQL for a query that resolved no scope.

A query that finds no scope value fails loudly. It does not bind an empty value, which would return no rows and look like real data.

Two compiler refusals protect this. A scope reached through an optional reference is refused. A record with an empty hop lands in no scope at all, and it then disappears with nothing to report it. A public page may not read a scoped table either, because an anonymous request has no session and therefore no scope.

A hook runs with the application’s authority

Section titled “A hook runs with the application’s authority”

This is the one place where the engine deliberately gives your code more reach than the person in front of it.

This is intended. A treasurer who cannot see a member must still be stopped from deleting that member’s fee group, and a hook that counted only visible records would answer “none in use” and permit a delete that the database then refuses.

Two obligations follow, and they are yours:

  • Never use a hook’s result to decide what a person may see.
  • Treat anything a hook writes into a message, a mail or an amended column as disclosed. On a write by a logged-in person, ctx.mail.send takes any recipient string you hand it. On a write by a visitor with no login, it takes only the address of one of the organization’s logins, so a public form cannot be used to send mail to anybody.

Everything else about a hook is narrow. It cannot reach the engine’s own tables, which is where password hashes and live sessions are. It cannot compose a SQL statement, only ask for the rows of a named table. It has no filesystem and no outbound HTTP. Its budget per call is 100 milliseconds of wall clock, 50 database calls, 5 mails for an after hook, and 10,000 rows per query.

Two limits of the sandbox are worth stating plainly. A single call into a built-in function can overrun the time budget, measured at 7 times over. The memory allowance is sampled rather than enforced, and a per-organization memory limit is not possible in the current JavaScript engine.

An image is authorized by the record that owns it

Section titled “An image is authorized by the record that owns it”

An image address contains the table, the field, the record and the content digest. The runtime asks whether a record of that table, with that id, carrying that digest, is one this person may see. It asks your access rules, on every request.

Guessing a digest gets you nothing, because the digest is not the credential. A record you may not see gives the same 404 as any other unknown address. The content type served is the one the engine produced when it re-encoded the upload, never a claim from the uploader.

A reader’s time zone never reaches a query

Section titled “A reader’s time zone never reaches a query”

A person can choose the time zone their pages are drawn in. That choice changes rendering and nothing else. today stays relative to the application’s zone, so two readers of one page always get the same records. A calendar feed keeps the application’s zone, including its ETag, so a shared event cannot recur at a different instant for each subscriber.

One exception is worth knowing. On a page declared timezone: user, the reader’s zone decides which instant a wall-clock time they type means. That is the feature, and it is the only place a reader’s own setting reaches stored data.

Three things need your discipline, and this list is short on purpose.

  1. Write fields: on every form. A form without it takes every ordinary writable column. That default is correct today. A column you add to the table next year, however, joins every defaulted form with no warning.
  2. Treat a hook as application code, per the section above.
  3. An unguessable on a public route is a bearer credential. Anybody holding the address holds the access. The engine rate-limits guessing at 30 misses per minute, per organization and client, and that limit is in memory only.

Your tenant database holds an mtd_audit table that the runtime writes and no application can declare, read or drop. It is in your own file, so it is in your backup, it travels with you, and your administrator can query it with any SQLite tool.

Each event carries the time in UTC, the class, the actor and their role as it was at the time, the route pattern, and the records the act touched. The actor is the identity the request resolved, never a second derivation of it.

An event of an application also names that application. An event of the organization — a sign-in, a lockout — names none, because it belongs to no application. A person refused a page of an application they hold no role in is anonymous for the decision and still named on the event. Authorization and attribution are two questions, and one value for both would give either a leak or an unattributable refusal.

These classes are recorded today:

Class What it records Kept for
auth.login, auth.logout who signed in and out 365 days
auth.invitation_accepted a recipient finished account setup 365 days
auth.lockout an account locked by failed attempts 365 days
access.denied a signed-in person refused a page or a record 90 days
row.transition a lifecycle move, with the state it came from 3 years
scope.switch somebody changing which term or class they are working in 365 days
write.ignored_names a request body that named columns it may not write 90 days
hook.denied, hook.failed a hook refused a capability, or one that failed 90 days
sensitive.read a granted sensitive column read, with the records returned 3 years
sensitive.write a granted sensitive column written 3 years
admin.authority an act on a login: created, disabled, enabled, its password replaced, its sessions ended, the administrator flag set or cleared forever
admin.command an operational act on the administration page: a job marked due, the nightly reset switched on, off or re-timed, its template replaced, a reset marked due 365 days
job.run one occurrence of a scheduled job, with its outcome, duration, records written and images removed 365 days

admin.command is a separate class from admin.authority because it answers a different question with a different lifetime. “Who may do what here” is asked years later by somebody who was not there. “Why did the digest go out twice at 11:04” is asked while the answer still matters, and it arrives every time somebody presses a button.

A successful reset of a demonstration organization writes no job.run event. The log it would be written into is the file being replaced. A failed reset does write one, because that file survives and is where an operator asks why.

admin.authority is kept forever because it produces a handful of rows a year, and the question it answers is asked years later by somebody who was not there. A refused act records nothing, because the event is queued after the act succeeded. The grant-admin and revoke-admin commands record nothing either: there is no session at a command line, so there is no actor to name.

Three properties are worth knowing because they shape what you can ask of the log.

A retention is a maximum, and the sweep enforces it. The audit log records who read what and when, so it is personal data itself. An unbounded audit log is a liability rather than a thorough one. A class cannot be added to the engine without a retention: the build fails.

An event never fails the act. If the audit row cannot be written, the request still succeeds and the failure is reported to the operator. The engine does not refuse a read because it could not record it.

An anonymous refusal is not recorded. access.denied covers signed-in people only. An unknown visitor asking for addresses that do not exist would otherwise let a stranger grow your database by asking.

A read of a granted sensitive column writes one record per page view per column, carrying the ids the page actually returned. A listing of 25 records with one sensitive column writes one audit record, not 25.

That granularity is what lets the log answer the question an audit exists for: which teachers saw this pupil’s accommodation last term? A record per request with no ids could not answer it, and a record per row would outgrow your application’s own data.

Three cases differ, and each is deliberate:

  • An export has no page window, so it records the number of rows written and no ids. “She exported all 340 grades at 14:12” is the honest description of one deliberate act.
  • A crosstab aggregates, so the rows are inside the aggregate and their ids are not knowable. The record says who read the column and when, with no rows. Aggregation is not anonymization: a cell computed over one record is that record’s value.
  • An empty page records nothing. Nobody’s data was read.

A write records one entry per column, against the record it changed. A role without the grant never writes the column — its value is preserved — so saving that form records no write.

MatterData is in development. Two gaps matter to anybody evaluating it for real data.

A hook reads sensitive columns regardless of grant, as described above, and that read is not recorded. The audit log covers what a person reads through a page, an export or a form. It does not cover what your own business logic reads with the application’s authority. The decision on whether to narrow this is open.

Deploy and rollback are not yet recorded. Two classes are reserved for them and wait for the deployment commands.

Read Project status before you put regulated data into a MatterData application.