Files
zeekayandHanzo Dev e4fc5ab433 Read IAM at the addresses IAM serves
The three binaries that resolve identity from IAM each spelled its addresses in
their own string literals, and every one of those literals named a verb-noun
address IAM no longer serves. A guard reads the 410 as "not this person", so the
SSO and waitlist paths denied everyone they could not resolve any other way.

The address is now a value with one home (package iam) that all three read:

  account       -> /v1/iam/account          envelope, reader unchanged
  one person    -> /v1/iam/users/{owner}/{name}
  people        -> /v1/iam/users?owner=     owner-scoped, no ownerless listing
  organizations -> /v1/iam/organizations    cursor-paged, 100 to a page
  applications  -> /v1/iam/applications?owner=
  roles         -> /v1/iam/roles?owner=
  audit trail   -> /v1/iam/audit-logs?owner=

The resource collections answer the list itself under a key named for the
resource, so the admin API reads that key rather than unwrapping `data`, and
pageSize/p/field/value/sortField are gone -- the collections take limit/offset
or a cursor, and an unknown key binds nothing at all. audit-logs, roles and
applications answer an owner whole, so the caller's page is applied where the
page now is.

iam.User refuses anything that is not two non-empty segments: half an identifier
would otherwise address the collection, which answers 200 with a list that every
reader here would take for one person.

Tests assert the address each guard asks for and that a 410, a 401, a 200
carrying {"status":"error"} and a half identifier all deny. The account read
answers its refusal at HTTP 200, so the status code alone never decides there.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-23 21:49:26 -07:00

67 lines
2.1 KiB
Go

// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package iam
import "testing"
// A person's record is addressed by BOTH halves of the natural key or not at
// all. The danger is not a 404 — it is that Users+"/"+half addresses the
// COLLECTION, which answers 200 with a list, and every caller here reads the
// answer as one person.
func TestUserRefusesHalfAnIdentifier(t *testing.T) {
for _, id := range []string{
"", // nothing
"/", // both halves empty
"hanzo", // no name
"hanzo/", // empty name
"/alice", // empty owner
"a/b/c", // not a natural key
"hanzo//bob", // empty middle segment
"hanzo/../admin", // a traversal is three segments, not a name
} {
if got := User(id); got != "" {
t.Errorf("User(%q) = %q, want \"\" — a half identifier must yield no address", id, got)
}
}
}
// The address is the collection plus the two segments, each escaped, so a name
// carrying a slash or a space cannot reach a different route.
func TestUserAddressesOnePerson(t *testing.T) {
for _, tc := range []struct{ id, want string }{
{"hanzo/alice", "/v1/iam/users/hanzo/alice"},
{"hanzo/a b", "/v1/iam/users/hanzo/a%20b"},
{"hanzo/a.b", "/v1/iam/users/hanzo/a.b"},
} {
if got := User(tc.id); got != tc.want {
t.Errorf("User(%q) = %q, want %q", tc.id, got, tc.want)
}
}
}
// The addresses IAM actually serves. A literal here is the point: if one of
// these constants is edited to an address IAM retired, this test says so.
func TestAddressesAreTheServedOnes(t *testing.T) {
for name, got := range map[string]string{
"Account": Account,
"Users": Users,
"Organizations": Organizations,
"Applications": Applications,
"Roles": Roles,
"AuditLogs": AuditLogs,
} {
want := map[string]string{
"Account": "/v1/iam/account",
"Users": "/v1/iam/users",
"Organizations": "/v1/iam/organizations",
"Applications": "/v1/iam/applications",
"Roles": "/v1/iam/roles",
"AuditLogs": "/v1/iam/audit-logs",
}[name]
if got != want {
t.Errorf("%s = %q, want %q", name, got, want)
}
}
}