diff --git a/.env.example b/.env.example index a16c5e2..95efd5b 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,8 @@ AUTH_NATIVE_ENABLED=false CANDIDATE_NATIVE_ENABLED=false # 第一批管理端只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 及共享 Redis 同时使用。 ADMIN_NATIVE_READS_ENABLED=false +# 学校、班级、管理员维护及自主注册开关切换;同样要求原生认证和共享 Redis。 +ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/MIGRATION.md b/MIGRATION.md index bd6d22f..09186a9 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,7 +12,7 @@ - [x] 招生公示与 HMAC 文书验真公开接口 - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) - [x] 考生业务 -- [ ] 管理后台、审批流和考务编排(第一批管理端只读接口已原生化) +- [ ] 管理后台、审批流和考务编排(管理端读取与组织维护已部分原生化) - [x] 考生志愿填报与招生录取查询 - [ ] Excel、文书和缓存 - [ ] 容器入口切换及 Node.js 后端移除 @@ -54,14 +54,17 @@ $env:AUTH_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true' ``` -管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。其余管理端写入、审批流和考务编排接口仍转发给 Node,因此该开关同样要求原生认证和共享 Redis: +管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。 + +其余审批流和考务编排接口仍转发给 Node,因此两个管理端开关都要求原生认证和共享 Redis;组织维护开关还必须与只读开关一起启用: ```powershell $env:AUTH_NATIVE_ENABLED = 'true' $env:ADMIN_NATIVE_READS_ENABLED = 'true' +$env:ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true' ``` -`GET /health/migration` 的 `administration.nativeReadsEnabled` 和 `administration.routes` 会报告这一批端点是否已切换。 +`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index d757c18..d1defe0 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -423,6 +423,7 @@ try { CANDIDATE_NATIVE_ENABLED = 'true' CANDIDATE_NATIVE_ALLOW_MEMORY = 'true' ADMIN_NATIVE_READS_ENABLED = 'true' + ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true' LegacyNode__Enabled = 'true' LegacyNode__BaseUrl = $legacyBaseUrl @@ -736,6 +737,120 @@ try { if ($classAdminsForbidden.StatusCode -ne 403) { throw 'Native admins route did not preserve the class-admin boundary' } + + $schoolCreateBody = @{ + name = '原生迁移测试学校' + code = 'NATIVE_SMOKE' + address = '迁移测试路 1 号' + isSourceSchool = $true + isAdmissionSchool = $true + } | ConvertTo-Json -Compress + $schoolCreateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/schools" -Method Post -ContentType 'application/json' -Body $schoolCreateBody -WebSession $nativeSession + if ($schoolCreateResponse.StatusCode -ne 201 -or $schoolCreateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native school creation did not use the ASP.NET Core endpoint' + } + $createdSchool = ($schoolCreateResponse.Content | ConvertFrom-Json).school + if ($createdSchool.code -ne 'NATIVE_SMOKE' -or $createdSchool.active -ne $true) { + throw 'Native school creation returned an unexpected projection' + } + $duplicateSchool = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/schools" -Method Post -ContentType 'application/json' -Body $schoolCreateBody -WebSession $nativeSession -SkipHttpErrorCheck + if ($duplicateSchool.StatusCode -ne 409) { + throw 'Native school creation did not reject a duplicate school code' + } + $schoolCreateForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/schools" -Method Post -ContentType 'application/json' -Body $schoolCreateBody -WebSession $nativeSchoolSession -SkipHttpErrorCheck + if ($schoolCreateForbidden.StatusCode -ne 403 -or $schoolCreateForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native school creation did not preserve the super-admin boundary' + } + + $schoolPatchBody = @{ address = '迁移测试路 2 号'; isAdmissionSchool = $false } | ConvertTo-Json -Compress + $schoolPatch = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/schools/$($createdSchool.id)" -Method Patch -ContentType 'application/json' -Body $schoolPatchBody -WebSession $nativeSession + if ($schoolPatch.school.address -ne '迁移测试路 2 号' -or $schoolPatch.school.isAdmissionSchool -ne $false) { + throw 'Native school update did not persist the requested fields' + } + + $newSchoolAdminBody = @{ + username = 'native_school_writer' + password = '12345678' + displayName = '原生校级管理员' + adminLevel = 'school' + schoolId = $createdSchool.id + } | ConvertTo-Json -Compress + $newSchoolAdminResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admins" -Method Post -ContentType 'application/json' -Body $newSchoolAdminBody -WebSession $nativeSession + if ($newSchoolAdminResponse.StatusCode -ne 201 -or $newSchoolAdminResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native administrator creation did not use the ASP.NET Core endpoint' + } + $createdSchoolAdmin = ($newSchoolAdminResponse.Content | ConvertFrom-Json).admin + + $newSchoolAdminSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new() + $newSchoolAdminLoginBody = @{ username = 'native_school_writer'; password = '12345678' } | ConvertTo-Json -Compress + Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $newSchoolAdminLoginBody -WebSession $newSchoolAdminSession | Out-Null + $classCreateBody = @{ name = '迁移测试班'; grade = '2026级' } | ConvertTo-Json -Compress + $classCreateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/classes" -Method Post -ContentType 'application/json' -Body $classCreateBody -WebSession $newSchoolAdminSession + if ($classCreateResponse.StatusCode -ne 201 -or $classCreateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native class creation did not use the ASP.NET Core endpoint' + } + $createdClass = ($classCreateResponse.Content | ConvertFrom-Json).schoolClass + $superClassForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/classes" -Method Post -ContentType 'application/json' -Body $classCreateBody -WebSession $nativeSession -SkipHttpErrorCheck + if ($superClassForbidden.StatusCode -ne 403) { + throw 'Native class creation did not preserve the school-admin boundary' + } + $classPatchBody = @{ name = '迁移测试一班'; active = $true } | ConvertTo-Json -Compress + $classPatch = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/classes/$($createdClass.id)" -Method Patch -ContentType 'application/json' -Body $classPatchBody -WebSession $newSchoolAdminSession + if ($classPatch.schoolClass.name -ne '迁移测试一班') { + throw 'Native class update did not persist the requested name' + } + + $newClassAdminBody = @{ + username = 'native_class_writer' + password = '12345678' + displayName = '原生班级管理员' + adminLevel = 'super' + classId = $createdClass.id + } | ConvertTo-Json -Compress + $newClassAdminResponse = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/admins" -Method Post -ContentType 'application/json' -Body $newClassAdminBody -WebSession $newSchoolAdminSession + if ($newClassAdminResponse.admin.adminLevel -ne 'class' -or $newClassAdminResponse.admin.schoolId -ne $createdSchool.id) { + throw 'School admin did not create a class-scoped administrator' + } + $createdClassAdmin = $newClassAdminResponse.admin + $newClassAdminSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new() + $newClassAdminLoginBody = @{ username = 'native_class_writer'; password = '12345678' } | ConvertTo-Json -Compress + Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $newClassAdminLoginBody -WebSession $newClassAdminSession | Out-Null + + $disableClassAdminBody = @{ active = $false; displayName = '原生班级管理员(停用)' } | ConvertTo-Json -Compress + $disabledClassAdmin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/admins/$($createdClassAdmin.id)" -Method Patch -ContentType 'application/json' -Body $disableClassAdminBody -WebSession $newSchoolAdminSession + if ($disabledClassAdmin.admin.displayName -ne '原生班级管理员(停用)') { + throw 'Native administrator update did not persist the display name' + } + $invalidatedClassSession = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/auth/me" -WebSession $newClassAdminSession -SkipHttpErrorCheck + $invalidatedClassIdentity = $invalidatedClassSession.Content | ConvertFrom-Json + if ($invalidatedClassSession.StatusCode -ne 200 -or $null -ne $invalidatedClassIdentity.user) { + throw 'Disabling an administrator did not invalidate existing sessions' + } + + $resetClassAdmin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/admins/$($createdClassAdmin.id)/reset-password" -Method Post -WebSession $newSchoolAdminSession + if ($resetClassAdmin.username -ne 'native_class_writer' -or $resetClassAdmin.temporaryPassword -notmatch '^Reset-[A-Za-z0-9_-]+$') { + throw 'Native administrator password reset returned an invalid temporary password' + } + $resetLoginBody = @{ username = 'native_class_writer'; password = $resetClassAdmin.temporaryPassword } | ConvertTo-Json -Compress + $resetLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $resetLoginBody + if ($resetLogin.user.username -ne 'native_class_writer') { + throw 'The temporary administrator password is not compatible with native authentication' + } + + $adminStateBeforeSetting = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/admins" -WebSession $nativeSession + $originalSelfRegistration = [bool]$adminStateBeforeSetting.selfRegistrationEnabled + $settingBody = @{ enabled = -not $originalSelfRegistration } | ConvertTo-Json -Compress + $settingUpdate = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/settings/self-registration" -Method Put -ContentType 'application/json' -Body $settingBody -WebSession $nativeSession + if ($settingUpdate.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or ($settingUpdate.Content | ConvertFrom-Json).enabled -eq $originalSelfRegistration) { + throw 'Native self-registration setting did not persist the requested value' + } + $settingForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/settings/self-registration" -Method Put -ContentType 'application/json' -Body $settingBody -WebSession $nativeSchoolSession -SkipHttpErrorCheck + if ($settingForbidden.StatusCode -ne 403) { + throw 'Native self-registration setting did not preserve the super-admin boundary' + } + $restoreSettingBody = @{ enabled = $originalSelfRegistration } | ConvertTo-Json -Compress + Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/settings/self-registration" -Method Put -ContentType 'application/json' -Body $restoreSettingBody -WebSession $nativeSession | Out-Null + $adminCandidateRoute = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -WebSession $nativeSession -SkipHttpErrorCheck if ($adminCandidateRoute.StatusCode -ne 403) { throw 'Native candidate API did not enforce the candidate role boundary' @@ -819,6 +934,7 @@ try { NativeCandidateDocuments = 'passed' NativeCandidateAdmissions = 'passed' NativeAdminReads = 'passed' + NativeAdminOrganizationWrites = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Administration/IAdminOrganizationService.cs b/src/Eis.Application/Administration/IAdminOrganizationService.cs new file mode 100644 index 0000000..c09b21d --- /dev/null +++ b/src/Eis.Application/Administration/IAdminOrganizationService.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Nodes; + +namespace Eis.Application.Administration; + +public interface IAdminOrganizationService +{ + Task CreateSchoolAsync( + string sessionToken, + JsonObject body, + CancellationToken cancellationToken); + + Task UpdateSchoolAsync( + string sessionToken, + string schoolId, + JsonObject body, + CancellationToken cancellationToken); + + Task CreateClassAsync( + string sessionToken, + JsonObject body, + CancellationToken cancellationToken); + + Task UpdateClassAsync( + string sessionToken, + string classId, + JsonObject body, + CancellationToken cancellationToken); + + Task CreateAdminAsync( + string sessionToken, + JsonObject body, + CancellationToken cancellationToken); + + Task UpdateAdminAsync( + string sessionToken, + string adminId, + JsonObject body, + CancellationToken cancellationToken); + + Task ResetAdminPasswordAsync( + string sessionToken, + string adminId, + CancellationToken cancellationToken); + + Task UpdateSelfRegistrationAsync( + string sessionToken, + JsonObject body, + CancellationToken cancellationToken); +} diff --git a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs index efff3ec..e76177f 100644 --- a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs +++ b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs @@ -1,31 +1,43 @@ namespace Eis.Infrastructure.Administration; -public sealed record AdminMigrationOptions(bool NativeReadsEnabled) +public sealed record AdminMigrationOptions( + bool NativeReadsEnabled, + bool NativeOrganizationWritesEnabled = false) { public static AdminMigrationOptions FromEnvironment( bool configuredNativeReadsEnabled, bool authenticationNativeEnabled, - bool sharesLegacySessions) + bool sharesLegacySessions, + bool configuredNativeOrganizationWritesEnabled = false) { - var enabled = ParseBoolean( + var readsEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), configuredNativeReadsEnabled); - if (enabled && !authenticationNativeEnabled) + var organizationWritesEnabled = ParseBoolean( + Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"), + configuredNativeOrganizationWritesEnabled); + if (organizationWritesEnabled && !readsEnabled) { throw new InvalidOperationException( - "启用原生管理端读取接口前必须同时设置 AUTH_NATIVE_ENABLED=true"); + "启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true"); + } + var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled; + if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled) + { + throw new InvalidOperationException( + "启用原生管理端接口前必须同时设置 AUTH_NATIVE_ENABLED=true"); } var allowMemoryForIsolatedTesting = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"), fallback: false); - if (enabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting) + if (anyNativeAdminEndpointEnabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting) { throw new InvalidOperationException( - "管理端仍有写入接口需要转发给 Node;启用原生管理端读取接口必须配置共享 Redis 会话"); + "管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话"); } - return new AdminMigrationOptions(enabled); + return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled); } private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch diff --git a/src/Eis.Infrastructure/Administration/AdminOrganizationService.cs b/src/Eis.Infrastructure/Administration/AdminOrganizationService.cs new file mode 100644 index 0000000..3d2f8ef --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminOrganizationService.cs @@ -0,0 +1,433 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Eis.Application.Administration; +using Eis.Infrastructure.Authentication; + +namespace Eis.Infrastructure.Administration; + +internal sealed partial class AdminOrganizationService( + IAuthenticationStateStore authenticationState, + AuthenticationRepository authenticationRepository, + PasswordCompatibilityService passwords, + AdminReadSnapshotLoader snapshotLoader, + AdminWriteRepository repository) : IAdminOrganizationService +{ + private static readonly IReadOnlyDictionary LevelNames = new Dictionary(StringComparer.Ordinal) + { + ["super"] = "超级管理员", + ["school"] = "校级管理员", + ["class"] = "班级管理员" + }; + + public async Task CreateSchoolAsync( + string sessionToken, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + if (Level(user) != "super") return Error(403, "只有超级管理员可以创建学校"); + + var name = Clean(Text(body["name"]), 100); + var code = Clean(Text(body["code"]), 40).ToUpperInvariant(); + var address = Clean(Text(body["address"]), 200); + var isSourceSchool = !ExactlyFalse(body["isSourceSchool"]); + var isAdmissionSchool = !ExactlyFalse(body["isAdmissionSchool"]); + if (name.Length == 0 || code.Length == 0) return Error(400, "学校名称和学校代码不能为空"); + if (!isSourceSchool && !isAdmissionSchool) return Error(400, "学校至少应设置为生源校或招生校"); + if (!SchoolCodePattern().IsMatch(code)) return Error(400, "学校代码只能包含字母、数字、下划线和连字符"); + + var snapshot = await snapshotLoader.LoadAsync(cancellationToken); + if (snapshot.Schools.Any(item => string.Equals(item.Code, code, StringComparison.OrdinalIgnoreCase))) + return Error(409, "学校代码已存在"); + if (snapshot.Schools.Any(item => string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase))) + return Error(409, "学校名称已存在"); + + var school = new AdminSchool( + Uid("school"), name, code, address, isSourceSchool, isAdmissionSchool, !ExactlyFalse(body["active"])); + await repository.SaveSchoolAsync( + school, + isNew: true, + Audit(user, "创建学校", $"{name} · {code}"), + cancellationToken); + return Result(201, new JsonObject { ["ok"] = true, ["school"] = SchoolJson(school) }); + } + + public async Task UpdateSchoolAsync( + string sessionToken, + string schoolId, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + if (Level(user) != "super") return Error(403, "只有超级管理员可以维护学校"); + + var snapshot = await snapshotLoader.LoadAsync(cancellationToken); + var existing = snapshot.Schools.FirstOrDefault(item => item.Id == schoolId); + if (existing is null) return Error(404, "学校不存在"); + var name = Clean(NullishText(body, "name", existing.Name), 100); + var code = Clean(NullishText(body, "code", existing.Code), 40).ToUpperInvariant(); + var address = Clean(NullishText(body, "address", existing.Address), 200); + var isSourceSchool = NullishBoolean(body, "isSourceSchool", existing.IsSourceSchool); + var isAdmissionSchool = NullishBoolean(body, "isAdmissionSchool", existing.IsAdmissionSchool); + var active = NullishBoolean(body, "active", existing.Active); + if (name.Length == 0 || code.Length == 0) return Error(400, "学校名称和学校代码不能为空"); + if (!isSourceSchool && !isAdmissionSchool) return Error(400, "学校至少应设置为生源校或招生校"); + if (!SchoolCodePattern().IsMatch(code)) return Error(400, "学校代码只能包含字母、数字、下划线和连字符"); + if (snapshot.Schools.Any(item => item.Id != existing.Id && string.Equals(item.Code, code, StringComparison.OrdinalIgnoreCase))) + return Error(409, "学校代码已存在"); + if (snapshot.Schools.Any(item => item.Id != existing.Id && string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase))) + return Error(409, "学校名称已存在"); + + var school = existing with + { + Name = name, + Code = code, + Address = address, + IsSourceSchool = isSourceSchool, + IsAdmissionSchool = isAdmissionSchool, + Active = active + }; + await repository.SaveSchoolAsync( + school, + isNew: false, + Audit(user, "维护学校", $"{name} · {code} · {(active ? "启用" : "停用")}"), + cancellationToken); + return Success(new JsonObject { ["ok"] = true, ["school"] = SchoolJson(school) }); + } + + public async Task CreateClassAsync( + string sessionToken, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + if (Level(user) != "school") return Error(403, "只有校级管理员可以新增本校班级"); + var snapshot = await snapshotLoader.LoadAsync(cancellationToken); + if (!snapshot.Schools.Any(item => item.Id == user.SchoolId && item.Active && item.IsSourceSchool)) + return Error(409, "当前学校未设置为已启用的生源校"); + + var name = Clean(Text(body["name"]), 100); + var grade = Clean(Text(body["grade"]), 60); + if (name.Length == 0 || grade.Length == 0) return Error(400, "年级和班级名称不能为空"); + if (snapshot.Classes.Any(item => item.SchoolId == user.SchoolId && item.Name == name)) + return Error(409, "本校已存在同名班级"); + + var schoolClass = new AdminClass(Uid("class"), user.SchoolId!, name, grade, !ExactlyFalse(body["active"])); + await repository.SaveClassAsync( + schoolClass, + isNew: true, + Audit(user, "新增本校班级", $"{grade} · {name}"), + cancellationToken); + return Result(201, new JsonObject { ["ok"] = true, ["schoolClass"] = ClassJson(schoolClass) }); + } + + public async Task UpdateClassAsync( + string sessionToken, + string classId, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + if (Level(user) != "school") return Error(403, "只有校级管理员可以维护本校班级"); + var snapshot = await snapshotLoader.LoadAsync(cancellationToken); + var existing = snapshot.Classes.FirstOrDefault(item => item.Id == classId && item.SchoolId == user.SchoolId); + if (existing is null) return Error(404, "班级不存在"); + + var name = Clean(NullishText(body, "name", existing.Name), 100); + var grade = Clean(NullishText(body, "grade", existing.Grade), 60); + if (name.Length == 0 || grade.Length == 0) return Error(400, "年级和班级名称不能为空"); + if (snapshot.Classes.Any(item => item.Id != existing.Id && item.SchoolId == user.SchoolId && item.Name == name)) + return Error(409, "本校已存在同名班级"); + + var schoolClass = existing with + { + Name = name, + Grade = grade, + Active = NullishBoolean(body, "active", existing.Active) + }; + await repository.SaveClassAsync( + schoolClass, + isNew: false, + Audit(user, "更新本校班级", $"{grade} · {name} · {(schoolClass.Active ? "启用" : "停用")}"), + cancellationToken); + return Success(new JsonObject { ["ok"] = true, ["schoolClass"] = ClassJson(schoolClass) }); + } + + public async Task CreateAdminAsync( + string sessionToken, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var username = Clean(Text(body["username"]), 50); + var password = TruthyText(body["password"]); + var displayName = Clean(Text(body["displayName"]), 50); + var adminLevel = Level(user) == "school" ? "class" : Clean(Text(body["adminLevel"]), 20); + if (Level(user) is not ("super" or "school")) return Error(403, "当前账号不能创建管理员"); + if (username.Length == 0 || displayName.Length == 0 || password.Length < 8 || + adminLevel is not ("super" or "school" or "class")) + return Error(400, "请完整填写管理员账号、姓名、层级和至少 8 位密码"); + + var snapshot = await snapshotLoader.LoadAsync(cancellationToken); + if (snapshot.Users.Any(item => string.Equals(item.Username, username, StringComparison.OrdinalIgnoreCase))) + return Error(409, "该登录账号已存在"); + var schoolId = adminLevel == "super" + ? null + : Level(user) == "school" ? user.SchoolId : Clean(Text(body["schoolId"]), 64); + var classId = adminLevel == "class" ? Clean(Text(body["classId"]), 64) : null; + if (adminLevel != "super" && !snapshot.Schools.Any(item => item.Id == schoolId && item.Active && item.IsSourceSchool)) + return Error(400, "校级和班级管理员必须绑定已启用的生源校"); + if (adminLevel == "class" && !snapshot.Classes.Any(item => item.Id == classId && item.SchoolId == schoolId)) + return Error(400, "请选择该学校下的有效班级"); + + var created = new AdminUser( + Uid("usr"), username, "admin", adminLevel, schoolId, classId, displayName, null, + true, false, false, null, NowIso()); + await repository.CreateAdminAsync( + created, + passwords.Hash(password), + Audit(user, "创建管理员", $"{displayName} · {LevelNames[adminLevel]}"), + cancellationToken); + return Result(201, new JsonObject { ["ok"] = true, ["admin"] = SafeUser(created) }); + } + + public async Task UpdateAdminAsync( + string sessionToken, + string adminId, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + if (Level(user) is not ("super" or "school")) return Error(403, "当前账号不能维护管理员"); + var snapshot = await snapshotLoader.LoadAsync(cancellationToken); + var target = snapshot.Users.FirstOrDefault(item => item.Id == adminId && item.Role == "admin" && + (Level(user) == "super" || item.AdminLevel == "class" && item.SchoolId == user.SchoolId)); + if (target is null) return Error(404, "管理员账户不存在或不在当前管理范围"); + if (target.Id == user.Id && ExactlyFalse(body["active"])) + return Error(409, "不能停用当前正在使用的管理员账户"); + + var requestedClassId = Clean(TruthyText(body["classId"], target.ClassId ?? string.Empty), 64); + var schoolClass = target.AdminLevel == "class" + ? snapshot.Classes.FirstOrDefault(item => item.Id == requestedClassId && item.SchoolId == target.SchoolId) + : null; + if (target.AdminLevel == "class" && schoolClass is null) + return Error(400, "请选择该管理员所属学校的有效班级"); + var password = TruthyText(body["password"]); + if (password.Length is > 0 and < 8) return Error(400, "重置密码至少 8 位"); + var displayName = Clean(TruthyText(body["displayName"], target.DisplayName), 50); + var active = NullishBoolean(body, "active", target.Active); + var updated = target with + { + DisplayName = displayName, + ClassId = schoolClass?.Id ?? target.ClassId, + Active = active + }; + await repository.UpdateAdminAsync( + updated.Id, + updated.DisplayName, + updated.ClassId, + updated.Active, + password.Length == 0 ? null : passwords.Hash(password), + Audit(user, "维护管理员账户", $"{updated.DisplayName} · {LevelNames[updated.AdminLevel ?? "super"]} · {(active ? "启用" : "停用")}"), + cancellationToken); + if (!active || password.Length > 0) await authenticationState.DeleteUserSessionsAsync(updated.Id); + return Success(new JsonObject { ["ok"] = true, ["admin"] = SafeUser(updated) }); + } + + public async Task ResetAdminPasswordAsync( + string sessionToken, + string adminId, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + if (Level(user) is not ("super" or "school")) return Error(403, "当前账号不能重置管理员密码"); + var snapshot = await snapshotLoader.LoadAsync(cancellationToken); + var target = snapshot.Users.FirstOrDefault(item => item.Id == adminId && item.Role == "admin" && + (Level(user) == "super" || item.AdminLevel == "class" && item.SchoolId == user.SchoolId)); + if (target is null) return Error(404, "管理员账户不存在或不在当前管理范围"); + if (target.Id == user.Id) return Error(409, "当前账号请在“账户安全”中修改自己的密码"); + + var temporaryPassword = $"Reset-{Base64Url(RandomNumberGenerator.GetBytes(7))}"; + await repository.UpdateAdminAsync( + target.Id, + target.DisplayName, + target.ClassId, + active: true, + passwords.Hash(temporaryPassword), + Audit(user, "重置管理员密码", $"{target.DisplayName} · {target.Username}"), + cancellationToken); + await authenticationState.DeleteUserSessionsAsync(target.Id); + return Success(new JsonObject + { + ["ok"] = true, + ["username"] = target.Username, + ["temporaryPassword"] = temporaryPassword + }); + } + + public async Task UpdateSelfRegistrationAsync( + string sessionToken, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + if (Level(user) != "super") return Error(403, "当前管理员层级无权执行此操作"); + var enabled = JsBoolean(body["enabled"]); + await repository.UpdateSelfRegistrationAsync( + enabled, + Audit( + user, + enabled ? "开启自主注册" : "关闭自主注册", + enabled ? "考生可从公开入口申请报名号" : "仅允许使用学校下发的报名号登录"), + cancellationToken); + return Success(new JsonObject { ["ok"] = true, ["enabled"] = enabled }); + } + + private async Task ResolveAsync(string sessionToken, CancellationToken cancellationToken) + { + if (sessionToken.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录")); + var userId = await authenticationState.GetSessionUserIdAsync(sessionToken); + if (userId is null) return ResolvedAdmin.Failed(Error(401, "请先登录")); + var user = await authenticationRepository.FindUserByIdAsync(userId, cancellationToken); + if (user is not { Active: true, ArchivedAt: null }) return ResolvedAdmin.Failed(Error(401, "请先登录")); + return user.Role == "admin" + ? new ResolvedAdmin(user, null) + : ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作")); + } + + private static JsonObject SchoolJson(AdminSchool item) => new() + { + ["id"] = item.Id, + ["name"] = item.Name, + ["code"] = item.Code, + ["address"] = item.Address, + ["isSourceSchool"] = item.IsSourceSchool, + ["isAdmissionSchool"] = item.IsAdmissionSchool, + ["active"] = item.Active + }; + + private static JsonObject ClassJson(AdminClass item) => new() + { + ["id"] = item.Id, + ["schoolId"] = item.SchoolId, + ["name"] = item.Name, + ["grade"] = item.Grade, + ["active"] = item.Active + }; + + private static JsonObject SafeUser(AdminUser item) => new() + { + ["id"] = item.Id, + ["username"] = item.Username, + ["role"] = item.Role, + ["adminLevel"] = item.Role == "admin" ? item.AdminLevel ?? "super" : null, + ["schoolId"] = JsonValue.Create(item.SchoolId), + ["classId"] = JsonValue.Create(item.ClassId), + ["displayName"] = item.DisplayName, + ["candidateNumber"] = JsonValue.Create(item.CandidateNumber), + ["mustChangePassword"] = item.MustChangePassword, + ["totpEnabled"] = item.TotpEnabled, + ["archived"] = item.ArchivedAt is not null + }; + + private static AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) => + new(Uid("log"), user.Id, action, detail, NowIso()); + + private static string NullishText(JsonObject body, string property, string fallback) => + body[property] is null ? fallback : Text(body[property]); + + private static bool NullishBoolean(JsonObject body, string property, bool fallback) => + body[property] is null ? fallback : JsBoolean(body[property]); + + private static bool ExactlyFalse(JsonNode? node) => + node is JsonValue value && value.TryGetValue(out var boolean) && !boolean; + + private static bool JsBoolean(JsonNode? node) + { + if (node is null) return false; + if (node is not JsonValue value) return true; + if (value.TryGetValue(out var boolean)) return boolean; + if (value.TryGetValue(out var text)) return text.Length > 0; + if (value.TryGetValue(out var number)) return number != 0 && !double.IsNaN(number); + return true; + } + + private static string TruthyText(JsonNode? node, string fallback = "") => + JsBoolean(node) ? Text(node) : fallback; + + private static string Text(JsonNode? node) + { + if (node is null) return string.Empty; + if (node is JsonValue value) + { + if (value.TryGetValue(out var text)) return text; + if (value.TryGetValue(out var boolean)) return boolean ? "true" : "false"; + if (value.TryGetValue(out var number)) return number.ToString(CultureInfo.InvariantCulture); + } + return node.ToJsonString(); + } + + private static string Clean(string value, int maximum) + { + var cleaned = value.Trim(); + return cleaned[..Math.Min(cleaned.Length, maximum)]; + } + + private static string Level(AuthenticationUser user) => user.AdminLevel ?? "super"; + + private static string Uid(string prefix) => + $"{prefix}_{ToBase36(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())}_{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(4))}"; + + private static string ToBase36(long value) + { + const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; + Span buffer = stackalloc char[16]; + var position = buffer.Length; + do + { + buffer[--position] = alphabet[(int)(value % 36)]; + value /= 36; + } + while (value > 0); + return new string(buffer[position..]); + } + + private static string NowIso() => + DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); + + private static string Base64Url(byte[] value) => + Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static AdminEndpointResult Success(JsonObject body) => Result(200, body); + + private static AdminEndpointResult Result(int statusCode, JsonObject body) => new(statusCode, body); + + private static AdminEndpointResult Error(int status, string message) => + Result(status, new JsonObject { ["ok"] = false, ["message"] = message }); + + [GeneratedRegex("^[A-Z0-9_-]+$", RegexOptions.CultureInvariant)] + private static partial Regex SchoolCodePattern(); + + private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) + { + public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); + } +} diff --git a/src/Eis.Infrastructure/Administration/AdminWriteRepository.cs b/src/Eis.Infrastructure/Administration/AdminWriteRepository.cs new file mode 100644 index 0000000..093c3bb --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminWriteRepository.cs @@ -0,0 +1,184 @@ +using System.Data.Common; +using Eis.Infrastructure.Data; + +namespace Eis.Infrastructure.Administration; + +internal sealed record AdminAuditEntry( + string Id, + string ActorId, + string Action, + string Detail, + string CreatedAt); + +internal sealed class AdminWriteRepository(IRelationalConnectionFactory connectionFactory) +{ + public Task SaveSchoolAsync( + AdminSchool school, + bool isNew, + AdminAuditEntry audit, + CancellationToken cancellationToken) + { + var operation = isNew + ? new SqlOperation( + """ + INSERT INTO schools (id, name, code, address, is_source_school, is_admission_school, active) + VALUES (@id, @name, @code, @address, @isSourceSchool, @isAdmissionSchool, @active) + """, + [ + new("@id", school.Id), new("@name", school.Name), new("@code", school.Code), + new("@address", Optional(school.Address)), new("@isSourceSchool", Number(school.IsSourceSchool)), + new("@isAdmissionSchool", Number(school.IsAdmissionSchool)), new("@active", Number(school.Active)) + ]) + : new SqlOperation( + """ + UPDATE schools SET name = @name, code = @code, address = @address, + is_source_school = @isSourceSchool, is_admission_school = @isAdmissionSchool, active = @active + WHERE id = @id + """, + [ + new("@name", school.Name), new("@code", school.Code), new("@address", Optional(school.Address)), + new("@isSourceSchool", Number(school.IsSourceSchool)), new("@isAdmissionSchool", Number(school.IsAdmissionSchool)), + new("@active", Number(school.Active)), new("@id", school.Id) + ]); + return ExecuteWithAuditAsync(operation, audit, cancellationToken); + } + + public Task SaveClassAsync( + AdminClass schoolClass, + bool isNew, + AdminAuditEntry audit, + CancellationToken cancellationToken) + { + var operation = isNew + ? new SqlOperation( + """ + INSERT INTO school_classes (id, school_id, name, grade, active) + VALUES (@id, @schoolId, @name, @grade, @active) + """, + [ + new("@id", schoolClass.Id), new("@schoolId", schoolClass.SchoolId), new("@name", schoolClass.Name), + new("@grade", schoolClass.Grade), new("@active", Number(schoolClass.Active)) + ]) + : new SqlOperation( + "UPDATE school_classes SET name = @name, grade = @grade, active = @active WHERE id = @id", + [ + new("@name", schoolClass.Name), new("@grade", schoolClass.Grade), + new("@active", Number(schoolClass.Active)), new("@id", schoolClass.Id) + ]); + return ExecuteWithAuditAsync(operation, audit, cancellationToken); + } + + public Task CreateAdminAsync( + AdminUser user, + string passwordHash, + AdminAuditEntry audit, + CancellationToken cancellationToken) => ExecuteWithAuditAsync( + new SqlOperation( + """ + INSERT INTO users ( + id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at + ) VALUES ( + @id, @username, @passwordHash, 'admin', @adminLevel, @schoolId, @classId, @active, @displayName, @createdAt + ) + """, + [ + new("@id", user.Id), new("@username", user.Username), new("@passwordHash", passwordHash), + new("@adminLevel", user.AdminLevel), new("@schoolId", user.SchoolId), new("@classId", user.ClassId), + new("@active", Number(user.Active)), new("@displayName", user.DisplayName), new("@createdAt", user.CreatedAt) + ]), + audit, + cancellationToken); + + public Task UpdateAdminAsync( + string userId, + string displayName, + string? classId, + bool active, + string? passwordHash, + AdminAuditEntry audit, + CancellationToken cancellationToken) + { + var operation = passwordHash is null + ? new SqlOperation( + "UPDATE users SET display_name = @displayName, class_id = @classId, active = @active WHERE id = @id", + [ + new("@displayName", displayName), new("@classId", classId), + new("@active", Number(active)), new("@id", userId) + ]) + : new SqlOperation( + """ + UPDATE users SET display_name = @displayName, class_id = @classId, active = @active, + password_hash = @passwordHash WHERE id = @id + """, + [ + new("@displayName", displayName), new("@classId", classId), new("@active", Number(active)), + new("@passwordHash", passwordHash), new("@id", userId) + ]); + return ExecuteWithAuditAsync(operation, audit, cancellationToken); + } + + public Task UpdateSelfRegistrationAsync( + bool enabled, + AdminAuditEntry audit, + CancellationToken cancellationToken) => ExecuteWithAuditAsync( + new SqlOperation( + "UPDATE schema_metadata SET self_registration_enabled = @enabled WHERE id = 1", + [new("@enabled", Number(enabled))]), + audit, + cancellationToken); + + private async Task ExecuteWithAuditAsync( + SqlOperation operation, + AdminAuditEntry audit, + CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + try + { + await ExecuteAsync(connection, transaction, operation, cancellationToken); + await ExecuteAsync(connection, transaction, new SqlOperation( + """ + INSERT INTO audit_logs (id, actor_id, action, detail, created_at) + VALUES (@id, @actorId, @action, @detail, @createdAt) + """, + [ + new("@id", audit.Id), new("@actorId", audit.ActorId), new("@action", audit.Action), + new("@detail", audit.Detail), new("@createdAt", audit.CreatedAt) + ]), cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + private static async Task ExecuteAsync( + DbConnection connection, + DbTransaction transaction, + SqlOperation operation, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = operation.Sql; + foreach (var item in operation.Parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = item.Name; + parameter.Value = item.Value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private static int Number(bool value) => value ? 1 : 0; + + private static string? Optional(string value) => value.Length == 0 ? null : value; + + private sealed record SqlOperation(string Sql, IReadOnlyList Parameters); + + private sealed record SqlParameterValue(string Name, object? Value); +} diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index 840cfa4..3ee94e9 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -53,7 +53,9 @@ public static class DependencyInjection services.AddScoped(); services.AddSingleton(adminMigrationOptions); services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; } diff --git a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs index b9db60b..a531e99 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -1,5 +1,6 @@ using Eis.Application.Administration; using Eis.Infrastructure.Administration; +using System.Text.Json.Nodes; namespace Eis.Web.Administration; @@ -23,6 +24,24 @@ public static class NativeAdminReadEndpoints Execute(context, service.GetAdminsAsync(Token(context), cancellationToken))); endpoints.MapGet("/api/admin/exams", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) => Execute(context, service.GetExamsAsync(Token(context), cancellationToken))); + + if (!options.NativeOrganizationWritesEnabled) return endpoints; + endpoints.MapPost("/api/admin/schools", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.CreateSchoolAsync(Token(context), body, cancellationToken))); + endpoints.MapPatch("/api/admin/schools/{schoolId}", (HttpContext context, string schoolId, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.UpdateSchoolAsync(Token(context), schoolId, body, cancellationToken))); + endpoints.MapPost("/api/admin/classes", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.CreateClassAsync(Token(context), body, cancellationToken))); + endpoints.MapPatch("/api/admin/classes/{classId}", (HttpContext context, string classId, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.UpdateClassAsync(Token(context), classId, body, cancellationToken))); + endpoints.MapPost("/api/admin/admins", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.CreateAdminAsync(Token(context), body, cancellationToken))); + endpoints.MapPatch("/api/admin/admins/{adminId}", (HttpContext context, string adminId, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.UpdateAdminAsync(Token(context), adminId, body, cancellationToken))); + endpoints.MapPost("/api/admin/admins/{adminId}/reset-password", (HttpContext context, string adminId, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.ResetAdminPasswordAsync(Token(context), adminId, cancellationToken))); + endpoints.MapPut("/api/admin/settings/self-registration", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.UpdateSelfRegistrationAsync(Token(context), body, cancellationToken))); return endpoints; } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 29aaa9c..ff1bfd1 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -45,7 +45,8 @@ var candidateMigrationOptions = CandidateMigrationOptions.FromEnvironment( var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( builder.Configuration.GetValue("AdminMigration:NativeReadsEnabled"), authenticationOptions.NativeEnabled, - authenticationOptions.SharesLegacySessions); + authenticationOptions.SharesLegacySessions, + builder.Configuration.GetValue("AdminMigration:NativeOrganizationWritesEnabled")); builder.Services.AddEisInfrastructure( DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), @@ -100,9 +101,18 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c administration = new { nativeReadsEnabled = adminMigrationOptions.NativeReadsEnabled, - nativeRoutes = adminMigrationOptions.NativeReadsEnabled - ? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" } - : [] + nativeOrganizationWritesEnabled = adminMigrationOptions.NativeOrganizationWritesEnabled, + nativeRoutes = (adminMigrationOptions.NativeReadsEnabled + ? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" } + : []) + .Concat(adminMigrationOptions.NativeOrganizationWritesEnabled + ? new[] + { + "POST/PATCH schools", "POST/PATCH classes", "POST/PATCH admins", + "POST admin password reset", "PUT self-registration setting" + } + : []) + .ToArray() }, features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) }, statusCode: statusCode); diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index f7794ba..5c8551c 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -10,7 +10,8 @@ "NativeEnabled": false }, "AdminMigration": { - "NativeReadsEnabled": false + "NativeReadsEnabled": false, + "NativeOrganizationWritesEnabled": false }, "Logging": { "LogLevel": { diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs index 0ba1347..82000e2 100644 --- a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs @@ -7,7 +7,7 @@ public sealed class AdminMigrationOptionsTests [Fact] public void DisabledByDefault() { - WithEnvironment(null, null, () => + WithEnvironment(null, null, null, () => { var options = AdminMigrationOptions.FromEnvironment( configuredNativeReadsEnabled: false, @@ -21,7 +21,7 @@ public sealed class AdminMigrationOptionsTests [Fact] public void RequiresNativeAuthentication() { - WithEnvironment("true", "true", () => + WithEnvironment("true", null, "true", () => { var exception = Assert.Throws(() => AdminMigrationOptions.FromEnvironment( @@ -36,7 +36,7 @@ public sealed class AdminMigrationOptionsTests [Fact] public void RequiresSharedSessionsOutsideIsolatedTests() { - WithEnvironment("true", null, () => + WithEnvironment("true", null, null, () => { var exception = Assert.Throws(() => AdminMigrationOptions.FromEnvironment( @@ -51,7 +51,7 @@ public sealed class AdminMigrationOptionsTests [Fact] public void AllowsSharedRedisSessions() { - WithEnvironment("true", null, () => + WithEnvironment("true", null, null, () => { var options = AdminMigrationOptions.FromEnvironment( configuredNativeReadsEnabled: false, @@ -62,22 +62,56 @@ public sealed class AdminMigrationOptionsTests }); } + [Fact] + public void OrganizationWritesRequireNativeReads() + { + WithEnvironment("false", "true", null, () => + { + var exception = Assert.Throws(() => + AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: true)); + + Assert.Contains("ADMIN_NATIVE_READS_ENABLED=true", exception.Message); + }); + } + + [Fact] + public void EnablesOrganizationWritesWithReadsAndSharedSessions() + { + WithEnvironment("true", "true", null, () => + { + var options = AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: true); + + Assert.True(options.NativeReadsEnabled); + Assert.True(options.NativeOrganizationWritesEnabled); + }); + } + private static void WithEnvironment( string? nativeReadsEnabled, + string? nativeOrganizationWritesEnabled, string? allowMemory, Action test) { var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"); + var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"); try { Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", nativeReadsEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED", nativeOrganizationWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); test(); } finally { Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", previousNativeReadsEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED", previousNativeOrganizationWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); } }