fix: enroll revoke cross-tenant isolation (check token ownership before status update)

This commit is contained in:
2026-09-13 08:41:09 +08:00
parent 90ada35d86
commit 84c709bbcc
2 changed files with 252 additions and 42 deletions
+59 -4
View File
@@ -50,13 +50,39 @@ 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)
_, _ = 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
}
st := r.URL.Query().Get("status")
if id > 0 && st != "" {
if st != "" {
status := 0
if st == "1" {
status = 1
@@ -70,6 +96,7 @@ func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request)
}
}
}
}
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
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
+180 -25
View File
@@ -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 {
// 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 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
}
defer rows.Close()
var count int
if rows.Next() {
_ = rows.Scan(&count)
}
if count > 0 {
return nil
}
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)
}
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)
@@ -522,6 +559,10 @@ func (s *Store) ensureSettings() error {
"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