Skip to content

Protect a list endpoint

Protecting list endpoints is trickier than checking access to a single resource. Your endpoint might return 10,000 items, but the user can only see 50. You need to filter the list efficiently without checking every single item. This guide shows you the patterns you’ll see in production.

This guide assumes you’ve already read Protect an endpoint and understand how to use Check() and CheckForUpdate() for individual permission checks.

When a user requests a list of resources, you need to answer: “Which of these can this user access?” You have two fundamental approaches:

  1. Pre-filtering: Find out what the user can access, then query only those resources
  2. Post-filtering: Fetch resources first, then check each one for access

Each approach has coarse-grained (workspace-level) and fine-grained (per-resource) variations. Pick the one that fits your data model and performance needs.

Pre-filtering reduces database load by querying only what the user can access. It provides filter criteria to your database query before fetching results.

Uses StreamedListObjects to find all workspaces where the user has the required permission, then queries your database for resources in those workspaces.

How it works:

flowchart LR
    A[User requests list] --> B[StreamedListObjects:<br/>Get accessible workspaces]
    B --> C[Query DB:<br/>workspace_id IN ...]
    C --> D[Return filtered results]

    style A fill:#e1f5ff,stroke:#333,color:#000
    style B fill:#fff4e1,stroke:#333,color:#000
    style C fill:#fff4e1,stroke:#333,color:#000
    style D fill:#e8f5e9,stroke:#333,color:#000

When to use:

  • Your resources are assigned to workspaces
  • Workspace membership is the primary access control mechanism
  • High-cardinality resources (thousands of items or more) — per-resource checks get expensive

Implementation:

def get_accessible_workspaces(stub, user_id: str, permission: str):
"""Get all workspaces the user can access with the given permission.
This uses the list_workspaces helper from kessel.rbac.v2 which
handles StreamedListObjects pagination automatically.
"""
subject = principal_subject(id=user_id, domain="redhat")
workspace_ids = []
for response in list_workspaces(stub, subject, permission):
workspace_ids.append(response.object.resource_id)
return workspace_ids

Then use those workspace IDs to filter your database query:

from sqlalchemy import select
from sqlalchemy.orm import Session
def list_integrations(session: Session, stub, user_id: str):
"""List integrations the user can access.
Pre-filtering: Get allowed workspaces first, then query only those.
"""
# Step 1: Get workspaces the user can access
allowed_workspaces = get_accessible_workspaces(
stub, user_id, "myservice_integration_view"
)
if not allowed_workspaces:
return []
# Step 2: Query database with workspace filter
query = select(Integration).where(
Integration.workspace_id.in_(allowed_workspaces)
)
result = session.execute(query)
return result.scalars().all()

What it costs:

  • Initial cost: One Kessel API call to get workspace list
  • Database cost: Single filtered query (efficient with proper indexes)
  • Scales well: Performance depends on how many workspaces the user can access, not your total resource count

Uses StreamedListObjects with your specific resource type to find exactly which resource IDs the user can access, then queries your database for only those resources.

When to use:

  • Resources have permissions independent of workspace hierarchy
  • Access is granted on individual resources, not workspace-level
  • The accessible resource count stays under a few thousand

How it works:

  1. Use StreamedListObjects with your resource type (not workspace) and permission
  2. Get back a list of specific resource IDs the user can access
  3. Query your database with WHERE id IN (...) for those exact IDs

Implementation:

def get_accessible_resource_ids(stub, user_id: str, permission: str):
"""Get all resource IDs the user can access with the given permission.
This uses StreamedListObjects with your specific resource type to find
exactly which resource IDs the user can access.
"""
subject = principal_subject(id=user_id, domain="redhat")
resource_type = representation_type_pb2.RepresentationType(
resource_type="integration",
reporter_type="myservice"
)
request = streamed_list_objects_request_pb2.StreamedListObjectsRequest(
object_type=resource_type,
relation=permission,
subject=subject
)
# Get accessible resource IDs
resource_ids = []
for response in stub.StreamedListObjects(request):
resource_ids.append(response.object.resource_id)
return resource_ids

Then use those resource IDs to filter your database query:

from sqlalchemy import select
from sqlalchemy.orm import Session
def list_integrations(session: Session, stub, user_id: str):
"""List integrations the user can access.
Pre-filtering (fine-grained): Get allowed resource IDs first, then query only those.
"""
# Step 1: Get resource IDs the user can access
allowed_ids = get_accessible_resource_ids(stub, user_id, "view")
if not allowed_ids:
return []
# Step 2: Query database with resource ID filter
query = select(Integration).where(Integration.id.in_(allowed_ids))
result = session.execute(query)
return result.scalars().all()

