Skip to content

fix: gate email abilities on mailbox ownership, not just use_tools - #3510

Open
chubes4 wants to merge 1 commit into
mainfrom
feat/3507-email-ownership-scoping
Open

chubes4 wants to merge 1 commit into
mainfrom
feat/3507-email-ownership-scoping

Conversation

@chubes4

@chubes4 chubes4 commented Sep 17, 2026

Copy link
Copy Markdown
Member

Fixes #3507

Where auth refs live and how ownership is resolved

email_imap mailbox refs are already a richer model than a flat user_id column — EmailAuth (extending BaseAuthProvider) stores named accounts scoped by site / user / agent ownership (AUTH_SCOPE_SITE|USER|AGENT), plus per-agent operation delegations (grant_agent()/revoke_agent()). EmailAuth::resolve_mailbox() / resolve_mailbox_for_principal() is the single authorization choke point every execute() path already calls: it resolves the ref, checks the caller against the account's owner (or an agent's delegation grant), and returns a WP_Error on denial.

The actual gap #3507 reports isn't a missing owner model — it's that ability permission_callbacks never consulted it. Every email ability's checkPermission() was a flat use_tools/can_manage capability floor. Ownership was enforced only deep inside execute()'s IMAP connection logic, so the Abilities API's permission_callback contract ("can this call proceed") never actually answered the resource-specific question — any use_tools holder passed the gate for any auth_ref.

What changed

Added EmailMailboxPermission (inc/Abilities/Email/EmailMailboxPermission.php), a trait providing authorizeMailboxRef( $input, $operation ):

  • Requires the use_tools/can_manage floor first (unchanged baseline).
  • When normalized input carries a concrete auth_ref, resolves it through EmailAuth::resolve_mailbox() as a side-effect-free preflight (_skip_audit context flag added to EmailAuth::audit() so the preflight doesn't double-log every real operation).
  • Denies only on email_mailbox_forbidden — "this ref exists and you don't own it." Every other resolution failure (auth_ref_unresolved, auth_ref_invalid, email_mailbox_ambiguous) passes the permission gate and is left to execute() to report with its specific error — those are configuration/state problems, not authorization decisions, and collapsing them into a generic 403 would be a UX regression (confirmed against the existing EmailAbilityRestTest::test_rest_visible_email_test_connection_resolves_to_ability contract).
  • When no auth_ref is present at all (e.g. a chat-tool availability probe called with no input), the capability floor alone governs — execute() still enforces whatever default/legacy-sender rule applies.

Every ability gated

fetch-email, email-reply, email-delete, email-move, email-flag, email-batch-move, email-batch-flag, email-batch-delete, email-unsubscribe, email-batch-unsubscribe, email-test-connection, send-email, send-email-queued. The EmailAbilities CRUD class's per-operation callbacks (each ability needs its own operation set — reply vs delete vs organize+delete for move, etc.) live in a new EmailAbilitiesPermissions trait rather than inline in EmailAbilities.php, which was already close to the codebase's 1500-line file-size audit threshold (homeboy review audit --profile pr flagged this on the first pass; splitting the trait out resolved it cleanly — verified green on the second pass).

send-email's one special case: when input carries a _mailbox_grant — a shape only SendEmailQueuedAbility's Action Scheduler worker ever produces — checkPermission() defers to execute()'s independent HMAC signature verification against the grant's own issuer identity, instead of re-deriving ownership from PermissionHelper's ambient context. That context is empty during real AS dispatch (no acting user/agent), so re-checking ownership there would incorrectly deny a send that was already authorized at queue time. send-email-queued itself needs no such case — queuing is always synchronous with live ambient context; the deferred send is dispatched directly against the datamachine_send_email_worker hook, never through wp_get_ability('datamachine/send-email-queued')->execute().

Decision on the existing unowned/global ref

email_imap:default (site scope, owner_id 0) stays a deliberately shared operational mailbox, gated by EmailAuth::can_use_default()'s management-capability check (manage_options / datamachine_manage_agents / datamachine_manage_flows / datamachine_manage_settings) — not by a bare use_tools floor.

This is why authorizeMailboxRef() reuses EmailAuth::resolve_mailbox() instead of routing through PermissionHelper::owns_resource() as the issue originally suggested: owns_resource() treats resource_user_id === 0 as "shared, accessible to anyone with the capability" (correct for jobs/agents in single-agent mode), which would have been a regression here — it would open the default mailbox to any use_tools holder, exactly the hole this PR closes. EmailAuth's named-account/delegation model already encodes the correct, narrower policy; reusing it keeps one source of truth instead of introducing a second, weaker mechanism that disagrees with the first.

Tests

New tests/Unit/Abilities/EmailMailboxPermissionTest.php:

  • owner acts on their own named (AUTH_SCOPE_USER) mailbox → allowed
  • non-owner with use_tools → denied
  • admin does not get a standing backdoor into another user's connected mailbox (matches EmailAuth::can_access()'s existing contract — no admin override for user-scoped accounts)
  • admin retains access to the shared email_imap:default mailbox
  • a use_tools-only (non-management) caller is denied the shared default
  • missing auth_ref falls back to the capability floor
  • an unconfigured ref is not treated as a permission denial (lets execute() report it)
  • send-email denies a direct call against an unowned ref, and defers to the signed grant when _mailbox_grant is present
  • send-email-queued gates ownership at queue time with no grant special case

