Skip to content

Commit 95d2cd4

Browse files
Merge pull request #8 from crydensync/feat/tier5-users-admin-surface
Feat/tier5 users admin surface
2 parents 58a3415 + 9ba061a commit 95d2cd4

20 files changed

Lines changed: 3805 additions & 38 deletions

README.md

Lines changed: 77 additions & 6 deletions
Large diffs are not rendered by default.

anomalyreview/memory.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package anomalyreview
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"sync"
7+
"time"
8+
)
9+
10+
// MemoryStore is the in-process Store, for tests and for any embedding
11+
// host that wants the review surface without a database behind it.
12+
//
13+
// It is a faithful double, which here means one specific thing: it has to
14+
// refuse a review of an event that does not exist, because Postgres does
15+
// — through the foreign key on audit_events. A double that accepted any
16+
// id would let a test assert a 404 that production never produces, and
17+
// the branch that actually runs in production would be the untested one.
18+
//
19+
// So it refuses, and it needs to be told what exists. RegisterEvents is
20+
// how. That is a real seam rather than a wart: cryden's AuditStore has no
21+
// lookup-by-event-id, so this package cannot ask the engine whether an
22+
// event is real even in production — the database answers instead, and
23+
// the double answers from what the test declared.
24+
type MemoryStore struct {
25+
mu sync.RWMutex
26+
// rows is keyed by audit event id. A missing key is unreviewed.
27+
rows map[string]Review
28+
// known is the set of audit event ids that exist. Postgres reads this
29+
// from audit_events via the foreign key; here a test supplies it.
30+
known map[string]bool
31+
}
32+
33+
func NewMemoryStore() *MemoryStore {
34+
return &MemoryStore{
35+
rows: make(map[string]Review),
36+
known: make(map[string]bool),
37+
}
38+
}
39+
40+
var _ Store = (*MemoryStore)(nil)
41+
42+
// RegisterEvents declares that these audit event ids exist, so that Set
43+
// will accept a review of them and refuse one of anything else. It is the
44+
// memory store's stand-in for the foreign key, and is not part of Store
45+
// because nothing in production needs it — Postgres answers the same
46+
// question from the table itself.
47+
func (s *MemoryStore) RegisterEvents(eventIDs ...string) {
48+
s.mu.Lock()
49+
defer s.mu.Unlock()
50+
for _, id := range eventIDs {
51+
s.known[id] = true
52+
}
53+
}
54+
55+
func (s *MemoryStore) StatusesFor(_ context.Context, eventIDs []string) (map[string]Review, error) {
56+
s.mu.RLock()
57+
defer s.mu.RUnlock()
58+
59+
out := make(map[string]Review, len(eventIDs))
60+
for _, id := range eventIDs {
61+
if review, ok := s.rows[id]; ok {
62+
out[id] = review
63+
}
64+
}
65+
return out, nil
66+
}
67+
68+
func (s *MemoryStore) Set(_ context.Context, eventID string, status Status, note, reviewerID string) (Review, error) {
69+
// Validated with the same call the Postgres store makes, not a
70+
// reimplementation of it, so a status cannot hold in one store and
71+
// not the other.
72+
if err := ValidateStatus(status); err != nil {
73+
return Review{}, err
74+
}
75+
if len(note) > maxNoteLength {
76+
return Review{}, fmt.Errorf("%w: %d characters, the limit is %d", ErrNoteTooLong, len(note), maxNoteLength)
77+
}
78+
79+
s.mu.Lock()
80+
defer s.mu.Unlock()
81+
82+
if !s.known[eventID] {
83+
return Review{}, fmt.Errorf("%w: %s", ErrNoSuchEvent, eventID)
84+
}
85+
86+
review := Review{
87+
EventID: eventID,
88+
Status: status,
89+
Note: note,
90+
ReviewerID: reviewerID,
91+
UpdatedAt: time.Now(),
92+
}
93+
s.rows[eventID] = review
94+
return review, nil
95+
}
96+
97+
// Status is a test helper the PostgresStore has no equivalent for: the
98+
// memory store can answer "what did we decide about this event" without a
99+
// round trip, which keeps a failing assertion readable. Not part of
100+
// Store, because nothing in production reads one row at a time.
101+
func (s *MemoryStore) Status(eventID string) (Status, bool) {
102+
s.mu.RLock()
103+
defer s.mu.RUnlock()
104+
review, ok := s.rows[eventID]
105+
return review.Status, ok
106+
}
107+
108+
// Len is how many events have a recorded review, of any status.
109+
func (s *MemoryStore) Len() int {
110+
s.mu.RLock()
111+
defer s.mu.RUnlock()
112+
return len(s.rows)
113+
}

