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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { RequestActor } from "../guards.js";
|
|
3
|
+
import { buildActorScopeContexts } from "../data-access/context.js";
|
|
4
|
+
import type { ManagedDepartment, ManagedRole } from "./entities.js";
|
|
5
|
+
import { IdentityRoleAssignment } from "./identity-role-assignment.js";
|
|
6
|
+
import { InMemoryEntityRepository } from "./repository.js";
|
|
7
|
+
|
|
8
|
+
const audit = {
|
|
9
|
+
createdAt: "2026-09-10T00:00:00Z",
|
|
10
|
+
updatedAt: "2026-09-10T00:00:00Z",
|
|
11
|
+
createdBy: "system",
|
|
12
|
+
updatedBy: "system",
|
|
13
|
+
};
|
|
14
|
+
const departmentRole: ManagedRole = {
|
|
15
|
+
...audit,
|
|
16
|
+
id: "department-role",
|
|
17
|
+
code: "department-manager",
|
|
18
|
+
name: "部门管理员",
|
|
19
|
+
dataScope: "department",
|
|
20
|
+
departmentIds: [],
|
|
21
|
+
permissionKeys: ["system.user.manage"],
|
|
22
|
+
menuKeys: [],
|
|
23
|
+
status: "active",
|
|
24
|
+
};
|
|
25
|
+
const customRole: ManagedRole = {
|
|
26
|
+
...departmentRole,
|
|
27
|
+
id: "custom-role",
|
|
28
|
+
code: "custom",
|
|
29
|
+
dataScope: "custom",
|
|
30
|
+
departmentIds: ["a", "b"],
|
|
31
|
+
};
|
|
32
|
+
const rootRole: ManagedRole = {
|
|
33
|
+
...departmentRole,
|
|
34
|
+
id: "root-role",
|
|
35
|
+
code: "super-admin",
|
|
36
|
+
dataScope: "all",
|
|
37
|
+
};
|
|
38
|
+
const departments = new InMemoryEntityRepository<ManagedDepartment>(
|
|
39
|
+
["a", "b"].map((id) => ({
|
|
40
|
+
...audit,
|
|
41
|
+
id,
|
|
42
|
+
name: id,
|
|
43
|
+
code: id,
|
|
44
|
+
order: 0,
|
|
45
|
+
status: "active",
|
|
46
|
+
})),
|
|
47
|
+
);
|
|
48
|
+
const policy = new IdentityRoleAssignment(
|
|
49
|
+
departments,
|
|
50
|
+
new InMemoryEntityRepository([departmentRole, customRole, rootRole]),
|
|
51
|
+
);
|
|
52
|
+
function actor(roles: ManagedRole[]): RequestActor {
|
|
53
|
+
return {
|
|
54
|
+
id: "manager",
|
|
55
|
+
name: "管理员",
|
|
56
|
+
type: "user",
|
|
57
|
+
roleCodes: roles.map((role) => role.code),
|
|
58
|
+
permissionKeys: ["system.user.manage"],
|
|
59
|
+
...buildActorScopeContexts(
|
|
60
|
+
"manager",
|
|
61
|
+
"a",
|
|
62
|
+
roles.map((role) => ({
|
|
63
|
+
...role,
|
|
64
|
+
departmentIds: role.departmentIds || [],
|
|
65
|
+
})),
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("delegated role assignment", () => {
|
|
71
|
+
test("委派管理员不能把自己提为超管,超管可授予所有启用角色", async () => {
|
|
72
|
+
expect(
|
|
73
|
+
await policy.validateUser(
|
|
74
|
+
{ id: "manager", departmentId: "a", roleIds: [rootRole.id] },
|
|
75
|
+
undefined,
|
|
76
|
+
actor([departmentRole]),
|
|
77
|
+
),
|
|
78
|
+
).toContain("超级管理员");
|
|
79
|
+
expect(
|
|
80
|
+
await policy.validateUser(
|
|
81
|
+
{ departmentId: "b", roleIds: [departmentRole.id] },
|
|
82
|
+
undefined,
|
|
83
|
+
actor([rootRole]),
|
|
84
|
+
),
|
|
85
|
+
).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("不能先修改同部门高权账户资料或移除角色,再接管身份", async () => {
|
|
89
|
+
const target = {
|
|
90
|
+
...audit,
|
|
91
|
+
id: "root-user",
|
|
92
|
+
username: "root",
|
|
93
|
+
displayName: "超级管理员",
|
|
94
|
+
departmentId: "a",
|
|
95
|
+
roleIds: [rootRole.id],
|
|
96
|
+
status: "active" as const,
|
|
97
|
+
};
|
|
98
|
+
expect(
|
|
99
|
+
await policy.validateUser(
|
|
100
|
+
{ displayName: "改名" },
|
|
101
|
+
target,
|
|
102
|
+
actor([departmentRole]),
|
|
103
|
+
),
|
|
104
|
+
).toContain("超级管理员");
|
|
105
|
+
expect(
|
|
106
|
+
await policy.validateUser(
|
|
107
|
+
{ roleIds: [departmentRole.id] },
|
|
108
|
+
target,
|
|
109
|
+
actor([departmentRole]),
|
|
110
|
+
),
|
|
111
|
+
).toContain("超级管理员");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("跨部门使用本人角色仍须证明目标授权范围不扩大", async () => {
|
|
115
|
+
expect(
|
|
116
|
+
await policy.validateUser(
|
|
117
|
+
{ id: "other", departmentId: "b", roleIds: [departmentRole.id] },
|
|
118
|
+
undefined,
|
|
119
|
+
actor([departmentRole]),
|
|
120
|
+
),
|
|
121
|
+
).toContain("扩大");
|
|
122
|
+
expect(
|
|
123
|
+
await policy.validateUser(
|
|
124
|
+
{ id: "other", departmentId: "b", roleIds: [departmentRole.id] },
|
|
125
|
+
undefined,
|
|
126
|
+
actor([departmentRole, customRole]),
|
|
127
|
+
),
|
|
128
|
+
).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("新建未持有角色可授予范围内账户,但权限或目标范围超限仍拒绝", async () => {
|
|
132
|
+
const roles = new InMemoryEntityRepository<ManagedRole>([departmentRole]);
|
|
133
|
+
const assignment = new IdentityRoleAssignment(departments, roles);
|
|
134
|
+
const manager = actor([departmentRole]);
|
|
135
|
+
const draft = {
|
|
136
|
+
...departmentRole,
|
|
137
|
+
id: "new-role",
|
|
138
|
+
code: "new-role",
|
|
139
|
+
name: "新角色",
|
|
140
|
+
};
|
|
141
|
+
expect(
|
|
142
|
+
await assignment.validateRole(draft, undefined, manager),
|
|
143
|
+
).toBeUndefined();
|
|
144
|
+
await roles.create(draft, manager);
|
|
145
|
+
expect(
|
|
146
|
+
await assignment.validateUser(
|
|
147
|
+
{ departmentId: "a", roleIds: [draft.id] },
|
|
148
|
+
undefined,
|
|
149
|
+
manager,
|
|
150
|
+
),
|
|
151
|
+
).toBeUndefined();
|
|
152
|
+
expect(
|
|
153
|
+
await assignment.validateUser(
|
|
154
|
+
{ departmentId: "b", roleIds: [draft.id] },
|
|
155
|
+
undefined,
|
|
156
|
+
manager,
|
|
157
|
+
),
|
|
158
|
+
).toContain("扩大");
|
|
159
|
+
expect(
|
|
160
|
+
await assignment.validateRole({ name: "改共享角色" }, draft, manager),
|
|
161
|
+
).toContain("本人持有");
|
|
162
|
+
await roles.update(
|
|
163
|
+
draft.id,
|
|
164
|
+
{ permissionKeys: ["system.role.manage"] },
|
|
165
|
+
manager,
|
|
166
|
+
);
|
|
167
|
+
expect(
|
|
168
|
+
await assignment.validateUser(
|
|
169
|
+
{ departmentId: "a", roleIds: [draft.id] },
|
|
170
|
+
undefined,
|
|
171
|
+
manager,
|
|
172
|
+
),
|
|
173
|
+
).toContain("超出");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("不能通过修改共享角色或改超级管理员编码提升权限", async () => {
|
|
177
|
+
expect(
|
|
178
|
+
await policy.validateRole(
|
|
179
|
+
{ code: "super-admin" },
|
|
180
|
+
departmentRole,
|
|
181
|
+
actor([departmentRole]),
|
|
182
|
+
),
|
|
183
|
+
).toContain("超出");
|
|
184
|
+
expect(
|
|
185
|
+
await policy.validateRole(
|
|
186
|
+
{ dataScope: "all" },
|
|
187
|
+
departmentRole,
|
|
188
|
+
actor([departmentRole]),
|
|
189
|
+
),
|
|
190
|
+
).toContain("共享角色");
|
|
191
|
+
expect(
|
|
192
|
+
await policy.validateRole(
|
|
193
|
+
{ permissionKeys: ["system.role.manage"] },
|
|
194
|
+
departmentRole,
|
|
195
|
+
actor([departmentRole]),
|
|
196
|
+
),
|
|
197
|
+
).toContain("超出");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { RequestActor } from "../guards.js";
|
|
2
|
+
import {
|
|
3
|
+
buildScopeContext,
|
|
4
|
+
isServerScopeContext,
|
|
5
|
+
} from "../data-access/context.js";
|
|
6
|
+
import {
|
|
7
|
+
buildDataScopeQuerySpecification,
|
|
8
|
+
type DataScopeQueryBranch,
|
|
9
|
+
} from "../data-access/query-specification.js";
|
|
10
|
+
import { departmentDescendantResolver } from "../data-access/department-tree.js";
|
|
11
|
+
import type {
|
|
12
|
+
ManagedDepartment,
|
|
13
|
+
ManagedRole,
|
|
14
|
+
ManagedUser,
|
|
15
|
+
} from "./entities.js";
|
|
16
|
+
import type { EntityRepository } from "./repository.js";
|
|
17
|
+
|
|
18
|
+
export class IdentityRoleAssignment {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly departments: EntityRepository<ManagedDepartment>,
|
|
21
|
+
private readonly roles: EntityRepository<ManagedRole>,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
async validateUser(
|
|
25
|
+
input: Partial<ManagedUser>,
|
|
26
|
+
current: ManagedUser | undefined,
|
|
27
|
+
actor: RequestActor | undefined,
|
|
28
|
+
): Promise<string | undefined> {
|
|
29
|
+
if (!actor) return "缺少角色授权主体";
|
|
30
|
+
if (actor.roleCodes.includes("super-admin")) return undefined;
|
|
31
|
+
if (!isServerScopeContext(actor.scopeContext))
|
|
32
|
+
return "缺少角色授权数据范围";
|
|
33
|
+
const candidates = current ? [current, { ...current, ...input }] : [input];
|
|
34
|
+
for (const user of candidates) {
|
|
35
|
+
const ids = user.roleIds || [];
|
|
36
|
+
for (const id of ids) {
|
|
37
|
+
const role = await this.roles.get(id);
|
|
38
|
+
if (!role) return "角色不存在或已删除";
|
|
39
|
+
if (role.code === "super-admin")
|
|
40
|
+
return "只有超级管理员能授予或维护超级管理员身份";
|
|
41
|
+
if (
|
|
42
|
+
!role.permissionKeys.every((key) =>
|
|
43
|
+
actor.permissionKeys.includes(key),
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return "不能授予超出本人权限的角色";
|
|
47
|
+
if (
|
|
48
|
+
!(await this.withinScope(
|
|
49
|
+
role,
|
|
50
|
+
user.id || "new-user",
|
|
51
|
+
user.departmentId,
|
|
52
|
+
actor,
|
|
53
|
+
))
|
|
54
|
+
)
|
|
55
|
+
return "角色授权不能扩大操作者的数据范围";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async validateRole(
|
|
62
|
+
input: Partial<ManagedRole>,
|
|
63
|
+
current: ManagedRole | undefined,
|
|
64
|
+
actor: RequestActor | undefined,
|
|
65
|
+
): Promise<string | undefined> {
|
|
66
|
+
if (!actor) return "缺少角色授权主体";
|
|
67
|
+
if (actor.roleCodes.includes("super-admin")) return undefined;
|
|
68
|
+
if (!isServerScopeContext(actor.scopeContext))
|
|
69
|
+
return "缺少角色授权数据范围";
|
|
70
|
+
const role = { ...current, ...input } as ManagedRole;
|
|
71
|
+
if (current && !actor.scopeContext.roleIds.includes(current.id))
|
|
72
|
+
return "只能维护本人持有的角色";
|
|
73
|
+
if (
|
|
74
|
+
role.code === "super-admin" ||
|
|
75
|
+
!role.permissionKeys.every((key) => actor.permissionKeys.includes(key))
|
|
76
|
+
)
|
|
77
|
+
return "角色权限不能超出操作者权限";
|
|
78
|
+
// Changing a shared role can expand every member, even when the editor's own scope is unchanged.
|
|
79
|
+
if (
|
|
80
|
+
current &&
|
|
81
|
+
(role.dataScope !== current.dataScope ||
|
|
82
|
+
JSON.stringify(role.departmentIds || []) !==
|
|
83
|
+
JSON.stringify(current.departmentIds || []))
|
|
84
|
+
)
|
|
85
|
+
return "委派管理员不能修改共享角色的数据范围";
|
|
86
|
+
return (await this.withinScope(
|
|
87
|
+
role,
|
|
88
|
+
actor.id,
|
|
89
|
+
actor.scopeContext.departmentId,
|
|
90
|
+
actor,
|
|
91
|
+
))
|
|
92
|
+
? undefined
|
|
93
|
+
: "角色数据范围不能超出操作者范围";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private async withinScope(
|
|
97
|
+
role: ManagedRole,
|
|
98
|
+
userId: string,
|
|
99
|
+
departmentId: string | undefined,
|
|
100
|
+
actor: RequestActor,
|
|
101
|
+
): Promise<boolean> {
|
|
102
|
+
if (!isServerScopeContext(actor.scopeContext)) return false;
|
|
103
|
+
const resolver = {
|
|
104
|
+
descendants: departmentDescendantResolver(this.departments),
|
|
105
|
+
};
|
|
106
|
+
const target = await buildDataScopeQuerySpecification(
|
|
107
|
+
buildScopeContext(userId, departmentId, [
|
|
108
|
+
{ ...role, departmentIds: role.departmentIds || [] },
|
|
109
|
+
]),
|
|
110
|
+
resolver,
|
|
111
|
+
);
|
|
112
|
+
const allowed = await buildDataScopeQuerySpecification(
|
|
113
|
+
actor.scopeContext,
|
|
114
|
+
resolver,
|
|
115
|
+
);
|
|
116
|
+
return target.branches.every((branch) =>
|
|
117
|
+
included(branch, allowed.branches, departmentId),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function included(
|
|
123
|
+
target: DataScopeQueryBranch,
|
|
124
|
+
allowed: readonly DataScopeQueryBranch[],
|
|
125
|
+
departmentId: string | undefined,
|
|
126
|
+
): boolean {
|
|
127
|
+
if (
|
|
128
|
+
allowed.some((branch) => branch.kind === "all" || branch.kind === "tenant")
|
|
129
|
+
)
|
|
130
|
+
return true;
|
|
131
|
+
if (target.kind === "all" || target.kind === "tenant") return false;
|
|
132
|
+
if (target.kind === "owner")
|
|
133
|
+
return allowed.some((branch) =>
|
|
134
|
+
branch.kind === "owner"
|
|
135
|
+
? branch.ownerId === target.ownerId
|
|
136
|
+
: branch.kind === "dimension" &&
|
|
137
|
+
branch.dimension === "departmentId" &&
|
|
138
|
+
Boolean(departmentId && branch.values.includes(departmentId)),
|
|
139
|
+
);
|
|
140
|
+
const values = new Set(
|
|
141
|
+
allowed.flatMap((branch) =>
|
|
142
|
+
branch.kind === "dimension" && branch.dimension === target.dimension
|
|
143
|
+
? [...branch.values]
|
|
144
|
+
: [],
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
return target.values.every((value) => values.has(value));
|
|
148
|
+
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { IdentityRoleAssignment } from "./identity-role-assignment.js";
|
|
2
|
+
import {
|
|
3
|
+
userWritableFields,
|
|
4
|
+
roleWritableFields,
|
|
5
|
+
validateUserFields,
|
|
6
|
+
validateRoleFields,
|
|
7
|
+
} from "./identity-write-validation.js";
|
|
1
8
|
import type { FeatureFlagDefinition } from "@southwind-ai/shared";
|
|
2
9
|
import type { AuthRepository } from "../auth/repository.js";
|
|
3
10
|
import { departmentDescendantResolver } from "../data-access/department-tree.js";
|
|
@@ -17,7 +24,8 @@ import { RoleDepartmentScopePolicy } from "./role-department-scope-policy.js";
|
|
|
17
24
|
export function identityRouteConfigs(
|
|
18
25
|
repositories: SystemRepositories,
|
|
19
26
|
features: FeatureFlagDefinition[],
|
|
20
|
-
authRepository?: Pick<AuthRepository, "revokeUserSessions"
|
|
27
|
+
authRepository?: Pick<AuthRepository, "revokeUserSessions"> &
|
|
28
|
+
Partial<Pick<AuthRepository, "findUserByReference">>,
|
|
21
29
|
authorizationRevocations?: AuthorizationRevocationPublisher,
|
|
22
30
|
): {
|
|
23
31
|
users: ResourceRouteConfig<ManagedUser>;
|
|
@@ -32,6 +40,10 @@ export function identityRouteConfigs(
|
|
|
32
40
|
const roleDepartmentScope = new RoleDepartmentScopePolicy(
|
|
33
41
|
repositories.departments,
|
|
34
42
|
);
|
|
43
|
+
const assignment = new IdentityRoleAssignment(
|
|
44
|
+
repositories.departments,
|
|
45
|
+
repositories.roles,
|
|
46
|
+
);
|
|
35
47
|
const mutationRunner = repositories.organizationMutationRunner;
|
|
36
48
|
return {
|
|
37
49
|
users: {
|
|
@@ -50,14 +62,19 @@ export function identityRouteConfigs(
|
|
|
50
62
|
},
|
|
51
63
|
),
|
|
52
64
|
projectResponse: projectMaskedUser,
|
|
53
|
-
|
|
65
|
+
writableFields: userWritableFields,
|
|
66
|
+
validateInput: async (input, current, actor) =>
|
|
67
|
+
(await validateUserFields(input, repositories.users, current)) ||
|
|
54
68
|
(await userDepartmentValidator(repositories.departments)(input)) ||
|
|
55
|
-
(await policy.validate(input, current))
|
|
69
|
+
(await policy.validate(input, current)) ||
|
|
70
|
+
(await assignment.validateUser(input, current, actor)),
|
|
56
71
|
importable: false,
|
|
57
72
|
backupExport: false,
|
|
58
73
|
mutationRunner,
|
|
59
74
|
beforeUpdate: (current, input) => policy.protectUpdate(current, input),
|
|
60
|
-
beforeDelete: (current) =>
|
|
75
|
+
beforeDelete: async (current, actor) =>
|
|
76
|
+
(await assignment.validateUser({}, current, actor)) ||
|
|
77
|
+
(await policy.protectDelete(current)),
|
|
61
78
|
afterUpdate: (current, updated, actor) =>
|
|
62
79
|
policy.afterUpdate(current, updated, actor),
|
|
63
80
|
afterDelete: (deleted) => policy.afterDelete(deleted),
|
|
@@ -73,9 +90,14 @@ export function identityRouteConfigs(
|
|
|
73
90
|
),
|
|
74
91
|
beforeUpdate: (current, input) =>
|
|
75
92
|
policy.protectRoleUpdate(current, input),
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
93
|
+
writableFields: roleWritableFields,
|
|
94
|
+
validateInput: async (input, current, actor) =>
|
|
95
|
+
(await validateRoleFields(input, repositories.roles, current)) ||
|
|
96
|
+
(await roleDepartmentScope.validate(input, current)) ||
|
|
97
|
+
(await assignment.validateRole(input, current, actor)),
|
|
98
|
+
beforeDelete: async (current, actor) =>
|
|
99
|
+
(await assignment.validateRole({}, current, actor)) ||
|
|
100
|
+
policy.protectRoleDelete(current),
|
|
79
101
|
afterUpdate: (current, updated) =>
|
|
80
102
|
policy.afterRoleUpdate(current, updated),
|
|
81
103
|
afterDelete: (deleted) => policy.afterRoleDelete(deleted),
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createFoundationSnapshot } from "../foundation.js";
|
|
3
|
+
import { createRouteTestRuntime } from "./route-test-runtime.js";
|
|
4
|
+
import { createSystemRepositories } from "./seed.js";
|
|
5
|
+
import { FakeRouteApp } from "./route-test-app.js";
|
|
6
|
+
import { registerSystemRoutes } from "./routes.js";
|
|
7
|
+
import {
|
|
8
|
+
identityMutationResponse,
|
|
9
|
+
validateUserFields,
|
|
10
|
+
validateRoleFields,
|
|
11
|
+
} from "./identity-write-validation.js";
|
|
12
|
+
import { UserAccountPolicy } from "./user-account-policy.js";
|
|
13
|
+
|
|
14
|
+
async function fixture() {
|
|
15
|
+
const snapshot = createFoundationSnapshot();
|
|
16
|
+
const repositories = createSystemRepositories(snapshot);
|
|
17
|
+
const runtime = createRouteTestRuntime(snapshot, {
|
|
18
|
+
WINDY_DEV_ADMIN_TOKEN: "identity-security",
|
|
19
|
+
});
|
|
20
|
+
const guardContext = await runtime.createGuardContext(
|
|
21
|
+
new Request("http://localhost/api/system/users", {
|
|
22
|
+
headers: { authorization: "Bearer identity-security" },
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
const app = new FakeRouteApp();
|
|
26
|
+
registerSystemRoutes(app, repositories, runtime, snapshot.features);
|
|
27
|
+
return { app, repositories, guardContext };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("identity write boundary", () => {
|
|
31
|
+
test("用户写入审计字段被拒绝,最后管理员保持可见", async () => {
|
|
32
|
+
const { app, repositories, guardContext } = await fixture();
|
|
33
|
+
const current = (await repositories.users.all())[0]!;
|
|
34
|
+
for (const field of [
|
|
35
|
+
"deletedAt",
|
|
36
|
+
"deletedBy",
|
|
37
|
+
"createdBy",
|
|
38
|
+
"createdAt",
|
|
39
|
+
"id",
|
|
40
|
+
"passwordHash",
|
|
41
|
+
]) {
|
|
42
|
+
const response = (await app.invoke("PUT", "/system/users/:id", {
|
|
43
|
+
guardContext,
|
|
44
|
+
params: { id: current.id },
|
|
45
|
+
query: {},
|
|
46
|
+
body: { [field]: "injected" },
|
|
47
|
+
})) as Response;
|
|
48
|
+
expect(response.status).toBe(400);
|
|
49
|
+
expect(await repositories.users.get(current.id)).toEqual(current);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("用户与角色缺必填、非法状态和重复唯一字段返回业务错误", async () => {
|
|
54
|
+
const { repositories } = await fixture();
|
|
55
|
+
expect(
|
|
56
|
+
await validateUserFields(
|
|
57
|
+
{ username: "", displayName: "测试", roleIds: [] },
|
|
58
|
+
repositories.users,
|
|
59
|
+
),
|
|
60
|
+
).toContain("用户名");
|
|
61
|
+
expect(
|
|
62
|
+
await validateRoleFields(
|
|
63
|
+
{ code: "role", name: "角色" },
|
|
64
|
+
repositories.roles,
|
|
65
|
+
),
|
|
66
|
+
).toContain("权限");
|
|
67
|
+
const current = (await repositories.users.all())[0]!;
|
|
68
|
+
expect(
|
|
69
|
+
await validateUserFields(
|
|
70
|
+
{
|
|
71
|
+
username: current.username,
|
|
72
|
+
displayName: "重复",
|
|
73
|
+
roleIds: ["role_user"],
|
|
74
|
+
},
|
|
75
|
+
repositories.users,
|
|
76
|
+
),
|
|
77
|
+
).toMatchObject({ error: "DUPLICATE_IDENTITY" });
|
|
78
|
+
const result = (await identityMutationResponse(async () => {
|
|
79
|
+
throw { cause: { code: "23505", detail: "private value" } };
|
|
80
|
+
})) as Response;
|
|
81
|
+
expect(result.status).toBe(409);
|
|
82
|
+
expect(JSON.stringify(await result.json())).not.toContain("private value");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("无凭据的第二管理员不能让最后可登录管理员被停用", async () => {
|
|
86
|
+
const { repositories, guardContext } = await fixture();
|
|
87
|
+
const first = (await repositories.users.all())[0]!;
|
|
88
|
+
await repositories.users.update(
|
|
89
|
+
first.id,
|
|
90
|
+
{ roleIds: ["role_super_admin"] },
|
|
91
|
+
guardContext.actor,
|
|
92
|
+
);
|
|
93
|
+
const current = (await repositories.users.get(first.id))!;
|
|
94
|
+
await repositories.users.create(
|
|
95
|
+
{
|
|
96
|
+
username: "not-initialized",
|
|
97
|
+
displayName: "未初始化管理员",
|
|
98
|
+
departmentId: current.departmentId,
|
|
99
|
+
roleIds: ["role_super_admin"],
|
|
100
|
+
},
|
|
101
|
+
guardContext.actor,
|
|
102
|
+
);
|
|
103
|
+
const policy = new UserAccountPolicy(
|
|
104
|
+
repositories.users,
|
|
105
|
+
repositories.roles,
|
|
106
|
+
{
|
|
107
|
+
revokeUserSessions: async () => 0,
|
|
108
|
+
findUserByReference: async (id) =>
|
|
109
|
+
id === current.id
|
|
110
|
+
? {
|
|
111
|
+
id,
|
|
112
|
+
username: current.username,
|
|
113
|
+
displayName: current.displayName,
|
|
114
|
+
passwordHash: "credential",
|
|
115
|
+
credentialState: "active",
|
|
116
|
+
status: "active",
|
|
117
|
+
roleCodes: ["super-admin"],
|
|
118
|
+
permissionKeys: [],
|
|
119
|
+
}
|
|
120
|
+
: undefined,
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
expect(
|
|
124
|
+
await policy.protectUpdate(current, { status: "disabled" }),
|
|
125
|
+
).toContain("至少一个");
|
|
126
|
+
});
|
|
127
|
+
});
|