package server import ( "context" "errors" "net/http" "time" "gitea.meghsakha.com/platform/tenant-registry/internal/keycloak" "gitea.meghsakha.com/platform/tenant-registry/internal/store" ) // membershipItem is one tenant a user belongs to. Source records which // system produced the authoritative half: "registry" when the tenant exists // here (status/plan/products come from our tables), "keycloak" when only // the attribute projection knows it (e.g. a tenant seeded directly in the // realm that the registry has not onboarded yet). type membershipItem struct { keycloak.Claims Source string `json:"source"` } type membershipsResp struct { UserID string `json:"user_id"` Memberships []membershipItem `json:"memberships"` } // getUserMemberships is GET /v1/users/{id}/memberships — the membership // authority endpoint (ratified auth design, model B2). Product backends // call it to answer "which tenants does this JWT subject belong to, with // which roles", instead of trusting token claims or client headers. // // Resolution: Keycloak (via the Adapter) supplies user→tenant links and // org_roles; where the tenant is registered here, status, plan, and // product entitlements are overridden from the registry tables, which are // authoritative for lifecycle and billing state. func (s *Server) getUserMemberships(w http.ResponseWriter, r *http.Request) { userID := r.PathValue("id") if userID == "" { writeError(w, http.StatusBadRequest, "invalid_input", "user id required") return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() claims, err := s.Keycloak.Memberships(ctx, userID) if err != nil { switch { case errors.Is(err, keycloak.ErrUserNotFound): writeError(w, http.StatusNotFound, "not_found", "user does not exist") case errors.Is(err, keycloak.ErrUnavailable), errors.Is(err, keycloak.ErrUnauthorized): writeError(w, http.StatusServiceUnavailable, "keycloak_unavailable", err.Error()) default: writeError(w, http.StatusInternalServerError, "internal", err.Error()) } return } items := make([]membershipItem, 0, len(claims)) for _, c := range claims { items = append(items, s.enrichMembership(ctx, c)) } writeJSON(w, http.StatusOK, membershipsResp{UserID: userID, Memberships: items}) } // enrichMembership overrides the Keycloak attribute projection with // registry truth when the tenant is known here. Lookup prefers the slug — // the attribute tenant_id predates the registry for hand-seeded dev users // and may not be a registry id. func (s *Server) enrichMembership(ctx context.Context, c keycloak.Claims) membershipItem { var ( t *store.Tenant err error ) if c.TenantSlug != "" { t, err = s.Store.GetTenantBySlug(ctx, c.TenantSlug) } else { t, err = s.Store.GetTenant(ctx, c.TenantID) } if err != nil || t == nil { return membershipItem{Claims: c, Source: "keycloak"} } products := []string{} if tps, perr := s.Store.ListTenantProducts(ctx, t.ID); perr == nil { for _, p := range tps { if p.Enabled { products = append(products, p.Product) } } } return membershipItem{ Claims: keycloak.Claims{ TenantID: t.ID, TenantSlug: t.Slug, OrgRoles: c.OrgRoles, // roles stay Keycloak-owned Products: products, Plan: t.Plan, TenantStatus: t.Status, }, Source: "registry", } }