Skip to main content
Version: V2-Next

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_DATASET permission scoped to dataspace-A and dataspace-B
  • GET /v2/datasets should 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 ValueMeaningBackend Action
*TENANT scope (wildcard)Skip filtering, return all results
id1,id2,id3Specific scope IDs (UUIDs)Filter results to these scopes
Empty/missingNo scopes or not applicableReturn 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-Ids header 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 CountApproximate Header Size
100 UUIDs~3.7 KB
500 UUIDs~18.5 KB
850 UUIDs~32 KB

Mitigations:

  1. Wildcard for TENANT scope: Users with tenant-level access receive * instead of enumerated IDs
  2. Realistic limits: Users typically have 1-20 scope assignments; 200 scopes is an extreme edge case
  3. 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:

  1. The Policy Decision Point (OPA) determines the allowed scope IDs
  2. The Policy Enforcement Point (APISIX) propagates this as a header
  3. 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:

  1. Parse the X-Allowed-Scope-Ids header
  2. Inject scope constraints into database queries
  3. 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:

  1. No code modification: We can't add header parsing or query filtering logic
  2. Different query languages: External backends use OData, NGSI-LD, or proprietary query syntax
  3. No injection hook: These backends don't expose a mechanism to inject scope filters

Recommendations for External Backends​

ApproachViabilityNotes
Block collection endpointsRecommendedOPA denies collection requests; only resource endpoints allowed
Filtering proxyFuture optionBuild a proxy that translates scope IDs to backend-native query syntax
Accept data leakageNot acceptableViolates 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_upstream directive
  • Backend: Request filter, scope bean, JPA specifications
  • Each service needing filtering: Query preprocessing logic

Alternatives Considered​

AlternativeDecisionRationale
Backend calls AuthZ Repository directlyRejectedMoves authorization logic into backend, violating "OPA as sole PDP"
OPA Partial Evaluation / Compile APIRejectedOverly complex; requires parsing OPA AST and converting to SQL
Backend calls OPA directlyRejectedBypasses APISIX, loses gateway observability
Shared cache (Redis)DeferredAdded infrastructure complexity; OPA already fetches user context
Return all data, filter in applicationRejectedSecurity risk (data leakage) and performance impact

References​