Data-Level Security

The policy decides what the data looks like

Most authorization systems answer a binary question: can this user do this thing? SAPL answers a richer one: can this user do this thing, and if so, what should the data look like?

A PERMIT decision can carry obligations that transform the method’s input before execution, or reshape its output after execution. The application code does not implement these transformations. It implements the business logic. The policy decides what gets capped, what gets redacted, and what gets filtered. Change the policy, change the data. No code change. No redeployment.

This applies everywhere a PEP intercepts a method call: REST endpoints, service layer methods, database queries, AI tool calls, and MCP server tools. An AI agent calling a patient lookup tool gets the same obligation-driven redaction as a nurse accessing the same data through a web UI. The policy is the same. The enforcement mechanism is the same.

The examples in this guide are from the polyglot demo suite: spring-demo, Python demos (FastAPI, Flask, Django, Tornado), the NestJS demo, and the FastMCP demo. All implement the same constraint handler patterns and share the same SAPL policies.

Two interception points

The PEP sits between the caller and the protected method. It enforces obligations at two distinct points in the execution chain:

Execution chain: Caller to PRE (modify arguments) to Method to POST (filter response), with return path back to caller

The caller can be a web request, a service method, an AI agent invoking a tool, or an MCP client calling a server tool. The PEP does not care. It intercepts the method, evaluates the policy, and applies obligations.

Pre-invocation handlers modify the method’s arguments before it executes. When the method is a database query, this means the query itself changes. The database only returns authorized rows. Unauthorized data never leaves the database, never crosses the network, and never enters the application’s memory. When the method is an AI tool call, the policy can cap parameters, inject constraints, or narrow the scope of the operation before the tool runs. This is the strongest form of data-level security.

Post-invocation handlers transform or filter the method’s return value after it executes. The method runs with its original arguments, retrieves the full result, and the PEP applies redaction, field removal, or collection filtering before returning to the caller. When an AI agent calls a patient lookup tool, the response is redacted before the LLM ever sees the data. This is simpler to implement but means the full dataset is retrieved first. For large result sets, this has performance and security implications: the data briefly exists in the application’s memory before filtering.

Both interception points use the same policy. The obligation type determines which handler runs. The annotation determines which points are available:

Annotation PRE (modify arguments) POST (filter response)
@PreEnforce Yes No
@PostEnforce No Yes
@QueryEnforce Yes (query rewriting) Yes (result filtering)
@Enforce* (streaming) Yes Yes

@PreEnforce evaluates the policy before the method runs. It can modify arguments but never sees the return value. @PostEnforce evaluates the policy after the method runs, using the return value as part of the authorization subscription. It can transform the response but cannot modify the arguments. @QueryEnforce does both: it rewrites the query before execution and can filter the result set after. The streaming enforcement annotations (@EnforceTillDenied, @EnforceDropWhileDenied, @EnforceRecoverableIfDenied) also wrap the method and have access to both interception points.

Pre-invocation: modifying arguments

A fund transfer endpoint accepts an amount. The policy permits the transfer but caps the amount at 5,000:

policy "permit-transfer"
permit
  action == "transfer";
  resource == "account";
obligation
  {
    "type": "capTransferAmount",
    "maxAmount": 5000
  }

The method is annotated with @PreEnforce, which tells the PEP to evaluate the policy and apply obligations before the method executes:

@PreEnforce(action = "'transfer'", resource = "'account'")
public Mono<TransferResult> doTransfer(Double amount, String recipient) {
    return Mono.just(new TransferResult(amount, recipient, "completed"));
}
@pre_enforce(action="transfer", resource="account")
def do_transfer(amount: float = 10000.0, recipient: str = "default"):
    return {"transferred": amount, "recipient": recipient, "status": "completed"}
@Post('transfer')
@PreEnforce({ action: 'transfer', resource: 'account' })
transfer(@Query('amount') amount: string, @Query('recipient') recipient: string) {
    return { transferred: Number(amount), recipient, status: 'completed' };
}

The application registers a constraint handler that the PEP calls when it encounters the capTransferAmount obligation. The handler modifies the method’s arguments before the method runs. The endpoint function never sees the original value.

@Component
class CapTransferHandler implements MethodInvocationConstraintHandlerProvider {

    @Override
    public boolean isResponsible(Value constraint) {
        return constraint instanceof ObjectValue obj
            && obj.get("type") instanceof TextValue t
            && "capTransferAmount".equals(t.value());
    }

