fastify-auto-sqlapi 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_BACKEND.md +28 -1
- package/BREAKING_CHANGES.md +75 -0
- package/CHANGELOG.md +55 -0
- package/README.md +140 -42
- package/dist/bin/generate-schema.js +9 -1
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +2 -0
- package/dist/lib/cli/schema-codegen.d.ts +7 -0
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +25 -0
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +57 -14
- package/dist/lib/read-access.d.ts +25 -0
- package/dist/lib/read-access.d.ts.map +1 -1
- package/dist/lib/read-access.js +47 -0
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +31 -11
- package/dist/lib/table-helpers.d.ts +16 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +55 -1
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/cli.d.ts.map +1 -1
- package/dist/types/join.d.ts +9 -0
- package/dist/types/join.d.ts.map +1 -1
- package/docs/adr/0009-schema-exclusion-config-blacklist.md +47 -0
- package/docs/adr/0010-joins-do-not-run-route-guards.md +100 -0
- package/docs/adr/0011-join-fields-allowlist.md +95 -0
- package/docs/adr/README.md +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# 0010. A declared join is a read grant — joins do not run the target table's route guards
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-07-30
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
`onRequests` and `operations` are configured per table, which reads as "this table's
|
|
9
|
+
protection". They are not. Both are properties of the *routes generated for* a table:
|
|
10
|
+
`mergeOnRequests` composes the global and per-table hooks into `fastify.route({ onRequest })`,
|
|
11
|
+
and `operations` decides which routes get registered at all.
|
|
12
|
+
|
|
13
|
+
A join is resolved inside the query of the **host** table's route. `searchEngine` validates a
|
|
14
|
+
join alias against the host's `allowedReadJoins` and nothing else — it never looks up the
|
|
15
|
+
target's `operations`, and no Fastify hook fires mid-query. So:
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
// agent: readable by every employee
|
|
19
|
+
allowedReadJoins: [buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true })],
|
|
20
|
+
|
|
21
|
+
// user: admins only... except through the join above
|
|
22
|
+
const TableUser = defineTable({
|
|
23
|
+
...exportTableInfo(SchemaUser),
|
|
24
|
+
onRequests: [requireAdmin],
|
|
25
|
+
operations: [], // no HTTP routes at all
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`POST /search/agent` with `joinLeft: { user: { selection: 'id,email' } }` returns the emails.
|
|
30
|
+
`requireAdmin` never ran, and `operations: []` did not matter: the caller was authorized as a
|
|
31
|
+
reader of `agent`, and the join is part of what `agent` exposes.
|
|
32
|
+
|
|
33
|
+
Reviewers reach this and read it as a hole to plug. It is a boundary, and the boundary needs
|
|
34
|
+
to be stated rather than discovered.
|
|
35
|
+
|
|
36
|
+
## Decision
|
|
37
|
+
|
|
38
|
+
**Declaring a relation in `allowedReadJoins` grants read access to the target table, under
|
|
39
|
+
the authorization of the host table.** The target's own route guards do not apply and are not
|
|
40
|
+
made to apply.
|
|
41
|
+
|
|
42
|
+
Route-level controls (`onRequests`, `operations`) do not follow joins. Configuration-level
|
|
43
|
+
controls do, and are the ones to use on a join target:
|
|
44
|
+
|
|
45
|
+
| Control | Follows a join | Granularity |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `onRequests` | no | per route |
|
|
48
|
+
| `operations` | no | per route |
|
|
49
|
+
| `readExclude` | yes | per table, static |
|
|
50
|
+
| the relation's `Schema` + `selection` | yes | per relation, static |
|
|
51
|
+
| `tenantScope` + `getTenantId` | yes | per request |
|
|
52
|
+
|
|
53
|
+
To expose a table broadly while keeping some of its columns narrow, declare the relation
|
|
54
|
+
against a **trimmed copy of the Schema** and give it an explicit `selection`:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
const SchemaUserPublic = { ...SchemaUser, fields: { id: SchemaUser.fields.id, name: SchemaUser.fields.name } };
|
|
58
|
+
|
|
59
|
+
buildRelation(SchemaAgent, 'userId', SchemaUserPublic, 'id', { unique: true, selection: 'id, name' })
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Every read surface validates against the schema the relation was declared with — `selection`,
|
|
63
|
+
`filters`, `conditions`, `orderBy`, aggregations — so a field outside it is `400 Unknown
|
|
64
|
+
field`, not a hidden column. The explicit `selection` is not redundant: a relation left at the
|
|
65
|
+
default `'*'` emits a literal `SELECT *` when the target declares no `readExclude`, and the
|
|
66
|
+
extra columns are dropped only by the response serializer — they are still present on the
|
|
67
|
+
programmatic `sqlApi.search()` path.
|
|
68
|
+
|
|
69
|
+
## Alternatives considered
|
|
70
|
+
|
|
71
|
+
- **Run the target's `onRequests` when it is joined** — rejected: a Fastify request hook may
|
|
72
|
+
reply and terminate; there is no coherent meaning for that halfway through building one
|
|
73
|
+
table's query. It would also fire N times per request with no request lifecycle to attach to.
|
|
74
|
+
- **`canBeJoined?: (request) => Promise<boolean>` on `ITable`** — rejected: wrong granularity.
|
|
75
|
+
The real cases are "join yes, this column no", which a boolean cannot express, and the
|
|
76
|
+
refusal has no good answer (a `400` leaks the relation's existence; omitting the join
|
|
77
|
+
silently is indistinguishable from "no related rows"). It also adds a per-join await and
|
|
78
|
+
cannot be enforced for programmatic callers.
|
|
79
|
+
- **Make the relation's `selection` a ceiling instead of a default** — rejected: breaking, and
|
|
80
|
+
it does not solve the case it looks like it solves. A ceiling is still static, and it caps
|
|
81
|
+
only the projection: the excluded field stays filterable through `conditions`, which is the
|
|
82
|
+
bisection leak `readExclude` exists to prevent. Trimming the schema closes every surface at
|
|
83
|
+
once.
|
|
84
|
+
- **Refusing to register a table that is a join target of a less-protected table** — rejected:
|
|
85
|
+
the plugin has no way to compare two `onRequests` arrays for strictness, and ADR 0002 says
|
|
86
|
+
auth is the consumer's to model.
|
|
87
|
+
|
|
88
|
+
## Consequences
|
|
89
|
+
|
|
90
|
+
- `allowedReadJoins` must be read as a security decision, not just an ergonomics one. Adding a
|
|
91
|
+
relation widens the reachable data set for everyone authorized on the host table.
|
|
92
|
+
- Column-level protection that depends on the caller's role has no mechanism today.
|
|
93
|
+
`readExclude` is per table and static; the trimmed schema is per relation and static. The
|
|
94
|
+
known gap is a request-resolved `readExclude`, which would work without new plumbing at the
|
|
95
|
+
join sites — every path already resolves it through `dbTables[joinSchema.tableName]` — but
|
|
96
|
+
requires the generated response and filter schemas to stay the widest set and the runtime to
|
|
97
|
+
narrow. Not decided here.
|
|
98
|
+
- `tenantScope` remains the one request-derived protection that crosses joins, and is the
|
|
99
|
+
precedent for the shape any future one should take: table configuration plus a plugin-level
|
|
100
|
+
resolver, not a route hook.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# 0011. Per-relation field restriction is an allowlist, not a blocklist
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-07-31
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
[ADR 0010](./0010-joins-do-not-run-route-guards.md) establishes that a declared join is a read
|
|
9
|
+
grant, and that the only thing narrowing what a relation exposes is the schema it was declared
|
|
10
|
+
with. The pattern it documented was to hand-write a trimmed copy:
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
const SchemaUserPublic = { ...SchemaUser, fields: { id: SchemaUser.fields.id, name: SchemaUser.fields.name } };
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
That works, but the copy is derived state with nothing keeping it in sync. Schemas are
|
|
17
|
+
regenerated from the database as disposable mirrors (ADR 0007), so the copy silently drifts:
|
|
18
|
+
adding a column to `user` leaves `SchemaUserPublic` correct, dropping or renaming one leaves it
|
|
19
|
+
referencing a field that no longer exists, and neither shows up until a request fails.
|
|
20
|
+
|
|
21
|
+
The restriction belongs in the relation, where someone reading the table configuration is
|
|
22
|
+
already looking. The open question was its polarity.
|
|
23
|
+
|
|
24
|
+
## Decision
|
|
25
|
+
|
|
26
|
+
`buildRelation` takes `fields: string[]` — an **allowlist** of the target's fields reachable
|
|
27
|
+
through that relation. The relation is declared against a schema narrowed to that list, so every
|
|
28
|
+
existing check (`field in schema.fields`) enforces it without a parallel code path: `selection`,
|
|
29
|
+
`filters`, `conditions`, `orderBy`, aggregations, `aggregations.by`, and the generated request
|
|
30
|
+
and response schemas.
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true, fields: ['id', 'name'] })
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Allowlist, not a `excludeFields` blocklist, because the failure modes are not symmetric. A
|
|
37
|
+
column added to the table later is not reachable through the relation until someone adds it —
|
|
38
|
+
the new column is invisible by default, and the cost of forgetting is a `400`. Under a blocklist
|
|
39
|
+
the same event silently widens every relation that did not anticipate it, and the cost of
|
|
40
|
+
forgetting is disclosure. On a join — a grant handed out under *another* table's authorization —
|
|
41
|
+
fail-closed is the only defensible default. It also reads the same way as `selection`, which is
|
|
42
|
+
already an allowlist.
|
|
43
|
+
|
|
44
|
+
Three rules follow from making it real rather than cosmetic:
|
|
45
|
+
|
|
46
|
+
- **It must include the join field.** That column is what correlates the fetched rows with the
|
|
47
|
+
main ones; omitting it hands the caller rows it cannot match to anything. Rejected at
|
|
48
|
+
declaration, like `readExclude` refusing to hide a primary key.
|
|
49
|
+
- **It is rejected on `allowedWriteJoins`.** Write paths resolve a secondary's upsert rule by
|
|
50
|
+
looking its schema up in `upsertMap` **by identity**; a narrowed relation carries a copy, so
|
|
51
|
+
the lookup would miss and the upsert would quietly degrade to a plain insert. The narrowed
|
|
52
|
+
schema would also drop columns the caller legitimately sent.
|
|
53
|
+
- **Computed fields on the target resolve against the narrowed schema.** A computed field reads
|
|
54
|
+
whatever columns it likes; resolving it against the full table schema would leave a documented
|
|
55
|
+
way around the allowlist (`salaryBand` returning what `salary` says). One that stays inside the
|
|
56
|
+
list keeps working; one that reaches outside is a `400`.
|
|
57
|
+
|
|
58
|
+
The default `selection: '*'` is spelled out into the allowed columns on a restricted relation.
|
|
59
|
+
It used to emit a real `SELECT *`, with the extra columns removed only by the response
|
|
60
|
+
serializer — which does not run for a caller of `sqlApi.search()`. An allowlist that leaks on its
|
|
61
|
+
default is not an allowlist.
|
|
62
|
+
|
|
63
|
+
## Alternatives considered
|
|
64
|
+
|
|
65
|
+
- **`excludeFields: string[]` (blocklist)** — rejected on the fail-open argument above. It is
|
|
66
|
+
shorter to write when hiding one column out of twenty, and it would have read like
|
|
67
|
+
`readExclude`; neither outweighs a new column defaulting to exposed.
|
|
68
|
+
- **`lockSelection: true`, making the declared `selection` a ceiling** — rejected: it caps the
|
|
69
|
+
projection only. The excluded column stays reachable from `filters`, `conditions` and
|
|
70
|
+
`orderBy`, which is the bisection leak `readExclude` exists to prevent — a guard that hides a
|
|
71
|
+
value from the output while leaving it interrogable is worse than none, because it reads as
|
|
72
|
+
protection.
|
|
73
|
+
- **Making the relation's `selection` a ceiling instead of adding a field** — rejected in
|
|
74
|
+
ADR 0010: breaking, and still static.
|
|
75
|
+
- **Threading the allowlist through every validation site instead of narrowing the schema** —
|
|
76
|
+
rejected: it would leave each new read surface to remember the check. Narrowing the schema is
|
|
77
|
+
closed by construction, which is the property that made the hand-written trimmed copy work in
|
|
78
|
+
the first place.
|
|
79
|
+
- **Keeping the hand-written trimmed copy as the documented pattern** — rejected: it duplicates
|
|
80
|
+
regenerated state. It still works, and is the way out if a relation needs a shape `fields`
|
|
81
|
+
cannot express.
|
|
82
|
+
|
|
83
|
+
## Consequences
|
|
84
|
+
|
|
85
|
+
- `fields` restricts **reading** only. It is not a substitute for `readExclude`, which hides a
|
|
86
|
+
column from the owning table's own routes too, nor for `onRequests`, which is what decides who
|
|
87
|
+
may call them.
|
|
88
|
+
- It is static, like everything else that crosses a join. Role-dependent column visibility —
|
|
89
|
+
admins seeing `email`, nobody else — still has no mechanism; the candidate remains a
|
|
90
|
+
request-resolved `readExclude`, unaffected by this ADR.
|
|
91
|
+
- A relation and its write counterpart can no longer be the same `buildRelation` call when the
|
|
92
|
+
read side is restricted. Declaring two is the intended answer, not a workaround.
|
|
93
|
+
- The generated agent manifest still describes join targets by table name only, so an LLM client
|
|
94
|
+
reading it may ask for a field the relation does not expose and get a `400`. Listing a
|
|
95
|
+
restricted relation's fields in the manifest is a follow-up.
|
package/docs/adr/README.md
CHANGED
|
@@ -23,6 +23,9 @@ not by editing history.
|
|
|
23
23
|
| [0006](./0006-raw-db-errors.md) | Raw DB errors surface as 500 — no SQLSTATE→HTTP mapping |
|
|
24
24
|
| [0007](./0007-explicit-table-generation.md) | Table generation is explicit — `--all` is a migration helper, not the default |
|
|
25
25
|
| [0008](./0008-agent-client-interface.md) | Agent client interface: static grammar + runtime manifest |
|
|
26
|
+
| [0009](./0009-schema-exclusion-config-blacklist.md) | Schema exclusion is a config blacklist — excluded schemas are removed as orphans |
|
|
27
|
+
| [0010](./0010-joins-do-not-run-route-guards.md) | A declared join is a read grant — joins do not run the target table's route guards |
|
|
28
|
+
| [0011](./0011-join-fields-allowlist.md) | Per-relation field restriction is an allowlist, not a blocklist |
|
|
26
29
|
|
|
27
30
|
## When to write one
|
|
28
31
|
|
package/package.json
CHANGED