anomalyreview/store.go

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
// Package anomalyreview records what a human decided about an event
2+
// cryden flagged, and owns the rule about which decisions exist.
3+
//
4+
// It exists because the engine stops one step short of it. cryden
5+
// records that a login tripped anomaly signals, or that one IP sprayed
6+
// many accounts, and it says so in prose: an anomaly event "annotates a
7+
// login that was allowed to proceed, it is never a rejection". What it
8+
// has no concept of is a person having read one — so "we looked at this
9+
// and it was fine" lives here, in this repo's own table
10+
// (migrations/014_reviewed_anomalies.up.sql), keyed on the audit event
11+
// id.
12+
//
13+
// Two properties are the whole design, and both are about not losing
14+
// evidence:
15+
//
16+
// - A review is a row in this table, never a write to cryden's audit
17+
// history. The event reads exactly the same before and after
18+
// somebody dismisses it.
19+
// - Nothing here deletes. Dismissing sets a status; withdrawing a
20+
// judgement sets it back to StatusUnreviewed, which is stored rather
21+
// than represented by a missing row, so the record that someone
22+
// looked and who they were survives the change of mind.
23+
//
24+
// This is a write, and it is deliberately not in tension with CLAUDE.md's
25+
// read-only rule. That rule is about the AI-assisted tools, which are
26+
// read-only because the interfaces they are built from carry no method
27+
// that can act. This package is not reachable from any of them: it is
28+
// the console's own record of an operator's judgement, the same shape as
29+
// the per-user metadata endpoints beside it.
30+
package anomalyreview
31+
32+
import (
33+
"context"
34+
"database/sql"
35+
"errors"
36+
"fmt"
37+
"time"
38+
39+
"github.com/lib/pq"
40+
)
41+
42+
// The three ways a call here can be refused.
43+
var (
44+
// ErrNoSuchEvent means there is no audit event with that id, so
45+
// there is nothing to review. Reported rather than stored, so a
46+
// console that acted on a stale list is told instead of being shown
47+
// a success that annotates nothing.
48+
ErrNoSuchEvent = errors.New("anomalyreview: no audit event with that id")
49+
// ErrInvalidStatus means the status is not one this api defines.
50+
ErrInvalidStatus = errors.New("anomalyreview: unknown review status")
51+
// ErrNoteTooLong means the note is longer than a note needs to be.
52+
ErrNoteTooLong = errors.New("anomalyreview: note is too long")
53+
)
54+
55+
// Status is one reviewer's judgement about one flagged event.
56+
type Status string
57+
58+
const (
59+
// StatusUnreviewed is "nobody has called this, or the last call was
60+
// withdrawn". It is a stored value rather than the absence of a row,
61+
// which is what lets a withdrawn judgement keep its attribution
62+
// instead of erasing itself.
63+
StatusUnreviewed Status = "unreviewed"
64+
65+
// StatusConfirmed means an operator read the event and judged it a
66+
// real incident. It records the judgement and nothing else — no
67+
// action follows from it automatically, because there is nothing in
68+
// this repo that can act on an account beyond what an operator does
69+
// by hand.
70+
StatusConfirmed Status = "confirmed"
71+
72+
// StatusDismissed means an operator read the event and judged it
73+
// noise. The event stays exactly where it was; only this table
74+
// changes.
75+
StatusDismissed Status = "dismissed"
76+
)
77+
78+
// Statuses returns every status this api defines, in the order a console
79+
// should offer them.
80+
func Statuses() []Status {
81+
return []Status{StatusUnreviewed, StatusConfirmed, StatusDismissed}
82+
}
83+
84+
// maxNoteLength bounds a note. Bounded because an unbounded free-text
85+
// field on an admin endpoint is a way to fill a table through a form,
86+
// and generous because the point of the field is that an operator can
87+
// explain themselves.
88+
const maxNoteLength = 500
89+
90+
// ValidateStatus is the rule about which decisions exist, in one place.
91+
// Both stores call it before they write, the same discipline
92+
// usermeta.ValidateKey uses: a future caller — a second endpoint, a
93+
// bootstrap command, a migration — goes through a Store and cannot get a
94+
// different answer by not knowing about this function.
95+
//
96+
// It does not trust the CHECK constraint to be the rule. The constraint
97+
// is the backstop that no writer can bypass; this is the version that can
98+
// say which value was wrong and why.
99+
func ValidateStatus(status Status) error {
100+
switch status {
101+
case StatusUnreviewed, StatusConfirmed, StatusDismissed:
102+
return nil
103+
default:
104+
return fmt.Errorf("%w: %q must be one of %q, %q or %q",
105+
ErrInvalidStatus, status, StatusUnreviewed, StatusConfirmed, StatusDismissed)
106+
}
107+
}
108+
109+
// Review is one recorded decision about one flagged event.
110+
type Review struct {
111+
// EventID is cryden's audit event id — the primary key here, and the
112+
// only key the console ever shows an operator.
113+
EventID string
114+
115+
Status Status
116+
117+
// Note is the reviewer's own words. Empty is normal.
118+
Note string
119+
120+
// ReviewerID is the operator who made the call, empty when the
121+
// account behind it has since been deleted (the column is ON DELETE
122+
// SET NULL). An empty value here therefore means "attributed to an
123+
// account that no longer exists", never "unattributed" — a review is
124+
// only ever written by an authenticated operator.
125+
ReviewerID string
126+
127+
// UpdatedAt is when this decision replaced the previous one.
128+
UpdatedAt time.Time
129+
}
130+
131+
// Store is the persistence this package offers. An interface with two
132+
// implementations for the same reason every other store in this repo
133+
// has one: the handlers have to be testable without a database, and a
134+
// double written against the same contract is the only honest way to do
135+
// that.
136+
type Store interface {
137+
// StatusesFor returns the review of each id in eventIDs that has
138+
// one. An id with no row is absent from the map, and absent means
139+
// unreviewed — the same thing a missing row has always meant here.
140+
//
141+
// Takes a slice rather than one id because its only caller is a list
142+
// endpoint annotating a page of events; a per-event call would be
143+
// one query per row on the busiest read on this surface.
144+
StatusesFor(ctx context.Context, eventIDs []string) (map[string]Review, error)
145+
146+
// Set records one decision, creating it or replacing the previous
147+
// one. Returns the stored review, so a caller can answer with what
148+
// actually landed rather than with what it hoped would.
149+
//
150+
// A status of StatusUnreviewed is a real write, not a delete: it
151+
// records that this operator withdrew the last judgement, and when.
152+
Set(ctx context.Context, eventID string, status Status, note, reviewerID string) (Review, error)
153+
}
154+
155+
// PostgresStore is the real store. Constructed once in main.go with the
156+
// same *sql.DB every other store in this repo gets.
157+
type PostgresStore struct {
158+
db *sql.DB
159+
}
160+
161+
func NewStore(db *sql.DB) *PostgresStore {
162+
return &PostgresStore{db: db}
163+
}
164+
165+
var _ Store = (*PostgresStore)(nil)
166+
167+
func (s *PostgresStore) StatusesFor(ctx context.Context, eventIDs []string) (map[string]Review, error) {
168+
// Short-circuited rather than sent as an empty array: `= ANY('{}')`
169+
// is a valid query that matches nothing, so this is not a
170+
// correctness fix, it is a round trip an empty page does not need.
171+
if len(eventIDs) == 0 {
172+
return map[string]Review{}, nil
173+
}
174+
175+
rows, err := s.db.QueryContext(ctx, `
176+
SELECT event_id, status, note, reviewer_id, updated_at
177+
FROM reviewed_anomalies
178+
WHERE event_id = ANY($1)
179+
`, pq.Array(eventIDs))
180+
if err != nil {
181+
return nil, err
182+
}
183+
defer rows.Close()
184+
185+
out := make(map[string]Review, len(eventIDs))
186+
for rows.Next() {
187+
review, err := scanReview(rows)
188+
if err != nil {
189+
return nil, err
190+
}
191+
out[review.EventID] = review
192+
}
193+
return out, rows.Err()
194+
}
195+
196+
func (s *PostgresStore) Set(ctx context.Context, eventID string, status Status, note, reviewerID string) (Review, error) {
197+
if err := ValidateStatus(status); err != nil {
198+
return Review{}, err
199+
}
200+
if len(note) > maxNoteLength {
201+
return Review{}, fmt.Errorf("%w: %d characters, the limit is %d", ErrNoteTooLong, len(note), maxNoteLength)
202+
}
203+
204+
// Empty is stored as SQL NULL rather than as an empty uuid, matching
205+
// cryden's own audit store: "" is not a uuid, and a real NULL is the
206+
// honest representation of "this account is gone".
207+
var reviewer sql.NullString
208+
if reviewerID != "" {
209+
reviewer = sql.NullString{String: reviewerID, Valid: true}
210+
}
211+
212+
// One statement rather than a read-then-write, so two operators
213+
// deciding on the same event at the same moment cannot lose one of
214+
// the decisions. RETURNING is what makes the answer the stored row
215+
// rather than a reconstruction of it.
216+
row := s.db.QueryRowContext(ctx, `
217+
INSERT INTO reviewed_anomalies (event_id, status, note, reviewer_id, updated_at)
218+
VALUES ($1, $2, $3, $4, now())
219+
ON CONFLICT (event_id) DO UPDATE
220+
SET status = EXCLUDED.status, note = EXCLUDED.note,
221+
reviewer_id = EXCLUDED.reviewer_id, updated_at = now()
222+
RETURNING event_id, status, note, reviewer_id, updated_at
223+
`, eventID, string(status), note, reviewer)
224+
225+
review, err := scanReview(row)
226+
if err != nil {
227+
// The foreign key doing the job this repo cannot do in Go:
228+
// cryden exposes no lookup-by-event-id, so "does this event
229+
// exist" is answered by the database refusing the write. A
230+
// caller reads it as a 404.
231+
//
232+
// Note what this does NOT catch: an id that is not a uuid at all
233+
// fails as an invalid-input-syntax error, not as a foreign-key
234+
// violation, so it stays a 500 here. That is deliberate — a
235+
// malformed path segment is refused by looksLikeUUID in the
236+
// handler, where it is an input-shape question, and reaching
237+
// Postgres with one is a bug in this repo rather than a bad
238+
// request.
239+
var pqErr *pq.Error
240+
if errors.As(err, &pqErr) && pqErr.Code == foreignKeyViolation {
241+
return Review{}, fmt.Errorf("%w: %s", ErrNoSuchEvent, eventID)
242+
}
243+
return Review{}, err
244+
}
245+
return review, nil
246+
}
247+
248+
// foreignKeyViolation is SQLSTATE 23503, matched by code rather than by
249+
// message — the same reason aiprovider's privilege check matches 42501
250+
// by code: the message is localized and version-dependent, the code is
251+
// specified.
252+
const foreignKeyViolation = "23503"
253+
254+
// rowScanner is the part of *sql.Row and *sql.Rows that scanReview
255+
// needs, so one scan serves both the SELECT and the INSERT ... RETURNING.
256+
type rowScanner interface {
257+
Scan(dest ...any) error
258+
}
259+
260+
func scanReview(src rowScanner) (Review, error) {
261+
var (
262+
review Review
263+
status string
264+
reviewer sql.NullString
265+
)
266+
if err := src.Scan(&review.EventID, &status, &review.Note, &reviewer, &review.UpdatedAt); err != nil {
267+
return Review{}, err
268+
}
269+
review.Status = Status(status)
270+
if reviewer.Valid {
271+
review.ReviewerID = reviewer.String
272+
}
273+
return review, nil
274+
}

0 commit comments

Comments
 (0)