    @Override
    public Consumer<ReflectiveMethodInvocation> getHandler(Value constraint) {
        var maxAmount = ((NumberValue) ((ObjectValue) constraint).get("maxAmount"))
            .value().doubleValue();
        return invocation -> {
            var args = invocation.getArguments();
            for (int i = 0; i < args.length; i++) {
                if (args[i] instanceof Double d && d > maxAmount) {
                    args[i] = maxAmount;
                    invocation.setArguments(args);
                    return;
                }
            }
        };
    }
}
class CapTransferHandler:

    def is_responsible(self, constraint):
        return isinstance(constraint, dict) \
            and constraint.get("type") == "capTransferAmount"

    def get_handler(self, constraint):
        max_amount = constraint.get("maxAmount", 0)

        def handler(context):
            if "amount" in context.kwargs:
                requested = float(context.kwargs["amount"])
                if requested > max_amount:
                    context.kwargs["amount"] = max_amount
                return
            for i, arg in enumerate(context.args):
                if isinstance(arg, (int, float)) and arg > max_amount:
                    context.args[i] = max_amount
                    return

        return handler
@Injectable()
@SaplConstraintHandler('methodInvocation')
export class CapTransferHandler implements MethodInvocationConstraintHandlerProvider {

  isResponsible(constraint: any): boolean {
    return constraint?.type === 'capTransferAmount';
  }

  getHandler(constraint: any): (context: MethodInvocationContext) => void {
    const maxAmount = constraint.maxAmount;
    return (context) => {
      const requested = Number(context.args[0]);
      if (requested > maxAmount) {
        context.args[0] = maxAmount;
      }
    };
  }
}

The request POST /api/transfer?amount=8000 reaches the endpoint with amount = 5000. The policy capped it. The endpoint processed it. The client received the result. All three frameworks produce the same output from the same policy.

Post-invocation: built-in content filtering

For common data transformations, no custom handler is needed. SAPL ships a built-in filterJsonContent obligation that supports three actions on any JSON path:

policy "permit-patient-full"
permit
  action == "readPatientFull";
  resource == "patientFull";
obligation
  {
    "type": "filterJsonContent",
    "actions": [
      { "type": "blacken", "path": "$.ssn", "discloseRight": 4 },
      { "type": "delete", "path": "$.internal_notes" },
      { "type": "replace", "path": "$.email", "replacement": "redacted@example.com" }
    ]
  }

The endpoint returns a patient record. The PEP applies the obligation:

Field Original After obligation
ssn 123-45-6789 XXXXXXXX6789
internal_notes "Patient history..." (deleted)
email jane@hospital.org redacted@example.com

No handler code. The obligation type filterJsonContent is registered by the SDK. The policy author controls what gets blackened, deleted, or replaced.

Post-invocation: filtering collections

When the endpoint returns a list, an obligation can filter elements by a policy-defined criterion. A classification filter removes documents the user should not see:

policy "permit-documents"
permit
  action == "readDocuments";
  resource == "documents";
obligation
  {
    "type": "filterByClassification",
    "maxLevel": "INTERNAL"
  }

The endpoint returns four documents:

[
  { "title": "Q3 Report",     "classification": "PUBLIC" },
  { "title": "Org Chart",     "classification": "INTERNAL" },
  { "title": "Merger Plan",   "classification": "CONFIDENTIAL" },
  { "title": "Board Minutes", "classification": "SECRET" }
]

The client receives two:

[
  { "title": "Q3 Report",  "classification": "PUBLIC" },
  { "title": "Org Chart",  "classification": "INTERNAL" }
]

The handler is a FilterPredicateConstraintHandlerProvider in Spring, a FilterPredicateConstraintHandlerProvider in Python, and the equivalent in NestJS. Each checks the element’s classification against the policy-specified maxLevel and excludes elements that exceed it. Elements without a classification are excluded (fail-closed).

AI tool calls and MCP servers

The same obligation mechanisms apply when an AI agent calls a tool or an MCP client calls a server tool. The PEP intercepts the tool invocation, evaluates the policy, and applies obligations. The AI agent or LLM never sees unauthorized data.

Pre-invocation: a policy caps the number of results an AI agent can request from a search tool. The limitResults handler modifies the limit parameter before the tool executes. The LLM asked for 1000 results. The tool received 50. The agent does not know the limit was applied.

Post-invocation: a policy redacts PII from patient records before they reach the LLM. The tool returns the full record. The PEP blackens the SSN and removes the address. The LLM generates its response from the redacted data. The original data never enters the model’s context window.

