159 lines
4.0 KiB
Go
159 lines
4.0 KiB
Go
// 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])
|
|
}
|