Skip to main content

Row-level access

The problem

In a CRM, salespeople should only see their own customers. In a multi-tenant SaaS, each customer should only see their own data. In an HR app, an employee should only see their own expense claims (their manager sees their team's).

You don't want to enforce this in the frontend — too easy to bypass. You don't want to write an authorisation middleware per connector — too easy to forget. You want the database to do it.

The pattern

Every query the framework runs has access to :session_user — a placeholder bound on the server from the caller's JWT, never from the client. Adding WHERE owner = :session_user to a query restricts the rows the caller can see, at the SQL level, without any framework or client code involved.

A few variations of the same idea:

Use casePredicate
One row per ownerWHERE owner = :session_user
Multi-tenantWHERE tenant_id = (SELECT tenant FROM users WHERE id = :session_user)
Manager sees teamWHERE owner IN (SELECT id FROM users WHERE manager = :session_user OR id = :session_user)
Role-based escalationWHERE owner = :session_user OR :session_user IN ('admin', 'sales-manager')

The framework's permission system stays in place — sql:customers:list still gates whether the caller can run the query at all. Row-level access narrows the rows returned by that query.

The recipe

1. Schema — add the owner column

ALTER TABLE customers ADD COLUMN owner VARCHAR(64);
UPDATE customers SET owner = 'alice' WHERE; -- backfill existing rows

The owner is whatever identifier matches the JWT's sub claim — typically the username, or an email, or an internal user ID — pick one and stay consistent.

2. Filter the read query

SELECT id, name, status, industry
FROM customers
WHERE owner = :session_user
ORDER BY name;

Test the query as the admin user — empty result. Test as alice after assigning her ownership of some rows — only her rows return.

3. Filter writes too

The same pattern for UPDATE and DELETE:

-- update
UPDATE customers SET name = :name, status = :status
WHERE id = :id AND owner = :session_user;

-- delete
DELETE FROM customers WHERE id = :id AND owner = :session_user;

The AND owner = :session_user guard prevents the API call DELETE /api/sql/customers/delete?id=42 from succeeding when row 42 belongs to someone else. The query returns "0 rows affected"; the framework treats this as a 404 and the response is exactly the same as "row doesn't exist". No information leak.

4. Force the owner on insert

INSERT INTO customers (name, status, owner, created_at)
VALUES (:name, :status, :session_user, CURRENT_TIMESTAMP);

Notice :session_user instead of :owner — even if the form payload sneaks an owner parameter, the SQL ignores it and uses the server-side value. The user can only create rows owned by themselves.

5. Role-based escalation

When the manager should see everything, drop the predicate based on the JWT's role list:

SELECT id, name, status, industry, owner
FROM customers
WHERE owner = :session_user
OR 'manager' = ANY(string_to_array(:session_roles, ','))
ORDER BY name;

The framework binds :session_roles (comma-separated list of the caller's roles) on every call. The expression "either it's mine or I'm a manager" handles both cases in one query.

For Oracle / SQL Server, replace the array trick with INSTR(:session_roles, 'manager') > 0.

Multi-tenant pattern

For SaaS / multi-tenant apps, the predicate moves from per-row owner to per-tenant filtering. A typical setup:

-- The tenant lookup needs to happen inside the query (we can't trust a 'tenant' parameter from the client)
WITH user_tenant AS (
SELECT tenant_id FROM ly2_users WHERE username = :session_user
)
SELECT *
FROM invoices
WHERE tenant_id = (SELECT tenant_id FROM user_tenant);

Or with a JWT custom claim — the framework's OIDC layer surfaces every claim under :session_<claim_name>:

WHERE tenant_id = :session_tenant_id

The tenant_id claim is set by the IdP / SSO layer; the framework binds it like any other session value.

Performance notes

ConcernMitigation
Index the owner columnCritical. Every read query filters by it; an index turns it from full-scan to single-row lookup.
N+1 anti-patternDon't loop per-row in the application; let one query with WHERE owner = :session_user return the right set. The framework does this naturally.
Per-tenant materialized viewsFor massive tenants, a per-tenant view (vw_invoices_tenant_42) might outperform a generic WHERE predicate. Switch the connector's query target accordingly.

What this gives you

PropertyWhy
Frontend can't bypass the gateThe frontend never sees rows for other users; row-level filtering is in SQL.
Direct REST calls can't bypass itThe framework binds :session_user from the verified JWT, not from the request body.
The AI assistant inherits the gateThe same :session_user placeholder applies to AI-triggered queries. An admin asks "how many customers do I have?" → all rows. A salesperson asks the same → their rows only.
Excel export inherits the gateThe export endpoint runs the same query; the rows it ships are the filtered set.
Audit trail is preservedcreated_by = :session_user records who created each row; nobody can impersonate.

Variations

You want…Do this
Public rows + private rows on the same tableWHERE owner = :session_user OR is_public = true.
Read-only access to other-owner rowsThe read query has no WHERE; the write queries do. A salesperson sees everyone's customers but can only edit their own.
Time-bound accessWHERE owner = :session_user OR (shared_until > CURRENT_TIMESTAMP AND shared_with = :session_user) — the row goes back to private once the share expires.
Per-role row visibilityCombine :session_user with :session_roles as in the manager example. Or define separate queries (list-mine / list-team / list-all) and gate each with its own permission code.

What's next