Authorization Enforcement
Authorization rules are enforced by the platform at the data layer, not recreated independently in each handler and client.
Policies defined in Inspire apply to queries, mutations, routes, includes, and live queries. They are not guidelines for application code to follow. They are constraints the platform enforces on every data operation, regardless of how or where that operation originates.
How Enforcement Works
The compiler translates @grant and @deny rules from your schema into SQL WHERE clause fragments, stored in the compiled artifact. At runtime, the database engine injects these fragments into every query before execution. Application code never constructs its own WHERE clauses for access control.
Consider a schema with a simple ownership grant:
entity Project {
name: string
ownerId: string
@grant read where resource.ownerId == principal.id
}When browser code executes a query like this:
const projects = await query(
select(Project).fields(Project.id, Project.name)
);The database engine transforms it before it reaches PostgreSQL:
SELECT id, name FROM projects WHERE owner_id = $1The $1 parameter is the authenticated principal's ID, supplied by the platform. The application did not add that WHERE clause. It cannot omit it. Every read against Project is scoped to rows the principal is authorized to see.
This applies uniformly: SDK queries, @expose function calls, live query refreshes, and includes all pass through the same enforcement layer.
Deny Always Wins
If any @deny rule matches, the operation is blocked regardless of how many @grant rules also match. This makes denies safe for hard invariants.
entity AuditLog {
action: string
actorId: string
createdAt: datetime
@grant read to role(Admin)
@deny delete to *
@deny update to *
}No one can delete or update audit log entries. Not admins, not the system, not any future role you add. The deny is absolute. You do not need to exclude audit logs from bulk-delete operations in your application code. The database engine refuses the operation.
This property is useful for compliance-sensitive data, immutable records, and any entity where certain operations should be structurally impossible rather than conventionally avoided.
Field-Level Grants
Grants can restrict which columns a principal receives, not just which rows.
entity Customer {
name: string
email: string
phone: string
internalNotes: string
@grant read(name, email) to role(Support)
@grant read to role(Admin)
}A principal with only the Support role sees name and email. A principal with the Admin role sees all columns. If a principal holds both roles, the allowed columns are the union of all matching grants. In this case, all columns, because the Admin grant has no field restriction.
The rule: when a principal matches multiple read grants, allowed columns are merged. If any matching grant specifies no field restriction, all columns are returned.
Missing Policy Means Missing Row
A query that returns no rows because the principal lacks access does not produce a 403 error. The result is simply empty.
This is by design. Application code does not need to distinguish between "no data exists" and "you are not allowed to see it." A project list for a user with no projects and a project list for a user with no read access both return an empty array. The frontend renders the same empty state either way.
// Returns [] if the user has no projects, or if they lack read access
const projects = await query(select(Project));
// Returns null if the project doesn't exist, or if the user can't read it
const project = await query(
select(Project).where(eq(Project.id, projectId)).first()
);This has a security property: an unauthorized user cannot probe for the existence of rows they cannot access. The response is indistinguishable from "that row does not exist." The query returns exactly what the principal is allowed to see, nothing more.
Mutations Follow The Same Model
Enforcement is not limited to reads. Insert, update, and delete operations are also governed by policies.
entity Order {
status: string
customerId: string
total: number
@grant create where resource.customerId == principal.id
@grant update where resource.customerId == principal.id
@deny update where resource.status == "fulfilled"
}A user can only create orders assigned to themselves. They can update their own orders, but not once the order is fulfilled. The deny on fulfilled orders is absolute.
If a mutation targets a row the principal cannot access, the operation fails. Unlike reads, mutations surface errors: you cannot silently skip a write the user explicitly requested.
Where Application-Level Checks Still Belong
The data layer handles row-level and field-level access. But not every authorization question is about data access. Some are about workflow validity.
- Can this user start a checkout? (Depends on cart state, payment method, business rules.)
- Has this order already been fulfilled? (Depends on workflow state, not just row ownership.)
- Is this invitation still valid? (Depends on expiry, usage count, external conditions.)
These decisions belong in @expose functions, where you have the full context of the operation:
export const startCheckout = expose(async (cartId: string) => {
const cart = await query(select(Cart).where(eq(Cart.id, cartId)).first());
if (!cart) throw new Error("Cart not found");
if (cart.items.length === 0) throw new Error("Cart is empty");
if (cart.status !== "active") throw new Error("Cart is not active");
// Proceed with checkout logic
});The policy system already ensured the principal can read their own cart. The function-level logic validates that the checkout operation makes sense right now.
Use policies for "who can see and modify what data." Use function-level logic for "is this operation valid in the current state."
Summary
- Policies belong with entities, in the schema, close to the data they protect.
- Workflow-specific admission checks belong at the function or route boundary.
- Frontend code should not invent access decisions.
- The data layer enforces policies on every operation. Application code benefits from this without needing to replicate it.
Policies go in the schema. Workflow logic goes in handlers. The platform carries the authorization burden so application code does not have to.