package server import ( "context" "errors" "net/http" "time" "gitea.meghsakha.com/platform/tenant-registry/internal/keycloak" "gitea.meghsakha.com/platform/tenant-registry/internal/store" ) // provisionKeycloak is called inside createTenant after the DB insert // succeeds. Best-effort: a failure does NOT roll the tenant back. The // audit_log captures the error so the operator can heal it later // (resending the invite is a one-click in the KC admin UI). // // Returns the InviteURL so the API response can surface it for dev. func (s *Server) provisionKeycloak(ctx context.Context, t *store.Tenant, adminEmail, adminName string) (string, error) { if adminEmail == "" { // Skip silently — caller chose not to invite anyone yet (sales-led // flow, demo tenant, test fixture, etc.). return "", nil } res, err := s.Keycloak.CreateOrgAndInvite(ctx, keycloak.InviteInput{ TenantID: t.ID, Slug: t.Slug, Name: t.Name, AdminEmail: adminEmail, AdminName: adminName, }) if err != nil { s.Log.Error("keycloak provision failed", "tenant_id", t.ID, "slug", t.Slug, "err", err) return "", err } s.Log.Info("keycloak provisioned", "tenant_id", t.ID, "kc_org_id", res.OrganizationID, "kc_user_id", res.UserID) return res.InviteURL, nil } // kcClaims is POST /v1/internal/keycloak/claims. Called by Keycloak's // protocol mapper (or by a dev tester) to fetch the current entitlement // bundle for a user. Lookup chain: // 1. body.tenant_slug → tenant // 2. body.tenant_id → tenant // 3. body.user_attrs.tenant_id → tenant // // At least one must be present. type kcClaimsReq struct { TenantID string `json:"tenant_id,omitempty"` TenantSlug string `json:"tenant_slug,omitempty"` UserAttrs map[string]string `json:"user_attrs,omitempty"` } func (s *Server) kcClaims(w http.ResponseWriter, r *http.Request) { var in kcClaimsReq if !decodeJSON(w, r, &in) { return } id := in.TenantID if id == "" { id = in.UserAttrs["tenant_id"] } slug := in.TenantSlug if slug == "" { slug = in.UserAttrs["tenant_slug"] } if id == "" && slug == "" { writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id or tenant_slug required") return } ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() var ( t *store.Tenant err error ) if id != "" { t, err = s.Store.GetTenant(ctx, id) } else { t, err = s.Store.GetTenantBySlug(ctx, slug) } if err != nil { if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", "tenant does not exist") return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } products, err := s.Store.ListTenantProducts(ctx, t.ID) if err != nil && !errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } productKeys := make([]string, 0, len(products)) for _, p := range products { if p.Enabled { productKeys = append(productKeys, p.Product) } } writeJSON(w, http.StatusOK, keycloak.Claims{ TenantID: t.ID, TenantSlug: t.Slug, OrgRoles: []string{}, // populated by /v1/users/:id role lookup — out of scope until M5.2 Products: productKeys, Plan: t.Plan, TenantStatus: t.Status, }) }