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:
e3mrah 2026-05-09 15:03:34 +04:00 committed by GitHub
parent 500b800709
commit c4e1895f6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 138 additions and 4 deletions

View File

@ -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,12 +557,35 @@ 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,
"tier": pinSessionTier,
"realm_access": map[string]any{
"roles": []string{pinSessionRealmRole},
},
"iat": time.Now().Unix(),
"exp": time.Now().Add(pinSessionTTL).Unix(),
"jti": uuid.NewString(),

View File

@ -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")