mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
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]>
67 lines
2.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|