GET /v1/users/{id}/memberships answers 'which tenants does this JWT
subject belong to, with which org_roles and entitlements' (ratified
auth design, model B2). Keycloak supplies user->tenant links and roles
via the Adapter (attribute projection until the realm migrates to
Organizations); registered tenants override status/plan/products from
registry tables.
New internal/authn verifies Keycloak bearer tokens (OIDC discovery +
JWKS, audience AUTH_EXPECTED_AUDIENCE, default tenant-registry). With
AUTH_ENABLED=true all routes except /healthz + /readyz require a token
and the server refuses to start if the verifier cannot initialize;
false (dev default) keeps the API open. Completes the M5.2-deferred
org_roles lookup and replaces the 'M4.3 adds JWT validation' TODO in
the spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
105 lines
3.3 KiB
Go
105 lines
3.3 KiB
Go
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",
|
|
}
|
|
}
|