fix: enroll revoke cross-tenant isolation (check token ownership before status update)
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user