create-windy 0.6.21 → 0.7.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/dist/cli.js +11 -4
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/server/src/audit/policy.ts +6 -1
- package/template/apps/server/src/auth/bootstrap-admin.ts +10 -1
- package/template/apps/server/src/auth/composition.ts +5 -0
- package/template/apps/server/src/auth/credential-authorization.test.ts +257 -0
- package/template/apps/server/src/auth/credential-routes.test.ts +1 -0
- package/template/apps/server/src/auth/credential-service.ts +47 -12
- package/template/apps/server/src/auth/drizzle-repository.ts +13 -5
- package/template/apps/server/src/auth/drizzle-session-operations.ts +1 -0
- package/template/apps/server/src/auth/repository.ts +9 -6
- package/template/apps/server/src/auth/routes.test-support.ts +1 -0
- package/template/apps/server/src/auth/runtime.ts +4 -0
- package/template/apps/server/src/auth/service-options.ts +2 -0
- package/template/apps/server/src/auth/service.ts +2 -0
- package/template/apps/server/src/auth/user-credential-access.ts +37 -0
- package/template/apps/server/src/foundation-activation.test.ts +24 -1
- package/template/apps/server/src/foundation.ts +0 -2
- package/template/apps/server/src/index.ts +1 -0
- package/template/apps/server/src/runtime-development.ts +2 -0
- package/template/apps/server/src/runtime.test.ts +13 -4
- package/template/apps/server/src/system/department-organization.ts +58 -59
- package/template/apps/server/src/system/department-read-routes.ts +54 -0
- package/template/apps/server/src/system/department-routes.test.ts +27 -19
- package/template/apps/server/src/system/department-routes.ts +95 -33
- package/template/apps/server/src/system/department-transition-safety.test.ts +148 -0
- package/template/apps/server/src/system/department-validation.ts +79 -0
- package/template/apps/server/src/system/drizzle-repository.ts +14 -0
- package/template/apps/server/src/system/drizzle-system-configs.ts +2 -0
- package/template/apps/server/src/system/identity-read-model.ts +105 -0
- package/template/apps/server/src/system/identity-role-assignment.test.ts +199 -0
- package/template/apps/server/src/system/identity-role-assignment.ts +148 -0
- package/template/apps/server/src/system/identity-route-config.ts +29 -7
- package/template/apps/server/src/system/identity-write-security.test.ts +127 -0
- package/template/apps/server/src/system/identity-write-validation.ts +170 -0
- package/template/apps/server/src/system/organization-read-security.test.ts +221 -0
- package/template/apps/server/src/system/redis-health.test.ts +1 -1
- package/template/apps/server/src/system/repository.ts +29 -2
- package/template/apps/server/src/system/resource-routes.ts +116 -72
- package/template/apps/server/src/system/route-audit.test.ts +117 -0
- package/template/apps/server/src/system/route-helpers.ts +14 -0
- package/template/apps/server/src/system/route-types.ts +11 -2
- package/template/apps/server/src/system/routes.test.ts +0 -68
- package/template/apps/server/src/system/routes.ts +29 -2
- package/template/apps/server/src/system/user-account-policy.test.ts +3 -1
- package/template/apps/server/src/system/user-account-policy.ts +26 -9
- package/template/apps/web/src/pages/system/SystemResourceRoutePage.vue +27 -13
- package/template/apps/web/src/pages/system/SystemResourceRoutePage.webtest.ts +68 -0
- package/template/apps/web/src/pages/system/components/PagedSelectionList.vue +131 -0
- package/template/apps/web/src/pages/system/components/PagedSelectionList.webtest.ts +32 -0
- package/template/apps/web/src/pages/system/components/SystemRecordDetailDialog.vue +67 -0
- package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +114 -38
- package/template/apps/web/src/pages/system/components/SystemResourceEditor.webtest.ts +180 -0
- package/template/apps/web/src/pages/system/components/SystemResourceFilters.vue +8 -0
- package/template/apps/web/src/pages/system/components/SystemResourceForm.vue +8 -2
- package/template/apps/web/src/pages/system/components/SystemResourcePage.department.webtest.ts +89 -0
- package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +42 -46
- package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +12 -55
- package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +14 -2
- package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.vue +121 -85
- package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.webtest.ts +57 -0
- package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.vue +227 -0
- package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.webtest.ts +62 -0
- package/template/apps/web/src/pages/system/components/department/DepartmentTransitionDialog.vue +72 -52
- package/template/apps/web/src/pages/system/components/role-policy/RolePermissionTree.vue +150 -79
- package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.vue +98 -8
- package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.webtest.ts +108 -0
- package/template/apps/web/src/pages/system/components/selection-options.ts +71 -0
- package/template/apps/web/src/pages/system/components/selection-options.webtest.ts +49 -0
- package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.vue +19 -27
- package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.webtest.ts +2 -6
- package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.vue +128 -0
- package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.webtest.ts +76 -0
- package/template/apps/web/src/pages/system/components/user-account/RolePermissionSummary.vue +61 -3
- package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +144 -90
- package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.webtest.ts +95 -0
- package/template/apps/web/src/pages/system/components/user-account/UserListFilters.vue +168 -0
- package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.vue +33 -57
- package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.webtest.ts +4 -4
- package/template/apps/web/src/pages/system/components/user-account/save-user-account.ts +4 -1
- package/template/apps/web/src/pages/system/components/user-account/user-account-draft.ts +81 -0
- package/template/apps/web/src/pages/system/composables/useDepartmentTransitions.ts +6 -10
- package/template/apps/web/src/pages/system/composables/useResourceEditor.ts +72 -0
- package/template/apps/web/src/pages/system/composables/useResourceEditor.webtest.ts +110 -0
- package/template/apps/web/src/pages/system/composables/useSystemResource.ts +27 -1
- package/template/apps/web/src/pages/system/resource-config.ts +2 -0
- package/template/apps/web/src/services/system-api.ts +25 -0
- package/template/docs/architecture/foundation-infrastructure.md +12 -25
- package/template/docs/development/crud-generator.md +1 -1
- package/template/docs/operations/legacy-data-migration.md +73 -0
- package/template/docs/platform/bulk-data-jobs.md +1 -1
- package/template/docs/platform/module-configuration.md +2 -2
- package/template/docs/platform/organization-membership.md +37 -6
- package/template/docs/platform/scheduled-task-executor.md +1 -1
- package/template/package.json +2 -1
- package/template/packages/config/index.test.ts +1 -5
- package/template/packages/config/package.json +2 -2
- package/template/packages/config/src/platform-boundary.test.ts +39 -0
- package/template/packages/config/src/platform.ts +0 -28
- package/template/packages/database/drizzle/0063_license_project_lifecycle.sql +28 -0
- package/template/packages/database/drizzle/meta/0063_snapshot.json +15053 -0
- package/template/packages/database/drizzle/meta/_journal.json +7 -0
- package/template/packages/database/package.json +2 -2
- package/template/packages/database/src/agent-runtime-schema.test.ts +5 -1
- package/template/packages/database/src/schema/index.ts +1 -0
- package/template/packages/database/src/schema/license-issuance-requests.ts +59 -0
- package/template/packages/database/src/schema/license-v1.ts +5 -0
- package/template/packages/jobs/package.json +2 -2
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/system-api-permissions.ts +14 -0
- package/template/packages/modules/src/system-features.ts +0 -11
- package/template/packages/modules/src/system-module-catalog.test.ts +4 -0
- package/template/packages/modules/src/system-module-catalog.ts +0 -1
- package/template/packages/server-sdk/index.ts +1 -49
- package/template/packages/server-sdk/package.json +3 -2
- package/template/packages/server-sdk/src/ai-runtime-readiness-exports.ts +49 -0
- package/template/packages/shared/index.ts +2 -1
- package/template/packages/shared/package.json +1 -1
- package/template/packages/shared/src/license-console-capabilities.ts +163 -0
- package/template/packages/shared/src/license-console.ts +94 -0
- package/template/packages/shared/src/license.ts +5 -0
- package/template/docs/adapters/ruoyi-vue-compatibility.md +0 -93
- package/template/packages/shared/src/ruoyi.ts +0 -98
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
identityMutationResponse,
|
|
3
|
+
validateWritableFields,
|
|
4
|
+
} from "./identity-write-validation.js";
|
|
1
5
|
import { recordMutationAudit } from "../audit/policy.js";
|
|
2
6
|
import { searchAuditEvent } from "../audit/search-summary.js";
|
|
3
7
|
import type { createServerRuntime } from "../runtime.js";
|
|
@@ -60,7 +64,12 @@ export function registerResourceRoutes<T extends ManagedEntity>(
|
|
|
60
64
|
searchAuditEvent(guardContext, config.path, search, result.total),
|
|
61
65
|
);
|
|
62
66
|
}
|
|
63
|
-
return {
|
|
67
|
+
return {
|
|
68
|
+
...result,
|
|
69
|
+
items: config.projectReadResponses
|
|
70
|
+
? await config.projectReadResponses(result.items, guardContext)
|
|
71
|
+
: projectEntities(config, result.items),
|
|
72
|
+
};
|
|
64
73
|
},
|
|
65
74
|
);
|
|
66
75
|
});
|
|
@@ -86,7 +95,9 @@ export function registerResourceRoutes<T extends ManagedEntity>(
|
|
|
86
95
|
);
|
|
87
96
|
if (item instanceof Response) return item;
|
|
88
97
|
return item
|
|
89
|
-
?
|
|
98
|
+
? config.projectReadResponses
|
|
99
|
+
? (await config.projectReadResponses([item], guardContext))[0]
|
|
100
|
+
: projectEntity(config, item)
|
|
90
101
|
: notFound(config.path, requiredId(params));
|
|
91
102
|
},
|
|
92
103
|
);
|
|
@@ -152,25 +163,35 @@ export function registerResourceRoutes<T extends ManagedEntity>(
|
|
|
152
163
|
"manage",
|
|
153
164
|
config.feature,
|
|
154
165
|
async () =>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
config.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
166
|
+
identityMutationResponse(() =>
|
|
167
|
+
runResourceMutation(config, async () => {
|
|
168
|
+
const fieldError =
|
|
169
|
+
config.writableFields &&
|
|
170
|
+
validateWritableFields(body, config.writableFields);
|
|
171
|
+
if (fieldError) return invalidResource(fieldError);
|
|
172
|
+
const input = asPartial<T>(body);
|
|
173
|
+
const validationError = await config.validateInput?.(
|
|
174
|
+
input,
|
|
175
|
+
undefined,
|
|
176
|
+
guardContext.actor,
|
|
177
|
+
);
|
|
178
|
+
if (validationError) return invalidResource(validationError);
|
|
179
|
+
const policyError = await config.beforeCreate?.(input);
|
|
180
|
+
if (policyError) return resourceConflict(policyError);
|
|
181
|
+
const item = await accessCall(() =>
|
|
182
|
+
config.dataAccess.mode === "request"
|
|
183
|
+
? config.dataAccess.access.create(input, guardContext.actor)
|
|
184
|
+
: config.repository.create(input, guardContext.actor),
|
|
185
|
+
);
|
|
186
|
+
if (item instanceof Response) return item;
|
|
187
|
+
await recordMutationAudit(
|
|
188
|
+
runtime,
|
|
189
|
+
config.path,
|
|
190
|
+
resourceEvent(guardContext, "crud.create", config.path, item.id),
|
|
191
|
+
);
|
|
192
|
+
return projectEntity(config, item);
|
|
193
|
+
}),
|
|
194
|
+
),
|
|
174
195
|
);
|
|
175
196
|
});
|
|
176
197
|
|
|
@@ -184,33 +205,47 @@ export function registerResourceRoutes<T extends ManagedEntity>(
|
|
|
184
205
|
"manage",
|
|
185
206
|
config.feature,
|
|
186
207
|
async () =>
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
208
|
+
identityMutationResponse(() =>
|
|
209
|
+
runResourceMutation(config, async () => {
|
|
210
|
+
const id = requiredId(params);
|
|
211
|
+
const fieldError =
|
|
212
|
+
config.writableFields &&
|
|
213
|
+
validateWritableFields(body, config.writableFields);
|
|
214
|
+
if (fieldError) return invalidResource(fieldError);
|
|
215
|
+
const input = asPartial<T>(body);
|
|
216
|
+
const current = await readAccessible(
|
|
217
|
+
config,
|
|
218
|
+
id,
|
|
219
|
+
guardContext.actor,
|
|
220
|
+
);
|
|
221
|
+
if (current instanceof Response) return current;
|
|
222
|
+
if (!current) return notFound(config.path, id);
|
|
223
|
+
const validationError = await config.validateInput?.(
|
|
224
|
+
input,
|
|
225
|
+
current,
|
|
226
|
+
guardContext.actor,
|
|
227
|
+
);
|
|
228
|
+
if (validationError) return invalidResource(validationError);
|
|
229
|
+
const policyError = await config.beforeUpdate?.(current, input);
|
|
230
|
+
if (policyError) return resourceConflict(policyError);
|
|
231
|
+
const item = await accessCall(() =>
|
|
232
|
+
config.dataAccess.mode === "request"
|
|
233
|
+
? config.dataAccess.access.update(id, input, guardContext.actor)
|
|
234
|
+
: config.repository.update(id, input, guardContext.actor),
|
|
235
|
+
);
|
|
236
|
+
if (item instanceof Response) return item;
|
|
237
|
+
if (!item) {
|
|
238
|
+
return notFound(config.path, id);
|
|
239
|
+
}
|
|
240
|
+
await config.afterUpdate?.(current, item, guardContext.actor);
|
|
241
|
+
await recordMutationAudit(
|
|
242
|
+
runtime,
|
|
243
|
+
config.path,
|
|
244
|
+
resourceEvent(guardContext, "crud.update", config.path, id),
|
|
245
|
+
);
|
|
246
|
+
return projectEntity(config, item);
|
|
247
|
+
}),
|
|
248
|
+
),
|
|
214
249
|
);
|
|
215
250
|
});
|
|
216
251
|
|
|
@@ -224,30 +259,39 @@ export function registerResourceRoutes<T extends ManagedEntity>(
|
|
|
224
259
|
"manage",
|
|
225
260
|
config.feature,
|
|
226
261
|
async () =>
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
return
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
262
|
+
identityMutationResponse(() =>
|
|
263
|
+
runResourceMutation(config, async () => {
|
|
264
|
+
const id = requiredId(params);
|
|
265
|
+
const current = await readAccessible(
|
|
266
|
+
config,
|
|
267
|
+
id,
|
|
268
|
+
guardContext.actor,
|
|
269
|
+
);
|
|
270
|
+
if (current instanceof Response) return current;
|
|
271
|
+
if (!current) return notFound(config.path, id);
|
|
272
|
+
const policyError = await config.beforeDelete?.(
|
|
273
|
+
current,
|
|
274
|
+
guardContext.actor,
|
|
275
|
+
);
|
|
276
|
+
if (policyError) return resourceConflict(policyError);
|
|
277
|
+
const item = await accessCall(() =>
|
|
278
|
+
config.dataAccess.mode === "request"
|
|
279
|
+
? config.dataAccess.access.softDelete(id, guardContext.actor)
|
|
280
|
+
: config.repository.softDelete(id, guardContext.actor),
|
|
281
|
+
);
|
|
282
|
+
if (item instanceof Response) return item;
|
|
283
|
+
if (!item) {
|
|
284
|
+
return notFound(config.path, id);
|
|
285
|
+
}
|
|
286
|
+
await config.afterDelete?.(item, guardContext.actor);
|
|
287
|
+
await recordMutationAudit(
|
|
288
|
+
runtime,
|
|
289
|
+
config.path,
|
|
290
|
+
resourceEvent(guardContext, "crud.delete", config.path, id),
|
|
291
|
+
);
|
|
292
|
+
return projectEntity(config, item);
|
|
293
|
+
}),
|
|
294
|
+
),
|
|
251
295
|
);
|
|
252
296
|
});
|
|
253
297
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createFoundationSnapshot } from "../foundation.js";
|
|
3
|
+
import { createServerRuntime, type AuditEventWriter } from "../runtime.js";
|
|
4
|
+
import { FakeRouteApp } from "./route-test-app.js";
|
|
5
|
+
import { registerSystemRoutes } from "./routes.js";
|
|
6
|
+
import { createSystemRepositories } from "./seed.js";
|
|
7
|
+
|
|
8
|
+
async function routeFixture(auditWriter: AuditEventWriter) {
|
|
9
|
+
const snapshot = createFoundationSnapshot();
|
|
10
|
+
const runtime = createServerRuntime(
|
|
11
|
+
{ WINDY_DEV_ADMIN_TOKEN: "token_1" },
|
|
12
|
+
{ auditWriter, featureCatalog: snapshot.features },
|
|
13
|
+
);
|
|
14
|
+
const app = new FakeRouteApp();
|
|
15
|
+
registerSystemRoutes(
|
|
16
|
+
app,
|
|
17
|
+
createSystemRepositories(snapshot),
|
|
18
|
+
runtime,
|
|
19
|
+
snapshot.features,
|
|
20
|
+
);
|
|
21
|
+
const guardContext = await runtime.createGuardContext(
|
|
22
|
+
new Request("http://localhost/api/system/users", {
|
|
23
|
+
headers: { authorization: "Bearer token_1" },
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
return { app, runtime, guardContext };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("system mutation audit policies", () => {
|
|
30
|
+
test("角色与部门变更使用关键策略,字典变更保持普通策略", async () => {
|
|
31
|
+
const policies: string[] = [];
|
|
32
|
+
const { app, guardContext } = await routeFixture({
|
|
33
|
+
write: async (_event, policy) => void policies.push(policy || "ordinary"),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
await app.invoke("POST", "/system/roles", {
|
|
37
|
+
guardContext,
|
|
38
|
+
params: {},
|
|
39
|
+
query: {},
|
|
40
|
+
body: {
|
|
41
|
+
code: "security",
|
|
42
|
+
name: "安全角色",
|
|
43
|
+
dataScope: "self",
|
|
44
|
+
permissionKeys: [],
|
|
45
|
+
menuKeys: [],
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
await app.invoke("POST", "/system/departments", {
|
|
49
|
+
guardContext,
|
|
50
|
+
params: {},
|
|
51
|
+
query: {},
|
|
52
|
+
body: {
|
|
53
|
+
code: "dept-security",
|
|
54
|
+
name: "安全部",
|
|
55
|
+
parentId: "dept_platform",
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await app.invoke("POST", "/system/dictionaries", {
|
|
60
|
+
guardContext,
|
|
61
|
+
params: {},
|
|
62
|
+
query: {},
|
|
63
|
+
body: { code: "audit-dict", name: "审计字典", items: [] },
|
|
64
|
+
});
|
|
65
|
+
expect(policies).toEqual(["critical", "critical", "ordinary"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("关键资源审计失败会拒绝请求,普通资源仍完成并报告降级", async () => {
|
|
69
|
+
const { app, runtime, guardContext } = await routeFixture({
|
|
70
|
+
write: async () => {
|
|
71
|
+
throw new Error("audit persistence unavailable");
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await expect(
|
|
76
|
+
app.invoke("POST", "/system/users", {
|
|
77
|
+
guardContext,
|
|
78
|
+
params: {},
|
|
79
|
+
query: {},
|
|
80
|
+
body: {
|
|
81
|
+
username: "security-user",
|
|
82
|
+
displayName: "安全用户",
|
|
83
|
+
roleIds: ["role_user"],
|
|
84
|
+
departmentId: "dept_platform",
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
).rejects.toThrow("audit persistence unavailable");
|
|
88
|
+
|
|
89
|
+
await expect(
|
|
90
|
+
app.invoke("POST", "/system/departments", {
|
|
91
|
+
guardContext,
|
|
92
|
+
params: {},
|
|
93
|
+
query: {},
|
|
94
|
+
body: {
|
|
95
|
+
code: "dept-available",
|
|
96
|
+
name: "可用部门",
|
|
97
|
+
parentId: "dept_platform",
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
).rejects.toThrow("audit persistence unavailable");
|
|
101
|
+
await expect(
|
|
102
|
+
app.invoke("POST", "/system/dictionaries", {
|
|
103
|
+
guardContext,
|
|
104
|
+
params: {},
|
|
105
|
+
query: {},
|
|
106
|
+
body: { code: "available-dict", name: "可用字典", items: [] },
|
|
107
|
+
}),
|
|
108
|
+
).resolves.toMatchObject({ code: "available-dict" });
|
|
109
|
+
await Bun.sleep(0);
|
|
110
|
+
|
|
111
|
+
expect(runtime.auditSink.status()).toMatchObject({
|
|
112
|
+
state: "degraded",
|
|
113
|
+
failedAttempts: 3,
|
|
114
|
+
dropped: 3,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -16,10 +16,24 @@ export type RouteContext = {
|
|
|
16
16
|
export function parseListQuery(query: RouteContext["query"]) {
|
|
17
17
|
const pagination = parsePagination(query);
|
|
18
18
|
if (pagination instanceof Response) return pagination;
|
|
19
|
+
if (
|
|
20
|
+
(query.status &&
|
|
21
|
+
!["draft", "active", "disabled", "archived"].includes(query.status)) ||
|
|
22
|
+
[query.departmentId, query.roleId].some(
|
|
23
|
+
(id) => id !== undefined && (!id.trim() || id.length > 64),
|
|
24
|
+
)
|
|
25
|
+
) {
|
|
26
|
+
return Response.json(
|
|
27
|
+
{ error: "INVALID_QUERY", message: "状态或组织筛选条件不合法" },
|
|
28
|
+
{ status: 400 },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
19
31
|
return {
|
|
20
32
|
...pagination,
|
|
21
33
|
q: query.q,
|
|
22
34
|
status: query.status as LifecycleStatus | undefined,
|
|
35
|
+
departmentId: query.departmentId,
|
|
36
|
+
roleId: query.roleId,
|
|
23
37
|
includeDeleted: query.includeDeleted === "true",
|
|
24
38
|
sort: query.sort
|
|
25
39
|
? query.sort.split(",").map((field) => ({
|
|
@@ -2,7 +2,7 @@ import type { FeatureFlagDefinition } from "@southwind-ai/shared";
|
|
|
2
2
|
import type { ManagedEntity } from "./entities.js";
|
|
3
3
|
import type { EntityRepository, MaybePromise } from "./repository.js";
|
|
4
4
|
import type { ScopedResourceAccess } from "../data-access/scoped-resource.js";
|
|
5
|
-
import type { RequestActor } from "../guards.js";
|
|
5
|
+
import type { RequestActor, RequestGuardContext } from "../guards.js";
|
|
6
6
|
import type { MutationRunner } from "./mutation-runner.js";
|
|
7
7
|
|
|
8
8
|
export interface ResourcePolicyError {
|
|
@@ -42,20 +42,29 @@ export interface ResourceRouteConfig<T extends ManagedEntity> {
|
|
|
42
42
|
importable?: boolean;
|
|
43
43
|
backupExport?: boolean;
|
|
44
44
|
mutationRunner?: MutationRunner;
|
|
45
|
+
writableFields?: readonly (keyof T)[];
|
|
45
46
|
validateInput?: (
|
|
46
47
|
input: Partial<T>,
|
|
47
48
|
current?: T,
|
|
49
|
+
actor?: RequestActor,
|
|
48
50
|
) => MaybePromise<ResourcePolicyResult>;
|
|
49
51
|
dataAccess:
|
|
50
52
|
| { readonly mode: "platform" }
|
|
51
53
|
| { readonly mode: "request"; readonly access: ScopedResourceAccess<T> };
|
|
54
|
+
projectReadResponses?: (
|
|
55
|
+
entities: T[],
|
|
56
|
+
context: RequestGuardContext,
|
|
57
|
+
) => MaybePromise<Record<string, unknown>[]>;
|
|
52
58
|
projectResponse?: (entity: T) => Record<string, unknown>;
|
|
53
59
|
beforeCreate?: (input: Partial<T>) => MaybePromise<ResourcePolicyResult>;
|
|
54
60
|
beforeUpdate?: (
|
|
55
61
|
current: T,
|
|
56
62
|
input: Partial<T>,
|
|
57
63
|
) => MaybePromise<ResourcePolicyResult>;
|
|
58
|
-
beforeDelete?: (
|
|
64
|
+
beforeDelete?: (
|
|
65
|
+
current: T,
|
|
66
|
+
actor?: RequestActor,
|
|
67
|
+
) => MaybePromise<ResourcePolicyResult>;
|
|
59
68
|
afterUpdate?: (
|
|
60
69
|
current: T,
|
|
61
70
|
updated: T,
|
|
@@ -214,74 +214,6 @@ describe("system routes", () => {
|
|
|
214
214
|
]);
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
-
test("用户与角色变更使用关键策略,普通资源变更保持降级可用", async () => {
|
|
218
|
-
const policies: string[] = [];
|
|
219
|
-
const { app, guardContext } = await routeFixture(undefined, {
|
|
220
|
-
write: async (_event, policy) => void policies.push(policy || "ordinary"),
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
await app.invoke("POST", "/system/roles", {
|
|
224
|
-
guardContext,
|
|
225
|
-
params: {},
|
|
226
|
-
query: {},
|
|
227
|
-
body: { code: "security", name: "安全角色" },
|
|
228
|
-
});
|
|
229
|
-
await app.invoke("POST", "/system/departments", {
|
|
230
|
-
guardContext,
|
|
231
|
-
params: {},
|
|
232
|
-
query: {},
|
|
233
|
-
body: {
|
|
234
|
-
code: "dept-security",
|
|
235
|
-
name: "安全部",
|
|
236
|
-
parentId: "dept_platform",
|
|
237
|
-
},
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
expect(policies).toEqual(["critical", "ordinary"]);
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
test("关键资源审计失败会拒绝请求,普通资源仍完成并报告降级", async () => {
|
|
244
|
-
const { app, runtime, guardContext } = await routeFixture(undefined, {
|
|
245
|
-
write: async () => {
|
|
246
|
-
throw new Error("audit persistence unavailable");
|
|
247
|
-
},
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
await expect(
|
|
251
|
-
app.invoke("POST", "/system/users", {
|
|
252
|
-
guardContext,
|
|
253
|
-
params: {},
|
|
254
|
-
query: {},
|
|
255
|
-
body: {
|
|
256
|
-
username: "security-user",
|
|
257
|
-
displayName: "安全用户",
|
|
258
|
-
roleIds: ["role_user"],
|
|
259
|
-
departmentId: "dept_platform",
|
|
260
|
-
},
|
|
261
|
-
}),
|
|
262
|
-
).rejects.toThrow("audit persistence unavailable");
|
|
263
|
-
|
|
264
|
-
await expect(
|
|
265
|
-
app.invoke("POST", "/system/departments", {
|
|
266
|
-
guardContext,
|
|
267
|
-
params: {},
|
|
268
|
-
query: {},
|
|
269
|
-
body: {
|
|
270
|
-
code: "dept-available",
|
|
271
|
-
name: "可用部门",
|
|
272
|
-
parentId: "dept_platform",
|
|
273
|
-
},
|
|
274
|
-
}),
|
|
275
|
-
).resolves.toMatchObject({ code: "dept-available" });
|
|
276
|
-
await Bun.sleep(0);
|
|
277
|
-
|
|
278
|
-
expect(runtime.auditSink.status()).toMatchObject({
|
|
279
|
-
state: "degraded",
|
|
280
|
-
failedAttempts: 2,
|
|
281
|
-
dropped: 2,
|
|
282
|
-
});
|
|
283
|
-
});
|
|
284
|
-
|
|
285
217
|
test("Feature API 只允许修改状态并让下一请求读取新运行态", async () => {
|
|
286
218
|
const snapshot = createFoundationSnapshot();
|
|
287
219
|
const repositories = createSystemRepositories(snapshot);
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { identityReadModel } from "./identity-read-model.js";
|
|
2
|
+
import { registerDepartmentReadRoutes } from "./department-read-routes.js";
|
|
3
|
+
import { departmentWritableFields } from "./department-validation.js";
|
|
1
4
|
import type { FeatureFlagDefinition } from "@southwind-ai/shared";
|
|
2
5
|
import { registerFeatureFlagRoutes } from "../feature/routes.js";
|
|
3
6
|
import { findSystemFeature } from "../route-guards.js";
|
|
@@ -30,7 +33,8 @@ export function registerSystemRoutes(
|
|
|
30
33
|
runtime: ReturnType<typeof createServerRuntime>,
|
|
31
34
|
features: FeatureFlagDefinition[],
|
|
32
35
|
options: {
|
|
33
|
-
authRepository?: Pick<AuthRepository, "revokeUserSessions"
|
|
36
|
+
authRepository?: Pick<AuthRepository, "revokeUserSessions"> &
|
|
37
|
+
Partial<Pick<AuthRepository, "findUserByReference">>;
|
|
34
38
|
authorizationRevocations?: AuthorizationRevocationPublisher;
|
|
35
39
|
} = {},
|
|
36
40
|
) {
|
|
@@ -40,7 +44,27 @@ export function registerSystemRoutes(
|
|
|
40
44
|
options.authRepository,
|
|
41
45
|
options.authorizationRevocations,
|
|
42
46
|
);
|
|
43
|
-
const departmentPolicy = new DepartmentOrganizationPolicy(
|
|
47
|
+
const departmentPolicy = new DepartmentOrganizationPolicy(
|
|
48
|
+
repositories,
|
|
49
|
+
async (actors, occurredAt) => {
|
|
50
|
+
for (const previousActor of actors)
|
|
51
|
+
await options.authorizationRevocations?.publish({
|
|
52
|
+
previousActor,
|
|
53
|
+
occurredAt,
|
|
54
|
+
eventId: `system-organization-authorization:${previousActor.id}:${occurredAt}`,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
const readModel = identityReadModel(repositories, runtime, features);
|
|
59
|
+
identity.users.projectReadResponses = readModel.users;
|
|
60
|
+
registerDepartmentReadRoutes(
|
|
61
|
+
app,
|
|
62
|
+
departmentPolicy,
|
|
63
|
+
readModel,
|
|
64
|
+
runtime,
|
|
65
|
+
features,
|
|
66
|
+
repositories.organizationMutationRunner,
|
|
67
|
+
);
|
|
44
68
|
// @windy-module system.data-governance begin
|
|
45
69
|
registerUserPlaintextRoutes(app, identity.users, runtime);
|
|
46
70
|
// @windy-module system.data-governance end
|
|
@@ -73,6 +97,7 @@ export function registerSystemRoutes(
|
|
|
73
97
|
{ mode: "platform" },
|
|
74
98
|
),
|
|
75
99
|
importable: false,
|
|
100
|
+
writableFields: departmentWritableFields,
|
|
76
101
|
mutationRunner: repositories.organizationMutationRunner,
|
|
77
102
|
validateInput: (input, current) =>
|
|
78
103
|
departmentPolicy.validateInput(input, current),
|
|
@@ -80,6 +105,8 @@ export function registerSystemRoutes(
|
|
|
80
105
|
beforeUpdate: (current, input) =>
|
|
81
106
|
departmentPolicy.beforeUpdate(current, input),
|
|
82
107
|
beforeDelete: (current) => departmentPolicy.beforeDelete(current),
|
|
108
|
+
afterUpdate: (current, updated) =>
|
|
109
|
+
departmentPolicy.afterUpdate(current, updated),
|
|
83
110
|
},
|
|
84
111
|
runtime,
|
|
85
112
|
);
|
|
@@ -113,7 +113,9 @@ function fixture(users: ManagedUser[], auth = new InMemoryAuthRepository()) {
|
|
|
113
113
|
return {
|
|
114
114
|
roles,
|
|
115
115
|
users: userRepository,
|
|
116
|
-
policy: new UserAccountPolicy(userRepository, roles,
|
|
116
|
+
policy: new UserAccountPolicy(userRepository, roles, {
|
|
117
|
+
revokeUserSessions: auth.revokeUserSessions.bind(auth),
|
|
118
|
+
}),
|
|
117
119
|
};
|
|
118
120
|
}
|
|
119
121
|
|
|
@@ -16,7 +16,8 @@ export class UserAccountPolicy {
|
|
|
16
16
|
constructor(
|
|
17
17
|
private readonly users: EntityRepository<ManagedUser>,
|
|
18
18
|
private readonly roles: EntityRepository<ManagedRole>,
|
|
19
|
-
private readonly auth?: Pick<AuthRepository, "revokeUserSessions"
|
|
19
|
+
private readonly auth?: Pick<AuthRepository, "revokeUserSessions"> &
|
|
20
|
+
Partial<Pick<AuthRepository, "findUserByReference">>,
|
|
20
21
|
private readonly authorizationRevocations?: AuthorizationRevocationPublisher,
|
|
21
22
|
) {}
|
|
22
23
|
|
|
@@ -77,16 +78,22 @@ export class UserAccountPolicy {
|
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
void actor;
|
|
80
|
-
if (
|
|
81
|
+
if (
|
|
82
|
+
current.status === "active" &&
|
|
83
|
+
(updated.status !== "active" || Boolean(updated.deletedAt))
|
|
84
|
+
) {
|
|
81
85
|
await this.auth?.revokeUserSessions(updated.id, new Date());
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
async afterDelete(deleted: ManagedUser): Promise<void> {
|
|
86
|
-
const previousActor = await resolveSystemActorForUser(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
const previousActor = await resolveSystemActorForUser(
|
|
91
|
+
{ roles: this.roles },
|
|
92
|
+
{
|
|
93
|
+
...deleted,
|
|
94
|
+
status: "active",
|
|
95
|
+
},
|
|
96
|
+
);
|
|
90
97
|
if (previousActor) {
|
|
91
98
|
await this.authorizationRevocations?.publish({
|
|
92
99
|
previousActor,
|
|
@@ -141,7 +148,12 @@ export class UserAccountPolicy {
|
|
|
141
148
|
}
|
|
142
149
|
|
|
143
150
|
private async isRecoverableAdmin(user: ManagedUser): Promise<boolean> {
|
|
144
|
-
if (user.status !== "active") return false;
|
|
151
|
+
if (user.status !== "active" || user.deletedAt) return false;
|
|
152
|
+
if (
|
|
153
|
+
this.auth?.findUserByReference &&
|
|
154
|
+
!(await this.auth.findUserByReference(user.id))?.passwordHash
|
|
155
|
+
)
|
|
156
|
+
return false;
|
|
145
157
|
const roles = await Promise.all(
|
|
146
158
|
user.roleIds.map((id) => this.roles.get(id)),
|
|
147
159
|
);
|
|
@@ -175,6 +187,7 @@ export class UserAccountPolicy {
|
|
|
175
187
|
function userAuthorizationChanged(left: ManagedUser, right: ManagedUser) {
|
|
176
188
|
return (
|
|
177
189
|
left.status !== right.status ||
|
|
190
|
+
left.deletedAt !== right.deletedAt ||
|
|
178
191
|
left.departmentId !== right.departmentId ||
|
|
179
192
|
stableStrings(left.roleIds) !== stableStrings(right.roleIds)
|
|
180
193
|
);
|
|
@@ -183,9 +196,13 @@ function userAuthorizationChanged(left: ManagedUser, right: ManagedUser) {
|
|
|
183
196
|
function roleAuthorizationChanged(left: ManagedRole, right: ManagedRole) {
|
|
184
197
|
return (
|
|
185
198
|
left.status !== right.status ||
|
|
199
|
+
left.deletedAt !== right.deletedAt ||
|
|
200
|
+
left.code !== right.code ||
|
|
186
201
|
left.dataScope !== right.dataScope ||
|
|
187
|
-
stableStrings(left.permissionKeys) !==
|
|
188
|
-
|
|
202
|
+
stableStrings(left.permissionKeys) !==
|
|
203
|
+
stableStrings(right.permissionKeys) ||
|
|
204
|
+
stableStrings(left.departmentIds ?? []) !==
|
|
205
|
+
stableStrings(right.departmentIds ?? [])
|
|
189
206
|
);
|
|
190
207
|
}
|
|
191
208
|
|