@opensaas/stack-core 0.38.0 → 0.39.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 +123 -0
- package/CLAUDE.md +21 -0
- package/dist/access/access-filter.d.ts +46 -1
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +113 -4
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +4 -9
- package/dist/access/declared-dependencies.js.map +1 -1
- package/dist/access/engine.d.ts +0 -20
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +0 -25
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +49 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +75 -0
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +39 -1
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +108 -42
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +229 -2
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-transforms.d.ts +0 -27
- package/dist/access/field-transforms.d.ts.map +1 -1
- package/dist/access/field-visibility.d.ts +0 -4
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +13 -39
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +6 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +12 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/query-validation.d.ts +121 -0
- package/dist/access/query-validation.d.ts.map +1 -0
- package/dist/access/query-validation.js +292 -0
- package/dist/access/query-validation.js.map +1 -0
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +2 -10
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-label-filter.d.ts +23 -23
- package/dist/access/relationship-label-filter.d.ts.map +1 -1
- package/dist/access/relationship-label-filter.js +5 -76
- package/dist/access/relationship-label-filter.js.map +1 -1
- package/dist/access/relationship-label-filter.test.js +19 -56
- package/dist/access/relationship-label-filter.test.js.map +1 -1
- package/dist/access/transaction-registry.d.ts +45 -0
- package/dist/access/transaction-registry.d.ts.map +1 -0
- package/dist/access/transaction-registry.js +37 -0
- package/dist/access/transaction-registry.js.map +1 -0
- package/dist/access/types.d.ts +55 -59
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +9 -7
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +10 -18
- package/dist/config/index.js.map +1 -1
- package/dist/config/label.d.ts +6 -13
- package/dist/config/label.d.ts.map +1 -1
- package/dist/config/label.js +6 -13
- package/dist/config/label.js.map +1 -1
- package/dist/config/nav-count.d.ts +11 -19
- package/dist/config/nav-count.d.ts.map +1 -1
- package/dist/config/nav-count.js +11 -19
- package/dist/config/nav-count.js.map +1 -1
- package/dist/config/plugin-engine.d.ts +0 -10
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +1 -37
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +94 -109
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/apply-defaults.d.ts +8 -31
- package/dist/context/apply-defaults.d.ts.map +1 -1
- package/dist/context/apply-defaults.js +12 -41
- package/dist/context/apply-defaults.js.map +1 -1
- package/dist/context/hook-pipeline.d.ts +4 -29
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +8 -41
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +4 -15
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +190 -193
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +7 -26
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +73 -171
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +36 -47
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/transaction-boundary.js +92 -79
- package/dist/context/transaction-boundary.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +25 -64
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +81 -111
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/format-prisma-default.d.ts.map +1 -1
- package/dist/fields/format-prisma-default.js +4 -7
- package/dist/fields/format-prisma-default.js.map +1 -1
- package/dist/fields/index.d.ts +17 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +136 -147
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +26 -3
- package/dist/filter/collect.d.ts.map +1 -1
- package/dist/filter/collect.js +30 -12
- package/dist/filter/collect.js.map +1 -1
- package/dist/filter/filter.test.js +98 -15
- package/dist/filter/filter.test.js.map +1 -1
- package/dist/filter/index.d.ts +1 -0
- package/dist/filter/index.d.ts.map +1 -1
- package/dist/filter/index.js.map +1 -1
- package/dist/filter/map.d.ts.map +1 -1
- package/dist/filter/map.js +0 -5
- package/dist/filter/map.js.map +1 -1
- package/dist/filter/parse.d.ts.map +1 -1
- package/dist/filter/parse.js +2 -9
- package/dist/filter/parse.js.map +1 -1
- package/dist/filter/serialize.d.ts.map +1 -1
- package/dist/filter/serialize.js +2 -4
- package/dist/filter/serialize.js.map +1 -1
- package/dist/hooks/index.d.ts +4 -42
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +5 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -4
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/dist/lib/case-utils.d.ts +0 -32
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +0 -32
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/mcp/handler.d.ts +0 -4
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +12 -69
- package/dist/mcp/handler.js.map +1 -1
- package/dist/query/index.d.ts +26 -227
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +14 -117
- package/dist/query/index.js.map +1 -1
- package/dist/utils/password.d.ts +0 -68
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +7 -76
- package/dist/utils/password.js.map +1 -1
- package/dist/validation/field-config.d.ts.map +1 -1
- package/dist/validation/field-config.js +0 -6
- package/dist/validation/field-config.js.map +1 -1
- package/dist/validation/schema.d.ts +0 -7
- package/dist/validation/schema.d.ts.map +1 -1
- package/dist/validation/schema.js +0 -10
- package/dist/validation/schema.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.ts +152 -5
- package/src/access/declared-dependencies.ts +4 -9
- package/src/access/engine.ts +0 -25
- package/src/access/errors.ts +81 -0
- package/src/access/field-access.test.ts +312 -2
- package/src/access/field-access.ts +118 -42
- package/src/access/field-transforms.ts +8 -39
- package/src/access/field-visibility.ts +16 -47
- package/src/access/index.ts +20 -2
- package/src/access/query-validation.ts +359 -0
- package/src/access/relationship-count.ts +2 -10
- package/src/access/relationship-label-filter.test.ts +19 -68
- package/src/access/relationship-label-filter.ts +27 -104
- package/src/access/transaction-registry.ts +58 -0
- package/src/access/types.ts +83 -86
- package/src/config/index.ts +10 -22
- package/src/config/label.ts +6 -13
- package/src/config/nav-count.ts +11 -19
- package/src/config/plugin-engine.ts +1 -37
- package/src/config/types.ts +95 -110
- package/src/context/apply-defaults.ts +12 -44
- package/src/context/hook-pipeline.ts +12 -67
- package/src/context/index.ts +263 -247
- package/src/context/nested-operations.ts +94 -226
- package/src/context/transaction-boundary.ts +113 -109
- package/src/context/write-pipeline.ts +99 -155
- package/src/fields/format-prisma-default.ts +4 -7
- package/src/fields/index.ts +155 -147
- package/src/filter/collect.ts +48 -12
- package/src/filter/filter.test.ts +153 -15
- package/src/filter/index.ts +1 -0
- package/src/filter/map.ts +0 -5
- package/src/filter/parse.ts +2 -9
- package/src/filter/serialize.ts +2 -4
- package/src/hooks/index.ts +5 -59
- package/src/index.ts +23 -4
- package/src/internal.ts +5 -0
- package/src/lib/case-utils.ts +0 -32
- package/src/mcp/handler.ts +13 -71
- package/src/query/index.ts +29 -235
- package/src/utils/password.ts +7 -76
- package/src/validation/field-config.ts +0 -6
- package/src/validation/schema.ts +0 -10
- package/tests/context.test.ts +700 -1
- package/tests/field-types.test.ts +222 -0
- package/tests/mcp-handler.test.ts +34 -0
- package/tests/transaction-boundary-outermost.test.ts +530 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
1
|
+
import { describe, it, expect, expectTypeOf } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
checkFieldAccess,
|
|
4
|
+
filterWritableFields,
|
|
5
|
+
isFieldReadableForPredicate,
|
|
6
|
+
} from './field-access.js'
|
|
7
|
+
import { InvalidFieldAccessResultError } from './errors.js'
|
|
3
8
|
import { ValidationError } from '../hooks/index.js'
|
|
9
|
+
import type { FieldAccess, FieldAccessControl } from './types.js'
|
|
4
10
|
|
|
5
11
|
// A non-sudo access context. The cast is localized to test setup (mirrors the
|
|
6
12
|
// existing sudo-context casts in this file): the runtime AccessContext carries
|
|
@@ -21,6 +27,283 @@ function sudoContext() {
|
|
|
21
27
|
} as any
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
// ── #914: `FieldAccess['read']`'s `item` must type as present, matching what
|
|
31
|
+
// Field Visibility (field-visibility.ts) actually passes ──
|
|
32
|
+
|
|
33
|
+
describe("FieldAccess['read'] item typing (issue #914)", () => {
|
|
34
|
+
type Item = { ownerId: string }
|
|
35
|
+
|
|
36
|
+
it('types item as present for a read rule that reads a property off it — no cast, no `any`', () => {
|
|
37
|
+
const fieldAccess: FieldAccess<Item> = {
|
|
38
|
+
read: ({ session, item }) => item.ownerId === session?.userId,
|
|
39
|
+
}
|
|
40
|
+
expect(typeof fieldAccess.read).toBe('function')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('still compiles a read rule that ignores item entirely', () => {
|
|
44
|
+
const fieldAccess: FieldAccess<Item> = {
|
|
45
|
+
read: () => true,
|
|
46
|
+
}
|
|
47
|
+
expect(typeof fieldAccess.read).toBe('function')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('leaves the create branch unchanged — item is still absent, there genuinely is no row yet', () => {
|
|
51
|
+
// Pinned against `FieldAccessControl`'s discriminated union directly
|
|
52
|
+
// (rather than `FieldAccess['create']`'s destructured callback), because
|
|
53
|
+
// `create`/`update` — unlike `read` — are untouched by this fix and keep
|
|
54
|
+
// accepting the full `FieldAccessControl` union in `FieldAccess`.
|
|
55
|
+
type CreateArgs = Extract<Parameters<FieldAccessControl<Item>>[0], { operation: 'create' }>
|
|
56
|
+
expectTypeOf<CreateArgs['item']>().toEqualTypeOf<undefined>()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// Type-level pin: `resolveReadableFieldValue` (field-visibility.ts) is the
|
|
60
|
+
// sole caller of `checkFieldAccess` for `operation: 'read'`, and always
|
|
61
|
+
// supplies `item: accessItem` — a full row, never `undefined`. This
|
|
62
|
+
// assertion has no runtime effect (`expectTypeOf` is a no-op outside
|
|
63
|
+
// `vitest --typecheck`); its value is that `pnpm build`/`tsc` fails on this
|
|
64
|
+
// file the moment `FieldAccess['read']`'s `item` type drifts back to
|
|
65
|
+
// optional/absent, so the declared type and the call site cannot silently
|
|
66
|
+
// diverge again.
|
|
67
|
+
it("pins FieldAccess['read']'s item type against the field-visibility.ts call site", () => {
|
|
68
|
+
type ReadArgs = Parameters<NonNullable<FieldAccess<Item>['read']>>[0]
|
|
69
|
+
expectTypeOf<ReadArgs['item']>().toEqualTypeOf<Item>()
|
|
70
|
+
expectTypeOf<ReadArgs['operation']>().toEqualTypeOf<'read'>()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// ── #913: a field rule returning a filter must not be granted blanket access ──
|
|
75
|
+
|
|
76
|
+
describe('checkFieldAccess', () => {
|
|
77
|
+
it('allows when the rule returns true', async () => {
|
|
78
|
+
const allowed = await checkFieldAccess({ read: () => true }, 'read', {
|
|
79
|
+
session: null,
|
|
80
|
+
item: { ownerId: 'someone-else' },
|
|
81
|
+
context: nonSudoContext(),
|
|
82
|
+
})
|
|
83
|
+
expect(allowed).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('denies when the rule returns false', async () => {
|
|
87
|
+
const allowed = await checkFieldAccess({ read: () => false }, 'read', {
|
|
88
|
+
session: null,
|
|
89
|
+
item: { ownerId: 'someone-else' },
|
|
90
|
+
context: nonSudoContext(),
|
|
91
|
+
})
|
|
92
|
+
expect(allowed).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('allows when no field access is configured', async () => {
|
|
96
|
+
const allowed = await checkFieldAccess(undefined, 'read', {
|
|
97
|
+
session: null,
|
|
98
|
+
context: nonSudoContext(),
|
|
99
|
+
})
|
|
100
|
+
expect(allowed).toBe(true)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('allows when no rule is configured for the operation', async () => {
|
|
104
|
+
const allowed = await checkFieldAccess({ update: () => false }, 'read', {
|
|
105
|
+
session: null,
|
|
106
|
+
context: nonSudoContext(),
|
|
107
|
+
})
|
|
108
|
+
expect(allowed).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('throws InvalidFieldAccessResultError, not allow, when a read rule returns a filter', async () => {
|
|
112
|
+
// The exact reproduction from the issue: a rule written to scope a field
|
|
113
|
+
// by row, which the previous fail-open default granted full access to.
|
|
114
|
+
const fieldAccess = {
|
|
115
|
+
read: () => ({ ownerId: { equals: 'someone-else' } }),
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
117
|
+
} as any
|
|
118
|
+
|
|
119
|
+
await expect(
|
|
120
|
+
checkFieldAccess(fieldAccess, 'read', {
|
|
121
|
+
session: null,
|
|
122
|
+
item: { ownerId: 'the-owner' },
|
|
123
|
+
context: nonSudoContext(),
|
|
124
|
+
}),
|
|
125
|
+
).rejects.toThrow(InvalidFieldAccessResultError)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('throws for a filter-returning rule on create, where there is no item to test it against', async () => {
|
|
129
|
+
const fieldAccess = {
|
|
130
|
+
create: () => ({ ownerId: { equals: 'someone-else' } }),
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
132
|
+
} as any
|
|
133
|
+
|
|
134
|
+
await expect(
|
|
135
|
+
checkFieldAccess(fieldAccess, 'create', {
|
|
136
|
+
session: null,
|
|
137
|
+
context: nonSudoContext(),
|
|
138
|
+
inputData: { ownerId: 'the-owner' },
|
|
139
|
+
}),
|
|
140
|
+
).rejects.toThrow(InvalidFieldAccessResultError)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('throws for a filter-returning rule on update', async () => {
|
|
144
|
+
const fieldAccess = {
|
|
145
|
+
update: () => ({ ownerId: { equals: 'someone-else' } }),
|
|
146
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
147
|
+
} as any
|
|
148
|
+
|
|
149
|
+
await expect(
|
|
150
|
+
checkFieldAccess(fieldAccess, 'update', {
|
|
151
|
+
session: null,
|
|
152
|
+
item: { ownerId: 'the-owner' },
|
|
153
|
+
context: nonSudoContext(),
|
|
154
|
+
inputData: { ownerId: 'someone-else' },
|
|
155
|
+
}),
|
|
156
|
+
).rejects.toThrow(InvalidFieldAccessResultError)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('throws for a non-boolean, non-filter result too (e.g. undefined)', async () => {
|
|
160
|
+
const fieldAccess = {
|
|
161
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
162
|
+
read: () => undefined as any,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await expect(
|
|
166
|
+
checkFieldAccess(fieldAccess, 'read', {
|
|
167
|
+
session: null,
|
|
168
|
+
item: {},
|
|
169
|
+
context: nonSudoContext(),
|
|
170
|
+
}),
|
|
171
|
+
).rejects.toThrow(InvalidFieldAccessResultError)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('throws with a descriptive message for null, and for other primitive results', async () => {
|
|
175
|
+
const nullAccess = {
|
|
176
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
177
|
+
read: () => null as any,
|
|
178
|
+
}
|
|
179
|
+
await expect(
|
|
180
|
+
checkFieldAccess(nullAccess, 'read', {
|
|
181
|
+
session: null,
|
|
182
|
+
item: {},
|
|
183
|
+
context: nonSudoContext(),
|
|
184
|
+
}),
|
|
185
|
+
).rejects.toThrow(/returned null, not a boolean/)
|
|
186
|
+
|
|
187
|
+
const numberAccess = {
|
|
188
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
189
|
+
read: () => 42 as any,
|
|
190
|
+
}
|
|
191
|
+
await expect(
|
|
192
|
+
checkFieldAccess(numberAccess, 'read', {
|
|
193
|
+
session: null,
|
|
194
|
+
item: {},
|
|
195
|
+
context: nonSudoContext(),
|
|
196
|
+
}),
|
|
197
|
+
).rejects.toThrow(/returned a number, not a boolean/)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('sudo bypasses the rule entirely, so a filter-returning rule never reaches the throw', async () => {
|
|
201
|
+
const fieldAccess = {
|
|
202
|
+
read: () => ({ ownerId: { equals: 'someone-else' } }),
|
|
203
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
204
|
+
} as any
|
|
205
|
+
|
|
206
|
+
const allowed = await checkFieldAccess(fieldAccess, 'read', {
|
|
207
|
+
session: null,
|
|
208
|
+
item: { ownerId: 'the-owner' },
|
|
209
|
+
context: sudoContext(),
|
|
210
|
+
})
|
|
211
|
+
expect(allowed).toBe(true)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
describe('isFieldReadableForPredicate (#915)', () => {
|
|
216
|
+
it('allows when there is no field access configured', async () => {
|
|
217
|
+
const readable = await isFieldReadableForPredicate(undefined, {
|
|
218
|
+
session: null,
|
|
219
|
+
context: nonSudoContext(),
|
|
220
|
+
})
|
|
221
|
+
expect(readable).toBe(true)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('allows when the rule only inspects session (never touches item)', async () => {
|
|
225
|
+
const readable = await isFieldReadableForPredicate(
|
|
226
|
+
{ read: ({ session }) => session?.userId === 'admin' },
|
|
227
|
+
{ session: { userId: 'admin' }, context: nonSudoContext() },
|
|
228
|
+
)
|
|
229
|
+
expect(readable).toBe(true)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('denies when the rule returns false', async () => {
|
|
233
|
+
const readable = await isFieldReadableForPredicate(
|
|
234
|
+
{ read: () => false },
|
|
235
|
+
{ session: null, context: nonSudoContext() },
|
|
236
|
+
)
|
|
237
|
+
expect(readable).toBe(false)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('denies a row-dependent rule that dereferences `item` directly', async () => {
|
|
241
|
+
const readable = await isFieldReadableForPredicate(
|
|
242
|
+
{ read: ({ item, session }) => item.ownerId === session?.userId },
|
|
243
|
+
{ session: { userId: 'user-1' }, context: nonSudoContext() },
|
|
244
|
+
)
|
|
245
|
+
expect(readable).toBe(false)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('denies a row-dependent rule even when it reads `item` via optional chaining', async () => {
|
|
249
|
+
// This is the exact idiom `InvalidFieldAccessResultError`'s own message
|
|
250
|
+
// recommends (`item?.ownerId === session?.userId`) — it must still deny,
|
|
251
|
+
// not silently misevaluate `undefined === session?.userId` against a
|
|
252
|
+
// poisoned `item`.
|
|
253
|
+
const readable = await isFieldReadableForPredicate(
|
|
254
|
+
{ read: ({ item, session }) => item?.ownerId === session?.userId },
|
|
255
|
+
{ session: null, context: nonSudoContext() },
|
|
256
|
+
)
|
|
257
|
+
expect(readable).toBe(false)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('denies a row-dependent rule that only enumerates `item`s keys', async () => {
|
|
261
|
+
const readable = await isFieldReadableForPredicate(
|
|
262
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
263
|
+
{ read: ({ item }) => Object.keys(item as any).length > 0 },
|
|
264
|
+
{ session: null, context: nonSudoContext() },
|
|
265
|
+
)
|
|
266
|
+
expect(readable).toBe(false)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('propagates InvalidFieldAccessResultError instead of folding it into a denial', async () => {
|
|
270
|
+
const fieldAccess = {
|
|
271
|
+
read: () => ({ ownerId: { equals: 'someone-else' } }),
|
|
272
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
273
|
+
} as any
|
|
274
|
+
|
|
275
|
+
await expect(
|
|
276
|
+
isFieldReadableForPredicate(fieldAccess, { session: null, context: nonSudoContext() }),
|
|
277
|
+
).rejects.toThrow(InvalidFieldAccessResultError)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('propagates a genuine error the rule throws for its own reasons, not just non-boolean results', async () => {
|
|
281
|
+
// The rule never touches `item` — it throws for a reason of its own.
|
|
282
|
+
// This must NOT be folded into an ordinary `false` denial (that would
|
|
283
|
+
// mask a real bug in the rule as "field not readable"), matching how the
|
|
284
|
+
// post-query path (`filterReadableFields`) already lets such a throw
|
|
285
|
+
// propagate unchanged.
|
|
286
|
+
const fieldAccess: FieldAccess = {
|
|
287
|
+
read: ({ session }) => {
|
|
288
|
+
if (session === null) throw new Error('unexpected anonymous access')
|
|
289
|
+
return true
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
await expect(
|
|
294
|
+
isFieldReadableForPredicate(fieldAccess, { session: null, context: nonSudoContext() }),
|
|
295
|
+
).rejects.toThrow('unexpected anonymous access')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('sudo bypasses the rule entirely, so a row-dependent rule never denies', async () => {
|
|
299
|
+
const readable = await isFieldReadableForPredicate(
|
|
300
|
+
{ read: ({ item, session }) => item.ownerId === session?.userId },
|
|
301
|
+
{ session: null, context: sudoContext() },
|
|
302
|
+
)
|
|
303
|
+
expect(readable).toBe(true)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
24
307
|
describe('filterWritableFields', () => {
|
|
25
308
|
it('should filter out foreign key fields when their corresponding relationship field exists', async () => {
|
|
26
309
|
// Setup: Define field configs with a relationship field
|
|
@@ -361,6 +644,33 @@ describe('filterWritableFields', () => {
|
|
|
361
644
|
).rejects.toThrow(/status/)
|
|
362
645
|
})
|
|
363
646
|
|
|
647
|
+
// ── #913: a field rule returning a filter must not grant blanket write access ──
|
|
648
|
+
|
|
649
|
+
it('throws InvalidFieldAccessResultError, not a blanket write, when field access returns a filter (non-sudo)', async () => {
|
|
650
|
+
const fieldConfigs = {
|
|
651
|
+
title: { type: 'text' },
|
|
652
|
+
status: {
|
|
653
|
+
type: 'text',
|
|
654
|
+
access: {
|
|
655
|
+
// Written as a row-scoping rule; field access does not honour filters.
|
|
656
|
+
update: () => ({ status: { equals: 'draft' } }),
|
|
657
|
+
create: () => ({ status: { equals: 'draft' } }),
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
661
|
+
} as any
|
|
662
|
+
const data = { title: 'Test', status: 'published' }
|
|
663
|
+
|
|
664
|
+
await expect(
|
|
665
|
+
filterWritableFields(data, fieldConfigs, 'update', {
|
|
666
|
+
session: null,
|
|
667
|
+
item: { id: 'post-1', status: 'draft' },
|
|
668
|
+
context: nonSudoContext(),
|
|
669
|
+
inputData: data,
|
|
670
|
+
}),
|
|
671
|
+
).rejects.toThrow(InvalidFieldAccessResultError)
|
|
672
|
+
})
|
|
673
|
+
|
|
364
674
|
it('does NOT throw on a would-be-denied field under sudo', async () => {
|
|
365
675
|
const fieldConfigs = {
|
|
366
676
|
title: { type: 'text' },
|
|
@@ -1,9 +1,48 @@
|
|
|
1
1
|
import type { Session, AccessContext } from './types.js'
|
|
2
|
-
import type { FieldAccess } from './types.js'
|
|
2
|
+
import type { FieldAccess, FieldAccessControl } from './types.js'
|
|
3
3
|
// `ValidationError` is referenced only inside function bodies (call-time), never
|
|
4
4
|
// at module-evaluation time, so the field-access ⇄ hooks import cycle is safe
|
|
5
5
|
// under ESM live bindings.
|
|
6
6
|
import { ValidationError } from '../hooks/index.js'
|
|
7
|
+
import { InvalidFieldAccessResultError } from './errors.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Marks a throw caused by touching {@link createPoisonedItem}'s `item`, as
|
|
11
|
+
* opposed to some other error a field rule legitimately raises. Not exported
|
|
12
|
+
* — callers only ever see its effect (a `false` from
|
|
13
|
+
* `isFieldReadableForPredicate`), never the class itself.
|
|
14
|
+
*/
|
|
15
|
+
class PredicateTimeItemAccessError extends Error {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An `item` that throws {@link PredicateTimeItemAccessError} on ANY attempt to
|
|
19
|
+
* read a property off it — including via optional chaining (`item?.x`),
|
|
20
|
+
* since the Proxy itself is a truthy object and optional chaining still
|
|
21
|
+
* performs the property read once its base is non-nullish. Used by
|
|
22
|
+
* `isFieldReadableForPredicate` to detect a read rule that depends on the
|
|
23
|
+
* fetched row: there is no row yet at predicate-evaluation time (see that
|
|
24
|
+
* function's doc), so any rule that reaches into `item` at all cannot be
|
|
25
|
+
* answered here.
|
|
26
|
+
*/
|
|
27
|
+
function createPoisonedItem(): Record<string, unknown> {
|
|
28
|
+
return new Proxy(
|
|
29
|
+
{},
|
|
30
|
+
{
|
|
31
|
+
get(_target, prop) {
|
|
32
|
+
throw new PredicateTimeItemAccessError(String(prop))
|
|
33
|
+
},
|
|
34
|
+
has(_target, prop) {
|
|
35
|
+
throw new PredicateTimeItemAccessError(String(prop))
|
|
36
|
+
},
|
|
37
|
+
ownKeys() {
|
|
38
|
+
throw new PredicateTimeItemAccessError('ownKeys')
|
|
39
|
+
},
|
|
40
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
41
|
+
throw new PredicateTimeItemAccessError(String(prop))
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
) as Record<string, unknown>
|
|
45
|
+
}
|
|
7
46
|
|
|
8
47
|
/**
|
|
9
48
|
* Shared field-level access evaluation.
|
|
@@ -24,6 +63,10 @@ import { ValidationError } from '../hooks/index.js'
|
|
|
24
63
|
* (read/update/delete), and on the `inputData` being written (create/update),
|
|
25
64
|
* so all of those are accepted. Do not introduce a parallel evaluator with a
|
|
26
65
|
* narrower signature.
|
|
66
|
+
*
|
|
67
|
+
* Returns a strict boolean, never a filter: a rule that returns anything else
|
|
68
|
+
* throws `InvalidFieldAccessResultError` rather than defaulting to allow (see
|
|
69
|
+
* that error's doc, ADR-0001, and ADR-0030).
|
|
27
70
|
*/
|
|
28
71
|
export async function checkFieldAccess(
|
|
29
72
|
fieldAccess: FieldAccess | undefined,
|
|
@@ -35,18 +78,25 @@ export async function checkFieldAccess(
|
|
|
35
78
|
inputData?: Record<string, unknown>
|
|
36
79
|
},
|
|
37
80
|
): Promise<boolean> {
|
|
38
|
-
// Skip access check in sudo mode
|
|
39
81
|
if (args.context._isSudo) {
|
|
40
82
|
return true
|
|
41
83
|
}
|
|
42
84
|
|
|
43
85
|
if (!fieldAccess) {
|
|
44
|
-
return true
|
|
86
|
+
return true
|
|
45
87
|
}
|
|
46
88
|
|
|
47
|
-
|
|
89
|
+
// `FieldAccess['read']` is narrower than `FieldAccess['create'/'update']` (it
|
|
90
|
+
// only accepts the single `operation: 'read'` call shape, see `types.ts`),
|
|
91
|
+
// so indexing by a not-yet-narrowed `operation` union produces a callable
|
|
92
|
+
// whose effective parameter type collapses to an intersection TypeScript
|
|
93
|
+
// can't satisfy generically here. Widen back to the general
|
|
94
|
+
// `FieldAccessControl` — the same shape this function has always built and
|
|
95
|
+
// passed below — since by construction the args object always matches
|
|
96
|
+
// whichever operation is actually requested.
|
|
97
|
+
const accessControl = fieldAccess[operation] as FieldAccessControl | undefined
|
|
48
98
|
if (!accessControl) {
|
|
49
|
-
return true
|
|
99
|
+
return true
|
|
50
100
|
}
|
|
51
101
|
|
|
52
102
|
const result = await accessControl({
|
|
@@ -57,51 +107,79 @@ export async function checkFieldAccess(
|
|
|
57
107
|
operation,
|
|
58
108
|
} as Parameters<typeof accessControl>[0])
|
|
59
109
|
|
|
60
|
-
// If result is false, deny access
|
|
61
110
|
if (result === false) {
|
|
62
111
|
return false
|
|
63
112
|
}
|
|
64
113
|
|
|
65
|
-
// If result is true, allow access
|
|
66
114
|
if (result === true) {
|
|
67
115
|
return true
|
|
68
116
|
}
|
|
69
117
|
|
|
70
|
-
//
|
|
71
|
-
|
|
118
|
+
// `FieldAccessControl` is typed to return `boolean` only — field access is a
|
|
119
|
+
// per-field visibility decision, not a row filter (ADR-0001, ADR-0030). A
|
|
120
|
+
// well-typed rule can never reach this line; getting here means a caller
|
|
121
|
+
// bypassed the type (most notably by returning a Prisma filter, the shape
|
|
122
|
+
// operation-level `AccessControl` accepts but this does not). Fail loudly
|
|
123
|
+
// and closed — this operation applies identically whether or not `item` is
|
|
124
|
+
// available, so `create` (which has no `item` to evaluate a filter against)
|
|
125
|
+
// needs no separate answer: neither operation ever honours a non-boolean.
|
|
126
|
+
throw new InvalidFieldAccessResultError(operation, result)
|
|
72
127
|
}
|
|
73
128
|
|
|
74
129
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
130
|
+
* Whether a field's `read` access allows it to be NAMED in a `where`/`orderBy`
|
|
131
|
+
* predicate, evaluated BEFORE the query runs (#915).
|
|
132
|
+
*
|
|
133
|
+
* Field-level `read` access has always been a post-query check (Field
|
|
134
|
+
* Visibility, `field-visibility.ts`): it strips a denied key from a row that
|
|
135
|
+
* has already been fetched. That leaves the predicate itself unconstrained —
|
|
136
|
+
* a caller can still filter or sort by a field whose value they could never
|
|
137
|
+
* read, and recover it (or its relative order) by probing. This function is
|
|
138
|
+
* the pre-query counterpart, called from `query-validation.ts`'s `where`/
|
|
139
|
+
* `orderBy` walk for every key that resolves to a declared field.
|
|
140
|
+
*
|
|
141
|
+
* It delegates to the same evaluator Field Visibility uses (`checkFieldAccess`),
|
|
142
|
+
* per this module's canonical-evaluator rule above. The one difference is the
|
|
143
|
+
* `item` it hands the rule: there is no fetched row
|
|
144
|
+
* yet, so a rule that depends on one (the shape `FieldAccess['read']`
|
|
145
|
+
* documents as the norm, e.g. `item?.ownerId === session?.userId`) cannot be
|
|
146
|
+
* answered here. Rather than skip the check for such a rule — which would
|
|
147
|
+
* reopen exactly the hole this closes for the fields most likely to be
|
|
148
|
+
* sensitive — it is handed a poisoned `item` that throws on any property
|
|
149
|
+
* read, and that throw is caught and resolved to `false`: a row-dependent
|
|
150
|
+
* `read` rule always denies at predicate time, documented and deliberate
|
|
151
|
+
* (see docs/adr/0031). A rule that never touches `item` (checking only
|
|
152
|
+
* `session`, the common case for a field meant to be filterable/sortable at
|
|
153
|
+
* all) evaluates normally and returns its real answer.
|
|
154
|
+
*
|
|
155
|
+
* A rule that returns a non-boolean is a distinct, louder failure (#913,
|
|
156
|
+
* ADR-0030) — `InvalidFieldAccessResultError` — and is deliberately NOT
|
|
157
|
+
* folded into the `false` here; it propagates so the config bug it signals is
|
|
158
|
+
* never mistaken for an ordinary field-level denial.
|
|
77
159
|
*/
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
160
|
+
export async function isFieldReadableForPredicate(
|
|
161
|
+
fieldAccess: FieldAccess | undefined,
|
|
162
|
+
args: {
|
|
163
|
+
session: Session | null
|
|
164
|
+
context: AccessContext & { _isSudo?: boolean }
|
|
165
|
+
},
|
|
166
|
+
): Promise<boolean> {
|
|
167
|
+
try {
|
|
168
|
+
return await checkFieldAccess(fieldAccess, 'read', {
|
|
169
|
+
session: args.session,
|
|
170
|
+
context: args.context,
|
|
171
|
+
item: createPoisonedItem(),
|
|
172
|
+
})
|
|
173
|
+
} catch (err) {
|
|
174
|
+
// Only the poisoned-item signal means "row-dependent, deny". Anything
|
|
175
|
+
// else — including `InvalidFieldAccessResultError` and a genuine bug in
|
|
176
|
+
// the rule itself — propagates unchanged rather than being silently
|
|
177
|
+
// folded into an ordinary denial (found in review of #925).
|
|
178
|
+
if (err instanceof PredicateTimeItemAccessError) return false
|
|
179
|
+
throw err
|
|
98
180
|
}
|
|
99
|
-
return true
|
|
100
181
|
}
|
|
101
182
|
|
|
102
|
-
/**
|
|
103
|
-
* Filter fields from input data based on write access (create/update)
|
|
104
|
-
*/
|
|
105
183
|
export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
106
184
|
data: T,
|
|
107
185
|
fieldConfigs: Record<
|
|
@@ -122,8 +200,7 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
|
122
200
|
): Promise<Partial<T>> {
|
|
123
201
|
const filtered: Record<string, unknown> = {}
|
|
124
202
|
|
|
125
|
-
//
|
|
126
|
-
// Foreign keys should not be in the data when using Prisma's relation syntax
|
|
203
|
+
// Foreign keys must not appear in `data` when using Prisma's relation syntax.
|
|
127
204
|
const foreignKeyFields = new Set<string>()
|
|
128
205
|
// Map each raw per-part column name contributed by a multi-column field
|
|
129
206
|
// (e.g. storage image()/file() in Keystone-parity mode) back to its OWNING
|
|
@@ -162,19 +239,18 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
|
162
239
|
for (const [fieldName, value] of Object.entries(data)) {
|
|
163
240
|
const fieldConfig = fieldConfigs[fieldName]
|
|
164
241
|
|
|
165
|
-
// Skip system fields
|
|
166
242
|
if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
|
|
167
243
|
continue
|
|
168
244
|
}
|
|
169
245
|
|
|
170
|
-
//
|
|
171
|
-
//
|
|
246
|
+
// Virtual fields don't store in the database — skipped here, but their
|
|
247
|
+
// resolveInput hooks still run as a separate side-effect step.
|
|
172
248
|
if (fieldConfig && 'virtual' in fieldConfig && fieldConfig.virtual) {
|
|
173
249
|
continue
|
|
174
250
|
}
|
|
175
251
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
252
|
+
// Prevents conflicts with Prisma's relation syntax (e.g.,
|
|
253
|
+
// `author: { connect: { id } }`).
|
|
178
254
|
if (foreignKeyFields.has(fieldName)) {
|
|
179
255
|
continue
|
|
180
256
|
}
|
|
@@ -1,29 +1,20 @@
|
|
|
1
1
|
import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
|
|
2
2
|
import type { HashedPassword } from '../utils/password.js'
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* Extract the return type of a field's afterOperation hook
|
|
6
|
-
* If the field has an afterOperation hook, infer its return type
|
|
7
|
-
* Otherwise, use the original type
|
|
8
|
-
*/
|
|
9
4
|
export type InferFieldReadType<TField extends FieldConfig, TOriginal> = TField extends {
|
|
10
|
-
//
|
|
11
|
-
//
|
|
5
|
+
// Uses `any` here so TypeScript's conditional type inference can pick up the
|
|
6
|
+
// exact return type `R` from a hook of any signature.
|
|
12
7
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
8
|
hooks?: { afterOperation?: (...args: any[]) => infer R }
|
|
14
9
|
}
|
|
15
10
|
? R extends never
|
|
16
|
-
? TOriginal
|
|
17
|
-
: R
|
|
18
|
-
: TOriginal
|
|
11
|
+
? TOriginal
|
|
12
|
+
: R
|
|
13
|
+
: TOriginal
|
|
19
14
|
|
|
20
|
-
/**
|
|
21
|
-
* Transform a Prisma model's field types based on OpenSaas field configs
|
|
22
|
-
* This applies afterOperation hook transformations to field types
|
|
23
|
-
*/
|
|
24
15
|
export type TransformModelFields<
|
|
25
|
-
//
|
|
26
|
-
//
|
|
16
|
+
// Uses `any` so this constraint accepts Prisma's generated model types, which are
|
|
17
|
+
// indexed by string keys.
|
|
27
18
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
19
|
TModel extends Record<string, any>,
|
|
29
20
|
TFields extends Record<string, FieldConfig>,
|
|
@@ -33,34 +24,22 @@ export type TransformModelFields<
|
|
|
33
24
|
: TModel[K]
|
|
34
25
|
}
|
|
35
26
|
|
|
36
|
-
/**
|
|
37
|
-
* Get the field configs for a specific list from the OpenSaas config
|
|
38
|
-
*/
|
|
39
27
|
export type GetListFields<
|
|
40
28
|
TConfig extends OpenSaasConfig,
|
|
41
29
|
TListKey extends keyof TConfig['lists'],
|
|
42
30
|
> = TConfig['lists'][TListKey]['fields']
|
|
43
31
|
|
|
44
|
-
/**
|
|
45
|
-
* Transform a Prisma model result based on OpenSaas config
|
|
46
|
-
* Applies field hooks transformations
|
|
47
|
-
*/
|
|
48
32
|
export type TransformResult<
|
|
49
33
|
TConfig extends OpenSaasConfig,
|
|
50
34
|
TListKey extends keyof TConfig['lists'],
|
|
51
35
|
TResult,
|
|
52
36
|
> =
|
|
53
|
-
//
|
|
54
|
-
// This pattern is standard in TypeScript for conditional types on object shapes
|
|
37
|
+
// Uses `any` to check whether TResult is an object type that can be transformed.
|
|
55
38
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
39
|
TResult extends Record<string, any>
|
|
57
40
|
? TransformModelFields<TResult, GetListFields<TConfig, TListKey>>
|
|
58
41
|
: TResult
|
|
59
42
|
|
|
60
|
-
/**
|
|
61
|
-
* Transform a Prisma operation's return type
|
|
62
|
-
* Handles single results, arrays, and null cases
|
|
63
|
-
*/
|
|
64
43
|
export type TransformOperationResult<
|
|
65
44
|
TConfig extends OpenSaasConfig,
|
|
66
45
|
TListKey extends keyof TConfig['lists'],
|
|
@@ -76,20 +55,10 @@ export type TransformOperationResult<
|
|
|
76
55
|
>
|
|
77
56
|
: never
|
|
78
57
|
|
|
79
|
-
/**
|
|
80
|
-
* Known field type mappings for afterOperation hooks
|
|
81
|
-
* These provide concrete type hints for common field transformations
|
|
82
|
-
*/
|
|
83
58
|
export interface FieldTypeTransforms {
|
|
84
59
|
password: HashedPassword
|
|
85
|
-
// Future field types can be added here
|
|
86
|
-
// richText: TiptapContent
|
|
87
|
-
// json: JSONValue
|
|
88
60
|
}
|
|
89
61
|
|
|
90
|
-
/**
|
|
91
|
-
* Helper to infer field type based on field config type
|
|
92
|
-
*/
|
|
93
62
|
export type InferFieldTypeTransform<TField extends FieldConfig> = TField extends {
|
|
94
63
|
type: infer TType
|
|
95
64
|
}
|