Plus updated require_once wiring in the existing pure-PHP smoke tests (send-email-template-smoke.php, email-reply-sent-copy-smoke.php, send-email-ability-lazy-definitions-smoke.php, abilities-send-email-load-order-smoke.php, lightweight-ability-manifest-smoke.php) that require these ability classes directly without an autoloader.

Verification (real results)

  • Pure-PHP smoke tests (no WordPress needed): all 8 relevant files pass locally, including named-mailbox-security-contract-smoke.php, named-mailbox-delegation-smoke.php, legacy-email-upgrade-auth-smoke.php.
  • homeboy review audit --changed-since origin/main --profile pr: pass, 0 introduced findings (after splitting EmailAbilitiesPermissions out — first pass flagged a god_file line-count finding on EmailAbilities.php, resolved).
  • homeboy review lint --changed-only: phpcs 0 findings, eslint 0 findings, phpstan 69 findings — all pre-existing, confirmed by re-running the identical lint against unmodified main (via git stash) with an isolated failing test: the same 69 findings reproduce with zero relation to this diff. They're IMAP\Connection vs resource type-signature drift from a PHP/phpstan-stub version bump, plus a few right side of && is always true / empty() offset findings — all in pre-existing execute()-path IMAP code untouched by this PR, none in the new permission-callback code. Reporting honestly rather than claiming or hiding this: this is pre-existing debt, not introduced here.
  • homeboy review test -- --filter Email (real WordPress test runtime via WP Codebox): 19/20 passed. The 1 failure (EmailAbilityRestTest::test_rest_visible_get_orphaned_posts_round_trip) is unrelated to email — confirmed pre-existing by running the identical filtered test against unmodified main, where it fails identically.

Out of scope (per the issue)

  • Mailbox connect/attach UI (issue's step 3) — noted as a follow-up, not attempted here.
  • data-machine-socials — the issue explicitly flags this as a sibling with a legitimately different answer (shared brand credentials vs. per-user inboxes) and out of scope for this PR.

🤖 Generated with Claude Code

…3507)

Email ability permission_callbacks previously only enforced a flat
use_tools/can_manage capability floor and left mailbox ownership entirely
to execute()-time resolution inside EmailAuth::resolve_mailbox(). Any
use_tools holder could pass an arbitrary auth_ref and only get denied deep
inside IMAP connection logic — or succeed if that ref happened to be
accessible (e.g. the shared default). The Abilities API permission_callback
contract was answering "does this caller have a Data Machine capability"
instead of "can this caller reach this specific mailbox".

Add EmailMailboxPermission, a trait providing authorizeMailboxRef() that
reuses EmailAuth's existing resolve_mailbox() authorization (named accounts
scoped by site/user/agent ownership, plus per-agent operation delegations)
as a side-effect-free preflight. It denies only on the specific
email_mailbox_forbidden outcome — "this ref exists and you don't own it" —
and lets execute() report every other resolution failure (not configured,
malformed, ambiguous) with its own specific error, since those are
configuration/state problems, not authorization decisions.

Wire it into every email ability's permission_callback: fetch-email,
email-reply, email-delete, email-move, email-flag, email-batch-move,
email-batch-flag, email-batch-delete, email-unsubscribe,
email-batch-unsubscribe, email-test-connection, send-email, and
send-email-queued. EmailAbilities' shared callbacks live in a new
EmailAbilitiesPermissions trait to keep EmailAbilities.php under the
codebase's file-size threshold.

send-email special-cases input carrying a `_mailbox_grant`: that shape is
only ever produced by SendEmailQueuedAbility's Action Scheduler worker,
whose dispatch context has no ambient PermissionHelper identity. The grant
is independently re-verified by signature inside execute() against its own
issuer identity, so checkPermission() defers to that instead of incorrectly
denying a previously-authorized send.

Decision on existing unowned refs: email_imap:default (site scope,
user_id 0) stays a deliberately shared operational mailbox gated by
EmailAuth::can_use_default()'s management-capability check — narrower than
generic owns_resource() semantics for resource_user_id === 0, which would
treat it as open to any use_tools holder. That's why this reuses
EmailAuth's resolution instead of PermissionHelper::owns_resource(): the
richer named-account/delegation model already encodes the right policy and
owns_resource() would either duplicate or weaken it.

Adds EmailMailboxPermissionTest covering: owner acting on their own named
mailbox, a non-owner denied, an admin NOT bypassing another user's personal
mailbox, an admin retaining access to the shared default, a use_tools-only
caller denied the shared default, a missing ref falling back to the
capability floor, an unconfigured ref not being treated as a denial, and
the send-email grant bypass contract.

Fixes #3507
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Email abilities are capability-gated, not ownership-gated — blocks per-user mailboxes for MCP

1 participant