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,191 @@
package httpserver
// Subject access and erasure for long-term memory.
//
// WHY THESE ROUTES EXIST AT ALL. memory.Held and memory.Forget were written
// the day the store was, and without a route 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: a subject
// access request has a statutory clock, and an erasure that depends on
// somebody being available is one that can be missed.
//
// WHAT AUTHORISES THEM. Memories about a candidate are read and erased by
// whoever may read and delete that candidate's application — the same policy
// row, not a new one. Inventing a `memories` permission would let the two
// drift: somebody barred from a candidate's record could still read what an
// agent inferred about them, which is the same disclosure by another route.
//
// WORKSPACE MEMORIES ARE NOT PERSONAL DATA and are listed to anyone who may
// read the organisation's own records. They are still erasable, because a
// wrong operational fact repeated into every answer is its own problem.
import (
"net/http"
"strings"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/domain"
"github.com/krow/krow-backend/go-api/internal/memory"
)
func (s *Server) routeMemories(mux *http.ServeMux) int {
if s.memories == nil {
return 0
}
mux.HandleFunc("GET /api/v1/memories", s.handleMemoriesList)
mux.HandleFunc("DELETE /api/v1/memories", s.handleMemoriesForget)
return 2
}
// memoryResource maps a subject onto the record whose permission governs it,
// and the operation that permission must allow.
//
// THE OPERATION IS THE SECURITY DECISION, and the first version got it 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 one has no row scoping — so the
// check passed and the response would have carried the whole organisation's
// memories about everybody. Caught by a test before it shipped.
//
// So a personal memory 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". It needs no new
// permission and cannot drift from the record's own policy.
//
// A workspace fact has no personal subject and no disclosure risk, so it stays
// at `list` on the organisation's own postings — the least privileged thing
// that still means "works here".
func memoryResource(subject memory.Subject) (string, domain.Op, bool) {
switch subject {
case memory.SubjectCandidate:
return "job-applications", domain.OpDelete, true
case memory.SubjectUser:
return "users", domain.OpDelete, true
case memory.SubjectWorkspace:
return "job-postings", domain.OpList, true
default:
return "", 0, false
}
}
// memoryRequest parses and authorises, or writes the error and returns false.
func (s *Server) memoryRequest(w http.ResponseWriter, r *http.Request) (
authctx.Identity, memory.Subject, string, bool,
) {
ident, err := authctx.MustFrom(r.Context())
if err != nil {
writeError(w, s.log, domain.Internal(err))
return authctx.Identity{}, "", "", false
}
subject := memory.Subject(strings.TrimSpace(r.URL.Query().Get("subject")))
subjectID := strings.TrimSpace(r.URL.Query().Get("id"))
path, op, known := memoryResource(subject)
if !known {
writeError(w, s.log, domain.Validation(
"subject must be workspace, candidate or user", map[string]string{
"subject": "required",
}))
return authctx.Identity{}, "", "", false
}
if subject != memory.SubjectWorkspace && subjectID == "" {
writeError(w, s.log, domain.Validation(
"a candidate or user subject needs an id", map[string]string{"id": "required"}))
return authctx.Identity{}, "", "", false
}
role, ok := domain.ParseRole(ident.Role)
if !ok {
s.log.Warn("memory request refused: unknown role",
"user_id", ident.UserID, "role", ident.Role)
writeError(w, s.log, domain.Forbidden())
return authctx.Identity{}, "", "", false
}
svc, ok := s.api.Get(path)
if !ok {
writeError(w, s.log, domain.Internal(errUnregisteredResource(path)))
return authctx.Identity{}, "", "", false
}
if !svc.Resource().Policy.Allows(op, role) {
s.log.Warn("memory request refused",
"user_id", ident.UserID, "role", ident.Role,
"subject", string(subject), "required_resource", path)
writeError(w, s.log, domain.Forbidden())
return authctx.Identity{}, "", "", false
}
return ident, subject, subjectID, true
}
// memoryView is one memory as a subject access request should read it.
//
// Every field a person is entitled to know: what is held, who decided it, when
// it was written, when it goes. `author` is the one that matters most — "an
// agent inferred this" and "a recruiter wrote this" are different claims and a
// response that flattened them would be misleading.
type memoryView struct {
ID string `json:"id"`
Subject string `json:"subject"`
SubjectID string `json:"subjectId,omitempty"`
Text string `json:"text"`
Author string `json:"author"`
RunID string `json:"sourceRunId,omitempty"`
Written string `json:"written"`
}
func (s *Server) handleMemoriesList(w http.ResponseWriter, r *http.Request) {
ident, subject, subjectID, ok := s.memoryRequest(w, r)
if !ok {
return
}
records, err := s.memories.Held(r.Context(), ident, subject, subjectID)
if err != nil {
writeError(w, s.log, domain.Internal(err))
return
}
out := make([]memoryView, 0, len(records))
for _, rec := range records {
out = append(out, memoryView{
ID: rec.ID, Subject: string(rec.SubjectType), SubjectID: rec.SubjectID,
Text: rec.Text, Author: string(rec.Author), RunID: rec.SourceRunID,
Written: rec.CreatedDate.UTC().Format("2006-01-02T15:04:05Z"),
})
}
writeJSON(w, http.StatusOK, envelope{Data: out})
}
func (s *Server) handleMemoriesForget(w http.ResponseWriter, r *http.Request) {
ident, subject, subjectID, ok := s.memoryRequest(w, r)
if !ok {
return
}
if subject == memory.SubjectWorkspace && subjectID == "" {
/* Refused rather than interpreted. "Erase every workspace memory" is
a plausible thing to want and a catastrophic thing to do by a
mistyped query string, so it is not reachable by omission. */
writeError(w, s.log, domain.Validation(
"erasing workspace memories needs an explicit id", map[string]string{"id": "required"}))
return
}
removed, err := s.memories.Forget(r.Context(), ident, subject, subjectID)
if err != nil {
writeError(w, s.log, domain.Internal(err))
return
}
/* Logged at Info, always. An erasure is the one memory operation somebody
may later need to prove happened, and the row itself is redacted — so
the log line is the durable record of who asked and when. */
s.log.Info("memories erased",
"tenant_id", ident.OrgID, "user_id", ident.UserID,
"subject", string(subject), "subject_id", subjectID, "removed", removed)
writeJSON(w, http.StatusOK, envelope{Data: map[string]any{
"erased": removed,
"subject": string(subject),
}})
}