docs: every write example states its status; security explains sensitive columns and how to serve one
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
/** The docs pages whose examples are conformance cases, and the options every database runs them with. */
|
/** The docs pages whose examples are conformance cases, and the options every database runs them with. */
|
||||||
export const PAGES = ['query-grammar.md', 'writes.md'];
|
export const PAGES = ['query-grammar.md', 'writes.md', 'security.md'];
|
||||||
|
|
||||||
const PROCEDURES = {
|
const PROCEDURES = {
|
||||||
'todos/{todo}/complete': { function: 'demo.complete_todo' },
|
'todos/{todo}/complete': { function: 'demo.complete_todo' },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ restloom decides what can be asked. Your backend decides who may ask it, through
|
|||||||
## What restloom enforces
|
## What restloom enforces
|
||||||
|
|
||||||
- **Exposure:** only the schemas and tables you name are served, and only their visible columns.
|
- **Exposure:** only the schemas and tables you name are served, and only their visible columns.
|
||||||
|
- **Sensitive columns:** a column whose name looks like a secret is hidden without being asked, see below.
|
||||||
- **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.
|
- **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:
|
- **Bounded requests:** every request is limited:
|
||||||
|
|
||||||
@@ -21,6 +22,45 @@ 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.
|
- **Errors:** they answer stable codes. A database message never reaches the client. Unexpected errors answer `internal` and reach your `onError` hook.
|
||||||
|
|
||||||
|
## Sensitive columns
|
||||||
|
|
||||||
|
restloom reads every column name when it reads the catalog. A name made of a word such as `password`, `hash`, `salt`, `secret`, `token`, `api_key`, `private_key`, `otp`, `mfa` or `recovery`, alone or between underscores, marks the column as sensitive: `password_hash` and `reset_token` are hidden, `tokenizer` is not. The full list is in [Columns](./discovery.md#columns).
|
||||||
|
|
||||||
|
A hidden column is not served at all. It cannot be read, filtered, sorted or written, `select=*` leaves it out, and naming it answers as a column that does not exist, so a client cannot even learn it is there:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /users?select=name,password_hash
|
||||||
|
```
|
||||||
|
|
||||||
|
Answer: 400
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "unknown_column", "message": "password_hash" }
|
||||||
|
```
|
||||||
|
|
||||||
|
The check runs on names, so it guards against the column nobody thought about when the table grew. It does not know what a column holds: hide a sensitive column with a harmless name yourself, with `hide`.
|
||||||
|
|
||||||
|
To serve a column the check hides on purpose, name it in `show`. It then behaves as any other column, and is writable only if `write` lists it:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
resources: {
|
||||||
|
files: { show: ['sha256_hash'] },
|
||||||
|
users: { hide: ['phone'] },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`sensitive` replaces the whole pattern, for every resource. Start from `DEFAULT_SENSITIVE` to add words rather than lose the defaults:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createREST, DEFAULT_SENSITIVE } from '@crudy/restloom';
|
||||||
|
|
||||||
|
createREST({
|
||||||
|
db,
|
||||||
|
expose: { schemas: ['app'] },
|
||||||
|
sensitive: new RegExp(`${DEFAULT_SENSITIVE.source}|(^|_)(iban|ssn)(_|$)`, 'i'),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Hooks
|
## Hooks
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ POST /tags
|
|||||||
[{ "id": 7, "name": "a" }, { "name": "b" }]
|
[{ "id": 7, "name": "a" }, { "name": "b" }]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Answer: 400
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "error": "mixed_columns" }
|
{ "error": "mixed_columns" }
|
||||||
```
|
```
|
||||||
@@ -68,6 +70,8 @@ POST /todos
|
|||||||
{ "name": "book dentist", "status": "open", "pinned": false }
|
{ "name": "book dentist", "status": "open", "pinned": false }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Answer: 400
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "error": "not_writable", "message": "pinned" }
|
{ "error": "not_writable", "message": "pinned" }
|
||||||
```
|
```
|
||||||
@@ -101,6 +105,8 @@ A row still referenced by another is not deleted:
|
|||||||
DELETE /tags/3
|
DELETE /tags/3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Answer: 409
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "error": "still_referenced" }
|
{ "error": "still_referenced" }
|
||||||
```
|
```
|
||||||
@@ -109,6 +115,8 @@ DELETE /tags/3
|
|||||||
DELETE /todos/1
|
DELETE /todos/1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Answer: 405
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "error": "method_not_allowed" }
|
{ "error": "method_not_allowed" }
|
||||||
```
|
```
|
||||||
@@ -130,6 +138,8 @@ Answer: 200
|
|||||||
DELETE /steps
|
DELETE /steps
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Answer: 400
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "error": "filter_required" }
|
{ "error": "filter_required" }
|
||||||
```
|
```
|
||||||
@@ -144,6 +154,8 @@ POST /tags
|
|||||||
{ "id": 1, "name": "duplicate key" }
|
{ "id": 1, "name": "duplicate key" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Answer: 409
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "error": "duplicate" }
|
{ "error": "duplicate" }
|
||||||
```
|
```
|
||||||
@@ -154,6 +166,8 @@ POST /steps
|
|||||||
{ "id": 9, "title": "Call the clinic", "todo_id": 99, "priority": 1, "due_on": "2026-04-01" }
|
{ "id": 9, "title": "Call the clinic", "todo_id": 99, "priority": 1, "due_on": "2026-04-01" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Answer: 409
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "error": "reference_missing" }
|
{ "error": "reference_missing" }
|
||||||
```
|
```
|
||||||
@@ -170,6 +184,8 @@ POST /todos/3/complete
|
|||||||
{ "note": "bought a new one" }
|
{ "note": "bought a new one" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Answer: 200
|
||||||
|
|
||||||
```json
|
```json
|
||||||
3
|
3
|
||||||
```
|
```
|
||||||
@@ -180,6 +196,8 @@ A function returning a set answers an array of its rows. File taxes belongs to A
|
|||||||
POST /tags/home/todos
|
POST /tags/home/todos
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Answer: 200
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{ "id": 2, "name": "file taxes", "status": "open", "owner_id": 1, "estimate": 1, "pinned": true, "created_at": "2026-01-05T09:30:00+00:00" },
|
{ "id": 2, "name": "file taxes", "status": "open", "owner_id": 1, "estimate": 1, "pinned": true, "created_at": "2026-01-05T09:30:00+00:00" },
|
||||||
@@ -193,6 +211,8 @@ POST /todos/3/complete
|
|||||||
{ "why": "too busy" }
|
{ "why": "too busy" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Answer: 400
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "error": "unknown_argument", "message": "why" }
|
{ "error": "unknown_argument", "message": "why" }
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user