Access Control
Kirak uses an AccessPolicyEngine that runs on every request before any database query executes. It combines role-based access control (RBAC) and row-level security (RLS) into a single, stateless decision.
Deny by Default, Everywhere
Section titled “Deny by Default, Everywhere”Access is decided by the model’s access block:
Model has "access" block? -> Yes: the operation's role list decides (RBAC + optional RLS) -> No: every operation is denied (403), for every role| Model | Behaviour |
|---|---|
"access" present |
Deny by default; explicit per-operation role lists with optional RLS |
No "access" key |
Every operation denied – there is no open or “dev” mode |
A model without an access block is locked, not open: startup logs a warning naming it (“all operations will be denied”). Set "strict_access": true in kirak.json to turn that warning into a startup error, so a forgotten block is caught before deploy instead of showing up as 403s. To make an operation public, list guest (no credential) or * (any role, guest included) for it.
The access Block (Policy Path)
Section titled “The access Block (Policy Path)”Deny by Default
Section titled “Deny by Default”If a model has an access block and an operation is NOT listed in it, all roles are denied regardless of what the JWT contains:
{ "access": { "fetch": [{ "role": "user" }] // "destroy" not listed -> 403 for everyone, including admins }}Role Matching
Section titled “Role Matching”For each operation, Kirak scans the rules array and applies the first matching rule:
- Exact match –
rule.role === current_user.role– wins over wildcard. - Wildcard –
"role": "*"– matches any user, including unauthenticated requests. Applied only if no exact match found.
{ "access": { "fetch": [ { "role": "admin" }, // exact match for admins { "role": "*" } // fallback: anyone, including unauthenticated users ], "destroy": [ { "role": "admin" } // admins only -- wildcard not present ] }}Row-Level Security (RLS)
Section titled “Row-Level Security (RLS)”Add a condition to a rule to automatically append a parameterized WHERE fragment. The user can only see or modify rows that match the condition.
{ "access": { "fetch": [{ "role": "user", "condition": "user_id = {user_id}" }], "update": [{ "role": "user", "condition": "user_id = {user_id}" }], "delete": [{ "role": "user", "condition": "user_id = {user_id}" }] }}{user_id} is a placeholder resolved from the current user’s JWT claims. If the claim is missing, the value resolves to NULL, which matches zero rows – safe-deny by omission.
Multiple conditions joined with AND/OR:
{ "role": "manager", "condition": "department_id = {department_id} AND is_active = 1" }Condition Grammar
Section titled “Condition Grammar”RLS conditions are validated at startup against a strict grammar. Any condition that doesn’t match is rejected with ConfigurationError – preventing injection at configuration time.
Allowed form: field operator {variable_or_literal} [AND|OR ...]
| Allowed operators | Example condition |
|---|---|
= |
user_id = {user_id} |
!= / <> |
status != {status} |
<, >, <=, >= |
score >= {min_score} |
IS NULL |
deleted_at IS NULL |
IS NOT NULL |
archived_at IS NOT NULL |
LIKE |
name LIKE {pattern} |
Values are always parameterized – never interpolated into the SQL string. The engine uses dialect-aware placeholders (%s for MySQL, $N for PostgreSQL).
Ownership Injection on Create
Section titled “Ownership Injection on Create”For create operations, when the condition is a simple equality (field = {variable}), Kirak automatically injects the ownership field into the INSERT payload. The caller does not need to supply it – and cannot override it:
{ "create": [{ "role": "user", "condition": "user_id = {user_id}" }]}Create request { "data": { "title": "Post" } } -> Kirak injects user_id from the JWT automatically. Attempting to override user_id in the request body returns 403.
For complex conditions (OR, AND, subqueries), ownership injection is skipped and bulk create is disabled. Use single-record create with explicit user filtering in a hook instead.
Field-Level Security
Section titled “Field-Level Security”Control which roles can read or write individual fields using access.fields:
{ "access": { "fetch": [{ "role": "*" }], "create": [{ "role": "admin" }], "fields": { "salary": { "read": ["admin", "hr"], "write": ["admin"] }, "ssn": { "read": [], "write": [] }, "phone": { "read": ["admin", "user"] } } }}Semantics:
- Field listed with non-empty array -> role must appear in the array.
- Field listed with
[](empty array) -> nobody can access it (hidden from all roles). - Field absent from
access.fields-> no restriction (all readable/writable roles have access).
Field-level rules apply after the operation-level permission check passes. A user who is denied at the operation level never reaches field-level evaluation.
The legacy per-field mechanism (read_roles, write_roles, create_roles, update_roles on individual field definitions) is supported for backward compatibility but cannot be mixed with access.fields on the same model. New models should use access.fields.
JWT Claim Resolution
Section titled “JWT Claim Resolution”Condition placeholders ({variable}) are resolved from the authenticated user’s token claims – no users row is read. The placeholders available are:
| Placeholder | Description |
|---|---|
{user_id} |
User’s primary key, from the sub claim (integer, or UUID string) |
{role} |
User’s role string (user, admin, superadmin, etc.) |
{email} |
User’s email address |
No other claims are available: other users columns, users.extra_data, and data added to the login response by an after_login hook are not in the token, and Kirak has no way to add claims to it. The one exception is a call from your own code after set_user_context(...): placeholders then resolve from the keys of that identity, so it can carry more (see Tenant isolation). A role change reaches the token at its next refresh (see JWT Tokens).
If a placeholder references a claim that is absent from the token, the resolved value is NULL. field = NULL matches zero rows – the request continues but returns no data.
Common Patterns
Section titled “Common Patterns”Public read, authenticated write
Section titled “Public read, authenticated write”{ "access": { "fetch": [{ "role": "*" }], "search": [{ "role": "*" }], "count": [{ "role": "*" }], "create": [{ "role": "user" }, { "role": "admin" }], "update": [{ "role": "user", "condition": "author_id = {user_id}" }, { "role": "admin" }], "delete": [{ "role": "user", "condition": "author_id = {user_id}" }, { "role": "admin" }], "destroy": [{ "role": "admin" }] }}Admin-only model
Section titled “Admin-only model”{ "access": { "fetch": [{ "role": "admin" }], "create": [{ "role": "admin" }], "update": [{ "role": "admin" }], "delete": [{ "role": "admin" }], "destroy": [{ "role": "admin" }] }}Tenant isolation (multi-tenant)
Section titled “Tenant isolation (multi-tenant)”{ "access": { "fetch": [{ "role": "user", "condition": "tenant_id = {tenant_id}" }], "create": [{ "role": "user", "condition": "tenant_id = {tenant_id}" }], "update": [{ "role": "user", "condition": "tenant_id = {tenant_id}" }], "delete": [{ "role": "user", "condition": "tenant_id = {tenant_id}" }] }}tenant_id is not a token claim, and Kirak has no way to add one: the access token always carries exactly sub, email, role, jti, type, iat and exp, and an after_login hook runs after it is signed. So on Kirak’s own routes (/api/..., GraphQL) {tenant_id} resolves to NULL and these rules return no rows, create nothing and change nothing – they fail closed.
Serve tenant data from your own routes instead. Look up the caller’s tenant, add it to the identity with set_user_context, and make the calls inside it; the conditions then resolve {tenant_id} from that identity:
from fastapi import Requestfrom kirak.core.context import reset_user_context, set_user_context
@app.get("/my-tenant/projects")async def tenant_projects(request: Request): kirak = request.app.state.kirak caller = (await kirak.auth.get_current_user({"request": request})).get("data") or {} token = set_user_context({"role": "system", "user_id": None, "token": None}) try: membership = await kirak.fetch("memberships", {"user_id": caller["user_id"], "limit": 1}) finally: reset_user_context(token) rows = membership.get("data") or [] tenant_id = rows[0]["tenant_id"] if rows else None # no membership: NULL, no rows
token = set_user_context({**caller, "tenant_id": tenant_id}) try: return await kirak.fetch("projects", {}) finally: reset_user_context(token)The memberships lookup runs as system, so give that model a {"role": "system"} fetch rule. Any key you set this way can be used as a placeholder; one the identity lacks resolves to NULL. Do not filter tenants in a before_fetch hook: an async hook that raises or times out is skipped, and the query then runs unfiltered.