From a048d47959e8d98d37cc9d096a5e775292341498 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Tue, 1 Sep 2026 21:20:03 +0000 Subject: [PATCH] feat(tenants): provision the product tenant with the registry UUID on create (#21) --- cmd/server/main.go | 15 ++++- internal/config/config.go | 11 ++++ internal/product/product.go | 106 +++++++++++++++++++++++++++++++ internal/product/product_test.go | 88 +++++++++++++++++++++++++ internal/server/server.go | 16 ++++- internal/server/tenants.go | 22 +++++++ 6 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 internal/product/product.go create mode 100644 internal/product/product_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index a029b1f..64507ff 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,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/server" "gitea.meghsakha.com/platform/tenant-registry/internal/store" ) @@ -74,7 +75,19 @@ func main() { slog.Warn("AUTH_ENABLED=false — API is unauthenticated (dev only)") } - handler := server.NewRouter(&server.Server{Cfg: cfg, Log: logger, Store: s, Keycloak: kc, Auth: av}) + // Downstream product provisioning. Wired only when PRODUCT_API_URL is set; + // otherwise a no-op so an unconfigured deployment still creates tenants. + // The product's gate is not yet enforcing, so no token is attached today — + // AuthHeaderFunc is the seam for when it is. + var pv product.Provisioner = product.NoopProvisioner{} + if cfg.ProductAPIURL != "" { + pv = product.NewHTTPProvisioner(cfg.ProductAPIURL, cfg.ProductAPIPath, nil, cfg.ProductTimeout) + slog.Info("product provisioner configured", "url", cfg.ProductAPIURL, "path", cfg.ProductAPIPath) + } else { + slog.Warn("PRODUCT_API_URL not set — product tenant provisioning disabled (anchors may diverge)") + } + + handler := server.NewRouter(&server.Server{Cfg: cfg, Log: logger, Store: s, Keycloak: kc, Auth: av, Product: pv}) srv := &http.Server{ Addr: cfg.Addr, Handler: handler, diff --git a/internal/config/config.go b/internal/config/config.go index e8c69f6..a2060f1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/product/product.go b/internal/product/product.go new file mode 100644 index 0000000..f5c18fc --- /dev/null +++ b/internal/product/product.go @@ -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) + } +} diff --git a/internal/product/product_test.go b/internal/product/product_test.go new file mode 100644 index 0000000..dd37db4 --- /dev/null +++ b/internal/product/product_test.go @@ -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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index ab2923c..11bb4f6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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. diff --git a/internal/server/tenants.go b/internal/server/tenants.go index 3ad895d..75de6d9 100644 --- a/internal/server/tenants.go +++ b/internal/server/tenants.go @@ -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}) }