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

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)
}