mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-04-25 04:16:22 +02:00
282 lines
6.7 KiB
Go
282 lines
6.7 KiB
Go
// Copyright 2026 Syntrex Lab. All rights reserved.
|
||
// Use of this source code is governed by an Apache-2.0 license
|
||
// that can be found in the LICENSE file.
|
||
|
||
package shadow_ai
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// --- Tiered Approval Workflow ---
|
||
// Implements §6 of the ТЗ: data classification → approval tier → SLA tracking.
|
||
|
||
// ApprovalStatus tracks the state of an approval request.
|
||
type ApprovalStatus string
|
||
|
||
const (
|
||
ApprovalPending ApprovalStatus = "pending"
|
||
ApprovalApproved ApprovalStatus = "approved"
|
||
ApprovalDenied ApprovalStatus = "denied"
|
||
ApprovalExpired ApprovalStatus = "expired"
|
||
ApprovalAutoApproved ApprovalStatus = "auto_approved"
|
||
)
|
||
|
||
// DefaultApprovalTiers defines the approval requirements per data classification.
|
||
func DefaultApprovalTiers() []ApprovalTier {
|
||
return []ApprovalTier{
|
||
{
|
||
Name: "Tier 1: Public Data",
|
||
DataClass: DataPublic,
|
||
ApprovalNeeded: nil, // Auto-approve
|
||
SLA: 0,
|
||
AutoApprove: true,
|
||
},
|
||
{
|
||
Name: "Tier 2: Internal Data",
|
||
DataClass: DataInternal,
|
||
ApprovalNeeded: []string{"manager"},
|
||
SLA: 4 * time.Hour,
|
||
AutoApprove: false,
|
||
},
|
||
{
|
||
Name: "Tier 3: Confidential Data",
|
||
DataClass: DataConfidential,
|
||
ApprovalNeeded: []string{"manager", "soc"},
|
||
SLA: 24 * time.Hour,
|
||
AutoApprove: false,
|
||
},
|
||
{
|
||
Name: "Tier 4: Critical Data",
|
||
DataClass: DataCritical,
|
||
ApprovalNeeded: []string{"ciso"},
|
||
SLA: 0, // Manual only, no auto-expire
|
||
AutoApprove: false,
|
||
},
|
||
}
|
||
}
|
||
|
||
// ApprovalEngine manages the tiered approval workflow.
|
||
type ApprovalEngine struct {
|
||
mu sync.RWMutex
|
||
tiers []ApprovalTier
|
||
requests map[string]*ApprovalRequest
|
||
logger *slog.Logger
|
||
}
|
||
|
||
// NewApprovalEngine creates an engine with default tiers.
|
||
func NewApprovalEngine() *ApprovalEngine {
|
||
return &ApprovalEngine{
|
||
tiers: DefaultApprovalTiers(),
|
||
requests: make(map[string]*ApprovalRequest),
|
||
logger: slog.Default().With("component", "shadow-ai-approvals"),
|
||
}
|
||
}
|
||
|
||
// SubmitRequest creates a new approval request based on data classification.
|
||
// Returns the request or auto-approves if the tier allows it.
|
||
func (ae *ApprovalEngine) SubmitRequest(userID, docID string, dataClass DataClassification) *ApprovalRequest {
|
||
ae.mu.Lock()
|
||
defer ae.mu.Unlock()
|
||
|
||
tier := ae.findTier(dataClass)
|
||
|
||
req := &ApprovalRequest{
|
||
ID: genApprovalID(),
|
||
DocID: docID,
|
||
UserID: userID,
|
||
Tier: tier.Name,
|
||
DataClass: dataClass,
|
||
Status: string(ApprovalPending),
|
||
CreatedAt: time.Now(),
|
||
}
|
||
|
||
// Set expiry based on SLA.
|
||
if tier.SLA > 0 {
|
||
req.ExpiresAt = req.CreatedAt.Add(tier.SLA)
|
||
}
|
||
|
||
// Auto-approve for public data.
|
||
if tier.AutoApprove {
|
||
req.Status = string(ApprovalAutoApproved)
|
||
req.ApprovedBy = "system"
|
||
req.ResolvedAt = time.Now()
|
||
ae.logger.Info("auto-approved",
|
||
"request_id", req.ID,
|
||
"user", userID,
|
||
"data_class", dataClass,
|
||
)
|
||
} else {
|
||
ae.logger.Info("approval required",
|
||
"request_id", req.ID,
|
||
"user", userID,
|
||
"data_class", dataClass,
|
||
"tier", tier.Name,
|
||
"approvers", tier.ApprovalNeeded,
|
||
)
|
||
}
|
||
|
||
ae.requests[req.ID] = req
|
||
return req
|
||
}
|
||
|
||
// Approve approves a pending request.
|
||
func (ae *ApprovalEngine) Approve(requestID, approvedBy string) error {
|
||
ae.mu.Lock()
|
||
defer ae.mu.Unlock()
|
||
|
||
req, ok := ae.requests[requestID]
|
||
if !ok {
|
||
return fmt.Errorf("request %s not found", requestID)
|
||
}
|
||
|
||
if req.Status != string(ApprovalPending) {
|
||
return fmt.Errorf("request %s is not pending (status: %s)", requestID, req.Status)
|
||
}
|
||
|
||
req.Status = string(ApprovalApproved)
|
||
req.ApprovedBy = approvedBy
|
||
req.ResolvedAt = time.Now()
|
||
|
||
ae.logger.Info("approved",
|
||
"request_id", requestID,
|
||
"approved_by", approvedBy,
|
||
)
|
||
return nil
|
||
}
|
||
|
||
// Deny denies a pending request.
|
||
func (ae *ApprovalEngine) Deny(requestID, deniedBy, reason string) error {
|
||
ae.mu.Lock()
|
||
defer ae.mu.Unlock()
|
||
|
||
req, ok := ae.requests[requestID]
|
||
if !ok {
|
||
return fmt.Errorf("request %s not found", requestID)
|
||
}
|
||
|
||
if req.Status != string(ApprovalPending) {
|
||
return fmt.Errorf("request %s is not pending (status: %s)", requestID, req.Status)
|
||
}
|
||
|
||
req.Status = string(ApprovalDenied)
|
||
req.DeniedBy = deniedBy
|
||
req.Reason = reason
|
||
req.ResolvedAt = time.Now()
|
||
|
||
ae.logger.Info("denied",
|
||
"request_id", requestID,
|
||
"denied_by", deniedBy,
|
||
"reason", reason,
|
||
)
|
||
return nil
|
||
}
|
||
|
||
// GetRequest returns an approval request by ID.
|
||
func (ae *ApprovalEngine) GetRequest(requestID string) (*ApprovalRequest, bool) {
|
||
ae.mu.RLock()
|
||
defer ae.mu.RUnlock()
|
||
req, ok := ae.requests[requestID]
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
cp := *req
|
||
return &cp, true
|
||
}
|
||
|
||
// PendingRequests returns all pending approval requests.
|
||
func (ae *ApprovalEngine) PendingRequests() []ApprovalRequest {
|
||
ae.mu.RLock()
|
||
defer ae.mu.RUnlock()
|
||
|
||
var result []ApprovalRequest
|
||
for _, req := range ae.requests {
|
||
if req.Status == string(ApprovalPending) {
|
||
result = append(result, *req)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// ExpireOverdue marks overdue pending requests as expired.
|
||
// Returns the number of expired requests.
|
||
func (ae *ApprovalEngine) ExpireOverdue() int {
|
||
ae.mu.Lock()
|
||
defer ae.mu.Unlock()
|
||
|
||
now := time.Now()
|
||
expired := 0
|
||
|
||
for _, req := range ae.requests {
|
||
if req.Status == string(ApprovalPending) && !req.ExpiresAt.IsZero() && now.After(req.ExpiresAt) {
|
||
req.Status = string(ApprovalExpired)
|
||
req.ResolvedAt = now
|
||
expired++
|
||
ae.logger.Warn("request expired",
|
||
"request_id", req.ID,
|
||
"user", req.UserID,
|
||
"expired_at", req.ExpiresAt,
|
||
)
|
||
}
|
||
}
|
||
return expired
|
||
}
|
||
|
||
// Stats returns approval workflow statistics.
|
||
func (ae *ApprovalEngine) Stats() map[string]int {
|
||
ae.mu.RLock()
|
||
defer ae.mu.RUnlock()
|
||
|
||
stats := map[string]int{
|
||
"total": len(ae.requests),
|
||
"pending": 0,
|
||
"approved": 0,
|
||
"denied": 0,
|
||
"expired": 0,
|
||
"auto_approved": 0,
|
||
}
|
||
for _, req := range ae.requests {
|
||
switch ApprovalStatus(req.Status) {
|
||
case ApprovalPending:
|
||
stats["pending"]++
|
||
case ApprovalApproved:
|
||
stats["approved"]++
|
||
case ApprovalDenied:
|
||
stats["denied"]++
|
||
case ApprovalExpired:
|
||
stats["expired"]++
|
||
case ApprovalAutoApproved:
|
||
stats["auto_approved"]++
|
||
}
|
||
}
|
||
return stats
|
||
}
|
||
|
||
// Tiers returns the approval tier configuration.
|
||
func (ae *ApprovalEngine) Tiers() []ApprovalTier {
|
||
return ae.tiers
|
||
}
|
||
|
||
func (ae *ApprovalEngine) findTier(dataClass DataClassification) ApprovalTier {
|
||
for _, t := range ae.tiers {
|
||
if t.DataClass == dataClass {
|
||
return t
|
||
}
|
||
}
|
||
// Default to most restrictive.
|
||
return ae.tiers[len(ae.tiers)-1]
|
||
}
|
||
|
||
var approvalCounter uint64
|
||
var approvalCounterMu sync.Mutex
|
||
|
||
func genApprovalID() string {
|
||
approvalCounterMu.Lock()
|
||
approvalCounter++
|
||
id := approvalCounter
|
||
approvalCounterMu.Unlock()
|
||
return fmt.Sprintf("apr-%d-%d", time.Now().UnixMilli(), id)
|
||
}
|