feat(tenants): provision the product tenant with the registry UUID on create (#21)
This commit was merged in pull request #21.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user