Speed tradeoffs:

  • Kessel call: One API call to get the resource IDs
  • Database: WHERE id IN (...) query — fast with an index
  • Watch out: If the user can access most of your resources anyway, workspace-level filtering is simpler

Post-filtering queries your database first, then checks permissions on the results before returning them to the client. Simpler to implement, but watch out — it gets inefficient with large result sets.

Query the database first, then check if the user has permission on the workspace(s) that each resource belongs to. Filter out resources where the user lacks workspace permission.

When to use:

  • You get the data as-is with no way to filter upfront — Kafka consumers, batch processors, or fixed API responses
  • Resources are assigned to workspaces (have a workspace_id field)
  • The result set is small enough that checking a few workspace permissions is acceptable

How it works:

  1. Query database and get all matching resources
  2. Collect the unique workspace IDs from the resources
  3. Use CheckBulk() to verify permission on those workspaces
  4. Filter out resources where the user doesn’t have workspace permission

Implementation:

def filter_by_workspace_permission(integrations, user_id: str, permission: str):
"""Filter integrations by checking workspace permissions.
Post-filtering (coarse): Check workspace-level permissions for unique workspaces.
"""
# Step 1: Get unique workspace IDs
workspace_ids = list(set(i.workspace_id for i in integrations))
# Step 2: Check workspace permissions with CheckBulk
items = [
check_bulk_request_pb2.CheckBulkRequestItem(
object=workspace_resource(ws_id),
relation=permission,
subject=principal_subject(id=user_id, domain="redhat")
)
for ws_id in workspace_ids
]
request = check_bulk_request_pb2.CheckBulkRequest(items=items)
response = stub.CheckBulk(request)
# Step 3: Build set of allowed workspaces
allowed_workspaces = set()
for index, pair in enumerate(response.pairs):
if pair.item.allowed == allowed_pb2.ALLOWED_TRUE:
allowed_workspaces.add(workspace_ids[index])
# Step 4: Filter resources by allowed workspaces
return [i for i in integrations if i.workspace_id in allowed_workspaces]

Full example:

from sqlalchemy import select
from sqlalchemy.orm import Session
def list_integrations(session: Session, stub, user_id: str):
"""List integrations the user can access.
Post-filtering (coarse): Query all integrations, then check workspace permissions.
"""
# Step 1: Query database
query = select(Integration).filter_by(status="active")
result = session.execute(query)
all_integrations = result.scalars().all()
# Step 2: Filter by workspace permission
accessible = filter_by_workspace_permission(
all_integrations,
user_id,
"myservice_integration_view"
)
return accessible

The tradeoff:

  • Database: Unfiltered query (might fetch resources user can’t access)
  • Authorization: Check workspace permissions (fewer checks than per-resource)
  • Network: One CheckBulk() call for all unique workspaces

Query your database first, then use CheckBulk to check permission on every returned resource. The middleware filters out any resources the user cannot access before returning to the client.

When to use:

  • Small result sets (hundreds or low thousands)
  • User likely has access to most results
  • Resources have per-resource permissions (not just workspace-level)
  • Pre-filtering isn’t possible due to permission model

Implementation:

def filter_integrations_by_permission(integrations, user_id: str, permission: str):
"""Filter integrations using CheckBulk to batch permission checks.
This is more efficient than calling Check() in a loop.
"""
# Build bulk check request with one item per integration
items = []
for integration in integrations:
item = check_bulk_request_pb2.CheckBulkRequestItem(
object=workspace_resource(integration.workspace_id),
relation=permission,
subject=principal_subject(id=user_id, domain="redhat"),
)
items.append(item)
request = check_bulk_request_pb2.CheckBulkRequest(items=items)
# Make single API call to check all permissions
response = stub.CheckBulk(request)
# Filter integrations based on bulk check results
accessible_integrations = []
for index, pair in enumerate(response.pairs):
if pair.item.allowed == allowed_pb2.ALLOWED_TRUE:
accessible_integrations.append(integrations[index])
return accessible_integrations

Full example:

