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
+43 -23
View File
@@ -35,30 +35,34 @@ func NewAPIController(g *gin.RouterGroup) *APIController {
}
func (a *APIController) checkAPIAuth(c *gin.Context) {
// A verified client certificate (a completed mTLS handshake) authenticates
// the caller, equivalent to a valid bearer token. api_authed must be set so
// the CSRF middleware lets cert-authed mutations through.
if c.Request.TLS != nil && len(c.Request.TLS.VerifiedChains) > 0 {
setAuthenticated := func(scope string) {
if u, err := a.userService.GetFirstUser(); err == nil {
session.SetAPIAuthUser(c, u)
}
c.Set("api_authed", true)
c.Set("api_token_scope", model.ApiScopeNodeSync)
c.Set("api_token_scope", scope)
}
matchedScope := ""
if after, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer "); ok {
if row, matched := a.apiTokenService.MatchToken(after); matched {
matchedScope = row.Scope
}
}
// Verified mTLS keeps its historical node-sync identity. Only an explicit
// node-admin bearer elevates it to software-update authority.
if c.Request.TLS != nil && len(c.Request.TLS.VerifiedChains) > 0 {
scope := model.ApiScopeNodeSync
if matchedScope == model.ApiScopeNodeAdmin {
scope = model.ApiScopeNodeAdmin
}
setAuthenticated(scope)
c.Next()
return
}
auth := c.GetHeader("Authorization")
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
tok := after
if row, ok := a.apiTokenService.MatchToken(tok); ok {
if u, err := a.userService.GetFirstUser(); err == nil {
session.SetAPIAuthUser(c, u)
}
c.Set("api_authed", true)
c.Set("api_token_scope", row.Scope)
c.Next()
return
}
if matchedScope != "" {
setAuthenticated(matchedScope)
c.Next()
return
}
if !session.IsLogin(c) {
// A presented Bearer token is not an anonymous scan: return 401 so
@@ -120,7 +124,20 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{
"/hosts/list": {http.MethodGet: {}},
}
// enforceTokenScope applies explicit allowlists to monitor and node-sync tokens.
var nodeAdminScopeAllow = map[string]map[string]struct{}{
"/server/updatePanel": {http.MethodPost: {}},
}
func scopeAllows(allow map[string]map[string]struct{}, path, method string) bool {
methods, exists := allow[path]
if !exists {
return false
}
_, exists = methods[method]
return exists
}
// enforceTokenScope applies explicit allowlists to restricted API tokens.
// Admin tokens and session-login users retain their existing behavior.
func (a *APIController) enforceTokenScope(c *gin.Context) {
scopeVal, ok := c.Get("api_token_scope")
@@ -147,11 +164,14 @@ func (a *APIController) enforceTokenScope(c *gin.Context) {
return
}
case model.ApiScopeNodeSync:
if methods, allowed := nodeSyncScopeAllow[rel]; allowed {
if _, allowedMethod := methods[c.Request.Method]; allowedMethod {
c.Next()
return
}
if scopeAllows(nodeSyncScopeAllow, rel, c.Request.Method) {
c.Next()
return
}
case model.ApiScopeNodeAdmin:
if scopeAllows(nodeSyncScopeAllow, rel, c.Request.Method) || scopeAllows(nodeAdminScopeAllow, rel, c.Request.Method) {
c.Next()
return
}
default:
deny()