收紧全局设置与DNS权限并更新进度清单
限制系统设置修改、DNS在线测试和续期操作的角色权限,补充前后端回归测试与开发进度记录。
This commit is contained in:
parent
f254dd9248
commit
ba6bd80382
@ -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())
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 仍为“完成,待复审”。
|
||||
|
||||
下一步继续审查剩余资源权限联动,开发阶段完成后统一执行生产构建、部署和实机验收。
|
||||
|
||||
@ -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 仍为“完成,待复审”。
|
||||
|
||||
下一步继续审查剩余资源权限联动,开发阶段完成后统一执行生产构建、部署和实机验收。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user