@opensaas/stack-core 0.39.2 → 0.41.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 +189 -0
- package/CLAUDE.md +103 -0
- package/dist/access/access-filter.d.ts +157 -14
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +470 -42
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +982 -17
- 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 +54 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +84 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +56 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/errors.d.ts +62 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +85 -2
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +12 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +22 -2
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +66 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +107 -15
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +9 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +11 -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 +98 -32
- 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 +35 -2
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +121 -27
- 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 +74 -15
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +3 -2
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +31 -34
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/index.d.ts +13 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +50 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
- package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
- package/dist/fields/relationship-foreign-key.test.js +18 -0
- package/dist/fields/relationship-foreign-key.test.js.map +1 -0
- 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/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/case-utils.d.ts +15 -0
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +21 -0
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/lib/case-utils.test.js +23 -1
- package/dist/lib/case-utils.test.js.map +1 -1
- package/dist/lib/prisma-errors.d.ts +38 -0
- package/dist/lib/prisma-errors.d.ts.map +1 -0
- package/dist/lib/prisma-errors.js +53 -0
- package/dist/lib/prisma-errors.js.map +1 -0
- package/dist/mcp/constants.d.ts +23 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +23 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/field-schema.d.ts +8 -0
- package/dist/mcp/field-schema.d.ts.map +1 -0
- package/dist/mcp/field-schema.js +69 -0
- package/dist/mcp/field-schema.js.map +1 -0
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +76 -90
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/projection.d.ts +62 -0
- package/dist/mcp/projection.d.ts.map +1 -0
- package/dist/mcp/projection.js +390 -0
- package/dist/mcp/projection.js.map +1 -0
- package/dist/utils/password.d.ts +3 -1
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +4 -2
- package/dist/utils/password.js.map +1 -1
- package/package.json +2 -2
- package/src/access/access-filter.test.ts +1528 -18
- package/src/access/access-filter.ts +708 -42
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +109 -1
- package/src/access/errors.ts +97 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +171 -25
- package/src/access/index.ts +25 -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 +98 -32
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +203 -42
- package/src/context/nested-operations.ts +96 -27
- package/src/context/write-pipeline.ts +60 -36
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/hooks/index.ts +22 -15
- package/src/index.ts +17 -1
- package/src/lib/case-utils.test.ts +32 -0
- package/src/lib/case-utils.ts +26 -0
- package/src/lib/prisma-errors.ts +86 -0
- package/src/mcp/constants.ts +24 -0
- package/src/mcp/field-schema.ts +84 -0
- package/src/mcp/handler.ts +109 -107
- package/src/mcp/projection.ts +497 -0
- package/src/utils/password.ts +5 -3
- package/tests/access-relationships.test.ts +3 -3
- package/tests/access.test.ts +83 -0
- package/tests/context.test.ts +452 -17
- package/tests/field-types.test.ts +113 -0
- package/tests/hook-context-secured.test.ts +424 -0
- package/tests/mcp-fields-projection-access.test.ts +250 -0
- package/tests/mcp-handler.test.ts +695 -4
- package/tests/nested-access-and-hooks.test.ts +389 -0
- package/tests/nested-write-synthetic-relation.test.ts +437 -0
- package/tests/password-types.test.ts +28 -0
- package/tests/password.test.ts +16 -2
- package/tests/prisma-errors.test.ts +141 -0
- package/tests/singleton.test.ts +9 -1
- package/tests/to-one-include-access-filter.test.ts +172 -0
- package/tests/with-session.test.ts +243 -0
- package/tests/write-pipeline.test.ts +29 -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
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { resolveSyntheticReverseRelation } from './engine.js'
|
|
3
|
+
import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
|
|
4
|
+
|
|
5
|
+
// A relationship field pointing at another list.
|
|
6
|
+
function rel(ref: string, many = false): FieldConfig {
|
|
7
|
+
return { type: 'relationship', ref, many } as unknown as FieldConfig
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* #978: a list-only `ref` (`ref: 'ListName'`, no target field) generates a
|
|
12
|
+
* synthetic back-relation on its target — `from_<SourceList>_<sourceField>`
|
|
13
|
+
* (see `getSyntheticFieldName` in fields/index.ts). `resolveSyntheticReverseRelation`
|
|
14
|
+
* is the runtime counterpart that recognises the same name and resolves it
|
|
15
|
+
* back to the declared field that owns it, so a nested write addressed to it
|
|
16
|
+
* gets the same hook pipeline a declared relationship field gets.
|
|
17
|
+
*/
|
|
18
|
+
describe('resolveSyntheticReverseRelation', () => {
|
|
19
|
+
const config: OpenSaasConfig = {
|
|
20
|
+
db: { provider: 'sqlite', url: 'file:./dev.db' },
|
|
21
|
+
lists: {
|
|
22
|
+
Account: { fields: { name: { type: 'text' } as unknown as FieldConfig } },
|
|
23
|
+
ChargeRequest: {
|
|
24
|
+
fields: {
|
|
25
|
+
kind: { type: 'text' } as unknown as FieldConfig,
|
|
26
|
+
// List-only ref — no field named on Account, so a back-relation is
|
|
27
|
+
// synthesized on it as `from_ChargeRequest_account`.
|
|
28
|
+
account: rel('Account'),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
// A bidirectional relationship — Post.author names User.posts directly,
|
|
32
|
+
// so no back-relation is synthesized anywhere for it.
|
|
33
|
+
User: { fields: { posts: rel('Post.author', true) } },
|
|
34
|
+
Post: { fields: { author: rel('User.posts') } },
|
|
35
|
+
},
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
} as any
|
|
38
|
+
|
|
39
|
+
it('resolves the synthetic back-relation name to its owning field', () => {
|
|
40
|
+
const result = resolveSyntheticReverseRelation('from_ChargeRequest_account', 'Account', config)
|
|
41
|
+
expect(result).not.toBeNull()
|
|
42
|
+
expect(result?.sourceListName).toBe('ChargeRequest')
|
|
43
|
+
expect(result?.sourceFieldName).toBe('account')
|
|
44
|
+
expect(result?.sourceFieldConfig.ref).toBe('Account')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns null for a name that does not match the synthetic construction', () => {
|
|
48
|
+
expect(
|
|
49
|
+
resolveSyntheticReverseRelation('from_ChargeRequest_amount', 'Account', config),
|
|
50
|
+
).toBeNull()
|
|
51
|
+
expect(resolveSyntheticReverseRelation('chargeRequests', 'Account', config)).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns null on the wrong parent list', () => {
|
|
55
|
+
expect(
|
|
56
|
+
resolveSyntheticReverseRelation('from_ChargeRequest_account', 'ChargeRequest', config),
|
|
57
|
+
).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns null for a bidirectional ref — its other side is a real declared field, not a synthetic one', () => {
|
|
61
|
+
expect(resolveSyntheticReverseRelation('from_Post_author', 'User', config)).toBeNull()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns null for a genuinely unknown key', () => {
|
|
65
|
+
expect(resolveSyntheticReverseRelation('totallyBogusKey', 'Account', config)).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
})
|
package/src/access/engine.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { AccessControl, Session, AccessContext, PrismaFilter } from './types.js'
|
|
2
|
-
import type { OpenSaasConfig, ListConfig } from '../config/types.js'
|
|
2
|
+
import type { OpenSaasConfig, ListConfig, RelationshipField } from '../config/types.js'
|
|
3
|
+
import { getSyntheticFieldName } from '../fields/index.js'
|
|
4
|
+
import { InvalidCreateAccessResultError } from './errors.js'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Access engine — operation-level access control and shared helpers.
|
|
@@ -45,6 +47,81 @@ export function getRelatedListConfig(
|
|
|
45
47
|
return { listName, listConfig }
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/** A synthetic reverse relation, resolved back to the declared field that owns it. */
|
|
51
|
+
export interface SyntheticReverseRelation {
|
|
52
|
+
sourceListName: string
|
|
53
|
+
sourceFieldName: string
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
|
|
55
|
+
sourceFieldConfig: RelationshipField<any>
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
57
|
+
sourceListConfig: ListConfig<any>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a candidate data key as the synthetic back-relation a list-only
|
|
62
|
+
* `ref` (`ref: 'ListName'`, no target field) generates on its target model —
|
|
63
|
+
* Prisma requires an opposite field there, but the config never declares one,
|
|
64
|
+
* so it never appears in `parentListName`'s own `fields`. Reuses
|
|
65
|
+
* `getSyntheticFieldName` (the same construction `getPrismaRelation` emits the
|
|
66
|
+
* schema with) rather than re-deriving the `from_<List>_<field>` format by
|
|
67
|
+
* string parsing, so the two cannot drift (#978).
|
|
68
|
+
*
|
|
69
|
+
* Returns the declared relationship field that owns the relation — the write
|
|
70
|
+
* pipeline treats a resolved synthetic key exactly like a nested write through
|
|
71
|
+
* that field, so it runs the same hooks/access/recovery machinery a declared
|
|
72
|
+
* relationship field gets. Returns `null` when `fieldName` isn't one of these
|
|
73
|
+
* on `parentListName` (a genuinely unknown key, or a bidirectional relation's
|
|
74
|
+
* ref, which never synthesizes a back-relation).
|
|
75
|
+
*/
|
|
76
|
+
export function resolveSyntheticReverseRelation(
|
|
77
|
+
fieldName: string,
|
|
78
|
+
parentListName: string,
|
|
79
|
+
config: OpenSaasConfig,
|
|
80
|
+
): SyntheticReverseRelation | null {
|
|
81
|
+
for (const [sourceListName, sourceListConfig] of Object.entries(config.lists)) {
|
|
82
|
+
for (const [sourceFieldName, sourceFieldConfig] of Object.entries(sourceListConfig.fields)) {
|
|
83
|
+
if (sourceFieldConfig.type !== 'relationship') continue
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
|
|
85
|
+
const rel = sourceFieldConfig as RelationshipField<any>
|
|
86
|
+
// Only a list-only ref ('ListName', no '.fieldName') synthesizes a
|
|
87
|
+
// back-relation — a bidirectional ref's other side is a real field.
|
|
88
|
+
const refParts = rel.ref.split('.')
|
|
89
|
+
if (refParts.length !== 1 || refParts[0] !== parentListName) continue
|
|
90
|
+
if (getSyntheticFieldName(sourceListName, sourceFieldName) !== fieldName) continue
|
|
91
|
+
return { sourceListName, sourceFieldName, sourceFieldConfig: rel, sourceListConfig }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
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
|
+
|
|
48
125
|
export async function checkAccess<T = Record<string, unknown>>(
|
|
49
126
|
accessControl: AccessControl<T> | undefined,
|
|
50
127
|
args: {
|
|
@@ -63,6 +140,37 @@ export async function checkAccess<T = Record<string, unknown>>(
|
|
|
63
140
|
return result
|
|
64
141
|
}
|
|
65
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Evaluate operation-level `create` access. The single evaluator shared by the
|
|
145
|
+
* write pipeline's top-level create and the nested-create path — both must
|
|
146
|
+
* reject the same way, or the two drift again (#1009).
|
|
147
|
+
*
|
|
148
|
+
* Unlike `checkAccess` (which `query`/`update`/`delete` call directly and which
|
|
149
|
+
* legitimately returns a filter for them to re-check against a row), create
|
|
150
|
+
* has no existing row and no way to test a filter against input data. A rule
|
|
151
|
+
* that returns anything other than a strict boolean — most notably a filter,
|
|
152
|
+
* which type-checks against the shared `AccessControl` signature and reads as
|
|
153
|
+
* though it scopes the create — throws `InvalidCreateAccessResultError`
|
|
154
|
+
* rather than being silently treated as an allow. See that error's doc,
|
|
155
|
+
* ADR-0022, and ADR-0030.
|
|
156
|
+
*/
|
|
157
|
+
export async function checkCreateAccess<T = Record<string, unknown>>(
|
|
158
|
+
listKey: string,
|
|
159
|
+
accessControl: AccessControl<T> | undefined,
|
|
160
|
+
args: {
|
|
161
|
+
session: Session | null
|
|
162
|
+
context: AccessContext
|
|
163
|
+
},
|
|
164
|
+
): Promise<boolean> {
|
|
165
|
+
const result = await checkAccess(accessControl, args)
|
|
166
|
+
|
|
167
|
+
if (isBoolean(result)) {
|
|
168
|
+
return result
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
throw new InvalidCreateAccessResultError(listKey, result)
|
|
172
|
+
}
|
|
173
|
+
|
|
66
174
|
export function mergeFilters(
|
|
67
175
|
userFilter: PrismaFilter | undefined,
|
|
68
176
|
accessFilter: boolean | PrismaFilter,
|
package/src/access/errors.ts
CHANGED
|
@@ -99,7 +99,62 @@ export class RelationFilterAccessDeniedError extends Error {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
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
|
+
|
|
157
|
+
function describeAccessResult(result: unknown): string {
|
|
103
158
|
if (result === null) return 'null'
|
|
104
159
|
if (result === undefined) return 'undefined'
|
|
105
160
|
if (typeof result === 'object') return 'an object (e.g. a Prisma filter)'
|
|
@@ -135,7 +190,7 @@ export class InvalidFieldAccessResultError extends Error {
|
|
|
135
190
|
constructor(operation: 'read' | 'create' | 'update', result: unknown) {
|
|
136
191
|
super(
|
|
137
192
|
`Field-level access control for operation "${operation}" returned ` +
|
|
138
|
-
`${
|
|
193
|
+
`${describeAccessResult(result)}, not a boolean. Field access is a per-field ` +
|
|
139
194
|
`visibility decision — it must return true or false, and (unlike operation-level access) ` +
|
|
140
195
|
`cannot scope which rows are affected. If you meant to restrict access based on the row or ` +
|
|
141
196
|
`the write payload, evaluate the condition yourself and return a boolean, e.g. ` +
|
|
@@ -145,3 +200,43 @@ export class InvalidFieldAccessResultError extends Error {
|
|
|
145
200
|
this.operation = operation
|
|
146
201
|
}
|
|
147
202
|
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Thrown when operation-level `create` access control returns anything other
|
|
206
|
+
* than a strict `boolean`. `OperationAccess['create']` is typed as
|
|
207
|
+
* `AccessControl`, which also accepts a `PrismaFilter` — the shape
|
|
208
|
+
* `query`/`update`/`delete` legitimately use to scope which rows an
|
|
209
|
+
* operation may touch (see ADR-0001). Create has none of that to scope: there
|
|
210
|
+
* is no existing row, and — unlike update/delete, which re-check a returned
|
|
211
|
+
* filter against the target via `findFirst` — no equivalent re-check is
|
|
212
|
+
* possible against data that doesn't exist in the database yet.
|
|
213
|
+
*
|
|
214
|
+
* A filter-returning `create` rule type-checks (the shared `AccessControl`
|
|
215
|
+
* type admits it) and reads as though it scopes the create. Before this it
|
|
216
|
+
* fell through a check that only tested `=== false` and was silently treated
|
|
217
|
+
* as a full allow. This is the same defect shape ADR-0030 closed for
|
|
218
|
+
* field-level access, resolved the same way per ADR-0022 (an engine that
|
|
219
|
+
* cannot compute a scope must deny, never pass through) — a loud, distinct
|
|
220
|
+
* failure rather than a wider `null`/`[]` Silent failure, because this is a
|
|
221
|
+
* config bug, not an access denial.
|
|
222
|
+
*
|
|
223
|
+
* Deliberately does not expose the offending result as a public field, for
|
|
224
|
+
* the same reason as `InvalidFieldAccessResultError`: there is no
|
|
225
|
+
* concretely-typed shape to give it, and the root CLAUDE.md forbids exposing
|
|
226
|
+
* `unknown`/`any` as part of a package's external API.
|
|
227
|
+
*/
|
|
228
|
+
export class InvalidCreateAccessResultError extends Error {
|
|
229
|
+
public listKey: string
|
|
230
|
+
|
|
231
|
+
constructor(listKey: string, result: unknown) {
|
|
232
|
+
super(
|
|
233
|
+
`Operation-level "create" access control for "${listKey}" returned ` +
|
|
234
|
+
`${describeAccessResult(result)}, not a boolean. Create cannot be row-scoped — there is no ` +
|
|
235
|
+
`existing row, and no input data is available to test a filter against here. Return a ` +
|
|
236
|
+
`boolean from create access, or move the ownership check into a \`resolveInput\` or ` +
|
|
237
|
+
`\`validate\` hook, where the input data is in scope.`,
|
|
238
|
+
)
|
|
239
|
+
this.name = 'InvalidCreateAccessResultError'
|
|
240
|
+
this.listKey = listKey
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -712,3 +712,81 @@ describe('filterWritableFields', () => {
|
|
|
712
712
|
expect(filtered.author).toEqual({ connect: { id: 'user-1' } })
|
|
713
713
|
})
|
|
714
714
|
})
|
|
715
|
+
|
|
716
|
+
// ── #978: under sudo, only a synthetic reverse-relation key is a recognised
|
|
717
|
+
// undeclared key — anything else is refused, even under sudo ──────────────
|
|
718
|
+
|
|
719
|
+
describe('filterWritableFields — #978 tightened sudo undeclared-key guard', () => {
|
|
720
|
+
// A minimal config: Account is the target of ChargeRequest's list-only ref
|
|
721
|
+
// (`ref: 'Account'`, no field named on Account), so the generator
|
|
722
|
+
// synthesizes `from_ChargeRequest_account` on Account.
|
|
723
|
+
const config = {
|
|
724
|
+
db: { provider: 'sqlite', url: 'file:./dev.db' },
|
|
725
|
+
lists: {
|
|
726
|
+
Account: { fields: { name: { type: 'text' } } },
|
|
727
|
+
ChargeRequest: {
|
|
728
|
+
fields: {
|
|
729
|
+
kind: { type: 'text' },
|
|
730
|
+
account: { type: 'relationship', ref: 'Account' },
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
735
|
+
} as any
|
|
736
|
+
|
|
737
|
+
it('passes a synthetic reverse-relation key through under sudo, when config/listName are supplied', async () => {
|
|
738
|
+
const fieldConfigs = { name: { type: 'text' } }
|
|
739
|
+
const data = {
|
|
740
|
+
name: 'Acme',
|
|
741
|
+
from_ChargeRequest_account: { create: { kind: 'DEPOSIT' } },
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'update', {
|
|
745
|
+
session: null,
|
|
746
|
+
item: { id: 'a1' },
|
|
747
|
+
context: sudoContext(),
|
|
748
|
+
inputData: data,
|
|
749
|
+
listName: 'Account',
|
|
750
|
+
config,
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
expect(filtered).toHaveProperty('name', 'Acme')
|
|
754
|
+
expect(filtered).toHaveProperty('from_ChargeRequest_account')
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
it('throws on a genuinely unknown key under sudo, when config/listName are supplied', async () => {
|
|
758
|
+
const fieldConfigs = { name: { type: 'text' } }
|
|
759
|
+
const data = {
|
|
760
|
+
name: 'Acme',
|
|
761
|
+
totallyBogusKey: 'value',
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
await expect(
|
|
765
|
+
filterWritableFields(data, fieldConfigs, 'update', {
|
|
766
|
+
session: null,
|
|
767
|
+
item: { id: 'a1' },
|
|
768
|
+
context: sudoContext(),
|
|
769
|
+
inputData: data,
|
|
770
|
+
listName: 'Account',
|
|
771
|
+
config,
|
|
772
|
+
}),
|
|
773
|
+
).rejects.toThrow(/totallyBogusKey/)
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
it('keeps the pre-#978 blanket sudo passthrough when config/listName are omitted', async () => {
|
|
777
|
+
// Pins the fallback direct unit tests above (e.g. "passes undeclared data
|
|
778
|
+
// keys through under sudo") rely on: with no config to resolve a synthetic
|
|
779
|
+
// key against, any undeclared key still passes through under sudo.
|
|
780
|
+
const fieldConfigs = { name: { type: 'text' } }
|
|
781
|
+
const data = { name: 'Acme', totallyBogusKey: 'value' }
|
|
782
|
+
|
|
783
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'update', {
|
|
784
|
+
session: null,
|
|
785
|
+
item: { id: 'a1' },
|
|
786
|
+
context: sudoContext(),
|
|
787
|
+
inputData: data,
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
expect(filtered).toHaveProperty('totallyBogusKey', 'value')
|
|
791
|
+
})
|
|
792
|
+
})
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { Session, AccessContext } from './types.js'
|
|
2
2
|
import type { FieldAccess, FieldAccessControl } from './types.js'
|
|
3
|
+
import type { OpenSaasConfig } from '../config/types.js'
|
|
3
4
|
// `ValidationError` is referenced only inside function bodies (call-time), never
|
|
4
5
|
// at module-evaluation time, so the field-access ⇄ hooks import cycle is safe
|
|
5
6
|
// under ESM live bindings.
|
|
6
7
|
import { ValidationError } from '../hooks/index.js'
|
|
7
8
|
import { InvalidFieldAccessResultError } from './errors.js'
|
|
9
|
+
import { resolveSyntheticReverseRelation } from './engine.js'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Marks a throw caused by touching {@link createPoisonedItem}'s `item`, as
|
|
@@ -196,6 +198,17 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
|
196
198
|
item?: Record<string, unknown>
|
|
197
199
|
context: AccessContext & { _isSudo?: boolean }
|
|
198
200
|
inputData?: Record<string, unknown>
|
|
201
|
+
/**
|
|
202
|
+
* The list being written and the full config — used ONLY to recognise a
|
|
203
|
+
* synthetic reverse-relation key (`from_<List>_<field>`, #978) among the
|
|
204
|
+
* undeclared keys sudo would otherwise pass through unchecked. Both
|
|
205
|
+
* production call sites (the write pipeline, nested-operations) supply
|
|
206
|
+
* these; a direct unit test that omits them keeps the pre-#978 sudo
|
|
207
|
+
* behaviour of passing any undeclared key through, since it has no config
|
|
208
|
+
* to resolve a synthetic key against.
|
|
209
|
+
*/
|
|
210
|
+
listName?: string
|
|
211
|
+
config?: OpenSaasConfig
|
|
199
212
|
},
|
|
200
213
|
): Promise<Partial<T>> {
|
|
201
214
|
const filtered: Record<string, unknown> = {}
|
|
@@ -286,10 +299,29 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
|
286
299
|
// declares (e.g. back-relations like `from_Enrolment_student`), so allowing
|
|
287
300
|
// an undeclared key to pass through lets a non-sudo caller drive ungated
|
|
288
301
|
// nested writes on undeclared back-relations. Mirror Keystone's
|
|
289
|
-
// GraphQL-schema behaviour and reject it.
|
|
290
|
-
// bypass, so undeclared keys still pass through under sudo.
|
|
302
|
+
// GraphQL-schema behaviour and reject it.
|
|
291
303
|
if (!fieldConfig) {
|
|
292
304
|
if (isSudo) {
|
|
305
|
+
// #978 — sudo bypasses ACCESS CONTROL, not the hooks/validation a
|
|
306
|
+
// recognised relation is entitled to. A synthetic reverse-relation key
|
|
307
|
+
// (a list-only ref's back-relation) is handed to the caller unchanged
|
|
308
|
+
// so processNestedOperations can run its target list's full pipeline,
|
|
309
|
+
// exactly as it would for a declared relationship field. Any other
|
|
310
|
+
// undeclared key has no such route to hooks — passing it straight to
|
|
311
|
+
// Prisma is the same silent-bypass shape this issue closed for
|
|
312
|
+
// relations, so it is refused even under sudo. `listName`/`config`
|
|
313
|
+
// are omitted only by direct unit tests of this function, which keep
|
|
314
|
+
// the pre-#978 blanket sudo passthrough since they have no config to
|
|
315
|
+
// resolve a synthetic key against.
|
|
316
|
+
if (args.listName && args.config) {
|
|
317
|
+
const synthetic = resolveSyntheticReverseRelation(fieldName, args.listName, args.config)
|
|
318
|
+
if (!synthetic) {
|
|
319
|
+
throw new ValidationError([
|
|
320
|
+
`Cannot ${operation} "${fieldName}": it is not a field of this list. ` +
|
|
321
|
+
`Undeclared data keys are rejected, even under sudo.`,
|
|
322
|
+
])
|
|
323
|
+
}
|
|
324
|
+
}
|
|
293
325
|
filtered[fieldName] = value
|
|
294
326
|
continue
|
|
295
327
|
}
|