Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Public & Private Endpoints

Our task manager has users, tasks, and relationships. Now let’s add a public API that anyone can access without authentication — while keeping sensitive records hidden.

The Problem

You want one set of routes that serves both:

  • Admins (authenticated) — see everything, full CRUD
  • Public (unauthenticated) — read-only, private records hidden

crudcrate’s scoping system handles this with two features:

  1. ScopeCondition — a middleware-injected filter that restricts which rows are returned
  2. exclude(scoped) — hides fields from the response when a scope is active

Adding a Privacy Field

Add an is_private field to your entity:

#[derive(Clone, Debug, DeriveEntityModel, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "tasks")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

    #[crudcrate(filterable, sortable)]
    pub title: String,

    pub description: Option<String>,

    #[crudcrate(filterable, exclude(scoped))]
    pub is_private: bool,
}

exclude(scoped) does two things:

  1. Hides the field from API responses when a scope is active — public users never see is_private in the JSON
  2. Strips the field from filters/sorting — public users can’t probe it via ?filter={"is_private":true}

Writing Scope Middleware

A scope is just Axum middleware that injects a ScopeCondition into the request. You decide when to inject it — typically when the user isn’t authenticated:

use axum::{extract::Request, middleware::Next, response::Response};
use crudcrate::ScopeCondition;
use sea_orm::{ColumnTrait, Condition};

async fn scope_tasks(mut req: Request, next: Next) -> Response {
    if !is_admin(&req) {
        // Only show public tasks
        req.extensions_mut().insert(ScopeCondition::new(
            Condition::all().add(task::Column::IsPrivate.eq(false)),
        ));
    }
    next.run(req).await
}

When ScopeCondition is present, crudcrate automatically:

  • Filters list queries (GET /) to only return matching rows
  • Returns 404 for GET /:id if the record doesn’t pass the condition
  • Blocks all writes (POST, PUT, DELETE) with 403 Forbidden
  • Uses the scoped response model (without exclude(scoped) fields)
  • Returns correct pagination counts reflecting the filtered total

When ScopeCondition is not present (admin requests), everything works normally — full CRUD, all fields visible.

Mounting the Routes

Apply the scope middleware to your router. Layer it after your auth middleware so the auth status is available:

use axum::{middleware::from_fn, Router};

let app = Router::new()
    .nest(
        "/api/tasks",
        Task::router(&db)
            .layer(from_fn(scope_tasks))      // Check scope based on auth
            .layer(keycloak_pass_layer)        // Auth (passthrough mode)
            .into(),
    );

What Happens

Public user (no auth token):

# List — only public tasks, no is_private in response
curl http://localhost:3000/api/tasks
# [{"id": "...", "title": "Public task", "description": "..."}]

# Private task — 404
curl http://localhost:3000/api/tasks/private-uuid
# {"error": "task not found"}

# Write — blocked
curl -X POST http://localhost:3000/api/tasks -d '{"title": "hack"}'
# 403 Forbidden

# Filter on is_private — silently ignored
curl 'http://localhost:3000/api/tasks?filter={"is_private":true}'
# Returns same results as without filter

Admin (valid auth token):

# List — all tasks, is_private visible
curl -H "Authorization: Bearer TOKEN" http://localhost:3000/api/tasks
# [{"id": "...", "title": "Public task", "is_private": false},
#  {"id": "...", "title": "Secret task", "is_private": true}]

# Full CRUD works
curl -X POST -H "Authorization: Bearer TOKEN" \
  http://localhost:3000/api/tasks \
  -d '{"title": "New task", "is_private": true}'
# 201 Created

Scoping with Relationships

If your entities have parent-child relationships, you probably want privacy to cascade. For example, if an area is private, all sites in that area should be hidden too.

Add Expr::cust() subqueries to your scope condition:

async fn scope_sites(mut req: Request, next: Next) -> Response {
    if !is_admin(&req) {
        req.extensions_mut().insert(ScopeCondition::new(
            Condition::all()
                .add(site::Column::IsPrivate.eq(false))
                .add(Expr::cust(
                    "(area_id IS NULL OR area_id NOT IN \
                     (SELECT id FROM areas WHERE is_private = true))"
                )),
        ));
    }
    next.run(req).await
}

Warning: Expr::cust() passes raw SQL directly to the database. Never interpolate user input into the string — this creates SQL injection vulnerabilities. Use only static strings or Sea-ORM’s typed column API for dynamic conditions.

Scoping with Joins

If your entity has join() fields (nested children in the response), exclude(scoped) propagates automatically through joins.

// Parent: Customer
#[crudcrate(filterable, exclude(scoped))]
pub is_private: bool,

#[crudcrate(non_db_attr, join(one, all))]
pub vehicles: Vec<Vehicle>,

// Child: Vehicle
#[crudcrate(filterable, exclude(scoped))]
pub is_private: bool,

When a scoped request fetches a customer, the response looks like:

{
  "id": "...",
  "name": "Alice",
  "vehicles": [
    {"id": "...", "make": "Toyota", "model": "Corolla", "year": 2020}
  ]
}

No is_private on the customer or on any nested vehicle. crudcrate generates CustomerScopedList with vehicles: Vec<VehicleScopedList> — the scoped types cascade through every join level.

How join scoping works

Two layers protect joined children on scoped requests:

  1. Field stripping: The scoped types (VehicleScopedList) omit exclude(scoped) fields from the JSON.
  2. SQL-level row filtering: Every child batch query includes the child’s ScopeFilterable::scope_condition() as an additional WHERE clause. This applies on both get_one_scoped and get_all_scoped handlers, at every depth when depth > 1 — the scoped batch loader recurses via get_one_scoped, propagating the scope through grandchildren and beyond. Private rows never leave Postgres.
  3. In-memory defense in depth: From<ListModel> for ScopedList still runs ScopeFilterable::is_scope_visible() over each child as a belt-and-suspenders guard, so a custom read::many::body hook that bypasses the SQL filter still strips private rows before serialisation.

For automatic filtering to work, the child entity must have at least one exclude(scoped) boolean field (the derive macro generates the required ScopeFilterable::scope_condition() from those fields). If it doesn’t, the child’s scope_condition() returns None and every child row is returned — identical to unscoped behaviour.

Quick Reference

AttributeEffect
exclude(scoped)Field hidden from response when scoped
ScopeCondition::new(condition)Filter rows in list/get_one
Scope + write requestAutomatically returns 403 Forbidden
Scope + filter on excluded columnFilter silently ignored
Scope + join fieldsChild entities use scoped types too

Entities Without exclude(scoped)

If a child entity doesn’t use exclude(scoped), crudcrate generates a type alias (type ChildScopedList = ChildList) so parent joins still compile. No action needed — it just works.


Next: Custom Logic - Hooks - add validation and side effects to your endpoints.