@pikku/core 0.12.64 → 0.12.67
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/CHANGELOG.md +311 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/permissions.d.ts +12 -4
- package/dist/permissions.js +11 -32
- package/dist/scopes.d.ts +14 -0
- package/dist/scopes.js +39 -8
- package/dist/testing/service-tests.js +37 -0
- package/dist/types/core.types.d.ts +22 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
- package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
- package/dist/wirings/ai-agent/index.d.ts +1 -1
- package/dist/wirings/ai-agent/index.js +1 -1
- package/dist/wirings/ai-agent/voice-input.js +3 -3
- package/dist/wirings/cli/cli-runner.js +3 -0
- package/dist/wirings/cli/command-parser.d.ts +2 -0
- package/dist/wirings/cli/command-parser.js +59 -2
- package/dist/wirings/credential/credential.types.d.ts +14 -0
- package/dist/wirings/credential/validate-credential-definitions.js +1 -0
- package/dist/wirings/gateway/gateway-runner.js +100 -50
- package/dist/wirings/gateway/gateway.types.d.ts +8 -5
- package/dist/wirings/http/http.types.d.ts +3 -3
- package/dist/wirings/scope/validate-scope-definitions.d.ts +8 -0
- package/dist/wirings/scope/validate-scope-definitions.js +16 -1
- package/dist/wirings/secret/secret.types.d.ts +14 -0
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/variable/validate-variable-definitions.js +2 -0
- package/dist/wirings/variable/variable.types.d.ts +14 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
- package/dist/wirings/workflow/workflow.types.d.ts +0 -2
- package/package.json +2 -1
- package/src/index.ts +2 -1
- package/src/permissions.test.ts +14 -8
- package/src/permissions.ts +14 -36
- package/src/scopes.test.ts +37 -1
- package/src/scopes.ts +48 -9
- package/src/testing/service-tests.ts +49 -0
- package/src/types/core.types.ts +23 -0
- package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
- package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
- package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
- package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
- package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
- package/src/wirings/ai-agent/index.ts +4 -0
- package/src/wirings/ai-agent/voice-input.test.ts +90 -0
- package/src/wirings/ai-agent/voice-input.ts +8 -10
- package/src/wirings/cli/cli-runner.ts +4 -0
- package/src/wirings/cli/command-parser.test.ts +130 -0
- package/src/wirings/cli/command-parser.ts +80 -2
- package/src/wirings/credential/credential.types.ts +14 -0
- package/src/wirings/credential/validate-credential-definitions.ts +1 -0
- package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
- package/src/wirings/gateway/gateway-runner.ts +114 -68
- package/src/wirings/gateway/gateway.types.ts +7 -9
- package/src/wirings/http/http.types.ts +6 -4
- package/src/wirings/scope/scope.test.ts +25 -0
- package/src/wirings/scope/validate-scope-definitions.ts +16 -1
- package/src/wirings/secret/secret.types.ts +14 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/variable/validate-variable-definitions.ts +2 -0
- package/src/wirings/variable/variable.types.ts +14 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
- package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
- package/src/wirings/workflow/workflow.types.ts +0 -2
- package/tsconfig.tsbuildinfo +1 -1
package/src/permissions.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CoreServices,
|
|
3
3
|
CoreUserSession,
|
|
4
|
-
PermissionMetadata,
|
|
5
4
|
PikkuWire,
|
|
6
5
|
} from './types/core.types.js'
|
|
7
6
|
import type {
|
|
@@ -60,29 +59,6 @@ const verifyPermissions = async (
|
|
|
60
59
|
return false
|
|
61
60
|
}
|
|
62
61
|
|
|
63
|
-
/**
|
|
64
|
-
* Retrieves a registered permission function by its name.
|
|
65
|
-
*
|
|
66
|
-
* This function looks up permissions that was registered with registerPermission.
|
|
67
|
-
* It's used internally by the framework to resolve permission references in metadata.
|
|
68
|
-
*
|
|
69
|
-
* @param {string} name - The unique name (pikkuFuncId) of the permission function.
|
|
70
|
-
* @param {string | null} packageName - Optional package namespace.
|
|
71
|
-
* @returns {CorePikkuPermission | undefined} The permission function, or undefined if not found.
|
|
72
|
-
*
|
|
73
|
-
* @internal
|
|
74
|
-
*/
|
|
75
|
-
const getPermissionByName = (
|
|
76
|
-
name: string,
|
|
77
|
-
packageName: string | null = null
|
|
78
|
-
): CorePikkuPermission | undefined => {
|
|
79
|
-
const permissionStore = pikkuState(packageName, 'misc', 'permissions')
|
|
80
|
-
const permission = permissionStore[name]
|
|
81
|
-
if (Array.isArray(permission) && permission.length === 1) {
|
|
82
|
-
return permission[0]
|
|
83
|
-
}
|
|
84
|
-
return undefined
|
|
85
|
-
}
|
|
86
62
|
|
|
87
63
|
const globalPermissionsCache: Record<
|
|
88
64
|
string,
|
|
@@ -190,12 +166,15 @@ export const runPermissions = async ({
|
|
|
190
166
|
wire,
|
|
191
167
|
data,
|
|
192
168
|
packageName = null,
|
|
169
|
+
label = 'function',
|
|
193
170
|
}: {
|
|
194
171
|
funcPermissions?: CorePermissionGroup | CorePikkuPermission[]
|
|
195
172
|
services: CoreServices
|
|
196
173
|
wire: PikkuWire<any, never, any, CoreUserSession, never, never, never>
|
|
197
174
|
data: any
|
|
198
175
|
packageName?: string | null
|
|
176
|
+
/** What the non-global gate is called in debug logs, e.g. 'function', 'agent'. */
|
|
177
|
+
label?: string
|
|
199
178
|
}) => {
|
|
200
179
|
const globals = resolveGlobalPermissions(packageName)
|
|
201
180
|
for (const entry of globals) {
|
|
@@ -211,7 +190,7 @@ export const runPermissions = async ({
|
|
|
211
190
|
: funcPermissions
|
|
212
191
|
if (group && Object.keys(group).length > 0) {
|
|
213
192
|
if (!(await verifyPermissions(group, services, data, wire))) {
|
|
214
|
-
services.logger.debug(
|
|
193
|
+
services.logger.debug(`Permission denied - ${label} permission`)
|
|
215
194
|
throw new ForbiddenError('Permission denied')
|
|
216
195
|
}
|
|
217
196
|
}
|
|
@@ -224,14 +203,20 @@ export const runPermissions = async ({
|
|
|
224
203
|
* request data which isn't available at filter time. Global auth requirements
|
|
225
204
|
* are included so a filtered list honours app-wide auth.
|
|
226
205
|
*
|
|
227
|
-
*
|
|
206
|
+
* `funcPermissions` is the live {@link CorePermissionGroup} from the function or
|
|
207
|
+
* agent config, not the metadata form: the `pikkuAuth` brand only survives on
|
|
208
|
+
* the actual predicate objects, and the by-name registry those metadata entries
|
|
209
|
+
* would resolve against is never populated. Passing metadata here would silently
|
|
210
|
+
* collect nothing and let every gated tool through.
|
|
211
|
+
*
|
|
212
|
+
* @param funcPermissions - The live permission group from the func/agent config
|
|
228
213
|
* @param session - The user's session
|
|
229
214
|
* @param services - Singleton services
|
|
230
215
|
* @param packageName - Optional package namespace
|
|
231
216
|
* @returns true if the session passes the auth checks (or no auth checks exist)
|
|
232
217
|
*/
|
|
233
218
|
export const checkAuthPermissions = async (
|
|
234
|
-
funcPermissions:
|
|
219
|
+
funcPermissions: CorePermissionGroup | undefined,
|
|
235
220
|
session: CoreUserSession,
|
|
236
221
|
services: CoreServices,
|
|
237
222
|
packageName: string | null = null
|
|
@@ -271,15 +256,8 @@ export const checkAuthPermissions = async (
|
|
|
271
256
|
collect(entry)
|
|
272
257
|
}
|
|
273
258
|
|
|
274
|
-
if (funcPermissions
|
|
275
|
-
|
|
276
|
-
if (meta.type === 'wire') {
|
|
277
|
-
const permission = getPermissionByName(meta.name, packageName)
|
|
278
|
-
if (permission) {
|
|
279
|
-
collect(permission)
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
259
|
+
if (funcPermissions) {
|
|
260
|
+
collect(funcPermissions)
|
|
283
261
|
}
|
|
284
262
|
|
|
285
263
|
// No auth permissions = allowed (only data-dependent permissions exist)
|
package/src/scopes.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test } from 'node:test'
|
|
2
2
|
import * as assert from 'node:assert'
|
|
3
|
-
import { verifyScopes } from './scopes.js'
|
|
3
|
+
import { hasScopes, verifyScopes } from './scopes.js'
|
|
4
4
|
import { MissingScopeError } from './errors/errors.js'
|
|
5
5
|
import type { CoreUserSession } from './types/core.types.js'
|
|
6
6
|
|
|
@@ -165,3 +165,39 @@ describe('verifyScopes', () => {
|
|
|
165
165
|
)
|
|
166
166
|
})
|
|
167
167
|
})
|
|
168
|
+
|
|
169
|
+
describe('hasScopes', () => {
|
|
170
|
+
test('true on an exact match', () => {
|
|
171
|
+
assert.equal(hasScopes(['invoices:create'], ['invoices:create']), true)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('false when the grant is unrelated', () => {
|
|
175
|
+
assert.equal(hasScopes(['invoices:create'], ['billing:read']), false)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('shares the hierarchy rules with verifyScopes', () => {
|
|
179
|
+
assert.equal(hasScopes(['admin:impersonate'], ['admin']), true)
|
|
180
|
+
assert.equal(hasScopes(['admin:impersonate'], ['admin:*']), true)
|
|
181
|
+
assert.equal(hasScopes(['admin:impersonate'], ['*']), true)
|
|
182
|
+
assert.equal(hasScopes(['admin'], ['admin:impersonate']), false)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('AND semantics: every required scope must be satisfied', () => {
|
|
186
|
+
assert.equal(hasScopes(['a:read', 'a:write'], ['a:read']), false)
|
|
187
|
+
assert.equal(hasScopes(['a:read', 'a:write'], ['a']), true)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('fails closed on absent or empty grants', () => {
|
|
191
|
+
assert.equal(hasScopes(['invoices:create'], undefined), false)
|
|
192
|
+
assert.equal(hasScopes(['invoices:create'], []), false)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('an empty requirement is satisfied by anything', () => {
|
|
196
|
+
assert.equal(hasScopes([], undefined), true)
|
|
197
|
+
assert.equal(hasScopes(undefined, undefined), true)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('accepts a Set of held grants', () => {
|
|
201
|
+
assert.equal(hasScopes(['admin:impersonate'], new Set(['admin'])), true)
|
|
202
|
+
})
|
|
203
|
+
})
|
package/src/scopes.ts
CHANGED
|
@@ -37,6 +37,51 @@ const satisfyingGrants = (scope: string): string[] => {
|
|
|
37
37
|
const holds = (held: ReadonlySet<string>, scope: string): boolean =>
|
|
38
38
|
satisfyingGrants(scope).some((grant) => held.has(grant))
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The first required scope a set of held grants does not satisfy, or `null`
|
|
42
|
+
* when every one is satisfied.
|
|
43
|
+
*
|
|
44
|
+
* Scopes are an AND gate: every entry in `required` must be satisfied. This is
|
|
45
|
+
* deliberately distinct from `permissions`, which OR together — a scope can
|
|
46
|
+
* only ever narrow access, so adding one to a function can never widen it.
|
|
47
|
+
*
|
|
48
|
+
* Fails closed: an absent `held` satisfies nothing.
|
|
49
|
+
*/
|
|
50
|
+
const firstUnsatisfied = (
|
|
51
|
+
required: readonly string[] | undefined,
|
|
52
|
+
held: Iterable<string> | undefined
|
|
53
|
+
): string | null => {
|
|
54
|
+
if (!required || required.length === 0) {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const grants = new Set(held ?? [])
|
|
59
|
+
for (const scope of required) {
|
|
60
|
+
if (!holds(grants, scope)) {
|
|
61
|
+
return scope
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether a set of held grants satisfies every required scope.
|
|
69
|
+
*
|
|
70
|
+
* The non-throwing counterpart to {@link verifyScopes}, for deciding rather
|
|
71
|
+
* than enforcing — an authorization gate that falls back to another check when
|
|
72
|
+
* it is not satisfied, rather than rejecting the request outright.
|
|
73
|
+
*
|
|
74
|
+
* Fails closed: an absent or empty `held` satisfies nothing. An empty
|
|
75
|
+
* `required` is satisfied by anything.
|
|
76
|
+
*
|
|
77
|
+
* @param required - Scopes to check for. Empty means no gate.
|
|
78
|
+
* @param held - The grants held, e.g. `session.scopes`. May be undefined.
|
|
79
|
+
*/
|
|
80
|
+
export const hasScopes = (
|
|
81
|
+
required: readonly string[] | undefined,
|
|
82
|
+
held: Iterable<string> | undefined
|
|
83
|
+
): boolean => firstUnsatisfied(required, held) === null
|
|
84
|
+
|
|
40
85
|
/**
|
|
41
86
|
* Verifies that a session holds every required scope, throwing on the first
|
|
42
87
|
* one it does not.
|
|
@@ -56,14 +101,8 @@ export const verifyScopes = (
|
|
|
56
101
|
required: readonly string[] | undefined,
|
|
57
102
|
session: CoreUserSession | undefined
|
|
58
103
|
): void => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const held = new Set(session?.scopes ?? [])
|
|
64
|
-
for (const scope of required) {
|
|
65
|
-
if (!holds(held, scope)) {
|
|
66
|
-
throw new MissingScopeError(scope)
|
|
67
|
-
}
|
|
104
|
+
const missing = firstUnsatisfied(required, session?.scopes)
|
|
105
|
+
if (missing !== null) {
|
|
106
|
+
throw new MissingScopeError(missing)
|
|
68
107
|
}
|
|
69
108
|
}
|
|
@@ -1086,6 +1086,55 @@ export function defineServiceTests(config: ServiceTestConfig): void {
|
|
|
1086
1086
|
assert.ok(Array.isArray(threads))
|
|
1087
1087
|
})
|
|
1088
1088
|
|
|
1089
|
+
if (services.aiStorageService) {
|
|
1090
|
+
const storageFactory = services.aiStorageService
|
|
1091
|
+
|
|
1092
|
+
// The `owners` constraint is what keeps the generated thread-management
|
|
1093
|
+
// functions from leaking across tenants: a caller may only list threads
|
|
1094
|
+
// owned by one of their session principals, matching the
|
|
1095
|
+
// `principal:sub-partition` composition resolveOwnerResourceId writes.
|
|
1096
|
+
test('listThreads scopes to the given owners, including sub-partitions', async () => {
|
|
1097
|
+
const storage = await storageFactory()
|
|
1098
|
+
await storage.createThread('owner-alice')
|
|
1099
|
+
await storage.createThread('owner-alice:project-1')
|
|
1100
|
+
await storage.createThread('owner-bob:secret')
|
|
1101
|
+
|
|
1102
|
+
const threads = await agentService.listThreads({
|
|
1103
|
+
owners: ['owner-alice'],
|
|
1104
|
+
})
|
|
1105
|
+
const ids = threads.map((t) => t.resourceId)
|
|
1106
|
+
|
|
1107
|
+
assert.ok(ids.includes('owner-alice'))
|
|
1108
|
+
assert.ok(ids.includes('owner-alice:project-1'))
|
|
1109
|
+
assert.ok(
|
|
1110
|
+
!ids.some((id) => id.startsWith('owner-bob')),
|
|
1111
|
+
"another owner's threads must not be listed"
|
|
1112
|
+
)
|
|
1113
|
+
})
|
|
1114
|
+
|
|
1115
|
+
test('listThreads with an owner does not match a lookalike prefix', async () => {
|
|
1116
|
+
const storage = await storageFactory()
|
|
1117
|
+
await storage.createThread('owner-al')
|
|
1118
|
+
await storage.createThread('owner-alice-evil:p')
|
|
1119
|
+
|
|
1120
|
+
const threads = await agentService.listThreads({
|
|
1121
|
+
owners: ['owner-al'],
|
|
1122
|
+
})
|
|
1123
|
+
const ids = threads.map((t) => t.resourceId)
|
|
1124
|
+
|
|
1125
|
+
assert.ok(ids.includes('owner-al'))
|
|
1126
|
+
assert.ok(!ids.includes('owner-alice-evil:p'))
|
|
1127
|
+
})
|
|
1128
|
+
|
|
1129
|
+
test('listThreads with an empty owners list returns nothing', async () => {
|
|
1130
|
+
const storage = await storageFactory()
|
|
1131
|
+
await storage.createThread('owner-empty-check')
|
|
1132
|
+
|
|
1133
|
+
const threads = await agentService.listThreads({ owners: [] })
|
|
1134
|
+
assert.deepEqual(threads, [])
|
|
1135
|
+
})
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1089
1138
|
test('getThread returns null for missing', async () => {
|
|
1090
1139
|
const thread = await agentService.getThread('missing-thread')
|
|
1091
1140
|
assert.equal(thread, null)
|
package/src/types/core.types.ts
CHANGED
|
@@ -269,6 +269,22 @@ export interface CoreUserSession {
|
|
|
269
269
|
scopes?: string[]
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
/**
|
|
273
|
+
* The shape pikku needs from whatever auth library a project wires: something
|
|
274
|
+
* that can answer an HTTP request and expose its own endpoints as callable
|
|
275
|
+
* methods. Kept structural so core stays independent of any one auth package —
|
|
276
|
+
* `@pikku/better-auth`'s `BetterAuthInstance` is this type.
|
|
277
|
+
*/
|
|
278
|
+
export interface AuthInstance {
|
|
279
|
+
handler: (request: Request) => Promise<Response>
|
|
280
|
+
api: Record<string, any>
|
|
281
|
+
/**
|
|
282
|
+
* The auth library's resolved context. Optional because a hand-built instance
|
|
283
|
+
* may omit it.
|
|
284
|
+
*/
|
|
285
|
+
$context?: Promise<any>
|
|
286
|
+
}
|
|
287
|
+
|
|
272
288
|
/**
|
|
273
289
|
* Interface for core singleton services provided by Pikku.
|
|
274
290
|
*/
|
|
@@ -341,6 +357,13 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
|
|
|
341
357
|
* better-auth's `mapSession`), never by the function runner.
|
|
342
358
|
*/
|
|
343
359
|
scopeService?: ScopeService
|
|
360
|
+
/**
|
|
361
|
+
* The project's resolved auth instance, built once by the factory an auth
|
|
362
|
+
* package registers (e.g. `pikkuBetterAuth`) and injected by the generated
|
|
363
|
+
* `pikkuServices` wrapper — which is why service factories are forbidden from
|
|
364
|
+
* returning it themselves. Absent when the project wires no auth.
|
|
365
|
+
*/
|
|
366
|
+
auth?: () => Promise<AuthInstance>
|
|
344
367
|
}
|
|
345
368
|
|
|
346
369
|
/**
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { beforeEach, describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
resetPikkuState,
|
|
6
|
+
pikkuState,
|
|
7
|
+
setSingletonServices,
|
|
8
|
+
} from '../../pikku-state.js'
|
|
9
|
+
import { assertAgentAuthorized } from './ai-agent-prepare.js'
|
|
10
|
+
import { clearPermissionsCache } from '../../permissions.js'
|
|
11
|
+
import { ForbiddenError, MissingScopeError } from '../../errors/errors.js'
|
|
12
|
+
import type { CoreAIAgent } from './ai-agent.types.js'
|
|
13
|
+
import type { CoreUserSession } from '../../types/core.types.js'
|
|
14
|
+
|
|
15
|
+
const logger = {
|
|
16
|
+
debug: () => {},
|
|
17
|
+
info: () => {},
|
|
18
|
+
warn: () => {},
|
|
19
|
+
error: () => {},
|
|
20
|
+
} as any
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
resetPikkuState()
|
|
24
|
+
clearPermissionsCache()
|
|
25
|
+
setSingletonServices({ logger } as any)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const addAgent = (
|
|
29
|
+
agentName: string,
|
|
30
|
+
overrides: Partial<CoreAIAgent> = {}
|
|
31
|
+
): CoreAIAgent => {
|
|
32
|
+
const agent: CoreAIAgent = {
|
|
33
|
+
name: agentName,
|
|
34
|
+
description: `${agentName} description`,
|
|
35
|
+
goal: `${agentName} goal`,
|
|
36
|
+
instructions: `${agentName} instructions`,
|
|
37
|
+
model: 'test/test-model',
|
|
38
|
+
...overrides,
|
|
39
|
+
} as CoreAIAgent
|
|
40
|
+
|
|
41
|
+
pikkuState(null, 'agent', 'agents').set(agentName, agent)
|
|
42
|
+
return agent
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sessionService = (session: CoreUserSession | undefined) =>
|
|
46
|
+
({
|
|
47
|
+
get: () => session,
|
|
48
|
+
setInitial: () => {},
|
|
49
|
+
sessionChanged: false,
|
|
50
|
+
}) as any
|
|
51
|
+
|
|
52
|
+
describe('assertAgentAuthorized', () => {
|
|
53
|
+
test('resolves when the agent declares no authorization and a session exists', async () => {
|
|
54
|
+
const agent = addAgent('open')
|
|
55
|
+
await assertAgentAuthorized(
|
|
56
|
+
agent,
|
|
57
|
+
{ sessionService: sessionService({ userId: 'u1' }) },
|
|
58
|
+
null
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('does not require a session unless auth is explicitly true', async () => {
|
|
63
|
+
const agent = addAgent('sessionless')
|
|
64
|
+
await assertAgentAuthorized(
|
|
65
|
+
agent,
|
|
66
|
+
{ sessionService: sessionService(undefined) },
|
|
67
|
+
null
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('resolves with no sessionService at all (cron / queue invocation)', async () => {
|
|
72
|
+
const agent = addAgent('no-session-service')
|
|
73
|
+
await assertAgentAuthorized(agent, {}, null)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('throws ForbiddenError when auth is explicitly true and no session exists', async () => {
|
|
77
|
+
const agent = addAgent('needs-session', { auth: true })
|
|
78
|
+
await assert.rejects(
|
|
79
|
+
() =>
|
|
80
|
+
assertAgentAuthorized(
|
|
81
|
+
agent,
|
|
82
|
+
{ sessionService: sessionService(undefined) },
|
|
83
|
+
null
|
|
84
|
+
),
|
|
85
|
+
ForbiddenError
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('enforces scopes even when auth is not required', async () => {
|
|
90
|
+
const agent = addAgent('scoped-sessionless', { scopes: ['reports:read'] })
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
() => assertAgentAuthorized(agent, {}, null),
|
|
93
|
+
MissingScopeError
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('throws ForbiddenError when the permission group denies', async () => {
|
|
98
|
+
const agent = addAgent('guarded', {
|
|
99
|
+
permissions: { admin: async () => false },
|
|
100
|
+
})
|
|
101
|
+
await assert.rejects(
|
|
102
|
+
() =>
|
|
103
|
+
assertAgentAuthorized(
|
|
104
|
+
agent,
|
|
105
|
+
{ sessionService: sessionService({ userId: 'u1' }) },
|
|
106
|
+
null
|
|
107
|
+
),
|
|
108
|
+
ForbiddenError
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('resolves when at least one permission branch passes', async () => {
|
|
113
|
+
const agent = addAgent('or-group', {
|
|
114
|
+
permissions: {
|
|
115
|
+
admin: async () => false,
|
|
116
|
+
owner: async () => true,
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
await assertAgentAuthorized(
|
|
120
|
+
agent,
|
|
121
|
+
{ sessionService: sessionService({ userId: 'u1' }) },
|
|
122
|
+
null
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('passes the session through to permission functions', async () => {
|
|
127
|
+
let seen: CoreUserSession | undefined
|
|
128
|
+
const agent = addAgent('sees-session', {
|
|
129
|
+
permissions: {
|
|
130
|
+
admin: async (_services: any, _data: any, wire: any) => {
|
|
131
|
+
seen = wire.session
|
|
132
|
+
return true
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
await assertAgentAuthorized(
|
|
137
|
+
agent,
|
|
138
|
+
{ sessionService: sessionService({ userId: 'u1' }) },
|
|
139
|
+
null
|
|
140
|
+
)
|
|
141
|
+
assert.equal(seen?.userId, 'u1')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('throws MissingScopeError when a required scope is not held', async () => {
|
|
145
|
+
const agent = addAgent('scoped', { scopes: ['reports:read'] })
|
|
146
|
+
await assert.rejects(
|
|
147
|
+
() =>
|
|
148
|
+
assertAgentAuthorized(
|
|
149
|
+
agent,
|
|
150
|
+
{ sessionService: sessionService({ userId: 'u1', scopes: [] }) },
|
|
151
|
+
null
|
|
152
|
+
),
|
|
153
|
+
MissingScopeError
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('resolves when the session holds the required scope', async () => {
|
|
158
|
+
const agent = addAgent('scoped-ok', { scopes: ['reports:read'] })
|
|
159
|
+
await assertAgentAuthorized(
|
|
160
|
+
agent,
|
|
161
|
+
{
|
|
162
|
+
sessionService: sessionService({
|
|
163
|
+
userId: 'u1',
|
|
164
|
+
scopes: ['reports:read'],
|
|
165
|
+
}),
|
|
166
|
+
},
|
|
167
|
+
null
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('resolves when an ancestor scope grant covers the requirement', async () => {
|
|
172
|
+
const agent = addAgent('scoped-parent', { scopes: ['reports:read'] })
|
|
173
|
+
await assertAgentAuthorized(
|
|
174
|
+
agent,
|
|
175
|
+
{
|
|
176
|
+
sessionService: sessionService({ userId: 'u1', scopes: ['reports'] }),
|
|
177
|
+
},
|
|
178
|
+
null
|
|
179
|
+
)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('checks scopes before permissions', async () => {
|
|
183
|
+
let permissionRan = false
|
|
184
|
+
const agent = addAgent('scope-first', {
|
|
185
|
+
scopes: ['reports:read'],
|
|
186
|
+
permissions: {
|
|
187
|
+
admin: async () => {
|
|
188
|
+
permissionRan = true
|
|
189
|
+
return true
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
await assert.rejects(
|
|
194
|
+
() =>
|
|
195
|
+
assertAgentAuthorized(
|
|
196
|
+
agent,
|
|
197
|
+
{ sessionService: sessionService({ userId: 'u1', scopes: [] }) },
|
|
198
|
+
null
|
|
199
|
+
),
|
|
200
|
+
MissingScopeError
|
|
201
|
+
)
|
|
202
|
+
assert.equal(permissionRan, false)
|
|
203
|
+
})
|
|
204
|
+
})
|