Admin Approval Process Architecture¶
Обзор¶
Comprehensive документация процесса admin approval в Saga DeFi Platform - архитектура, security модель, и best practices для administrative operations.
Admin Approval Scope¶
Операции требующие admin approval:¶
- Withdrawal Requests - вывод средств пользователей
- Large Investment Modifications - изменения больших инвестиций
- User Account Actions - блокировка, разблокировка, удаление
- 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)
}
Related Documentation¶
API Endpoints Summary¶
Admin Approval Endpoints:
GET /api/admin/withdrawals/pending- List pending withdrawalsPOST /api/admin/withdrawals/{id}/approve- Approve withdrawalPOST /api/admin/withdrawals/{id}/reject- Reject withdrawalGET /api/admin/audit-log- View admin action historyGET /api/admin/stats/pending-approvals- Pending approval counts
Authentication:
- All endpoints require
Authorization: Bearer <admin_jwt_token> - Admin email must be in
config/auth.yamladmin_emails list
📋 Метаданные¶
Версия: 2.4.82
Обновлено: 2025-10-21
Статус: Published