ADR 029: Collection Endpoint Authorization Filtering
Date: 2026-02-05
Status: Proposed
Decision Makers: Architecture Board
Context
Resource endpoints (e.g., GET /v2/datasets/{id}) can be authorized by verifying the user's permission scope matches the specific resource ID. However, collection endpoints (e.g., GET /v2/datasets) present a challenge: how do we filter results to only include resources the user is authorized to see?
Key Constraints
- OPA must remain the sole Policy Decision Point (PDP)
- Backend services should not contain authorization logic
- Solution must work with existing APISIX + OPA architecture
- Performance: avoid duplicate HTTP calls where possible
Example Scenario
- User has
READ_DATASETpermission scoped todataspace-Aanddataspace-B GET /v2/datasetsshould only return datasets in those two dataspaces- User should NOT see datasets in
dataspace-C
Checked Architecture Principles
- [full] Model-centric data flow
- [full] Distributed architecture with unified user experience
- [full] Modular design
- [partial] Integration capability through defined interfaces — Requires header contract between OPA and backend
- [full] Open source as the default — Uses APISIX, OPA
- [full] Cloud-native architecture
- [full] Prefer standard solutions over custom development — Uses existing APISIX OPA plugin feature
- [full] Self-contained deployment
- [full] Technological consistency to ensure maintainability — Extends existing OPA/Rego patterns
- [full] Multi-tenancy — Scope filtering is essential for multi-tenant isolation
- [full] Security by design — Centralized authorization in OPA, no authz logic in backend
Decision
Use APISIX OPA plugin's send_headers_upstream feature to pass allowed scope IDs to the backend via HTTP headers.
Key Insight: The APISIX OPA plugin supports send_headers_upstream (PR #9710, merged July 2023), allowing a single OPA call to handle both authorization AND scope header passing — no additional plugins required.
Architecture
ANY /v2/* request
│
▼
APISIX
│ 1. proxy-rewrite: Set backend identifier header
│ 2. openid-connect: JWT validation
│ 3. opa: Authorization + scope extraction
▼
OPA
│ Evaluates policy
│ Returns: {"allow": true, "headers": {"X-Allowed-Scope-Ids": "scope-1,scope-2"}}
▼
APISIX
│ allow=true → Forward request + pass X-Allowed-Scope-Ids header
│ allow=false → Return 403 Forbidden
▼
Backend Service
│ Parse header, apply query filter
▼
Filtered results
Header Contract
The X-Allowed-Scope-Ids header communicates OPA's authorization decision to the backend:
| Header Value | Meaning | Backend Action |
|---|---|---|
* | TENANT scope (wildcard) | Skip filtering, return all results |
id1,id2,id3 | Specific scope IDs (UUIDs) | Filter results to these scopes |
| Empty/missing | No scopes or not applicable | Return empty results (fail-secure) |
Component Responsibilities
OPA (Policy Decision Point)
- Evaluates user permissions for the requested resource type
- Determines allowed scope IDs based on user's group assignments
- Returns scope IDs in the decision response headers
- TENANT-scoped permissions return wildcard (
*) to avoid header bloat
APISIX (Policy Enforcement Point)
- Forwards OPA's headers to the backend via
send_headers_upstream - No modification to header values — pure passthrough
- Blocks requests if OPA denies authorization
Backend Service (Query Execution)
- Parses
X-Allowed-Scope-Idsheader early in request lifecycle - Stores scope IDs in request-scoped context
- Applies scope filter to collection queries via JPA Specification pattern
- Does NOT make authorization decisions — only executes the filter OPA specified
Header Size Considerations
HTTP headers have practical size limits (~32KB). Since scope IDs are UUIDs (36 chars each):
| Scope Count | Approximate Header Size |
|---|---|
| 100 UUIDs | ~3.7 KB |
| 500 UUIDs | ~18.5 KB |
| 850 UUIDs | ~32 KB |
Mitigations:
- Wildcard for TENANT scope: Users with tenant-level access receive
*instead of enumerated IDs - Realistic limits: Users typically have 1-20 scope assignments; 200 scopes is an extreme edge case
- Configurable buffer sizes: APISIX and backend can be configured for 32KB headers if needed
Pattern Name
This approach is known as "Authorization Scope Filtering" or "Policy-Enforced Query Filtering". It's a variant of Row-Level Security (RLS) where:
- The Policy Decision Point (OPA) determines the allowed scope IDs
- The Policy Enforcement Point (APISIX) propagates this as a header
- The Backend applies the filter mechanically without making authorization decisions
Related patterns:
- Row-Level Security (RLS): Database-native filtering based on user context
- Attribute-Based Access Control (ABAC): Policy decisions based on attributes (scopes are an attribute)
- Authorization Context Propagation: OPA's decision context flows through the request chain
This is NOT "query plan pushing" — that term refers to distributed database query optimizations.
Limitation: Requires Backend Control
IMPORTANT: This solution only works when we control the backend implementation.
Why Backend Control is Required
The filtering mechanism requires the backend to:
- Parse the
X-Allowed-Scope-Idsheader - Inject scope constraints into database queries
- Ensure all collection endpoints respect the filter
This is only possible when we can modify the backend code.
External Backends
For external/third-party backends (e.g., FROST Server, Stellio), we cannot apply this pattern because:
- No code modification: We can't add header parsing or query filtering logic
- Different query languages: External backends use OData, NGSI-LD, or proprietary query syntax
- No injection hook: These backends don't expose a mechanism to inject scope filters
Recommendations for External Backends
| Approach | Viability | Notes |
|---|---|---|
| Block collection endpoints | Recommended | OPA denies collection requests; only resource endpoints allowed |
| Filtering proxy | Future option | Build a proxy that translates scope IDs to backend-native query syntax |
| Accept data leakage | Not acceptable | Violates security requirements |
Current recommendation: For external backends, do not expose collection endpoints. Configure OPA to deny requests to collection patterns. Users must query by specific resource ID, where standard scope validation applies.
Consequences
Benefits
- OPA remains sole PDP: Authorization decisions stay centralized
- Clean separation: OPA decides, backend filters
- Single OPA call: No additional latency from separate scope queries
- Debuggable: Header value visible in logs and traces
- Extensible: Same pattern works for any scoped entity type
Trade-offs
- Backend code changes required: Each filtered entity needs a query specification
- Header size limits: Very large scope counts may approach limits (mitigated by wildcard)
- External backends excluded: Pattern doesn't work without backend control
Affected Components
- OPA policy: Scope header generation rules
- APISIX configuration:
send_headers_upstreamdirective - Backend: Request filter, scope bean, JPA specifications
- Each service needing filtering: Query preprocessing logic
Alternatives Considered
| Alternative | Decision | Rationale |
|---|---|---|
| Backend calls AuthZ Repository directly | Rejected | Moves authorization logic into backend, violating "OPA as sole PDP" |
| OPA Partial Evaluation / Compile API | Rejected | Overly complex; requires parsing OPA AST and converting to SQL |
| Backend calls OPA directly | Rejected | Bypasses APISIX, loses gateway observability |
| Shared cache (Redis) | Deferred | Added infrastructure complexity; OPA already fetches user context |
| Return all data, filter in application | Rejected | Security risk (data leakage) and performance impact |