mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
cases, jwks, docs: close Red's three low findings before the tag
Red's re-review passed (0 critical/high/medium) but left three low findings, and two are "one and only one way" violations worth closing before a version tag. LOW-2 — one way to mutate a case, org-checked. There were two ways to append a case event: an org-checked AddEventFor with no callers, and an unchecked AddEvent the handler actually used, safe only because the handler happened to resolve the tenant first. That is a cross-tenant write one refactor away. AddEvent now takes the tenant and checks it (absorbing AddEventFor, which is deleted), and Resolve and UpdateStatus do the same — there is no unchecked public path left to reach for. A test proves each refuses a caller from another tenant holding the exact case id. LOW-1 — the tenant-key invariant was false for Rule.OrgID. The types doc said every OrgID holds the brand-qualified tenant key, but the rule library is the deployment's catalog, seeded at startup from AML_DEFAULT_ORG with no Host in scope, so its OrgID is a bare org and nothing scopes on it. The invariant now names that one exception, on the field where an auditor reads it, so it is true rather than aspirational. LOW-3 — a duplicate kid in a JWKS was overwritten last-wins and silently, against this package's own "left out loudly" principle: a token the shadowed key signed would be refused with nothing logged. It is now first-wins and announced. This is unreachable from the real IAM (which dedupes by cert name) and is a guard against a malformed set. Also documented, not changed: the aud pin does not prove a token transited this app's login gate — IAM's RFC 8693 token-exchange path can stamp a chosen aud, and is gated instead by the IAM_KEY_MINT_ALLOWED_APPS allowlist. Named in iam.go as a deploy-time trust dependency rather than left as an implied guarantee. 18 packages green, race clean, gofmt clean. The case org-check is mutation-killed. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -28,6 +28,17 @@ package api
|
||||
// from the same issuer authenticates here — one minted for a marketing site,
|
||||
// for another tenant's app, for anything at all on hanzo.id. RFC 9068 §4 makes
|
||||
// this the resource server's own check, and nobody else can make it.
|
||||
//
|
||||
// What the aud pin does NOT establish is that the token came through this
|
||||
// app's own login gate. IAM's RFC 8693 token-exchange path
|
||||
// (internal/oidc/token_exchange.go) takes aud from the caller's requested
|
||||
// resource and can stamp a chosen owner, so a token bearing this app's aud
|
||||
// need not have transited login.go. That path is not attacker-reachable — it
|
||||
// is gated by IAM_KEY_MINT_ALLOWED_APPS, an exact-clientId allowlist that
|
||||
// fails closed when empty and additionally requires reserved-org ownership —
|
||||
// so the pin's guarantee rests on that env value being audited for the brand
|
||||
// serving AML. This is a deploy-time trust delegation to IAM, named here so it
|
||||
// is a stated dependency rather than an implied one.
|
||||
// - It is an access token. IAM stamps tokenType, so an id_token — issued to a
|
||||
// browser for the browser's own consumption, not a credential for an API, and
|
||||
// carrying the same iss and the same aud — is refusable, and is refused.
|
||||
|
||||
@@ -270,6 +270,17 @@ func decodeKeys(body []byte) (Keyset, error) {
|
||||
log.Printf("[aml] jwks: unsupported key kid=%q kty=%q alg=%q crv=%q: %v", k.Kid, k.Kty, k.Alg, k.Crv, err)
|
||||
continue
|
||||
}
|
||||
// A duplicate kid is kept first-wins and announced, not overwritten
|
||||
// silently. Two keys under one kid means a token naming that kid verifies
|
||||
// against only one of them; a silent last-wins would refuse a token the
|
||||
// other key signed while logging nothing — the quiet partial lockout this
|
||||
// package's "left out loudly" principle exists to prevent. The upstream IAM
|
||||
// dedupes by cert name, so this is a guard against a malformed set, and it
|
||||
// says so when it fires.
|
||||
if _, dup := out[k.Kid]; dup {
|
||||
log.Printf("[aml] jwks: duplicate kid=%q — keeping the first, ignoring the rest", k.Kid)
|
||||
continue
|
||||
}
|
||||
out[k.Kid] = key
|
||||
}
|
||||
if len(out) == 0 {
|
||||
|
||||
+3
-3
@@ -452,7 +452,7 @@ func (h *Handler) caseOf(e *core.RequestEvent) (*types.Case, string, error) {
|
||||
|
||||
func (h *Handler) addCaseEvent() func(e *core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
c, _, err := h.caseOf(e)
|
||||
c, tenant, err := h.caseOf(e)
|
||||
switch {
|
||||
case errors.Is(err, errNoCase):
|
||||
return fail(e, http.StatusNotFound, "no such case")
|
||||
@@ -464,7 +464,7 @@ func (h *Handler) addCaseEvent() func(e *core.RequestEvent) error {
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&evt); err != nil {
|
||||
return fail(e, http.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
if err := h.Cases.AddEvent(c.ID, evt); err != nil {
|
||||
if err := h.Cases.AddEvent(tenant, c.ID, evt); err != nil {
|
||||
return fail(e, http.StatusNotFound, err.Error())
|
||||
}
|
||||
return e.JSON(http.StatusCreated, map[string]string{"status": "ok"})
|
||||
@@ -519,7 +519,7 @@ func (h *Handler) resolveCase() func(e *core.RequestEvent) error {
|
||||
return unavailable(e, "retain assessment", err)
|
||||
}
|
||||
|
||||
if err := h.Cases.Resolve(c.ID, in.Resolution, in.By, assessment); err != nil {
|
||||
if err := h.Cases.Resolve(orgID, c.ID, in.Resolution, in.By, assessment); err != nil {
|
||||
return fail(e, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return e.JSON(http.StatusOK, map[string]string{
|
||||
|
||||
+13
-27
@@ -136,12 +136,12 @@ func (s *Store) List(orgID, status string) []*types.Case {
|
||||
}
|
||||
|
||||
// UpdateStatus changes a case's status and records the event.
|
||||
func (s *Store) UpdateStatus(caseID, status, authorID string) error {
|
||||
func (s *Store) UpdateStatus(orgID, caseID, status, authorID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
c, ok := s.cases[caseID]
|
||||
if !ok {
|
||||
if !ok || c.OrgID != orgID {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
@@ -166,14 +166,16 @@ func (s *Store) UpdateStatus(caseID, status, authorID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddEventFor appends to a case timeline after confirming the case belongs to the
|
||||
// organisation making the request.
|
||||
// AddEvent appends to a case timeline after confirming the case belongs to the
|
||||
// tenant making the request.
|
||||
//
|
||||
// The organisation is checked rather than assumed. A case identifier is returned
|
||||
// to whoever submitted the transaction, so treating possession of the identifier
|
||||
// as authority to write on the case lets one tenant annotate another's
|
||||
// investigation.
|
||||
func (s *Store) AddEventFor(orgID, caseID string, evt types.CaseEvent) error {
|
||||
// The tenant is checked, not assumed. A case identifier is handed back to whoever
|
||||
// submitted the transaction, so treating possession of the identifier as authority
|
||||
// to write on the case would let one tenant annotate another's investigation. This
|
||||
// is the ONE way to append an event: there is deliberately no unchecked variant to
|
||||
// reach for, because the earlier one was reachable one refactor away from a
|
||||
// cross-tenant write.
|
||||
func (s *Store) AddEvent(orgID, caseID string, evt types.CaseEvent) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -189,22 +191,6 @@ func (s *Store) AddEventFor(orgID, caseID string, evt types.CaseEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddEvent appends a note, file, or other event to a case timeline.
|
||||
func (s *Store) AddEvent(caseID string, evt types.CaseEvent) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.cases[caseID]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
evt.ID = uuid.NewString()
|
||||
evt.CaseID = caseID
|
||||
evt.CreatedAt = time.Now().UTC()
|
||||
s.events[caseID] = append(s.events[caseID], evt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Events returns the timeline for a case.
|
||||
func (s *Store) Events(caseID string) []types.CaseEvent {
|
||||
s.mu.RLock()
|
||||
@@ -235,7 +221,7 @@ func (s *Store) Assign(caseID, assigneeID string) error {
|
||||
// report, the reasons must be documented and kept with the internal suspicion
|
||||
// report (JMLSG 6.32). So a dismissal here cannot be a row that quietly changes
|
||||
// state: without the record of the decision there is no closure.
|
||||
func (s *Store) Resolve(caseID, resolution, authorID, assessment string) error {
|
||||
func (s *Store) Resolve(orgID, caseID, resolution, authorID, assessment string) error {
|
||||
if assessment == "" {
|
||||
return ErrNoAssessment
|
||||
}
|
||||
@@ -244,7 +230,7 @@ func (s *Store) Resolve(caseID, resolution, authorID, assessment string) error {
|
||||
defer s.mu.Unlock()
|
||||
|
||||
c, ok := s.cases[caseID]
|
||||
if !ok {
|
||||
if !ok || c.OrgID != orgID {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
|
||||
+31
-9
@@ -73,7 +73,7 @@ func TestUpdateStatus(t *testing.T) {
|
||||
s := NewStore()
|
||||
c := s.Create("org1", types.SeverityHigh, nil, nil)
|
||||
|
||||
err := s.UpdateStatus(c.ID, types.CaseInReview, "user1")
|
||||
err := s.UpdateStatus(c.OrgID, c.ID, types.CaseInReview, "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateStatus error: %v", err)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestUpdateStatus(t *testing.T) {
|
||||
|
||||
func TestUpdateStatusNotFound(t *testing.T) {
|
||||
s := NewStore()
|
||||
err := s.UpdateStatus("bad-id", types.CaseClosed, "user1")
|
||||
err := s.UpdateStatus("org1", "bad-id", types.CaseClosed, "user1")
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
@@ -104,7 +104,7 @@ func TestAddEvent(t *testing.T) {
|
||||
s := NewStore()
|
||||
c := s.Create("org1", types.SeverityLow, nil, nil)
|
||||
|
||||
err := s.AddEvent(c.ID, types.CaseEvent{
|
||||
err := s.AddEvent(c.OrgID, c.ID, types.CaseEvent{
|
||||
AuthorID: "user1",
|
||||
Kind: types.EventNote,
|
||||
Body: "test note",
|
||||
@@ -124,7 +124,7 @@ func TestAddEvent(t *testing.T) {
|
||||
|
||||
func TestAddEventNotFound(t *testing.T) {
|
||||
s := NewStore()
|
||||
err := s.AddEvent("bad-id", types.CaseEvent{})
|
||||
err := s.AddEvent("org1", "bad-id", types.CaseEvent{})
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func TestResolve(t *testing.T) {
|
||||
s := NewStore()
|
||||
c := s.Create("org1", types.SeverityHigh, nil, nil)
|
||||
|
||||
err := s.Resolve(c.ID, types.ResolutionSARFiled, "user1", "assessment-1")
|
||||
err := s.Resolve(c.OrgID, c.ID, types.ResolutionSARFiled, "user1", "assessment-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
@@ -186,7 +186,7 @@ func TestCaseStoreEviction(t *testing.T) {
|
||||
// Create and immediately close 15 cases.
|
||||
for i := 0; i < 15; i++ {
|
||||
c := s.Create("org1", types.SeverityLow, nil, nil)
|
||||
s.Resolve(c.ID, types.ResolutionCleared, "system", "assessment-1")
|
||||
s.Resolve(c.OrgID, c.ID, types.ResolutionCleared, "system", "assessment-1")
|
||||
}
|
||||
|
||||
// Wait for closed cases to become stale.
|
||||
@@ -210,7 +210,7 @@ func TestCaseStoreEvictExpiredDirect(t *testing.T) {
|
||||
for i := 0; i < 5; i++ {
|
||||
c := s.Create("org1", types.SeverityLow, nil, nil)
|
||||
if i < 3 {
|
||||
s.Resolve(c.ID, types.ResolutionCleared, "system", "assessment-1")
|
||||
s.Resolve(c.OrgID, c.ID, types.ResolutionCleared, "system", "assessment-1")
|
||||
} else {
|
||||
openIDs = append(openIDs, c.ID)
|
||||
}
|
||||
@@ -240,7 +240,7 @@ func TestResolveRefusesWithoutARetainedAssessment(t *testing.T) {
|
||||
s := NewStore()
|
||||
c := s.Create("org1", types.SeverityHigh, []string{"alert-1"}, []string{"user-1"})
|
||||
|
||||
if err := s.Resolve(c.ID, types.ResolutionFalsePositive, "mlro", ""); !errors.Is(err, ErrNoAssessment) {
|
||||
if err := s.Resolve(c.OrgID, c.ID, types.ResolutionFalsePositive, "mlro", ""); !errors.Is(err, ErrNoAssessment) {
|
||||
t.Fatalf("err = %v, want ErrNoAssessment", err)
|
||||
}
|
||||
got := s.Get(c.ID)
|
||||
@@ -251,7 +251,7 @@ func TestResolveRefusesWithoutARetainedAssessment(t *testing.T) {
|
||||
t.Error("the case carries a closing date it did not earn")
|
||||
}
|
||||
|
||||
if err := s.Resolve(c.ID, types.ResolutionFalsePositive, "mlro", "assessment-1"); err != nil {
|
||||
if err := s.Resolve(c.OrgID, c.ID, types.ResolutionFalsePositive, "mlro", "assessment-1"); err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
got = s.Get(c.ID)
|
||||
@@ -267,3 +267,25 @@ func TestResolveRefusesWithoutARetainedAssessment(t *testing.T) {
|
||||
t.Errorf("the closing event does not name the assessment: %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
// A case mutator refuses a caller from a different tenant, even with the exact
|
||||
// case id. This is the LOW-2 close: there is one org-checked way to write a case,
|
||||
// so possession of the id is not authority over the case.
|
||||
func TestCaseMutatorsRefuseAnotherTenant(t *testing.T) {
|
||||
s := NewStore()
|
||||
c := s.Create("lux/acme", types.SeverityHigh, nil, nil)
|
||||
|
||||
if err := s.AddEvent("zoo/acme", c.ID, types.CaseEvent{Kind: types.EventNote, Body: "x"}); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("AddEvent from another tenant: err=%v, want ErrNotFound", err)
|
||||
}
|
||||
if err := s.UpdateStatus("zoo/acme", c.ID, types.CaseClosed, "attacker"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("UpdateStatus from another tenant: err=%v, want ErrNotFound", err)
|
||||
}
|
||||
if err := s.Resolve("zoo/acme", c.ID, "cleared", "attacker", "assessment-1"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Resolve from another tenant: err=%v, want ErrNotFound", err)
|
||||
}
|
||||
// The owning tenant still works.
|
||||
if err := s.AddEvent("lux/acme", c.ID, types.CaseEvent{Kind: types.EventNote, Body: "ok"}); err != nil {
|
||||
t.Errorf("owning tenant refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestCheckEscalation_NotBreached(t *testing.T) {
|
||||
func TestCheckEscalation_AlreadyClosed(t *testing.T) {
|
||||
s := NewStore()
|
||||
c := s.Create("org1", types.SeverityCritical, nil, nil)
|
||||
_ = s.Resolve(c.ID, "cleared", "analyst", "assessment-1")
|
||||
_ = s.Resolve(c.OrgID, c.ID, "cleared", "analyst", "assessment-1")
|
||||
|
||||
config := DefaultTriageConfig()
|
||||
escalated := s.CheckEscalation(c, config)
|
||||
@@ -167,7 +167,7 @@ func TestNoTimerPathCanCloseACase(t *testing.T) {
|
||||
}
|
||||
|
||||
// And the closure path still refuses without one.
|
||||
if err := s.Resolve(c.ID, "cleared", "analyst-1", ""); err == nil {
|
||||
if err := s.Resolve(c.OrgID, c.ID, "cleared", "analyst-1", ""); err == nil {
|
||||
t.Fatal("a case was closed with no retained assessment")
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -8,6 +8,12 @@
|
||||
// tokenisation vault. Writing a bare org into one of these fields puts two brands'
|
||||
// institutions of the same name into one tenant — one set of records, and one
|
||||
// vault, which decrypts across the two.
|
||||
//
|
||||
// The one exception is Rule.OrgID. The rule library is the deployment's catalog,
|
||||
// seeded at startup from AML_DEFAULT_ORG before any request — so there is no Host,
|
||||
// hence no brand, to qualify it with. It is the catalog's configured owner, never
|
||||
// a request tenant key, and nothing scopes a store read on it. It is documented on
|
||||
// the field so the distinction is stated where an auditor reads it, not inferred.
|
||||
package types
|
||||
|
||||
import (
|
||||
@@ -168,7 +174,11 @@ type Entity struct {
|
||||
// a coverage claim checkable: a reviewer reads the citation, reads the
|
||||
// expression, and decides whether the second implements the first.
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
// OrgID is the catalog's configured owner (the deployment's AML_DEFAULT_ORG),
|
||||
// NOT a request tenant key — see the package doc's one exception. The library
|
||||
// is seeded at startup with no Host in scope, so it cannot be brand-qualified,
|
||||
// and nothing scopes a store read on it.
|
||||
OrgID string `json:"org_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
|
||||
Reference in New Issue
Block a user