@opensaas/stack-core 0.40.0 → 0.42.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 +65 -0
- package/CLAUDE.md +52 -0
- package/dist/access/access-filter.d.ts +88 -18
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +343 -36
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +759 -6
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/denied-relation-visibility.test.d.ts +2 -0
- package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
- package/dist/access/denied-relation-visibility.test.js +114 -0
- package/dist/access/denied-relation-visibility.test.js.map +1 -0
- package/dist/access/engine.d.ts +11 -0
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +26 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +34 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +47 -0
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -2
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +73 -23
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +7 -3
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +9 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/query-validation.d.ts +31 -0
- package/dist/access/query-validation.d.ts.map +1 -1
- package/dist/access/query-validation.js +75 -16
- package/dist/access/query-validation.js.map +1 -1
- package/dist/access/relationship-count.d.ts +41 -3
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +44 -7
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-count.test.js +62 -0
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/synthetic-include-read.test.d.ts +2 -0
- package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
- package/dist/access/synthetic-include-read.test.js +121 -0
- package/dist/access/synthetic-include-read.test.js.map +1 -0
- package/dist/config/types.d.ts +70 -58
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +2 -2
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +16 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +76 -26
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +2 -2
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +21 -3
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +23 -29
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/hooks/index.d.ts +16 -15
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/package.json +2 -2
- package/src/access/access-filter.test.ts +1403 -159
- package/src/access/access-filter.ts +525 -47
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.ts +28 -0
- package/src/access/errors.ts +55 -0
- package/src/access/field-visibility.ts +105 -24
- package/src/access/index.ts +11 -0
- package/src/access/multi-column-read-write.test.ts +3 -2
- package/src/access/query-validation.ts +136 -9
- package/src/access/relationship-count.test.ts +75 -0
- package/src/access/relationship-count.ts +53 -10
- package/src/access/synthetic-include-read.test.ts +179 -0
- package/src/config/types.ts +76 -50
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +120 -39
- package/src/context/nested-operations.ts +35 -11
- package/src/context/write-pipeline.ts +52 -32
- package/src/hooks/index.ts +22 -15
- package/tests/context.test.ts +193 -5
- package/tests/hook-context-secured.test.ts +424 -0
- package/tests/nested-access-and-hooks.test.ts +324 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { filterReadableFields } from './field-visibility.js'
|
|
3
|
+
import { emptyDeclaredOnlyTree } from './declared-dependencies.js'
|
|
4
|
+
import type { ToOneAccessVisibilityTree } from './access-filter.js'
|
|
5
|
+
import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
|
|
6
|
+
import type { AccessContext } from './types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Regression coverage for the `filterReadableFields` half of issue #1103: a
|
|
10
|
+
* relation `buildAccessScopedInclude` denied outright is absent from the raw
|
|
11
|
+
* row (Prisma never fetched it), so the main per-field loop — which only
|
|
12
|
+
* ever visits keys `Object.entries(workingItem)` contains — never sees it.
|
|
13
|
+
* The dedicated post-query pass this file exercises is what forces the key
|
|
14
|
+
* present anyway: `null` for a to-one relation (issue #974, pre-existing),
|
|
15
|
+
* `[]` for a to-many one (issue #1103, this fix) — never a silently absent
|
|
16
|
+
* key, which is what previously broke the fragment API's typed contract
|
|
17
|
+
* (`ResultOf` types a to-many relation as an array).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function rel(ref: string, many = false): FieldConfig {
|
|
21
|
+
return { type: 'relationship', ref, many } as unknown as FieldConfig
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeContext(): AccessContext {
|
|
25
|
+
return {
|
|
26
|
+
session: null,
|
|
27
|
+
_isSudo: false,
|
|
28
|
+
_resolveOutputChain: [],
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
|
|
30
|
+
} as any
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function config(): OpenSaasConfig {
|
|
34
|
+
return {
|
|
35
|
+
db: { provider: 'sqlite' },
|
|
36
|
+
lists: {
|
|
37
|
+
Author: {
|
|
38
|
+
fields: {
|
|
39
|
+
name: { type: 'text' } as FieldConfig,
|
|
40
|
+
secrets: rel('Secret.author', true),
|
|
41
|
+
profile: rel('Profile.author', false),
|
|
42
|
+
hiddenSecrets: {
|
|
43
|
+
...rel('Secret.author', true),
|
|
44
|
+
access: { read: () => false },
|
|
45
|
+
} as unknown as FieldConfig,
|
|
46
|
+
},
|
|
47
|
+
access: { operation: { query: () => true } },
|
|
48
|
+
},
|
|
49
|
+
Secret: {
|
|
50
|
+
fields: { value: { type: 'text' } as FieldConfig },
|
|
51
|
+
access: { operation: { query: () => false } },
|
|
52
|
+
},
|
|
53
|
+
Profile: {
|
|
54
|
+
fields: { bio: { type: 'text' } as FieldConfig },
|
|
55
|
+
access: { operation: { query: () => false } },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
59
|
+
} as any as OpenSaasConfig
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Term ← Bill.term (list-only ref, no field on Term) — the synthetic
|
|
63
|
+
// back-relation case, always to-many (#1082).
|
|
64
|
+
function syntheticConfig(): OpenSaasConfig {
|
|
65
|
+
return {
|
|
66
|
+
db: { provider: 'sqlite' },
|
|
67
|
+
lists: {
|
|
68
|
+
Term: {
|
|
69
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
70
|
+
access: { operation: { query: () => true } },
|
|
71
|
+
},
|
|
72
|
+
Bill: {
|
|
73
|
+
fields: { amount: { type: 'integer' } as FieldConfig, term: rel('Term') },
|
|
74
|
+
access: { operation: { query: () => false } },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
78
|
+
} as any as OpenSaasConfig
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('filterReadableFields — denied relations forced present (issues #974 / #1103)', () => {
|
|
82
|
+
it('forces a denied to-many relation to [], not an absent key', async () => {
|
|
83
|
+
const cfg = config()
|
|
84
|
+
const authorRow = { id: 'a1', name: 'A' } // `secrets` absent — never fetched by Prisma
|
|
85
|
+
const toOneVisibility: ToOneAccessVisibilityTree = {
|
|
86
|
+
filters: { secrets: { kind: 'denied' } },
|
|
87
|
+
nested: {},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const result = await filterReadableFields(
|
|
91
|
+
authorRow,
|
|
92
|
+
cfg.lists.Author.fields,
|
|
93
|
+
{ session: null, context: makeContext() },
|
|
94
|
+
cfg,
|
|
95
|
+
0,
|
|
96
|
+
'Author',
|
|
97
|
+
emptyDeclaredOnlyTree(),
|
|
98
|
+
undefined,
|
|
99
|
+
toOneVisibility,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
expect((result as Record<string, unknown>).secrets).toEqual([])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('still forces a denied to-one relation to null (regression check, issue #974)', async () => {
|
|
106
|
+
const cfg = config()
|
|
107
|
+
const authorRow = { id: 'a1', name: 'A' } // `profile` absent — never fetched by Prisma
|
|
108
|
+
const toOneVisibility: ToOneAccessVisibilityTree = {
|
|
109
|
+
filters: { profile: { kind: 'denied' } },
|
|
110
|
+
nested: {},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const result = await filterReadableFields(
|
|
114
|
+
authorRow,
|
|
115
|
+
cfg.lists.Author.fields,
|
|
116
|
+
{ session: null, context: makeContext() },
|
|
117
|
+
cfg,
|
|
118
|
+
0,
|
|
119
|
+
'Author',
|
|
120
|
+
emptyDeclaredOnlyTree(),
|
|
121
|
+
undefined,
|
|
122
|
+
toOneVisibility,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
expect((result as Record<string, unknown>).profile).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('forces a denied synthetic back-relation to [] — always to-many (#1082 interaction)', async () => {
|
|
129
|
+
const cfg = syntheticConfig()
|
|
130
|
+
const termRow = { id: 't1', name: 'Term 1' } // `from_Bill_term` absent — never fetched
|
|
131
|
+
const toOneVisibility: ToOneAccessVisibilityTree = {
|
|
132
|
+
filters: { from_Bill_term: { kind: 'denied' } },
|
|
133
|
+
nested: {},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const result = await filterReadableFields(
|
|
137
|
+
termRow,
|
|
138
|
+
cfg.lists.Term.fields,
|
|
139
|
+
{ session: null, context: makeContext() },
|
|
140
|
+
cfg,
|
|
141
|
+
0,
|
|
142
|
+
'Term',
|
|
143
|
+
emptyDeclaredOnlyTree(),
|
|
144
|
+
undefined,
|
|
145
|
+
toOneVisibility,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
expect((result as Record<string, unknown>).from_Bill_term).toEqual([])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('omits a denied relation entirely when field-level read access also denies it, rather than forcing []', async () => {
|
|
152
|
+
const cfg = config()
|
|
153
|
+
const authorRow = { id: 'a1', name: 'A' }
|
|
154
|
+
const toOneVisibility: ToOneAccessVisibilityTree = {
|
|
155
|
+
filters: { hiddenSecrets: { kind: 'denied' } },
|
|
156
|
+
nested: {},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const result = await filterReadableFields(
|
|
160
|
+
authorRow,
|
|
161
|
+
cfg.lists.Author.fields,
|
|
162
|
+
{ session: null, context: makeContext() },
|
|
163
|
+
cfg,
|
|
164
|
+
0,
|
|
165
|
+
'Author',
|
|
166
|
+
emptyDeclaredOnlyTree(),
|
|
167
|
+
undefined,
|
|
168
|
+
toOneVisibility,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
expect('hiddenSecrets' in result).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
})
|
package/src/access/engine.ts
CHANGED
|
@@ -94,6 +94,34 @@ export function resolveSyntheticReverseRelation(
|
|
|
94
94
|
return null
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Enumerate every synthetic back-relation name a list-only `ref` elsewhere in
|
|
99
|
+
* the config synthesizes onto `parentListName` — the same relations
|
|
100
|
+
* `resolveSyntheticReverseRelation` resolves one at a time given a candidate
|
|
101
|
+
* key, returned here as the full set for a caller that instead needs "every
|
|
102
|
+
* relation this list carries" with no candidate to check (e.g. `_count:
|
|
103
|
+
* true`'s "count every relation" expansion in `access-filter.ts`, issue
|
|
104
|
+
* #1087 — a bare `_count: true` must include a synthetic back-relation's
|
|
105
|
+
* count exactly as it always has, not only a caller-named one).
|
|
106
|
+
*/
|
|
107
|
+
export function listSyntheticReverseRelationNames(
|
|
108
|
+
parentListName: string,
|
|
109
|
+
config: OpenSaasConfig,
|
|
110
|
+
): string[] {
|
|
111
|
+
const names: string[] = []
|
|
112
|
+
for (const [sourceListName, sourceListConfig] of Object.entries(config.lists)) {
|
|
113
|
+
for (const [sourceFieldName, sourceFieldConfig] of Object.entries(sourceListConfig.fields)) {
|
|
114
|
+
if (sourceFieldConfig.type !== 'relationship') continue
|
|
115
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
|
|
116
|
+
const rel = sourceFieldConfig as RelationshipField<any>
|
|
117
|
+
const refParts = rel.ref.split('.')
|
|
118
|
+
if (refParts.length !== 1 || refParts[0] !== parentListName) continue
|
|
119
|
+
names.push(getSyntheticFieldName(sourceListName, sourceFieldName))
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return names
|
|
123
|
+
}
|
|
124
|
+
|
|
97
125
|
export async function checkAccess<T = Record<string, unknown>>(
|
|
98
126
|
accessControl: AccessControl<T> | undefined,
|
|
99
127
|
args: {
|
package/src/access/errors.ts
CHANGED
|
@@ -99,6 +99,61 @@ export class RelationFilterAccessDeniedError extends Error {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Thrown when a caller-supplied `include` names a key that resolves to
|
|
104
|
+
* neither a declared relationship, a synthetic back-relation (a list-only
|
|
105
|
+
* `ref`'s ORM-required opposite field, which no list config declares — see
|
|
106
|
+
* `resolveSyntheticReverseRelation`), nor `_count`. Before this,
|
|
107
|
+
* `buildAccessScopedInclude`'s scoping walk passed an unrecognised key
|
|
108
|
+
* straight through unscoped — the one surface among `data`/`where`/`orderBy`/
|
|
109
|
+
* `include` that failed open rather than closed (issue #1082). `sudo` never
|
|
110
|
+
* reaches this: it skips `buildAccessScopedInclude` entirely (see
|
|
111
|
+
* `resolveReadInclude` in `context/index.ts`), matching every other
|
|
112
|
+
* access-control escape hatch.
|
|
113
|
+
*/
|
|
114
|
+
export class UndeclaredIncludeKeyError extends Error {
|
|
115
|
+
public listKey: string
|
|
116
|
+
public fieldKey: string
|
|
117
|
+
|
|
118
|
+
constructor(listKey: string, fieldKey: string) {
|
|
119
|
+
super(
|
|
120
|
+
`Cannot include "${listKey}.${fieldKey}" — it is not a field of this list. ` +
|
|
121
|
+
`Undeclared include keys are rejected, matching data/where/orderBy keys.`,
|
|
122
|
+
)
|
|
123
|
+
this.name = 'UndeclaredIncludeKeyError'
|
|
124
|
+
this.listKey = listKey
|
|
125
|
+
this.fieldKey = fieldKey
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Thrown when a caller-supplied `_count.select` in `include` names a key that
|
|
131
|
+
* is not a countable to-many relation — undeclared, a scalar, a virtual, or a
|
|
132
|
+
* to-one relationship. Mirrors `UndeclaredIncludeKeyError`'s rejection for the
|
|
133
|
+
* ordinary `include` walk, for the one key shape that was allowlisted through
|
|
134
|
+
* unscoped rather than rejected (#1082's "Out of scope", closed here by
|
|
135
|
+
* #1087). A key naming a synthetic back-relation (#1082) is NOT rejected — it
|
|
136
|
+
* is a genuine countable to-many, resolved the same way the ordinary
|
|
137
|
+
* `include` walk resolves it. `sudo` never reaches this: it skips
|
|
138
|
+
* `buildAccessScopedInclude` entirely (see `resolveReadInclude` in
|
|
139
|
+
* `context/index.ts`), matching every other access-control escape hatch.
|
|
140
|
+
*/
|
|
141
|
+
export class UndeclaredCountKeyError extends Error {
|
|
142
|
+
public listKey: string
|
|
143
|
+
public fieldKey: string
|
|
144
|
+
|
|
145
|
+
constructor(listKey: string, fieldKey: string) {
|
|
146
|
+
super(
|
|
147
|
+
`Cannot count "${listKey}.${fieldKey}" — it is not a countable to-many relationship on this ` +
|
|
148
|
+
`list. A \`_count.select\` key must name a declared to-many relationship or a synthetic ` +
|
|
149
|
+
`back-relation, matching the ordinary include keys this list accepts.`,
|
|
150
|
+
)
|
|
151
|
+
this.name = 'UndeclaredCountKeyError'
|
|
152
|
+
this.listKey = listKey
|
|
153
|
+
this.fieldKey = fieldKey
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
102
157
|
function describeAccessResult(result: unknown): string {
|
|
103
158
|
if (result === null) return 'null'
|
|
104
159
|
if (result === undefined) return 'undefined'
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import type { Session, AccessContext } from './types.js'
|
|
2
|
-
import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
|
|
3
|
-
import { getRelatedListConfig } from './engine.js'
|
|
2
|
+
import type { OpenSaasConfig, FieldConfig, ListConfig } from '../config/types.js'
|
|
3
|
+
import { getRelatedListConfig, resolveSyntheticReverseRelation } from './engine.js'
|
|
4
4
|
import { checkFieldAccess } from './field-access.js'
|
|
5
5
|
import { RESOLVE_CHAIN_MAX_LENGTH } from './depth-limits.js'
|
|
6
6
|
import { ResolveOutputCycleError } from './errors.js'
|
|
7
7
|
import type { DeclaredOnlyTree } from './declared-dependencies.js'
|
|
8
8
|
import { emptyDeclaredOnlyTree } from './declared-dependencies.js'
|
|
9
9
|
import type { FieldSelectionScope } from '../query/index.js'
|
|
10
|
-
import type { ToOneAccessVisibilityTree } from './access-filter.js'
|
|
11
|
-
import {
|
|
10
|
+
import type { ToOneAccessVisibilityTree, CountAccessDenialTree } from './access-filter.js'
|
|
11
|
+
import {
|
|
12
|
+
emptyToOneAccessVisibilityTree,
|
|
13
|
+
isToOneRelationship,
|
|
14
|
+
emptyCountAccessDenialTree,
|
|
15
|
+
} from './access-filter.js'
|
|
12
16
|
// NOTE: `context/index.ts` imports `filterReadableFields` from this module
|
|
13
17
|
// (via the `access/index.ts` barrel) — this is an intentional cyclic
|
|
14
18
|
// dependency, the same shape and for the same reason as the one documented in
|
|
@@ -54,6 +58,26 @@ import { buildDbDelegate } from '../context/index.js'
|
|
|
54
58
|
* before any field-level access check or `resolveOutput` hook runs, so the
|
|
55
59
|
* rest of the pipeline sees exactly what a denied to-one read has always
|
|
56
60
|
* meant elsewhere: `null`, never a thrown error.
|
|
61
|
+
*
|
|
62
|
+
* **A denied to-many relation is forced to `[]`, the same way (issue
|
|
63
|
+
* #1103).** `buildAccessScopedInclude` drops a to-many relation from
|
|
64
|
+
* `include` on the same outright `query` denial as a to-one one, and records
|
|
65
|
+
* the same `kind: 'denied'` entry for it. The key is therefore just as absent
|
|
66
|
+
* from `workingItem` as a denied to-one key, and the fix is the same fixup
|
|
67
|
+
* loop — it now forces the key present using the field's own declared
|
|
68
|
+
* arity: `null` for a to-one relation (unchanged), `[]` for a to-many one,
|
|
69
|
+
* rather than leaving a to-many key silently missing where the fragment
|
|
70
|
+
* API's `ResultOf` type (`query/index.ts`) promises an array.
|
|
71
|
+
*
|
|
72
|
+
* **`_count` denial injection (issue #1087).** A caller-supplied `_count.select`
|
|
73
|
+
* key whose related list denies `query` access outright is omitted from the
|
|
74
|
+
* select `buildAccessScopedInclude` sends to Prisma — there is no way to ask
|
|
75
|
+
* Prisma for a guaranteed `0`, and no query is needed to know one (unlike the
|
|
76
|
+
* to-one existence check above). This module is where that becomes the
|
|
77
|
+
* caller-visible `0`: every key in a `CountAccessDenialTree` at this level is
|
|
78
|
+
* written into `filtered._count` as `0`, whether or not `_count` came back
|
|
79
|
+
* from the database at all — a count is a session-relative value, and `0` is
|
|
80
|
+
* what "no visible rows" means for it, never an absent key.
|
|
57
81
|
*/
|
|
58
82
|
|
|
59
83
|
type ResolveOutputHookRuntime = (args: {
|
|
@@ -216,6 +240,10 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
216
240
|
// regardless of that relation's own arity, since a filtered to-one can sit
|
|
217
241
|
// beneath a to-many hop.
|
|
218
242
|
toOneVisibility: ToOneAccessVisibilityTree = emptyToOneAccessVisibilityTree(),
|
|
243
|
+
// `_count.select` keys denied outright at THIS level (issue #1087, see
|
|
244
|
+
// module doc above), and the same tree one level down for each nested
|
|
245
|
+
// relation whose own nested include named a further `_count`.
|
|
246
|
+
countDenials: CountAccessDenialTree = emptyCountAccessDenialTree(),
|
|
219
247
|
): Promise<Partial<T>> {
|
|
220
248
|
const filtered: Record<string, unknown> = {}
|
|
221
249
|
|
|
@@ -279,25 +307,49 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
279
307
|
// cap used to let a relation be scoped correctly at the DB level while
|
|
280
308
|
// still returning with unfiltered fields past this function's own,
|
|
281
309
|
// separately-tracked limit (issue #830).
|
|
310
|
+
const isDeclaredRelationshipField =
|
|
311
|
+
fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && !!fieldConfig.ref
|
|
312
|
+
// A synthetic back-relation (#1082) — no declared field of its own on
|
|
313
|
+
// this list, so resolved by name against the owning list's relationship
|
|
314
|
+
// field instead. `undefined` (not `fieldConfig`) is the signal it's
|
|
315
|
+
// worth trying: a declared-but-non-relationship field (e.g. a scalar or
|
|
316
|
+
// virtual) must fall through to the generic path below, unchanged.
|
|
317
|
+
const synthetic =
|
|
318
|
+
!isDeclaredRelationshipField && fieldConfig === undefined && config && listKey
|
|
319
|
+
? resolveSyntheticReverseRelation(fieldName, listKey, config)
|
|
320
|
+
: null
|
|
321
|
+
|
|
282
322
|
if (
|
|
283
323
|
config &&
|
|
284
|
-
|
|
285
|
-
'ref' in fieldConfig &&
|
|
286
|
-
fieldConfig.ref &&
|
|
324
|
+
(isDeclaredRelationshipField || synthetic) &&
|
|
287
325
|
value !== null &&
|
|
288
326
|
value !== undefined
|
|
289
327
|
) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
328
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
329
|
+
let relatedConfig: { listName: string; listConfig: ListConfig<any> } | null = null
|
|
330
|
+
|
|
331
|
+
if (isDeclaredRelationshipField) {
|
|
332
|
+
const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
|
|
333
|
+
...args,
|
|
334
|
+
item: workingItem,
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
if (!canRead) {
|
|
338
|
+
accessDeniedKeys.add(fieldName)
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
299
341
|
|
|
300
|
-
|
|
342
|
+
relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
|
|
343
|
+
} else if (synthetic) {
|
|
344
|
+
// No declared field means no field-level `read` gate of its own to
|
|
345
|
+
// check here — the owning list's OWN field-level access is enforced
|
|
346
|
+
// by the recursive `filterReadableFields` call below, exactly as it
|
|
347
|
+
// would be for a declared relationship's related rows.
|
|
348
|
+
relatedConfig = {
|
|
349
|
+
listName: synthetic.sourceListName,
|
|
350
|
+
listConfig: synthetic.sourceListConfig,
|
|
351
|
+
}
|
|
352
|
+
}
|
|
301
353
|
// The declared-only tree for whatever THIS relation's own list computes,
|
|
302
354
|
// e.g. a field on the related list that declares its own `needs`. Falls
|
|
303
355
|
// back to an empty tree when this relation isn't declaration-related at
|
|
@@ -318,6 +370,11 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
318
370
|
// This key's OWN to-one existence check, if `fieldName` itself is a
|
|
319
371
|
// filtered to-one relation (as opposed to one further down its tree).
|
|
320
372
|
const toOneEntry = toOneVisibility.filters[fieldName]
|
|
373
|
+
// This relation's own denied `_count` keys, if `buildAccessScopedInclude`
|
|
374
|
+
// flagged any beneath it (issue #1087). Falls back to empty — the
|
|
375
|
+
// common case for a relation with no denied `_count` anywhere in its
|
|
376
|
+
// own nested include.
|
|
377
|
+
const nestedCountDenials = countDenials.nested[fieldName] ?? emptyCountAccessDenialTree()
|
|
321
378
|
|
|
322
379
|
if (relatedConfig) {
|
|
323
380
|
if (Array.isArray(value)) {
|
|
@@ -333,6 +390,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
333
390
|
nestedDeclaredOnly,
|
|
334
391
|
nestedSelection,
|
|
335
392
|
nestedToOneVisibility,
|
|
393
|
+
nestedCountDenials,
|
|
336
394
|
),
|
|
337
395
|
),
|
|
338
396
|
)
|
|
@@ -352,6 +410,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
352
410
|
nestedDeclaredOnly,
|
|
353
411
|
nestedSelection,
|
|
354
412
|
nestedToOneVisibility,
|
|
413
|
+
nestedCountDenials,
|
|
355
414
|
)
|
|
356
415
|
: null
|
|
357
416
|
}
|
|
@@ -381,12 +440,14 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
381
440
|
}
|
|
382
441
|
}
|
|
383
442
|
|
|
384
|
-
//
|
|
385
|
-
// were never asked of Prisma at all, so `fieldName`
|
|
386
|
-
// `workingItem` and the loop above never visits it. Force
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
443
|
+
// Relations `buildAccessScopedInclude` denied outright (to-one: issue #974,
|
|
444
|
+
// to-many: issue #1103) were never asked of Prisma at all, so `fieldName`
|
|
445
|
+
// has no entry in `workingItem` and the loop above never visits it. Force
|
|
446
|
+
// it present here — matching what a denied read means everywhere else in
|
|
447
|
+
// the context — rather than leaving the key silently absent: `null` for a
|
|
448
|
+
// to-one relation, `[]` for a to-many one. A synthetic back-relation
|
|
449
|
+
// (`fieldConfig` undefined — #1082) is always to-many, the same arity
|
|
450
|
+
// `buildAccessScopedInclude` assumes for it.
|
|
390
451
|
for (const [fieldName, entry] of Object.entries(toOneVisibility.filters)) {
|
|
391
452
|
if (entry.kind !== 'denied') continue
|
|
392
453
|
if (fieldName in filtered || fieldName in workingItem) continue
|
|
@@ -403,7 +464,27 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
403
464
|
continue
|
|
404
465
|
}
|
|
405
466
|
|
|
406
|
-
|
|
467
|
+
const isToMany = !fieldConfig || !isToOneRelationship(fieldConfig)
|
|
468
|
+
filtered[fieldName] = isToMany ? [] : null
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// `_count.select` keys `buildAccessScopedInclude` denied outright (issue
|
|
472
|
+
// #1087) were omitted from the select sent to Prisma, so `_count` may be
|
|
473
|
+
// absent from `workingItem` entirely, or present but missing exactly these
|
|
474
|
+
// keys. Write each denied key in as `0` — matching what a denied count has
|
|
475
|
+
// always meant for the admin list view's own scoped counts — unless a
|
|
476
|
+
// fragment's own selection excluded `_count` altogether, in which case
|
|
477
|
+
// there is nothing to inject it into.
|
|
478
|
+
if (countDenials.keys.size > 0 && !(selection?.fields && !selection.fields.has('_count'))) {
|
|
479
|
+
const existingCount =
|
|
480
|
+
filtered._count && typeof filtered._count === 'object'
|
|
481
|
+
? (filtered._count as Record<string, unknown>)
|
|
482
|
+
: {}
|
|
483
|
+
const mergedCount = { ...existingCount }
|
|
484
|
+
for (const key of countDenials.keys) {
|
|
485
|
+
mergedCount[key] = 0
|
|
486
|
+
}
|
|
487
|
+
filtered._count = mergedCount
|
|
407
488
|
}
|
|
408
489
|
|
|
409
490
|
// The item a virtual field's hook sees: stored columns and fetched
|
package/src/access/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export {
|
|
|
20
20
|
isPrismaFilter,
|
|
21
21
|
getRelatedListConfig,
|
|
22
22
|
resolveSyntheticReverseRelation,
|
|
23
|
+
listSyntheticReverseRelationNames,
|
|
23
24
|
} from './engine.js'
|
|
24
25
|
export type { SyntheticReverseRelation } from './engine.js'
|
|
25
26
|
// Canonical field-level access evaluation (shared by read and write paths).
|
|
@@ -42,13 +43,19 @@ export {
|
|
|
42
43
|
resolveToOneAccessVisibility,
|
|
43
44
|
emptyToOneAccessFilterTree,
|
|
44
45
|
emptyToOneAccessVisibilityTree,
|
|
46
|
+
emptyCountAccessDenialTree,
|
|
45
47
|
} from './access-filter.js'
|
|
46
48
|
export type {
|
|
47
49
|
ToOneAccessFilterTree,
|
|
48
50
|
ToOneAccessFilterEntry,
|
|
49
51
|
ToOneAccessVisibilityTree,
|
|
50
52
|
ToOneVisibility,
|
|
53
|
+
CountAccessDenialTree,
|
|
51
54
|
} from './access-filter.js'
|
|
55
|
+
// Access-scoped to-many relationship counts (admin list view, issue #732)
|
|
56
|
+
// and the shared per-relation resolver `_count` scoping (issue #1087) reuses.
|
|
57
|
+
export { isToManyRelationshipField, resolveCountAccessEntryForList } from './relationship-count.js'
|
|
58
|
+
export type { CountAccessEntry } from './relationship-count.js'
|
|
52
59
|
// Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
|
|
53
60
|
export { filterReadableFields } from './field-visibility.js'
|
|
54
61
|
// Declared Dependencies — folding `needs` into an include without widening
|
|
@@ -69,3 +76,7 @@ export { InvalidFieldAccessResultError } from './errors.js'
|
|
|
69
76
|
export { InvalidCreateAccessResultError } from './errors.js'
|
|
70
77
|
// Thrown when a relation filter's related list denies query access outright (#916).
|
|
71
78
|
export { RelationFilterAccessDeniedError } from './errors.js'
|
|
79
|
+
// Thrown when a caller `include` names a key that is neither declared, synthetic, nor `_count` (#1082).
|
|
80
|
+
export { UndeclaredIncludeKeyError } from './errors.js'
|
|
81
|
+
// Thrown when a caller `_count.select` names a key that is not a countable to-many relation (#1087).
|
|
82
|
+
export { UndeclaredCountKeyError } from './errors.js'
|
|
@@ -2,7 +2,8 @@ import { describe, it, expect } from 'vitest'
|
|
|
2
2
|
import { filterReadableFields } from './field-visibility.js'
|
|
3
3
|
import { executeFieldResolveInputHooks, splitMultiColumnFields } from '../hooks/index.js'
|
|
4
4
|
import type { FieldConfig } from '../config/types.js'
|
|
5
|
-
import type {
|
|
5
|
+
import type { FieldAccess } from './types.js'
|
|
6
|
+
import type { StackContext } from '../context/index.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Generic core wiring for multi-column fields (the contract storage
|
|
@@ -48,7 +49,7 @@ function multiColumnField(access?: FieldAccess): FieldConfig {
|
|
|
48
49
|
} as unknown as FieldConfig
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
function makeContext(overrides: { isSudo?: boolean } = {}):
|
|
52
|
+
function makeContext(overrides: { isSudo?: boolean } = {}): StackContext {
|
|
52
53
|
return {
|
|
53
54
|
session: null,
|
|
54
55
|
_isSudo: overrides.isSudo ?? false,
|