from sqlalchemy import select
from sqlalchemy.orm import Session
def list_integrations(session: Session, stub, user_id: str):
"""List integrations the user can access.
Post-filtering with CheckBulk: Query all integrations, then batch-check permissions.
"""
# Step 1: Query all integrations from database
query = select(Integration)
result = session.execute(query)
all_integrations = result.scalars().all()
# Step 2: Use CheckBulk to filter by permission
accessible = filter_integrations_by_permission(
all_integrations,
user_id,
"myservice_integration_view"
)
return accessible

The tradeoff:

  • Database: You fetch everything, even stuff the user can’t see
  • Authorization: One batched CheckBulk per 1,000 resources
  • Network: One round-trip per batch
  • When it makes sense: If the user can access most results anyway
ApproachDatabase LoadKessel API CallsBest For
Pre-filter (coarse)Low - workspace filter1 StreamedListObjects for workspacesHigh-cardinality resources, workspace-based access
Pre-filter (fine)Low - resource ID filter1 StreamedListObjects for resource IDsResource-level permissions, bounded accessible set
Post-filter (coarse)Medium - varies by queryCheckBulk for unique workspacesData sources you can’t filter upfront
Post-filter (fine)High - unfiltered query1-N CheckBulk (1000 items/batch)Small result sets, user likely accesses most

Key tradeoffs:

  1. Database vs. Kessel load: Pre-filtering trades one API call for a faster database query
  2. Cardinality: High-cardinality resources favor pre-filtering to avoid over-fetching
  3. Access probability: If users usually access most of the results anyway, post-filtering can work
  4. Pagination: Pre-filtering enables efficient pagination; post-filtering can skip results unpredictably
flowchart TD
    START(["How should I protect<br/>this list endpoint?"])
    Q1{"Are resources<br/>assigned to workspaces?"}
    Q2{"Can you query by<br/>workspace ID efficiently?"}
    Q3{"Is the result set<br/>always small?<br/>(&lt; 100 items)"}
    Q4{"Do you need<br/>resource-level permissions<br/>(not workspace-level)?"}

    PRE_COARSE["✅ <strong>Pre-filter (coarse)</strong><br/>StreamedListObjects + workspace query<br/><em>Recommended for most cases</em>"]
    PRE_FINE["✅ <strong>Pre-filter (fine)</strong><br/>StreamedListObjects + resource ID query"]
    POST_COARSE["⚠️ <strong>Post-filter (coarse)</strong><br/>Query + workspace check per result<br/><em>Use only for small result sets</em>"]
    POST_FINE["⚠️ <strong>Post-filter (fine)</strong><br/>Query + resource check per result<br/><em>Last resort - performance cost</em>"]

    START --> Q1
    Q1 -- "Yes" --> Q2
    Q1 -- "No" --> Q4
    Q2 -- "Yes" --> PRE_COARSE
    Q2 -- "No" --> Q3
    Q3 -- "Yes" --> POST_COARSE
    Q3 -- "No" --> POST_FINE
    Q4 -- "Yes" --> PRE_FINE
    Q4 -- "No" --> POST_FINE

    style PRE_COARSE fill:#e8f5e9,stroke:#333,color:#000
    style PRE_FINE fill:#fff9e6,stroke:#333,color:#000
    style POST_COARSE fill:#fff4e1,stroke:#333,color:#000
    style POST_FINE fill:#ffe6e6,stroke:#333,color:#000

Two Insights applications serve as reference implementations:

Digital Roadmap: Pre-filtering with StreamedListObjects

Section titled “Digital Roadmap: Pre-filtering with StreamedListObjects”

Digital Roadmap uses pre-filtering to show users only the roadmap items in workspaces they can access.

Pattern:

  1. Call StreamedListObjects to get accessible workspaces
  2. Query roadmap items with WHERE workspace_id IN (...)
  3. Return filtered results

Key implementation: src/roadmap/common.py - uses StreamedListObjects to get accessible workspace IDs, then filters database queries with those IDs.

Why this works: Roadmap items are workspace-scoped, and users typically have access to fewer than 100 workspaces. Pre-filtering keeps the database query small and fast.

Config Manager uses a simplified pattern for organization-level resources.

Pattern:

  1. Look up the user’s default workspace (their organization’s root workspace)
  2. Check permission on that single workspace
  3. If allowed, return all resources for that organization

Key implementation: internal/http/middleware/authorization/kessel.go - the middleware checks the default workspace permission before allowing access to organization-wide configuration.

Why this works: Config Manager’s profiles are organization-wide settings, not workspace-scoped resources. One check tells you if the user can manage settings for their org — no need to check individual profiles.