create-windy 0.2.33 → 0.2.34
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/dist/cli.js +7 -1
- package/package.json +1 -1
- package/template/.agents/skills/windy-business-module/SKILL.md +10 -1
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +4 -0
- package/template/apps/agent-server/README.md +10 -0
- package/template/apps/agent-server/src/southwind_agent_server/app.py +62 -1
- package/template/apps/agent-server/src/southwind_agent_server/contracts.py +35 -0
- package/template/apps/agent-server/src/southwind_agent_server/engine.py +2 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +31 -3
- package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +22 -9
- package/template/apps/agent-server/src/southwind_agent_server/provider_audit.py +144 -0
- package/template/apps/agent-server/src/southwind_agent_server/provider_governance.py +227 -0
- package/template/apps/agent-server/src/southwind_agent_server/service.py +1 -0
- package/template/apps/agent-server/tests/test_api.py +16 -1
- package/template/apps/agent-server/tests/test_openai_provider.py +79 -0
- package/template/apps/agent-server/tests/test_provider_governance.py +215 -0
- package/template/apps/server/src/agent/provider-input-governance.test.ts +82 -0
- package/template/apps/server/src/agent/provider-input-governance.ts +140 -0
- package/template/apps/server/src/agent/remote-runtime.test.ts +22 -0
- package/template/apps/server/src/agent/remote-runtime.ts +1 -0
- package/template/apps/server/src/agent/run-contracts.ts +2 -0
- package/template/apps/server/src/agent/run-facade.test.ts +52 -2
- package/template/apps/server/src/agent/run-facade.ts +36 -2
- package/template/apps/server/src/index.ts +28 -45
- package/template/apps/server/src/module-host/initialize-contracts.ts +6 -0
- package/template/apps/server/src/module-host/initialize.ts +30 -44
- package/template/apps/server/src/module-host/request-context.ts +23 -17
- package/template/apps/server/src/module-host/resource-file-policies.test.ts +197 -0
- package/template/apps/server/src/module-host/resource-file-policies.ts +108 -0
- package/template/apps/server/src/module-host/route-declaration.ts +45 -0
- package/template/apps/server/src/module-host/route-installer.ts +27 -3
- package/template/apps/server/src/module-host/storage-port.test.ts +170 -1
- package/template/apps/server/src/module-host/storage-port.ts +134 -1
- package/template/apps/server/src/module-host/storage-scope.ts +96 -0
- package/template/apps/server/src/module-host/tool-handlers.test.ts +305 -0
- package/template/apps/server/src/module-host/tool-handlers.ts +38 -4
- package/template/apps/server/src/module-host/user-directory-composition.test.ts +126 -0
- package/template/apps/server/src/module-host/user-directory-port.test.ts +238 -0
- package/template/apps/server/src/module-host/user-directory-port.ts +143 -0
- package/template/apps/server/src/module-host.ts +4 -0
- package/template/apps/server/src/runtime-development.ts +1 -0
- package/template/apps/server/src/system/built-in-roles.ts +1 -0
- package/template/docker-compose.yml +2 -0
- package/template/docs/architecture/ai-egress.md +49 -7
- package/template/docs/architecture/ai-runtime.md +20 -1
- package/template/docs/architecture/data-scope-query-enforcement.md +4 -0
- package/template/docs/architecture/object-storage.md +22 -2
- package/template/docs/development/custom-login-page.md +3 -3
- package/template/docs/platform/agent-runtime.md +44 -1
- package/template/docs/platform/business-user-directory.md +82 -0
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/ai-operation-composition.test.ts +68 -0
- package/template/packages/modules/src/ai-operation-composition.ts +115 -0
- package/template/packages/modules/src/manifest.ts +26 -0
- package/template/packages/modules/src/provider-data-policy-composition.test.ts +74 -0
- package/template/packages/modules/src/system-audit-actions.ts +5 -0
- package/template/packages/modules/src/system-features.ts +5 -0
- package/template/packages/modules/src/system-module-catalog.test.ts +16 -0
- package/template/packages/modules/src/system-module-catalog.ts +26 -3
- package/template/packages/modules/src/system-permissions.ts +2 -0
- package/template/packages/server-sdk/index.ts +11 -0
- package/template/packages/server-sdk/package.json +3 -2
- package/template/packages/server-sdk/src/ai-operation-registration.ts +2 -1
- package/template/packages/server-sdk/src/module-host.ts +8 -0
- package/template/packages/server-sdk/src/request-context.ts +2 -0
- package/template/packages/server-sdk/src/storage-port.ts +66 -0
- package/template/packages/server-sdk/src/tool-registration.ts +6 -0
- package/template/packages/server-sdk/src/user-directory-port.ts +42 -0
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ModuleInitializer,
|
|
4
|
+
ModuleResourceFileAccessPolicy,
|
|
5
|
+
} from "@southwind-ai/server-sdk";
|
|
3
6
|
import type { FileMetadata, UploadPolicy } from "@southwind-ai/storage";
|
|
4
7
|
import { createStorageRuntime } from "../storage/runtime.js";
|
|
5
8
|
import { initializeModuleHosts } from "./initialize.js";
|
|
@@ -83,6 +86,120 @@ describe("模块受限 Upload/BlobRead Port", () => {
|
|
|
83
86
|
expect(opened.id).toBe(file.id);
|
|
84
87
|
});
|
|
85
88
|
|
|
89
|
+
test("资源绑定策略允许案件范围内跨上传者读取且只返回最小元数据", async () => {
|
|
90
|
+
const { storage } = setup();
|
|
91
|
+
const file = await seedFile(storage, "user_uploader", "shop.attachment");
|
|
92
|
+
const decisions: Array<{
|
|
93
|
+
outcome: "success" | "denied";
|
|
94
|
+
fileId: string;
|
|
95
|
+
}> = [];
|
|
96
|
+
const port = resourcePort(storage, {
|
|
97
|
+
authorize: ({ fileId, resourceId }, context) =>
|
|
98
|
+
fileId === file.id &&
|
|
99
|
+
resourceId === "order_1" &&
|
|
100
|
+
context.actor.id === "user_reader",
|
|
101
|
+
record: (_policy, input, outcome) => {
|
|
102
|
+
decisions.push({ outcome, fileId: input.fileId });
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const opened = await port.openResourceFile({
|
|
107
|
+
fileId: file.id,
|
|
108
|
+
purpose: "shop.attachment",
|
|
109
|
+
resourceType: "shop.order-file",
|
|
110
|
+
resourceId: "order_1",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(opened).toMatchObject({
|
|
114
|
+
id: file.id,
|
|
115
|
+
purpose: "shop.attachment",
|
|
116
|
+
filename: "a.txt",
|
|
117
|
+
mimeType: "text/plain",
|
|
118
|
+
byteSize: 3,
|
|
119
|
+
sha256: file.contentHash.value,
|
|
120
|
+
});
|
|
121
|
+
expect("ownerId" in opened).toBe(false);
|
|
122
|
+
expect("artifactRef" in opened).toBe(false);
|
|
123
|
+
expect(decisions).toEqual([{ outcome: "success", fileId: file.id }]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("资源绑定、平台 Guard、用途与资源类型任一不满足均 fail-closed", async () => {
|
|
127
|
+
const { storage } = setup();
|
|
128
|
+
const file = await seedFile(storage, "user_uploader", "shop.attachment");
|
|
129
|
+
let resolverCalls = 0;
|
|
130
|
+
const deniedByGuard = resourcePort(storage, {
|
|
131
|
+
platformAllowed: false,
|
|
132
|
+
authorize: () => {
|
|
133
|
+
resolverCalls += 1;
|
|
134
|
+
return true;
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
await expect(
|
|
138
|
+
deniedByGuard.openResourceFile(resourceRequest(file.id)),
|
|
139
|
+
).rejects.toThrow("资源文件访问未授权");
|
|
140
|
+
expect(resolverCalls).toBe(0);
|
|
141
|
+
|
|
142
|
+
const deniedByBinding = resourcePort(storage, {
|
|
143
|
+
authorize: () => false,
|
|
144
|
+
});
|
|
145
|
+
await expect(
|
|
146
|
+
deniedByBinding.openResourceFile(resourceRequest(file.id)),
|
|
147
|
+
).rejects.toThrow("资源文件访问未授权");
|
|
148
|
+
await expect(
|
|
149
|
+
deniedByBinding.openResourceFile(resourceRequest("file_not_exists")),
|
|
150
|
+
).rejects.toThrow("资源文件访问未授权");
|
|
151
|
+
|
|
152
|
+
await expect(
|
|
153
|
+
deniedByBinding.openResourceFile({
|
|
154
|
+
...resourceRequest(file.id),
|
|
155
|
+
resourceType: "shop.unknown",
|
|
156
|
+
}),
|
|
157
|
+
).rejects.toThrow("资源文件访问未授权");
|
|
158
|
+
await expect(
|
|
159
|
+
deniedByBinding.openResourceFile({
|
|
160
|
+
...resourceRequest(file.id),
|
|
161
|
+
purpose: "other.attachment",
|
|
162
|
+
}),
|
|
163
|
+
).rejects.toThrow("资源文件访问未授权");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("资源拒绝与成功审计只接收稳定 ID 和 purpose", async () => {
|
|
167
|
+
const { storage } = setup();
|
|
168
|
+
const file = await seedFile(storage, "user_uploader", "shop.attachment");
|
|
169
|
+
const outcomes: string[] = [];
|
|
170
|
+
const port = resourcePort(storage, {
|
|
171
|
+
authorize: () => false,
|
|
172
|
+
record: (_policy, input, outcome) => {
|
|
173
|
+
outcomes.push(`${outcome}:${input.resourceId}:${input.purpose}`);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await expect(
|
|
178
|
+
port.openResourceFile(resourceRequest(file.id)),
|
|
179
|
+
).rejects.toThrow("资源文件访问未授权");
|
|
180
|
+
expect(outcomes).toEqual(["denied:order_1:shop.attachment"]);
|
|
181
|
+
|
|
182
|
+
const failedResolver = resourcePort(storage, {
|
|
183
|
+
authorize: () => {
|
|
184
|
+
throw new Error("database details");
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
await expect(
|
|
188
|
+
failedResolver.openResourceFile(resourceRequest(file.id)),
|
|
189
|
+
).rejects.toThrow("资源文件访问未授权");
|
|
190
|
+
|
|
191
|
+
const storageDenied: string[] = [];
|
|
192
|
+
const missingFile = resourcePort(storage, {
|
|
193
|
+
record: (_policy, _input, outcome) => {
|
|
194
|
+
storageDenied.push(outcome);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
await expect(
|
|
198
|
+
missingFile.openResourceFile(resourceRequest("file_not_exists")),
|
|
199
|
+
).rejects.toThrow("资源文件访问未授权");
|
|
200
|
+
expect(storageDenied).toEqual(["denied"]);
|
|
201
|
+
});
|
|
202
|
+
|
|
86
203
|
test("命名空间重叠模块注册同一用途在启动时失败", async () => {
|
|
87
204
|
const initializer = (
|
|
88
205
|
moduleName: string,
|
|
@@ -121,6 +238,58 @@ function setup() {
|
|
|
121
238
|
return { storage, port };
|
|
122
239
|
}
|
|
123
240
|
|
|
241
|
+
interface ResourcePortOptions {
|
|
242
|
+
platformAllowed?: boolean;
|
|
243
|
+
authorize?: ModuleResourceFileAccessPolicy["authorize"];
|
|
244
|
+
record?: NonNullable<
|
|
245
|
+
Parameters<typeof createModuleStoragePort>[1]["recordResourceFileDecision"]
|
|
246
|
+
>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function resourcePort(
|
|
250
|
+
storage: ReturnType<typeof createStorageRuntime>,
|
|
251
|
+
options: ResourcePortOptions = {},
|
|
252
|
+
) {
|
|
253
|
+
const policy: ModuleResourceFileAccessPolicy = {
|
|
254
|
+
purpose: "shop.attachment",
|
|
255
|
+
resourceType: "shop.order-file",
|
|
256
|
+
featureKey: "shop.order",
|
|
257
|
+
permissionKey: "shop.order.read",
|
|
258
|
+
auditAction: "shop.order.file-read",
|
|
259
|
+
authorize: options.authorize ?? (() => true),
|
|
260
|
+
};
|
|
261
|
+
return createModuleStoragePort(
|
|
262
|
+
{ uploads: storage.uploads, files: storage.files },
|
|
263
|
+
{
|
|
264
|
+
moduleName: "shop",
|
|
265
|
+
ownerId: "user_reader",
|
|
266
|
+
purposes: new Set(["shop.attachment"]),
|
|
267
|
+
actor: {
|
|
268
|
+
id: "user_reader",
|
|
269
|
+
type: "user",
|
|
270
|
+
name: "读取者",
|
|
271
|
+
roleCodes: ["reader"],
|
|
272
|
+
permissionKeys: ["shop.order.read"],
|
|
273
|
+
},
|
|
274
|
+
requestId: "request_resource",
|
|
275
|
+
signal: new AbortController().signal,
|
|
276
|
+
resourceFileAccessPolicies: [policy],
|
|
277
|
+
authorizeResourceFileAccess: () =>
|
|
278
|
+
Promise.resolve(options.platformAllowed ?? true),
|
|
279
|
+
recordResourceFileDecision: options.record,
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function resourceRequest(fileId: string) {
|
|
285
|
+
return {
|
|
286
|
+
fileId,
|
|
287
|
+
purpose: "shop.attachment",
|
|
288
|
+
resourceType: "shop.order-file",
|
|
289
|
+
resourceId: "order_1",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
124
293
|
function policy(purpose: string): UploadPolicy {
|
|
125
294
|
return {
|
|
126
295
|
purpose,
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ModuleActor,
|
|
3
|
+
ModuleResourceFileAccessPolicy,
|
|
4
|
+
ModuleResourceFileRequest,
|
|
5
|
+
ModuleStoragePort,
|
|
6
|
+
} from "@southwind-ai/server-sdk";
|
|
2
7
|
import {
|
|
3
8
|
UploadError,
|
|
4
9
|
type FileService,
|
|
@@ -15,6 +20,19 @@ export interface ModuleStorageScope {
|
|
|
15
20
|
ownerId: string;
|
|
16
21
|
/** 本模块经 Host 注册的上传用途集合;跨模块与平台用途一律拒绝。 */
|
|
17
22
|
purposes: ReadonlySet<string>;
|
|
23
|
+
/** Host 颁发的 Actor 只读视图;资源文件访问策略不能从输入伪造 Actor。 */
|
|
24
|
+
actor?: ModuleActor;
|
|
25
|
+
requestId?: string;
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
resourceFileAccessPolicies?: readonly ModuleResourceFileAccessPolicy[];
|
|
28
|
+
authorizeResourceFileAccess?: (
|
|
29
|
+
policy: ModuleResourceFileAccessPolicy,
|
|
30
|
+
) => Promise<boolean>;
|
|
31
|
+
recordResourceFileDecision?: (
|
|
32
|
+
policy: ModuleResourceFileAccessPolicy,
|
|
33
|
+
input: ModuleResourceFileRequest,
|
|
34
|
+
outcome: "success" | "denied",
|
|
35
|
+
) => Promise<void> | void;
|
|
18
36
|
}
|
|
19
37
|
|
|
20
38
|
/**
|
|
@@ -23,6 +41,7 @@ export interface ModuleStorageScope {
|
|
|
23
41
|
* - 上传会话强制 purpose ∈ 本模块已注册 Policy,Owner 由宿主以当前 Actor 补齐;
|
|
24
42
|
* - 文件读取走 FileService 的 Owner 约束,且文件用途必须属于本模块;
|
|
25
43
|
* - 公开读取走 FileService 的 Purpose 约束,用途同样必须属于本模块;
|
|
44
|
+
* - 资源文件读取须匹配当前模块注册的绑定策略,可读取显式依赖模块的用途;
|
|
26
45
|
* - 模块不直接接触 BlobStore 或 Upload Repository。
|
|
27
46
|
*/
|
|
28
47
|
export function createModuleStoragePort(
|
|
@@ -43,9 +62,105 @@ export function createModuleStoragePort(
|
|
|
43
62
|
requireModulePurpose(scope, purpose);
|
|
44
63
|
return services.files.openPublic(fileId, purpose);
|
|
45
64
|
},
|
|
65
|
+
async openResourceFile(input) {
|
|
66
|
+
const policy = findResourceFileAccessPolicy(scope, input);
|
|
67
|
+
const actor = scope.actor;
|
|
68
|
+
if (
|
|
69
|
+
!actor ||
|
|
70
|
+
!scope.requestId ||
|
|
71
|
+
!scope.signal ||
|
|
72
|
+
!scope.authorizeResourceFileAccess
|
|
73
|
+
) {
|
|
74
|
+
throw forbiddenResourceFile();
|
|
75
|
+
}
|
|
76
|
+
if (!(await authorizeHost(scope, policy))) {
|
|
77
|
+
return denyResourceFile(scope, policy, input);
|
|
78
|
+
}
|
|
79
|
+
if (!(await authorizeBusinessResource(scope, policy, input, actor))) {
|
|
80
|
+
return denyResourceFile(scope, policy, input);
|
|
81
|
+
}
|
|
82
|
+
let opened: Awaited<ReturnType<FileService["open"]>>;
|
|
83
|
+
try {
|
|
84
|
+
opened = await services.files.open(input.fileId, actor.id, true);
|
|
85
|
+
} catch {
|
|
86
|
+
return denyResourceFile(scope, policy, input);
|
|
87
|
+
}
|
|
88
|
+
if (opened.purpose !== input.purpose) {
|
|
89
|
+
return denyResourceFile(scope, policy, input);
|
|
90
|
+
}
|
|
91
|
+
await scope.recordResourceFileDecision?.(policy, input, "success");
|
|
92
|
+
return {
|
|
93
|
+
id: opened.id,
|
|
94
|
+
purpose: opened.purpose,
|
|
95
|
+
filename: opened.filename,
|
|
96
|
+
mimeType: opened.mimeType,
|
|
97
|
+
byteSize: opened.byteSize,
|
|
98
|
+
sha256: opened.contentHash.value,
|
|
99
|
+
body: opened.body,
|
|
100
|
+
};
|
|
101
|
+
},
|
|
46
102
|
};
|
|
47
103
|
}
|
|
48
104
|
|
|
105
|
+
async function denyResourceFile(
|
|
106
|
+
scope: ModuleStorageScope,
|
|
107
|
+
policy: ModuleResourceFileAccessPolicy,
|
|
108
|
+
input: ModuleResourceFileRequest,
|
|
109
|
+
): Promise<never> {
|
|
110
|
+
try {
|
|
111
|
+
await scope.recordResourceFileDecision?.(policy, input, "denied");
|
|
112
|
+
} catch {
|
|
113
|
+
// 审计失败不能把底层错误暴露给调用方,访问仍保持 fail-closed。
|
|
114
|
+
}
|
|
115
|
+
throw forbiddenResourceFile();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function authorizeHost(
|
|
119
|
+
scope: ModuleStorageScope,
|
|
120
|
+
policy: ModuleResourceFileAccessPolicy,
|
|
121
|
+
): Promise<boolean> {
|
|
122
|
+
try {
|
|
123
|
+
return (await scope.authorizeResourceFileAccess?.(policy)) === true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function authorizeBusinessResource(
|
|
130
|
+
scope: ModuleStorageScope,
|
|
131
|
+
policy: ModuleResourceFileAccessPolicy,
|
|
132
|
+
input: ModuleResourceFileRequest,
|
|
133
|
+
actor: ModuleActor,
|
|
134
|
+
): Promise<boolean> {
|
|
135
|
+
try {
|
|
136
|
+
return (
|
|
137
|
+
(await policy.authorize(
|
|
138
|
+
{ fileId: input.fileId, resourceId: input.resourceId },
|
|
139
|
+
{
|
|
140
|
+
actor,
|
|
141
|
+
requestId: scope.requestId!,
|
|
142
|
+
signal: scope.signal!,
|
|
143
|
+
},
|
|
144
|
+
)) === true
|
|
145
|
+
);
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function findResourceFileAccessPolicy(
|
|
152
|
+
scope: ModuleStorageScope,
|
|
153
|
+
input: ModuleResourceFileRequest,
|
|
154
|
+
): ModuleResourceFileAccessPolicy {
|
|
155
|
+
const policy = scope.resourceFileAccessPolicies?.find(
|
|
156
|
+
(candidate) =>
|
|
157
|
+
candidate.purpose === input.purpose &&
|
|
158
|
+
candidate.resourceType === input.resourceType,
|
|
159
|
+
);
|
|
160
|
+
if (!policy) throw forbiddenResourceFile();
|
|
161
|
+
return policy;
|
|
162
|
+
}
|
|
163
|
+
|
|
49
164
|
function requireModulePurpose(
|
|
50
165
|
scope: ModuleStorageScope,
|
|
51
166
|
purpose: string,
|
|
@@ -57,3 +172,21 @@ function requireModulePurpose(
|
|
|
57
172
|
);
|
|
58
173
|
}
|
|
59
174
|
}
|
|
175
|
+
|
|
176
|
+
function forbiddenResourceFile(): UploadError {
|
|
177
|
+
return new UploadError("UPLOAD_FORBIDDEN", "资源文件访问未授权");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function unavailableModuleStoragePort(
|
|
181
|
+
moduleName: string,
|
|
182
|
+
): ModuleStoragePort {
|
|
183
|
+
const fail = (): never => {
|
|
184
|
+
throw new Error(`模块 ${moduleName} 的存储 Port 未挂载`);
|
|
185
|
+
};
|
|
186
|
+
return {
|
|
187
|
+
createUploadSession: () => fail(),
|
|
188
|
+
openFile: () => fail(),
|
|
189
|
+
openPublicFile: () => fail(),
|
|
190
|
+
openResourceFile: () => fail(),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { randomUUIDv7 } from "bun";
|
|
2
|
+
import type { FeatureFlagDefinition } from "@southwind-ai/shared";
|
|
3
|
+
import type {
|
|
4
|
+
ModuleResourceFileAccessPolicy,
|
|
5
|
+
ModuleResourceFileRequest,
|
|
6
|
+
} from "@southwind-ai/server-sdk";
|
|
7
|
+
import { evaluateAccessGuards } from "../access-guards.js";
|
|
8
|
+
import { isServerScopeContext } from "../data-access/context.js";
|
|
9
|
+
import type { RequestGuardContext } from "../guards.js";
|
|
10
|
+
import type { createServerRuntime } from "../runtime.js";
|
|
11
|
+
import { findFeature } from "../route-guards.js";
|
|
12
|
+
import type { ModuleStorageScope } from "./storage-port.js";
|
|
13
|
+
import { toModuleActor } from "./request-context.js";
|
|
14
|
+
|
|
15
|
+
type ServerRuntime = ReturnType<typeof createServerRuntime>;
|
|
16
|
+
|
|
17
|
+
export interface ModuleStorageScopeInput {
|
|
18
|
+
readonly moduleName: string;
|
|
19
|
+
readonly purposes: ReadonlySet<string>;
|
|
20
|
+
readonly resourceFileAccessPolicies: readonly ModuleResourceFileAccessPolicy[];
|
|
21
|
+
readonly guardContext: RequestGuardContext;
|
|
22
|
+
readonly runtime: ServerRuntime;
|
|
23
|
+
readonly features: readonly FeatureFlagDefinition[];
|
|
24
|
+
readonly signal: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createModuleStorageScope(
|
|
28
|
+
input: ModuleStorageScopeInput,
|
|
29
|
+
): ModuleStorageScope {
|
|
30
|
+
const actor = toModuleActor(input.guardContext.actor);
|
|
31
|
+
return {
|
|
32
|
+
moduleName: input.moduleName,
|
|
33
|
+
ownerId: actor.id,
|
|
34
|
+
purposes: input.purposes,
|
|
35
|
+
actor,
|
|
36
|
+
requestId: input.guardContext.requestId,
|
|
37
|
+
signal: input.signal,
|
|
38
|
+
resourceFileAccessPolicies: input.resourceFileAccessPolicies,
|
|
39
|
+
async authorizeResourceFileAccess(policy) {
|
|
40
|
+
if (
|
|
41
|
+
!isServerScopeContext(input.guardContext.actor.scopeContext) ||
|
|
42
|
+
input.guardContext.actor.scopeContext.actorId !== actor.id
|
|
43
|
+
) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const feature = findFeature([...input.features], policy.featureKey);
|
|
47
|
+
if (!feature) return false;
|
|
48
|
+
const result = await evaluateAccessGuards(
|
|
49
|
+
input.runtime,
|
|
50
|
+
input.guardContext,
|
|
51
|
+
{
|
|
52
|
+
permission: { permissionKey: policy.permissionKey },
|
|
53
|
+
feature,
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
return result.decision.allowed;
|
|
57
|
+
},
|
|
58
|
+
recordResourceFileDecision(policy, request, outcome) {
|
|
59
|
+
input.runtime.auditSink.recordEvent(
|
|
60
|
+
resourceFileEvent(input, policy, request, outcome),
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resourceFileEvent(
|
|
67
|
+
input: ModuleStorageScopeInput,
|
|
68
|
+
policy: ModuleResourceFileAccessPolicy,
|
|
69
|
+
request: ModuleResourceFileRequest,
|
|
70
|
+
outcome: "success" | "denied",
|
|
71
|
+
) {
|
|
72
|
+
return {
|
|
73
|
+
id: randomUUIDv7(),
|
|
74
|
+
action: policy.auditAction,
|
|
75
|
+
outcome,
|
|
76
|
+
actor: {
|
|
77
|
+
id: input.guardContext.actor.id,
|
|
78
|
+
type:
|
|
79
|
+
input.guardContext.actor.type === "anonymous"
|
|
80
|
+
? ("user" as const)
|
|
81
|
+
: input.guardContext.actor.type,
|
|
82
|
+
name: input.guardContext.actor.name,
|
|
83
|
+
},
|
|
84
|
+
target: {
|
|
85
|
+
type: request.resourceType,
|
|
86
|
+
id: request.resourceId,
|
|
87
|
+
},
|
|
88
|
+
module: input.moduleName,
|
|
89
|
+
requestId: input.guardContext.requestId,
|
|
90
|
+
metadata: {
|
|
91
|
+
fileId: request.fileId,
|
|
92
|
+
purpose: request.purpose,
|
|
93
|
+
},
|
|
94
|
+
occurredAt: new Date().toISOString(),
|
|
95
|
+
};
|
|
96
|
+
}
|