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
+184 -29
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 {
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