This is the same filterJsonContent obligation, the same MethodInvocationConstraintHandlerProvider, and the same MappingConstraintHandlerProvider shown above. The FastMCP demo implements all of these handlers for MCP tool calls. See the AI Tool Authorization and MCP Server Authorization guides for the full AI-specific walkthrough.

Pre-invocation at the database: query rewriting

When the method being protected is a database query, argument transformation becomes row-level security. Spring’s @QueryEnforce annotation integrates SAPL with R2DBC and MongoDB to rewrite queries before they reach the database. The database only returns rows the user is authorized to see. No post-query filtering. No data leakage through pagination.

@PreEnforce and @PostEnforce use Spring AOP to intercept any method on any Spring bean. @QueryEnforce works differently. It hooks into Spring Data’s repository proxy pipeline via RepositoryFactoryCustomizer, which means it understands the query semantics of the method it protects. It knows whether the method uses a @Query annotation or a Spring Data method-name query, and it transforms the query before the database executes it. The obligation conditions become part of the SQL WHERE clause or MongoDB filter document. The database only returns authorized rows.

@Repository
public interface BookRepository extends R2dbcRepository<Book, Long> {
    @QueryEnforce(action = "'findAll'")
    Flux<Book> findAll();
}

The policy attaches query conditions as obligations. The SDK translates these into WHERE clauses or MongoDB filter documents. The application code does not construct these conditions. The policy does.

SQL (R2DBC)

The r2dbcQueryManipulation obligation injects SQL WHERE clause fragments into the query:

set "book listing"
first or abstain errors propagate
for action == "findAll"

policy "deny if scope null or empty"
deny
    subject.principal.dataScope in [null, undefined, []];

policy "enforce filtering"
permit
obligation {
    "type": "r2dbcQueryManipulation",
    "conditions": [
        "category IN " + string.replace(
            string.replace(
                standard.toString(subject.principal.dataScope),
                "[", "("),
            "]", ")")
    ]
}

A user with dataScope: [1, 2, 3] triggers the obligation "conditions": ["category IN (1, 2, 3)"]. The SDK appends this as a WHERE clause. The original SELECT * FROM books becomes SELECT * FROM books WHERE category IN (1, 2, 3).

The obligation supports additional fields beyond conditions:

Field Purpose Example
conditions SQL WHERE fragments (AND-combined) ["active = true", "role = 'USER'"]
selection Column projection (whitelist or blacklist) {"type": "whitelist", "columns": ["id", "name"]}
transformations SQL functions applied to columns {"firstname": "UPPER", "email": "LOWER"}
alias Table alias for qualified column names "p"

MongoDB

The mongoQueryManipulation obligation injects MongoDB query documents:

set "book listing"
first or abstain errors propagate
for action == "findAll"

policy "deny if scope null or empty"
deny
    subject.principal.dataScope in [null, undefined, []];

policy "enforce filtering"
permit
obligation {
    "type"       : "mongoQueryManipulation",
    "conditions" : [
        "{ 'category' : { '$in' : " + subject.principal.dataScope + " } }"
    ]
}

The conditions are MongoDB query documents as JSON strings. Multiple conditions are AND-combined. The SDK merges them with the repository method’s existing @Query annotation.

Field Purpose Example
conditions MongoDB query documents (AND-combined) ["{ 'status': 'active' }", "{ 'price': { '$lte': 100 } }"]
selection Field projection (whitelist or blacklist) {"type": "blacklist", "columns": ["password", "ssn"]}

Both R2DBC and MongoDB query rewriting use built-in constraint handlers. No custom handler code is needed. The policy author writes the conditions, and the SDK applies them to the database query.

For JPA (blocking), the same result is achieved using @PreEnforce with a custom MethodInvocationConstraintHandlerProvider that modifies the method’s filter parameter before execution (as shown in the argument modification section above).

See the queryrewriting demos for working examples with each database technology.

Seven handler types

The examples above show three of the seven constraint handler types available in every SDK:

Type Phase What it does Example
MethodInvocation Pre-invocation Modifies arguments before method runs Cap transfer amount, rewrite query
Mapping Post-invocation Transforms return value Redact fields, reshape response
FilterPredicate Post-invocation Filters collection elements Classification filter
Runnable On decision Side effect (no data access) Log access
Consumer Post-invocation Observes return value (read-only) Audit trail
ErrorHandler On error Observes exception Notify on error
ErrorMapping On error Transforms exception Add support URL

All seven are available in Spring, Python (Flask, FastAPI, Django, Tornado), NestJS, and .NET. The filterJsonContent built-in handles the most common case (blacken, delete, replace on JSON paths) without any custom handler code.