收紧全局设置与DNS权限并更新进度清单

限制系统设置修改、DNS在线测试和续期操作的角色权限,补充前后端回归测试与开发进度记录。
This commit is contained in:
xuans 2026-09-29 22:33:32 +08:00
parent f254dd9248
commit ba6bd80382
12 changed files with 160 additions and 10 deletions

View File

@ -42,6 +42,47 @@ func TestAdminDNSProvidersReturnsCatalog(t *testing.T) {
}
}
func TestAdminDNSMutationRequiresSuperAdmin(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg.SetupReady = true
server := New(Dependencies{Config: cfg, SetupDNS: &fakeSetupDNSVerifier{}})
cookie := httpUserSessionCookie(server, "dns-admin", repository.UserRoleAdmin)
cases := []struct {
name string
method string
path string
body string
}{
{
name: "在线测试",
method: http.MethodPost,
path: "/api/v1/dns/test",
body: `{"provider":"cloudflare","test_domain":"example.com","credentials":{"api_token":"test-token"}}`,
},
{
name: "阿里云权限检查",
method: http.MethodPost,
path: "/api/v1/dns/aliyun/check",
body: `{"domain":"example.com"}`,
},
}
for _, item := range cases {
t.Run(item.name, func(t *testing.T) {
request := httptest.NewRequest(item.method, item.path, strings.NewReader(item.body))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(cookie)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("普通管理员执行 DNS 变更操作应返回 403,实际为 %d:%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "只有超级管理员可以管理系统设置") {
t.Fatalf("DNS 权限错误提示不明确:%s", response.Body.String())
}
})
}
}
func TestAdminDNSAccountsExposeProjectScope(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg.SetupReady = true
@ -122,7 +163,7 @@ func TestAdminDNSTestReturnsVerificationResult(t *testing.T) {
"credentials":{"api_token":"test-token"}
}`))
request.Header.Set("Content-Type", "application/json")
serveAdminHTTP(server, response, request)
serveSuperAdminHTTP(server, response, request)
if response.Code != http.StatusOK {
t.Fatalf("DNS 在线测试失败:%d %s", response.Code, response.Body.String())
}
@ -152,7 +193,7 @@ func TestAdminAliyunDNSTestReportsFastVerificationTimeout(t *testing.T) {
"credentials":{"access_key_id":"test-id","access_key_secret":"test-secret","managed_zones":"example.com","allowed_domains":"example.com"}
}`))
request.Header.Set("Content-Type", "application/json")
serveAdminHTTP(server, response, request)
serveSuperAdminHTTP(server, response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"verification_timeout_secs":6`) {
t.Fatalf("管理端阿里云 DNS 在线测试未返回 6 秒等待上限:%d %s", response.Code, response.Body.String())
}
@ -165,7 +206,7 @@ func TestAdminDNSTestRejectsUnavailableService(t *testing.T) {
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/dns/test", strings.NewReader(`{"provider":"cloudflare","test_domain":"example.com","credentials":{"api_token":"test-token"}}`))
request.Header.Set("Content-Type", "application/json")
serveAdminHTTP(server, response, request)
serveSuperAdminHTTP(server, response, request)
if response.Code != http.StatusServiceUnavailable {
t.Fatalf("DNS 服务未初始化时应返回 503,实际为 %d:%s", response.Code, response.Body.String())
}

View File

@ -221,11 +221,11 @@ func (s *Server) registerRoutes() {
api.GET("/tasks/:id/attempts", s.listTaskAttempts)
adminAPI.GET("/settings", s.getSettings)
adminAPI.PUT("/settings", s.updateSettings)
adminAPI.PUT("/settings", s.requireSuperAdminSystemConfig, s.updateSettings)
adminAPI.GET("/dns/providers", s.listAdminDNSProviders)
adminAPI.GET("/dns/accounts", s.listDNSAccounts)
adminAPI.POST("/dns/test", s.dnsOperation, s.testAdminDNS)
adminAPI.POST("/dns/aliyun/check", s.dnsOperation, s.checkAliyunDNSDomain)
adminAPI.POST("/dns/test", s.requireSuperAdminSystemConfig, s.dnsOperation, s.testAdminDNS)
adminAPI.POST("/dns/aliyun/check", s.requireSuperAdminSystemConfig, s.dnsOperation, s.checkAliyunDNSDomain)
adminAPI.POST("/renewal/run", s.requireSuperAdmin, s.longOperation(certificateTaskTimeout), s.runRenewalNow)
domainAPI.POST("/domains/:id/certificate/import", s.longOperation(certificateTaskTimeout), s.importCertificateFiles)
domainAPI.POST("/domains/:id/certificate/validate", s.longOperation(certificateTaskTimeout), s.validateCertificate)

View File

@ -31,6 +31,23 @@ func TestSettingsAPIRequiresAuthentication(t *testing.T) {
}
}
func TestSettingsAPIUpdateRequiresSuperAdmin(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg.SetupReady = true
server := New(Dependencies{Config: cfg})
request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{"server":{"port":5443}}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(httpUserSessionCookie(server, "settings-admin", repository.UserRoleAdmin))
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("普通管理员修改系统设置应返回 403,实际为 %d:%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "只有超级管理员可以管理系统设置") {
t.Fatalf("系统设置权限错误提示不明确:%s", response.Body.String())
}
}
func TestSettingsAPIUpdatesSafeFieldsWithoutReturningSecrets(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)

View File

@ -104,3 +104,8 @@ func serveAdminHTTP(server *Server, recorder *httptest.ResponseRecorder, request
request.AddCookie(httpUserSessionCookie(server, "test-admin", repository.UserRoleAdmin))
server.Handler().ServeHTTP(recorder, request)
}
func serveSuperAdminHTTP(server *Server, recorder *httptest.ResponseRecorder, request *http.Request) {
request.AddCookie(httpUserSessionCookie(server, "test-super-admin", repository.UserRoleSuperAdmin))
server.Handler().ServeHTTP(recorder, request)
}

View File

@ -23,6 +23,14 @@ type userRequest struct {
}
func (s *Server) requireSuperAdmin(c *gin.Context) {
s.requireSuperAdminAction(c, "管理用户")
}
func (s *Server) requireSuperAdminSystemConfig(c *gin.Context) {
s.requireSuperAdminAction(c, "管理系统设置")
}
func (s *Server) requireSuperAdminAction(c *gin.Context, action string) {
s.configMu.RLock()
setupReady := s.config.SetupReady
s.configMu.RUnlock()
@ -39,7 +47,7 @@ func (s *Server) requireSuperAdmin(c *gin.Context) {
return
}
if session.Role != repository.UserRoleSuperAdmin {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "只有超级管理员可以管理用户", "request_id": requestIDFromContext(c)})
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "只有超级管理员可以" + action, "request_id": requestIDFromContext(c)})
return
}
if !s.sameOriginForAPI(c.Request) {

View File

@ -781,4 +781,17 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全
- [~] P0-07 仍需继续完成三数据库生产矩阵、浏览器清缓存验收、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务及最终生产门禁。
- [!] 总体验收不得升级,P0-03 仍为“完成,待复审”。
下一步完成开发阶段剩余非权限项后,再统一执行生产构建、部署和实机验收。
下一步完成开发阶段剩余非权限项后,再统一执行生产构建、部署和实机验收。
## 本轮开发进度(2026-09-29,续)
总体验收位置继续保持 **P0-03:完成,待复审**;本轮完成全局系统设置与 DNS 管理权限收紧,不将局部自动化测试结果视为生产部署验收通过。
- [x] 普通管理员保留系统设置、DNS 服务商目录和 DNS 账户归属信息的读取能力。
- [x] 系统设置修改、DNS API 在线测试和阿里云 DNS 权限检查统一限制为超级管理员。
- [x] 前端普通管理员进入系统设置时改为只读,并隐藏全局续期执行按钮;后端继续保留权限拒绝保护。
- [x] 新增后端全局配置权限回归测试和前端普通管理员操作保护测试;前端 230 项定向测试通过,DNS、设置和续期定向后端测试通过。
- [~] P0-07 仍需继续完成三数据库生产矩阵、浏览器清缓存验收、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务及最终生产门禁。
- [!] 总体验收不得升级,P0-03 仍为“完成,待复审”。
下一步继续审查剩余资源权限联动,开发阶段完成后统一执行生产构建、部署和实机验收。

View File

@ -1421,4 +1421,17 @@ P0-11 生产验收门禁
- [~] P0-07 仍需继续完成三数据库生产矩阵、浏览器清缓存验收、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务及最终生产门禁。
- [!] 总体验收不得升级,P0-03 仍为“完成,待复审”。
下一步完成开发阶段剩余非权限项后,再统一执行生产构建、部署和实机验收。
下一步完成开发阶段剩余非权限项后,再统一执行生产构建、部署和实机验收。
## 本轮开发进度(2026-09-29,续)
总体验收位置继续保持 **P0-03:完成,待复审**;本轮完成全局系统设置与 DNS 管理权限收紧,不将局部自动化测试结果视为生产部署验收通过。
- [x] 普通管理员保留系统设置、DNS 服务商目录和 DNS 账户归属信息的读取能力。
- [x] 系统设置修改、DNS API 在线测试和阿里云 DNS 权限检查统一限制为超级管理员。
- [x] 前端普通管理员进入系统设置时改为只读,并隐藏全局续期执行按钮;后端继续保留权限拒绝保护。
- [x] 新增后端全局配置权限回归测试和前端普通管理员操作保护测试;前端 230 项定向测试通过,DNS、设置和续期定向后端测试通过。
- [~] P0-07 仍需继续完成三数据库生产矩阵、浏览器清缓存验收、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务及最终生产门禁。
- [!] 总体验收不得升级,P0-03 仍为“完成,待复审”。
下一步继续审查剩余资源权限联动,开发阶段完成后统一执行生产构建、部署和实机验收。

View File

@ -15,6 +15,8 @@ function dnsVerificationTimeoutFor(providerID) {
export function useDashboard() {
const status = ref(null);
const currentUser = ref(null);
const canManageSystemSettings = computed(() => currentUser.value?.role === "super_admin");
const canRunRenewal = computed(() => currentUser.value?.role === "super_admin");
const domains = ref([]);
const servers = ref([]);
const sshKeys = ref([]);
@ -864,6 +866,10 @@ export function useDashboard() {
}
async function runRenewalNow() {
if (!canRunRenewal.value) {
error.value = "只有超级管理员可以执行续期检查";
return;
}
error.value = "";
notice.value = "";
try {
@ -904,6 +910,10 @@ export function useDashboard() {
}
async function testDNS() {
if (!canManageSystemSettings.value) {
dnsTestError.value = "只有超级管理员可以进行 DNS 在线测试";
return;
}
handleDNSProviderChange();
const dns = settingsForm.value.dns;
const provider = selectedDNSProvider.value;
@ -986,6 +996,10 @@ export function useDashboard() {
async function saveSettings() {
settingsError.value = "";
if (!canManageSystemSettings.value) {
settingsError.value = "只有超级管理员可以保存系统设置";
return;
}
verificationClock.value = Date.now();
if (!dnsReadyToSave.value) {
settingsError.value = "DNS 服务商配置尚未通过在线测试,请先完成测试";
@ -1216,6 +1230,8 @@ export function useDashboard() {
...domainForm,
status,
currentUser,
canManageSystemSettings,
canRunRenewal,
domains,
servers,
sshKeys,

View File

@ -276,11 +276,25 @@ async function setup(t, request) {
return Response.json({ items: [] });
});
const { state } = mounted;
state.currentUser.value = { account: "test-admin", role: "super_admin" };
state.openSettings();
await nextTick();
return mounted;
}
test("普通管理员不能在前端执行全局续期、DNS验证或保存系统设置", async (t) => {
const { state } = await setup(t, () => verifiedResponse());
state.currentUser.value = { account: "operator", role: "admin" };
state.showSettings.value = true;
await state.testDNS();
assert.equal(state.dnsTestError.value, "只有超级管理员可以进行 DNS 在线测试");
await state.saveSettings();
assert.equal(state.settingsError.value, "只有超级管理员可以保存系统设置");
await state.runRenewalNow();
assert.equal(state.error.value, "只有超级管理员可以执行续期检查");
});
test("编辑服务器正确保留已有密码状态,新建和未配置密码仍要求输入", async (t) => {
const { state } = await setup(t, () => verifiedResponse());
state.openServerEdit({ id: "configured", auth_method: "password", password_configured: true });
@ -540,6 +554,7 @@ test("settingsReadEditSaveContract", async (t) => {
}
return Response.json({ items: [] });
});
state.currentUser.value = { account: "test-admin", role: "super_admin" };
state.openSettings();
state.settingsForm.value.acme.renewal_threshold_days = 91;
assert.equal(state.dnsReadyToSave.value, true);

View File

@ -129,6 +129,7 @@ const {
validateDomain,
deployDomain,
runRenewalNow,
canRunRenewal,
cancelTask,
openTaskDetails,
closeTaskDetails,
@ -158,7 +159,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
<button class="secondary-button" type="button" @click="loadData">
刷新状态
</button>
<button v-if="activeSection === 'activity'" class="secondary-button" type="button" @click="runRenewalNow">
<button v-if="activeSection === 'activity' && canRunRenewal" class="secondary-button" type="button" @click="runRenewalNow">
立即检查续期
</button>
<button

View File

@ -161,3 +161,18 @@
grid-template-columns: 1fr;
}
}
.settings-fields {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.settings-readonly-notice {
margin: 0 0 16px;
padding: 10px 12px;
border: 1px solid #f1d48a;
border-radius: 8px;
color: #755b18;
background: #fff8e6;
}

View File

@ -16,6 +16,7 @@ const {
dnsVerificationElapsedSeconds,
dnsVerificationTimeoutSeconds,
dnsReadyToSave,
canManageSystemSettings,
handleDNSProviderChange,
clearDNSCredentials,
testDNS,
@ -36,6 +37,9 @@ const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSet
</div>
<button class="close-button" type="button" aria-label="关闭" @click="closeSettings">×</button>
</div>
<p v-if="!canManageSystemSettings" class="settings-readonly-notice">
当前账号仅可查看系统设置,修改配置和 DNS 在线验证需要超级管理员权限。
</p>
<div class="settings-tabs" role="tablist" aria-labelledby="settings-title">
<button
id="settings-tab-service"
@ -87,6 +91,7 @@ const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSet
</button>
</div>
<form novalidate @submit.prevent="submitSettings">
<fieldset :disabled="!canManageSystemSettings" class="settings-fields">
<section
v-show="activeSection === 'service'"
id="settings-panel-service"
@ -381,6 +386,7 @@ const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSet
{{ settingsSaving ? "保存中……" : "保存设置" }}
</button>
</div>
</fieldset>
</form>
</section>
</div>