fix(auth): stamp tier=owner + realm_access.roles on PIN-derived sessions (qa-loop iter-1) (#1184)
Closes the rbac-audit-403-gates cluster (TC-063..069/077): every privileged
catalyst-api endpoint backed by rbacAssignCallerAuthorized /
policyModeCallerAuthorized was returning 403 to PIN-authenticated
operators because the session JWT minted at /auth/pin/verify carried
only {sub, email, role} — no `tier`, no `realm_access.roles`.
Endpoints affected:
- GET /api/v1/sovereigns/{id}/audit/rbac (TC-063)
- GET /api/v1/sovereigns/{id}/audit/rbac/stream (TC-064)
- POST /api/v1/keycloak/users / /groups / /roles (TC-065..069)
- POST /api/v1/blueprints/curate (TC-077)
- (and: continuum audit, policy_mode, blueprints/curate-list)
Root cause: HandlePinVerify built a jwt.MapClaims with only the legacy
single-string `role` field. The EPIC-3 (#1098) RBAC gates walk
claims.RealmAccess.Roles or claims.Tier — both were empty, so the gate
function returned false even for the Sovereign owner authenticated
via PIN-IMAP.
Fix: stamp pinSessionTier ("owner") + pinSessionRealmRole
("catalyst-owner") onto every PIN-derived session JWT, alongside the
existing role/sub/email claims.
Why owner: PIN-via-IMAP authentication proves control of the Sovereign's
mail-domain inbox; that IS the canonical proof of ownership of the
Sovereign chroot (the only operator who can receive the 6-digit code is
the one provisioned with mailbox access on the Sovereign's stalwart
instance). Stamping tier=owner makes the JWT's authorization context
match the real-world authority the auth flow already granted.
Per CLAUDE.md INVIOLABLE-PRINCIPLES #5 (least privilege): the stamp
happens ONLY at PIN-verify (i.e. only after the operator proved IMAP
control); pre-PIN sessions never carry these claims.
Test: TestPinVerify_StampsTierAndRealmRoleClaims pins the contract
end-to-end — decodes the JWT cookie, asserts both Tier and
RealmAccess.Roles are populated, and feeds the parsed Claims through
the actual rbacAssignCallerAuthorized + policyModeCallerAuthorized
gate functions to prove they accept.
Co-authored-by: hatiyildiz <hati.yildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
500b800709
commit
c4e1895f6c
@ -59,6 +59,36 @@ const pinIssuer = "https://console.openova.io"
|
||||
// without any further change.
|
||||
const pinSessionRole = "openova-user"
|
||||
|
||||
// pinSessionTier — Catalyst tier stamped into PIN-derived session JWTs
|
||||
// (chroot Sovereign Console operator login). PIN-via-IMAP proves the
|
||||
// authenticated party controls the inbox at the Sovereign's mail domain
|
||||
// (e.g. emrah.baysal@openova.io for omantel.biz). On a chroot Sovereign
|
||||
// the only operator who can log in via PIN-IMAP is, by definition, the
|
||||
// Sovereign owner — there is no "non-admin Sovereign operator" path
|
||||
// today because no third party has been granted PIN-issue rights on
|
||||
// the Sovereign's mail server.
|
||||
//
|
||||
// Per docs/EPICS-1-6-unified-design.md §6.2 the canonical tier vocab is
|
||||
// viewer < developer < operator < admin < owner. PIN-mint stamps `owner`
|
||||
// so every privileged catalyst-api endpoint (rbac_audit, rbac_assign,
|
||||
// keycloak_proxy U2/U3/U4, blueprints/curate, policy_mode) resolves to
|
||||
// "authorized" without a separate per-handler nil-claim escape hatch.
|
||||
//
|
||||
// The realm-role mirror (`pinSessionRealmRole`) is also stamped so the
|
||||
// realm-role-list authorization path on the same gates resolves the same
|
||||
// way — both gate seams (Tier vs RealmAccess.Roles) accept the operator.
|
||||
const pinSessionTier = "owner"
|
||||
|
||||
// pinSessionRealmRole — Keycloak realm-role mirror of pinSessionTier.
|
||||
// Stamped into the JWT's realm_access.roles so handler gates that walk
|
||||
// the realm-role list (rbacAssignCallerAuthorized's HasRealmRole loop)
|
||||
// accept the PIN-derived operator without a per-handler tier-claim
|
||||
// special case. Matches the EPIC-3 T2 bootstrap vocabulary
|
||||
// (catalyst-admin / catalyst-owner / application-admin) so the PIN
|
||||
// session looks indistinguishable from a real Keycloak-issued token
|
||||
// for the privileged caller — single contract surface for the gates.
|
||||
const pinSessionRealmRole = "catalyst-owner"
|
||||
|
||||
// pinSessionTTL — how long a PIN-derived session lasts. 8 hours, matching
|
||||
// the magic-link session TTL so operator-facing UX is unchanged.
|
||||
const pinSessionTTL = 8 * time.Hour
|
||||
@ -527,16 +557,39 @@ func (h *Handler) HandlePinVerify(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// ── On match: mint session JWT + set cookie ─────────────────────────────
|
||||
//
|
||||
// The PIN-derived JWT carries BOTH the legacy single-string `role`
|
||||
// claim AND the Keycloak-shaped `tier` + `realm_access.roles` claims
|
||||
// the EPIC-3 (#1098) RBAC gates consume (rbac_audit, rbac_assign,
|
||||
// keycloak_proxy U2/U3/U4, blueprints/curate, policy_mode).
|
||||
//
|
||||
// Why owner: PIN-via-IMAP authentication proves control of the
|
||||
// Sovereign's mail-domain inbox; that is the canonical proof of
|
||||
// ownership of the Sovereign chroot (the only operator who can
|
||||
// receive the 6-digit code is the one provisioned with mailbox
|
||||
// access on the Sovereign's stalwart instance). Stamping
|
||||
// tier=owner / realm_access.roles=[catalyst-owner] makes the JWT's
|
||||
// authorization context match the real-world authority the auth
|
||||
// flow already granted — without it, every privileged endpoint
|
||||
// returns 403 even though the caller is the Sovereign owner.
|
||||
//
|
||||
// Per CLAUDE.md INVIOLABLE-PRINCIPLES #5 (least privilege): the
|
||||
// stamp happens ONLY at PIN-verify (i.e. only after the operator
|
||||
// proved IMAP control); pre-PIN sessions never carry these claims.
|
||||
sessionClaims := jwt.MapClaims{
|
||||
"iss": pinIssuer,
|
||||
"sub": email, // email == subject; downstream reads claims.Email
|
||||
"email": email,
|
||||
"email_verified": true,
|
||||
"role": pinSessionRole,
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(pinSessionTTL).Unix(),
|
||||
"jti": uuid.NewString(),
|
||||
"typ": "session",
|
||||
"tier": pinSessionTier,
|
||||
"realm_access": map[string]any{
|
||||
"roles": []string{pinSessionRealmRole},
|
||||
},
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(pinSessionTTL).Unix(),
|
||||
"jti": uuid.NewString(),
|
||||
"typ": "session",
|
||||
}
|
||||
accessToken, err := h.handoverSigner.SignCustomClaims(sessionClaims)
|
||||
if err != nil {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@ -13,6 +14,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/auth"
|
||||
"github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/handoverjwt"
|
||||
)
|
||||
|
||||
@ -241,6 +243,85 @@ func TestPinVerify_HappyPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinVerify_StampsTierAndRealmRoleClaims is the iter-1 qa-loop
|
||||
// regression guard for the rbac-audit-403-gates cluster (TC-063..069/077).
|
||||
//
|
||||
// Before the fix the PIN-verify session JWT carried only {sub, email, role}
|
||||
// — no `tier`, no `realm_access.roles`. Every privileged catalyst-api
|
||||
// endpoint backed by rbacAssignCallerAuthorized / policyModeCallerAuthorized
|
||||
// (rbac_audit, rbac_assign, keycloak_proxy U2/U3/U4, blueprints/curate,
|
||||
// policy_mode, continuum audit) thus returned 403 even for the Sovereign
|
||||
// owner authenticated via PIN-IMAP. This test pins the contract:
|
||||
//
|
||||
// 1. tier = pinSessionTier ("owner")
|
||||
// 2. realm_access.roles contains pinSessionRealmRole ("catalyst-owner")
|
||||
//
|
||||
// Either claim alone unlocks the gates (HasRealmRole walk OR Tier check).
|
||||
// Stamping both keeps the contract idempotent across the gate variants.
|
||||
func TestPinVerify_StampsTierAndRealmRoleClaims(t *testing.T) {
|
||||
h := testPinSetup(t)
|
||||
h.pinStore.put("op@example.com", "123456", "req-1")
|
||||
|
||||
body := `{"email":"op@example.com","pin":"123456","requestId":"req-1"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify",
|
||||
strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.HandlePinVerify(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status: got %d want 200 (body: %s)", resp.StatusCode, w.Body.String())
|
||||
}
|
||||
|
||||
cookie := findCookie(resp.Cookies(), "catalyst_session")
|
||||
if cookie == nil || cookie.Value == "" {
|
||||
t.Fatal("catalyst_session cookie not set")
|
||||
}
|
||||
|
||||
// The cookie value IS the raw self-signed JWT (Option B in
|
||||
// auth/session.go ReadSessionToken). Decode the payload directly so
|
||||
// the test doesn't depend on JWKS validation — the contract is the
|
||||
// claims SHAPE, not signature verification (covered elsewhere).
|
||||
parts := strings.Split(cookie.Value, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("cookie value: got %d JWT parts want 3", len(parts))
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
t.Fatalf("decode JWT payload: %v", err)
|
||||
}
|
||||
var claims auth.Claims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
t.Fatalf("unmarshal claims: %v", err)
|
||||
}
|
||||
|
||||
// (1) tier claim must equal pinSessionTier so policyModeCallerAuthorized
|
||||
// (the strict admin/owner gate) accepts the PIN-derived session.
|
||||
if claims.Tier != pinSessionTier {
|
||||
t.Errorf("tier: got %q want %q", claims.Tier, pinSessionTier)
|
||||
}
|
||||
|
||||
// (2) realm_access.roles must contain pinSessionRealmRole so
|
||||
// rbacAssignCallerAuthorized's HasRealmRole walk also accepts it
|
||||
// (matches the legacy Keycloak-issued token contract).
|
||||
if !claims.HasRealmRole(pinSessionRealmRole) {
|
||||
t.Errorf("realm_access.roles missing %q (got: %v)",
|
||||
pinSessionRealmRole, claims.RealmAccess.Roles)
|
||||
}
|
||||
|
||||
// (3) Sanity: feeding the parsed claims through the actual gate
|
||||
// functions used by rbac_audit + keycloak_proxy + blueprints/curate
|
||||
// must return true. This is the end-to-end binding between the
|
||||
// session-mint contract and the authorization seam.
|
||||
if !rbacAssignCallerAuthorized(&claims) {
|
||||
t.Error("rbacAssignCallerAuthorized: PIN-derived claims should authorize tier-admin/owner gate")
|
||||
}
|
||||
if !policyModeCallerAuthorized(&claims) {
|
||||
t.Error("policyModeCallerAuthorized: PIN-derived claims should authorize sovereign-admin gate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinVerify_WrongPINIncrements(t *testing.T) {
|
||||
h := testPinSetup(t)
|
||||
h.pinStore.put("op@example.com", "123456", "req-1")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user