Kizaki
Guides

Authorization

Model row access with ownership, roles, and deny rules instead of spreading checks through handlers and UI code.

Start with the simplest rule that matches your app:

  • ownership for personal data
  • roles for admin access
  • denies for hard stops such as immutable records

Then layer them as the product grows. Most apps never need more than two or three policies per entity.

Ownership

The most common starting point. Grant access when the authenticated user matches the entity's owner field.

entity Document {
  title: string
  body: string
  ownerId: __User.id

  @grant read, write where resource.ownerId == principal.id
    @why("Authors manage their own documents.")
}

This single rule covers reads, creates, and updates. The database engine evaluates resource.ownerId == principal.id on every query against Document. There is no way to forget the check and no way to bypass it from application code.

Role-Based Access

When certain users need broader access than ownership provides, add a role grant. System roles are declared in your schema and assigned to users through the platform.

@system("admin") {
  displayName: "Administrator"
}

entity Document {
  title: string
  body: string
  ownerId: __User.id

  @grant read, write where resource.ownerId == principal.id
    @why("Authors manage their own documents.")
  @grant read, write, delete to role(Admin)
    @why("Admins have full access to all documents.")
}

Role grants are additive. A user who is both an owner and an admin gets the union of both grants.

Junction Access (Many-To-Many)

When access depends on membership in a group, use a via grant that joins through a junction entity.

entity Team {
  name: string
}

entity TeamMember {
  teamId: Team.id
  userId: __User.id
  role: string = "member"

  @unique([teamId, userId])
}

entity Project {
  name: string
  description: string
  teamId: Team.id

  @grant read to * via TeamMember
    @why("Team members can view shared projects.")
  @grant write to * via TeamMember where via.role == "editor"
    @why("Only editors can modify projects.")
}

The database engine automatically joins through TeamMember to determine access. You do not write the join yourself, and it cannot be skipped. The via clause matches on the foreign key relationship: Project.teamId connects to Team.id, and TeamMember.teamId + TeamMember.userId connect the user to the team.

You can further restrict via grants with conditions on the junction entity, as shown with via.role == "editor" above.

Field-Level Grants

When a role should see only certain columns, specify the fields in the grant.

entity Customer {
  name: string
  email: string
  phone: string
  internalNotes: string
  ownerId: __User.id

  @grant read where resource.ownerId == principal.id
    @why("Customers see all their own data.")
  @grant read(name, email) to role(Support)
    @why("Support sees contact info only.")
  @grant read, write to role(Admin)
    @why("Admins have full access.")
}

When a principal matches multiple read grants, the allowed columns are the union of all matching grants. If any matching grant has no field restriction (like the ownership grant above), all fields are returned. Field-level grants are most useful when a role sees a strict subset of columns and no broader grant applies to that role.

Deny Rules

Deny rules create hard boundaries that no grant can override. Deny always wins.

entity AuditLog {
  action: string
  detail: string
  userId: __User.id
  createdAt: datetime

  @grant read to role(Admin)
    @why("Admins can review the audit trail.")
  @deny write to *
    @why("Audit records are append-only.")
  @deny delete to *
    @why("Audit records cannot be removed.")
}

Use deny rules for invariants that must hold regardless of who is asking:

  • immutable records that should never be modified after creation
  • preventing deletion of system-critical data
  • protecting specific status transitions

A @deny delete to * cannot be overridden by any grant, including admin grants.

Combining Patterns

A realistic entity often layers ownership, roles, and deny rules together.

entity Order {
  total: decimal(10,2)
  status: OrderStatus
  customerEmail: string
  ownerId: __User.id

  @grant read, write where resource.ownerId == principal.id
    @why("Customers manage their own orders.")
  @grant read to role(Support)
    @why("Support can view any order.")
  @grant read, write, delete to role(Admin)
    @why("Admins have full access.")
  @deny delete where resource.status != "Draft"
    @why("Only draft orders can be deleted.")
}

The deny rule here is conditional. Admins can delete draft orders (their grant allows it and the deny does not apply), but no one can delete an order that has moved past draft status.

The @why Annotation

Every grant and deny should include a @why explaining the business reason behind the rule.

@grant read to * via TeamMember
  @why("Team members can view shared projects.")

@why serves two purposes. It documents intent for the next developer (or agent) working in the schema, preventing misinterpretation of subtle authorization rules. It also appears in the security report generated by kizaki compile --security-report, making it possible to audit the full authorization model without reading every entity definition.

Write @why annotations as statements about the business rule, not descriptions of the syntax. "Customers manage their own orders" is better than "Grants read and write when ownerId matches."

A Good Mental Model

Think about authorization in layers:

  1. who can invoke a workflow belongs at the exposed-function or route boundary
  2. which rows are visible or mutable belongs on the entity
  3. which fields should come back is a combination of field-level grants and .fields()

This split prevents a common failure mode where an endpoint is protected but still reads or returns more data than intended.

Common Mistakes

Putting authorization in handler code instead of policies. Move if (user.role !== "admin") throw ... checks from @expose functions to entity policies. Handler-level checks are easy to forget when new endpoints are added.

Overly broad grants. A @grant read, write to * with no condition lets every authenticated user read and modify every row. Start with ownership or role-based restrictions and broaden only when the product requires it.

Forgetting that deny always wins. If admins cannot delete records, check for a @deny delete to *. Deny is intentionally absolute. If only some users should delete, use a conditional deny or replace the blanket deny with selective grants.

Using system roles where junction policies work. If access means "members of this team can see these projects," use a via grant through a membership entity. Creating a system role per team does not scale.

On this page