|
| 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