Перейти к содержанию

Admin Approval Process Architecture

Обзор

Comprehensive документация процесса admin approval в Saga DeFi Platform - архитектура, security модель, и best practices для administrative operations.

Admin Approval Scope

Операции требующие admin approval:

  1. Withdrawal Requests - вывод средств пользователей
  2. Large Investment Modifications - изменения больших инвестиций
  3. User Account Actions - блокировка, разблокировка, удаление
  4. System Configuration Changes - критические настройки

Операции НЕ требующие approval:

  • ✅ Faucet requests (автоматические с rate limiting)
  • ✅ Стандартные investments (в пределах лимитов)
  • ✅ Баланс queries (read-only операции)
  • ✅ User registration (с Web3 validation)

Admin Authentication Architecture

JWT-Based Admin Authentication

AdminClaims Structure:

// backend/auth/service/token_manager.go
type AdminClaims struct {
    jwt.StandardClaims
    Email       string   `json:"email"`
    Permissions []string `json:"permissions"`
    Roles       []string `json:"roles"`
}

Admin Token Generation:

# Генерация admin токена
make jwt-admin EMAIL=admin@saga-test.com

# Output:
# Admin JWT Token (valid for 24h):
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Admin Configuration:

# config/auth.yaml
auth:
  admin_emails:
    - admin@saga-test.com
    - superadmin@saga.surf
  jwt:
    secret: "${JWT_SECRET}"
    expiration_hours: 24
    issuer: "saga-platform"

Admin Authorization Middleware

// backend/shared/http/admin_auth_middleware.go
func AdminAuthMiddleware(cfg *config.UnifiedConfig) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // 1. Extract JWT token from Authorization header
            authHeader := r.Header.Get("Authorization")
            if authHeader == "" {
                http.Error(w, "Требуется авторизация", http.StatusUnauthorized)
                return
            }

            tokenString := strings.TrimPrefix(authHeader, "Bearer ")

            // 2. Validate admin token
            validator := jwt_validator.NewJWTValidator(cfg)
            claims, err := validator.ValidateAdminToken(tokenString)
            if err != nil {
                http.Error(w, "Недействительный токен", http.StatusUnauthorized)
                return
            }

            // 3. Check admin email in config
            if !cfg.IsAdminEmail(claims.Email) {
                http.Error(w, "Недостаточно прав", http.StatusForbidden)
                return
            }

            // 4. Add admin email to request context
            ctx := context.WithValue(r.Context(), "admin_email", claims.Email)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

Withdrawal Approval Flow

1. Pending Withdrawals List

API Endpoint: GET /api/admin/withdrawals/pending

Backend Handler:

// backend/shared/routing/admin_handler.go
func (h *AdminHandler) GetPendingWithdrawals(w http.ResponseWriter, r *http.Request) {
    // Admin middleware уже проверил права

    withdrawals, err := h.service.GetPendingWithdrawals(ctx)
    if err != nil {
        http_helpers.SendError(w, err.Error(), http.StatusInternalServerError)
        return
    }

    // Возврат списка pending withdrawals
    http_helpers.SendSuccess(w, withdrawals)
}

Service Implementation:

// backend/shared/services/admin_service.go
func (s *AdminService) GetPendingWithdrawals(ctx context.Context) ([]*Withdrawal, error) {
    // Получение всех withdrawals со статусом 'pending'
    withdrawals, err := s.repo.GetWithdrawalsByStatus(ctx, "pending")
    if err != nil {
        return nil, err
    }

    // Сортировка по дате создания (старые первыми)
    sort.Slice(withdrawals, func(i, j int) bool {
        return withdrawals[i].CreatedAt.Before(withdrawals[j].CreatedAt)
    })

    return withdrawals, nil
}

Admin UI Display:

// frontend/admin-app/src/components/WithdrawalApproval.tsx
const PendingWithdrawals = () => {
  const [withdrawals, setWithdrawals] = useState<Withdrawal[]>([]);

  useEffect(() => {
    fetch('/api/admin/withdrawals/pending', {
      headers: {
        'Authorization': `Bearer ${adminToken}`
      }
    })
    .then(res => res.json())
    .then(data => setWithdrawals(data));
  }, []);

  return (
    <div className="pending-withdrawals">
      <h2>Pending Withdrawal Requests</h2>
      {withdrawals.map(w => (
        <WithdrawalCard
          key={w.id}
          withdrawal={w}
          onApprove={() => approveWithdrawal(w.id)}
          onReject={() => rejectWithdrawal(w.id)}
        />
      ))}
    </div>
  );
};

