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]>
66 lines
2.8 KiB
Go
66 lines
2.8 KiB
Go
// Copyright 2026 Hanzo AI, Inc.
|
|
// SPDX-License-Identifier: MIT OR Apache-2.0
|
|
|
|
// Package iam names the addresses Hanzo IAM answers on.
|
|
//
|
|
// Three binaries in this module read identity from IAM — the admin guard, the
|
|
// waitlist guard and the admin API — and each used to spell the addresses in its
|
|
// own string literal. An address that lives in three places is an address that
|
|
// can be checked in none: when IAM retired the verb-noun spellings, every one of
|
|
// those literals kept compiling and started answering 410, and a guard reads a
|
|
// 410 as "not this person".
|
|
//
|
|
// So the address is a VALUE with one home. A caller says which resource it wants;
|
|
// where that resource lives is stated here once.
|
|
//
|
|
// Two conventions live behind these addresses and a reader has to know which it
|
|
// is holding. [Account] answers the envelope — `{"status":"ok","data":{…}}`, and
|
|
// a refusal is HTTP 200 carrying `{"status":"error"}`, so the STATUS CODE ALONE
|
|
// NEVER DECIDES a caller's verdict there. Everything else is typed CRUD: the body
|
|
// IS the record or the list, and a refusal is a real 4xx carrying an RFC 9457
|
|
// problem document.
|
|
package iam
|
|
|
|
import (
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// The resource addresses. Each one is a collection except [Account], which is the
|
|
// calling person themselves.
|
|
const (
|
|
// Account is the signed-in caller's own record, resolved from the credential
|
|
// on the request. Envelope convention — see the package comment.
|
|
Account = "/v1/iam/account"
|
|
|
|
// Users is the people in one organization. `owner` is REQUIRED; `email`
|
|
// narrows the page to one address; `limit` and `offset` page it. There is no
|
|
// ownerless listing, so a caller that wants every tenant's people asks
|
|
// [Organizations] first and reads each one.
|
|
Users = "/v1/iam/users"
|
|
|
|
// Organizations is the tenants the caller may act in, paged by `cursor`.
|
|
Organizations = "/v1/iam/organizations"
|
|
|
|
// Applications, Roles and AuditLogs are each scoped by `owner`.
|
|
Applications = "/v1/iam/applications"
|
|
Roles = "/v1/iam/roles"
|
|
AuditLogs = "/v1/iam/audit-logs"
|
|
)
|
|
|
|
// User is the address of one person's record, addressed by the natural key IAM
|
|
// writes as "owner/name".
|
|
//
|
|
// It returns "" for anything that is not exactly two non-empty segments. A caller
|
|
// holding half an identifier has not named a person, and building a URL out of it
|
|
// would address the COLLECTION instead — which answers 200 with a list, and a
|
|
// reader looking for one record would take the first thing in it. Refusing here
|
|
// is what keeps a malformed identifier a denial rather than a wrong person.
|
|
func User(id string) string {
|
|
owner, name, ok := strings.Cut(id, "/")
|
|
if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
|
|
return ""
|
|
}
|
|
return Users + "/" + url.PathEscape(owner) + "/" + url.PathEscape(name)
|
|
}
|