Compare commits
2 Commits
a1f6966200
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b6a1c56ea | ||
|
|
2ca85077a3 |
37
CHANGELOG.md
Normal file
37
CHANGELOG.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v0.3.0 - 2026-06-17
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- `audit` package with the shared business-audit event contract.
|
||||||
|
- Safe audit details redaction for sensitive keys such as token, secret,
|
||||||
|
password, api_key, authorization and webhook_url.
|
||||||
|
- `audit.Client` for `POST /api/internal/audit/events` in Portal.
|
||||||
|
|
||||||
|
Migration:
|
||||||
|
|
||||||
|
1. Push `portal-common` commit and tag `v0.3.0`.
|
||||||
|
2. In each service, update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get gitea.estateliga.work/admin/portal-common@v0.3.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Use `audit.NewClient(cfg.PortalBaseURL, cfg.PortalAPIKey)` or equivalent
|
||||||
|
Portal URL / internal key config already present in the service.
|
||||||
|
4. Send business events from handlers/workers after the durable operation
|
||||||
|
succeeds.
|
||||||
|
5. Keep Portal proxy audit mappings only as fallback until duplicate events are
|
||||||
|
checked in production.
|
||||||
|
|
||||||
|
## v0.2.0
|
||||||
|
|
||||||
|
Existing shared packages:
|
||||||
|
|
||||||
|
- `db`
|
||||||
|
- `middleware`
|
||||||
|
- `portal`
|
||||||
|
- `redisx`
|
||||||
|
- `eventbus`
|
||||||
40
README.md
40
README.md
@@ -7,6 +7,7 @@ Shared Go-библиотека для микросервисов портала.
|
|||||||
- `db/` — pgxpool init + slow-query tracer. Заменяет идентичный код в 9 сервисах.
|
- `db/` — pgxpool init + slow-query tracer. Заменяет идентичный код в 9 сервисах.
|
||||||
- `middleware/` — `InternalAuth` (X-Internal-Key + X-User-*) + хелперы для кастомных заголовков.
|
- `middleware/` — `InternalAuth` (X-Internal-Key + X-User-*) + хелперы для кастомных заголовков.
|
||||||
- `portal/` — HTTP-клиент portal-сервиса: directory с кэшем+stale fallback, notifications, deactivate user.
|
- `portal/` — HTTP-клиент portal-сервиса: directory с кэшем+stale fallback, notifications, deactivate user.
|
||||||
|
- `audit/` — общий контракт business-audit событий и клиент отправки в Portal.
|
||||||
|
|
||||||
## Использование
|
## Использование
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ Shared Go-библиотека для микросервисов портала.
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
import (
|
import (
|
||||||
|
"gitea.estateliga.work/admin/portal-common/audit"
|
||||||
"gitea.estateliga.work/admin/portal-common/db"
|
"gitea.estateliga.work/admin/portal-common/db"
|
||||||
"gitea.estateliga.work/admin/portal-common/middleware"
|
"gitea.estateliga.work/admin/portal-common/middleware"
|
||||||
"gitea.estateliga.work/admin/portal-common/portal"
|
"gitea.estateliga.work/admin/portal-common/portal"
|
||||||
@@ -25,8 +27,31 @@ r := chi.NewRouter()
|
|||||||
r.Use(middleware.InternalAuth(cfg.InternalAPIKey))
|
r.Use(middleware.InternalAuth(cfg.InternalAPIKey))
|
||||||
|
|
||||||
portalCli := portal.New(cfg.PortalBaseURL, cfg.PortalAPIKey)
|
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>.<entity>_<verb>`:
|
||||||
|
|
||||||
|
- `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)
|
## Dev-режим (без push в Gitea)
|
||||||
|
|
||||||
В `go.mod` сервиса добавить `replace`:
|
В `go.mod` сервиса добавить `replace`:
|
||||||
@@ -38,9 +63,22 @@ replace gitea.estateliga.work/admin/portal-common => ../portal-common
|
|||||||
Когда библиотека стабилизируется, заменить на pinned тег:
|
Когда библиотека стабилизируется, заменить на pinned тег:
|
||||||
|
|
||||||
```
|
```
|
||||||
require gitea.estateliga.work/admin/portal-common v0.1.0
|
require gitea.estateliga.work/admin/portal-common v0.3.0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
После изменения публичных пакетов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
git tag v0.3.0
|
||||||
|
git push origin main --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
Сервисы обновляются только после публикации тега, без коммита локального
|
||||||
|
`replace`.
|
||||||
|
|
||||||
## Зачем
|
## Зачем
|
||||||
|
|
||||||
До этого 9 сервисов копировали один в один: pgxpool init, slow-query tracer (500ms threshold), InternalAuth middleware. Tweak'ать tuning централизованно было невозможно. Сейчас изменения идут в одном репо, сервисы пересобираются.
|
До этого 9 сервисов копировали один в один: pgxpool init, slow-query tracer (500ms threshold), InternalAuth middleware. Tweak'ать tuning централизованно было невозможно. Сейчас изменения идут в одном репо, сервисы пересобираются.
|
||||||
|
|||||||
158
audit/audit.go
Normal file
158
audit/audit.go
Normal file
@@ -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])
|
||||||
|
}
|
||||||
85
audit/audit_test.go
Normal file
85
audit/audit_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user