2. Approval Action

API Endpoint: POST /api/admin/withdrawals/{id}/approve

Frontend Request:

const approveWithdrawal = async (withdrawalId: number) => {
  const response = await fetch(`/api/admin/withdrawals/${withdrawalId}/approve`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${adminToken}`,
      'Content-Type': 'application/json'
    }
  });

  if (response.ok) {
    toast.success('Withdrawal approved successfully');
    // Refresh list
    fetchPendingWithdrawals();
  } else {
    toast.error('Failed to approve withdrawal');
  }
};

Backend Handler:

func (h *AdminHandler) ApproveWithdrawal(w http.ResponseWriter, r *http.Request) {
    // 1. Extract withdrawal ID from URL
    withdrawalID := chi.URLParam(r, "id")

    // 2. Get admin email from context (added by middleware)
    adminEmail := r.Context().Value("admin_email").(string)

    // 3. Call service to approve
    withdrawal, err := h.service.ApproveWithdrawal(ctx, withdrawalID, adminEmail)
    if err != nil {
        http_helpers.SendError(w, err.Error(), http.StatusInternalServerError)
        return
    }

    // 4. Log admin action
    logger.InfoStructured("Admin approved withdrawal",
        "admin_email", adminEmail,
        "withdrawal_id", withdrawalID,
        "amount", withdrawal.Amount.String(),
        "user_id", withdrawal.UserID,
    )

    http_helpers.SendSuccess(w, withdrawal)
}

Service Approval Logic:

func (s *AdminService) ApproveWithdrawal(ctx context.Context, withdrawalID string, adminEmail string) (*Withdrawal, error) {
    // 1. Load withdrawal from database
    withdrawal, err := s.repo.GetWithdrawalByID(ctx, withdrawalID)
    if err != nil {
        return nil, errors.New("withdrawal not found")
    }

    // 2. Validate current state
    if withdrawal.Status != "pending" {
        return nil, errors.New("only pending withdrawals can be approved")
    }

    // 3. Update withdrawal status
    withdrawal.Status = "approved"
    withdrawal.ApprovedBy = adminEmail
    withdrawal.ApprovedAt = time.Now().UTC()

    // 4. Save to database (atomic transaction)
    err = s.repo.UpdateWithdrawal(ctx, withdrawal)
    if err != nil {
        return nil, err
    }

    // 5. Queue for blockchain execution
    go s.executionService.QueueWithdrawal(withdrawal)

    // 6. Audit log
    s.auditLog.LogAdminAction(ctx, AuditLogEntry{
        AdminEmail:  adminEmail,
        Action:      "approve_withdrawal",
        ResourceID:  withdrawalID,
        ResourceType: "withdrawal",
        Details:     fmt.Sprintf("Amount: %s, User: %d", withdrawal.Amount.String(), withdrawal.UserID),
        Timestamp:   time.Now().UTC(),
    })

    return withdrawal, nil
}

3. Rejection Flow

API Endpoint: POST /api/admin/withdrawals/{id}/reject

Rejection with Reason:

type RejectWithdrawalRequest struct {
    Reason string `json:"reason" validate:"required,min=10"`
}

func (h *AdminHandler) RejectWithdrawal(w http.ResponseWriter, r *http.Request) {
    withdrawalID := chi.URLParam(r, "id")
    adminEmail := r.Context().Value("admin_email").(string)

    var req RejectWithdrawalRequest
    json.NewDecoder(r.Body).Decode(&req)

    // Валидация причины отклонения
    if len(req.Reason) < 10 {
        http_helpers.SendError(w, "Reason must be at least 10 characters", http.StatusBadRequest)
        return
    }

    withdrawal, err := h.service.RejectWithdrawal(ctx, withdrawalID, adminEmail, req.Reason)
    if err != nil {
        http_helpers.SendError(w, err.Error(), http.StatusInternalServerError)
        return
    }

    logger.InfoStructured("Admin rejected withdrawal",
        "admin_email", adminEmail,
        "withdrawal_id", withdrawalID,
        "reason", req.Reason,
    )

    http_helpers.SendSuccess(w, withdrawal)
}

Audit Trail System

Audit Log Structure

Database Schema:

CREATE TABLE admin_audit_log (
    id BIGSERIAL PRIMARY KEY,
    admin_email VARCHAR(255) NOT NULL,
    action VARCHAR(50) NOT NULL, -- 'approve_withdrawal', 'reject_withdrawal', etc.
    resource_type VARCHAR(50) NOT NULL, -- 'withdrawal', 'user', 'investment'
    resource_id VARCHAR(100) NOT NULL,
    details TEXT,
    ip_address VARCHAR(45),
    user_agent TEXT,
    created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Индексы для быстрого поиска
CREATE INDEX idx_audit_admin_email ON admin_audit_log(admin_email);
CREATE INDEX idx_audit_action ON admin_audit_log(action);
CREATE INDEX idx_audit_created_at ON admin_audit_log(created_at DESC);

Audit Log Implementation

// backend/shared/services/audit_log_service.go
type AuditLogEntry struct {
    AdminEmail   string
    Action       string
    ResourceType string
    ResourceID   string
    Details      string
    IPAddress    string
    UserAgent    string
    Timestamp    time.Time
}

func (s *AuditLogService) LogAdminAction(ctx context.Context, entry AuditLogEntry) error {
    query := `
        INSERT INTO admin_audit_log (
            admin_email, action, resource_type, resource_id,
            details, ip_address, user_agent, created_at
        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
    `

    _, err := s.db.ExecContext(ctx, query,
        entry.AdminEmail,
        entry.Action,
        entry.ResourceType,
        entry.ResourceID,
        entry.Details,
        entry.IPAddress,
        entry.UserAgent,
        entry.Timestamp,
    )

    if err != nil {
        logger.ErrorStructured("Failed to log admin action",
            "error", err.Error(),
            "admin_email", entry.AdminEmail,
            "action", entry.Action,
        )
        return err
    }

    return nil
}

Audit Log Query

API для просмотра audit logs:

// GET /api/admin/audit-log
func (h *AdminHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
    // Query parameters для фильтрации
    adminEmail := r.URL.Query().Get("admin_email")
    action := r.URL.Query().Get("action")
    startDate := r.URL.Query().Get("start_date")
    endDate := r.URL.Query().Get("end_date")

    filters := AuditLogFilters{
        AdminEmail: adminEmail,
        Action:     action,
        StartDate:  parseDate(startDate),
        EndDate:    parseDate(endDate),
    }

    logs, err := h.service.GetAuditLog(ctx, filters)
    if err != nil {
        http_helpers.SendError(w, err.Error(), http.StatusInternalServerError)
        return
    }

    http_helpers.SendSuccess(w, logs)
}

Security Best Practices

1. Rate Limiting for Admin Actions

// Prevent admin action spam
var adminActionLimiter = httprate.LimitByIP(
    10,                 // 10 actions
    1*time.Minute,     // per minute
)

router.Group(func(r chi.Router) {
    r.Use(AdminAuthMiddleware(cfg))
    r.Use(adminActionLimiter)

    r.Post("/api/admin/withdrawals/{id}/approve", h.ApproveWithdrawal)
    r.Post("/api/admin/withdrawals/{id}/reject", h.RejectWithdrawal)
})

2. Two-Factor Confirmation (Optional)

Для критических операций:

type ApprovalConfirmation struct {
    WithdrawalID string `json:"withdrawal_id"`
    ConfirmCode  string `json:"confirm_code"` // Отправлен на email админа
}

func (h *AdminHandler) ConfirmApproval(w http.ResponseWriter, r *http.Request) {
    var req ApprovalConfirmation
    json.NewDecoder(r.Body).Decode(&req)

    // Verify confirmation code
    valid := s.verificationService.VerifyCode(req.ConfirmCode, adminEmail)
    if !valid {
        http_helpers.SendError(w, "Invalid confirmation code", http.StatusForbidden)
        return
    }

    // Proceed with approval
    withdrawal, err := h.service.ApproveWithdrawal(ctx, req.WithdrawalID, adminEmail)
    // ...
}

3. IP Whitelist (Production)

# config/network.yaml (production)
network:
  admin_ip_whitelist:
    - "203.0.113.0/24"  # Office network
    - "198.51.100.5"    # VPN server
func IPWhitelistMiddleware(cfg *config.UnifiedConfig) func(http.Handler) http.Handler {
    allowedIPs := cfg.GetAdminIPWhitelist()

    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            clientIP := r.RemoteAddr

            if !isIPAllowed(clientIP, allowedIPs) {
                logger.WarnStructured("Admin access from unauthorized IP",
                    "ip", clientIP,
                    "path", r.URL.Path,
                )
                http.Error(w, "Access denied", http.StatusForbidden)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

Admin Dashboard Metrics

Pending Approvals Widget

// Real-time pending count
const PendingApprovalsWidget = () => {
  const { data } = useQuery('pending-count', async () => {
    const res = await fetch('/api/admin/stats/pending-approvals', {
      headers: { 'Authorization': `Bearer ${adminToken}` }
    });
    return res.json();
  }, {
    refetchInterval: 10000 // Refresh every 10 seconds
  });

  return (
    <div className="widget pending-approvals">
      <h3>Pending Approvals</h3>
      <div className="count">{data?.pending_count || 0}</div>
      <div className="breakdown">
        <div>Withdrawals: {data?.pending_withdrawals || 0}</div>
        <div>Investments: {data?.pending_investments || 0}</div>
      </div>
    </div>
  );
};

Admin Activity Log

// Recent admin actions display
const AdminActivityLog = () => {
  const { data } = useQuery('admin-activity', async () => {
    const res = await fetch('/api/admin/audit-log?limit=20', {
      headers: { 'Authorization': `Bearer ${adminToken}` }
    });
    return res.json();
  });

  return (
    <div className="admin-activity-log">
      <h3>Recent Admin Actions</h3>
      <table>
        <thead>
          <tr>
            <th>Timestamp</th>
            <th>Admin</th>
            <th>Action</th>
            <th>Resource</th>
            <th>Details</th>
          </tr>
        </thead>
        <tbody>
          {data?.map(log => (
            <tr key={log.id}>
              <td>{formatTimestamp(log.created_at)}</td>
              <td>{log.admin_email}</td>
              <td>{log.action}</td>
              <td>{log.resource_type}:{log.resource_id}</td>
              <td>{log.details}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

Testing Admin Approval Flow

Integration Test

// backend/tests/integration/admin_approval_test.go
func TestAdminApprovalFlow(t *testing.T) {
    server := setupTestServer()
    defer server.Close()

    // 1. User creates withdrawal (pending)
    userToken := generateUserToken("user@test.com")
    withdrawalReq := WithdrawalRequest{
        Amount:        SafeDecimalFromString("100.00"),
        WalletAddress: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
    }

    resp := makeAuthRequest(server, "POST", "/api/withdrawals", withdrawalReq, userToken)
    var withdrawal Withdrawal
    json.Unmarshal(resp.Body, &withdrawal)
    assert.Equal(t, "pending", withdrawal.Status)

    // 2. Admin lists pending withdrawals
    adminToken := generateAdminToken("admin@saga-test.com")
    listResp := makeAuthRequest(server, "GET", "/api/admin/withdrawals/pending", nil, adminToken)

    var pending []Withdrawal
    json.Unmarshal(listResp.Body, &pending)
    assert.GreaterOrEqual(t, len(pending), 1)

    // 3. Admin approves withdrawal
    approveResp := makeAuthRequest(server, "POST",
        fmt.Sprintf("/api/admin/withdrawals/%d/approve", withdrawal.ID),
        nil, adminToken)
    assert.Equal(t, 200, approveResp.StatusCode)

    // 4. Verify audit log entry
    auditResp := makeAuthRequest(server, "GET",
        fmt.Sprintf("/api/admin/audit-log?resource_id=%d", withdrawal.ID),
        nil, adminToken)

    var auditLogs []AuditLogEntry
    json.Unmarshal(auditResp.Body, &auditLogs)
    assert.Equal(t, 1, len(auditLogs))
    assert.Equal(t, "approve_withdrawal", auditLogs[0].Action)
}

API Endpoints Summary

Admin Approval Endpoints:

  • GET /api/admin/withdrawals/pending - List pending withdrawals
  • POST /api/admin/withdrawals/{id}/approve - Approve withdrawal
  • POST /api/admin/withdrawals/{id}/reject - Reject withdrawal
  • GET /api/admin/audit-log - View admin action history
  • GET /api/admin/stats/pending-approvals - Pending approval counts

Authentication:

  • All endpoints require Authorization: Bearer <admin_jwt_token>
  • Admin email must be in config/auth.yaml admin_emails list



📋 Метаданные

Версия: 2.4.82

Обновлено: 2025-10-21

Статус: Published