Answer a subject access request and an erasure over HTTP
Some checks failed
CI / test (push) Failing after 4m38s
CI / fixture (push) Failing after 8s

memory.Held and memory.Forget were written the day the store was and had no
route, so the honest answer to "show me what you hold about this candidate" was
"a developer runs a query". That is not a compliance posture; it is a promise
with no mechanism, and a subject access request has a statutory clock.

  GET    /api/v1/memories?subject=candidate&id=…   what is held
  DELETE /api/v1/memories?subject=candidate&id=…   erase it

Each memory comes back with its author, so "an agent inferred this" and "a
recruiter wrote this" stay distinguishable — a response that flattened them
would be misleading in the one place it matters most.

THE AUTHORISATION IS THE WHOLE DESIGN, AND THE FIRST VERSION WAS WRONG. Gating
a read of candidate memories on `list` of job-applications looked right and was
not: a talent may list applications because every other read path scopes them
to their OWN rows, and this route has no row scoping — so the check passed and
the response would have carried the whole organisation's memories about
everybody. A test caught it before it shipped.

A personal memory now requires `delete` on the record it concerns, for reading
as much as for erasing. Deleting somebody's application is an administrative
capability and nothing scopes it to self, which makes it the honest proxy for
"may act on other people's records here". No new permission, and it cannot
drift from the record's own policy. Workspace facts have no personal subject
and stay at `list`.

Erasing workspace memories refuses without an explicit id: "erase everything"
is a plausible thing to want and a catastrophic thing to do by a mistyped query
string, so it is not reachable by omission. An erasure is logged at Info with
who asked and when — the row itself is redacted, so the log is the durable
record that it happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 20:35:18 +05:30
parent e90bc33d0f
commit 7d04b2f0b5
3 changed files with 300 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
package httpserver_test
import (
"net/http"
"testing"
)
/* Subject access and erasure for long-term memory.
The interesting assertions are the refusals: a route that lists what an
agent inferred about a named person is a disclosure route, and it has to be
gated on the same permission as the record itself. */
func TestMemoriesNeedASubject(t *testing.T) {
a := newAPI(t)
for _, path := range []string{
"/api/v1/memories",
"/api/v1/memories?subject=everything",
} {
/* 422, which is this API's code for a well-formed request that cannot
be acted on — see domain.Validation. */
if res := a.do(http.MethodGet, path, nil); res.code != http.StatusUnprocessableEntity {
t.Errorf("GET %s = %d, want 422", path, res.code)
}
}
}
// A memory about a person that names no person cannot be produced for them,
// so asking for "all candidate memories" is a mistake rather than a query.
func TestAPersonalSubjectNeedsAnId(t *testing.T) {
a := newAPI(t)
res := a.do(http.MethodGet, "/api/v1/memories?subject=candidate", nil)
if res.code != http.StatusUnprocessableEntity {
t.Errorf("got %d, want 422", res.code)
}
}
// Nothing held yet is an empty list, not an error: "we hold nothing about this
// person" is a valid and important answer to a subject access request.
func TestHoldingNothingIsAnEmptyList(t *testing.T) {
a := newAPI(t)
res := a.do(http.MethodGet,
"/api/v1/memories?subject=candidate&id=11111111-1111-1111-1111-111111111111", nil)
if res.code != http.StatusOK {
t.Fatalf("got %d, want 200: %v", res.code, res.body)
}
data, ok := res.body["data"].([]any)
if !ok && res.body["data"] != nil {
t.Fatalf("data is not a list: %#v", res.body["data"])
}
if len(data) != 0 {
t.Errorf("got %d memories, want none", len(data))
}
}
// THE DISCLOSURE BOUNDARY. Somebody who may not read a candidate's
// application must not be able to read what an agent inferred about them —
// that is the same disclosure by another route.
func TestAReaderWithoutTheRecordCannotReadItsMemories(t *testing.T) {
a := newAPI(t)
talent := signInAs(t, a.handler, a.h.Pool, a.orgID, "talent", "talent-mem@example.test", "talent")
res := a.as(talent, http.MethodGet,
"/api/v1/memories?subject=candidate&id=11111111-1111-1111-1111-111111111111", nil)
if res.code != http.StatusForbidden {
t.Errorf("got %d, want 403 — a talent read another person's inferred memories", res.code)
}
}
func TestAReaderWithoutDeleteCannotErase(t *testing.T) {
a := newAPI(t)
talent := signInAs(t, a.handler, a.h.Pool, a.orgID, "talent", "talent-del@example.test", "talent")
res := a.as(talent, http.MethodDelete,
"/api/v1/memories?subject=candidate&id=11111111-1111-1111-1111-111111111111", nil)
if res.code != http.StatusForbidden {
t.Errorf("got %d, want 403", res.code)
}
}
// "Erase every workspace memory" is a plausible thing to want and a
// catastrophic thing to do by a mistyped query string.
func TestErasingWorkspaceMemoriesNeedsAnExplicitId(t *testing.T) {
a := newAPI(t)
res := a.do(http.MethodDelete, "/api/v1/memories?subject=workspace", nil)
if res.code != http.StatusUnprocessableEntity {
t.Errorf("got %d, want 422 — a bare delete reached the whole workspace", res.code)
}
}
// An erasure against nothing is still a successful erasure: the caller asked
// for a state, and the state holds.
func TestErasingNothingSucceeds(t *testing.T) {
a := newAPI(t)
res := a.do(http.MethodDelete,
"/api/v1/memories?subject=candidate&id=11111111-1111-1111-1111-111111111111", nil)
if res.code != http.StatusOK {
t.Fatalf("got %d, want 200: %v", res.code, res.body)
}
}
func TestMemoriesRefuseAnAnonymousCaller(t *testing.T) {
a := newAPI(t)
res := a.doAnon(http.MethodGet,
"/api/v1/memories?subject=candidate&id=11111111-1111-1111-1111-111111111111", nil)
if res.code != http.StatusUnauthorized {
t.Errorf("got %d, want 401", res.code)
}
}