# SAPL - Streaming Attribute Policy Language > SAPL is an open-source policy language and authorization engine for > Attribute-Based Access Control (ABAC). It supports both traditional > request-response authorization and streaming/reactive authorization > via publish-subscribe attribute streams, with zero overhead when > streaming is not used. SAPL provides SDKs and framework integrations > for Java (Spring Security), Python (Django, Flask, FastAPI, Tornado, > FastMCP), Node.js (NestJS, Express), .NET (ASP.NET Core), and > PHP (Symfony). ## Key Differentiators - Sub-microsecond median evaluation latency, 2M+ decisions/sec (8 cores, JVM) - 8-18x faster than Cedar, 100-200x faster than OPA and OpenFGA in the Cedar OOPSLA 2024 benchmark scenarios - Request-response AND streaming authorization in the same engine, same policies, same deployment - Human-readable policy language purpose-built for authorization - Built-in policy testing DSL (SAPLTest) with coverage reporting - Parameterized attribute finders with composable stream expressions - First-class obligations and advice in authorization decisions - AI agent authorization: MCP server SDK, Spring AI integration, RAG and human-in-the-loop patterns ## Performance - Embedded evaluation: sub-microsecond median latency - Server throughput: 2M+ decisions/sec over RSocket (8 cores, JVM) - Stable latency scaling to 10,000 policies - [Engine comparison](https://sapl.io/guides/comparison/): SAPL vs Cedar, OPA, OpenFGA, Cerbos with reproducible benchmarks - [Full benchmarks](https://sapl.io/guides/performance/): Throughput, latency, scaling across deployment modes ## Getting Started - [Why SAPL?](https://sapl.io/docs/latest/1_1_WhySAPL/): Design rationale and comparison with OPA, Cedar, XACML - [Getting Started](https://sapl.io/docs/latest/1_2_GettingStarted/): Quick start guide - [Playground](https://playground.sapl.io/): Try SAPL policies in the browser - [GitHub](https://github.com/heutelbeck/sapl-policy-engine): Source code and releases ## Documentation - [FAQ](https://sapl.io/faq): What is ABAC, how SAPL works, core concepts - [Policy Language](https://sapl.io/docs/latest/): SAPL syntax, expressions, combining algorithms - [Testing DSL](https://sapl.io/docs/latest/): SAPLTest for policy testing with coverage ## SDKs and APIs - [HTTP API](https://sapl.io/docs/latest/6_1_HTTPApi/): REST/SSE interface for any language, includes multi-subscription endpoints - [Java API](https://sapl.io/docs/latest/6_2_JavaApi/): Reactive API for embedded or remote PDP access - [Spring SDK](https://sapl.io/docs/latest/6_3_Spring/): Annotation-based PEP, embedded PDP, WebFlux - [NestJS SDK](https://sapl.io/docs/latest/6_4_NestJS/): Decorators, constraint handlers, streaming SSE - [Django SDK](https://sapl.io/docs/latest/6_5_Django/): Decorators, async views, streaming - [Flask SDK](https://sapl.io/docs/latest/6_6_Flask/): Pre/post enforcement, constraint handlers - [FastAPI SDK](https://sapl.io/docs/latest/6_7_FastAPI/): Full ABAC with streaming SSE - [Tornado SDK](https://sapl.io/docs/latest/6_8_Tornado/): Async enforcement, streaming - [FastMCP SDK](https://sapl.io/docs/latest/6_9_FastMCP/): MCP server authorization (AI agent tool access) - [.NET SDK](https://sapl.io/docs/latest/6_10_DotNet/): Attributes, constraint handlers, SSE - [PHP SDK](https://sapl.io/docs/latest/6_11_PHP/): Symfony bundle, enforcement attributes, streaming SSE, Doctrine query rewriting ## Deployment - **Embedded PDP**: Runs inside a JVM application with policies from classpath, filesystem, or signed bundles - **SAPL Node**: Standalone PDP server exposing HTTP and RSocket APIs. CLI for bundle creation, signing, and credential management - **Native binary**: GraalVM native image for minimal footprint - **Operations**: Health/readiness probes (Actuator), Prometheus metrics (decisions, latency, active subscriptions), structured decision logging, Kubernetes liveness/readiness/startup probes - **Signed bundles**: Ed25519 signatures for policy integrity verification ## Streaming Enforcement (permit, suspend, deny) Since SAPL 4.1, policies vote with three effect verbs: permit, deny, and suspend. DENY is terminal and ends the stream. SUSPEND pauses it: the PEP stops forwarding data, keeps the subscription alive, and the next PERMIT resumes the flow. PEPs that cannot suspend (one-shot request-response enforcement) treat SUSPEND as DENY, so the same policies protect both endpoint styles. All SDKs expose a single streaming enforcement annotation (`@StreamEnforce` in Spring and NestJS, `@stream_enforce` in the Python SDKs, `[StreamEnforce]` in .NET, `#[StreamEnforce]` in PHP) with two flags: - **signalTransitions**: when true, every suspend and resume boundary is surfaced to the client (signal callbacks via TransitionSignals in Spring and NestJS, in-band ACCESS_SUSPENDED / ACCESS_GRANTED frames in the SSE bindings). When false, the client sees data while permitted and silence while suspended. - **pauseRapDuringSuspend**: when true, the PEP unsubscribes from the protected data source while suspended and resubscribes on resume. When false, the source stays subscribed and items are silently dropped. Fail closed: DENY, INDETERMINATE, NOT_APPLICABLE, and unfulfillable obligations terminate the stream. Only an explicit SUSPEND pauses it. The PEP is a four-state machine (Pending, Permitting, Suspended, Terminated) driven by the PDP decision verb. Earlier 4.0.x SDKs used three PEP-side annotations (EnforceTillDenied, EnforceDropWhileDenied, EnforceRecoverableIfDenied). Since 4.1 the choice between terminating and pausing lives in the policy verb, not in application code. ## AI Security Guides - [RAG pipeline authorization](https://sapl.io/guides/ai-rag/): Document-level access control in retrieval-augmented generation - [AI tool authorization](https://sapl.io/guides/ai-tools/): Per-tool authorization for Spring AI applications - [Human-in-the-loop](https://sapl.io/guides/ai-hitl/): Policy-driven approval workflows for AI tool execution - [MCP server authorization](https://sapl.io/guides/ai-mcp/): Per-tool, per-resource, per-prompt access control for MCP servers ## Comparison with Other Authorization Engines For detailed feature tables and benchmark charts, see the [engine comparison guide](https://sapl.io/guides/comparison/). ### SAPL vs OPA (Open Policy Agent) - OPA uses Rego, a general-purpose query language. SAPL uses a purpose-built authorization language with readable syntax. - SAPL is 100-200x faster than OPA in the Cedar OOPSLA 2024 benchmark scenarios (embedded evaluation). - OPA is request-response only. SAPL supports both request-response and streaming authorization. - OPA loads external data via bundles. SAPL integrates external data sources as parameterized, composable attribute streams during evaluation. - OPA tests are Rego rules. SAPL has a dedicated testing DSL (SAPLTest) with mocking and coverage. - OPA is Go-native (CNCF graduated). SAPL provides SDKs for Java, Python, Node.js, and .NET. ### SAPL vs Cedar (AWS) - SAPL is 8-18x faster than Cedar in the Cedar OOPSLA 2024 benchmark scenarios (embedded evaluation, Cedar 3.0 and 4.10). - Cedar requires all data upfront in the request or entity store, enabling formal verification via Lean proofs. SAPL accesses external data during evaluation via attribute finders. - Cedar is request-response only. SAPL supports both request-response and streaming authorization. - Cedar has no built-in policy testing DSL. SAPL has SAPLTest with mocking and coverage. - Cedar does not include obligations or advice in decisions. SAPL supports first-class obligations and advice. - Cedar is Rust-native, open source. SAPL is JVM-native with SDKs for Java, Python, Node.js, and .NET. ### SAPL vs OpenFGA - SAPL is 100-200x faster than OpenFGA in the Cedar OOPSLA 2024 benchmark scenarios. - OpenFGA implements the Google Zanzibar model, purpose-built for ReBAC at scale (CNCF incubating). SAPL supports ReBAC via graph functions alongside ABAC, RBAC, and streaming. - OpenFGA is request-response only. SAPL supports both request-response and streaming authorization. - OpenFGA does not include obligations or advice in decisions. SAPL supports first-class obligations and advice. ### SAPL vs XACML - XACML uses XML-based policy syntax. SAPL uses a concise, human-readable syntax. - XACML is request-response only. SAPL supports both request-response and streaming authorization. - SAPL inherits XACML's architectural concepts (PEP, PDP, PIP, PAP, obligations, advice) and redesigns the policy language and evaluation engine for streaming. ### SAPL vs Spring Security - Spring Security provides built-in role and authority checks. SAPL externalizes authorization logic into policies, supporting ABAC with dynamic attributes. - Spring Security authorization is hardcoded in application code. SAPL policies can be updated without application restart. - SAPL integrates with Spring Security via AOP annotations and authorization managers, complementing rather than replacing Spring Security. ## Academic Publications Heutelbeck, D. (2019). Attribute Stream-Based Access Control (ASBAC) - Functional Architecture and Patterns. In *Proceedings of the 2019 International Conference of Security and Management (SAM'19)*. Heutelbeck, D. (2019). The Structure and Agency Policy Language (SAPL) for Attribute Stream-Based Access Control (ASBAC). In *Proceedings of the 2nd International Workshop on Emerging Technologies for Authorization and Authentication (ETAA 2019)*. Heutelbeck, D., Baur, M.L., and Kluba, M. (2021). In-Memory Policy Indexing for Policy Retrieval Points in Attribute-Based Access Control. In *Proceedings of the 26th ACM Symposium on Access Control Models and Technologies (SACMAT '21)*, pp. 59-70. Association for Computing Machinery, New York, NY. Heutelbeck, D. (2021). Demo: Attribute-Stream-Based Access Control (ASBAC) with the Streaming Attribute Policy Language (SAPL). In *Proceedings of the 26th ACM Symposium on Access Control Models and Technologies (SACMAT '21)*, pp. 95-97. Association for Computing Machinery, New York, NY. ## Project - License: Apache 2.0 - Origin: European research (FTK, Horizon Europe grants No. 101080923 and No. 957852) - Self-hosted, no vendor lock-in, no proprietary dependencies - SDKs: Java/Spring, Python, Node.js/NestJS, .NET, PHP/Symfony --- # SAPL Documentation Reference ## Why SAPL? SAPL is a policy language and authorization engine for Attribute Stream-Based Access Control (ASBAC). It provides a concise, purpose-built syntax for writing authorization policies, a managed infrastructure for integrating external data sources, and a stream-oriented evaluation model that supports both continuous streaming and one-shot request-response authorization, with zero overhead when streaming is not used. ### From ABAC to ASBAC Attribute-Based Access Control (ABAC) is the established model for fine-grained authorization: decisions are based on attributes of the subject, action, resource, and environment. Every authorization engine in the current landscape implements some form of ABAC. In traditional ABAC, the Policy Decision Point (PDP) evaluates a request against policies, consults external data sources if needed, and returns a decision. This works well when authorization is a gate: a single check at the start of an operation. But many real-world scenarios require authorization that persists beyond a single check. A trader exceeds their daily risk limit while placing orders on a trading platform. A device's safety certification expires during an active control session. A user's clearance is revoked while they are connected to a classified data stream. In these cases, the authorization decision must change when conditions change, without the application polling for updates or missing the transition entirely. **Attribute Stream-Based Access Control (ASBAC)** extends ABAC by treating attributes as streams rather than snapshots. When a policy accesses an external attribute, the engine subscribes to a live data stream. If the underlying data changes (a clock crosses a shift boundary, a certificate expires, a permission is revoked), the engine re-evaluates the policy and pushes an updated decision to the application automatically. ASBAC is a strict superset of ABAC. Every request-response authorization decision is also valid in a streaming model. When policies do not access streaming attributes, SAPL's stratified policy compilation produces a fully synchronous evaluation path with no asynchronous stream-processing overhead. Applications that use SAPL purely for request-response authorization do not pay for streaming capabilities they are not using. ### Why a New Language and Engine Streaming attributes are not a feature that can be added to an existing request-response authorization engine. They change the system architecture from the ground up. **The language operates on streams, not just data.** In SAPL, adding angle brackets to an expression turns it into a stream subscription. Writing `` in a policy does not fetch the current time once. It subscribes to a time stream that emits new values as time passes. The policy author writes what looks like a simple expression. The engine manages the ongoing subscription, re-evaluates the policy when new values arrive, and pushes updated decisions to the application. Continuous monitoring comes from the language semantics, not from infrastructure the application has to build. **The evaluation model is fundamentally different.** A request-response-only engine evaluates a policy once and returns. A streaming engine maintains live evaluation contexts. When any subscribed attribute stream emits a new value, the engine re-evaluates the affected expressions and determines whether the decision has changed. This requires incremental expression evaluation, dependency tracking between expressions and their attribute sources, and an efficient mechanism for propagating changes through the policy without re-evaluating everything from scratch. **The engine must manage stream lifecycles.** When two policies access the same attribute, the engine shares a single connection to the data source rather than creating two. When a data source disconnects, the engine retries with exponential backoff. When no policy is currently using an attribute, the engine keeps the connection alive briefly in case a new subscription arrives. When a PIP plugin is loaded or unloaded at runtime, active streams reconnect automatically. When policies are added, removed, or modified, the engine re-evaluates all active subscriptions against the new policy set without interrupting them. **Policies must be able to parameterize and compose attribute access.** A streaming attribute finder is more than a named data source. It is a parameterized, composable expression. Policies need to pass runtime values to data sources, control stream behavior (timeouts, retry policies, caching), and chain one attribute lookup into the parameters of another: ```sapl subject.employeeId.))> ``` This expression passes the subject's employee ID to a scheduling data source, with a parameter derived from a time stream. Both the scheduling data and the time are live streams. When the day changes, the shift lookup updates, and the policy re-evaluates. The policy author writes a single line. The engine manages the stream composition, lifecycle, and resilience. **The PDP-PEP contract is different.** In request-response authorization, the application asks a question and gets an answer. In ASBAC, the application subscribes and receives a stream of decisions. The subscription is the stable contract. Policies change, attribute sources reconnect, configuration updates, static data reloads. The PDP absorbs every kind of change internally and expresses the net effect as decision updates on the existing subscription. The PEP never has to reconnect, re-query, or even know what changed. ### What SAPL Provides - **A purpose-built policy language** with subject, action, resource, and environment as first-class concepts. Angle brackets (`<...>`) denote attribute stream access, making the distinction between local data and external streams visible in the policy syntax. - **Parameterized attribute finders** that accept runtime arguments, entity context, and composed expressions, not just static attribute identifiers. PIPs can be overloaded by parameter count, and policies control per-access stream behavior (timeouts, retries, caching) via options. - **Obligations and advice** as first-class policy constructs. Obligations are structured instructions that the application must enforce before granting access, even on PERMIT. Advice is optional. Both travel with the authorization decision and are declarable in the policy language itself. - **Streaming and request-response** in the same engine, the same policies, and the same deployment. The evaluation strategy is determined automatically by the policies loaded into the PDP. - **A testing DSL** (SAPLTest) with declarative PIP and function mocking, streaming assertions, domain-aware matchers for decisions, obligations, advice, and resource transformations. - **Hot-reloadable policies** that take effect without restarting the PDP. Active subscriptions re-evaluate against the new policy set automatically. - **Open source** with no proprietary dependencies or licensing barriers. SAPL's authorization architecture follows the component model defined in RFC 2904: Policy Enforcement Points (PEP), Policy Decision Points (PDP), Policy Information Points (PIP), and Policy Administration Points (PAP). Concepts like obligations and advice also have deep roots in authorization standards. SAPL inherits these proven architectural ideas and redesigns the policy language, evaluation engine, and data integration model for a streaming world. SAPL is used in production across European research and industry projects, including industrial energy market systems and eHealth applications managing access to highly sensitive participant data in multi-centre clinical studies. ### Choosing an Authorization Engine Different authorization engines reflect different design priorities. A brief, non-exhaustive overview: **OPA** (Open Policy Agent) provides Rego, a query language for policy evaluation, with broad integration across the cloud-native ecosystem including Kubernetes, Envoy, and Terraform. It evaluates policies in a request-response model. External data is loaded via bundles or fetched during evaluation. **Cedar** (AWS) provides a purpose-built authorization language with formal verification tooling that can mathematically prove properties about policy sets (e.g., "no policy can ever grant access to resource X"). Requiring all data upfront in the request or an entity store is what makes this verification possible. The trade-off is that policies cannot access external data sources during evaluation. **XACML** (OASIS) established the foundational architecture for attribute-based access control: PEP, PDP, PIP, PAP, obligations, and advice. SAPL inherits these architectural concepts. XACML uses XML-based policy syntax and a request-response evaluation model. **SAPL** provides a purpose-built authorization language with parameterized external data integration, streaming and request-response evaluation, first-class obligations and advice, and a dedicated policy testing language. All of these engines implement attribute-based access control. The meaningful differences are in how they integrate external data, whether they support streaming evaluation, what they include in the authorization decision beyond permit/deny, and how they approach policy testing and verification. To get started, continue to [Getting Started](../1_2_GettingStarted/). ## Getting Started SAPL is an authorization engine for streaming and request-response access control. It evaluates policies against authorization subscriptions and can push updated decisions when policies, attributes, or subscriptions change. This guide introduces the policy syntax through the browser-based playground, then walks through hands-on policy evaluation using the CLI. ### Learning Policy Syntax The [SAPL Playground](https://playground.sapl.io/) runs entirely in your browser and requires no installation. Open the playground, write policies, create authorization subscriptions, and observe how the PDP evaluates them. The playground includes example policies demonstrating common authorization patterns. The playground is primarily useful for learning the policy syntax and testing basic policy logic. The playground cannot connect to external attribute sources, and access to PIPs calling out to location tracking, HTTP servers, or MQTT brokers are present but calls are blocked. However, it is a useful tool to learn how a PDP works, how multiple policies and policy sets interact with each other, and how the streaming nature of SAPL works. It even allows you to graphically dig into traces of individual decisions for learning or debugging of your policies. You can also use it to share authorization scenarios with others. ### Evaluating Policies The `sapl` CLI lets you evaluate policies locally without starting a server. Download the binary for your platform from the [releases page](https://github.com/heutelbeck/sapl-policy-engine/releases) and extract it. Each archive contains the `sapl` binary, the `LICENSE`, and a `README.md`. On Linux, DEB and RPM packages are also available (see [SAPL Node Getting Started](../7_1_GettingStarted/#installing-with-deb-or-rpm)). Verify the installation: ```bash sapl --version ``` #### Write a Policy By default, the CLI loads policies from `~/.sapl/`. Create the directory and a policy file:
Bash ```bash mkdir -p ~/.sapl ```
PowerShell ```powershell mkdir ~\.sapl ```
Create `~/.sapl/allow-mrt.sapl`: ```sapl-demo policy "Dr. House is allowed to use the MRT!" permit subject == "housemd" & action == "use" & resource == "MRT"; ``` {: data-subject="housemd" data-action="use" data-resource="MRT" } {: .note } > If you are reading this on the documentation site, the embedded playground above shows the result of evaluating this single policy in isolation. It displays the outcome of the one matching policy only. A full PDP additionally applies a *combining algorithm* on top that merges the results of all loaded policies into a final decision. For example, when no policy matches, the embedded playground shows `NOT_APPLICABLE`, while a PDP configured with a deny-by-default algorithm returns `DENY`. The CLI examples below use a full PDP with such a configuration. #### Evaluate with decide-once The `decide-once` command evaluates the subscription against all loaded policies, prints the result, and exits. A subscription has three required components: `--subject`, `--action`, and `--resource`, each a JSON value:
Bash ```bash sapl decide-once --subject '"housemd"' --action '"use"' --resource '"MRT"' ```
PowerShell ```powershell sapl decide-once --subject '\"housemd\"' --action '\"use\"' --resource '\"MRT\"' ```
Alternatively, pass the subscription as a JSON file with `--file` (`-f`), or pipe it from stdin with `-f -`: ```bash echo '{"subject":"housemd","action":"use","resource":"MRT"}' | sapl decide-once -f - ``` This prints `{"decision":"PERMIT"}`. The policy matches: subject `housemd`, action `use`, resource `MRT`. > **Quoting:** Because subscription components are JSON values, strings must include double quotes. In Bash, wrap them in single quotes: `--subject '"housemd"'`. In PowerShell, use backslash-escaped quotes: `--subject '\"housemd\"'`. The remaining examples use the short flags `-s`, `-a`, `-r`. Two optional subscription components are available for policies that need them: `--environment` (`-e`) provides additional context as a JSON value, and `--secrets` provides a JSON object accessible to policies via the `secrets()` function. Try a request that does not match:
Bash ```bash sapl decide-once -s '"cuddy"' -a '"use"' -r '"MRT"' ```
PowerShell ```powershell sapl decide-once -s '\"cuddy\"' -a '\"use\"' -r '\"MRT\"' ```
This returns `{"decision":"DENY"}`. No policy matches subject `cuddy`, so the PDP denies the request. When multiple policies exist, a *combining algorithm* determines how their individual results are merged into a final decision. The default algorithm denies access unless a policy explicitly permits it (see [PDP Configuration](../2_2_PDPConfiguration/)). #### Streaming with decide The `decide` command holds the subscription open and prints a new decision whenever the result changes:
Bash ```bash sapl decide -s '"housemd"' -a '"use"' -r '"MRT"' ```
PowerShell ```powershell sapl decide -s '\"housemd\"' -a '\"use\"' -r '\"MRT\"' ```
The CLI prints `{"decision":"PERMIT"}` and keeps running. Now edit `~/.sapl/allow-mrt.sapl` while the command is running: change `"housemd"` to `"cuddy"` and save. The PDP detects the change, recompiles the policy, and immediately prints `{"decision":"DENY"}`. Change it back and `PERMIT` returns. No restart, no polling. Press `Ctrl+C` to stop the stream. #### Time-based Streaming The PDP loads all `.sapl` files from the policy directory. Create a second policy file `~/.sapl/cuddy-time-limited.sapl` that gives Cuddy time-limited access: ```sapl-demo policy "Dr. Cuddy has time-limited MRT access" permit subject == "cuddy" & action == "use" & resource == "MRT"; time.secondOf() % 10 < 5; ``` {: data-subject="cuddy" data-action="use" data-resource="MRT" } `` is an *attribute stream*. It emits the current UTC timestamp once per second. The `time.secondOf` function extracts the seconds component. The modulo expression makes the policy applicable only when the current second is 0-4 within each 10-second window. Start a streaming subscription for Cuddy:
Bash ```bash sapl decide -s '"cuddy"' -a '"use"' -r '"MRT"' ```
PowerShell ```powershell sapl decide -s '\"cuddy\"' -a '\"use\"' -r '\"MRT\"' ```
Watch the decision flip between `PERMIT` and `DENY` every five seconds. The PDP re-evaluates the policy each time `` emits a new timestamp and pushes the updated decision. The application does not poll. #### Using Policies in Scripts The `check` command evaluates a subscription and exits with a code that encodes the decision: `0` for a plain PERMIT, `2` for DENY. A PERMIT carrying obligations exits `4` instead, since the caller must enforce the obligations rather than treat access as unconditionally granted, and the remaining outcomes have their own codes (run `sapl check --help` for the full list). No output is written to stdout, making it ideal for shell scripts. Here is a script that checks authorization before starting the MRT:
Bash ```bash if sapl check -s '"housemd"' -a '"use"' -r '"MRT"'; then echo "Access granted. Starting MRT..." # start-mrt else echo "Access denied." fi ```
PowerShell ```powershell sapl check -s '\"housemd\"' -a '\"use\"' -r '\"MRT\"' if ($LASTEXITCODE -eq 0) { Write-Host "Access granted. Starting MRT..." # Start-MRT } else { Write-Host "Access denied." } ```
Try it with `'"cuddy"'`. The script denies access. Change Cuddy's policy and run again. No code change needed. ### Next Steps You now have policies that grant and deny access, react to live changes, and can be used from shell scripts. From here you can go in several directions: **Learn the policy language.** The examples above use simple equality checks. SAPL supports pattern matching, arithmetic, functions, and attribute streams for expressing complex authorization logic. See [The SAPL Policy Language](../2_0_TheSAPLPolicyLanguage/). **Test and validate policies.** Write unit tests for your policies with `sapl test` (see [Testing SAPL Policies](../5_0_TestingSAPLPolicies/)). Use `sapl check` in CI pipelines to verify that policy changes do not break expected decisions. **Run the PDP as a server.** So far you used the CLI to evaluate policies locally. The same `sapl` binary can run as an HTTP server that applications query for authorization decisions over the network. The CLI commands `decide`, `decide-once`, and `check` also work as clients against a remote PDP server using `--remote`. See [SAPL Node](../7_1_GettingStarted/). **Integrate into your application.** SAPL provides SDKs for Java ([Java API](../6_2_JavaApi/)), [Spring](../6_3_Spring/), [NestJS](../6_4_NestJS/), Python ([Django](../6_5_Django/), [Flask](../6_6_Flask/), [FastAPI](../6_7_FastAPI/), [Tornado](../6_8_Tornado/), [FastMCP](../6_9_FastMCP/)), and [.NET](../6_10_DotNet/). See [SDKs and APIs](../6_0_SDKsAndAPIs/) for the full list. **Build your own integration.** If no SDK exists for your language or framework, you can implement a client against the [HTTP API](../6_1_HTTPApi/) directly. Authors building reusable PEP libraries can follow the [Spring](../6_3_Spring/) and [NestJS](../6_4_NestJS/) integrations as reference implementations of the unified enforcement model. **Explore demo applications.** Complete working examples show how SAPL integrates with real frameworks. Each demo includes policies, Docker infrastructure, and runnable endpoints: * [Java/Spring demos](https://github.com/heutelbeck/sapl-demos). Embedded and remote PDP usage, Spring MVC and WebFlux method security, database query rewriting for row-level security with JPA, R2DBC, and MongoDB, OAuth2/JWT integration, and MQTT as a Policy Information Point. * [NestJS demo](https://github.com/heutelbeck/sapl-nestjs-demo). All seven constraint handler types, content filtering, resource replacement, streaming SSE with continuous authorization, service-level enforcement, and JWT-based ABAC with Keycloak. * [Node.js demos](https://github.com/heutelbeck/sapl-nodejs-demos). An Express demo covering basic enforcement and constraint handling, and a NestJS demo with external data source integration. * [Python demos](https://github.com/heutelbeck/sapl-python-demos). Four demos covering FastAPI, Django, Flask, and FastMCP (Model Context Protocol). The FastAPI and Django demos include JWT authentication, SSE streaming, and all constraint handler types. The Flask demo covers basic pre/post enforcement. * [.NET demo](https://github.com/heutelbeck/sapl-dotnet-demos). ASP.NET Core with attribute-driven enforcement, all constraint handler types, service-layer enforcement, JWT export endpoints, and SSE streaming with till-denied, drop-while-denied, and recoverable patterns. **AI and LLM authorization.** SAPL can enforce access control on AI tool calls, RAG pipelines, and MCP servers. These demos show how policies control what an LLM is allowed to see and do: * [RAG clinical trial](https://github.com/heutelbeck/sapl-demos/tree/main/rag-clinical-trial) (Java/Spring AI). Document-level access control in a RAG pipeline. SAPL obligations modify pgvector search filters before retrieval, ensuring the LLM never sees unauthorized documents. Access is controlled by role, site assignment, and declared purpose, including GDPR purpose limitation for personal data. * [MCP tool-calling](https://github.com/heutelbeck/sapl-demos/tree/main/mcp-clinical-trial) (Java/Spring AI). Policy-driven access control on Spring AI `@Tool` methods. SAPL policies decide per tool and per user which clinical trial data the LLM can access. * [Human-in-the-loop](https://github.com/heutelbeck/sapl-demos/tree/main/hitl-clinical-trial) (Java/Spring AI). Policy-driven human approval for safety-critical AI tool calls. SAPL obligations trigger blocking approval dialogs with configurable timeouts and mandatory review flags before the LLM can execute high-risk actions. * [FastMCP](https://github.com/heutelbeck/sapl-python-demos/tree/main/fastmcp_demo) (Python). SAPL authorization for MCP servers, showing both global middleware and per-component `auth=sapl()` approaches. Policies control tool visibility and access with JWT authentication via Keycloak. ## The SAPL Policy Language SAPL (Streaming Attribute Policy Language) is a domain-specific language for expressing and evaluating access control policies. It supports both request/response and publish/subscribe authorization protocols, both based on JSON. The architecture follows the terminology defined by [RFC 2904 "AAA Authorization Framework"](https://tools.ietf.org/html/rfc2904). ![SAPL_Architecture.svg](/docs/XXXSAPLVERSIONXXX/assets/sapl_reference_images/SAPL_Architecture.svg) ### SAPL at a Glance Here is what a SAPL policy looks like: ```sapl-demo policy "compartmentalize read access by department" permit resource.type == "patient_record" & action == "read"; subject.role == "doctor"; resource.department == subject.department; ``` **In plain English:** *"Permit reading patient records if the reader is a doctor from the same department as the record."* > **Attributes** > > In this policy, `subject.role`, `resource.type`, and `subject.department` are all so-called attributes. > The comparison `resource.department == subject.department` works for any department without modification. > This is an advantage of ABAC over RBAC: instead of creating separate roles like "cardiologyDoctor", > "radiologyDoctor", "neurologyDoctor" (and updating them with every new department), one policy handles all > departments by comparing attributes. Add ten new departments and the policy needs no changes. > Attributes are either sent with the authorization question or looked up dynamically. > In this example they come from the authorization question only. ### Data Flow SAPL policies evaluate **JSON authorization subscriptions** (input) to produce a sequence of **JSON authorization decisions** (output). Internally, SAPL's data model extends JSON with `undefined` values and error states to enable robust policy evaluation. ![ABAC authorization flow: Subject attempts action, PEP builds subscription, PDP retrieves policies from PRP and fetches attributes from PIP, PDP returns decision, PEP enforces by denying or executing and delivering result](/docs/XXXSAPLVERSIONXXX/assets/sapl_reference_images/abac-flow.svg) A typical scenario: a subject (e.g., a user or system) attempts to take action (e.g., read or cancel an order) on a protected resource (e.g., a domain object or a file). The system implements a **policy enforcement point (PEP)** protecting its resources. The PEP collects information about the subject, action, resource, and potential other relevant data in an authorization subscription and sends it to a **policy decision point (PDP)** that evaluates SAPL policies to decide if it grants access. The decision is sent back to the PEP, which either grants or denies access. The PDP subscribes to all data sources referenced by the policies, and new decisions are sent to the PEP whenever indicated by the policies and data sources. ### Streaming and One-Shot Evaluation SAPL's authorization protocol operates in two modes: **streaming** and **one-shot**. In **streaming mode**, the PEP subscribes to a decision stream by sending an authorization subscription to the PDP. The PDP evaluates the subscription against all applicable policies, returns an initial authorization decision, and then keeps the subscription open. Whenever policies change, external attributes update, or environment conditions shift, the PDP automatically pushes a new decision to the PEP. The PEP does not need to re-request authorization. Updates arrive as they happen. In **one-shot mode**, the PEP sends the same authorization subscription but receives a single decision and the connection closes. This is the traditional request-response pattern, suitable for use cases where continuous updates are not needed. For policies that do not access external attributes (PIPs), the PDP evaluates the subscription on a fully synchronous code path with no reactive or asynchronous overhead. The same policies serve both modes. The PDP does not require separate policies for streaming and one-shot evaluation. ### Reference Start with [Authorization Subscriptions](../2_1_AuthorizationSubscriptions/) and [Policy Structure](../2_4_PolicyStructure/) for the fundamentals, then explore the detailed pages for each language feature. ## Schemas for Authorization Subscriptions SAPL allows predefined structure for authorization subscription elements using [JSON Schema](https://json-schema.org/) (2020-12 version). Schemas serve three purposes: - Defining the contract between PEP and PDP for the structure of authorization subscriptions - Enforcing that contract at evaluation time, making non-compliant subscriptions automatically inapplicable - Enabling richer code completion in SAPL editors based on the known structure ### Schema Syntax Schema statements are declared after any [imports](2_9_Imports.md) and before the policy or policy set. Each schema targets one subscription element: ``` schema enforced schema ``` Where `` is `subject`, `action`, `resource`, or `environment`, and `` is a SAPL expression evaluating to a JSON Schema object. **Enforced vs. non-enforced:** Without `enforced`, the schema is used only by SAPL editors for code completion. With `enforced`, the engine implicitly adds schema validation to the document's applicability check. If an explicit target expression is also present, both the explicit target and the schema validation must hold for the document to be applicable. **Multiple schemas:** A subscription element may have more than one schema statement. When enforcing, the element is valid if it satisfies at least one of the provided schemas. **Restrictions:** Schema expressions must not contain attribute finder expressions (``) since schemas are evaluated at compile time without access to external data sources. **External schema references:** If a schema uses `$ref` to reference other schemas, the engine resolves these from a PDP-level variable called `schemas`. This variable must be an array of JSON Schema objects, each with a `$id` field. PDP variables are part of the [PDP configuration](../2_2_PDPConfiguration/#variables) and are not to be confused with the `environment` object in the authorization subscription. ### Variable Schemas Schemas can also be attached to variable definitions for IDE support. These schemas are not enforced at runtime but enable code completion for the variable's value: ```sapl var account = resource.account schema { "type": "object", "properties": { "balance": { "type": "number" }, "owner": { "type": "string" } } }; ``` Multiple schemas can be attached to a single variable, separated by commas: ```sapl var data = resource.payload schema { "type": "object" }, { "type": "array" }; ``` ### Example The following policy uses an enforced schema to ensure that the subject contains the expected fields: ```sapl subject enforced schema { "type": "object", "required": ["username", "role"], "properties": { "username": { "type": "string" }, "role": { "type": "string", "enum": ["admin", "user", "guest"] } } } policy "admin access" permit subject.role == "admin"; ``` A compliant authorization subscription: ```json { "subject": { "username": "alice", "role": "admin" }, "action": "read", "resource": "dashboard" } ``` This subscription matches the schema (both required fields present, `role` is a valid enum value), so the policy is evaluated normally and returns `PERMIT`. A non-compliant authorization subscription: ```json { "subject": { "username": "bob" }, "action": "read", "resource": "dashboard" } ``` This subscription fails schema validation (missing required field `role`), so the document is not applicable regardless of whether the policy body would match. The engine returns `NOT_APPLICABLE`. ## Evaluation Semantics This section defines how the SAPL engine evaluates expressions, with particular attention to evaluation order, short-circuit behavior, and how cost strata interact with streaming attribute subscriptions. For how individual policies and policy sets map evaluation results to decision values (`PERMIT`, `DENY`, `SUSPEND`, `NOT_APPLICABLE`, `INDETERMINATE`), see [Policy Structure](../2_4_PolicyStructure/#policy-evaluation-result) and [Policy Sets](../2_6_PolicySets/#policy-set-evaluation-result). ### Cost-Stratified Short-Circuit Evaluation All AND and OR operators (`&`, `&&`, `|`, `||`) use **cost-stratified short-circuit evaluation**. The compiler flattens chains of AND/OR operators into N-ary operations. For example, `a && b && c && d` is compiled into a single conjunction rather than a chain of nested binary operations. This enables the engine to sort all operands by cost stratum, regardless of how many there are. If any operand in a lower (cheaper) stratum short-circuits the result, all operands in higher (more expensive) strata are never evaluated and their subscriptions are never created. Within the streaming stratum, the two operator forms differ in their subscription strategy. `&&`/`||` (lazy) subscribe to attribute sources sequentially, while `&`/`|` (eager) subscribe to all sources in parallel. See [Lazy vs Eager](../2_7_Expressions/#lazy-vs-eager-subscription-strategy-within-the-streaming-stratum) for details. ### The Three Strata SAPL categorizes expressions into three strata based on their evaluation cost: 1. **Constants** (e.g., `true`, `false`, `1 + 2`): Evaluated at compile time. This stratum also includes **PDP variables**: values configured by the operator in the [PDP configuration](../2_2_PDPConfiguration/#variables) are known when policies are loaded and are automatically constant-folded into this stratum. A condition referencing a PDP variable (e.g., comparing against a configured tenant name or feature flag) is as cheap to evaluate as a literal `true` or `false`. 2. **Pure expressions** (e.g., `subject.isActive`, `resource.type`): Evaluated at runtime without external subscriptions. This includes the four authorization subscription fields (`subject`, `resource`, `action`, `environment`), which are only known when a concrete subscription arrives. 3. **Streaming expressions** (e.g., ``, `subject.`): Require asynchronous subscription to external data sources ### Evaluation Rules 1. **Cross-strata ordering:** Lower (cheaper) strata are always evaluated before higher (more expensive) strata, regardless of operand position in the source. 2. **Within-strata ordering:** Within the same stratum, operands are evaluated strictly left-to-right as they appear in the source. 3. **Short-circuit behavior:** Only the dominating value short-circuits: `false` for AND, `true` for OR. When a dominating value is found, evaluation stops and the remaining operands are not evaluated. An error or undefined operand does **not** short-circuit, so it never lets the engine skip the remaining operands or their subscriptions. 4. **Kleene three-valued logic:** Boolean operators follow Kleene strong three-valued logic. An operand that is not `true` or `false`, an error, `undefined`, or any other non-boolean value, acts as a third value, *unknown*. Errors and undefined are treated alike. AND is `false` if any operand is `false`, otherwise *unknown* if any operand is unknown, otherwise `true`. OR is `true` if any operand is `true`, otherwise *unknown* if any operand is unknown, otherwise `false`. Only when no operand carries the dominating value does an unknown operand determine the result, in which case the operator yields an error (the original error, or a type-mismatch error for `undefined` or another non-boolean). Because the dominating value wins regardless of operand position or stratum, the result does not depend on evaluation order: a dominating `false` (AND) or `true` (OR) in any stratum rescues an unknown in any other. ### Examples **Constant short-circuits subscription access** ```sapl subject.isActive && false ``` Since `false` is a constant (lower stratum) that determines the AND result, `subject.isActive` (higher stratum) is **never evaluated**. This is equivalent to just `false`. **Subscription access short-circuits attribute finder** ```sapl subject.isAdmin || ``` If `subject.isAdmin` is `true`, the attribute finder `` is **never subscribed to**. The external system is never contacted. **Operand position does not matter for cross-strata** ```sapl && false ``` Even though `` appears on the left, the constant `false` is evaluated first. The attribute stream is **never subscribed to**. This may be surprising if you expect strict left-to-right evaluation as in imperative programming languages. **Left-to-right within the same stratum** ```sapl true || (1/0 > 0) ``` Both operands are constants (same stratum). Left-to-right order applies: `true` is evaluated first and short-circuits. The division by zero is **never evaluated**, so no error occurs. ```sapl (1/0 > 0) || true ``` Again both are constants, but now the error-producing expression comes first. Under Kleene logic the error does **not** short-circuit, and the dominating `true` wins regardless of position, so the result is `true`. The division-by-zero error never surfaces, because a dominating value is present. **A dominating value rescues an error in any stratum** ```sapl subject.isActive || (1/0 > 0) ``` Here `subject.isActive` is a pure expression (higher stratum) and `1/0 > 0` is a constant (lower stratum) that produces an error. The error does not short-circuit, so `subject.isActive` is still evaluated. If it is `true`, the dominating `true` wins and the result is `true`: the pure expression **rescues** the constant error. Only if `subject.isActive` is `false`, leaving no dominating value for the OR, does the error become the result. ### Implications for Policy Authors {: .info } **Why this matters for attribute finders:** Attribute finders subscribe to external data sources. Skipping their evaluation when unnecessary avoids unnecessary network calls, reduces latency, and prevents side effects from unused subscriptions. This is particularly valuable when combining quick checks with expensive external lookups. {: .info } **Constant errors do not by themselves determine the result:** Constant expressions that produce errors (like `1/0`) are evaluated at compile time, but under Kleene logic a constant error is only the third value *unknown*. It is carried alongside the expression, and a dominating value in any operand or stratum still wins. The constant error becomes the result only when no operand carries the dominating value (no `false` in an AND, no `true` in an OR). To avoid an error result, ensure a dominating operand is reachable, or guard the constant with conditional logic. ### Body Condition Evaluation Each semicolon-terminated statement in a policy body is an operand of an implicit conjunction. The body is equivalent to connecting all its conditions with `&&` (lazy AND). The compiler flattens them into a single N-ary AND operation, exactly like an explicit `a && b && c` expression. This means body conditions participate fully in cost-stratified short-circuit evaluation: all conditions are sorted by cost stratum, and if any condition in a cheaper stratum evaluates to `false`, conditions in more expensive strata are never evaluated and their subscriptions are never created. On the streaming stratum, body conditions use the lazy (resource-optimized) subscription strategy. To use eager (latency-optimized) evaluation, combine conditions explicitly using `&` within a single expression. Combined with the [recommended condition ordering](../2_8_FunctionsAndAttributes/#structuring-policy-conditions) (fast local checks first, PIP lookups later), this ensures that expensive external calls are avoided whenever possible. ## Authorization Subscriptions A SAPL authorization subscription is a JSON object, i.e., a set of name/value pairs or *attributes*. An authorization subscription consists of these **required fields**: - **subject**: Who is making the request (user, system, service) - **action**: What operation is being attempted (read, write, delete, etc.) - **resource**: What is being accessed (document, record, API resource, etc.) And these **optional fields**: - **environment**: Additional contextual information (time, location, IP address, etc.) - **secrets**: Sensitive data (API keys, tokens, credentials) needed by Policy Information Points --- *Introduction: Sample Authorization Subscription* ```json { "subject": { "username": "alice", "role": "doctor", "department": "cardiology" }, "action": "read", "resource": { "type": "patient_record", "patientId": 123, "department": "cardiology" }, "environment": { "timestamp": "2025-10-06T14:30:00Z" } } ``` This authorization subscription expresses the intent of Dr. Alice, a doctor from the cardiology department, to read patient record #123, which belongs to the cardiology department. Notice how each field provides attributes that policies can check: `subject.role`, `subject.department`, `resource.type`, and `resource.department`. Also note that `patientId` is a number, not a string. Subscriptions can use any JSON value type. The PEP constructs this JSON object from the application context and sends it to the PDP, which evaluates it against all applicable policies to produce an authorization decision. ### The Secrets Field Policy Information Points (PIPs) often need credentials to access external data sources during policy evaluation. For example, a PIP may need an API key to query a patient database, or a token to call an external risk-scoring service. These credentials are sensitive and must be handled with care: - They must **never appear in policies**. Hardcoding credentials in policy text is a security risk and makes credential rotation impossible. - They must **never appear in logs or decision output**. SAPL automatically redacts secrets from all serialization and logging. - They must be **available to PIPs at evaluation time**. Without credentials, PIPs cannot fetch the attributes policies need. The `secrets` field solves this by providing a **secure side-channel** for passing credentials to PIPs without exposing them in policies, logs, or authorization decisions. SAPL supports two complementary channels for providing secrets: **Subscription-level secrets** are sent by the PEP as part of each authorization subscription. This is useful when credentials are specific to the current request context, such as an OAuth token that the calling user already possesses: ```json { "subject": "alice", "action": "read", "resource": "patient_record", "secrets": { "oauth_token": "eyJhbGciOiJSUzI1..." } } ``` **PDP-level secrets** are configured centrally in the [PDP configuration](../2_2_PDPConfiguration/#secrets). This is the right choice for infrastructure credentials shared across all evaluations, such as database connection strings or API keys for external services. PDP-level secrets are configured once and automatically available to all PIPs during evaluation. Both channels are available to PIPs via the `AttributeAccessContext`, and PIPs can use whichever is appropriate for their use case. ### Best Practice: Domain-Driven Authorization The authorization subscription above uses **business domain language**: `action: "read"` and `resource.type: "patient_record"`. This follows Domain-Driven Design principles: policies should speak your business's ubiquitous language, not implementation details like HTTP verbs or URLs. In practice, a PEP in a REST API would translate infrastructure operations into domain concepts before requesting authorization: ``` GET /api/patients/123 becomes {action: "read", resource: {type: "patient_record", patientId: 123}} ``` This keeps policies independent of technology choices. The same policies work whether you use REST, GraphQL, gRPC, or direct database access. #### Quick Start vs. Production While you can use **technical subscriptions** (`action: "HTTP:GET"`, `resource: "https://..."`) for rapid prototyping, **domain-driven subscriptions** are strongly recommended for production systems. Domain-driven subscriptions offer several advantages: - **Decoupled from infrastructure**: Technical subscriptions lead to technical policies. If your subscription uses `action: "HTTP:GET"`, your policies must check `action == "HTTP:GET"`. Change from REST to GraphQL? All policies break. Domain subscriptions decouple policies from infrastructure. - **Readable by non-technical stakeholders**: Domain policies like `action == "read"` can be reviewed by compliance officers and business analysts. Technical policies like `action =~ "^(GET|POST).*"` cannot. - **Testable as business rules**: Tests express business intent rather than technical details. Compare: "Can cardiologists read cardiology records?" vs. "Does GET /api/patients/123 with header X-Role:cardiologist return 200?" - **Technology-independent**: The same policies work across REST APIs, GraphQL endpoints, gRPC services, message queues, or direct database access. Migrate infrastructure without touching policies. For example, compare these two equivalent policies: ```sapl policy "allow GET on patient API" permit action =~ "^GET" & resource =~ "^https://medical\.org/api/patients/.*"; ``` ```sapl policy "allow reading patient records" permit action == "read" & resource.type == "patient_record"; ``` The domain-driven variant communicates intent to domain stakeholders such as compliance officers. Use technical subscriptions for rapid prototyping but migrate to domain-driven subscriptions before production deployment. #### Default Subscriptions Are PEP-Specific When you do not set the subscription fields explicitly, each PEP fills them from its framework context, and the resulting shape is **not the same across PEPs**, in structure and in field names alike. - Even within the Spring integration the two PEP styles differ. Method security (`@PreEnforce` / `@PostEnforce` / `@StreamEnforce`) nests the data as `action.http` / `action.java` and `resource.http` / `resource.java`; the HTTP filter PEP (`saplHttp()`) places the serialized request flat on both `action` and `resource` (so `action.method`, `resource.path`, `resource.host`). - Across PEPs the same concept lands under different keys. The HTTP verb is `action.method` in some integrations and `action.httpMethod` in others; the invoked method or handler name appears as `action.handler`, `action.view`, `action.endpoint`, or `action.java.name`; route parameters are `resource.params`, `resource.view_args`, or `resource.kwargs`. - The authenticated principal has no common shape, and roles in particular live in different places (`subject.roles`, `subject.authorities[].authority`, `subject.realm_access.roles`, or nowhere by default). A policy that checks roles against a default subject is therefore tied to one PEP. This is the strongest practical reason to prefer the explicit, domain-driven subscriptions described above. An explicit `subject` / `action` / `resource` that you control is portable across PEPs and stable across infrastructure changes, whereas a policy written against a PEP's default shape is specific to that PEP. For the exact default shape your integration emits, see its PEP documentation page. ## PDP Configuration The PDP evaluates policies against authorization subscriptions. Its runtime behavior is governed by a configuration which is loaded alongside policy documents. This configuration is independent of any specific deployment model and defines what the PDP needs regardless of how it is deployed. ### Combining Algorithm The combining algorithm determines how votes from multiple matching policies resolve into a single authorization decision. When no algorithm is configured, the PDP uses a default of `PRIORITY_DENY` with `DENY` default decision and `PROPAGATE` error handling. This default is deliberately restrictive: it denies by default and propagates errors so that misconfigurations fail visibly rather than silently granting access. For a detailed explanation of each algorithm and guidance on choosing one, see [Combining Algorithms](../2_5_CombiningAlgorithms/). ### Variables Variables are optional key-value pairs available to all policies during evaluation. They are constant-folded into the constants stratum at compilation time (see [Evaluation Semantics](../2_11_EvaluationSemantics/#the-three-strata)), making them as cheap to evaluate as literal values. Common uses include: - **Feature flags and tenant identifiers**: Values that policies need but that do not belong in the authorization subscription. - **Attribute finder option defaults** (`attributeFinderOptions`): Global defaults for stream behavior such as timeouts, retries, and polling intervals. See [The Attribute Broker](../2_8_FunctionsAndAttributes/#the-attribute-broker) for the full option reference. Variables are not to be confused with the `environment` field of the authorization subscription. The `environment` carries request-scoped context sent by the PEP, while variables are operator-configured constants shared across all evaluations. ### Secrets Secrets are optional PDP-level credentials available to PIPs during evaluation. They complement subscription-level secrets (see [Authorization Subscriptions](../2_1_AuthorizationSubscriptions/#secrets)) and are the right choice for infrastructure credentials shared across all evaluations, such as database connection strings or API keys for external services. PDP-level secrets are configured once and automatically available to all PIPs via the `AttributeAccessContext`. ### The `pdp.json` Format In SAPL Node and bundle-based deployments, the PDP configuration is stored as a `pdp.json` file alongside policy documents: ```json { "configurationId": "my-app-v1", "algorithm": { "votingMode": "PRIORITY_DENY", "defaultDecision": "DENY", "errorHandling": "ABSTAIN" }, "compilerFlags": { "indexing": "AUTO", "unrollInOperator": false, "minPoliciesForIndexing": 10, "maxIndexNodes": 500000 }, "variables": { "tenantId": "acme-corp", "featureFlags": { "experimentalPipEnabled": false }, "attributeFinderOptions": { "initialTimeOutMs": 5000, "retries": 3 } }, "secrets": { "externalApiKey": "sk-..." } } ``` The `algorithm` object is optional. When absent, the PDP uses the default combining algorithm (`PRIORITY_DENY`, `DENY`, `PROPAGATE`). The `variables` and `secrets` sections are also optional. The `compilerFlags` object is optional. All fields within it are optional and default to the values shown above: - `indexing` - policy index strategy: `AUTO` (heuristic selection), `NAIVE` (linear scan), `CANONICAL` (count-and-eliminate algorithm), or `SMTDD` (semantic multi-terminal BDD with equality grouping). Default: `AUTO`. In AUTO mode, the PDP selects NAIVE for small policy sets and attempts SMTDD for larger ones, falling back to CANONICAL if the diagram exceeds the node limit. - `unrollInOperator` - when `true`, transforms `EXPR in [a, b, c]` into equality chains for improved index matching. Default: `false`. - `minPoliciesForIndexing` - minimum policy count before `AUTO` mode considers advanced indexing. Below this threshold, NAIVE is used. Default: `10`. - `maxIndexNodes` - maximum number of diagram nodes for SMTDD index construction. If exceeded, AUTO falls back to CANONICAL. Default: `500000`. - `lowLatencyMode` - when `true`, the compiler emits eager operator variants that subscribe all children in parallel for the lowest end-to-end decision latency, at the cost of subscribing to children whose values may turn out to be unneeded. When `false`, operators emit lazy variants that short-circuit on the first incomplete or error child, minimizing the per-pass subscription set. The observable decision is identical in both modes. Default: `true`. - `maxPolicyDocuments` - maximum number of `*.sapl` documents loaded from a directory source. Loading stops once this cap is reached. Default: `10000`. The key `compilerOptions` is accepted as a synonym for `compilerFlags`; both name the same options object. The `configurationId` is a version identifier for the configuration. It appears in health endpoints and decision logs, enabling operators to correlate authorization decisions with the exact policy set that produced them. For bundles, this field is **required**. For directory and resource sources, it is optional and auto-generated from the source path and content hash when absent. For deployment details, see [SAPL Node](../7_0_SaplNode/). For the bundle structure that packages `pdp.json` with policy documents, see [Bundle Wire Protocol](../7_5_BundleWireProtocol/). ## Authorization Decisions The SAPL authorization decision in response to an authorization subscription is a JSON object. It contains the attribute `decision` as well as the optional attributes `resource`, `obligations`, and `advice`. For example, given an authorization subscription requesting read access to a patient record, a simple SAPL authorization decision would look as follows: *Introduction: Sample Authorization Decision* ```json { "decision": "PERMIT" } ``` ### Decision Values The `decision` attribute can have one of five values, each with specific meaning for the PEP: | Decision | Meaning | PEP Action | |----------|---------|------------| | `PERMIT` | A policy explicitly grants access. | Grant access. | | `DENY` | A policy explicitly prohibits the action. | Deny access. | | `SUSPEND` | A policy explicitly pauses access. The subscription remains alive; a later decision may resume it. | Streaming PEPs: suspend data forwarding, retain the subscription, resume on a later `PERMIT`. One-shot PEPs that cannot suspend treat `SUSPEND` as `DENY`. | | `NOT_APPLICABLE` | No policy matched the authorization subscription. | Deny access (fail-safe). | | `INDETERMINATE` | An error occurred during evaluation (network failures, unavailable PIPs, malformed policies). | Deny access (fail-safe). | **Only a `PERMIT` decision should result in granting access.** All other values must be treated as access denied. How a final decision arises from individual policy votes is the job of the [combining algorithm](../2_5_CombiningAlgorithms/). For how each policy maps its body evaluation to a vote (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, or `NOT_APPLICABLE`), see [Policy Structure](../2_4_PolicyStructure/#policy-evaluation-result). ### Why Five Decision Values? SAPL distinguishes these decision values rather than simply PERMIT/DENY because SAPL PEPs typically integrate with existing application frameworks that coordinate multiple authorization mechanisms, and because streaming use cases need a denial form that does not terminate the subscription. **NOT_APPLICABLE enables composability**: When no SAPL policy matches an authorization subscription, NOT_APPLICABLE allows the PEP to signal "I have no opinion on this request" rather than forcing a DENY. This enables SAPL to coexist with other authorization mechanisms (framework ACLs, role-based checks, etc.) rather than requiring SAPL policies for every access decision. Organizations can adopt SAPL incrementally, writing policies for complex scenarios while relying on existing authorization for simple cases. **INDETERMINATE distinguishes errors from policy decisions**: When policy evaluation fails due to technical issues (network failures, unavailable PIPs, malformed policies), INDETERMINATE signals a system failure rather than a policy denial. This is important for operational reasons: failures can trigger investigation or security monitoring, and technical failures may resolve on a retry while policy denials will not. **SUSPEND distinguishes pause from terminal denial**: A `SUSPEND` decision tells a streaming PEP to stop forwarding data without terminating the subscription. The PEP keeps the authorization stream alive, so when a later evaluation produces `PERMIT`, data forwarding resumes. This supports scenarios like maintenance windows, rate limits, or per-user temporary blocks. One-shot PEPs (those that resolve a single decision and exit, like `@PreEnforce`) cannot suspend. They treat `SUSPEND` as `DENY`. This decision model enables **compositional authorization** where SAPL integrates as one component in a larger authorization ecosystem. The distinction between explicit policy decisions (PERMIT/DENY/SUSPEND), absence of policy coverage (NOT_APPLICABLE), and technical failures (INDETERMINATE) allows frameworks and PEPs to handle each case appropriately. ### Optional Attributes The authorization decision may include additional attributes beyond `decision`: - **`resource`**: Contains a transformed or filtered version of the requested resource when the policy includes a `transform` statement. This allows policies to redact sensitive information or modify the resource before it is returned. - **`obligations`**: An array of tasks that the PEP **must** fulfill before acting on the decision. If the PEP cannot fulfill these obligations, access must not be granted on a `PERMIT` decision. On a `SUSPEND`, the PEP must apply the obligations (e.g., logging the suspension) before pausing. Examples include logging requirements or sending notifications. - **`advice`**: An array of tasks that the PEP **should** perform, but their fulfillment is not mandatory for granting access. These are optional recommendations from policies. > **Note:** An obligation in a `DENY` decision effectively acts like advice because the unsuccessful handling of the obligation cannot change the overall decision outcome, since access is already denied. The same applies to `SUSPEND` for one-shot PEPs that treat `SUSPEND` as `DENY`. For streaming PEPs that honour `SUSPEND` as a pause, the obligation is binding (the PEP must execute it before suspending). Here is an example of a decision with all optional attributes present. It corresponds to a policy that permits access but redacts the patient's SSN via a `transform` statement, requires audit logging via an `obligation`, and suggests notifying the data owner via `advice`: ```json { "decision": "PERMIT", "resource": { "type": "patient_record", "patientId": 123, "ssn": "XXX-XX-6789" }, "obligations": [ { "type": "logAccess", "level": "audit" } ], "advice": [ { "type": "notifyDataOwner" } ] } ``` The PEP receiving this decision must grant access (PERMIT), return the transformed `resource` (with the redacted SSN) instead of the original, execute the `logAccess` obligation (and deny access if it cannot), and optionally perform the `notifyDataOwner` advice. ## What is a Policy? A policy expresses a collection of authorization rules that implement an arbitrary access control model required by an application. The basic idea is that a policy states "if these conditions are met, then vote `permit`, `deny`, or `suspend` in regard to the current authorization process." If the conditions are not met, the policy abstains from voting. This is the core. Everything else is either a refinement of the basic idea or tools for dealing with the coordination of votes cast from multiple policies and how to resolve them deterministically to the correct overall decision. For example, one policy might say "you are a financial advisor, and therefore you are permitted to read financial reports of clients." But at the same time another policy might say "you worked with another client in the same sector, and therefore you may have a conflict of interest and may not read the report of this other client." By defining either the right combining algorithm on PDP level or by using a policy set, an organization can ensure that these conflicting policies are resolved deterministically to a single overall decision. This way an organization can implement an access control matching the actual application domain. Further, policies can define constraints on access, i.e., obligations and advice, that define additional requirements for the application through which users access resources. For example, redacting partial information, adjusting queries on the fly, or triggering side effects and processes like audits. ## A Simple Policy Example Here is a simple example of a SAPL policy: ```sapl-demo policy "I am a minimal example" permit action == "read"; ``` Each policy starts with the keyword `policy` followed by a unique policy name (string). Then comes the **effect**, which is `permit`, `deny`, or `suspend`. This expresses that "if the policy conditions are met, cast a vote with this effect." The effect is then followed by a number of statements that define the policy conditions, each terminated by a semicolon. Each condition must be an expression that evaluates to `true` or `false`. The policy is considered *applicable* if all conditions evaluate to `true`. Then, and only then, the policy casts a vote with its effect. > **Note:** If an error occurs during policy evaluation, the policy will cast an `indeterminate` vote. The combining algorithm of the PDP will decide how to map this to a final decision. > **Note:** If the policy has no conditions, it is always considered applicable and will always cast a vote with the indicated effect. ## SAPL Documents Policies and policy sets are organized into documents. A document is managed as a text file with the `.sapl` extension. Each SAPL document contains exactly **one** policy or [policy set](../2_6_PolicySets/). A document cannot contain both, and it cannot contain more than one of either. Optionally, the policy or policy set can be preceded by: - **[Import statements](../2_9_Imports/)** for referencing functions and attribute finders from libraries - **[Schema statements](../2_10_Schemas/)** for validating authorization subscription elements Imports and schemas must appear before the policy or policy set. The PDP loads all `.sapl` files from its configured policy source and evaluates the documents together using the configured [combining algorithm](../2_5_CombiningAlgorithms/). ## Policy Syntax Every policy begins with the keyword `policy` followed by a unique name (a string literal). After the name, a policy consists of: - An **effect**: `permit`, `deny`, or `suspend` - An optional **body**: semicolon-separated conditions and value definitions - Optional **obligation** blocks (requirements the PEP **must** fulfill) - Optional **advice** blocks (recommendations the PEP **should** consider) - An optional **transform** expression (resource transformation) ```sapl-demo policy "permit reading patient records for doctors" permit // effect resource.type == "patient_record"; // condition 1 action == "read"; // condition 2 var dept = subject.department; // value definition resource.department == dept; // condition 3 obligation { "type": "logAccess", "level": "info" } advice { "type": "notifyDataOwner" } transform resource |- filter.blacken ``` ### Name The policy name is a string that uniquely identifies the policy. In systems with many policies and policy sets, a naming schema is recommended, for example `"policy:patientdata:permit-doctors-read"`. ### Effect The effect is `permit`, `deny`, or `suspend`. It determines the vote the policy casts when it is applicable, i.e., when all conditions in the body are satisfied. - `permit` casts a vote to grant access. - `deny` casts a vote to terminally deny access. - `suspend` casts a vote to pause access without terminating the subscription. Streaming PEPs that support suspension stop forwarding data while keeping the subscription alive, so a later `permit` resumes the flow. One-shot PEPs that cannot suspend treat `suspend` as `deny`. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for the PEP-side behaviour. {: .note } > Since multiple policies can be applicable and the combining algorithm can be chosen, it might make a difference whether there is an explicit `deny` policy or whether there is just no permitting policy for a certain situation. The same applies to `suspend`. ```sapl-demo policy "allow doctors to read patient records" permit subject.role == "doctor"; action == "read"; resource.type == "patient_record"; ``` ```sapl-demo policy "deny access outside business hours" deny resource.type == "patient_record"; action == "read"; !; ``` ```sapl-demo policy "suspend during maintenance window" suspend resource.type == "patient_record"; ; ``` The `` and `` syntaxes access streaming attributes (covered in [Functions and Attribute Finders](../2_8_FunctionsAndAttributes/)). ### Body The body is optional and contains **statements** separated by semicolons. A statement is either: - A **condition**: an expression that must evaluate to `true` or `false` - A **value definition**: `var name = expression` All conditions must evaluate to `true` for the policy to apply. If the body is missing or contains no conditions, the policy is applicable to any authorization subscription. The values from the authorization subscription are bound to `subject`, `action`, `resource`, and `environment`. ```sapl-demo policy "compartmentalize read access by department" permit resource.type == "patient_record"; action == "read"; var userDept = subject.department; var resourceDept = resource.department; subject.role == "doctor"; userDept == resourceDept; ``` A variable assignment starts with the keyword `var`, followed by an identifier, `=`, and an expression. The result can then be used in later statements within the same policy. This avoids redundant calculations or repeated calls to external attribute sources, and improves readability. Variable assignments always evaluate to `true`. A variable assignment can optionally include the keyword `schema` followed by one or more JSON schema expressions separated by `,`. These schemas are used by policy editors for code completion and do not affect evaluation. {: .info } > Policy sets use a separate `for` target expression to control applicability. Individual policies express all conditions in the body. See [Policy Sets](../2_6_PolicySets/) for details. #### Policy Evaluation Result Evaluating a policy against an authorization subscription means assigning a decision value. The body conditions are evaluated as a conjunction (all must be true): | **Body Conditions** | **Policy Value** | |:---------------------------|:----------------------------------------------| | All evaluate to `true` | Policy's **Effect** (`PERMIT`, `DENY`, or `SUSPEND`) | | Any evaluates to `false` | `NOT_APPLICABLE` | | Any produces an error | `INDETERMINATE` | | No body present | Policy's **Effect** (`PERMIT`, `DENY`, or `SUSPEND`) | Conditions are evaluated lazily: if an earlier condition evaluates to `false`, later conditions are not evaluated and cannot produce errors. For details on how the engine optimizes evaluation order across cost strata, see [Evaluation Semantics](../2_11_EvaluationSemantics/). **Automatic Optimization:** The SAPL compiler analyzes the body and identifies statements that do not use attribute finders (`<>` operator). These statements are automatically used for fast policy indexing, allowing the PDP to efficiently select relevant policies from large policy stores without evaluating external attributes. ### Obligations An obligation expression consists of the keyword `obligation` followed by an expression. It describes a task the PEP **must** fulfill before acting on the decision. If the PEP cannot fulfill an obligation, it must not grant access even on a `PERMIT` decision. Similarly, a streaming PEP must not resume on a `PERMIT` whose obligations cannot be fulfilled, and must apply the obligations associated with a `SUSPEND` (e.g., logging the suspension) before pausing. A common use case is *break-the-glass* scenarios: in an emergency, a doctor may access records they normally cannot read, but this access must be logged to prevent abuse. Logging is a requirement for granting access and therefore must be expressed as an obligation. The PDP collects all obligations from applicable policies. Depending on the final decision, the obligations belonging to that decision are included in the authorization decision object. An obligation can be any JSON value: a string (like `"create_emergency_access_log"`), an object (like `{ "task": "create_log", "content": "emergency_access" }`), or any other type. The PEP must be implemented to process these obligations. A policy can contain multiple obligation expressions. All obligations must appear before any advice. ### Advice An advice expression is similar to an obligation but fulfilling the described task is not mandatory. The advice expression consists of the keyword `advice` followed by an expression. If the final decision is `PERMIT`, `DENY`, or `SUSPEND`, advice from all applicable policies evaluating to that decision is included in the authorization decision object. A policy can contain multiple advice expressions. All advice must appear after any obligations. ### Transformation A transformation statement starts with the keyword `transform` followed by an expression. If a policy with a transformation evaluates to its effect (`PERMIT`, `DENY`, or `SUSPEND`), the result of the expression is returned as the `resource` in the authorization decision. Transformations enable **field-level access control**: instead of writing a separate policy for each attribute of a resource, a single policy can redact or filter sensitive fields. The original resource is accessible via the identifier `resource`: ```sapl transform resource |- { @.someValue : remove, @.anotherValue : filter.blacken } ``` This removes the attribute `someValue` and blackens the value of `anotherValue`. It is not possible to combine transformation results from multiple policies. If more than one applicable policy evaluates to the same effect (`PERMIT`, `DENY`, or `SUSPEND`) and more than one contains a transformation, the combining algorithm cannot return that decision (*transformation uncertainty*). See [Combining Algorithms](../2_5_CombiningAlgorithms/) for details. For organizing multiple policies with shared combining algorithms and target expressions, see [Policy Sets](../2_6_PolicySets/). ## Combining Algorithms When evaluating an authorization subscription, multiple policies may vote differently. A combining algorithm defines how individual votes are combined into a single result. Combining algorithms apply at two levels: - **Policy set level**: A policy set contains multiple policies. The combining algorithm specified in the policy set determines the policy set's vote. - **PDP level**: The PDP evaluates multiple top-level policy documents (policies and policy sets). The PDP-level combining algorithm determines the authorization decision returned to the PEP. The PDP-level combining algorithm is part of the [PDP configuration](../2_2_PDPConfiguration/#combining-algorithm). ### Algorithm Notation A combining algorithm declaration in SAPL looks like this: ``` priority deny or permit ``` This reads naturally: "priority to deny, or permit by default." The notation separates three orthogonal concerns: ``` or [errors ] ``` **Voting style** determines how competing votes resolve: | Style | Resolution | |--------------------|-------------------------------------------------------------------------------------------------| | `priority deny` | Any deny wins over any number of permits or suspends | | `priority permit` | Any permit wins over any number of denies or suspends | | `priority suspend` | Any suspend wins over any number of permits or denies | | `first` | Policies are evaluated in order; the first non-abstain vote wins | | `unanimous` | All applicable policies must agree on effect; constraints are merged | | `unanimous strict` | All applicable policies must return equal decisions including obligations, advice, and resource | | `unique` | Exactly one policy must match; multiple matches are a configuration error | **Default** determines the result when no policy votes: | Default | No-vote result | |-----------|------------------| | `deny` | `DENY` | | `permit` | `PERMIT` | | `suspend` | `SUSPEND` | | `abstain` | `NOT_APPLICABLE` | **Error handling** determines how the final `INDETERMINATE` result of the combining process is presented. The clause is optional. When omitted, `errors abstain` applies. | Handling | Effect on final result | |--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `errors abstain` | If the combining algorithm's accumulated result is `INDETERMINATE`, it is converted to `NOT_APPLICABLE` at the end. The configured default decision then applies as for a no-vote case. | | `errors propagate` | If the combining algorithm's accumulated result is `INDETERMINATE`, it is returned as-is. | {: .info } > The clause governs the **final** disposition of `INDETERMINATE`. It does not filter erroring policies out of the combining process. Erroring policies still participate as `INDETERMINATE` votes inside the algorithm, where they may block a priority decision (see [Extended Indeterminate](#extended-indeterminate-and-criticality)). Reading this clause as "errors are invisible" or "treated as if did not vote" is a misread of the algorithm. These three concerns interact to determine the possible result space: | Default | Error handling | Possible results | |-------------------------------|--------------------|----------------------------------------------------------------| | `deny`, `permit`, or `suspend`| `errors abstain` | `PERMIT`, `DENY`, `SUSPEND` | | `abstain` | `errors abstain` | `PERMIT`, `DENY`, `SUSPEND`, `NOT_APPLICABLE` | | `deny`, `permit`, or `suspend`| `errors propagate` | `PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE` | | `abstain` | `errors propagate` | `PERMIT`, `DENY`, `SUSPEND`, `NOT_APPLICABLE`, `INDETERMINATE` | If you choose a non-abstain default and omit the error handling clause, the PEP sees concrete decisions only (`PERMIT`, `DENY`, or `SUSPEND`). Errors and missing policies are absorbed into the default. Add `errors propagate` when the PEP must distinguish errors from normal denials. ### Extended Indeterminate and Criticality Every vote carries an `Outcome` field that records which effects the vote represents. For a concrete vote (`PERMIT`, `DENY`, `SUSPEND`), the outcome is just the vote's own decision. For an `INDETERMINATE` vote, the outcome records which decisions the policy *could have produced* had it not errored. This is the **extended indeterminate marker** from XACML 3.0. Priority-based combining algorithms use this marker to decide whether an error blocks an otherwise-winning concrete decision. An error is **critical** if its outcome includes the priority decision: the policy that errored could have voted the priority, and the algorithm cannot safely return any non-priority decision while that uncertainty exists. For example, under `priority deny`: - Concrete `PERMIT` + `INDETERMINATE` whose outcome includes `DENY` → result is `INDETERMINATE` (the error could have been the priority deny that should win). - Concrete `PERMIT` + `INDETERMINATE` whose outcome is `PERMIT` only → the error could not have produced a deny, so the concrete `PERMIT` survives. Under `errors abstain` this `INDETERMINATE` result is converted to `NOT_APPLICABLE` at the end, then the default decision applies. Under `errors propagate` the `INDETERMINATE` reaches the PEP directly. ### Trace and short-circuit notes - **`contributingVotes`** in a result captures the votes the algorithm actually observed during folding. Algorithms may short-circuit (stop folding additional votes once the result is determined). A short-circuited vote does not appear in `contributingVotes`. This is intentional: the trace records what the algorithm did, not what it might have done. - **Errors** propagated into an `INDETERMINATE` result preserve the first-observed error. Subsequent errors observed before short-circuit are not retained. - **Short-circuit applies to** `priority` (on critical `INDETERMINATE`), `unanimous` (once disagreement becomes ambiguous and irrecoverable), and `unique` (on any `INDETERMINATE`, since uniqueness is broken). It does not apply to `first` (which evaluates in declaration order until a non-`NOT_APPLICABLE` vote is found). ### Combining Decisions with Constraints A SAPL decision is more than just `PERMIT` or `DENY`. Policies may attach constraints: - **Obligations**: Actions the PEP must perform (for example, log access, notify owner). - **Advice**: Recommendations the PEP should follow (for example, display warning). - **Resource transformation**: A replacement for the original resource, which may have information redacted. When the combining algorithm produces a `PERMIT` or `DENY`, it collects **obligations and advice** from all policies that voted for that result. The PEP receives the union of all collected constraints. Collection works at both levels: - **Policy set level**: Obligations and advice from all contained policies voting for the winning effect are bundled as the policy set's constraints. For the `first` voting style, only evaluated policies contribute. - **PDP level**: Obligations and advice from all top-level documents voting for the final decision are collected. **Resource transformations** cannot be merged. If multiple policies vote `PERMIT` and more than one includes a transformation, the algorithm faces *transformation uncertainty*. Since there is no way to combine two different transformed resources, the algorithm cannot return `PERMIT`. How this is handled depends on the error handling setting: | Error handling | Transformation uncertainty result | |--------------------|-----------------------------------| | `errors abstain` | `DENY` | | `errors propagate` | `INDETERMINATE` | Algorithms where only one policy can contribute a permit vote (`unique`, `first`) cannot encounter transformation uncertainty. ### Choosing an Algorithm The safest starting point is: ``` priority deny or deny ``` This denies access unless a policy explicitly permits, deny votes cannot be overridden, and missing policies result in denial. For PDP-level configuration, this is the recommended default. Deviations should be justified by specific application requirements. {: .info } > When no PDP-level combining algorithm is configured, the default is `priority deny or deny errors propagate`. This denies by default and propagates errors so that misconfigurations fail visibly. See [PDP Configuration](../2_2_PDPConfiguration/) for details. For other scenarios: | Scenario | Recommended algorithm | Rationale | |--------------------------------------------|--------------------------------------|------------------------------------------------------------| | Fail-closed default | `priority deny or deny` | Deny wins, missing policies denied | | One permit is sufficient | `priority permit or deny` | A single permit overrides all denies | | Maintenance window or temporary block | `priority suspend or deny` | Any policy that votes `suspend` overrides permits and denies; useful for explicit pause/maintenance scenarios | | All stakeholders must agree | `unanimous or deny` | Disagreement results in deny | | Business-priority ordering | `first or deny` | Declaration order determines priority (policy set only) | | Exactly one policy per request | `unique or abstain errors propagate` | Detects ambiguous configurations | | Errors must be visible to PEP | Add `errors propagate` | `INDETERMINATE` signals errors instead of hiding them | ### Voting Styles #### `priority deny` Any `DENY` vote wins over any number of `PERMIT` or `SUSPEND` votes. This is the conservative choice: a single deny is enough to block access. If no policy votes `DENY`, the result depends on what other concrete votes were cast: - All concrete votes agree (all `PERMIT`, or all `SUSPEND`) → that decision wins, with merged constraints. - Concrete votes disagree on the non-priority decisions (`PERMIT` and `SUSPEND` in different policies) → the per-priority chain `DENY > SUSPEND > PERMIT` decides: `SUSPEND` wins. Only the winner's constraints survive. The loser's vote remains in `contributingVotes` but its obligations/advice are dropped. - No concrete vote → default applies. **With `errors propagate`:** An error whose outcome marker includes `DENY` is critical and blocks any non-`DENY` concrete result. The algorithm returns `INDETERMINATE`. An error whose outcome cannot include `DENY` does not block. See [Extended Indeterminate](#extended-indeterminate-and-criticality). #### `priority permit` The mirror of `priority deny`. Any `PERMIT` vote wins over any number of `DENY` or `SUSPEND` votes. If no policy votes `PERMIT`, the algorithm resolves the remaining concretes by chain `PERMIT > SUSPEND > DENY`. With `DENY` and `SUSPEND` both voted, `SUSPEND` wins (closer to the permit intent: the door stays open and may resume). Transformation uncertainty blocks a `PERMIT`: if multiple permits have conflicting transformations, the permit cannot be returned. **With `errors propagate`:** An error whose outcome marker includes `PERMIT` is critical and blocks the algorithm from returning a non-`PERMIT` concrete result. #### `priority suspend` Any `SUSPEND` vote wins over any number of `PERMIT` or `DENY` votes. Use this when an explicit suspension policy must override otherwise-applicable permits and denies, for example a maintenance-window policy that pauses all access. If no policy votes `SUSPEND`, the chain `SUSPEND > DENY > PERMIT` decides among the remaining concretes: `DENY` wins over `PERMIT`. Both `SUSPEND` and `DENY` are denial-flavoured outcomes. Under suspend-priority, denial outranks permission. **With `errors propagate`:** An error whose outcome marker includes `SUSPEND` is critical. #### `unanimous` All applicable policies must agree on effect. If every applicable policy votes the same concrete decision (`PERMIT`, `DENY`, or `SUSPEND`), the result is that decision with merged constraints. If policies disagree, the disagreement is treated according to the error handling setting, as abstain (falling through to the default) or as `INDETERMINATE`. Transformation uncertainty applies: if all policies vote `PERMIT` but more than one includes a transformation, the unanimous permit cannot be returned. The same applies for `SUSPEND` with conflicting transformations. **`unanimous strict`** is a stricter variant. Instead of requiring agreement on effect and merging constraints, it requires all applicable policies to return *equal* decisions: same effect, same obligations, same advice, same resource transformation. No constraint merging occurs. If decisions differ in any way, it is treated as disagreement. #### `unique` Exactly one policy must have a matching target expression. If no policy matches, the default applies. If more than one policy matches, this is a configuration error. With `errors abstain`, the result is the default. With `errors propagate`, the result is `INDETERMINATE`. When exactly one policy matches, the result is that policy's vote (`PERMIT`, `DENY`, `SUSPEND`, or `INDETERMINATE` if the policy itself errors). `SUSPEND` and `INDETERMINATE` both count as applicable for the uniqueness check. Transformation uncertainty cannot occur since only one policy contributes. #### `first` Policies are evaluated in declaration order. The first policy to vote `PERMIT`, `DENY`, or `SUSPEND` determines the result. Policies that vote `NOT_APPLICABLE` are skipped. With `errors abstain`, an `INDETERMINATE` vote does NOT skip the policy: it is the chosen vote, and the set-level `errors abstain` then converts the INDETERMINATE to NOT_APPLICABLE at the end. With `errors propagate`, an `INDETERMINATE` vote is the chosen vote and propagates as-is. If no policy produces a non-`NOT_APPLICABLE` vote, the default applies. **Not available at PDP level.** The `first` voting style requires a defined evaluation order. Within a policy set, declaration order establishes this sequence. At the PDP level, the order of policy documents is undefined. ### Appendix: Migration from SAPL 3.x {: .info } > This section is for teams upgrading from SAPL 3.x. New users can skip it. SAPL 3.x used algorithm names inspired by XACML (for example, `deny-overrides`, `permit-unless-deny`). SAPL 4.0 replaces these with the composable notation described above: | SAPL 3.x | SAPL 4.0 | |-----------------------|-----------------------------------------------| | `deny-overrides` | `priority deny or abstain errors propagate` | | `permit-overrides` | `priority permit or abstain errors propagate` | | `permit-unless-deny` | `priority deny or permit` | | `deny-unless-permit` | `priority permit or deny` | | `first-applicable` | `first or abstain errors propagate` | | `only-one-applicable` | `unique or abstain errors propagate` | `priority suspend` and the `unanimous` / `unanimous strict` voting styles have no SAPL 3.x equivalent. #### Why the names were replaced The old names created an unnecessary cognitive load in two ways. First, the leading word changed meaning between naming patterns. In `X-overrides`, the leading word is the **priority** (what wins). In `X-unless-Y`, the leading word is the **default** (what happens when nothing votes). This means `deny-overrides` and `deny-unless-permit` both start with "deny" but have opposite priorities. To find all algorithms where deny wins, you need `deny-overrides` (obvious) and `permit-unless-deny` (counterintuitive, since it starts with "permit"). Second, each name hides one or two of the three orthogonal concerns. `X-overrides` hides the default (`NOT_APPLICABLE`) and error behavior (`propagate`). `X-unless-Y` hides the error behavior (`abstain`) and disguises the priority as the subordinate clause. The composable notation eliminates both problems. `priority deny or permit` reads naturally: "priority to deny, or permit by default." `priority deny or abstain errors propagate` makes clear that errors are not swallowed. Beyond clarity, the composable notation gives policy authors more fine-grained control. The old fixed set of six algorithms left gaps. For example, there was no way to express "priority deny, but return NOT_APPLICABLE when no policy votes" or "unanimous agreement required." SAPL 4.0 closes these gaps with the `unanimous` and `unanimous strict` voting styles and by making all permutations of voting style, default, and error handling available. ## SAPL Policy Set While a policy can either be a top-level SAPL document or be contained in a policy set, policy sets are always top-level documents. For evaluating an authorization subscription, the PDP evaluates existing policy sets. Policy sets are evaluated against an authorization subscription by checking their target expression, if applicable, evaluating their policies, and combining multiple votes according to a combining algorithm specified in the policy set. Finally, similarly to policies, policy sets vote `PERMIT`, `DENY`, `SUSPEND`, `NOT_APPLICABLE`, or `INDETERMINATE`. Policy sets are used to structure multiple policies and provide an order for the policies they contain. Hence, their policies can be evaluated one after another. A policy set definition starts with the keyword `set`. ### Name The keyword `set` is followed by the policy set name. The name is a string *identifying* the policy set. It must be unique within all policy sets and policies. ### Combining Algorithm The name is followed by a [combining algorithm](../2_5_CombiningAlgorithms/) that specifies how the policy set resolves its policies' votes. For example: ``` set "example-policies" priority deny or deny ``` ### Target Expression After the combining algorithm, an **optional** target expression can be specified. The target expression is a condition for applying the policy set. It starts with the keyword `for` followed by an expression that must evaluate to either `true` or `false`. If the condition evaluates to `true` for a certain authorization subscription, the policy set *matches* this subscription. In case the target expression is missing, the policy set matches any authorization subscription. The policy sets' target expression is used to select matching policy sets from a large collection of policy documents before evaluating them. As this needs to be done efficiently, there are no [attribute finder steps](../2_8_FunctionsAndAttributes/#attribute-finders-and-policy-information-points) allowed at this place. ### Variable Assignments The target expression can be followed by any number of variable assignments. Variable assignments are used to make a value available in all later policies under a certain name. An assignment starts with the keyword `var`, followed by an identifier under which the assigned value should be available, followed by `=` and an expression. Since variable assignments are only evaluated if the policy set's target matches, attribute finders may be used. In case a policy within the policy set assigns a variable already assigned in the policy set, the assignment in the policy overwrites the old. The overwritten value only exists within the particular policy. In other policies, the variable has the value defined in the policy set. ### Policies Each policy set must contain one or more policies. [See above](../2_4_PolicyStructure/#policy-syntax) how to describe a SAPL policy. If the combining algorithm uses the `first` voting style, the policies are evaluated in the order in which they appear in the policy set. In each policy, functions and attribute finders imported at the beginning of the SAPL document can be used under their shorter name. All variables assigned for the policy set (see [Variable Assignments](#variable-assignments) above) are available within the policies but can be overwritten by a variable assignment within a particular policy. ### Example: First Applicable Policy The `first or deny` algorithm evaluates policies in document order and uses the first applicable policy's vote. This is useful when policies have overlapping conditions and business rules dictate a priority. > **Note:** This is a fictional scenario to illustrate how policy order can encode business priority. ```sapl-demo set "facility access control" first or deny for resource.type == "facility" policy "VIP always allowed" permit subject.id in resource.vipList; policy "blacklisted users denied" deny subject.id in resource.blacklist; policy "standard access during business hours" permit ; ``` The policy order encodes business priority: "VIP status trumps blacklist status." | Scenario | Result | Reason | |----------------------------------------|----------|--------------------------------------| | VIP who is also blacklisted | `permit` | VIP policy checked first | | Blacklisted user during business hours | `deny` | Blacklist before hours check | | Normal user during business hours | `permit` | Hours policy applies | | Normal user outside business hours | `deny` | No policy applies, default is `deny` | If the blacklist policy came first, VIPs on the blacklist would be denied. The `first or deny` algorithm lets organizations express "check these exceptions first" patterns that cannot be achieved with priority-based algorithms where all permits or all denies are grouped together. ### Policy Set Evaluation Result Evaluating a policy set against an authorization subscription means assigning a decision value based on the target expression and the contained policies: | **Target Expression** | **Policy Values** | **Policy Set Value** | |:-----------------------|:------------------|:--------------------------------------------------------------| | `false` (not matching) | don't care | `NOT_APPLICABLE` | | `true` (matching) | care | Result of the **Combining Algorithm** applied to the Policies | | *Error* | don't care | `INDETERMINATE` | An error in the target expression always makes the whole set `INDETERMINATE`, regardless of the combining algorithm and its error handling. The combining algorithm's error handling governs errors raised *inside* the policies, not a failure of the set's own target. This separation is deliberate: the target is evaluated independently of, and ahead of, the policies, which is what allows a policy set to participate in the compile-time policy index. Routing a target error through the combining algorithm instead would couple target and body evaluation and remove that ability. For how combining algorithms resolve multiple votes into a single decision, see [Combining Algorithms](../2_5_CombiningAlgorithms/). ## SAPL Expressions To ensure flexibility, various parts of a policy can be **expressions** that are evaluated at runtime. For example, a policy's target must be an expression evaluating to `true` or `false`. SAPL contains a uniform expression language that offers various useful features while still being easy to read and write. Since JSON is the base data model, each expression evaluates to a JSON data type. This section covers the lexical building blocks, data types, and expression syntax. ### Identifiers Multiple elements in policies or policy sets require identifiers. For example, object keys in dot notation, function name components, and variable names all use identifiers. An identifier consists of alphanumeric characters, `_` and `$`, and must not start with a number. Valid Identifiers ``` a_long_name aLongName $name _name name123 ``` Invalid Identifiers ``` a#name 1name ``` #### Reserved Identifiers Certain SAPL keywords can also be used as identifiers in expression contexts such as dot notation key steps, object keys, and function name components. These reserved identifiers are: - The authorization subscription attributes: `subject`, `action`, `resource`, `environment` - The combining algorithm keywords: `abstain`, `errors`, `first`, `or`, `priority`, `propagate`, `strict`, `unanimous`, `unique` For example, `object.subject` uses `subject` as a key step, and `{priority: 5}` uses `priority` as an object key. #### Variable Names Variable names (after the `var` keyword) accept plain identifiers, the subscription attribute keywords (`subject`, `action`, `resource`, `environment`), and the combining algorithm keywords listed above, all without escaping. Hard keywords (such as `permit`, `deny`, `var`, `in`, `true`) must use the backtick escape to be used as variable names (e.g., `` var `permit` = ... ``). #### Backtick Escape A backtick-quoted identifier (e.g. `` `permit` ``) is treated as a plain identifier even when it spells a keyword. Use it for hard keywords that are not [reserved identifiers](#reserved-identifiers): `as`, `advice`, `deny`, `each`, `enforced`, `for`, `import`, `in`, `obligation`, `permit`, `policy`, `schema`, `set`, `transform`, `true`, `false`, `null`, `undefined`, `var`. The escape works for variable names, expression identifiers, and object keys (e.g. `` object.`in` `` or `` object[`in`] ``). Reserved identifiers never need escaping. The `^` character is exclusively the bitwise-XOR operator, so `a^b` parses as XOR with no whitespace required. ### Strings Whenever strings are expected, the SAPL document must contain any sequence of characters enclosed by double quotes `"`. Any quote character occurring in the string must be escaped by a preceding `\`, e.g., `"the name is \"John Doe\""`. ### Comments Comments are used to store information in a SAPL document which is only intended for human readers and has no meaning for the PDP. Comments are simply ignored when the PDP evaluates a document. SAPL supports single-line and multi-line comments. A single-line comment starts with `//` and ends at the end of the line, no matter which characters follow. Sample Single-Line Comment ``` policy "test" // a policy for testing ``` Multi-line comments start with `/*` and end with `*/`. Everything in between is ignored. Sample Multi-Line Comment ``` policy "test" /* A policy for testing. Remove before deployment! */ ``` ### JSON Data Types SAPL is based on the **JavaScript Object Notation** or **JSON**, an [ECMA Standard](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf) for the representation of structured data. Any value occurring within the SAPL language is a JSON data type, and any expression within a policy evaluates to a JSON data type. The types and their JSON notations are: - Primitive Types - **Number**: A signed decimal number, e.g., `-1.9`. There is no distinction between integer and floating-point numbers. In case an integer is expected (e.g., for a numeric index), the decimal number is rounded to an integer number. - **String**: A sequence of zero or more characters, written in double quotes, e.g., `"a string"`. - **Boolean**: Either `true` or `false`. - **null**: Marks an empty value, `null`. - Structured Types - **Object**: An unordered set of name/value pairs. The name is a string. The value must be one of the available data types. It can also be an object itself. The name/value pair is also called an attribute of the object. E.g. { "firstAttribute" : "first value", "secondAttribute" : 123 } - **Array**: An ordered sequence of zero or more values of any JSON data type. E.g. \[ "A value", 123, {"attribute" : "value"} \] ### Expression Types SAPL knows **basic expressions** and **operator expressions** (created from other expressions using operators). A **basic expression** is either a - **Value Expression**: a value explicitly defined in the corresponding JSON notation (e.g., `"a value"`) - **Identifier Expression**: any [identifier](#identifiers), typically the name of a variable or an authorization subscription attribute (`subject`, `resource`, `action`, or `environment`) - **Function Expression**: a function call (e.g., `simple.get_minimum(resource.array)`) - **Relative Expression**: `@` or `#`, which refer to context-dependent values (current element and its position) - **Grouped Expression**: any expression enclosed in parentheses, e.g., `(1 + 1)` Each of these basic expressions can contain one or more **selection steps** (e.g., `subject.name`, which is the identifier expression `subject` followed by the selection step `.name` selecting the value of the `name` attribute). Additionally, a basic expression can contain a **filter component** (`|- Filter`) which will be applied to the evaluation result. If the expression evaluates to an array, instead of applying a filter, each item can be transformed using a **subtemplate component** (`:: Subtemplate`). **Operator expressions** can be constructed using prefix or infix **operators** (e.g., `1 + subject.age` or `! subject.isBlocked`). SAPL supports infix and prefix operators. They may be applied in connection with any expression. An operator expression within parentheses (e.g., `(1 + subject.age)`) is a basic expression again and thus may contain selection steps, filter, or subtemplate statements. ### Value Expressions A basic value expression is the simplest type. The value is denoted in the corresponding JSON format. `true`, `false`, `null`, and `undefined` are value expressions as well as `"a string"` or any number (like `6` or `100.51`). For denoting objects, the keys can be strings or bare identifiers, and the values can be any expression, e.g. ```sapl { "id" : (3+5), "name" : functions.generate_name() } ``` For arrays, the items can be any expression, e.g. ```sapl [ (3+5), subject.name ] ``` ### Identifier Expressions A basic identifier expression consists of any [identifier](#identifiers), typically the name of a variable or an authorization subscription attribute (i.e., `subject`, `resource`, `action`, or `environment`). Any [reserved identifier](#reserved-identifiers) is also valid here. It evaluates to the variable’s or the subscription attribute’s value. ### Function Expressions A basic function expression consists of a function name and any number of arguments between parentheses which are separated by commas. The arguments must be expressions, e.g. ```sapl library.a_function(subject.name, (environment.day_of_week + 1)) ``` Each function is available under its fully qualified name. The fully qualified name starts with the library name, consisting of one or more identifiers separated by periods `.` (e.g., `sapl.functions.simple`). The library name is followed by a period `.` and an identifier for the function name (e.g., `sapl.functions.simple.append`). Which function libraries are available depends on the configuration of the PDP. [Imports](../2_9_Imports/) at the beginning of a SAPL document can be used to make functions available under shorter names. A basic import makes a function available under its simple name (e.g., `import sapl.functions.simple.append` makes `append` available). An aliased import provides an alternative name (e.g., `import sapl.functions.simple.append as add` makes it available as `add`). If there are no arguments passed to the function, empty parentheses have to be denoted (e.g., `random_number()`). When evaluating a function expression, the expressions representing the function call arguments are evaluated first. Afterward, the results are passed to the function as arguments. The expression evaluates to the function's return value. For the conceptual model of functions (purity, determinism, target safety) and how they differ from attribute finders, see [Functions and Attribute Finders](../2_8_FunctionsAndAttributes/). ### Relative Expressions SAPL provides two relative expressions: `@` (relative value) and `#` (relative location). These can be used in contexts characterized by an implicit loop, where they dynamically evaluate based on the current iteration: - **`@` (relative value)**: References the current element's value - **`#` (relative location)**: References the current element's position, i.e., an index (number) for arrays, or a key (string) for objects Assuming the variable `array` contains an array with multiple numbers, the expression `array[?(@ > 10)]` can be used to return any element greater than 10. In this context, `@` evaluates to the array item for which the condition is currently checked, and `#` evaluates to its index. The contexts in which `@` and `#` can be used are: - Expressions within a condition step (`@` evaluates to the array item or attribute value, `#` evaluates to the index or key) - Subtemplate (`@` evaluates to the current element, `#` evaluates to its index or key) - Arguments of a filter function if `each` is used (`@` evaluates to the array item to which the filter function is going to be applied) ## Operators SAPL provides a collection of arithmetic, comparison, logical, string and filtering operators, which can be used to build expressions from other expressions. ### Arithmetic Operators Assuming `exp1` and `exp2` are expressions evaluating to numbers, the following operators can be applied. All of them evaluate to number. - `+exp1` (unary plus, no-op) - `-exp1` (negation) - `exp1 * exp2` (multiplication) - `exp1 / exp2` (division) - `exp1 % exp2` (modulo) - `exp1 + exp2` (addition) - `exp1 - exp2` (subtraction) An expression can contain multiple arithmetic operators. The order in which they are evaluated can be specified using **parentheses**, e.g., `(1 + 2) * 3`. In case multiple operators are used without parentheses (e.g., `4 + 3 * 2`), the **operator precedence** determines how the expression is evaluated. Operators with higher precedence are evaluated first. The following precedence is assigned to arithmetic operators: - `+` (unary plus), `-` (negation): precedence **10** (highest) - `*` (multiplication), `/` (division), `%` (modulo): precedence **9** - `+` (addition), `-` (subtraction): precedence **8** As `*` has a higher precedence than `+`, `4 + 3 * 2` would be evaluated as `4 + (3 * 2)`. Except for the unary operators, multiple operators with the same precedence (e.g., `5 - 2 + 1`) are **left-associative**, i.e., `5 - 2 + 1` is evaluated like `(5 - 2) + 1`. Unary operators are non-associative, i.e., `--1` needs to be replaced by `-(-1)`. ### Comparison Operators 1. Number comparison Assuming `exp1` and `exp2` are expressions evaluating to numbers, the following operators can be applied. All of them evaluate to `true` or `false`. 1. `exp1 < exp2` (`true` if `exp2` is greater than `exp1`) 2. `exp1 > exp2` (`true` if `exp1` is greater than `exp2`) 3. `exp1 <= exp2` (`true` if `exp2` is equal to or greater than `exp1`) 4. `exp1 >= exp2` (`true` if `exp1` is equal to or greater than `exp2`) 2. Equals and Not Equals Assuming `exp1` and `exp2` are expressions, the equals and not-equals operators can be used to compare the results: `exp1 == exp2` evaluates to `true` if the results are equal. `exp1 != exp2` evaluates to `true` if the results are not equal. 3. Regular Expression Assuming `exp1` and `exp2` are expressions evaluating to strings, the regular expression match operator can be used: `exp1 =~ exp2` The expression evaluates to `true` if the result of evaluating `exp1` matches the pattern contained in the result of evaluating `exp2`. The pattern needs to be specified according to the [java.util.regex package](https://docs.oracle.com/javase/7/docs/api/java/util/regex/Pattern.html). 4. `in` (element of) Assuming `exp1` is an expression and `exp2` is an expression evaluating to an array, the `in` operator can be used: `exp1 in exp2` The expression evaluates to `true` if the array `exp2` evaluates to contains the result of evaluating `exp1`. Otherwise, the expression evaluates to `false`. 5. `any in` / `all in` (collection membership) The `in` operator can be prefixed with `any` or `all` to check multiple values at once. The left-hand side must be an array: `exp1 any in exp2` evaluates to `true` if at least one element of `exp1` is contained in `exp2`. Evaluates to `false` if `exp1` is empty. `exp1 all in exp2` evaluates to `true` if every element of `exp1` is contained in `exp2`. Evaluates to `true` if `exp1` is empty (vacuously true). ``` subject.roles any in resource.requiredRoles ["read", "write"] all in subject.permissions ``` 6. Precedence and Associativity Comparison operators (`<`, `>`, `<=`, `>=`, `in`, `any in`, `all in`) have precedence **7**. The `has` operator (see below) has precedence **6.5**. Equality operators (`==`, `!=`, `=~`) have precedence **6**. This is important for combining them with logical operators (see below). All comparison and equality operators are **non-associative**, i.e., an expression may not contain multiple comparison operators (like `3 < var < 5`). However, they can be combined with logical operators which have a different precedence (thus, the faulty example could be replaced by `3 < var && var < 5`). ### Key Membership Operator The `has` operator checks whether an object contains a key or keys. 1. `exp1 has exp2` evaluates to `true` if the object `exp1` contains the string key `exp2`. Non-object left-hand sides (arrays, strings, numbers, booleans, null, undefined) return `false`. The right-hand side must be a string. Non-string values produce an error. If either side is `undefined`, the result is `false`. 2. `exp1 has any exp2` evaluates to `true` if the object `exp1` contains at least one key from the string array `exp2`. Returns `false` for an empty array. 3. `exp1 has all exp2` evaluates to `true` if the object `exp1` contains all keys from the string array `exp2`. Returns `true` for an empty array (vacuously true). ``` // Single key check closureResult has "dept_0" // Any key from array user.permissions has any ["read", "write", "admin"] // All keys required config has all ["host", "port", "protocol"] ``` The `has` operator has precedence **6.5** (between comparison and equality). This means `obj has "key" == true` parses as `(obj has "key") == true`. ### Logical Operators Assuming `exp1` and `exp2` are expressions evaluating to `true` or `false`, the following operators can be applied. The new expression evaluates to `true` or `false`: - `!exp1` (negation), precedence **10** (highest) - `exp1 & exp2` (AND), precedence **5** - `exp1 ^ exp2` (XOR), precedence **4** - `exp1 | exp2` (OR), precedence **3** - `exp1 && exp2` (AND), precedence **2** - `exp1 || exp2` (OR), precedence **1** (lowest) SAPL provides two forms of AND (`&`, `&&`) and two forms of OR (`|`, `||`). Both forms produce identical authorization decisions. They differ in two ways: precedence and streaming stratum behavior. #### Precedence `&` and `|` bind tighter than `&&` and `||`. For example, `a && b | c` is parsed as `a && (b | c)`, not `(a && b) | c`. This allows policy authors to group conditions naturally without parentheses. For example: ```sapl (subject.role == "doctor" | subject.role == "nurse") && resource.type == "patient_record" ``` can be written without parentheses as: ```sapl subject.role == "doctor" | subject.role == "nurse" && resource.type == "patient_record" ``` because `|` binds tighter than `&&`. The XOR operator (`^`) always evaluates both operands since both are needed to determine the result. `&&`, `||`, `&`, `|`, and `^` are left-associative. `!` is non-associative, i.e., `!!true` must be replaced by `!(!true)`. #### Cost-Stratified Short-Circuit Evaluation All AND and OR operators (`&`, `&&`, `|`, `||`) use **cost-stratified short-circuit evaluation**. The engine categorizes operands into three cost strata (constants, pure expressions, streaming expressions) and evaluates cheaper strata first, regardless of operand position in the source. If a cheaper operand short-circuits the result, more expensive operands are never evaluated and their subscriptions are never created. This cross-strata short-circuit applies equally to all four operators. There is no way to override it, and no reason to: if a constant or pure expression already determines the result, subscribing to an attribute finder would be wasted work. ```sapl false & & ``` Neither PIP is ever contacted. The constant `false` short-circuits the AND across strata, regardless of whether `&` or `&&` is used. Only the dominating value short-circuits: `false` for AND, `true` for OR. An operand that is not `true` or `false`, an error, `undefined`, or any other non-boolean value, does not short-circuit. AND and OR follow Kleene strong three-valued logic, in which errors and undefined are treated alike as a third value, *unknown*. A dominating `false` (AND) or `true` (OR) in any operand wins regardless of position or stratum, so `(1/0 > 0) || true` is `true`, not an error. The result is an error only when no operand carries the dominating value. For the full evaluation rules, examples, and implications for attribute finder subscriptions, see [Evaluation Semantics](../2_11_EvaluationSemantics/). #### Lazy vs Eager: Subscription Strategy Within the Streaming Stratum The `&`/`|` vs `&&`/`||` choice only matters when multiple attribute finder subscriptions survive the cross-strata gate. When the engine has determined that it needs to subscribe to multiple streaming sources, the operator form controls how it manages those subscriptions: - `&&` / `||` (**lazy**, resource-optimized): Subscribes to attribute sources one at a time. If one source short-circuits the result, the next source is never subscribed to. This minimizes the number of concurrent PDP connections and network resources consumed. - `&` / `|` (**eager**, latency-optimized): Subscribes to all attribute sources in parallel immediately. The result is updated as soon as any source emits a new value. This trades higher resource consumption for lower latency. ```sapl subject.isActive & & ``` If `subject.isActive` is `false` at runtime, neither PIP is contacted (pure gates streaming, same as `&&`). If `subject.isActive` is `true`, both PIPs are subscribed in parallel. With `&&` instead, PIP A would be subscribed first, and PIP B only if A does not short-circuit. A policy using `&` produces the same authorization decisions as one using `&&` (given the same precedence grouping). The difference is purely in runtime behavior. Choosing between the two forms will never change whether access is granted or denied. **When to use which form:** Use `&&` / `||` when minimizing concurrent attribute subscriptions is important, for example when attribute finders connect to rate-limited external services. Use `&` / `|` when low decision latency matters more than connection count, for example when combining multiple fast attribute sources where waiting sequentially would add unnecessary delay. ### String Concatenation The operator `+` concatenates two strings, e.g., `"Hello" + " World!"` evaluates to `"Hello World!"`. String concatenation is applied if the left operand is an expression evaluating to a string. If the right expression evaluates to a string as well, the two strings are concatenated. Otherwise, an error is thrown. #### Selection Steps SAPL provides an easy way of accessing attributes of an object (or items of an array). The **basic access** mechanism has a similar syntax to programming languages like JavaScript or Java (e.g., `object.attribute`, `user.address.street` or `array[10]`). Beyond that, SAPL offers **extended possibilities** for expressing more sophisticated queries against JSON structures (e.g., `persons[?(@.age >= 50)]`). ### Overview The following table provides an overview of the different types of selection steps. Given that the following object is stored in the variable `object`: Structure of `object` ```json { "key" : "value1", "array1" : [ { "key" : "value2" }, { "key" : "value3" } ], "array2" : [ 1, 2, 3, 4, 5 ] } ``` | Expression | Returned Value | Explanation | |-------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------| | `object.key`
`object["key"]` | `"value1"` | **Key step** in dot notation and bracket notation | | `object.array1[0]` | `{ "key" : "value2" }` | **Index step** | | `object.array2[-1]` | `5` | **Index step** with negative value n returns the n-th last element | | `object.*`
`object[*]` | ["value1",
[
{ "key" : "value2" },
{ "key" : "value3" }
],
[ 1, 2, 3, 4, 5 ]
] | **Wildcard step** applied to an object, it returns an array with the value of each attribute. Applied to an array, it returns the array itself | | `object.array2[0:-2:2]` | `[ 1, 3 ]` | **Array slicing step** starting from first to second last element with a step size of two | | `object..key`
`object..["key"]` | `[ "value1", "value2", "value3" ]` | **Recursive descent step** looking for an attribute | | `object..[0]` | `[ { "key" : "value2" }, 1 ]` | **Recursive descent step** looking for an array index | | `object.array2[(3+1)]` | `5` | **Expression step** that evaluates to number (index); can also evaluate to an attribute name | | `object.array2[?(@>2)]` | `[ 3, 4, 5 ]` | **Condition step** that evaluates to true/false, `@` references the current item, `#` its index/key. Can also be applied to an object | | `object.array2[2,3]` | `[ 3 , 4 ]` | **Union step** for more than one array index | | `object["key","array2"]` | `[ "value1", [ 1, 2, 3, 4, 5 ] ]` | **Union step** for more than one attribute | *Table 1. Selection Steps Overview* ### Basic Access The basic access syntax is quite similar to accessing an object’s attributes in JavaScript or Java: - **Attributes of an object** can be accessed by their key (**key step**) using the *dot notation* (`resource.key`) or the *bracket notation* (`resource["key"]`). Both expressions return the value of the specified attribute. For using the dot notation, the specified key must be an [identifier](#identifiers). Otherwise, the bracket notation with a string between square brackets is necessary, e.g., if the key contains whitespace characters (`resource["another key"]`). - **Indices of an array** may be accessed by putting the index between square brackets (**index step**, `array[3]`). The index can be a negative number `-n`, which evaluates to the `n`\-th element from the end of the array, starting with -1 as the last element's index. `array[-2]` would return the second last element of the array `array`. **Handling missing data:** - **Missing keys:** When a key step accesses an attribute that doesn't exist in an object, the result is `undefined`. This allows policies to gracefully handle optional attributes using conditional logic. - **Out-of-bounds indices:** When an index step accesses an array index that doesn't exist (either beyond the array length or more negative than the array allows), an error is returned. Unlike missing object keys, out-of-bounds array access is treated as a programming error since array lengths are typically known or should be checked beforehand. Multiple selection steps can be **chained**. The steps are evaluated from left to right. Each step is applied to the result returned from the previous step. {: .info } **Example**

The expression `object.array\[2\]` first selects the attribute with key array from the object `object` (first step). Then it returns the third element (index 2) of that array (second step). | ### Extended Possibilities SAPL supports querying for specific parts of a JSON structure. Except for an **expression step**, all of these steps return an array since the number of elements found can vary. Even if only a single result is retrieved, the expression returns an array containing one item. #### Expression Step `[(Expression)]` An expression step returns the value of an attribute with a key or an array item with an index specified by an expression. `Expression` must evaluate to a string or a number. If `Expression` evaluates to a string, the selection can only be applied to an object. If `Expression` evaluates to a number, the selection can only be applied to an array. When an expression evaluates to a non-integer number (e.g., `3.7`), the value is truncated toward zero to produce an integer index (e.g., `3`). > The expression step can be used to refer to custom variables (`object.array[(anIndex+2)]`) or apply custom functions (`object.array[(max_value(object.array))]`. #### Wildcard Step `.*` or `[*]` A wildcard step can be applied to an object or an array. When applied to an object, it returns an array containing all attribute values. As attributes of an object have no order, the sorting of the result is not defined. When applied to an array, the step just leaves the array untouched. > Applied to an object >```sapl > { > "key1":"value1", > "key2":"value2" > } >``` > the selection step `.*` or `[*]` returns the following array: `["value1", "value2"]` (possibly with a different sorting of the items). Applied to an array `[1, 2, 3]`, the selection step `.*` **or** `[*]` returns the original array `[1, 2, 3]`. #### Recursive Descent Step `..key`, `..["key"]`, `..[1]`, `..*` or `..[*]` Looks for the specified key or array index in the current object or array and, recursively, in its children (i.e., the values of its attributes or its items). The recursive descent step can be applied to both an object and an array. It returns an array containing all attribute values or array items found. If the specified key is an asterisk (`..` **or** `[]`, wildcard), all attribute values and array items in the whole structure are returned. As attributes of an object are not sorted, the order of items in the result array may vary. {: .info } **Depth limit:** To prevent runaway recursion on deeply nested structures, recursive descent is limited to a maximum depth of 500 levels. If this limit is exceeded, an error is returned. > Applied to an `object` > >```sapl > { > "key" : "value1", > "anotherkey" : { > "key" : "value2" > } > } >``` > > The selection step `object..key` returns the following array: `["value1", "value2"]` (any attribute value with key `key`, the items may be in a different order). > > The wildcard selection step `object..` **or** `object..[]` returns `["value1", {"key":"value2"}, "value2"]` (recursively each attribute value and array item in the whole structure `object`, the sorting may be different). #### Condition `[?(Condition)]` Condition steps return an array containing all attribute values or array items for which `Condition` evaluates to `true`. It can be applied to both an object (then it checks each attribute value) and an array (then it checks each item). `Condition` must be an expression in which [relative expressions](#relative-expressions) `@` and `#` can be used. `@` evaluates to the current attribute value or array item, and `#` to its key or index. Both can be followed by further selection steps. As attributes have no order, the sorting of the result array of a condition step applied to an object is not specified. > Applied to the array `[1, 2, 3, 4, 5]`, the selection step `[?(@ > 2)]` returns the array `[3, 4, 5]` (containing all values that are greater than 2). #### Array Slicing `[Start:Stop:Step]` The slice contains the items with indices between `Start` and `Stop`, with `Start` being inclusive and `Stop` being exclusive. `Step` describes the distance between the elements to be included in the slice, i.e., with a `Step` of 2, only each second element would be included (with `Start` as the first element's index). All parts except the first colon are optional. `Step` defaults to 1. **Default values:** When `Step` is positive (or omitted), `Start` defaults to `0` and `Stop` defaults to the array length. When `Step` is negative, `Start` defaults to the last index and `Stop` defaults to before the first element, allowing the slice to traverse the array in reverse. A `Step` of `0` results in an error. **Negative indices:** Both `Start` and `Stop` support negative indices, which count from the end of the array. For example, `-1` refers to the last element, `-2` to the second-to-last, and so on. These indices are converted to their positive equivalents before slicing. **How slicing works:** The `Step` value determines both the direction and stride of iteration: - **Positive step:** Iteration proceeds forward from `Start` toward `Stop`, selecting every `Step`-th element - **Negative step:** Iteration proceeds backward from `Start` toward `Stop`, selecting every `|Step|`-th element If the direction of `Step` is inconsistent with the range (e.g., trying to go forward when `Start > Stop`, or backward when `Start < Stop`), the result is an empty array. {: .warning } **Changed in SAPL 4.0:** In SAPL 3.x, negative step values did not reverse iteration direction. Starting with SAPL 4.0, a negative step now iterates backward through the array. For example, `[::-1]` previously returned elements in their original order, but now returns the array reversed. Review any policies using negative step values when upgrading. **Examples:** > Applied to `[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`: > - `[1:4]` returns `[1, 2, 3]` (elements from index 1 up to, but not including, index 4) > - `[::3]` returns `[0, 3, 6, 9]` (every third element, starting from the beginning) > - `[::-1]` returns `[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]` (all elements in reverse order) > - `[::-3]` returns `[9, 6, 3, 0]` (every third element, in reverse) > - `[5:2:-1]` returns `[5, 4, 3]` (from index 5 down to, but not including, index 2) > - `[1:5:-1]` returns `[]` (empty, cannot go backward from 1 to 5) > - `[-3:]` returns `[7, 8, 9]` (the last three elements) > - `[:-3]` returns `[0, 1, 2, 3, 4, 5, 6]` (all but the last three elements) #### Index Union `[index1, index2, …​]` By using the bracket notation, a set of multiple array indices (numbers) can be denoted separated by commas. This returns an array containing the items of the original array if the item’s index is contained in the specified indices. Since a **set** of indices is specified, the indices' order is ignored, and duplicate elements are removed. The result array contains the specified elements in their original order. Indices that do not exist in the original array are ignored. > Both `[3, 2, 2]` and `[2, 3]` return the same result. #### Attribute Union `["attribute1", "attribute2", …​]` By using the bracket notation, a set of multiple attribute keys (strings) can be denoted separated by commas. This returns an array containing the values of the denoted attributes. Since a **set** of attribute keys is specified, the keys' order is ignored, and duplicate elements are removed. As attributes have no order, the sorting of the resulting array is not specified. Attributes that do not exist are ignored. #### Attribute Selection on Array Although arrays do not have attributes (they have items), a key step can be applied to an array (e.g., `array.value`). This will loop through each item of the array and look for the specified attribute in this item. An array containing all values of the attributes found is returned. In other words, the selection step is not applied to the result of the previous step (the array) but to each item of the result, and the (sub-)results are concatenated. In case an array item is no object or does not contain the specified attribute, it is skipped. > Applied to an object > >```sapl > { > "array":[ > {"key":"value1"}, > {"key":"value2"} > ] > } >``` > > `array.key` returns the following array: `["value1", "value2"]` (the value of the `key` attribute of each item of `array`). #### Attribute Finder `.` In SAPL, it is possible to receive attributes that are not contained in the authorization subscription. Those attributes can be provided by external PIPs and obtained through attribute finders. The standard attributes in SAPL are intended to gather more information in regard to a given JSON value, i.e., the subject, action, resource, environment objects in the subscription, or any other JSON value. A standard attribute finder is called via the selection step `.`. Where `finder.name` either is a fully qualified attribute finder name or can be a shorter name if imports are used (the finder name or the library alias followed by a period `.` and the finder name). Any number of selection steps can be appended after such a step. An attribute accessed this way is treated as a subscription. I.e., the PDP will subscribe to the data source, and whenever a new value is returned, the policy is reevaluated, and a new decision is calculated. The attribute finder receives the result of the previous selection as an argument and returns a JSON value. Optionally, an attribute finder may be supplied with a list of parameters: `.`. Additionally, an attribute finder may accept options in brackets: `.`. The options expression is evaluated and passed to the attribute finder alongside the regular parameters. Attribute finders may be nested: `subject..,…​)>`. Here, whenever the attributes with `name2` and `name3` all have an initial result, and whenever one of the results change, the attribute with name `name` is re-subscribed with the new input parameters. An environment attribute finder is an attribute finder intended for accessing information possibly independent of subscription data, e.g., current time or an organization-wide emergency level. These environment attributes are not to be confused with the data which is contained in the environment object in the subscription. The data contained there is environment data provided by the PEP from its application context at subscription time and may not be accessible from the PDP otherwise. Environment attributes do not require a left-hand input and can be accessed without a leading value, variable, or sequence of selection steps: `` may refer to a stream indicating an emergency level in an organization. Analogous to standard attributes, these attributes may be parameterized and nested. All attribute finders may be followed by arbitrary selection steps. In some scenarios, it may not be the right thing to subscribe to attributes, but to just retrieve the data once on subscription time. For this, SAPL offers the head operator for both standard and environment attributes. Prepending the pipe symbol `|` in front of an attribute finder step will only return the first value returned by the attribute finder. E.g.: `subject.id.|`. However, such an attribute may still return a stream if used with nested attributes which do not employ the head operator. > Assuming a doctor should only be allowed to access patient data from patients on her unit. The following expression retrieves the unit (attribute finder `pip.hospital_units.by_patientid`) by the requested patient id (`action.patientid`) and selects the id of the supervising doctor (`.doctorid`): > > action.patientid..doctorid For the conceptual model of attribute finders, streaming, and the attribute broker, see [Functions and Attribute Finders](../2_8_FunctionsAndAttributes/). ## Filtering SAPL provides syntax elements filtering values by applying **filters**, and that can potentially modify the value. Filters can only be applied to basic expressions (remember that an expression in parentheses is a basic expression). Filtering is denoted by the `|-` operator after the expression. Which **filter function** is applied in what way can be defined by a **simple filtering component** or by an **extended filtering component**, which consists of several filter statements. ### Filter Functions SAPL provides three **built-in filter functions**: remove Removes a whole attribute (key and value pair) of an object or an item of an array without leaving a replacement. filter.replace(replacement) Replaces an attribute or an element by the result of evaluating the expression `replacement`. filter.blacken(disclose\\\_left=0,disclose\\\_right=0,replacement="X") Replaces each char of an attribute or item (which must be a string) by `replacement`, leaving `show\_left` chars from the beginning and `show\_right` chars from the end unchanged. By default, no chars are visible, and each char is replaced by `X`. > `filter.blacken` could be used to reveal only the first digit of the credit card number and replace the other digits by `X`. > `filter.replace` and `filter.blacken` are part of the library `filter`. Importing this library through `import filter` makes the functions available under their simple names. Example: We take the following object: Object Structure ```sapl { "value" : "aValue", "id" : 5 } ``` If value is removed, the resulting object is ```{ "id" : 5 }```. If instead ```filter.replace``` is applied to value with the Expression null, the resulting object is ```{ "value" : null, "id" : 5 }```. If the function ```filter.blacken``` is applied to value without specifying any arguments, the result would be ```{ "value" : "XXXXXX", "id" : 5 }```. ### Simple Filtering A simple filter component applies a **filter function** to the preceding value. The syntax is: ``` BasicExpression |- Function ``` `BasicExpression` is evaluated to a value, the function is applied to this value, and the result is returned. If no other arguments are passed to the function, the empty parentheses `()` after the function name can be omitted. In case `BasicExpression` evaluates to an array, the whole array is passed to the filter function. The **keyword** `each` before `Function` can be used to apply the function to each array item instead: ``` Expression |- each Function ``` Example: Let us assume our resource contains an array of credit card numbers: ```sapl { "numbers": [ "1234123412341234", "2345234523452345", "3456345634563456" ] } ``` The function ```blacken(1)``` without any additional parameters takes a string and replaces everything by ```X``` except the first character. We can receive the blackened numbers through the basic expression ```resource.numbers |- each blacken(1)```: ```sapl [ "1XXXXXXXXXXXXXXX", "2XXXXXXXXXXXXXXX", "3XXXXXXXXXXXXXXX" ] ``` Without the keyword each, the function blacken would be applied to the array itself, resulting in an error, as stated above, blacken can only be applied to a String. ### Extended Filtering Extended filtering can be used to state more precisely how a value should be altered. E.g., the expression ```sapl resource |- { @.credit_card : blacken } ``` would return the original resource except for the value of the attribute `credit_card` being blackened. Extended filtering components consist of one or more **filter statements**. Each filter statement has a target expression and specifies a filter function that shall be applied to the attribute value (or to each of its items if the keyword `each` is used). The basic syntax is: ```sapl Expression |- { FilterStatement, FilterStatement, ... } ``` The syntax of a filter statement is: ```sapl each TargetRelativeExpression : Function ``` `each` is an optional keyword. If used, the `TargetRelativeExpression` must evaluate to an array. In this case, `Function` is applied to each item of that array. `TargetRelativeExpression` contains a basic relative expression starting with `@`. The character `@` references the result of the evaluation of `Expression`, so attributes of the value to filter can be accessed easily. Bear in mind that attribute finder steps are not allowed at this place. The value of the attribute selected by the target expression is replaced by the result of the filter function. The filter statements are applied successively from top to bottom. > Some filter functions can be applied to both arrays and other types (e.g., `remove`). Yet, there are selection steps resulting in a "helper array" that cannot be modified. If, for instance, `.*` is applied to the object `{"key1" : "value1", "key2" : "value2"}`, the result would be `["value1", "value2"]`. It is not possible to apply a filter function directly to this array because changing the array itself would not have any effect. The array has been constructed merely to hold multiple values for further processing. In this case, the policy would **have to** use the keyword `each` and apply the function to each item. The attempt to alter a helper array will result in an error. ### Custom Filter Functions Any function available in SAPL can be used in a filter statement. Hence, it is easy to add custom filter functions. When used in a filter statement, the value to filter is passed to the function as its first argument. Consequently, the arguments specified in the function call are passed as second, third, etc., arguments. > Assuming a filter function `roundto` should round a value to the closest multiple of a given number, e.g., `207 |- roundto(100)` should return `200`. In its definition, the function needs two formal parameters. The first parameter is reserved for the original value and the second one for the number to round to. ## Subtemplate A subtemplate applies a template expression to a value, enabling transformation of data structures. The subtemplate is denoted after a double colon using the `::` operator. A subtemplate component is an optional part of a basic expression. E.g., the basic expression: ``` resource.patients :: { "name" : @.name } ``` This expression would return the `patients` array from the resource but with each item containing only one attribute `name`. The subtemplate syntax is: ``` Value :: Expression ``` The `Expression` represents the replacement template. Within this expression, the relative expressions `@` and `#` can be used: - **`@` (relative value)**: References the current element being transformed - **`#` (relative location)**: References the current position, i.e., index for arrays, key for objects **Subtemplate behavior:** - **Arrays:** The template is mapped over each array element. For each item, `Expression` is evaluated with `@` referencing that item and `#` referencing its index (0, 1, 2, ...). The result is an array of the same length with transformed elements. Empty arrays remain empty. - **Objects:** The template is mapped over each object value. For each entry, `Expression` is evaluated with `@` referencing the value and `#` referencing the key (as a string). The result is an **array** containing the transformed values. Empty objects return an empty array. - **Scalar values:** The template is applied directly to the value, with `@` referencing that value and `#` set to `0`. The result is the evaluation of the template expression. - **Error and undefined values:** Error and undefined values are propagated without applying the template. **Example: Array transformation** Given the variable `array` contains the following array: ```json [ { "id" : 1 }, { "id" : 2 } ] ``` The basic expression ```sapl array :: { "aKey" : "aValue", "identifier" : @.id } ``` would evaluate to: ```json [ {"aKey" : "aValue", "identifier" : 1 }, {"aKey" : "aValue", "identifier" : 2 } ] ``` **Example: Using `#` for index access** The `#` symbol provides access to the current index (for arrays) or key (for objects): ```sapl [10, 20, 30] :: # ``` evaluates to `[0, 1, 2]` (the indices). ```sapl [10, 20, 30] :: (@ + #) ``` evaluates to `[10, 21, 32]` (each value plus its index). {: .warning } **Operator precedence:** The `::` operator binds tighter than arithmetic and comparison operators. This means `[1, 2, 3] :: @ * 2` parses as `([1, 2, 3] :: @) * 2`, not `[1, 2, 3] :: (@ * 2)`. Always use parentheses around complex template expressions: `[1, 2, 3] :: (@ * 2)`, `array :: (@ > 5)`, etc. **Example: Object transformation** When a subtemplate is applied to an object, it iterates over the object's values and returns an **array**: Given the variable `scores` contains: ```json { "alice" : 95, "bob" : 87, "carol" : 92 } ``` The expression `scores :: @` returns `[95, 87, 92]` (an array of values). The expression `scores :: #` returns `["alice", "bob", "carol"]` (an array of keys). The expression: ```sapl scores :: { "player" : #, "score" : @ } ``` returns an array of objects: ```json [ { "player" : "alice", "score" : 95 }, { "player" : "bob", "score" : 87 }, { "player" : "carol", "score" : 92 } ] ``` **Example: Scalar transformation** Given the variable `user` contains the following object: ```json { "id" : 123, "name" : "Alice", "email" : "alice@example.com" } ``` The basic expression ```sapl user :: { "userId" : @.id, "displayName" : @.name } ``` would evaluate to: ```json { "userId" : 123, "displayName" : "Alice" } ``` Note: In this case, the entire `user` object is treated as a scalar (not iterated), so `@` references the whole object and `#` equals `0`. ## Functions and Attribute Finders SAPL expressions can call **functions** and access **attributes**. Both extend what policies can express beyond simple JSON manipulation, but they serve fundamentally different purposes and have different runtime characteristics. Understanding this distinction is essential for writing correct and efficient policies. For the expression syntax of function calls and attribute finder steps, see [Expressions](../2_7_Expressions/). This section covers the conceptual model: what functions and attributes are, why SAPL has both, and how streaming attributes enable continuous authorization. ### Functions Functions are pure computations. Given the same input arguments, a function always returns the same output. They are: - **Synchronous**: They execute immediately and return a value. - **Deterministic**: Same inputs always produce the same output. - **Side-effect-free**: They do not access external resources, databases, or network services. - **Without access to environment variables**: Functions cannot read PDP-level configuration. Because of these properties, functions can be used in **any part** of a SAPL document, including target expressions. The engine can safely evaluate them at any time without concern for ordering, external availability, or subscription lifecycle. Functions are organized in **function libraries**. Each library has a name consisting of one or more identifiers separated by periods (e.g., `simple.string` or `filter`). The fully qualified name of a function consists of the library name followed by a period and the function name (e.g., `simple.string.append`). [Imports](../2_9_Imports/) can shorten these names. SAPL ships with a standard set of function libraries. See [Functions](../3_0_Functions/) for the complete reference of built-in function libraries. ### Attribute Finders and Policy Information Points When a PEP constructs an authorization subscription, it includes information available in the application's current context: the authenticated user (subject), the requested operation (action), the target resource, and contextual data (environment). This represents what the application **knows at that moment**. However, policies often require information that is not readily available to the PEP: - A user's department membership (stored in HR systems) - Security clearance levels (maintained in identity management) - Current work schedules (managed by scheduling systems) - Real-time conditions (current time, system status, resource availability) **Policy Information Points (PIPs)** bridge this knowledge gap by fetching attributes from external sources on demand. In SAPL, PIPs are accessed through **attribute finders**, a dedicated syntax that signals external I/O and potential streaming behavior. Consider a policy that needs to check a user's role stored in an external user directory: ```sapl policy "doctors read patient data" permit action == "read" & resource.type == "patient_record"; subject.username..function == "doctor"; ``` The expression `subject.username.` uses an attribute finder to fetch the user's profile from an external PIP. The result is a JSON object whose `function` attribute is compared to `"doctor"`. Unlike functions, attribute finders are: - **Asynchronous**: They may involve network calls, database queries, or other I/O operations. - **Non-deterministic**: The same attribute may return different values at different times (current time, a user's role after a promotion, a sensor reading). - **Subscription-based**: They return data streams. The PDP subscribes to them, and the PIP pushes new values whenever the underlying data changes. - **Not safe for target expressions**: Because they may be slow and involve external dependencies, attribute finders must not be used in target expressions. Attribute finders can access environment variables configured at the PDP level, unlike functions. This distinction is why attributes use a dedicated syntax (angle brackets `<...>`) rather than the function call syntax. The angle brackets signal to both the reader and the engine that this expression involves external I/O and may trigger ongoing subscriptions. ### Static vs. Streaming Attributes The authorization subscription provides a **snapshot** of what the PEP knows when making the request. Once sent, these attributes do not change unless the PEP creates a new subscription. This works well for stable data like usernames or resource identifiers, but many authorization decisions depend on **dynamic conditions** that change over time. Because the authorization protocol is based on the PDP pushing decisions to the PEP (not the PEP pushing updated attributes), **dynamic attributes must come from PIPs**. This is why time-based policies use `` rather than expecting the PEP to include timestamps: the PDP needs to access time continuously, not just at subscription creation. PIPs enable both: 1. **Bridging knowledge gaps**: Fetching data the PEP does not have 2. **Enabling streaming policies**: Providing dynamic attributes that update over time ### Streaming Attributes and Continuous Authorization Consider access control based on work shifts: ```sapl policy "read patient records during business hours" permit resource.type == "patient_record" & action == "read"; subject.role == "doctor"; resource.department == subject.department; ; ``` The `` attribute streams. When a doctor is granted access at 17:59, the PEP receives PERMIT and maintains the connection. At exactly 18:00:01, the PDP automatically pushes a new DENY decision, without any polling or manual refresh. The PEP can then terminate the session or deny further operations. This is **Attribute Stream-based Access Control (ASBAC)**: policies respond to changing conditions in real-time. #### Composing Streaming Attributes PIPs can be composed for more sophisticated scenarios: ```sapl policy "doctors read records during assigned shift" permit resource.type == "patient_record" & action == "read"; subject.role == "doctor"; resource.department == subject.department; var currentDay = time.dayOfWeek(); var todaysShift = subject.employeeId.; ; ``` This policy: 1. Gets the current day of the week from `` (a built-in streaming PIP) 2. Uses that to fetch the doctor's shift schedule for today from a custom `schedules` PIP 3. Checks if the current time is within the shift window All of these attributes stream. When the clock crosses a shift boundary, or when shift schedules are updated in the scheduling system, the PDP automatically sends new decisions to the PEP. > This example assumes a custom `schedules` PIP that provides shift information. See [Custom Attribute Finders](../8_3_CustomAttributeFinders/) for implementing custom PIPs. Traditional access control systems make one-time decisions. SAPL maintains continuous authorization that adapts to changing conditions (time passing, data updates, or policy changes), all without the PEP needing to re-request decisions. ### Built-in and Custom PIPs SAPL includes a library of built-in PIPs for common authorization needs: time and date operations, HTTP access, JWT token handling, and more. See [Attribute Finders](../4_0_AttributeFinders/) for the complete reference of built-in PIPs. **SAPL's plugin architecture enables domain-specific authorization.** Organizations can implement custom PIPs as plugins to integrate their domain-specific data sources and business logic into authorization policies: - HR systems and organizational hierarchies - Scheduling and calendar systems - Compliance and regulatory engines - Real-time monitoring and metrics - Any database, API, or system relevant to authorization decisions Custom PIPs are implemented as plugins to the PDP, requiring no modifications to SAPL core. Implementation details are covered in [Custom Attribute Finders](../8_3_CustomAttributeFinders/). ### Structuring Policy Conditions While there is no grammar-level distinction between body statements, it is good practice to put fast, local checks first and slower PIP-based lookups later. This helps the engine skip expensive external calls early when simple conditions already determine the outcome. **Recommended ordering:** 1. **Fast local checks first**: Resource type, action, simple equality checks on subscription attributes. These evaluate instantly and can short-circuit the rest. 2. **PIP-based lookups later**: Attribute finder expressions may involve network calls or database queries and should only run when the fast checks have already passed. For details on how the engine optimizes evaluation order across cost strata, see [Evaluation Semantics](../2_11_EvaluationSemantics/). > **Policy sets** have a dedicated `FOR` clause that acts as a target expression for filtering which policies in the set are evaluated. See [Policy Sets](../2_6_PolicySets/) for details. ### Attribute Finder Parameters and Options Attribute finders accept **parameters** in parentheses and **options** in square brackets. For the expression syntax, see the [attribute finder step](../2_7_Expressions/#attribute-finder-findername) in Expressions. #### Parameters Parameters are additional inputs passed to a PIP inside parentheses. They are positional and can be any SAPL expression, including literals, variables, or function results. What parameters mean depends on the PIP. Common uses include temporal boundaries, configuration objects, and behavior modifiers: ```sapl // update interval in milliseconds // start and end times // request configuration object topic. // QoS level ``` PIPs can be overloaded by parameter count. For example, `` returns the current time with a default update interval, while `` does the same with a 5-second interval. #### Options Options control the **stream infrastructure** that wraps every attribute lookup. They are specified in square brackets after the parameters and must be a JSON object: ```sapl ``` Options are not passed to the PIP itself. Instead, they configure how the engine handles the SAPL stream produced by the attribute lookup. The available option fields are: | Option | Default | Purpose | |--------------------|---------|-------------------------------------------------------------| | `initialTimeOutMs` | `3000` | Timeout for the first value. Emits `undefined` if exceeded. | | `pollIntervalMs` | `30000` | Re-subscription interval when the PIP stream completes. | | `retries` | `3` | Retry attempts with exponential backoff on errors. | | `backoffMs` | `1000` | Initial backoff delay between retries. | | `fresh` | `false` | If `true`, bypasses the shared stream cache. | #### Priority Chain Options follow a three-level priority chain: 1. **Policy-level options** (in square brackets in the policy expression) have the highest priority. 2. **PDP-level defaults** (configured in the PDP settings under `variables.attributeFinderOptions`, see [PDP Configuration](../2_2_PDPConfiguration/#variables)) override built-in defaults. 3. **Built-in defaults** (listed in the table above) apply when no override is specified. This allows operators to tune stream behavior globally without modifying policies, while individual policies can override when needed. ### The Attribute Broker The attribute broker mediates between policies and PIPs, managing stream lifecycle and resilience. #### For Policy Authors - **Stream sharing:** Identical attribute accesses share one PIP connection, reducing load on external systems. The `fresh` option bypasses this cache when a policy needs an independent stream. - **The resilience pipeline:** Every attribute lookup is automatically wrapped in a pipeline: timeout, retry with exponential backoff, poll on stream completion, and error-to-undefined conversion. Each option (`initialTimeOutMs`, `retries`, `backoffMs`, `pollIntervalMs`) controls one stage. #### For PDP Operators - **Global option defaults:** Configure default attribute finder options under `variables.attributeFinderOptions` in the PDP settings to tune stream behavior across all policies without modifying them. See [PDP Configuration](../2_2_PDPConfiguration/#variables) for details. - **Grace period:** After the last subscriber disconnects, the broker keeps PIP connections alive for 3 seconds. This prevents unnecessary reconnections when policies are rapidly re-evaluated. - **Hot-swapping:** PIPs can be loaded and unloaded at runtime. Active streams automatically reconnect to newly loaded PIPs. #### For PIP Developers - **Resolution priority:** When the broker receives an attribute request, it resolves the PIP using: exact parameter match, then varargs match, then repository fallback, then error. - **Automatic resilience wrapping:** PIP authors implement only the data-fetching logic. The broker automatically wraps PIP streams with the timeout/retry/polling pipeline. - **Collision detection:** If multiple PIPs register the same fully qualified attribute name, the broker detects the collision at load time and logs a warning. ## Imports SAPL provides access to functions and attribute finders organized in libraries. Within policies, you can reference these using their fully qualified names: ```sapl filter.blacken(resource.secret) subject..department ``` Import statements let you use shorter names, making policies easier to read and write. ### Import Syntax Import statements must appear at the beginning of a SAPL document, before any [schema statements](2_10_Schemas.md) and the policy or policy set. Each import statement starts with the keyword `import` and must specify a fully qualified function or attribute finder name: ``` import . import . as ``` All identifiers in an import statement (library segments, function name, and alias) follow the [identifier rules](2_7_Expressions.md#identifiers), including [reserved identifiers](2_7_Expressions.md#reserved-identifiers). #### Basic Import Import a function or attribute finder by its fully qualified name: ```sapl import filter.blacken import user.profile ``` After importing, use the simple name directly: ```sapl policy "show account" permit subject..role == "teller"; transform resource |- { @.cardNumber : blacken(4) } ``` #### Aliased Import Use `as` to provide an alternative name, useful when: - Two libraries export functions with the same name - You want a more descriptive name in context ```sapl import time.now as currentTime import clock.now as systemTime import filter.blacken as redact ``` ### Import Conflicts Each imported name must be unique within a document. The compiler reports an error if you attempt to import the same name twice: ```sapl import time.now import clock.now // Error: Import conflict: 'now' already imported ``` **Solution:** Use an alias for one of the imports: ```sapl import time.now import clock.now as systemNow // OK ``` ### Unresolved References If you use a function or attribute finder without importing it or qualifying it fully, the compiler reports an error: ```sapl policy "example" permit obligation blacken(data); // Error: Unresolved reference 'blacken' ``` **Solutions:** 1. Add an import: `import filter.blacken` 2. Use the fully qualified name: `filter.blacken(data)` ### Complete Example ```sapl import filter.blacken import time.dayOfWeek import user.roles policy "weekday-access" permit action == "read"; var day = dayOfWeek(); day in ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"]; "employee" in subject.; obligation { "audit" : { "user": blacken(subject.id) } } ``` ### See Also - [Functions and Attribute Finders](2_8_FunctionsAndAttributes.md): Conceptual model - [Functions](3_0_Functions.md): Built-in function library reference - [Attribute Finders](4_0_AttributeFinders.md): Built-in PIP library reference ## Functions SAPL ships with a set of standard function libraries. Functions are pure, synchronous computations that can be used in any SAPL expression, including target expressions. For the conceptual model, properties, and how functions differ from attribute finders, see [Functions and Attribute Finders](../2_8_FunctionsAndAttributes/). The following pages document each built-in function library. ## Attribute Finders SAPL ships with a set of built-in Policy Information Points (PIPs) that provide attribute finders for common authorization needs. Attribute finders access external, potentially streaming data sources during policy evaluation. For the conceptual model, streaming behavior, parameters, options, and the attribute broker, see [Functions and Attribute Finders](../2_8_FunctionsAndAttributes/). The following pages document each built-in PIP library. ## Testing SAPL Policies Policies are code. They encode authorization logic that determines who can access what under which conditions. Like any code, policies can have bugs, miss edge cases, and break when requirements change. SAPL provides a dedicated test DSL and a built-in test runner for testing policies directly. A complete test looks like this: ```sapltest requirement "patient record access" { given - document "patient-access" scenario "doctors can read patient records" when "Dr. Smith" attempts "read" on "patient_record" expect permit; scenario "nurses cannot delete patient records" when "Nurse Jones" attempts "delete" on "patient_record" expect not-applicable; } ``` > **Unit tests vs. CLI:** A unit test evaluates a single policy document in isolation. When no rule in that document matches, the result is `NOT_APPLICABLE`. The CLI commands (`decide-once`, `check`) wrap the evaluation in a *combining algorithm* that maps `NOT_APPLICABLE` to `DENY` by default. To test the combined behavior, use integration tests (see [Unit Tests and Integration Tests](../5_8_UnitAndIntegrationTests/)). Test files use the `.sapltest` extension and are placed alongside your policies. ### Approach The test DSL follows a BDD-inspired (Behavior-Driven Development) structure similar to frameworks like Cucumber or Spock. Each test is a scenario that describes preconditions, an action, and the expected outcome. Tests are organized into **requirements** that group related **scenarios**. Each scenario follows a **Given-When-Expect** structure: - **given** sets up the test: which policy to load, which functions and attributes to mock. - **when** defines the authorization subscription: who is attempting what action on which resource. - **expect** declares the expected decision. The DSL uses `expect` rather than `then` because policy evaluation is a pure computation. There are no side effects to observe. The PDP receives a subscription and produces a decision. `expect` expresses this declarative relationship directly. ### Prerequisites The `sapl` CLI is required. Download the binary for your platform from the [releases page](https://github.com/heutelbeck/sapl-policy-engine/releases) and verify the installation with `sapl --version`. See [SAPL Node Getting Started](../7_1_GettingStarted/#installing-with-deb-or-rpm) for package installation on Linux. ### Running Tests The `sapl test` command discovers `.sapl` and `.sapltest` files, runs all scenarios, and generates coverage reports. Place your policies and tests in the same directory: ``` policies/ patient-access.sapl patient-access-tests.sapltest ``` Run the tests: ```bash sapl test --dir ./policies ``` By default, `sapl test` looks in the current directory. If your policies and tests live in separate directories, use `--testdir`: ```bash sapl test --dir ./policies --testdir ./tests ``` The test runner prints results per requirement and scenario, with pass/fail status and timing: ``` patient-access-tests.sapltest patient record access PASS doctors can read patient records 12ms PASS nurses cannot delete patient records 8ms Tests: 2 passed, 2 total Time: 20ms ``` Exit codes encode the result: `0` for all tests passed, `2` for failures, `3` for quality gate not met (see [Coverage](../5_9_Coverage/)). ### CI/CD Integration Use `sapl test` in CI pipelines to verify that policy changes do not break expected decisions. The command returns non-zero exit codes on failure, making it a drop-in quality gate. #### GitHub Actions The [`setup-sapl`](https://github.com/heutelbeck/setup-sapl) action installs the SAPL CLI on any GitHub Actions runner. It downloads the correct binary for the runner's platform and adds it to the PATH. Run policy tests: ```yaml steps: - uses: actions/checkout@v4 - uses: heutelbeck/setup-sapl@v1 - run: sapl test --dir ./policies ``` Enforce coverage quality gates: ```yaml steps: - uses: actions/checkout@v4 - uses: heutelbeck/setup-sapl@v1 - run: sapl test --dir ./policies --policy-hit-ratio 100 --condition-hit-ratio 80 ``` Generate a SonarQube coverage report: ```yaml steps: - uses: actions/checkout@v4 - uses: heutelbeck/setup-sapl@v1 - run: sapl test --dir ./policies --sonar --output sapl-coverage - uses: SonarSource/sonarqube-scan-action@v5 with: args: -Dsonar.coverageReportPaths=sapl-coverage/sonar/sonar-generic-coverage.xml ``` The action supports `ubuntu-latest` (x86_64 and ARM64) and `windows-latest`. See the [setup-sapl README](https://github.com/heutelbeck/setup-sapl) for version pinning and all options. #### Other CI Systems On any CI system, download the binary from the [releases page](https://github.com/heutelbeck/sapl-policy-engine/releases) and run: ```bash sapl test --dir ./policies --policy-hit-ratio 100 ``` This fails the build if any policy is not exercised by at least one test. See [Coverage](../5_9_Coverage/) for all available thresholds and report options. ### Java Project Integration For projects that already use Maven, SAPL tests can run as part of the Maven build lifecycle via JUnit 5. This is useful when your policies live inside a Java project and you want test results in the same `mvn verify` run alongside your application tests. Add the test dependency and coverage plugin to your `pom.xml`: ```xml io.sapl sapl-bom ${sapl.version} pom import io.sapl sapl-test test io.sapl sapl-maven-plugin ${sapl.version} coverage enable-coverage-collection report-coverage-information ``` Policies live in `src/main/resources/policies/`. Tests live in `src/test/resources/`. A single JUnit adapter class in `src/test/java/` connects the test framework to JUnit: ``` src/ main/ resources/ policies/ patient-access.sapl test/ java/ com/example/ SaplTests.java resources/ patient-access-tests.sapltest ``` The adapter class: ```java public class SaplTests extends JUnitTestAdapter { } ``` This empty class discovers all `.sapltest` files in `src/test/resources/` and runs them as JUnit 5 dynamic tests. Run your tests with: ``` mvn verify ``` If your policies use custom function libraries or PIPs, register them in the adapter: ```java public class SaplTests extends JUnitTestAdapter { @Override protected Map> getFixtureRegistrations() { return Map.of( ImportType.STATIC_FUNCTION_LIBRARY, Map.of("temporal", TemporalFunctionLibrary.class), ImportType.PIP, Map.of("user", new UserPIP())); } } ``` ## Java Fixture API The `SaplTestFixture` class provides a programmatic API for testing SAPL policies from Java. It uses a fluent Given-When-Then builder pattern. Use this API when the test DSL does not cover your use case, for example when you need custom assertion predicates, specific timing control, or integration with Java test infrastructure. ### Creating a Test Fixture The fixture API has two entry points. `createSingleTest()` creates a unit test that evaluates a single policy document. `createIntegrationTest()` loads multiple documents and combines their decisions through a combining algorithm. Both follow the same fluent builder chain: load policies, set up mocks, submit a subscription, assert the decision. ```java // Unit test (single document) var result = SaplTestFixture.createSingleTest() .withPolicyFromResource("policies/patient-access.sapl") .givenFunction("time.dayOfWeek", args(any()), Value.of("MONDAY")) .whenDecide(AuthorizationSubscription.of("Dr. Smith", "read", "patient_record")) .expectPermit() .verify(); // Integration test (multiple documents) var result = SaplTestFixture.createIntegrationTest() .withConfigurationFromResources("policiesIT") .withCombiningAlgorithm(CombiningAlgorithm.of(PRIORITY_DENY, DENY, ABSTAIN)) .whenDecide(AuthorizationSubscription.of("user", "read", "resource")) .expectPermit() .verify(); ``` ### Loading Policies Unit tests load a single document by path or inline source. Integration tests load a full configuration directory containing multiple `.sapl` files and a `pdp.json`. | Method | Description | |------------------------------------------|--------------------------------------------------------| | `withPolicyFromResource(path)` | Load from classpath resource | | `withPolicyFromFile(path)` | Load from filesystem | | `withPolicy(source)` | Inline policy source | | `withConfigurationFromResources(path)` | Load all policies and pdp.json from classpath directory | | `withConfigurationFromDirectory(path)` | Load all policies and pdp.json from filesystem directory | ### Mocking The fixture provides the same mocking capabilities as the test DSL. Functions are mocked with argument matchers from `io.sapl.test.Matchers`. Attribute mocks are identified by a mock ID string and an attribute name. Variables and secrets configure PDP-level state. ```java import static io.sapl.test.Matchers.*; // Function mock .givenFunction("time.dayOfWeek", args(any()), Value.of("MONDAY")) // Environment attribute mock with initial value .givenEnvironmentAttribute("timeMock", "time.now", args(), Value.of("2026-01-15T10:00:00Z")) // Entity attribute mock .givenAttribute("upperMock", "string.upper", any(), args(), Value.of("HELLO")) // PDP variables and secrets .givenVariable("tenant", Value.of("hospital-north")) .givenSecret("api_key", Value.of("sk-test-key")) ``` ### Decision Expectations Simple expectations check only the decision type. For policies that attach obligations, advice, or a transformed resource, use `expectDecisionMatches()` with a decision matcher. Custom predicates give full control for assertions that the built-in matchers do not cover. ```java // Simple decisions .expectPermit() .expectDeny() .expectSuspend() .expectIndeterminate() .expectNotApplicable() // Decision matcher with obligations and resource .expectDecisionMatches(isPermit() .containsObligation(Value.of(Map.of("type", "logAccess"))) .withResource(expectedResource)) // Custom predicate .expectDecisionMatches(isPermit() .containsObligationMatching(obligation -> obligation instanceof ObjectValue obj && obj.get("type") instanceof TextValue(var type) && "audit".equals(type))) ``` ### Streaming Streaming tests emit new values to attribute mocks between expectations, just like `then` blocks in the DSL. The `thenEmit()` method takes the mock ID and the new value. The fixture waits for the PDP to re-evaluate and checks the next expectation. ```java .givenEnvironmentAttribute("timeMock", "time.now", args(), Value.of("morning")) .whenDecide(subscription) .expectPermit() .thenEmit("timeMock", Value.of("night")) .expectDeny() .verify(); ``` ### Registering Libraries and PIPs When your policies use custom function libraries or PIPs, register them with the fixture. Function libraries are registered by class (the fixture instantiates them). PIPs are registered as instances, which allows injecting dependencies like service clients. ```java // Static function library .withFunctionLibrary(TemporalFunctionLibrary.class) // All default function libraries .withDefaultFunctionLibraries() // PIP instance .withPolicyInformationPoint(new UserPIP(userService)) ``` ### Execution The `verify()` method executes the test with a default timeout of 10 seconds: ```java TestResult result = fixture.verify(); ``` A custom timeout can be specified: ```java TestResult result = fixture.verify(Duration.ofSeconds(30)); ``` ## Test Structure A `.sapltest` file contains one or more **requirements**. Each requirement contains one or more **scenarios**. A scenario is a single test case that submits an authorization subscription and checks the decision. ```sapltest requirement "access control for medical records" { scenario "authorized doctor can read" when "Dr. Smith" attempts "read" on "patient_record" expect permit; scenario "unauthorized user is denied" when "guest" attempts "read" on "patient_record" expect deny; } requirement "audit logging" { scenario "access is logged" when "Dr. Smith" attempts "read" on "patient_record" expect decision is permit, with obligation; } ``` Requirement and scenario names must be unique within their scope. Requirement names must be unique within a file. Scenario names must be unique within a requirement. ### Scenarios Each scenario follows a **Given-When-Expect** structure: ```sapltest scenario "name" given - when expect verify - ; ``` The `given` and `verify` blocks are optional. The `when` and `expect` blocks are required. The scenario ends with a semicolon. ### Central Given Blocks A `given` block at the requirement level applies to all scenarios in that requirement. Scenario-level `given` blocks extend the central configuration with additional preconditions. ```sapltest requirement "patient record access" { given - document "patient-access" - attribute "timeMock" emits "2026-01-15T10:00:00Z" scenario "doctor can read during business hours" when "Dr. Smith" attempts "read" on "patient_record" expect permit; scenario "doctor denied after hours" given - attribute "timeMock" emits "2026-01-15T23:00:00Z" when "Dr. Smith" attempts "read" on "patient_record" expect deny; } ``` The central `given` block is the right place for the document specification and shared mocks. Scenario-level blocks add or override mocks for specific test cases. ## Authorization Subscriptions The `when` clause defines the authorization subscription that the test submits to the PDP. It specifies the subject, action, and resource, with optional environment and secrets. ### Basic Form ```sapltest when "Dr. Smith" attempts "read" on "patient_record" ``` The keywords `subject`, `action`, and `resource` can be added for readability but are optional: ```sapltest when subject "Dr. Smith" attempts action "read" on resource "patient_record" ``` ### Structured Values The subject, action, and resource can be any JSON value, not just strings: ```sapltest when { "name": "Dr. Smith", "role": "doctor", "department": "cardiology" } attempts { "java": { "name": "findById" } } on { "type": "patient_record", "id": 42 } ``` ### Environment The optional `in` clause adds environment data to the subscription: ```sapltest when "Dr. Smith" attempts "read" on "patient_record" in { "tenant": "hospital-north", "region": "eu-west" } ``` ### Secrets The optional `with secrets` clause adds per-subscription secrets: ```sapltest when "Dr. Smith" attempts "read" on "patient_record" with secrets { "oauth_token": "eyJhbGciOi..." } ``` Secrets are available to PIPs through `AttributeAccessContext.subscriptionSecrets()` but are not accessible from within policies. ### Complete Example ```sapltest when subject { "name": "Dr. Smith", "role": "doctor" } attempts action "read" on resource { "type": "patient_record", "id": 42 } in environment { "time": "2026-01-15T10:00:00Z" } with secrets { "api_key": "sk-..." } ``` ## Decision Expectations The `expect` clause defines what authorization decision the test expects from the PDP. ### Simple Expectations The simplest form checks only the decision type: ```sapltest expect permit; expect deny; expect suspend; expect indeterminate; expect not-applicable; ``` ### Decision with Obligations Policies can attach obligations to their decisions. The `expect` clause can verify their presence and content. **Check that any obligation is present:** ```sapltest expect decision is permit, with obligation; ``` **Check for a specific obligation (exact match):** ```sapltest expect decision is permit, with obligation equals { "type": "logAccess", "user": "Dr. Smith" }; ``` **Check obligation type with matchers:** ```sapltest expect decision is permit, with obligation matching object; ``` **Check obligation by key presence:** ```sapltest expect decision is permit, with obligation containing key "type"; ``` **Check obligation by key and value:** ```sapltest expect decision is permit, with obligation containing key "type" with value matching text "logAccess"; ``` **Check obligation with structured matcher:** ```sapltest expect decision is permit, with obligation matching object where { "type" is text "logAccess" and "user" is text }; ``` ### Decision with Advice Advice uses the same syntax as obligations: ```sapltest expect decision is permit, with advice equals { "notify": "admin" }; expect decision is permit, with advice containing key "channel" with value matching text "email"; ``` ### Decision with Resource Policies can include a transformed resource in the decision: ```sapltest expect decision is permit, with resource equals { "id": 42, "diagnosis": "REDACTED" }; expect decision is permit, with resource matching object; expect decision is permit, with resource matching text "filtered-content"; ``` ### Combined Assertions Multiple assertions can be combined in a single `expect` clause: ```sapltest expect decision is permit, with obligation containing key "type", with resource matching object, with advice; ``` ### Inline Syntax Obligations and resource can also be specified directly after the decision type: ```sapltest expect permit with obligations { "type": "logAccess", "message": "accessed patient data" } with resource { "id": 42, "diagnosis": "REDACTED" } with advice { "display": "Access logged for compliance" }; ``` ## Mocking Policies often depend on external data through functions and attribute finders (PIPs). In tests, these dependencies are replaced with mocks that return controlled values. ### Function Mocking A function mock intercepts calls to a SAPL function and returns a predefined value: ```sapltest given - function time.dayOfWeek("2026-01-15T10:00:00Z") maps to "WEDNESDAY" ``` **No parameters:** ```sapltest - function system.timestamp() maps to "2026-01-15T10:00:00Z" ``` **Matching any argument:** ```sapltest - function time.dayOfWeek(any) maps to "FRIDAY" ``` **Multiple parameters with mixed matchers:** ```sapltest - function string.concat("hello", " ", any) maps to "hello world" ``` **Typed matchers:** ```sapltest - function time.dayOfWeek(matching text) maps to "MONDAY" - function math.process(matching number) maps to 100 - function logic.check(matching boolean) maps to true - function json.transform(matching object) maps to {} - function collection.process(matching array) maps to [] - function util.handle(matching null) maps to "handled" ``` **Matching a specific value within a type:** ```sapltest - function time.dayOfWeek(matching text "2026-01-15T10:00:00Z") maps to "WEDNESDAY" ``` ### Error and Undefined Returns Mocks can return error or undefined values: ```sapltest - function service.fetch(any) maps to error - function service.fetch(any) maps to error("Service unavailable") - function lookup.find(any) maps to undefined ``` An error return causes the enclosing policy condition to evaluate to `INDETERMINATE`. An undefined return behaves like a missing value. ### Environment Attribute Mocking Environment attributes are accessed in policies as ``. Each mock requires a unique **mock ID** (a string you choose) that identifies the mock for later operations like streaming and verification. ```sapltest given - attribute "timeMock" emits "2026-01-15T10:00:00Z" ``` The `emits` clause sets the initial value. Omit it to create a mock without an initial value (useful when the first value is emitted in a `then` block): ```sapltest - attribute "statusMock" ``` **With argument matchers:** ```sapltest - attribute "timeMock" emits "2026-01-15T10:00:00Z" - attribute "configMock" emits 30 ``` **Error and undefined:** ```sapltest - attribute "failMock" emits error("Connection refused") - attribute "failMock" emits error - attribute "missingMock" emits undefined ``` ### Entity Attribute Mocking Entity attributes are accessed in policies as `value.`. The mock specifies a matcher for the entity (left-hand) value: **Any entity:** ```sapltest - attribute "upperMock" any. emits "HELLO" ``` **Exact entity value:** ```sapltest - attribute "upperMock" "hello". emits "HELLO" - attribute "profileMock" 42. emits { "name": "Alice" } - attribute "flagMock" true. emits "enabled" - attribute "dataMock" { "id": 1 }. emits { "status": "active" } ``` **Typed entity matcher:** ```sapltest - attribute "validMock" matching text. emits true - attribute "processMock" matching object. emits { "result": "ok" } ``` **Entity attribute with parameters:** ```sapltest - attribute "qualMock" "Alice". emits ["Java", "Python"] - attribute "msgMock" any. emits { "payload": "data" } ``` ### Mock ID The mock ID is a string that uniquely identifies an attribute mock within a scenario. It serves two purposes: 1. **Streaming**: The `then` block uses the mock ID to emit new values mid-test (see [Streaming Tests](../5_6_StreamingTests/)). 2. **Verification**: The `verify` block can reference mocks by their attribute name to check call counts (see [Verification](../5_7_Verification/)). Choose descriptive IDs that reflect what the mock represents: ```sapltest - attribute "currentTime" emits "2026-01-15T10:00:00Z" - attribute "userLocation" any. emits { "lat": 48.1, "lon": 11.6 } ``` ## Matchers Matchers are used throughout the test DSL to match values in function arguments, attribute entity parameters, and decision assertions. They follow a consistent syntax. ### Value Matchers Value matchers appear in function and attribute mock definitions: | Matcher | Matches | Example | |---------------------------|--------------------------------|--------------------------------------------------| | `any` | Any value | `function f(any) maps to true` | | Literal value | Exact match | `function f("hello") maps to true` | | `matching ` | Any value of the given type | `function f(matching text) maps to true` | | `matching ` | Specific value with type check | `function f(matching text "hello") maps to true` | ### Type Matchers Type matchers check that a value is of a specific JSON type: | Matcher | Matches | |--------------------|-------------------| | `matching text` | Any string value | | `matching number` | Any numeric value | | `matching boolean` | Any boolean value | | `matching object` | Any JSON object | | `matching array` | Any JSON array | | `matching null` | Null value | ### String Matchers String matchers provide detailed text matching within `matching text ...` or in object/array `where` clauses: | Matcher | Description | |----------------------------------------------------|-------------------------------------| | `text "exact"` | Exact string match | | `text empty` | Empty string | | `text blank` | Blank string (whitespace only) | | `text null` | Null string | | `text null-or-empty` | Null or empty | | `text null-or-blank` | Null or blank | | `text containing "sub"` | Contains substring | | `text containing "sub" case-insensitive` | Contains, ignoring case | | `text starting with "pre"` | Starts with prefix | | `text starting with "pre" case-insensitive` | Starts with, ignoring case | | `text ending with "suf"` | Ends with suffix | | `text ending with "suf" case-insensitive` | Ends with, ignoring case | | `text equal to "val" case-insensitive` | Equals, ignoring case | | `text equal to "val" with compressed whitespace` | Equals after normalizing whitespace | | `text with regex "^[A-Z]+$"` | Matches regular expression | | `text with length 8` | Exact string length | | `text containing stream "a", "b", "c" in order` | Contains substrings in order | ### Object Matchers Object matchers verify JSON object structure within `where` clauses: ```sapltest expect decision is permit, with obligation matching object where { "type" is text "logAccess" and "user" is text and "timestamp" is number }; ``` Each field specifies a key and a type matcher joined by `and`: ```sapltest "fieldName" is ``` The type matcher can be any of: `text`, `text "value"`, `number`, `number 42`, `boolean`, `boolean true`, `null`, `object`, `array`. ### Array Matchers Array matchers verify JSON array contents within `where` clauses: ```sapltest expect decision is permit, with resource matching array where [text "a", text "b", number 42]; ``` Each element position specifies a type matcher. The array must match the specified elements in order. ### Decision Matchers Decision matchers are used in `expect decision ...` clauses: | Matcher | Description | |-----------------------------------------------------------------|----------------------------------------------------------| | `any` | Matches any decision | | `is permit` | Decision is PERMIT | | `is deny` | Decision is DENY | | `is suspend` | Decision is SUSPEND | | `is indeterminate` | Decision is INDETERMINATE | | `is not-applicable` | Decision is NOT_APPLICABLE | | `with obligation` | Decision contains at least one obligation | | `with obligation equals ` | Decision contains the exact obligation | | `with obligation matching ` | Decision contains an obligation matching the type | | `with obligation containing key "k"` | Decision contains an obligation with key "k" | | `with obligation containing key "k" with value matching ` | Key "k" has a value matching the type | | `with advice` | Decision contains at least one advice | | `with advice equals ` | Decision contains the exact advice | | `with resource` | Decision contains a resource | | `with resource equals ` | Decision contains the exact resource | | `with resource matching ` | Decision contains a resource matching the type | Multiple decision matchers are separated by commas: ```sapltest expect decision is permit, with obligation, with resource matching object; ``` ## Streaming Tests SAPL policies can depend on attribute values that change over time. When an attribute emits a new value, the PDP re-evaluates the policy and may produce a different decision. Streaming tests verify this behavior by emitting values to attribute mocks between expectations. ### Then Blocks A `then` block emits a new value to an attribute mock, identified by its mock ID. The PDP re-evaluates the policy, and the next `expect` clause checks the resulting decision. ```sapltest requirement "time-based access control" { scenario "access changes when time passes" given - document "office-hours" - attribute "timeMock" emits "2026-01-15T10:00:00Z" - function time.hourOf(any) maps to 10 when "employee" attempts "enter" on "office" expect permit then - attribute "timeMock" emits "2026-01-15T23:00:00Z" expect deny; } ``` The flow is: 1. The mock emits `"2026-01-15T10:00:00Z"` as its initial value. 2. The policy evaluates to `PERMIT`. 3. The `then` block emits `"2026-01-15T23:00:00Z"` to the mock. 4. The PDP re-evaluates the policy with the new attribute value. 5. The policy now evaluates to `DENY`. ### Multiple Steps Tests can chain multiple `then`/`expect` pairs to verify a sequence of decisions: ```sapltest scenario "sensor status changes" given - document "emergency-access" - attribute "sensorMock" emits "normal" when "operator" attempts "override" on "valve" expect deny then - attribute "sensorMock" emits "warning" expect deny then - attribute "sensorMock" emits "critical" expect permit then - attribute "sensorMock" emits "normal" expect deny; ``` ### Multiple Emissions Per Step A single `then` block can emit values to multiple attribute mocks: ```sapltest scenario "combined status change" given - document "access-policy" - attribute "timeMock" emits "morning" - attribute "statusMock" emits "online" when "user" attempts "access" on "resource" expect permit then - attribute "timeMock" emits "night" - attribute "statusMock" emits "maintenance" expect deny; ``` ### Error Emissions Attribute mocks can emit errors to test how policies handle PIP failures: ```sapltest scenario "PIP failure causes indeterminate" given - document "data-access" - attribute "dbMock" emits "connected" when "user" attempts "read" on "data" expect permit then - attribute "dbMock" emits error("Connection lost") expect indeterminate; ``` ## Verification The optional `verify` block at the end of a scenario checks how many times functions and attributes were called during policy evaluation. This is useful for ensuring that policies access the expected data sources and that mocks are actually exercised. ### Function Call Verification ```sapltest verify - function time.dayOfWeek("2026-01-15T10:00:00Z") is called once; ``` **Verify with argument matchers:** ```sapltest verify - function time.dayOfWeek(any) is called once; ``` **Verify exact call count:** ```sapltest verify - function logger.log(any) is called 3 times; ``` **Verify never called:** ```sapltest verify - function expensive.compute(any) is called 0 times; ``` ### Attribute Call Verification **Environment attributes:** ```sapltest verify - attribute is called once; ``` **Environment attributes with parameters:** ```sapltest verify - attribute is called 2 times; ``` **Entity attributes:** ```sapltest verify - attribute any. is called once; ``` **Entity attributes with specific entity:** ```sapltest verify - attribute "Alice". is called once; ``` ### Multiple Verifications A single `verify` block can contain multiple assertions: ```sapltest verify - function time.dayOfWeek(any) is called once - function time.secondOf(any) is called 4 times - attribute is called 0 times; ``` ### Amount Syntax | Syntax | Meaning | |-----------|---------------------------------------------------------------| | `once` | Exactly 1 time | | `N times` | Exactly N times (N must be 0 or 2 or more; use `once` for 1) | ### Complete Example ```sapltest requirement "audit trail verification" { scenario "read access logs the request" given - document "audited-access" - function audit.log(any) maps to true - attribute "timeMock" emits "2026-01-15T10:00:00Z" when "Dr. Smith" attempts "read" on "patient_record" expect decision is permit, with obligation containing key "type" verify - function audit.log(any) is called once - attribute is called once; } ``` ## Unit Tests and Integration Tests The test DSL supports two testing modes: **unit tests** that evaluate a single policy document in isolation, and **integration tests** that evaluate multiple documents together through the PDP's combining algorithm. ### Unit Tests A unit test loads a single SAPL document (policy or policy set) using the `document` directive: ```sapltest requirement "patient access policy" { given - document "patient-access" scenario "doctor can read" when "Dr. Smith" attempts "read" on "patient_record" expect permit; } ``` The document name matches the policy or policy set name declared in the `.sapl` file. The test framework automatically uses a combining algorithm that requires exactly one matching document. Unit tests verify a single document's behavior in isolation. They are fast and make failures easy to diagnose. ### Integration Tests An integration test loads multiple documents and evaluates them together. This requires specifying a combining algorithm: **Explicit document list:** ```sapltest requirement "combined access control" { given - documents "policy_A", "policy_B", "policy_C" - priority deny or deny scenario "all policies agree" when "admin" attempts "manage" on "system" expect permit; scenario "deny overrides permit" when "guest" attempts "manage" on "system" expect deny; } ``` **Loading from a configuration directory:** The `configuration` directive loads all `.sapl` files and the `pdp.json` from a directory: ```sapltest requirement "full PDP integration" { given - configuration "policiesIT" scenario "PDP behavior matches production" when "user" attempts "read" on "resource" expect permit; } ``` The directory path is relative to the test resources. This is the closest to production behavior, as it uses the same policy set and PDP configuration. **Loading pdp.json separately:** The `pdp-configuration` directive loads only the PDP configuration (combining algorithm, variables) from a file, while documents are specified separately: ```sapltest requirement "custom PDP config" { given - pdp-configuration "policiesIT/pdp.json" - documents "policy_A", "policy_B" scenario "uses configured algorithm" when "user" attempts "read" on "resource" expect permit; } ``` ### Combining Algorithms Integration tests require a combining algorithm. It can be specified in the `given` block or loaded from `pdp.json`: ```sapltest - priority deny or deny - priority permit or deny - priority deny or abstain errors propagate - priority permit or abstain errors propagate - unanimous or deny - unanimous strict or deny - unique or abstain errors propagate - first or deny - first or abstain errors propagate ``` See [Combining Algorithm](../2_5_CombiningAlgorithms/) for details on each algorithm. ### Variables and Secrets PDP-level variables and secrets can be defined in the `given` block: ```sapltest given - document "tenant-policy" - variables { "tenant": "hospital-north", "maxSessionMinutes": 30 } - secrets { "api_key": "sk-test-key", "db_password": "test-password" } ``` Variables are accessible in policies through the `environment` object. Secrets are accessible to PIPs through `AttributeAccessContext.pdpSecrets()` but are not visible to policy expressions. ### Validation Rules The test framework enforces the following rules: - Unit tests (`document`) cannot specify a combining algorithm (it is set automatically). - The `document` directive must appear in the requirement-level `given` block, not in scenario-level blocks. - `configuration` cannot be combined with `document`, `documents`, or `pdp-configuration`. - `pdp-configuration` cannot be combined with `configuration`. ## Coverage Tests verify that policies behave correctly for specific scenarios, but they do not show which parts of a policy were actually exercised. Coverage analysis closes this gap. It tracks which policy sets, policies, conditions, and branches were evaluated during testing and highlights what remains untested. ### Coverage Metrics | Metric | Description | |----------------------------|-----------------------------------------------------------------------| | Policy set hit ratio | Percentage of policy sets that were evaluated during testing | | Policy hit ratio | Percentage of individual policies that were evaluated during testing | | Policy condition hit ratio | Percentage of condition branches that were exercised (true and false) | | Branch coverage | Overall branch coverage across all policy documents | ### CLI Coverage The `sapl test` command collects coverage data, generates reports, and optionally enforces minimum thresholds. When a threshold is not met, the command exits with code `3`. This makes `sapl test` a quality gate for policy code in any CI pipeline. Run tests with coverage thresholds: ```bash sapl test --dir ./policies --policy-hit-ratio 100 --condition-hit-ratio 70 ``` #### Options | Option | Default | Description | |----------------------------|--------------------|---------------------------------------------------------| | `--policy-set-hit-ratio` | `0` | Required percentage of policy sets evaluated (0-100) | | `--policy-hit-ratio` | `0` | Required percentage of policies evaluated (0-100) | | `--condition-hit-ratio` | `0` | Required percentage of condition branches covered (0-100) | | `--branch-coverage-ratio` | `0` | Required overall branch coverage (0-100) | | `--html` / `--no-html` | `--html` | Generate an HTML coverage report | | `--sonar` / `--no-sonar` | `--no-sonar` | Generate a SonarQube-compatible XML report | | `--output` | `./sapl-coverage` | Output directory for coverage data and reports | A threshold of `0` disables the check. Any value from `1` to `100` enforces that minimum. #### Exit Codes | Code | Meaning | |------|-----------------------------------------------------------| | `0` | All tests passed and quality gate met (if configured) | | `1` | Error during test execution (I/O, parse errors) | | `2` | One or more tests failed | | `3` | Tests passed but coverage is below the required threshold | #### Reports Coverage data is written to `/coverage.ndjson` during test execution. Depending on the report options, the command also produces: - **HTML report** in `/html/` with line-level coverage highlighting per policy file - **SonarQube report** in `/sonar/sonar-generic-coverage.xml` #### SonarQube Integration Generate a SonarQube-compatible report and point SonarQube at the output: ```bash sapl test --dir ./policies --sonar --output ./sapl-coverage ``` Add the report path to your SonarQube configuration: ``` sonar.coverageReportPaths=sapl-coverage/sonar/sonar-generic-coverage.xml ``` The generated report uses SonarQube's generic test coverage format, which is supported by all SonarQube editions. ### Maven Plugin For Java projects, the SAPL Maven plugin integrates coverage into the build lifecycle. It collects coverage data during test execution, generates reports, and optionally enforces minimum thresholds. When a threshold is not met, the build fails. This makes the plugin a quality gate alongside `mvn verify`. ```xml io.sapl sapl-maven-plugin ${sapl.version} 100 100 70 0 true false true coverage enable-coverage-collection report-coverage-information ``` #### Configuration Parameters | Parameter | Default | Description | |----------------------------|---------|------------------------------------------------------------| | `policySetHitRatio` | `0` | Required percentage of policy sets evaluated (0-100) | | `policyHitRatio` | `0` | Required percentage of policies evaluated (0-100) | | `policyConditionHitRatio` | `0` | Required percentage of condition branches covered (0-100) | | `branchCoverageRatio` | `0` | Required overall branch coverage (0-100) | | `enableHtmlReport` | `true` | Generate an HTML coverage report | | `enableSonarReport` | `false` | Generate a SonarQube-compatible XML report | | `failOnDisabledTests` | `true` | Fail the build if tests are skipped | | `coverageEnabled` | `true` | Enable or disable coverage collection | #### Plugin Goals The plugin provides two goals that should be executed together: | Goal | Phase | Description | |-------------------------------|------------------------|--------------------------------------------------------------| | `enable-coverage-collection` | `process-test-classes` | Cleans the coverage output directory before tests run | | `report-coverage-information` | `verify` | Reads coverage data, generates reports, validates thresholds | #### Coverage Output Coverage data is written to `target/sapl-coverage/coverage.ndjson` during test execution. The report goal reads this data and produces: - **HTML report** in `target/sapl-coverage/html/` with line-level coverage highlighting per policy file - **SonarQube report** in `target/sapl-coverage/sonar/sonar-generic-coverage.xml` (when enabled) #### SonarQube Integration To import SAPL coverage into SonarQube, enable the SonarQube report and configure the import path: ```xml true ``` Add the report path to your SonarQube configuration: ``` sonar.coverageReportPaths=target/sapl-coverage/sonar/sonar-generic-coverage.xml ``` ## SDKs and APIs SAPL provides multiple ways to connect applications to a Policy Decision Point (PDP). ### APIs - **[HTTP and RSocket API](../6_1_HTTPApi/):** Network APIs for any programming language. HTTP uses JSON over REST. RSocket uses protobuf over persistent TCP or Unix domain sockets for high-throughput workloads. Both offer the same five operations. - **[Java API](../6_2_JavaApi/):** Java APIs for embedded PDP evaluation and remote PDP access. Embedded evaluation is Reactor-free, while remote HTTP and RSocket clients expose Project Reactor types. ### Framework SDKs - **[Spring](../6_3_Spring/):** Annotation-driven and filter-based authorization for Spring Security and Spring WebFlux. - **[NestJS](../6_4_NestJS/):** Guards and decorators for NestJS applications. - **[Django](../6_5_Django/):** Decorators for Django views and services. - **[Flask](../6_6_Flask/):** Decorators for Flask routes and services. - **[FastAPI](../6_7_FastAPI/):** Decorators for FastAPI endpoints with async and streaming support. - **[Tornado](../6_8_Tornado/):** Decorators for Tornado handlers with async and streaming support. - **[FastMCP](../6_9_FastMCP/):** Middleware and per-component authorization for MCP servers. - **[.NET](../6_10_DotNet/):** Attributes and customizers for ASP.NET Core applications. - **[PHP](../6_11_PHP/):** Attributes for Symfony controllers and services, with Doctrine query rewriting. All SDKs and APIs expose the same authorization semantics: single subscriptions (streaming and one-shot) and multi-subscriptions (streaming and one-shot batch). ## .NET SDK Attribute-Based Access Control (ABAC) for ASP.NET Core using SAPL (Streaming Attribute Policy Language). Provides attribute-driven policy enforcement with a constraint handler architecture for obligations, advice, and response transformation. The `Sapl.AspNetCore` library integrates SAPL policy enforcement into ASP.NET Core applications. It hooks into the MVC filter pipeline and the DI container to enforce authorization on controller actions and service-layer methods. The library is fully async, supports Server-Sent Events streaming for continuous authorization, and works with standard ASP.NET Core middleware and routing. ### What is SAPL? SAPL is a policy language and Policy Decision Point (PDP) for attribute-based access control. Policies are written in a dedicated language and evaluated by the PDP, which streams authorization decisions based on subject, action, resource, and environment attributes. Three core concepts: 1. **Authorization subscription**: your app sends `{ subject, action, resource, environment }` to the PDP. 2. **PDP decision**: the PDP evaluates policies and returns `PERMIT` or `DENY`, optionally with obligations, advice, or a replacement resource. 3. **Constraint handlers**: registered handlers execute the policy's instructions (log, filter, transform, cap values, etc.). A PDP decision looks like this: ```json { "decision": "PERMIT", "obligations": [{ "type": "logAccess", "message": "Patient record accessed" }], "advice": [{ "type": "notifyAdmin" }] } ``` `decision` is always present (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, or `NOT_APPLICABLE`). The other fields are optional. `obligations` and `advice` are arrays of arbitrary JSON objects (by convention with a `type` field for handler dispatch), and `resource` (when present) replaces the action's return value entirely. For a deeper introduction to SAPL's subscription model and policy language, see the [SAPL documentation](https://sapl.io/docs/latest/). ### Installation Install the NuGet packages: ```bash dotnet add package Sapl.Core dotnet add package Sapl.AspNetCore ``` `Sapl.Core` contains the framework-agnostic core: the PDP client, authorization models, constraint handling engine, and enforcement engine. `Sapl.AspNetCore` adds ASP.NET Core integration: MVC filters, enforcement attributes, middleware, the subscription builder, and service-layer proxy support. Installing `Sapl.AspNetCore` automatically brings in `Sapl.Core` as a transitive dependency. The library requires .NET 9.0 or later. An RSocket transport is available in the separate `Sapl.Rsocket` package as an alternative to HTTP for the PDP connection. `AddSapl` wires the HTTP client. The RSocket client is registered explicitly. See the demo and integration tests for the RSocket setup. A complete working demo with constraint handlers, service-layer enforcement, and streaming authorization is available at [sapl-dotnet-demos](https://github.com/heutelbeck/sapl-dotnet-demos). ### Setup #### Service Registration Call `AddSapl` on your `IServiceCollection` in `Program.cs`. This registers the PDP client, the enforcement engine and planner, the subscription resolver, and the MVC filters. ```csharp using Sapl.AspNetCore.Extensions; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddSapl(options => { options.BaseUrl = "https://localhost:8443"; options.Token = "sapl_your_api_key_here"; }); var app = builder.Build(); app.UseSaplAccessDenied(); app.MapControllers(); app.Run(); ``` For basic authentication instead of a bearer token: ```csharp builder.Services.AddSapl(options => { options.BaseUrl = "https://localhost:8443"; options.Username = "myPdpClient"; options.Secret = "myPassword"; }); ``` `Token` (bearer) and `Username`/`Secret` (basic auth) are mutually exclusive. Configure one or the other. #### Configuration from appsettings.json Instead of configuring options inline, you can bind from a configuration section: ```csharp builder.Services.AddSapl(builder.Configuration, sectionName: "Sapl"); ``` With the corresponding `appsettings.json`: ```json { "Sapl": { "BaseUrl": "https://localhost:8443", "Token": "sapl_your_api_key_here" } } ``` #### Local Development (HTTP) For local development without TLS, point `BaseUrl` at an `http://` URL: ```csharp builder.Services.AddSapl(options => { options.BaseUrl = "http://localhost:8443"; }); ``` #### Access Denied Middleware Register the access denied middleware to catch `AccessDeniedException` from enforcement filters and return HTTP 403 automatically: ```csharp app.UseSaplAccessDenied(); ``` This middleware should be registered before `app.MapControllers()`. Without it, unhandled `AccessDeniedException` would propagate as HTTP 500. ### Enforcement Attributes `[PreEnforce]` and `[PostEnforce]` can be placed on controller action methods or on the controller class itself. `[StreamEnforce]` applies to methods only and targets actions returning `IAsyncEnumerable`. The attributes work through ASP.NET Core's MVC filter pipeline, so they require standard controller routing (`[ApiController]`, `MapControllers()`). #### [PreEnforce] Authorizes **before** the action executes. The action only runs on PERMIT. ```csharp using Microsoft.AspNetCore.Mvc; using Sapl.Core.Attributes; [ApiController] [Route("api")] public sealed class PatientController : ControllerBase { [HttpGet("patient/{id}")] [PreEnforce(Action = "readPatient", Resource = "patient")] public IActionResult GetPatient(string id) { return Ok(new { id, name = "Jane Doe", ssn = "123-45-6789" }); } } ``` Use `[PreEnforce]` for actions with side effects (database writes, emails) that should not execute when access is denied. On denial, the filter short-circuits and returns HTTP 403. #### [PostEnforce] Authorizes **after** the action executes. The action always runs. Its return value is available to the subscription builder and to constraint handlers for transformation. ```csharp [HttpGet("patients")] [PostEnforce(Action = "readPatients", Resource = "patients")] public IActionResult GetPatients() { return Ok(patients.Select(p => new { p.Id, p.Name, p.Ssn })); } ``` Use `[PostEnforce]` when the policy needs to see the actual return value to make its authorization decision (e.g., deny based on the data's classification) or when constraint handlers need to transform the result (e.g., blacken SSN fields). On denial, the return value is discarded and HTTP 403 is returned. #### Building the Authorization Subscription Each attribute accepts named properties to customize the authorization subscription fields: `Subject`, `Action`, `Resource`, `Environment`, and `Secrets`. **Default Values** When not explicitly provided, the subscription fields are derived from the HTTP context: | Field | Default | | ------------- | ------------------------------------------------------------------------------ | | `Subject` | Claims from `HttpContext.User` as a dictionary, or `"anonymous"` | | `Action` | `{"method": actionName, "controller": controllerName, "httpMethod": "GET"}` | | `Resource` | `{"path": requestPath, "params": routeValues, "query": queryString}` | | `Environment` | Not sent unless explicitly specified | | `Secrets` | Not sent unless explicitly specified | JWT integration is automatic: if the request carries a Bearer token and the ASP.NET Core authentication middleware has validated it, the claims principal is used as the subject. All JWT claims are included. **Static Values** Pass a string directly via the attribute property: ```csharp [PreEnforce(Action = "read", Resource = "patient")] ``` **Secrets** The `Secrets` property carries sensitive data that the PDP needs for policy evaluation but that must not appear in logs. It is excluded from debug logging automatically: ```csharp [PreEnforce(Action = "exportData", Secrets = "jwt")] ``` **Subscription Customizers** For subscription fields that require computed or structured values (which C# attributes cannot express as constants), implement `ISubscriptionCustomizer` and reference it via the `Customizer` property: ```csharp using Sapl.Core.Subscription; public sealed class PatientDetailCustomizer : ISubscriptionCustomizer { public void Customize(SubscriptionContext context, SubscriptionBuilder builder) { builder.WithStaticResource(new { type = "patientDetail" }); } } ``` ```csharp [PostEnforce(Action = "getPatientDetail", Customizer = typeof(PatientDetailCustomizer))] public async Task GetPatientDetail(string id) { return await LoadPatient(id); } ``` The customizer receives a `SubscriptionContext` with access to the `ClaimsPrincipal`, method name, class name, method arguments, return value (`PostEnforce` only), bearer token, and additional HTTP properties (path, route params, query string). Customizers are resolved from the DI container or instantiated via `ActivatorUtilities`, so they can have constructor-injected dependencies. #### Streaming Enforcement For SSE endpoints, the single `[StreamEnforce]` attribute provides continuous authorization. The PDP streams decisions over time and the PEP drives them through a four-state machine (Pending, Permitting, Suspended, Terminated). The decision verb chooses the behaviour, not a client-side attribute choice: - **DENY** terminates the stream. - **SUSPEND** pauses it: items are dropped while suspended and forwarding resumes on the next PERMIT. The subscription stays alive. - **PERMIT** forwards items, transformed by any output handlers. `[StreamEnforce]` carries the same subscription properties as the other attributes (`Subject`, `Action`, `Resource`, `Environment`, `Secrets`, `Customizer`) plus one streaming flag: | Property | Effect | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SignalTransitions` | When `true`, each suspend/resume boundary surfaces an out-of-band frame to the subscriber (`ACCESS_SUSPENDED` on entering suspended, `ACCESS_GRANTED` on resuming). When `false` (default) transitions are silent and items simply drop while suspended. | The three streaming semantics the old library expressed with three separate attributes are now expressed with one attribute plus the policy's decision verb: | Goal | Policy decision in the closed window | `SignalTransitions` | | ------------------------------------- | ------------------------------------ | ------------------- | | Terminate on access loss | `deny` | (any) | | Pause silently, resume on permit | `suspend` | `false` | | Pause with client-visible status | `suspend` | `true` | ```csharp [ApiController] [Route("api/streaming")] public sealed class StreamingController(IStreamingService streamingService) : ControllerBase { [HttpGet("heartbeat/till-denied")] [StreamEnforce(Action = "stream:terminate", Resource = "heartbeat")] public IAsyncEnumerable HeartbeatTillDenied() => streamingService.Heartbeats(HttpContext.RequestAborted); [HttpGet("heartbeat/silent-suspending")] [StreamEnforce(Action = "stream:suspend", Resource = "heartbeat")] public IAsyncEnumerable HeartbeatSilentSuspending() => streamingService.Heartbeats(HttpContext.RequestAborted); [HttpGet("heartbeat/observed-suspending")] [StreamEnforce(Action = "stream:suspend", Resource = "heartbeat", SignalTransitions = true)] public IAsyncEnumerable HeartbeatObservedSuspending() => streamingService.Heartbeats(HttpContext.RequestAborted); } ``` `[StreamEnforce]` applies to methods returning `IAsyncEnumerable`. The filter renders the enforced stream as `text/event-stream`. On a terminal denial it writes a final `ACCESS_DENIED` frame before closing. ### How Enforcement Works The attributes above are convenient, but to use them well it helps to understand what actually happens behind the scenes. This section walks through the enforcement lifecycle so you can reason about behavior. #### The Deny Invariant Only `PERMIT` grants access. The PDP can return five possible decisions (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, `NOT_APPLICABLE`), and only `PERMIT` ever results in your action running or your stream forwarding data. Everything else means denial. Streaming PEPs that honour `SUSPEND` pause the stream while keeping the subscription alive. One-shot PEPs treat `SUSPEND` as `DENY`. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for details. A `PERMIT` with obligations is not a free pass. The enforcement engine checks that every obligation in the decision has a registered handler. If even one obligation cannot be fulfilled, the engine treats the decision as a denial. If a handler accepts responsibility but fails during execution, that also results in denial. Advice is softer: if an advice handler fails, the engine logs the failure and moves on. Advice never causes denial. | Aspect | Obligation | Advice | |-----------------|-----------------------------------------------------------|-------------------------------------------------| | All handled? | Required. Unhandled obligations deny access (403). | Optional. Unhandled advice is silently ignored. | | Handler failure | Denies access (403). | Logs a warning and continues. | This means you can always trust that if your action runs, every obligation attached to the decision has been successfully enforced. #### Enforcement Locations Depending on the attribute, constraint handlers can intervene at different points in the lifecycle of a request or stream. For request-response actions (`[PreEnforce]` and `[PostEnforce]`), constraints can run at four points: | Location | When it happens | What constraints do here | |-----------------------|--------------------------------------|-----------------------------------------------------| | On decision | Authorization decision arrives | Side effects like logging, audit, or notification | | Pre-method invocation | Before the protected action executes | Modify action arguments (`[PreEnforce]` only) | | On return value | After the action returns | Transform, filter, or replace the result | | On error | If the action throws | Transform or observe the error | For streaming actions (`[StreamEnforce]`), constraints can run at these points: | Location | When it happens | What constraints do here | |--------------------|----------------------------------------------|-----------------------------------------| | On decision | Each new decision from the PDP stream | Side effects like logging, audit | | On each data item | Each element yielded by the stream | Transform, filter, or replace items | | On stream error | Stream produces an error | Transform or observe the error | | On stream complete | Stream finishes normally | Cleanup and finalization | | On cancel | Client disconnects or enforcement terminates | Release resources and close connections | #### PreEnforce Lifecycle When you decorate an action with `[PreEnforce]`, here is what happens step by step. First, the enforcement filter builds an authorization subscription from the attribute properties (or from defaults if you left them out) and sends it to the PDP as a one-shot request. The PDP evaluates the subscription against all matching policies and returns a single decision. If the decision is anything other than `PERMIT`, the filter runs best-effort handlers and short-circuits with HTTP 403. Your action never runs. If the decision is `PERMIT`, the engine resolves all constraint handlers. It walks through the obligations and advice attached to the decision and checks which registered handlers claim responsibility for each one. If any obligation has no matching handler, the engine denies access right there, because it cannot guarantee the obligation will be enforced. With all handlers resolved, execution proceeds through the enforcement locations in order. On-decision handlers run first (logging, audit). Then method-invocation handlers run, which can modify action arguments if the policy requires it. Then your actual action executes. After the action returns, the engine applies return-value handlers: resource replacement if the decision included one, filter predicates, mapping handlers, and consumer handlers. If any obligation handler fails at any stage, the engine denies access. #### PostEnforce Lifecycle `[PostEnforce]` inverts the order. Your action runs first, regardless of the authorization outcome. Only after it returns does the engine build the authorization subscription (now including the return value) and consult the PDP. This means the PDP can make decisions based on the actual data your action produced. For example, a policy might permit access to a record only if its classification level is below a threshold, something that can only be checked after loading the record. If the decision is not `PERMIT`, the engine discards the return value and returns HTTP 403. If the decision is `PERMIT`, constraint handlers proceed through the same stages as `[PreEnforce]`, minus the method-invocation handlers (since the action has already run). Return-value handlers can still transform the result before it reaches the caller. Because the action runs before the PDP is consulted, if the action itself throws an exception, that exception propagates directly. The PDP is never called, because there is no return value to include in the subscription. SAPL PEP libraries share a single unified enforcement model. It is a strict fail-closed state machine over the five decision verbs, where only `PERMIT` grants access and only an explicit `SUSPEND` pauses a stream without terminating it. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for the decision-verb semantics. ### Constraint Handlers When the PDP returns a decision with `obligations` or `advice`, the `EnforcementPlanner` asks every registered `IConstraintHandlerProvider` to translate each constraint into the handlers that enforce it, then schedules those handlers against the points of the request or stream lifecycle. #### The Provider Model A constraint handler provider implements a single method: ```csharp public interface IConstraintHandlerProvider { IReadOnlyList GetConstraintHandlers( JsonElement constraint, IReadOnlySet supportedSignals); } ``` Given one constraint, the provider returns an empty list if it does not recognise it, or one or more `ScopedHandler`s. Each `ScopedHandler` binds a handler to the lifecycle point (`SignalType`) it runs at, with a priority: ```csharp public sealed record ScopedHandler(ConstraintHandler Handler, SignalType SignalType, int Priority); ``` Because a provider returns a list, one obligation can drive several handlers across different lifecycle points. #### Handler Shapes `ConstraintHandler` is one of three shapes: | Shape | Signature | Use | | ---------------------------- | -------------------------- | -------------------------------------------------------- | | `ConstraintHandler.Runner` | `Action Run` | A side effect with no value (log, notify, audit). | | `ConstraintHandler.Consumer` | `Action Accept` | Inspect the signal value without changing it. | | `ConstraintHandler.Mapper` | `Func Apply` | Transform the signal value (filter, redact, cap, replace). | #### Lifecycle Points (SignalType) A handler is scheduled against one `SignalType`. The value it sees depends on the point: | SignalType | Fires when | Value the handler sees | | -------------------------- | ------------------------------------------------- | ----------------------------------------------- | | `SignalType.Decision` | A decision arrives | the `AuthorizationDecision` | | `SignalType.Input` | Before the protected method runs (`[PreEnforce]`) | the argument dictionary (keyed by parameter name) | | `SignalType.Output(type)` | After the method returns, or per stream item | the return value or item | | `SignalType.Error` | The method throws | the exception | | `SignalType.Complete` | A stream completes normally | (none) | | `SignalType.Cancel` | The subscriber cancels | (none) | | `SignalType.Termination` | A stream terminates by enforcement | (none) | Each enforcement point advertises which signals it supports. `GetConstraintHandlers` receives that `supportedSignals` set, so a provider can bind to the right one (for example, only attach an output mapper when an `Output` signal is available). When several mappers bind to the same signal, the `Priority` on each `ScopedHandler` orders them (higher runs first). #### Static Helpers `IConstraintHandlerProvider` exposes two static helpers for the common dispatch pattern: - `ConstraintIsOfType(constraint, "typeName")` returns true when the constraint object's `type` field matches. - `StringField(constraint, "field")` returns the string value of a named field, or null. #### Registering Custom Handlers Register handlers in `Program.cs` using the `AddSaplConstraintHandler()` extension method: ```csharp using Sapl.AspNetCore.Extensions; builder.Services.AddSaplConstraintHandler(); builder.Services.AddSaplConstraintHandler(); builder.Services.AddSaplConstraintHandler(); ``` This method inspects the type and automatically registers it under all applicable constraint handler interfaces it implements. The default lifetime is `Singleton`. An optional `ServiceLifetime` parameter can be passed if a different lifetime is needed. Handlers are resolved from the DI container, so they can have constructor-injected dependencies like `ILogger`. #### Writing a Runner on the Decision A runner performs a side effect when a decision arrives. This `logAccess` handler is the simplest shape: ```csharp using System.Text.Json; using Sapl.Core.Pep.Constraints; public sealed class LogAccessHandler(ILogger logger) : IConstraintHandlerProvider { public IReadOnlyList GetConstraintHandlers( JsonElement constraint, IReadOnlySet supportedSignals) { if (!IConstraintHandlerProvider.ConstraintIsOfType(constraint, "logAccess")) return []; var message = IConstraintHandlerProvider.StringField(constraint, "message") ?? "Access logged"; return [new ScopedHandler( new ConstraintHandler.Runner(() => logger.LogInformation("[POLICY] {Message}", message)), SignalType.Decision, 0)]; } } ``` #### Writing a Mapper on the Input An input mapper rewrites method arguments before execution. This runs only with `[PreEnforce]`. The handler receives the argument dictionary keyed by parameter name and returns it (possibly mutated): ```csharp using System.Text.Json; using Sapl.Core.Pep.Constraints; public sealed class CapTransferHandler(ILogger logger) : IConstraintHandlerProvider { public IReadOnlyList GetConstraintHandlers( JsonElement constraint, IReadOnlySet supportedSignals) { if (!IConstraintHandlerProvider.ConstraintIsOfType(constraint, "capTransferAmount")) return []; var maxAmount = constraint.TryGetProperty("maxAmount", out var max) && max.TryGetDouble(out var value) ? value : 5000d; return [new ScopedHandler( new ConstraintHandler.Mapper(args => Cap(args, maxAmount)), SignalType.Input, 0)]; } private object? Cap(object? args, double maxAmount) { if (args is IDictionary arguments && arguments.TryGetValue("amount", out var raw) && raw is double requested && requested > maxAmount) { logger.LogInformation("[CAP] transfer amount {Requested} -> {Max}", requested, maxAmount); arguments["amount"] = maxAmount; } return args; } } ``` #### Writing a Mapper on the Output An output mapper transforms the return value. This `redactFields` handler binds to whichever `Output` signal the enforcement point advertises, then redacts named fields from the JSON response: ```csharp using System.Text.Json; using System.Text.Json.Nodes; using Sapl.Core.Pep.Constraints; public sealed class RedactFieldsHandler : IConstraintHandlerProvider { public IReadOnlyList GetConstraintHandlers( JsonElement constraint, IReadOnlySet supportedSignals) { if (!IConstraintHandlerProvider.ConstraintIsOfType(constraint, "redactFields")) return []; var output = supportedSignals.FirstOrDefault(signal => signal.Kind == SignalKind.Output); if (output is null) return []; var fields = ReadFields(constraint); return [new ScopedHandler(new ConstraintHandler.Mapper(value => Redact(value, fields)), output, 0)]; } private static object? Redact(object? value, IReadOnlyList fields) { var json = value is JsonElement el ? el.GetRawText() : JsonSerializer.Serialize(value); if (JsonNode.Parse(json) is not JsonObject obj) return value; foreach (var field in fields.Where(obj.ContainsKey)) obj[field] = "[REDACTED]"; return JsonDocument.Parse(obj.ToJsonString()).RootElement.Clone(); } private static List ReadFields(JsonElement constraint) { var fields = new List(); if (constraint.TryGetProperty("fields", out var array) && array.ValueKind == JsonValueKind.Array) fields.AddRange(array.EnumerateArray().Select(f => f.GetString()).OfType()); return fields; } } ``` ### Built-in Constraint Handlers #### ContentFilteringConstraintHandlerProvider **Constraint type:** `filterJsonContent` Transforms response values by deleting, replacing, or blackening fields. When the value is a list, each action is applied to every element. A policy can attach this obligation: ``` policy "permit-read-patient" permit action == "readPatient"; resource == "patient"; obligation { "type": "filterJsonContent", "actions": [ { "type": "blacken", "path": "$.ssn", "discloseRight": 4 }, { "type": "delete", "path": "$.internalNotes" }, { "type": "replace", "path": "$.classification", "replacement": "REDACTED" } ] } ``` The `blacken` action supports these options: | Option | Type | Default | Description | | --------------- | ------ | ---------------------- | ------------------------------------------- | | `path` | string | (required) | JSONPath to a string field | | `replacement` | string | `"*"` | Character used for masking | | `discloseLeft` | number | `0` | Characters to leave unmasked from the left | | `discloseRight` | number | `0` | Characters to leave unmasked from the right | | `length` | number | (masked section length)| Override the length of the masked section | #### Path Syntax Paths are resolved with Newtonsoft `SelectToken`, so JSONPath is supported: simple dot paths (`$.field.nested`), recursive descent (`$..ssn`), array indexing (`$.items[0]`), wildcards (`$.users[*].email`), and filter expressions (`$.books[?(@.price<10)]`). `blacken` targets a single text node (the first match). A path that does not resolve is left unchanged. ### Service-Layer Enforcement SAPL enforcement is not limited to controller actions. The same attributes work on service interface methods, enforced transparently through .NET's `DispatchProxy` mechanism. This lets you push authorization into the service layer, so that controllers remain free of SAPL concerns. #### Registering Enforced Services Register a service interface with its implementation using `AddSaplService()`: ```csharp builder.Services.AddSaplService(); ``` This registers the real `PatientService` in the DI container and wraps it with a `SaplProxy`. When any code resolves `IPatientService`, it receives the proxy. The proxy intercepts every call to a method decorated with a SAPL attribute and runs the enforcement engine before or after the real method, exactly as the MVC filters do for controller actions. Methods without SAPL attributes pass through to the real implementation unmodified. #### Decorating Interface Methods Place SAPL attributes directly on the interface methods. All three enforcement attributes are supported: `[PreEnforce]`, `[PostEnforce]`, and `[StreamEnforce]`. ```csharp using Sapl.Core.Attributes; public interface IPatientService { [PreEnforce(Action = "listPatients", Resource = "patients")] Task ListPatients(CancellationToken ct = default); [PostEnforce(Action = "getPatientDetail", Customizer = typeof(PatientDetailCustomizer))] Task GetPatientDetail(string id, CancellationToken ct = default); [PreEnforce(Action = "transfer", Resource = "account")] Task Transfer(double amount, CancellationToken ct = default); } ``` The implementation class needs no SAPL annotations at all: ```csharp public sealed class PatientService : IPatientService { public Task ListPatients(CancellationToken ct) { return Task.FromResult(patients); } public Task GetPatientDetail(string id, CancellationToken ct) { return Task.FromResult(patients.FirstOrDefault(p => p.Id == id)); } public Task Transfer(double amount, CancellationToken ct) { return Task.FromResult(new { transferred = amount }); } } ``` #### How the Proxy Works `SaplProxy` extends `DispatchProxy`, a built-in .NET mechanism for creating interface-based runtime proxies. When a proxied method is called, the proxy reads the SAPL attribute from the interface method via reflection and delegates to the `SaplMethodInterceptor`, which builds the authorization subscription, calls the PDP, resolves constraint handlers, and executes the real method at the appropriate point in the enforcement lifecycle. The proxy supports both synchronous and async return types. For `Task` methods, the proxy correctly awaits the underlying method. For `IAsyncEnumerable` methods carrying `[StreamEnforce]`, the proxy drives the source stream through the streaming enforcement engine. On denial, the proxy throws `AccessDeniedException`. When called from a controller with `UseSaplAccessDenied()` middleware registered, this is caught and translated to HTTP 403 automatically. #### Streaming Services Streaming enforcement on services works the same as on controllers. The interface method returns `IAsyncEnumerable` and the proxy wraps it: ```csharp using Sapl.Core.Attributes; public interface IStreamingService { [StreamEnforce(Action = "stream:suspend", Resource = "heartbeat")] IAsyncEnumerable Heartbeats(CancellationToken ct = default); } ``` At the service (proxy) layer the enforced stream keeps yielding the concrete element type. Boundary frames (`ACCESS_SUSPENDED` / `ACCESS_GRANTED` / `ACCESS_DENIED`) are a transport concern rendered by the SSE controller filter, and so `SignalTransitions` applies to controller-level streaming. ### Transaction Integration A `[PreEnforce]` method's output obligations, and the entire `[PostEnforce]` decision, run *after* the protected method has executed and written to the database. Without a surrounding transaction, a denial at that point cannot undo the write: the row is committed, the caller gets a 403, and the database is left inconsistent. SAPL closes this with an opt-in transaction boundary backed by EF Core. Install the package and register it once, after `AddDbContext` and `AddSapl`: ```bash dotnet add package Sapl.EntityFrameworkCore ``` ```csharp builder.Services.AddDbContext(o => o.UseSqlite(connectionString)); builder.Services.AddSapl(o => o.BaseUrl = "https://pdp.example.com"); builder.Services.AddSaplEntityFrameworkCoreTransactions(); // enables rollback ``` With the manager registered, both enforcement paths (the controller MVC filter and the service-layer proxy) wrap the method invocation and all enforcement stages in a single transaction on `AppDbContext`. The transaction commits only on a clean `PERMIT` with every obligation discharged; otherwise the `AccessDeniedException` propagates out of the boundary and the write is rolled back before the access-denied middleware turns it into a 403. Three situations roll back: - a `[PostEnforce]` decision that is not `PERMIT`, - a `[PostEnforce]` obligation handler failure (at the decision or the output stage), - a `[PreEnforce]` output-obligation failure (the pre-decision permits, but its output obligations run after the method has written). This is opt-in. Without `AddSaplEntityFrameworkCoreTransactions`, the enforcement paths run with no transaction boundary and behave exactly as before. `Sapl.Core` and `Sapl.AspNetCore` carry no Entity Framework dependency; only the separate `Sapl.EntityFrameworkCore` package does. The boundary is on the `TDbContext` you register. Any write made through that context anywhere in the call chain, including in nested services the protected method calls, is inside the transaction and rolls back. A write that goes through a different `DbContext`, a separate connection, or raw ADO.NET is outside the boundary and is not rolled back. ### Manual PDP Access For cases where attributes are not suitable, inject `IPolicyDecisionPoint` directly and call the PDP programmatically: ```csharp using Sapl.Core.Authorization; using Sapl.Core.Client; [HttpGet("hello")] public async Task GetHello( [FromServices] IPolicyDecisionPoint pdp) { var subscription = AuthorizationSubscription.Create( subject: "anonymous", action: "read", resource: "hello"); var decision = await pdp.DecideOnceAsync(subscription, HttpContext.RequestAborted); if (decision.Decision == Decision.Permit && decision.Obligations is null) { return Ok(new { message = "hello" }); } return StatusCode(403, new { error = "Access denied" }); } ``` When using the PDP client directly, you are responsible for checking the decision, enforcing obligations, and handling resource replacement. The `IPolicyDecisionPoint` interface exposes both one-shot and streaming endpoints: | Method | Return Type | Description | | ---------------------- | -------------------------------------------- | -------------------------------------------- | | `DecideOnceAsync` | `Task` | One-shot single subscription | | `Decide` | `IAsyncEnumerable` | Streaming single subscription | | `MultiDecideAllOnceAsync` | `Task` | One-shot multi subscription (all decisions) | | `MultiDecide` | `IAsyncEnumerable` | Streaming multi subscription | | `MultiDecideAll` | `IAsyncEnumerable` | Streaming multi subscription (all decisions) | ### Client Resilience The PDP client treats every transport problem as an operational condition, never as a policy outcome, and never lets one surface as an exception. A connection drop, timeout, or decode error fails closed to `INDETERMINATE`, which the PEP enforces as a denial, so a transient PDP outage can never accidentally grant access. One-shot requests (`DecideOnceAsync`, `MultiDecideAllOnceAsync`) fail closed to `INDETERMINATE` immediately, with no retry, and never throw. The returned `Task` always completes with a decision. In steady state the connection is warm, so only a cold or dropped connection fails closed. Subscriptions (the streaming `Decide`) never terminate on a transport problem or on a server-side stream completion. The returned `IAsyncEnumerable` never throws or ends for a transport condition. Either condition yields one `INDETERMINATE` and then reconnects with bounded exponential backoff, indefinitely. Consecutive identical decisions are de-duplicated, so an outage yields a single `INDETERMINATE`, not a flood. A subscription ends only when the consumer cancels it or the client shuts down. This contract holds identically across the HTTP and RSocket transports and across every SAPL PEP client. ### Demo Application A complete working demo is available at [sapl-dotnet-demos](https://github.com/heutelbeck/sapl-dotnet-demos). It includes: - Manual PDP access (no attributes) - `[PreEnforce]` and `[PostEnforce]` with content filtering and field redaction - Service-layer enforcement using `DispatchProxy` and interface attributes - Constraint handlers across every signal and shape (decision/input/output/error, runner/consumer/mapper) - SSE streaming with the three semantics (till-denied, silent-suspending, observed-suspending) - JWT-based ABAC ### Configuration Reference All options are set via `PdpClientOptions`, either inline or from configuration: | Property | Type | Default | Description | | ----------------------------- | ------ | --------------------------- | --------------------------------------------------------- | | `BaseUrl` | `string` | `"https://localhost:8443"` | PDP server URL | | `Token` | `string?` | `null` | Bearer token for authentication | | `Username` | `string?` | `null` | Basic auth username (mutually exclusive with `Token`) | | `Secret` | `string?` | `null` | Basic auth password | | `TimeoutMs` | `int` | `5000` | PDP request timeout in milliseconds | | `StreamingRetryBaseDelayMs` | `int` | `1000` | Base delay for exponential backoff on reconnection | | `StreamingRetryMaxDelayMs` | `int` | `30000` | Maximum delay between reconnection attempts | Streaming retries use exponential backoff with jitter. The delay doubles on each attempt up to the maximum, with random jitter between 50% and 100% of the calculated delay. After 5 consecutive failures, log severity escalates from Warning to Error. ### Troubleshooting | Symptom | Likely Cause | Fix | | ------------------------------------ | ------------------------------------- | ----------------------------------------------------------------- | | All decisions are INDETERMINATE | PDP unreachable | Check `BaseUrl` and that the PDP is running | | 403 despite PERMIT decision | Unhandled obligation | Check a provider's `GetConstraintHandlers` returns a handler for the obligation `type` | | Handler not firing | Missing registration | Call `AddSaplConstraintHandler()` in `Program.cs` | | Subject is `"anonymous"` | No authenticated user | Configure ASP.NET Core authentication middleware and JWT validation | | Content filter throws | Invalid JSONPath | Paths resolve through Newtonsoft `SelectToken`; check the JSONPath syntax (recursive descent, array indexing, wildcards, and filter expressions are all supported) | | Service method throws `AccessDeniedException` | Normal denial behavior | Register `UseSaplAccessDenied()` middleware for automatic 403 | | Streaming SSE empty | Action does not return `IAsyncEnumerable` | Ensure streaming methods return `IAsyncEnumerable` | | HTTP 500 on service denial | Missing middleware | Add `app.UseSaplAccessDenied()` before `app.MapControllers()` | ### License Apache-2.0 ## PHP SDK Attribute-Based Access Control (ABAC) for Symfony using SAPL (Streaming Attribute Policy Language). The `sapl/sapl-php` bundle provides attribute-driven policy enforcement on controller actions and service methods, a constraint handler architecture for obligations and advice, continuous authorization for streaming responses, and optional data-layer query rewriting through Doctrine. The library connects to a SAPL Node Policy Decision Point (PDP) over HTTP, with Server-Sent Events for continuous decisions. It enforces three attributes, `#[PreEnforce]`, `#[PostEnforce]`, and `#[StreamEnforce]`, and wires itself into the Symfony container through a single bundle. ### What is SAPL? SAPL is a policy language and Policy Decision Point for attribute-based access control. Policies are written in a dedicated language and evaluated by the PDP, which streams authorization decisions based on subject, action, resource, and environment attributes. Three core concepts: - **Authorization subscription.** A question to the PDP, carrying the subject, the action, the resource, and optional environment. - **Authorization decision.** The answer: `PERMIT`, `DENY`, `INDETERMINATE`, `NOT_APPLICABLE`, or `SUSPEND`, optionally with obligations and advice. - **Obligations and advice.** Conditions attached to a decision. An obligation must be fulfilled or the decision flips to deny. Advice is best-effort. ## How sapl/sapl-php Works The bundle subscribes to the Symfony `kernel.controller_arguments` event, the last point at which the resolved controller can be wrapped. When a controller or service method carries one of the enforcement attributes, the bundle replaces the call with a wrapper that runs the matching enforcement point around it. For `#[PreEnforce]`, the wrapper builds an authorization subscription, asks the PDP, and runs the method only on a `PERMIT` whose obligations are all handled. For `#[PostEnforce]`, the method runs first and its return value becomes part of the subscription, so the policy can decide on the actual result. For `#[StreamEnforce]`, the method returns a stream and the wrapper enforces a live decision stream over it. Enforcement is fail-closed. A `DENY`, an `INDETERMINATE`, a `NOT_APPLICABLE`, or an unhandled obligation results in an `AccessDeniedException`, which Symfony renders as `403`. ## Installation ```bash composer require sapl/sapl-php ``` The library requires PHP 8.3 or later and Symfony 7.3. It uses ReactPHP (`react/http`, `react/async`) for the streaming transport. The Doctrine query-rewriting shims are optional and pull in `doctrine/orm` or `doctrine/mongodb-odm` only if you use them. Register the bundle in `config/bundles.php` (Symfony Flex does this automatically): ```php return [ // ... Sapl\Symfony\SaplBundle::class => ['all' => true], ]; ``` ## Configuration Configuration lives under the `sapl` key. Only `pdp.base_url` is required. ```yaml # config/packages/sapl.yaml sapl: pdp: base_url: '%env(SAPL_PDP_BASE_URL)%' # required, the SAPL Node URL token: null # bearer token for the PDP (optional) username: null # HTTP Basic username (optional) secret: null # HTTP Basic password (optional) timeout: 5.0 # request timeout in seconds verify_peer: true # verify the PDP TLS certificate ``` ```dotenv # .env SAPL_PDP_BASE_URL=https://localhost:8443 ``` Plain `http` base URLs are restricted to loopback hosts. Use `https` for any non-local PDP. ## Authenticating to the PDP The client presents one of three credentials to the SAPL Node, matching the node's configured authentication: - **None.** Leave `token`, `username`, and `secret` unset. The node must allow unauthenticated access. - **Bearer token.** Set `token`. The client sends `Authorization: Bearer `. - **HTTP Basic.** Set `username` and `secret`. The client sends `Authorization: Basic ...`. For rotating credentials, implement `Sapl\Pdp\Auth\TokenProvider` and register it as the `sapl.token_provider` service. The client calls `accessToken()` for each request and `invalidate()` on a `401` or `403`, so a refreshed token is fetched on the next call. ```php interface TokenProvider { public function accessToken(): string; public function invalidate(): void; } ``` ## Enforcement Attributes All three attributes target a method or a class and accept the same subscription fields. Each field is a literal value or a `Sapl\Symfony\Expression`, evaluated against a context of `subject`, `args` (the method arguments by name), and `request`. `#[PostEnforce]` additionally exposes `returnValue`. When a field is omitted, sensible defaults apply: the subject comes from the `SubjectResolver`, the action from the method name, and the resource from the class and method. ### `#[PreEnforce]` Enforces before the method runs. On a non-`PERMIT` decision or an unhandled obligation, the method never executes. ```php use Sapl\Symfony\PreEnforce; #[Route('/api/patient/{id}', methods: ['GET'])] #[PreEnforce(action: 'readPatient', resource: 'patient')] public function patient(string $id): array { return Patients::byId($id) ?? throw new NotFoundHttpException('Patient not found'); } ``` ### `#[PostEnforce]` Runs the method first, then decides with the return value in scope. Use it when the policy depends on the actual resource, for example row-level checks on a fetched record. ```php use Sapl\Symfony\PostEnforce; use Sapl\Symfony\Expression; #[PostEnforce( action: 'getPatientDetail', resource: new Expression("{'type': 'patientDetail', 'data': returnValue}"), )] public function getPatientDetail(string $id): ?array { return Patients::byId($id); } ``` ### `#[StreamEnforce]` Enforces a continuous decision over a streaming response. See [Streaming Enforcement](#streaming-enforcement) below. In addition to the subscription fields it accepts two flags, `signalTransitions` and `pauseRapDuringSuspend`. ### Subject Resolution When an attribute has no explicit `subject`, the subject is taken from the `Sapl\Symfony\SubjectResolver` service. The default `TokenStorageSubjectResolver` reads the current Symfony security token. An authenticated user becomes `{ "username": , "roles": [...] }`, and an unauthenticated request becomes the string `"anonymous"`. Override the behaviour by aliasing `SubjectResolver` to your own implementation. ## Constraint Handlers A decision can carry obligations and advice. The bundle resolves them to handlers through providers implementing `Sapl\Pep\Constraints\ConstraintHandlerProvider`: ```php interface ConstraintHandlerProvider { /** * @param list $supportedSignals * @return list */ public function getConstraintHandlers(mixed $constraint, array $supportedSignals): array; } ``` Tag a provider service with `sapl.constraint_handler_provider` and it is picked up automatically. An obligation with no matching handler fails closed and the decision is denied. Advice with no handler is ignored. The built-in `ContentFilteringProvider` handles the `filterJsonContent` obligation, redacting the response with `blacken`, `replace`, or `delete` actions on JSON paths before it is returned. ## Query Rewriting The bundle ships two data-layer shims that narrow the rows an enforced method reads, rather than filtering them in memory. The Doctrine ORM filter honours the `sql:queryRewriting` obligation and the Doctrine ODM filter honours `mongo:queryRewriting`. Both are PreEnforce-only and fail closed. See [Query Rewriting](../6_12_QueryRewriting/) for the obligation schema, the shared semantics, and the Doctrine setup, including the `columns`-against-entities caveat. ```php #[Route('/sql/patients', methods: ['GET'])] #[PreEnforce(subject: new Expression('subject["username"]'), action: 'readSqlPatients', resource: 'patients')] public function patients(): array { // The policy attaches a sql:queryRewriting obligation that narrows the rows // this repository call returns at the database, per subject. return $this->entityManager->getRepository(PatientRecord::class)->findBy([], ['id' => 'ASC']); } ``` ## Database Transactions A `#[PreEnforce]` method's output obligations, and the entire `#[PostEnforce]` decision, run *after* the protected method has executed and flushed to the database. Without a surrounding transaction, a denial at that point cannot undo the write: the row is committed, the caller gets a 403, and the database is left inconsistent. SAPL closes this with an opt-in transaction boundary backed by Doctrine. Set `transactional: true` (the default is `false`); when Doctrine ORM is present the bundle wires a provider built on `EntityManager::wrapInTransaction()`: ```yaml # config/packages/sapl.yaml sapl: transactional: true pdp: base_url: '%env(SAPL_PDP_BASE_URL)%' ``` With it enabled, both enforcement paths (the controller subscriber and the service proxy) wrap the method invocation and the enforcement stages in a single Doctrine transaction. The transaction commits only on a clean `PERMIT` with every obligation discharged; otherwise the `AccessDeniedException` propagates out of the boundary and the flush is rolled back before it becomes a 403. Three situations roll back: - a `#[PostEnforce]` decision that is not `PERMIT`, - a `#[PostEnforce]` obligation handler failure (at the decision or the output stage), - a `#[PreEnforce]` output-obligation failure (the pre-decision permits, but its output obligations run after the method has written). This is opt-in. With `transactional: false` (the default) the PEP owns no transaction and enforcement behaves exactly as before. The boundary is on the Doctrine `EntityManager`. Any write flushed through it anywhere in the call chain, including in nested services the protected method calls, is inside the transaction and rolls back. Native SQL, a raw DBAL connection, or a write that bypasses the EntityManager is outside the boundary and is not rolled back. Per Doctrine, after a rollback the EntityManager is closed; this is harmless here because the request is being denied and ends with a 403. ## Streaming Enforcement A `#[StreamEnforce]` method returns a ReactPHP `React\Stream\ReadableStreamInterface`. The bundle opens a decision stream from the PDP and gates the method's item stream on it. ```php use Sapl\Symfony\StreamEnforce; use React\Stream\ReadableStreamInterface; #[Route('/api/streaming/heartbeat/till-denied', methods: ['GET'])] #[StreamEnforce(action: 'stream:terminate', resource: 'heartbeat')] public function tillDenied(): ReadableStreamInterface { return Heartbeat::source(); } ``` The decision verb steers the stream: - **PERMIT** lets items flow. - **DENY**, **INDETERMINATE**, or **NOT_APPLICABLE** terminates the stream. - **SUSPEND** pauses output. The next **PERMIT** resumes it. Two flags tune the suspend behaviour: - `pauseRapDuringSuspend` (default `false`). When `true`, the underlying item stream is paused during a suspend, so items produced while suspended are held and delivered on resume rather than dropped. - `signalTransitions` (default `false`). When `true`, the output stream also emits suspend and resume boundary signals, so a subscriber can react to the transitions. ```php #[StreamEnforce(action: 'stream:suspend', resource: 'heartbeat', pauseRapDuringSuspend: true, signalTransitions: true)] public function observedSuspending(): ReadableStreamInterface { return Heartbeat::source(); } ``` ## Manual PDP Access For full control, inject `Sapl\Pdp\PolicyDecisionPoint` (the bundle binds it to the `HttpPdpClient`) and call it directly: - `decideOnce(AuthorizationSubscription): AuthorizationDecision`. One-shot decision. Fails closed to `INDETERMINATE` on any transport error. - `multiDecideAllOnce(MultiAuthorizationSubscription): MultiAuthorizationDecision`. One-shot batch. - `decide(AuthorizationSubscription): ReadableStreamInterface`. Continuous stream, reconnecting with backoff. - `multiDecide(...)` and `multiDecideAll(...)`. Streaming multi-subscription variants. ## Transport and Resilience The PHP SDK talks to the PDP over HTTP only. One-shot calls use the Symfony HTTP client. Streaming calls use ReactPHP and consume Server-Sent Events, with no response timeout so a long-lived decision stream stays open. One-shot calls do not retry. They fail closed to `INDETERMINATE` so a transport error never leaks into a `PERMIT`. Streaming calls reconnect on failure with bounded exponential backoff, controlled by `retryBaseDelaySeconds` and `retryMaxDelaySeconds` on the client options. ## Query Rewriting Many applications want to filter results at the database, not in memory. SAPL supports this with query rewriting: a policy attaches a query-rewriting obligation, and the SAPL integration for your database intercepts the query your application issues, applies the obligation, and sends the rewritten query to the driver. Your data-access code does not change. You enforce on the calling method as usual, and the obligation does the rest. Two backends are supported today: relational databases (SQL) and MongoDB. The obligation is identical across every SDK that supports a backend, so the same policy works unchanged on every SAPL Policy Enforcement Point (PEP) for that backend. ## How It Works You apply enforcement (`@PreEnforce` in Spring, `@pre_enforce` in the Python SDKs, `#[PreEnforce]` in PHP) to the service or handler method as usual, and the policy attaches a query-rewriting obligation. While that decision is being enforced, the queries your code issues are intercepted, the obligation is applied, and the rewritten query is forwarded to the driver. When no enforcement is active, the query passes through unchanged. There is no global filter. The obligation applies only inside the protected call, so the same repository or collection called outside an enforced method runs unfiltered. Three rules hold for every backend and SDK: - **Narrowing only.** The obligation can never widen the user's filter, only narrow it. It is combined with whatever the user already requested using `AND`, so if the user asked for `category = 'art'` and the obligation adds `tenant_id = 7`, the database returns only rows where both hold. The obligation never overrides a field the user is already filtering on. - **Fail closed.** An unsupported or malformed obligation is rejected, and the decision is denied. - **Register before you rely on it.** The integration must be registered before its obligations take effect. If a decision carries a query-rewriting obligation but the matching integration is not registered, SAPL denies the decision rather than silently ignoring the obligation. So registering the integration is mandatory wherever you write query-rewriting policies. ## The Obligation ### SQL: `sql:queryRewriting` For relational backends the obligation's `type` is `sql:queryRewriting` (the alias `relational:queryRewriting` is accepted as a synonym). It carries three optional parts. ```jsonc { "type": "sql:queryRewriting", "criteria": [], // typed criteria, AND-joined at top level "conditions": [], // raw SQL fragments, AND-joined "columns": [] // SELECT projection narrowing } ``` A typed criterion is a JSON object with `column`, `op`, and `value`. ```json { "column": "status", "op": "=", "value": "active" } ``` The supported operators are `=`, `!=`, `>`, `>=`, `<`, `<=`, `in` (with an array `value`), `like`, `notLike`, `isNull`, and `isNotNull`. The `isNull` and `isNotNull` operators do not need a `value`. Criteria can be grouped with `and` and `or`, and groups can be nested. Each top-level entry in the `criteria` array is combined with the others using `AND`. ```json [ { "column": "tenant_id", "op": "=", "value": 7 }, { "or": [ { "column": "owner_id", "op": "=", "value": "alice" }, { "column": "is_public", "op": "=", "value": true } ]} ] ``` The `conditions` array carries raw SQL fragments for features the typed language does not cover, such as `BETWEEN`, `EXISTS`, or vendor functions. ```json { "conditions": [ "created_at > CURRENT_TIMESTAMP - INTERVAL '7 days'" ] } ``` The `columns` array narrows the `SELECT` projection. For `SELECT *` the obligation columns become the projection, and for an explicit projection they intersect with it. `columns` applies only to `SELECT`. For `UPDATE` and `DELETE` it is ignored. ### MongoDB: `mongo:queryRewriting` For MongoDB the obligation's `type` is `mongo:queryRewriting`. The schema mirrors the SQL form, minus the `columns` projection feature. ```jsonc { "type": "mongo:queryRewriting", "criteria": [], // typed criteria, AND-joined at top level "conditions": [] // raw BSON fragments, AND-joined } ``` The typed criteria language accepts the same operators as SQL except `like` and `notLike`. For pattern matching use the `conditions` escape hatch with `$regex`. ```json { "type": "mongo:queryRewriting", "criteria": [ { "column": "tenantId", "op": "=", "value": 7 } ] } ``` `conditions` carries raw MongoDB query fragments, each combined with the user's query inside a top-level `$and`. ```json { "conditions": [ "{ \"name\": { \"$regex\": \"^A\" } }" ] } ``` Condition fragments must be valid JSON (double-quoted), not MongoDB shell syntax. Every SAPL MongoDB integration parses them with a strict JSON parser, so a single-quoted or unquoted fragment is rejected (and the decision denied) identically everywhere. This is what lets a `mongo:queryRewriting` obligation behave the same on Spring and in Python. ### Shared Semantics - Typed criteria are added as extra conditions combined with the user's query using `AND`, so they never conflict with a field the user is already filtering on. - `conditions` fragments are combined with the user's query inside a top-level `$and` (or `AND`-ed into the SQL `WHERE`). The original query is preserved. - The obligation can only narrow access, never widen it. - A malformed criterion, an unsupported statement, or (for MongoDB) a non-JSON condition causes the decision to be denied. - **Portability across PEPs.** Because the obligation and its behaviour are identical across SDKs, the same obligation produces the same narrowing on every PEP for that backend. A `mongo:queryRewriting` obligation authored once works unchanged on the Spring MongoDB integration, the Python `sapl_pymongo` integration, the NestJS Mongoose integration, and the PHP Doctrine ODM integration. The same holds for `sql:queryRewriting` across the SQL integrations, with two integration caveats. The NestJS Prisma integration supports the typed `criteria` and `columns` but not the raw-SQL `conditions` escape hatch, since Prisma's `where` is structured rather than SQL. The PHP Doctrine ORM integration supports `criteria` and `conditions` but not `columns`, since a Doctrine query hydrates entities and cannot narrow its projection without changing the result shape. ## Integrations ### Spring (R2DBC and MongoDB) The SAPL Spring Boot starter activates a transparent integration when it sees `R2dbcRepository` or `ReactiveMongoTemplate` on the classpath. It wraps `DatabaseClient` for R2DBC and `ReactiveMongoTemplate` for MongoDB. Every query path (derived queries, `@Query` methods, direct `databaseClient.sql(...)` or template calls) ultimately runs through the wrapped bean. You annotate the calling service method with `@PreEnforce`, and no repository annotations are needed. Each integration has its own opt-out property, both default `true`. ```properties # Disable the R2DBC integration io.sapl.method-security.r2dbc-shim.enabled=false # Disable the MongoDB integration io.sapl.method-security.mongo-shim.enabled=false ``` The R2DBC integration rewrites every statement that runs through the wrapped `DatabaseClient` (derived queries, `@Query` methods, `R2dbcEntityTemplate` calls, and direct `databaseClient.sql(...)`), adding `criteria` and `conditions` as `WHERE` predicates via JSqlParser and narrowing the `columns` projection on a `SELECT` by intersection (never widening; ignored on other statements). A malformed criterion or a statement JSqlParser cannot rewrite is denied. The MongoDB integration rewrites the `find`, `findOne`, `exists`, `count`, and `remove` family and the fluent `ReactiveFindOperation` chain, which back the reactive repository CRUD surface, merging `criteria` and JSON `conditions` into the query filter. A non-JSON condition is denied. Database access that does not run through the wrapped bean is not intercepted: a raw R2DBC `ConnectionFactory` or JDBC connection, an aggregation pipeline or any non-`find` MongoDB operation, or a second unwrapped `DatabaseClient` or `ReactiveMongoTemplate`. That is the fail-open path you must account for, the Spring equivalent of off-session access: once the integration is registered the obligation is accepted, so these paths run unfiltered rather than denied. Keep enforced reads on the wrapped bean. ### Python: SQLAlchemy Use the `sapl_sqlalchemy` package with any of the Python SDKs (FastAPI, Flask, Tornado, Django on SQLAlchemy). Register the listener and the provider once at startup. ```python from sapl_sqlalchemy import SqlQueryRewritingProvider, register_orm_listener from sapl_fastapi import register_provider # or the register_provider of your SDK register_orm_listener() register_provider(SqlQueryRewritingProvider()) ``` `register_orm_listener()` attaches to the SQLAlchemy `Session` class, so it covers every session including `AsyncSession` through its sync-session proxy, and registers the integration so it can satisfy a `sql:queryRewriting` obligation. Until you call it, a decision carrying that obligation is denied. The integration hooks into the `do_orm_execute` ORM event, which fires for every query a session runs. A `Select`, an ORM `Update`, and an ORM `Delete` get the authorised `WHERE` predicate added, and a column-typed select gets its projection narrowed. Raw `text()` run through the session, a set operation such as `UNION` combined with predicates, and a column projection against an entity-typed select are rejected, and the decision is denied. Execution that bypasses the ORM session entirely (SQLAlchemy Core `engine.execute()`, a raw DBAPI cursor) never triggers the event, so no filter is applied. This is a fail-open path you must account for: once the integration is registered the obligation is accepted, so off-session access is left unfiltered rather than denied. Off-session database access means you own row-level security manually for that path. ### Python: Django ORM For applications on Django's native ORM, `sapl_django` ships a Django-specific provider. ```python from sapl_django import DjangoQueryRewritingProvider, register_orm_listener, register_provider register_orm_listener() register_provider(DjangoQueryRewritingProvider()) ``` The provider translates `criteria` into Django `Q` objects, `conditions` into a raw `WHERE` via `add_extra`, and `columns` into `.only()`. It hooks into `SQLCompiler.execute_sql`, which fires for every query in an enforced call, including prefetch and cascade-delete selects against other models. A query is a target only when its model carries the columns the criteria reference (or the projection columns). Non-target queries pass through unchanged, so an unrelated model is never given a column it lacks. A column projection through `.only()` defers the other fields rather than blocking them, and a deferred field still loads lazily on first access. Treat `columns` as a projection for efficiency, not as hard column-level access control. For hard column security, pair it with content filtering on the response. ### Python: MongoDB (PyMongo) For applications using the PyMongo asynchronous driver (`AsyncMongoClient`), `sapl_pymongo` provides the MongoDB integration. PyMongo has no central hook for rewriting queries, so the integration works by wrapping each collection. Wrap each collection once at startup, which also registers the integration, and register the provider. ```python from sapl_pymongo import MongoDbQueryRewritingProvider, wrap_async_collection from sapl_fastapi import register_provider # or the register_provider of your SDK register_provider(MongoDbQueryRewritingProvider()) widgets = wrap_async_collection(database["widgets"]) # wraps and registers the integration ``` Use `wrap_collection` for a synchronous `Collection` (the blocking enforcement path) and `wrap_async_collection` for an `AsyncCollection`. The wrapper covers `find`, `find_one`, `aggregate`, `count_documents`, `update_*`, and `delete_*`. Each applies the obligation to the query before passing it to the driver. An aggregation pipeline cannot be narrowed by this obligation, so it is rejected (and the decision denied), as is a malformed condition. Because wrapping the collection is what registers the integration, you cannot enable it without also installing the interception. A collection used without wrapping, or a raw `database.command(...)`, is not intercepted: that is the fail-open path you must account for, the MongoDB equivalent of off-session SQL access. Wrap every collection an enforced method may reach. ### NestJS: Mongoose For NestJS applications on Mongoose, `@sapl/nestjs/mongoose` provides the MongoDB integration. Register the shim once at startup, apply the plugin to your schemas, and register the provider in your module. ```ts import { registerMongooseShim, createSaplMongoosePlugin, MongoDbQueryRewritingProvider } from '@sapl/nestjs/mongoose'; registerMongooseShim(); // advertise the obligation mongoose.plugin(createSaplMongoosePlugin(cls)); // or schema.plugin(...) per schema; cls is the nestjs-cls ClsService // add MongoDbQueryRewritingProvider to your SAPL module's providers ``` The plugin hooks Mongoose query middleware for `find`, `findOne`, `countDocuments`, `update*`, and `delete*`, applying the obligation to the filter before the driver runs it. An aggregation pipeline cannot be narrowed by this obligation, so it is rejected (and the decision denied), as is a malformed condition. The plugin reads the active enforcement plan from the request-scoped CLS context the `@PreEnforce` PEP populates, so no repository changes are needed. You annotate the calling service method with `@PreEnforce` as usual. Until `registerMongooseShim()` runs, a decision carrying a `mongo:queryRewriting` obligation is denied. A schema without the plugin is not intercepted: that is the fail-open path you must account for. Apply the plugin to every schema an enforced method may reach. ### NestJS: Prisma For NestJS applications on Prisma, `@sapl/nestjs/prisma` provides the SQL integration. Register the shim, extend your Prisma client, and register the provider. ```ts import { registerPrismaShim, createSaplPrismaExtension, SqlQueryRewritingProvider } from '@sapl/nestjs/prisma'; registerPrismaShim(); const prisma = basePrismaClient.$extends(createSaplPrismaExtension(cls)); // cls is the nestjs-cls ClsService // add SqlQueryRewritingProvider to your SAPL module's providers ``` The extension hooks Prisma's `$allOperations` for filter operations (`findMany`, `findFirst`, `count`, `aggregate`, `groupBy`, `updateMany`, `deleteMany`), AND-merging the obligation's `criteria` into the operation's `where` and narrowing `columns` to a `select`. Prisma's `where` is structured rather than SQL, so the `conditions` escape hatch cannot be lowered and is rejected (the decision denied). Policies targeting Prisma use typed `criteria`. A unique-key operation (`findUnique`, `update`, `delete`, `upsert`) cannot be safely AND-narrowed, so it is denied while an obligation is active. Use `findFirst`, `updateMany`, or `deleteMany` instead. Operations without a filter (`create`, `createMany`) pass through. Until `registerPrismaShim()` runs, a decision carrying a `sql:queryRewriting` obligation is denied. A client used without the extension is not intercepted: that is the fail-open path you must account for. Extend every client an enforced method may reach. ### PHP: Doctrine (ORM and ODM) For Symfony applications the `sapl/sapl-php` bundle integrates with Doctrine. It uses the Doctrine ORM `SQLFilter` for relational backends and the Doctrine ODM `BsonFilter` for MongoDB. Unlike the other integrations, which intercept the query your code issues, the Doctrine filters are pull-based. Doctrine calls the filter and AND-merges the returned predicate into the root entity, every join, and every subquery on its own. The integration contributes a narrowing predicate rather than rewriting a query string. The bundle registers the providers automatically when the Doctrine packages are present. You register and enable the filter in your Doctrine configuration. ```yaml # relational (Doctrine ORM) doctrine: orm: filters: sapl_sql: class: Sapl\Doctrine\Orm\SaplSqlFilter enabled: true # MongoDB (Doctrine ODM) doctrine_mongodb: document_managers: default: filters: sapl_mongo: class: Sapl\Doctrine\Odm\SaplBsonFilter enabled: true ``` You annotate the calling service or controller method with `#[PreEnforce]` as usual. The filter applies only while that decision is being enforced and is inert otherwise. Both shims are PreEnforce-only. The SQL filter honours `sql:queryRewriting` (and the `relational:queryRewriting` alias) with the typed `criteria` and the raw-SQL `conditions` escape hatch. It does not support the `columns` projection. A Doctrine ORM query hydrates entities, so narrowing the SELECT list would change the result shape, and an obligation carrying `columns` is rejected (the decision denied). This matches the Python SQLAlchemy integration, which likewise rejects a column projection against an entity-typed select. The Mongo filter honours `mongo:queryRewriting` with typed `criteria` and strict-JSON `conditions`. An aggregation pipeline cannot be narrowed and is rejected (the decision denied). Until the filter is registered and enabled, a decision carrying the matching obligation is denied. Native SQL, a raw DBAL connection, or any read that bypasses the Doctrine filter is not intercepted. That is the fail-open path you must account for, the Doctrine equivalent of off-session access. Keep enforced reads on the ORM or ODM. ## HTTP and RSocket API The SAPL PDP server exposes two network APIs for authorization decisions: HTTP/JSON and RSocket/protobuf. Both offer the same five operations with identical semantics. HTTP is the default and works with any HTTP client. RSocket is an optional high-performance transport using binary protobuf serialization over persistent TCP or Unix domain socket connections. ## HTTP API The HTTP API requires no SDK. Any application that can make HTTP requests can use the PDP. All endpoints accept `POST` requests with `application/json` bodies. Streaming endpoints return `text/event-stream` (Server-Sent Events). One-shot endpoints return `application/json`. All endpoints are located under a shared base URL, typically `https://:/api/pdp/`. ### Endpoint Overview | Endpoint | Method | Response Content-Type | Behavior | |----------|--------|----------------------|----------| | `/api/pdp/decide` | POST | `text/event-stream` | Streaming decisions for a single subscription | | `/api/pdp/decide-once` | POST | `application/json` | One-shot decision for a single subscription | | `/api/pdp/multi-decide` | POST | `text/event-stream` | Streaming individual decisions for multiple subscriptions | | `/api/pdp/multi-decide-all` | POST | `text/event-stream` | Streaming batch decisions for multiple subscriptions | | `/api/pdp/multi-decide-all-once` | POST | `application/json` | One-shot batch decisions for multiple subscriptions | ### Authentication All endpoints require authentication. SAPL Node supports four authentication modes that can be combined: | Mode | Header | Configuration | |------|--------|--------------| | Unauthenticated | (none) | `allow-no-auth: true` (development only) | | Basic Auth | `Authorization: Basic ...` | `allow-basic-auth: true` + user entries | | API Key | `Authorization: Bearer sapl_...` | `allow-api-key-auth: true` + user entries | | OAuth2 / JWT | `Authorization: Bearer ` | `allow-oauth2-auth: true` + issuer URI | Generate credentials with the SAPL CLI: ```shell sapl generate basic --id service-a --pdp-id default sapl generate apikey --id service-b --pdp-id production ``` For full authentication configuration, TLS setup, and multi-tenant routing, see [Security](../7_6_Security/). ### Authorization Subscription Format A single authorization subscription is a JSON object with three required fields and two optional fields: ```json { "subject": { "username": "alice", "role": "doctor", "department": "cardiology" }, "action": "read", "resource": { "type": "patient_record", "patientId": 123 }, "environment": { "timestamp": "2025-10-06T14:30:00Z", "ipAddress": "192.168.1.42" }, "secrets": { "jwt": "eyJhbGciOi..." } } ``` - **subject** (required): Who is making the request. Any JSON value (string, number, object, array, boolean, or null). - **action** (required): What operation is being attempted. Any JSON value. - **resource** (required): What is being accessed. Any JSON value. - **environment** (optional): Additional context such as time, location, or IP address. Any JSON value. - **secrets** (optional): Sensitive data for Policy Information Points (tokens, API keys, credentials). Any JSON value. Not included in logs or traces. For the full subscription format, see [Authorization Subscriptions](../2_1_AuthorizationSubscriptions/). ### Authorization Decision Format Every endpoint returns authorization decisions as JSON objects: ```json { "decision": "PERMIT", "obligations": [ { "type": "log_access", "message": "Patient record accessed by alice" } ], "advice": [ { "type": "notify", "channel": "audit" } ], "resource": { "type": "patient_record", "patientId": 123, "name": "***REDACTED***" } } ``` - **decision** (always present): One of `PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, or `NOT_APPLICABLE`. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for the per-decision PEP semantics, including the `SUSPEND` pause-vs-terminal distinction. - **obligations** (optional): An array of JSON objects. Instructions the PEP **must** enforce before acting on the decision. If a PEP cannot fulfill any obligation, it must deny access regardless of the decision. - **advice** (optional): An array of JSON objects. Suggestions the PEP **should** follow but may ignore without affecting the authorization outcome. - **resource** (optional): A JSON value that replaces the original resource data (e.g., with fields redacted or transformed). A minimal decision contains only the `decision` field: ```json { "decision": "DENY" } ``` For details on how PEPs must handle obligations and advice, see [Authorization Decisions](../2_3_AuthorizationDecisions/). ### Single Subscription Endpoints #### Decide (Streaming) ``` POST {baseURL}/decide Content-Type: application/json Accept: text/event-stream ``` Returns an initial decision, then pushes updated decisions whenever policies, attributes, or conditions change. Each SSE event contains a complete authorization decision in its `data` field. The server may send SSE comment events (`: keep-alive`) to keep the connection alive. The client must close the connection to stop receiving updates. **Request body:** ```json { "subject": "alice", "action": "read", "resource": "document" } ``` **Response** (Server-Sent Events, one event per decision change): ``` data: {"decision":"PERMIT"} data: {"decision":"DENY","obligations":[{"type":"log_access","reason":"policy changed"}]} : keep-alive ``` **Example with curl:** ```shell curl -N -X POST http://localhost:8080/api/pdp/decide \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sapl_..." \ -d '{"subject":"alice","action":"read","resource":"document"}' ``` **Example with the SAPL CLI** (streams decisions as NDJSON): ```shell sapl decide --remote --insecure --url http://localhost:8080 --token sapl_... \ -s '"alice"' -a '"read"' -r '"document"' ``` Sending credentials over a plaintext `http://` connection is refused by default. The `--insecure` flag accepts that risk for local development. Drop it and use an `https://` URL in production. #### Decide Once (One-Shot) ``` POST {baseURL}/decide-once Content-Type: application/json Accept: application/json ``` Returns a single authorization decision and closes the connection. Use this for request-response scenarios where continuous updates are not needed. **Request body:** ```json { "subject": { "username": "alice", "role": "doctor" }, "action": "read", "resource": { "type": "patient_record", "patientId": 123 } } ``` **Response:** ```json { "decision": "PERMIT", "obligations": [ { "type": "log_access", "message": "Patient record accessed" } ] } ``` **Example with the SAPL CLI:** ```shell sapl decide-once --remote --insecure --url http://localhost:8080 --token sapl_... \ -s '{"username":"alice","role":"doctor"}' -a '"read"' -r '{"type":"patient_record","patientId":123}' ``` The `sapl check` command returns an exit code instead of JSON output, making it suitable for shell scripts and CI/CD pipelines: ```shell sapl check --remote --insecure --url http://localhost:8080 --token sapl_... \ -s '"alice"' -a '"read"' -r '"document"' && echo "PERMIT" ``` For the full CLI reference, see [Command Line](../7_9_CommandLine/). ### Multi-Subscription Endpoints Multi-subscriptions bundle multiple authorization subscriptions into a single request. This is useful when a PEP needs to evaluate several authorization questions at once, for example when rendering a UI that shows multiple resources with different access levels. A multi-subscription is a JSON object mapping client-chosen subscription IDs to individual authorization subscriptions: ```json { "read-patient-record": { "subject": { "username": "alice", "role": "doctor" }, "action": "read", "resource": { "type": "patient_record", "patientId": 123 } }, "write-clinical-notes": { "subject": { "username": "alice", "role": "doctor" }, "action": "write", "resource": { "type": "clinical_notes", "patientId": 123 } }, "delete-audit-log": { "subject": { "username": "alice", "role": "doctor" }, "action": "delete", "resource": { "type": "audit_log" } } } ``` Each key is a unique subscription ID chosen by the PEP. Each value is a standard authorization subscription with `subject`, `action`, `resource`, and optionally `environment` and `secrets`. #### Multi Decide (Streaming Individual) ``` POST {baseURL}/multi-decide Content-Type: application/json Accept: text/event-stream ``` Returns individual decisions as they change, each tagged with its subscription ID. Only subscriptions whose decisions actually changed emit updates. This is efficient when most decisions remain stable. **Response** (Server-Sent Events, one event per changed decision): ``` data: {"subscriptionId":"read-patient-record","decision":{"decision":"PERMIT"}} data: {"subscriptionId":"write-clinical-notes","decision":{"decision":"PERMIT","obligations":[{"type":"log_access"}]}} data: {"subscriptionId":"delete-audit-log","decision":{"decision":"DENY"}} data: {"subscriptionId":"write-clinical-notes","decision":{"decision":"DENY"}} ``` Each event contains a `subscriptionId` identifying which subscription the decision belongs to, and a `decision` object with the authorization decision including any obligations, advice, or resource transformations. #### Multi Decide All (Streaming Batch) ``` POST {baseURL}/multi-decide-all Content-Type: application/json Accept: text/event-stream ``` Returns all decisions as a single object whenever any decision changes. Each message contains the complete current state of all decisions. **Response** (Server-Sent Events, one event per change to any decision): ``` data: {"read-patient-record":{"decision":"PERMIT"},"write-clinical-notes":{"decision":"PERMIT","obligations":[{"type":"log_access"}]},"delete-audit-log":{"decision":"DENY"}} data: {"read-patient-record":{"decision":"PERMIT"},"write-clinical-notes":{"decision":"DENY"},"delete-audit-log":{"decision":"DENY"}} ``` This format is simpler to process than individual updates because each message is a complete snapshot. The trade-off is that every message repeats all decisions, even those that have not changed. #### Multi Decide All Once (One-Shot Batch) ``` POST {baseURL}/multi-decide-all-once Content-Type: application/json Accept: application/json ``` Returns all decisions as a single JSON object and closes the connection. The format is identical to the streaming batch endpoint, but the connection closes after the first response. **Response:** ```json { "read-patient-record": { "decision": "PERMIT" }, "write-clinical-notes": { "decision": "PERMIT", "obligations": [ { "type": "log_access", "message": "Clinical notes accessed" } ] }, "delete-audit-log": { "decision": "DENY" } } ``` All multi-subscription decisions may include optional `resource`, `obligations`, and `advice` fields, as described in [Authorization Decisions](../2_3_AuthorizationDecisions/). ### OpenID Authorization API 1.0 In addition to the native endpoints above, the node exposes `POST /access/v1/evaluation` as a binding for the [OpenID Authorization API 1.0](https://openid.net/specs/authorization-api-1_0-01.html). It is a strict subset of the native API: a single one-shot evaluation per request, boolean decision, no streaming or batching. The boolean is `true` only for a `PERMIT` that carries no obligations and no transformed resource. `DENY`, `INDETERMINATE`, `NOT_APPLICABLE`, `SUSPEND` and any `PERMIT` carrying obligations or a transformed resource all map to `false`, so a vanilla OpenID PEP that ignores the response `context` cannot accidentally grant access that depended on PEP-side enforcement. Whenever the boolean is `false` the response also carries a `reason_admin` (technical: `INDETERMINATE`, `PERMIT`-needs-enforcement) or `reason_user` (subject-facing: `DENY`, `NOT_APPLICABLE`, `SUSPEND`) field per the OpenID spec. SAPL-aware clients read the response `context.sapl.*` for the full picture: the SAPL verb under `context.sapl.decision`, and any `context.sapl.{obligations,advice,resource}` slots that were populated. ### Error Handling A PEP encountering connectivity issues or errors with the PDP server must treat this as an `INDETERMINATE` decision and deny access. The PEP should reconnect using an exponential backoff strategy to avoid overloading the PDP. ### Keep-Alive Streaming connections use periodic SSE comment events (`: keep-alive`) to prevent firewalls and proxies from closing idle connections and to let the server detect clients that drop without closing. A PEP should treat a prolonged absence of any events (decisions or keep-alives) as a connection failure. ### Reverse Proxy Configuration The streaming endpoints (`/api/pdp/decide`, `/api/pdp/multi-decide`, `/api/pdp/multi-decide-all`) use SSE over long-lived HTTP POST connections. Default proxy configurations buffer responses and time out idle connections, both of which break SSE streaming. Requirements for any reverse proxy in front of SAPL Node: 1. **Disable response buffering.** SSE events must be flushed immediately. 2. **Set a long read timeout.** Streaming connections stay open indefinitely. 3. **Preserve chunked transfer encoding.** Do not add `Content-Length` headers to streaming responses. 4. **Forward the HTTP method.** All PDP endpoints use POST. SAPL Node sends periodic keep-alive frames on idle connections, every 15 seconds by default: ```yaml io.sapl.node: keep-alive: 15 ``` Set the proxy read timeout above this interval (e.g., 60 seconds). Keep-alive is always on and cannot be disabled. See [Configuration](../7_2_Configuration/) for the property reference. #### nginx ```nginx location /api/pdp/ { proxy_pass http://127.0.0.1:8080; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding on; } location /actuator/ { proxy_pass http://127.0.0.1:8080; } ``` #### Apache Enable `mod_proxy` and `mod_proxy_http`. Disable response buffering for the PDP path: ```apache ProxyPass /api/pdp/ http://127.0.0.1:8080/api/pdp/ ProxyPassReverse /api/pdp/ http://127.0.0.1:8080/api/pdp/ SetEnv proxy-sendchunked 1 SetEnv proxy-sendcl 0 ProxyTimeout 3600 ProxyPass /actuator/ http://127.0.0.1:8080/actuator/ ProxyPassReverse /actuator/ http://127.0.0.1:8080/actuator/ ``` The one-shot endpoints (`/api/pdp/decide-once`, `/api/pdp/multi-decide-all-once`) and actuator endpoints work with default proxy settings. ### Server Implementation The SAPL Policy Engine ships with **SAPL Node**, a standalone PDP server. SAPL Node supports filesystem directories, signed bundles, and remote bundle fetching as policy sources. It is available as a Docker container and as a native binary. See [SAPL Node](../7_0_SaplNode/) for deployment and configuration. ## RSocket API The RSocket API provides the same five operations as HTTP using protobuf serialization over persistent TCP or Unix domain socket (UDS) connections. It is significantly faster than HTTP/JSON for high-throughput workloads. RSocket is enabled by default on port 7000, bound to `127.0.0.1`. For server configuration, see [Configuration](../7_2_Configuration/#rsocket-properties). ### Wire Format Each RSocket payload has two parts: - **Metadata**: the route name as a UTF-8 string (e.g., `"decide-once"`) - **Data**: the protobuf-encoded request or response message No composite metadata, no MIME type negotiation. The route string alone determines the operation. ### Protobuf Specification The wire format is defined by two `.proto` files shipped in the `sapl-api-proto` module. These are the platform-independent specification. Any language with a protobuf compiler and an RSocket client library can build a compatible client from them. #### Service Definition (`sapl_service.proto`) ```protobuf service PolicyDecisionPointService { rpc Decide(AuthorizationSubscription) returns (stream AuthorizationDecision); rpc DecideOnce(AuthorizationSubscription) returns (AuthorizationDecision); rpc MultiDecide(MultiAuthorizationSubscription) returns (stream IdentifiableAuthorizationDecision); rpc MultiDecideAll(MultiAuthorizationSubscription) returns (stream MultiAuthorizationDecision); rpc MultiDecideAllOnce(MultiAuthorizationSubscription) returns (MultiAuthorizationDecision); } ``` #### Message Definitions (`sapl_types.proto`) ```protobuf message AuthorizationSubscription { Value subject = 1; Value action = 2; Value resource = 3; Value environment = 4; Value secrets = 5; } message AuthorizationDecision { Decision decision = 1; ArrayValue obligations = 2; ArrayValue advice = 3; Value resource = 4; } enum Decision { INDETERMINATE = 0; PERMIT = 1; DENY = 2; NOT_APPLICABLE = 3; SUSPEND = 4; } message Value { oneof kind { NullValue null_value = 1; bool bool_value = 2; string number_value = 3; // BigDecimal as string for precision string text_value = 4; ArrayValue array_value = 5; ObjectValue object_value = 6; bool undefined_value = 7; ErrorValue error_value = 8; } } message MultiAuthorizationSubscription { repeated IdentifiableAuthorizationSubscription subscriptions = 1; } message MultiAuthorizationDecision { map decisions = 1; } ``` Numbers are encoded as decimal strings to preserve arbitrary precision. `INDETERMINATE` is enum value 0 so that uninitialized proto3 fields default to the safe denial state. ### Operations | Route | RSocket Pattern | Request | Response | |-------|----------------|---------|----------| | `decide` | Request-Stream | `AuthorizationSubscription` | `AuthorizationDecision` (stream) | | `decide-once` | Request-Response | `AuthorizationSubscription` | `AuthorizationDecision` | | `multi-decide` | Request-Stream | `MultiAuthorizationSubscription` | `IdentifiableAuthorizationDecision` (stream) | | `multi-decide-all` | Request-Stream | `MultiAuthorizationSubscription` | `MultiAuthorizationDecision` (stream) | | `multi-decide-all-once` | Request-Response | `MultiAuthorizationSubscription` | `MultiAuthorizationDecision` | Streaming operations push updated decisions whenever policies, attributes, or context change. Unlike HTTP SSE, RSocket streams support native backpressure. ### Authentication Authentication is performed once during the RSocket connection setup frame, not per request. Credentials are encoded in the setup frame's metadata using the RSocket authentication metadata extension. | Method | Metadata Encoding | |--------|-------------------| | Basic Auth | `AuthMetadataCodec.encodeSimpleMetadata()` | | API Key / Bearer Token | `AuthMetadataCodec.encodeBearerMetadata()` | If authentication fails, the server rejects the setup with a `REJECTED_SETUP` error frame. ### Connection Lifecycle RSocket connections are persistent. Connection lifetime is bounded by credential expiry (JWT `exp` claim) and an optional server-configured maximum. The effective lifetime is the minimum of these two bounds. Expired connections are disposed by the server. Clients must reconnect. ### Error Handling All operations return `INDETERMINATE` on unparseable requests, encoding failures, or unknown routes. This matches the HTTP API's fail-safe behavior. ### Comparison | Aspect | HTTP | RSocket | |--------|------|---------| | Serialization | JSON | Protobuf | | Streaming | Server-Sent Events | Native RSocket streams | | Connection | Per-request or multiplexed | Persistent TCP or UDS | | Authentication | Per-request HTTP headers | Once at connection setup | | Backpressure | None (SSE) | Native flow control | | Interoperability | Any HTTP client | Requires RSocket + protobuf library | ## Java API The core SAPL decision types are defined in the `sapl-api` module: ```xml io.sapl sapl-api 4.1.2 ``` An application reaches a PDP in one of two ways. An **embedded PDP** runs in process and evaluates policies locally. It is Reactor-free and exposes decisions through the SAPL `Stream` primitive and synchronous one-shot calls. A **remote PDP client** connects to a SAPL Node (or any SAPL-compatible server) over HTTP or RSocket, and exposes decisions reactively as `Flux` and `Mono`. Both speak the same authorization semantics as the HTTP API, with single subscriptions (streaming and one-shot) and multi-subscriptions (streaming and batch). ### Authorization Decisions A decision is an `io.sapl.api.pdp.AuthorizationDecision`, a record with four components. | Component | Type | Description | |---------------|-----------------------|---------------------------------------------------------| | `decision` | `Decision` | One of the five decision verbs below. | | `obligations` | `ArrayValue` | Constraints the PEP must fulfil, or it denies access. | | `advice` | `ArrayValue` | Constraints the PEP should fulfil on a best-effort basis.| | `resource` | `Value` | A replacement resource, when the policy supplies one. | The `decision` is always present and carries one of five verbs. | Verb | Meaning | |------------------|--------------------------------------------------------------------------------------------------------------------| | `PERMIT` | Access is granted. | | `DENY` | Access is denied. | | `SUSPEND` | Access is paused. The subscription stays alive and may resume on a later `PERMIT`. A one-shot PEP that cannot suspend treats `SUSPEND` as `DENY`. | | `INDETERMINATE` | An error prevented a decision. | | `NOT_APPLICABLE` | No policy matched the subscription. | Singletons exist for the simple cases, for example `AuthorizationDecision.PERMIT` and `AuthorizationDecision.SUSPEND`. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for the full decision-verb semantics. ### The PDP Interfaces The two access styles correspond to two interfaces. **Embedded** uses `io.sapl.api.pdp.StreamingPolicyDecisionPoint` (the concrete embedded PDP, `BlockingPolicyDecisionPoint`, implements it). It is Reactor-free. | Method | Returns | Behaviour | |---------------------------------------------|------------------------------------------|-----------------------------------------------------------------------| | `decideOnce(AuthorizationSubscription)` | `AuthorizationDecision` | One-shot, synchronous. No Reactor on the call path. | | `decide(AuthorizationSubscription)` | `Stream` | Streaming. A SAPL `Stream`, consumed with `awaitNext()` and closed. | | `decide(MultiAuthorizationSubscription)` | `Stream` | Streaming individual. Each decision is tagged with its subscription ID.| | `decideAll(MultiAuthorizationSubscription)` | `Stream` | Streaming batch. All decisions in one object whenever any changes. | **Remote** uses `io.sapl.reactive.api.pdp.ReactivePolicyDecisionPoint`, based on Project Reactor (). | Method | Returns | Behaviour | |---------------------------------------------|----------------------------------------|--------------------------------------| | `decideOnce(AuthorizationSubscription)` | `Mono` | One-shot reactive. | | `decide(AuthorizationSubscription)` | `Flux` | Streaming. | | `decide(MultiAuthorizationSubscription)` | `Flux` | Streaming individual. | | `decideAll(MultiAuthorizationSubscription)` | `Flux` | Streaming batch. | Every method also has an overload taking a `String pdpId` for routing to a named PDP in a multi-tenant deployment. ### Embedded PDP SAPL requires Java 21 or newer and is compatible with Java 25. Configure a Java version in your project: ```xml 21 ${java.version} ${java.version} ``` Add the embedded PDP dependency: ```xml io.sapl sapl-pdp 4.1.2 ``` For snapshot builds, add the Maven Central snapshot repository: ```xml Central Portal Snapshots central-portal-snapshots https://central.sonatype.com/repository/maven-snapshots/ false true ``` For projects using multiple SAPL dependencies, import the bill of materials: ```xml io.sapl sapl-bom 4.1.2 pom import ``` Build a PDP with `PolicyDecisionPointBuilder` (package `io.sapl.pdp`). For policies bundled in your application resources (the `src/main/resources/policies` folder), use `withResourcesSource()`. For policies on the filesystem, with live-reload on changes, use `withDirectorySource()`. Custom Policy Information Points and function libraries bind through `withPolicyInformationPoint(...)` and `withFunctionLibrary(...)`: ```java import io.sapl.pdp.PDPComponents; import io.sapl.pdp.PolicyDecisionPointBuilder; // Option A: load policies from application resources (src/main/resources/policies) var components = PolicyDecisionPointBuilder.withDefaults() .withPolicyInformationPoint(new MyCustomPip()) .withFunctionLibrary(new MyFunctionLibrary()) .withResourcesSource() .build(); // Option B: load policies from a filesystem directory (with live-reload) var components = PolicyDecisionPointBuilder.withDefaults() .withDirectorySource(Path.of("/etc/sapl/policies")) .build(); ``` `build()` returns a `PDPComponents` record, which is `AutoCloseable`. Obtain the PDP with `components.pdp()`. It is a `BlockingPolicyDecisionPoint`. Create the configuration file `pdp.json` in the policies directory: ```json { "algorithm": { "votingMode": "PRIORITY_PERMIT", "defaultDecision": "DENY", "errorHandling": "ABSTAIN" }, "variables": {} } ``` Add a policy file `test_policy.sapl` in the same directory: ```sapl policy "permit reading" permit action == "read"; subject == "willi" & resource =~ "some.+"; ``` For a single synchronous decision, use `decideOnce`. It returns the `AuthorizationDecision` directly, with no Reactor on the call path: ```java try (var components = PolicyDecisionPointBuilder.withDefaults().withResourcesSource().build()) { var pdp = components.pdp(); var subscription = AuthorizationSubscription.of("willi", "read", "something"); var decision = pdp.decideOnce(subscription); System.out.println(decision.decision()); // PERMIT, DENY, SUSPEND, INDETERMINATE, or NOT_APPLICABLE } ``` For continuous decisions that update when policies or attributes change, `decide` returns a SAPL `Stream`. Read from it with `awaitNext()` and close it when finished. The `try`-with-resources block closes both the stream and the `PDPComponents`: ```java try (var components = PolicyDecisionPointBuilder.withDefaults().withResourcesSource().build()) { var pdp = components.pdp(); try (var stream = pdp.decide(subscription)) { var decision = stream.awaitNext(); System.out.println(decision.decision()); } } ``` `PDPComponents` owns the policy sources, the attribute broker, and their background threads. Always close it (directly via `close()` or through `try`-with-resources) so those resources are released. The [Embedded PDP Demo](https://github.com/heutelbeck/sapl-demos/tree/master/embedded-pdp) shows this end to end, including a custom PIP and function library. ### Remote PDP Client For a non-Spring application that connects to a SAPL Node or other remote PDP server. Both HTTP/JSON and RSocket/protobuf transports are supported: ```xml io.sapl sapl-pdp-remote 4.1.2 ``` `RemotePolicyDecisionPoint.builder()` selects the transport with `.http()` or `.rsocket()`, and returns a `ReactivePolicyDecisionPoint`: ```java import io.sapl.pdp.remote.RemotePolicyDecisionPoint; import io.sapl.reactive.api.pdp.ReactivePolicyDecisionPoint; // HTTP ReactivePolicyDecisionPoint pdp = RemotePolicyDecisionPoint.builder().http() .baseUrl("https://localhost:8443") .basicAuth("clientKey", "clientSecret") .build(); // RSocket (high-throughput protobuf transport) ReactivePolicyDecisionPoint pdp = RemotePolicyDecisionPoint.builder().rsocket() .host("localhost").port(7000) .secure() .apiKey("sapl_7f3a...") .build(); ``` Both builders expose `basicAuth(key, secret)`, `apiKey(key)`, and `oauth2(...)` for authentication. TLS differs by transport. The HTTP builder gets TLS from an `https://` base URL (it defaults to `https://localhost:8443`); use `secure(SslContext)` or `withUnsecureSSL()` only to customize certificate trust. The RSocket builder has no URL scheme, so it enables TLS via `secure()` (or `secure(SslContext)` / `withUnsecureSSL()`); it defaults `port` to `7000` and also accepts `socketPath(...)` and `keepAlive(...)`. Sending credentials over a plaintext connection (an `http://` base URL, or an RSocket connection without TLS) is refused at `build()` time. Call `allowInsecureTransport()` to accept that risk for local development, or use an `https://` URL (or RSocket `secure()`) in production. Consume decisions reactively. A streaming subscription keeps receiving updated decisions until you unsubscribe. Use `blockFirst()` or `take(1)` to consume just the first: ```java var subscription = AuthorizationSubscription.of("willi", "read", "something"); // Reactive streaming pdp.decide(subscription) .doOnNext(decision -> System.out.println(decision.decision())) .subscribe(); // One-shot, blocking on the reactive result var decision = pdp.decideOnce(subscription).block(); ``` The [Remote PDP Demo](https://github.com/heutelbeck/sapl-demos/tree/master/remote-pdp) shows HTTP and multi-subscription usage. For the RSocket wire protocol, see [RSocket API](../6_1_HTTPApi/#rsocket-api). ### Spring Boot Applications For Spring Boot applications, use the unified starter. It includes the embedded PDP, the remote PDP client, Spring Security integration, and autoconfigures the PDP: ```xml io.sapl sapl-spring-boot-starter 4.1.2 ``` By default the embedded PDP is active. To connect to a remote PDP server instead, configure the remote PDP properties (prefix `io.sapl.pdp.remote`): | Property | Type | Default | Description | |----------------------|-----------|----------|-------------------------------------------------------------------------| | `enabled` | `boolean` | `false` | Activates the remote PDP client and disables the embedded PDP. | | `type` | `String` | `"http"` | Connection transport, `http` or `rsocket`. | | `host` | `String` | | Host of the remote PDP. An HTTP base URL, or a hostname for RSocket. | | `port` | `int` | `7000` | RSocket port. | | `socketPath` | `String` | | RSocket Unix domain socket path, as an alternative to host and port. | | `tls` | `boolean` | `false` | Enables TLS on the RSocket transport. | | `key` | `String` | | Client key for basic authentication. Requires `secret`. | | `secret` | `String` | | Client secret for basic authentication. Requires `key`. | | `bearerToken` | `String` | | A SAPL API key or bearer token sent as `Authorization: Bearer`. | | `tokenRelay` | `boolean` | `false` | Forwards the incoming user's OAuth2 token to the PDP (HTTP only). | | `oauth2` | object | | OAuth2 client-credentials configuration (`clientRegistrationId`, etc.). | | `keepAlive` | duration | `20s` | RSocket keep-alive interval. | | `maxLifeTime` | duration | `90s` | RSocket connection maximum lifetime. | | `ignoreCertificates` | `boolean` | `false` | Disables TLS certificate verification. For development only. | Configure exactly one authentication method: `key` and `secret` together, `bearerToken` alone, `tokenRelay`, or `oauth2`. Example using basic authentication over HTTP: ```properties io.sapl.pdp.remote.enabled=true io.sapl.pdp.remote.type=http io.sapl.pdp.remote.host=https://pdp.example.com:8443 io.sapl.pdp.remote.key=your-client-key io.sapl.pdp.remote.secret=your-client-secret ``` Example using a bearer token over RSocket: ```properties io.sapl.pdp.remote.enabled=true io.sapl.pdp.remote.type=rsocket io.sapl.pdp.remote.host=pdp.example.com io.sapl.pdp.remote.port=7000 io.sapl.pdp.remote.tls=true io.sapl.pdp.remote.bearerToken=sapl_7f3a... ``` #### Reducing Application Footprint When using only a remote PDP, exclude the embedded PDP dependency to reduce the application size: ```xml io.sapl sapl-spring-boot-starter 4.1.2 io.sapl sapl-pdp ``` ### Deployment Options SAPL provides three ways to deploy a PDP: - **Embedded PDP**: Runs inside a JVM application with policies loaded from the classpath, a filesystem directory, or signed bundles. Suitable for single-instance applications or microservices where policies are deployed alongside the application. - **SAPL Node**: A standalone, headless PDP server that exposes the PDP over HTTP and RSocket. Supports filesystem directories, signed bundles, and remote bundle fetching. Designed for centralized policy management across multiple applications. - **Remote PDP client**: A lightweight client library that connects to a SAPL Node (or any SAPL-compatible server) over HTTP or RSocket. Applications use this when policies are managed centrally rather than embedded. ## Spring SDK This library integrates SAPL authorization into Spring Boot applications. You write authorization rules as external policy files, and SAPL enforces them at runtime without code changes or redeployment. For background on why and when to use policy-based authorization, see [Why SAPL?](../1_1_WhySAPL/). The flow is straightforward. Your application sends an authorization subscription to the Policy Decision Point (PDP). The PDP evaluates its policies and returns a decision. If the decision carries constraints (obligations or advice), constraint handlers execute the appropriate logic before the result reaches the caller. Working examples covering common scenarios are at [sapl-demos](https://github.com/heutelbeck/sapl-demos). ## Quick Start This walkthrough shows how the pieces fit together end to end. **1. Add the SAPL BOM to your `pom.xml`.** ```xml io.sapl sapl-bom 4.1.2 pom import ``` Released SAPL artifacts are available from Maven Central. If you intentionally test unreleased SAPL builds, use the matching `-SNAPSHOT` version and add the Central Portal snapshots repository. **2. Add the starter dependency.** ```xml io.sapl sapl-spring-boot-starter ``` **3. Configure the embedded PDP** in `application.properties`. ```properties io.sapl.pdp.embedded.enabled=true io.sapl.pdp.embedded.pdp-config-type=RESOURCES io.sapl.pdp.embedded.policies-path=/policies ``` This tells SAPL to run a PDP inside your application and load policies from `src/main/resources/policies/`. **4. Enable SAPL method security.** ```java @Configuration @EnableWebSecurity @EnableSaplMethodSecurity // for blocking applications // or @EnableReactiveSaplMethodSecurity for WebFlux public class SecurityConfig { } ``` **5. Annotate a method.** ```java @PreEnforce( subject = "authentication.name", action = "'read'", resource = "{ 'id': #id, 'ownerId': @bookOwnershipService.ownerOf(#id) }" ) public Book findById(Long id) { return bookRepository.findById(id); } ``` The `@bookOwnershipService` expression calls a Spring bean before the repository method runs. The policy can then compare the authenticated user with the owner of the requested book. **6. Write a policy** in `src/main/resources/policies/books.sapl`. ``` policy "users can read their own books" permit action == "read"; subject == resource.ownerId; ``` When someone calls `findById(42)`, SAPL checks whether the authenticated user owns book 42. If yes, the method runs. If no, an `AccessDeniedException` is thrown. That is the basic pattern. The annotation tells SAPL what to check. The policy decides the outcome. ## Method Security Method security is where most applications start with SAPL. You annotate methods, and SAPL intercepts the calls to enforce policies. This assumes you have `spring-boot-starter-web` (for servlet) or `spring-boot-starter-webflux` (for reactive) in your dependencies. ### Blocking Applications For servlet-based Spring Web applications, enable method security and use `@PreEnforce` or `@PostEnforce`. ```java @Configuration @EnableSaplMethodSecurity public class SecurityConfig { } ``` **`@PreEnforce`** checks authorization before the method runs. ```java @PreEnforce public void deleteBook(Long id) { bookRepository.deleteById(id); } ``` If the PDP does not return `PERMIT`, the method never executes. **`@PostEnforce`** checks authorization after the method runs, with access to the return value. ```java @PostEnforce(resource = "returnObject") public Book findById(Long id) { return bookRepository.findById(id); } ``` This is useful when the decision depends on the returned data, or when you want the policy to transform the result. The return object is serialized to JSON for the authorization subscription, so make sure your domain classes are Jackson-serializable. Either follow standard JavaBean conventions, or add Jackson annotations where needed. ### Reactive Applications For WebFlux applications, use the reactive variant. ```java @Configuration @EnableReactiveSaplMethodSecurity public class SecurityConfig { } ``` The same `@PreEnforce` and `@PostEnforce` annotations work here. They integrate with the reactive pipeline instead of blocking. One restriction is worth knowing about. `@PostEnforce` on reactive methods only works with `Mono`, not `Flux`. The resource value must be a single object, not a stream. If you need to enforce on a `Flux` return type, apply the policy at a different layer such as filtering inside the publisher, or use `@PreEnforce` together with query-rewriting obligations. ### How Enforcement Works The annotations are convenient. To use them well, it helps to understand what happens behind the scenes. This section walks through the enforcement lifecycle so you can reason about behavior. #### The Deny Invariant One rule governs all enforcement. Only `PERMIT` grants access. The PDP can return five possible decisions (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, `NOT_APPLICABLE`). Only `PERMIT` ever results in access being granted. Everything else means denial. Streaming PEPs that honour `SUSPEND` pause the data flow without terminating the subscription, so a later `PERMIT` resumes it. One-shot PEPs treat `SUSPEND` as `DENY`. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for the per-decision PEP semantics. A decision from the PDP looks like this. ```json { "decision": "PERMIT", "obligations": [{ "type": "logAccess", "message": "Salary data accessed" }], "advice": [{ "type": "notifyAdmin" }] } ``` The `decision` field is always present. The other fields are optional. The `obligations` and `advice` arrays carry JSON objects, by convention with a `type` field for handler dispatch. When `resource` is present in the decision, it replaces the method's return value entirely. A `PERMIT` with obligations is not a free pass. The PEP checks that every obligation in the decision has a registered handler. If even one obligation cannot be fulfilled, the PEP treats the decision as a denial. If a handler accepts responsibility for an obligation but fails during execution, that also results in denial. Advice is softer. The PEP tries to execute advice handlers too. If one fails, it logs the failure and moves on. Advice never causes denial. | Aspect | Obligation | Advice | |---|---|---| | All handled? | Required. Unhandled obligations deny access (`AccessDeniedException`). | Optional. Unhandled advice is silently ignored. | | Handler failure | Denies access (`AccessDeniedException`). | Logs a warning and continues. | This means you can always trust that if your method runs, every obligation attached to the decision has been successfully enforced. #### Enforcement Locations Enforcement does not happen at a single checkpoint. Constraint handlers can intervene at different points in the request lifecycle. SAPL models each point as a distinct *signal*. A handler attaches to a particular signal type, and the PEP fires that signal at the matching lifecycle point. For request-response methods, the relevant signals are the following. | Signal | Fires when | Typical handler | |---|---|---| | `DecisionSignal` | Authorization decision arrives | Logging, audit, notification. | | `InputSignal` | Before the method runs (with arguments) | Argument inspection or transformation in `@PreEnforce`. | | `OutputSignal` | After the method returns (with return value) | Transform, filter, or replace the result. | | `ErrorSignal` | Method throws | Transform or observe the error. | There are additional signals for reactive lifecycle events (`SubscriptionSignal`, `CancelSignal`, `CompleteSignal`, `TerminationSignal`, `AfterTerminationSignal`). They behave the same way. A handler attaches to a signal, the PEP fires it at the right moment. #### `@PreEnforce` Lifecycle When you annotate a method with `@PreEnforce`, here is the sequence. The PEP builds an authorization subscription from the SpEL expressions in the annotation (or from defaults if you left them out) and sends it to the PDP as a one-shot request. The PDP evaluates the subscription against all matching policies and returns a single decision. If the decision is anything other than `PERMIT`, the PEP throws an `AccessDeniedException` immediately. Your method never runs. If the decision is `PERMIT`, the PEP resolves all constraint handlers. It walks through the obligations and advice attached to the decision and checks which registered handlers claim responsibility for each one. If any obligation has no matching handler, the PEP denies access right there, because it cannot guarantee the obligation will be enforced. With handlers resolved, execution proceeds through the signals in order. `DecisionSignal` handlers run first (logging, audit). Then `InputSignal` handlers run, which can transform method arguments if the policy requires it. Then your actual method executes. After the method returns, `OutputSignal` handlers apply (resource replacement if the decision included one, mapping handlers, consumer handlers). If any obligation handler fails at any stage, the PEP throws `AccessDeniedException`. One important consequence is worth calling out. If your method performs a database write and an obligation handler fails after the method has returned, the PEP throws `AccessDeniedException`. With the automatic transaction ordering described in [Transaction Integration](#transaction-integration) below, this exception propagates through the `TransactionInterceptor` and triggers a rollback. The database write does not persist. #### `@PostEnforce` Lifecycle `@PostEnforce` inverts the order. Your method runs first, regardless of the authorization outcome. Only after it returns does the PEP build the authorization subscription (now including `returnObject` as a SpEL variable) and consult the PDP. This means the PDP can make decisions based on the actual data your method produced. For example, a policy might permit access to a document only if the document's classification level is below a threshold. That is something you can only check after loading the document. If the decision is not `PERMIT`, the PEP discards the return value and throws `AccessDeniedException`. The method ran and its side effects happened. If the method modified a database, the transaction ordering described below ensures a rollback. If the decision is `PERMIT`, constraint handlers proceed through the same stages as `@PreEnforce`, minus the `InputSignal` handlers (since the method has already run). `OutputSignal` handlers can still transform the result before it reaches the caller. There is one subtlety worth keeping in mind. Because the method runs before the PDP is consulted, if the method itself throws an exception, that exception propagates directly. The PDP is never called. There is no return value to include in the subscription, and no point in authorizing a failed operation. SAPL PEP libraries share a single unified enforcement model. It is a strict fail-closed state machine over the five decision verbs, where only `PERMIT` grants access and only an explicit `SUSPEND` pauses a stream without terminating it. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for the decision-verb semantics. ### Building the Authorization Subscription Every authorization check sends a subscription to the PDP with four components. - **subject** Who is making the request. - **action** What they are trying to do. - **resource** What they are trying to access. - **environment** Contextual information such as time or IP address. By default SAPL collects everything it can find, which creates verbose subscriptions. In practice you will want to be explicit. ```java @PreEnforce( subject = "authentication.principal", action = "'delete'", resource = "#book" ) public void deleteBook(Book book) { ... } ``` The values are Spring Expression Language (SpEL) expressions. The evaluation context exposes a few useful root variables. - `authentication` The current Spring Security `Authentication`. - `#paramName` Method parameters by name (such as `#orderId`). - `@beanName` Spring beans (such as `@userService.checkAccess()`). - `methodInvocation` The method invocation itself, including its method and arguments. - `returnObject` The method's return value (only available in `@PostEnforce`). A few patterns you will see often. ```java // Use the username as subject subject = "authentication.name" // Use a literal string as action action = "'create-report'" // Use a method parameter as resource resource = "#orderId" // Call a bean method subject = "@userService.getCurrentUserProfile()" // Build a custom object inline resource = "{ 'type': 'book', 'id': #id }" ``` ### Combining `@PreEnforce` and `@PostEnforce` You can use both annotations on the same method. Both must permit for the result to reach the caller. ```java @PreEnforce(action = "'read'") @PostEnforce(resource = "returnObject") public Document getDocument(Long id) { ... } ``` You cannot mix SAPL annotations with Spring Security annotations like `@PreAuthorize` on the same method. Choose one authorization mechanism per method. ### Streaming Enforcement with `@StreamEnforce` `@PreEnforce` and `@PostEnforce` make a single authorization decision and either let the method run or deny it. They suit request-response endpoints. For methods that return a `Flux`, the decision is rarely a single point in time. The same subscription stays open while the policy evaluates against attribute streams that may change. SAPL exposes a third method-security annotation for this case. ```java @StreamEnforce public Flux sensorReadings(String deviceId) { return readings.streamFor(deviceId); } ``` `@StreamEnforce` consumes a continuous stream of authorization decisions from the PDP. As decisions change, the PEP lets items flow, drops them silently, or terminates the subscription accordingly. The annotation only applies to methods that return a `Flux`. For `Mono` returns use `@PreEnforce`/`@PostEnforce`. #### How Decisions Affect the Subscription Every decision the PDP emits during the lifetime of the subscription has one of five verbs, and each maps to a single observable effect. | PDP decision | Effect on the subscription | |---|---| | `PERMIT` | Items from the protected method flow through to the subscriber. | | `SUSPEND` | Items are silently dropped. The subscription stays open. A later `PERMIT` resumes the flow. | | `INDETERMINATE` | The subscription terminates with an `AccessDeniedException`. | | `NOT_APPLICABLE` | The subscription terminates with an `AccessDeniedException`. | | `DENY` | The subscription terminates with an `AccessDeniedException`. | Under the strict fail-closed discipline, `INDETERMINATE`, `NOT_APPLICABLE`, and a `PERMIT` whose decision-scoped enforcement fails all terminate the subscription with an `AccessDeniedException`. Only an explicit `SUSPEND` from the PDP silences (rather than terminates) the subscription. Operators who want `NOT_APPLICABLE` to silence rather than terminate set the combining algorithm's `defaultDecision` to `SUSPEND` at the PDP level, producing a real `SUSPEND` decision the streaming PEP then routes through suspension. A subscription that has been silenced by a `SUSPEND` resumes the moment the PDP emits a `PERMIT` again. This is the use case the `suspend` verb in policies was designed for. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for the policy-side semantics. Per-item obligation failure also terminates the subscription, with an `AccessDeniedException` carrying a message indicating the per-item discharge failure. The strict fail-closed default removes the prior `terminateOnItemEnforcementFailure` annotation flag: per-item failure is now unconditionally terminal, matching strict `@PreEnforce` semantics on a per-item timeline. #### Two Flags `@StreamEnforce` carries two boolean flags, both defaulting to `false`. Each addresses one orthogonal concern. ```java @StreamEnforce( signalTransitions = boolean, // default false pauseRapDuringSuspend = boolean // default false ) ``` **`signalTransitions`**. Surfaces every suspend/resume boundary to the subscriber as a non-terminal exception on the error channel. When `false` (the default), boundary transitions are silent: the subscriber sees items while permitted and silence while suspended, with no programmatic notification of the transition itself. When `true`, the subscriber receives an `AccessDeniedException` (with the suspend reason) every time the subscription is silenced, and an `AccessGrantedException` every time it resumes. Both directions are gated symmetrically by the same flag. Terminal denies bypass the gate entirely and surface as a normal Reactor terminal error regardless. Subscribers that want to render UI state changes per transition (e.g. "stream paused, waiting for access") opt in to `signalTransitions=true`. **`pauseRapDuringSuspend`**. Controls the underlying connection while the subscription is silenced. With the default `false`, the protected method's `Flux` stays subscribed throughout the silenced period. Items keep arriving from upstream and are silently dropped on the way to the subscriber. Lower latency on resume. Preserves whatever state upstream holds (subscription IDs, message offsets, etc.). With `true`, the upstream subscription is disposed when the subscription is silenced and re-established when the subscription resumes. Stops upstream side effects during suspension at the cost of paying re-subscription latency on resume. Opt in for upstream sources with expensive side effects that must not run when the subscriber is denied access. #### Backpressure Transparency `@StreamEnforce` does not change the backpressure characteristics of the protected stream. The subscriber's `request(N)` propagates through the wrapper to the protected `Flux`. A demand-respecting source (database cursor, paginated feed, iterable adapter) sees real demand and backpressures upstream accordingly. An unbounded source remains unbounded. The wrapper carries no hidden buffer. Items that are silently dropped under `SUSPEND` are accounted for by an extra single-item request to the upstream, so `request(N)` continues to mean "up to N delivered items" regardless of how many items the gate drops along the way. When `pauseRapDuringSuspend = true`, outstanding subscriber demand is replayed to the fresh upstream subscription on resume. #### Three Common Patterns The flag combinations encode the three behavioural patterns most streaming endpoints want. **Terminate on deny.** The subscription should end the moment access is revoked, and the subscriber should know. ```java @StreamEnforce public Flux liveTrades() { ... } ``` Defaults are sufficient. A `DENY` from the PDP terminates the subscription with `AccessDeniedException`. A `SUSPEND` keeps the subscription alive but silently drops items. The subscriber sees data while permitted and a terminal error if denied. This matches subscription-based business models where service delivery should stop when the subscriber's contract ends. **Drop while suspended, silent transitions.** The subscription should survive deny windows transparently. The subscriber sees data when permitted and silence otherwise, with no boundary events. ```java @StreamEnforce public Flux telemetry() { ... } ``` Same defaults. The difference is in the policy, use the `suspend` verb instead of `deny` for the deny windows. The PDP returns `SUSPEND`, items are silently dropped, the subscription stays open. When the policy returns `PERMIT` again, items resume. Useful for legacy clients that cannot renegotiate connections, or when revealing the deny condition itself would leak information. **Survive deny with explicit transition signals.** The subscription should survive, and the subscriber wants to know about every boundary. ```java @StreamEnforce(signalTransitions = true) public Flux marketData() { ... } ``` The PDP returns `SUSPEND` for windows where access should pause. The PEP emits a non-terminal `AccessDeniedException` every time the subscription is silenced and an `AccessGrantedException` every time it resumes. The subscriber observes these on the error channel and can update UI state, log the boundary, or trigger client-side replay logic. The subscription itself stays open across all transitions until either the client cancels or the PDP issues a terminal `DENY`. For the third pattern, a helper class `TransitionSignals` ships with the PEP for translating those non-terminal exceptions into application-level callbacks. See [Streaming Constraint Handlers](#streaming-constraint-handlers) below. #### Subscription, Action, and Resource `@StreamEnforce` carries the same SpEL slots as `@PreEnforce` for shaping the authorization subscription. ```java @StreamEnforce( subject = "authentication.principal", action = "'subscribe'", resource = "#deviceId" ) public Flux sensorReadings(String deviceId) { ... } ``` When omitted, defaults are derived from the method invocation as for the request-response annotations. See [Building the Authorization Subscription](#building-the-authorization-subscription) above. #### Streaming Constraint Handlers The same `ConstraintHandlerProvider` mechanism that powers `@PreEnforce` and `@PostEnforce` applies. Per-item handlers attach to the `OutputSignal` and run on every emitted item. Decision-scoped handlers attach to the `DecisionSignal` and run once per decision arrival. See [Constraints](#constraints) below. For the recoverable pattern, the helper: ```java @GetMapping(value = "/feed", produces = MediaType.APPLICATION_NDJSON_VALUE) public Flux> feed() { return TransitionSignals.onTransitions( quoteService.liveQuotes(), suspended -> log.info("Stream suspended: {}", suspended.getMessage()), granted -> log.info("Stream resumed: {}", granted.getMessage())) .map(quote -> ServerSentEvent.builder().data(quote).build()); } ``` translates the non-terminal `AccessDeniedException` / `AccessGrantedException` events emitted under `signalTransitions=true` into ordinary callbacks, then re-emits a clean `Flux` to the downstream consumer. ### Transaction Integration When a `@PreEnforce` or `@PostEnforce` method is also `@Transactional`, an obligation handler failure must trigger a transaction rollback. Consider this service method. ```java @Transactional @PreEnforce public Order createOrder(OrderRequest request) { return orderRepository.save(new Order(request)); } ``` If the PDP returns `PERMIT` with an obligation, and the obligation handler fails after the method has successfully saved the order, the correct behavior is to roll back the database transaction. The order should not persist if the obligation cannot be fulfilled. #### Automatic AOP Order Adjustment When you enable SAPL method security via `@EnableSaplMethodSecurity` or `@EnableReactiveSaplMethodSecurity`, the transaction interceptor order is automatically adjusted so that the transaction boundary wraps the SAPL enforcement interceptors. No manual configuration is required. This places the interceptors in the correct order from outermost to innermost. 1. Spring Security `@PreAuthorize` (order 500). Fast deny, no transaction started. 2. `TransactionInterceptor` (order `Integer.MAX_VALUE - 3`). Begins the transaction. 3. SAPL `@PreEnforce` (order `Integer.MAX_VALUE - 1`). 4. SAPL `@PostEnforce` (order `Integer.MAX_VALUE`). Innermost. 5. The actual method executes. When a SAPL obligation handler throws after the method returns, the exception propagates outward through the `TransactionInterceptor`, which rolls back the transaction. The automatic adjustment only applies when the transaction advisor still has Spring's default order. If you have explicitly configured a custom order via `@EnableTransactionManagement(order = ...)`, your setting is preserved. For reactive methods returning `Mono`, the constraint handlers are wired into the reactive pipeline. The `ReactiveTransactionManager` sees the error signal within the pipeline and rolls back automatically, independent of AOP interceptor ordering. #### Disabling Automatic Adjustment If the automatic reordering conflicts with your specific AOP interceptor ordering requirements, you can disable it. ```properties io.sapl.method-security.adjust-transaction-order=false ``` With this property set, the transaction interceptor keeps its default order. Be aware that in blocking scenarios, this means obligation handler failures after a successful method call will not trigger a rollback. The database might be left in an inconsistent state. ## HTTP Request Security Beyond method security, you can apply SAPL to the HTTP layer. This protects endpoints based on request attributes before any controller code runs and lets policy obligations shape the request that reaches the controller, the response that goes back to the client, and the deny page rendered when access is refused. ### Servlet Wiring Apply SAPL to `HttpSecurity` through the dedicated configurer the starter ships. One call wires the authorization manager, the HTTP PEP filter, and the access-denied handler. ```java import static io.sapl.spring.pep.http.servlet.SaplHttpSecurityConfigurer.saplHttp; import static org.springframework.security.config.Customizer.withDefaults; @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.with(saplHttp(), withDefaults()) .formLogin(withDefaults()) .httpBasic(withDefaults()) .build(); } ``` `saplHttp()` is `io.sapl.spring.pep.http.servlet.SaplHttpSecurityConfigurer.saplHttp()`. The configurer pulls `SaplAuthorizationManager`, `SaplAccessDeniedHandler`, and `SaplHttpPepFilter` from the application context. All three are deployed by `AuthorizationManagerConfiguration` as `@ConditionalOnMissingBean` beans, so applications can override any of them by declaring their own bean of the same type. ### Reactive Wiring Reactive applications use the dedicated reactive configurer, applied explicitly to `ServerHttpSecurity`. ```java import io.sapl.spring.pep.http.reactive.SaplServerHttpSecurityConfigurer; import static org.springframework.security.config.Customizer.withDefaults; @Bean SecurityWebFilterChain filterChain(ServerHttpSecurity http, ApplicationContext context) { SaplServerHttpSecurityConfigurer.apply(http, context); return http.formLogin(withDefaults()).httpBasic(withDefaults()).build(); } ``` `SaplServerHttpSecurityConfigurer.apply(http, context)` pulls `ReactiveSaplAuthorizationManager`, `SaplServerAccessDeniedHandler`, and `SaplHttpPepWebFilter` from the application context. All three are deployed by `AuthorizationManagerConfiguration` as `@ConditionalOnMissingBean` beans, so applications can override any of them by declaring their own bean of the same type. The reactive backend fires the same five signals as the servlet backend (documented below) and the request serializer exposes the same field names on both stacks. The full schema is documented in [The HTTP Request Shape](#the-http-request-shape) below. ### The HTTP Request Shape The default subscription factory serialises the inbound request and places it on both `action` and `resource`. Both stacks (servlet and reactive) emit the same field layout, so a single policy works against either backend. The shape is intentionally close to a normalised request view: top-level URL parts where they are most useful, grouped peer information under `client`/`server`, and parsed forwarding-header data under `forwarded` so policies do not have to split the headers themselves. A typical request behind a TLS-terminating reverse proxy serialises to: ```json { "method": "GET", "url": "https://api.example.com:8443/myapp/users/42?role=admin", "scheme": "https", "host": "api.example.com", "port": 8443, "path": "/myapp/users/42", "query": "role=admin", "queryParameters": { "role": ["admin"] }, "contextPath": "/myapp", "applicationPath": "/users/42", "isSecure": true, "client": { "address": "203.0.113.7", "host": "client.example.com", "port": 54402 }, "server": { "address": "10.0.0.1", "host": "internal-host", "port": 8443 }, "headers": { "host": ["api.example.com"], "authorization": ["Bearer ..."], "x-forwarded-for": ["198.51.100.1, 203.0.113.7"] }, "cookies": [ { "name": "session", "value": "abc123" } ], "forwarded": { "for": ["198.51.100.1", "203.0.113.7"], "host": "api.example.com", "proto": "https", "port": 443 }, "contentType": "application/json", "contentLength": 142, "characterEncoding": "UTF-8" } ``` #### URL parts | Field | Description | |---|---| | `method` | HTTP method (`GET`, `POST`, ...). | | `url` | The full request URL including the query string. | | `scheme` | `http` or `https` as observed at this hop. | | `host` | The host the client addressed (from the URI on the reactive stack, from the `Host` header on the servlet stack). | | `port` | The matching port. | | `path` | The request path only, without scheme, host, or query. Replaces the older `requestedURI`. | | `query` | The raw query string with no leading `?`. Absent when the request has no query. | | `queryParameters` | The query string parsed into a multi-valued map; values are URL-decoded. Absent when the request has no query. Form-encoded request bodies are not exposed here; they are not authorization input by default. | | `contextPath` | The servlet context root, usually `""` for Spring Boot apps. | | `applicationPath` | `path` with `contextPath` stripped. Useful when the app is mounted under a context root. | | `isSecure` | `true` iff `scheme == "https"` for this hop. Behind TLS-terminating proxies this reflects the proxy-to-app leg, not the user-to-proxy leg. Use `forwarded.proto` for end-to-end. | #### Connection peer information | Field | Description | |---|---| | `client.address` | The direct peer's IP. Behind a reverse proxy this is the proxy IP, not the original client. | | `client.host` | Reverse-DNS name of the direct peer (or the IP). | | `client.port` | Direct peer port. | | `server.address` | The bind interface IP this hop landed on. | | `server.host` | The bind interface name. | | `server.port` | The bind port. | #### Headers, cookies | Field | Description | |---|---| | `headers` | A map from lowercase header name to a list of values. Lowercased to match HTTP/2 wire format and Spring's case-insensitive `HttpHeaders` contract; policies should always read with lowercase keys. | | `cookies` | A list of `{name, value}` objects. | #### Forwarded chain When standard reverse-proxy forwarding headers are present, a parsed view sits at `forwarded`. RFC 7239 `Forwarded` is preferred. The legacy `X-Forwarded-{For,Host,Proto,Port}` family is the fallback. The `forwarded` block is omitted entirely when no relevant header is present. | Field | Description | |---|---| | `forwarded.for` | The forwarding chain left-to-right; element `[0]` is the original client when the policy trusts the chain. | | `forwarded.host` | The original `Host` the user typed. | | `forwarded.proto` | The original scheme (`http` or `https`), normalised to lowercase. | | `forwarded.port` | The explicit forwarded port, when signalled. | The serializer parses these headers but does not judge whether to trust them. Whether to honour the chain is a policy decision. Typical patterns gate on `client.address` being in a trusted proxy range. For SAPL to receive Spring's own rewritten request URI/host/scheme (based on these headers), wire `ForwardedHeaderTransformer` (reactive) or `ForwardedHeaderFilter` (servlet) per the Spring documentation. #### Body metadata | Field | Description | |---|---| | `contentType` | Request body media type, when set. | | `contentLength` | Body length in bytes, when known (`>=0`). | | `characterEncoding` | Body charset, when set. | The body itself is not exposed by the serializer because reading it at the authorization point would consume the single-shot input stream. Policies that need to inspect bodies require a separate mechanism. #### Distinguishing localhost from a custom domain The most common host-based check works directly: ```sapl permit resource.host == "api.example.com" | resource.host == "internal.example.com"; ``` Behind a reverse proxy, `host` reflects what this hop saw. To check the host the client actually used, also consult `forwarded.host`: ```sapl permit resource.host == "api.example.com" | resource.forwarded.host == "api.example.com"; ``` ### Customizing the Authorization Subscription By default both authorization managers serialize the inbound request and place it on `action` and `resource`, with the resolved `Authentication` on `subject` and `environment` left undefined. That default is verbose. Most applications eventually want a tighter shape that lines up with what their policies actually reference. The shape is owned by an `AuthorizationSubscriptionFactory` (servlet) or `ReactiveAuthorizationSubscriptionFactory` (reactive) bean. The starter registers a default factory under `@ConditionalOnMissingBean`. Three override paths are available, in increasing order of locality. Replace the global factory bean. A single `@Bean` of `AuthorizationSubscriptionFactory` (or its reactive sibling) replaces the default everywhere. ```java @Bean AuthorizationSubscriptionFactory subscriptionFactory(ObjectMapper mapper) { return (auth, request) -> AuthorizationSubscription.of( auth.getName(), request.getMethod(), Map.of("path", request.getRequestURI(), "tenant", request.getHeader("X-Tenant")), mapper); } ``` Override per filter chain via the configurer. The customizer parameter of `http.with(saplHttp(), ...)` carries the same fluent setter. This is the right place when one chain wants a different subscription shape from another. ```java http.with(saplHttp(), c -> c.subscriptionFactory( (auth, req) -> AuthorizationSubscription.of(auth.getName(), req.getMethod(), req.getRequestURI(), mapper))); ``` The reactive form is the same idea, with the second `apply(...)` overload taking the customizer: ```java SaplServerHttpSecurityConfigurer.apply(http, context, c -> c.subscriptionFactory( (auth, exchange) -> Mono.just(AuthorizationSubscription.of(auth.getName(), exchange.getRequest().getMethod().name(), exchange.getRequest().getURI().getPath(), mapper)))); ``` The reactive factory returns `Mono` so it can enrich the subscription asynchronously (for example resolving subject attributes from a reactive store) without blocking the event loop. Synchronous customizations stay one-line via `Mono.just(...)`. Replace the manager outright. When you need behaviour beyond shaping the subscription (for example pre-authorization caching), construct your own `SaplAuthorizationManager` (or its reactive sibling) and pass it through `c.authorizationManager(...)`. The configurer then skips the factory lookup entirely. ### What the HTTP PEP Fires Five signals reach the planner over the course of a single HTTP exchange. Constraint handlers attach to whichever fits the work they do. | Signal | Fires from | Carrier | Typical handler | |------------------------------|-------------------------------------------------------------------------------------------|------------------------|---------------------------------------------------------------------------------| | `DecisionSignal` | The authorization manager (servlet or reactive) | `AuthorizationDecision` | Audit logging, metrics, decision-tagged side effects. | | `HttpRequestSignal` | The authorization manager | `HttpRequest` | Read-only request observation (audit, structured access logs, rate limiting). | | `HttpRequestMutationSignal` | The HTTP PEP filter pre-chain (`SaplHttpPepFilter` / `SaplHttpPepWebFilter`) | `MutableHttpRequest` | Inject headers or attributes that downstream filters and the controller see. | | `HttpResponseSignal` | The HTTP PEP filter post-chain | `MutableHttpResponse` | Read or replace status, headers, and body produced by the controller. | | `HttpDenialSignal` | The access-denied handler (`SaplAccessDeniedHandler` / `SaplServerAccessDeniedHandler`) | `MutableHttpResponse` | Shape the deny response for an authenticated denial (status, headers, body, redirect). | The authorization manager stores the active `EnforcementPlan` on a request or exchange attribute keyed by `HttpEnforcementContext.PLAN_ATTRIBUTE` so the downstream PEP filter and access-denied handler find the same plan and dispatch additional signals against it. `HttpRequestSignal` carries an `org.springframework.http.HttpRequest` view of the inbound request. Mappers are not admissible at this signal because the manager treats the request as read-only at the authorization point. For request mutation use `HttpRequestMutationSignal`, which fires on the permit path before the controller runs. `HttpResponseSignal` fires only on the normal-return path. If the chain throws, the buffered response is discarded and the exception propagates so the standard Spring error pipeline can produce its own response. Authenticated denials route through the SAPL access-denied handler and fire `HttpDenialSignal` instead. Anonymous denials route through Spring Security's `AuthenticationEntryPoint` (typically a login redirect or a 401 challenge) and never reach the SAPL deny handler. ### MutableHttpRequest and MutableHttpResponse `MutableHttpRequest` and `MutableHttpResponse` are SAPL abstractions over the underlying request and response on either backend. Handlers see this interface and write portable code. Servlet implementations live under `io.sapl.spring.pep.http.servlet`, reactive implementations under `io.sapl.spring.pep.http.reactive`. Cast to a backend type only when a feature outside the interface is required. ```java public interface MutableHttpRequest { void setHeader(String name, String value); void addHeader(String name, String value); void removeHeader(String name); void setAttribute(String name, Object value); HttpRequest snapshot(); boolean isModified(); } public interface MutableHttpResponse { boolean setStatusCode(HttpStatusCode status); boolean setStatusCode(int statusValue); HttpStatusCode getStatusCode(); void setHeader(String name, String value); void addHeader(String name, String value); void removeHeader(String name); HttpHeaders headers(); String getBody(); void setBody(String body); void writeBody(String contentType, String body); boolean isModified(); } ``` `setStatusCode` returns `boolean` to match the reactive `ServerHttpResponse.setStatusCode` contract: `true` when the status was applied, `false` when the response is already committed. Servlet implementations always return `true` since the buffered status is set on a buffer, not on the underlying response. Most callers ignore the return value. `isModified()` ticks for every typed mutation. The PEP filter uses it to skip forwarding the request wrapper down the chain when the obligation handler observed without changing anything. The access-denied handler uses it together with the plan's denial-signal entry list to decide whether to commit the obligation-shaped response or fall back to Spring's default 403. ### Performance Characteristics The HTTP PEP filter wraps the request and response only when it has work to do. It checks the active plan for handlers scheduled at `HttpRequestMutationSignal` and `HttpResponseSignal` before installing either wrapper. The common case (a permit decision with no HTTP signal handlers) runs against the raw request and response with no extra copy. When response-side handlers are scheduled, the filter installs a buffering wrapper that captures every controller byte in memory and re-emits it on commit. This makes body inspection and rewrite possible but is unsuitable for unbounded streaming bodies. Constraint handler authors who need response shaping should be aware of the in-memory capture. Routes that intentionally stream large payloads should not register response-signal handlers. When request-side handlers are scheduled, the filter installs a header-override wrapper, fires the mutation signal, and only forwards the wrapper to the chain when at least one handler actually called a setter. Pure observation handlers cost nothing beyond the signal dispatch. ### Constraint Handlers at the HTTP Layer Constraint handlers attach to HTTP signals exactly the way they attach to method-security signals. The provider returns a list of `ScopedConstraintHandler` entries scoped to the signal each handler should fire on. See [Writing Custom Handlers](#writing-custom-handlers) below for the general shape. A short example, an obligation that injects an `X-Tenant` header on the request before the controller runs: ```java @Component public class TenantHeaderHandler implements ConstraintHandlerProvider { @Override public List getConstraintHandlers( Value constraint, Set supportedSignals) { if (!ConstraintHandlerProvider.constraintIsOfType(constraint, "tenant-header")) { return List.of(); } if (!supportedSignals.contains(Signal.HttpRequestMutationSignal.SIGNAL_TYPE)) { return List.of(); } if (!(constraint instanceof ObjectValue obj) || !(obj.get("value") instanceof TextValue(String tenant))) { return List.of(); } ConstraintHandler.Consumer handler = request -> request.setHeader("X-Tenant", tenant); return List.of(new ScopedConstraintHandler( handler, Signal.HttpRequestMutationSignal.SIGNAL_TYPE, 0)); } } ``` The matching policy: ```sapl policy "stamp_tenant" permit action.method == "GET"; resource.path =~ "/api/.*"; obligation { "type": "tenant-header", "value": "demo-tenant" } ``` ## Constraints So far we have talked about permit and deny decisions. SAPL can do more. A decision can include constraints that the PEP must enforce. The obligation and advice contract was covered above in [The Deny Invariant](#the-deny-invariant). This section shows how to write policies with constraints, and how to implement the handlers that enforce them. A policy with constraints looks like this. ``` policy "permit with logging" permit action == "read-salary"; obligation { "type": "logAccess", "message": "Salary data accessed" } advice { "type": "notify", "channel": "audit" } ``` ### Built-in Constraint Handlers SAPL Spring Security ships with handlers for common scenarios. #### `filterJsonContent` `ContentFilteringProvider` filters or transforms properties within returned objects. You can blacken (replace with a marker character), delete, or replace specific JSON paths. ``` obligation { "type": "filterJsonContent", "actions": [ { "type": "blacken", "path": "$.ssn" }, { "type": "delete", "path": "$.salary" } ] } ``` The full schema looks like this. ```json { "type": "filterJsonContent", "conditions": [ { "path": "$.field", "type": "==", "value": "..." } ], "actions": [ { "path": "$.field", "type": "delete" }, { "path": "$.field", "type": "blacken", "replacement": "X", "length": 4, "discloseLeft": 1, "discloseRight": 1 }, { "path": "$.field", "type": "replace", "replacement": "REDACTED" } ] } ``` The optional `conditions` array narrows which elements the actions apply to. Each condition has a JSONPath, a comparison type (`==`, `!=`, `>=`, `<=`, `>`, `<`, or `=~` for regex), and a value. All conditions must match (AND-joined) for the actions to apply. Action types are `delete` (remove the node), `blacken` (obfuscate text with optional partial disclosure), and `replace` (substitute the value). The provider works on `Optional`, `List`, `Set`, `Mono`, `Flux`, arrays, and single objects. #### `jsonContentFilterPredicate` `ContentFilterPredicateProvider` filters elements out of collections based on a predicate. This is useful for age-gating or classification-based filtering. ``` policy "age-rating filter" permit action == "list books"; obligation { "type": "jsonContentFilterPredicate", "conditions": [ { "path": "$.ageRating", "type": "<=", "value": timeBetween(subject.birthday, dateOf(|), "years") } ] } ``` This example uses SAPL's built-in `timeBetween` and `dateOf` functions to calculate the user's age and filter out books with age ratings above that age. The schema accepts only `conditions`, with the same shape as in `filterJsonContent`. Elements that do not match all conditions are dropped from the collection. #### Query Rewriting `SqlQueryRewritingProvider` and `MongoDbQueryRewritingProvider` rewrite database queries to filter at the data layer. See [Query Rewriting](../6_12_QueryRewriting/) for details. ### Writing Custom Handlers When the built-in handlers are not enough, you write your own. A constraint handler is a Spring bean that implements `ConstraintHandlerProvider`. The interface is small. ```java public interface ConstraintHandlerProvider { List getConstraintHandlers( Value constraint, Set supportedSignals); } ``` The PEP calls `getConstraintHandlers` for each constraint in a decision. Your provider inspects the constraint and decides whether it can handle it. If yes, return one or more `ScopedConstraintHandler` entries. Each entry bundles three things together. - A `ConstraintHandler`, which is the actual logic. - The `SignalType` it should attach to. - A priority (lower runs earlier among handlers on the same signal). If no, return an empty list, and the PEP will ask other providers. If no provider claims a constraint that arrived as an obligation, the PEP denies access. If more than one provider claims the same constraint, the planner treats that as ambiguous and denies access. A single obligation can drive several handlers across different lifecycle points. For example, an `auditAndStamp` obligation can return both a `DecisionSignal` runner that records the decision and an `HttpResponseSignal` consumer that adds an audit header to the response. The planner schedules each handler against its own signal independently. The bundle is all-or-nothing during admissibility checks. If any handler in the returned list is not well-formed (for example a mapper attached to a signal the calling PEP does not advertise), the entire claim is rejected. There are three handler shapes, all under the sealed `ConstraintHandler` interface. | Shape | Signature | Use when | |---|---|---| | `Mapper` | `T apply(T)` | You need to transform the value flowing through a value signal. Examples include redacting fields in `OutputSignal` or rewriting a SQL string in `SqlShimSignal`. | | `Consumer` | `void accept(T)` | You need a side effect that has access to the value but does not change it. Example: structured audit logging that records the return value. | | `Runner` | `void run()` | You need a side effect that does not need a value. Examples include logging the decision or sending a notification. | One subtle rule is worth knowing before you hit it. A `Mapper` may only be returned for an obligation, never for advice. If a constraint arrived as advice and your provider returns a `Mapper`, the planner replaces it with a synthetic failure runner during planning. The reasoning is that advice is allowed to fail silently. A value transformation that silently does not happen would leave the caller unable to tell whether the result was transformed or not, which is an unsafe contract. If you want a transformation to apply, the policy must mark the constraint as an obligation. `Consumer` and `Runner` handlers can be returned for either obligation or advice. Here is a complete example that logs access attempts on every decision. ```java @Component public class LogAccessHandler implements ConstraintHandlerProvider { private static final Logger log = LoggerFactory.getLogger(LogAccessHandler.class); private static final String CONSTRAINT_TYPE = "logAccess"; @Override public List getConstraintHandlers( Value constraint, Set supportedSignals) { if (!ConstraintHandlerProvider.constraintIsOfType(constraint, CONSTRAINT_TYPE)) { return List.of(); } var message = extractMessage(constraint); ConstraintHandler.Runner handler = () -> log.info(message); return List.of(new ScopedConstraintHandler(handler, DecisionSignal.SIGNAL_TYPE, 0)); } private static String extractMessage(Value constraint) { if (constraint instanceof ObjectValue obj && obj.get("message") instanceof TextValue(String text)) { return text; } return "Access logged"; } } ``` Two things are worth pointing out. First, the responsibility check uses the static helper `ConstraintHandlerProvider.constraintIsOfType(constraint, type)`, which checks whether the constraint is a JSON object with a `type` field matching the given string. This is the convention used by all built-in providers. You are free to use a different convention if it makes more sense for your obligations. Second, the handler attaches to `DecisionSignal.SIGNAL_TYPE`. The PEP fires `DecisionSignal` once when the decision arrives, before the method runs. If you want to log on completion instead, attach to `CompleteSignal.SIGNAL_TYPE`. If you want to inspect the return value, attach to `OutputSignal.typeFor(SomeReturnType.class)` and use a `Consumer` handler. Spring auto-discovers any bean implementing `ConstraintHandlerProvider`. Just annotate with `@Component` and put it in a scanned package. ## Query Rewriting Spring Data applications can filter results at the database with no changes to your repositories: apply `@PreEnforce` to the calling service method, and the policy attaches a `sql:queryRewriting` or `mongo:queryRewriting` obligation that the SAPL integration applies before the query reaches the driver. The SAPL Spring Boot starter wires this automatically for R2DBC repositories and reactive MongoDB. See [Query Rewriting](../6_12_QueryRewriting/) for the obligation format, the shared semantics, worked examples, and the per-backend opt-out properties. ## Configuration SAPL Spring Security is configured through `application.properties` or `application.yml`. The properties control which PDP to use and how it behaves, plus a few cross-cutting toggles for method security, JWT injection, and query rewriting. ### Embedded PDP The embedded PDP runs inside your application. Policies are loaded from bundled resources or a filesystem directory. ```properties io.sapl.pdp.embedded.enabled=true io.sapl.pdp.embedded.pdp-config-type=RESOURCES io.sapl.pdp.embedded.policies-path=/policies io.sapl.pdp.embedded.config-path=/policies ``` The full property list. | Property | Default | Description | |---|---|---| | `io.sapl.pdp.embedded.enabled` | `true` | Enable or disable the embedded PDP. | | `io.sapl.pdp.embedded.pdp-config-type` | `RESOURCES` | Source of policies and configuration. See [PDP Data Sources](#pdp-data-sources) below. | | `io.sapl.pdp.embedded.policies-path` | `/policies` | Directory containing `.sapl` policy files. | | `io.sapl.pdp.embedded.config-path` | `/policies` | Directory containing the `pdp.json` configuration file. | | `io.sapl.pdp.embedded.function-cache-size` | `10000` | Maximum number of cached pure-function results. SAPL functions are side-effect-free, so the PDP caches results across evaluations using a Window-TinyLFU policy. Set to `0` to disable caching. | | `io.sapl.pdp.embedded.metrics-enabled` | `false` | Record PDP decision metrics for Prometheus through Micrometer. | | `io.sapl.pdp.embedded.print-trace` | `false` | Log the full JSON evaluation trace on each decision. Verbose, for debugging. | | `io.sapl.pdp.embedded.print-json-report` | `false` | Log the JSON decision report on each decision. | | `io.sapl.pdp.embedded.print-text-report` | `false` | Log a human-readable decision report on each decision. | | `io.sapl.pdp.embedded.print-subscription-events` | `false` | Log new authorization subscriptions. | | `io.sapl.pdp.embedded.print-unsubscription-events` | `false` | Log ended authorization subscriptions. | | `io.sapl.pdp.embedded.pretty-print-reports` | `false` | Pretty-print JSON in logged traces and reports. | #### PDP Data Sources The `pdp-config-type` property selects where policies come from. | Value | Behavior | |---|---| | `RESOURCES` | Loads from the classpath. Bundled in your JAR, fixed at build time. Convenient for development. | | `DIRECTORY` | Loads from a filesystem directory and watches for changes. Updates apply to live subscriptions. | | `MULTI_DIRECTORY` | Loads multiple subdirectories from a base directory. Each subdirectory name becomes a `pdpId` for multi-tenant routing. | | `BUNDLES` | Loads `.saplbundle` files from a directory. Each bundle filename (without extension) becomes a `pdpId`. | | `REMOTE_BUNDLES` | Fetches `.saplbundle` files from a remote HTTP server. Supports polling and long-poll change detection. | For development, `RESOURCES` is convenient because policies travel with the JAR. For production with dynamic policy updates, use `DIRECTORY` and point to a directory that can be updated without redeployment. For multi-tenant deployments, the `MULTI_DIRECTORY`, `BUNDLES`, and `REMOTE_BUNDLES` source types create one `pdpId` per subdirectory or bundle. #### Bundle Security When using `BUNDLES` or `REMOTE_BUNDLES`, you can configure signature verification so tampered bundles are rejected at load time. The defaults are conservative. If you set a public key, all bundles must be signed and verify against that key. If you do not set a key, you must explicitly enable unsigned acceptance with `allow-unsigned=true`. Otherwise startup fails. | Property | Default | Description | |---|---|---| | `io.sapl.pdp.embedded.bundle-security.public-key-path` | none | Path to an Ed25519 public key file. | | `io.sapl.pdp.embedded.bundle-security.public-key` | none | Base64-encoded Ed25519 public key. Alternative to `public-key-path` for containerized deployments. | | `io.sapl.pdp.embedded.bundle-security.allow-unsigned` | `false` | Accept unsigned bundles. Use only in development. | | `io.sapl.pdp.embedded.bundle-security.unsigned-tenants` | empty | List of tenant identifiers that may load unsigned bundles without the global `allow-unsigned` flag. | | `io.sapl.pdp.embedded.bundle-security.keys.` | empty map | Named key catalogue mapping key identifiers to Base64-encoded Ed25519 public keys. | | `io.sapl.pdp.embedded.bundle-security.tenants.` | empty map | Per-tenant key binding. Maps a tenant identifier to a list of trusted key identifiers from the catalogue. | #### Remote Bundle Fetching When `pdp-config-type=REMOTE_BUNDLES`, bundles are fetched from a remote HTTP server. Change detection uses HTTP conditional requests (ETag and `If-None-Match`). | Property | Default | Description | |---|---|---| | `io.sapl.pdp.embedded.remote-bundles.base-url` | none | Base URL of the bundle server. Bundles are fetched as `{base-url}/{pdpId}`. | | `io.sapl.pdp.embedded.remote-bundles.pdp-ids` | empty | List of PDP identifiers to fetch bundles for. | | `io.sapl.pdp.embedded.remote-bundles.mode` | `POLLING` | `POLLING` for interval-based or `LONG_POLL` for long-poll change detection. | | `io.sapl.pdp.embedded.remote-bundles.poll-interval` | `5s` | Default polling interval. | | `io.sapl.pdp.embedded.remote-bundles.long-poll-timeout` | `30s` | Server hold timeout for long-poll mode. | | `io.sapl.pdp.embedded.remote-bundles.auth-header-name` | none | HTTP header name for authentication (such as `Authorization`). | | `io.sapl.pdp.embedded.remote-bundles.auth-header-value` | none | HTTP header value for authentication (such as `Bearer `). | | `io.sapl.pdp.embedded.remote-bundles.allow-insecure-http` | `false` | Permit configured auth headers over plaintext HTTP. Use only on trusted local or proxied hops. | | `io.sapl.pdp.embedded.remote-bundles.follow-redirects` | `true` | Follow HTTP 3xx redirects. | | `io.sapl.pdp.embedded.remote-bundles.pdp-id-poll-intervals.` | empty | Per-`pdpId` poll interval overrides. | | `io.sapl.pdp.embedded.remote-bundles.first-backoff` | `500ms` | Initial backoff after a fetch failure. | | `io.sapl.pdp.embedded.remote-bundles.max-backoff` | `5s` | Maximum backoff after repeated failures. | ### Remote PDP The remote PDP connects to an external PDP server (such as SAPL Node). Use this when policies are managed centrally or when multiple applications share the same policies. Two transports are supported, `http` and `rsocket`. The HTTP transport is the broadest fit. The RSocket transport uses protobuf framing over a long-lived TCP connection and trades per-request flexibility (no token relay) for substantially higher per-call throughput. ```properties io.sapl.pdp.remote.enabled=true io.sapl.pdp.remote.type=http io.sapl.pdp.remote.host=https://pdp.example.org:8443 # Basic authentication io.sapl.pdp.remote.key=myapp io.sapl.pdp.remote.secret=secret123 # Or bearer token authentication (SAPL API key or static JWT) io.sapl.pdp.remote.bearer-token=your-token # Or OAuth2 client_credentials grant (Spring mints and refreshes the JWT) io.sapl.pdp.remote.oauth2.client-registration-id=sapl-pdp # Or token relay (forward the caller's bearer token; HTTP only) io.sapl.pdp.remote.token-relay=true ``` For the RSocket transport, configure the hostname and port directly. TLS is opt-in via the `tls` property (or `ignore-certificates` for development with self-signed certificates). ```properties io.sapl.pdp.remote.enabled=true io.sapl.pdp.remote.type=rsocket io.sapl.pdp.remote.host=pdp.example.org io.sapl.pdp.remote.port=7000 io.sapl.pdp.remote.bearer-token=your-token # Enable TLS against a properly trusted certificate io.sapl.pdp.remote.tls=true # Or connect via a Unix domain socket (host and port are ignored when set) io.sapl.pdp.remote.socket-path=/var/run/sapl-pdp.sock ``` #### Authentication methods | Method | HTTP | RSocket | Properties | |---|---|---|---| | No auth | yes | yes | omit all credential properties | | Basic | yes | yes | `key` + `secret` | | Bearer token (SAPL API key or static JWT) | yes | yes | `bearer-token` | | Token relay (forward caller's JWT per request) | yes | no (by design) | `token-relay=true` | | OAuth2 `client_credentials` (managed JWT lifecycle) | yes | yes | `oauth2.client-registration-id` | OAuth2 `client_credentials` requires `spring-boot-starter-security-oauth2-client` on the classpath and a Spring Security OAuth2 client registration. The starter resolves the registration through Spring's `ReactiveClientRegistrationRepository`, so consumers configure both blocks in tandem: ```yaml io.sapl.pdp.remote: enabled: true type: rsocket host: pdp.example.org port: 7000 tls: true oauth2: client-registration-id: sapl-pdp spring.security.oauth2.client: registration.sapl-pdp: provider: keycloak client-id: sapl-pdp-client client-secret: ... authorization-grant-type: client_credentials provider.keycloak: issuer-uri: https://idp.example.org/realms/sapl ``` The token is cached and refreshed by Spring's `OAuth2AuthorizedClientManager`. On the RSocket transport, each (re)connect mints a fresh BEARER setup-frame metadata payload from the current token. When the SAPL Node disposes the connection on JWT `exp`, the client reconnects with a freshly issued token. End-to-end this is transparent to the consumer's controllers. #### Property reference | Property | Default | Description | |---|---|---| | `io.sapl.pdp.remote.enabled` | `false` | Enable or disable the remote PDP. | | `io.sapl.pdp.remote.type` | `http` | Connection type. Either `http` or `rsocket`. | | `io.sapl.pdp.remote.host` | empty | HTTP URL when `type=http`. Hostname when `type=rsocket`. | | `io.sapl.pdp.remote.port` | `7000` | TCP port. Used only when `type=rsocket`. | | `io.sapl.pdp.remote.socket-path` | empty | Unix domain socket path. When set, `host` and `port` are ignored. Used only when `type=rsocket`. | | `io.sapl.pdp.remote.tls` | `false` | Enable TLS for the connection. Used only when `type=rsocket`. The HTTP transport selects TLS via the `https://` scheme on `host`. | | `io.sapl.pdp.remote.keep-alive` | `20s` | RSocket KEEPALIVE frame interval. Used only when `type=rsocket`. | | `io.sapl.pdp.remote.max-life-time` | `90s` | Maximum time without an inbound KEEPALIVE before the connection is considered dead. Used only when `type=rsocket`. | | `io.sapl.pdp.remote.key` | empty | Username for basic authentication. | | `io.sapl.pdp.remote.secret` | empty | Password for basic authentication. | | `io.sapl.pdp.remote.bearer-token` | empty | Bearer token for token authentication. Carries either a SAPL API key (`sapl_*`) or a static JWT. Renamed from `api-key` in 4.1.0; the old name no longer binds. | | `io.sapl.pdp.remote.token-relay` | `false` | Forward the caller's JWT on each PDP request. Mutually exclusive with `key`/`secret`, `bearer-token`, and `oauth2.client-registration-id`. Supported only on the HTTP transport. RSocket authenticates once at connection setup and cannot relay per-request user credentials. | | `io.sapl.pdp.remote.oauth2.client-registration-id` | empty | Spring Security OAuth2 client registration ID. Enables the `client_credentials` grant on both transports. Mutually exclusive with `key`/`secret`, `bearer-token`, and `token-relay`. | | `io.sapl.pdp.remote.oauth2.principal-name` | empty (defaults to `client-registration-id`) | Principal name used as cache key in Spring's `OAuth2AuthorizedClientManager`. Override only when you need distinct cached clients for the same registration. | | `io.sapl.pdp.remote.ignore-certificates` | `false` | Skip TLS certificate validation. Not for production. | You must configure exactly one authentication mechanism. Token relay is useful when each request to the PDP should carry the caller's identity, so the PDP can apply its own user-aware policies. The RSocket transport authenticates once at connection setup, so a single connection is bound to a single identity for its lifetime. Use `oauth2.client-registration-id` for managed service-account JWTs over RSocket. #### Client Resilience The remote PDP client treats every transport problem as an operational condition, never as a policy outcome, and never lets one surface as an exception. A connection drop, timeout, or decode error fails closed to `INDETERMINATE`, which the PEP enforces as a denial, so a transient PDP outage can never accidentally grant access. One-shot requests (`decideOnce`, `multiDecideAllOnce`) fail closed to `INDETERMINATE` immediately, with no retry, and never throw. The returned `Mono` always completes with a decision. In steady state the connection is warm, so only a cold or dropped connection fails closed. Subscriptions (the streaming `decide`, `multiDecide`, and `decideAll`) never terminate on a transport problem or on a server-side stream completion. The returned `Flux` never signals `onError` for a transport condition. Either condition emits one `INDETERMINATE` and then reconnects with bounded exponential backoff, indefinitely. Consecutive identical decisions are de-duplicated, so an outage yields a single `INDETERMINATE`, not a flood. A subscription ends only when the consumer cancels it or the client shuts down. This contract holds identically across the HTTP transport (`RemoteHttpPolicyDecisionPoint`) and the RSocket transport, and across every SAPL PEP client. ### Method Security Properties | Property | Default | Description | |---|---|---| | `io.sapl.method-security.adjust-transaction-order` | `true` | Reorder the `TransactionInterceptor` so the transaction wraps SAPL enforcement. Set to `false` if you have explicit AOP order requirements. See [Transaction Integration](#transaction-integration). | | `io.sapl.method-security.r2dbc-shim.enabled` | `true` | Wrap `DatabaseClient` for R2DBC query rewriting. Set to `false` to disable the shim. See [Disabling the Shim per Engine](#disabling-the-shim-per-engine). | | `io.sapl.method-security.mongo-shim.enabled` | `true` | Wrap `ReactiveMongoTemplate` for MongoDB query rewriting. Set to `false` to disable the shim. See [Disabling the Shim per Engine](#disabling-the-shim-per-engine). | ### JWT Token Injection When your application is an OAuth2 resource server using Spring Security's JWT support, SAPL can automatically inject the bearer token into authorization subscription secrets. This allows the JWT PIP to validate tokens and extract claims in policies through ``. This is opt-in for a reason. Passing a bearer token across the PEP and PDP boundary is a deliberate security trade-off. The token is placed into `subscriptionSecrets`, which is never exposed to policy evaluation, never appears in logs or `toString()` output, and is only accessible to PIPs through the `AttributeAccessContext`. It does cross a trust boundary, so it requires explicit activation. ```properties io.sapl.jwt.inject-token=true io.sapl.jwt.secrets-key=jwt ``` | Property | Default | Description | |---|---|---| | `io.sapl.jwt.inject-token` | `false` | Inject the raw encoded JWT from `JwtAuthenticationToken` into subscription secrets. | | `io.sapl.jwt.secrets-key` | `jwt` | Key name in subscription secrets. Must match the `secretsKey` configured in the JWT PIP section of `pdp.json`. | The auto-configuration activates only when both conditions are met. 1. `io.sapl.jwt.inject-token=true` is set. 2. `spring-security-oauth2-resource-server` is on the classpath, providing `JwtAuthenticationToken`. Once enabled, every authorization subscription built from `@PreEnforce` or `@PostEnforce` will automatically include the bearer token in its secrets when the authenticated principal is a `JwtAuthenticationToken`. For other authentication types, no token is injected. If the annotation also specifies an explicit `secrets` SpEL expression, the SpEL expression takes precedence and the auto-injected token is not used. Policies can then validate and inspect the token through the JWT PIP. ``` policy "require valid token with admin scope" permit .valid; "admin" in .payload.scope ``` The corresponding `pdp.json` configures the JWT PIP with public key resolution. ```json { "variables": { "jwt": { "secretsKey": "jwt", "publicKeyServer": { "uri": "https://auth-server:9000/public-key/{kid}", "method": "GET", "keyCachingTtlMillis": 300000 } } } } ``` Always use an `https` URI for the public key server. Keys fetched over plain `http` can be substituted by a network attacker, who could then forge tokens the PIP would accept as trusted. TLS authenticates the key server and protects the keys in transit. ### Subject Field Stripping When no explicit `subject` expression is provided in `@PreEnforce` or `@PostEnforce`, SAPL serializes the full `Authentication` object as the subject. To prevent accidental credential leakage, the following fields are automatically stripped from the default subject serialization. | Field | Description | |---|---| | `credentials` | Removed from the root authentication object. | | `token.tokenValue` | Raw encoded token removed from the token object (such as a JWT bearer token). | | `principal.password` | Password removed from the principal object. | | `principal.tokenValue` | Raw encoded token removed from the principal object. | Stripping applies only to the default subject construction. If you provide an explicit `subject` SpEL expression, no stripping occurs. You are responsible for excluding sensitive fields. ## Health Indicator When Spring Boot Actuator is on the classpath and the embedded PDP is enabled, SAPL automatically registers a health indicator at `/actuator/health`. It reports the operational status of all configured PDP instances. The mapping from PDP states to overall health. | PDP State | Health Status | Meaning | |---|---|---| | All `LOADED` | `UP` | All PDPs have successfully compiled their policies. | | Any `STALE` | `UP` (with warning) | A policy reload failed, but the PDP is still serving the previous valid configuration. | | Any `ERROR` or no PDPs | `DOWN` | A PDP has no valid configuration and is returning `INDETERMINATE` decisions. | Each PDP's details include the configuration ID, combining algorithm, document count, and timestamps for the last successful and failed loads. This information appears in the health endpoint response under the `sapl` component. No additional configuration is needed. The health indicator is active whenever `spring-boot-starter-actuator` is a dependency and `io.sapl.pdp.embedded.enabled` is `true` (the default). ## Common Questions **How does this differ from `@PreAuthorize`?** Spring's `@PreAuthorize` evaluates a SpEL expression at runtime. The logic lives in your Java code. SAPL evaluates external policy files, so the logic is separate from your code. This matters when policies change frequently, when non-developers need to review rules, or when the same policies apply across multiple applications. **What is the performance impact?** Each authorization check calls the PDP. With an embedded PDP, this is an in-memory call, typically sub-millisecond. With a remote PDP, there is network latency. The PDP caches policy evaluation, so repeated similar requests are fast. For most applications, the overhead is negligible compared to database or network I/O. **Can I use SAPL alongside `@PreAuthorize`?** On different methods, yes. On the same method, no. SAPL annotations and Spring Security annotations cannot be combined on a single method. **What happens if the PDP is unavailable?** With an embedded PDP, this is not an issue since it is part of your application. With a remote PDP, you configure the behavior such as deny by default, permit by default, or use cached decisions. The safe default is deny. **Where do policy files go?** By default, `src/main/resources/policies/`. The embedded PDP loads from this path when `pdp-config-type=RESOURCES`. If you use `DIRECTORY`, specify an absolute path and the PDP will watch for changes. ## Troubleshooting | Symptom | Likely Cause | Fix | |---|---|---| | `AccessDeniedException` despite PERMIT | Unhandled obligation | Check that a constraint handler's responsibility check matches the obligation's `type`. | | Handler not firing | Missing `@Component` | Ensure the handler class is annotated with `@Component` and lives in a scanned package. | | All decisions are DENY or INDETERMINATE | PDP unreachable or misconfigured | Verify `io.sapl.pdp.embedded.enabled` or remote PDP connection settings. | | `ClassCastException` on return-value transformation | Return type not Jackson-serializable | Add Jackson annotations or ensure the class follows JavaBean conventions. | | `@PostEnforce` not seeing `returnObject` | Method returns void | `@PostEnforce` requires a non-void return value to build the subscription. | | Obligation handler runs but access still denied | Handler threw an exception | Check logs for handler errors. Any obligation handler failure results in denial. | | Transaction not rolling back on denial | Custom transaction order | Verify `io.sapl.method-security.adjust-transaction-order` is not disabled. See [Transaction Integration](#transaction-integration). | | Query manipulation obligation present but query was not rewritten | Shim disabled or driver bean not wrapped | Confirm `io.sapl.method-security.r2dbc-shim.enabled` (or `mongo-shim.enabled`) is `true`. The corresponding starter (`spring-data-r2dbc` or `spring-data-mongodb` reactive) must also be on the classpath for the auto-configuration to register the `BeanPostProcessor`. | ## Next Steps The best way to learn is to try it. Start with method security on one or two endpoints. Write simple permit and deny policies. Once that works, add an obligation to see how constraints work, then a query rewriting obligation to see how the shim transparently filters at the database layer. For more details. - [SAPL Documentation](https://sapl.io/docs) for the policy language reference. - [sapl-demos](https://github.com/heutelbeck/sapl-demos) for example applications. ## NestJS SDK Attribute-Based Access Control (ABAC) for NestJS using SAPL (Streaming Attribute Policy Language). Provides decorator-driven policy enforcement with a constraint handler architecture for obligations, advice, and response transformation. Version 2.0 re-architected enforcement from the legacy constraint-bundle model to the SAPL 4.1 planner and `@StreamEnforce` model, added the RSocket transport, support for the new `SUSPEND` decision verb, and data-layer query rewriting. Projects upgrading from 1.x should consult the `@sapl/nestjs` CHANGELOG for the migration table. ## What is SAPL? SAPL is a policy language and Policy Decision Point (PDP) for attribute-based access control. Policies are written in a dedicated language and evaluated by the PDP, which streams authorization decisions based on subject, action, resource, and environment attributes. ## How @sapl/nestjs Works Three core concepts: 1. **Authorization subscription**: your app sends `{ subject, action, resource, environment }` to the PDP. 2. **PDP decision**: the PDP evaluates policies and returns a decision verb (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, or `NOT_APPLICABLE`), optionally with obligations, advice, or a replacement resource. 3. **Constraint handlers**: registered handlers execute the policy's instructions (log, filter, transform, cap values, etc.). A PDP decision looks like this: ```json { "decision": "PERMIT", "obligations": [{ "type": "logAccess", "message": "Patient record accessed" }], "advice": [{ "type": "notifyAdmin" }] } ``` `decision` is always present (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, or `NOT_APPLICABLE`). The other fields are optional. `obligations` and `advice` are arrays of arbitrary JSON objects (by convention with a `type` field for handler dispatch), and `resource` (when present) replaces the controller's return value entirely. For a deeper introduction to SAPL's subscription model and policy language, see the [SAPL documentation](https://sapl.io/docs/latest/). ## Installation [![npm](https://img.shields.io/npm/v/@sapl/nestjs)](https://www.npmjs.com/package/@sapl/nestjs) Install the library and its required peer dependencies: ```bash npm install @sapl/nestjs @toss/nestjs-aop nestjs-cls ``` If you use transactions and want obligation failures to trigger rollbacks, also install the transactional integration: ```bash npm install @nestjs-cls/transactional ``` The library requires Node.js 22 or later, NestJS 11, and RxJS 7. A complete working demo with JWT authentication, constraint handlers, and streaming enforcement is available at [sapl-nestjs-demo](https://github.com/heutelbeck/sapl-nestjs-demo). ## Setup ### Direct Configuration (API Key) ```typescript import { Module } from '@nestjs/common'; import { SaplModule } from '@sapl/nestjs'; @Module({ imports: [ SaplModule.forRoot({ baseUrl: 'https://localhost:8443', token: 'sapl_your_api_key_here', timeout: 5000, // PDP request timeout in ms (default: 5000) }), ], }) export class AppModule {} ``` ### Direct Configuration (Basic Auth) ```typescript @Module({ imports: [ SaplModule.forRoot({ baseUrl: 'https://localhost:8443', username: 'myPdpClient', secret: 'myPassword', }), ], }) export class AppModule {} ``` `token` (API key or JWT) and `username`/`secret` (Basic Auth) are mutually exclusive. Configure one or the other. Providing both throws an error at startup. ### Direct Configuration (OAuth2 client_credentials) For a service account registered at an OIDC issuer, set `oauth2`. The client obtains a bearer token via the `client_credentials` grant and refreshes it automatically before expiry. It works on both transports and is mutually exclusive with `token` and `username`/`secret`. ```typescript @Module({ imports: [ SaplModule.forRoot({ baseUrl: 'https://localhost:8443', oauth2: { issuerUrl: 'https://issuer.example.org/realms/sapl', clientId: 'sapl-client', clientSecret: 'your-client-secret', scope: 'sapl', // optional }, }), ], }) export class AppModule {} ``` ### Async Configuration ```typescript import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { SaplModule } from '@sapl/nestjs'; @Module({ imports: [ ConfigModule.forRoot(), SaplModule.forRootAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ baseUrl: config.get('SAPL_PDP_URL', 'https://localhost:8443'), token: config.get('SAPL_PDP_TOKEN'), }), inject: [ConfigService], }), ], }) export class AppModule {} ``` `SaplModule` registers everything automatically: - `PdpService` for PDP communication - `EnforcementPlanner` for constraint handler discovery and enforcement plan construction - `ProviderRegistry` for constraint handler provider discovery - `PreEnforceAspect`, `PostEnforceAspect`, and `StreamEnforceAspect` via `@toss/nestjs-aop` - `ClsModule` from `nestjs-cls` for request context propagation - Built-in `ContentFilteringProvider` and `ContentFilterPredicateProvider` The decorators work on any injectable class method (controllers, services, repositories, etc.). Methods without enforcement decorators are unaffected. ### Transport The PDP client speaks HTTP by default. To opt into the high-throughput binary protocol against a SAPL Node listening on its RSocket port, set `transport: 'rsocket'`. ```typescript SaplModule.forRoot({ baseUrl: 'https://localhost:8443', transport: 'rsocket', rsocketPort: 7000, token: 'sapl_your_api_key_here', }), ``` The RSocket transport is covered in more detail under [RSocket Transport](#rsocket-transport) below. ## Security ### Transport Security `@sapl/nestjs` encrypts PDP communication by default. Authorization decisions and potentially sensitive information are transmitted over this connection. Using unencrypted transport would expose this data to network-level attackers. The library enforces a loopback-only plaintext rule. A plain `http://` base URL (HTTP transport) or a missing `tls` block (RSocket transport) is accepted only when the target host is a loopback address (`localhost`, `127.0.0.1`, `::1`). Any plaintext connection to a non-loopback host is refused at client construction with an error. On loopback the HTTP client logs a warning to flag that production must use TLS. For custom CA certificates, self-signed certificates, or mutual TLS, pass a `tls` block. The library never reads files. Load PEM contents yourself, for example with `fs.readFileSync`, and pass the contents. ```typescript import { readFileSync } from 'node:fs'; SaplModule.forRoot({ baseUrl: 'https://pdp.example.org:8443', token: 'sapl_your_api_key_here', tls: { ca: readFileSync('ca.pem'), // cert and key for mutual TLS, both optional // cert: readFileSync('client-cert.pem'), // key: readFileSync('client-key.pem'), // rejectUnauthorized defaults to true. Leave true in production. }, }), ``` The same `tls` block applies to both transports. On the RSocket transport `servername` selects the SNI host name and defaults to the connection host. On the HTTP transport SNI is derived from the URL and `servername` is ignored. `rejectUnauthorized` defaults to `true` and should be set to `false` only in tests against self-signed certificates without a provided CA. ### Response Validation PDP responses are validated before use. Malformed responses (non-object, missing or invalid `decision` field) are treated as `INDETERMINATE` (deny). Unknown fields in the response are silently dropped to stay robust against future PDP extensions. ### Streaming Limits The streaming SSE parser enforces a 64 KB buffer limit per connection. If the PDP sends data without newline delimiters exceeding this limit, the connection is aborted and an `INDETERMINATE` decision is emitted. This protects against memory exhaustion from misbehaving upstream connections. ## Decorators ### @PreEnforce Authorizes **before** the method executes. The method only runs on PERMIT. Works on any injectable class method. ```typescript import { Controller, Get } from '@nestjs/common'; import { PreEnforce } from '@sapl/nestjs'; @Controller('api') export class PatientController { @PreEnforce({ action: 'read', resource: 'patient' }) @Get('patient') getPatient() { return { name: 'Jane Doe', ssn: '123-45-6789' }; } } ``` Use `@PreEnforce` for methods with side effects (database writes, emails) that should not execute when access is denied. ### @PostEnforce Authorizes **after** the method executes. The method always runs. Its return value is available via `ctx.returnValue` in subscription field callbacks. ```typescript import { Controller, Get, Param } from '@nestjs/common'; import { PostEnforce } from '@sapl/nestjs'; @Controller('api') export class RecordController { @PostEnforce({ action: 'read', resource: (ctx) => ({ type: 'record', data: ctx.returnValue }), }) @Get('record/:id') getRecord(@Param('id') id: string) { return { id, value: 'sensitive-data' }; } } ``` Use `@PostEnforce` when the policy needs to see the actual return value to make its authorization decision (e.g., deny based on the data's classification). ### Subscription Fields Both decorators accept `SubscriptionOptions` to customize the authorization subscription: ```typescript type SubscriptionField = T | ((ctx: SubscriptionContext) => T); ``` The `SubscriptionContext` provides: | Field | Type | Description | | ------------- | -------------------------------------- | ------------------------------------------------------ | | `request` | `any` | Full Express request (`req.user`, `req.headers`, etc.) | | `params` | `Record` | Route parameters (`@Get(':id')` -> `ctx.params.id`) | | `query` | `Record` | Query string parameters | | `body` | `any` | Request body (POST/PUT) | | `handler` | `string` | Handler method name | | `controller` | `string` | Controller class name | | `returnValue` | `any` | Handler return value (`@PostEnforce` only) | | `args` | `any[] \| undefined` | Method arguments (optional) | #### Default Values | Field | Default | | ------------- | --------------------------------------------------------------------------------- | | `subject` | `req.user ?? 'anonymous'` (decoded JWT claims, or `'anonymous'` if no auth guard) | | `action` | `{ method, controller, handler }` | | `resource` | `{ path, params }` | | `environment` | `{ ip, hostname }` | | `secrets` | Not sent unless explicitly specified | The `secrets` field carries sensitive data (tokens, API keys) that the PDP needs for policy evaluation but that must not appear in logs. It is excluded from debug logging automatically. Use it when a policy needs to inspect credentials, for example passing a raw JWT so the PDP can read its claims: ```typescript @PreEnforce({ action: 'exportData', resource: (ctx) => ({ pilotId: ctx.params.pilotId }), secrets: (ctx) => ({ jwt: ctx.request.headers.authorization?.split(' ')[1] }), }) ``` ### Shaping the Deny Response On denial the PEP throws a NestJS `ForbiddenException` (the streaming PEP throws `AccessDeniedError`, a subclass of `ForbiddenException`). There is no per-decorator deny callback. To shape the deny response, catch the exception with a standard NestJS exception filter. ```typescript import { ArgumentsHost, Catch, ExceptionFilter, ForbiddenException } from '@nestjs/common'; @Catch(ForbiddenException) export class AccessDeniedFilter implements ExceptionFilter { catch(exception: ForbiddenException, host: ArgumentsHost) { const response = host.switchToHttp().getResponse(); response.status(403).json({ error: 'access_denied' }); } } ``` Exception filters integrate correctly with `@Transactional`. A per-decorator deny-return would silently commit the transaction when a post-method obligation fails, which is why deny shaping lives in the exception filter rather than the decorator. ## How Enforcement Works The decorators above are convenient, but to use them well it helps to understand what actually happens behind the scenes. This section walks through the enforcement lifecycle so you can reason about behavior. ### The Deny Invariant Only `PERMIT` grants access. The PDP can return five possible decisions (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, `NOT_APPLICABLE`), and only `PERMIT` ever results in your method running or your stream forwarding data. Everything else means denial. Streaming PEPs that honour `SUSPEND` pause the stream while keeping the subscription alive. One-shot PEPs treat `SUSPEND` as `DENY`. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for details. A `PERMIT` with obligations is not a free pass. The PEP checks that every obligation in the decision has a registered handler. If even one obligation cannot be fulfilled, the PEP treats the decision as a denial. If a handler accepts responsibility but fails during execution, that also results in denial. Advice is softer: if an advice handler fails, the PEP logs the failure and moves on. Advice never causes denial. | Aspect | Obligation | Advice | |-----------------|------------------------------------------------------------------|--------------------------------------------------| | All handled? | Required. Unhandled obligations deny access (ForbiddenException) | Optional. Unhandled advice is silently ignored. | | Handler failure | Denies access (ForbiddenException) | Logs a warning and continues. | This means you can always trust that if your method runs, every obligation attached to the decision has been successfully enforced. ### Enforcement Locations Depending on the decorator, constraint handlers can intervene at different points in the lifecycle of a request or stream. For request-response methods (`@PreEnforce` and `@PostEnforce`), constraints can run at four points: | Location | When it happens | What constraints do here | |-----------------------|--------------------------------------|-------------------------------------------------| | On decision | Authorization decision arrives | Side effects like logging, audit, or notification| | Pre-method invocation | Before the protected method executes | Modify method arguments (`@PreEnforce` only) | | On return value | After the method returns | Transform, filter, or replace the result | | On error | If the method throws | Transform or observe the error | For the streaming method decorator (`@StreamEnforce`), constraints attach to a wider set of lifecycle signals: | Signal | When it fires | What constraints do here | |---------------|----------------------------------------------|-----------------------------------------| | `decision` | Each new decision from the PDP stream | Side effects like logging, audit | | `output` | Each element emitted by the source stream | Transform, filter, or observe items | | `error` | Source stream produces an error | Transform or observe the error | | `subscribe` | The source stream is subscribed | Setup side effects | | `complete` | Source stream completes normally | Cleanup and finalization | | `cancel` | Subscriber cancels | Release resources | | `termination` | The pipeline finalizes for any reason | Final cleanup | A handler decides which signals it attaches to. A side-effect-only handler attaches to a void signal such as `decision`, `complete`, or `cancel`. A handler that observes or transforms a value attaches to a data-carrying signal (`input`, `output`, or `error`). The `input` signal exists only on `@PreEnforce`, where a handler can rewrite the method arguments before the method runs. After the method has executed there is nothing to rewrite, so `@PostEnforce` does not advertise it. ### PreEnforce Lifecycle When you decorate a method with `@PreEnforce`, here is what happens step by step. First, the PEP builds an authorization subscription from the decorator options (or from defaults if you left them out) and sends it to the PDP as a one-shot request. The PDP evaluates the subscription against all matching policies and returns a single decision. If the decision is anything other than `PERMIT`, the PEP throws a `ForbiddenException` immediately. Your method never runs. If the decision is `PERMIT`, the PEP resolves all constraint handlers. It walks through the obligations and advice attached to the decision and checks which registered handlers claim responsibility for each one. If any obligation has no matching handler, the PEP denies access right there, because it cannot guarantee the obligation will be enforced. With all handlers resolved, execution proceeds through the enforcement locations in order. On-decision handlers run first (logging, audit). Then method-invocation handlers run, which can modify method arguments if the policy requires it. Then your actual method executes. After the method returns, the PEP applies return-value handlers: resource replacement if the decision included one, filter predicates, mapping handlers, and consumer handlers. If any obligation handler fails at any stage, the PEP denies access. If you have transaction integration enabled (`transactional: true`), a constraint handler failure after the method returns will trigger a rollback, so the database write does not persist. ### PostEnforce Lifecycle `@PostEnforce` inverts the order. Your method runs first, regardless of the authorization outcome. Only after it returns does the PEP build the authorization subscription (now including `ctx.returnValue`) and consult the PDP. This means the PDP can make decisions based on the actual data your method produced. For example, a policy might permit access to a record only if its classification level is below a threshold, something that can only be checked after loading the record. If the decision is not `PERMIT`, the PEP discards the return value and throws `ForbiddenException`. If you have transaction integration enabled, this triggers a rollback. If the decision is `PERMIT`, constraint handlers proceed through the same stages as `@PreEnforce`, minus the method-invocation handlers (since the method has already run). Return-value handlers can still transform the result before it reaches the caller. Because the method runs before the PDP is consulted, if the method itself throws an exception, that exception propagates directly. The PDP is never called, because there is no return value to include in the subscription. SAPL PEP libraries share a single unified enforcement model. It is a strict fail-closed state machine over the five decision verbs, where only `PERMIT` grants access and only an explicit `SUSPEND` pauses a stream without terminating it. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for the decision-verb semantics. ## Constraint Handlers When the PDP returns a decision with `obligations` or `advice`, the `EnforcementPlanner` queries the registered constraint handler providers, builds an enforcement plan, and the active aspect executes that plan against each lifecycle signal. ### Obligation vs. Advice Semantics The core contract between obligations and advice is covered in [The Deny Invariant](#the-deny-invariant) above. In short, unhandled or failing obligations deny access, advice failures are logged and ignored. ### The Provider Interface There is a single constraint handler provider interface. A provider inspects one constraint and returns the scoped handlers that enforce it, or an empty array when it does not recognise the constraint. ```typescript interface ConstraintHandlerProvider { getHandlers(constraint: unknown): ReadonlyArray; } ``` A `ScopedHandler` bundles four things. The `signal` it attaches to, a `priority` (lower runs earlier among handlers on the same signal), a `shape`, and the handler function itself. ```typescript interface ScopedHandler { readonly signal: SignalKind; readonly priority: number; readonly shape: 'runner' | 'consumer' | 'mapper'; readonly handler: (value: unknown) => unknown | void; } ``` The three shapes determine what the handler does with the value passed to it. | Shape | Signature | Use when | |------------|--------------------|--------------------------------------------------------------------------| | `runner` | `() => void` | A side effect that needs no value. Logging the decision, sending a notification. | | `consumer` | `(value) => void` | A side effect that observes the value but does not change it. Structured audit logging of the response. | | `mapper` | `(value) => value` | A transformation of the value flowing through a data-carrying signal. Redacting fields in `output`, rewriting an error. | A single provider can return several handlers across different signals for one constraint. For example one constraint can drive both a `decision` runner that records the outcome and an `output` consumer that audits the response. Two admissibility rules apply. A `mapper` may only be returned for an obligation, never for advice. Advice is allowed to fail silently, and a value transformation that silently does not happen would leave the caller unable to tell whether the result was transformed. If a provider returns a mapper for an advice constraint, the planner replaces the whole claim with a synthetic failure runner. The second rule is that `consumer` and `mapper` handlers attach only to the data-carrying signals (`input`, `output`, `error`), while `runner` handlers attach to any signal. A handler scoped to a signal the active PEP does not advertise is inadmissible, and an inadmissible handler for an obligation denies access. ### Lifecycle Signals A signal is the discriminated union of lifecycle events at which handlers may attach. There are eight kinds, four value-carrying and four void. | Kind | Carries | Fires | |---------------|-------------------|------------------------------------------------------------| | `decision` | the decision | When a decision arrives (runners only). | | `input` | the method args | Before the method runs, `@PreEnforce` only. Args are mutable. | | `output` | the return value | After the method returns, or per item on a stream. | | `error` | the thrown error | When the method or stream produces an error. | | `subscribe` | nothing | When a streaming source is subscribed. | | `cancel` | nothing | When the subscriber cancels. | | `complete` | nothing | When the source completes normally. | | `termination` | nothing | When the streaming pipeline finalizes for any reason. | Which signals a PEP advertises depends on the decorator. `@PreEnforce` advertises `decision`, `input`, `output`, and `error`. `@PostEnforce` advertises `decision`, `output`, and `error` (no `input`, the method has already run). `@StreamEnforce` advertises `decision`, `output`, `error`, `subscribe`, `cancel`, `complete`, and `termination`. ### Registering Custom Handlers A constraint handler is an injectable class annotated with `@SaplConstraintHandler('provider')`. The literal `'provider'` is the only accepted argument. It tags the class for discovery. ```typescript import { Injectable } from '@nestjs/common'; import { SaplConstraintHandler } from '@sapl/nestjs'; import type { ConstraintHandlerProvider, ScopedHandler } from '@sapl/nestjs'; @Injectable() @SaplConstraintHandler('provider') export class AuditLogHandler implements ConstraintHandlerProvider { getHandlers(constraint: unknown): ReadonlyArray { if ((constraint as { type?: unknown })?.type !== 'logAccess') { return []; } const message = (constraint as { message?: string }).message ?? 'Access logged'; return [ { signal: 'decision', priority: 0, shape: 'runner', handler: () => console.log(`Audit: ${message}`), }, ]; } } ``` Register the handler in any module's `providers` array. The `ProviderRegistry` discovers all `@SaplConstraintHandler('provider')`-decorated classes automatically. A handler that rewrites the method arguments returns a `mapper` on the `input` signal. The `input` value is the argument array, and the mapper returns the replacement array. This signal is only available under `@PreEnforce`. ```typescript @Injectable() @SaplConstraintHandler('provider') export class CapTransferHandler implements ConstraintHandlerProvider { getHandlers(constraint: unknown): ReadonlyArray { if ((constraint as { type?: unknown })?.type !== 'capTransferAmount') { return []; } const max = (constraint as { maxAmount: number }).maxAmount; const argIndex = (constraint as { argIndex?: number }).argIndex ?? 0; return [ { signal: 'input', priority: 0, shape: 'mapper', handler: (value) => { const args = [...(value as unknown[])]; if (Number(args[argIndex]) > max) { args[argIndex] = max; } return args; }, }, ]; } } ``` ## Built-in Constraint Handlers ### ContentFilteringProvider **Constraint type:** `filterJsonContent` Transforms response values by deleting, replacing, or blackening fields. ```json { "type": "filterJsonContent", "actions": [ { "type": "blacken", "path": "$.ssn", "discloseRight": 4 }, { "type": "delete", "path": "$.internalNotes" }, { "type": "replace", "path": "$.classification", "replacement": "REDACTED" } ] } ``` The `blacken` action supports these options: | Option | Type | Default | Description | | --------------- | ------ | ----------------------------- | ------------------------------------------ | | `path` | string | (required) | Dot-notation path to a string field | | `replacement` | string | `"\u2588"` (block character) | Character used for masking | | `discloseLeft` | number | `0` | Characters to leave unmasked from the left | | `discloseRight` | number | `0` | Characters to leave unmasked from the right | | `length` | number | (masked section length) | Override the length of the masked section | ### ContentFilterPredicateProvider **Constraint type:** `jsonContentFilterPredicate` Filters array elements or nullifies single values that do not meet conditions. ```json { "type": "jsonContentFilterPredicate", "conditions": [ { "path": "$.classification", "type": "!=", "value": "top-secret" } ] } ``` ### ContentFilter Limitations The built-in content filter supports **simple dot-notation paths only** (`$.field.nested`). Recursive descent (`$..ssn`), bracket notation (`$['field']`), array indexing (`$.items[0]`), wildcards (`$.users[*].email`), and filter expressions (`$.books[?(@.price<10)]`) are not supported and will throw an error. ## Query Rewriting Constraint handlers also cover data-layer enforcement: a policy can attach a query-rewriting obligation that narrows the rows an enforced method reads at the database, rather than filtering them in memory. The query an enforced method issues is rewritten transparently, fail-closed and narrowing-only. Two integrations ship as optional subpath exports, so you install only the driver you use: - **`@sapl/nestjs/mongoose`** for MongoDB on Mongoose. Register the shim and apply `createSaplMongoosePlugin(cls)` to your schemas, then add `MongoDbQueryRewritingProvider` to your module. It honours the `mongo:queryRewriting` obligation. - **`@sapl/nestjs/prisma`** for SQL on Prisma. Register the shim and extend your client with `createSaplPrismaExtension(cls)`, then add `SqlQueryRewritingProvider`. It honours the `sql:queryRewriting` obligation (typed `criteria` and `columns`, because Prisma's structured `where` cannot lower the raw-SQL `conditions` escape hatch). The obligation format is identical across every SAPL PEP for a backend, so the same `mongo:queryRewriting` policy works unchanged on the Spring, Python, and NestJS MongoDB integrations. See [Query Rewriting](6_12_QueryRewriting.md) for the obligation schema, semantics, and setup. ## Streaming Enforcement with @StreamEnforce `@PreEnforce` and `@PostEnforce` make a single authorization decision and either let the method run or deny it. They suit request-response endpoints. For SSE endpoints that return an `Observable`, the decision is rarely a single point in time. The same subscription stays open while the policy evaluates against attribute streams that may change. The single `@StreamEnforce` decorator covers this case. ```typescript import { Injectable } from '@nestjs/common'; import { Observable, interval, map } from 'rxjs'; import { StreamEnforce } from '@sapl/nestjs'; @Injectable() export class HeartbeatService { @StreamEnforce({ action: 'stream:heartbeat', resource: 'heartbeat' }) heartbeat(): Observable { return interval(2000).pipe(map((i) => ({ seq: i }))); } } ``` `@StreamEnforce` consumes a continuous stream of authorization decisions from the PDP. As decisions change, the aspect lets items flow, drops them silently, or terminates the subscription accordingly. The protected method must return an `Observable`. For `Observable`-of-one or request-response semantics, use `@PreEnforce`/`@PostEnforce`. ### How Decisions Affect the Subscription Every decision the PDP emits during the lifetime of the subscription has one of five verbs, and each maps to a single observable effect. | PDP decision | Effect on the subscription | |---|---| | `PERMIT` | Items from the protected method flow through to the subscriber. | | `SUSPEND` | Items are silently dropped. The subscription stays open. A later `PERMIT` resumes the flow. | | `INDETERMINATE` | The subscription terminates with an `AccessDeniedError`. | | `NOT_APPLICABLE` | The subscription terminates with an `AccessDeniedError`. | | `DENY` | The subscription terminates with an `AccessDeniedError`. | Under the strict fail-closed discipline, `INDETERMINATE`, `NOT_APPLICABLE`, and a `PERMIT` whose decision-scoped enforcement fails all terminate the subscription with an `AccessDeniedError`. Only an explicit `SUSPEND` from the PDP silences (rather than terminates) the subscription. Operators who want `NOT_APPLICABLE` to silence rather than terminate set the combining algorithm's `defaultDecision` to `SUSPEND` at the PDP level, producing a real `SUSPEND` decision the streaming PEP then routes through suspension. A subscription that has been silenced by a `SUSPEND` resumes the moment the PDP emits a `PERMIT` again. This is the use case the `suspend` verb in policies was designed for. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for the policy-side semantics. Per-item obligation failure also terminates the subscription, with an `AccessDeniedError` carrying a message indicating the per-item discharge failure. Per-item failure is unconditionally terminal, matching strict `@PreEnforce` semantics on a per-item timeline. `AccessDeniedError` is a subclass of NestJS `ForbiddenException`, so a terminal denial routes through the HTTP layer as a 403 natively and is caught by the same exception filters that catch a `@PreEnforce` denial. ### The Streaming State Machine The streaming pipeline is a four-state machine over the decision verbs. It starts in `Pending` before any decision arrives. A `PERMIT` (with successful decision-scoped enforcement) moves it to `Permitting`, where items flow. A `SUSPEND` moves it to `Suspended`, where items are dropped and the subscription stays open. Any terminal verb (`DENY`, `INDETERMINATE`, `NOT_APPLICABLE`, a `PERMIT` whose enforcement fails, a per-item failure, a source error, or subscriber cancel) moves it to the absorbing `Terminated` state. From `Suspended` a later `PERMIT` returns to `Permitting`. The machine is the local realization of the unified enforcement model described under [Authorization Decisions](../2_3_AuthorizationDecisions/). ### Two Flags `@StreamEnforce` carries two boolean flags, both defaulting to `false`. Each addresses one orthogonal concern. ```typescript @StreamEnforce({ signalTransitions: false, // default false pauseRapDuringSuspend: false, // default false }) ``` **`signalTransitions`**. Surfaces every suspend/resume boundary to the subscriber as a non-terminal value on the `next` channel. When `false` (the default), boundary transitions are silent. The subscriber sees items while permitted and silence while suspended, with no programmatic notification of the transition itself. When `true`, the subscriber receives an `AccessSuspendedSignal` value every time the subscription is silenced and an `AccessGrantedSignal` value (carrying the granting decision) every time it resumes. These arrive on the `next` channel, not the error channel. Subscribers detect them with `instanceof` or with the `TransitionSignals` helper operators below. Terminal denials bypass the gate entirely and surface on the `error` channel as `AccessDeniedError` regardless of this flag. **`pauseRapDuringSuspend`**. Controls the underlying source Observable while the subscription is silenced. With the default `false`, the protected method's Observable stays subscribed throughout the silenced period. Items keep arriving from upstream and are silently dropped on the way to the subscriber. Lower latency on resume, and upstream state is preserved. With `true`, the upstream subscription is disposed when the subscription enters `Suspended` and re-established when it resumes into `Permitting`. This stops upstream side effects during suspension at the cost of paying re-subscription latency on resume. Opt in for upstream sources with expensive side effects that must not run while the subscriber is denied access. ### The Source Observable and Authorization Ordering The protected method's Observable is subscribed only after the first `PERMIT` decision arrives from the PDP. For hot observables (WebSocket streams, event emitters), events emitted before the initial `PERMIT` are not buffered and will not be delivered. This is intentional. Data should not be buffered before authorization is confirmed. RxJS is push-only, so the streaming pipeline carries no demand-forwarding logic and no hidden buffer. A slow subscriber backs up in its own buffers, not in the PEP. ### Subscriber-Side Transition Handling When `signalTransitions = true`, the `TransitionSignals` helper operators translate the in-band `AccessSuspendedSignal` / `AccessGrantedSignal` values into ordinary callbacks and re-emit a clean stream of source values to the downstream consumer. ```typescript import { TransitionSignals } from '@sapl/nestjs'; const clean = TransitionSignals.onTransitions( heartbeatService.heartbeat(), (suspended) => log.info('Stream suspended'), (granted) => log.info('Stream resumed', granted.decision), ); ``` `TransitionSignals` exposes three operators. `onSuspend` observes the suspend boundary, `onGranted` observes the resume boundary, and `onTransitions` composes both. Each operator either drops the boundary value from the stream or, with an extra substitute callback, replaces it with a value of the source type. ### Three Common Patterns The flag combinations encode the three behavioural patterns most streaming endpoints want. **Terminate on deny.** The subscription should end the moment access is revoked, and the subscriber should know. The defaults are sufficient. A `DENY` from the PDP terminates the subscription with `AccessDeniedError`. A `SUSPEND` keeps the subscription alive but silently drops items. ```typescript @StreamEnforce({ action: 'stream:trades' }) liveTrades(): Observable { ... } ``` **Drop while suspended, silent transitions.** The subscription should survive deny windows transparently, with no boundary events. The defaults are again sufficient. The difference is in the policy, which uses the `suspend` verb instead of `deny` for the deny windows. The PDP returns `SUSPEND`, items are silently dropped, the subscription stays open, and a later `PERMIT` resumes the flow. ```typescript @StreamEnforce({ action: 'stream:telemetry' }) telemetry(): Observable { ... } ``` **Survive deny with explicit transition signals.** The subscription should survive, and the subscriber wants to know about every boundary. The policy returns `SUSPEND` for windows where access should pause, and the subscriber observes the boundary signals. ```typescript @StreamEnforce({ action: 'stream:market', signalTransitions: true }) marketData(): Observable { ... } ``` ### Subscription, Action, and Resource `@StreamEnforce` carries the same `SubscriptionOptions` slots as `@PreEnforce` for shaping the authorization subscription. When omitted, defaults are derived from the request and method invocation as for the request-response annotations. See [Subscription Fields](#subscription-fields) above. ### Streaming Constraint Handlers The same `ConstraintHandlerProvider` mechanism that powers `@PreEnforce` and `@PostEnforce` applies. Decision-scoped handlers attach to the `decision` signal and run once per decision arrival. Per-item handlers attach to the `output` signal and run on every emitted item. The full set of signals `@StreamEnforce` advertises is listed under [Lifecycle Signals](#lifecycle-signals) above. ## Manual PDP Access ```typescript import { Controller, ForbiddenException, Get, Request } from '@nestjs/common'; import { PdpService } from '@sapl/nestjs'; @Controller('api') export class AppController { constructor(private readonly pdpService: PdpService) {} @Get('hello') async getHello(@Request() req) { const decision = await this.pdpService.decideOnce({ subject: req.user, action: 'read', resource: 'hello', }); if (decision.decision === 'PERMIT' && !decision.obligations?.length) { return { message: 'Hello World' }; } throw new ForbiddenException('Access denied'); } } ``` ### Multi-Subscription API When you need authorization decisions for multiple resources in a single request, use the multi-subscription methods instead of calling `decideOnce` in a loop. #### One-Shot (multiDecideAllOnce) Returns a snapshot mapping each subscription ID to its decision: ```typescript const result = await this.pdpService.multiDecideAllOnce({ subscriptions: { readPatient: { subject: req.user, action: 'read', resource: 'patient' }, readLab: { subject: req.user, action: 'read', resource: 'labResults' }, readNotes: { subject: req.user, action: 'read', resource: 'clinicalNotes' }, }, }); // result.decisions['readPatient'].decision === 'PERMIT' // result.decisions['readLab'].decision === 'DENY' // result.decisions['readNotes'].decision === 'PERMIT' ``` #### Streaming Individual Decisions (multiDecide) Emits an `IdentifiableAuthorizationDecision` each time an individual subscription's decision changes: ```typescript this.pdpService.multiDecide({ subscriptions: { readPatient: { subject: req.user, action: 'read', resource: 'patient' }, readLab: { subject: req.user, action: 'read', resource: 'labResults' }, }, }).subscribe((event) => { // event.subscriptionId === 'readPatient' // event.decision.decision === 'PERMIT' }); ``` #### Streaming Complete Snapshots (multiDecideAll) Emits a `MultiAuthorizationDecision` containing all current decisions whenever any individual decision changes: ```typescript this.pdpService.multiDecideAll({ subscriptions: { readPatient: { subject: req.user, action: 'read', resource: 'patient' }, readLab: { subject: req.user, action: 'read', resource: 'labResults' }, }, }).subscribe((snapshot) => { // snapshot.decisions['readPatient'].decision === 'PERMIT' // snapshot.decisions['readLab'].decision === 'DENY' }); ``` Both streaming methods reconnect with exponential backoff on connection loss and suppress consecutive duplicate events. ## Advanced Configuration ### Using nestjs-cls (Continuation-Local Storage) in Your Application CLS (Continuation-Local Storage) provides per-request context that follows the async call chain, similar to thread-local storage in Java. `@sapl/nestjs` uses it internally to pass the HTTP request object from the middleware layer into the AOP aspects without requiring explicit parameter passing. `SaplModule` manages `ClsModule` from `nestjs-cls` automatically. CLS middleware is mounted globally and the HTTP request is stored at the `CLS_REQ` key. **If you already use `nestjs-cls`:** Remove your own `ClsModule.forRoot()` call. Since `ClsService` is globally available, inject it anywhere to set/get custom CLS values as before. Your interceptors and guards that use `ClsService` continue to work unchanged. **If you need custom CLS options** (custom `idGenerator`, `setup` callback, guard/interceptor mode instead of middleware): Pass them via the `cls` option in `SaplModule.forRoot()`: ```typescript SaplModule.forRoot({ baseUrl: 'https://localhost:8443', cls: { middleware: { mount: true, setup: (cls, req) => { cls.set('TENANT_ID', req.headers['x-tenant-id']); }, }, }, }) ``` The `cls` options are merged into the default configuration (`{ global: true, middleware: { mount: true } }`), so you only need to specify the parts you want to customize. The `cls` option is honoured only by `SaplModule.forRoot()`. `SaplModule.forRootAsync()` ignores it, because module imports are resolved before the async factory runs and the factory result is not available at import time. `ClsModule` always gets the defaults under `forRootAsync`. Applications that need custom CLS setup with async configuration inject `ClsService` in a guard or interceptor instead. ### Transaction Integration #### The Problem When `@PreEnforce` and a database transaction coexist on a method, the transaction typically commits inside the method body. SAPL's post-method constraint handlers run after the method returns. If a constraint handler fails at that point, the transaction has already committed and cannot be rolled back. The same problem applies to `@PostEnforce`. The method executes (and commits its transaction) before the PDP even makes its authorization decision. A subsequent DENY cannot undo committed database writes. #### The Solution Set `transactional: true` in `SaplModule.forRoot()`. When enabled, `@PreEnforce` and `@PostEnforce` wrap method execution and constraint handling in a single database transaction via `@nestjs-cls/transactional`. Any constraint failure, method error, or DENY decision triggers a rollback. ```bash npm install @nestjs-cls/transactional @nestjs-cls/transactional-adapter-typeorm ``` ```typescript import { Module } from '@nestjs/common'; import { SaplModule } from '@sapl/nestjs'; import { ClsPluginTransactional } from '@nestjs-cls/transactional'; import { TransactionalAdapterTypeOrm } from '@nestjs-cls/transactional-adapter-typeorm'; @Module({ imports: [ TypeOrmModule.forRoot({ /* ... */ }), SaplModule.forRoot({ baseUrl: 'https://localhost:8443', token: 'sapl_api_key', transactional: true, cls: { plugins: [ new ClsPluginTransactional({ imports: [TypeOrmModule], adapter: new TransactionalAdapterTypeOrm({ dataSourceName: 'default' }), }), ], }, }), ], }) export class AppModule {} ``` For Prisma, replace the adapter: ```bash npm install @nestjs-cls/transactional @nestjs-cls/transactional-adapter-prisma ``` ```typescript cls: { plugins: [ new ClsPluginTransactional({ imports: [PrismaModule], adapter: new TransactionalAdapterPrisma({ prismaInjectionToken: PrismaService }), }), ], }, ``` #### What Gets Wrapped | Decorator | Without `transactional` | With `transactional: true` | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `@PreEnforce` | Phase 1 (pre-method handlers) runs outside any transaction. Phase 2 (method) and Phase 3 (post-method handlers) run independently. | Phase 2 + Phase 3 are wrapped in `withTransaction()`. Constraint failure after method execution triggers rollback. | | `@PostEnforce` | Method runs first, then PDP check, then constraint handlers. Each step is independent. | The entire sequence (method + PDP check + constraint handling) runs in a single transaction. A DENY after method execution triggers rollback. | #### Manual Alternative: Decorator Ordering If you cannot use `@nestjs-cls/transactional`, ensure `@Transactional()` is applied above the enforcement decorator so the transaction boundary wraps the entire enforcement lifecycle: ```typescript @Transactional() // outer: starts transaction @PreEnforce() // inner: method + constraints run inside the transaction @Post('transfer') transfer(@Body() dto: TransferDto) { return this.accountService.transfer(dto); } ``` NestJS decorators execute bottom-up, so `@PreEnforce` runs first (inside the transaction started by `@Transactional`). #### Limitation: Callback-Based Transactions Methods that manage their own transaction via callback APIs (`prisma.$transaction(async (tx) => ...)`, `queryRunner.startTransaction()`) are not affected by `transactional: true`. The SAPL transaction wrapper and the method's internal transaction are independent. Use the decorator-based approach or restructure to use `@nestjs-cls/transactional`'s `TransactionHost` instead. #### Runtime Warning If `transactional: true` is set but `@nestjs-cls/transactional` is not installed or `ClsPluginTransactional` is not registered, `SaplTransactionAdapter` logs a warning at first request time and falls back to non-transactional execution. ## RSocket Transport The PDP client speaks one of two transports, selected at module configuration time by `transport`. The default `'http'` is the broadest fit. Its streaming path decodes server-sent events, with the buffer limit described under [Streaming Limits](#streaming-limits). The `'rsocket'` transport uses protobuf framing over a long-lived TCP connection against a SAPL Node listening on its RSocket port, with streaming carried over RSocket request-stream rather than SSE, trading per-request flexibility for substantially higher per-call throughput. ```typescript SaplModule.forRoot({ baseUrl: 'https://pdp.example.org:8443', transport: 'rsocket', rsocketHost: 'pdp.example.org', // defaults to the hostname from baseUrl rsocketPort: 7000, // defaults to 7000 token: 'sapl_your_api_key_here', tls: { ca: caPem }, }), ``` `rsocketHost` defaults to the hostname extracted from `baseUrl`, and `rsocketPort` defaults to `7000`. The same loopback-only plaintext rule applies. Without a `tls` block the RSocket client refuses to connect to a non-loopback host. ### Authentication The RSocket transport authenticates once at connection setup. The credential is carried in the setup-frame metadata and binds the whole connection to a single identity for its lifetime. The auth modes are wired through `SaplModule.forRoot`. | Mode | Configuration | |---|---| | No auth | omit `token`, `username`, `secret`, and `oauth2` | | Basic | `username` + `secret` | | API key (bearer) | `token` | | OAuth2 client_credentials | `oauth2` | These are the same `token`, `username`/`secret`, and `oauth2` fields used by the HTTP transport, and they are mutually exclusive on both transports. The `oauth2` option wraps `openid-client` for the `client_credentials` grant with automatic refresh. On RSocket the bearer token is acquired before connection setup and carried in the setup-frame metadata. All options for `SaplModule.forRoot()` / `SaplModule.forRootAsync()`: | Option | Type | Default | Description | | ------------------------- | --------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------- | | `baseUrl` | `string` | (required) | Base URL of the SAPL PDP server for the HTTP transport. Also the RSocket host fallback. | | `transport` | `'http' \| 'rsocket'` | `'http'` | Which transport the PDP client uses. | | `rsocketHost` | `string` | hostname from `baseUrl` | RSocket host. Only used when `transport: 'rsocket'`. | | `rsocketPort` | `number` | `7000` | RSocket TCP port. Only used when `transport: 'rsocket'`. | | `token` | `string` | - | Bearer token (API key or JWT). Mutually exclusive with `username`/`secret`. | | `username` | `string` | - | Basic Auth username. Must be used together with `secret`. Mutually exclusive with `token`. | | `secret` | `string` | - | Basic Auth password. Must be used together with `username`. Mutually exclusive with `token`. | | `oauth2` | `OAuth2TokenProviderOptions`| - | OAuth2 client_credentials config (`issuerUrl`, `clientId`, `clientSecret`, `scope?`). Mutually exclusive with `token` and `username`/`secret`. | | `timeout` | `number` | `5000` | Timeout in ms for PDP HTTP requests. | | `streamingRetryBaseDelay` | `number` | `1000` | Initial delay in ms before the first streaming reconnection. | | `streamingRetryMaxDelay` | `number` | `30000` | Maximum backoff delay in ms for streaming reconnection. | | `tls` | `TlsConfig` | - | TLS configuration for the connection. See [Transport Security](#transport-security). | | `cls` | `Partial` | `{ global: true, middleware: { mount: true } }` | Options merged into `ClsModule.forRoot()`. Ignored by `forRootAsync`. | | `transactional` | `boolean` | `false` | Wrap enforcement in a database transaction via `@nestjs-cls/transactional`. | The `tls` block (`TlsConfig`) carries `ca`, `cert`, `key`, `servername`, and `rejectUnauthorized`. All fields take PEM contents, not file paths. See [Transport Security](#transport-security). ## Client Resilience The PDP client treats every transport problem as an operational condition, never as a policy outcome, and never lets one surface as an exception. A connection drop, timeout, or decode error fails closed to `INDETERMINATE`, which the PEP enforces as a denial, so a transient PDP outage can never accidentally grant access. One-shot requests (`decideOnce`) fail closed to `INDETERMINATE` immediately, with no retry, and never reject the returned promise. In steady state the connection is warm, so only a cold or dropped connection fails closed. Subscriptions (the streaming `decide`) never terminate on a transport problem or on a server-side stream completion. The returned RxJS `Observable` never errors or completes for a transport condition. Either condition emits one `INDETERMINATE` and then reconnects with bounded exponential backoff, indefinitely. Consecutive identical decisions are de-duplicated, so an outage yields a single `INDETERMINATE`, not a flood. A subscription ends only when the consumer unsubscribes or the client shuts down. This contract holds identically across the HTTP and RSocket transports and across every SAPL PEP client. ## Troubleshooting | Symptom | Likely Cause | Fix | | ------------------------------- | ----------------------------------- | --------------------------------------------------------------------------- | | All decisions are INDETERMINATE | PDP unreachable | Check `baseUrl` and that the PDP is running. | | 403 despite PERMIT decision | Unhandled obligation | Check that a provider's `getHandlers` matches the obligation `type`. | | Handler not firing | Missing registration | Add `@SaplConstraintHandler('provider')` and add the class to a module's `providers`. | | Subject is `'anonymous'` | No auth guard populating `req.user` | Add `@UseGuards()` or set `subject` explicitly in the decorator options. | | Content filter throws | Unsupported JSONPath | Only simple dot paths are supported (`$.field.nested`). | | CLS context missing | Module order | Ensure `SaplModule` is imported before modules that use it. | | Plaintext connection refused | Non-loopback host without TLS | Use `https://` (HTTP) or a `tls` block (RSocket), or run the PDP on localhost. | | Streaming buffer overflow | PDP proxy injecting data | Check the network path to the PDP. The buffer limit is 64 KB per SSE line. | ## License Apache-2.0 ## Django SDK Attribute-Based Access Control (ABAC) for Django using SAPL (Streaming Attribute Policy Language). Provides decorator-driven policy enforcement with a constraint handler architecture for obligations, advice, and response transformation. The `sapl-django` library integrates SAPL policy enforcement into Django applications, supporting both synchronous and asynchronous views, with Server-Sent Events streaming for continuous authorization. ### What is SAPL? SAPL is a policy language and Policy Decision Point (PDP) for attribute-based access control. Policies are written in a dedicated language and evaluated by the PDP, which streams authorization decisions based on subject, action, resource, and environment attributes. Three core concepts: 1. **Authorization subscription**: your app sends `{ subject, action, resource, environment }` to the PDP. 2. **PDP decision**: the PDP evaluates policies and returns `PERMIT` or `DENY`, optionally with obligations, advice, or a replacement resource. 3. **Constraint handlers**: registered handlers execute the policy's instructions (log, filter, transform, cap values, etc.). A PDP decision looks like this: ```json { "decision": "PERMIT", "obligations": [{ "type": "logAccess", "message": "Patient record accessed" }], "advice": [{ "type": "notifyAdmin" }] } ``` `decision` is always present (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, or `NOT_APPLICABLE`). The other fields are optional. `obligations` and `advice` are arrays of arbitrary JSON objects (by convention with a `type` field for handler dispatch), and `resource` (when present) replaces the view's return value entirely. For a deeper introduction to SAPL's subscription model and policy language, see the [SAPL documentation](https://sapl.io/docs/latest/). ### Installation Install the library and the base dependency: ```bash pip install sapl-django ``` This also installs `sapl-base`, which provides the PDP client, the `EnforcementPlanner`, and content filtering. The library requires Python 3.12 or later and Django 5.0+. A complete working demo with constraint handlers, content filtering, and streaming enforcement is available at [sapl-python-demos/django_demo](https://github.com/heutelbeck/sapl-python-demos/tree/main/django_demo). ### Setup #### Configuration via Django Settings Add `SAPL_CONFIG` to your Django settings module: ```python # settings.py SAPL_CONFIG = { "base_url": "https://localhost:8443", "token": "sapl_your_api_key_here", } ``` For basic authentication instead of an API key: ```python SAPL_CONFIG = { "base_url": "https://localhost:8443", "username": "myPdpClient", "secret": "myPassword", } ``` `token` (API key) and `username`/`secret` (Basic Auth) are mutually exclusive. Configure one or the other. For local development without TLS, point `base_url` at a loopback host. A plain `http://` URL is accepted only when the host is `localhost`, `127.0.0.1`, or `::1`. Any plain-HTTP URL targeting a remote host is refused at construction time, so plaintext authorization decisions never leave the machine. ```python SAPL_CONFIG = { "base_url": "http://localhost:8443", } ``` #### Middleware Add `SaplRequestMiddleware` to the `MIDDLEWARE` list. It propagates the current `HttpRequest` via `contextvars` so the subscription builder can access it during enforcement: ```python # settings.py MIDDLEWARE = [ "sapl_django.middleware.SaplRequestMiddleware", "django.middleware.common.CommonMiddleware", # ... ] ``` The middleware supports both synchronous (`__call__`) and asynchronous (`__acall__`) request handling. #### Installed Apps Add `sapl_django` to `INSTALLED_APPS`: ```python INSTALLED_APPS = [ "django.contrib.contenttypes", "django.contrib.auth", "sapl_django", # your apps ... ] ``` The PDP client and the `EnforcementPlanner` are created lazily on first use from `SAPL_CONFIG`. The built-in `ContentFilteringProvider` and `ContentFilterPredicateProvider` are registered automatically. No explicit initialization call is required. ### Enforcement Decorators The `@pre_enforce` and `@post_enforce` decorators work on both synchronous (`def`) and asynchronous (`async def`) Django view functions. `@stream_enforce` requires an async view served under ASGI. The decorated view must accept `request: HttpRequest` as a parameter (typically the first argument). The decorators auto-detect the view kind, so you write the view in whichever style suits it. An async view runs on the async enforcement core. A sync view runs on the blocking core, which executes the view off the event loop, so synchronous Django ORM access works normally with no `SynchronousOnlyOperation`. When you configure a transaction provider (see [Database Transactions](#database-transactions)) it must match the view kind: a sync context-manager factory such as `transaction.atomic` for sync views, an async one for async views. #### @pre_enforce Authorizes **before** the view executes. The view only runs on PERMIT. ```python from django.http import HttpRequest, JsonResponse from sapl_django import pre_enforce @pre_enforce(action="read", resource="patient") async def get_patient(request: HttpRequest, patient_id: str) -> JsonResponse: return JsonResponse({"id": patient_id, "name": "Jane Doe", "ssn": "123-45-6789"}) ``` Use `@pre_enforce` for views with side effects (database writes, emails) that should not execute when access is denied. On denial, Django's `PermissionDenied` exception is raised, which returns HTTP 403. #### @post_enforce Authorizes **after** the view executes. The view always runs. Its return value is available to the subscription builder via the `return_value` argument. ```python from django.http import HttpRequest, JsonResponse from sapl_django import post_enforce @post_enforce( action="read", resource=lambda ctx: {"type": "record", "data": ctx.return_value}, ) async def get_record(request: HttpRequest, record_id: str) -> JsonResponse: return JsonResponse({"id": record_id, "value": "sensitive-data"}) ``` Use `@post_enforce` when the policy needs to see the actual return value to make its authorization decision (e.g., deny based on the data's classification). On denial, the return value is discarded and `PermissionDenied` is raised. #### Building the Authorization Subscription Each decorator accepts keyword arguments to customize the authorization subscription fields: `subject`, `action`, `resource`, `environment`, and `secrets`. **Default Values** When not explicitly provided, the subscription fields are derived from the Django `HttpRequest`: | Field | Default | | ------------- | --------------------------------------------------------------------------- | | `subject` | `request.user.username`, else JWT claims from the `Authorization` header, else `"anonymous"` | | `action` | `{"method": request.method, "view": function_name}` | | `resource` | `{"path": request.path, "kwargs": resolver_match.kwargs}` | | `environment` | `{"ip": request.META["REMOTE_ADDR"]}` (when available) | | `secrets` | Not sent unless explicitly specified | **Static Values** Pass a string or dict directly: ```python @pre_enforce(action="read", resource="patient") ``` **Dynamic Values (Callables)** Pass a callable that receives a `SubscriptionContext` and returns the field value. The context provides `request`, `return_value` (`None` for `@pre_enforce`), `params` (URL kwargs), `query` (query string), and `args` (resolved function arguments): ```python @pre_enforce( subject=lambda ctx: ctx.request.user.username, resource=lambda ctx: {"path": ctx.request.path, "method": ctx.request.method}, ) ``` **Secrets** The `secrets` field carries sensitive data (tokens, API keys) that the PDP needs for policy evaluation but that must not appear in logs. It is excluded from debug logging automatically. Use it when a policy needs to inspect credentials, for example passing a raw JWT so the PDP can read its claims: ```python @pre_enforce( action="exportData", resource=lambda ctx: {"pilotId": ctx.params.get("pilot_id")}, secrets=lambda ctx: {"jwt": getattr(ctx.request, "sapl_token", None)} if ctx.request else None, ) ``` #### @stream_enforce Streaming enforcement applies an authorization decision continuously to a stream of items your view produces. The decorated view returns an **async iterator** of data items. SAPL opens a streaming PDP subscription and applies each decision to the stream as it runs: `PERMIT` passes items through, `SUSPEND` pauses, `DENY` ends it. The enforced result is **itself an async iterator** of authorised items, so it is independent of how you deliver them. `@stream_enforce` is the ready-made binding for **Server-Sent Events**. It wraps the enforced iterator in a Django `StreamingHttpResponse` that renders each item as an SSE `data:` frame on `text/event-stream`. SSE is the delivery shown here. For another delivery mode (a WebSocket, a gRPC stream, or consuming the stream in-process) drive the enforcement directly with `run_pipeline` from `sapl_base.pep.streaming`. It takes your async iterator and returns the enforced async iterator, with no transport assumptions. ```python import asyncio from datetime import datetime, timezone from django.http import HttpRequest from sapl_django import stream_enforce @stream_enforce(action="stream:heartbeat", resource="heartbeat") async def heartbeat(request: HttpRequest): seq = 0 while True: yield {"seq": seq, "ts": datetime.now(timezone.utc).isoformat()} seq += 1 await asyncio.sleep(2) ``` A single decorator now covers every streaming case. The behaviour is driven by the policy verbs and by two boolean flags, both defaulting to `False`. ```python @stream_enforce( action="stream:heartbeat", resource="heartbeat", signal_transitions=False, # default pause_rap_during_suspend=False, # default ) ``` **Verb routing.** Every decision the PDP emits during the lifetime of the subscription maps to one observable effect. | PDP decision | Effect on the stream | | ---------------- | ----------------------------------------------------------------------------------------------------- | | `PERMIT` | Items flow through to the consumer. | | `SUSPEND` | Items are silently dropped. The subscription stays open. A later `PERMIT` resumes the flow. | | `DENY` | The stream terminates. The SSE binding emits a final `ACCESS_DENIED` frame before closing. | | `INDETERMINATE` | The subscription terminates, the same way `DENY` does. | | `NOT_APPLICABLE` | The subscription terminates, the same way `DENY` does. | Under the strict fail-closed discipline only an explicit `SUSPEND` keeps the subscription alive while pausing it. `DENY`, `INDETERMINATE`, and `NOT_APPLICABLE` all terminate. For keep-alive semantics where access pauses and later resumes, the policy must emit `SUSPEND` rather than `DENY`. Operators who want `NOT_APPLICABLE` to pause rather than terminate set the combining algorithm's `defaultDecision` to `SUSPEND` at the PDP level. **signal_transitions.** With the default `False`, suspend and resume boundaries are silent. The consumer sees items while permitted and a gap while suspended, with no boundary item. With `True`, the enforced stream carries an `ACCESS_SUSPENDED` boundary item each time it is suspended and an `ACCESS_GRANTED` boundary item each time it resumes (the SSE binding renders these as frames). Use this when the consumer should show a paused/resumed status. **pause_rap_during_suspend.** With the default `False`, the protected async iterator stays subscribed during suspension. Items keep arriving from upstream and are dropped on the way to the client, giving lower latency on resume. With `True`, the upstream iterator is cancelled on entry to the suspended state and re-subscribed on resume. Use this for upstream sources with expensive side effects that must not run while access is paused. | Scenario | Configuration | | ---------------------------------------------- | ------------------------------------------------------------ | | Access loss is permanent (revoked credentials) | policy emits `deny`; defaults | | Client does not need to know about gaps | policy emits `suspend`; defaults | | Client should show suspended/restored status | policy emits `suspend`; `signal_transitions=True` | ### How Enforcement Works The decorators above are convenient, but to use them well it helps to understand what actually happens behind the scenes. This section walks through the enforcement lifecycle so you can reason about behavior. #### The Deny Invariant Only `PERMIT` grants access. The PDP can return five possible decisions (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, `NOT_APPLICABLE`), and only `PERMIT` ever results in your view running or your stream forwarding data. Everything else means denial. The streaming PEP honours `SUSPEND` by pausing the stream while keeping the subscription alive, so a later `PERMIT` resumes it. One-shot enforcement (`@pre_enforce`, `@post_enforce`) treats `SUSPEND` as a denial. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for details. A `PERMIT` with obligations is not a free pass. The PEP checks that every obligation in the decision has a registered handler. If even one obligation cannot be fulfilled, the PEP treats the decision as a denial. If a handler accepts responsibility but fails during execution, that also results in denial. Advice is softer: if an advice handler fails, the PEP logs the failure and moves on. Advice never causes denial. | Aspect | Obligation | Advice | |-----------------|--------------------------------------------------------------------|-------------------------------------------------| | All handled? | Required. Unhandled obligations deny access (PermissionDenied). | Optional. Unhandled advice is silently ignored. | | Handler failure | Denies access (PermissionDenied). | Logs a warning and continues. | This means you can always trust that if your view runs, every obligation attached to the decision has been successfully enforced. #### Enforcement Locations Depending on the decorator, constraint handlers can intervene at different points in the lifecycle of a request or stream. For request-response views (`@pre_enforce` and `@post_enforce`), constraints can run at four points: | Location | When it happens | What constraints do here | |-----------------------|--------------------------------------|--------------------------------------------------| | On decision | Authorization decision arrives | Side effects like logging, audit, or notification | | Pre-method invocation | Before the protected view executes | Modify view arguments (`@pre_enforce` only) | | On return value | After the view returns | Transform, filter, or replace the result | | On error | If the view throws | Transform or observe the error | For streaming views (`@stream_enforce`), constraints can run at five points: | Location | When it happens | What constraints do here | |--------------------|----------------------------------------------|-----------------------------------------| | On decision | Each new decision from the PDP stream | Side effects like logging, audit | | On each data item | Each element yielded by the async iterator | Transform, filter, or replace items | | On stream error | The iterator produces an error | Transform or observe the error | | On stream complete | The iterator finishes normally | Cleanup and finalization | | On cancel | Client disconnects or enforcement terminates | Release resources and close connections | SAPL models each of these points as a named signal, and a handler attaches to whichever signal fits the work it does. A handler that fires once when the decision arrives attaches to the decision signal. A handler that processes each emitted item attaches to the output signal. The signal a handler attaches to determines when it runs. The same `ConstraintHandlerProvider` mechanism is used for one-shot and streaming enforcement alike. #### PreEnforce Lifecycle When you decorate a view with `@pre_enforce`, here is what happens step by step. First, the PEP builds an authorization subscription from the decorator options (or from defaults if you left them out) and sends it to the PDP as a one-shot request. The PDP evaluates the subscription against all matching policies and returns a single decision. If the decision is anything other than `PERMIT`, the PEP raises `PermissionDenied` immediately. Your view never runs. If the decision is `PERMIT`, the PEP resolves all constraint handlers. It walks through the obligations and advice attached to the decision and checks which registered handlers claim responsibility for each one. If any obligation has no matching handler, the PEP denies access right there, because it cannot guarantee the obligation will be enforced. With all handlers resolved, execution proceeds through the enforcement locations in order. On-decision handlers run first (logging, audit). Then method-invocation handlers run, which can modify view arguments if the policy requires it. Then your actual view executes. After the view returns, the PEP applies return-value handlers: resource replacement if the decision included one, filter predicates, mapping handlers, and consumer handlers. If any obligation handler fails at any stage, the PEP denies access. #### PostEnforce Lifecycle `@post_enforce` inverts the order. Your view runs first, regardless of the authorization outcome. Only after it returns does the PEP build the authorization subscription (now including the return value) and consult the PDP. This means the PDP can make decisions based on the actual data your view produced. For example, a policy might permit access to a record only if its classification level is below a threshold, something that can only be checked after loading the record. If the decision is not `PERMIT`, the PEP discards the return value and raises `PermissionDenied`. If the decision is `PERMIT`, constraint handlers proceed through the same stages as `@pre_enforce`, minus the method-invocation handlers (since the view has already run). Return-value handlers can still transform the result before it reaches the caller. Because the view runs before the PDP is consulted, if the view itself raises an exception, that exception propagates directly. The PDP is never called, because there is no return value to include in the subscription. SAPL PEP libraries share a single unified enforcement model. It is a strict fail-closed state machine over the five decision verbs, where only `PERMIT` grants access and only an explicit `SUSPEND` pauses a stream without terminating it. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for the decision-verb semantics. ### Constraint Handlers When the PDP returns a decision with `obligations` or `advice`, the `EnforcementPlanner` resolves and schedules all matching handlers. #### The ConstraintHandlerProvider Protocol There is one extension point. A constraint handler is an object that implements the `ConstraintHandlerProvider` protocol, which has a single method. ```python from collections.abc import Sequence from typing import Any, Protocol from sapl_base.pep import ScopedHandler class ConstraintHandlerProvider(Protocol): def get_handlers(self, constraint: Any) -> Sequence[ScopedHandler]: ... ``` The planner calls `get_handlers` for each constraint in a decision. The provider inspects the constraint and decides whether it can handle it. If it can, it returns one or more `ScopedHandler` entries. If it cannot, it returns an empty sequence and the planner asks the other providers. If no provider claims a constraint that arrived as an obligation, or if more than one provider claims the same constraint, the planner schedules a synthetic failure runner so the decision fails closed. A `ScopedHandler` bundles three things. | Field | Description | | ---------- | ---------------------------------------------------------------------------------------------------- | | `signal` | The `SignalKind` the handler attaches to. The decision signal runs once when the decision arrives. The output signal runs on the return value or on each streamed item. | | `priority` | Lower runs earlier among handlers on the same signal. | | `shape` | `"runner"` is `() -> None`, `"consumer"` is `(value) -> None`, `"mapper"` is `(value) -> value`. | | `handler` | The callable itself. | The three shapes mirror the work a handler does. A `runner` is a side effect that needs no value, such as logging on a decision. A `consumer` is a side effect that has access to the value but does not change it, such as auditing the response. A `mapper` transforms the value flowing through a data-carrying signal, such as redacting fields. A mapper is admissible only for an obligation, never for advice. Advice is allowed to fail silently, and a value transformation that silently did not happen would leave the caller unable to tell whether the result was transformed. #### Registering Custom Handlers ```python from collections.abc import Sequence from typing import Any from sapl_django import register_provider from sapl_base.pep import DECISION, ScopedHandler class LogAccessProvider: def get_handlers(self, constraint: Any) -> Sequence[ScopedHandler]: if not (isinstance(constraint, dict) and constraint.get("type") == "logAccess"): return () message = constraint.get("message", "Access logged") def run() -> None: print(f"[POLICY] {message}") return (ScopedHandler(signal=DECISION, priority=0, shape="runner", handler=run),) # Register during Django app startup (e.g., in AppConfig.ready()) register_provider(LogAccessProvider()) ``` Register providers in your Django `AppConfig.ready()` method so they are available when the first request arrives. Registration rebuilds the planner. A single obligation can drive several handlers at different signals. The provider returns one `ScopedHandler` per handler, and the planner schedules each one against its own signal. The bundle is all-or-nothing during admissibility checks. If any handler in the returned sequence is not well-formed for the constraint's tag, the entire claim is rejected and the decision fails closed. ### Built-in Constraint Handlers #### ContentFilteringProvider **Constraint type:** `filterJsonContent` Transforms response values by deleting, replacing, or blackening fields. A policy can attach this obligation: ``` policy "permit-read-patient" permit action == "readPatient"; resource == "patient"; obligation { "type": "filterJsonContent", "actions": [ { "type": "blacken", "path": "$.ssn", "discloseRight": 4 }, { "type": "delete", "path": "$.internalNotes" }, { "type": "replace", "path": "$.classification", "replacement": "REDACTED" } ] } ``` The `blacken` action supports these options: | Option | Type | Default | Description | | --------------- | ------ | ----------------------------- | ------------------------------------------- | | `path` | string | (required) | Dot-notation path to a string field | | `replacement` | string | `"\u2588"` (block character) | Character used for masking | | `discloseLeft` | number | `0` | Characters to leave unmasked from the left | | `discloseRight` | number | `0` | Characters to leave unmasked from the right | | `length` | number | (masked section length) | Override the length of the masked section | #### ContentFilterPredicateProvider **Constraint type:** `jsonContentFilterPredicate` Filters array elements or nullifies single values that do not meet conditions. ```json { "type": "jsonContentFilterPredicate", "conditions": [ { "path": "$.classification", "type": "!=", "value": "top-secret" } ] } ``` #### ContentFilter Limitations The built-in content filter supports **simple dot-notation paths only** (`$.field.nested`). Recursive descent (`$..ssn`), bracket notation (`$['field']`), array indexing (`$.items[0]`), wildcards (`$.users[*].email`), and filter expressions (`$.books[?(@.price<10)]`) are not supported. ### Query Rewriting Django applications can filter results at the database through SAPL's native ORM integration: a policy attaches a `sql:queryRewriting` obligation and the integration rewrites the query before it reaches the database, so unauthorised rows never leave it. Register it once at startup, for example in `AppConfig.ready()`. ```python from sapl_django import ( DjangoQueryRewritingProvider, register_orm_listener, register_provider, ) register_orm_listener() register_provider(DjangoQueryRewritingProvider()) ``` See [Query Rewriting](../6_12_QueryRewriting/) for the obligation format and the shared semantics. Two Django-specific points. The integration applies an obligation only to queries whose model actually has the referenced columns, so unrelated models pass through unchanged. The `columns` projection uses `.only()`, which defers fields rather than blocking them, so pair it with content filtering when you need hard column-level security. Raw SQL and direct cursor access are not covered. ### Streaming Authorization For SSE endpoints returning async iterators, `@stream_enforce` provides continuous authorization where the PDP streams decisions over time. Access may flip between permitted, suspended, and denied based on time, location, or context changes. Django streaming responses use `StreamingHttpResponse` with `content_type="text/event-stream"`. The decorator wraps each yielded item in SSE format automatically. A time-based policy that cycles between `PERMIT` and `SUSPEND`, so the stream pauses and resumes without terminating: ``` policy "streaming-heartbeat-time-based" permit action == "stream:heartbeat"; resource == "heartbeat"; var second = time.secondOf(); second >= 0 && second < 20 || second >= 40; suspend action == "stream:heartbeat"; resource == "heartbeat"; ``` Deploy with ASGI (e.g., Daphne or Uvicorn) for async view and streaming support: ```bash uvicorn demo_project.asgi:application --host 0.0.0.0 --port 3000 ``` ### Manual PDP Access For cases where decorators are not suitable, access the PDP client directly: ```python from django.http import HttpRequest, JsonResponse from sapl_django import get_pdp_client from sapl_base.types import AuthorizationSubscription, Decision async def get_hello(request: HttpRequest) -> JsonResponse: pdp_client = get_pdp_client() subscription = AuthorizationSubscription( subject="anonymous", action="read", resource="hello", ) decision = await pdp_client.decide_once(subscription) if decision.decision == Decision.PERMIT and not decision.obligations: return JsonResponse({"message": "hello"}) return JsonResponse({"error": "Access denied"}, status=403) ``` When using the PDP client directly, you are responsible for checking the decision, enforcing obligations, and handling resource replacement. ### Service Layer Enforcement The same `@pre_enforce` and `@post_enforce` decorators work at any layer, not just on Django views. When used on a service method without an `HttpRequest` parameter, the decorator automatically translates denial into Django's `PermissionDenied` exception, which the calling view can handle normally: ```python from sapl_django import pre_enforce, post_enforce @pre_enforce(action="listPatients", resource="patients") async def list_patients() -> list[dict]: return [dict(p) for p in PATIENTS] @post_enforce( action="getPatientDetail", resource=lambda ctx: {"type": "patientDetail", "data": ctx.return_value}, ) async def get_patient_detail(patient_id: str) -> dict | None: return next((dict(p) for p in PATIENTS if p["id"] == patient_id), None) ``` The calling view does not need any special error handling. `PermissionDenied` propagates through Django's normal exception handling and returns HTTP 403: ```python from django.http import HttpRequest, JsonResponse from . import patient_service async def get_patient_detail(request: HttpRequest, patient_id: str) -> JsonResponse: result = await patient_service.get_patient_detail(patient_id) return JsonResponse(result) ``` Service-layer decorators accept the same subscription field options (`subject`, `action`, `resource`, `environment`, `secrets`) as when used on views. When no `HttpRequest` is available, subject defaults to `"anonymous"` and environment is empty. ### Database Transactions `@pre_enforce` and `@post_enforce` can own a transaction boundary, so a denial that lands after the view has written to the database rolls the write back. Three triggers cause a rollback: a `@post_enforce` DENY, a `@post_enforce` output-obligation failure, and a `@pre_enforce` output-obligation failure (the pre-decision permits, but its output obligations run after the view writes). A clean PERMIT commits. This is opt-in. With no provider configured the PEP owns no transaction and enforcement behaves exactly as before. A provider is a zero-arg factory returning a context manager that commits on clean exit and rolls back on a propagated exception. It must match the view kind it protects: a sync context manager for sync views, an async one for async views. Sync views run on the blocking core, which uses the provider as a sync context manager. `transaction.atomic` is exactly such a factory, so pass it directly: ```python from django.db import transaction from sapl_django.config import set_transaction_provider set_transaction_provider(transaction.atomic) ``` A sync SQLAlchemy `session.begin` is passed the same way: `set_transaction_provider(lambda: get_current_session().begin())`. Async views run on the async core, which uses the provider as an async context manager, so pass an async SQLAlchemy `AsyncSession.begin()` directly: `set_transaction_provider(lambda: get_current_async_session().begin())`. Transactional enforcement with the Django ORM is a sync-view feature. Django's `transaction.atomic` is async-unsafe, so it cannot run on an async view. The async enforcement core opens the transaction boundary on the event loop thread, where entering `transaction.atomic` raises `SynchronousOnlyOperation`. To wrap a Django ORM write in an enforced transaction, write the view as a sync `def` and pass `transaction.atomic` directly, as above. Async views can still own a transaction over an async-native resource such as async SQLAlchemy, but not over the Django ORM. ### Client Resilience The PDP client treats every transport problem as an operational condition, never as a policy outcome, and never lets one surface as an exception. A connection drop, timeout, or decode error fails closed to `INDETERMINATE`, which the PEP enforces as a denial, so a transient PDP outage can never accidentally grant access. One-shot requests (`decide_once`) fail closed to `INDETERMINATE` immediately, with no retry, and never throw. In steady state the connection is warm, so only a cold or dropped connection fails closed. Subscriptions (streaming `decide`) never terminate on a transport problem or on a server-side stream completion. Either condition emits one `INDETERMINATE` and then reconnects with bounded exponential backoff, indefinitely. Consecutive identical decisions are de-duplicated, so an outage yields a single `INDETERMINATE`, not a flood. A subscription ends only when the consumer cancels it or the client shuts down. This contract holds identically across the HTTP and RSocket transports and across every SAPL PEP client. ### Demo Application A complete working demo is available at [sapl-python-demos/django_demo](https://github.com/heutelbeck/sapl-python-demos/tree/main/django_demo). It includes: - Manual PDP access (no decorators) - `@pre_enforce` and `@post_enforce` with content filtering - Service-layer enforcement using the same decorators on plain async functions - Custom constraint handler providers returning runner, consumer, and mapper handlers - SSE streaming with `@stream_enforce`, covering terminate-on-deny, drop-while-suspended, and signalled suspend/resume - JWT-based ABAC with secrets ### Configuration Reference All options are set via the `SAPL_CONFIG` dictionary in Django settings: | Key | Type | Default | Description | | ------------------------------ | ------- | --------------------------- | -------------------------------------------------------- | | `base_url` | `str` | `"https://localhost:8443"` | PDP server URL. Plain `http://` is accepted only for loopback hosts | | `token` | `str` | `None` | Bearer token / API key for authentication | | `username` | `str` | `None` | Basic auth username (mutually exclusive with `token`) | | `secret` | `str` | `None` | Basic auth secret | | `timeout_seconds` | `float` | `5.0` | PDP request timeout in seconds | | `streaming_retry_base_delay_seconds` | `float` | `1.0` | Base delay in seconds for exponential backoff on reconnect | | `streaming_retry_max_delay_seconds` | `float` | `30.0` | Maximum delay in seconds for exponential backoff | ### Troubleshooting | Symptom | Likely Cause | Fix | | ------------------------------------ | --------------------------------------- | ---------------------------------------------------------------- | | All decisions are INDETERMINATE | PDP unreachable | Check `base_url` and that PDP is running | | 403 despite PERMIT decision | Unhandled obligation | Check the provider's `get_handlers()` claims the obligation `type` | | Handler not firing | Missing registration | Call `register_provider()` in `AppConfig.ready()` | | Subject is `"anonymous"` | No authenticated user on request | Set up Django authentication or set subject explicitly | | Content filter throws | Unsupported path syntax | Only simple dot paths supported (`$.field.nested`) | | `ImproperlyConfigured` | Missing `SAPL_CONFIG` | Add `SAPL_CONFIG` dict to Django settings | | Streaming not working | Running under WSGI | Use ASGI server (Uvicorn/Daphne) for async views | ### License Apache-2.0 ## Flask SDK Attribute-Based Access Control (ABAC) for Flask using SAPL (Streaming Attribute Policy Language). Provides decorator-driven policy enforcement with a constraint handler architecture for obligations, advice, and response transformation. The `sapl-flask` library integrates SAPL policy enforcement into Flask applications as a Flask extension. Flask is WSGI and always synchronous, so the one-shot enforcement decorators run on the blocking enforcement core, which executes the view and its PDP communication off the event loop. The library also supports streaming responses with Server-Sent Events for continuous authorization. ### What is SAPL? SAPL is a policy language and Policy Decision Point (PDP) for attribute-based access control. Policies are written in a dedicated language and evaluated by the PDP, which streams authorization decisions based on subject, action, resource, and environment attributes. Three core concepts: 1. **Authorization subscription**: your app sends `{ subject, action, resource, environment }` to the PDP. 2. **PDP decision**: the PDP evaluates policies and returns `PERMIT` or `DENY`, optionally with obligations, advice, or a replacement resource. 3. **Constraint handlers**: registered handlers execute the policy's instructions (log, filter, transform, cap values, etc.). A PDP decision looks like this: ```json { "decision": "PERMIT", "obligations": [{ "type": "logAccess", "message": "Patient record accessed" }], "advice": [{ "type": "notifyAdmin" }] } ``` `decision` is always present (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, or `NOT_APPLICABLE`). The other fields are optional. `obligations` and `advice` are arrays of arbitrary JSON objects (by convention with a `type` field for handler dispatch), and `resource` (when present) replaces the view's return value entirely. For a deeper introduction to SAPL's subscription model and policy language, see the [SAPL documentation](https://sapl.io/docs/latest/). ### Installation Install the library and the base dependency: ```bash pip install sapl-flask ``` This also installs `sapl-base`, which provides the PDP client, constraint engine, and content filtering. The library requires Python 3.12 or later and Flask 3.0+. A complete working demo with constraint handlers and streaming enforcement is available at [sapl-python-demos/flask_demo](https://github.com/heutelbeck/sapl-python-demos/tree/main/flask_demo). ### Setup #### Flask Extension Initialize the `SaplFlask` extension with your Flask application. Configuration is read from `app.config`: ```python from flask import Flask from sapl_flask import SaplFlask app = Flask(__name__) app.config["SAPL_BASE_URL"] = "https://localhost:8443" app.config["SAPL_TOKEN"] = "sapl_your_api_key_here" sapl = SaplFlask(app) ``` For basic authentication instead of an API key: ```python app.config["SAPL_BASE_URL"] = "https://localhost:8443" app.config["SAPL_USERNAME"] = "myPdpClient" app.config["SAPL_SECRET"] = "myPassword" sapl = SaplFlask(app) ``` `SAPL_TOKEN` (API key) and `SAPL_USERNAME`/`SAPL_SECRET` (Basic Auth) are mutually exclusive. Configure one or the other. #### Application Factory Pattern For applications using the factory pattern, use `init_app`: ```python from sapl_flask import SaplFlask sapl = SaplFlask() def create_app(): app = Flask(__name__) app.config["SAPL_BASE_URL"] = "https://localhost:8443" app.config["SAPL_TOKEN"] = "sapl_your_api_key_here" sapl.init_app(app) return app ``` #### Local Development (HTTP) For local development without TLS, point `SAPL_BASE_URL` at a loopback host. A plain `http://` URL is accepted only when the host is `localhost`, `127.0.0.1`, or `::1`. Any plain-HTTP URL targeting a remote host is refused at construction time, so plaintext authorization decisions never leave the machine. ```python app.config["SAPL_BASE_URL"] = "http://localhost:8443" sapl = SaplFlask(app) ``` #### Cleanup Register the extension's `close()` method with `atexit` to release PDP connections on shutdown: ```python import atexit sapl = SaplFlask(app) atexit.register(sapl.close) ``` The extension registers itself as `app.extensions["sapl"]` and is automatically discoverable by the enforcement decorators within any Flask application context. ### Enforcement Decorators All decorators work on synchronous Flask view functions. Because Flask is always synchronous, the one-shot decorators (`@pre_enforce`, `@post_enforce`) always run on the blocking enforcement core, which executes the view off the event loop, so synchronous database and IO access works normally. When you configure a transaction provider (see [Database Transactions](#database-transactions)) it must be a sync context-manager factory, since the blocking core uses it as a sync context manager. The Flask request context is accessed via `flask.request` and `flask.g`, so no explicit `request` parameter is needed in decorator arguments. #### @pre_enforce Authorizes **before** the view executes. The view only runs on PERMIT. ```python from flask import Flask, jsonify from sapl_flask import SaplFlask, pre_enforce app = Flask(__name__) sapl = SaplFlask(app) @app.route("/patient/") @pre_enforce(action="readPatient", resource="patient") def get_patient(patient_id: str): return jsonify({"id": patient_id, "name": "Jane Doe", "ssn": "123-45-6789"}) ``` Use `@pre_enforce` for views with side effects (database writes, emails) that should not execute when access is denied. On denial, Flask's `abort(403)` is called. #### @post_enforce Authorizes **after** the view executes. The view always runs. Its return value is available to the subscription builder via the `return_value` parameter. ```python from sapl_flask import post_enforce @app.route("/record/") @post_enforce( action="read", resource=lambda: {"type": "record", "path": request.path}, ) def get_record(record_id: str): return jsonify({"id": record_id, "value": "sensitive-data"}) ``` Use `@post_enforce` when the policy needs to see the actual return value to make its authorization decision (e.g., deny based on the data's classification). On denial, the return value is discarded and `abort(403)` is called. #### Building the Authorization Subscription Each decorator accepts keyword arguments to customize the authorization subscription fields: `subject`, `action`, `resource`, `environment`, and `secrets`. **Default Values** When not explicitly provided, the subscription fields are derived from the Flask request context: | Field | Default | | ------------- | ------------------------------------------------------------------------------- | | `subject` | `g.user`, or `flask_login.current_user`, or `"anonymous"` | | `action` | `{"method": request.method, "endpoint": function_name}` | | `resource` | `{"path": request.path, "view_args": request.view_args}` | | `environment` | `{"ip": request.remote_addr}` (when available) | | `secrets` | Not sent unless explicitly specified | Flask-Login integration is automatic: if `flask-login` is installed and a user is authenticated, `current_user.username` (or `str(current_user)`) is used as the subject. **Static Values** Pass a string or dict directly: ```python @pre_enforce(action="read", resource="patient") ``` **Dynamic Values (Callables)** Pass a callable that receives a `SubscriptionContext` and returns the field value. The context provides `request`, `return_value` (`None` for `@pre_enforce`), `params` (view args), `query` (query string), and `args` (resolved function arguments): ```python @pre_enforce( subject=lambda ctx: getattr(ctx.request, "user", "anonymous") if ctx.request else "anonymous", resource=lambda ctx: {"path": ctx.params, "method": ctx.request.method} if ctx.request else {}, ) ``` The `SubscriptionContext` is the same across all Python SAPL integrations, making subscription field callables portable between frameworks. **Secrets** The `secrets` field carries sensitive data (tokens, API keys) that the PDP needs for policy evaluation but that must not appear in logs. It is excluded from debug logging automatically. Use it when a policy needs to inspect credentials, for example passing a raw JWT so the PDP can read its claims: ```python @pre_enforce( action="exportData", resource=lambda ctx: {"pilotId": ctx.params.get("pilot_id")}, secrets=lambda ctx: {"jwt": g.token} if hasattr(g, "token") else None, ) ``` #### @stream_enforce Streaming enforcement applies an authorization decision continuously to a stream of items your view produces. The decorated view returns an **async iterator** of data items. SAPL opens a streaming PDP subscription and applies each decision to the stream as it runs: `PERMIT` passes items through, `SUSPEND` pauses, `DENY` ends it. The enforced result is **itself an async iterator** of authorised items, so it is independent of how you deliver them. `@stream_enforce` is the ready-made binding for **Server-Sent Events**: it wraps the enforced iterator in a Flask `Response` that renders each item as an SSE `data:` frame on `text/event-stream`. SSE is the delivery shown here. For another delivery mode (a WebSocket, a gRPC stream, or consuming the stream in-process) drive the enforcement directly with `run_pipeline` from `sapl_base.pep.streaming`: it takes your async iterator and returns the enforced async iterator, with no transport assumptions. ```python import asyncio from datetime import datetime, timezone from sapl_flask import stream_enforce @app.route("/stream/heartbeat") @stream_enforce(action="stream:heartbeat", resource="heartbeat") async def heartbeat(): seq = 0 while True: yield {"seq": seq, "ts": datetime.now(timezone.utc).isoformat()} seq += 1 await asyncio.sleep(2) ``` A single decorator now covers every streaming case. The behaviour is driven by the policy verbs and by two boolean flags, both defaulting to `False`. ```python @stream_enforce( action="stream:heartbeat", resource="heartbeat", signal_transitions=False, # default pause_rap_during_suspend=False, # default ) ``` **Verb routing.** Every decision the PDP emits during the lifetime of the subscription maps to one observable effect. | PDP decision | Effect on the stream | | ---------------- | ----------------------------------------------------------------------------------------------------- | | `PERMIT` | Items flow through to the consumer. | | `SUSPEND` | Items are silently dropped. The subscription stays open. A later `PERMIT` resumes the flow. | | `DENY` | The stream terminates. The SSE binding emits a final `ACCESS_DENIED` frame before closing. | | `INDETERMINATE` | The subscription terminates, the same way `DENY` does. | | `NOT_APPLICABLE` | The subscription terminates, the same way `DENY` does. | Under the strict fail-closed discipline only an explicit `SUSPEND` keeps the subscription alive while pausing it. `DENY`, `INDETERMINATE`, and `NOT_APPLICABLE` all terminate. For keep-alive semantics where access pauses and later resumes, the policy must emit `SUSPEND` rather than `DENY`. Operators who want `NOT_APPLICABLE` to pause rather than terminate set the combining algorithm's `defaultDecision` to `SUSPEND` at the PDP level. **signal_transitions.** With the default `False`, suspend and resume boundaries are silent. The consumer sees items while permitted and a gap while suspended, with no boundary item. With `True`, the enforced stream carries an `ACCESS_SUSPENDED` boundary item each time it is suspended and an `ACCESS_GRANTED` boundary item each time it resumes (the SSE binding renders these as frames). Use this when the consumer should show a paused/resumed status. **pause_rap_during_suspend.** With the default `False`, the protected async iterator stays subscribed during suspension. Items keep arriving from upstream and are dropped on the way to the client, giving lower latency on resume. With `True`, the upstream iterator is cancelled on entry to the suspended state and re-subscribed on resume. Use this for upstream sources with expensive side effects that must not run while access is paused. | Scenario | Configuration | | ---------------------------------------------- | ------------------------------------------------------------ | | Access loss is permanent (revoked credentials) | policy emits `deny`; defaults | | Client does not need to know about gaps | policy emits `suspend`; defaults | | Client should show suspended/restored status | policy emits `suspend`; `signal_transitions=True` | ### How Enforcement Works The decorators above are convenient, but to use them well it helps to understand what actually happens behind the scenes. This section walks through the enforcement lifecycle so you can reason about behavior. #### The Deny Invariant Only `PERMIT` grants access. The PDP can return five possible decisions (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, `NOT_APPLICABLE`), and only `PERMIT` ever results in your view running or your stream forwarding data. Everything else means denial. The streaming PEP honours `SUSPEND` by pausing the stream while keeping the subscription alive, so a later `PERMIT` resumes it. One-shot enforcement (`@pre_enforce`, `@post_enforce`) treats `SUSPEND` as a denial. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for details. A `PERMIT` with obligations is not a free pass. The PEP checks that every obligation in the decision has a registered handler. If even one obligation cannot be fulfilled, the PEP treats the decision as a denial. If a handler accepts responsibility but fails during execution, that also results in denial. Advice is softer: if an advice handler fails, the PEP logs the failure and moves on. Advice never causes denial. | Aspect | Obligation | Advice | |-----------------|-----------------------------------------------------------|-------------------------------------------------| | All handled? | Required. Unhandled obligations deny access (403). | Optional. Unhandled advice is silently ignored. | | Handler failure | Denies access (403). | Logs a warning and continues. | This means you can always trust that if your view runs, every obligation attached to the decision has been successfully enforced. #### Enforcement Locations Depending on the decorator, constraint handlers can intervene at different points in the lifecycle of a request or stream. For request-response views (`@pre_enforce` and `@post_enforce`), constraints can run at four points: | Location | When it happens | What constraints do here | |-----------------------|--------------------------------------|--------------------------------------------------| | On decision | Authorization decision arrives | Side effects like logging, audit, or notification | | Pre-method invocation | Before the protected view executes | Modify view arguments (`@pre_enforce` only) | | On return value | After the view returns | Transform, filter, or replace the result | | On error | If the view throws | Transform or observe the error | For streaming views (`@stream_enforce`), constraints can run at five points: | Location | When it happens | What constraints do here | |--------------------|----------------------------------------------|-----------------------------------------| | On decision | Each new decision from the PDP stream | Side effects like logging, audit | | On each data item | Each element yielded by the async iterator | Transform, filter, or replace items | | On stream error | The iterator produces an error | Transform or observe the error | | On stream complete | The iterator finishes normally | Cleanup and finalization | | On cancel | Client disconnects or enforcement terminates | Release resources and close connections | SAPL models each of these points as a named signal, and a handler attaches to whichever signal fits the work it does. A handler that fires once when the decision arrives attaches to the decision signal. A handler that processes each emitted item attaches to the output signal. The signal a handler attaches to determines when it runs. The same `ConstraintHandlerProvider` mechanism is used for one-shot and streaming enforcement alike. #### PreEnforce Lifecycle When you decorate a view with `@pre_enforce`, here is what happens step by step. First, the PEP builds an authorization subscription from the decorator options (or from defaults if you left them out) and sends it to the PDP as a one-shot request. The PDP evaluates the subscription against all matching policies and returns a single decision. If the decision is anything other than `PERMIT`, the PEP calls `abort(403)` immediately. Your view never runs. If the decision is `PERMIT`, the PEP resolves all constraint handlers. It walks through the obligations and advice attached to the decision and checks which registered handlers claim responsibility for each one. If any obligation has no matching handler, the PEP denies access right there, because it cannot guarantee the obligation will be enforced. With all handlers resolved, execution proceeds through the enforcement locations in order. On-decision handlers run first (logging, audit). Then method-invocation handlers run, which can modify view arguments if the policy requires it. Then your actual view executes. After the view returns, the PEP applies return-value handlers: resource replacement if the decision included one, filter predicates, mapping handlers, and consumer handlers. If any obligation handler fails at any stage, the PEP denies access. #### PostEnforce Lifecycle `@post_enforce` inverts the order. Your view runs first, regardless of the authorization outcome. Only after it returns does the PEP build the authorization subscription (now including the return value) and consult the PDP. This means the PDP can make decisions based on the actual data your view produced. For example, a policy might permit access to a record only if its classification level is below a threshold, something that can only be checked after loading the record. If the decision is not `PERMIT`, the PEP discards the return value and calls `abort(403)`. If the decision is `PERMIT`, constraint handlers proceed through the same stages as `@pre_enforce`, minus the method-invocation handlers (since the view has already run). Return-value handlers can still transform the result before it reaches the caller. Because the view runs before the PDP is consulted, if the view itself raises an exception, that exception propagates directly. The PDP is never called, because there is no return value to include in the subscription. SAPL PEP libraries share a single unified enforcement model. It is a strict fail-closed state machine over the five decision verbs, where only `PERMIT` grants access and only an explicit `SUSPEND` pauses a stream without terminating it. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for the decision-verb semantics. ### Constraint Handlers When the PDP returns a decision with `obligations` or `advice`, the `EnforcementPlanner` resolves and schedules all matching handlers. #### The ConstraintHandlerProvider Protocol There is one extension point. A constraint handler is an object that implements the `ConstraintHandlerProvider` protocol, which has a single method. ```python from collections.abc import Sequence from typing import Any, Protocol from sapl_base.pep import ScopedHandler class ConstraintHandlerProvider(Protocol): def get_handlers(self, constraint: Any) -> Sequence[ScopedHandler]: ... ``` The planner calls `get_handlers` for each constraint in a decision. The provider inspects the constraint and decides whether it can handle it. If it can, it returns one or more `ScopedHandler` entries. If it cannot, it returns an empty sequence and the planner asks the other providers. If no provider claims a constraint that arrived as an obligation, or if more than one provider claims the same constraint, the planner schedules a synthetic failure runner so the decision fails closed. A `ScopedHandler` bundles three things. | Field | Description | | ---------- | ---------------------------------------------------------------------------------------------------- | | `signal` | The `SignalKind` the handler attaches to. The decision signal runs once when the decision arrives. The output signal runs on the return value or on each streamed item. | | `priority` | Lower runs earlier among handlers on the same signal. | | `shape` | `"runner"` is `() -> None`, `"consumer"` is `(value) -> None`, `"mapper"` is `(value) -> value`. | | `handler` | The callable itself. | The three shapes mirror the work a handler does. A `runner` is a side effect that needs no value, such as logging on a decision. A `consumer` is a side effect that has access to the value but does not change it, such as auditing the response. A `mapper` transforms the value flowing through a data-carrying signal, such as redacting fields. A mapper is admissible only for an obligation, never for advice. Advice is allowed to fail silently, and a value transformation that silently did not happen would leave the caller unable to tell whether the result was transformed. #### Registering Custom Handlers Register providers on the `SaplFlask` extension instance after calling `SaplFlask(app)` or `sapl.init_app(app)`: ```python from collections.abc import Sequence from typing import Any from sapl_flask import SaplFlask from sapl_base.pep import DECISION, ScopedHandler class LogAccessProvider: def get_handlers(self, constraint: Any) -> Sequence[ScopedHandler]: if not (isinstance(constraint, dict) and constraint.get("type") == "logAccess"): return () message = constraint.get("message", "Access logged") def run() -> None: print(f"[POLICY] {message}") return (ScopedHandler(signal=DECISION, priority=0, shape="runner", handler=run),) sapl = SaplFlask(app) sapl.register_provider(LogAccessProvider()) ``` Registration rebuilds the planner. A single obligation can drive several handlers at different signals. The provider returns one `ScopedHandler` per handler, and the planner schedules each one against its own signal. The bundle is all-or-nothing during admissibility checks. If any handler in the returned sequence is not well-formed for the constraint's tag, the entire claim is rejected and the decision fails closed. ### Built-in Constraint Handlers #### ContentFilteringProvider **Constraint type:** `filterJsonContent` Transforms response values by deleting, replacing, or blackening fields. A policy can attach this obligation: ``` policy "permit-read-patient" permit action == "readPatient"; resource == "patient"; obligation { "type": "filterJsonContent", "actions": [ { "type": "blacken", "path": "$.ssn", "discloseRight": 4 }, { "type": "delete", "path": "$.internalNotes" }, { "type": "replace", "path": "$.classification", "replacement": "REDACTED" } ] } ``` The `blacken` action supports these options: | Option | Type | Default | Description | | --------------- | ------ | ----------------------------- | ------------------------------------------- | | `path` | string | (required) | Dot-notation path to a string field | | `replacement` | string | `"\u2588"` (block character) | Character used for masking | | `discloseLeft` | number | `0` | Characters to leave unmasked from the left | | `discloseRight` | number | `0` | Characters to leave unmasked from the right | | `length` | number | (masked section length) | Override the length of the masked section | #### ContentFilterPredicateProvider **Constraint type:** `jsonContentFilterPredicate` Filters array elements or nullifies single values that do not meet conditions. ```json { "type": "jsonContentFilterPredicate", "conditions": [ { "path": "$.classification", "type": "!=", "value": "top-secret" } ] } ``` #### ContentFilter Limitations The built-in content filter supports **simple dot-notation paths only** (`$.field.nested`). Recursive descent (`$..ssn`), bracket notation (`$['field']`), array indexing (`$.items[0]`), wildcards (`$.users[*].email`), and filter expressions (`$.books[?(@.price<10)]`) are not supported. ### Query Rewriting Flask applications can filter results at the database through SAPL's SQLAlchemy integration, the `sapl-sqlalchemy` package: a policy attaches a `sql:queryRewriting` obligation and the integration rewrites the query before it reaches the database, so unauthorised rows never leave it. Install it separately and register it once at startup. ```bash pip install sapl-sqlalchemy ``` ```python from sapl_sqlalchemy import SqlQueryRewritingProvider, register_orm_listener register_orm_listener() sapl.register_provider(SqlQueryRewritingProvider()) ``` See [Query Rewriting](../6_12_QueryRewriting/) for the obligation format, the shared semantics, and what the integration does and does not cover (including the off-session fail-open caveat). ### Streaming Authorization For SSE endpoints, `@stream_enforce` provides continuous authorization where the PDP streams decisions over time. Access may flip between permitted, suspended, and denied based on time, location, or context changes. Flask streaming responses use `Response` with `mimetype="text/event-stream"`. The decorator bridges between Flask's synchronous response model and the async PDP streaming protocol using a dedicated event loop, and renders each yielded item as an SSE frame. A time-based policy that cycles between `PERMIT` and `SUSPEND`, so the stream pauses and resumes without terminating: ``` policy "streaming-heartbeat-time-based" permit action == "stream:heartbeat"; resource == "heartbeat"; var second = time.secondOf(); second >= 0 && second < 20 || second >= 40; suspend action == "stream:heartbeat"; resource == "heartbeat"; ``` Connect with curl to observe streaming behavior: ```bash curl -N http://localhost:3000/stream/heartbeat ``` ### Manual PDP Access For cases where decorators are not suitable, access the PDP client directly via the extension: ```python import asyncio from flask import jsonify, abort from sapl_flask import get_sapl_extension from sapl_base.types import AuthorizationSubscription, Decision @app.route("/hello") def get_hello(): sapl = get_sapl_extension() 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: return jsonify({"message": "hello"}) abort(403, description="Access denied by policy") ``` When using the PDP client directly, you are responsible for checking the decision, enforcing obligations, and handling resource replacement. Flask views are synchronous, so wrap async PDP calls with `asyncio.run()`. ### Service Layer Enforcement The same `@pre_enforce` and `@post_enforce` decorators work at any layer, not just on Flask views. When used on a service method outside of a Flask request context, the decorator automatically translates denial into `abort(403)` when a request context is available, or propagates the error normally otherwise: ```python from sapl_flask import pre_enforce, post_enforce @pre_enforce(action="listPatients", resource="patients") def list_patients() -> list[dict]: return [dict(p) for p in PATIENTS] @post_enforce( action="getPatientDetail", resource=lambda ctx: {"type": "patientDetail", "data": ctx.return_value}, ) def get_patient_detail(patient_id: str) -> dict | None: return next((dict(p) for p in PATIENTS if p["id"] == patient_id), None) ``` The calling view does not need any special error handling. The decorator handles denial automatically: ```python from flask import jsonify from services import patient_service @app.route("/services/patients/") def get_patient_detail(patient_id: str): result = patient_service.get_patient_detail(patient_id) return jsonify(result) ``` Service-layer decorators accept the same subscription field options (`subject`, `action`, `resource`, `environment`, `secrets`) as when used on views. When no Flask request context is available, subject defaults to `"anonymous"` and environment is empty. ### Database Transactions `@pre_enforce` and `@post_enforce` can own a transaction boundary, so a denial that lands after the view has written to the database rolls the write back. Three triggers cause a rollback: a `@post_enforce` DENY, a `@post_enforce` output-obligation failure, and a `@pre_enforce` output-obligation failure (the pre-decision permits, but its output obligations run after the view writes). A clean PERMIT commits. This is opt-in. With no provider configured the PEP owns no transaction and enforcement behaves exactly as before. A provider is a zero-arg factory returning a context manager that commits on clean exit and rolls back on a propagated exception. `set_transaction_provider` is a method on the `SaplFlask` extension. Flask views are synchronous and run on the blocking core, which uses the provider as a sync context manager, so pass the sync context-manager factory directly. A sync SQLAlchemy `session.begin` is exactly such a factory: ```python sapl = SaplFlask(app) sapl.set_transaction_provider(lambda: get_current_session().begin()) ``` The factory should resolve the current request's session (for example a request-scoped session held in a contextvar). Django's `transaction.atomic` is passed the same way: ```python from django.db import transaction sapl.set_transaction_provider(transaction.atomic) ``` ### Client Resilience The PDP client treats every transport problem as an operational condition, never as a policy outcome, and never lets one surface as an exception. A connection drop, timeout, or decode error fails closed to `INDETERMINATE`, which the PEP enforces as a denial, so a transient PDP outage can never accidentally grant access. One-shot requests (`decide_once`) fail closed to `INDETERMINATE` immediately, with no retry, and never throw. In steady state the connection is warm, so only a cold or dropped connection fails closed. Subscriptions (streaming `decide`) never terminate on a transport problem or on a server-side stream completion. Either condition emits one `INDETERMINATE` and then reconnects with bounded exponential backoff, indefinitely. Consecutive identical decisions are de-duplicated, so an outage yields a single `INDETERMINATE`, not a flood. A subscription ends only when the consumer cancels it or the client shuts down. This contract holds identically across the HTTP and RSocket transports and across every SAPL PEP client. ### Demo Application A complete working demo is available at [sapl-python-demos/flask_demo](https://github.com/heutelbeck/sapl-python-demos/tree/main/flask_demo). It includes: - Manual PDP access (no decorators) - `@pre_enforce` and `@post_enforce` with content filtering - Service-layer enforcement using the same decorators on plain functions - Custom constraint handler providers returning runner, consumer, and mapper handlers - SSE streaming with `@stream_enforce`, covering terminate-on-deny, drop-while-suspended, and signalled suspend/resume - JWT-based ABAC with secrets ### Configuration Reference All options are set via `app.config`: | Key | Type | Default | Description | | ---------------------------------- | ------- | --------------------------- | -------------------------------------------------------- | | `SAPL_BASE_URL` | `str` | `"https://localhost:8443"` | PDP server URL. Plain `http://` is accepted only for loopback hosts | | `SAPL_TOKEN` | `str` | `None` | Bearer token / API key for authentication | | `SAPL_USERNAME` | `str` | `None` | Basic auth username (mutually exclusive with `TOKEN`) | | `SAPL_SECRET` | `str` | `None` | Basic auth secret | | `SAPL_TIMEOUT` | `float` | `5.0` | PDP request timeout in seconds | Streaming retry configuration is set at the `sapl_base` level via the PDP client options. The `SaplFlask` extension does not expose these through `app.config`. ### Troubleshooting | Symptom | Likely Cause | Fix | | ------------------------------------ | --------------------------------------- | ---------------------------------------------------------------- | | All decisions are INDETERMINATE | PDP unreachable | Check `SAPL_BASE_URL` and that PDP is running | | 403 despite PERMIT decision | Unhandled obligation | Check the provider's `get_handlers()` claims the obligation `type` | | Handler not firing | Missing registration | Call `sapl.register_provider()` after init | | Subject is `"anonymous"` | No user in `g.user` or flask-login | Set `g.user` in a before_request hook or use flask-login | | Content filter throws | Unsupported path syntax | Only simple dot paths supported (`$.field.nested`) | | `RuntimeError: SAPL not initialized` | Extension not registered | Call `SaplFlask(app)` or `sapl.init_app(app)` | | Streaming response empty | Generator not yielding dicts | Ensure generator yields dicts (serialized as JSON SSE events) | ### License Apache-2.0 ## FastAPI SDK Attribute-Based Access Control (ABAC) for FastAPI using SAPL (Streaming Attribute Policy Language). Provides decorator-driven policy enforcement with a constraint handler architecture for obligations, advice, and response transformation. The `sapl-fastapi` library integrates SAPL policy enforcement into FastAPI and Starlette applications. It works on both synchronous (`def`) and asynchronous (`async def`) endpoints, supports Server-Sent Events streaming for continuous authorization, and works with FastAPI's dependency injection system. ### What is SAPL? SAPL is a policy language and Policy Decision Point (PDP) for attribute-based access control. Policies are written in a dedicated language and evaluated by the PDP, which streams authorization decisions based on subject, action, resource, and environment attributes. Three core concepts: 1. **Authorization subscription**: your app sends `{ subject, action, resource, environment }` to the PDP. 2. **PDP decision**: the PDP evaluates policies and returns `PERMIT` or `DENY`, optionally with obligations, advice, or a replacement resource. 3. **Constraint handlers**: registered handlers execute the policy's instructions (log, filter, transform, cap values, etc.). A PDP decision looks like this: ```json { "decision": "PERMIT", "obligations": [{ "type": "logAccess", "message": "Patient record accessed" }], "advice": [{ "type": "notifyAdmin" }] } ``` `decision` is always present (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, or `NOT_APPLICABLE`). The other fields are optional. `obligations` and `advice` are arrays of arbitrary JSON objects (by convention with a `type` field for handler dispatch), and `resource` (when present) replaces the endpoint's return value entirely. For a deeper introduction to SAPL's subscription model and policy language, see the [SAPL documentation](https://sapl.io/docs/latest/). ### Installation Install the library and the base dependency: ```bash pip install sapl-fastapi ``` This also installs `sapl-base`, which provides the PDP client, constraint engine, and content filtering. The library requires Python 3.12 or later and FastAPI 0.100+. A complete working demo with JWT authentication, constraint handlers, content filtering, and streaming enforcement is available at [sapl-python-demos/fastapi_demo](https://github.com/heutelbeck/sapl-python-demos/tree/main/fastapi_demo). ### Setup #### Lifespan Configuration Configure SAPL during application startup using FastAPI's lifespan context manager: ```python import os from contextlib import asynccontextmanager from collections.abc import AsyncIterator from fastapi import FastAPI from sapl_fastapi import SaplConfig, configure_sapl, cleanup_sapl @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: config = SaplConfig( base_url=os.getenv("SAPL_PDP_URL", "https://localhost:8443"), token=os.getenv("SAPL_PDP_TOKEN"), ) configure_sapl(config) yield await cleanup_sapl() app = FastAPI(lifespan=lifespan) ``` For basic authentication instead of an API key: ```python config = SaplConfig( base_url="https://localhost:8443", username="myPdpClient", secret="myPassword", ) ``` `token` (API key) and `username`/`secret` (Basic Auth) are mutually exclusive. Configure one or the other. #### Local Development (HTTP) For local development without TLS, point `base_url` at a loopback host. A plain `http://` URL is accepted only when the host is `localhost`, `127.0.0.1`, or `::1`. Any plain-HTTP URL targeting a remote host is refused at construction time, so plaintext authorization decisions never leave the machine. ```python config = SaplConfig( base_url="http://localhost:8443", ) ``` #### What configure_sapl Registers `configure_sapl()` creates the module-level singleton PDP client and the `EnforcementPlanner`. It automatically registers the built-in `ContentFilteringProvider` and `ContentFilterPredicateProvider` for content filtering support. Custom constraint handler providers are registered separately via `register_provider()`. `cleanup_sapl()` closes the PDP client and releases HTTP connections. Always call it during shutdown (in the lifespan `yield` teardown block). ### Enforcement Decorators The decorators work on both synchronous (`def`) and asynchronous (`async def`) FastAPI endpoint functions. The decorated endpoint **must** include `request: Request` as a parameter (either positional or keyword) so the decorator can extract request context. The decorators auto-detect the endpoint kind, so you write the endpoint in whichever style suits it. An async endpoint runs on the async enforcement core. A sync endpoint runs on the blocking core, which executes the endpoint off the event loop, so synchronous database and IO access works normally. FastAPI and Starlette already run sync `def` endpoints in a threadpool, so the blocking core runs cleanly there. When you configure a transaction provider (see [Database Transactions](#database-transactions)) it must match the endpoint kind: a sync context-manager factory for sync endpoints, an async one for async endpoints. #### @pre_enforce Authorizes **before** the endpoint executes. The endpoint only runs on PERMIT. ```python from fastapi import FastAPI, Request from sapl_fastapi import pre_enforce app = FastAPI() @app.get("/patient/{patient_id}") @pre_enforce(action="readPatient", resource="patient") async def get_patient(request: Request, patient_id: str): return {"id": patient_id, "name": "Jane Doe", "ssn": "123-45-6789"} ``` Use `@pre_enforce` for endpoints with side effects (database writes, emails) that should not execute when access is denied. On denial, an `HTTPException` with status 403 is raised. #### @post_enforce Authorizes **after** the endpoint executes. The endpoint always runs. Its return value is available to the subscription builder via the `return_value` argument of callable fields. ```python from fastapi import Request from sapl_fastapi import post_enforce @app.get("/record/{record_id}") @post_enforce( action="read", resource=lambda ctx: {"type": "record", "data": ctx.return_value}, ) async def get_record(request: Request, record_id: str): return {"id": record_id, "value": "sensitive-data"} ``` Use `@post_enforce` when the policy needs to see the actual return value to make its authorization decision (e.g., deny based on the data's classification). On denial, the return value is discarded and `HTTPException(403)` is raised. #### Building the Authorization Subscription Each decorator accepts keyword arguments to customize the authorization subscription fields: `subject`, `action`, `resource`, `environment`, and `secrets`. **Default Values** When not explicitly provided, the subscription fields are derived from the Starlette `Request`: | Field | Default | | ------------- | ----------------------------------------------------------------------------- | | `subject` | `request.state.user` or `request.scope["user"]`, or `"anonymous"` | | `action` | `{"method": request.method, "handler": function_name}` | | `resource` | `{"path": request.url.path, "params": dict(request.path_params)}` | | `environment` | `{"ip": request.client.host}` (when available) | | `secrets` | Not sent unless explicitly specified | The `subject` default integrates with FastAPI/Starlette authentication middleware. If you set `request.state.user` in an authentication dependency or middleware, it is automatically used as the subject. **Static Values** Pass a string or dict directly: ```python @pre_enforce(action="read", resource="patient") ``` **Dynamic Values (Callables)** Pass a callable that receives a `SubscriptionContext` and returns the field value. The context provides `request`, `return_value` (`None` for `@pre_enforce`), `params` (path parameters), `query` (query string), and `args` (resolved function arguments): ```python @pre_enforce( subject=lambda ctx: getattr(ctx.request.state, "user", "anonymous") if ctx.request else "anonymous", resource=lambda ctx: {"pilotId": ctx.params.get("pilot_id")}, ) ``` **Secrets** The `secrets` field carries sensitive data (tokens, API keys) that the PDP needs for policy evaluation but that must not appear in logs. It is excluded from debug logging automatically. Use it when a policy needs to inspect credentials, for example passing a raw JWT so the PDP can read its claims: ```python @pre_enforce( action="exportData", resource=lambda ctx: {"pilotId": ctx.params.get("pilot_id")}, secrets=lambda ctx: {"jwt": getattr(ctx.request.state, "token", None)} if ctx.request and getattr(ctx.request.state, "token", None) else None, ) ``` #### @stream_enforce Streaming enforcement applies an authorization decision continuously to a stream of items your endpoint produces. The decorated endpoint returns an **async iterator** of data items. SAPL opens a streaming PDP subscription and applies each decision to the stream as it runs: `PERMIT` passes items through, `SUSPEND` pauses, `DENY` ends it. The enforced result is **itself an async iterator** of authorised items, so it is independent of how you deliver them. `@stream_enforce` is the ready-made binding for **Server-Sent Events**: it wraps the enforced iterator in a Starlette `StreamingResponse` that renders each item as an SSE `data:` frame on `text/event-stream`. SSE is the delivery shown here. For another delivery mode (a WebSocket, a gRPC stream, or consuming the stream in-process) drive the enforcement directly with `run_pipeline` from `sapl_base.pep.streaming`: it takes your async iterator and returns the enforced async iterator, with no transport assumptions. ```python import asyncio from datetime import datetime, timezone from fastapi import Request from sapl_fastapi import stream_enforce @app.get("/stream/heartbeat") @stream_enforce(action="stream:heartbeat", resource="heartbeat") async def heartbeat(request: Request): seq = 0 while True: yield {"seq": seq, "ts": datetime.now(timezone.utc).isoformat()} seq += 1 await asyncio.sleep(2) ``` A single decorator now covers every streaming case. The behaviour is driven by the policy verbs and by two boolean flags, both defaulting to `False`. ```python @stream_enforce( action="stream:heartbeat", resource="heartbeat", signal_transitions=False, # default pause_rap_during_suspend=False, # default ) ``` **Verb routing.** Every decision the PDP emits during the lifetime of the subscription maps to one observable effect. | PDP decision | Effect on the stream | | ---------------- | ----------------------------------------------------------------------------------------------------- | | `PERMIT` | Items flow through to the consumer. | | `SUSPEND` | Items are silently dropped. The subscription stays open. A later `PERMIT` resumes the flow. | | `DENY` | The stream terminates. The SSE binding emits a final `ACCESS_DENIED` frame before closing. | | `INDETERMINATE` | The subscription terminates, the same way `DENY` does. | | `NOT_APPLICABLE` | The subscription terminates, the same way `DENY` does. | Under the strict fail-closed discipline only an explicit `SUSPEND` keeps the subscription alive while pausing it. `DENY`, `INDETERMINATE`, and `NOT_APPLICABLE` all terminate. For keep-alive semantics where access pauses and later resumes, the policy must emit `SUSPEND` rather than `DENY`. Operators who want `NOT_APPLICABLE` to pause rather than terminate set the combining algorithm's `defaultDecision` to `SUSPEND` at the PDP level. **signal_transitions.** With the default `False`, suspend and resume boundaries are silent. The consumer sees items while permitted and a gap while suspended, with no boundary item. With `True`, the enforced stream carries an `ACCESS_SUSPENDED` boundary item each time it is suspended and an `ACCESS_GRANTED` boundary item each time it resumes (the SSE binding renders these as frames). Use this when the consumer should show a paused/resumed status. **pause_rap_during_suspend.** With the default `False`, the protected async iterator stays subscribed during suspension. Items keep arriving from upstream and are dropped on the way to the client, giving lower latency on resume. With `True`, the upstream iterator is cancelled on entry to the suspended state and re-subscribed on resume. Use this for upstream sources with expensive side effects that must not run while access is paused. | Scenario | Configuration | | ---------------------------------------------- | ------------------------------------------------------------ | | Access loss is permanent (revoked credentials) | policy emits `deny`; defaults | | Client does not need to know about gaps | policy emits `suspend`; defaults | | Client should show suspended/restored status | policy emits `suspend`; `signal_transitions=True` | ### How Enforcement Works The decorators above are convenient, but to use them well it helps to understand what actually happens behind the scenes. This section walks through the enforcement lifecycle so you can reason about behavior. #### The Deny Invariant Only `PERMIT` grants access. The PDP can return five possible decisions (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, `NOT_APPLICABLE`), and only `PERMIT` ever results in your endpoint running or your stream forwarding data. Everything else means denial. The streaming PEP honours `SUSPEND` by pausing the stream while keeping the subscription alive, so a later `PERMIT` resumes it. One-shot enforcement (`@pre_enforce`, `@post_enforce`) treats `SUSPEND` as a denial. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for details. A `PERMIT` with obligations is not a free pass. The PEP checks that every obligation in the decision has a registered handler. If even one obligation cannot be fulfilled, the PEP treats the decision as a denial. If a handler accepts responsibility but fails during execution, that also results in denial. Advice is softer: if an advice handler fails, the PEP logs the failure and moves on. Advice never causes denial. | Aspect | Obligation | Advice | |-----------------|---------------------------------------------------------------------|-------------------------------------------------| | All handled? | Required. Unhandled obligations deny access (HTTPException 403). | Optional. Unhandled advice is silently ignored. | | Handler failure | Denies access (HTTPException 403). | Logs a warning and continues. | This means you can always trust that if your endpoint runs, every obligation attached to the decision has been successfully enforced. #### Enforcement Locations Depending on the decorator, constraint handlers can intervene at different points in the lifecycle of a request or stream. For request-response endpoints (`@pre_enforce` and `@post_enforce`), constraints can run at four points: | Location | When it happens | What constraints do here | |-----------------------|-----------------------------------------|-----------------------------------------------------| | On decision | Authorization decision arrives | Side effects like logging, audit, or notification | | Pre-method invocation | Before the protected endpoint executes | Modify endpoint arguments (`@pre_enforce` only) | | On return value | After the endpoint returns | Transform, filter, or replace the result | | On error | If the endpoint throws | Transform or observe the error | For streaming endpoints (`@stream_enforce`), constraints can run at five points: | Location | When it happens | What constraints do here | |--------------------|----------------------------------------------|-----------------------------------------| | On decision | Each new decision from the PDP stream | Side effects like logging, audit | | On each data item | Each element yielded by the async iterator | Transform, filter, or replace items | | On stream error | The iterator produces an error | Transform or observe the error | | On stream complete | The iterator finishes normally | Cleanup and finalization | | On cancel | Client disconnects or enforcement terminates | Release resources and close connections | SAPL models each of these points as a named signal, and a handler attaches to whichever signal fits the work it does. A handler that fires once when the decision arrives attaches to the decision signal. A handler that processes each emitted item attaches to the output signal. The signal a handler attaches to determines when it runs. The same `ConstraintHandlerProvider` mechanism is used for one-shot and streaming enforcement alike. #### PreEnforce Lifecycle When you decorate an endpoint with `@pre_enforce`, here is what happens step by step. First, the PEP builds an authorization subscription from the decorator options (or from defaults if you left them out) and sends it to the PDP as a one-shot request. The PDP evaluates the subscription against all matching policies and returns a single decision. If the decision is anything other than `PERMIT`, the PEP raises `HTTPException(403)` immediately. Your endpoint never runs. If the decision is `PERMIT`, the PEP resolves all constraint handlers. It walks through the obligations and advice attached to the decision and checks which registered handlers claim responsibility for each one. If any obligation has no matching handler, the PEP denies access right there, because it cannot guarantee the obligation will be enforced. With all handlers resolved, execution proceeds through the enforcement locations in order. On-decision handlers run first (logging, audit). Then method-invocation handlers run, which can modify endpoint arguments if the policy requires it. Then your actual endpoint executes. After the endpoint returns, the PEP applies return-value handlers: resource replacement if the decision included one, filter predicates, mapping handlers, and consumer handlers. If any obligation handler fails at any stage, the PEP denies access. #### PostEnforce Lifecycle `@post_enforce` inverts the order. Your endpoint runs first, regardless of the authorization outcome. Only after it returns does the PEP build the authorization subscription (now including the return value) and consult the PDP. This means the PDP can make decisions based on the actual data your endpoint produced. For example, a policy might permit access to a record only if its classification level is below a threshold, something that can only be checked after loading the record. If the decision is not `PERMIT`, the PEP discards the return value and raises `HTTPException(403)`. If the decision is `PERMIT`, constraint handlers proceed through the same stages as `@pre_enforce`, minus the method-invocation handlers (since the endpoint has already run). Return-value handlers can still transform the result before it reaches the caller. Because the endpoint runs before the PDP is consulted, if the endpoint itself raises an exception, that exception propagates directly. The PDP is never called, because there is no return value to include in the subscription. SAPL PEP libraries share a single unified enforcement model. It is a strict fail-closed state machine over the five decision verbs, where only `PERMIT` grants access and only an explicit `SUSPEND` pauses a stream without terminating it. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for the decision-verb semantics. ### Constraint Handlers When the PDP returns a decision with `obligations` or `advice`, the `EnforcementPlanner` resolves and schedules all matching handlers. #### The ConstraintHandlerProvider Protocol There is one extension point. A constraint handler is an object that implements the `ConstraintHandlerProvider` protocol, which has a single method. ```python from collections.abc import Sequence from typing import Any, Protocol from sapl_base.pep import ScopedHandler class ConstraintHandlerProvider(Protocol): def get_handlers(self, constraint: Any) -> Sequence[ScopedHandler]: ... ``` The planner calls `get_handlers` for each constraint in a decision. The provider inspects the constraint and decides whether it can handle it. If it can, it returns one or more `ScopedHandler` entries. If it cannot, it returns an empty sequence and the planner asks the other providers. If no provider claims a constraint that arrived as an obligation, or if more than one provider claims the same constraint, the planner schedules a synthetic failure runner so the decision fails closed. A `ScopedHandler` bundles three things. | Field | Description | | ---------- | ---------------------------------------------------------------------------------------------------- | | `signal` | The `SignalKind` the handler attaches to. The decision signal runs once when the decision arrives. The output signal runs on the return value or on each streamed item. | | `priority` | Lower runs earlier among handlers on the same signal. | | `shape` | `"runner"` is `() -> None`, `"consumer"` is `(value) -> None`, `"mapper"` is `(value) -> value`. | | `handler` | The callable itself. | The three shapes mirror the work a handler does. A `runner` is a side effect that needs no value, such as logging on a decision. A `consumer` is a side effect that has access to the value but does not change it, such as auditing the response. A `mapper` transforms the value flowing through a data-carrying signal, such as redacting fields. A mapper is admissible only for an obligation, never for advice. Advice is allowed to fail silently, and a value transformation that silently did not happen would leave the caller unable to tell whether the result was transformed. #### Registering Custom Handlers Register providers during application startup, inside the lifespan function: ```python from collections.abc import Sequence from typing import Any from sapl_fastapi import configure_sapl, register_provider, SaplConfig from sapl_base.pep import DECISION, ScopedHandler class LogAccessProvider: def get_handlers(self, constraint: Any) -> Sequence[ScopedHandler]: if not (isinstance(constraint, dict) and constraint.get("type") == "logAccess"): return () message = constraint.get("message", "Access logged") def run() -> None: print(f"[POLICY] {message}") return (ScopedHandler(signal=DECISION, priority=0, shape="runner", handler=run),) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: configure_sapl(SaplConfig(base_url="https://localhost:8443")) register_provider(LogAccessProvider()) yield await cleanup_sapl() ``` Registration rebuilds the planner. A single obligation can drive several handlers at different signals. The provider returns one `ScopedHandler` per handler, and the planner schedules each one against its own signal. The bundle is all-or-nothing during admissibility checks. If any handler in the returned sequence is not well-formed for the constraint's tag, the entire claim is rejected and the decision fails closed. ### Built-in Constraint Handlers #### ContentFilteringProvider **Constraint type:** `filterJsonContent` Registered automatically by `configure_sapl()`. Transforms response values by deleting, replacing, or blackening fields. A policy can attach this obligation: ``` policy "permit-read-patient" permit action == "readPatient"; resource == "patient"; obligation { "type": "filterJsonContent", "actions": [ { "type": "blacken", "path": "$.ssn", "discloseRight": 4 }, { "type": "delete", "path": "$.internalNotes" }, { "type": "replace", "path": "$.classification", "replacement": "REDACTED" } ] } ``` The `blacken` action supports these options: | Option | Type | Default | Description | | --------------- | ------ | ----------------------------- | ------------------------------------------- | | `path` | string | (required) | Dot-notation path to a string field | | `replacement` | string | `"\u2588"` (block character) | Character used for masking | | `discloseLeft` | number | `0` | Characters to leave unmasked from the left | | `discloseRight` | number | `0` | Characters to leave unmasked from the right | | `length` | number | (masked section length) | Override the length of the masked section | #### ContentFilterPredicateProvider **Constraint type:** `jsonContentFilterPredicate` Registered automatically by `configure_sapl()`. Filters array elements or nullifies single values that do not meet conditions. ```json { "type": "jsonContentFilterPredicate", "conditions": [ { "path": "$.classification", "type": "!=", "value": "top-secret" } ] } ``` #### ContentFilter Limitations The built-in content filter supports **simple dot-notation paths only** (`$.field.nested`). Recursive descent (`$..ssn`), bracket notation (`$['field']`), array indexing (`$.items[0]`), wildcards (`$.users[*].email`), and filter expressions (`$.books[?(@.price<10)]`) are not supported. ### Query Rewriting FastAPI applications can filter results at the database through SAPL's SQLAlchemy integration, the `sapl-sqlalchemy` package: a policy attaches a `sql:queryRewriting` obligation and the integration rewrites the query before it reaches the database, so unauthorised rows never leave it. Install it separately and register it once at startup. ```bash pip install sapl-sqlalchemy ``` ```python from sapl_sqlalchemy import SqlQueryRewritingProvider, register_orm_listener from sapl_fastapi import register_provider register_orm_listener() register_provider(SqlQueryRewritingProvider()) ``` See [Query Rewriting](../6_12_QueryRewriting/) for the obligation format, the shared semantics, and what the integration does and does not cover (including the off-session fail-open caveat). ### Streaming Authorization For SSE endpoints returning async iterators, `@stream_enforce` provides continuous authorization where the PDP streams decisions over time. Access may flip between permitted, suspended, and denied based on time, location, or context changes. The decorator returns a Starlette `StreamingResponse` with `media_type="text/event-stream"`. Each yielded item is rendered as an SSE `data:` event (dicts are JSON-serialized). A time-based policy that cycles between `PERMIT` and `SUSPEND`, so the stream pauses and resumes without terminating: ``` policy "streaming-heartbeat-time-based" permit action == "stream:heartbeat"; resource == "heartbeat"; var second = time.secondOf(); second >= 0 && second < 20 || second >= 40; suspend action == "stream:heartbeat"; resource == "heartbeat"; ``` Connect with curl to observe streaming behavior: ```bash curl -N http://localhost:3000/stream/heartbeat ``` ### Manual PDP Access For cases where decorators are not suitable, access the PDP client directly: ```python from fastapi import FastAPI, HTTPException, Request from sapl_fastapi import get_pdp_client from sapl_base.types import AuthorizationSubscription, Decision app = FastAPI() @app.get("/hello") async def get_hello(request: Request): pdp_client = get_pdp_client() subscription = AuthorizationSubscription( subject="anonymous", action="read", resource="hello", ) decision = await pdp_client.decide_once(subscription) if decision.decision == Decision.PERMIT and not decision.obligations: return {"message": "hello"} raise HTTPException(status_code=403, detail="Access denied") ``` When using the PDP client directly, you are responsible for checking the decision, enforcing obligations, and handling resource replacement. ### Service Layer Enforcement The same `@pre_enforce` and `@post_enforce` decorators work at any layer, not just on FastAPI endpoints. When used on a service method without a `Request` parameter, the decorator automatically translates denial into `HTTPException(403)`: ```python from sapl_fastapi import pre_enforce, post_enforce @pre_enforce(action="listPatients", resource="patients") async def list_patients() -> list[dict]: return [dict(p) for p in PATIENTS] @post_enforce( action="getPatientDetail", resource=lambda ctx: {"type": "patientDetail", "data": ctx.return_value}, ) async def get_patient_detail(patient_id: str) -> dict | None: return next((dict(p) for p in PATIENTS if p["id"] == patient_id), None) ``` The calling endpoint does not need any special error handling. The `HTTPException` propagates through FastAPI's normal exception handling and returns HTTP 403: ```python from fastapi import FastAPI, Request from services import patient_service app = FastAPI() @app.get("/services/patients/{patient_id}") async def get_patient_detail(request: Request, patient_id: str): result = await patient_service.get_patient_detail(patient_id) return result ``` Service-layer decorators accept the same subscription field options (`subject`, `action`, `resource`, `environment`, `secrets`) as when used on endpoints. When no `Request` is available, subject defaults to `"anonymous"` and environment is empty. ### Database Transactions `@pre_enforce` and `@post_enforce` can own a transaction boundary, so a denial that lands after the endpoint has written to the database rolls the write back. Three triggers cause a rollback: a `@post_enforce` DENY, a `@post_enforce` output-obligation failure, and a `@pre_enforce` output-obligation failure (the pre-decision permits, but its output obligations run after the endpoint writes). A clean PERMIT commits. This is opt-in. With no provider configured the PEP owns no transaction and enforcement behaves exactly as before. A provider is a zero-arg factory returning a context manager that commits on clean exit and rolls back on a propagated exception. It must match the endpoint kind it protects: a sync context manager for sync endpoints, an async one for async endpoints. Async endpoints run on the async core, which uses the provider as an async context manager, so pass an async SQLAlchemy `AsyncSession.begin()` directly: ```python from sapl_fastapi.dependencies import set_transaction_provider set_transaction_provider(lambda: get_current_async_session().begin()) ``` Sync endpoints run on the blocking core, which uses the provider as a sync context manager, so pass a sync context-manager factory directly. A sync SQLAlchemy session built from `create_engine` plus `session.begin` is exactly such a factory: ```python set_transaction_provider(lambda: get_current_sync_session().begin()) ``` The factory should resolve the current request's session (for example a request-scoped session held in a contextvar). ### Client Resilience The PDP client treats every transport problem as an operational condition, never as a policy outcome, and never lets one surface as an exception. A connection drop, timeout, or decode error fails closed to `INDETERMINATE`, which the PEP enforces as a denial, so a transient PDP outage can never accidentally grant access. One-shot requests (`decide_once`) fail closed to `INDETERMINATE` immediately, with no retry, and never throw. In steady state the connection is warm, so only a cold or dropped connection fails closed. Subscriptions (streaming `decide`) never terminate on a transport problem or on a server-side stream completion. Either condition emits one `INDETERMINATE` and then reconnects with bounded exponential backoff, indefinitely. Consecutive identical decisions are de-duplicated, so an outage yields a single `INDETERMINATE`, not a flood. A subscription ends only when the consumer cancels it or the client shuts down. This contract holds identically across the HTTP and RSocket transports and across every SAPL PEP client. ### Demo Application A complete working demo is available at [sapl-python-demos/fastapi_demo](https://github.com/heutelbeck/sapl-python-demos/tree/main/fastapi_demo). It includes: - Manual PDP access (no decorators) - `@pre_enforce` and `@post_enforce` with content filtering - Service-layer enforcement using the same decorators on plain async functions - Custom constraint handler providers returning runner, consumer, and mapper handlers - SSE streaming with `@stream_enforce`, covering terminate-on-deny, drop-while-suspended, and signalled suspend/resume - JWT-based ABAC with secrets ### Configuration Reference All options are set via the `SaplConfig` dataclass passed to `configure_sapl()`: | Parameter | Type | Default | Description | | ---------------------------- | ------- | --------------------------- | -------------------------------------------------------- | | `base_url` | `str` | `"https://localhost:8443"` | PDP server URL. Plain `http://` is accepted only for loopback hosts | | `token` | `str` | `None` | Bearer token / API key for authentication | | `username` | `str` | `None` | Basic auth username (mutually exclusive with `token`) | | `secret` | `str` | `None` | Basic auth secret | | `timeout_seconds` | `float` | `5.0` | PDP request timeout in seconds | | `streaming_retry_base_delay_seconds` | `float` | `1.0` | Base delay in seconds for exponential backoff on retry | | `streaming_retry_max_delay_seconds` | `float` | `30.0` | Maximum delay in seconds for exponential backoff | ### Troubleshooting | Symptom | Likely Cause | Fix | | ------------------------------------ | --------------------------------------- | ---------------------------------------------------------------- | | All decisions are INDETERMINATE | PDP unreachable | Check `base_url` and that PDP is running | | 403 despite PERMIT decision | Unhandled obligation | Check the provider's `get_handlers()` claims the obligation `type` | | Handler not firing | Missing registration | Call `register_provider()` in lifespan | | Subject is `"anonymous"` | No auth middleware setting `state.user` | Set `request.state.user` in auth dependency or middleware | | Content filter throws | Unsupported path syntax | Only simple dot paths supported (`$.field.nested`) | | `RuntimeError: SAPL not configured` | Missing `configure_sapl()` | Call `configure_sapl()` in lifespan before yield | | `RuntimeError: No Request object` | Missing `request: Request` parameter | Add `request: Request` to endpoint function signature | | Streaming response not SSE | Missing `text/event-stream` content | Use streaming decorators; they set the content type automatically | ### License Apache-2.0 ## Tornado SDK Attribute-Based Access Control (ABAC) for Tornado using SAPL (Streaming Attribute Policy Language). Provides decorator-driven policy enforcement with a constraint handler architecture for obligations, advice, and response transformation. The `sapl-tornado` library integrates SAPL policy enforcement into Tornado applications. It is fully async-native, supports Server-Sent Events streaming for continuous authorization, and works with Tornado's `RequestHandler` lifecycle. ### What is SAPL? SAPL is a policy language and Policy Decision Point (PDP) for attribute-based access control. Policies are written in a dedicated language and evaluated by the PDP, which streams authorization decisions based on subject, action, resource, and environment attributes. Three core concepts: 1. **Authorization subscription**: your app sends `{ subject, action, resource, environment }` to the PDP. 2. **PDP decision**: the PDP evaluates policies and returns `PERMIT` or `DENY`, optionally with obligations, advice, or a replacement resource. 3. **Constraint handlers**: registered handlers execute the policy's instructions (log, filter, transform, cap values, etc.). A PDP decision looks like this: ```json { "decision": "PERMIT", "obligations": [{ "type": "logAccess", "message": "Patient record accessed" }], "advice": [{ "type": "notifyAdmin" }] } ``` `decision` is always present (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, or `NOT_APPLICABLE`). The other fields are optional. `obligations` and `advice` are arrays of arbitrary JSON objects (by convention with a `type` field for handler dispatch), and `resource` (when present) replaces the handler's return value entirely. For a deeper introduction to SAPL's subscription model and policy language, see the [SAPL documentation](https://sapl.io/docs/latest/). ### Installation Install the library and the base dependency: ```bash pip install sapl-tornado ``` This also installs `sapl-base`, which provides the PDP client, the `EnforcementPlanner`, and content filtering. The library requires Python 3.12 or later and Tornado 6.0+. A complete working demo with constraint handlers, content filtering, and streaming enforcement is available at [sapl-python-demos/tornado_demo](https://github.com/heutelbeck/sapl-python-demos/tree/main/tornado_demo). ### Setup #### Configuration at Startup Configure SAPL before starting the Tornado IOLoop: ```python import os import tornado.ioloop import tornado.web from sapl_tornado import SaplConfig, configure_sapl, cleanup_sapl def make_app() -> tornado.web.Application: return tornado.web.Application([ (r"/patient/(?P[^/]+)", PatientHandler), ]) def main() -> None: config = SaplConfig( base_url=os.getenv("SAPL_PDP_URL", "https://localhost:8443"), token=os.getenv("SAPL_PDP_TOKEN"), ) configure_sapl(config) app = make_app() app.listen(3000) tornado.ioloop.IOLoop.current().start() ``` For basic authentication instead of an API key: ```python config = SaplConfig( base_url="https://localhost:8443", username="myPdpClient", secret="myPassword", ) ``` `token` (API key) and `username`/`secret` (Basic Auth) are mutually exclusive. Configure one or the other. #### Local Development (HTTP) For local development without TLS, point `base_url` at a loopback host. A plain `http://` URL is accepted only when the host is `localhost`, `127.0.0.1`, or `::1`. Any plain-HTTP URL targeting a remote host is refused at construction time, so plaintext authorization decisions never leave the machine. ```python config = SaplConfig( base_url="http://localhost:8443", ) ``` #### Cleanup Call `cleanup_sapl()` during shutdown to release PDP connections: ```python import atexit import asyncio from sapl_tornado import cleanup_sapl atexit.register(lambda: asyncio.run(cleanup_sapl())) ``` #### What configure_sapl Registers `configure_sapl()` creates the module-level singleton PDP client and the `EnforcementPlanner`. It automatically registers the built-in `ContentFilteringProvider` and `ContentFilterPredicateProvider` for content filtering support. Custom constraint handler providers are registered separately via `register_provider()`. `cleanup_sapl()` closes the PDP client and releases HTTP connections. Always call it during shutdown. ### Enforcement Decorators All decorators work on async Tornado `RequestHandler` methods. The decorator extracts the `RequestHandler` instance (via `self`) and its `request` property to build the authorization subscription. Path parameters are extracted from `handler.path_kwargs`. Handler methods must be `async def`. A sync handler method would run directly on the IOLoop thread, where a running event loop is already active and the blocking enforcement core cannot run, so sync handler methods are unsupported. #### @pre_enforce Authorizes **before** the handler method executes. The method only runs on PERMIT. ```python import tornado.web from sapl_tornado import pre_enforce class PatientHandler(tornado.web.RequestHandler): @pre_enforce(action="readPatient", resource="patient") async def get(self, patient_id: str): self.write({"id": patient_id, "name": "Jane Doe", "ssn": "123-45-6789"}) ``` Use `@pre_enforce` for handlers with side effects (database writes, emails) that should not execute when access is denied. On denial, Tornado's `HTTPError(403)` is raised. When the decorated method returns a value (dict, list, or string), the decorator automatically writes it to the response. You can also write directly to `self` inside the handler as usual. #### @post_enforce Authorizes **after** the handler method executes. The method always runs. Its return value is available to the subscription builder via the `return_value` field of the `SubscriptionContext`. ```python from sapl_tornado import post_enforce class RecordHandler(tornado.web.RequestHandler): @post_enforce( action="read", resource=lambda ctx: {"type": "record", "data": ctx.return_value}, ) async def get(self, record_id: str): return {"id": record_id, "value": "sensitive-data"} ``` Use `@post_enforce` when the policy needs to see the actual return value to make its authorization decision (e.g., deny based on the data's classification). On denial, the return value is discarded and `HTTPError(403)` is raised. #### Building the Authorization Subscription Each decorator accepts keyword arguments to customize the authorization subscription fields: `subject`, `action`, `resource`, `environment`, and `secrets`. **Default Values** When not explicitly provided, the subscription fields are derived from the Tornado `HTTPServerRequest` and `RequestHandler`: | Field | Default | | ------------- | ----------------------------------------------------------------------------- | | `subject` | `handler.current_user` or `"anonymous"` | | `action` | `{"method": request.method, "handler": function_name}` | | `resource` | `{"path": request.path, "params": handler.path_kwargs}` | | `environment` | `{"ip": request.remote_ip}` (when available) | | `secrets` | Not sent unless explicitly specified | The `subject` default integrates with Tornado's `get_current_user()` method. If you override `get_current_user()` on your handler, its return value is automatically used as the subject. **Static Values** Pass a string or dict directly: ```python @pre_enforce(action="read", resource="patient") ``` **Dynamic Values (Callables)** Pass a callable that receives a `SubscriptionContext` and returns the field value. The context provides `request`, `return_value` (`None` for `@pre_enforce`), `params` (path kwargs), `query` (query arguments), and `args` (resolved function arguments): ```python @pre_enforce( subject=lambda ctx: ctx.request.remote_ip if ctx.request else "anonymous", resource=lambda ctx: {"pilotId": ctx.params.get("pilot_id")}, ) ``` **Secrets** The `secrets` field carries sensitive data (tokens, API keys) that the PDP needs for policy evaluation but that must not appear in logs. It is excluded from debug logging automatically. Use it when a policy needs to inspect credentials, for example passing a raw JWT so the PDP can read its claims: ```python @pre_enforce( action="exportData", resource=lambda ctx: {"pilotId": ctx.params.get("pilot_id")}, secrets=lambda ctx: {"jwt": _extract_bearer_token(ctx.request)} if ctx.request else None, ) ``` #### @stream_enforce Streaming enforcement applies an authorization decision continuously to a stream of items your handler produces. The decorated handler method returns an async iterator of data items. SAPL opens a streaming PDP subscription and applies each decision to the stream as it runs: `PERMIT` passes items through, `SUSPEND` pauses the flow while keeping the subscription open, and `DENY` ends it. The enforced result is itself an async iterator of authorised items, so it is independent of how you deliver them. `@stream_enforce` is the ready-made binding for Server-Sent Events: it renders the enforced stream as SSE `data:` frames on `text/event-stream`, sets `Content-Type: text/event-stream` and `Cache-Control: no-cache`, and calls `handler.finish()` when the stream ends. SSE is the delivery shown here. For another delivery mode (a WebSocket, a gRPC stream, or consuming the stream in-process) drive the enforcement directly with `run_pipeline` from `sapl_base.pep.streaming`: it takes your async iterator and returns the enforced async iterator, with no transport assumptions. ```python import asyncio from datetime import datetime, timezone from sapl_tornado import stream_enforce class HeartbeatHandler(tornado.web.RequestHandler): @stream_enforce(action="stream:heartbeat", resource="heartbeat") async def get(self): seq = 0 while True: yield {"seq": seq, "ts": datetime.now(timezone.utc).isoformat()} seq += 1 await asyncio.sleep(2) ``` A single decorator now covers every streaming case. The behaviour is driven by the policy verbs and by two boolean flags, both defaulting to `False`. ```python @stream_enforce( action="stream:heartbeat", resource="heartbeat", signal_transitions=False, # default pause_rap_during_suspend=False, # default ) ``` **Verb routing.** Every decision the PDP emits during the lifetime of the subscription maps to one observable effect. | PDP decision | Effect on the stream | | ---------------- | ----------------------------------------------------------------------------------------------------- | | `PERMIT` | Items flow through to the consumer. | | `SUSPEND` | Items are silently dropped. The subscription stays open. A later `PERMIT` resumes the flow. | | `DENY` | The stream terminates; the SSE binding emits a final `ACCESS_DENIED` frame before closing. | | `INDETERMINATE` | The subscription terminates, the same way `DENY` does. | | `NOT_APPLICABLE` | The subscription terminates, the same way `DENY` does. | Under the strict fail-closed discipline only an explicit `SUSPEND` keeps the subscription alive while pausing it. `DENY`, `INDETERMINATE`, and `NOT_APPLICABLE` all terminate. For keep-alive semantics where access pauses and later resumes, the policy must emit `SUSPEND` rather than `DENY`. Operators who want `NOT_APPLICABLE` to pause rather than terminate set the combining algorithm's `defaultDecision` to `SUSPEND` at the PDP level. **signal_transitions.** With the default `False`, suspend and resume boundaries are silent. The consumer sees items while permitted and a gap while suspended, with no boundary marker. With `True`, the enforced stream carries an `ACCESS_SUSPENDED` boundary item each time it is suspended and an `ACCESS_GRANTED` boundary item each time it resumes (the SSE binding renders these as frames). Use this when the consumer should render a paused/resumed status. **pause_rap_during_suspend.** With the default `False`, the protected async iterator stays subscribed during suspension. Items keep arriving from upstream and are dropped on the way to the client, giving lower latency on resume. With `True`, the upstream iterator is cancelled on entry to the suspended state and re-subscribed on resume. Use this for upstream sources with expensive side effects that must not run while access is paused. | Scenario | Configuration | | ---------------------------------------------- | ------------------------------------------------------------ | | Access loss is permanent (revoked credentials) | policy emits `deny`; defaults | | Client does not need to know about gaps | policy emits `suspend`; defaults | | Client should show suspended/restored status | policy emits `suspend`; `signal_transitions=True` | ### How Enforcement Works The decorators above are convenient, but to use them well it helps to understand what actually happens behind the scenes. This section walks through the enforcement lifecycle so you can reason about behavior. #### The Deny Invariant Only `PERMIT` grants access. The PDP can return five possible decisions (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, `NOT_APPLICABLE`), and only `PERMIT` ever results in your handler running or your stream forwarding data. Everything else means denial. The streaming PEP honours `SUSPEND` by pausing the stream while keeping the subscription alive, so a later `PERMIT` resumes it. One-shot enforcement (`@pre_enforce`, `@post_enforce`) treats `SUSPEND` as a denial. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for details. A `PERMIT` with obligations is not a free pass. The PEP checks that every obligation in the decision has a registered handler. If even one obligation cannot be fulfilled, the PEP treats the decision as a denial. If a handler accepts responsibility but fails during execution, that also results in denial. Advice is softer: if an advice handler fails, the PEP logs the failure and moves on. Advice never causes denial. | Aspect | Obligation | Advice | |-----------------|----------------------------------------------------------------|-------------------------------------------------| | All handled? | Required. Unhandled obligations deny access (HTTPError 403). | Optional. Unhandled advice is silently ignored. | | Handler failure | Denies access (HTTPError 403). | Logs a warning and continues. | This means you can always trust that if your handler runs, every obligation attached to the decision has been successfully enforced. #### Enforcement Locations Depending on the decorator, constraint handlers can intervene at different points in the lifecycle of a request or stream. For request-response handlers (`@pre_enforce` and `@post_enforce`), constraints can run at four points: | Location | When it happens | What constraints do here | |-----------------------|-----------------------------------------|-----------------------------------------------------| | On decision | Authorization decision arrives | Side effects like logging, audit, or notification | | Pre-method invocation | Before the protected handler executes | Modify handler arguments (`@pre_enforce` only) | | On return value | After the handler returns | Transform, filter, or replace the result | | On error | If the handler throws | Transform or observe the error | For streaming handlers (`@stream_enforce`), constraints can run at five points: | Location | When it happens | What constraints do here | |--------------------|----------------------------------------------|-----------------------------------------| | On decision | Each new decision from the PDP stream | Side effects like logging, audit | | On each data item | Each element yielded by the async iterator | Transform, filter, or replace items | | On stream error | The iterator produces an error | Transform or observe the error | | On stream complete | The iterator finishes normally | Cleanup and finalization | | On cancel | Client disconnects or enforcement terminates | Release resources and close connections | SAPL models each of these points as a named signal, and a handler attaches to whichever signal fits the work it does. A handler that fires once when the decision arrives attaches to the decision signal. A handler that processes each emitted item attaches to the output signal. The signal a handler attaches to determines when it runs. The same `ConstraintHandlerProvider` mechanism is used for one-shot and streaming enforcement alike. #### PreEnforce Lifecycle When you decorate a handler method with `@pre_enforce`, here is what happens step by step. First, the PEP builds an authorization subscription from the decorator options (or from defaults if you left them out) and sends it to the PDP as a one-shot request. The PDP evaluates the subscription against all matching policies and returns a single decision. If the decision is anything other than `PERMIT`, the PEP raises `HTTPError(403)` immediately. Your handler never runs. If the decision is `PERMIT`, the PEP resolves all constraint handlers. It walks through the obligations and advice attached to the decision and checks which registered handlers claim responsibility for each one. If any obligation has no matching handler, the PEP denies access right there, because it cannot guarantee the obligation will be enforced. With all handlers resolved, execution proceeds through the enforcement locations in order. On-decision handlers run first (logging, audit). Then method-invocation handlers run, which can modify handler arguments if the policy requires it. Then your actual handler executes. After the handler returns, the PEP applies return-value handlers: resource replacement if the decision included one, filter predicates, mapping handlers, and consumer handlers. If any obligation handler fails at any stage, the PEP denies access. #### PostEnforce Lifecycle `@post_enforce` inverts the order. Your handler runs first, regardless of the authorization outcome. Only after it returns does the PEP build the authorization subscription (now including the return value) and consult the PDP. This means the PDP can make decisions based on the actual data your handler produced. For example, a policy might permit access to a record only if its classification level is below a threshold, something that can only be checked after loading the record. If the decision is not `PERMIT`, the PEP discards the return value and raises `HTTPError(403)`. If the decision is `PERMIT`, constraint handlers proceed through the same stages as `@pre_enforce`, minus the method-invocation handlers (since the handler has already run). Return-value handlers can still transform the result before it reaches the caller. Because the handler runs before the PDP is consulted, if the handler itself raises an exception, that exception propagates directly. The PDP is never called, because there is no return value to include in the subscription. SAPL PEP libraries share a single unified enforcement model. It is a strict fail-closed state machine over the five decision verbs, where only `PERMIT` grants access and only an explicit `SUSPEND` pauses a stream without terminating it. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for the decision-verb semantics. ### Constraint Handlers When the PDP returns a decision with `obligations` or `advice`, the `EnforcementPlanner` resolves and schedules all matching handlers. #### The ConstraintHandlerProvider Protocol There is one extension point. A constraint handler is an object that implements the `ConstraintHandlerProvider` protocol, which has a single method. ```python from collections.abc import Sequence from typing import Any, Protocol from sapl_base.pep import ScopedHandler class ConstraintHandlerProvider(Protocol): def get_handlers(self, constraint: Any) -> Sequence[ScopedHandler]: ... ``` The planner calls `get_handlers` for each constraint in a decision. The provider inspects the constraint and decides whether it can handle it. If it can, it returns one or more `ScopedHandler` entries. If it cannot, it returns an empty sequence and the planner asks the other providers. If no provider claims a constraint that arrived as an obligation, or if more than one provider claims the same constraint, the planner schedules a synthetic failure runner so the decision fails closed. A `ScopedHandler` bundles three things. | Field | Description | | ---------- | ---------------------------------------------------------------------------------------------------- | | `signal` | The `SignalKind` the handler attaches to. The decision signal runs once when the decision arrives. The output signal runs on the return value or on each streamed item. | | `priority` | Lower runs earlier among handlers on the same signal. | | `shape` | `"runner"` is `() -> None`, `"consumer"` is `(value) -> None`, `"mapper"` is `(value) -> value`. | | `handler` | The callable itself. | The three shapes mirror the work a handler does. A `runner` is a side effect that needs no value, such as logging on a decision. A `consumer` is a side effect that has access to the value but does not change it, such as auditing the response. A `mapper` transforms the value flowing through a data-carrying signal, such as redacting fields. A mapper is admissible only for an obligation, never for advice. Advice is allowed to fail silently, and a value transformation that silently did not happen would leave the caller unable to tell whether the result was transformed. #### Registering Custom Handlers Register providers during application startup (after calling `configure_sapl()`): ```python from collections.abc import Sequence from typing import Any from sapl_tornado import configure_sapl, register_provider, SaplConfig from sapl_base.pep import DECISION, ScopedHandler class LogAccessProvider: def get_handlers(self, constraint: Any) -> Sequence[ScopedHandler]: if not (isinstance(constraint, dict) and constraint.get("type") == "logAccess"): return () message = constraint.get("message", "Access logged") def run() -> None: print(f"[POLICY] {message}") return (ScopedHandler(signal=DECISION, priority=0, shape="runner", handler=run),) configure_sapl(SaplConfig(base_url="https://localhost:8443")) register_provider(LogAccessProvider()) ``` Registration rebuilds the planner. A single obligation can drive several handlers at different signals. The provider returns one `ScopedHandler` per handler, and the planner schedules each one against its own signal. The bundle is all-or-nothing during admissibility checks. If any handler in the returned sequence is not well-formed for the constraint's tag, the entire claim is rejected and the decision fails closed. A handler that transforms the response attaches to the output signal as a `mapper`. The pattern below caps a transfer amount by rewriting the value flowing through the output signal. ```python from collections.abc import Sequence from typing import Any from sapl_base.pep import OUTPUT, ScopedHandler class CapTransferProvider: def get_handlers(self, constraint: Any) -> Sequence[ScopedHandler]: if not (isinstance(constraint, dict) and constraint.get("type") == "capTransferAmount"): return () max_amount = constraint.get("maxAmount", 0) def cap(value: Any) -> Any: if isinstance(value, dict) and float(value.get("amount", 0)) > max_amount: return {**value, "amount": max_amount} return value return (ScopedHandler(signal=OUTPUT, priority=0, shape="mapper", handler=cap),) ``` ### Built-in Constraint Handlers #### ContentFilteringProvider **Constraint type:** `filterJsonContent` Registered automatically by `configure_sapl()`. Transforms response values by deleting, replacing, or blackening fields. A policy can attach this obligation: ``` policy "permit-read-patient" permit action == "readPatient"; resource == "patient"; obligation { "type": "filterJsonContent", "actions": [ { "type": "blacken", "path": "$.ssn", "discloseRight": 4 }, { "type": "delete", "path": "$.internalNotes" }, { "type": "replace", "path": "$.classification", "replacement": "REDACTED" } ] } ``` The `blacken` action supports these options: | Option | Type | Default | Description | | --------------- | ------ | ----------------------------- | ------------------------------------------- | | `path` | string | (required) | Dot-notation path to a string field | | `replacement` | string | `"\u2588"` (block character) | Character used for masking | | `discloseLeft` | number | `0` | Characters to leave unmasked from the left | | `discloseRight` | number | `0` | Characters to leave unmasked from the right | | `length` | number | (masked section length) | Override the length of the masked section | #### ContentFilterPredicateProvider **Constraint type:** `jsonContentFilterPredicate` Registered automatically by `configure_sapl()`. Filters array elements or nullifies single values that do not meet conditions. ```json { "type": "jsonContentFilterPredicate", "conditions": [ { "path": "$.classification", "type": "!=", "value": "top-secret" } ] } ``` #### ContentFilter Limitations The built-in content filter supports **simple dot-notation paths only** (`$.field.nested`). Recursive descent (`$..ssn`), bracket notation (`$['field']`), array indexing (`$.items[0]`), wildcards (`$.users[*].email`), and filter expressions (`$.books[?(@.price<10)]`) are not supported. ### Query Rewriting Tornado applications can filter results at the database through SAPL's SQLAlchemy integration, the `sapl-sqlalchemy` package: a policy attaches a `sql:queryRewriting` obligation and the integration rewrites the query before it reaches the database, so unauthorised rows never leave it. Install it separately and register it once at startup. ```bash pip install sapl-sqlalchemy ``` ```python from sapl_sqlalchemy import SqlQueryRewritingProvider, register_orm_listener from sapl_tornado import register_provider register_orm_listener() register_provider(SqlQueryRewritingProvider()) ``` See [Query Rewriting](../6_12_QueryRewriting/) for the obligation format, the shared semantics, and what the integration does and does not cover (including the off-session fail-open caveat). ### Streaming Authorization For SSE endpoints returning async iterators, `@stream_enforce` provides continuous authorization where the PDP streams decisions over time. Access may flip between permitted, suspended, and denied based on time, location, or context changes. Tornado streaming responses are written directly to the response via `handler.write()` and `handler.flush()`. The decorator sets the SSE headers (`Content-Type: text/event-stream`, `Cache-Control: no-cache`) and calls `handler.finish()` when the stream ends. Each yielded item is rendered as an SSE `data:` event (dicts are JSON-serialized). With `signal_transitions=True`, suspend and resume boundaries arrive as `ACCESS_SUSPENDED` and `ACCESS_GRANTED` frames. A terminating `DENY` arrives as a final `ACCESS_DENIED` frame. A time-based policy that cycles between `PERMIT` and `SUSPEND`, so the stream pauses and resumes without terminating: ``` policy "streaming-heartbeat-time-based" permit action == "stream:heartbeat"; resource == "heartbeat"; var second = time.secondOf(); second >= 0 && second < 20 || second >= 40; suspend action == "stream:heartbeat"; resource == "heartbeat"; ``` Connect with curl to observe streaming behavior: ```bash curl -N http://localhost:3000/stream/heartbeat ``` ### Manual PDP Access For cases where decorators are not suitable, access the PDP client directly: ```python import tornado.web from sapl_tornado import get_pdp_client from sapl_base.types import AuthorizationSubscription, Decision class HelloHandler(tornado.web.RequestHandler): async def get(self): pdp_client = get_pdp_client() subscription = AuthorizationSubscription( subject="anonymous", action="read", resource="hello", ) decision = await pdp_client.decide_once(subscription) if decision.decision == Decision.PERMIT and not decision.obligations: self.write({"message": "hello"}) else: raise tornado.web.HTTPError(403) ``` When using the PDP client directly, you are responsible for checking the decision, enforcing obligations, and handling resource replacement. ### Service Layer Enforcement The same `@pre_enforce` and `@post_enforce` decorators work at any layer, not just on Tornado `RequestHandler` methods. When used on a service method without a `RequestHandler`, the decorator automatically translates denial into Tornado's `HTTPError(403)`: ```python from sapl_tornado import pre_enforce, post_enforce @pre_enforce(action="listPatients", resource="patients") async def list_patients() -> list[dict]: return [dict(p) for p in PATIENTS] @post_enforce( action="getPatientDetail", resource=lambda ctx: {"type": "patientDetail", "data": ctx.return_value}, ) async def get_patient_detail(patient_id: str) -> dict | None: return next((dict(p) for p in PATIENTS if p["id"] == patient_id), None) ``` The calling handler does not need any special error handling. Tornado's default `write_error` handles the `HTTPError(403)` and returns an appropriate error response: ```python import json import tornado.web from services import patient_service class PatientDetailHandler(tornado.web.RequestHandler): async def get(self, patient_id): result = await patient_service.get_patient_detail(patient_id) self.set_header("Content-Type", "application/json; charset=UTF-8") self.write(json.dumps(result)) ``` Service-layer decorators accept the same subscription field options (`subject`, `action`, `resource`, `environment`, `secrets`) as when used on handler methods. When no `RequestHandler` is available, subject defaults to `"anonymous"` and environment is empty. ### Database Transactions `@pre_enforce` and `@post_enforce` can own a transaction boundary, so a denial that lands after the handler has written to the database rolls the write back. Three triggers cause a rollback: a `@post_enforce` DENY, a `@post_enforce` output-obligation failure, and a `@pre_enforce` output-obligation failure (the pre-decision permits, but its output obligations run after the handler writes). A clean PERMIT commits. This is opt-in. With no provider configured the PEP owns no transaction and enforcement behaves exactly as before. A provider is a zero-arg factory returning a context manager that commits on clean exit and rolls back on a propagated exception. It must match the handler kind it protects. Tornado handler methods are async, so they run on the async core, which uses the provider as an async context manager. Pass an async SQLAlchemy `AsyncSession.begin()` directly: ```python from sapl_tornado.dependencies import set_transaction_provider set_transaction_provider(lambda: get_current_session().begin()) ``` The factory should resolve the current request's session (for example a request-scoped `AsyncSession` held in a contextvar). ### Client Resilience The PDP client treats every transport problem as an operational condition, never as a policy outcome, and never lets one surface as an exception. A connection drop, timeout, or decode error fails closed to `INDETERMINATE`, which the PEP enforces as a denial, so a transient PDP outage can never accidentally grant access. One-shot requests (`decide_once`) fail closed to `INDETERMINATE` immediately, with no retry, and never throw. In steady state the connection is warm, so only a cold or dropped connection fails closed. Subscriptions (streaming `decide`) never terminate on a transport problem or on a server-side stream completion. Either condition emits one `INDETERMINATE` and then reconnects with bounded exponential backoff, indefinitely. Consecutive identical decisions are de-duplicated, so an outage yields a single `INDETERMINATE`, not a flood. A subscription ends only when the consumer cancels it or the client shuts down. This contract holds identically across the HTTP and RSocket transports and across every SAPL PEP client. ### Demo Application A complete working demo is available at [sapl-python-demos/tornado_demo](https://github.com/heutelbeck/sapl-python-demos/tree/main/tornado_demo). It includes: - Manual PDP access (no decorators) - `@pre_enforce` and `@post_enforce` with content filtering - Service-layer enforcement using the same decorators on plain async functions - Custom constraint handler providers returning runner, consumer, and mapper handlers - SSE streaming with `@stream_enforce`, covering terminate-on-deny, drop-while-suspended, and signalled suspend/resume - JWT-based ABAC with secrets ### Configuration Reference All options are set via the `SaplConfig` dataclass passed to `configure_sapl()`: | Parameter | Type | Default | Description | | ------------------------------------ | ------- | --------------------------- | -------------------------------------------------------- | | `base_url` | `str` | `"https://localhost:8443"` | PDP server URL. Plain `http://` is accepted only for loopback hosts | | `token` | `str` | `None` | Bearer token / API key for authentication | | `username` | `str` | `None` | Basic auth username (mutually exclusive with `token`) | | `secret` | `str` | `None` | Basic auth secret | | `timeout_seconds` | `float` | `5.0` | PDP request timeout in seconds | | `streaming_retry_base_delay_seconds` | `float` | `1.0` | Base delay in seconds for exponential backoff on retry | | `streaming_retry_max_delay_seconds` | `float` | `30.0` | Maximum delay in seconds for exponential backoff | ### Troubleshooting | Symptom | Likely Cause | Fix | | ------------------------------------ | --------------------------------------- | ---------------------------------------------------------------- | | All decisions are INDETERMINATE | PDP unreachable | Check `base_url` and that PDP is running | | 403 despite PERMIT decision | Unhandled obligation | Check the provider's `get_handlers()` claims the obligation `type` | | Handler not firing | Missing registration | Call `register_provider()` after `configure_sapl()` | | Subject is `"anonymous"` | No `get_current_user()` override | Override `get_current_user()` on your handler or set subject explicitly | | Content filter throws | Unsupported path syntax | Only simple dot paths supported (`$.field.nested`) | | `RuntimeError: SAPL not configured` | Missing `configure_sapl()` | Call `configure_sapl()` before starting the IOLoop | | Streaming response not SSE | Missing headers | Use `@stream_enforce`; it sets the headers automatically | | Stream not finishing | Handler already finished | `@stream_enforce` calls `handler.finish()`. Do not call it manually. | ### License Apache-2.0 ## FastMCP SDK Policy-based authorization for [FastMCP](https://gofastmcp.com/) servers using SAPL (Streaming Attribute Policy Language). The `sapl-fastmcp` library provides two authorization paths. A global `SAPLMiddleware` intercepts all MCP operations with full constraint handler support, and a per-component `auth=sapl()` check covers simpler binary permit and deny decisions. Both paths query the SAPL PDP for every tool call, resource read, and prompt access. ### What is SAPL? SAPL is a policy language and Policy Decision Point (PDP) for attribute-based access control. Policies are written in a dedicated language and evaluated by the PDP, which streams authorization decisions based on subject, action, resource, and environment attributes. Three core concepts shape the integration. 1. Authorization subscription. Your app sends `{ subject, action, resource, environment }` to the PDP. 2. PDP decision. The PDP evaluates policies and returns `PERMIT` or `DENY`, optionally with obligations, advice, or a replacement resource. 3. Constraint handlers. Registered handlers execute the policy's instructions such as log, filter, transform, or cap values. A PDP decision looks like this. ```json { "decision": "PERMIT", "obligations": [{ "type": "logAccess", "message": "Patient record accessed" }], "advice": [{ "type": "notifyAdmin" }] } ``` The `decision` field is always present (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, or `NOT_APPLICABLE`). The other fields are optional. The `obligations` and `advice` arrays carry JSON objects, by convention with a `type` field for handler dispatch. When `resource` is present, it replaces the component's return value entirely. For a deeper introduction to SAPL's subscription model and policy language, see the [SAPL documentation](https://sapl.io/docs/latest/). ### What is MCP? The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) is a standardized interface for AI agents and LLMs to access external tools, resources, and prompts. [FastMCP](https://gofastmcp.com/) is a Python framework for building MCP servers. Authorization matters because MCP servers expose capabilities to AI agents that may act on behalf of different users with different privilege levels. A single MCP server might serve tools for querying public data alongside tools that access PII or perform destructive operations. Without authorization, every agent has full access to every tool regardless of who it represents. ### Installation ```bash pip install sapl-fastmcp ``` This also installs `sapl-base`, which provides the PDP client, the enforcement planner, and the built-in content filters. The library requires Python 3.12+ and FastMCP 3.1.0+. A complete working demo with JWT authentication, constraint handlers, stealth mode, and both authorization paths is available at [sapl-python-demos/fastmcp_demo](https://github.com/heutelbeck/sapl-python-demos/tree/main/fastmcp_demo). ### Choosing an Authorization Path The library offers two ways to enforce authorization. They can be used independently or together on the same server. | Aspect | `SAPLMiddleware` | `auth=sapl()` | |--------|-----------------|---------------| | Enforcement point | Single middleware intercepts all operations | Each component has its own auth check | | Constraint handlers | Full lifecycle, including input transformation and output mapping | Decision-scoped handlers only | | Stealth mode | Supported, hides from listings and masks denial as not-found | Not supported, warning logged | | Finalize callbacks | Supported | Not supported | | Listing filter | Multi-decide hides unauthorized stealth components | FastMCP's built-in per-component visibility | | Decorators | `@pre_enforce` and `@post_enforce` customize each component | Fields set in the `sapl()` call | | Pre-enforce and post-enforce | Both supported | Pre-enforce only | | Setup complexity | Slightly more, pass the PDP client and planner explicitly | Simpler, `configure_sapl()` plus `sapl()` | Use the middleware when you need constraint handlers that modify arguments or transform results, stealth mode, finalize callbacks, or post-enforce. Use `auth=sapl()` for simpler setups where a binary permit or deny per component is sufficient. ### Setup: Per-Component Auth (`auth=sapl()`) Call `configure_sapl()` once before the server starts. This initializes the PDP client and the enforcement planner. ```python from sapl_base.transport import HttpPdpClientOptions from sapl_fastmcp import configure_sapl, register_provider, sapl configure_sapl(HttpPdpClientOptions(base_url="https://localhost:8443")) ``` To register a constraint handler provider, call `register_provider()`. A provider claims the constraints it understands and returns the scoped handlers that enforce them. ```python register_provider(AccessLoggingProvider()) ``` Then protect individual components with `auth=sapl()`. ```python from fastmcp import FastMCP mcp = FastMCP("server", auth=jwt_verifier) # Defaults: subject=token claims, action="hello", resource="mcp" @mcp.tool(auth=sapl()) def hello(name: str) -> str: return f"Hello, {name}!" # Static override: action="read_status" instead of "get_time" @mcp.tool(auth=sapl(action="read_status")) def get_time() -> str: from datetime import datetime, timezone return datetime.now(timezone.utc).isoformat() # Callable override: extract username from token claims @mcp.tool(auth=sapl( subject=lambda ctx: ctx.token.claims.get("preferred_username") if ctx.token else "anonymous", action="write_config", resource="server_config", secrets=lambda ctx: {"raw_token": ctx.token.token if ctx.token else None}, )) def write_config(key: str, value: str) -> dict: return {"key": key, "value": value, "status": "updated"} ``` #### Subscription Field Defaults (Auth Path) | Field | Default | |-------|---------| | `subject` | Token claims dict, or `client_id`, or `"anonymous"` | | `action` | Component name, for example `"hello"` or `"server_status"` | | `resource` | `"mcp"` | | `environment` | Not sent | | `secrets` | Not sent | Each field accepts a static value, a `Callable[[AuthContext], Any]`, or `None` to use the default. Falsy values like `0`, `""`, or `False` are valid overrides and will not trigger the default. ### Setup: Middleware (`SAPLMiddleware`) Configure the runtime once, then build the middleware from the configured PDP client and planner. ```python from fastmcp import FastMCP from sapl_base.transport import HttpPdpClientOptions from sapl_fastmcp import ( SAPLMiddleware, configure_sapl, get_pdp_client, get_planner, register_provider, ) configure_sapl(HttpPdpClientOptions(base_url="https://localhost:8443")) register_provider(AccessLoggingProvider()) register_provider(LimitResultsProvider()) register_provider(FilterByClassificationProvider()) mcp = FastMCP(name="analytics", auth=jwt_verifier) mcp.add_middleware(SAPLMiddleware(get_pdp_client(), get_planner())) ``` The `SAPLMiddleware` constructor takes the PDP client first and the enforcement planner second. The planner is optional. When omitted, the middleware builds a fresh `EnforcementPlanner` with no registered providers. ```python SAPLMiddleware(pdp, planner=None, enforce_listing=True) ``` The PDP client and planner are injected at construction time, so the middleware does not depend on module-level globals. You can configure different PDP connections for different servers by building separate `HttpPdpClient` and `EnforcementPlanner` instances and passing them directly. Components without a `@pre_enforce` or `@post_enforce` decorator pass through with no PDP call, allowing gradual adoption. ### Enforcement Decorators (`@pre_enforce` / `@post_enforce`) These decorators are only used with the middleware path. They attach metadata to the function as `fn.__sapl__`, and the middleware reads this metadata at request time. The decorators do not wrap the function, so its identity is preserved for FastMCP's introspection. #### @pre_enforce The PDP is queried before the tool executes. The tool only runs on `PERMIT`. ```python from sapl_fastmcp import pre_enforce @mcp.tool(tags={"public"}) @pre_enforce() def query_public_data(dataset: str, date_range: str = "last_30d") -> dict: return {"dataset": dataset, "rows": 14823} ``` With overrides. ```python @mcp.tool(tags={"pii"}) @pre_enforce( resource=lambda ctx: { "name": ctx.component.name, "tags": list(ctx.component.tags), "segment": ctx.arguments.get("segment"), }, stealth=True, ) def query_customer_data(segment: str, limit: int = 10) -> dict: return {"segment": segment, "total_matches": 2847, "limit": limit} ``` #### @post_enforce The tool executes first, then the PDP is queried with the return value available in the subscription context. If the decision is not `PERMIT`, the result is suppressed. ```python from sapl_fastmcp import post_enforce @mcp.tool(tags={"engineering"}) @post_enforce(resource=lambda ctx: { "name": ctx.component.name, "tags": list(ctx.component.tags), "model": ctx.arguments.get("model_id"), "result_summary": ctx.return_value, }) def run_model(model_id: str, dataset: str) -> dict: return {"model_id": model_id, "status": "completed", "accuracy": 0.924} ``` Use `@post_enforce` when the policy needs to see the actual return value to decide. For example, a policy might permit access only if the result's classification level is below a threshold. You cannot apply both `@pre_enforce` and `@post_enforce` to the same function. Attempting to do so raises `TypeError`. #### Subscription Field Defaults (Middleware Path) | Field | Default | |-------|---------| | `subject` | Token claims dict, or `client_id`, or `"anonymous"` | | `action` | Operation verb, one of `"call"`, `"read"`, or `"get"` | | `resource` | Dict with `name`, `arguments`, `tags`, and optionally `uri` | | `environment` | Not sent | | `secrets` | Not sent | Each field accepts a static value, a `Callable[[SubscriptionContext], Any]`, or `None` to use the default. ### SubscriptionContext Reference The `SubscriptionContext` is available to callable field overrides in the middleware path. | Field | Type | Description | |-------|------|-------------| | `token` | `AccessToken` or `None` | OAuth token from the request | | `component` | `Any` | The FastMCP Tool, Resource, ResourceTemplate, or Prompt object | | `operation` | `"call"`, `"read"`, `"get"`, `"list"`, or `None` | MCP operation verb | | `arguments` | `dict[str, Any]` | Tool or prompt arguments, empty for resources | | `uri` | `str` or `None` | Resource URI, only for read operations | | `return_value` | `Any` | Tool return value, set for `@post_enforce` only and `None` otherwise | For the `auth=sapl()` path, callable fields receive an `AuthContext` from FastMCP instead of a `SubscriptionContext`. The `AuthContext` provides `token`, the `AccessToken`, and `component`, the FastMCP component. ### Stealth Mode When `stealth=True` is set on `@pre_enforce` or `@post_enforce`, two things happen. 1. The component is hidden from listings when the subject is not authorized. The listing filter uses multi-decide to batch-query the PDP for all stealth components at once. 2. Denial raises `NotFoundError` instead of `AccessDeniedError`, making hidden components indistinguishable from non-existent ones. ```python @mcp.tool(tags={"pii", "export"}) @pre_enforce(action="export_data", stealth=True) def export_csv(query_ref: str, columns: str = "all") -> dict: return {"query_ref": query_ref, "rows_exported": 2847} ``` An unauthorized user calling this tool receives the same `NotFoundError` they would get for a tool that does not exist. The tool also does not appear in `tools/list` responses for that user. Stealth only works with `SAPLMiddleware`. Using `stealth=True` with `auth=sapl()` logs a warning and has no effect. ### Finalize Callbacks The `finalize` parameter on decorators provides an async callback that runs after enforcement regardless of outcome. It receives the `AuthorizationDecision` and the `SubscriptionContext`. ```python async def _purge_finalize(decision, ctx: SubscriptionContext) -> None: """In production this would commit or roll back a database transaction.""" logger.info( "purge_finalize: decision=%s, dataset=%s", decision.decision.value, ctx.arguments.get("dataset_id"), ) @mcp.tool(tags={"destructive", "compliance"}) @pre_enforce(finalize=_purge_finalize, stealth=True) def purge_dataset(dataset_id: str, reason: str) -> dict: return {"dataset_id": dataset_id, "status": "purged", "records_deleted": 15234} ``` The callback signature is `async def finalize(decision: AuthorizationDecision, ctx: SubscriptionContext) -> None`. The finalize callback always runs, even when the tool throws an exception. Exceptions in the finalize callback itself are logged and swallowed, and they never affect the enforcement outcome. Use finalize for transaction commit or rollback, resource cleanup, or audit logging. Finalize only works with `SAPLMiddleware`. It has no effect with `auth=sapl()`. ### How Enforcement Works #### The Deny Invariant Only `PERMIT` grants access. The PDP can return five possible decisions (`PERMIT`, `DENY`, `SUSPEND`, `INDETERMINATE`, `NOT_APPLICABLE`), and only `PERMIT` ever results in your tool running. Everything else means denial. FastMCP operations are one-shot, so the PEP treats `SUSPEND` as `DENY`. See [Authorization Decisions](../2_3_AuthorizationDecisions/) for the per-decision PEP semantics. A `PERMIT` with obligations is not a free pass. The enforcement point checks that every obligation in the decision has a registered handler. If even one obligation cannot be fulfilled, the decision is treated as a denial. If a handler accepts responsibility but fails during execution, that also results in denial. Advice is softer. If an advice handler fails, the failure is logged and the request proceeds. | Aspect | Obligation | Advice | |--------|-----------|--------| | All handled? | Required. Unhandled obligations deny access. | Optional. Unhandled advice is silently ignored. | | Handler failure | Denies access. | Logs a warning and continues. | #### Enforcement Signals (Middleware Path) The middleware delegates the pre and post enforcement logic to `sapl_base.pep`. Constraint handlers attach to one of four signals. | Signal | When | What handlers do | |--------|------|------------------| | `DECISION` | Decision arrives | Side effects such as logging or audit | | `INPUT` | Before the tool executes, `@pre_enforce` only | Modify tool arguments | | `OUTPUT` | After the tool returns | Transform, filter, or replace the result | | `ERROR` | The tool throws | Transform or observe the error | #### Pre-Enforce Lifecycle The middleware builds an authorization subscription from the decorator options or defaults and sends it to the PDP. If the decision is not `PERMIT`, `AccessDeniedError` is raised, or `NotFoundError` if stealth is set. If the decision is `PERMIT`, the planner resolves all constraint handlers. `DECISION` handlers run first, then `INPUT` handlers, which can modify tool arguments, then the tool executes, then `OUTPUT` handlers apply. #### Post-Enforce Lifecycle The tool executes first. Then the middleware builds the authorization subscription including the return value and queries the PDP. If the decision is not `PERMIT`, the return value is suppressed. If it is `PERMIT`, `OUTPUT` handlers can transform the result. `INPUT` handlers do not run because the tool has already executed. #### Auth Path Lifecycle The `auth=sapl()` path uses gate-level enforcement only. It builds the subscription, queries the PDP, runs `DECISION` handlers with obligations strict and advice best-effort, and returns a boolean. Resource replacement in the auth path is not supported and causes denial. There is no argument modification, result transformation, or error mapping. ### Constraint Handlers When the PDP returns a decision with `obligations` or `advice`, the enforcement planner resolves and runs the matching handlers. #### The Provider Model A constraint handler provider implements a single method. ```python from collections.abc import Sequence from typing import Any from sapl_base.pep import ScopedHandler class AccessLoggingProvider: def get_handlers(self, constraint: Any) -> Sequence[ScopedHandler]: ... ``` `get_handlers(constraint)` inspects one constraint. If the provider claims it, the method returns the scoped handlers that enforce it. If the provider does not claim the constraint, it returns an empty sequence. The planner enforces exactly one claim per constraint. If no provider claims an obligation, or if more than one does, the planner installs a synthetic failure runner and the decision becomes a denial. Each `ScopedHandler` declares the signal it attaches to, a priority that orders handlers within a signal, a shape, and the handler callable. | Shape | Signature | Admissible at | |-------|-----------|---------------| | `runner` | `() -> None` | `DECISION` and other signals | | `consumer` | `(value) -> None` | `OUTPUT` and `ERROR`, data-carrying signals | | `mapper` | `(value) -> value` | `INPUT`, `OUTPUT`, and `ERROR`, data-carrying signals | #### Registering Providers Both paths register providers the same way. The `register_provider()` function adds a provider to the configured runtime and rebuilds the planner. ```python from sapl_fastmcp import register_provider register_provider(AccessLoggingProvider()) register_provider(LimitResultsProvider()) register_provider(FilterByClassificationProvider()) ``` For an explicitly constructed planner passed to the middleware, supply the providers at construction time. ```python from sapl_base.pep import EnforcementPlanner planner = EnforcementPlanner(providers=( AccessLoggingProvider(), LimitResultsProvider(), FilterByClassificationProvider(), )) ``` #### Example: Decision Handler (Logging) A `DECISION`-signal runner runs once per decision arrival. It produces a side effect and returns nothing. ```python from collections.abc import Sequence from typing import Any from sapl_base.pep import DECISION, ScopedHandler class AccessLoggingProvider: def get_handlers(self, constraint: Any) -> Sequence[ScopedHandler]: if not isinstance(constraint, dict) or constraint.get("type") != "logAccess": return () message = constraint.get("message", "Tool access") subject = constraint.get("subject", "unknown") action = constraint.get("action", "unknown") def handler() -> None: logger.info("ACCESS LOG: %s, subject=%s, action=%s", message, subject, action) return (ScopedHandler(signal=DECISION, priority=0, shape="runner", handler=handler),) ``` #### Example: Input Handler (Argument Capping) An `INPUT`-signal mapper runs before the tool, receives the call arguments as `(args, kwargs)`, and returns the modified arguments. It runs only on the `@pre_enforce` path. ```python from collections.abc import Sequence from typing import Any from sapl_base.pep import INPUT, ScopedHandler class LimitResultsProvider: def get_handlers(self, constraint: Any) -> Sequence[ScopedHandler]: if not isinstance(constraint, dict) or constraint.get("type") != "limitResults": return () max_limit = int(constraint.get("maxLimit", 10)) def handler(value: Any) -> Any: args, kwargs = value current = kwargs.get("limit") if current is not None: try: if int(current) > max_limit: kwargs = {**kwargs, "limit": max_limit} except (TypeError, ValueError): kwargs = {**kwargs, "limit": max_limit} return (args, kwargs) return (ScopedHandler(signal=INPUT, priority=0, shape="mapper", handler=handler),) ``` #### Example: Output Handler (Classification Filter) An `OUTPUT`-signal mapper runs after the tool returns, receives the return value, and returns a transformed value. This one filters list elements by their classification. ```python from collections.abc import Sequence from typing import Any from sapl_base.pep import OUTPUT, ScopedHandler class FilterByClassificationProvider: def get_handlers(self, constraint: Any) -> Sequence[ScopedHandler]: if not isinstance(constraint, dict) or constraint.get("type") != "filterByClassification": return () allowed = set(constraint.get("allowedLevels", [])) def handler(value: Any) -> Any: if not isinstance(value, list): return value return [ element for element in value if not isinstance(element, dict) or element.get("classification") in allowed ] return (ScopedHandler(signal=OUTPUT, priority=20, shape="mapper", handler=handler),) ``` ### Built-in Constraint Handlers #### ContentFilteringProvider Constraint type: `filterJsonContent` Transforms response values by deleting, replacing, or blackening fields. A policy can attach this obligation. ``` obligation { "type": "filterJsonContent", "actions": [ { "type": "blacken", "path": "$.ssn", "discloseRight": 4 }, { "type": "delete", "path": "$.internalNotes" }, { "type": "replace", "path": "$.classification", "replacement": "REDACTED" } ] } ``` The `blacken` action supports these options. | Option | Type | Default | Description | |--------|------|---------|-------------| | `path` | string | required | Dot-notation path to a string field | | `replacement` | string | block character | Character used for masking | | `discloseLeft` | number | `0` | Characters to leave unmasked from the left | | `discloseRight` | number | `0` | Characters to leave unmasked from the right | | `length` | number | masked section length | Override the length of the masked section | #### ContentFilterPredicateProvider Constraint type: `jsonContentFilterPredicate` Filters array elements or nullifies single values that do not meet conditions. ```json { "type": "jsonContentFilterPredicate", "conditions": [ { "path": "$.classification", "type": "!=", "value": "top-secret" } ] } ``` #### ContentFilter Limitations The built-in content filter supports simple dot-notation paths only (`$.field.nested`). Recursive descent (`$..ssn`), bracket notation (`$['field']`), array indexing (`$.items[0]`), wildcards (`$.users[*].email`), and filter expressions (`$.books[?(@.price<10)]`) are not supported. #### Registration Both built-in providers are registered automatically when `configure_sapl()` initializes the runtime. They are always present in the planner alongside any providers you register. When you build an `EnforcementPlanner` explicitly to pass to the middleware, add `ContentFilteringProvider` and `ContentFilterPredicateProvider` yourself if you want them. ### STDIO Transport SAPL authorization is bypassed for the STDIO transport. STDIO is a local subprocess transport with no network boundary and no authentication context, meaning no tokens and no headers. This matches FastMCP's built-in `AuthorizationMiddleware` behavior. All middleware hooks pass through without PDP calls when the transport is STDIO. From an authorization perspective, constraining agent actions over STDIO requires a different trust and identity model that is outside the scope of the current integration. ### Manual PDP Access For cases where neither middleware nor `auth=sapl()` fits, access the PDP client directly. ```python from sapl_base import AuthorizationSubscription, Decision from sapl_fastmcp import get_pdp_client pdp = get_pdp_client() subscription = AuthorizationSubscription( subject="anonymous", action="read", resource="hello", ) decision = await pdp.decide_once(subscription) if decision.decision == Decision.PERMIT and not decision.obligations: # proceed ... ``` When using the PDP client directly, you are responsible for checking the decision, enforcing obligations, and handling resource replacement. ### Writing SAPL Policies for MCP SAPL policies evaluate against the subscription fields your MCP server sends. This section uses the demo's `analytics.sapl` policy file as a running example. #### Subscription Shape With the middleware path using default subscription fields. - `subject` is the JWT claims dict, including `preferred_username`, `realm_access.roles`, and similar fields. - `action` is the operation verb, one of `"call"`, `"read"`, or `"get"`. - `resource` is a dict with `name`, `arguments`, `tags`, and optionally `uri`. #### Tag-Based Policies Components tagged in FastMCP have their tags included in the resource. A policy granting access to all public components. ``` policy "public-access" permit "public" in resource.tags; ``` #### Role-Based Policies With JWT claims as the subject, check roles from the identity provider. ``` policy "engineering-access" permit "engineering" in resource.tags; "ENGINEER" in subject.realm_access.roles; ``` #### Obligation Examples Attach constraints that handlers enforce at runtime. ``` policy "analyst-customer-queries" permit resource.name == "query_customer_data"; "ANALYST" in subject.realm_access.roles; obligation { "type": "limitResults", "maxLimit": 5 } obligation { "type": "logAccess", "message": "Customer PII query (result limit enforced)", "subject": subject.preferred_username, "action": action } ``` The `limitResults` obligation is handled by an `INPUT`-signal mapper that caps the `limit` argument before the tool executes. The `logAccess` obligation is handled by a `DECISION`-signal runner that logs the access event. #### Output Filter Obligations Filter list results based on element properties. ``` policy "analyst-export-listing" permit resource.name == "list_data_exports"; "ANALYST" in subject.realm_access.roles; obligation { "type": "filterByClassification", "allowedLevels": ["public", "internal"] } ``` The `FilterByClassificationProvider` removes list elements whose `classification` field is not in the allowed set. #### Advice (Best-Effort) Use `advice` instead of `obligation` when failure should not block access. ``` policy "pii-access" permit "pii" in resource.tags; "ANALYST" in subject.realm_access.roles | "COMPLIANCE" in subject.realm_access.roles; advice { "type": "logAccess", "message": "PII data accessed", "subject": subject.preferred_username, "action": action } ``` #### Policy Set Ordering Use `first or abstain` to apply the first matching policy. More specific policies should come before general ones. ``` set "analytics" first or abstain policy "analyst-customer-queries" // specific: analyst + customer data + obligations permit ... policy "public-access" // general: all authenticated users + public tag permit ... policy "default-deny" // catch-all: log and deny deny ... ``` #### Default Deny A catch-all deny policy at the end ensures unauthorized access is logged. ``` policy "default-deny" deny obligation { "type": "logAccess", "message": "Unauthorized access attempt denied", "subject": subject.preferred_username, "action": action } ``` ### Client Resilience The PDP client treats every transport problem as an operational condition, never as a policy outcome, and never lets one surface as an exception. A connection drop, timeout, or decode error fails closed to `INDETERMINATE`, which the PEP enforces as a denial, so a transient PDP outage can never accidentally grant access. One-shot requests (`decide_once`) fail closed to `INDETERMINATE` immediately, with no retry, and never throw. In steady state the connection is warm, so only a cold or dropped connection fails closed. Subscriptions (streaming `decide`) never terminate on a transport problem or on a server-side stream completion. Either condition emits one `INDETERMINATE` and then reconnects with bounded exponential backoff, indefinitely. Consecutive identical decisions are de-duplicated, so an outage yields a single `INDETERMINATE`, not a flood. A subscription ends only when the consumer cancels it or the client shuts down. This contract holds identically across the HTTP and RSocket transports and across every SAPL PEP client. ### Demo Application A complete working demo is available at [sapl-python-demos/fastmcp_demo](https://github.com/heutelbeck/sapl-python-demos/tree/main/fastmcp_demo). It includes the following. - Middleware server (`middleware_server.py`) with `@pre_enforce`, `@post_enforce`, stealth mode, finalize callbacks, and several constraint handler providers - Per-component auth server (`auth_server.py`) with `auth=sapl()` on every tool, resource, and prompt - MCP client (`client.py`) that exercises both servers - Automated end-to-end test (`demo.py`) with a decision matrix across four users with different roles (ANALYST, ENGINEER, COMPLIANCE, INTERN) - SAPL policy file (`analytics.sapl`) with tag-based, role-based, and obligation-driven policies ### Configuration Reference `HttpPdpClientOptions` parameters passed to `configure_sapl()` or `HttpPdpClient()`. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `base_url` | `str` | required | PDP server URL | | `token` | `str` | `None` | Bearer token or API key | | `username` | `str` | `None` | Basic auth username | | `secret` | `str` | `None` | Basic auth secret | | `tls` | `TlsConfig` | `None` | TLS configuration for client certificates and trust | | `timeout_seconds` | `float` | transport default | PDP request timeout in seconds | The auth options are mutually exclusive. Pass exactly one of `token`, the `username` and `secret` pair, or `token_provider`. Pass none when targeting a SAPL Node configured with `allow-no-auth`. `SAPLMiddleware` constructor parameters. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `pdp` | `HttpPdpClient` | required | PDP client instance | | `planner` | `EnforcementPlanner` | new instance | Enforcement planner with registered providers | | `enforce_listing` | `bool` | `True` | Enable the multi-decide listing filter for stealth components | ### Troubleshooting | Symptom | Likely Cause | Fix | |---------|-------------|-----| | All decisions INDETERMINATE | PDP unreachable | Check `base_url`, verify the PDP is running | | AccessDeniedError despite PERMIT | Unhandled obligation | Confirm a provider claims the obligation `type` in `get_handlers` | | Handler not firing | Missing registration | Call `register_provider` before the server starts | | Subject is `"anonymous"` | No JWT configured or STDIO transport | Configure an auth provider on FastMCP | | Stealth warning in logs | `stealth=True` with `auth=sapl()` | Use `SAPLMiddleware` for stealth mode | | `RuntimeError: SAPL not configured` | Missing `configure_sapl()` | Call `configure_sapl()` before the server starts | | STDIO requests bypass auth | Expected behavior | SAPL skips STDIO, a local transport with no auth context | | Content filter throws | Unsupported path syntax | Only simple dot paths are supported (`$.field.nested`) | ### License Apache-2.0 ## SAPL Node This section covers deploying and operating the SAPL Node standalone PDP server. - **[Getting Started](../7_1_GettingStarted/):** Download the binary, set up a working node, deploy a policy, and query the PDP. Includes the CLI reference for bundle and credential management. - **[Configuration](../7_2_Configuration/):** Application properties, Spring profiles, and CLI overrides for SAPL Node runtime settings. - **[Policy Sources](../7_3_PolicySources/):** Configuring where the PDP loads policies from. - **[Remote Bundles](../7_4_RemoteBundles/):** Fetching policy bundles from a remote HTTP server. - **[Bundle Wire Protocol](../7_5_BundleWireProtocol/):** The HTTP protocol for distributing bundles between servers and PDP clients. - **[Security](../7_6_Security/):** Authentication modes, TLS, interface binding, and a hardened production configuration example. - **[Monitoring](../7_7_Monitoring/):** PDP health states, decision metrics, structured decision logging, and evaluation diagnostics. - **[Benchmarking](../7_8_Benchmarking/):** Embedded PDP benchmarks and remote server load testing with HTTP and RSocket. ## Getting Started SAPL Node is both a PDP server and a CLI tool in a single binary. Applications query it for authorization decisions via HTTP. You use the same binary on your workstation to create, sign, and inspect policy bundles and to generate client credentials. Whether you run the server directly or in Docker, you will need the binary locally for these operations. ### Getting the Binary Download the archive for your platform from the [releases page](https://github.com/heutelbeck/sapl-policy-engine/releases). On Linux or macOS, extract the archive: ```shell tar xzf sapl-*-linux-amd64.tar.gz ``` On Windows, extract the `.zip` file using Explorer or any archive tool. Each archive contains the `sapl` binary (ready to run, no runtime dependencies), the `LICENSE`, and a `README.md`. Place the binary somewhere on your `PATH` or in the directory where you plan to run the server. Verify the installation: ```shell ./sapl --version ``` #### Installing with DEB or RPM On Debian/Ubuntu and Red Hat/Fedora, platform packages are available from the same releases page. These install the binary to `/usr/bin/sapl` and set up the PATH automatically. See the [Getting Started guide](../1_2_GettingStarted/) for package installation commands. #### GitHub Actions For CI pipelines, use the [`setup-sapl`](https://github.com/heutelbeck/setup-sapl) action to install the CLI automatically: ```yaml steps: - uses: actions/checkout@v4 - uses: heutelbeck/setup-sapl@v1 - run: sapl test --dir ./policies ``` The action downloads the correct binary for the runner platform and adds it to the PATH. See the [Testing CI/CD Integration](../5_0_TestingSAPLPolicies/#cicd-integration) section for coverage gates and SonarQube integration examples. ### Quick Start This walkthrough sets up a working node from scratch, deploys a policy, and queries the PDP. No configuration files are needed. The built-in defaults keep setup minimal: no TLS and policies loaded from the current directory. The node ships fail-closed and requires choosing an authentication mode before it starts. This walkthrough passes `--no-auth` to accept unauthenticated requests for local exploration. Create a working directory and a policy file: ```shell mkdir demo ``` Create `demo/tick.sapl`. This policy uses the built-in time PIP to grant access only when the current second is divisible by 5: ``` policy "tick" permit time.secondOf() % 5 == 0; ``` The `` attribute is a stream. It emits the current UTC timestamp once per second. Every time a new value arrives, the PDP re-evaluates the policy and pushes an updated decision to all connected clients. Start the server: ```shell cd demo && ./sapl --no-auth ``` The node starts on `localhost:8080` with no TLS. The `--no-auth` flag accepts unauthenticated requests for local exploration. Without an authentication choice the node fails closed and refuses to start. No `pdp.json` is needed. When absent, the PDP uses the default combining algorithm (`PRIORITY_DENY` with `DENY` default and `PROPAGATE` error handling). In a separate terminal, request a one-shot decision:
sapl CLI ```shell sapl decide-once --remote -s '"anyone"' -a '"read"' -r '"clock"' ```
curl ```shell curl -s http://localhost:8080/api/pdp/decide-once -H 'Content-Type: application/json' -d '{"subject":"anyone","action":"read","resource":"clock"}' ```
The response is a single JSON object. Depending on the current second, the decision is either `PERMIT` or `DENY`. With the default `DENY` combining algorithm, the seconds where the policy does not apply resolve to `DENY` rather than `NOT_APPLICABLE`. Now try streaming. This is where SAPL shows its strength. The PDP holds the connection open and pushes a new decision every time the policy evaluation result changes:
sapl CLI ```shell sapl decide --remote -s '"anyone"' -a '"read"' -r '"clock"' ```
curl ```shell curl -N http://localhost:8080/api/pdp/decide -H 'Content-Type: application/json' -d '{"subject":"anyone","action":"read","resource":"clock"}' ```
Watch the output. Every few seconds, the decision flips between `PERMIT` and `DENY` as the current time crosses a multiple of five. The application does not need to poll. The PDP pushes changes as they happen. Press `Ctrl+C` to stop the stream. The PDP cleans up the subscription automatically. Try editing `tick.sapl` while the stream is running. Change `% 5` to `% 10` and save. The PDP detects the change, recompiles the policy, and pushes an updated decision on the same connection. No restart needed. While the server is running, you can also check its operational state. The health endpoint shows whether policies loaded successfully: ```shell curl -s http://localhost:8080/actuator/health | jq . ``` You should see `"status": "UP"` with a `pdps` detail block showing the state `LOADED`, the active combining algorithm, and the number of loaded documents. If a policy has a syntax error, the state changes to `ERROR` and the health status drops to `DOWN`. The info endpoint shows PDP configuration. This endpoint requires authentication in production. Here it is reachable because the node was started with `--no-auth`: ```shell curl -s http://localhost:8080/actuator/info | jq . ``` For Prometheus metrics, Kubernetes probes, and decision logging, see [Monitoring](../7_7_Monitoring/). ### Directory Layout The minimal working directory is simply the binary and your policy files: ``` demo/ sapl tick.sapl ``` For more complex setups, add a `pdp.json` to configure the combining algorithm and a `config/application.yml` to override defaults: ``` demo/ sapl pdp.json (optional) tick.sapl config/ application.yml (optional, overrides built-in defaults) ``` Spring Boot automatically loads `config/application.yml` on startup. The `config-path` and `policies-path` properties default to `.` (the working directory). For bundle based deployments, the working directory holds `.saplbundle` files instead of raw `.sapl` files. See [Policy Sources](../7_3_PolicySources/) for the different source types and [Configuration](../7_2_Configuration/) for the full property reference. ### Installing with DEB or RPM Download the package for your distribution from the [releases page](https://github.com/heutelbeck/sapl-policy-engine/releases). ```shell sudo dpkg -i sapl_4.1.2_amd64.deb ``` Or for RPM-based distributions: ```shell sudo rpm -i sapl-4.1.2.x86_64.rpm ``` #### What the Package Installs The package creates a `sapl` system user (no shell, no login) and installs the following files: ``` /usr/bin/sapl binary /usr/share/man/man1/sapl*.1 man pages /usr/share/bash-completion/completions/sapl tab completion /usr/lib/systemd/system/sapl.service systemd unit /etc/sapl/application.yml configuration /var/lib/sapl/ data directory (sapl:sapl, 0750) /var/lib/sapl/README quickstart guide /var/lib/sapl/example/ example policies and pdp.json ``` The configuration file at `/etc/sapl/application.yml` is preserved on package upgrades. The service unit explicitly loads this file with `--spring.config.location=file:/etc/sapl/application.yml`, which replaces the JAR-embedded defaults entirely. The service is configured in `BUNDLES` mode with signature verification enabled. The node will not start serving decisions until bundle security is configured. #### Deploying Your First Bundle Use the included example policies to create a signed bundle: ```shell sudo sapl bundle keygen -o /etc/sapl/signing sudo sapl bundle create -i /var/lib/sapl/example -o /var/lib/sapl/default.saplbundle -k /etc/sapl/signing.pem ``` Configure the public key in `/etc/sapl/application.yml`: ```yaml io.sapl.pdp.embedded: bundle-security: public-key-path: /etc/sapl/signing.pub ``` #### Managing the Service Start the service and enable it on boot: ```shell sudo systemctl enable --now sapl ``` Other common operations: ```shell sudo systemctl stop sapl sudo systemctl restart sapl systemctl status sapl ``` #### Inspecting Logs The service logs to the journal. View logs with `journalctl`: ```shell journalctl -u sapl -f journalctl -u sapl --since today journalctl -u sapl -p err ``` Enable evaluation diagnostics by setting `print-text-report: true` in `/etc/sapl/application.yml` and restarting the service. See [Monitoring](../7_7_Monitoring/) for all diagnostic options. #### Verifying ```shell curl -s http://localhost:8080/actuator/health | jq . ``` You should see `"status": "UP"`. The PDP watches `/var/lib/sapl/` for bundle changes and reloads automatically. Replace the example policies with your own by creating `.sapl` files in a directory and rebuilding the bundle. See `/var/lib/sapl/README` for the full workflow. #### Service Hardening The systemd unit runs with strict security restrictions: `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome=true`, and write access limited to `/var/lib/sapl/`. The service cannot write outside its data directory. Place signing keys and TLS keystores in `/etc/sapl/` (readable but not writable by the service). ### Running with Docker For container deployments, the server runs inside Docker while you use the local `sapl` binary for CLI operations like bundle creation and credential generation. The container image is `ghcr.io/heutelbeck/sapl-node`. Released versions use the version tag (e.g., `ghcr.io/heutelbeck/sapl-node:4.1.2`). The examples below use the current development tag `4.1.2`. This is the original JVM image. It is the best default for sustained throughput, low tail latency, and deployments that load extension jars at runtime. SAPL Node also publishes minimal native images. The `ghcr.io/heutelbeck/sapl-node:-min` tag is a multi architecture manifest for amd64 and arm64. Use `ghcr.io/heutelbeck/sapl-node:-min-amd64` to pin x86_64 deployments. Use `ghcr.io/heutelbeck/sapl-node:-min-arm64` to pin ARM64 deployments. The minimal images package the native binary on a distroless base. They are smaller, have less attack surface, and boot faster than the JVM image. The tradeoff is a lower performance ceiling under sustained load because there is no JIT, no ZGC, and no runtime loadable extension jars. All Docker images default to `BUNDLES` mode with signature verification enabled. The node will not start until bundle security is configured. To get started with signed bundles: ```shell mkdir policies echo '{"configurationId":"v1","algorithm":{"votingMode":"PRIORITY_DENY","defaultDecision":"DENY","errorHandling":"PROPAGATE"}}' > policies/pdp.json echo 'policy "allow-all" permit' > policies/allow-all.sapl sapl bundle keygen -o signing sapl bundle create -i ./policies -o ./bundles/default.saplbundle -k signing.pem ``` Run the container, mounting the bundles directory and the public key: ```shell docker run -p 8443:8443 -v ./bundles:/pdp/data:ro -v ./signing.pub:/pdp/signing.pub:ro -e SERVER_ADDRESS=0.0.0.0 -e IO_SAPL_PDP_EMBEDDED_BUNDLESECURITY_PUBLICKEYPATH=/pdp/signing.pub ghcr.io/heutelbeck/sapl-node:4.1.2 ``` For development or evaluation without signing, disable signature verification: ```shell docker run -p 8443:8443 -v ./bundles:/pdp/data:ro -e SERVER_ADDRESS=0.0.0.0 -e IO_SAPL_PDP_EMBEDDED_BUNDLESECURITY_ALLOWUNSIGNED=true ghcr.io/heutelbeck/sapl-node:4.1.2 ``` To use raw `.sapl` files instead of bundles (for learning or demos), override the policy source type: ```shell docker run -p 8443:8443 -v ./policies:/pdp/data:ro -e SERVER_ADDRESS=0.0.0.0 -e IO_SAPL_PDP_EMBEDDED_PDPCONFIGTYPE=DIRECTORY ghcr.io/heutelbeck/sapl-node:4.1.2 ``` The `SERVER_ADDRESS=0.0.0.0` override is required so Docker's port mapping can reach the server. The default `127.0.0.1` only accepts connections from within the container. Environment variables follow Spring Boot's naming convention: dots become underscores, camelCase becomes uppercase. For example, `io.sapl.node.allow-basic-auth` becomes `IO_SAPL_NODE_ALLOWBASICAUTH`. See [Configuration](../7_2_Configuration/) for all available properties. You can also mount a full `application.yml` instead of using individual environment variables: ```shell docker run -p 8443:8443 -v ./config:/pdp/config:ro -v ./bundles:/pdp/data:ro -e SERVER_ADDRESS=0.0.0.0 ghcr.io/heutelbeck/sapl-node:4.1.2 ``` ### CLI Reference The `sapl` binary doubles as a CLI tool. The CLI commands (`decide`, `decide-once`, `check`, `test`) do not use Spring Boot configuration. They resolve policies through command-line options or automatic discovery from `~/.sapl/`. #### Policy Discovery When no `--dir` or `--bundle` flag is given, CLI commands look for policies in `~/.sapl/`: - If `.sapl` files are found, the CLI uses `DIRECTORY` mode. - If `.saplbundle` files are found, the CLI uses `BUNDLES` mode and auto-discovers `~/.sapl/public-key.pem` for signature verification. - If both `.sapl` and `.saplbundle` files are present, the CLI exits with an error (ambiguous source). - Use `--no-verify` to skip bundle signature verification during development. ``` ~/.sapl/ pdp.json optional PDP configuration *.sapl policy files (DIRECTORY mode) *.saplbundle bundle files (BUNDLES mode) public-key.pem optional signing key for bundle verification ``` #### Remote Mode All evaluation commands accept `--remote` to connect to a running SAPL Node instead of evaluating locally. By default, the connection uses HTTP/JSON. Add `--rsocket` to use RSocket/protobuf transport instead, which provides higher throughput and lower latency for high-volume workloads. **HTTP (default):** | Flag | Environment Variable | Default | |------|---------------------|---------| | `--url` | `SAPL_URL` | `http://localhost:8080` | | `--token` | `SAPL_BEARER_TOKEN` | | | `--basic-auth` | `SAPL_BASIC_AUTH` | | **RSocket (`--rsocket`):** | Flag | Default | |------|---------| | `--host` | `localhost` | | `--port` | `7000` | Sending credentials over a plaintext connection (an `http://` URL, or RSocket without `--rsocket-tls`) is refused by default. Use `--insecure` to accept that risk during development; it also skips TLS certificate verification. Flags take precedence over environment variables. For evaluation command details (`decide`, `decide-once`, `check`), see [Getting Started](../1_2_GettingStarted/). For `test`, see [Testing SAPL Policies](../5_0_TestingSAPLPolicies/). #### Bundle and Credential Commands The following commands run locally without starting the server. Use them to manage bundles and generate credentials for your `application.yml`. | Command | Description | |---------|-------------| | `sapl bundle create` | Create a `.saplbundle` archive from a directory of `.sapl` files and a `pdp.json`. Optionally signs in the same step. | | `sapl bundle sign` | Sign a bundle with an Ed25519 private key. | | `sapl bundle verify` | Verify a signed bundle against a public key. | | `sapl bundle inspect` | Display bundle contents, signature status, and policy list. | | `sapl bundle keygen` | Generate an Ed25519 keypair for bundle signing. | | `sapl generate basic` | Generate Basic Auth credentials and a ready-to-paste YAML block. | | `sapl generate apikey` | Generate an API key and a ready-to-paste YAML block. | Run any command with `--help` for the full option reference. See also the [CLI Reference](../7_9_CommandLine/) for the complete man page documentation. For authentication and TLS setup, see [Security](../7_6_Security/). For health checks and metrics, see [Monitoring](../7_7_Monitoring/). ## SAPL Node Configuration SAPL Node is configured via Spring Boot's `application.yml`. This page is the reference for all runtime settings that control PDP behavior, authentication, and diagnostics. For policy level configuration such as the combining algorithm, variables, and secrets, see [PDP Configuration](../2_2_PDPConfiguration/). For bundle security and remote bundle properties, see [Remote Bundles](../7_4_RemoteBundles/). ### Configuration File Location SAPL Node ships with a self-documenting `application.yml` built into the JAR. The defaults are functional out of the box: no TLS, no authentication required, policies loaded from the current directory. To override defaults, place an `application.yml` in a `config/` directory next to the JAR. Spring Boot loads this file automatically on startup, and its values take precedence over the built-in defaults. To use a different location, pass the path as a startup argument: ```shell sapl --spring.config.location=file:/etc/sapl/application.yml ``` In containerized deployments, every property can be set via environment variables. Spring Boot converts property names to uppercase with underscores replacing dots and hyphens. For example, `io.sapl.pdp.embedded.pdp-config-type` becomes `IO_SAPL_PDP_EMBEDDED_PDP_CONFIG_TYPE`. Configuration values follow Spring Boot's standard precedence: command line arguments override environment variables, and environment variables override values in `application.yml`. ### PDP Properties All properties live under the prefix `io.sapl.pdp.embedded`: | Property | Type | Default | Description | |----------|------|---------|-------------| | `enabled` | `boolean` | `true` | Enables the embedded PDP auto configuration. | | `pdp-config-type` | `PDPDataSource` | `RESOURCES` | Policy source type. One of `RESOURCES`, `DIRECTORY`, `MULTI_DIRECTORY`, `BUNDLES`, or `REMOTE_BUNDLES`. SAPL Node overrides this to `DIRECTORY` (binary) or `BUNDLES` (packages, Docker). See [Policy Sources](../7_3_PolicySources/). | | `config-path` | `String` | `.` | Path to `pdp.json`. For `RESOURCES`, this is relative to the classpath root. For filesystem sources, it is an absolute or relative filesystem path. The SAPL Node default is `.` (current directory). Package installations override this to `/var/lib/sapl`. | | `policies-path` | `String` | `.` | Path to `.sapl` files or bundles. Same path resolution rules as `config-path`. The SAPL Node default is `.` (current directory). | | `function-cache-size` | `int` | `10000` | Maximum number of entries in the function result cache. SAPL functions are pure and side-effect-free, so results are cached across evaluations using Window-TinyLFU eviction. Set to `0` to disable caching. | | `coarse-timestamps` | `boolean` | `false` | Uses a coarse-resolution cached clock for observability timestamps (decision trace and attribute value freshness) instead of the accurate system clock. Cheaper per decision at high throughput, at the cost of coarser timestamp precision. Temporal policy reasoning (time PIP, certificate validity, JWT expiry, scheduling) always uses the accurate clock. | | `metrics-enabled` | `boolean` | `false` (code) / `true` (shipped) | Records PDP decision metrics for Prometheus via Micrometer. The engine default is `false`; the node's bundled `application.yml` enables it. See [Monitoring](../7_7_Monitoring/). | | `print-trace` | `boolean` | `false` | Logs the full JSON evaluation trace on each decision. | | `print-json-report` | `boolean` | `false` | Logs the JSON evaluation report on each decision. | | `print-text-report` | `boolean` | `false` | Logs a human readable text evaluation report on each decision. | | `pretty-print-reports` | `boolean` | `false` | Pretty prints JSON in logged traces and reports. | | `print-subscription-events` | `boolean` | `false` | Logs new authorization subscription lifecycle events. | | `print-unsubscription-events` | `boolean` | `false` | Logs ended authorization subscription lifecycle events. | Bundle security sub properties (`bundle-security.*`) and remote bundle sub properties (`remote-bundles.*`) are documented in [Remote Bundles](../7_4_RemoteBundles/). ### Node Properties All properties live under the prefix `io.sapl.node`: | Property | Type | Default | Description | |----------|------|---------|-------------| | `allow-no-auth` | `boolean` | `false` | Permits unauthenticated requests. Disabled by default (fail-closed). Set to `true` for local exploration when no upstream authentication is in place. | | `allow-basic-auth` | `boolean` | `false` | Enables HTTP Basic authentication. | | `allow-api-key-auth` | `boolean` | `false` | Enables API key authentication via Bearer tokens. | | `allow-oauth2-auth` | `boolean` | `false` | Enables OAuth2/JWT authentication. | | `reject-on-missing-pdp-id` | `boolean` | `false` | Rejects users at startup if their `pdp-id` is not set. When `false`, missing values default to `default-pdp-id`. | | `default-pdp-id` | `String` | `"default"` | Fallback PDP identifier for users without an explicit `pdp-id`. | | `users[].id` | `String` | | Client identifier for logging and diagnostics. | | `users[].pdp-id` | `String` | | PDP identifier that routes this client to a specific tenant's policies. | | `users[].basic.username` | `String` | | Username for HTTP Basic authentication. | | `users[].basic.secret` | `String` | | Argon2 encoded password for HTTP Basic authentication. | | `users[].api-key-id` | `String` | | Public identifier of the API key (the middle segment of the `sapl__` wire format), generated by `sapl generate apikey` and printed alongside the encoded key. Required for every api-key user: the server refuses to start if an `api-key` is configured without its `api-key-id`. Used to route incoming API key requests to the matching user in O(1). | | `users[].api-key` | `String` | | Argon2 encoded API key. The client sends the plaintext key as a Bearer token in the `Authorization` header. | | `oauth.pdp-id-claim` | `String` | `"sapl_pdp_id"` | JWT claim name used to extract the PDP identifier for tenant routing. | | `scalar.oauth-client-id` | `String` | | OIDC client id pre-filled in the Scalar API reference Authorize dialog. Optional and independent of whether OAuth2 is enabled server-side. | | `scalar.oauth-redirect-uri` | `String` | `/scalar` | Redirect URI used by the Scalar API reference OAuth2 flow. | | `keep-alive` | `long` | `15` | Seconds between SSE keep-alive frames on idle streaming connections. Prevents proxies and firewalls from dropping inactive connections and lets the server detect clients that drop without closing. Always on and cannot be disabled; values below `1` are raised to the default. Keep it below the smallest proxy idle timeout on the path. See [Reverse Proxy Configuration](../7_6_Security/#reverse-proxy-configuration). | | `max-multi-subscription-count` | `int` | `256` | Maximum number of entries accepted in one `MultiAuthorizationSubscription` on public HTTP and RSocket PDP endpoints. Requests above the limit are rejected before PDP fan-out. Raise only for clients that intentionally batch more subscriptions in one request. | | `http.sse.keep-alive-pool-size` | `int` | `0` (auto) | Size of the scheduled thread pool that emits SSE keep-alive frames. `0` (or any non-positive value) auto-sizes to `max(2, availableProcessors / 2)`. | | `http.auth-cache.positive-ttl` | `Duration` | `5m` | Time successful authentication results stay cached on the bypass-Spring `/api/pdp/*` HTTP path before re-verification. Higher values trade staleness for fewer Argon2 verifications per second. | | `http.auth-cache.negative-ttl` | `Duration` | `5s` | Time failed authentication results stay cached. Short by design so a transient lookup miss recovers quickly while still throttling brute-force probes. | | `http.auth-cache.max-size` | `long` | `10000` | Maximum number of cached authentication outcomes. Caps memory exposure when a client cycles through many distinct `Authorization` headers. Caffeine evicts least-recently-used entries when the cap is reached. | See [Security](../7_6_Security/) for details on each authentication mode and credential generation. ### RSocket Properties All properties live under the prefix `sapl.pdp.rsocket`. The RSocket endpoint is enabled by default on port 7000. | Property | Type | Default | Description | |----------|------|---------|-------------| | `enabled` | `boolean` | `true` | Enables the protobuf RSocket PDP endpoint. | | `port` | `int` | `7000` | TCP port for RSocket connections. Ignored when `socket-path` is set. | | `socket-path` | `String` | | Unix domain socket path. When set, the server binds to this socket instead of TCP. Requires platform support (Linux epoll or macOS kqueue). | | `max-inbound-payload-size` | `int` | `16777215` | Maximum size in bytes of an inbound RSocket payload. The RSocket protocol fixes the per-frame ceiling at 16 MB; the configured value must be at least that, since any single frame must fit. Per-IP and per-account caps belong at an upstream load balancer or firewall. | | `ssl.bundle` | `String` | | Name of a Spring Boot SSL bundle (configured under `spring.ssl.bundle.*`) used to terminate TLS on the RSocket transport. When unset, the server speaks plain TCP. The same bundle definition can be shared with the HTTP server, so a single keystore covers both transports. | Connection lifetime is soft. JWT credentials are validated at every decision call. Expired tokens are then rejected, and the client is expected to reconnect with a refreshed credential. The server does not maintain a separate hard-disconnect timer. Connection counts are bounded by the OS file-descriptor limit (`ulimit -n` on Linux). Per-IP or per-account caps are not enforced inside the node and need to be applied at an upstream load balancer or firewall. Example: ```yaml sapl: pdp: rsocket: enabled: true port: 7000 ``` Unix domain socket (alternative to TCP): ```yaml sapl: pdp: rsocket: enabled: true socket-path: /var/run/sapl.sock ``` TLS via a shared SSL bundle (same bundle as the HTTP server): ```yaml spring: ssl: bundle: jks: sapl-bundle: key: alias: sapl-node password: changeit keystore: location: file:/etc/sapl/keystore.p12 password: changeit type: PKCS12 server: ssl: enabled: true bundle: sapl-bundle sapl: pdp: rsocket: enabled: true ssl: bundle: sapl-bundle ``` CLI clients connect with `--rsocket --rsocket-tls` (and `--insecure` to skip certificate verification against self-signed dev certificates). The RSocket endpoint shares the same authentication configuration as the HTTP endpoints (`io.sapl.node.users`, `allow-basic-auth`, `allow-api-key-auth`, `allow-oauth2-auth`). Authentication occurs once at connection setup. See [RSocket API](../6_1_HTTPApi/#rsocket-api) for the wire protocol specification. ### OpenID Authorization API Properties The OpenID Authorization API binding at `/access/v1/evaluation` is enabled by default and shares the authentication configuration with the rest of the HTTP transport. Use `io.sapl.node.openid-authz-api.enabled=false` to disable it. Before SAPL Node 4.1.0 prerelease builds used `io.sapl.server.openid-authz-api.enabled`. That legacy key is not read anymore. Update deployments to the `io.sapl.node` property before upgrading. One additional knob caps request body size: | Property | Type | Default | Description | |----------|------|---------|-------------| | `io.sapl.node.openid-authz-api.enabled` | `boolean` | `true` | Enables the OpenID Authorization API 1.0 binding. | | `io.sapl.node.http.max-request-body-bytes` | `long` | `65536` | Caps the request body size on the HTTP PDP endpoints, covering both `/api/pdp/*` and the OpenID Authorization API on `/access/v1/*`. Requests exceeding the limit are rejected with `413 Content Too Large`: a declared `Content-Length` over the limit is rejected before any body bytes are read, and a chunked body sent without a `Content-Length` is aborted with the same status once the limit is crossed while reading. Authorization subscriptions are small (typically below 1 KiB); raise only when policies routinely receive large `properties` maps. Mirrors the `sapl.pdp.rsocket.max-inbound-payload-size` guard on the RSocket transport. | ### CLI Argument Overrides Any property can be passed as a command line argument using Spring Boot's `--property=value` syntax: ```shell sapl --io.sapl.pdp.embedded.pdp-config-type=BUNDLES --io.sapl.pdp.embedded.policies-path=/opt/bundles ``` In Docker, set the equivalent environment variable: ```shell docker run -e IO_SAPL_NODE_ALLOWNOAUTH=true ghcr.io/heutelbeck/sapl-node:4.1.2 ``` ### Spring Profiles Spring profiles allow environment specific configuration overrides. Activate a profile at startup: ```shell sapl --spring.profiles.active=docker ``` Place a `application-docker.yml` in the config directory. Properties in the profile file override the defaults from `application.yml`. This is useful for toggling TLS, authentication modes, or log levels between development and production environments. ### Default Configuration SAPL Node ships with a fail-closed default: out of the box no authentication mode is enabled and the server refuses to start. Configure at least one credential mode (or `allow-no-auth: true`) before launching. Place `.sapl` files in the working directory and start the server with the chosen auth setup. The PDP monitors the directory and reloads on changes. The `pdp.json` file is optional. When absent, the PDP uses the default combining algorithm (`PRIORITY_DENY` with `DENY` default and `PROPAGATE` error handling). The effective defaults are: ```yaml io.sapl: pdp.embedded: pdp-config-type: DIRECTORY config-path: . policies-path: . metrics-enabled: true node: allow-no-auth: false allow-basic-auth: false allow-api-key-auth: false allow-oauth2-auth: false server: address: 127.0.0.1 port: 8080 ssl: enabled: false ``` The server binds to `127.0.0.1` (localhost only). This is safe for development. For container or network deployments, set `server.address: 0.0.0.0`. For local exploration without configuring credentials, override the auth default at startup: ```shell sapl --io.sapl.node.allow-no-auth=true ``` ### Package and Docker Defaults Linux packages (DEB/RPM) and the Docker image default to `BUNDLES` mode with signature verification enabled. This is the secure production default. The node will not start until bundle security is configured: either provide a public key for signature verification or explicitly set `bundle-security.allow-unsigned: true`. To deploy your first bundle: ```shell sapl bundle keygen -o signing sapl bundle create -i ./policies -o /var/lib/sapl/default.saplbundle -k signing.pem ``` Then configure the public key in `application.yml`: ```yaml io.sapl.pdp.embedded: bundle-security: public-key-path: /etc/sapl/signing.pub ``` The PDP detects the new bundle automatically and begins serving decisions. To opt out of signature verification during evaluation, set `bundle-security.allow-unsigned: true`. The node logs a warning on every startup when signature verification is disabled. ### Production Configuration This configuration uses signed bundles with API key authentication, TLS, and metrics. For the full hardened version with cipher suites and interface binding, see [Security](../7_6_Security/). ```yaml io.sapl: pdp.embedded: pdp-config-type: BUNDLES policies-path: /opt/sapl/bundles metrics-enabled: true bundle-security: public-key-path: /opt/sapl/keys/signing.pub node: allow-api-key-auth: true users: - id: "service-a" pdp-id: "default" api-key-id: "" api-key: "$argon2id$v=19$m=16384,t=2,p=1$..." server: port: 8443 ssl: enabled: true key-store: file:/opt/sapl/tls/keystore.p12 key-store-password: "${KEYSTORE_PASSWORD}" key-store-type: PKCS12 ``` Generate the API key hash with `sapl generate apikey --id service-a --pdp-id default`. The command prints the Argon2 encoded value for the configuration and the plaintext key for the client. See [Getting Started](../7_1_GettingStarted/) for the full CLI reference. ### Runtime and Latency Tuning SAPL Node ships as a native binary and as a JVM application. The native binary starts in milliseconds and has a small memory footprint. It suits most deployments and is the default for the Linux packages. It uses GraalVM's serial garbage collector, which is fine for typical workloads. For latency-sensitive or high-throughput deployments, run the JVM build. On the JVM you can use a low-pause garbage collector that keeps garbage collection off the request-latency tail. ZGC is the recommended choice. ```bash java -XX:+UseZGC -jar sapl-node.jar server ``` You can also set it through the environment, which the JVM reads even when started with `java -jar`. ```bash JAVA_TOOL_OPTIONS="-XX:+UseZGC" java -jar sapl-node.jar server ``` The Docker image runs the JVM build and enables ZGC by default, so containerized deployments get low-pause collection with no extra configuration. Heap size is derived automatically from the container memory limit. Garbage collection is the dominant source of tail latency in any managed runtime. The default throughput collector (G1) can pause for tens of milliseconds or more under load, and those pauses show up at the high percentiles such as p99 and p99.9. A concurrent collector such as ZGC keeps pauses below a millisecond at a small cost to peak throughput. For an authorization server on the request path, predictable tail latency is usually the better trade. ### Minimal Native Images (-min) Alongside the original JVM image, SAPL Node publishes minimal native images. The `ghcr.io/heutelbeck/sapl-node:-min` tag is a multi architecture manifest for amd64 and arm64. Use `ghcr.io/heutelbeck/sapl-node:-min-amd64` to pin x86_64 deployments. Use `ghcr.io/heutelbeck/sapl-node:-min-arm64` to pin ARM64 deployments. The minimal images package the static native binary on a `distroless/static` base. There is no JVM, no shell, no package manager, and no libc the binary depends on, which minimizes both image size and attack surface. The original image runs the JVM build on a buildpack base. It enables ZGC, supports runtime extension jars in `/pdp/data/lib`, and is about 436 MB. The minimal native image runs the native binary on `distroless/static`, uses the serial collector, does not support runtime extension jars, and is about 180 MB. Both images run as a nonroot user. The minimal native image is compatible with the JVM image. It uses the same mount point (`/pdp/data`), the same Spring profile (`docker`), the same ports (8080 and 7000), and the same environment variable configuration. Mount policies, configuration, and TLS material exactly as for the JVM image. The one difference is runtime loadable extensions. A native binary is compiled ahead of time with a closed world, so it cannot load third party PIP or function library jars at runtime. Deployments that drop extension jars into `/pdp/data/lib` must use the JVM image, or build a custom native image with the extensions compiled in. Because the native binary has no JIT and uses the serial collector, it has a lower performance ceiling under sustained load. Use the JVM image with ZGC when throughput and low tail latency matter most. Use the minimal native image when small footprint, fast startup, and minimal attack surface matter most. ## Policy Administration Point (PAP) The PAP manages the policies in the policy store. How policies are authored, reviewed, and deployed depends on the policy source type configured for the PDP. ### Policy Source Types The PDP supports five policy source types, configured via `io.sapl.pdp.embedded.pdp-config-type`: | Source Type | Description | Hot-Reload | Multi-Tenant | |-------------------|--------------------------------------------------------------------------------------------------------------------|------------|--------------| | `RESOURCES` | Loads policies from the Java classpath (`src/main/resources/policies`). Fixed at build time. | No | No | | `DIRECTORY` | Monitors a filesystem directory for `.sapl` files and `pdp.json`. Changes are detected and reloaded automatically. | Yes | No | | `MULTI_DIRECTORY` | Monitors subdirectories within a base directory. Each subdirectory name becomes a tenant ID. | Yes | Yes | | `BUNDLES` | Monitors a directory for `.saplbundle` files (signed ZIP archives). Each bundle filename becomes a tenant ID. | Yes | Yes | | `REMOTE_BUNDLES` | Fetches `.saplbundle` files from a remote HTTP server using ETag-based polling or long-polling. | Yes | Yes | For the `RESOURCES` source, the policy store is part of the application build. Policies are authored alongside the application code and deployed together. This is suitable for applications where policies change infrequently and are tested as part of the build process. For filesystem-based sources (`DIRECTORY`, `MULTI_DIRECTORY`, `BUNDLES`), the PDP monitors the configured path and automatically reloads policies when files change. Any tool that modifies files in the monitored directory acts as a PAP: a text editor, a Git checkout, a CI/CD pipeline, or a management script. For `REMOTE_BUNDLES`, the PDP periodically fetches bundles from an HTTP server. The server can be any HTTP endpoint that serves `.saplbundle` files at the expected paths. This enables centralized policy management where a dedicated policy server distributes bundles to multiple PDP instances. ### Bundle Security The `BUNDLES` and `REMOTE_BUNDLES` source types support Ed25519 signature verification. Bundles can be signed with `sapl bundle sign` and verified against a configured public key or per-tenant key catalogue. By default, signature verification is mandatory. Unsigned bundles are only accepted in development environments with an explicit opt-in (`allow-unsigned: true`). See [Getting Started](../7_1_GettingStarted/) for a quickstart with the `DIRECTORY` source, [Remote Bundles](../7_4_RemoteBundles/) for remote bundle configuration, and [Security](../7_6_Security/) for bundle signing. ## Remote Bundle Configuration SAPL PDP nodes can fetch `.saplbundle` files from a remote HTTP server. This enables centralized policy distribution without requiring filesystem access on the node. ### Deployment Models - **Open core:** Any HTTP server (S3, CDN, Nginx, Artifactory) serves bundles as static files. - **Enterprise:** A Policy Administration Point (PAP) manages bundles for a node cluster using the same HTTP protocol. ### Enabling Remote Bundles Set the PDP configuration type to `REMOTE_BUNDLES`: ```yaml io.sapl.pdp.embedded: pdp-config-type: REMOTE_BUNDLES remote-bundles: base-url: https://pap.example.com/bundles pdp-ids: - production - staging ``` Bundles are addressed by convention: `{baseUrl}/{pdpId}`. The example above resolves to: - `GET https://pap.example.com/bundles/production` - `GET https://pap.example.com/bundles/staging` ### Configuration Reference All properties live under `io.sapl.pdp.embedded.remote-bundles`: | Property | Type | Default | Description | |------------------------|--------------------------|--------------|------------------------------------------| | `base-url` | `String` | _(required)_ | Base URL of the bundle server. | | `pdp-ids` | `List` | _(required)_ | PDP identifiers to fetch bundles for. | | `mode` | `POLLING` or `LONG_POLL` | `POLLING` | Change detection mode. | | `poll-interval` | `Duration` | `5s` | Interval between polls (POLLING mode). | | `long-poll-timeout` | `Duration` | `30s` | Server hold time (LONG_POLL mode). | | `auth-header-name` | `String` | _(none)_ | HTTP header name for authentication. | | `auth-header-value` | `String` | _(none)_ | HTTP header value for authentication. | | `allow-insecure-http` | `boolean` | `false` | Permit auth credentials over plaintext HTTP. | | `follow-redirects` | `boolean` | `true` | Follow HTTP 3xx redirects. | | `pdp-id-poll-intervals`| `Map` | _(empty)_ | Per-pdpId poll interval overrides. | | `first-backoff` | `Duration` | `500ms` | Initial backoff after a fetch failure. | | `max-backoff` | `Duration` | `5s` | Maximum backoff after repeated failures. | ### Change Detection #### Regular Polling (works with any HTTP server) The node sends `GET {baseUrl}/{pdpId}` at the configured interval. HTTP conditional requests (`If-None-Match` with ETag) avoid redundant downloads. The server responds `304 Not Modified` if the bundle has not changed. ```yaml io.sapl.pdp.embedded: remote-bundles: mode: POLLING poll-interval: 5s ``` #### Long-Poll (requires server support) The node sends `GET {baseUrl}/{pdpId}` with `If-None-Match`. The server holds the connection until the bundle changes or a timeout occurs. On change, the server responds `200 OK` with the new bundle. On timeout, the server responds `304 Not Modified` and the node reconnects immediately. ```yaml io.sapl.pdp.embedded: remote-bundles: mode: LONG_POLL long-poll-timeout: 30s ``` If the server does not support long-polling (responds immediately with 304), the behavior degrades gracefully to regular polling. ### Authentication The node sends a configurable HTTP header on every request: ```yaml io.sapl.pdp.embedded: remote-bundles: auth-header-name: Authorization auth-header-value: Bearer eyJhbGciOiJSUz... ``` This covers OAuth2 bearer tokens, static API keys, and custom authentication headers. Both `auth-header-name` and `auth-header-value` must be provided together or both omitted. When an authentication header is configured, the bundle URL must use `https` by default. For trusted local or TLS-terminating deployments that intentionally use plaintext HTTP, set `allow-insecure-http: true`. The node logs a warning because the credential is sent in cleartext on that hop. ### Bundle Security Remote bundles use the same signature verification as local bundles via the shared `bundle-security` configuration block. Signatures are mandatory by default for remote bundles. ```yaml io.sapl.pdp.embedded: pdp-config-type: REMOTE_BUNDLES bundle-security: public-key-path: /path/to/key.pub # OR public-key: MCowBQYDK2VwAyEA... # Per-tenant key bindings (optional) keys: prod-key: MCowBQYDK2VwAyEA... tenants: production: [prod-key] ``` For individual tenants that should accept unsigned bundles without enabling the global escape hatch, use the `unsigned-tenants` list: ```yaml bundle-security: public-key-path: /path/to/key.pub unsigned-tenants: - development - staging ``` Tenants listed here may load unsigned bundles while all other tenants still require valid signatures. For development only, disable signature verification globally: ```yaml bundle-security: allow-unsigned: true ``` ### Per-pdpId Poll Interval Each pdpId inherits the global `poll-interval` unless overridden: ```yaml io.sapl.pdp.embedded: remote-bundles: poll-interval: 60s pdp-id-poll-intervals: staging: 10s # Override for staging ``` In this example, `production` polls every 60 seconds while `staging` polls every 10 seconds. ### Health and Lifecycle The node exposes three health states via Spring Boot Actuator: | State | Condition | Health Status | |-------|-----------|---------------| | DOWN | No bundle fetched yet (startup) | DOWN | | UP | Bundle loaded, remote reachable | UP | | DEGRADED | Bundle loaded, remote unreachable | UP (with warning) | At startup, the node is DOWN. It transitions to UP per-pdpId as each bundle is successfully fetched. If the remote becomes unreachable after a successful fetch, the node continues serving the last-known bundle in DEGRADED state. ### Size Limit Remote bundle responses are limited to 256 MiB. Bundles exceeding this limit are rejected. This limit is enforced by the client and cannot be configured. ### Retry Behavior On fetch failure, the node retries with exponential backoff (with jitter). The backoff starts at `first-backoff` and caps at `max-backoff`. After recovery, the backoff resets to the initial value. ### Graceful Shutdown On application shutdown, all fetch loops are cancelled and HTTP connections are released. No manual intervention is needed. ### Programmatic Configuration For non-Spring environments, the builder API supports remote bundles directly: ```java var securityPolicy = BundleSecurityPolicy.builder(publicKey).build(); var config = new RemoteBundleSourceConfig( "https://pap.example.com/bundles", List.of("production"), RemoteBundleSourceConfig.FetchMode.POLLING, Duration.ofSeconds(30), Duration.ofSeconds(30), "Authorization", "Bearer token", false, true, securityPolicy, Map.of(), Duration.ofMillis(500), Duration.ofSeconds(5)); var pdp = PolicyDecisionPointBuilder.withDefaults(mapper, clock) .withRemoteBundleSource(config) .build() .pdp(); ``` ## Remote Bundle Wire Protocol This document specifies the HTTP protocol used by SAPL nodes to fetch `.saplbundle` files from remote servers. Third-party servers can implement this protocol to serve bundles to SAPL nodes. ### URL Convention Bundles are addressed by convention: ``` {baseUrl}/{pdpId} ``` Example: `https://pap.example.com/bundles/production` The `pdpId` is a path segment appended to the configured base URL. No query parameters are used. ### Regular Polling #### Request ```http GET {baseUrl}/{pdpId} HTTP/1.1 Host: pap.example.com If-None-Match: "v42-sha256-abc123" Accept: application/octet-stream Authorization: Bearer eyJhbGciOiJSUz... ``` | Header | Required | Description | |--------|----------|-------------| | `If-None-Match` | After first fetch | ETag from the previous response. Omitted on first request. | | `Accept` | Yes | Always `application/octet-stream`. | | Auth header | If configured | Custom header name and value (e.g., `Authorization: Bearer ...`). | #### Response: Bundle Changed (200) ```http HTTP/1.1 200 OK Content-Type: application/octet-stream ETag: "v43-sha256-def456" Content-Length: 12345 <.saplbundle ZIP bytes> ``` | Header | Required | Description | |--------|----------|-------------| | `Content-Type` | Recommended | Should be `application/octet-stream`. The client does not validate this header. | | `ETag` | Recommended | Opaque version identifier. Used by the client for `If-None-Match` on subsequent requests. | | `Content-Length` | Recommended | Size of the response body in bytes. | The response body is the raw `.saplbundle` ZIP archive bytes. #### Response: Not Modified (304) ```http HTTP/1.1 304 Not Modified ETag: "v42-sha256-abc123" ``` The server returns 304 when the bundle has not changed since the ETag provided in `If-None-Match`. No response body is sent. ### Long-Poll Mode Long-poll uses the same request format as regular polling. The server behavior differs: 1. If the bundle has changed since the provided ETag: respond immediately with `200 OK` and the new bundle. 2. If unchanged: hold the connection open until either: - The bundle changes: respond `200 OK` with the new bundle. - The server's hold timeout expires: respond `304 Not Modified`. The client reconnects immediately after receiving either response. #### Server Timeout Advertisement (Optional) Servers supporting long-poll MAY include an informational header: ```http X-Long-Poll-Timeout: 30 ``` This indicates the maximum hold time in seconds. It is informational for client-side logging and diagnostics only. The client does not use this value to configure its own timeouts. ### Error Responses | Status | Meaning | Client Behavior | |--------|---------|-----------------| | `200` | Bundle returned | Parse, verify signature, load configuration. | | `304` | Not modified | Keep current bundle, re-poll after interval. | | `301`, `302`, `307`, `308` | Redirect | Follow redirect (if enabled in client config). | | `401`, `403` | Authentication failure | Log error, retry with exponential backoff. | | `404` | pdpId not found on server | Log error, retry with exponential backoff. | | `5xx` | Server error | Log error, retry with exponential backoff. | All error responses trigger retry with exponential backoff. The client never stops retrying. After the server recovers, the client resumes normal operation. ### Bundle Format The response body for `200 OK` is a `.saplbundle` file: a ZIP archive containing: - **`pdp.json`** (required): PDP configuration including combining algorithm and a `configurationId` field that uniquely identifies this configuration version. - **`*.sapl`** files: SAPL policy documents. - **`.sapl-manifest.json`** (if signed): Cryptographic manifest with SHA-256 content hashes and Ed25519 signature. See the SAPL bundle documentation for the full archive format specification. ### Security Considerations #### Transport Security Servers SHOULD use HTTPS. The client supports TLS via standard Spring Boot SSL configuration. #### Bundle Signatures Bundles fetched over HTTP are verified using Ed25519 signatures by default. The signature verification is performed client-side using the public key configured on the node. This provides end-to-end integrity verification independent of transport security. A server serving unsigned bundles to a client with mandatory signature verification will cause the client to reject the bundle and retry. The client never loads an unsigned bundle when signatures are required. #### Authentication The protocol supports a single configurable HTTP header for authentication. The header name and value are sent on every request. Common patterns: | Pattern | Header Name | Header Value | |---------|------------|--------------| | OAuth2 Bearer Token | `Authorization` | `Bearer eyJhbGci...` | | Static API Key | `X-Api-Key` | `sk-abc123...` | | Basic Auth | `Authorization` | `Basic dXNlcjpwYXNz...` | ### Implementing a Compatible Server A minimal compatible server must: 1. Serve `.saplbundle` ZIP files at `{baseUrl}/{pdpId}` with `200 OK`. 2. Keep bundle responses under 256 MiB (the client rejects larger responses). 3. Return `404` for unknown pdpIds. 4. Optionally: support `If-None-Match` / `ETag` for conditional requests with `304` responses. For long-poll support, the server must additionally: 5. Hold the connection open when the bundle has not changed. 6. Respond with `200 OK` when the bundle changes during the hold. 7. Respond with `304 Not Modified` when the hold timeout expires. Static file servers (Nginx, S3, CDN) inherently support the regular polling mode with ETag-based conditional requests. ## Security This section covers securing the SAPL Node HTTP API: authentication, TLS, and interface binding. For bundle signing and signature verification, see [Policy Sources](../7_3_PolicySources/) and [Remote Bundles](../7_4_RemoteBundles/). ### Default Security Posture SAPL Node defaults differ by deployment context. The binary is optimized for a quick development start. Packages and Docker are optimized for production safety. **Binary (development):** Binds to `127.0.0.1`, no TLS, no authentication, `DIRECTORY` mode. Drop `.sapl` files in the working directory and start evaluating policies immediately. **Packages and Docker (production):** `BUNDLES` mode with signature verification enabled. The node starts and accepts connections, but reports health `DOWN` and returns `INDETERMINATE` for all decisions until a signed bundle is deployed. This way it serves authorization decisions only once a signed bundle is in place. In both contexts, authorization requests without matching policies are denied. **Binary (development)** security progression: | Level | What to configure | Use case | |-------|-------------------|----------| | 0 | Nothing | Local development, learning, CI | | 1 | Enable auth, generate credentials | Multi-service on same host | | 2 | Enable TLS, bind to `0.0.0.0` | Network-exposed service | **Packages and Docker (production)** security progression: | Level | What to configure | Use case | |-------|-------------------|----------| | 0 | Configure public key (or allow-unsigned to opt out) | First start | | 1 | Enable auth, generate credentials | Multi-service on same host, trusted network | | 2 | Enable TLS, bind to `0.0.0.0` | Network-exposed service | | 3 | TLS, auth, signed bundles, metrics | Production | ### Authentication SAPL Node supports four authentication modes. Each mode is controlled by a boolean property under `io.sapl.node`. Multiple modes can be active at the same time. When all four modes are disabled, every request is rejected. By default all four modes are disabled (fail-closed) and the node does not start until at least one is enabled. For local exploration, `allow-no-auth: true` is the quickest path. Production deployments typically rely on one or more credential-based modes. A request is authenticated if it matches any enabled mode. The first successful match determines the client identity and PDP routing. The `pdp-id` from the matched credential entry selects which tenant's policies evaluate the request. ### Unauthenticated Access ```yaml io.sapl.node: allow-no-auth: true default-pdp-id: "default" ``` When `allow-no-auth` is `true`, requests without credentials are accepted and routed to the `default-pdp-id`. This is intended for development environments or deployments where an API gateway or service mesh handles authentication before requests reach the node. With this flag, any client that can reach the HTTP or RSocket port can submit authorization subscriptions and receive decisions, so it assumes an outer layer (a gateway, service mesh, or reverse proxy) owns authentication, or that the port is reachable only from trusted callers. If you enable it while a transport is bound to a non-loopback address, the node logs a startup warning naming the transport. See [Interface Binding](#interface-binding). ### Basic Authentication Enable Basic Auth and define users in the `users` list: ```yaml io.sapl.node: allow-basic-auth: true users: - id: "service-a" pdp-id: "default" basic: username: "xwuUaRD65G" secret: "$argon2id$v=19$m=16384,t=2,p=1$..." ``` The `secret` field contains the Argon2 encoded password. Generate credentials with the CLI: ```shell sapl generate basic --id service-a --pdp-id default ``` The command prints the plaintext password and the YAML configuration block. Store the plaintext securely. Only the encoded value goes into `application.yml`. ### API Key Authentication API keys are sent as Bearer tokens in the `Authorization` header. The wire format is `sapl__` and the client sends `Authorization: Bearer sapl__` on each request. ```yaml io.sapl.node: allow-api-key-auth: true users: - id: "service-b" pdp-id: "production" api-key-id: "" api-key: "$argon2id$v=19$m=16384,t=2,p=1$..." ``` Generate a key with the CLI: ```shell sapl generate apikey --id service-b --pdp-id production ``` The command prints three things: the plaintext API key, its public `api-key-id` (the middle segment of the wire format), and the Argon2 encoded hash. Both `api-key-id` and `api-key` go into the user entry. The plaintext is shown once and cannot be recovered from the hash. The `api-key-id` is what the server uses to find the matching user entry. An API key whose id is not configured is rejected. ### OAuth2 and JWT SAPL Node can validate JWT tokens using Spring Security's resource server support. Enable OAuth2 authentication and configure the issuer: ```yaml io.sapl.node: allow-oauth2-auth: true oauth: pdp-id-claim: "sapl_pdp_id" spring.security.oauth2: resourceserver: jwt: issuer-uri: https://auth.example.com/realm ``` The node fetches the JWKS endpoint from the issuer URI and validates token signatures automatically. The `pdp-id-claim` property specifies which JWT claim contains the PDP identifier for tenant routing. If the claim is absent, the `default-pdp-id` is used. ### CSRF Posture The SAPL Node disables Spring's CSRF token mechanism. The API is stateless, no session cookies are issued, and the CSRF safety invariant is **Bearer-only authentication** (API key, OAuth2 JWT). Browsers do not auto-attach `Authorization: Bearer` headers, and cross-origin JavaScript cannot set them without CORS approval, so an attacker page cannot issue a credentialed request to the node. Basic authentication is the exception. Browsers cache Basic credentials for the duration of the session and re-send them on every request to the origin, which reintroduces a CSRF surface. The current PDP endpoints are decision-only, do not mutate state, and the response body is not readable cross-origin under Same-Origin Policy, so the practical payoff for an attacker is limited to triggering work on the node. The node logs a WARN at startup whenever Basic auth is enabled. For production deployments prefer API key or OAuth2 JWT. If you add state-mutating endpoints, disable Basic auth on those routes or pair them with an explicit CSRF defense. ### Multi Tenant Routing Every credential entry includes a `pdp-id` that routes the client to a specific tenant's policies. For `MULTI_DIRECTORY` sources, the `pdp-id` maps to a subdirectory name. For `BUNDLES` sources, it maps to a bundle filename without the `.saplbundle` extension. ```yaml io.sapl.node: default-pdp-id: "default" reject-on-missing-pdp-id: false users: - id: "prod-client" pdp-id: "production" api-key-id: "" api-key: "$argon2id$..." - id: "staging-client" pdp-id: "staging" api-key-id: "" api-key: "$argon2id$..." ``` When `reject-on-missing-pdp-id` is `false`, any user entry without a `pdp-id` is automatically assigned the `default-pdp-id`. When set to `true`, the node fails at startup if any user entry lacks a `pdp-id`. For OAuth2, the PDP identifier is extracted from the JWT claim specified by `oauth.pdp-id-claim`. If the claim is missing and `reject-on-missing-pdp-id` is `false`, the token is routed to `default-pdp-id`. ### TLS TLS is disabled by default so the node starts without a certificate. The HTTP server ships on port 8080 (plain HTTP). Enable TLS by configuring a keystore and binding to the HTTPS-conventional port 8443: ```yaml server: port: 8443 ssl: enabled: true key-store: file:/opt/sapl/tls/keystore.p12 key-store-password: "${KEYSTORE_PASSWORD}" key-store-type: PKCS12 ``` #### Sharing TLS material across HTTP and RSocket via SSL bundles Spring Boot SSL bundles centralise keystore configuration so HTTP and RSocket can terminate TLS using the same material. Define the bundle once under `spring.ssl.bundle.*`, then reference it by name from each transport: ```yaml spring: ssl: bundle: jks: sapl-bundle: key: alias: sapl-node password: "${KEYSTORE_PASSWORD}" keystore: location: file:/opt/sapl/tls/keystore.p12 password: "${KEYSTORE_PASSWORD}" type: PKCS12 server: port: 8443 ssl: enabled: true bundle: sapl-bundle sapl: pdp: rsocket: enabled: true ssl: bundle: sapl-bundle ``` CLI clients connect with `--rsocket --rsocket-tls`. The `--insecure` flag skips certificate verification against self-signed development certificates. See [Configuration](../7_2_Configuration/#rsocket-properties) for the full RSocket property reference. The default configuration restricts connections to modern cipher suites and protocol versions: ```yaml server: ssl: enabled-protocols: - TLSv1.3 - TLSv1.2 protocol: TLSv1.3 ciphers: - TLS_AES_128_GCM_SHA256 - TLS_AES_256_GCM_SHA384 - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 - TLS_DHE_RSA_WITH_AES_256_GCM_SHA384 - TLS_DHE_RSA_WITH_AES_128_GCM_SHA256 - TLS_DHE_DSS_WITH_AES_256_GCM_SHA384 - TLS_DHE_DSS_WITH_AES_128_GCM_SHA256 - TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384 - TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 - TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384 - TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 - TLS_DHE_RSA_WITH_AES_256_CBC_SHA256 - TLS_DHE_RSA_WITH_AES_128_CBC_SHA256 - TLS_DHE_DSS_WITH_AES_256_CBC_SHA256 - TLS_DHE_DSS_WITH_AES_128_CBC_SHA256 ``` TLSv1.3 is preferred. TLSv1.2 is included for compatibility with older clients. All listed cipher suites use AES with GCM or CBC mode and require forward secrecy via ECDHE or DHE key exchange. ### Interface Binding The node exposes two transports, and each binds to a network interface independently. - HTTP is controlled by `server.address`. - RSocket is controlled by `sapl.pdp.rsocket.address`. Both default to `127.0.0.1` (loopback only), so out of the box neither transport is reachable from the network. This is appropriate for local development and for a node that serves only processes on the same host. Exposing a transport to the network is a deliberate, per-transport step. Binding HTTP to all interfaces does not expose RSocket, and binding RSocket does not expose HTTP. To accept remote connections, set the address of each transport you intend to expose: ```yaml server: address: 0.0.0.0 sapl: pdp: rsocket: address: 0.0.0.0 ``` For container deployments both transports must reach beyond loopback so Docker port mapping works. The `docker` Spring profile sets both addresses to `0.0.0.0`. You can also override them individually with the `SERVER_ADDRESS` and `SAPL_PDP_RSOCKET_ADDRESS` environment variables. On a non-loopback address, credentials and decisions travel over the network, so this is the natural point to add TLS and an authentication mode, or to place the node behind a gateway, service mesh, or reverse proxy that terminates both. If `allow-no-auth` is enabled while either transport is bound to a non-loopback address, the node logs a startup warning naming the transport and the address. It still starts, since anonymous access behind an outer trust boundary is a legitimate setup. The warning just makes the exposure visible so it stays a deliberate choice. If you do not use RSocket, `sapl.pdp.rsocket.enabled: false` keeps it off. ### Hardened Configuration Example This is a complete `application.yml` for production deployments. It enables TLS with the default cipher suite list, API key authentication, signed bundles, and metrics. Copy this file and replace the placeholder values. ```yaml io.sapl: pdp.embedded: pdp-config-type: BUNDLES policies-path: /opt/sapl/bundles metrics-enabled: true bundle-security: public-key-path: /opt/sapl/keys/signing.pub node: allow-no-auth: false allow-basic-auth: false allow-api-key-auth: true allow-oauth2-auth: false users: - id: "service-a" pdp-id: "default" api-key-id: "" api-key: "$argon2id$v=19$m=16384,t=2,p=1$..." server: address: 0.0.0.0 port: 8443 ssl: enabled: true key-store: file:/opt/sapl/tls/keystore.p12 key-store-password: "${KEYSTORE_PASSWORD}" key-store-type: PKCS12 enabled-protocols: - TLSv1.3 - TLSv1.2 protocol: TLSv1.3 management: endpoint: health: show-details: when-authorized probes: enabled: true endpoints: web: exposure: include: health,info,prometheus health: livenessstate: enabled: true readinessstate: enabled: true logging.level: "[io.sapl]": INFO "[org.springframework]": INFO ``` Generate API keys with `sapl generate apikey --id service-a --pdp-id default`. For the full property reference, see [Configuration](../7_2_Configuration/). For health checks and Kubernetes probes, see [Monitoring](../7_7_Monitoring/). ### Reverse Proxy Configuration The streaming PDP endpoints (`/api/pdp/decide`, `/api/pdp/multi-decide`, `/api/pdp/multi-decide-all`) use Server-Sent Events (SSE) over long-lived HTTP POST connections. Default proxy configurations buffer responses and time out idle connections, both of which break SSE streaming. The key requirements for any reverse proxy in front of SAPL Node: 1. **Disable response buffering.** SSE events must be flushed immediately to the client. 2. **Set a long read timeout.** Streaming connections stay open indefinitely. The proxy must not close them after a short idle period. 3. **Preserve chunked transfer encoding.** Do not add `Content-Length` headers to streaming responses. 4. **Forward the HTTP method.** All PDP endpoints use POST. #### Keep-Alive Frames SAPL Node sends periodic SSE comment frames (`: keep-alive`) on idle connections, both to keep proxies and firewalls from dropping them and to detect clients that drop without closing. Tune the interval in `application.yml`: ```yaml io.sapl.node: keep-alive: 15 ``` This sends a keep-alive frame every 15 seconds (the default). Keep the interval below the smallest idle timeout on the path; that is, set the proxy read timeout above it (60 seconds is typical). Keep-alive is always on and cannot be disabled; an interval below `1` is raised to the default. See [Configuration](../7_2_Configuration/) for the property reference. #### nginx ```nginx location /api/pdp/ { proxy_pass http://127.0.0.1:8080; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding on; } location /actuator/ { proxy_pass http://127.0.0.1:8080; } ``` #### Apache Enable `mod_proxy` and `mod_proxy_http`. Disable response buffering for the PDP path: ```apache ProxyPass /api/pdp/ http://127.0.0.1:8080/api/pdp/ ProxyPassReverse /api/pdp/ http://127.0.0.1:8080/api/pdp/ SetEnv proxy-sendchunked 1 SetEnv proxy-sendcl 0 ProxyTimeout 3600 ProxyPass /actuator/ http://127.0.0.1:8080/actuator/ ProxyPassReverse /actuator/ http://127.0.0.1:8080/actuator/ ``` The non-streaming endpoints (`/api/pdp/decide-once`, `/api/pdp/multi-decide-all-once`) and actuator endpoints work with default proxy settings and do not require special configuration. ## Monitoring and Observability SAPL Node exposes health, metrics, and decision data through standard Spring Boot Actuator and Micrometer interfaces. There is no proprietary monitoring agent. Use your existing observability stack (Prometheus, Grafana, Loki, ELK, or any tool that consumes these standard interfaces). ### PDP Health Indicator The PDP reports one of three operational states: | State | Meaning | Health Status | |-------|---------|---------------| | `LOADED` | Policies compiled and active. The PDP is fully operational. | UP | | `STALE` | A hot reload failed, but the PDP is still serving decisions from the previous valid configuration. | UP (with warning) | | `ERROR` | No valid configuration loaded. The PDP cannot make valid authorization decisions and serves INDETERMINATE. | DOWN | In multi tenant deployments, the health indicator aggregates the state of all PDP instances. If all instances are `LOADED`, health is UP. If any instance is `STALE` while none are `ERROR`, health is UP with a warning detail. If any instance is `ERROR`, health is DOWN. The health endpoint returns detail fields for each PDP instance: | Field | Description | |-------|-------------| | `state` | Current operational state (`LOADED`, `STALE`, or `ERROR`). | | `configurationId` | Identifier of the active configuration. Absent in `ERROR` state. | | `combiningAlgorithm` | The combining algorithm in use, with `votingMode`, `defaultDecision`, and `errorHandling` fields. Absent in `ERROR` state. | | `documentCount` | Number of SAPL documents in the active configuration. | | `lastSuccessfulLoad` | Timestamp of the last successful configuration load. | | `lastFailedLoad` | Timestamp of the last failed configuration load. Absent if no failure occurred. | | `lastError` | Error message from the last failed load. Absent if no failure occurred. | Example health response with one loaded and one stale PDP: ```json { "status": "UP", "components": { "pdp": { "status": "UP", "details": { "warning": "One or more PDPs are serving stale policies", "pdps": { "default": { "state": "LOADED", "configurationId": "v42", "combiningAlgorithm": { "votingMode": "PRIORITY_PERMIT", "defaultDecision": "DENY", "errorHandling": "ABSTAIN" }, "documentCount": 12, "lastSuccessfulLoad": "2026-03-10T08:15:30Z" }, "staging": { "state": "STALE", "configurationId": "v5", "combiningAlgorithm": { "votingMode": "PRIORITY_DENY", "defaultDecision": "DENY", "errorHandling": "PROPAGATE" }, "documentCount": 3, "lastSuccessfulLoad": "2026-03-10T07:00:00Z", "lastFailedLoad": "2026-03-10T08:10:00Z", "lastError": "Parse error in staging-policy.sapl at line 5" } } } } } } ``` Detail fields are only visible to authenticated users. The default configuration uses `show-details: when-authorized`. See [Security](../7_6_Security/) for securing actuator endpoints. ### Actuator Endpoints | Endpoint | Unauthenticated returns | Authenticated additionally returns | |----------|--------------------------|------------------------------------| | `/actuator/health` | `{"status":"UP","groups":["liveness","readiness"]}` | full per-component breakdown (diskSpace, ssl, pdp, livenessState, readinessState, ping) with the details described above | | `/actuator/health/liveness` | `{"status":"UP"}` | same | | `/actuator/health/readiness` | `{"status":"UP"}` | same | | `/actuator/info` | `401` | `{git:{branch, commit.id}, sapl:{configType, configPath, policiesPath}}` | | `/actuator/prometheus` | `401` | all Prometheus metrics | The split is driven by `management.endpoint.health.show-details: when-authorized` (Spring Boot default) and `management.endpoints.web.exposure.include: health,info,prometheus`. Health endpoints are unauthenticated so Kubernetes probes work without credentials. Authentication only widens the response, never gates the probe itself. Info and Prometheus require authentication to prevent information disclosure of policy-source layout and metric labels. ### Kubernetes Probes Configure liveness, readiness, and startup probes for Kubernetes deployments: ```yaml apiVersion: apps/v1 kind: Deployment spec: template: spec: containers: - name: sapl image: ghcr.io/heutelbeck/sapl-node:4.1.2 ports: - containerPort: 8443 livenessProbe: httpGet: path: /actuator/health/liveness port: 8443 initialDelaySeconds: 15 periodSeconds: 10 readinessProbe: httpGet: path: /actuator/health/readiness port: 8443 initialDelaySeconds: 10 periodSeconds: 5 startupProbe: httpGet: path: /actuator/health/liveness port: 8443 initialDelaySeconds: 5 periodSeconds: 5 failureThreshold: 12 ``` The startup probe gives the PDP time to compile policies before liveness checks begin. With the values above, the maximum startup time is 65 seconds (`initialDelaySeconds` + `periodSeconds` * `failureThreshold` = 5 + 5 * 12 = 65). Once the startup probe succeeds, Kubernetes switches to the liveness and readiness probes. The liveness probe detects a hung JVM process. The readiness probe gates traffic until the node is ready. Both are independent of the PDP compilation state: a node that is still loading policies is alive but not yet ready. ### Decision Metrics SAPL Node exposes four custom Prometheus metrics covering the golden signals for PDP decision traffic: | Metric | Type | Tags | Description | |--------|------|------|-------------| | `sapl.decisions` | Counter | `decision` (PERMIT, DENY, SUSPEND, INDETERMINATE, NOT_APPLICABLE) | Total authorization decisions by outcome. | | `sapl.decision.first.latency` | Timer | | Time from subscription to first decision. | | `sapl.subscriptions.active` | Gauge | | Currently active SSE streaming subscriptions. | | `sapl.subscription.duration` | Timer | | Total lifetime of completed subscriptions. | These metrics cover both one shot (`decide-once`) and streaming (`decide`) endpoints. Standard Spring Boot HTTP metrics (`http.server.requests`) are also available for request level monitoring. Enable metrics in `application.yml`: ```yaml io.sapl.pdp.embedded: metrics-enabled: true ``` SAPL Node enables metrics by default. When embedding the PDP as a library, `metrics-enabled` defaults to `false`. When disabled, no metrics are recorded and there is zero runtime overhead. The property is a final boolean that the JIT compiler evaluates at startup. Dead metric recording branches are eliminated entirely. Configure Prometheus to scrape the metrics endpoint: ```yaml scrape_configs: - job_name: sapl metrics_path: /actuator/prometheus basic_auth: username: prometheus password: secret static_configs: - targets: ['sapl:8443'] ``` The prometheus endpoint requires authentication. Use a dedicated service account with Basic Auth or API key credentials. See [Security](../7_6_Security/) for credential generation. ### Info Endpoint The `/actuator/info` endpoint returns PDP configuration under the `sapl` key: ```json { "sapl": { "configType": "BUNDLES", "configPath": "/policies", "policiesPath": "bundles" } } ``` This endpoint requires authentication and is intended for operational dashboards and inventory systems. ### Decision Logging The PDP emits structured JSON log entries via the reporting interceptor. Each entry contains the authorization subscription (subject, action, resource, environment), the decision (PERMIT, DENY, SUSPEND, INDETERMINATE, NOT_APPLICABLE), and any obligations or advice attached to the decision. Enable subscription lifecycle logging with two properties: ```yaml io.sapl.pdp.embedded: print-subscription-events: true print-unsubscription-events: true ``` These log when a new authorization subscription starts and when it ends. This is useful for tracking active clients and debugging connection lifecycle issues. Filtering, retention, and alerting on decision log entries are handled by your log infrastructure (Loki, ELK, Fluentd, CloudWatch). The PDP does not push logs to any external service. ### Evaluation Diagnostics Four properties control diagnostic output during policy evaluation: | Property | Description | |----------|-------------| | `print-trace` | Logs the full JSON evaluation trace on each decision. Shows every evaluation step the PDP performed. | | `print-json-report` | Logs a JSON evaluation report on each decision. More compact than the full trace. | | `print-text-report` | Logs a human readable text report on each decision. Shows which policies matched, how each was evaluated, and why the combining algorithm produced its result. | | `pretty-print-reports` | Pretty prints JSON in logged traces and reports. | Enable all diagnostics during development: ```yaml io.sapl.pdp.embedded: print-trace: true print-json-report: true print-text-report: true pretty-print-reports: true ``` The text report is the most useful diagnostic tool for understanding why a particular decision was reached. It provides a step by step view of the evaluation process in a format designed for human consumption. Disable all diagnostic properties in production. They produce significant log volume under load and are intended for development and staging environments only. See [Configuration](../7_2_Configuration/) for the full property reference. ## Benchmarking SAPL Node includes two commands for measuring PDP performance: `sapl benchmark` for embedded evaluation throughput, and `sapl loadtest` for remote server load testing. ### Embedded Benchmark The `benchmark` command measures policy evaluation throughput and latency of an embedded PDP using a built-in timing harness. It runs entirely in-process without a server. ```bash sapl benchmark --rbac -o ./results ``` The `--rbac` flag uses a built-in RBAC scenario that requires no policy files. Alternatively, point at your own policies: ```bash sapl benchmark --dir ./policies -s '"alice"' -a '"read"' -r '"doc"' -o ./results ``` #### Options | Option | Default | Description | |----------------------------|----------------------|-----------------------------------------------------------| | `--rbac` | | Use built-in RBAC benchmark (no files needed) | | `--dir`, `--bundle` | `~/.sapl/` | Policy source | | `-s`, `-a`, `-r` | | Subscription components (JSON values) | | `-b`, `--benchmark` | `decideOnceBlocking` | Method: `decideOnceBlocking`, `decideStreamFirst`, `noOp` | | `-t`, `--threads` | `1` | Concurrent benchmark threads | | `--warmup-iterations` | `3` | Number of warmup iterations | | `--warmup-time` | `45` | Seconds per warmup iteration | | `--measurement-iterations` | `5` | Number of measurement iterations | | `--measurement-time` | `45` | Seconds per measurement iteration | | `--latency` | `true` | Run a latency measurement pass after throughput | | `-o`, `--output` | | Output directory for Markdown, CSV, and JSON reports | | `--machine-readable` | `false` | Output single-line parseable results for scripts | {: .note } > For rigorous benchmarks with JIT isolation across forked JVMs, convergence checking, and advanced JVM tuning, use the `sapl-benchmark-sapl4` module instead. The embedded `sapl benchmark` command is designed for quick assessments. ### Remote Load Test The `loadtest` command measures throughput and per-request latency of a running SAPL Node server. It supports both HTTP/JSON and RSocket/protobuf transports. #### HTTP ```bash sapl loadtest --url http://localhost:8080 -s '"alice"' -a '"read"' -r '"doc"' ``` #### RSocket ```bash sapl loadtest --rsocket --host localhost --port 7000 -s '"alice"' -a '"read"' -r '"doc"' ``` #### Options | Option | Default | Description | |-------------------------|-------------------------|----------------------------------------------------| | `--url` | `http://localhost:8080` | HTTP server URL | | `--rsocket` | | Use RSocket/protobuf transport instead of HTTP | | `--host` | `localhost` | RSocket server host | | `--port` | `7000` | RSocket server port | | `--socket-path` | | Unix domain socket path (alternative to host/port) | | `--concurrency` | `64` | Concurrent in-flight requests (HTTP) | | `--connections` | `8` | Number of TCP connections (RSocket) | | `--vt-per-connection` | `512` | Virtual threads per RSocket connection | | `--rate` | `0` | Target req/s (0 = saturation mode) | | `--warmup-seconds` | `5` | Warmup duration | | `--measurement-seconds` | `10` | Measurement duration | | `-o`, `--output` | | Output directory for reports | | `--label` | | Label for the report | | `--machine-readable` | `false` | Output single-line parseable results for scripts | #### Saturation vs Paced Mode By default (`--rate 0`), the load generator sends requests as fast as possible (saturation mode) to find the server's throughput ceiling. When `--rate` is set, it sends requests at a fixed rate with coordinated omission correction for accurate latency measurement under controlled load. #### RSocket Connection Tuning RSocket mode distributes load across multiple TCP connections. Each connection runs a configurable number of virtual threads. The total concurrency is `--connections` multiplied by `--vt-per-connection`. For example, 8 connections with 512 virtual threads each gives 4096 concurrent in-flight requests. ```bash sapl loadtest --rsocket --connections 8 --vt-per-connection 512 -s '"alice"' -a '"read"' -r '"doc"' ``` For the full option reference, see the [CLI Reference](../7_9_CommandLine/). # Command Line SAPL Node PDP server and policy CLI. ## Commands - [`sapl`](#sapl) -- SAPL Node PDP server and policy CLI. - [`sapl server`](#sapl-server) -- Start the PDP server (default when no subcommand is given). - [`sapl bundle`](#sapl-bundle) -- Manage policy bundles for deployment. - [`sapl bundle create`](#sapl-bundle-create) -- Create a policy bundle from a directory. - [`sapl bundle sign`](#sapl-bundle-sign) -- Sign a policy bundle with an Ed25519 private key. - [`sapl bundle verify`](#sapl-bundle-verify) -- Verify a signed policy bundle against an Ed25519 public key. - [`sapl bundle inspect`](#sapl-bundle-inspect) -- Show bundle contents and metadata. - [`sapl bundle keygen`](#sapl-bundle-keygen) -- Generate an Ed25519 keypair for bundle signing. - [`sapl check`](#sapl-check) -- Evaluate authorization and exit with a decision code. - [`sapl decide`](#sapl-decide) -- Stream authorization decisions as NDJSON. - [`sapl decide-once`](#sapl-decide-once) -- Evaluate a single authorization decision and print the result as JSON. - [`sapl generate`](#sapl-generate) -- Generate authentication credentials for PDP server clients. - [`sapl generate basic`](#sapl-generate-basic) -- Generate HTTP Basic Auth credentials with Argon2id-encoded password. - [`sapl generate apikey`](#sapl-generate-apikey) -- Generate a Bearer token API key with Argon2id-encoded hash. - [`sapl test`](#sapl-test) -- Run SAPL tests and generate coverage reports. - [`sapl benchmark`](#sapl-benchmark) -- Benchmark embedded PDP evaluation performance. - [`sapl loadtest`](#sapl-loadtest) -- Load test a running SAPL PDP server. ## sapl server Start the PDP server (default when no subcommand is given). Launches the SAPL Policy Decision Point as an HTTP server. Clients send authorization subscriptions via the HTTP API and receive decisions as JSON responses or Server-Sent Event streams. A high-performance RSocket endpoint with protobuf serialization is enabled by default on port 7000 for lower-latency authorization. Disable it explicitly with --sapl.pdp.rsocket.enabled=false when only the HTTP transport is needed. The server is configured via `application.yml`. Place it in a config/ subdirectory of the working directory, or specify a custom location with --spring.config.location=file:/path/to/application.yml. Any Spring Boot property can be overridden on the command line: --server.port=9090 --sapl.pdp.rsocket.enabled=false --sapl.pdp.rsocket.port=7000 Shortcut for local development: `--no-auth` accept unauthenticated requests (alias for --io.sapl.node.allow-no-auth=true) Key configuration areas: policy source type (DIRECTORY, BUNDLES), authentication (no-auth, basic, API key, OAuth2), TLS, RSocket, and observability (health endpoints, Prometheus metrics). **Synopsis** ``` sapl server [-hV] ``` **Options** | Option | Description | Default | |--------|-------------|---------| | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | **Exit Codes** | Code | Description | |------|-------------| | 0 | Clean shutdown | | 1 | Startup or runtime error | **Examples** ```shell # Start with default settings sapl server # Local development without authentication sapl server --no-auth # Start on a custom port sapl server --server.port=9090 # Use a custom configuration file sapl server --spring.config.location=file:/etc/sapl/application.yml ``` See Also: [sapl generate basic](#sapl-generate-basic), [sapl generate apikey](#sapl-generate-apikey) ## sapl bundle Manage policy bundles for deployment. Bundles package SAPL policies and PDP configuration into a single `.saplbundle` file. They can be cryptographically signed with Ed25519 keys for integrity verification at load time. **Synopsis** ``` sapl bundle [-hV] [COMMAND] ``` **Options** | Option | Description | Default | |--------|-------------|---------| | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | ### sapl bundle create Create a policy bundle from a directory. Packages all `.sapl` policy files and `pdp.json` from the input directory into a `.saplbundle` file. Policies are validated for correct SAPL syntax during creation. Optionally signs the bundle when a private key is provided. This is equivalent to creating then running 'sapl bundle sign'. **Synopsis** ``` sapl bundle create [-hV] -i= [-k=] [--key-id=] -o= ``` **Options** | Option | Description | Default | |--------|-------------|---------| | `-i, --input ` | Input directory containing policies | | | `-k, --key ` | Ed25519 private key file (PEM format) for signing | | | `--key-id ` | Key identifier for rotation support | `default` | | `-o, --output ` | Output bundle file path | | | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | **Exit Codes** | Code | Description | |------|-------------| | 0 | Bundle created successfully | | 1 | Error (invalid input, no policies found, or I/O error) | **Examples** ```shell # Create an unsigned bundle sapl bundle create -i ./policies -o policies.saplbundle # Create and sign in one step sapl bundle create -i ./policies -o policies.saplbundle -k signing.pem --key-id prod-2026 ``` See Also: [sapl bundle sign](#sapl-bundle-sign), [sapl bundle keygen](#sapl-bundle-keygen) ### sapl bundle sign Sign a policy bundle with an Ed25519 private key. Creates a manifest containing SHA-256 hashes of all files in the bundle and signs it with the provided Ed25519 private key. The signature enables the PDP server to verify bundle integrity and authenticity at load time. By default, the input bundle is overwritten with the signed version. Use `-o` to write to a different file. **Synopsis** ``` sapl bundle sign [-hV] -b= -k= [--key-id=] [-o=] ``` **Options** | Option | Description | Default | |--------|-------------|---------| | `-b, --bundle ` | Bundle file to sign | | | `-k, --key ` | Ed25519 private key file (PEM format) | | | `--key-id ` | Key identifier for rotation support | `default` | | `-o, --output ` | Output file (default: overwrites input) | | | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | **Exit Codes** | Code | Description | |------|-------------| | 0 | Bundle signed successfully | | 1 | Error (bundle or key not found, or signing failed) | **Examples** ```shell # Sign a bundle (overwrites the original) sapl bundle sign -b policies.saplbundle -k signing.pem # Sign and write to a new file sapl bundle sign -b policies.saplbundle -k signing.pem -o signed.saplbundle --key-id prod-2026 ``` See Also: [sapl bundle keygen](#sapl-bundle-keygen), [sapl bundle verify](#sapl-bundle-verify) ### sapl bundle verify Verify a signed policy bundle against an Ed25519 public key. Validates the bundle's Ed25519 signature and checks SHA-256 hashes of all files against the manifest. Reports the key ID, creation timestamp, and number of verified files on success. **Synopsis** ``` sapl bundle verify [-hV] -b= -k= ``` **Options** | Option | Description | Default | |--------|-------------|---------| | `-b, --bundle ` | Bundle file to verify | | | `-k, --key ` | Ed25519 public key file (PEM format) | | | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | **Exit Codes** | Code | Description | |------|-------------| | 0 | Verification successful | | 1 | Verification failed, bundle not signed, or error | **Examples** ```shell # Verify a signed bundle sapl bundle verify -b policies.saplbundle -k signing.pub ``` See Also: [sapl bundle sign](#sapl-bundle-sign), [sapl bundle inspect](#sapl-bundle-inspect) ### sapl bundle inspect Show bundle contents and metadata. Displays the signature status, PDP configuration (pdp.json), and a list of all policies with their sizes. Useful for auditing bundles before deployment. **Synopsis** ``` sapl bundle inspect [-hV] -b= ``` **Options** | Option | Description | Default | |--------|-------------|---------| | `-b, --bundle ` | Bundle file to inspect | | | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | **Exit Codes** | Code | Description | |------|-------------| | 0 | Inspection completed | | 1 | Error reading bundle | **Examples** ```shell # Show bundle contents and signature status sapl bundle inspect -b policies.saplbundle ``` See Also: [sapl bundle verify](#sapl-bundle-verify) ### sapl bundle keygen Generate an Ed25519 keypair for bundle signing. Creates a PKCS#8 PEM-encoded private key (.pem) and an X.509 PEM-encoded public key (.pub). The private key is used with 'sapl bundle sign' or 'sapl bundle create -k'. The public key is configured on the PDP server to verify bundle signatures. **Synopsis** ``` sapl bundle keygen [-hV] [--force] -o= ``` **Options** | Option | Description | Default | |--------|-------------|---------| | `-o, --output ` | Output file prefix (creates .pem and .pub) | | | `--force` | Overwrite existing files | | | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | **Exit Codes** | Code | Description | |------|-------------| | 0 | Keypair generated | | 1 | Error (file exists without --force, or generation failed) | **Examples** ```shell # Generate a new signing keypair sapl bundle keygen -o signing-key # Overwrite existing key files sapl bundle keygen -o signing-key --force ``` See Also: [sapl bundle sign](#sapl-bundle-sign), [sapl bundle create](#sapl-bundle-create) ## sapl check Evaluate authorization and exit with a decision code. Evaluates a single authorization subscription against policies and exits with a code that encodes the decision. No output is written to stdout, making this command ideal for shell scripts and CI/CD pipelines. By default, policies are loaded from ~/.sapl/. Use `--dir` for a different directory, `--bundle` for a bundle file, or `--remote` to query a running PDP server. **Synopsis** ``` sapl check [-hV] [--json-report] [--text-report] [--trace] [--remote [--rsocket] [--url=] [--host=] [--port=] [--rsocket-tls] [--insecure] [--basic-auth= | --token=]] [--dir= | --bundle=] [--public-key= | --no-verify] [-f= | [-s= -a= -r= [-e=] [--secrets=]]] ``` **Options** *Remote Connection:* | Option | Description | Default | |--------|-------------|---------| | `--remote` | Connect to a remote PDP server instead of evaluating locally | | | `--rsocket` | Use RSocket/protobuf transport instead of HTTP/JSON | | | `--url ` | Remote PDP URL for HTTP (default: http://localhost:8080, env: SAPL_URL) | | | `--host ` | RSocket host (default: localhost) | `localhost` | | `--port ` | RSocket port (default: 7000) | `7000` | | `--rsocket-tls` | Enable TLS for the RSocket transport (use with --rsocket) | | | `--insecure` | Accept insecure transport (skip TLS certificate verification and allow credentials over plaintext). Development only | | | `--basic-auth ` | HTTP Basic credentials as user:password (env: SAPL_BASIC_AUTH) | | | `--token ` | Bearer token for API key or JWT (env: SAPL_BEARER_TOKEN) | | *Policy Source:* | Option | Description | Default | |--------|-------------|---------| | `--dir ` | Directory containing `.sapl` policy files and `pdp.json` | | | `--bundle ` | Policy bundle file (.saplbundle) | | *Bundle Verification:* | Option | Description | Default | |--------|-------------|---------| | `--public-key ` | Ed25519 public key file (PEM) for bundle signature verification | | | `--no-verify` | Skip bundle signature verification (development only) | | *Subscription Input:* | Option | Description | Default | |--------|-------------|---------| | `-f, --file ` | Read authorization subscription from a JSON file. Use - for stdin. | | | `-s, --subject ` | Subject as a JSON value (string, number, object, or array) | | | `-a, --action ` | Action as a JSON value (string, number, object, or array) | | | `-r, --resource ` | Resource as a JSON value (string, number, object, or array) | | | `-e, --environment ` | Environment as a JSON value (optional context for policy evaluation) | | | `--secrets ` | Secrets as a JSON object (available to policies via the secrets() function) | | | `--trace` | Print the full policy evaluation trace to stderr | | | `--json-report` | Print a machine-readable JSON evaluation report to stderr | | | `--text-report` | Print a human-readable text evaluation report to stderr | | | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | **Exit Codes** | Code | Description | |------|-------------| | 0 | PERMIT without obligations or resource transformation | | 1 | Error during evaluation | | 2 | DENY | | 3 | NOT_APPLICABLE (no matching policy) | | 4 | INDETERMINATE, or PERMIT with obligations/resource transformation | | 5 | SUSPEND | **Examples** ```shell # Check using local policies sapl check --dir ./policies -s '"alice"' -a '"read"' -r '"doc"' # Use as a CI/CD gate (exit 0 means PERMIT) if sapl check --bundle policies.saplbundle -s '"ci"' -a '"deploy"' -r '"prod"'; then echo "Permitted"; fi # Read subscription from stdin echo '{"subject":"alice","action":"read","resource":"doc"}' | sapl check -f - # Query a remote PDP server sapl check --remote --url https://pdp.example.com --token $SAPL_BEARER_TOKEN -s '"alice"' -a '"read"' -r '"doc"' ``` See Also: [sapl decide once](#sapl-decide-once), [sapl decide](#sapl-decide) ## sapl decide Stream authorization decisions as NDJSON. Subscribes to the policy decision point and prints each decision as a JSON line to stdout (Newline Delimited JSON). When policies change, attributes update, or the subscription context evolves, a new decision line is emitted automatically. Each decision is one compact line; `--pretty` indents them for reading but breaks the NDJSON format. Runs until interrupted (Ctrl+C) or the decision stream completes. By default, policies are loaded from ~/.sapl/. Use `--dir` for a different directory, `--bundle` for a bundle file, or `--remote` to query a running PDP server. **Synopsis** ``` sapl decide [-hV] [--json-report] [--pretty] [--text-report] [--trace] [--remote [--rsocket] [--url=] [--host=] [--port=] [--rsocket-tls] [--insecure] [--basic-auth= | --token=]] [--dir= | --bundle=] [--public-key= | --no-verify] [-f= | [-s= -a= -r= [-e=] [--secrets=]]] ``` **Options** *Remote Connection:* | Option | Description | Default | |--------|-------------|---------| | `--remote` | Connect to a remote PDP server instead of evaluating locally | | | `--rsocket` | Use RSocket/protobuf transport instead of HTTP/JSON | | | `--url ` | Remote PDP URL for HTTP (default: http://localhost:8080, env: SAPL_URL) | | | `--host ` | RSocket host (default: localhost) | `localhost` | | `--port ` | RSocket port (default: 7000) | `7000` | | `--rsocket-tls` | Enable TLS for the RSocket transport (use with --rsocket) | | | `--insecure` | Accept insecure transport (skip TLS certificate verification and allow credentials over plaintext). Development only | | | `--basic-auth ` | HTTP Basic credentials as user:password (env: SAPL_BASIC_AUTH) | | | `--token ` | Bearer token for API key or JWT (env: SAPL_BEARER_TOKEN) | | *Policy Source:* | Option | Description | Default | |--------|-------------|---------| | `--dir ` | Directory containing `.sapl` policy files and `pdp.json` | | | `--bundle ` | Policy bundle file (.saplbundle) | | *Bundle Verification:* | Option | Description | Default | |--------|-------------|---------| | `--public-key ` | Ed25519 public key file (PEM) for bundle signature verification | | | `--no-verify` | Skip bundle signature verification (development only) | | *Subscription Input:* | Option | Description | Default | |--------|-------------|---------| | `-f, --file ` | Read authorization subscription from a JSON file. Use - for stdin. | | | `-s, --subject ` | Subject as a JSON value (string, number, object, or array) | | | `-a, --action ` | Action as a JSON value (string, number, object, or array) | | | `-r, --resource ` | Resource as a JSON value (string, number, object, or array) | | | `-e, --environment ` | Environment as a JSON value (optional context for policy evaluation) | | | `--secrets ` | Secrets as a JSON object (available to policies via the secrets() function) | | | `--trace` | Print the full policy evaluation trace to stderr | | | `--json-report` | Print a machine-readable JSON evaluation report to stderr | | | `--text-report` | Print a human-readable text evaluation report to stderr | | | `--pretty` | Indent each decision for readability. This breaks the NDJSON one-decision-per-line format. | | | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | **Exit Codes** | Code | Description | |------|-------------| | 0 | Clean shutdown (stream completed or interrupted) | | 1 | Error during evaluation | **Examples** ```shell # Stream decisions using local policies (Ctrl+C to stop) sapl decide --dir ./policies -s '"alice"' -a '"read"' -r '"doc"' # Stream from a remote PDP server sapl decide --remote --token $SAPL_BEARER_TOKEN -s '"alice"' -a '"read"' -r '"doc"' # Read subscription from a JSON file sapl decide -f request.json --bundle policies.saplbundle ``` See Also: [sapl decide once](#sapl-decide-once), [sapl check](#sapl-check) ## sapl decide-once Evaluate a single authorization decision and print the result as JSON. Evaluates the authorization subscription against policies once and prints the full decision to stdout as a JSON object containing the decision (PERMIT, DENY, SUSPEND, NOT_APPLICABLE, INDETERMINATE), any obligations, advice, and resource transformations. The JSON is compact by default; pass `--pretty` for an indented, human-readable form. By default, policies are loaded from ~/.sapl/. Use `--dir` for a different directory, `--bundle` for a bundle file, or `--remote` to query a running PDP server. **Synopsis** ``` sapl decide-once [-hV] [--json-report] [--pretty] [--text-report] [--trace] [--remote [--rsocket] [--url=] [--host=] [--port=] [--rsocket-tls] [--insecure] [--basic-auth= | --token=]] [--dir= | --bundle=] [--public-key= | --no-verify] [-f= | [-s= -a= -r= [-e=] [--secrets=]]] ``` **Options** *Remote Connection:* | Option | Description | Default | |--------|-------------|---------| | `--remote` | Connect to a remote PDP server instead of evaluating locally | | | `--rsocket` | Use RSocket/protobuf transport instead of HTTP/JSON | | | `--url ` | Remote PDP URL for HTTP (default: http://localhost:8080, env: SAPL_URL) | | | `--host ` | RSocket host (default: localhost) | `localhost` | | `--port ` | RSocket port (default: 7000) | `7000` | | `--rsocket-tls` | Enable TLS for the RSocket transport (use with --rsocket) | | | `--insecure` | Accept insecure transport (skip TLS certificate verification and allow credentials over plaintext). Development only | | | `--basic-auth ` | HTTP Basic credentials as user:password (env: SAPL_BASIC_AUTH) | | | `--token ` | Bearer token for API key or JWT (env: SAPL_BEARER_TOKEN) | | *Policy Source:* | Option | Description | Default | |--------|-------------|---------| | `--dir ` | Directory containing `.sapl` policy files and `pdp.json` | | | `--bundle ` | Policy bundle file (.saplbundle) | | *Bundle Verification:* | Option | Description | Default | |--------|-------------|---------| | `--public-key ` | Ed25519 public key file (PEM) for bundle signature verification | | | `--no-verify` | Skip bundle signature verification (development only) | | *Subscription Input:* | Option | Description | Default | |--------|-------------|---------| | `-f, --file ` | Read authorization subscription from a JSON file. Use - for stdin. | | | `-s, --subject ` | Subject as a JSON value (string, number, object, or array) | | | `-a, --action ` | Action as a JSON value (string, number, object, or array) | | | `-r, --resource ` | Resource as a JSON value (string, number, object, or array) | | | `-e, --environment ` | Environment as a JSON value (optional context for policy evaluation) | | | `--secrets ` | Secrets as a JSON object (available to policies via the secrets() function) | | | `--trace` | Print the full policy evaluation trace to stderr | | | `--json-report` | Print a machine-readable JSON evaluation report to stderr | | | `--text-report` | Print a human-readable text evaluation report to stderr | | | `--pretty` | Indent the decision JSON for readability instead of compact single-line output. | | | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | **Exit Codes** | Code | Description | |------|-------------| | 0 | Decision printed successfully | | 1 | Error during evaluation | **Examples** ```shell # Evaluate using local policies sapl decide-once --dir ./policies -s '"alice"' -a '"read"' -r '"doc"' # Read subscription from a JSON file sapl decide-once -f request.json --bundle policies.saplbundle # Read subscription from stdin echo '{"subject":"alice","action":"read","resource":"doc"}' | sapl decide-once -f - # Query a remote PDP server with a complex subject sapl decide-once --remote --token $SAPL_BEARER_TOKEN -s '{"role":"admin"}' -a '"write"' -r '"config"' ``` See Also: [sapl check](#sapl-check), [sapl decide](#sapl-decide) ## sapl generate Generate authentication credentials for PDP server clients. Creates credentials with Argon2id-encoded hashes and outputs ready-to-use configuration snippets for `application.yml`. Credentials can use HTTP Basic Auth or API key (Bearer token). **Synopsis** ``` sapl generate [-hV] [COMMAND] ``` **Options** | Option | Description | Default | |--------|-------------|---------| | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | ### sapl generate basic Generate HTTP Basic Auth credentials with Argon2id-encoded password. Creates a random username and password, encodes the password with Argon2id, and prints the credentials along with an `application.yml` configuration snippet and ready-to-paste curl usage examples for the common shells (bash and PowerShell). Store the plaintext password securely. Only the Argon2id hash goes into server configuration. **Synopsis** ``` sapl generate basic [-hV] [-i=] [-p=] ``` **Options** | Option | Description | Default | |--------|-------------|---------| | `-i, --id ` | User ID (default: generated) | | | `-p, --pdp-id ` | PDP ID for routing (default: 'default') | `default` | | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | **Exit Codes** | Code | Description | |------|-------------| | 0 | Credentials generated successfully | | 1 | Error during generation | **Examples** ```shell # Generate random credentials sapl generate basic # Generate with custom ID and PDP routing sapl generate basic --id service-a --pdp-id production ``` See Also: [sapl generate apikey](#sapl-generate-apikey), [sapl server](#sapl-server) ### sapl generate apikey Generate a Bearer token API key with Argon2id-encoded hash. Creates an API key with the format sapl_ and encodes it with Argon2id. Prints the key along with an `application.yml` configuration snippet and a curl usage example. The API key is used as a Bearer token in the Authorization header. **Synopsis** ``` sapl generate apikey [-hV] [-i=] [-p=] ``` **Options** | Option | Description | Default | |--------|-------------|---------| | `-i, --id ` | User ID (default: generated) | | | `-p, --pdp-id ` | PDP ID for routing (default: 'default') | `default` | | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | **Exit Codes** | Code | Description | |------|-------------| | 0 | API key generated successfully | | 1 | Error during generation | **Examples** ```shell # Generate a random API key sapl generate apikey # Generate with custom ID and PDP routing sapl generate apikey --id my-service --pdp-id production ``` See Also: [sapl generate basic](#sapl-generate-basic), [sapl server](#sapl-server) ## sapl test Run SAPL tests and generate coverage reports. Discovers `.sapl` policy files and .sapltest test files from a directory, executes all test scenarios, and generates coverage reports. Policies and tests are matched by the document names referenced in the test files. Policies are discovered from --dir. Tests are discovered from `--testdir` if specified, otherwise from --dir. Coverage data is written to the output directory as coverage.ndjson. HTML and SonarQube reports can be generated from this data. Quality gate thresholds can be configured to fail the command when coverage ratios are below the required percentages. **Synopsis** ``` sapl test [-hV] [--[no-]html] [--[no-]sonar] [--branch-coverage-ratio=] [--condition-hit-ratio=] [--dir=] [--output=] [--policy-hit-ratio=] [--policy-set-hit-ratio=] [--testdir=] ``` **Options** | Option | Description | Default | |--------|-------------|---------| | `--dir ` | Directory containing `.sapl` policy files | `.` | | `--testdir ` | Directory containing .sapltest test files (default: same as --dir) | | | `--output ` | Output directory for coverage data and reports | `./sapl-coverage` | | `--html` | Generate HTML coverage report | `true` | | `--sonar` | Generate SonarQube coverage report | `false` | | `--policy-set-hit-ratio ` | Required policy set hit ratio, 0-100 (0 = disabled) | `0` | | `--policy-hit-ratio ` | Required policy hit ratio, 0-100 (0 = disabled) | `0` | | `--condition-hit-ratio ` | Required condition hit ratio, 0-100 (0 = disabled) | `0` | | `--branch-coverage-ratio ` | Required branch coverage ratio, 0-100 (0 = disabled) | `0` | | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | **Exit Codes** | Code | Description | |------|-------------| | 0 | All tests passed (and quality gate met, if configured) | | 1 | Error during test execution (I/O, parse errors) | | 2 | One or more tests failed | | 3 | Quality gate not met (tests passed but coverage below threshold) | **Examples** ```shell # Run tests from current directory sapl test # Run tests from a specific directory sapl test --dir ./my-policies # Policies in one directory, tests in another sapl test --dir ./policies --testdir ./tests # Generate only SonarQube report (no HTML) sapl test --no-html --sonar # Custom output directory sapl test --output ./reports/sapl-coverage # Enforce a coverage threshold sapl test --policy-hit-ratio 80 ``` See Also: [sapl check](#sapl-check), [sapl decide](#sapl-decide) ## sapl benchmark Benchmark embedded PDP evaluation performance. Quick assessment of policy evaluation throughput and latency for an embedded PDP using a built-in timing harness. Use `--rbac` for a self-contained benchmark without policy files, or provide a policy directory (--dir) or bundle (--bundle). When `--output` is specified, produces Markdown and CSV reports with timestamped filenames. For rigorous benchmarks with proper JIT isolation, use the sapl-benchmark-sapl4 module instead. For remote server load testing (HTTP or RSocket), use 'sapl loadtest' instead. **Synopsis** ``` sapl benchmark [-hV] [--latency] [--machine-readable] [--rbac] [-b=] [--measurement-iterations=] [--measurement-time=] [-o=] [--output-prefix=] [-t=] [--warmup-iterations=] [--warmup-time=] [--dir= | --bundle=] [--public-key= | --no-verify] [-f= | [-s= -a= -r= [-e=] [--secrets=]]] ``` **Options** *Policy Source:* | Option | Description | Default | |--------|-------------|---------| | `--dir ` | Directory containing `.sapl` policy files and `pdp.json` | | | `--bundle ` | Policy bundle file (.saplbundle) | | *Bundle Verification:* | Option | Description | Default | |--------|-------------|---------| | `--public-key ` | Ed25519 public key file (PEM) for bundle signature verification | | | `--no-verify` | Skip bundle signature verification (development only) | | *Subscription Input:* | Option | Description | Default | |--------|-------------|---------| | `-f, --file ` | Read authorization subscription from a JSON file. Use - for stdin. | | | `-s, --subject ` | Subject as a JSON value (string, number, object, or array) | | | `-a, --action ` | Action as a JSON value (string, number, object, or array) | | | `-r, --resource ` | Resource as a JSON value (string, number, object, or array) | | | `-e, --environment ` | Environment as a JSON value (optional context for policy evaluation) | | | `--secrets ` | Secrets as a JSON object (available to policies via the secrets() function) | | | `--rbac` | Use built-in RBAC benchmark (no policy files or subscription needed). | | | `--warmup-iterations ` | Number of warmup iterations before measurement | `3` | | `--warmup-time ` | Duration of each warmup iteration in seconds | `45` | | `--measurement-iterations ` | Number of measurement iterations | `5` | | `--measurement-time ` | Duration of each measurement iteration in seconds | `45` | | `-t, --threads ` | Number of concurrent benchmark threads | `1` | | `-b, --benchmark ` | Benchmark method to run (decideOnceBlocking, decideStreamFirst, noOp) | `decideOnceBlocking` | | `--latency` | Run a separate latency measurement pass after throughput | `true` | | `-o, --output ` | Output directory for benchmark results (JSON, Markdown, CSV) | | | `--machine-readable` | Output single-line parseable results for script integration | `false` | | `--output-prefix ` | Filename prefix for output files (e.g., scenario_indexing) | | | `-h, --help` | Show this help message and exit. | | | `-V, --version` | Print version information and exit. | | **Exit Codes** | Code | Description | |------|-------------| | 0 | Benchmark completed successfully | | 1 | Error during benchmark | **Examples** ```shell # Built-in RBAC benchmark (no files needed) sapl benchmark --rbac -o ./results # Quick benchmark with local policies sapl benchmark --dir ./policies -s '"alice"' -a '"read"' -r '"doc"' # Multi-threaded benchmark with config file sapl benchmark --rbac -c configs/standard.json -o ./results ``` See Also: [sapl loadtest](#sapl-loadtest), [sapl check](#sapl-check), [sapl decide once](#sapl-decide-once) ## sapl loadtest Load test a running SAPL PDP server. Measures server throughput and per-request latency distribution under controlled concurrency. Supports saturation mode (as fast as possible) and paced mode (--rate) with coordinated omission correction for accurate latency measurement under controlled load. Both HTTP and RSocket modes use reactive request pipelines and pre-serialize the request payload to eliminate client-side overhead from the measurement. For embedded PDP benchmarking, use 'sapl benchmark' instead. **Synopsis** ``` sapl loadtest [-hV] [--insecure] [--machine-readable] [--rsocket] [--concurrency=] [--connections=] [--host=] [--label=