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
@@ -27,6 +27,13 @@ type Config struct {
|
||||
// contains AuthAudience.
|
||||
AuthEnabled bool
|
||||
AuthAudience string
|
||||
|
||||
// Downstream product provisioning. When ProductAPIURL is set, tenant
|
||||
// creation also provisions the tenant in the product's own database with
|
||||
// the SAME registry UUID, so both anchors agree. Empty ⇒ no-op adapter.
|
||||
ProductAPIURL string
|
||||
ProductAPIPath string
|
||||
ProductTimeout time.Duration
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
@@ -49,6 +56,10 @@ func Load() (*Config, error) {
|
||||
|
||||
AuthEnabled: getenv("AUTH_ENABLED", "false") == "true",
|
||||
AuthAudience: getenv("AUTH_EXPECTED_AUDIENCE", "tenant-registry"),
|
||||
|
||||
ProductAPIURL: os.Getenv("PRODUCT_API_URL"),
|
||||
ProductAPIPath: getenv("PRODUCT_API_TENANTS_PATH", "/sdk/v1/tenants"),
|
||||
ProductTimeout: 10 * time.Second,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package product
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The whole point of this port: the product must be created with the
|
||||
// REGISTRY's uuid, so both systems anchor to the same tenant identity.
|
||||
func TestProvisionTenant_sendsTheRegistryUUID(t *testing.T) {
|
||||
var got Tenant
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPProvisioner(srv.URL, "/sdk/v1/tenants", nil, 5*time.Second)
|
||||
err := p.ProvisionTenant(context.Background(), Tenant{
|
||||
ID: "2f875d6a-1d94-433a-b2ec-8529451a2d89", Name: "Acme", Slug: "acme",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("provision: %v", err)
|
||||
}
|
||||
if got.ID != "2f875d6a-1d94-433a-b2ec-8529451a2d89" {
|
||||
t.Errorf("registry uuid not sent: %+v", got)
|
||||
}
|
||||
if got.Slug != "acme" || got.Name != "Acme" {
|
||||
t.Errorf("payload wrong: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-provisioning must be safe: onboarding may retry, and the product's
|
||||
// insert is idempotent on the primary key.
|
||||
func TestProvisionTenant_conflictIsSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPProvisioner(srv.URL, "", nil, 5*time.Second)
|
||||
if err := p.ProvisionTenant(context.Background(), Tenant{ID: "x"}); err != nil {
|
||||
t.Fatalf("409 should be treated as already-provisioned, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionTenant_serverErrorIsUnavailable(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPProvisioner(srv.URL, "", nil, 5*time.Second)
|
||||
err := p.ProvisionTenant(context.Background(), Tenant{ID: "x"})
|
||||
if !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("want ErrUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionTenant_attachesAuthHeaderWhenSupplied(t *testing.T) {
|
||||
var seen string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPProvisioner(srv.URL, "", func(context.Context) (string, error) {
|
||||
return "Bearer tok-123", nil
|
||||
}, 5*time.Second)
|
||||
if err := p.ProvisionTenant(context.Background(), Tenant{ID: "x"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen != "Bearer tok-123" {
|
||||
t.Errorf("auth header = %q", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopProvisioner(t *testing.T) {
|
||||
if err := (NoopProvisioner{}).ProvisionTenant(context.Background(), Tenant{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/authn"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/product"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
@@ -20,8 +21,19 @@ type Server struct {
|
||||
Cfg *config.Config
|
||||
Log *slog.Logger
|
||||
Store store.Store
|
||||
Keycloak keycloak.Adapter // never nil — main wires Mock when KC env is unset
|
||||
Auth *authn.Verifier // nil ⇒ AUTH_ENABLED=false, API is open (dev only)
|
||||
Keycloak keycloak.Adapter // never nil — main wires Mock when KC env is unset
|
||||
Auth *authn.Verifier // nil ⇒ AUTH_ENABLED=false, API is open (dev only)
|
||||
Product product.Provisioner // never nil — main wires Noop when PRODUCT_API_URL is unset
|
||||
}
|
||||
|
||||
// productProvisioner guarantees the "never nil" invariant the struct documents.
|
||||
// Tests construct Server directly and would otherwise panic; an unset provisioner
|
||||
// simply means no downstream provisioning, never a crash mid-tenant-creation.
|
||||
func (s *Server) productProvisioner() product.Provisioner {
|
||||
if s.Product == nil {
|
||||
return product.NoopProvisioner{}
|
||||
}
|
||||
return s.Product
|
||||
}
|
||||
|
||||
// NewRouter builds the http.Handler with logging middleware applied.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/product"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
@@ -93,6 +94,27 @@ func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Best-effort product provisioning, same contract as Keycloak above: the
|
||||
// registry is the authority for tenant identity, so the product gets a row
|
||||
// keyed by OUR uuid. A failure must not roll the tenant back — it is
|
||||
// recorded as an audit event so the divergence is traceable and fixable.
|
||||
// Without this the anchors drift and the product's gate rejects every
|
||||
// request from the tenant because it cannot resolve the org slug.
|
||||
if perr := s.productProvisioner().ProvisionTenant(ctx, product.Tenant{
|
||||
ID: t.ID, Name: t.Name, Slug: t.Slug,
|
||||
}); perr != nil {
|
||||
s.emitAudit(ctx, r, store.AuditEvent{
|
||||
TenantID: t.ID, Action: "product.provision_failed",
|
||||
TargetID: t.ID, TargetType: "tenant",
|
||||
Metadata: map[string]interface{}{"err": perr.Error()},
|
||||
})
|
||||
} else {
|
||||
s.emitAudit(ctx, r, store.AuditEvent{
|
||||
TenantID: t.ID, Action: "product.tenant_provisioned",
|
||||
TargetID: t.ID, TargetType: "tenant",
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, createTenantResp{Tenant: t, InviteURL: inviteURL})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user