planes: the case plane signs with the credential too

Closing a case is the oldest governed act here and the one an obligation rests
on directly (AMLR Art. 69(2)): the retained assessment is what a supervisor
reads. Its decider was bound to the credential with the rest of them, and this
adds what was missing — the tests that say so, and the mutation that says the
tests can fail.

Two properties, both already true and neither proven: the retained assessment is
signed by the token's subject and not by the `by` a request body carried, and a
credential that names nobody cannot close a case at all. The second refuses at
the assessment, which is the record that matters, and it refuses as a REFUSAL
(400) rather than as an outage — a 503 reads as "retry", and this one never
clears.

The console still renders a "by" field on its resolve form. It is now ignored,
which is the point, but a form asking for something the API discards is a lie to
the analyst filling it in: ui/ owes a one-field deletion. That repo is another
track's.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-01 16:55:32 -07:00
co-authored by Hanzo Dev
parent 7de786224f
commit 8f0cf7ac9f
2 changed files with 66 additions and 0 deletions
+61
View File
@@ -13,6 +13,7 @@ import (
"github.com/luxfi/aml/internal/instance" "github.com/luxfi/aml/internal/instance"
"github.com/luxfi/aml/pkg/lists" "github.com/luxfi/aml/pkg/lists"
"github.com/luxfi/aml/pkg/models" "github.com/luxfi/aml/pkg/models"
"github.com/luxfi/aml/pkg/retention"
"github.com/luxfi/aml/pkg/suppress" "github.com/luxfi/aml/pkg/suppress"
"github.com/luxfi/aml/pkg/types" "github.com/luxfi/aml/pkg/types"
"github.com/luxfi/aml/pkg/watch" "github.com/luxfi/aml/pkg/watch"
@@ -234,3 +235,63 @@ func reqWithPath(t *testing.T, key, value string) *http.Request {
e.Request.SetPathValue(key, value) e.Request.SetPathValue(key, value)
return e.Request return e.Request
} }
// TestClosingACaseNamesTheCredential.
//
// The case plane is the other governed act, and the oldest one: closing a case is
// the retained assessment a supervisor reads. The decider on it was free text too,
// and here the record that carries it is the one an obligation rests on
// (AMLR Art. 69(2)).
func TestClosingACaseNamesTheCredential(t *testing.T) {
h := plane(t)
h.Identity = func(*http.Request) (Caller, error) { return Caller{Tenant: acme, Subject: "u-7"}, nil }
c := h.Cases.Create(acme, types.SeverityHigh, []string{"alert-1"}, []string{"ivan-petrov-42"})
e, rec := send(http.MethodPost, "/v1/aml/cases/"+c.ID+"/resolve", map[string]any{
"resolution": types.ResolutionFalsePositive,
"considered": []string{"velocity over 30 days", "customer profile"},
"rationale": "salary payments consistent with the profile on file",
"by": "the head of compliance", // the lie
})
e.Request.SetPathValue("id", c.ID)
if err := h.resolveCase()(e); err != nil {
t.Fatalf("transport: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("resolve = %d: %s", rec.Code, rec.Body.String())
}
record := only(t, h, retention.ClassAssessment)
if record.Assessment.By != "u-7" {
t.Fatalf("the retained assessment is signed %q, want the token's subject u-7", record.Assessment.By)
}
}
// TestACaseCannotBeClosedByNobody. Fail secure at the record that matters: the
// assessment refuses a decider it does not have, and it refuses as a REFUSAL
// (400) and not as an outage (503), because 503 reads as "retry" and this one
// never clears.
func TestACaseCannotBeClosedByNobody(t *testing.T) {
h := plane(t)
h.Identity = func(*http.Request) (Caller, error) { return Caller{Tenant: acme}, nil }
c := h.Cases.Create(acme, types.SeverityHigh, []string{"alert-1"}, []string{"ivan-petrov-42"})
e, rec := send(http.MethodPost, "/v1/aml/cases/"+c.ID+"/resolve", map[string]any{
"resolution": types.ResolutionFalsePositive,
"considered": []string{"velocity over 30 days"},
"rationale": "consistent with the profile on file",
"by": "the head of compliance",
})
e.Request.SetPathValue("id", c.ID)
if err := h.resolveCase()(e); err != nil {
t.Fatalf("transport: %v", err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("resolve = %d: %s, want 400", rec.Code, rec.Body.String())
}
if got := h.Cases.Get(c.ID); got.Status == types.CaseClosed {
t.Fatal("a case was closed by nobody")
}
if held, err := h.Records.Len(); err != nil || held != 0 {
t.Fatalf("an assessment signed by nobody was retained: %d (%v)", held, err)
}
}
+5
View File
@@ -190,6 +190,11 @@ M = [
"./pkg/api/", "TestTheDeciderIsTheCredentialAndNotTheBody", "./pkg/api/", "TestTheDeciderIsTheCredentialAndNotTheBody",
"a governed record names the verified subject, not the request body"), "a governed record names the verified subject, not the request body"),
("case-decider-from-the-body", "pkg/api/routes.go",
"\t\t// The decider, from the credential and after the body, exactly as the typed\n\t\t// adapters do it for the record planes.\n\t\tdecide(&in, who.Subject)", "\t\t_ = who",
"./pkg/api/", "TestClosingACaseNamesTheCredential",
"the retained assessment is signed by the credential, not the request body"),
("crowding-unbounded", "pkg/suppress/shelf.go", ("crowding-unbounded", "pkg/suppress/shelf.go",
"\tif inForce >= s.maxInForce() {", "\tif false {", "\tif inForce >= s.maxInForce() {", "\tif false {",
"./pkg/suppress/", "TestCrowdingIsRefusedWhereItCostsARequest", "./pkg/suppress/", "TestCrowdingIsRefusedWhereItCostsARequest",