# Row-level security

> Policies that bind a logical attribute to a value set for a set of subjects, injected as a filter into the query plan below any aggregation: decided per query, cached per decision, and fail-closed

Source: https://aipril.dev/docs/platform/access/row-level-security

## Decision and enforcement are separate

The design follows the standard split, which is what keeps the rule out of the client:

- **The decision point** answers, for a subject and a set of resources: denied, these row filters,
  these column masks.
- **The enforcement point** sits between compiling a query and executing it. It asks for the
  decision, and **injects it into the plan**.

Nothing downstream can opt out, because by the time a query executes the restriction is part of it.

## How a policy is expressed

A policy binds an **attribute** - a logical name mapped to a physical column - to a **value set**, for
a set of subjects.

Value sets are reusable allow-lists, and an entry can be:

- a **static literal**,
- a **claim from the caller's token**, resolved at decision time,
- or a **per-subject value** loaded in advance.

The middle one is what makes a single policy serve an organisation: "the region on the caller's
token" is one policy, not one policy per region.

## How policies combine

| Situation | Result |
|---|---|
| Several policies on the same attribute | Their values are OR-unioned |
| Policies on different attributes | AND-combined, as nested filters |
| An empty allowed set, with nulls not permitted | No rows, explicitly and not by accident |

## Where the filter lands in the plan

**Strictly below any aggregation.** That is the detail that decides whether row security is real:
filtering after a sum gives a correct-looking total computed over rows the caller may not see. Here
the restriction applies before the aggregate, so a total is the total of what this person may see.

## Fail-closed

If the decision cannot be reached, the request errors out. It does not log and continue, and it does
not fall back to unrestricted.

Calls arriving without a subject - a service token, a trusted internal path - skip row security by
construction, and the platform **records that as a bypass event** instead of leaving it
indistinguishable from a normal query.

## Freshness

Decisions are cached to keep repeated queries cheap, and every write to a policy bumps a version and
invalidates the cached decisions that depended on it. A permission change does not wait for a cache
to expire.

## Auditability

Every decision, and every bypass, is written to a decision log, queryable and administrator-gated -
so "why did this person see this row" and "what ran without row security" are both answerable after
the fact. The platform-wide record is separate - see [Audit](https://aipril.dev/docs/platform/audit.md).

## Related

- [Column masking](https://aipril.dev/docs/platform/access/column-masking.md) - the second half of the decision: which
  values inside the rows a caller may read.
- [Roles and grants](https://aipril.dev/docs/platform/access/roles.md) - whether the object is visible at all, decided
  before any of this.
- [Data engine](https://aipril.dev/docs/engine/under-the-hood.md) - where the plan these filters are injected into is
  compiled and executed.
- [Tenant isolation](https://aipril.dev/docs/platform/access/tenant-isolation.md) - the layer underneath, answering
  which organisation a query is even looking at.
