RBAC Phase 1: membership authority endpoint + fail-closed API auth (#14)
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
This commit was merged in pull request #14.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
// Package authn validates Keycloak-issued bearer tokens for the
|
||||
// tenant-registry API (RBAC rollout Phase 1; fail-closed per the ratified
|
||||
// compliance auth design, model B2).
|
||||
//
|
||||
// The package only verifies — issuer, signature via JWKS, expiry, and
|
||||
// audience. It deliberately does not authorize: tenant-registry IS the
|
||||
// membership authority, so its callers are services (INTERNAL_SERVICE_ONLY
|
||||
// posture) holding client_credentials tokens whose audience includes
|
||||
// AUTH_EXPECTED_AUDIENCE.
|
||||
package authn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
)
|
||||
|
||||
// ErrNoToken means the Authorization header was absent or not a Bearer
|
||||
// scheme. Handlers map it to 401 TOKEN_MISSING.
|
||||
var ErrNoToken = errors.New("authorization header missing or not Bearer")
|
||||
|
||||
// Principal is the verified caller identity, placed in the request context
|
||||
// so handlers (and, later, audit writes) can attribute actions.
|
||||
type Principal struct {
|
||||
Subject string // JWT sub — the Keycloak user or service-account id
|
||||
ClientID string // JWT azp — which OAuth client obtained the token
|
||||
Issuer string
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// WithPrincipal returns ctx carrying p.
|
||||
func WithPrincipal(ctx context.Context, p *Principal) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, p)
|
||||
}
|
||||
|
||||
// PrincipalFrom extracts the verified principal, if any.
|
||||
func PrincipalFrom(ctx context.Context) (*Principal, bool) {
|
||||
p, ok := ctx.Value(ctxKey{}).(*Principal)
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Verifier checks bearer tokens against one issuer + audience. A nil
|
||||
// *Verifier means auth is disabled (AUTH_ENABLED=false) and the server
|
||||
// passes requests through unauthenticated.
|
||||
type Verifier struct {
|
||||
issuer string
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// New performs OIDC discovery against issuer and prepares JWKS-backed
|
||||
// verification. Callers must treat an error as fatal when AUTH_ENABLED=true
|
||||
// (AUTH_CONFIG_INCOMPLETE — refuse to start, never fall open).
|
||||
func New(ctx context.Context, issuer, audience string) (*Verifier, error) {
|
||||
if issuer == "" || audience == "" {
|
||||
return nil, errors.New("issuer and audience are required")
|
||||
}
|
||||
provider, err := oidc.NewProvider(ctx, issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc discovery for %s: %w", issuer, err)
|
||||
}
|
||||
return &Verifier{
|
||||
issuer: issuer,
|
||||
verifier: provider.Verifier(&oidc.Config{ClientID: audience}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify checks the raw Authorization header value and returns the caller
|
||||
// principal. Signature, issuer, expiry, and audience are all enforced by
|
||||
// the underlying oidc verifier.
|
||||
func (v *Verifier) Verify(ctx context.Context, authorization string) (*Principal, error) {
|
||||
raw, ok := strings.CutPrefix(authorization, "Bearer ")
|
||||
if !ok || strings.TrimSpace(raw) == "" {
|
||||
return nil, ErrNoToken
|
||||
}
|
||||
tok, err := v.verifier.Verify(ctx, strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var claims struct {
|
||||
Azp string `json:"azp"`
|
||||
}
|
||||
_ = tok.Claims(&claims) // azp is informational; absence is not an error
|
||||
return &Principal{Subject: tok.Subject, ClientID: claims.Azp, Issuer: tok.Issuer}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user