tenseleyflow/shithub / 192d41d

Browse files

Add auth audit recorder writing typed actions to auth_audit_log

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
192d41d0d511777875cf2397608eb30eebe34748
Parents
74ae64c
Tree
363a6f1

2 changed files

StatusFile+-
A internal/auth/audit/audit.go 96 0
A internal/auth/audit/audit_test.go 74 0
internal/auth/audit/audit.goadded
@@ -0,0 +1,96 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package audit writes structured rows into the auth_audit_log table for
4
+// security-relevant events. The schema is generic — actor, action, target,
5
+// meta JSON — so future sprints reuse this for permission changes,
6
+// org-membership changes, admin actions, etc.
7
+//
8
+// Callers MUST NOT put secret material in the meta JSON. The values land
9
+// unredacted in the DB; treat the table contents as confidential but
10
+// never sensitive (no plaintext passwords, no TOTP secrets, no PATs).
11
+package audit
12
+
13
+import (
14
+	"context"
15
+	"encoding/json"
16
+	"errors"
17
+	"fmt"
18
+
19
+	"github.com/jackc/pgx/v5/pgtype"
20
+
21
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
22
+)
23
+
24
+// DBTX matches sqlc's DBTX so callers can pass *pgxpool.Pool or a tx.
25
+type DBTX = usersdb.DBTX
26
+
27
+// Action is a typed action constant. Reuse these across packages so the
28
+// set stays tight and grep-able.
29
+type Action string
30
+
31
+const (
32
+	Action2FAEnabled            Action = "2fa_enabled"
33
+	Action2FADisabled           Action = "2fa_disabled"
34
+	ActionRecoveryCodesIssued   Action = "recovery_codes_issued"
35
+	ActionRecoveryCodeUsed      Action = "recovery_code_used"
36
+	ActionRecoveryRegenerated   Action = "recovery_codes_regenerated"
37
+	ActionAdminCleared2FA       Action = "admin_cleared_2fa"
38
+	ActionPasswordChanged       Action = "password_changed"
39
+	ActionPasswordReset         Action = "password_reset_consumed"
40
+	ActionLoginSucceeded        Action = "login_succeeded"
41
+	ActionLoginFailedThrottled  Action = "login_failed_throttled"
42
+	ActionAccountSuspended      Action = "account_suspended"
43
+)
44
+
45
+// Target is a typed target-type constant.
46
+type Target string
47
+
48
+const (
49
+	TargetUser Target = "user"
50
+)
51
+
52
+// Recorder writes audit rows. Bound to the sqlc queries handle.
53
+type Recorder struct {
54
+	q *usersdb.Queries
55
+}
56
+
57
+// NewRecorder constructs a recorder.
58
+func NewRecorder() *Recorder { return &Recorder{q: usersdb.New()} }
59
+
60
+// Record inserts a single audit-log row. actorID is the user_id of the
61
+// actor performing the action (use 0 for system / admin-CLI actions).
62
+// targetID is the affected entity (use 0 for actions without a single
63
+// concrete target).
64
+//
65
+// meta MUST be a JSON-serializable map; secrets and PII (tokens, raw
66
+// passwords, etc.) MUST NOT appear here.
67
+func (r *Recorder) Record(
68
+	ctx context.Context, db DBTX,
69
+	actorID int64, action Action, target Target, targetID int64, meta map[string]any,
70
+) error {
71
+	if action == "" || target == "" {
72
+		return errors.New("audit: action and target_type required")
73
+	}
74
+	if meta == nil {
75
+		meta = map[string]any{}
76
+	}
77
+	metaJSON, err := json.Marshal(meta)
78
+	if err != nil {
79
+		return fmt.Errorf("audit: marshal meta: %w", err)
80
+	}
81
+	var actorParam pgtype.Int8
82
+	if actorID != 0 {
83
+		actorParam = pgtype.Int8{Int64: actorID, Valid: true}
84
+	}
85
+	var targetParam pgtype.Int8
86
+	if targetID != 0 {
87
+		targetParam = pgtype.Int8{Int64: targetID, Valid: true}
88
+	}
89
+	return r.q.InsertAuditLog(ctx, db, usersdb.InsertAuditLogParams{
90
+		ActorID:    actorParam,
91
+		Action:     string(action),
92
+		TargetType: string(target),
93
+		TargetID:   targetParam,
94
+		Meta:       metaJSON,
95
+	})
96
+}
internal/auth/audit/audit_test.goadded
@@ -0,0 +1,74 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package audit
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"testing"
9
+
10
+	"github.com/jackc/pgx/v5/pgtype"
11
+
12
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
13
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
14
+)
15
+
16
+func pgInt(v int64) pgtype.Int8 { return pgtype.Int8{Int64: v, Valid: true} }
17
+
18
+func TestRecord_RoundTrip(t *testing.T) {
19
+	t.Parallel()
20
+	pool := dbtest.NewTestDB(t)
21
+	ctx := context.Background()
22
+	q := usersdb.New()
23
+
24
+	// Seed a user so actor_id FK is satisfied.
25
+	user, err := q.CreateUser(ctx, pool, usersdb.CreateUserParams{
26
+		Username:     "alice",
27
+		DisplayName:  "Alice",
28
+		PasswordHash: "$argon2id$v=19$m=16384,t=1,p=1$AAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
29
+	})
30
+	if err != nil {
31
+		t.Fatalf("CreateUser: %v", err)
32
+	}
33
+
34
+	r := NewRecorder()
35
+	if err := r.Record(ctx, pool, user.ID, Action2FAEnabled, TargetUser, user.ID, map[string]any{
36
+		"recovery_count": 10,
37
+	}); err != nil {
38
+		t.Fatalf("Record: %v", err)
39
+	}
40
+
41
+	rows, err := q.ListAuditLogForTarget(ctx, pool, usersdb.ListAuditLogForTargetParams{
42
+		TargetType: string(TargetUser),
43
+		TargetID:   pgInt(user.ID),
44
+		Limit:      10,
45
+	})
46
+	if err != nil {
47
+		t.Fatalf("ListAuditLogForTarget: %v", err)
48
+	}
49
+	if len(rows) != 1 {
50
+		t.Fatalf("got %d rows, want 1", len(rows))
51
+	}
52
+	if rows[0].Action != string(Action2FAEnabled) {
53
+		t.Fatalf("action = %q, want %q", rows[0].Action, Action2FAEnabled)
54
+	}
55
+	var meta map[string]any
56
+	if err := json.Unmarshal(rows[0].Meta, &meta); err != nil {
57
+		t.Fatalf("meta JSON: %v", err)
58
+	}
59
+	if meta["recovery_count"] == nil {
60
+		t.Fatalf("meta missing recovery_count: %v", meta)
61
+	}
62
+}
63
+
64
+func TestRecord_RejectsEmpty(t *testing.T) {
65
+	t.Parallel()
66
+	pool := dbtest.NewTestDB(t)
67
+	r := NewRecorder()
68
+	if err := r.Record(context.Background(), pool, 0, "", TargetUser, 0, nil); err == nil {
69
+		t.Fatal("expected error for empty action")
70
+	}
71
+	if err := r.Record(context.Background(), pool, 0, Action2FAEnabled, "", 0, nil); err == nil {
72
+		t.Fatal("expected error for empty target_type")
73
+	}
74
+}