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:
ScopeCondition— a middleware-injected filter that restricts which rows are returnedexclude(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:
- Hides the field from API responses when a scope is active — public users never see
is_privatein the JSON - 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 /:idif 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:
- Field stripping: The scoped types (
VehicleScopedList) omitexclude(scoped)fields from the JSON. - SQL-level row filtering: Every child batch query includes the child’s
ScopeFilterable::scope_condition()as an additionalWHEREclause. This applies on bothget_one_scopedandget_all_scopedhandlers, at every depth whendepth > 1— the scoped batch loader recurses viaget_one_scoped, propagating the scope through grandchildren and beyond. Private rows never leave Postgres. - In-memory defense in depth:
From<ListModel> for ScopedListstill runsScopeFilterable::is_scope_visible()over each child as a belt-and-suspenders guard, so a customread::many::bodyhook 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
| Attribute | Effect |
|---|---|
exclude(scoped) | Field hidden from response when scoped |
ScopeCondition::new(condition) | Filter rows in list/get_one |
| Scope + write request | Automatically returns 403 Forbidden |
| Scope + filter on excluded column | Filter silently ignored |
| Scope + join fields | Child 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.