Files
zeekay 4679327ddc
Hanzo CI/CD / test (linux-amd64) (push) Skipped
Hanzo CI/CD / test (linux-arm64) (push) Skipped
Hanzo CI/CD / test-advisory (push) Failing after 5m15s
Hanzo CI/CD / test-amd64 (push) Failing after 17m40s
Hanzo CI/CD / cicd (push) Skipped
CI/CD / cicd (push) Failing after 17m53s
Sentinel reads the errors /v1/event lands, and takes none itself
The event plane folded every occurrence into event.fact, and the Sentry
module still read and wrote event.error. Its reads now select the error
signal of event.fact, mapping issue, env, origin and the severity band.

Errors enter through /v1/event only. The Sentry envelope and store
routes, DSN keys and their rotation leave the module; the consumer of
event.error files each fact through Ingest(org, product), which keeps
the issue lifecycle and returns the issues a batch created.
2026-09-30 01:15:34 -07:00

274 lines
12 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
}
identity := authtypes.NewPrincipalUserIdentity(userID, orgID, email, authtypes.IdentNProviderIAM)
identity.Tenant = orgSlug
return identity, 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 caller that has only the org slug resolves to the
// exact org a logged-in session would. Exported for the event.error consumer
// (implsentry.Ingest), which reads the slug off the fact and has no session. One
// mapping, one place: this is the whole tenant identity, no state.
func OrgUUID(slug string) valuer.UUID { return toUUID("org", slug) }