feat(auth): add PAT auth, scoped REST/MCP access, CSRF, and tenant isolation

Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.

Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.

Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.

Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
naiba
2026-05-30 15:56:44 +00:00
co-authored by cloudcode
parent 029695344c
commit e8dabf5bc6
153 changed files with 16974 additions and 244 deletions
+24
View File
@@ -28,6 +28,8 @@ func listServerGroup(c *gin.Context) ([]*model.ServerGroupResponseItem, error) {
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
isAdmin := isMember && callerIsAdmin(c)
pat := patAccessorFromContext(c)
patLimited := pat != nil && patHasServerWhitelist(c)
visibleServerIDs := make(map[uint64]struct{})
if !isMember {
@@ -47,6 +49,9 @@ func listServerGroup(c *gin.Context) ([]*model.ServerGroupResponseItem, error) {
continue
}
}
if pat != nil && !pat.CanAccessServer(s.ServerId) {
continue
}
if _, ok := groupServers[s.ServerGroupId]; !ok {
groupServers[s.ServerGroupId] = make([]uint64, 0)
}
@@ -61,6 +66,9 @@ func listServerGroup(c *gin.Context) ([]*model.ServerGroupResponseItem, error) {
if !isMember && len(groupServers[s.ID]) == 0 {
continue
}
if patLimited && len(groupServers[s.ID]) == 0 {
continue
}
sgRes = append(sgRes, &model.ServerGroupResponseItem{
Group: s,
Servers: groupServers[s.ID],
@@ -169,6 +177,10 @@ func updateServerGroup(c *gin.Context) (any, error) {
return nil, singleton.Localizer.ErrorT("unauthorized")
}
if !patGroupMembershipAccessAllowed(c, sgDB.ID) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
sgDB.Name = sg.Name
var count int64
@@ -237,6 +249,18 @@ func batchDeleteServerGroup(c *gin.Context) (any, error) {
}
}
if pat := patAccessorFromContext(c); pat != nil && patHasServerWhitelist(c) {
var members []model.ServerGroupServer
if err := singleton.DB.Where("server_group_id in (?)", sgs).Find(&members).Error; err != nil {
return nil, err
}
for _, m := range members {
if !pat.CanAccessServer(m.ServerId) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
}
}
err := singleton.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Delete(&model.ServerGroup{}, "id in (?)", sgs).Error; err != nil {
return err