@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,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
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
package audit
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"log/slog"
|
|
9
|
+
"sync/atomic"
|
|
10
|
+
|
|
11
|
+
"github.com/google/uuid"
|
|
12
|
+
|
|
13
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
|
|
14
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// Service writes audit rows.
|
|
19
|
+
type Service struct {
|
|
20
|
+
db *sql.DB
|
|
21
|
+
log *slog.Logger
|
|
22
|
+
|
|
23
|
+
rejectedFailures atomic.Uint64
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// New returns a Service. log may be nil.
|
|
27
|
+
func New(database *sql.DB, log *slog.Logger) *Service {
|
|
28
|
+
if log == nil {
|
|
29
|
+
log = slog.New(slog.DiscardHandler)
|
|
30
|
+
}
|
|
31
|
+
return &Service{db: database, log: log}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// RejectedFailures counts track-B rows that could not be written (the
|
|
35
|
+
// audit-write-failures metric of PRD §10 reads this).
|
|
36
|
+
func (s *Service) RejectedFailures() uint64 { return s.rejectedFailures.Load() }
|
|
37
|
+
|
|
38
|
+
// Record is track A: it inserts the row in the caller's transaction, so the row
|
|
39
|
+
// commits or rolls back with the business write. An error must make the caller
|
|
40
|
+
// roll back (fail-closed). Any outcome is allowed.
|
|
41
|
+
func (s *Service) Record(ctx context.Context, tx *sql.Tx, e Event) error {
|
|
42
|
+
if tx == nil {
|
|
43
|
+
return errors.New("audit: Record needs the caller's transaction")
|
|
44
|
+
}
|
|
45
|
+
return insert(ctx, tx, e)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// RecordRejected is track B: it writes a rejected attempt (DENIED, FAILED or
|
|
49
|
+
// THROTTLED) in its own transaction. It is best effort: a failure is logged and
|
|
50
|
+
// counted, never returned, and a client disconnect does not drop the row (the
|
|
51
|
+
// transaction runs on a context detached from ctx, as db.InTx does). Only the
|
|
52
|
+
// central error adapter calls it (Backend.md §7).
|
|
53
|
+
func (s *Service) RecordRejected(ctx context.Context, e Event) {
|
|
54
|
+
if e.Outcome == Success {
|
|
55
|
+
s.rejectedFailed(ctx, e, errors.New("audit: RecordRejected called with outcome SUCCESS"))
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
_, err := db.InTx(ctx, s.db, func(ctx context.Context, tx *sql.Tx) error {
|
|
59
|
+
return insert(ctx, tx, e)
|
|
60
|
+
})
|
|
61
|
+
if err != nil {
|
|
62
|
+
s.rejectedFailed(ctx, e, err)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func (s *Service) rejectedFailed(ctx context.Context, e Event, err error) {
|
|
67
|
+
s.rejectedFailures.Add(1)
|
|
68
|
+
s.log.ErrorContext(ctx, "audit: rejected attempt not recorded",
|
|
69
|
+
"action", string(e.Action), "outcome", string(e.Outcome), "error", err)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type execer interface {
|
|
73
|
+
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const insertSQL = `INSERT INTO audit_log
|
|
77
|
+
(request_id, actor_emp_no, actor_role, action, outcome, target_type, target_id,
|
|
78
|
+
method, route, status, error_code, ip, user_agent, submitted_emp_no, before_json, after_json)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
80
|
+
|
|
81
|
+
// insert validates and sanitizes e, then writes it. occurred_at is the column
|
|
82
|
+
// default, i.e. database time.
|
|
83
|
+
func insert(ctx context.Context, x execer, e Event) error {
|
|
84
|
+
if !e.Action.Valid() {
|
|
85
|
+
return fmt.Errorf("audit: unknown action %q", e.Action)
|
|
86
|
+
}
|
|
87
|
+
if !e.Outcome.Valid() {
|
|
88
|
+
return fmt.Errorf("audit: unknown outcome %q", e.Outcome)
|
|
89
|
+
}
|
|
90
|
+
if e.TargetType != "" && !e.TargetType.Valid() {
|
|
91
|
+
return fmt.Errorf("audit: unknown target type %q", e.TargetType)
|
|
92
|
+
}
|
|
93
|
+
if e.TargetID != uuid.Nil && e.TargetType == "" {
|
|
94
|
+
return errors.New("audit: target ID without target type")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
role, empNo := e.ActorRole, e.ActorEmpNo
|
|
98
|
+
if role == "" {
|
|
99
|
+
role, empNo = security.ActorRoleFrom(ctx), security.ActorEmpNo(ctx)
|
|
100
|
+
}
|
|
101
|
+
switch role {
|
|
102
|
+
case security.ActorUser, security.ActorAdmin, security.ActorAnonymous, security.ActorSystem:
|
|
103
|
+
default:
|
|
104
|
+
return fmt.Errorf("audit: unknown actor role %q", role)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
before, err := cleanJSON(e.Before)
|
|
108
|
+
if err != nil {
|
|
109
|
+
return fmt.Errorf("before: %w", err)
|
|
110
|
+
}
|
|
111
|
+
after, err := cleanJSON(e.After)
|
|
112
|
+
if err != nil {
|
|
113
|
+
return fmt.Errorf("after: %w", err)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
var reqID any
|
|
117
|
+
if id, err := uuid.Parse(web.RequestIDFrom(ctx)); err == nil {
|
|
118
|
+
reqID = id.String()
|
|
119
|
+
}
|
|
120
|
+
var targetID any
|
|
121
|
+
if e.TargetID != uuid.Nil {
|
|
122
|
+
targetID = e.TargetID.String()
|
|
123
|
+
}
|
|
124
|
+
var status any
|
|
125
|
+
if e.Status > 0 {
|
|
126
|
+
status = e.Status
|
|
127
|
+
}
|
|
128
|
+
var ip any
|
|
129
|
+
if e.IP.IsValid() {
|
|
130
|
+
ip = e.IP.WithZone("").String() // INET6 has no zone
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
_, err = x.ExecContext(ctx, insertSQL,
|
|
134
|
+
reqID, nullStr(clean(empNo, maxEmpNo)), string(role), string(e.Action), string(e.Outcome),
|
|
135
|
+
nullStr(string(e.TargetType)), targetID,
|
|
136
|
+
nullStr(clean(e.Method, maxMethod)), nullStr(clean(e.Route, maxRoute)), status,
|
|
137
|
+
nullStr(clean(e.ErrorCode, maxErrorCode)), ip, nullStr(clean(e.UserAgent, maxUserAgent)),
|
|
138
|
+
nullStr(clean(e.SubmittedEmpNo, maxEmpNo)), nullBytes(before), nullBytes(after))
|
|
139
|
+
if err != nil {
|
|
140
|
+
return fmt.Errorf("audit: insert: %w", err)
|
|
141
|
+
}
|
|
142
|
+
return nil
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
func nullStr(s string) any {
|
|
146
|
+
if s == "" {
|
|
147
|
+
return nil
|
|
148
|
+
}
|
|
149
|
+
return s
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func nullBytes(b []byte) any {
|
|
153
|
+
if b == nil {
|
|
154
|
+
return nil
|
|
155
|
+
}
|
|
156
|
+
return string(b) // JSON column: send text, not a binary blob
|
|
157
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"log/slog"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/netip"
|
|
10
|
+
|
|
11
|
+
"github.com/go-chi/chi/v5"
|
|
12
|
+
|
|
13
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
14
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// Rejecter writes track-B audit rows. *audit.Service implements it.
|
|
18
|
+
type Rejecter interface {
|
|
19
|
+
RecordRejected(ctx context.Context, e audit.Event)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ErrorAdapter is the one place that turns a handler's error into a response
|
|
23
|
+
// and, when the error asks for it, into a track-B audit row (Backend.md §7).
|
|
24
|
+
// No other code calls RecordRejected.
|
|
25
|
+
type ErrorAdapter struct {
|
|
26
|
+
Audit Rejecter
|
|
27
|
+
Log *slog.Logger // nil = discard
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Write answers err as problem+json. When err wraps a *web.Error with an
|
|
31
|
+
// AuditReason, the rejected attempt is recorded first (best effort: the row's
|
|
32
|
+
// failure never changes the response). Errors without a reason are not audited,
|
|
33
|
+
// and neither is anything that is not a *web.Error (it answers 500).
|
|
34
|
+
func (a ErrorAdapter) Write(w http.ResponseWriter, r *http.Request, err error) {
|
|
35
|
+
var e *web.Error
|
|
36
|
+
if !errors.As(err, &e) {
|
|
37
|
+
web.WriteError(w, r, err)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
if e.AuditReason != "" {
|
|
41
|
+
a.record(r, e)
|
|
42
|
+
}
|
|
43
|
+
web.WriteProblem(w, r, e)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func (a ErrorAdapter) record(r *http.Request, e *web.Error) {
|
|
47
|
+
log := a.Log
|
|
48
|
+
if log == nil {
|
|
49
|
+
log = slog.New(slog.DiscardHandler)
|
|
50
|
+
}
|
|
51
|
+
if a.Audit == nil || e.AuditAction == "" {
|
|
52
|
+
// A programming error: the attempt cannot be recorded. Say so loudly,
|
|
53
|
+
// but still answer the client.
|
|
54
|
+
log.ErrorContext(r.Context(), "audit: rejected attempt without action or recorder",
|
|
55
|
+
"code", string(e.Code), "reason", e.AuditReason)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
outcome := audit.Outcome(e.AuditOutcome)
|
|
59
|
+
if outcome == "" {
|
|
60
|
+
outcome = audit.Denied
|
|
61
|
+
}
|
|
62
|
+
after, _ := json.Marshal(map[string]string{"reason": e.AuditReason}) // map of strings cannot fail
|
|
63
|
+
ev := audit.Event{
|
|
64
|
+
Action: audit.Action(e.AuditAction),
|
|
65
|
+
Outcome: outcome,
|
|
66
|
+
TargetType: audit.TargetType(e.AuditTargetType),
|
|
67
|
+
TargetID: e.AuditTargetID,
|
|
68
|
+
Method: r.Method,
|
|
69
|
+
Route: routeTemplate(r),
|
|
70
|
+
Status: e.Status,
|
|
71
|
+
ErrorCode: string(e.Code),
|
|
72
|
+
IP: remoteIP(r),
|
|
73
|
+
UserAgent: r.UserAgent(),
|
|
74
|
+
After: after,
|
|
75
|
+
}
|
|
76
|
+
a.Audit.RecordRejected(r.Context(), ev)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// routeTemplate is the matched chi pattern (/api/v1/ideas/{id}), never the raw
|
|
80
|
+
// path, so IDs do not leak into the log and rows group by route.
|
|
81
|
+
func routeTemplate(r *http.Request) string {
|
|
82
|
+
if rc := chi.RouteContext(r.Context()); rc != nil {
|
|
83
|
+
return rc.RoutePattern()
|
|
84
|
+
}
|
|
85
|
+
return ""
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// remoteIP is the TCP peer. Trusted-proxy handling is not decided yet.
|
|
89
|
+
func remoteIP(r *http.Request) netip.Addr {
|
|
90
|
+
ap, err := netip.ParseAddrPort(r.RemoteAddr)
|
|
91
|
+
if err != nil {
|
|
92
|
+
return netip.Addr{}
|
|
93
|
+
}
|
|
94
|
+
return ap.Addr().Unmap()
|
|
95
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
package server_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/http/httptest"
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
|
|
12
|
+
"github.com/go-chi/chi/v5"
|
|
13
|
+
"github.com/google/uuid"
|
|
14
|
+
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
16
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/server"
|
|
17
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
type fakeRejecter struct{ got []audit.Event }
|
|
21
|
+
|
|
22
|
+
func (f *fakeRejecter) RecordRejected(_ context.Context, e audit.Event) { f.got = append(f.got, e) }
|
|
23
|
+
|
|
24
|
+
func serve(t *testing.T, ad server.ErrorAdapter, err error) *httptest.ResponseRecorder {
|
|
25
|
+
t.Helper()
|
|
26
|
+
r := chi.NewRouter()
|
|
27
|
+
r.Get("/ideas/{id}", func(w http.ResponseWriter, req *http.Request) { ad.Write(w, req, err) })
|
|
28
|
+
req := httptest.NewRequest("GET", "/ideas/123", nil)
|
|
29
|
+
req.RemoteAddr = "192.0.2.7:5555"
|
|
30
|
+
req.Header.Set("User-Agent", "ua/1")
|
|
31
|
+
rec := httptest.NewRecorder()
|
|
32
|
+
r.ServeHTTP(rec, req)
|
|
33
|
+
return rec
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func TestAdapterWithReasonRecordsDenied(t *testing.T) {
|
|
37
|
+
id := uuid.New()
|
|
38
|
+
e := web.NewError(web.CodeNotFound, "resource not found")
|
|
39
|
+
e.AuditReason, e.AuditAction = "NOT_FOUND", "IDEA_VIEW"
|
|
40
|
+
e.AuditTargetType, e.AuditTargetID = "IDEA", id
|
|
41
|
+
f := &fakeRejecter{}
|
|
42
|
+
|
|
43
|
+
rec := serve(t, server.ErrorAdapter{Audit: f}, fmt.Errorf("wrapped: %w", e))
|
|
44
|
+
|
|
45
|
+
if rec.Code != 404 {
|
|
46
|
+
t.Fatalf("status = %d", rec.Code)
|
|
47
|
+
}
|
|
48
|
+
if len(f.got) != 1 {
|
|
49
|
+
t.Fatalf("rows = %d, want 1", len(f.got))
|
|
50
|
+
}
|
|
51
|
+
g := f.got[0]
|
|
52
|
+
if g.Outcome != audit.Denied || g.Action != audit.IdeaView || g.TargetType != audit.TargetIdea || g.TargetID != id ||
|
|
53
|
+
g.Method != "GET" || g.Route != "/ideas/{id}" || g.Status != 404 || g.ErrorCode != "NOT_FOUND" ||
|
|
54
|
+
g.IP.String() != "192.0.2.7" || g.UserAgent != "ua/1" || string(g.After) != `{"reason":"NOT_FOUND"}` {
|
|
55
|
+
t.Fatalf("event = %+v (after %s)", g, g.After)
|
|
56
|
+
}
|
|
57
|
+
if body := rec.Body.String(); contains(body, "reason") {
|
|
58
|
+
t.Fatalf("reason leaked to client: %s", body)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func TestAdapterOutcomeOverride(t *testing.T) {
|
|
63
|
+
e := web.NewError(web.CodeLoginThrottled, "")
|
|
64
|
+
e.AuditReason, e.AuditAction, e.AuditOutcome = "KEY_LOCKED", "AUTH_LOGIN", "THROTTLED"
|
|
65
|
+
f := &fakeRejecter{}
|
|
66
|
+
serve(t, server.ErrorAdapter{Audit: f}, e)
|
|
67
|
+
if len(f.got) != 1 || f.got[0].Outcome != audit.Throttled {
|
|
68
|
+
t.Fatalf("got %+v", f.got)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func TestAdapterWithoutReasonRecordsNothing(t *testing.T) {
|
|
73
|
+
f := &fakeRejecter{}
|
|
74
|
+
rec := serve(t, server.ErrorAdapter{Audit: f}, web.NewError(web.CodeEmailTaken, "x"))
|
|
75
|
+
if rec.Code != 409 || len(f.got) != 0 {
|
|
76
|
+
t.Fatalf("status %d rows %d", rec.Code, len(f.got))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func TestAdapterPlainErrorIs500AndUnaudited(t *testing.T) {
|
|
81
|
+
f := &fakeRejecter{}
|
|
82
|
+
rec := serve(t, server.ErrorAdapter{Audit: f}, errors.New("db password=hunter2"))
|
|
83
|
+
if rec.Code != 500 || len(f.got) != 0 || contains(rec.Body.String(), "hunter2") {
|
|
84
|
+
t.Fatalf("status %d rows %d body %s", rec.Code, len(f.got), rec.Body)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func TestAdapterReasonWithoutActionStillAnswers(t *testing.T) {
|
|
89
|
+
e := web.NewError(web.CodeForbidden, "no")
|
|
90
|
+
e.AuditReason = "ROLE"
|
|
91
|
+
f := &fakeRejecter{}
|
|
92
|
+
rec := serve(t, server.ErrorAdapter{Audit: f}, e)
|
|
93
|
+
if rec.Code != 403 || len(f.got) != 0 {
|
|
94
|
+
t.Fatalf("status %d rows %d", rec.Code, len(f.got))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func contains(s, sub string) bool { return strings.Contains(s, sub) }
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Package security holds the authenticated caller's identity: the Principal
|
|
2
|
+
// built by the session middleware and the context accessors every later layer
|
|
3
|
+
// uses to read it (Backend.md §6). It has no dependencies inside the module so
|
|
4
|
+
// that any package may import it.
|
|
5
|
+
package security
|
|
6
|
+
|
|
7
|
+
import "context"
|
|
8
|
+
|
|
9
|
+
// Role is an account role (PRD §3). Leader, Member and Applicant are relations
|
|
10
|
+
// to one Idea, not roles.
|
|
11
|
+
type Role string
|
|
12
|
+
|
|
13
|
+
// The two account roles.
|
|
14
|
+
const (
|
|
15
|
+
RoleUser Role = "USER"
|
|
16
|
+
RoleAdmin Role = "ADMIN"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// Valid reports whether r is one of the two account roles.
|
|
20
|
+
func (r Role) Valid() bool { return r == RoleUser || r == RoleAdmin }
|
|
21
|
+
|
|
22
|
+
// ActorRole is the role recorded in the audit log (PRD §7): an account role, or
|
|
23
|
+
// ANONYMOUS (no session) or SYSTEM (no caller, e.g. background cleanup).
|
|
24
|
+
type ActorRole string
|
|
25
|
+
|
|
26
|
+
// The audit actor roles.
|
|
27
|
+
const (
|
|
28
|
+
ActorUser ActorRole = "USER"
|
|
29
|
+
ActorAdmin ActorRole = "ADMIN"
|
|
30
|
+
ActorAnonymous ActorRole = "ANONYMOUS"
|
|
31
|
+
ActorSystem ActorRole = "SYSTEM"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// Principal is the caller resolved from a valid session. The role is read from
|
|
35
|
+
// the account on every request, never copied into the session (PRD §6.5).
|
|
36
|
+
type Principal struct {
|
|
37
|
+
UserID int64
|
|
38
|
+
EmpNo string
|
|
39
|
+
Role Role
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// IsAdmin reports whether the principal has role ADMIN.
|
|
43
|
+
func (p Principal) IsAdmin() bool { return p.Role == RoleAdmin }
|
|
44
|
+
|
|
45
|
+
// ActorRole maps the account role to its audit value. An invalid role maps to
|
|
46
|
+
// ANONYMOUS so an unexpected value is never recorded as privileged.
|
|
47
|
+
func (p Principal) ActorRole() ActorRole {
|
|
48
|
+
switch p.Role {
|
|
49
|
+
case RoleUser:
|
|
50
|
+
return ActorUser
|
|
51
|
+
case RoleAdmin:
|
|
52
|
+
return ActorAdmin
|
|
53
|
+
}
|
|
54
|
+
return ActorAnonymous
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type ctxKey int
|
|
58
|
+
|
|
59
|
+
const (
|
|
60
|
+
principalKey ctxKey = iota
|
|
61
|
+
systemKey
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
// WithPrincipal returns a context carrying p.
|
|
65
|
+
func WithPrincipal(ctx context.Context, p Principal) context.Context {
|
|
66
|
+
return context.WithValue(ctx, principalKey, p)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// PrincipalFrom returns the principal in ctx; ok is false when there is none.
|
|
70
|
+
func PrincipalFrom(ctx context.Context) (p Principal, ok bool) {
|
|
71
|
+
p, ok = ctx.Value(principalKey).(Principal)
|
|
72
|
+
return p, ok
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// WithSystem marks ctx as a system action (no caller). A principal in ctx still
|
|
76
|
+
// wins, so a request can never be recorded as SYSTEM by accident.
|
|
77
|
+
func WithSystem(ctx context.Context) context.Context {
|
|
78
|
+
return context.WithValue(ctx, systemKey, true)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ActorRoleFrom returns the audit actor role for ctx: the principal's role,
|
|
82
|
+
// else SYSTEM if marked with WithSystem, else ANONYMOUS.
|
|
83
|
+
func ActorRoleFrom(ctx context.Context) ActorRole {
|
|
84
|
+
if p, ok := PrincipalFrom(ctx); ok {
|
|
85
|
+
return p.ActorRole()
|
|
86
|
+
}
|
|
87
|
+
if sys, _ := ctx.Value(systemKey).(bool); sys {
|
|
88
|
+
return ActorSystem
|
|
89
|
+
}
|
|
90
|
+
return ActorAnonymous
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ActorEmpNo returns the caller's employee ID for the audit log, or "" when
|
|
94
|
+
// there is no principal.
|
|
95
|
+
func ActorEmpNo(ctx context.Context) string {
|
|
96
|
+
p, _ := PrincipalFrom(ctx)
|
|
97
|
+
return p.EmpNo
|
|
98
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
package security_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestPrincipalRoundTrip(t *testing.T) {
|
|
11
|
+
ctx := context.Background()
|
|
12
|
+
if _, ok := security.PrincipalFrom(ctx); ok {
|
|
13
|
+
t.Fatal("empty context must have no principal")
|
|
14
|
+
}
|
|
15
|
+
want := security.Principal{UserID: 7, EmpNo: "A123", Role: security.RoleUser}
|
|
16
|
+
got, ok := security.PrincipalFrom(security.WithPrincipal(ctx, want))
|
|
17
|
+
if !ok || got != want {
|
|
18
|
+
t.Fatalf("got %+v, %v", got, ok)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func TestActorRole(t *testing.T) {
|
|
23
|
+
bg := context.Background()
|
|
24
|
+
user := security.Principal{UserID: 1, EmpNo: "U1", Role: security.RoleUser}
|
|
25
|
+
admin := security.Principal{UserID: 2, EmpNo: "A1", Role: security.RoleAdmin}
|
|
26
|
+
bad := security.Principal{UserID: 3, EmpNo: "X", Role: "ROOT"}
|
|
27
|
+
|
|
28
|
+
tests := []struct {
|
|
29
|
+
name string
|
|
30
|
+
ctx context.Context
|
|
31
|
+
want security.ActorRole
|
|
32
|
+
emp string
|
|
33
|
+
}{
|
|
34
|
+
{"anonymous", bg, security.ActorAnonymous, ""},
|
|
35
|
+
{"user", security.WithPrincipal(bg, user), security.ActorUser, "U1"},
|
|
36
|
+
{"admin", security.WithPrincipal(bg, admin), security.ActorAdmin, "A1"},
|
|
37
|
+
{"invalid role is never privileged", security.WithPrincipal(bg, bad), security.ActorAnonymous, "X"},
|
|
38
|
+
{"system", security.WithSystem(bg), security.ActorSystem, ""},
|
|
39
|
+
{"principal wins over system", security.WithSystem(security.WithPrincipal(bg, user)), security.ActorUser, "U1"},
|
|
40
|
+
}
|
|
41
|
+
for _, tc := range tests {
|
|
42
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
43
|
+
if got := security.ActorRoleFrom(tc.ctx); got != tc.want {
|
|
44
|
+
t.Errorf("ActorRoleFrom = %q, want %q", got, tc.want)
|
|
45
|
+
}
|
|
46
|
+
if got := security.ActorEmpNo(tc.ctx); got != tc.emp {
|
|
47
|
+
t.Errorf("ActorEmpNo = %q, want %q", got, tc.emp)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func TestRole(t *testing.T) {
|
|
54
|
+
if !security.RoleUser.Valid() || !security.RoleAdmin.Valid() || security.Role("").Valid() || security.Role("ROOT").Valid() {
|
|
55
|
+
t.Error("Valid wrong")
|
|
56
|
+
}
|
|
57
|
+
if !(security.Principal{Role: security.RoleAdmin}).IsAdmin() || (security.Principal{Role: security.RoleUser}).IsAdmin() {
|
|
58
|
+
t.Error("IsAdmin wrong")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -3,6 +3,8 @@ package web
|
|
|
3
3
|
import (
|
|
4
4
|
"encoding/json"
|
|
5
5
|
"net/http"
|
|
6
|
+
|
|
7
|
+
"github.com/google/uuid"
|
|
6
8
|
)
|
|
7
9
|
|
|
8
10
|
// ProblemContentType is the RFC 9457 media type.
|
|
@@ -16,15 +18,29 @@ type FieldError struct {
|
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
// Error is the one error type handlers and services return. The central
|
|
19
|
-
// adapter turns it into problem+json;
|
|
20
|
-
//
|
|
21
|
+
// adapter turns it into problem+json; a non-empty AuditReason makes the adapter
|
|
22
|
+
// record a track-B row (Backend.md §7). Without a reason nothing is audited.
|
|
23
|
+
//
|
|
24
|
+
// The Audit* fields are plain strings because package audit imports web.
|
|
21
25
|
type Error struct {
|
|
22
|
-
Status
|
|
23
|
-
Code
|
|
24
|
-
Detail
|
|
25
|
-
Fields
|
|
26
|
+
Status int
|
|
27
|
+
Code ErrorCode
|
|
28
|
+
Detail string
|
|
29
|
+
Fields []FieldError
|
|
30
|
+
Cause error
|
|
31
|
+
|
|
32
|
+
// AuditReason is the real reason of a rejected attempt (CSRF, role, state,
|
|
33
|
+
// not-yours, not-found). It is stored in the row's after_json, never sent
|
|
34
|
+
// to the client.
|
|
26
35
|
AuditReason string
|
|
27
|
-
|
|
36
|
+
// AuditAction is the audit action of the attempted operation, e.g.
|
|
37
|
+
// "IDEA_VIEW". Required whenever AuditReason is set.
|
|
38
|
+
AuditAction string
|
|
39
|
+
// AuditOutcome is DENIED, FAILED or THROTTLED; empty means DENIED.
|
|
40
|
+
AuditOutcome string
|
|
41
|
+
// AuditTargetType and AuditTargetID name the resource, when there is one.
|
|
42
|
+
AuditTargetType string
|
|
43
|
+
AuditTargetID uuid.UUID
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
func (e *Error) Error() string {
|