// Package audit defines the shared business-audit contract used by Portal // microservices. package audit import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "regexp" "strings" "time" ) const ( MaxTokenLength = 80 MaxDetailsBytes = 16 * 1024 ) var ( tokenRe = regexp.MustCompile(`^[a-z][a-z0-9_.-]{1,79}$`) secretKeyRe = regexp.MustCompile(`(?i)token|secret|password|api_?key|bearer|authorization|webhook_url|bitrix`) ) // Event is the stable wire contract for business-audit events. type Event struct { Action string `json:"action"` EntityType string `json:"entity_type"` EntityID string `json:"entity_id,omitempty"` UserID string `json:"user_id,omitempty"` UserName string `json:"user_name,omitempty"` Details map[string]any `json:"details,omitempty"` IPAddress string `json:"ip_address,omitempty"` } // Normalize trims fields and redacts sensitive values in Details. func (e Event) Normalize() Event { e.Action = strings.TrimSpace(e.Action) e.EntityType = strings.TrimSpace(e.EntityType) e.EntityID = truncateRunes(strings.TrimSpace(e.EntityID), 255) e.UserID = strings.TrimSpace(e.UserID) e.UserName = strings.TrimSpace(e.UserName) e.IPAddress = strings.TrimSpace(e.IPAddress) if e.Details != nil { if redacted, ok := RedactSecrets(e.Details).(map[string]any); ok { e.Details = redacted } } return e } // Validate checks the event shape before it is sent to Portal. func (e Event) Validate() error { e = e.Normalize() if !ValidToken(e.Action) { return fmt.Errorf("invalid audit action %q", e.Action) } if !ValidToken(e.EntityType) { return fmt.Errorf("invalid audit entity_type %q", e.EntityType) } if e.Details != nil && detailsTooLarge(e.Details) { return fmt.Errorf("audit details exceed %d bytes", MaxDetailsBytes) } return nil } func ValidToken(value string) bool { value = strings.TrimSpace(value) return len(value) <= MaxTokenLength && tokenRe.MatchString(value) } // RedactSecrets recursively replaces values for sensitive JSON keys. func RedactSecrets(v any) any { switch x := v.(type) { case map[string]any: out := make(map[string]any, len(x)) for k, val := range x { if secretKeyRe.MatchString(k) { out[k] = "***" } else { out[k] = RedactSecrets(val) } } return out case []any: out := make([]any, len(x)) for i, val := range x { out[i] = RedactSecrets(val) } return out default: return v } } type Client struct { baseURL string apiKey string hc *http.Client } func NewClient(baseURL, apiKey string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, hc: &http.Client{Timeout: 5 * time.Second}, } } func (c *Client) Enabled() bool { return c != nil && c.baseURL != "" && c.apiKey != "" } func (c *Client) Send(ctx context.Context, event Event) error { if !c.Enabled() { return errors.New("audit client disabled") } event = event.Normalize() if err := event.Validate(); err != nil { return err } body, err := json.Marshal(event) if err != nil { return fmt.Errorf("marshal audit event: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/internal/audit/events", bytes.NewReader(body)) if err != nil { return fmt.Errorf("new audit request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Internal-Key", c.apiKey) resp, err := c.hc.Do(req) if err != nil { return fmt.Errorf("send audit event: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return fmt.Errorf("portal audit returned %d: %s", resp.StatusCode, string(respBody)) } return nil } func detailsTooLarge(value any) bool { raw, err := json.Marshal(value) return err != nil || len(raw) > MaxDetailsBytes } func truncateRunes(value string, max int) string { runes := []rune(value) if len(runes) <= max { return value } return string(runes[:max]) }