mirror of
https://github.com/hanzoai/o11y.git
synced 2026-11-03 02:04:48 +00:00
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]>
272 lines
11 KiB
Go
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) }
|