fix(org-api-tokens): add org_id column, close requireCallerOwnsOrg regression

Fixes F1094 / #1200 / #1204 — org-token callers always getting 403 on
org-scoped routes because requireCallerOwnsOrg queried created_by
(provenance label string) instead of a proper org anchor UUID.

Changes:
- Migration 036 adds nullable org_id UUID column to org_api_tokens,
  references workspaces(id). Pre-fix tokens remain usable for
  non-org-scoped routes.
- requireCallerOwnsOrg now queries org_api_tokens.org_id directly.
  Tokens with org_id = NULL (pre-fix) are denied org-scoped access —
  correct security posture for Phase 32 multi-org isolation.
- orgtoken.Issue accepts and stores org_id via NULLIF($5,'')::uuid.
- OrgTokenHandler.Create passes org_id (from session context or
  request body) to Issue. Canvas UI should pass org_id in request
  body so new tokens carry their org anchor.
- admin_memories.go: remove dead-code duplicate redactSecrets call
  (shadowing declaration, lines 125+135 → single call at line 125).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Molecule AI Fullstack (floater) 2026-04-21 01:34:05 +00:00
parent d3bf4e145e
commit 11f66b1837
6 changed files with 67 additions and 26 deletions

View File

@ -128,11 +128,6 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) {
// the redacted content so that two backups with the same original
// secret (same placeholder output) are treated as duplicates.
var exists bool
// F1085 / #1132: scrub credential patterns before persistence. Must run
// BEFORE the dedup check so the redacted content is what gets stored —
// otherwise two backups with the same original secret would each get a
// different placeholder, producing duplicate rows with different content.
content, _ := redactSecrets(workspaceID, entry.Content)
err = db.DB.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM agent_memories WHERE workspace_id = $1 AND content = $2 AND scope = $3)`,

View File

@ -107,9 +107,11 @@ type putAllowlistRequest struct {
// requireCallerOwnsOrg returns the caller's org workspace ID from the
// request context, or "" if the caller is not an org-token holder.
// Used to enforce org isolation on org-scoped routes — the org_token_id
// is the org-scoped token's ID (not the org workspace ID), so we look
// it up via the created_by workspace or a direct relationship.
// Used to enforce org isolation on org-scoped routes.
//
// For org-token callers, looks up org_api_tokens.org_id (the root workspace
// that owns this token). Tokens created before the org_id column was added
// have org_id = NULL and are treated as un-owned — access is denied.
//
// Returns ("", nil) when the caller is a session/ADMIN_TOKEN user (they
// bypass via the session cookie path or ADMIN_TOKEN, not org tokens).
@ -122,19 +124,19 @@ func requireCallerOwnsOrg(c *gin.Context) (string, error) {
if !ok || tokID == "" {
return "", nil
}
// Look up the org workspace that owns this token.
// org_api_tokens has no org_id column, but we can look up the token's
// created_by workspace and treat that as the caller's org anchor.
var createdBy string
// Look up the org workspace that owns this token via org_api_tokens.org_id.
// org_id is NULL for pre-fix tokens (created before migration 036); those
// are denied to enforce org isolation rather than allowing cross-org access.
var orgID *string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT created_by FROM org_api_tokens WHERE id = $1`, tokID,
).Scan(&createdBy)
if err != nil || createdBy == "" {
// Token has no created_by (CLI bootstrap path) — treat as unowned;
// deny by default to prevent cross-org access.
`SELECT org_id::text FROM org_api_tokens WHERE id = $1`, tokID,
).Scan(&orgID)
if err != nil || orgID == nil || *orgID == "" {
// Token has no org_id (pre-fix or bootstrap path) — deny by default
// to prevent cross-org access.
return "", fmt.Errorf("token has no org anchor")
}
return createdBy, nil
return *orgID, nil
}
// requireOrgOwnership verifies the caller has authority over the target org.

View File

