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:
parent
d3bf4e145e
commit
11f66b1837
@ -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)`,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE org_api_tokens DROP COLUMN IF EXISTS org_id;
|
||||
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user