diff --git a/README.md b/README.md index cff95b5..6c5dcee 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Shared Go-библиотека для микросервисов портала. - `db/` — pgxpool init + slow-query tracer. Заменяет идентичный код в 9 сервисах. - `middleware/` — `InternalAuth` (X-Internal-Key + X-User-*) + хелперы для кастомных заголовков. - `portal/` — HTTP-клиент portal-сервиса: directory с кэшем+stale fallback, notifications, deactivate user. +- `audit/` — общий контракт business-audit событий и клиент отправки в Portal. ## Использование @@ -14,6 +15,7 @@ Shared Go-библиотека для микросервисов портала. ```go import ( + "gitea.estateliga.work/admin/portal-common/audit" "gitea.estateliga.work/admin/portal-common/db" "gitea.estateliga.work/admin/portal-common/middleware" "gitea.estateliga.work/admin/portal-common/portal" @@ -25,8 +27,31 @@ r := chi.NewRouter() r.Use(middleware.InternalAuth(cfg.InternalAPIKey)) portalCli := portal.New(cfg.PortalBaseURL, cfg.PortalAPIKey) +auditCli := audit.NewClient(cfg.PortalBaseURL, cfg.PortalAPIKey) +_ = auditCli.Send(ctx, audit.Event{ + Action: "tasks.task_create", + EntityType: "task", + EntityID: taskID, + UserID: actorID, + UserName: actorName, + Details: map[string]any{ + "request_id": requestID, + }, +}) ``` +## Business audit vocabulary + +События именуются в формате `._`: + +- `service`: короткий код сервиса (`tasks`, `files`, `tg`, `pf`, `telephony`, `portal`). +- `entity`: бизнес-сущность (`task`, `node`, `channel`, `project`, `call`). +- `verb`: действие в прошедшем бизнес-смысле или команда (`create`, `update`, `delete`, `retry`, `share`, `move`). + +В `Details` можно класть диагностический контекст (`request_id`, `scope`, +`status`, `error_code`), но нельзя класть токены, пароли и API-ключи. Пакет +`audit` дополнительно вырезает чувствительные ключи перед отправкой. + ## Dev-режим (без push в Gitea) В `go.mod` сервиса добавить `replace`: diff --git a/audit/audit.go b/audit/audit.go new file mode 100644 index 0000000..67ab74a --- /dev/null +++ b/audit/audit.go @@ -0,0 +1,158 @@ +// 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]) +} diff --git a/audit/audit_test.go b/audit/audit_test.go new file mode 100644 index 0000000..19cbaf3 --- /dev/null +++ b/audit/audit_test.go @@ -0,0 +1,85 @@ +package audit + +import "testing" + +func TestEventNormalizeRedactsSecretsAndTruncatesEntityID(t *testing.T) { + event := Event{ + Action: " files.share_create ", + EntityType: " file_node ", + EntityID: longString("x", 300), + Details: map[string]any{ + "request_id": "rid-1", + "password": "plain", + "nested": map[string]any{ + "api_key": "secret", + }, + "items": []any{ + map[string]any{"token": "secret"}, + }, + }, + } + + got := event.Normalize() + if got.Action != "files.share_create" { + t.Fatalf("action was not trimmed: %q", got.Action) + } + if got.EntityType != "file_node" { + t.Fatalf("entity type was not trimmed: %q", got.EntityType) + } + if len([]rune(got.EntityID)) != 255 { + t.Fatalf("entity id was not truncated, got %d", len([]rune(got.EntityID))) + } + if got.Details["password"] != "***" { + t.Fatalf("password was not redacted: %#v", got.Details["password"]) + } + nested := got.Details["nested"].(map[string]any) + if nested["api_key"] != "***" { + t.Fatalf("nested api_key was not redacted: %#v", nested["api_key"]) + } + items := got.Details["items"].([]any) + if items[0].(map[string]any)["token"] != "***" { + t.Fatalf("array token was not redacted: %#v", items[0]) + } +} + +func TestEventValidate(t *testing.T) { + valid := Event{Action: "tasks.task_create", EntityType: "task"} + if err := valid.Validate(); err != nil { + t.Fatalf("valid event rejected: %v", err) + } + + invalidAction := Event{Action: "Task Created", EntityType: "task"} + if err := invalidAction.Validate(); err == nil { + t.Fatal("invalid action was accepted") + } + + invalidEntity := Event{Action: "tasks.task_create", EntityType: "1task"} + if err := invalidEntity.Validate(); err == nil { + t.Fatal("invalid entity type was accepted") + } +} + +func TestValidToken(t *testing.T) { + tests := map[string]bool{ + "files.node_move": true, + "ai.job-retry": true, + "a1": true, + "A1": false, + "a": false, + "files node": false, + longString("a", MaxTokenLength+1): false, + } + for value, want := range tests { + if got := ValidToken(value); got != want { + t.Fatalf("ValidToken(%q) = %v, want %v", value, got, want) + } + } +} + +func longString(ch string, n int) string { + out := "" + for range n { + out += ch + } + return out +}