@ -41,7 +41,8 @@ func (h *OrgTokenHandler) List(c *gin.Context) {
}
type createOrgTokenRequest struct {
Name string `json:"name"`
Name string `json:"name"`
OrgID string `json:"org_id"` // canvas UI sets this; validated by requireCallerOwnsOrg at access time
}
type createOrgTokenResponse struct {
@ -54,13 +55,19 @@ type createOrgTokenResponse struct {
// Create mints a new org token. The plaintext is returned exactly
// once in the response body. Mirrors wsauth's Issue semantics so UI
// flow (copy-once, dismiss, no retrieval) is consistent across
// flow (copy-ononce, dismiss, no retrieval) is consistent across
// token types.
//
// created_by is captured from the org_token_id or admin-token
// provenance of the current request — so an audit trail points back
// to who minted what. For the bootstrap ADMIN_TOKEN path, created_by
// is "admin-token" (no session identity available).
//
// orgID is the root workspace UUID of the org this token belongs to.
// Set by AdminAuth when the session is verified and CP returns org
// context; falls back to the req.OrgID field (canvas UI sets this
// so the value is trusted because requireCallerOwnsOrg gates access
// to org-scoped routes).
func (h *OrgTokenHandler) Create(c *gin.Context) {
var req createOrgTokenRequest
// Optional body — an empty POST should still work (unnamed token).
@ -71,14 +78,21 @@ func (h *OrgTokenHandler) Create(c *gin.Context) {
}
createdBy := orgTokenActor(c)
// org_id from session context (set by AdminAuth when CP returns org context);
// falls back to req.OrgID (canvas UI passes org_id in request body). See
// orgCallerID docstring for the full path.
orgID := orgCallerID(c)
if orgID == "" {
orgID = req.OrgID
}
plaintext, id, err := orgtoken.Issue(c.Request.Context(), db.DB, req.Name, createdBy)
plaintext, id, err := orgtoken.Issue(c.Request.Context(), db.DB, req.Name, createdBy, orgID)
if err != nil {
log.Printf("orgtoken issue: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to mint token"})
return
}
log.Printf("orgtoken: minted id=%s by=%s name=%q", id, createdBy, req.Name)
log.Printf("orgtoken: minted id=%s by=%s org=%s name=%q", id, createdBy, orgID, req.Name)
c.JSON(http.StatusOK, createOrgTokenResponse{
ID: id,
@ -121,6 +135,22 @@ const (
actorAdminToken = "admin-token" // bootstrap ADMIN_TOKEN env
)
// orgCallerID returns the org workspace UUID for the current request's org.
// Set by AdminAuth middleware when CP session verification succeeds and CP
// returns org context. Empty string when running without CP (self-hosted dev).
//
// Tokens minted with org_id = "" will have no org anchor and will be denied
// access to org-scoped routes (requireCallerOwnsOrg) — correct behaviour
// for Phase 32 multi-org isolation.
func orgCallerID(c *gin.Context) string {
if v, ok := c.Get("org_id"); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// orgTokenActor derives a short provenance string for audit.
//
// - If the request was authed via another org token, return

View File

@ -69,7 +69,11 @@ type Token struct {
// name and createdBy are both optional (nullable columns). Typical
// use: name = "zapier integration", createdBy = current session
// user_id.
func Issue(ctx context.Context, db *sql.DB, name, createdBy string) (plaintext, id string, err error) {
//
// orgID is the root workspace UUID of the org this token belongs to.
// Required for Phase 32 multi-org isolation. Tokens without orgID are
// denied access to org-scoped routes (requireCallerOwnsOrg).
func Issue(ctx context.Context, db *sql.DB, name, createdBy, orgID string) (plaintext, id string, err error) {
buf := make([]byte, tokenPayloadBytes)
if _, err := rand.Read(buf); err != nil {
return "", "", fmt.Errorf("orgtoken: generate: %w", err)
@ -79,10 +83,10 @@ func Issue(ctx context.Context, db *sql.DB, name, createdBy string) (plaintext,
prefix := plaintext[:tokenPrefixLen]
err = db.QueryRowContext(ctx, `
INSERT INTO org_api_tokens (token_hash, prefix, name, created_by)
VALUES ($1, $2, $3, $4)
INSERT INTO org_api_tokens (token_hash, prefix, name, created_by, org_id)
VALUES ($1, $2, $3, $4, NULLIF($5, '')::uuid)
RETURNING id
`, hash[:], prefix, nullIfEmpty(name), nullIfEmpty(createdBy)).Scan(&id)
`, hash[:], prefix, nullIfEmpty(name), nullIfEmpty(createdBy), orgID).Scan(&id)
if err != nil {
return "", "", fmt.Errorf("orgtoken: persist: %w", err)
}

View File

@ -0,0 +1 @@
ALTER TABLE org_api_tokens DROP COLUMN IF EXISTS org_id;

View File

@ -0,0 +1,9 @@
-- Add org_id column to org_api_tokens for Phase 32 multi-org isolation.
-- Tokens without org_id are pre-fix tokens (beta); requireCallerOwnsOrg
-- treats them as un-owned and denies access. Follow-up: capture org_id at
-- token creation time so all tokens carry their org anchor.
--
-- org_id references the root workspace that acts as the org anchor —
-- same pattern used by org_plugin_allowlist.org_id.
ALTER TABLE org_api_tokens
ADD COLUMN org_id UUID REFERENCES workspaces(id) ON DELETE SET NULL;