@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,80 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"log/slog"
|
|
8
|
+
"maps"
|
|
9
|
+
"net/http"
|
|
10
|
+
|
|
11
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
12
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// Rejecter writes track-B audit rows. *audit.Service implements it.
|
|
16
|
+
type Rejecter interface {
|
|
17
|
+
RecordRejected(ctx context.Context, e audit.Event)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ErrorAdapter is the one place that turns a handler's error into a response
|
|
21
|
+
// and, when the error asks for it, into a track-B audit row (Backend.md §7).
|
|
22
|
+
// No other code calls RecordRejected.
|
|
23
|
+
type ErrorAdapter struct {
|
|
24
|
+
Audit Rejecter
|
|
25
|
+
Log *slog.Logger // nil = discard
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Write answers err as problem+json. When err wraps a *web.Error with an
|
|
29
|
+
// AuditReason, the rejected attempt is recorded first (best effort: the row's
|
|
30
|
+
// failure never changes the response). Errors without a reason are not audited,
|
|
31
|
+
// and neither is anything that is not a *web.Error (it answers 500).
|
|
32
|
+
func (a ErrorAdapter) Write(w http.ResponseWriter, r *http.Request, err error) {
|
|
33
|
+
var e *web.Error
|
|
34
|
+
if !errors.As(err, &e) {
|
|
35
|
+
web.WriteError(w, r, err)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
if e.AuditReason != "" {
|
|
39
|
+
a.record(r, e)
|
|
40
|
+
}
|
|
41
|
+
web.WriteProblem(w, r, e)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func (a ErrorAdapter) record(r *http.Request, e *web.Error) {
|
|
45
|
+
log := a.Log
|
|
46
|
+
if log == nil {
|
|
47
|
+
log = slog.New(slog.DiscardHandler)
|
|
48
|
+
}
|
|
49
|
+
if a.Audit == nil || e.AuditAction == "" {
|
|
50
|
+
// A programming error: the attempt cannot be recorded. Say so loudly,
|
|
51
|
+
// but still answer the client.
|
|
52
|
+
log.ErrorContext(r.Context(), "audit: rejected attempt without action or recorder",
|
|
53
|
+
"code", string(e.Code), "reason", e.AuditReason)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
outcome := audit.Outcome(e.AuditOutcome)
|
|
57
|
+
if outcome == "" {
|
|
58
|
+
outcome = audit.Denied
|
|
59
|
+
}
|
|
60
|
+
detail := map[string]string{}
|
|
61
|
+
maps.Copy(detail, e.AuditDetail)
|
|
62
|
+
detail["reason"] = e.AuditReason // the reason always wins over a detail key
|
|
63
|
+
after, _ := json.Marshal(detail) // map of strings cannot fail
|
|
64
|
+
ev := audit.Event{
|
|
65
|
+
Action: audit.Action(e.AuditAction),
|
|
66
|
+
Outcome: outcome,
|
|
67
|
+
TargetType: audit.TargetType(e.AuditTargetType),
|
|
68
|
+
TargetID: e.AuditTargetID,
|
|
69
|
+
Method: r.Method,
|
|
70
|
+
Route: web.RoutePattern(r),
|
|
71
|
+
Status: e.Status,
|
|
72
|
+
ErrorCode: string(e.Code),
|
|
73
|
+
IP: web.ClientIP(r),
|
|
74
|
+
UserAgent: r.UserAgent(),
|
|
75
|
+
|
|
76
|
+
SubmittedEmpNo: e.AuditSubmittedEmpNo,
|
|
77
|
+
After: after,
|
|
78
|
+
}
|
|
79
|
+
a.Audit.RecordRejected(r.Context(), ev)
|
|
80
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
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) }
|
|
99
|
+
|
|
100
|
+
func TestAdapterCarriesSubmittedEmpNoAndDetail(t *testing.T) {
|
|
101
|
+
e := web.NewError(web.CodeEmpNoTaken, "taken")
|
|
102
|
+
e.AuditReason, e.AuditAction, e.AuditOutcome = "emp_no_taken", "AUTH_REGISTER", "FAILED"
|
|
103
|
+
e.AuditSubmittedEmpNo = "089290"
|
|
104
|
+
e.AuditDetail = map[string]string{"empNo": "089290", "reason": "must not override"}
|
|
105
|
+
f := &fakeRejecter{}
|
|
106
|
+
|
|
107
|
+
rec := serve(t, server.ErrorAdapter{Audit: f}, e)
|
|
108
|
+
|
|
109
|
+
if rec.Code != 409 || len(f.got) != 1 {
|
|
110
|
+
t.Fatalf("status %d rows %d", rec.Code, len(f.got))
|
|
111
|
+
}
|
|
112
|
+
g := f.got[0]
|
|
113
|
+
if g.Outcome != audit.Failed || g.SubmittedEmpNo != "089290" || string(g.After) != `{"empNo":"089290","reason":"emp_no_taken"}` {
|
|
114
|
+
t.Fatalf("event = %+v (after %s)", g, g.After)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"database/sql"
|
|
6
6
|
"database/sql/driver"
|
|
7
7
|
"errors"
|
|
8
|
+
"fmt"
|
|
8
9
|
"net/http"
|
|
9
10
|
"testing"
|
|
10
11
|
"time"
|
|
@@ -221,3 +222,27 @@ func TestSQLStateClassNonMySQL(t *testing.T) {
|
|
|
221
222
|
t.Error("non-MySQL error must have no class and no contention")
|
|
222
223
|
}
|
|
223
224
|
}
|
|
225
|
+
|
|
226
|
+
func TestDuplicateKey(t *testing.T) {
|
|
227
|
+
dup := func(msg string) error { return fmt.Errorf("wrap: %w", &mysql.MySQLError{Number: 1062, Message: msg}) }
|
|
228
|
+
cases := []struct {
|
|
229
|
+
name string
|
|
230
|
+
err error
|
|
231
|
+
key string
|
|
232
|
+
ok bool
|
|
233
|
+
}{
|
|
234
|
+
{"plain", dup("Duplicate entry 'a@b' for key 'uq_app_user_email'"), "uq_app_user_email", true},
|
|
235
|
+
{"table qualified", dup("Duplicate entry '1' for key 'app_user.uq_app_user_emp_no'"), "uq_app_user_emp_no", true},
|
|
236
|
+
{"value contains the marker", dup("Duplicate entry 'for key ''x' for key 'uq_a'"), "uq_a", true},
|
|
237
|
+
{"no key in message", dup("boom"), "", true},
|
|
238
|
+
{"unterminated", dup("Duplicate entry 'x' for key 'uq_a"), "uq_a", true},
|
|
239
|
+
{"other number", &mysql.MySQLError{Number: 1205}, "", false},
|
|
240
|
+
{"not a mysql error", errors.New("x"), "", false},
|
|
241
|
+
}
|
|
242
|
+
for _, tc := range cases {
|
|
243
|
+
key, ok := DuplicateKey(tc.err)
|
|
244
|
+
if key != tc.key || ok != tc.ok {
|
|
245
|
+
t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, key, ok, tc.key, tc.ok)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -2,6 +2,7 @@ package db
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"errors"
|
|
5
|
+
"strings"
|
|
5
6
|
|
|
6
7
|
"github.com/go-sql-driver/mysql"
|
|
7
8
|
)
|
|
@@ -31,3 +32,28 @@ func sqlStateClass(err error) string {
|
|
|
31
32
|
}
|
|
32
33
|
return string(me.SQLState[:2])
|
|
33
34
|
}
|
|
35
|
+
|
|
36
|
+
const errDupEntry = 1062 // ER_DUP_ENTRY
|
|
37
|
+
|
|
38
|
+
// DuplicateKey reports whether err is a unique-key violation (1062) and names
|
|
39
|
+
// the violated key, as in "Duplicate entry 'x' for key 'uq_app_user_email'".
|
|
40
|
+
func DuplicateKey(err error) (key string, ok bool) {
|
|
41
|
+
var me *mysql.MySQLError
|
|
42
|
+
if !errors.As(err, &me) || me.Number != errDupEntry {
|
|
43
|
+
return "", false
|
|
44
|
+
}
|
|
45
|
+
const marker = "for key '"
|
|
46
|
+
i := strings.LastIndex(me.Message, marker)
|
|
47
|
+
if i < 0 {
|
|
48
|
+
return "", true
|
|
49
|
+
}
|
|
50
|
+
rest := me.Message[i+len(marker):]
|
|
51
|
+
if j := strings.IndexByte(rest, '\''); j >= 0 {
|
|
52
|
+
rest = rest[:j]
|
|
53
|
+
}
|
|
54
|
+
// MariaDB may qualify the key with the table name ("app_user.uq_x").
|
|
55
|
+
if k := strings.LastIndexByte(rest, '.'); k >= 0 {
|
|
56
|
+
rest = rest[k+1:]
|
|
57
|
+
}
|
|
58
|
+
return rest, true
|
|
59
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
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 (
|
|
8
|
+
"context"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// Role is an account role (PRD §3). Leader, Member and Applicant are relations
|
|
14
|
+
// to one Idea, not roles.
|
|
15
|
+
type Role string
|
|
16
|
+
|
|
17
|
+
// The two account roles.
|
|
18
|
+
const (
|
|
19
|
+
RoleUser Role = "USER"
|
|
20
|
+
RoleAdmin Role = "ADMIN"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// Valid reports whether r is one of the two account roles.
|
|
24
|
+
func (r Role) Valid() bool { return r == RoleUser || r == RoleAdmin }
|
|
25
|
+
|
|
26
|
+
// ActorRole is the role recorded in the audit log (PRD §7): an account role, or
|
|
27
|
+
// ANONYMOUS (no session) or SYSTEM (no caller, e.g. background cleanup).
|
|
28
|
+
type ActorRole string
|
|
29
|
+
|
|
30
|
+
// The audit actor roles.
|
|
31
|
+
const (
|
|
32
|
+
ActorUser ActorRole = "USER"
|
|
33
|
+
ActorAdmin ActorRole = "ADMIN"
|
|
34
|
+
ActorAnonymous ActorRole = "ANONYMOUS"
|
|
35
|
+
ActorSystem ActorRole = "SYSTEM"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
// Principal is the caller resolved from a valid session. The role is read from
|
|
39
|
+
// the account on every request, never copied into the session (PRD §6.5).
|
|
40
|
+
type Principal struct {
|
|
41
|
+
UserID uuid.UUID
|
|
42
|
+
EmpNo string
|
|
43
|
+
Role Role
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// IsAdmin reports whether the principal has role ADMIN.
|
|
47
|
+
func (p Principal) IsAdmin() bool { return p.Role == RoleAdmin }
|
|
48
|
+
|
|
49
|
+
// ActorRole maps the account role to its audit value. An invalid role maps to
|
|
50
|
+
// ANONYMOUS so an unexpected value is never recorded as privileged.
|
|
51
|
+
func (p Principal) ActorRole() ActorRole {
|
|
52
|
+
switch p.Role {
|
|
53
|
+
case RoleUser:
|
|
54
|
+
return ActorUser
|
|
55
|
+
case RoleAdmin:
|
|
56
|
+
return ActorAdmin
|
|
57
|
+
}
|
|
58
|
+
return ActorAnonymous
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type ctxKey int
|
|
62
|
+
|
|
63
|
+
const (
|
|
64
|
+
principalKey ctxKey = iota
|
|
65
|
+
systemKey
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
// WithPrincipal returns a context carrying p.
|
|
69
|
+
func WithPrincipal(ctx context.Context, p Principal) context.Context {
|
|
70
|
+
return context.WithValue(ctx, principalKey, p)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// PrincipalFrom returns the principal in ctx; ok is false when there is none.
|
|
74
|
+
func PrincipalFrom(ctx context.Context) (p Principal, ok bool) {
|
|
75
|
+
p, ok = ctx.Value(principalKey).(Principal)
|
|
76
|
+
return p, ok
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// WithSystem marks ctx as a system action (no caller). A principal in ctx still
|
|
80
|
+
// wins, so a request can never be recorded as SYSTEM by accident.
|
|
81
|
+
func WithSystem(ctx context.Context) context.Context {
|
|
82
|
+
return context.WithValue(ctx, systemKey, true)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ActorRoleFrom returns the audit actor role for ctx: the principal's role,
|
|
86
|
+
// else SYSTEM if marked with WithSystem, else ANONYMOUS.
|
|
87
|
+
func ActorRoleFrom(ctx context.Context) ActorRole {
|
|
88
|
+
if p, ok := PrincipalFrom(ctx); ok {
|
|
89
|
+
return p.ActorRole()
|
|
90
|
+
}
|
|
91
|
+
if sys, _ := ctx.Value(systemKey).(bool); sys {
|
|
92
|
+
return ActorSystem
|
|
93
|
+
}
|
|
94
|
+
return ActorAnonymous
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ActorEmpNo returns the caller's employee ID for the audit log, or "" when
|
|
98
|
+
// there is no principal.
|
|
99
|
+
func ActorEmpNo(ctx context.Context) string {
|
|
100
|
+
p, _ := PrincipalFrom(ctx)
|
|
101
|
+
return p.EmpNo
|
|
102
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
package security_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/google/uuid"
|
|
8
|
+
|
|
9
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestPrincipalRoundTrip(t *testing.T) {
|
|
13
|
+
ctx := context.Background()
|
|
14
|
+
if _, ok := security.PrincipalFrom(ctx); ok {
|
|
15
|
+
t.Fatal("empty context must have no principal")
|
|
16
|
+
}
|
|
17
|
+
want := security.Principal{UserID: uuid.New(), EmpNo: "A123", Role: security.RoleUser}
|
|
18
|
+
got, ok := security.PrincipalFrom(security.WithPrincipal(ctx, want))
|
|
19
|
+
if !ok || got != want {
|
|
20
|
+
t.Fatalf("got %+v, %v", got, ok)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func TestActorRole(t *testing.T) {
|
|
25
|
+
bg := context.Background()
|
|
26
|
+
user := security.Principal{UserID: uuid.New(), EmpNo: "U1", Role: security.RoleUser}
|
|
27
|
+
admin := security.Principal{UserID: uuid.New(), EmpNo: "A1", Role: security.RoleAdmin}
|
|
28
|
+
bad := security.Principal{UserID: uuid.New(), EmpNo: "X", Role: "ROOT"}
|
|
29
|
+
|
|
30
|
+
tests := []struct {
|
|
31
|
+
name string
|
|
32
|
+
ctx context.Context
|
|
33
|
+
want security.ActorRole
|
|
34
|
+
emp string
|
|
35
|
+
}{
|
|
36
|
+
{"anonymous", bg, security.ActorAnonymous, ""},
|
|
37
|
+
{"user", security.WithPrincipal(bg, user), security.ActorUser, "U1"},
|
|
38
|
+
{"admin", security.WithPrincipal(bg, admin), security.ActorAdmin, "A1"},
|
|
39
|
+
{"invalid role is never privileged", security.WithPrincipal(bg, bad), security.ActorAnonymous, "X"},
|
|
40
|
+
{"system", security.WithSystem(bg), security.ActorSystem, ""},
|
|
41
|
+
{"principal wins over system", security.WithSystem(security.WithPrincipal(bg, user)), security.ActorUser, "U1"},
|
|
42
|
+
}
|
|
43
|
+
for _, tc := range tests {
|
|
44
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
45
|
+
if got := security.ActorRoleFrom(tc.ctx); got != tc.want {
|
|
46
|
+
t.Errorf("ActorRoleFrom = %q, want %q", got, tc.want)
|
|
47
|
+
}
|
|
48
|
+
if got := security.ActorEmpNo(tc.ctx); got != tc.emp {
|
|
49
|
+
t.Errorf("ActorEmpNo = %q, want %q", got, tc.emp)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestRole(t *testing.T) {
|
|
56
|
+
if !security.RoleUser.Valid() || !security.RoleAdmin.Valid() || security.Role("").Valid() || security.Role("ROOT").Valid() {
|
|
57
|
+
t.Error("Valid wrong")
|
|
58
|
+
}
|
|
59
|
+
if !(security.Principal{Role: security.RoleAdmin}).IsAdmin() || (security.Principal{Role: security.RoleUser}).IsAdmin() {
|
|
60
|
+
t.Error("IsAdmin wrong")
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -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,35 @@ 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
|
|
44
|
+
// AuditSubmittedEmpNo is the employee ID typed into a failed registration
|
|
45
|
+
// or login; the audit package sanitizes it before storing.
|
|
46
|
+
AuditSubmittedEmpNo string
|
|
47
|
+
// AuditDetail adds keys to the row's after_json beside "reason". Callers
|
|
48
|
+
// must not put secrets here.
|
|
49
|
+
AuditDetail map[string]string
|
|
28
50
|
}
|
|
29
51
|
|
|
30
52
|
func (e *Error) Error() string {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
package web
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"net/netip"
|
|
6
|
+
|
|
7
|
+
"github.com/go-chi/chi/v5"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// RoutePattern is the matched chi pattern (/api/v1/ideas/{id}), never the raw
|
|
11
|
+
// path, so IDs do not leak into the audit log and rows group by route. It is
|
|
12
|
+
// empty when no route matched.
|
|
13
|
+
func RoutePattern(r *http.Request) string {
|
|
14
|
+
if rc := chi.RouteContext(r.Context()); rc != nil {
|
|
15
|
+
return rc.RoutePattern()
|
|
16
|
+
}
|
|
17
|
+
return ""
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ClientIP is the TCP peer. Trusted-proxy handling is not decided yet.
|
|
21
|
+
func ClientIP(r *http.Request) netip.Addr {
|
|
22
|
+
ap, err := netip.ParseAddrPort(r.RemoteAddr)
|
|
23
|
+
if err != nil {
|
|
24
|
+
return netip.Addr{}
|
|
25
|
+
}
|
|
26
|
+
return ap.Addr().Unmap()
|
|
27
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
// Package migration holds schema-level tests against a real MariaDB with the real migrations.
|
|
4
|
+
package migration_test
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"database/sql"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
|
|
11
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const hash = "$2a$10$" + "abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234" // 53 chars after the prefix
|
|
15
|
+
|
|
16
|
+
func insert(d *sql.DB, empNo, name string, email any, pw, role string) error {
|
|
17
|
+
_, err := d.Exec(`INSERT INTO app_user (emp_no, name, email, password_hash, role) VALUES (?,?,?,?,?)`,
|
|
18
|
+
empNo, name, email, pw, role)
|
|
19
|
+
return err
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func TestAppUserFieldRules(t *testing.T) {
|
|
23
|
+
d := dbtest.New(t)
|
|
24
|
+
if len(hash) != 60 {
|
|
25
|
+
t.Fatalf("test hash has length %d", len(hash))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
valid := []struct {
|
|
29
|
+
name, empNo, nm string
|
|
30
|
+
email any
|
|
31
|
+
role string
|
|
32
|
+
}{
|
|
33
|
+
{"user", "089290", "王小明", "a@b", "USER"},
|
|
34
|
+
{"user 50 code points", "000001", strings.Repeat("名", 50), "x@y.z", "USER"},
|
|
35
|
+
{"admin without email", "000002", "Admin", nil, "ADMIN"},
|
|
36
|
+
{"second admin (NULL emails do not collide)", "000003", "Admin 2", nil, "ADMIN"},
|
|
37
|
+
{"email max length", "000004", "N", strings.Repeat("a", 252) + "@b", "USER"},
|
|
38
|
+
}
|
|
39
|
+
for _, c := range valid {
|
|
40
|
+
if err := insert(d, c.empNo, c.nm, c.email, hash, c.role); err != nil {
|
|
41
|
+
t.Errorf("%s: want accepted, got %v", c.name, err)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
invalid := []struct {
|
|
46
|
+
name, empNo, nm string
|
|
47
|
+
email any
|
|
48
|
+
pw, role string
|
|
49
|
+
}{
|
|
50
|
+
{"emp_no 5 digits", "12345", "N", "e1@x", hash, "USER"},
|
|
51
|
+
{"emp_no 7 digits", "1234567", "N", "e2@x", hash, "USER"},
|
|
52
|
+
{"emp_no letter", "12345a", "N", "e3@x", hash, "USER"},
|
|
53
|
+
{"emp_no newline", "12345\n", "N", "e4@x", hash, "USER"},
|
|
54
|
+
{"emp_no fullwidth digit", "123451", "N", "e5@x", hash, "USER"},
|
|
55
|
+
{"name empty", "100001", "", "e6@x", hash, "USER"},
|
|
56
|
+
{"name 51 code points", "100002", strings.Repeat("名", 51), "e7@x", hash, "USER"},
|
|
57
|
+
{"name control char", "100003", "a\tb", "e8@x", hash, "USER"},
|
|
58
|
+
{"name slash", "100004", "a/b", "e9@x", hash, "USER"},
|
|
59
|
+
{"name backslash", "100005", `a\b`, "e10@x", hash, "USER"},
|
|
60
|
+
{"name pipe", "100006", "a|b", "e11@x", hash, "USER"},
|
|
61
|
+
{"name quote", "100007", `a"b`, "e12@x", hash, "USER"},
|
|
62
|
+
{"email upper case", "100008", "N", "A@x", hash, "USER"},
|
|
63
|
+
{"email space", "100009", "N", "a b@x", hash, "USER"},
|
|
64
|
+
{"email non-ASCII", "100010", "N", "é@x", hash, "USER"},
|
|
65
|
+
{"email two @", "100011", "N", "a@b@c", hash, "USER"},
|
|
66
|
+
{"email no @", "100012", "N", "abc", hash, "USER"},
|
|
67
|
+
{"email empty local", "100013", "N", "@x", hash, "USER"},
|
|
68
|
+
{"email empty domain", "100014", "N", "a@", hash, "USER"},
|
|
69
|
+
{"email trailing newline", "100015", "N", "a@x\n", hash, "USER"},
|
|
70
|
+
{"email 255 chars", "100016", "N", strings.Repeat("a", 253) + "@b", hash, "USER"},
|
|
71
|
+
{"user without email", "100017", "N", nil, hash, "USER"},
|
|
72
|
+
{"admin with email", "100018", "N", "ad@x", hash, "ADMIN"},
|
|
73
|
+
{"role unknown", "100019", "N", "e13@x", hash, "OWNER"},
|
|
74
|
+
{"hash cost 12", "100020", "N", "e14@x", "$2a$12$" + hash[7:], "USER"},
|
|
75
|
+
{"hash plain text", "100021", "N", "e15@x", strings.Repeat("p", 60), "USER"},
|
|
76
|
+
{"duplicate emp_no", "089290", "N", "e16@x", hash, "USER"},
|
|
77
|
+
{"duplicate email", "100022", "N", "a@b", hash, "USER"},
|
|
78
|
+
}
|
|
79
|
+
for _, c := range invalid {
|
|
80
|
+
if err := insert(d, c.empNo, c.nm, c.email, c.pw, c.role); err == nil {
|
|
81
|
+
t.Errorf("%s: want rejected, got accepted", c.name)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func TestAppUserEmailUniquenessIsByteExact(t *testing.T) {
|
|
87
|
+
d := dbtest.New(t)
|
|
88
|
+
if err := insert(d, "200001", "N", "a@x", hash, "USER"); err != nil {
|
|
89
|
+
t.Fatal(err)
|
|
90
|
+
}
|
|
91
|
+
// Upper case is rejected by the CHECK, so the lower-cased form is the only one that can collide.
|
|
92
|
+
if err := insert(d, "200002", "N", "a@x", hash, "USER"); err == nil {
|
|
93
|
+
t.Fatal("duplicate lower-cased email accepted")
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func TestAppUserDefaults(t *testing.T) {
|
|
98
|
+
d := dbtest.New(t)
|
|
99
|
+
if err := insert(d, "300001", "N", "d@x", hash, "USER"); err != nil {
|
|
100
|
+
t.Fatal(err)
|
|
101
|
+
}
|
|
102
|
+
var id string
|
|
103
|
+
var enabled bool
|
|
104
|
+
var created, updated sql.NullTime
|
|
105
|
+
if err := d.QueryRow(`SELECT id, enabled, created_at, updated_at FROM app_user WHERE emp_no='300001'`).
|
|
106
|
+
Scan(&id, &enabled, &created, &updated); err != nil {
|
|
107
|
+
t.Fatal(err)
|
|
108
|
+
}
|
|
109
|
+
if len(id) != 36 || !enabled || !created.Valid || !updated.Valid {
|
|
110
|
+
t.Fatalf("defaults wrong: id=%q enabled=%v created=%v updated=%v", id, enabled, created, updated)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -56,9 +56,9 @@ This document checks what a Go implementation looks like if the current architec
|
|
|
56
56
|
|
|
57
57
|
| # | Item |
|
|
58
58
|
|---|---|
|
|
59
|
-
| O1 | **Facts checked on Go 1.26.10.** `encoding/json` (v1) matches field names case-insensitively (`"EMPNO"` fills `empNo`) and, for a duplicate key, the last one wins. `encoding/json/v2` is **still behind `GOEXPERIMENT=jsonv2`** and cannot be imported without it, so it is not used. With the experiment on, v2 rejects duplicate keys and matches names case-sensitively, which is what the PRD wants. `Backend.md` §3 proposes the `Token()` pre-pass plus exact-name field picking. When v2 becomes stable, only the decoder in `shared/web` is replaced; the `test/binding` tests stay. Waiting for owner confirmation of the decoder design. |
|
|
60
|
-
| O2 | Exclude generated code (sqlc, oapi-codegen) from the coverage denominator? Recommended: yes. |
|
|
61
|
-
| O3 | Probe paths: keep the `/actuator/...` paths or switch to `/healthz`-style paths and update the chart. |
|
|
59
|
+
| O1 (= OI-53) | **Facts checked on Go 1.26.10.** `encoding/json` (v1) matches field names case-insensitively (`"EMPNO"` fills `empNo`) and, for a duplicate key, the last one wins. `encoding/json/v2` is **still behind `GOEXPERIMENT=jsonv2`** and cannot be imported without it, so it is not used. With the experiment on, v2 rejects duplicate keys and matches names case-sensitively, which is what the PRD wants. `Backend.md` §3 proposes the `Token()` pre-pass plus exact-name field picking. When v2 becomes stable, only the decoder in `shared/web` is replaced; the `test/binding` tests stay. Waiting for owner confirmation of the decoder design. |
|
|
60
|
+
| O2 (= OI-54) | Exclude generated code (sqlc, oapi-codegen) from the coverage denominator? Recommended: yes. |
|
|
61
|
+
| O3 (= OI-55) | Probe paths: keep the `/actuator/...` paths or switch to `/healthz`-style paths and update the chart. |
|
|
62
62
|
|
|
63
63
|
## What adoption requires
|
|
64
64
|
|