@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.
@@ -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; AuditReason (set later by the audit PRs)
20
- // makes the adapter record a track-B row.
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 int
23
- Code ErrorCode
24
- Detail string
25
- Fields []FieldError
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
- Cause error
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 {