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