@johnnyboy11234/esg-idea-contest 0.1.0 → 0.2.1
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/api/openapi.yaml +44 -0
- package/backend/cmd/server/main.go +11 -1
- package/backend/db/migration/V002__audit_log_full.sql +32 -0
- package/backend/db/migration/V003__app_user.sql +36 -0
- package/backend/db/migration/V004__session.sql +19 -0
- package/backend/go.mod +1 -1
- package/backend/internal/account/account_test.go +191 -0
- package/backend/internal/account/handler.go +58 -0
- package/backend/internal/account/integration_test.go +268 -0
- package/backend/internal/account/internal/store/store.go +35 -0
- package/backend/internal/account/service.go +153 -0
- package/backend/internal/account/validate.go +100 -0
- package/backend/internal/api/api.gen.go +82 -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/auth/internal/store/session.go +111 -0
- package/backend/internal/auth/session.go +113 -0
- package/backend/internal/auth/session_integration_test.go +245 -0
- package/backend/internal/auth/session_test.go +36 -0
- package/backend/internal/server/api.go +7 -0
- package/backend/internal/server/errors.go +80 -0
- package/backend/internal/server/errors_test.go +116 -0
- package/backend/internal/shared/db/intx_test.go +25 -0
- package/backend/internal/shared/db/mysqlerr.go +26 -0
- package/backend/internal/shared/security/security.go +102 -0
- package/backend/internal/shared/security/security_test.go +62 -0
- package/backend/internal/shared/web/problem.go +29 -7
- package/backend/internal/shared/web/request.go +27 -0
- 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/frontend/src/shared/text/index.ts +93 -0
- package/frontend/src/shared/text/text.test.ts +22 -0
- package/package.json +1 -1
- package/frontend/src/shared/text/.gitkeep +0 -0
|
@@ -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: uuid.New(), 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
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
package audit
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestClean(t *testing.T) {
|
|
14
|
+
tests := []struct {
|
|
15
|
+
name string
|
|
16
|
+
in string
|
|
17
|
+
max int
|
|
18
|
+
want string
|
|
19
|
+
}{
|
|
20
|
+
{"plain", "E12345", 64, "E12345"},
|
|
21
|
+
{"control chars and newlines dropped", "a\x00b\r\nc\td", 64, "abcd"},
|
|
22
|
+
{"line separators dropped", "a
b
c", 64, "abc"},
|
|
23
|
+
{"invalid utf-8 dropped", "ab\xffcd", 64, "abcd"},
|
|
24
|
+
{"trimmed", " x ", 64, "x"},
|
|
25
|
+
{"cut by code point, not byte", strings.Repeat("創", 10), 4, "創創創創"},
|
|
26
|
+
{"emoji kept whole", "😀😀😀", 2, "😀😀"},
|
|
27
|
+
{"empty", "", 8, ""},
|
|
28
|
+
}
|
|
29
|
+
for _, tc := range tests {
|
|
30
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
31
|
+
if got := clean(tc.in, tc.max); got != tc.want {
|
|
32
|
+
t.Errorf("clean(%q, %d) = %q, want %q", tc.in, tc.max, got, tc.want)
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func TestCleanJSON(t *testing.T) {
|
|
39
|
+
tests := []struct {
|
|
40
|
+
name string
|
|
41
|
+
in string
|
|
42
|
+
want string // "" = NULL
|
|
43
|
+
wantErr bool
|
|
44
|
+
}{
|
|
45
|
+
{"empty is NULL", ``, "", false},
|
|
46
|
+
{"blank is NULL", " \n", "", false},
|
|
47
|
+
{"null is NULL", `null`, "", false},
|
|
48
|
+
{"compacted", "{ \"a\": 1 }", `{"a":1}`, false},
|
|
49
|
+
{"big number kept exactly", `{"n":12345678901234567890}`, `{"n":12345678901234567890}`, false},
|
|
50
|
+
{"password redacted", `{"password":"hunter2","name":"x"}`, `{"name":"x","password":"[REDACTED]"}`, false},
|
|
51
|
+
{"key spelling ignored", `{"Session_ID":"s","x-csrf-token":"c","Set-Cookie":"k"}`,
|
|
52
|
+
`{"Session_ID":"[REDACTED]","Set-Cookie":"[REDACTED]","x-csrf-token":"[REDACTED]"}`, false},
|
|
53
|
+
{"nested and arrays", `{"a":[{"token":"t","ok":true}]}`, `{"a":[{"ok":true,"token":"[REDACTED]"}]}`, false},
|
|
54
|
+
{"file content redacted", `{"filename":"a.pptx","content":"AAAA"}`, `{"content":"[REDACTED]","filename":"a.pptx"}`, false},
|
|
55
|
+
{"invalid", `{"a":`, "", true},
|
|
56
|
+
{"trailing data", `{"a":1} {"b":2}`, "", true},
|
|
57
|
+
}
|
|
58
|
+
for _, tc := range tests {
|
|
59
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
60
|
+
got, err := cleanJSON(json.RawMessage(tc.in))
|
|
61
|
+
if (err != nil) != tc.wantErr {
|
|
62
|
+
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
|
|
63
|
+
}
|
|
64
|
+
if string(got) != tc.want {
|
|
65
|
+
t.Errorf("got %s, want %s", got, tc.want)
|
|
66
|
+
}
|
|
67
|
+
if tc.want == "" && got != nil {
|
|
68
|
+
t.Errorf("want nil slice (NULL), got %q", got)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func TestCleanJSONOversizeIsMarker(t *testing.T) {
|
|
75
|
+
big := `{"text":"` + strings.Repeat("x", maxJSONBytes) + `"}`
|
|
76
|
+
got, err := cleanJSON(json.RawMessage(big))
|
|
77
|
+
if err != nil {
|
|
78
|
+
t.Fatal(err)
|
|
79
|
+
}
|
|
80
|
+
var m map[string]any
|
|
81
|
+
if err := json.Unmarshal(got, &m); err != nil {
|
|
82
|
+
t.Fatalf("marker is not valid JSON: %v", err)
|
|
83
|
+
}
|
|
84
|
+
if m["truncated"] != true || len(got) > 100 {
|
|
85
|
+
t.Errorf("marker = %s", got)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func TestInsertRejectsBadEventsBeforeTouchingTheDatabase(t *testing.T) {
|
|
90
|
+
ok := Event{Action: AuthLogin, Outcome: Success}
|
|
91
|
+
tests := []struct {
|
|
92
|
+
name string
|
|
93
|
+
mod func(*Event)
|
|
94
|
+
ctx context.Context
|
|
95
|
+
}{
|
|
96
|
+
{"unknown action", func(e *Event) { e.Action = "NOPE" }, nil},
|
|
97
|
+
{"empty action", func(e *Event) { e.Action = "" }, nil},
|
|
98
|
+
{"unknown outcome", func(e *Event) { e.Outcome = "MAYBE" }, nil},
|
|
99
|
+
{"unknown target type", func(e *Event) { e.TargetType = "TEAM" }, nil},
|
|
100
|
+
{"target id without type", func(e *Event) { e.TargetID = uuid.New() }, nil},
|
|
101
|
+
{"unknown actor role", func(e *Event) { e.ActorRole = "ROOT" }, nil},
|
|
102
|
+
{"bad before", func(e *Event) { e.Before = json.RawMessage(`{`) }, nil},
|
|
103
|
+
{"bad after", func(e *Event) { e.After = json.RawMessage(`[`) }, nil},
|
|
104
|
+
}
|
|
105
|
+
for _, tc := range tests {
|
|
106
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
107
|
+
e := ok
|
|
108
|
+
tc.mod(&e)
|
|
109
|
+
ctx := tc.ctx
|
|
110
|
+
if ctx == nil {
|
|
111
|
+
ctx = context.Background()
|
|
112
|
+
}
|
|
113
|
+
// A nil execer would panic if validation did not stop first.
|
|
114
|
+
if err := insert(ctx, nil, e); err == nil {
|
|
115
|
+
t.Error("want error")
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func TestRecordNeedsTransaction(t *testing.T) {
|
|
122
|
+
var tx *sql.Tx
|
|
123
|
+
if err := New(nil, nil).Record(context.Background(), tx, Event{Action: AuthLogin, Outcome: Success}); err == nil {
|
|
124
|
+
t.Error("want error for nil tx")
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func TestRecordRejectedRefusesSuccess(t *testing.T) {
|
|
129
|
+
s := New(nil, nil) // a nil DB would panic if it got that far
|
|
130
|
+
s.RecordRejected(context.Background(), Event{Action: AuthLogin, Outcome: Success})
|
|
131
|
+
if s.RejectedFailures() != 1 {
|
|
132
|
+
t.Errorf("failures = %d, want 1", s.RejectedFailures())
|
|
133
|
+
}
|
|
134
|
+
}
|