@johnnyboy11234/esg-idea-contest 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/db/migration/V002__audit_log_full.sql +32 -0
- package/backend/db/migration/V003__app_user.sql +36 -0
- package/backend/internal/audit/event.go +119 -0
- package/backend/internal/audit/integration_test.go +270 -0
- package/backend/internal/audit/sanitize.go +102 -0
- package/backend/internal/audit/sanitize_test.go +134 -0
- package/backend/internal/audit/service.go +157 -0
- package/backend/internal/server/errors.go +95 -0
- package/backend/internal/server/errors_test.go +98 -0
- package/backend/internal/shared/security/security.go +98 -0
- package/backend/internal/shared/security/security_test.go +60 -0
- package/backend/internal/shared/web/problem.go +23 -7
- package/backend/test/migration/app_user_test.go +112 -0
- package/docs/Adr/0001-Backend-in-go.md +3 -3
- package/docs/Open-items.md +8 -3
- package/docs/PR-plan.md +6 -4
- package/docs/PRD.md +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- V002 (B1-03): the remaining audit_log columns (PRD 6.11 "Fields"). Expand-only: V001 created
|
|
2
|
+
-- the skeleton (id, occurred_at); the table is still empty, so NOT NULL columns are safe.
|
|
3
|
+
-- Append-only: application code only INSERTs (test/arch scans for anything else).
|
|
4
|
+
-- "before" / "after" are reserved words in MariaDB, hence before_json / after_json.
|
|
5
|
+
-- No foreign keys: a row must outlive the user or Idea it describes.
|
|
6
|
+
|
|
7
|
+
ALTER TABLE audit_log
|
|
8
|
+
ADD COLUMN request_id UUID NULL COMMENT 'X-Request-Id of the request' AFTER occurred_at,
|
|
9
|
+
ADD COLUMN actor_emp_no VARCHAR(64) NULL COMMENT 'NULL for ANONYMOUS and SYSTEM' AFTER request_id,
|
|
10
|
+
ADD COLUMN actor_role VARCHAR(16) NOT NULL AFTER actor_emp_no,
|
|
11
|
+
ADD COLUMN action VARCHAR(40) NOT NULL AFTER actor_role,
|
|
12
|
+
ADD COLUMN outcome VARCHAR(16) NOT NULL AFTER action,
|
|
13
|
+
ADD COLUMN target_type VARCHAR(16) NULL AFTER outcome,
|
|
14
|
+
ADD COLUMN target_id UUID NULL AFTER target_type,
|
|
15
|
+
ADD COLUMN method VARCHAR(8) NULL AFTER target_id,
|
|
16
|
+
ADD COLUMN route VARCHAR(200) NULL COMMENT 'route template, never the raw path' AFTER method,
|
|
17
|
+
ADD COLUMN status SMALLINT UNSIGNED NULL AFTER route,
|
|
18
|
+
ADD COLUMN error_code VARCHAR(64) NULL AFTER status,
|
|
19
|
+
ADD COLUMN ip INET6 NULL AFTER error_code,
|
|
20
|
+
ADD COLUMN user_agent VARCHAR(256) NULL AFTER ip,
|
|
21
|
+
ADD COLUMN submitted_emp_no VARCHAR(64) NULL COMMENT 'sanitized employee ID typed into a failed login or registration' AFTER user_agent,
|
|
22
|
+
ADD COLUMN before_json JSON NULL AFTER submitted_emp_no,
|
|
23
|
+
ADD COLUMN after_json JSON NULL AFTER before_json,
|
|
24
|
+
-- Reserved for the optional hash chain (PRD 9 #5); nothing writes them yet.
|
|
25
|
+
ADD COLUMN prev_hash BINARY(32) NULL,
|
|
26
|
+
ADD COLUMN hash BINARY(32) NULL,
|
|
27
|
+
ADD CONSTRAINT ck_audit_log_actor_role CHECK (actor_role IN ('USER', 'ADMIN', 'ANONYMOUS', 'SYSTEM')),
|
|
28
|
+
ADD CONSTRAINT ck_audit_log_outcome CHECK (outcome IN ('SUCCESS', 'FAILED', 'DENIED', 'THROTTLED')),
|
|
29
|
+
ADD CONSTRAINT ck_audit_log_target_type CHECK (target_type IS NULL OR target_type IN ('USER', 'IDEA', 'DEADLINE')),
|
|
30
|
+
ADD KEY ix_audit_log_actor (actor_emp_no, occurred_at, id),
|
|
31
|
+
ADD KEY ix_audit_log_request (request_id),
|
|
32
|
+
ADD KEY ix_audit_log_target (target_id, occurred_at, id);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
-- V003 (B1-05): app_user, the one table for Users and Admins (PRD 4, 6.3).
|
|
2
|
+
-- Field rules are enforced here as well as in Go, so no code path can store a value the API would reject.
|
|
3
|
+
-- emp_no exactly 6 ASCII digits (string; keeps leading zeros)
|
|
4
|
+
-- name 1-50 code points, no control characters, none of / \ : * ? " < > | (PRD 6.2 #5, #8);
|
|
5
|
+
-- the NFC / whitespace clean-up itself is done by shared/text before the INSERT
|
|
6
|
+
-- email printable ASCII only, no whitespace, one '@' with non-empty sides, <= 254, already lower-cased (OI-14);
|
|
7
|
+
-- unique byte-exact (utf8mb4_bin). NULL only for Admins, who have no email (PRD 4).
|
|
8
|
+
-- password_hash bcrypt, cost 10 only (PRD 6.4, OI-13)
|
|
9
|
+
-- Enumerations are VARCHAR + CHECK, never ENUM. Times are database time in UTC.
|
|
10
|
+
|
|
11
|
+
CREATE TABLE app_user (
|
|
12
|
+
id UUID NOT NULL DEFAULT UUID(),
|
|
13
|
+
emp_no CHAR(6) NOT NULL,
|
|
14
|
+
name VARCHAR(50) NOT NULL,
|
|
15
|
+
email VARCHAR(254) NULL,
|
|
16
|
+
password_hash CHAR(60) NOT NULL,
|
|
17
|
+
role VARCHAR(8) NOT NULL,
|
|
18
|
+
-- Every authenticated request checks this (PRD 4); an Admin removed from Vault is disabled, not deleted.
|
|
19
|
+
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
20
|
+
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
|
21
|
+
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
|
22
|
+
PRIMARY KEY (id),
|
|
23
|
+
UNIQUE KEY uq_app_user_emp_no (emp_no),
|
|
24
|
+
-- Several NULLs are allowed (Admins); non-NULL values are unique.
|
|
25
|
+
UNIQUE KEY uq_app_user_email (email),
|
|
26
|
+
CONSTRAINT ck_app_user_emp_no CHECK (CHAR_LENGTH(emp_no) = 6 AND emp_no NOT REGEXP '[^0-9]'),
|
|
27
|
+
CONSTRAINT ck_app_user_name_length CHECK (CHAR_LENGTH(name) BETWEEN 1 AND 50),
|
|
28
|
+
CONSTRAINT ck_app_user_name_chars CHECK (name NOT REGEXP '[[:cntrl:]]' AND name NOT REGEXP '[/\\\\:*?"<>|]'),
|
|
29
|
+
CONSTRAINT ck_app_user_email CHECK (email IS NULL OR (
|
|
30
|
+
email NOT REGEXP '[^!-~]'
|
|
31
|
+
AND email = LOWER(email)
|
|
32
|
+
AND email REGEXP '^[^@]+@[^@]+$')),
|
|
33
|
+
CONSTRAINT ck_app_user_hash CHECK (password_hash REGEXP '^[$]2[aby][$]10[$][./A-Za-z0-9]{53}$'),
|
|
34
|
+
CONSTRAINT ck_app_user_role CHECK (role IN ('USER', 'ADMIN')),
|
|
35
|
+
CONSTRAINT ck_app_user_email_by_role CHECK ((role = 'USER' AND email IS NOT NULL) OR (role = 'ADMIN' AND email IS NULL))
|
|
36
|
+
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Package audit writes the Audit Log (PRD §6.11, Backend.md §7). Two tracks:
|
|
2
|
+
//
|
|
3
|
+
// - Record joins the caller's transaction, so a successful write and its audit
|
|
4
|
+
// row commit or roll back together (fail-closed).
|
|
5
|
+
// - RecordRejected opens its own transaction for DENIED / FAILED / THROTTLED
|
|
6
|
+
// rows; it is best effort and counts its failures.
|
|
7
|
+
//
|
|
8
|
+
// The table is append-only: this package only ever INSERTs.
|
|
9
|
+
package audit
|
|
10
|
+
|
|
11
|
+
import (
|
|
12
|
+
"encoding/json"
|
|
13
|
+
"net/netip"
|
|
14
|
+
|
|
15
|
+
"github.com/google/uuid"
|
|
16
|
+
|
|
17
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// Action is the audit action (PRD §6.11).
|
|
21
|
+
type Action string
|
|
22
|
+
|
|
23
|
+
// The recorded actions.
|
|
24
|
+
const (
|
|
25
|
+
AuthRegister Action = "AUTH_REGISTER"
|
|
26
|
+
AuthLogin Action = "AUTH_LOGIN"
|
|
27
|
+
AuthLogout Action = "AUTH_LOGOUT"
|
|
28
|
+
IdeaCreate Action = "IDEA_CREATE"
|
|
29
|
+
IdeaUpdate Action = "IDEA_UPDATE"
|
|
30
|
+
IdeaDelete Action = "IDEA_DELETE"
|
|
31
|
+
IdeaView Action = "IDEA_VIEW"
|
|
32
|
+
SlidesReplace Action = "SLIDES_REPLACE"
|
|
33
|
+
SlidesDownload Action = "SLIDES_DOWNLOAD"
|
|
34
|
+
JoinRequestCreate Action = "JOIN_REQUEST_CREATE"
|
|
35
|
+
JoinRequestApprove Action = "JOIN_REQUEST_APPROVE"
|
|
36
|
+
JoinRequestReject Action = "JOIN_REQUEST_REJECT"
|
|
37
|
+
JoinRequestWithdraw Action = "JOIN_REQUEST_WITHDRAW"
|
|
38
|
+
MemberRemove Action = "MEMBER_REMOVE"
|
|
39
|
+
MemberLeave Action = "MEMBER_LEAVE"
|
|
40
|
+
DeadlineUpdate Action = "DEADLINE_UPDATE"
|
|
41
|
+
AdminSync Action = "ADMIN_SYNC"
|
|
42
|
+
AuditQuery Action = "AUDIT_QUERY"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
var actions = map[Action]bool{
|
|
46
|
+
AuthRegister: true, AuthLogin: true, AuthLogout: true,
|
|
47
|
+
IdeaCreate: true, IdeaUpdate: true, IdeaDelete: true, IdeaView: true,
|
|
48
|
+
SlidesReplace: true, SlidesDownload: true,
|
|
49
|
+
JoinRequestCreate: true, JoinRequestApprove: true, JoinRequestReject: true, JoinRequestWithdraw: true,
|
|
50
|
+
MemberRemove: true, MemberLeave: true,
|
|
51
|
+
DeadlineUpdate: true, AdminSync: true, AuditQuery: true,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Valid reports whether a is a known action.
|
|
55
|
+
func (a Action) Valid() bool { return actions[a] }
|
|
56
|
+
|
|
57
|
+
// Outcome is the result of the audited operation.
|
|
58
|
+
type Outcome string
|
|
59
|
+
|
|
60
|
+
// The outcomes. SUCCESS goes through Record; the other three are rejected
|
|
61
|
+
// attempts and go through RecordRejected (ADMIN_SYNC FAILED may use either).
|
|
62
|
+
const (
|
|
63
|
+
Success Outcome = "SUCCESS"
|
|
64
|
+
Failed Outcome = "FAILED"
|
|
65
|
+
Denied Outcome = "DENIED"
|
|
66
|
+
Throttled Outcome = "THROTTLED"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
// Valid reports whether o is a known outcome.
|
|
70
|
+
func (o Outcome) Valid() bool {
|
|
71
|
+
return o == Success || o == Failed || o == Denied || o == Throttled
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// TargetType is the kind of resource a row is about.
|
|
75
|
+
type TargetType string
|
|
76
|
+
|
|
77
|
+
// The target types. DEADLINE has no ID.
|
|
78
|
+
const (
|
|
79
|
+
TargetUser TargetType = "USER"
|
|
80
|
+
TargetIdea TargetType = "IDEA"
|
|
81
|
+
TargetDeadline TargetType = "DEADLINE"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
// Valid reports whether t is a known target type.
|
|
85
|
+
func (t TargetType) Valid() bool {
|
|
86
|
+
return t == TargetUser || t == TargetIdea || t == TargetDeadline
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Event is one audit row. The request ID and, unless overridden, the actor come
|
|
90
|
+
// from the context; occurred_at is the database clock.
|
|
91
|
+
type Event struct {
|
|
92
|
+
Action Action
|
|
93
|
+
Outcome Outcome
|
|
94
|
+
|
|
95
|
+
// Actor override. Needed where the context has no principal yet (a login
|
|
96
|
+
// that just succeeded) or the actor is not the caller (ADMIN_SYNC). When
|
|
97
|
+
// ActorRole is empty both fields come from the context.
|
|
98
|
+
ActorRole security.ActorRole
|
|
99
|
+
ActorEmpNo string
|
|
100
|
+
|
|
101
|
+
TargetType TargetType
|
|
102
|
+
TargetID uuid.UUID // uuid.Nil = none
|
|
103
|
+
|
|
104
|
+
Method string
|
|
105
|
+
Route string // route template, e.g. /ideas/{id}
|
|
106
|
+
Status int // HTTP status; 0 = none
|
|
107
|
+
ErrorCode string
|
|
108
|
+
IP netip.Addr // zero = none
|
|
109
|
+
UserAgent string
|
|
110
|
+
|
|
111
|
+
// SubmittedEmpNo is the raw employee ID typed into a failed login or
|
|
112
|
+
// registration; it is sanitized before it is stored.
|
|
113
|
+
SubmittedEmpNo string
|
|
114
|
+
|
|
115
|
+
// Before and After are JSON documents (changed fields only). Secrets are
|
|
116
|
+
// redacted by key name before they are stored.
|
|
117
|
+
Before json.RawMessage
|
|
118
|
+
After json.RawMessage
|
|
119
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package audit_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"database/sql"
|
|
8
|
+
"errors"
|
|
9
|
+
"net/http"
|
|
10
|
+
"net/http/httptest"
|
|
11
|
+
"net/netip"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
|
|
15
|
+
"github.com/google/uuid"
|
|
16
|
+
|
|
17
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
18
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
|
|
19
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
|
|
20
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
21
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
type row struct {
|
|
25
|
+
RequestID, ActorEmpNo, TargetType, TargetID, Method, Route, ErrorCode, IP, UserAgent, Submitted sql.NullString
|
|
26
|
+
ActorRole, Action, Outcome string
|
|
27
|
+
Status sql.NullInt64
|
|
28
|
+
Before, After sql.NullString
|
|
29
|
+
PrevHash, Hash []byte
|
|
30
|
+
OccurredAtSet bool
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// floor is the highest audit_log.id before the current subtest; rows() sees only newer rows. The
|
|
34
|
+
// table is append-only, so tests never clear it (test/arch scans for that).
|
|
35
|
+
var floor int64
|
|
36
|
+
|
|
37
|
+
func rows(t *testing.T, d *sql.DB) []row {
|
|
38
|
+
t.Helper()
|
|
39
|
+
rs, err := d.Query(`SELECT request_id, actor_emp_no, target_type, target_id, method, route, error_code,
|
|
40
|
+
ip, user_agent, submitted_emp_no, actor_role, action, outcome, status, before_json, after_json,
|
|
41
|
+
prev_hash, hash, occurred_at IS NOT NULL FROM audit_log WHERE id > ? ORDER BY id`, floor)
|
|
42
|
+
if err != nil {
|
|
43
|
+
t.Fatal(err)
|
|
44
|
+
}
|
|
45
|
+
defer rs.Close()
|
|
46
|
+
var out []row
|
|
47
|
+
for rs.Next() {
|
|
48
|
+
var r row
|
|
49
|
+
if err := rs.Scan(&r.RequestID, &r.ActorEmpNo, &r.TargetType, &r.TargetID, &r.Method, &r.Route, &r.ErrorCode,
|
|
50
|
+
&r.IP, &r.UserAgent, &r.Submitted, &r.ActorRole, &r.Action, &r.Outcome, &r.Status, &r.Before, &r.After,
|
|
51
|
+
&r.PrevHash, &r.Hash, &r.OccurredAtSet); err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
out = append(out, r)
|
|
55
|
+
}
|
|
56
|
+
if err := rs.Err(); err != nil {
|
|
57
|
+
t.Fatal(err)
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func reset(t *testing.T, d *sql.DB) {
|
|
63
|
+
t.Helper()
|
|
64
|
+
if err := d.QueryRow(`SELECT COALESCE(MAX(id), 0) FROM audit_log`).Scan(&floor); err != nil {
|
|
65
|
+
t.Fatal(err)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func TestAudit(t *testing.T) {
|
|
70
|
+
d := dbtest.New(t)
|
|
71
|
+
svc := audit.New(d, nil)
|
|
72
|
+
reqID := uuid.NewString()
|
|
73
|
+
|
|
74
|
+
// Request ID is stored by web.RequestID middleware; reproduce it through the real middleware.
|
|
75
|
+
ctxWithReq := func(principal *security.Principal) context.Context {
|
|
76
|
+
var got context.Context
|
|
77
|
+
h := web.RequestID(handlerFunc(func(ctx context.Context) { got = ctx }))
|
|
78
|
+
h.ServeHTTP(httptest.NewRecorder(), newReq(reqID))
|
|
79
|
+
if principal != nil {
|
|
80
|
+
return security.WithPrincipal(got, *principal)
|
|
81
|
+
}
|
|
82
|
+
return got
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
t.Run("track A is written in the caller's transaction and carries every field", func(t *testing.T) {
|
|
86
|
+
reset(t, d)
|
|
87
|
+
ctx := ctxWithReq(&security.Principal{UserID: 7, EmpNo: "E0001", Role: security.RoleUser})
|
|
88
|
+
target := uuid.New()
|
|
89
|
+
out, err := db.InTx(ctx, d, func(ctx context.Context, tx *sql.Tx) error {
|
|
90
|
+
return svc.Record(ctx, tx, audit.Event{
|
|
91
|
+
Action: audit.IdeaUpdate, Outcome: audit.Success,
|
|
92
|
+
TargetType: audit.TargetIdea, TargetID: target,
|
|
93
|
+
Method: "PATCH", Route: "/ideas/{id}", Status: 200,
|
|
94
|
+
IP: netip.MustParseAddr("203.0.113.9"), UserAgent: "ua",
|
|
95
|
+
Before: []byte(`{"name":"a"}`), After: []byte(`{"name":"b","password":"p"}`),
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
if err != nil || out != db.Committed {
|
|
99
|
+
t.Fatalf("InTx = %v, %v", out, err)
|
|
100
|
+
}
|
|
101
|
+
got := rows(t, d)
|
|
102
|
+
if len(got) != 1 {
|
|
103
|
+
t.Fatalf("rows = %d, want 1", len(got))
|
|
104
|
+
}
|
|
105
|
+
r := got[0]
|
|
106
|
+
if r.RequestID.String != reqID || r.ActorEmpNo.String != "E0001" || r.ActorRole != "USER" ||
|
|
107
|
+
r.Action != "IDEA_UPDATE" || r.Outcome != "SUCCESS" || r.TargetType.String != "IDEA" ||
|
|
108
|
+
r.TargetID.String != target.String() || r.Method.String != "PATCH" || r.Route.String != "/ideas/{id}" ||
|
|
109
|
+
r.Status.Int64 != 200 || r.UserAgent.String != "ua" || !r.OccurredAtSet {
|
|
110
|
+
t.Errorf("row = %+v", r)
|
|
111
|
+
}
|
|
112
|
+
if r.IP.String != "::ffff:203.0.113.9" {
|
|
113
|
+
t.Errorf("ip = %q (IPv4 is stored as INET6 mapped)", r.IP.String)
|
|
114
|
+
}
|
|
115
|
+
if r.Before.String != `{"name": "a"}` && r.Before.String != `{"name":"a"}` {
|
|
116
|
+
t.Errorf("before = %q", r.Before.String)
|
|
117
|
+
}
|
|
118
|
+
if strings.Contains(r.After.String, "\"p\"") || !strings.Contains(r.After.String, "[REDACTED]") {
|
|
119
|
+
t.Errorf("password not redacted: %q", r.After.String)
|
|
120
|
+
}
|
|
121
|
+
if r.PrevHash != nil || r.Hash != nil {
|
|
122
|
+
t.Error("hash-chain columns must stay NULL")
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
t.Run("track A rolls back with the transaction", func(t *testing.T) {
|
|
127
|
+
reset(t, d)
|
|
128
|
+
boom := errors.New("business failure")
|
|
129
|
+
out, err := db.InTx(context.Background(), d, func(ctx context.Context, tx *sql.Tx) error {
|
|
130
|
+
if err := svc.Record(ctx, tx, audit.Event{Action: audit.AuthLogout, Outcome: audit.Success}); err != nil {
|
|
131
|
+
return err
|
|
132
|
+
}
|
|
133
|
+
return boom
|
|
134
|
+
})
|
|
135
|
+
if !errors.Is(err, boom) || out != db.RolledBack {
|
|
136
|
+
t.Fatalf("InTx = %v, %v", out, err)
|
|
137
|
+
}
|
|
138
|
+
if n := len(rows(t, d)); n != 0 {
|
|
139
|
+
t.Errorf("rows = %d, want 0 (rolled back with the business write)", n)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
t.Run("track A failure fails the operation", func(t *testing.T) {
|
|
144
|
+
reset(t, d)
|
|
145
|
+
out, err := db.InTx(context.Background(), d, func(ctx context.Context, tx *sql.Tx) error {
|
|
146
|
+
return svc.Record(ctx, tx, audit.Event{Action: "BOGUS", Outcome: audit.Success})
|
|
147
|
+
})
|
|
148
|
+
if err == nil || out != db.RolledBack {
|
|
149
|
+
t.Errorf("InTx = %v, %v; want RolledBack and an error", out, err)
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
t.Run("track B persists although the business transaction rolled back", func(t *testing.T) {
|
|
154
|
+
reset(t, d)
|
|
155
|
+
ctx := ctxWithReq(nil)
|
|
156
|
+
_, err := db.InTx(ctx, d, func(ctx context.Context, tx *sql.Tx) error {
|
|
157
|
+
svc.RecordRejected(ctx, audit.Event{
|
|
158
|
+
Action: audit.IdeaDelete, Outcome: audit.Denied, TargetType: audit.TargetIdea,
|
|
159
|
+
TargetID: uuid.New(), Method: "DELETE", Route: "/ideas/{id}", Status: 404, ErrorCode: "NOT_FOUND",
|
|
160
|
+
})
|
|
161
|
+
return errors.New("rejected")
|
|
162
|
+
})
|
|
163
|
+
if err == nil {
|
|
164
|
+
t.Fatal("want error")
|
|
165
|
+
}
|
|
166
|
+
got := rows(t, d)
|
|
167
|
+
if len(got) != 1 || got[0].Outcome != "DENIED" || got[0].ActorRole != "ANONYMOUS" ||
|
|
168
|
+
got[0].ActorEmpNo.Valid || got[0].ErrorCode.String != "NOT_FOUND" || got[0].RequestID.String != reqID {
|
|
169
|
+
t.Errorf("rows = %+v", got)
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
t.Run("track B survives a cancelled request context", func(t *testing.T) {
|
|
174
|
+
reset(t, d)
|
|
175
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
176
|
+
cancel()
|
|
177
|
+
svc.RecordRejected(ctx, audit.Event{Action: audit.AuthLogin, Outcome: audit.Failed, SubmittedEmpNo: "E0009"})
|
|
178
|
+
if got := rows(t, d); len(got) != 1 || got[0].Submitted.String != "E0009" {
|
|
179
|
+
t.Errorf("rows = %+v", got)
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
t.Run("track B failure is counted, not returned", func(t *testing.T) {
|
|
184
|
+
reset(t, d)
|
|
185
|
+
before := svc.RejectedFailures()
|
|
186
|
+
svc.RecordRejected(context.Background(), audit.Event{Action: "BOGUS", Outcome: audit.Denied})
|
|
187
|
+
if svc.RejectedFailures() != before+1 {
|
|
188
|
+
t.Error("failure not counted")
|
|
189
|
+
}
|
|
190
|
+
if n := len(rows(t, d)); n != 0 {
|
|
191
|
+
t.Errorf("rows = %d", n)
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
t.Run("hostile input is sanitized and truncated, never fatal", func(t *testing.T) {
|
|
196
|
+
reset(t, d)
|
|
197
|
+
svc.RecordRejected(context.Background(), audit.Event{
|
|
198
|
+
Action: audit.AuthLogin, Outcome: audit.Failed,
|
|
199
|
+
UserAgent: strings.Repeat("創", 1000) + "\r\nforged",
|
|
200
|
+
SubmittedEmpNo: "E1\x00\n" + strings.Repeat("9", 500),
|
|
201
|
+
Route: strings.Repeat("r", 1000), ErrorCode: strings.Repeat("c", 1000),
|
|
202
|
+
})
|
|
203
|
+
got := rows(t, d)
|
|
204
|
+
if len(got) != 1 {
|
|
205
|
+
t.Fatalf("rows = %d, want 1", len(got))
|
|
206
|
+
}
|
|
207
|
+
r := got[0]
|
|
208
|
+
if n := len([]rune(r.UserAgent.String)); n != 256 {
|
|
209
|
+
t.Errorf("user agent = %d code points, want 256", n)
|
|
210
|
+
}
|
|
211
|
+
if strings.ContainsAny(r.Submitted.String, "\x00\n") || len(r.Submitted.String) != 64 {
|
|
212
|
+
t.Errorf("submitted = %q", r.Submitted.String)
|
|
213
|
+
}
|
|
214
|
+
if len(r.Route.String) != 200 || len(r.ErrorCode.String) != 64 {
|
|
215
|
+
t.Errorf("route/errorCode lengths = %d/%d", len(r.Route.String), len(r.ErrorCode.String))
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
t.Run("actor override wins over the context", func(t *testing.T) {
|
|
220
|
+
reset(t, d)
|
|
221
|
+
ctx := security.WithSystem(context.Background())
|
|
222
|
+
_, err := db.InTx(ctx, d, func(ctx context.Context, tx *sql.Tx) error {
|
|
223
|
+
return svc.Record(ctx, tx, audit.Event{Action: audit.AuthLogin, Outcome: audit.Success,
|
|
224
|
+
ActorRole: security.ActorAdmin, ActorEmpNo: "A0001", TargetType: audit.TargetUser, TargetID: uuid.New()})
|
|
225
|
+
})
|
|
226
|
+
if err != nil {
|
|
227
|
+
t.Fatal(err)
|
|
228
|
+
}
|
|
229
|
+
if r := rows(t, d)[0]; r.ActorRole != "ADMIN" || r.ActorEmpNo.String != "A0001" {
|
|
230
|
+
t.Errorf("row = %+v", r)
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
t.Run("system actor and deadline target without an ID", func(t *testing.T) {
|
|
235
|
+
reset(t, d)
|
|
236
|
+
ctx := security.WithSystem(context.Background())
|
|
237
|
+
_, err := db.InTx(ctx, d, func(ctx context.Context, tx *sql.Tx) error {
|
|
238
|
+
return svc.Record(ctx, tx, audit.Event{Action: audit.AdminSync, Outcome: audit.Success,
|
|
239
|
+
TargetType: audit.TargetDeadline, After: []byte(`null`)})
|
|
240
|
+
})
|
|
241
|
+
if err != nil {
|
|
242
|
+
t.Fatal(err)
|
|
243
|
+
}
|
|
244
|
+
if r := rows(t, d)[0]; r.ActorRole != "SYSTEM" || r.TargetID.Valid || r.After.Valid {
|
|
245
|
+
t.Errorf("row = %+v", r)
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
t.Run("database CHECKs reject values outside the sets", func(t *testing.T) {
|
|
250
|
+
for _, stmt := range []string{
|
|
251
|
+
`INSERT INTO audit_log (actor_role, action, outcome) VALUES ('ROOT', 'X', 'SUCCESS')`,
|
|
252
|
+
`INSERT INTO audit_log (actor_role, action, outcome) VALUES ('USER', 'X', 'MAYBE')`,
|
|
253
|
+
`INSERT INTO audit_log (actor_role, action, outcome, target_type) VALUES ('USER', 'X', 'SUCCESS', 'TEAM')`,
|
|
254
|
+
} {
|
|
255
|
+
if _, err := d.Exec(stmt); err == nil {
|
|
256
|
+
t.Errorf("accepted: %s", stmt)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
type handlerFunc func(ctx context.Context)
|
|
263
|
+
|
|
264
|
+
func (f handlerFunc) ServeHTTP(_ http.ResponseWriter, r *http.Request) { f(r.Context()) }
|
|
265
|
+
|
|
266
|
+
func newReq(id string) *http.Request {
|
|
267
|
+
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
268
|
+
r.Header.Set(web.RequestIDHeader, id)
|
|
269
|
+
return r
|
|
270
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
package audit
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"strings"
|
|
8
|
+
"unicode"
|
|
9
|
+
"unicode/utf8"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// Column limits (V002). User-controlled text is cut to these by code point, so
|
|
13
|
+
// a hostile header can never make the audit insert fail.
|
|
14
|
+
const (
|
|
15
|
+
maxEmpNo = 64
|
|
16
|
+
maxMethod = 8
|
|
17
|
+
maxRoute = 200
|
|
18
|
+
maxErrorCode = 64
|
|
19
|
+
maxUserAgent = 256 // PRD §6.11
|
|
20
|
+
|
|
21
|
+
// maxJSONBytes caps before_json / after_json; a bigger document is replaced
|
|
22
|
+
// by a marker, never cut in the middle (that would be invalid JSON).
|
|
23
|
+
maxJSONBytes = 16 << 10
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
// redacted replaces the value of a secret-looking key.
|
|
27
|
+
const redacted = "[REDACTED]"
|
|
28
|
+
|
|
29
|
+
// secretKeys are JSON keys whose values are never stored (PRD §6.11: no
|
|
30
|
+
// passwords, tokens, session IDs, CSRF values or file contents). Compared after
|
|
31
|
+
// lower-casing and removing "_" and "-".
|
|
32
|
+
var secretKeys = map[string]bool{
|
|
33
|
+
"password": true, "passwd": true, "newpassword": true, "oldpassword": true, "currentpassword": true,
|
|
34
|
+
"token": true, "csrf": true, "csrftoken": true, "xcsrftoken": true,
|
|
35
|
+
"session": true, "sessionid": true, "cookie": true, "setcookie": true,
|
|
36
|
+
"authorization": true, "secret": true, "content": true, "filecontent": true,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// clean makes s safe to store and cuts it to max code points: invalid UTF-8
|
|
40
|
+
// and control characters (including NUL and line breaks, which would forge log
|
|
41
|
+
// lines) are dropped, and the result is trimmed.
|
|
42
|
+
func clean(s string, max int) string {
|
|
43
|
+
s = strings.ToValidUTF8(s, "")
|
|
44
|
+
s = strings.Map(func(r rune) rune {
|
|
45
|
+
if unicode.IsControl(r) || r == '
' || r == '
' {
|
|
46
|
+
return -1
|
|
47
|
+
}
|
|
48
|
+
return r
|
|
49
|
+
}, s)
|
|
50
|
+
s = strings.TrimSpace(s)
|
|
51
|
+
if utf8.RuneCountInString(s) > max {
|
|
52
|
+
s = string([]rune(s)[:max])
|
|
53
|
+
}
|
|
54
|
+
return s
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// cleanJSON validates raw, redacts secret keys and returns the compact
|
|
58
|
+
// document; nil means "store NULL" (empty input or JSON null).
|
|
59
|
+
func cleanJSON(raw json.RawMessage) ([]byte, error) {
|
|
60
|
+
if len(bytes.TrimSpace(raw)) == 0 {
|
|
61
|
+
return nil, nil
|
|
62
|
+
}
|
|
63
|
+
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
64
|
+
dec.UseNumber() // keep numbers exactly as written
|
|
65
|
+
var v any
|
|
66
|
+
if err := dec.Decode(&v); err != nil {
|
|
67
|
+
return nil, fmt.Errorf("audit: invalid JSON: %w", err)
|
|
68
|
+
}
|
|
69
|
+
if dec.More() {
|
|
70
|
+
return nil, fmt.Errorf("audit: trailing data after JSON value")
|
|
71
|
+
}
|
|
72
|
+
if v == nil {
|
|
73
|
+
return nil, nil
|
|
74
|
+
}
|
|
75
|
+
out, err := json.Marshal(redact(v))
|
|
76
|
+
if err != nil {
|
|
77
|
+
return nil, fmt.Errorf("audit: marshal JSON: %w", err)
|
|
78
|
+
}
|
|
79
|
+
if len(out) > maxJSONBytes {
|
|
80
|
+
return json.Marshal(map[string]any{"truncated": true, "bytes": len(out)})
|
|
81
|
+
}
|
|
82
|
+
return out, nil
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func redact(v any) any {
|
|
86
|
+
switch t := v.(type) {
|
|
87
|
+
case map[string]any:
|
|
88
|
+
for k, val := range t {
|
|
89
|
+
norm := strings.NewReplacer("_", "", "-", "").Replace(strings.ToLower(k))
|
|
90
|
+
if secretKeys[norm] {
|
|
91
|
+
t[k] = redacted
|
|
92
|
+
} else {
|
|
93
|
+
t[k] = redact(val)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
case []any:
|
|
97
|
+
for i, val := range t {
|
|
98
|
+
t[i] = redact(val)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return v
|
|
102
|
+
}
|