Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
576d733eae | ||
|
|
80565fdbf2 | ||
|
|
84516e9b4f | ||
|
|
4eaee521d4 | ||
|
|
03b479ab6f |
+25
-17
@@ -99,25 +99,33 @@ jobs:
|
|||||||
# this job silently never ran (deployment sat on an Aug-06 image).
|
# this job silently never ran (deployment sat on an Aug-06 image).
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
# The runner's default job container has no docker CLI, so
|
||||||
|
# docker/login-action + docker/build-push-action die with "Unable to
|
||||||
|
# locate executable file: docker". Same pattern as the proven
|
||||||
|
# breakpilot-compliance build-push-deploy.yml: run in docker:27-cli
|
||||||
|
# (talks to the runner's daemon) and use plain docker commands.
|
||||||
|
container: docker:27-cli
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git curl openssl
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} $(echo ${GITHUB_SERVER_URL} | sed -E "s#^(https?://)#\1ci:${{ secrets.GITHUB_TOKEN }}@#")/${GITHUB_REPOSITORY}.git .
|
||||||
|
|
||||||
- uses: docker/login-action@v3
|
- name: Login
|
||||||
with:
|
env:
|
||||||
registry: repo.breakpilot.com
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
|
||||||
password: ${{ secrets.REGISTRY_PASS }}
|
run: echo "$REGISTRY_PASS" | docker login repo.breakpilot.com -u "$REGISTRY_USER" --password-stdin
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- name: Build + push
|
||||||
with:
|
run: |
|
||||||
push: true
|
set -eu
|
||||||
tags: |
|
docker build \
|
||||||
repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:sha-${{ github.sha }}
|
-t repo.breakpilot.com/breakpilot/tenant-registry:latest \
|
||||||
repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:latest
|
-t repo.breakpilot.com/breakpilot/tenant-registry:sha-${GITHUB_SHA} \
|
||||||
|
.
|
||||||
- uses: anchore/sbom-action@v0
|
docker push repo.breakpilot.com/breakpilot/tenant-registry:latest
|
||||||
with:
|
docker push repo.breakpilot.com/breakpilot/tenant-registry:sha-${GITHUB_SHA}
|
||||||
image: repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:sha-${{ github.sha }}
|
|
||||||
|
|
||||||
# Notify orca to pull :latest and recreate the service (same pattern as
|
# Notify orca to pull :latest and recreate the service (same pattern as
|
||||||
# breakpilot-core/build-pitch-deck.yml). The stage-env deploy this used to
|
# breakpilot-core/build-pitch-deck.yml). The stage-env deploy this used to
|
||||||
@@ -127,7 +135,7 @@ jobs:
|
|||||||
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
|
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
|
||||||
ORCA_WEBHOOK_URL: http://46.225.100.82:6880/api/v1/webhooks/github
|
ORCA_WEBHOOK_URL: http://46.225.100.82:6880/api/v1/webhooks/github
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -eu
|
||||||
PAYLOAD="{\"ref\":\"refs/heads/main\",\"repository\":{\"full_name\":\"${GITHUB_REPOSITORY}\"},\"head_commit\":{\"id\":\"${GITHUB_SHA}\",\"message\":\"ci: tenant-registry image build\"}}"
|
PAYLOAD="{\"ref\":\"refs/heads/main\",\"repository\":{\"full_name\":\"${GITHUB_REPOSITORY}\"},\"head_commit\":{\"id\":\"${GITHUB_SHA}\",\"message\":\"ci: tenant-registry image build\"}}"
|
||||||
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -r | awk '{print $1}')
|
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -r | awk '{print $1}')
|
||||||
curl -sSf -k \
|
curl -sSf -k \
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
# /tenant-registry — long-running API server
|
# /tenant-registry — long-running API server
|
||||||
# /migrate — one-shot schema migrator (Orca init container in prod)
|
# /migrate — one-shot schema migrator (Orca init container in prod)
|
||||||
|
|
||||||
FROM golang:1.24-alpine AS build
|
FROM golang:1.25-alpine AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
+14
-1
@@ -13,6 +13,7 @@ import (
|
|||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/authn"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/authn"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
"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/server"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||||
)
|
)
|
||||||
@@ -74,7 +75,19 @@ func main() {
|
|||||||
slog.Warn("AUTH_ENABLED=false — API is unauthenticated (dev only)")
|
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{
|
srv := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ type Config struct {
|
|||||||
// contains AuthAudience.
|
// contains AuthAudience.
|
||||||
AuthEnabled bool
|
AuthEnabled bool
|
||||||
AuthAudience string
|
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) {
|
func Load() (*Config, error) {
|
||||||
@@ -49,6 +56,10 @@ func Load() (*Config, error) {
|
|||||||
|
|
||||||
AuthEnabled: getenv("AUTH_ENABLED", "false") == "true",
|
AuthEnabled: getenv("AUTH_ENABLED", "false") == "true",
|
||||||
AuthAudience: getenv("AUTH_EXPECTED_AUDIENCE", "tenant-registry"),
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,11 +75,12 @@ type Adapter interface {
|
|||||||
SyncClaims(ctx context.Context, userID string, c Claims) error
|
SyncClaims(ctx context.Context, userID string, c Claims) error
|
||||||
|
|
||||||
// Memberships resolves the tenants user userID (the Keycloak user id,
|
// Memberships resolves the tenants user userID (the Keycloak user id,
|
||||||
// i.e. the JWT `sub`) belongs to, as Keycloak records them. The realm
|
// i.e. the JWT `sub`) belongs to, as Keycloak records them. Since the
|
||||||
// has no Organizations yet, so this reads the user's attribute
|
// realm enabled Organizations (2026-09-01) this is an org-membership
|
||||||
// projection — zero or one memberships. When the realm migrates to
|
// query: one membership per enabled org the user belongs to (alias =
|
||||||
// Organizations this becomes an org-membership query and callers keep
|
// tenant slug, org attribute tenant_id = registry UUID). The legacy
|
||||||
// working unchanged. Returns ErrUserNotFound for an unknown user id.
|
// user-attribute projection no longer grants membership on its own.
|
||||||
|
// Returns ErrUserNotFound for an unknown user id.
|
||||||
Memberships(ctx context.Context, userID string) ([]Claims, error)
|
Memberships(ctx context.Context, userID string) ([]Claims, error)
|
||||||
|
|
||||||
// Health pings the admin endpoint. Used by readyz and the cluster cold-
|
// Health pings the admin endpoint. Used by readyz and the cluster cold-
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -24,6 +25,7 @@ type stubKC struct {
|
|||||||
emailCalls atomic.Int32
|
emailCalls atomic.Int32
|
||||||
healthCalls atomic.Int32
|
healthCalls atomic.Int32
|
||||||
syncCalls atomic.Int32
|
syncCalls atomic.Int32
|
||||||
|
lastOrgBody string
|
||||||
|
|
||||||
tokenFails atomic.Bool // when true, /token returns 401 once
|
tokenFails atomic.Bool // when true, /token returns 401 once
|
||||||
}
|
}
|
||||||
@@ -53,6 +55,8 @@ func newStubKC(t *testing.T) *stubKC {
|
|||||||
mux.HandleFunc("/admin/realms/test-realm/organizations", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/admin/realms/test-realm/organizations", func(w http.ResponseWriter, r *http.Request) {
|
||||||
s.orgCalls.Add(1)
|
s.orgCalls.Add(1)
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
s.lastOrgBody = string(body)
|
||||||
w.Header().Set("Location", s.srv.URL+"/admin/realms/test-realm/organizations/org-xyz")
|
w.Header().Set("Location", s.srv.URL+"/admin/realms/test-realm/organizations/org-xyz")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
return
|
return
|
||||||
@@ -135,6 +139,12 @@ func TestHTTPAdapter_createOrgAndInvite(t *testing.T) {
|
|||||||
t.Errorf("call counts: org=%d user=%d member=%d email=%d",
|
t.Errorf("call counts: org=%d user=%d member=%d email=%d",
|
||||||
s.orgCalls.Load(), s.userCalls.Load(), s.memberCalls.Load(), s.emailCalls.Load())
|
s.orgCalls.Load(), s.userCalls.Load(), s.memberCalls.Load(), s.emailCalls.Load())
|
||||||
}
|
}
|
||||||
|
// KC 26 rejects a domainless org; the adapter must send a synthetic
|
||||||
|
// per-slug domain so onboarding actually provisions.
|
||||||
|
if !strings.Contains(s.lastOrgBody, `"domains"`) ||
|
||||||
|
!strings.Contains(s.lastOrgBody, "acme.tenant.breakpilot.com") {
|
||||||
|
t.Errorf("org create body missing synthetic domain: %s", s.lastOrgBody)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHTTPAdapter_emailMissingAdminEmailRejected(t *testing.T) {
|
func TestHTTPAdapter_emailMissingAdminEmailRejected(t *testing.T) {
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import (
|
|||||||
|
|
||||||
// ─── organizations API ───────────────────────────────────────────────────
|
// ─── organizations API ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// orgDomainSuffix namespaces the synthetic org domain. The slug is unique in
|
||||||
|
// the registry, so "<slug>.tenant.breakpilot.com" is unique per organization
|
||||||
|
// and never a real deliverable mail domain we might clash with.
|
||||||
|
const orgDomainSuffix = ".tenant.breakpilot.com"
|
||||||
|
|
||||||
type orgCreate struct {
|
type orgCreate struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Alias string `json:"alias"`
|
Alias string `json:"alias"`
|
||||||
@@ -50,6 +55,16 @@ func (a *HTTPAdapter) CreateOrgAndInvite(ctx context.Context, in InviteInput) (*
|
|||||||
Name: in.Name,
|
Name: in.Name,
|
||||||
Alias: in.Slug,
|
Alias: in.Slug,
|
||||||
Description: fmt.Sprintf("Auto-provisioned from tenant-registry %s", in.TenantID),
|
Description: fmt.Sprintf("Auto-provisioned from tenant-registry %s", in.TenantID),
|
||||||
|
// Keycloak 26 rejects an organization with no domain ("You must
|
||||||
|
// provide at least one domain"). Membership is registry-authoritative
|
||||||
|
// (model B2), so we do NOT use Keycloak's email-domain auto-join; a
|
||||||
|
// synthetic per-tenant domain derived from the unique slug satisfies
|
||||||
|
// the constraint without depending on the customer's real mail domain
|
||||||
|
// (which may be a shared public domain and would collide across
|
||||||
|
// tenants). Unverified is fine — verification only gates auto-join.
|
||||||
|
Domains: []map[string]any{
|
||||||
|
{"name": in.Slug + orgDomainSuffix, "verified": false},
|
||||||
|
},
|
||||||
Attributes: map[string][]string{
|
Attributes: map[string][]string{
|
||||||
"tenant_id": {in.TenantID},
|
"tenant_id": {in.TenantID},
|
||||||
},
|
},
|
||||||
|
|||||||
+52
-17
@@ -18,7 +18,33 @@ type userRepresentation struct {
|
|||||||
Attributes map[string][]string `json:"attributes"`
|
Attributes map[string][]string `json:"attributes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memberships implements Adapter against GET /admin/realms/{realm}/users/{id}.
|
// memberOrgRepresentation is the slice of OrganizationRepresentation the
|
||||||
|
// membership query needs: the alias IS the tenant slug and the org
|
||||||
|
// attribute "tenant_id" carries the registry tenant UUID (both written by
|
||||||
|
// CreateOrgAndInvite, or provisioned by the realm admin for pre-existing
|
||||||
|
// tenants).
|
||||||
|
type memberOrgRepresentation struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Attributes map[string][]string `json:"attributes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memberships implements Adapter with Keycloak Organizations as the
|
||||||
|
// authoritative membership source (prod shape, realm orgs enabled
|
||||||
|
// 2026-09-01):
|
||||||
|
//
|
||||||
|
// 1. GET /users/{id} — preserves ErrUserNotFound semantics and supplies
|
||||||
|
// the per-user claim attributes (org_roles / products / plan /
|
||||||
|
// tenant_status) that SyncClaims maintains.
|
||||||
|
// 2. GET /organizations/members/{id}/organizations — the memberships.
|
||||||
|
//
|
||||||
|
// One Claims entry per ENABLED organization: TenantID = org attribute
|
||||||
|
// "tenant_id" (registry UUID), TenantSlug = org alias. A disabled org
|
||||||
|
// grants no membership. A user in no organization has zero memberships —
|
||||||
|
// the legacy user-attribute tenant projection is NO LONGER consulted for
|
||||||
|
// membership, so a stale tenant_id/tenant_slug user attribute cannot
|
||||||
|
// grant access the org model has revoked.
|
||||||
func (a *HTTPAdapter) Memberships(ctx context.Context, userID string) ([]Claims, error) {
|
func (a *HTTPAdapter) Memberships(ctx context.Context, userID string) ([]Claims, error) {
|
||||||
var u userRepresentation
|
var u userRepresentation
|
||||||
resp, err := a.adminCall(ctx, http.MethodGet, "/users/"+userID, nil, &u)
|
resp, err := a.adminCall(ctx, http.MethodGet, "/users/"+userID, nil, &u)
|
||||||
@@ -33,25 +59,34 @@ func (a *HTTPAdapter) Memberships(ctx context.Context, userID string) ([]Claims,
|
|||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
return nil, fmt.Errorf("keycloak get user: %d", resp.StatusCode)
|
return nil, fmt.Errorf("keycloak get user: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
return claimsFromAttributes(u.Attributes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// claimsFromAttributes builds the membership list from a user's attribute
|
var orgs []memberOrgRepresentation
|
||||||
// projection. A user with no tenant_id and no tenant_slug attribute simply
|
resp, err = a.adminCall(
|
||||||
// has no memberships — that is a valid state, not an error.
|
ctx, http.MethodGet, "/organizations/members/"+userID+"/organizations", nil, &orgs,
|
||||||
func claimsFromAttributes(attrs map[string][]string) []Claims {
|
)
|
||||||
c := Claims{
|
if err != nil {
|
||||||
TenantID: attrValue(attrs, "tenant_id"),
|
return nil, err
|
||||||
TenantSlug: attrValue(attrs, "tenant_slug"),
|
|
||||||
OrgRoles: attrValues(attrs, "org_roles"),
|
|
||||||
Products: attrValues(attrs, "products"),
|
|
||||||
Plan: attrValue(attrs, "plan"),
|
|
||||||
TenantStatus: attrValue(attrs, "tenant_status"),
|
|
||||||
}
|
}
|
||||||
if c.TenantID == "" && c.TenantSlug == "" {
|
if resp.StatusCode/100 != 2 {
|
||||||
return []Claims{}
|
_ = resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("keycloak member organizations: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
return []Claims{c}
|
|
||||||
|
claims := []Claims{}
|
||||||
|
for _, org := range orgs {
|
||||||
|
if !org.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
claims = append(claims, Claims{
|
||||||
|
TenantID: attrValue(org.Attributes, "tenant_id"),
|
||||||
|
TenantSlug: org.Alias,
|
||||||
|
OrgRoles: attrValues(u.Attributes, "org_roles"),
|
||||||
|
Products: attrValues(u.Attributes, "products"),
|
||||||
|
Plan: attrValue(u.Attributes, "plan"),
|
||||||
|
TenantStatus: attrValue(u.Attributes, "tenant_status"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func attrValue(attrs map[string][]string, key string) string {
|
func attrValue(attrs map[string][]string, key string) string {
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// stubUsersKC is a users-endpoint-only KC look-alike; stubKC (client_test.go)
|
// stubUsersKC is a users+organizations KC look-alike; stubKC (client_test.go)
|
||||||
// covers the org/invite paths and doesn't register GET /users/{id}.
|
// covers the org-create/invite paths and doesn't register these reads.
|
||||||
func stubUsersKC(t *testing.T, users map[string]userRepresentation) *httptest.Server {
|
func stubUsersKC(
|
||||||
|
t *testing.T,
|
||||||
|
users map[string]userRepresentation,
|
||||||
|
memberOrgs map[string][]memberOrgRepresentation,
|
||||||
|
) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -28,6 +32,14 @@ func stubUsersKC(t *testing.T, users map[string]userRepresentation) *httptest.Se
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(u)
|
_ = json.NewEncoder(w).Encode(u)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("GET /admin/realms/test-realm/organizations/members/{id}/organizations", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
orgs, ok := memberOrgs[r.PathValue("id")]
|
||||||
|
if !ok {
|
||||||
|
orgs = []memberOrgRepresentation{}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(orgs)
|
||||||
|
})
|
||||||
srv := httptest.NewServer(mux)
|
srv := httptest.NewServer(mux)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
return srv
|
return srv
|
||||||
@@ -40,10 +52,11 @@ func usersAdapter(srv *httptest.Server) *HTTPAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHTTPAdapter_Memberships(t *testing.T) {
|
func TestHTTPAdapter_Memberships(t *testing.T) {
|
||||||
srv := stubUsersKC(t, map[string]userRepresentation{
|
users := map[string]userRepresentation{
|
||||||
"u-1": {ID: "u-1", Username: "test@breakpilot.com", Enabled: true, Attributes: map[string][]string{
|
"u-1": {ID: "u-1", Username: "test@breakpilot.com", Enabled: true, Attributes: map[string][]string{
|
||||||
"tenant_id": {"acme-001"},
|
// legacy projection attrs — MUST NOT grant membership on their own
|
||||||
"tenant_slug": {"acme"},
|
"tenant_id": {"stale-legacy-001"},
|
||||||
|
"tenant_slug": {"stale"},
|
||||||
"tenant_status": {"active"},
|
"tenant_status": {"active"},
|
||||||
"plan": {"Scale"},
|
"plan": {"Scale"},
|
||||||
"org_roles": {"IT_ADMIN", "FINANCE"},
|
"org_roles": {"IT_ADMIN", "FINANCE"},
|
||||||
@@ -51,10 +64,30 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
|
|||||||
"products": {"compliance##certifai"},
|
"products": {"compliance##certifai"},
|
||||||
}},
|
}},
|
||||||
"u-2": {ID: "u-2", Username: "bare@breakpilot.com", Enabled: true},
|
"u-2": {ID: "u-2", Username: "bare@breakpilot.com", Enabled: true},
|
||||||
})
|
"u-3": {ID: "u-3", Username: "multi@breakpilot.com", Enabled: true},
|
||||||
|
"u-4": {ID: "u-4", Username: "attrs-only@breakpilot.com", Enabled: true, Attributes: map[string][]string{
|
||||||
|
"tenant_id": {"acme-001"},
|
||||||
|
"tenant_slug": {"acme"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
memberOrgs := map[string][]memberOrgRepresentation{
|
||||||
|
"u-1": {{
|
||||||
|
ID: "org-1", Alias: "acme", Enabled: true,
|
||||||
|
Attributes: map[string][]string{"tenant_id": {"2f875d6a-1d94-433a-b2ec-8529451a2d89"}},
|
||||||
|
}},
|
||||||
|
"u-3": {
|
||||||
|
{ID: "org-1", Alias: "acme", Enabled: true,
|
||||||
|
Attributes: map[string][]string{"tenant_id": {"2f875d6a-1d94-433a-b2ec-8529451a2d89"}}},
|
||||||
|
{ID: "org-2", Alias: "globex", Enabled: true,
|
||||||
|
Attributes: map[string][]string{"tenant_id": {"7c3f2b10-0000-4000-8000-000000000042"}}},
|
||||||
|
{ID: "org-3", Alias: "disabled-co", Enabled: false,
|
||||||
|
Attributes: map[string][]string{"tenant_id": {"9e9e9e9e-0000-4000-8000-000000000099"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
srv := stubUsersKC(t, users, memberOrgs)
|
||||||
a := usersAdapter(srv)
|
a := usersAdapter(srv)
|
||||||
|
|
||||||
t.Run("attribute projection becomes one membership", func(t *testing.T) {
|
t.Run("org membership becomes the claim, org attrs are authoritative", func(t *testing.T) {
|
||||||
got, err := a.Memberships(context.Background(), "u-1")
|
got, err := a.Memberships(context.Background(), "u-1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("memberships: %v", err)
|
t.Fatalf("memberships: %v", err)
|
||||||
@@ -63,8 +96,14 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
|
|||||||
t.Fatalf("want 1 membership, got %d", len(got))
|
t.Fatalf("want 1 membership, got %d", len(got))
|
||||||
}
|
}
|
||||||
c := got[0]
|
c := got[0]
|
||||||
if c.TenantID != "acme-001" || c.TenantSlug != "acme" || c.Plan != "Scale" || c.TenantStatus != "active" {
|
// tenant identity comes from the ORG (alias + tenant_id attribute),
|
||||||
t.Errorf("scalar claims wrong: %+v", c)
|
// never from the user's legacy projection attributes
|
||||||
|
if c.TenantID != "2f875d6a-1d94-433a-b2ec-8529451a2d89" || c.TenantSlug != "acme" {
|
||||||
|
t.Errorf("org identity wrong: %+v", c)
|
||||||
|
}
|
||||||
|
// per-user claim attrs still ride along
|
||||||
|
if c.Plan != "Scale" || c.TenantStatus != "active" {
|
||||||
|
t.Errorf("user claim attrs wrong: %+v", c)
|
||||||
}
|
}
|
||||||
if len(c.OrgRoles) != 2 || c.OrgRoles[0] != "IT_ADMIN" || c.OrgRoles[1] != "FINANCE" {
|
if len(c.OrgRoles) != 2 || c.OrgRoles[0] != "IT_ADMIN" || c.OrgRoles[1] != "FINANCE" {
|
||||||
t.Errorf("org_roles wrong: %v", c.OrgRoles)
|
t.Errorf("org_roles wrong: %v", c.OrgRoles)
|
||||||
@@ -74,7 +113,7 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("user without tenant attributes has zero memberships", func(t *testing.T) {
|
t.Run("user in no org has zero memberships", func(t *testing.T) {
|
||||||
got, err := a.Memberships(context.Background(), "u-2")
|
got, err := a.Memberships(context.Background(), "u-2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("memberships: %v", err)
|
t.Fatalf("memberships: %v", err)
|
||||||
@@ -84,6 +123,29 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("legacy tenant attributes alone grant NO membership", func(t *testing.T) {
|
||||||
|
got, err := a.Memberships(context.Background(), "u-4")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("memberships: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("attribute projection must not grant membership, got %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multiple orgs give multiple memberships, disabled org skipped", func(t *testing.T) {
|
||||||
|
got, err := a.Memberships(context.Background(), "u-3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("memberships: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("want 2 memberships (disabled org skipped), got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0].TenantSlug != "acme" || got[1].TenantSlug != "globex" {
|
||||||
|
t.Errorf("slugs wrong: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("unknown user is ErrUserNotFound", func(t *testing.T) {
|
t.Run("unknown user is ErrUserNotFound", func(t *testing.T) {
|
||||||
_, err := a.Memberships(context.Background(), "nope")
|
_, err := a.Memberships(context.Background(), "nope")
|
||||||
if !errors.Is(err, ErrUserNotFound) {
|
if !errors.Is(err, ErrUserNotFound) {
|
||||||
@@ -92,15 +154,24 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMock_Memberships(t *testing.T) {
|
func TestHTTPAdapter_Memberships_OrgQueryFailure(t *testing.T) {
|
||||||
m := NewMock()
|
mux := http.NewServeMux()
|
||||||
if _, err := m.Memberships(context.Background(), "ghost"); !errors.Is(err, ErrUserNotFound) {
|
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
t.Fatalf("want ErrUserNotFound, got %v", err)
|
w.Header().Set("Content-Type", "application/json")
|
||||||
}
|
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "t", "expires_in": 60})
|
||||||
want := Claims{TenantSlug: "acme", OrgRoles: []string{"IT_ADMIN"}}
|
})
|
||||||
m.Claims["u-1"] = want
|
mux.HandleFunc("GET /admin/realms/test-realm/users/{id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
got, err := m.Memberships(context.Background(), "u-1")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if err != nil || len(got) != 1 || got[0].TenantSlug != "acme" {
|
_ = json.NewEncoder(w).Encode(userRepresentation{ID: "u-1", Enabled: true})
|
||||||
t.Fatalf("got %+v err %v", got, err)
|
})
|
||||||
|
mux.HandleFunc("GET /admin/realms/test-realm/organizations/members/{id}/organizations", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
_, err := usersAdapter(srv).Memberships(context.Background(), "u-1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error when the org query fails, got 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/authn"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
||||||
|
"gitea.meghsakha.com/platform/tenant-registry/internal/product"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,8 +21,19 @@ type Server struct {
|
|||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
Store store.Store
|
Store store.Store
|
||||||
Keycloak keycloak.Adapter // never nil — main wires Mock when KC env is unset
|
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)
|
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.
|
// NewRouter builds the http.Handler with logging middleware applied.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.meghsakha.com/platform/tenant-registry/internal/product"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
"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})
|
writeJSON(w, http.StatusCreated, createTenantResp{Tenant: t, InviteURL: inviteURL})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user