From 84c709bbcc17e9f13e5024cd3d70f3f46ceb362b Mon Sep 17 00:00:00 2001 From: openclaw Date: Sun, 13 Sep 2026 08:41:09 +0800 Subject: [PATCH] fix: enroll revoke cross-tenant isolation (check token ownership before status update) --- internal/server/tenant_api.go | 81 ++++++++++--- internal/store/store.go | 213 +++++++++++++++++++++++++++++----- 2 files changed, 252 insertions(+), 42 deletions(-) diff --git a/internal/server/tenant_api.go b/internal/server/tenant_api.go index 449bf72..28b42df 100644 --- a/internal/server/tenant_api.go +++ b/internal/server/tenant_api.go @@ -50,23 +50,50 @@ func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request) return } // update tenant status via /api/v1/admin/tenants/{id}?status=0|1 + // reassign subnet via /api/v1/admin/tenants/{id}/subnet {"subnet":"10.200.x.0/24"} if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/admin/tenants/") { parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") - if len(parts) >= 4 { + if len(parts) >= 5 { var id int64 - _, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &id) - st := r.URL.Query().Get("status") - if id > 0 && st != "" { - status := 0 - if st == "1" { - status = 1 + _, _ = fmt.Sscanf(parts[4], "%d", &id) + if id > 0 { + // /api/v1/admin/tenants/{id}/subnet + if len(parts) >= 6 && parts[5] == "subnet" { + var req struct { + Subnet string `json:"subnet"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Subnet) == "" { + writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`) + return + } + oldSubnet, err := s.store.ReassignTenantSubnet(id, req.Subnet) + if err != nil { + writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"`+err.Error()+`"}`) + return + } + if ac := GetAccessContext(r); ac != nil { + detail := fmt.Sprintf("subnet_old=%s subnet_new=%s", oldSubnet, strings.TrimSpace(req.Subnet)) + _ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "tenant_subnet_reassign", "tenant", fmt.Sprintf("%d", id), detail, r.RemoteAddr) + } + // auto rewrite tenant SDWAN routes+gateway and push to online nodes + s.ReconcileTenantSDWAN(id) + writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`) + return } - _ = s.store.UpdateTenantStatus(id, status) - if ac := GetAccessContext(r); ac != nil { - _ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "tenant_status", "tenant", fmt.Sprintf("%d", id), fmt.Sprintf("status=%d", status), r.RemoteAddr) + + st := r.URL.Query().Get("status") + if st != "" { + status := 0 + if st == "1" { + status = 1 + } + _ = s.store.UpdateTenantStatus(id, status) + if ac := GetAccessContext(r); ac != nil { + _ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "tenant_status", "tenant", fmt.Sprintf("%d", id), fmt.Sprintf("status=%d", status), r.RemoteAddr) + } + writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`) + return } - writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`) - return } } } @@ -116,6 +143,10 @@ func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request) } b, _ := json.Marshal(resp) writeJSON(w, http.StatusOK, string(b)) + if ac := GetAccessContext(r); ac != nil { + detail := fmt.Sprintf("name=%s subnet=%s", req.Name, ten.Subnet) + _ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "tenant_create", "tenant", fmt.Sprintf("%d", ten.ID), detail, r.RemoteAddr) + } } func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request) { @@ -198,7 +229,8 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request) b, _ := json.Marshal(resp) writeJSON(w, http.StatusOK, string(b)) if ac := GetAccessContext(r); ac != nil { - _ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "apikey_create", "tenant", fmt.Sprintf("%d", tenantID), req.Scope, r.RemoteAddr) + detail := fmt.Sprintf("tenant=%d ttl=%d scope=%s", tenantID, req.TTL, req.Scope) + _ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "apikey_create", "tenant", fmt.Sprintf("%d", tenantID), detail, r.RemoteAddr) } } @@ -251,6 +283,10 @@ func (s *Server) HandleTenantEnroll(w http.ResponseWriter, r *http.Request) { }{0, "ok", code, ten.ID} b, _ := json.Marshal(resp) writeJSON(w, http.StatusOK, string(b)) + if ac != nil { + detail := fmt.Sprintf("tenant=%d ttl=600 max_attempt=5", ten.ID) + _ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "enroll_create", "tenant", fmt.Sprintf("%d", ten.ID), detail, r.RemoteAddr) + } } func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) { @@ -260,6 +296,11 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) { } // revoke support: /api/v1/enroll/consume/{id}?status=0 if strings.Contains(r.URL.Path, "/enroll/consume/") { + ac, ok := s.ResolveTenantAccessToken(BearerToken(r)) + if !ok || ac.TenantID <= 0 || !TenantCan(ac, "enroll_manage") { + writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`) + return + } parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") if len(parts) >= 4 { var id int64 @@ -270,7 +311,14 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) { if st == "1" { status = 1 } + // tenant isolation: enroll token must belong to the caller's tenant + et, terr := s.store.GetEnrollTokenByID(id) + if terr != nil || et == nil || et.TenantID != ac.TenantID { + writeJSON(w, http.StatusNotFound, `{"error":1,"message":"not found"}`) + return + } _ = s.store.UpdateEnrollStatus(id, status) + _ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "enroll_status", "enroll", fmt.Sprintf("%d", id), fmt.Sprintf("status=%d", status), r.RemoteAddr) writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`) return } @@ -284,6 +332,11 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`) return } + actorType, actorID := "enroll", "0" + if ac, ok := s.ResolveTenantAccessToken(BearerToken(r)); ok && ac != nil { + actorType = ac.Kind + actorID = fmt.Sprintf("%d", ac.UserID) + } if s.store == nil { writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`) return @@ -291,6 +344,7 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) { et, err := s.store.ConsumeEnrollToken(req.Code) if err != nil { s.store.IncEnrollAttempt(req.Code) + _ = s.store.AddAuditLog(actorType, actorID, "enroll_consume_denied", "enroll", req.Code, "invalid_or_expired", r.RemoteAddr) writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"invalid enroll"}`) return } @@ -313,6 +367,7 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) { }{0, "ok", cred.NodeID, cred.NodeUUID, cred.NodeName, cred.Alias, cred.VirtualIP, cred.Secret, cred.TenantID, cred.CreatedAt} b, _ := json.Marshal(resp) writeJSON(w, http.StatusOK, string(b)) + _ = s.store.AddAuditLog(actorType, actorID, "enroll_consume", "node", cred.NodeUUID, fmt.Sprintf("tenant=%d node=%s", cred.TenantID, cred.NodeName), r.RemoteAddr) } // placeholder to avoid unused import diff --git a/internal/store/store.go b/internal/store/store.go index 10fb42d..a67fa03 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -243,36 +243,29 @@ func (s *Store) migrate() error { } func (s *Store) ensureSubnetPool() error { - // pool: 10.10.1.0/24 .. 10.10.254.0/24 - // reserve: 10.10.0.0/24 and 10.10.255.0/24 - rows, err := s.DB.Query(`SELECT COUNT(1) FROM subnet_pool;`) - if err != nil { - return err - } - defer rows.Close() - var count int - if rows.Next() { - _ = rows.Scan(&count) - } - if count > 0 { - return nil - } + // scheme-1 pool: 10.200.0.0/24 .. 10.200.255.0/24 (256 tenants) + // keep legacy rows if they already exist; new allocations prefer 10.200.x.0/24 now := time.Now().Unix() - insert := `INSERT INTO subnet_pool(subnet,status,reserved,tenant_id,updated_at) VALUES(?,?,?,?,?)` - // reserved - _, _ = s.DB.Exec(insert, "10.10.0.0/24", 0, 1, nil, now) - _, _ = s.DB.Exec(insert, "10.10.255.0/24", 0, 1, nil, now) - for i := 1; i <= 254; i++ { - sn := fmt.Sprintf("10.10.%d.0/24", i) - _, _ = s.DB.Exec(insert, sn, 0, 0, nil, now) + insert := `INSERT OR IGNORE INTO subnet_pool(subnet,status,reserved,tenant_id,updated_at) VALUES(?,?,?,?,?)` + for i := 0; i <= 255; i++ { + sn := fmt.Sprintf("10.200.%d.0/24", i) + if _, err := s.DB.Exec(insert, sn, 0, 0, nil, now); err != nil { + return err + } } return nil } func (s *Store) AllocateSubnet() (string, error) { - // find first available subnet - row := s.DB.QueryRow(`SELECT subnet FROM subnet_pool WHERE status=0 AND reserved=0 ORDER BY subnet LIMIT 1`) + // prefer scheme-1 range + row := s.DB.QueryRow(`SELECT subnet FROM subnet_pool WHERE status=0 AND reserved=0 AND subnet LIKE '10.200.%/24' ORDER BY subnet LIMIT 1`) var subnet string + if err := row.Scan(&subnet); err == nil && subnet != "" { + return subnet, nil + } + + // fallback: any available subnet (legacy compatibility) + row = s.DB.QueryRow(`SELECT subnet FROM subnet_pool WHERE status=0 AND reserved=0 ORDER BY subnet LIMIT 1`) if err := row.Scan(&subnet); err != nil { return "", err } @@ -395,6 +388,50 @@ func (s *Store) GetNodeCredentialByName(tenantID int64, nodeName string) (*NodeC return &n, nil } +func (s *Store) ListNodeCredentialsByTenant(tenantID int64) ([]NodeCredential, error) { + rows, err := s.DB.Query(`SELECT id,node_uuid,node_name,alias,virtual_ip,tenant_id,status,last_seen,created_at FROM nodes WHERE tenant_id=? ORDER BY id DESC`, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]NodeCredential, 0) + for rows.Next() { + var n NodeCredential + var seen sql.NullInt64 + if err := rows.Scan(&n.NodeID, &n.NodeUUID, &n.NodeName, &n.Alias, &n.VirtualIP, &n.TenantID, &n.Status, &seen, &n.CreatedAt); err != nil { + return nil, err + } + if seen.Valid { + v := seen.Int64 + n.LastSeen = &v + } + out = append(out, n) + } + return out, nil +} + +func (s *Store) ListNodeCredentialsAll() ([]NodeCredential, error) { + rows, err := s.DB.Query(`SELECT id,node_uuid,node_name,alias,virtual_ip,tenant_id,status,last_seen,created_at FROM nodes ORDER BY id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]NodeCredential, 0) + for rows.Next() { + var n NodeCredential + var seen sql.NullInt64 + if err := rows.Scan(&n.NodeID, &n.NodeUUID, &n.NodeName, &n.Alias, &n.VirtualIP, &n.TenantID, &n.Status, &seen, &n.CreatedAt); err != nil { + return nil, err + } + if seen.Valid { + v := seen.Int64 + n.LastSeen = &v + } + out = append(out, n) + } + return out, nil +} + func (s *Store) SetNodeAlias(tenantID int64, nodeUUID, alias string) error { alias = strings.TrimSpace(alias) res, err := s.DB.Exec(`UPDATE nodes SET alias=? WHERE tenant_id=? AND node_uuid=?`, alias, tenantID, nodeUUID) @@ -519,9 +556,13 @@ func (s *Store) IncEnrollAttempt(code string) { func (s *Store) ensureSettings() error { defaults := map[string]string{ - "advanced_impersonate": "0", - "advanced_force_network": "0", - "advanced_cross_tenant": "0", + "advanced_impersonate": "0", + "advanced_force_network": "0", + "advanced_cross_tenant": "0", + "advanced_override_enabled": "0", + "allow_apikey_mutate_sdwan": "0", + "allow_operator_kick_cross_node": "0", + "allow_connect_cross_tenant": "0", } now := time.Now().Unix() for k, v := range defaults { @@ -567,14 +608,44 @@ func (s *Store) AddAuditLog(actorType, actorID, action, targetType, targetID, de return err } -func (s *Store) ListAuditLogs(tenantID int64, limit, offset int) ([]AuditLog, error) { - q := `SELECT id,actor_type,actor_id,action,target_type,target_id,detail,ip,created_at FROM audit_logs` +func (s *Store) ListAuditLogs(tenantID int64, limit, offset int, action, actorType, actorID, targetType, targetID string, fromTs, toTs int64) ([]AuditLog, error) { + q := `SELECT id,actor_type,actor_id,action,target_type,target_id,detail,ip,created_at FROM audit_logs WHERE 1=1` args := []any{} + if tenantID > 0 { // limit to logs related to this tenant - q += ` WHERE (target_type='tenant' AND target_id=?)` + q += ` AND (target_type='tenant' AND target_id=?)` args = append(args, fmt.Sprintf("%d", tenantID)) } + if action != "" { + q += ` AND action=?` + args = append(args, action) + } + if actorType != "" { + q += ` AND actor_type=?` + args = append(args, actorType) + } + if actorID != "" { + q += ` AND actor_id=?` + args = append(args, actorID) + } + if targetType != "" { + q += ` AND target_type=?` + args = append(args, targetType) + } + if targetID != "" { + q += ` AND target_id=?` + args = append(args, targetID) + } + if fromTs > 0 { + q += ` AND created_at>=?` + args = append(args, fromTs) + } + if toTs > 0 { + q += ` AND created_at<=?` + args = append(args, toTs) + } + q += ` ORDER BY id DESC` if limit > 0 { q += ` LIMIT ?` @@ -621,6 +692,75 @@ func (s *Store) UpdateTenantStatus(id int64, status int) error { return err } +// ReassignTenantSubnet reassigns tenant subnet with conflict checks. +// Allowed range (scheme-1): 10.200.0.0/16, /24 only. +func (s *Store) ReassignTenantSubnet(tenantID int64, newSubnet string) (string, error) { + newSubnet = strings.TrimSpace(newSubnet) + if newSubnet == "" { + return "", errors.New("subnet required") + } + ip, ipnet, err := net.ParseCIDR(newSubnet) + if err != nil || ipnet == nil { + return "", errors.New("invalid subnet") + } + ones, bits := ipnet.Mask.Size() + if bits != 32 || ones != 24 { + return "", errors.New("only /24 ipv4 subnet supported") + } + if ip4 := ip.To4(); ip4 == nil || ip4[0] != 10 || ip4[1] != 200 { + return "", errors.New("subnet must be in 10.200.0.0/16") + } + if ip.String() != ipnet.IP.String() { + return "", errors.New("subnet must be network address, e.g. 10.200.x.0/24") + } + + tx, err := s.DB.Begin() + if err != nil { + return "", err + } + defer func() { _ = tx.Rollback() }() + + var oldSubnet string + row := tx.QueryRow(`SELECT subnet FROM tenants WHERE id=?`, tenantID) + if err := row.Scan(&oldSubnet); err != nil { + return "", err + } + if oldSubnet == newSubnet { + if err := tx.Commit(); err != nil { + return "", err + } + return oldSubnet, nil + } + + // conflict check: target subnet must not belong to another tenant + var occupiedBy sql.NullInt64 + if err := tx.QueryRow(`SELECT id FROM tenants WHERE subnet=?`, newSubnet).Scan(&occupiedBy); err == nil { + if occupiedBy.Valid && occupiedBy.Int64 != tenantID { + return "", errors.New("subnet already occupied") + } + } + + now := time.Now().Unix() + // release old pool mark + _, _ = tx.Exec(`UPDATE subnet_pool SET status=0, tenant_id=NULL, updated_at=? WHERE subnet=? AND tenant_id=?`, now, oldSubnet, tenantID) + // assign tenant + if _, err := tx.Exec(`UPDATE tenants SET subnet=? WHERE id=?`, newSubnet, tenantID); err != nil { + return "", err + } + // ensure pool row exists and mark occupied + if _, err := tx.Exec(`INSERT OR IGNORE INTO subnet_pool(subnet,status,reserved,tenant_id,updated_at) VALUES(?,?,?,?,?)`, newSubnet, 1, 0, tenantID, now); err != nil { + return "", err + } + if _, err := tx.Exec(`UPDATE subnet_pool SET status=1, tenant_id=?, updated_at=? WHERE subnet=?`, tenantID, now, newSubnet); err != nil { + return "", err + } + + if err := tx.Commit(); err != nil { + return "", err + } + return oldSubnet, nil +} + func (s *Store) DeleteTenant(id int64) error { _, err := s.DB.Exec(`DELETE FROM tenants WHERE id=?`, id) return err @@ -680,6 +820,21 @@ func (s *Store) ListEnrollTokens(tenantID int64) ([]EnrollToken, error) { return out, nil } +// GetEnrollTokenByID returns a single enroll token (used for ownership checks) +func (s *Store) GetEnrollTokenByID(id int64) (*EnrollToken, error) { + row := s.DB.QueryRow(`SELECT id,tenant_id,token_hash,expires_at,used_at,max_attempt,attempts,status,created_at FROM enroll_tokens WHERE id=?`, id) + var et EnrollToken + var used sql.NullInt64 + if err := row.Scan(&et.ID, &et.TenantID, &et.Hash, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status, &et.CreatedAt); err != nil { + return nil, err + } + if used.Valid { + v := used.Int64 + et.UsedAt = &v + } + return &et, nil +} + func (s *Store) UpdateEnrollStatus(id int64, status int) error { _, err := s.DB.Exec(`UPDATE enroll_tokens SET status=? WHERE id=?`, status, id) return err