Skip to content

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.


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.


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
}
}

For each operation, Kirak scans the rules array and applies the first matching rule:

  1. Exact match – rule.role === current_user.role – wins over wildcard.
  2. 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
]
}
}

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" }

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).

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.


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.


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.


{
"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" }]
}
}
{
"access": {
"fetch": [{ "role": "admin" }],
"create": [{ "role": "admin" }],
"update": [{ "role": "admin" }],
"delete": [{ "role": "admin" }],
"destroy": [{ "role": "admin" }]
}
}
{
"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 Request
from 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.