@opensaas/stack-core 0.24.0 → 0.25.0
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +223 -0
- package/dist/access/access-filter.d.ts +39 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +121 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-access.d.ts +1 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +79 -4
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +213 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/types.d.ts +39 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/types.d.ts +318 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +153 -26
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +59 -3
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +552 -129
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +91 -0
- package/dist/context/transaction-boundary.d.ts.map +1 -0
- package/dist/context/transaction-boundary.js +329 -0
- package/dist/context/transaction-boundary.js.map +1 -0
- package/dist/context/write-pipeline.d.ts +15 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +173 -10
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.d.ts +2 -0
- package/dist/fields/calendar-day.test.d.ts.map +1 -0
- package/dist/fields/calendar-day.test.js +120 -0
- package/dist/fields/calendar-day.test.js.map +1 -0
- package/dist/fields/index.d.ts +18 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +93 -17
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +116 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +154 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/validation/schema.test.js +222 -1
- package/dist/validation/schema.test.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.ts +156 -0
- package/src/access/field-access.test.ts +255 -0
- package/src/access/field-access.ts +91 -5
- package/src/access/index.ts +1 -1
- package/src/access/types.ts +45 -0
- package/src/config/types.ts +364 -0
- package/src/context/index.ts +207 -37
- package/src/context/nested-operations.ts +969 -143
- package/src/context/transaction-boundary.ts +440 -0
- package/src/context/write-pipeline.ts +234 -13
- package/src/fields/calendar-day.test.ts +140 -0
- package/src/fields/index.ts +96 -16
- package/src/hooks/index.ts +265 -0
- package/src/validation/schema.test.ts +266 -1
- package/tests/access.test.ts +24 -16
- package/tests/context.test.ts +481 -0
- package/tests/field-types.test.ts +17 -3
- package/tests/nested-access-and-hooks.test.ts +1130 -54
- package/tests/nested-operation-registry.test.ts +28 -3
- package/tests/nested-write-hooks.test.ts +864 -0
- package/tests/transaction-boundary-hooks.test.ts +465 -0
- package/tsconfig.tsbuildinfo +1 -1
package/.turbo/turbo-build.log
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,228 @@
|
|
|
1
1
|
# @opensaas/stack-core
|
|
2
2
|
|
|
3
|
+
## 0.25.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#602](https://github.com/OpenSaasAU/stack/pull/602) [`44ec937`](https://github.com/OpenSaasAU/stack/commit/44ec9375baa4dacab4e34b03cbefb27c8aec07c9) Thanks [@borisno2](https://github.com/borisno2)! - Make `calendarDay` a `YYYY-MM-DD` string end-to-end (Keystone's CalendarDay scalar)
|
|
8
|
+
|
|
9
|
+
`calendarDay` is now a `YYYY-MM-DD` **string** at the `context.db` boundary in
|
|
10
|
+
both directions, so its type, validation, and runtime value finally agree.
|
|
11
|
+
Previously the field validated a `YYYY-MM-DD` string but its TypeScript type was
|
|
12
|
+
`Date`, so a typed caller passing `new Date(...)` hit a runtime `ValidationError`.
|
|
13
|
+
- The field/read type and the generated `CreateInput`/`UpdateInput` input types
|
|
14
|
+
are now `string`.
|
|
15
|
+
- Writes accept only a `YYYY-MM-DD` string; a malformed string or a `Date` is
|
|
16
|
+
rejected at runtime by validation (a `ValidationError`).
|
|
17
|
+
- Storage is unchanged: `DateTime @db.Date` on Postgres/MySQL, the SQLite TEXT
|
|
18
|
+
fallback as before.
|
|
19
|
+
|
|
20
|
+
**Behavioral change (reads):** reading a `calendarDay` now returns a
|
|
21
|
+
`YYYY-MM-DD` string instead of a `Date`. A field `resolveOutput` transform
|
|
22
|
+
normalises the value Prisma returns from the `@db.Date` column, using UTC
|
|
23
|
+
components to avoid timezone off-by-one. Consumers that previously relied on a
|
|
24
|
+
`Date` on read should update to the string form:
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
const event = await context.db.event.findUnique({ where: { id } })
|
|
28
|
+
event?.startDate // => '2025-01-15' (string, not Date)
|
|
29
|
+
|
|
30
|
+
// Writes: pass YYYY-MM-DD strings, not Date objects
|
|
31
|
+
await context.db.event.create({ data: { startDate: '2025-01-15' } })
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- [#593](https://github.com/OpenSaasAU/stack/pull/593) [`fadd9db`](https://github.com/OpenSaasAU/stack/commit/fadd9dbd17085f4dd15899371a054ec46f943ce4) Thanks [@{](https://github.com/{)! - Nested relation writes now run the full hook pipeline inside one transaction ([#569](https://github.com/OpenSaasAU/stack/issues/569))
|
|
35
|
+
|
|
36
|
+
A record written via a nested `create`, `update`, or `delete` now fires the SAME
|
|
37
|
+
list- and field-level `beforeOperation`/`afterOperation` hooks as the equivalent
|
|
38
|
+
top-level write — so side effects (workflows, notifications, billing) are
|
|
39
|
+
identical whether a record is written nested or top-level. Previously nested
|
|
40
|
+
writes ran only `resolveInput`/`validate`/field-rules and silently skipped the
|
|
41
|
+
before/after side-effect hooks.
|
|
42
|
+
- Nested **create** runs `beforeOperation` (create) → persist → `afterOperation`
|
|
43
|
+
receiving the created `item`.
|
|
44
|
+
- Nested **update** runs `afterOperation` receiving both `originalItem` (the row
|
|
45
|
+
before) and the updated `item`.
|
|
46
|
+
- Nested **delete** runs `beforeOperation`/`afterOperation` receiving the
|
|
47
|
+
`originalItem`.
|
|
48
|
+
|
|
49
|
+
Existing access control, validation, silent-failure, sudo-bypass, and the [#578](https://github.com/OpenSaasAU/stack/issues/578)
|
|
50
|
+
nested-`connect`/`connectOrCreate` read-access + DB-reachability behavior are
|
|
51
|
+
unchanged. Pass-through nested kinds (`disconnect`/`set`/`updateMany`/
|
|
52
|
+
`deleteMany`) are out of scope and behave as before. See ADR-0010.
|
|
53
|
+
|
|
54
|
+
For to-many nested creates (`create: [{A},{B}]`), each created record's
|
|
55
|
+
`afterOperation` now fires exactly once against its OWN distinct row, recovered
|
|
56
|
+
by id-diff against the rows that existed before the write — so a pre-existing
|
|
57
|
+
sibling is never passed as the "created" item, and multiple creates no longer
|
|
58
|
+
collapse to a single row.
|
|
59
|
+
|
|
60
|
+
BEHAVIOR CHANGE — every write is now transactional, and a throwing
|
|
61
|
+
`beforeOperation`/`afterOperation` (or validation) rolls the whole write back.
|
|
62
|
+
The entire operation (parent + all nested writes) now runs inside one
|
|
63
|
+
`prisma.$transaction`, so it is atomic. Previously an `afterOperation` that threw
|
|
64
|
+
left the row committed; now it rolls back with the transaction (more
|
|
65
|
+
Keystone-correct). If you relied on a thrown `afterOperation` leaving the row
|
|
66
|
+
persisted, move that work to run after the write returns.
|
|
67
|
+
|
|
68
|
+
Inside a `beforeOperation`/`afterOperation` hook, `context.db` (and
|
|
69
|
+
`context.prisma`) are now bound to the write's transaction, so any `context.db`
|
|
70
|
+
write a hook performs participates in — and rolls back with — the same
|
|
71
|
+
transaction. Externally-visible side effects that must survive a rollback should
|
|
72
|
+
not use `context.db` from within these hooks (transaction-boundary hooks for
|
|
73
|
+
that are deferred — see [#590](https://github.com/OpenSaasAU/stack/issues/590)).
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// Nested create now fires the related list's beforeOperation/afterOperation,
|
|
77
|
+
// atomically with the parent — a throw anywhere rolls the whole write back.
|
|
78
|
+
await context.db.post.update({
|
|
79
|
+
where: { id },
|
|
80
|
+
data: {
|
|
81
|
+
title: 'Updated',
|
|
82
|
+
create: { name: 'New Author' } }, // User hooks fire; atomic
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- [#594](https://github.com/OpenSaasAU/stack/pull/594) [`4f0d407`](https://github.com/OpenSaasAU/stack/commit/4f0d40721feff1a3109647a81fcbe47db5970026) Thanks [@borisno2](https://github.com/borisno2)! - Add an opt-in **Node build** of the generated `.opensaas/` bundle (ADR-0011, [#579](https://github.com/OpenSaasAU/stack/issues/579)).
|
|
88
|
+
|
|
89
|
+
Setting `output: { buildTarget: 'node' }` in `opensaas.config.ts` makes `opensaas generate` additionally compile the bundle to a plain-Node-loadable ESM form under `.opensaas/dist/` — `.js` + `.d.ts` with a `{"type":"module"}` marker — alongside the default `.ts` bundler form. The compiled entry is `.opensaas/dist/context.js`, with the Prisma client subtree at `.opensaas/dist/prisma-client/**` and the project config compiled in as a sibling, so a live module (e.g. better-auth's Prisma adapter) can be imported in a bundler-less runtime — plain Node, a Playwright e2e helper, or a build-time script — that the default `.ts` form cannot execute.
|
|
90
|
+
|
|
91
|
+
The Node build is purely additive: with `output.buildTarget` absent (the default), generation behaves exactly as before and no `.opensaas/dist/` is emitted.
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// opensaas.config.ts
|
|
95
|
+
export default config({
|
|
96
|
+
output: { buildTarget: 'node' },
|
|
97
|
+
// ...
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// then, from a plain-Node consumer (no bundler, no tsx):
|
|
101
|
+
import { createAuth } from '@opensaas/stack-auth/server'
|
|
102
|
+
import { config, rawOpensaasContext } from './.opensaas/dist/context.js'
|
|
103
|
+
|
|
104
|
+
const auth = createAuth(config, rawOpensaasContext)
|
|
105
|
+
await auth.api.signUpEmail({ body: { email, password, name } })
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The compile runs via the TypeScript compiler API with `rewriteRelativeImportExtensions` (turning the bundle's `.ts`-extension imports into runnable `.js` specifiers), `declaration`, `skipLibCheck`, and `noEmitOnError: false`, so it reuses the bundle's type-clean guarantee without adding a build dependency. `'node'` is the only `buildTarget` today; the field is a string-literal union so future compiled targets can be added without a breaking change.
|
|
109
|
+
|
|
110
|
+
- [#592](https://github.com/OpenSaasAU/stack/pull/592) [`e355c05`](https://github.com/OpenSaasAU/stack/commit/e355c05a0787980b997609c4571271ab5c250f36) Thanks [@borisno2](https://github.com/borisno2)! - Make the generated `.opensaas/prisma-client` subtree statically resolvable by default and add a `db.prismaGeneratorOptions` passthrough.
|
|
111
|
+
|
|
112
|
+
The generated `generator client { ... }` block now emits `importFileExtension = "ts"` and `moduleFormat = "esm"` by default, so the prisma-client subtree uses explicit `.ts` import extensions and matches the extension style the rest of the `.opensaas` bundle already uses — the whole import graph is statically resolvable by a bundler out of the box, no post-generation surgery required.
|
|
113
|
+
|
|
114
|
+
A new optional `db.prismaGeneratorOptions` lets you override these values when you need a different module/extension story (e.g. emitting `.js` extensions for a plain-Node consumer). Any value you supply wins; omitted keys fall back to the `ts`/`esm` defaults. The existing `previewFeatures = ["multiSchema"]` emission (when `db.schemas` is set) is preserved and coexists with the new options.
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
export default config({
|
|
118
|
+
db: {
|
|
119
|
+
provider: 'postgresql',
|
|
120
|
+
prismaGeneratorOptions: {
|
|
121
|
+
importFileExtension: 'js',
|
|
122
|
+
moduleFormat: 'commonjs',
|
|
123
|
+
},
|
|
124
|
+
// ... rest of config
|
|
125
|
+
},
|
|
126
|
+
// ...
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- [#600](https://github.com/OpenSaasAU/stack/pull/600) [`a93cebb`](https://github.com/OpenSaasAU/stack/commit/a93cebb5a6ba6550d8cdbb94f010c902ad7e29f1) Thanks [@relationship({](https://github.com/relationship({)! - Gate nested `connect` by the owning relationship field's field-level access
|
|
131
|
+
|
|
132
|
+
Nested `connect` (and the connect branch of `connectOrCreate`) is now gated by
|
|
133
|
+
the owning relationship field's create/update field-level access, in addition to
|
|
134
|
+
the target list's read/query access and DB-reachability check. This completes
|
|
135
|
+
the Keystone-parity rule that a connect requires both read access on the target
|
|
136
|
+
AND write access on the owning relationship field. `sudo` bypasses the check.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
Post: list({
|
|
140
|
+
fields: {
|
|
141
|
+
// A non-sudo caller can only connect an author when this field's
|
|
142
|
+
// update access permits it (and the target User is readable/reachable).
|
|
143
|
+
|
|
144
|
+
ref: 'User.posts',
|
|
145
|
+
access: { update: ({ session }) => session?.role === 'editor' },
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- [#584](https://github.com/OpenSaasAU/stack/pull/584) [`b17ec45`](https://github.com/OpenSaasAU/stack/commit/b17ec45127fe55f02437892e9fd389c67373635a) Thanks [@borisno2](https://github.com/borisno2)! - Add `findFirst` to access-controlled `context.db.<list>` delegates
|
|
152
|
+
|
|
153
|
+
`findFirst` is sugar over the existing access-filtered `findMany` (`take: 1`), so
|
|
154
|
+
it introduces no new access surface: it applies the exact same query-access checks
|
|
155
|
+
and access-controlled include building as `findMany`, then returns the first
|
|
156
|
+
matching row or `null`. It honours the read-side silent-failure contract — an
|
|
157
|
+
access-denied query yields `null` rather than throwing.
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
// Non-unique single-row lookup
|
|
161
|
+
const account = await context.db.account.findFirst({
|
|
162
|
+
where: { userId: '123' },
|
|
163
|
+
orderBy: { createdAt: 'desc' },
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// Narrow the single result with a query fragment
|
|
167
|
+
const post = await context.db.post.findFirst({
|
|
168
|
+
where: { published: true },
|
|
169
|
+
query: postFragment,
|
|
170
|
+
})
|
|
171
|
+
// post: ResultOf<typeof postFragment> | null
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The CLI type generator now emits a `findFirst` method (and `<List>FindFirstArgs`
|
|
175
|
+
type) for each list in the generated `.opensaas/types.ts`, so migrated apps that
|
|
176
|
+
reach for the familiar Prisma `findFirst` pattern get full type support.
|
|
177
|
+
|
|
178
|
+
- [#601](https://github.com/OpenSaasAU/stack/pull/601) [`8f98e25`](https://github.com/OpenSaasAU/stack/commit/8f98e25fbef4ec0fc3ff0cba456ff7f2f7ba2ea8) Thanks [@borisno2](https://github.com/borisno2)! - Add `beforeTransaction` / `afterTransaction` transaction-boundary hooks (list- and field-level)
|
|
179
|
+
|
|
180
|
+
These run OUTSIDE the write's database transaction (in addition to the in-transaction `beforeOperation`/`afterOperation`), for non-transactional side effects like external API calls that must not hold a transaction open and cannot be rolled back. They fire per `(list, operation)` involved in the write (the top-level list plus each nested create/update/delete list) and form a symmetric compensation bracket: `afterTransaction` always runs when its paired `beforeTransaction` ran, receiving the outcome (`status: 'committed' | 'rolled-back'` plus `error` on rollback). On commit it gets the persisted `item` (and `originalItem` for update/delete) **only for the top-level record** — for nested lists these are `undefined`, since the per-record persisted row is not recoverable outside the transaction; use the in-transaction `afterOperation` for per-record nested compensation. On rollback it gets no `item` so it can undo what `beforeTransaction` did. `connectOrCreate` is enumerated as a best-effort create involvement (a resolve-to-connect still fires the bracket with no write), so compensators should be idempotent.
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
list({
|
|
184
|
+
fields: { name: text() },
|
|
185
|
+
hooks: {
|
|
186
|
+
// Runs before the transaction opens.
|
|
187
|
+
beforeTransaction: async ({ operation, inputData }) => {
|
|
188
|
+
await billing.reserveSeat(inputData.seatId)
|
|
189
|
+
},
|
|
190
|
+
// Always runs after the transaction settles.
|
|
191
|
+
afterTransaction: async (args) => {
|
|
192
|
+
if (args.status === 'rolled-back') {
|
|
193
|
+
// The write did not persist (args.error explains why) — compensate.
|
|
194
|
+
await billing.releaseSeat(args.inputData.seatId)
|
|
195
|
+
} else {
|
|
196
|
+
await billing.confirmSeat(args.item.seatId)
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
A throwing `beforeTransaction` aborts the write (the transaction never opens) and fires `afterTransaction` (`rolled-back`) only for lists whose `beforeTransaction` already ran. A throwing `afterTransaction` does not stop the other compensators; errors are surfaced afterward. Sudo does not affect these hooks. This is an additive, non-Keystone extension and does not change the existing `beforeOperation`/`afterOperation` semantics.
|
|
204
|
+
|
|
205
|
+
### Patch Changes
|
|
206
|
+
|
|
207
|
+
- [#603](https://github.com/OpenSaasAU/stack/pull/603) [`be9a896`](https://github.com/OpenSaasAU/stack/commit/be9a8965ad6338c279e99cfe3bf24162e63ffb92) Thanks [@borisno2](https://github.com/borisno2)! - Enforce required json fields on create: an omitted key is now rejected while any
|
|
208
|
+
present value (object, array, primitive, or null) is still accepted.
|
|
209
|
+
|
|
210
|
+
- [#583](https://github.com/OpenSaasAU/stack/pull/583) [`e39d6e9`](https://github.com/OpenSaasAU/stack/commit/e39d6e9e37be2337c8cf1979053e76877f14296c) Thanks [@borisno2](https://github.com/borisno2)! - Make non-sudo writes fail loud in `filterWritableFields` (Keystone parity).
|
|
211
|
+
|
|
212
|
+
Undeclared `data` keys on create/update now throw instead of passing through unchecked ([#564](https://github.com/OpenSaasAU/stack/issues/564)), and fields denied by field-level access now throw instead of being silently stripped ([#568](https://github.com/OpenSaasAU/stack/issues/568)). `sudo` remains the single trusted bypass; system fields and relationship foreign keys still pass through. Raw multi-column split columns (e.g. `media_url`/`media_size` from an `image()`/`file()` field) are now gated by their owning field's write access — supplying them directly under non-sudo when that field denies the write throws, instead of bypassing the field's `access.create`/`access.update`.
|
|
213
|
+
|
|
214
|
+
Behavioural narrowing: a list-level `resolveInput` hook that adds keys to `resolvedData` which are not declared fields will now be rejected by the undeclared-key throw. No production hook does this today.
|
|
215
|
+
|
|
216
|
+
- [#605](https://github.com/OpenSaasAU/stack/pull/605) [`ca4973b`](https://github.com/OpenSaasAU/stack/commit/ca4973b504eadb123d179e8f4d16d6ec8c9f8fc1) Thanks [@borisno2](https://github.com/borisno2)! - Required json fields now reject a present `null` during validation rather than failing later as a DB NOT NULL violation. Omitted keys on update are still allowed; the Prisma column nullability is unchanged.
|
|
217
|
+
|
|
218
|
+
- [#602](https://github.com/OpenSaasAU/stack/pull/602) [`44ec937`](https://github.com/OpenSaasAU/stack/commit/44ec9375baa4dacab4e34b03cbefb27c8aec07c9) Thanks [@borisno2](https://github.com/borisno2)! - Fix update validation rejecting omitted required fields under zod 4.4 by using key-optionality (`.optional()`) instead of `z.union([schema, z.undefined()])`. Partial updates that omit a required-on-create field now validate; present values still enforce their rules.
|
|
219
|
+
|
|
220
|
+
- [#587](https://github.com/OpenSaasAU/stack/pull/587) [`ecbf834`](https://github.com/OpenSaasAU/stack/commit/ecbf834059a072c428b0739d6ebcf4c74be8c893) Thanks [@borisno2](https://github.com/borisno2)! - Fix false denial of nested `connect` (and `connectOrCreate`'s connect branch): connect now requires read/query access on the target and evaluates filter results via DB reachability (`findFirst({ where: { AND: [connection, accessFilter] } })`), so nested-relation and `AND`/`OR`/`some`/`none`/`not` filters no longer always fail.
|
|
221
|
+
|
|
222
|
+
- [#589](https://github.com/OpenSaasAU/stack/pull/589) [`481d6e0`](https://github.com/OpenSaasAU/stack/commit/481d6e00be90b1159b0b30eff015e5079c840158) Thanks [@borisno2](https://github.com/borisno2)! - Fix row-level access bypass when an explicit `include` is passed to non-sudo `findUnique`/`findMany`. The caller's `include` is now merged with (not replaced by) the access-controlled include: denied relations are dropped, each relation's access `where` is AND-combined with any caller nested `where`, and nested includes are filtered at every level. Sudo and query-fragment paths are unchanged. When no access-controlled include is computed (inside a `resolveOutput`/virtual-field context, at max include depth, or for a list with no relationships), the caller's `include` is passed through unchanged rather than dropped — avoiding fail-closed data loss.
|
|
223
|
+
|
|
224
|
+
- [#586](https://github.com/OpenSaasAU/stack/pull/586) [`4622b5f`](https://github.com/OpenSaasAU/stack/commit/4622b5fa8fc731e2c8995011f1be0cfe341578da) Thanks [@borisno2](https://github.com/borisno2)! - Enforce unique-`where` for `context.db.<list>.findUnique` — a non-unique `where` now throws a clear error instead of silently returning a nondeterministic row. Use `findFirst` for non-unique single-row lookups.
|
|
225
|
+
|
|
3
226
|
## 0.24.0
|
|
4
227
|
|
|
5
228
|
### Minor Changes
|
|
@@ -26,4 +26,43 @@ export declare function buildIncludeWithAccessControl(fieldConfigs: Record<strin
|
|
|
26
26
|
where?: PrismaFilter;
|
|
27
27
|
include?: Record<string, boolean | /*elided*/ any>;
|
|
28
28
|
}> | undefined>;
|
|
29
|
+
/**
|
|
30
|
+
* Merge a caller-supplied `include` with the access-controlled include — phase-1
|
|
31
|
+
* row/relation scoping for explicit caller selections.
|
|
32
|
+
*
|
|
33
|
+
* The caller's `include` decides WHICH relations to fetch; access control decides
|
|
34
|
+
* WHETHER each relation may be fetched and WITH WHAT filter. Replacing the
|
|
35
|
+
* access-controlled include with the caller's wholesale (the bug in #566) drops
|
|
36
|
+
* every per-relation access `where` and denied-relation exclusion, silently
|
|
37
|
+
* bypassing row-level access on any non-sudo read that passes `include`.
|
|
38
|
+
*
|
|
39
|
+
* For each relation the caller asks to include:
|
|
40
|
+
* - If the relation is a config-declared relationship but is ABSENT from the
|
|
41
|
+
* access-controlled include, its `query` access returned `false` → it is DROPPED
|
|
42
|
+
* (not fetched).
|
|
43
|
+
* - If it is present (allowed, possibly with a filter), the access entry is used
|
|
44
|
+
* as the base: the access `where` is AND-combined with any caller-supplied
|
|
45
|
+
* nested `where`, and nested includes are recursively merged using the related
|
|
46
|
+
* list's field configs (so deeply-nested selections are filtered at every
|
|
47
|
+
* level). A bare caller `true` becomes the access-controlled shape (filter +
|
|
48
|
+
* nested filtered include), never bare `true`.
|
|
49
|
+
* - If the caller names a key that is NOT a config-declared relationship, it is
|
|
50
|
+
* passed through unchanged (access control does not govern it).
|
|
51
|
+
*
|
|
52
|
+
* The access-controlled include is recursive to `MAX_DEPTH` (see
|
|
53
|
+
* `buildIncludeWithAccessControl`); beyond that depth no auto-include exists, so
|
|
54
|
+
* deeper caller selections pass through unscoped — consistent with the existing
|
|
55
|
+
* auto-include behaviour.
|
|
56
|
+
*
|
|
57
|
+
* `accessControlledInclude` being `undefined` is NOT "every relation denied". It
|
|
58
|
+
* means no access-controlled include was computed at all — a non-denial outcome
|
|
59
|
+
* that `buildIncludeWithAccessControl` returns when inside a `resolveOutput`/
|
|
60
|
+
* virtual-field context, at `MAX_DEPTH`, or when the list has no relationships.
|
|
61
|
+
* In every one of those cases there is nothing to merge against, so the caller's
|
|
62
|
+
* `include` is passed through unchanged (matching the prior `args.include || …`
|
|
63
|
+
* fallback). This is distinct from an `undefined` ENTRY inside a defined access
|
|
64
|
+
* include, which DOES mean the relation was denied and must be dropped (see the
|
|
65
|
+
* per-relation loop below). Only the whole-object `undefined` is a passthrough.
|
|
66
|
+
*/
|
|
67
|
+
export declare function mergeIncludeWithAccessControl(callerInclude: Record<string, unknown>, accessControlledInclude: Record<string, unknown> | undefined, fieldConfigs: Record<string, FieldConfig>, config: OpenSaasConfig): Record<string, unknown>;
|
|
29
68
|
//# sourceMappingURL=access-filter.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"access-filter.d.ts","sourceRoot":"","sources":["../../src/access/access-filter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACtE,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAGrE;;;;;;;;;;;;;;GAcG;AAEH;;;GAGG;AACH,wBAAsB,6BAA6B,CACjD,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,OAAO,EAAE,aAAa,CAAA;CACvB,EACD,MAAM,EAAE,cAAc,EACtB,KAAK,GAAE,MAAU;YAeuB,YAAY;cAAY,MAAM,CAAC,MAAM,2BAAe;gBAkD7F"}
|
|
1
|
+
{"version":3,"file":"access-filter.d.ts","sourceRoot":"","sources":["../../src/access/access-filter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACtE,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAGrE;;;;;;;;;;;;;;GAcG;AAEH;;;GAGG;AACH,wBAAsB,6BAA6B,CACjD,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,OAAO,EAAE,aAAa,CAAA;CACvB,EACD,MAAM,EAAE,cAAc,EACtB,KAAK,GAAE,MAAU;YAeuB,YAAY;cAAY,MAAM,CAAC,MAAM,2BAAe;gBAkD7F;AAiDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,6BAA6B,CAC3C,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACtC,uBAAuB,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC5D,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,MAAM,EAAE,cAAc,GACrB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAgEzB"}
|
|
@@ -65,4 +65,125 @@ export async function buildIncludeWithAccessControl(fieldConfigs, args, config,
|
|
|
65
65
|
}
|
|
66
66
|
return hasRelationships ? include : undefined;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Narrow an unknown include value to the structured object form (vs bare `true`
|
|
70
|
+
* or any other primitive). Caller-supplied includes arrive untyped at the
|
|
71
|
+
* runtime boundary, so we validate the shape here rather than casting.
|
|
72
|
+
*/
|
|
73
|
+
function asEntryObject(value) {
|
|
74
|
+
if (value && typeof value === 'object') {
|
|
75
|
+
const obj = value;
|
|
76
|
+
const where = obj.where;
|
|
77
|
+
const include = obj.include;
|
|
78
|
+
const entry = {};
|
|
79
|
+
if (where && typeof where === 'object')
|
|
80
|
+
entry.where = where;
|
|
81
|
+
if (include && typeof include === 'object')
|
|
82
|
+
entry.include = include;
|
|
83
|
+
return entry;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* AND-combine an access `where` with a caller-supplied nested `where`.
|
|
89
|
+
*
|
|
90
|
+
* The access filter is authoritative: the caller's filter may only NARROW the
|
|
91
|
+
* result further, never widen past what access permits. We therefore wrap both
|
|
92
|
+
* in a Prisma `AND` so neither can override the other. If only one side is
|
|
93
|
+
* present, it is returned as-is; if neither is present, the result is undefined.
|
|
94
|
+
*/
|
|
95
|
+
function andWhere(accessWhere, callerWhere) {
|
|
96
|
+
if (accessWhere && callerWhere) {
|
|
97
|
+
return { AND: [accessWhere, callerWhere] };
|
|
98
|
+
}
|
|
99
|
+
return accessWhere ?? callerWhere;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Merge a caller-supplied `include` with the access-controlled include — phase-1
|
|
103
|
+
* row/relation scoping for explicit caller selections.
|
|
104
|
+
*
|
|
105
|
+
* The caller's `include` decides WHICH relations to fetch; access control decides
|
|
106
|
+
* WHETHER each relation may be fetched and WITH WHAT filter. Replacing the
|
|
107
|
+
* access-controlled include with the caller's wholesale (the bug in #566) drops
|
|
108
|
+
* every per-relation access `where` and denied-relation exclusion, silently
|
|
109
|
+
* bypassing row-level access on any non-sudo read that passes `include`.
|
|
110
|
+
*
|
|
111
|
+
* For each relation the caller asks to include:
|
|
112
|
+
* - If the relation is a config-declared relationship but is ABSENT from the
|
|
113
|
+
* access-controlled include, its `query` access returned `false` → it is DROPPED
|
|
114
|
+
* (not fetched).
|
|
115
|
+
* - If it is present (allowed, possibly with a filter), the access entry is used
|
|
116
|
+
* as the base: the access `where` is AND-combined with any caller-supplied
|
|
117
|
+
* nested `where`, and nested includes are recursively merged using the related
|
|
118
|
+
* list's field configs (so deeply-nested selections are filtered at every
|
|
119
|
+
* level). A bare caller `true` becomes the access-controlled shape (filter +
|
|
120
|
+
* nested filtered include), never bare `true`.
|
|
121
|
+
* - If the caller names a key that is NOT a config-declared relationship, it is
|
|
122
|
+
* passed through unchanged (access control does not govern it).
|
|
123
|
+
*
|
|
124
|
+
* The access-controlled include is recursive to `MAX_DEPTH` (see
|
|
125
|
+
* `buildIncludeWithAccessControl`); beyond that depth no auto-include exists, so
|
|
126
|
+
* deeper caller selections pass through unscoped — consistent with the existing
|
|
127
|
+
* auto-include behaviour.
|
|
128
|
+
*
|
|
129
|
+
* `accessControlledInclude` being `undefined` is NOT "every relation denied". It
|
|
130
|
+
* means no access-controlled include was computed at all — a non-denial outcome
|
|
131
|
+
* that `buildIncludeWithAccessControl` returns when inside a `resolveOutput`/
|
|
132
|
+
* virtual-field context, at `MAX_DEPTH`, or when the list has no relationships.
|
|
133
|
+
* In every one of those cases there is nothing to merge against, so the caller's
|
|
134
|
+
* `include` is passed through unchanged (matching the prior `args.include || …`
|
|
135
|
+
* fallback). This is distinct from an `undefined` ENTRY inside a defined access
|
|
136
|
+
* include, which DOES mean the relation was denied and must be dropped (see the
|
|
137
|
+
* per-relation loop below). Only the whole-object `undefined` is a passthrough.
|
|
138
|
+
*/
|
|
139
|
+
export function mergeIncludeWithAccessControl(callerInclude, accessControlledInclude, fieldConfigs, config) {
|
|
140
|
+
// No access-controlled include was computed (resolveOutput/virtual context,
|
|
141
|
+
// MAX_DEPTH, or a list with no relationships) → nothing to scope against, so
|
|
142
|
+
// pass the caller's include through unchanged. Dropping relations here would be
|
|
143
|
+
// fail-closed data loss, not a denial. Denied relations are dropped only when a
|
|
144
|
+
// defined access include OMITS them (handled per-relation below).
|
|
145
|
+
if (accessControlledInclude === undefined) {
|
|
146
|
+
return callerInclude;
|
|
147
|
+
}
|
|
148
|
+
const merged = {};
|
|
149
|
+
const accessInclude = accessControlledInclude;
|
|
150
|
+
for (const [relationName, callerValue] of Object.entries(callerInclude)) {
|
|
151
|
+
const fieldConfig = fieldConfigs[relationName];
|
|
152
|
+
const isDeclaredRelationship = fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && !!fieldConfig.ref;
|
|
153
|
+
// Not a config-declared relationship → access control does not govern it; pass through unchanged.
|
|
154
|
+
if (!isDeclaredRelationship) {
|
|
155
|
+
merged[relationName] = callerValue;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const accessValue = accessInclude[relationName];
|
|
159
|
+
// Declared relationship absent from the access include → query access denied → drop it.
|
|
160
|
+
if (accessValue === undefined) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const accessEntry = asEntryObject(accessValue);
|
|
164
|
+
const callerEntry = asEntryObject(callerValue);
|
|
165
|
+
// Resolve the related list's field configs so nested includes merge recursively.
|
|
166
|
+
const relatedConfig = getRelatedListConfig(fieldConfig.ref, config);
|
|
167
|
+
const relatedFields = relatedConfig?.listConfig.fields;
|
|
168
|
+
const mergedWhere = andWhere(accessEntry?.where, callerEntry?.where);
|
|
169
|
+
let mergedNested;
|
|
170
|
+
if (callerEntry?.include && relatedFields) {
|
|
171
|
+
// Recurse: scope the caller's nested selection against the nested access include.
|
|
172
|
+
mergedNested = mergeIncludeWithAccessControl(callerEntry.include, accessEntry?.include, relatedFields, config);
|
|
173
|
+
}
|
|
174
|
+
else if (accessEntry?.include) {
|
|
175
|
+
// Caller selected the relation bare (no nested include); keep the
|
|
176
|
+
// access-controlled nested include so deeper relations stay filtered.
|
|
177
|
+
mergedNested = accessEntry.include;
|
|
178
|
+
}
|
|
179
|
+
const entry = {};
|
|
180
|
+
if (mergedWhere)
|
|
181
|
+
entry.where = mergedWhere;
|
|
182
|
+
if (mergedNested && Object.keys(mergedNested).length > 0)
|
|
183
|
+
entry.include = mergedNested;
|
|
184
|
+
// A bare-`true` relation with no access filter and no nested include stays `true`.
|
|
185
|
+
merged[relationName] = Object.keys(entry).length > 0 ? entry : true;
|
|
186
|
+
}
|
|
187
|
+
return merged;
|
|
188
|
+
}
|
|
68
189
|
//# sourceMappingURL=access-filter.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"access-filter.js","sourceRoot":"","sources":["../../src/access/access-filter.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AAE/D;;;;;;;;;;;;;;GAcG;AAEH;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,6BAA6B,CACjD,YAAyC,EACzC,IAGC,EACD,MAAsB,EACtB,QAAgB,CAAC;IAEjB,MAAM,SAAS,GAAG,CAAC,CAAA;IACnB,IAAI,KAAK,IAAI,SAAS,EAAE,CAAC;QACvB,OAAO,SAAS,CAAA;IAClB,CAAC;IAED,qEAAqE;IACrE,uEAAuE;IACvE,gFAAgF;IAChF,0EAA0E;IAC1E,IAAI,IAAI,CAAC,OAAO,CAAC,qBAAqB,CAAC,KAAK,GAAG,CAAC,EAAE,CAAC;QACjD,OAAO,SAAS,CAAA;IAClB,CAAC;IAID,MAAM,OAAO,GAAiC,EAAE,CAAA;IAChD,IAAI,gBAAgB,GAAG,KAAK,CAAA;IAE5B,KAAK,MAAM,CAAC,SAAS,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,CAAC;QACpE,IAAI,WAAW,EAAE,IAAI,KAAK,cAAc,IAAI,KAAK,IAAI,WAAW,IAAI,WAAW,CAAC,GAAG,EAAE,CAAC;YACpF,gBAAgB,GAAG,IAAI,CAAA;YACvB,MAAM,aAAa,GAAG,oBAAoB,CAAC,WAAW,CAAC,GAAa,EAAE,MAAM,CAAC,CAAA;YAE7E,IAAI,aAAa,EAAE,CAAC;gBAClB,0CAA0C;gBAC1C,MAAM,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,MAAM,EAAE,SAAS,EAAE,KAAK,CAAA;gBACrE,MAAM,YAAY,GAAG,MAAM,WAAW,CAAC,WAAW,EAAE;oBAClD,OAAO,EAAE,IAAI,CAAC,OAAO;oBACrB,OAAO,EAAE,IAAI,CAAC,OAAO;iBACtB,CAAC,CAAA;gBAEF,4DAA4D;gBAC5D,IAAI,YAAY,KAAK,KAAK,EAAE,CAAC;oBAC3B,SAAQ;gBACV,CAAC;gBAED,0BAA0B;gBAC1B,MAAM,YAAY,GAA4B,EAAE,CAAA;gBAEhD,yDAAyD;gBACzD,IAAI,OAAO,YAAY,KAAK,QAAQ,EAAE,CAAC;oBACrC,YAAY,CAAC,KAAK,GAAG,YAAY,CAAA;gBACnC,CAAC;gBAED,oCAAoC;gBACpC,MAAM,aAAa,GAAG,MAAM,6BAA6B,CACvD,aAAa,CAAC,UAAU,CAAC,MAAM,EAC/B,IAAI,EACJ,MAAM,EACN,KAAK,GAAG,CAAC,CACV,CAAA;gBAED,IAAI,aAAa,IAAI,MAAM,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBAC3D,YAAY,CAAC,OAAO,GAAG,aAAa,CAAA;gBACtC,CAAC;gBAED,wBAAwB;gBACxB,OAAO,CAAC,SAAS,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAA;YACjF,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,gBAAgB,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAA;AAC/C,CAAC"}
|
|
1
|
+
{"version":3,"file":"access-filter.js","sourceRoot":"","sources":["../../src/access/access-filter.ts"],"names":[],"mappings":"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"}
|
|
@@ -33,6 +33,7 @@ export declare function checkFieldAccess(fieldAccess: FieldAccess | undefined, o
|
|
|
33
33
|
export declare function filterWritableFields<T extends Record<string, unknown>>(data: T, fieldConfigs: Record<string, {
|
|
34
34
|
access?: FieldAccess;
|
|
35
35
|
type?: string;
|
|
36
|
+
getColumnNames?: (fieldName: string) => string[];
|
|
36
37
|
}>, operation: 'create' | 'update', args: {
|
|
37
38
|
session: Session | null;
|
|
38
39
|
item?: Record<string, unknown>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"field-access.d.ts","sourceRoot":"","sources":["../../src/access/field-access.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AACxD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"field-access.d.ts","sourceRoot":"","sources":["../../src/access/field-access.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AACxD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAM7C;;;;;;;;;GASG;AAEH;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,WAAW,GAAG,SAAS,EACpC,SAAS,EAAE,MAAM,GAAG,QAAQ,GAAG,QAAQ,EACvC,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,OAAO,EAAE,aAAa,GAAG;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACpC,GACA,OAAO,CAAC,OAAO,CAAC,CAmClB;AA8BD;;GAEG;AACH,wBAAsB,oBAAoB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC1E,IAAI,EAAE,CAAC,EACP,YAAY,EAAE,MAAM,CAClB,MAAM,EACN;IACE,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,cAAc,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,EAAE,CAAA;CACjD,CACF,EACD,SAAS,EAAE,QAAQ,GAAG,QAAQ,EAC9B,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,OAAO,EAAE,aAAa,GAAG;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACpC,GACA,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CA2HrB"}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// `ValidationError` is referenced only inside function bodies (call-time), never
|
|
2
|
+
// at module-evaluation time, so the field-access ⇄ hooks import cycle is safe
|
|
3
|
+
// under ESM live bindings.
|
|
4
|
+
import { ValidationError } from '../hooks/index.js';
|
|
1
5
|
/**
|
|
2
6
|
* Shared field-level access evaluation.
|
|
3
7
|
*
|
|
@@ -84,6 +88,23 @@ export async function filterWritableFields(data, fieldConfigs, operation, args)
|
|
|
84
88
|
// Build a set of foreign key field names to exclude
|
|
85
89
|
// Foreign keys should not be in the data when using Prisma's relation syntax
|
|
86
90
|
const foreignKeyFields = new Set();
|
|
91
|
+
// Map each raw per-part column name contributed by a multi-column field
|
|
92
|
+
// (e.g. storage image()/file() in Keystone-parity mode) back to its OWNING
|
|
93
|
+
// declared field. These columns are injected into the write payload by the
|
|
94
|
+
// field's `splitColumns` AFTER resolveInput and are intentionally NOT declared
|
|
95
|
+
// as their own entries in `fieldConfigs`, so without this map they would trip
|
|
96
|
+
// the #564 undeclared-key reject below.
|
|
97
|
+
//
|
|
98
|
+
// SECURITY (#568): a raw column must NOT be blanket-passed through. The hooks
|
|
99
|
+
// layer (`executeFieldResolveInputHooks`) only gates the owning field when the
|
|
100
|
+
// LOGICAL key (e.g. `media`) is present, because it iterates declared fields,
|
|
101
|
+
// not data keys. A non-sudo caller who supplies the raw columns DIRECTLY
|
|
102
|
+
// (`data: { media_url, media_size }`) never produces that logical key, so that
|
|
103
|
+
// gate never fires. We therefore gate each raw column HERE by its owning
|
|
104
|
+
// field's write access — denied (non-sudo) throws, allowed (or sudo) passes
|
|
105
|
+
// through — so the legitimate multi-column write path is preserved while the
|
|
106
|
+
// direct-raw-column bypass is closed.
|
|
107
|
+
const splitColumnOwners = new Map();
|
|
87
108
|
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
88
109
|
if (fieldConfig.type === 'relationship') {
|
|
89
110
|
// For non-many relationships, Prisma creates a foreign key field named `${fieldName}Id`
|
|
@@ -92,7 +113,13 @@ export async function filterWritableFields(data, fieldConfigs, operation, args)
|
|
|
92
113
|
foreignKeyFields.add(`${fieldName}Id`);
|
|
93
114
|
}
|
|
94
115
|
}
|
|
116
|
+
if (typeof fieldConfig.getColumnNames === 'function') {
|
|
117
|
+
for (const column of fieldConfig.getColumnNames(fieldName)) {
|
|
118
|
+
splitColumnOwners.set(column, { fieldName, access: fieldConfig.access });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
95
121
|
}
|
|
122
|
+
const isSudo = args.context._isSudo === true;
|
|
96
123
|
for (const [fieldName, value] of Object.entries(data)) {
|
|
97
124
|
const fieldConfig = fieldConfigs[fieldName];
|
|
98
125
|
// Skip system fields
|
|
@@ -109,14 +136,62 @@ export async function filterWritableFields(data, fieldConfigs, operation, args)
|
|
|
109
136
|
if (foreignKeyFields.has(fieldName)) {
|
|
110
137
|
continue;
|
|
111
138
|
}
|
|
112
|
-
//
|
|
113
|
-
|
|
139
|
+
// Raw per-part columns produced by a multi-column field's `splitColumns`.
|
|
140
|
+
// They are undeclared by design, so they must not trip the #564 reject — but
|
|
141
|
+
// they must NOT be blanket-passed through either: gate each one by its
|
|
142
|
+
// OWNING field's write access (see the SECURITY note where the map is built).
|
|
143
|
+
// This is the real gate for callers who supply the raw columns directly,
|
|
144
|
+
// because the logical-key gate in `executeFieldResolveInputHooks` never fires
|
|
145
|
+
// for them. Denied (non-sudo) throws — same fail-loud behaviour as a denied
|
|
146
|
+
// declared field (#568); allowed (or sudo, via `checkFieldAccess`) passes
|
|
147
|
+
// through, preserving the legitimate multi-column write path.
|
|
148
|
+
const splitColumnOwner = splitColumnOwners.get(fieldName);
|
|
149
|
+
if (splitColumnOwner) {
|
|
150
|
+
const canWrite = await checkFieldAccess(splitColumnOwner.access, operation, {
|
|
151
|
+
...args,
|
|
152
|
+
inputData: args.inputData,
|
|
153
|
+
});
|
|
154
|
+
if (!canWrite) {
|
|
155
|
+
throw new ValidationError([
|
|
156
|
+
`Cannot ${operation} "${splitColumnOwner.fieldName}" (via column "${fieldName}"): ` +
|
|
157
|
+
`field-level access denied.`,
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
filtered[fieldName] = value;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
// #564 — undeclared data keys must fail CLOSED.
|
|
164
|
+
// A key with no entry in `fieldConfigs` is not a field the list config
|
|
165
|
+
// exposes. The generated Prisma model has MORE fields than the config
|
|
166
|
+
// declares (e.g. back-relations like `from_Enrolment_student`), so allowing
|
|
167
|
+
// an undeclared key to pass through lets a non-sudo caller drive ungated
|
|
168
|
+
// nested writes on undeclared back-relations. Mirror Keystone's
|
|
169
|
+
// GraphQL-schema behaviour and reject it. `sudo` is the single trusted
|
|
170
|
+
// bypass, so undeclared keys still pass through under sudo.
|
|
171
|
+
if (!fieldConfig) {
|
|
172
|
+
if (isSudo) {
|
|
173
|
+
filtered[fieldName] = value;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
throw new ValidationError([
|
|
177
|
+
`Cannot ${operation} "${fieldName}": it is not a field of this list. ` +
|
|
178
|
+
`Undeclared data keys are rejected (use sudo to bypass).`,
|
|
179
|
+
]);
|
|
180
|
+
}
|
|
181
|
+
// #568 — fields denied by field-level access must THROW, not be silently
|
|
182
|
+
// dropped. Keystone threw a GraphQL access error for the same situation;
|
|
183
|
+
// silently stripping the field lets a write "succeed" while doing less than
|
|
184
|
+
// asked (and skips any hook side effects gated on that field).
|
|
185
|
+
// `checkFieldAccess` already returns `true` under sudo, so sudo writes never
|
|
186
|
+
// reach the throw below — no parallel sudo path is needed here.
|
|
187
|
+
const canWrite = await checkFieldAccess(fieldConfig.access, operation, {
|
|
114
188
|
...args,
|
|
115
189
|
inputData: args.inputData,
|
|
116
190
|
});
|
|
117
|
-
if (canWrite) {
|
|
118
|
-
|
|
191
|
+
if (!canWrite) {
|
|
192
|
+
throw new ValidationError([`Cannot ${operation} "${fieldName}": field-level access denied.`]);
|
|
119
193
|
}
|
|
194
|
+
filtered[fieldName] = value;
|
|
120
195
|
}
|
|
121
196
|
return filtered;
|
|
122
197
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"field-access.js","sourceRoot":"","sources":["../../src/access/field-access.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"field-access.js","sourceRoot":"","sources":["../../src/access/field-access.ts"],"names":[],"mappings":"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"}
|