fix(auth): add node-admin token scope for panel updates (#6666)

* feat(auth): add node-admin API token scope

Keep ordinary node-sync credentials unable to self-update a remote panel while allowing explicitly elevated node administrators to inherit synchronization access and invoke updatePanel.

* style(docs): format API scope table

* fix(auth): preserve mTLS node-sync semantics

Allow only an explicit node-admin bearer to elevate a verified mTLS request. Existing admin, monitor, and node-sync bearer values continue to use the historical node-sync identity on mTLS connections.

* chore(ci): rerun flaky checks

---------

Co-authored-by: libmur-dev <333915961+libmur-dev@users.noreply.github.com>
This commit is contained in:
libmur-dev
2026-10-18 00:45:26 +03:00
committed by GitHub
parent a1f2589eb5
commit a8d0700bf2
42 changed files with 506 additions and 123 deletions
+79
View File
@@ -107,6 +107,43 @@ func TestCheckAPIAuth_BearerSuccess(t *testing.T) {
}
}
func TestPanelUpdateRequiresNodeAdminScope(t *testing.T) {
engine, _ := newAPIAuthTestEngine(t)
cases := []struct {
name string
scope string
wantCode int
wantBody string
}{
{"node sync denied", model.ApiScopeNodeSync, http.StatusForbidden, `{"msg":"this API token is not permitted to access this endpoint","success":false}`},
{"node admin allowed", model.ApiScopeNodeAdmin, http.StatusOK, `{"reached":true}`},
{"monitor denied", model.ApiScopeMonitor, http.StatusForbidden, `{"msg":"this API token is not permitted to access this endpoint","success":false}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
plaintext := tc.name + "-token"
if err := database.GetDB().Create(&model.ApiToken{
Name: tc.name,
Token: crypto.HashTokenSHA256(plaintext),
Enabled: true,
Scope: tc.scope,
}).Error; err != nil {
t.Fatalf("seed token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", nil)
req.Header.Set("Authorization", "Bearer "+plaintext)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != tc.wantCode || w.Body.String() != tc.wantBody {
t.Fatalf("response = %d %s, want %d %s", w.Code, w.Body.String(), tc.wantCode, tc.wantBody)
}
})
}
}
// TestCheckAPIAuth_AcceptsVerifiedClientCert ensures verified mTLS authenticates
// as node-sync rather than bypassing scope checks as admin.
func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
@@ -135,6 +172,48 @@ func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
if w.Code != http.StatusForbidden {
t.Fatalf("updatePanel status = %d, want 403; body=%s", w.Code, w.Body.String())
}
for _, legacyScope := range []string{model.ApiScopeAdmin, model.ApiScopeMonitor, model.ApiScopeNodeSync} {
plaintext := "mtls-" + legacyScope + "-token"
if err := database.GetDB().Create(&model.ApiToken{
Name: "mtls-" + legacyScope,
Token: crypto.HashTokenSHA256(plaintext),
Enabled: true,
Scope: legacyScope,
}).Error; err != nil {
t.Fatalf("seed %s token: %v", legacyScope, err)
}
legacy := httptest.NewRequest(http.MethodGet, "/panel/api/server/status", nil)
legacy.Header.Set("Authorization", "Bearer "+plaintext)
legacy.TLS = &tls.ConnectionState{
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
}
w = httptest.NewRecorder()
engine.ServeHTTP(w, legacy)
if w.Code != http.StatusOK || w.Body.String() != `{"api_authed":true,"scope":"node-sync"}` {
t.Fatalf("mTLS %s response = %d %s, want preserved node-sync scope", legacyScope, w.Code, w.Body.String())
}
}
const plaintext = "mtls-node-admin-token"
if err := database.GetDB().Create(&model.ApiToken{
Name: "mtls-node-admin",
Token: crypto.HashTokenSHA256(plaintext),
Enabled: true,
Scope: model.ApiScopeNodeAdmin,
}).Error; err != nil {
t.Fatalf("seed node-admin token: %v", err)
}
allowed := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", nil)
allowed.Header.Set("Authorization", "Bearer "+plaintext)
allowed.TLS = &tls.ConnectionState{
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
}
w = httptest.NewRecorder()
engine.ServeHTTP(w, allowed)
if w.Code != http.StatusOK || w.Body.String() != `{"reached":true}` {
t.Fatalf("mTLS node-admin response = %d %s, want 200 update launch", w.Code, w.Body.String())
}
}
func TestNodeSyncScopeUsesFullPathPatterns(t *testing.T) {