Files
tenant-registry/internal/product/product.go
T
sharang a048d47959
ci / shared (push) Successful in 12s
ci / test (push) Failing after 16m8s
ci / image (push) Skipped
feat(tenants): provision the product tenant with the registry UUID on create (#21)
2026-09-01 21:20:03 +00:00

107 lines
3.3 KiB
Go

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