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.
Files changed (124) hide show
  1. package/dist/cli.js +11 -4
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/server/src/audit/policy.ts +6 -1
  5. package/template/apps/server/src/auth/bootstrap-admin.ts +10 -1
  6. package/template/apps/server/src/auth/composition.ts +5 -0
  7. package/template/apps/server/src/auth/credential-authorization.test.ts +257 -0
  8. package/template/apps/server/src/auth/credential-routes.test.ts +1 -0
  9. package/template/apps/server/src/auth/credential-service.ts +47 -12
  10. package/template/apps/server/src/auth/drizzle-repository.ts +13 -5
  11. package/template/apps/server/src/auth/drizzle-session-operations.ts +1 -0
  12. package/template/apps/server/src/auth/repository.ts +9 -6
  13. package/template/apps/server/src/auth/routes.test-support.ts +1 -0
  14. package/template/apps/server/src/auth/runtime.ts +4 -0
  15. package/template/apps/server/src/auth/service-options.ts +2 -0
  16. package/template/apps/server/src/auth/service.ts +2 -0
  17. package/template/apps/server/src/auth/user-credential-access.ts +37 -0
  18. package/template/apps/server/src/foundation-activation.test.ts +24 -1
  19. package/template/apps/server/src/foundation.ts +0 -2
  20. package/template/apps/server/src/index.ts +1 -0
  21. package/template/apps/server/src/runtime-development.ts +2 -0
  22. package/template/apps/server/src/runtime.test.ts +13 -4
  23. package/template/apps/server/src/system/department-organization.ts +58 -59
  24. package/template/apps/server/src/system/department-read-routes.ts +54 -0
  25. package/template/apps/server/src/system/department-routes.test.ts +27 -19
  26. package/template/apps/server/src/system/department-routes.ts +95 -33
  27. package/template/apps/server/src/system/department-transition-safety.test.ts +148 -0
  28. package/template/apps/server/src/system/department-validation.ts +79 -0
  29. package/template/apps/server/src/system/drizzle-repository.ts +14 -0
  30. package/template/apps/server/src/system/drizzle-system-configs.ts +2 -0
  31. package/template/apps/server/src/system/identity-read-model.ts +105 -0
  32. package/template/apps/server/src/system/identity-role-assignment.test.ts +199 -0
  33. package/template/apps/server/src/system/identity-role-assignment.ts +148 -0
  34. package/template/apps/server/src/system/identity-route-config.ts +29 -7
  35. package/template/apps/server/src/system/identity-write-security.test.ts +127 -0
  36. package/template/apps/server/src/system/identity-write-validation.ts +170 -0
  37. package/template/apps/server/src/system/organization-read-security.test.ts +221 -0
  38. package/template/apps/server/src/system/redis-health.test.ts +1 -1
  39. package/template/apps/server/src/system/repository.ts +29 -2
  40. package/template/apps/server/src/system/resource-routes.ts +116 -72
  41. package/template/apps/server/src/system/route-audit.test.ts +117 -0
  42. package/template/apps/server/src/system/route-helpers.ts +14 -0
  43. package/template/apps/server/src/system/route-types.ts +11 -2
  44. package/template/apps/server/src/system/routes.test.ts +0 -68
  45. package/template/apps/server/src/system/routes.ts +29 -2
  46. package/template/apps/server/src/system/user-account-policy.test.ts +3 -1
  47. package/template/apps/server/src/system/user-account-policy.ts +26 -9
  48. package/template/apps/web/src/pages/system/SystemResourceRoutePage.vue +27 -13
  49. package/template/apps/web/src/pages/system/SystemResourceRoutePage.webtest.ts +68 -0
  50. package/template/apps/web/src/pages/system/components/PagedSelectionList.vue +131 -0
  51. package/template/apps/web/src/pages/system/components/PagedSelectionList.webtest.ts +32 -0
  52. package/template/apps/web/src/pages/system/components/SystemRecordDetailDialog.vue +67 -0
  53. package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +114 -38
  54. package/template/apps/web/src/pages/system/components/SystemResourceEditor.webtest.ts +180 -0
  55. package/template/apps/web/src/pages/system/components/SystemResourceFilters.vue +8 -0
  56. package/template/apps/web/src/pages/system/components/SystemResourceForm.vue +8 -2
  57. package/template/apps/web/src/pages/system/components/SystemResourcePage.department.webtest.ts +89 -0
  58. package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +42 -46
  59. package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +12 -55
  60. package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +14 -2
  61. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.vue +121 -85
  62. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.webtest.ts +57 -0
  63. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.vue +227 -0
  64. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.webtest.ts +62 -0
  65. package/template/apps/web/src/pages/system/components/department/DepartmentTransitionDialog.vue +72 -52
  66. package/template/apps/web/src/pages/system/components/role-policy/RolePermissionTree.vue +150 -79
  67. package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.vue +98 -8
  68. package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.webtest.ts +108 -0
  69. package/template/apps/web/src/pages/system/components/selection-options.ts +71 -0
  70. package/template/apps/web/src/pages/system/components/selection-options.webtest.ts +49 -0
  71. package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.vue +19 -27
  72. package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.webtest.ts +2 -6
  73. package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.vue +128 -0
  74. package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.webtest.ts +76 -0
  75. package/template/apps/web/src/pages/system/components/user-account/RolePermissionSummary.vue +61 -3
  76. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +144 -90
  77. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.webtest.ts +95 -0
  78. package/template/apps/web/src/pages/system/components/user-account/UserListFilters.vue +168 -0
  79. package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.vue +33 -57
  80. package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.webtest.ts +4 -4
  81. package/template/apps/web/src/pages/system/components/user-account/save-user-account.ts +4 -1
  82. package/template/apps/web/src/pages/system/components/user-account/user-account-draft.ts +81 -0
  83. package/template/apps/web/src/pages/system/composables/useDepartmentTransitions.ts +6 -10
  84. package/template/apps/web/src/pages/system/composables/useResourceEditor.ts +72 -0
  85. package/template/apps/web/src/pages/system/composables/useResourceEditor.webtest.ts +110 -0
  86. package/template/apps/web/src/pages/system/composables/useSystemResource.ts +27 -1
  87. package/template/apps/web/src/pages/system/resource-config.ts +2 -0
  88. package/template/apps/web/src/services/system-api.ts +25 -0
  89. package/template/docs/architecture/foundation-infrastructure.md +12 -25
  90. package/template/docs/development/crud-generator.md +1 -1
  91. package/template/docs/operations/legacy-data-migration.md +73 -0
  92. package/template/docs/platform/bulk-data-jobs.md +1 -1
  93. package/template/docs/platform/module-configuration.md +2 -2
  94. package/template/docs/platform/organization-membership.md +37 -6
  95. package/template/docs/platform/scheduled-task-executor.md +1 -1
  96. package/template/package.json +2 -1
  97. package/template/packages/config/index.test.ts +1 -5
  98. package/template/packages/config/package.json +2 -2
  99. package/template/packages/config/src/platform-boundary.test.ts +39 -0
  100. package/template/packages/config/src/platform.ts +0 -28
  101. package/template/packages/database/drizzle/0063_license_project_lifecycle.sql +28 -0
  102. package/template/packages/database/drizzle/meta/0063_snapshot.json +15053 -0
  103. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  104. package/template/packages/database/package.json +2 -2
  105. package/template/packages/database/src/agent-runtime-schema.test.ts +5 -1
  106. package/template/packages/database/src/schema/index.ts +1 -0
  107. package/template/packages/database/src/schema/license-issuance-requests.ts +59 -0
  108. package/template/packages/database/src/schema/license-v1.ts +5 -0
  109. package/template/packages/jobs/package.json +2 -2
  110. package/template/packages/modules/package.json +1 -1
  111. package/template/packages/modules/src/system-api-permissions.ts +14 -0
  112. package/template/packages/modules/src/system-features.ts +0 -11
  113. package/template/packages/modules/src/system-module-catalog.test.ts +4 -0
  114. package/template/packages/modules/src/system-module-catalog.ts +0 -1
  115. package/template/packages/server-sdk/index.ts +1 -49
  116. package/template/packages/server-sdk/package.json +3 -2
  117. package/template/packages/server-sdk/src/ai-runtime-readiness-exports.ts +49 -0
  118. package/template/packages/shared/index.ts +2 -1
  119. package/template/packages/shared/package.json +1 -1
  120. package/template/packages/shared/src/license-console-capabilities.ts +163 -0
  121. package/template/packages/shared/src/license-console.ts +94 -0
  122. package/template/packages/shared/src/license.ts +5 -0
  123. package/template/docs/adapters/ruoyi-vue-compatibility.md +0 -93
  124. 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 { ...result, items: projectEntities(config, result.items) };
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
- ? projectEntity(config, item)
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
- runResourceMutation(config, async () => {
156
- const input = asPartial<T>(body);
157
- const validationError = await config.validateInput?.(input);
158
- if (validationError) return invalidResource(validationError);
159
- const policyError = await config.beforeCreate?.(input);
160
- if (policyError) return resourceConflict(policyError);
161
- const item = await accessCall(() =>
162
- config.dataAccess.mode === "request"
163
- ? config.dataAccess.access.create(input, guardContext.actor)
164
- : config.repository.create(input, guardContext.actor),
165
- );
166
- if (item instanceof Response) return item;
167
- await recordMutationAudit(
168
- runtime,
169
- config.path,
170
- resourceEvent(guardContext, "crud.create", config.path, item.id),
171
- );
172
- return projectEntity(config, item);
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
- runResourceMutation(config, async () => {
188
- const id = requiredId(params);
189
- const input = asPartial<T>(body);
190
- const current = await readAccessible(config, id, guardContext.actor);
191
- if (current instanceof Response) return current;
192
- if (!current) return notFound(config.path, id);
193
- const validationError = await config.validateInput?.(input, current);
194
- if (validationError) return invalidResource(validationError);
195
- const policyError = await config.beforeUpdate?.(current, input);
196
- if (policyError) return resourceConflict(policyError);
197
- const item = await accessCall(() =>
198
- config.dataAccess.mode === "request"
199
- ? config.dataAccess.access.update(id, input, guardContext.actor)
200
- : config.repository.update(id, input, guardContext.actor),
201
- );
202
- if (item instanceof Response) return item;
203
- if (!item) {
204
- return notFound(config.path, id);
205
- }
206
- await config.afterUpdate?.(current, item, guardContext.actor);
207
- await recordMutationAudit(
208
- runtime,
209
- config.path,
210
- resourceEvent(guardContext, "crud.update", config.path, id),
211
- );
212
- return projectEntity(config, item);
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
- runResourceMutation(config, async () => {
228
- const id = requiredId(params);
229
- const current = await readAccessible(config, id, guardContext.actor);
230
- if (current instanceof Response) return current;
231
- if (!current) return notFound(config.path, id);
232
- const policyError = await config.beforeDelete?.(current);
233
- if (policyError) return resourceConflict(policyError);
234
- const item = await accessCall(() =>
235
- config.dataAccess.mode === "request"
236
- ? config.dataAccess.access.softDelete(id, guardContext.actor)
237
- : config.repository.softDelete(id, guardContext.actor),
238
- );
239
- if (item instanceof Response) return item;
240
- if (!item) {
241
- return notFound(config.path, id);
242
- }
243
- await config.afterDelete?.(item, guardContext.actor);
244
- await recordMutationAudit(
245
- runtime,
246
- config.path,
247
- resourceEvent(guardContext, "crud.delete", config.path, id),
248
- );
249
- return projectEntity(config, item);
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?: (current: T) => MaybePromise<ResourcePolicyResult>;
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(repositories);
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, auth),
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 (current.status === "active" && updated.status !== "active") {
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({ roles: this.roles }, {
87
- ...deleted,
88
- status: "active",
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) !== stableStrings(right.permissionKeys) ||
188
- stableStrings(left.departmentIds ?? []) !== stableStrings(right.departmentIds ?? [])
202
+ stableStrings(left.permissionKeys) !==
203
+ stableStrings(right.permissionKeys) ||
204
+ stableStrings(left.departmentIds ?? []) !==
205
+ stableStrings(right.departmentIds ?? [])
189
206
  );
190
207
  }
191
208