ADR 015: Separate Authorization from Detail Queries

ADR 015: Separate Authorization from Detail Queries

Status

Accepted

Date

2026-03-18

Context

Detail query handlers (Events, Groups, Users) embedded authorization logic directly in their SQL queries by filtering on organization_id. This created several problems:

  1. Mixed concerns — Query handlers performed both data retrieval and access control in a single SQL query. Authorization logic (ltree hierarchy checks) was scattered across query handlers instead of centralized.
  2. Inconsistency — Some handlers used exact org match, others used ltree subtree queries, and the Organization detail handler had no org filter at all.
  3. Inefficiency — Every detail fetch ran a ltree subquery even when the caller was already authorized (e.g., superadmin).
  4. Security fragility — Authorization depended on every caller remembering to pass organizationId. If a new controller forgot to pass it, the query would silently return all results without filtering.

Decision

Separate data retrieval from authorization:

  1. Detail query handlers fetch entities by primary key only. No organization filtering. They are pure data retrieval.
  2. Authorization is enforced in the presentation layer via Symfony Security Voters, called explicitly in each controller method after fetching the entity.
  3. List queries retain organization filtering — this is data scoping (which records to show), not authorization on a single record.

Voter Design

VoterAttributeSubjectUse Case
OrgAccessVoterORG_ADMINorganization ID (string)Admin access to entities with organization_id (events, groups, orgs)
OrgMemberVoterORG_MEMBERorganization ID (string)Member access to entities with organization_id (API endpoints)
OrgUserAccessVoterORG_USER_ADMINtarget user ID (string)Admin access to users (who have memberships, not a single org)

Controller Pattern

// Admin controllers: fetch by PK, then authorize
$event = ($this->detailHandler)(new EventDetailQuery(eventId: $id));
$this->denyAccessUnlessGranted(OrgAccessVoter::ATTRIBUTE, $event->data['organization_id']);

// API controllers: fetch by PK, then authorize (member-level)
$event = ($this->detailHandler)(new EventDetailQuery(eventId: $id));
$this->denyAccessUnlessGranted(OrgMemberVoter::ATTRIBUTE, $event->data['organization_id']);

// User detail: different pattern (users have memberships, not a single org)
$user = ($this->detailHandler)(new UserDetailQuery(userId: $id));
$this->denyAccessUnlessGranted(OrgUserAccessVoter::ATTRIBUTE, $id);

Why Users Are Different

Events and groups have a single organization_id — the voter checks if the admin manages that org. Users have memberships across multiple orgs. The OrgUserAccessVoter checks whether the target user has any membership in an org the current admin manages (via ltree hierarchy).

OrgAccessVoter Uses ltree Cascading

The OrgAccessVoter delegates to OrgMembershipQueryInterface::isOrgAdmin(), which checks ltree ancestry. An ICF Movement admin can access events belonging to ICF Zurich because ICF Zurich’s path is a descendant of ICF Movement’s path.

Consequences

  • Single responsibility: Query handlers only retrieve data. Voters only authorize.
  • Centralized authorization: ltree logic lives in OrgMembershipQuery::isOrgAdmin(), used by all voters.
  • Fail-closed: If a controller forgets to call denyAccessUnlessGranted, the entity is still fetched but no authorization leaks into the query layer. The class-level #[IsGranted('ROLE_ORG_ADMIN')] provides a baseline gate.
  • Testability: Voters and query handlers can be tested independently.
  • Two queries per detail request: One for data, one for authorization (voter). This is acceptable — the voter query is a simple indexed lookup, and the separation of concerns outweighs the marginal cost.