Files
restloom/docs/security.md
T

3.1 KiB

Security and hooks

restloom decides what can be asked. Your backend decides who may ask it, through hooks.

What restloom enforces

  • Exposure: only the schemas and tables you name are served, and only their visible columns.
  • Bound values: every value is a bound parameter, checked against its column's type before any SQL runs. Names come from the catalog and are quoted; nothing from a request is written into SQL.
  • Bounded requests: every request is limited:
Limit Default Option
Rows per page 50, at most 1000 limits.page, limits.maxPage
Relations walked by a path or an embed 2 limits.depth
Filters, every or/and term included 50 limits.filters
Items in one in list 500 limits.inList
Rows in one write body 1000 limits.bodyRows
Statement time 5 s limits.timeoutMs

A request over a limit is refused, never quietly cut down.

  • Errors: they answer stable codes. A database message never reaches the client. Unexpected errors answer internal and reach your onError hook.

Hooks

createREST({
    db,
    expose: { schemas: ['app'] },
    hooks: {
        authorize(request, target) { /* throw a RestError to refuse */ },
        async transaction(tx, context) { /* session settings, role */ },
        scope(resource, context) { return { tenant_id: context.tenantId }; },
        onError(error, request) { log.error(error); },
    },
});
Hook Runs Use it to
authorize(request, target) before any SQL, with { resource, action } or { procedure, action: 'call' } refuse a request: throw new RestError(403, 'forbidden')
transaction(tx, context) first, in every transaction set the role and session settings row security reads
scope(resource, context) for every resource a query touches, embeds and relation filters included add equality filters, e.g. a tenant column, on databases without row security
onError(error, request) for every answer of internal log it

context is what your adapter builds from the request, usually the authenticated user. restloom never reads it.

Row security

With Postgres, row security is the strongest perimeter: the database itself hides the rows a session may not see. Connect as a role without BYPASSRLS, and set what your policies read in the transaction hook:

async transaction(tx, context) {
    await tx.query("SELECT set_config('app.user_id', $1, true)", [context.userId]);
}

Every read and write of the request then runs under those policies, embeds and relation filters included.

Scope

On a database without row security, scope keeps a request inside its perimeter. It returns column values a row must have, and restloom applies them:

  • Reads: every query on the resource, and every relation reaching it, only matches rows with those values.
  • Inserts: the scoped columns are filled in. A body giving another value is refused with out_of_scope.
  • Updates and deletes: only rows with those values are reached. An update moving a row out of scope is refused.