Multi-Framework Authorization
Write your authorization policies once. Enforce them identically in Spring, Flask, FastAPI, Django, Tornado, NestJS, and .NET. Every framework passes the same 28-endpoint test suite with identical behavior.
How this works
SAPL separates the authorization decision (PDP) from enforcement (PEP). Each framework SDK implements the same PEP behavior: subscribe to the PDP, enforce decisions, handle obligations and advice, apply constraint handlers. The PEP Implementation Specification specifies this contract. All SDKs listed below are built against it.
Each pattern in this guide links to the relevant specification section. For framework-specific details, see the SDK documentation:
| Framework | SDK Documentation | Demo |
|---|---|---|
| Spring | Spring SDK | spring-demo |
| Flask | Flask SDK | flask_demo |
| FastAPI | FastAPI SDK | fastapi_demo |
| Django | Django SDK | django_demo |
| Tornado | Tornado SDK | tornado_demo |
| NestJS | NestJS SDK | sapl-nestjs-demo |
| .NET | .NET SDK | sapl-dotnet-demos |
The examples below show Spring, Flask, FastAPI, NestJS, and .NET side-by-side. Django and Tornado follow the same Python decorator pattern as Flask and FastAPI. Their full implementations are in the demo repositories linked above.
The patterns
Each pattern shows the application code across frameworks, the shared SAPL policy, and what happens at runtime.
1. Manual PDP call
The simplest integration: call the PDP directly and inspect the decision. No annotations, no decorators. Useful for understanding the subscription model. (Spec: Basic Request-Response Enforcement)
@GetMapping("/api/hello")
Mono<HelloResponse> getHello() {
var subscription = AuthorizationSubscription.of("anonymous", "read", "hello");
return pdp.decideOnce(subscription).flatMap(decision -> {
if (decision.decision() == Decision.PERMIT
&& decision.obligations().isEmpty()
&& decision.resource() instanceof UndefinedValue) {
return Mono.just(new HelloResponse("hello"));
}
return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN));
});
}
@basic_bp.route("/hello")
def get_hello():
subscription = AuthorizationSubscription(
subject="anonymous", action="read", resource="hello",
)
decision = asyncio.run(sapl.pdp_client.decide_once(subscription))
if decision.decision == Decision.PERMIT \
and not decision.obligations and not decision.has_resource:
return jsonify({"message": "hello"})
abort(403)
@router.get("/hello")
async def get_hello(request: Request):
subscription = AuthorizationSubscription(
subject="anonymous", action="read", resource="hello",
)
decision = await pdp_client.decide_once(subscription)
if decision.decision == Decision.PERMIT \
and not decision.obligations and not decision.has_resource:
return {"message": "hello"}
raise HTTPException(status_code=403)
@Get('hello')
async getHello() {
const decision = await this.pdpService.decideOnce({
subject: 'anonymous', action: 'read', resource: 'hello',
});
if (decision.decision === 'PERMIT'
&& !decision.obligations?.length
&& decision.resource == null) {
return { message: 'hello' };
}
throw new ForbiddenException();
}
[HttpGet("hello")]
public async Task<IActionResult> GetHello()
{
var decision = await _pdp.DecideOnceAsync(
AuthorizationSubscription.Create("anonymous", "read", "hello"));
if (decision.Decision == Decision.Permit
&& (decision.Obligations is null || decision.Obligations.Count == 0)
&& !decision.Resource.HasValue)
return Ok(new { message = "hello" });
return StatusCode(403);
}
policy "permit-read-hello"
permit
action == "read";
resource == "hello";
Every framework creates the same subscription {subject: "anonymous", action: "read", resource: "hello"}, gets back PERMIT, and returns {"message": "hello"}.
2. Declarative enforcement with content filtering
One annotation or decorator replaces the manual PDP call. The framework handles the decision, and SAPL’s built-in content filter blackens the SSN before the response reaches the client. (Spec: PreEnforce, Content Filtering)
@GetMapping("/api/patient/{patientId}")
@PreEnforce(action = "'readPatient'", resource = "'patient'")
Mono<Patient> getPatient(@PathVariable String patientId) {
return Mono.justOrEmpty(Patients.findById(patientId));
}
@basic_bp.route("/patient/<patient_id>")
@pre_enforce(action="readPatient", resource="patient")
def get_patient(patient_id: str):
for p in PATIENTS:
if p["id"] == patient_id:
return dict(p)
abort(404)
@router.get("/patient/{patient_id}")
@pre_enforce(action="readPatient", resource="patient")
async def get_patient(request: Request, patient_id: str):
for p in PATIENTS:
if p["id"] == patient_id:
return dict(p)
raise HTTPException(status_code=404)
@PreEnforce({ action: 'readPatient', resource: 'patient' })
@Get('patient/:id')
getPatient(@Param('id') id: string) {
return this.patientService.getPatientById(id);
}
[HttpGet("patient/{id}")]
[PreEnforce(Action = "readPatient", Resource = "patient")]
public IActionResult GetPatient(string id)
{
var patient = PatientData.Find(id);
return patient is null ? NotFound() : Ok(patient);
}
The endpoint returns the full patient record. The policy permits but attaches a content filter obligation:
policy "permit-read-patient"
permit
action == "readPatient";
resource == "patient";
obligation
{
"type": "filterJsonContent",
"actions": [
{
"type": "blacken",
"path": "$.ssn",
"discloseRight": 4
}
]
}
The application code never touches the SSN. The framework applies the filter after the method returns, before the response is sent. The client receives "ssn": "XXXXX6789" instead of "ssn": "123-45-6789".
3. Argument manipulation via constraint handler
The policy caps the transfer amount at 5000. A MethodInvocationConstraintHandler modifies the method argument before execution. The application code sees the already-capped value. (Spec: Handler Types)
// Service method - the handler caps `amount` before this runs
@PreEnforce(action = "'transfer'", resource = "'account'")
public Mono<TransferResult> doTransfer(Double amount, String recipient) {
return Mono.just(new TransferResult(amount, recipient, "completed"));
}
# The handler caps `amount` in kwargs before this runs
@pre_enforce(action="transfer", resource="account")
def do_transfer(amount: float = 10000.0, recipient: str = "default-account"):
return {"transferred": amount, "recipient": recipient, "status": "completed"}
# The handler caps `amount` in kwargs before this runs
@router.post("/transfer")
@pre_enforce(action="transfer", resource="account")
async def transfer(request: Request, amount: float = 10000.0,
recipient: str = "default-account"):
return {"transferred": amount, "recipient": recipient, "status": "completed"}
// The handler caps `amount` in the request before this runs
@Post('transfer')
@PreEnforce({ action: 'transfer', resource: 'account' })
transfer(@Query('amount') amount: string) {
return { transferred: Number(amount), recipient: 'default-account',
status: 'completed' };
}
// The handler caps `amount` via HttpContext before this runs
[HttpPost("transfer")]
[PreEnforce(Action = "transfer", Resource = "account")]
public IActionResult Transfer([FromQuery] double amount)
{
return Ok(new { transferred = amount, status = "completed" });
}
policy "permit-transfer"
permit
action == "transfer";
resource == "account";
obligation
{
"type": "capTransferAmount",
"maxAmount": 5000
}
obligation
{
"type": "logAccess",
"message": "Fund transfer executed"
}
A POST /api/transfer?amount=8000 results in {"transferred": 5000}. The policy also logs the access as a second obligation. Both obligations must be fulfilled for the permit to take effect. (Spec: Obligation vs Advice)
4. Field redaction via mapping handler
A MappingConstraintHandler transforms the response after the method returns. The policy names which fields to redact. The handler replaces their values with [REDACTED]. (Spec: Handler Composition)
@GetMapping("/redacted")
@PreEnforce(action = "'readRedacted'", resource = "'redacted'")
Mono<FinancialRecord> getRedacted() {
return Mono.just(new FinancialRecord(
"John Smith", "987-65-4321", "4111-1111-1111-1111",
"john@example.com", 1500.0));
}
@constraints_bp.route("/redacted")
@pre_enforce(action="readRedacted", resource="redacted")
def get_redacted():
return {"name": "John Smith", "ssn": "987-65-4321",
"creditCard": "4111-1111-1111-1111",
"email": "john@example.com", "balance": 1500.0}
@router.get("/redacted")
@pre_enforce(action="readRedacted", resource="redacted")
async def get_redacted(request: Request):
return {"name": "John Smith", "ssn": "987-65-4321",
"creditCard": "4111-1111-1111-1111",
"email": "john@example.com", "balance": 1500.0}
@PreEnforce({ action: 'readRedacted', resource: 'redacted' })
@Get('redacted')
getRedacted() {
return { name: 'John Smith', ssn: '987-65-4321',
creditCard: '4111-1111-1111-1111',
email: 'john@example.com', balance: 1500.0 };
}
[HttpGet("redacted")]
[PreEnforce(Action = "readRedacted", Resource = "redacted")]
public IActionResult GetRedacted()
{
return Ok(new { name = "John Smith", ssn = "987-65-4321",
creditCard = "4111-1111-1111-1111",
email = "john@example.com", balance = 1500.0 });
}
policy "permit-redacted"
permit
action == "readRedacted";
resource == "redacted";
obligation
{
"type": "redactFields",
"fields": ["ssn", "creditCard"]
}
The response arrives with "ssn": "[REDACTED]" and "creditCard": "[REDACTED]". The name, email, and balance fields pass through unchanged. The policy decides which fields to redact, not the application code.
5. Recoverable SSE streaming
The PDP continuously re-evaluates the policy. When the decision changes from PERMIT to DENY, events are suspended. When it flips back, events resume. The stream stays open throughout. (Spec: EnforceRecoverableIfDenied)
// Controller uses SDK utility to emit suspend/restore signals
Flux<ServerSentEvent<Object>> heartbeatRecoverable() {
return recoverWith(
streamingService.heartbeatRecoverable().cast(Object.class),
e -> {}, () -> new StreamSignal("ACCESS_SUSPENDED", "Waiting for re-authorization"),
r -> {}, () -> new StreamSignal("ACCESS_RESTORED", "Authorization restored"))
.map(StreamingController::toSse);
}
// Service - signalAccessRecovery emits AccessRecoveredException on DENY-to-PERMIT
@EnforceRecoverableIfDenied(action = "'stream:heartbeat'", resource = "'heartbeat'",
signalAccessRecovery = true)
public Flux<HeartbeatEvent> heartbeatRecoverable() {
return Flux.interval(Duration.ofSeconds(2))
.map(tick -> new HeartbeatEvent(seq.getAndIncrement(),
Instant.now().toString()));
}
@streaming_bp.route("/heartbeat/recoverable")
@enforce_recoverable_if_denied(
action="stream:heartbeat", resource="heartbeat",
on_stream_deny=lambda d: {"type": "ACCESS_SUSPENDED",
"message": "Waiting for re-authorization"},
on_stream_recover=lambda d: {"type": "ACCESS_RESTORED",
"message": "Authorization restored"},
)
def heartbeat_recoverable():
return heartbeat_source()
@router.get("/heartbeat/recoverable")
@enforce_recoverable_if_denied(
action="stream:heartbeat", resource="heartbeat",
on_stream_deny=lambda d: {"type": "ACCESS_SUSPENDED",
"message": "Waiting for re-authorization"},
on_stream_recover=lambda d: {"type": "ACCESS_RESTORED",
"message": "Authorization restored"},
)
async def heartbeat_recoverable(request: Request):
return heartbeat_source()
@EnforceRecoverableIfDenied({
action: 'stream:heartbeat', resource: 'heartbeat',
onStreamDeny: (_d, sub) => sub.next({ data: JSON.stringify(
{ type: 'ACCESS_SUSPENDED', message: 'Waiting for re-authorization' }) }),
onStreamRecover: (_d, sub) => sub.next({ data: JSON.stringify(
{ type: 'ACCESS_RESTORED', message: 'Authorization restored' }) }),
})
heartbeatRecoverable(): Observable<any> {
return interval(2000).pipe(map(i => ({ data: JSON.stringify(
{ seq: i, ts: new Date().toISOString() }) })));
}
[HttpGet("heartbeat/recoverable")]
public async Task HeartbeatRecoverable()
{
var stream = _streamingService
.HeartbeatRecoverable(HttpContext.RequestAborted);
var withSignals = stream.RecoverWith(
onDenyItem: () => (object)new { type = "ACCESS_SUSPENDED",
message = "Waiting for re-authorization" },
onRecoverItem: () => (object)new { type = "ACCESS_RESTORED",
message = "Authorization restored" });
await WriteSseAsync(withSignals);
}
The policy uses the <time.now> PIP to cycle between PERMIT and DENY on 20-second boundaries:
policy "streaming-heartbeat-time-based"
permit
action == "stream:heartbeat";
resource == "heartbeat";
var second = time.secondOf(<time.now>);
second >= 0 && second < 20 || second >= 40;
obligation
{
"type": "logAccess",
"message": "Streaming heartbeat access"
}
The PDP subscribes to <time.now> and pushes new decisions as the time condition changes. The client sees heartbeat events during PERMIT windows, ACCESS_SUSPENDED when the decision flips to DENY, and ACCESS_RESTORED when it flips back. No reconnection, no polling.
How it runs
Each demo includes a docker-compose.yml that starts Keycloak for JWT-based tests. The Python, NestJS, and .NET demos connect to a SAPL Node PDP over HTTP. The Spring demo uses an embedded PDP with policies on the classpath. All demos share the same 23 SAPL policy files.
A single test script validates all 28 endpoints produce identical behavior:
Total: 28 | Passed: 28 | Failed: 0 | Skipped: 0
Related
- PEP Implementation Specification: the contract all SDKs implement
- Spring Security guide: deeper dive into Spring-specific patterns
- Policy Operations: git versioning, bundle signing, remote deployment