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
package/dist/cli.js
CHANGED
|
@@ -745,7 +745,7 @@ var platformModules = [
|
|
|
745
745
|
{
|
|
746
746
|
sourceName: "@southwind-ai/license",
|
|
747
747
|
name: "@southwind-ai/license",
|
|
748
|
-
version: "^0.1.
|
|
748
|
+
version: "^0.1.10"
|
|
749
749
|
},
|
|
750
750
|
{
|
|
751
751
|
sourceName: "qrcode",
|
|
@@ -777,7 +777,8 @@ var platformModules = [
|
|
|
777
777
|
],
|
|
778
778
|
schemaFiles: [
|
|
779
779
|
"packages/database/src/schema/license-legacy.ts",
|
|
780
|
-
"packages/database/src/schema/license-v1.ts"
|
|
780
|
+
"packages/database/src/schema/license-v1.ts",
|
|
781
|
+
"packages/database/src/schema/license-issuance-requests.ts"
|
|
781
782
|
],
|
|
782
783
|
databaseTables: [
|
|
783
784
|
"license_operators",
|
|
@@ -793,6 +794,7 @@ var platformModules = [
|
|
|
793
794
|
"licenses",
|
|
794
795
|
"license_replacements",
|
|
795
796
|
"license_events",
|
|
797
|
+
"license_issuance_requests",
|
|
796
798
|
"legacy_license_projects",
|
|
797
799
|
"legacy_license_issue_records",
|
|
798
800
|
"legacy_licenses"
|
|
@@ -3710,7 +3712,7 @@ ${includeOfflineLicense ? "- License \u6D88\u8D39\u7AEF\uFF1A[\u79BB\u7EBF\u6FC0
|
|
|
3710
3712
|
- \u8FD0\u7EF4\uFF1A[\u53EF\u89C2\u6D4B\u6027](./operations/observability.md)${includeOperations ? " \u4E0E [\u8FD0\u7EF4\u603B\u89C8](./platform/operations-overview.md)" : ""}\u3002
|
|
3711
3713
|
- \u4EA4\u4ED8\uFF1A[\u6309 registry digest \u4E0E OCI blob \u5DEE\u91CF\u53D1\u5E03](./operations/incremental-delivery.md)\u3002
|
|
3712
3714
|
${includeAgent ? "- Agent\u3001\u591A\u6A21\u6001 Runtime \u4E0E MCP\uFF1A[Agent Harness](./architecture/agent-harness.md)\u3001[Agent Runtime \u63A5\u5165](./platform/agent-runtime.md)\u3001[Session/Memory \u8FD0\u7EF4](./operations/agent-session-memory.md)\u3001[Session \u6545\u969C\u901F\u67E5](./operations/agent-session-troubleshooting.md)\u3001[Agent Workflow](./architecture/agent-workflow.md)\u3001[Workflow \u6062\u590D\u8FD0\u7EF4](./operations/agent-workflow-recovery.md)\u3001[Document AI Runtime](./platform/document-ai-runtime.md)\u3001[Speech AI Runtime](./platform/speech-ai-runtime.md)\u3001[\u53D7\u63A7\u5DE5\u5177](./platform/agent-tools.md)\u4E0E[MCP \u4E1A\u52A1\u80FD\u529B](./architecture/mcp-business-capabilities.md)\u3002" : ""}
|
|
3713
|
-
- \
|
|
3715
|
+
- \u6570\u636E\u8FC1\u79FB\uFF1A[\u65E7\u7CFB\u7EDF\u6570\u636E\u8FC1\u79FB\u7B56\u7565](./operations/legacy-data-migration.md)\u3002
|
|
3714
3716
|
`;
|
|
3715
3717
|
}
|
|
3716
3718
|
// src/project-files.ts
|
|
@@ -4089,7 +4091,7 @@ var templateDirectories = [
|
|
|
4089
4091
|
"packages/storage"
|
|
4090
4092
|
];
|
|
4091
4093
|
var templateDocumentationFiles = [
|
|
4092
|
-
"docs/
|
|
4094
|
+
"docs/operations/legacy-data-migration.md",
|
|
4093
4095
|
"docs/adr/0003-agent-session-event-source.md",
|
|
4094
4096
|
"docs/architecture/admin-surface-boundary.md",
|
|
4095
4097
|
"docs/architecture/agent-harness.md",
|
|
@@ -8512,6 +8514,11 @@ var recipes = [
|
|
|
8512
8514
|
id: "starter-0.6.20-to-0.6.21",
|
|
8513
8515
|
from: "0.6.20",
|
|
8514
8516
|
to: "0.6.21"
|
|
8517
|
+
},
|
|
8518
|
+
{
|
|
8519
|
+
id: "starter-0.6.21-to-0.7.0",
|
|
8520
|
+
from: "0.6.21",
|
|
8521
|
+
to: "0.7.0"
|
|
8515
8522
|
}
|
|
8516
8523
|
];
|
|
8517
8524
|
function resolveRecipeChain(sourceVersion, targetVersion) {
|
package/package.json
CHANGED
|
@@ -3,7 +3,12 @@ import type { createServerRuntime } from "../runtime.js";
|
|
|
3
3
|
|
|
4
4
|
type ServerRuntime = ReturnType<typeof createServerRuntime>;
|
|
5
5
|
|
|
6
|
-
const CRITICAL_RESOURCE_PATHS = new Set([
|
|
6
|
+
const CRITICAL_RESOURCE_PATHS = new Set([
|
|
7
|
+
"users",
|
|
8
|
+
"roles",
|
|
9
|
+
"departments",
|
|
10
|
+
"licenses",
|
|
11
|
+
]);
|
|
7
12
|
|
|
8
13
|
export async function recordMutationAudit(
|
|
9
14
|
runtime: ServerRuntime,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildActorScopeContexts } from "../data-access/context.js";
|
|
1
2
|
import type { AuthRepository } from "./repository.js";
|
|
2
3
|
import type { AuthService } from "./service.js";
|
|
3
4
|
import type { SystemRepositories } from "../system/seed.js";
|
|
@@ -25,8 +26,16 @@ export async function bootstrapAdminPassword(
|
|
|
25
26
|
id: "system_bootstrap",
|
|
26
27
|
type: "service",
|
|
27
28
|
name: "平台初始化",
|
|
28
|
-
roleCodes: ["
|
|
29
|
+
roleCodes: ["super-admin"],
|
|
29
30
|
permissionKeys: ["system.user.manage"],
|
|
31
|
+
...buildActorScopeContexts("system_bootstrap", undefined, [
|
|
32
|
+
{
|
|
33
|
+
id: "bootstrap",
|
|
34
|
+
code: "system",
|
|
35
|
+
dataScope: "all",
|
|
36
|
+
departmentIds: [],
|
|
37
|
+
},
|
|
38
|
+
]),
|
|
30
39
|
},
|
|
31
40
|
);
|
|
32
41
|
if (!result.ok) throw new Error(`初始化管理员密码失败:${result.code}`);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SystemRepositories } from "../system/seed.js";
|
|
2
|
+
import { createUserCredentialAccess } from "./user-credential-access.js";
|
|
1
3
|
import type { PlatformConfig } from "@southwind-ai/config";
|
|
2
4
|
import type { ServerPersistence } from "../persistence.js";
|
|
3
5
|
import type { createServerRuntime } from "../runtime.js";
|
|
@@ -5,6 +7,7 @@ import { createAuthRuntime } from "./runtime.js";
|
|
|
5
7
|
|
|
6
8
|
export function createComposedAuthRuntime(input: {
|
|
7
9
|
platformConfig: PlatformConfig;
|
|
10
|
+
repositories: SystemRepositories;
|
|
8
11
|
persistence?: ServerPersistence;
|
|
9
12
|
runtime: ReturnType<typeof createServerRuntime>;
|
|
10
13
|
}) {
|
|
@@ -12,6 +15,8 @@ export function createComposedAuthRuntime(input: {
|
|
|
12
15
|
undefined;
|
|
13
16
|
const auth = createAuthRuntime({
|
|
14
17
|
repository: input.persistence?.authRepository,
|
|
18
|
+
canManageUser: createUserCredentialAccess(input.repositories),
|
|
19
|
+
credentialMutationRunner: input.repositories.organizationMutationRunner,
|
|
15
20
|
sessionTtlSeconds: () => input.platformConfig.security.sessionTtlSeconds,
|
|
16
21
|
passwordPolicy: () => ({
|
|
17
22
|
minLength: input.platformConfig.security.passwordMinLength,
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { AuditEvent } from "@southwind-ai/shared";
|
|
3
|
+
import { buildActorScopeContexts } from "../data-access/context.js";
|
|
4
|
+
import type { RequestActor } from "../guards.js";
|
|
5
|
+
import { createFoundationSnapshot } from "../foundation.js";
|
|
6
|
+
import { createSystemRepositories } from "../system/seed.js";
|
|
7
|
+
import { CredentialService } from "./credential-service.js";
|
|
8
|
+
import { InMemoryAuthRepository, type AuthUserRecord } from "./repository.js";
|
|
9
|
+
import { createUserCredentialAccess } from "./user-credential-access.js";
|
|
10
|
+
|
|
11
|
+
const now = new Date("2026-09-10T00:00:00Z");
|
|
12
|
+
const user = (id: string, departmentId: string): AuthUserRecord => ({
|
|
13
|
+
id,
|
|
14
|
+
username: id,
|
|
15
|
+
displayName: id,
|
|
16
|
+
departmentId,
|
|
17
|
+
status: "active",
|
|
18
|
+
credentialState: "active",
|
|
19
|
+
passwordHash: "old-hash",
|
|
20
|
+
roleCodes: [],
|
|
21
|
+
permissionKeys: [],
|
|
22
|
+
});
|
|
23
|
+
const actor: RequestActor = {
|
|
24
|
+
id: "manager",
|
|
25
|
+
type: "user",
|
|
26
|
+
name: "部门管理员",
|
|
27
|
+
roleCodes: ["manager"],
|
|
28
|
+
permissionKeys: ["system.user.manage"],
|
|
29
|
+
...buildActorScopeContexts("manager", "dept_platform", [
|
|
30
|
+
{
|
|
31
|
+
id: "manager-role",
|
|
32
|
+
code: "manager",
|
|
33
|
+
dataScope: "department",
|
|
34
|
+
departmentIds: [],
|
|
35
|
+
},
|
|
36
|
+
]),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
async function fixture(
|
|
40
|
+
beforeHash?: (
|
|
41
|
+
system: ReturnType<typeof createSystemRepositories>,
|
|
42
|
+
) => Promise<void>,
|
|
43
|
+
) {
|
|
44
|
+
const system = createSystemRepositories(createFoundationSnapshot());
|
|
45
|
+
await system.roles.create(
|
|
46
|
+
{
|
|
47
|
+
id: "manager-role",
|
|
48
|
+
code: "manager",
|
|
49
|
+
name: "管理员",
|
|
50
|
+
dataScope: "department",
|
|
51
|
+
permissionKeys: ["system.user.manage"],
|
|
52
|
+
menuKeys: [],
|
|
53
|
+
},
|
|
54
|
+
actor,
|
|
55
|
+
);
|
|
56
|
+
const users = [user("local", "dept_platform"), user("outside", "other")];
|
|
57
|
+
for (const item of users)
|
|
58
|
+
await system.users.create(
|
|
59
|
+
{
|
|
60
|
+
id: item.id,
|
|
61
|
+
username: item.username,
|
|
62
|
+
displayName: item.displayName,
|
|
63
|
+
departmentId: item.departmentId,
|
|
64
|
+
status: "active",
|
|
65
|
+
roleIds: ["manager-role"],
|
|
66
|
+
},
|
|
67
|
+
actor,
|
|
68
|
+
);
|
|
69
|
+
const repository = new InMemoryAuthRepository(users);
|
|
70
|
+
await repository.createSession({
|
|
71
|
+
id: "session",
|
|
72
|
+
userId: "outside",
|
|
73
|
+
tokenHash: "outside-token",
|
|
74
|
+
status: "active",
|
|
75
|
+
issuedAt: now,
|
|
76
|
+
expiresAt: new Date(now.getTime() + 10000),
|
|
77
|
+
});
|
|
78
|
+
const events: AuditEvent[] = [];
|
|
79
|
+
const service = new CredentialService(
|
|
80
|
+
repository,
|
|
81
|
+
{
|
|
82
|
+
hash: async () => {
|
|
83
|
+
await beforeHash?.(system);
|
|
84
|
+
return "new-hash";
|
|
85
|
+
},
|
|
86
|
+
verify: async (hash, password) => hash === password,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
write: (event) => {
|
|
90
|
+
events.push(event);
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
() => now,
|
|
94
|
+
createUserCredentialAccess(system),
|
|
95
|
+
system.organizationMutationRunner,
|
|
96
|
+
);
|
|
97
|
+
return { repository, service, events, system };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("credential target authorization", () => {
|
|
101
|
+
test("范围外请求不计算密码hash,hash期间目标迁移或提权后锁内复核拒绝", async () => {
|
|
102
|
+
for (const change of [
|
|
103
|
+
{ departmentId: "other" },
|
|
104
|
+
{ roleIds: ["role_super_admin"] },
|
|
105
|
+
]) {
|
|
106
|
+
let hashes = 0;
|
|
107
|
+
const { service, repository, events } = await fixture(async (system) => {
|
|
108
|
+
hashes++;
|
|
109
|
+
await system.organizationMutationRunner.run(async () => {
|
|
110
|
+
await system.users.update("local", change, actor);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
expect(
|
|
114
|
+
await service.resetPassword("outside", "new-password", actor, {}),
|
|
115
|
+
).toEqual({ ok: false, code: "FORBIDDEN" });
|
|
116
|
+
expect(hashes).toBe(0);
|
|
117
|
+
expect(
|
|
118
|
+
await service.resetPassword("local", "new-password", actor, {}),
|
|
119
|
+
).toEqual({ ok: false, code: "FORBIDDEN" });
|
|
120
|
+
expect(hashes).toBe(1);
|
|
121
|
+
expect(
|
|
122
|
+
(await repository.findUserByReference("local"))?.passwordHash,
|
|
123
|
+
).toBe("old-hash");
|
|
124
|
+
expect(events.at(-1)?.outcome).toBe("denied");
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("密码写入返回false时管理员重置和本人改密都不误报成功或撤销会话", async () => {
|
|
129
|
+
const { service, repository, events } = await fixture();
|
|
130
|
+
await repository.createSession({
|
|
131
|
+
id: "local-session",
|
|
132
|
+
userId: "local",
|
|
133
|
+
tokenHash: "local-token",
|
|
134
|
+
status: "active",
|
|
135
|
+
issuedAt: now,
|
|
136
|
+
expiresAt: new Date(now.getTime() + 10000),
|
|
137
|
+
});
|
|
138
|
+
repository.updateUserPassword = async () => false;
|
|
139
|
+
expect(
|
|
140
|
+
await service.resetPassword("local", "new-password", actor, {}),
|
|
141
|
+
).toEqual({ ok: false, code: "USER_NOT_FOUND" });
|
|
142
|
+
expect(
|
|
143
|
+
await service.changeOwnPassword(
|
|
144
|
+
"old-hash",
|
|
145
|
+
"new-password",
|
|
146
|
+
{ ...actor, id: "local" },
|
|
147
|
+
{},
|
|
148
|
+
),
|
|
149
|
+
).toEqual({ ok: false, code: "USER_NOT_FOUND" });
|
|
150
|
+
expect((await repository.findSessionById("local-session"))?.status).toBe(
|
|
151
|
+
"active",
|
|
152
|
+
);
|
|
153
|
+
expect(events.every((event) => event.outcome !== "success")).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("密码写入持有组织操作锁,管理变更在写入完成后继续", async () => {
|
|
157
|
+
const { service, repository, system } = await fixture();
|
|
158
|
+
let started!: () => void;
|
|
159
|
+
let release!: () => void;
|
|
160
|
+
const writing = new Promise<void>((resolve) => {
|
|
161
|
+
started = resolve;
|
|
162
|
+
});
|
|
163
|
+
const resume = new Promise<void>((resolve) => {
|
|
164
|
+
release = resolve;
|
|
165
|
+
});
|
|
166
|
+
const original = repository.updateUserPassword.bind(repository);
|
|
167
|
+
repository.updateUserPassword = async (...args) => {
|
|
168
|
+
started();
|
|
169
|
+
await resume;
|
|
170
|
+
return original(...args);
|
|
171
|
+
};
|
|
172
|
+
const reset = service.resetPassword("local", "new-password", actor, {});
|
|
173
|
+
await writing;
|
|
174
|
+
let managementStarted = false;
|
|
175
|
+
const management = system.organizationMutationRunner.run(async () => {
|
|
176
|
+
managementStarted = true;
|
|
177
|
+
});
|
|
178
|
+
await Promise.resolve();
|
|
179
|
+
expect(managementStarted).toBe(false);
|
|
180
|
+
release();
|
|
181
|
+
expect(await reset).toEqual({ ok: true, userId: "local" });
|
|
182
|
+
await management;
|
|
183
|
+
expect(managementStarted).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("部门管理员按用户名重置范围外用户被拒绝,密码与会话保持原样", async () => {
|
|
187
|
+
const { repository, service, events } = await fixture();
|
|
188
|
+
expect(
|
|
189
|
+
await service.resetPassword("outside", "new-password", actor, {}),
|
|
190
|
+
).toEqual({ ok: false, code: "FORBIDDEN" });
|
|
191
|
+
expect(
|
|
192
|
+
(await repository.findUserByReference("outside"))?.passwordHash,
|
|
193
|
+
).toBe("old-hash");
|
|
194
|
+
expect((await repository.findSessionById("session"))?.status).toBe(
|
|
195
|
+
"active",
|
|
196
|
+
);
|
|
197
|
+
expect(events.at(-1)?.metadata).toEqual({ reason: "data-scope-denied" });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("同部门高权账户不能被委派管理员通过重置密码接管", async () => {
|
|
201
|
+
const { repository, service, system } = await fixture();
|
|
202
|
+
await system.users.update(
|
|
203
|
+
"local",
|
|
204
|
+
{ roleIds: ["role_super_admin"] },
|
|
205
|
+
actor,
|
|
206
|
+
);
|
|
207
|
+
expect(
|
|
208
|
+
await service.resetPassword("local", "new-password", actor, {}),
|
|
209
|
+
).toEqual({ ok: false, code: "FORBIDDEN" });
|
|
210
|
+
expect((await repository.findUserByReference("local"))?.passwordHash).toBe(
|
|
211
|
+
"old-hash",
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("同部门授权目标可重置,缺少服务端scope与授权port时拒绝", async () => {
|
|
216
|
+
const { repository, service } = await fixture();
|
|
217
|
+
expect(
|
|
218
|
+
await service.resetPassword("local", "new-password", actor, {}),
|
|
219
|
+
).toEqual({ ok: true, userId: "local" });
|
|
220
|
+
expect(
|
|
221
|
+
await service.resetPassword(
|
|
222
|
+
"local",
|
|
223
|
+
"new-password",
|
|
224
|
+
{ ...actor, scopeContext: undefined },
|
|
225
|
+
{},
|
|
226
|
+
),
|
|
227
|
+
).toEqual({ ok: false, code: "FORBIDDEN" });
|
|
228
|
+
const missing = new CredentialService(
|
|
229
|
+
repository,
|
|
230
|
+
{ hash: async () => "hash", verify: async () => true },
|
|
231
|
+
{ write: () => undefined },
|
|
232
|
+
() => now,
|
|
233
|
+
);
|
|
234
|
+
expect(
|
|
235
|
+
await missing.resetPassword("local", "new-password", actor, {}),
|
|
236
|
+
).toEqual({ ok: false, code: "FORBIDDEN" });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("已软删除但仍active的身份不能登录查询、重新认证或解析旧会话", async () => {
|
|
240
|
+
const repository = new InMemoryAuthRepository([
|
|
241
|
+
{ ...user("deleted", "dept_platform"), deletedAt: now },
|
|
242
|
+
]);
|
|
243
|
+
await repository.createSession({
|
|
244
|
+
id: "deleted-session",
|
|
245
|
+
userId: "deleted",
|
|
246
|
+
tokenHash: "deleted-token",
|
|
247
|
+
status: "active",
|
|
248
|
+
issuedAt: now,
|
|
249
|
+
expiresAt: new Date(now.getTime() + 10000),
|
|
250
|
+
});
|
|
251
|
+
expect(await repository.findUserByUsername("deleted")).toBeUndefined();
|
|
252
|
+
expect(await repository.findUserByReference("deleted")).toBeUndefined();
|
|
253
|
+
expect(
|
|
254
|
+
await repository.resolveSessionActor("deleted-token", now),
|
|
255
|
+
).toBeUndefined();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -121,6 +121,7 @@ function createFixture() {
|
|
|
121
121
|
const resolver = new RepositoryActorResolver(repository);
|
|
122
122
|
const runtime = createServerRuntime({}, { actorResolver: resolver });
|
|
123
123
|
const service = new AuthService(repository, {
|
|
124
|
+
canManageUser: async () => true,
|
|
124
125
|
auditWriter: { write: () => undefined },
|
|
125
126
|
});
|
|
126
127
|
const app = new Elysia({ prefix: "/api" })
|
|
@@ -30,6 +30,8 @@ export class CredentialService {
|
|
|
30
30
|
private readonly passwordHasher: PasswordHasher,
|
|
31
31
|
private readonly auditWriter: AuditEventWriter,
|
|
32
32
|
private readonly now: () => Date,
|
|
33
|
+
private readonly canManageUser?: import("./user-credential-access.js").UserCredentialAccess,
|
|
34
|
+
private readonly mutationRunner?: import("../system/mutation-runner.js").MutationRunner,
|
|
33
35
|
) {}
|
|
34
36
|
|
|
35
37
|
async resetPassword(
|
|
@@ -47,27 +49,56 @@ export class CredentialService {
|
|
|
47
49
|
await this.record(actor, userId, "failure", "user-not-found", request);
|
|
48
50
|
return { ok: false, code: "USER_NOT_FOUND" };
|
|
49
51
|
}
|
|
52
|
+
if (!(await this.canManageUser?.(user.id, actor))) {
|
|
53
|
+
await this.record(actor, userId, "denied", "data-scope-denied", request);
|
|
54
|
+
return { ok: false, code: "FORBIDDEN" };
|
|
55
|
+
}
|
|
50
56
|
const passwordHash = await this.hashPassword(password);
|
|
51
57
|
if (!passwordHash) {
|
|
52
58
|
await this.record(actor, userId, "failure", "invalid-password", request);
|
|
53
59
|
return { ok: false, code: "INVALID_PASSWORD" };
|
|
54
60
|
}
|
|
61
|
+
// Hash outside the organization lock, then recheck against the locked target.
|
|
62
|
+
const update = async (): Promise<PasswordUpdateResult> => {
|
|
63
|
+
if (!(await this.repository.findUserByReference(user.id))) {
|
|
64
|
+
await this.record(actor, user.id, "failure", "user-not-found", request);
|
|
65
|
+
return { ok: false, code: "USER_NOT_FOUND" };
|
|
66
|
+
}
|
|
67
|
+
if (!(await this.canManageUser?.(user.id, actor))) {
|
|
68
|
+
await this.record(
|
|
69
|
+
actor,
|
|
70
|
+
user.id,
|
|
71
|
+
"denied",
|
|
72
|
+
"data-scope-denied",
|
|
73
|
+
request,
|
|
74
|
+
);
|
|
75
|
+
return { ok: false, code: "FORBIDDEN" };
|
|
76
|
+
}
|
|
77
|
+
return this.applyReset(user.id, passwordHash, actor, request);
|
|
78
|
+
};
|
|
79
|
+
return this.mutationRunner ? this.mutationRunner.run(update) : update();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async applyReset(
|
|
83
|
+
userId: string,
|
|
84
|
+
passwordHash: string,
|
|
85
|
+
actor: RequestActor,
|
|
86
|
+
request: AuthRequestMetadata,
|
|
87
|
+
): Promise<PasswordUpdateResult> {
|
|
55
88
|
const changedAt = this.now();
|
|
56
|
-
await this.repository.updateUserPassword(
|
|
57
|
-
|
|
89
|
+
const updated = await this.repository.updateUserPassword(
|
|
90
|
+
userId,
|
|
58
91
|
{ passwordHash, credentialState: "must-change-password" },
|
|
59
92
|
changedAt,
|
|
60
93
|
actor.id,
|
|
61
94
|
);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
70
|
-
return { ok: true, userId: user.id };
|
|
95
|
+
if (!updated) {
|
|
96
|
+
await this.record(actor, userId, "failure", "user-not-found", request);
|
|
97
|
+
return { ok: false, code: "USER_NOT_FOUND" };
|
|
98
|
+
}
|
|
99
|
+
await this.repository.revokeUserSessions(userId, changedAt);
|
|
100
|
+
await this.record(actor, userId, "success", "administrator-reset", request);
|
|
101
|
+
return { ok: true, userId };
|
|
71
102
|
}
|
|
72
103
|
|
|
73
104
|
async changeOwnPassword(
|
|
@@ -111,12 +142,16 @@ export class CredentialService {
|
|
|
111
142
|
return { ok: false, code: "INVALID_PASSWORD" };
|
|
112
143
|
}
|
|
113
144
|
const changedAt = this.now();
|
|
114
|
-
await this.repository.updateUserPassword(
|
|
145
|
+
const updated = await this.repository.updateUserPassword(
|
|
115
146
|
user.id,
|
|
116
147
|
{ passwordHash, credentialState: "active", passwordChangedAt: changedAt },
|
|
117
148
|
changedAt,
|
|
118
149
|
actor.id,
|
|
119
150
|
);
|
|
151
|
+
if (!updated) {
|
|
152
|
+
await this.record(actor, user.id, "failure", "user-not-found", request);
|
|
153
|
+
return { ok: false, code: "USER_NOT_FOUND" };
|
|
154
|
+
}
|
|
120
155
|
const revokedSessions = await this.repository.revokeUserSessions(
|
|
121
156
|
user.id,
|
|
122
157
|
changedAt,
|
|
@@ -38,7 +38,7 @@ export class DrizzleAuthRepository implements AuthRepository {
|
|
|
38
38
|
const [user] = await this.db
|
|
39
39
|
.select()
|
|
40
40
|
.from(users)
|
|
41
|
-
.where(eq(users.username, username))
|
|
41
|
+
.where(and(eq(users.username, username), isNull(users.deletedAt)))
|
|
42
42
|
.limit(1);
|
|
43
43
|
if (!user) {
|
|
44
44
|
return undefined;
|
|
@@ -68,7 +68,12 @@ export class DrizzleAuthRepository implements AuthRepository {
|
|
|
68
68
|
const [user] = await this.db
|
|
69
69
|
.select()
|
|
70
70
|
.from(users)
|
|
71
|
-
.where(
|
|
71
|
+
.where(
|
|
72
|
+
and(
|
|
73
|
+
or(eq(users.id, reference), eq(users.username, reference)),
|
|
74
|
+
isNull(users.deletedAt),
|
|
75
|
+
),
|
|
76
|
+
)
|
|
72
77
|
.limit(1);
|
|
73
78
|
if (!user) {
|
|
74
79
|
return undefined;
|
|
@@ -142,6 +147,7 @@ export class DrizzleAuthRepository implements AuthRepository {
|
|
|
142
147
|
isNull(serverSessions.revokedAt),
|
|
143
148
|
gt(serverSessions.expiresAt, now),
|
|
144
149
|
eq(users.status, "active"),
|
|
150
|
+
isNull(users.deletedAt),
|
|
145
151
|
),
|
|
146
152
|
)
|
|
147
153
|
.limit(1);
|
|
@@ -190,7 +196,7 @@ export class DrizzleAuthRepository implements AuthRepository {
|
|
|
190
196
|
updatedAt,
|
|
191
197
|
updatedBy,
|
|
192
198
|
})
|
|
193
|
-
.where(eq(users.id, userId))
|
|
199
|
+
.where(and(eq(users.id, userId), isNull(users.deletedAt)))
|
|
194
200
|
.returning({ id: users.id });
|
|
195
201
|
return rows.length > 0;
|
|
196
202
|
}
|
|
@@ -210,7 +216,7 @@ export class DrizzleAuthRepository implements AuthRepository {
|
|
|
210
216
|
updatedAt,
|
|
211
217
|
updatedBy,
|
|
212
218
|
})
|
|
213
|
-
.where(eq(users.id, userId))
|
|
219
|
+
.where(and(eq(users.id, userId), isNull(users.deletedAt)))
|
|
214
220
|
.returning({ id: users.id });
|
|
215
221
|
return rows.length > 0 ? this.findUserByReference(userId) : undefined;
|
|
216
222
|
}
|
|
@@ -264,7 +270,9 @@ export class DrizzleAuthRepository implements AuthRepository {
|
|
|
264
270
|
.select()
|
|
265
271
|
.from(roles)
|
|
266
272
|
.where(inArray(roles.id, roleIds));
|
|
267
|
-
const activeRoles = roleRows.filter(
|
|
273
|
+
const activeRoles = roleRows.filter(
|
|
274
|
+
(role) => role.status === "active" && !role.deletedAt,
|
|
275
|
+
);
|
|
268
276
|
const compatibilityPermissionRoleIds = activeRoles
|
|
269
277
|
.filter((role) => !Array.isArray(role.permissionKeys))
|
|
270
278
|
.map((role) => role.id);
|
|
@@ -4,6 +4,7 @@ import type { DataAccessRolePolicy } from "../data-access/context.js";
|
|
|
4
4
|
import { buildActorScopeContexts } from "../data-access/context.js";
|
|
5
5
|
|
|
6
6
|
export interface AuthUserRecord {
|
|
7
|
+
deletedAt?: Date;
|
|
7
8
|
id: string;
|
|
8
9
|
username: string;
|
|
9
10
|
displayName: string;
|
|
@@ -100,7 +101,7 @@ export class InMemoryAuthRepository implements AuthRepository {
|
|
|
100
101
|
username: string,
|
|
101
102
|
): Promise<AuthUserRecord | undefined> {
|
|
102
103
|
const user = [...this.users.values()].find(
|
|
103
|
-
(item) => item.username === username,
|
|
104
|
+
(item) => item.username === username && !item.deletedAt,
|
|
104
105
|
);
|
|
105
106
|
return user ? structuredClone(user) : undefined;
|
|
106
107
|
}
|
|
@@ -109,7 +110,9 @@ export class InMemoryAuthRepository implements AuthRepository {
|
|
|
109
110
|
reference: string,
|
|
110
111
|
): Promise<AuthUserRecord | undefined> {
|
|
111
112
|
const user = [...this.users.values()].find(
|
|
112
|
-
(item) =>
|
|
113
|
+
(item) =>
|
|
114
|
+
!item.deletedAt &&
|
|
115
|
+
(item.id === reference || item.username === reference),
|
|
113
116
|
);
|
|
114
117
|
return user ? structuredClone(user) : undefined;
|
|
115
118
|
}
|
|
@@ -141,7 +144,7 @@ export class InMemoryAuthRepository implements AuthRepository {
|
|
|
141
144
|
return undefined;
|
|
142
145
|
}
|
|
143
146
|
const user = this.users.get(session.userId);
|
|
144
|
-
if (!user || user.status !== "active") {
|
|
147
|
+
if (!user || user.deletedAt || user.status !== "active") {
|
|
145
148
|
return undefined;
|
|
146
149
|
}
|
|
147
150
|
const current = this.sessions.get(session.id);
|
|
@@ -177,7 +180,7 @@ export class InMemoryAuthRepository implements AuthRepository {
|
|
|
177
180
|
void updatedAt;
|
|
178
181
|
void updatedBy;
|
|
179
182
|
const user = this.users.get(userId);
|
|
180
|
-
if (!user) {
|
|
183
|
+
if (!user || user.deletedAt) {
|
|
181
184
|
return false;
|
|
182
185
|
}
|
|
183
186
|
user.passwordHash = update.passwordHash;
|
|
@@ -195,7 +198,7 @@ export class InMemoryAuthRepository implements AuthRepository {
|
|
|
195
198
|
void updatedAt;
|
|
196
199
|
void updatedBy;
|
|
197
200
|
const user = this.users.get(userId);
|
|
198
|
-
if (!user) return undefined;
|
|
201
|
+
if (!user || user.deletedAt) return undefined;
|
|
199
202
|
user.displayName = input.displayName;
|
|
200
203
|
user.email = input.email;
|
|
201
204
|
user.phone = input.phone;
|
|
@@ -216,7 +219,7 @@ export class InMemoryAuthRepository implements AuthRepository {
|
|
|
216
219
|
})
|
|
217
220
|
.flatMap((session) => {
|
|
218
221
|
const user = this.users.get(session.userId);
|
|
219
|
-
return user?.status === "active"
|
|
222
|
+
return user?.status === "active" && !user.deletedAt
|
|
220
223
|
? [
|
|
221
224
|
{
|
|
222
225
|
sessionId: session.id,
|
|
@@ -20,6 +20,7 @@ export function authFixture(
|
|
|
20
20
|
const events: AuditEvent[] = [];
|
|
21
21
|
const runtime = createServerRuntime({}, { actorResolver: resolver });
|
|
22
22
|
const service = new AuthService(repository, {
|
|
23
|
+
canManageUser: async () => true,
|
|
23
24
|
passwordPolicy,
|
|
24
25
|
sessionTtlSeconds,
|
|
25
26
|
onSessionsRevoked,
|
|
@@ -6,6 +6,8 @@ import { createArgon2PasswordHasher } from "./password.js";
|
|
|
6
6
|
import { ReauthenticationService } from "./reauthentication.js";
|
|
7
7
|
|
|
8
8
|
interface AuthRuntimeInput {
|
|
9
|
+
credentialMutationRunner?: import("../system/mutation-runner.js").MutationRunner;
|
|
10
|
+
canManageUser?: import("./user-credential-access.js").UserCredentialAccess;
|
|
9
11
|
repository?: AuthRepository;
|
|
10
12
|
sessionTtlSeconds: number | (() => number);
|
|
11
13
|
passwordPolicy?: () => PasswordPolicy;
|
|
@@ -19,6 +21,8 @@ export function createAuthRuntime(input: AuthRuntimeInput) {
|
|
|
19
21
|
const passwordHasher = createArgon2PasswordHasher(input.passwordPolicy);
|
|
20
22
|
const authService = new AuthService(authRepository, {
|
|
21
23
|
sessionTtlSeconds: input.sessionTtlSeconds,
|
|
24
|
+
canManageUser: input.canManageUser,
|
|
25
|
+
credentialMutationRunner: input.credentialMutationRunner,
|
|
22
26
|
passwordPolicy: input.passwordPolicy,
|
|
23
27
|
auditWriter: { write: input.auditWriter },
|
|
24
28
|
auditReader: input.auditReader,
|
|
@@ -4,6 +4,8 @@ import type { AuthenticationProviderRegistry } from "./authentication-provider.j
|
|
|
4
4
|
import type { PasswordHasher } from "./password.js";
|
|
5
5
|
|
|
6
6
|
export interface AuthServiceOptions {
|
|
7
|
+
credentialMutationRunner?: import("../system/mutation-runner.js").MutationRunner;
|
|
8
|
+
canManageUser?: import("./user-credential-access.js").UserCredentialAccess;
|
|
7
9
|
sessionTtlSeconds?: number | (() => number);
|
|
8
10
|
passwordPolicy?: () => PasswordPolicy;
|
|
9
11
|
now?: () => Date;
|