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:
- 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.
- Inconsistency — Some handlers used exact org match, others used ltree subtree queries, and the Organization detail handler had no org filter at all.
- Inefficiency — Every detail fetch ran a ltree subquery even when the caller was already authorized (e.g., superadmin).
- 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:
- Detail query handlers fetch entities by primary key only. No organization filtering. They are pure data retrieval.
- Authorization is enforced in the presentation layer via Symfony Security Voters, called explicitly in each controller method after fetching the entity.
- List queries retain organization filtering — this is data scoping (which records to show), not authorization on a single record.
Voter Design
| Voter | Attribute | Subject | Use Case |
|---|---|---|---|
OrgAccessVoter | ORG_ADMIN | organization ID (string) | Admin access to entities with organization_id (events, groups, orgs) |
OrgMemberVoter | ORG_MEMBER | organization ID (string) | Member access to entities with organization_id (API endpoints) |
OrgUserAccessVoter | ORG_USER_ADMIN | target 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.