fix(org-tokens): verified-session (human) mints skip the approval gate (core#2593) #2596
@@ -48,11 +48,11 @@ type createOrgTokenRequest struct {
|
||||
}
|
||||
|
||||
type createOrgTokenResponse struct {
|
||||
ID string `json:"id"`
|
||||
Prefix string `json:"prefix"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Token string `json:"auth_token"` // plaintext — shown ONCE
|
||||
Warning string `json:"warning"` // UX hint: copy now
|
||||
ID string `json:"id"`
|
||||
Prefix string `json:"prefix"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Token string `json:"auth_token"` // plaintext — shown ONCE
|
||||
Warning string `json:"warning"` // UX hint: copy now
|
||||
}
|
||||
|
||||
// Create mints a new org token. The plaintext is returned exactly
|
||||
@@ -100,18 +100,38 @@ func (h *OrgTokenHandler) Create(c *gin.Context) {
|
||||
// concierge/platform root workspace (callerPlatformOrg); session
|
||||
// callers fall through. If no anchor can be derived, return a
|
||||
// controlled 4xx — never pass "" into the UUID query.
|
||||
ws := approvalAnchorForGate(c)
|
||||
if ws == "" {
|
||||
log.Printf("OrgTokenHandler.Create: no approval anchor for caller class=%s (admin-token callers need MOLECULE_PLATFORM_WORKSPACE_ID; org-token callers need a token with a valid org_id; session callers need a concierge root)", orgTokenActorClass(c))
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "no approval anchor for this caller class — set MOLECULE_PLATFORM_WORKSPACE_ID for admin-token callers, or call via a session / org-token with a valid org",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !gateDestructive(c, nil, ws, approvals.ActionOrgTokenMint,
|
||||
"mint org token "+req.Name,
|
||||
map[string]interface{}{"actor": orgTokenActorClass(c), "name": req.Name, "workspace_id": ws}) {
|
||||
return
|
||||
// (core#2593) Verified-session (human) callers SKIP the approval
|
||||
// gate by design: the gate exists to put a human between an AGENT
|
||||
// and a privileged mint — when the minter IS a human at the
|
||||
// dashboard (CP-verified WorkOS session; AdminAuth only sets
|
||||
// cp_session_actor after VerifiedCPSession confirms the cookie
|
||||
// upstream), routing them through a pending-approval that the same
|
||||
// human would approve is a pure no-op round-trip. Live regression
|
||||
// this fixes: Settings → Org API Keys → "+ New Key" returned the
|
||||
// raw anchor 400 because approvalAnchorForGate had no session
|
||||
// branch (the #2579 assumption "the UI mints via the concierge"
|
||||
// was wrong — the canvas mints directly with the browser session).
|
||||
//
|
||||
// SECURITY: keyed on cp_session_actor (set ONLY post-verification),
|
||||
// NEVER on the raw Cookie header — an admin-token agent attaching a
|
||||
// junk Cookie header must NOT bypass the gate (pinned by
|
||||
// TestOrgTokenHandler_Create_ActorFromSession).
|
||||
if actor := callerVerifiedSessionActor(c); actor != "" {
|
||||
log.Printf("orgtoken: mint by verified human session %s — approval gate bypassed by design (core#2593)", actor)
|
||||
} else {
|
||||
ws := approvalAnchorForGate(c)
|
||||
if ws == "" {
|
||||
log.Printf("OrgTokenHandler.Create: no approval anchor for caller class=%s (admin-token callers need MOLECULE_PLATFORM_WORKSPACE_ID; org-token callers need a token with a valid org_id)", orgTokenActorClass(c))
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "no approval anchor for this caller class — set MOLECULE_PLATFORM_WORKSPACE_ID for admin-token callers, or call via an org-token with a valid org",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !gateDestructive(c, nil, ws, approvals.ActionOrgTokenMint,
|
||||
"mint org token "+req.Name,
|
||||
map[string]interface{}{"actor": orgTokenActorClass(c), "name": req.Name, "workspace_id": ws}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
createdBy, orgID := orgTokenActor(c)
|
||||
@@ -148,7 +168,9 @@ func orgTokenActorClass(c *gin.Context) string {
|
||||
return actorOrgTokenPrefix + s
|
||||
}
|
||||
}
|
||||
if c.GetHeader("Cookie") != "" {
|
||||
// Verified session only — the raw Cookie header is forgeable by
|
||||
// bearer-authed agents and must not influence classification.
|
||||
if callerVerifiedSessionActor(c) != "" {
|
||||
return actorSession
|
||||
}
|
||||
return actorAdminToken
|
||||
@@ -181,8 +203,8 @@ func (h *OrgTokenHandler) Revoke(c *gin.Context) {
|
||||
// and in mint/revoke audit logs. Kept as constants so the labels
|
||||
// are greppable across services (log pipelines, audit queries).
|
||||
const (
|
||||
actorOrgTokenPrefix = "org-token:" // appended: 8-char plaintext prefix from the UI
|
||||
actorSession = "session" // WorkOS-session-verified call
|
||||
actorOrgTokenPrefix = "org-token:" // appended: 8-char plaintext prefix from the UI
|
||||
actorSession = "session" // WorkOS-session-verified call
|
||||
actorAdminToken = "admin-token" // bootstrap ADMIN_TOKEN env
|
||||
)
|
||||
|
||||
@@ -259,6 +281,27 @@ func approvalAnchorForGate(c *gin.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// callerVerifiedSessionActor returns the cp_session_actor identity
|
||||
// ("session:<hash>") when — and ONLY when — the request authenticated
|
||||
// via a CP-VERIFIED WorkOS session cookie (AdminAuth sets the key
|
||||
// strictly after VerifiedCPSession confirms the cookie upstream
|
||||
// against /cp/auth/me). Returns "" for every bearer-authed caller,
|
||||
// including ones that happen to carry an unverified/junk Cookie
|
||||
// header. This is the human-vs-agent discriminator for the approval
|
||||
// gate (core#2593) — do NOT replace it with a raw Cookie check, which
|
||||
// any agent can forge.
|
||||
func callerVerifiedSessionActor(c *gin.Context) string {
|
||||
v, ok := c.Get("cp_session_actor")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// orgTokenActor returns (createdBy, orgID) for the current request.
|
||||
//
|
||||
// - If authed via another org token (org_token_id in context),
|
||||
@@ -274,11 +317,14 @@ func orgTokenActor(c *gin.Context) (createdBy, orgID string) {
|
||||
return actorOrgTokenPrefix + s, callerOrg(c)
|
||||
}
|
||||
}
|
||||
// Session-tier auth doesn't stash a WorkOS user_id in the gin
|
||||
// context today. Until it does, treat session requests as "session"
|
||||
// with no org anchor.
|
||||
if c.GetHeader("Cookie") != "" {
|
||||
return actorSession, ""
|
||||
// Verified-session callers carry the cp_session_actor identity
|
||||
// ("session:<hash>") — record THAT as created_by so the audit
|
||||
// trail distinguishes which human session minted the token.
|
||||
// (Previously this keyed off the raw Cookie header, which both
|
||||
// misattributed bearer-authed requests carrying incidental
|
||||
// cookies AND lost the per-session identity.)
|
||||
if actor := callerVerifiedSessionActor(c); actor != "" {
|
||||
return actor, ""
|
||||
}
|
||||
return actorAdminToken, ""
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -349,14 +349,12 @@ func TestOrgTokenHandler_Create_ActorFromSession(t *testing.T) {
|
||||
h, _, cleanup := setupOrgTokenTest(t)
|
||||
defer cleanup()
|
||||
|
||||
// (core#2574 / #2579) Cookie present, no org_token_prefix, no
|
||||
// org_token_id, no admin-token env var. The session path has
|
||||
// no resolvable approval anchor — callerOrg returns "" and
|
||||
// the env var is unset. Per approvalAnchorForGate contract,
|
||||
// this returns a controlled 4xx. The gate works correctly:
|
||||
// session callers cannot mint org tokens without an explicit
|
||||
// org_id (set via the platform workspace env, or by calling
|
||||
// through the concierge with an org-token credential).
|
||||
// (core#2593) BYPASS-RESISTANCE: a raw Cookie header WITHOUT a
|
||||
// CP-verified session (cp_session_actor unset) must NOT be
|
||||
// treated as a human — otherwise any admin-token agent could
|
||||
// skip the approval gate by attaching a junk Cookie. Such a
|
||||
// caller has no org_token_id, no admin-token context and no
|
||||
// anchor env → controlled 400, gate intact.
|
||||
os.Unsetenv("MOLECULE_PLATFORM_WORKSPACE_ID")
|
||||
defer os.Unsetenv("MOLECULE_PLATFORM_WORKSPACE_ID")
|
||||
os.Unsetenv("MOLECULE_PLATFORM_APPROVAL_GATE")
|
||||
@@ -372,6 +370,52 @@ func TestOrgTokenHandler_Create_ActorFromSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrgTokenHandler_Create_VerifiedSession_SkipsGate (core#2593)
|
||||
// pins the human-mint path: a CP-VERIFIED session caller (AdminAuth
|
||||
// sets cp_session_actor only after VerifiedCPSession confirms the
|
||||
// WorkOS cookie upstream) mints WITHOUT an approval round-trip — the
|
||||
// human at the dashboard IS the approver, so 202-pending would be a
|
||||
// no-op loop. Live regression: Settings → Org API Keys → "+ New Key"
|
||||
// returned the anchor 400 because #2579 had no session branch.
|
||||
// The mock expects ONLY the orgtoken INSERT — any approval-flow SQL
|
||||
// would fail ExpectationsWereMet, proving the gate did not fire.
|
||||
func TestOrgTokenHandler_Create_VerifiedSession_SkipsGate(t *testing.T) {
|
||||
h, mock, cleanup := setupOrgTokenTest(t)
|
||||
defer cleanup()
|
||||
|
||||
os.Unsetenv("MOLECULE_PLATFORM_WORKSPACE_ID")
|
||||
defer os.Unsetenv("MOLECULE_PLATFORM_WORKSPACE_ID")
|
||||
os.Unsetenv("MOLECULE_PLATFORM_APPROVAL_GATE")
|
||||
defer os.Unsetenv("MOLECULE_PLATFORM_APPROVAL_GATE")
|
||||
|
||||
const actor = "session:deadbeefcafe1234"
|
||||
mock.ExpectQuery(`INSERT INTO org_api_tokens`).
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "from-dashboard", actor, nil).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("tok-human"))
|
||||
|
||||
c, w := buildCtx("POST", "/org/tokens", `{"name":"from-dashboard"}`)
|
||||
c.Set("cp_session_actor", actor)
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("verified-session mint MUST return 200 (no approval round-trip), got %d: %s",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
Prefix string `json:"prefix"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if body.ID != "tok-human" {
|
||||
t.Errorf("id = %q, want tok-human", body.ID)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet/extra SQL — approval-flow queries must NOT run for verified sessions: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgTokenHandler_Create_NameTooLong400(t *testing.T) {
|
||||
h, _, cleanup := setupOrgTokenTest(t)
|
||||
defer cleanup()
|
||||
|
||||
Reference in New Issue
Block a user