Files
zooqueenandHanzo Dev 44c7574c28 A person seated under a name is re-keyed to their IAM subject
The guard once asserted a name where it now asserts the IAM subject, so a row
seated then is keyed by uuid5 of the name. The subject misses that row, the
create collides on the email, and ensureUser answered the collision with
success, leaving GET /users/me and every row keyed to the person on an id no
session asserts.

On that collision ensureUser now calls user.Setter.RekeyUser: in one
transaction it finds the org's row by email and moves it, its user_role rows and
every other row that references users.id onto the subject. SQLite checks the
foreign keys at COMMIT (defer_foreign_keys), so a reference left behind refuses
the whole move. The role grants follow the rows once they commit.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-24 05:58:43 -07:00

272 lines
11 KiB
Go

// Copyright (C) 2025-2026, Hanzo Industries Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
// Package iamidentn is o11y's sole human-identity provider. o11y mints and
// stores no identity of its own: identity is asserted by the Hanzo IAM session
// that the edge gateway (hanzoai/gateway) already validated. The gateway
// verifies the hanzo.id JWT against IAM's JWKS and injects canonical, trusted
// identity headers — stripping any client-supplied copies first, per HIP-0026:
//
// X-Org-Id the org slug (JWT "owner" claim) — the tenant
// X-User-Id the user id (JWT "sub" claim)
// X-User-Email the user email
//
// o11y trusts these exactly like every other Hanzo service (cloud, ai,
// commerce): one IAM validates once at the edge, every service trusts the
// assertion. There is no o11y-native login, registration, invite, or token
// minting — Hanzo IAM is the only identity.
//
// The tenant is auto-provisioned with zero onboarding: on first sight of an
// (org, user) pair the matching o11y organization row is created if absent
// (idempotent) and the user is granted its admin role in Hanzo IAM.
// Authorization itself stays with iamauthz — every runtime access check is
// still a Hanzo IAM batch-enforce; this provider only ensures the founding
// grant exists so a logged-in Hanzo user is authorized for their own org.
package iamidentn
import (
"context"
"net/http"
"sync"
"github.com/google/uuid"
"github.com/hanzoai/o11y/pkg/errors"
"github.com/hanzoai/o11y/pkg/factory"
"github.com/hanzoai/o11y/pkg/identn"
"github.com/hanzoai/o11y/pkg/modules/user"
"github.com/hanzoai/o11y/pkg/types"
"github.com/hanzoai/o11y/pkg/types/authtypes"
"github.com/hanzoai/o11y/pkg/valuer"
)
// Canonical identity headers injected by hanzoai/gateway after it validates the
// Hanzo IAM session. Client-supplied copies are stripped by the gateway, so
// within the cluster these are trusted. They are a fixed contract, not config.
const (
headerOrgID = "X-Org-Id"
headerUserID = "X-User-Id"
headerEmail = "X-User-Email"
)
// OrgResolver resolves an existing o11y organization. Declared narrowly so this
// package depends only on what it uses; satisfied by organization.Getter.
type OrgResolver interface {
GetByIDOrName(context.Context, valuer.UUID, string) (*types.Organization, bool, error)
}
// OrgCreator creates an o11y organization, invoking the callback to bootstrap
// its managed roles inside the same transaction; satisfied by organization.Setter.
type OrgCreator interface {
Create(context.Context, *types.Organization, func(context.Context, valuer.UUID) error) error
}
// Authorizer bootstraps an org's managed roles. Satisfied by authz.AuthZ (Hanzo
// IAM). Every runtime access check still flows through the same authz service's
// batch-enforce; the founding role grant rides UserCreator, which performs it and
// the local user_role rows in one call.
type Authorizer interface {
CreateManagedRoles(context.Context, valuer.UUID, []*authtypes.Role) error
}
// UserResolver reads an o11y user row by (org, id); satisfied by user.Getter.
type UserResolver interface {
GetUserByOrgIDAndID(context.Context, valuer.UUID, valuer.UUID) (*types.User, error)
}
// UserCreator writes an o11y user row and grants it roles — the Hanzo IAM grant
// AND the local user_role entries, in that one call — and re-keys a row seated
// under an earlier key onto the subject. Satisfied by user.Setter.
type UserCreator interface {
CreateUser(context.Context, *types.User, ...user.CreateUserOption) error
RekeyUser(context.Context, valuer.UUID, valuer.Email, valuer.UUID) error
}
type provider struct {
settings factory.ScopedProviderSettings
resolver OrgResolver
creator OrgCreator
authorizer Authorizer
users UserResolver
userMaker UserCreator
// provisioned records "<userID>@<orgID>" pairs already resolved this run, so
// the org lookup + grant happen once, not on every request. Both underlying
// operations are idempotent, so this is a fast path, not a correctness gate.
provisioned sync.Map
}
func NewFactory(resolver OrgResolver, creator OrgCreator, authorizer Authorizer, users UserResolver, userMaker UserCreator) factory.ProviderFactory[identn.IdentN, identn.Config] {
return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderIAM.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) {
return New(providerSettings, resolver, creator, authorizer, users, userMaker)
})
}
func New(providerSettings factory.ProviderSettings, resolver OrgResolver, creator OrgCreator, authorizer Authorizer, users UserResolver, userMaker UserCreator) (identn.IdentN, error) {
return &provider{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/hanzoai/o11y/pkg/identn/iamidentn"),
resolver: resolver,
creator: creator,
authorizer: authorizer,
users: users,
userMaker: userMaker,
}, nil
}
func (p *provider) Name() authtypes.IdentNProvider {
return authtypes.IdentNProviderIAM
}
// Test matches when the gateway has asserted a tenant — the presence of the
// trusted X-Org-Id header is the Hanzo IAM session signal.
func (p *provider) Test(req *http.Request) bool {
return req.Header.Get(headerOrgID) != ""
}
func (p *provider) GetIdentity(req *http.Request) (*authtypes.Identity, error) {
ctx := req.Context()
orgSlug := req.Header.Get(headerOrgID)
userRef := req.Header.Get(headerUserID)
if orgSlug == "" || userRef == "" {
return nil, errors.NewUnauthenticatedf(errors.CodeUnauthenticated, "missing Hanzo IAM identity headers")
}
orgID := toUUID("org", orgSlug)
userID := toUUID("user", userRef)
// Email is descriptive (attribution/display), not load-bearing for authz;
// tolerate absence or an odd value rather than reject the session.
email, err := valuer.NewEmail(req.Header.Get(headerEmail))
if err != nil {
email = valuer.Email{}
}
if err := p.provision(ctx, orgID, orgSlug, userID, email); err != nil {
return nil, err
}
return authtypes.NewPrincipalUserIdentity(userID, orgID, email, authtypes.IdentNProviderIAM), nil
}
// provision resolves-or-creates the tenant org AND the person's o11y user row,
// once per (user, org). Idempotent and safe under concurrency.
//
// Both halves, or neither is any use. The org alone was what this did, and it
// left the tenant without members: o11y stores per-user rows — a dashboard's
// author, a saved view, a preference — keyed by user id, and GET /users/me reads
// one by (org, user). A session with no row answered "user not found" on the
// very call the console uses as its identity provider, so a person the gateway
// had authenticated could not open a single view. Provisioning the org and not
// the user is not a smaller grant; it is a tenant nobody can enter.
//
// This is NOT o11y owning identity. The row is a PROJECTION of the IAM identity
// keyed by IAM's own subject: no password, no invite, no SSO domain, nothing
// authenticates against it. It exists so the foreign keys have something to
// point at.
func (p *provider) provision(ctx context.Context, orgID valuer.UUID, orgSlug string, userID valuer.UUID, email valuer.Email) error {
key := userID.String() + "@" + orgID.String()
if _, ok := p.provisioned.Load(key); ok {
return nil
}
if err := p.ensureOrg(ctx, orgID, orgSlug); err != nil {
return err
}
if err := p.ensureUser(ctx, orgID, userID, email); err != nil {
return err
}
p.provisioned.Store(key, struct{}{})
return nil
}
// ensureUser creates the person's o11y row on first sight, with the org's admin
// role. CreateUser performs the Hanzo IAM grant and the local user_role entries
// in one call, so the founding grant is stated once rather than here and there.
//
// The email is load-bearing HERE and only here: a user row must have one, and
// o11y must not invent an address for a person. A session that asserts an org and
// a subject but no address cannot be seated, and says so rather than seating a
// half-built identity that fails later, further away.
//
// A create that finds the email already seated in the org re-keys that row onto
// the subject. The row is this person's either way: a concurrent request won the
// create (already keyed to the subject, so the re-key leaves it alone), or an
// earlier guard asserted a name and the row took uuid5 of it. Returning success
// on the second left GET /users/me and every row keyed to the person pointing at
// an id no session asserts any more.
func (p *provider) ensureUser(ctx context.Context, orgID, userID valuer.UUID, email valuer.Email) error {
if _, err := p.users.GetUserByOrgIDAndID(ctx, orgID, userID); err == nil {
return nil
} else if !errors.Ast(err, errors.TypeNotFound) {
return err
}
if email.IsZero() {
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "the Hanzo IAM session carries no "+headerEmail+"; a user cannot be seated without one")
}
u, err := types.NewUserWithID(userID, email.String(), email, orgID, types.UserStatusActive)
if err != nil {
return err
}
if err := p.userMaker.CreateUser(ctx, u, user.WithRoleNames([]string{authtypes.O11yAdminRoleName})); err != nil {
if !errors.Ast(err, errors.TypeAlreadyExists) {
return err
}
if err := p.userMaker.RekeyUser(ctx, orgID, email, userID); err != nil {
return err
}
p.settings.Logger().InfoContext(ctx, "re-keyed o11y user to its Hanzo IAM subject", "org_id", orgID.String(), "user_id", userID.String())
return nil
}
p.settings.Logger().InfoContext(ctx, "seated Hanzo IAM user in o11y org", "org_id", orgID.String(), "user_id", userID.String())
return nil
}
// ensureOrg creates the o11y org for a Hanzo org on first sight, complete with
// managed roles and default configs (mirroring first-user creation). Existing
// orgs and a lost create race both resolve to success.
func (p *provider) ensureOrg(ctx context.Context, orgID valuer.UUID, orgSlug string) error {
if _, _, err := p.resolver.GetByIDOrName(ctx, orgID, orgSlug); err == nil {
return nil
} else if !errors.Ast(err, errors.TypeNotFound) {
return err
}
org := types.NewOrganizationWithID(orgID, orgSlug, orgSlug)
managedRoles := authtypes.NewManagedRoles(orgID)
err := p.creator.Create(ctx, org, func(ctx context.Context, id valuer.UUID) error {
return p.authorizer.CreateManagedRoles(ctx, id, managedRoles)
})
if err != nil && !errors.Ast(err, errors.TypeAlreadyExists) {
return err
}
p.settings.Logger().InfoContext(ctx, "auto-provisioned o11y org for Hanzo IAM session", "org_id", orgID.String(), "org", orgSlug)
return nil
}
// toUUID maps a Hanzo IAM identifier to a stable o11y UUID. A value that is
// already a UUID (the user "sub") is used as-is; a slug (the org "owner") is
// mapped deterministically via UUIDv5, so the same Hanzo org always resolves to
// the same o11y org — the whole of the tenant mapping, no state required.
func toUUID(kind, value string) valuer.UUID {
if u, err := valuer.NewUUID(value); err == nil {
return u
}
derived := uuid.NewSHA1(uuid.NameSpaceURL, []byte("hanzo:o11y:"+kind+":"+value))
return valuer.MustNewUUID(derived.String())
}
// OrgUUID maps a Hanzo org slug to its o11y org UUID — the SAME deterministic
// mapping a session takes, so a KEYED (pk-) ingest that has only the org slug
// resolves to the exact org a logged-in session would. Exported for the keyed
// ingest path (implsentry.ResolveIngest), which has no session to read `owner`
// from. One mapping, one place: this is the whole tenant identity, no state.
func OrgUUID(slug string) valuer.UUID { return toUUID("org", slug) }