Implements the model-B2 membership authority (ratified compliance auth design) and inbound token verification.
- GET /v1/users/{id}/memberships: Keycloak supplies user->tenant links + org_roles (attribute projection until the realm migrates to Organizations); registered tenants override status/plan/products from registry tables (source: registry|keycloak). Closes the M5.2-deferred org_roles stub.
- New internal/authn: OIDC discovery + JWKS verification (go-oidc/v3), audience tenant-registry. /healthz + /readyz stay PUBLIC_EXPLICIT; all other routes INTERNAL_SERVICE_ONLY. AUTH_ENABLED=true refuses to start on incomplete config (fail-closed); false (dev default) keeps current behavior.
- Full suite green incl. postgres testcontainers; openapi.yaml updated (contract test passes).
Activation is a separate step (Auth-5): requires the orca-infra env merge and a portal service token (portal currently calls with no auth).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com>
Reviewed-on: #14
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",
|
|
}
|
|
}
|