feat(tenants): provision the product tenant with the registry UUID on create
Closes the tenant-anchor divergence that blocked the Auth-5 sdk/backend flips. The registry is the authority for tenant identity (model B2), but nothing ever told the product about a new tenant. The anchors drifted: the registry held acme/matrix-acme while the product database held only the legacy seed tenant 9282a473. With the SDK gate enabled its TenantResolver does GetTenantBySlug and would 403 EVERY authenticated request, because no real user's org slug existed locally. New internal/product port, mirroring keycloak.Adapter: handlers depend on the interface, main wires HTTPProvisioner when PRODUCT_API_URL is set and NoopProvisioner otherwise, so an unconfigured deployment still creates tenants. Tenant creation now also provisions the product tenant with OUR uuid, using the same best-effort contract as Keycloak provisioning: a failure does not roll the tenant back, it emits a product.provision_failed audit event so the divergence is traceable. Success emits product.tenant_provisioned. A 409 from the product counts as success — onboarding may retry and the product's insert is idempotent on the primary key (compliance#217). Server.productProvisioner() guarantees the documented never-nil invariant; tests construct Server directly and would otherwise panic mid-tenant-creation. 5 tests incl. the point of the whole port (the registry UUID is what gets sent). Full suite green with -race, coverage 71.4% (gate 70). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNdLL9BdsWm7MCyui5ffPD
This commit is contained in:
co-authored by
Claude Fable 5
parent
80565fdbf2
commit
576d733eae
@@ -0,0 +1,106 @@
|
||||
// Package product provisions a tenant into the downstream product database.
|
||||
//
|
||||
// The registry is the authority for tenant identity (ratified model B2), so a
|
||||
// product must anchor its own tenant row to the SAME canonical UUID. Without
|
||||
// this the anchors diverge: the registry knows "acme", the product database
|
||||
// does not, and the product's gate rejects every request from that tenant
|
||||
// because it cannot resolve the org slug.
|
||||
//
|
||||
// The Provisioner is a port, mirroring keycloak.Adapter: handlers depend on the
|
||||
// interface, main wires the HTTP implementation when PRODUCT_API_URL is set and
|
||||
// a no-op otherwise (dev convenience, and so an unconfigured deployment does
|
||||
// not fail tenant creation).
|
||||
package product
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrUnavailable means the product API could not be reached or refused us.
|
||||
var ErrUnavailable = errors.New("product: provisioning API unavailable")
|
||||
|
||||
// Tenant is the minimal identity a product needs to create its own row.
|
||||
type Tenant struct {
|
||||
ID string `json:"id"` // the registry UUID — the whole point
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
// Provisioner creates the tenant in a product's own datastore.
|
||||
type Provisioner interface {
|
||||
ProvisionTenant(ctx context.Context, t Tenant) error
|
||||
}
|
||||
|
||||
// NoopProvisioner is wired when PRODUCT_API_URL is unset.
|
||||
type NoopProvisioner struct{}
|
||||
|
||||
func (NoopProvisioner) ProvisionTenant(context.Context, Tenant) error { return nil }
|
||||
|
||||
// AuthHeaderFunc supplies an Authorization header per call, so a token is
|
||||
// fetched lazily and refreshed rather than captured at construction. Nil means
|
||||
// no header, which is the correct behaviour against a product whose gate is
|
||||
// not yet enforcing.
|
||||
type AuthHeaderFunc func(context.Context) (string, error)
|
||||
|
||||
// HTTPProvisioner calls the product's tenant-create endpoint.
|
||||
type HTTPProvisioner struct {
|
||||
BaseURL string
|
||||
Path string // e.g. /sdk/v1/tenants
|
||||
Auth AuthHeaderFunc
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPProvisioner(baseURL, path string, auth AuthHeaderFunc, timeout time.Duration) *HTTPProvisioner {
|
||||
if path == "" {
|
||||
path = "/sdk/v1/tenants"
|
||||
}
|
||||
return &HTTPProvisioner{
|
||||
BaseURL: baseURL, Path: path, Auth: auth,
|
||||
Client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HTTPProvisioner) ProvisionTenant(ctx context.Context, t Tenant) error {
|
||||
body, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.BaseURL+p.Path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if p.Auth != nil {
|
||||
h, aerr := p.Auth(ctx)
|
||||
if aerr != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUnavailable, aerr)
|
||||
}
|
||||
if h != "" {
|
||||
req.Header.Set("Authorization", h)
|
||||
}
|
||||
}
|
||||
resp, err := p.Client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusConflict:
|
||||
// Already provisioned. The product's insert is idempotent on the
|
||||
// primary key, so this is success from our point of view.
|
||||
return nil
|
||||
case resp.StatusCode/100 == 2:
|
||||
return nil
|
||||
default:
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("%w: %d %s", ErrUnavailable, resp.StatusCode, b)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user