Skip to content

RBAC & permissions

Genie’s authorization has two nouns.

A Resource is a protected thing — a view, dashboard, report, wizard or API surface — and it declares only what it can do. A Permission is a grant: it says who may do it, and under what conditions.

Resource "Orders" the protected thing
└── capabilities: List, View, Update, Delete, Export, Approve
Permission the grant
Orders → role "Supervisor"
└── List filtered to the caller's company
Update only while the order is a draft
Approve only for submitted orders under 50,000

Everything below follows from that split. The server re-validates on every operation; anything a response carries about permissions is a UI hint.

An operation a resource offers is a capability: a name, and nothing else. No type, no default, no value — it is permitted or it is not.

Framework verbs and business operations are the same kind of thing and live in the same list:

Capability Meaning
List see the grid
View read one record
Create insert
Update edit
Delete remove
Export export data
Import import data
Execute run an action or workflow
anything else a business operation — Approve, Reopen, Recalculate

The eight verbs above are the vocabulary the engine itself asks in (HasAccessAsync(user, "Orders", PermissionVerb.Update)). A caller’s verb flags are projected from the capability names they hold — computed, never stored.

A category groups resources in the admin UI and supplies the default capability set when a resource is declared without one:

Category Default capabilities
ObjectView List, View, Create, Update, Delete, Export, Import, Execute
Wizard List, View, Update, Delete, Export
Dashboard, Report, Page List, View, Export
Api, External, Workflow Execute
Configuration List, View, Update

Defaults only — a resource may declare its own set, and an administrator may add or remove capabilities afterwards.

A grant names a role or a user — exactly one, enforced by a check constraint.

Granting one person access to one report no longer means inventing a single-member role. The canonical use is individual or temporary access: “this contractor may update Orders until 31 December.”

Resolution unions the two and merges them most-permissively, so a direct grant is not a special case anywhere downstream.

Permission
ResourceId the thing
RoleId? | UserId? the principal — exactly one
CompanyId? tenant scope
StartTime?/EndTime? access window
└── capabilities, each with its optional rules

A grant with a CompanyId applies only while that company is the active session company. A grant without one applies wherever the principal does.

Role grants normally leave it null, because the role carries its own company. Direct user grants should set it: a user may belong to several companies, so a company-less user grant would apply in all of them. The admin UI stamps the caller’s active company automatically.

StartTime / EndTime bound a grant in time. Outside the window it confers nothing — no capabilities and no rules. Leave both blank for permanent access.

A granted capability can be narrowed by a rule — and which rule depends on the capability, because each question can only be answered at one stage. A Condition on List has no row to test; a row filter on Create has no rows to scope.

Capability Rule it carries Question Enforced
List, View, Export Filter which rows may I reach? in SQL, before any row is returned
Update, Delete, row actions Condition is this row in a state that permits it? after mapping → Permit__<Name>; re-checked on write
Create, Import, Execute Check are the values I’m submitting acceptable? before the write

A row action is the one capability answering to two — it names a row and can prompt for input, so it takes a Condition on the row and a Check on what the prompt submitted.

Two capabilities inherit rather than repeat, and both use the same silence-versus-explicit distinction Unrestricted draws: View and Export inherit List’s filter when they say nothing about rows, and Import inherits Create’s check when it states none. A rule of one’s own is a deliberate statement and is never widened by the one it would have inherited.

The authoring form follows this: pick List and it offers a Filter and its placement; pick Update and it offers a Condition; pick Create and it offers a Check. Nothing else is shown, because nothing else would be read.

A Filter is admin-authored SQL composed into the query before any row comes back.

It must reference session parameters rather than interpolated values — the data path binds them before executing, so no caller input ever enters the predicate:

Parameter Value
@SessionUserId the current user’s id
@SessionCompanyId the user’s active company
@SessionRoles comma-separated role names — split with STRING_SPLIT(@SessionRoles, ',')
@SessionCartId the browser/cart id

That set is open: a host adds its own by implementing ISessionParameterProvider, and they are discovered automatically.

Silence is not the same as “every row”

Section titled “Silence is not the same as “every row””

A row-scope capability has three states, not two. The empty one is the default, so it has to mean “I have nothing to say” rather than “no limit”:

Filter Unrestricted Means In the form
empty false silent — defer to whatever else scopes this both left alone
empty true every row, deliberately Allow every row ticked
set false scoped to that predicate Filter + placement filled in

Unrestricted="true" is the checkbox labelled “Allow every row — deliberate, so no other filter narrows it.” Same flag; the label spells out what leaving it clear does not do. The two together are a contradiction and are refused in both places.

The distinction is load-bearing twice:

  • A record read and an export inherit the grid’s scoping. View and Export run the same authored SQL as List, so either one that is silent about rows takes the List filter. Give one a filter of its own only when it must be narrower; tick Allow every row when it must be wider.
  • Adding a principal cannot widen anyone else’s scope. If it could, a second role — or a grant made to someone by name — that simply omitted a filter would erase the scoping every other grant imposes: a fail-open caused by granting a principal, not by editing a rule.

One filter, three capabilities scoped:

<Grant Resource="Orders" Capabilities="View,Export">
<Capability Name="List" FilterMode="Builder">
<Filter><![CDATA[ Status <> 'Closed' ]]></Filter>
</Capability>
<!-- View and Export say nothing about rows -->
</Grant>
The caller… resolves inherits? scoped by
opens the grid List List never inherits Status <> 'Closed'
opens one closed order View silent → borrows List Status <> 'Closed' → refused
exports Export silent → borrows List the same rows as the grid
deletes it, or runs a row action on it View (target-row guard) silent → borrows List refused

Without that fallback the second row resolves unrestricted: the grid hides the order while POST /api/v1/genie/object/values reads it in full, which would make the grid filter cosmetic.

To let this role open any historical order while keeping its working grid clean, say so — and note that leaving View blank could not have expressed it, because blank is how you asked to inherit:

<Capability Name="View" Unrestricted="true"/>

A filter must say where it belongs. One of two placements is required as soon as a predicate is present — in the form, in rbac.xml, and on import:

Mode Behaviour Choose it when
Builder AND-appended to the outer WHERE the engine builds (wrapped, for a single-record read) the predicate only names columns the view’s <Sql> projects
Inline substituted where the view author wrote {{permission-filter}}; nothing is appended the predicate must reach a column, alias or join the view keeps to itself

A declared mode that disagrees with the authored SQL is rejected, not silently degraded — Inline with no placeholder would drop the filter entirely, which is a fail-open. That check is precisely why placement is not inferred: it has nothing to check without a stated intent.

Auto remains the stored value for a capability carrying no predicate, where placement is a question about nothing. It is not offered when authoring, and Auto together with a filter is refused.

When several principals contribute a filter they merge into one OR-ed predicate, so one mode has to win: Inline beats Builder beats Auto.

A Condition decides whether an operation is legal on a row the caller already has. It is a Genie expression over the row’s columns plus session values, and it surfaces on that row as Permit__<Capability>.

<Capability Name="Approve">
<Condition>Status == 'Submitted'</Condition>
</Capability>

Four things to get right:

  • Write it as a positive permit. Every evaluation failure — a typo, an unknown column, a type mismatch — yields false. Status == 'Draft' denies on a mistake; Status != 'Approved' would grant on one.
  • Column names are case-sensitive and must match what the query returns.
  • The dialect has no IS NULL and no string functions. Use Col == null, and LIKE.
  • A Condition only narrows. It never grants what the capability itself withholds.

A Check guards a write by the values being submitted. It runs after field normalisation, so what it judges is exactly what will be written — defaults materialised, write-protected fields restored.

<Capability Name="Approve">
<Condition>Status == 'Submitted'</Condition>
<Constraint><![CDATA[ TotalAmount <= 50000 ]]></Constraint>
</Capability>

Mode decides where the check runs, which is not the same as how it is written — the SQL-flavoured predicate syntax (@Field, LIKE, IN, AND/OR, bare =) is accepted either way:

Mode Sees Costs
Expression (default) the submitted values and session parameters nothing — in process, cheap enough to run per row on an import
Sql those plus the database, so it can EXISTS into another table one round trip per write
<Capability Name="Create">
<Constraint Mode="Sql"><![CDATA[
EXISTS (SELECT 1 FROM Customers c WHERE c.Id = @CustomerId AND c.CreditHold = 0)
]]></Constraint>
</Capability>

In Sql mode the predicate is admin-authored and composed verbatim — the same trust a Filter carries — but the submitted values are bound as parameters, never interpolated. That is what makes it safe to let a check reference @CustomerId.

A refusal is a 400, not a 403: the caller holds the capability, so it is the values that are refused, and the message names the capability. An import refuses as a whole and names the offending rows, because the import already runs in one transaction — a refusal found mid-write would roll the accepted rows back anyway.

If the view SQL already returns a Permit__<Capability> column, that answer is used and the grant’s Condition is not evaluated at all — on read and on write, so display and enforcement cannot disagree.

SELECT o.*,
CASE WHEN o.Status = 'Draft' AND o.LockedBy IS NULL
THEN 1 ELSE 0 END AS [Permit__Update],
CASE WHEN o.Status = 'Draft' AND NOT EXISTS (SELECT 1 FROM Shipments s WHERE s.OrderId = o.Id)
THEN 1 ELSE 0 END AS [Permit__Delete]
FROM Orders o
WHERE {{permission-filter}}

Use it when the rule needs a join, an EXISTS or a window function — things the expression dialect cannot say. The test is key presence, not truthiness: a SQL NULL is still an answer.

SQL may answer “is this row eligible”. It may never answer “do you hold this capability.” A query returning Permit__Update = 1 for a caller with no Update grant yields false.

Permit__ is framework-owned, like Parent__, Form__ and Table__ in parameters. A view declaring a column named Permit__* is rejected at import.

The separator does the work, not the word: Permit, PermitNumber and PermitType are perfectly good business columns and are never mistaken for a capability.

A row action is gated on the capability of its own name — <RowAction Name="Approve"> requires the Approve capability, with nothing declared twice. Its optional Permission attribute overrides that with another capability’s name; because verbs are capabilities too, Permission="Update" is the same lookup.

A misspelled Permission resolves to a capability nothing declares, so the action is withheld rather than falling through to a default.

Whether a specific row qualifies is the capability’s Condition, resolved per row and re-checked server-side on execution — the row is re-fetched and re-judged, so a client that sends a Permit__Approve of its own gets nowhere.

@PermissionCapabilities carries the comma-joined names of the capabilities the caller holds:

WHERE EXISTS (SELECT 1 FROM STRING_SPLIT(@PermissionCapabilities, ',') WHERE value = 'Approve')

Only names cross that boundary. A capability’s filter, condition and constraint are authored predicates carrying table names, column names and business logic, and they never leave the server.

In React, hasCapability(model.Permissions, "Approve") gates a host button the same way the built-in verbs gate the standard toolbar.

A resource declaration is one line. Capabilities is the complete list; omit it to take the category’s defaults.

<Rbac>
<Resources>
<!-- Takes the Dashboard defaults: List, View, Export. -->
<Resource Name="InventoryDashboard" Category="Dashboard"/>
<!-- Complete list. Approve is a business operation, declared like any verb. -->
<Resource Name="Orders" Category="ObjectView"
Capabilities="List,View,Update,Delete,Export,Approve"/>
<!-- Long form, only when a capability needs a description for whoever grants it. -->
<Resource Name="PurchaseOrders" Category="ObjectView">
<Capability Name="List"/>
<Capability Name="Approve" Description="Approve a submitted order"/>
</Resource>
</Resources>
<Roles>
<Role Name="Supervisor">
<!-- Capabilities= grants with no rules; the elements grant with rules. -->
<Grant Resource="Orders" Capabilities="View,Export">
<Capability Name="List" FilterMode="Builder">
<Filter><![CDATA[
CompanyId = @SessionCompanyId
AND TeamId IN (SELECT id FROM Org.fn_user_teams(@SessionUserId))
]]></Filter>
</Capability>
<Capability Name="Update">
<Condition>Status == 'Draft'</Condition>
</Capability>
<Capability Name="Create">
<Constraint Mode="Sql"><![CDATA[
EXISTS (SELECT 1 FROM Customers c
WHERE c.Id = @CustomerId AND c.CreditHold = 0)
]]></Constraint>
</Capability>
<Capability Name="Approve">
<Condition>Status == 'Submitted'</Condition>
<Constraint><![CDATA[ Total <= 50000 ]]></Constraint>
</Capability>
</Grant>
</Role>
</Roles>
</Rbac>

A resource uses one form or the other — the compact attribute or the <Capability> elements, never both. A grant may combine them, because most grants hold a handful of plain capabilities and narrow only one or two.

Rule values are element bodies, never attributes: a filter is SQL and a condition is an expression, and both routinely contain <, > and newlines.

.rbac.xml is a source-controlled definition that moves between environments; usernames are runtime data. Naming individuals would break that split — and a full-sync import would start deleting people’s access.

Every import stage is scoped to a principal explicitly, so a grant made to a person survives an import that does not mention it, including --prune. Direct grants are made in the admin UI.

genie_import_rbac throws, naming the resource, capability and role, on:

  • A retired element — <Permissions>, <Permission>, <Allow>, <Attribute Key=…>. The message names the replacement.
  • A document declaring no resources and no roles. An empty result is almost always an unrecognised shape, and treating it as empty would deploy silently and leave you with no permissions.
  • Granting a capability the resource does not declare, or a resource the file does not declare.
  • An unknown Category or FilterMode.
  • FilterMode with no <Filter>, or Unrestricted together with a <Filter>.
  • Both capability forms on one resource, or two rule elements of the same kind on one capability.
  • An empty rule body — a rule that exists but says nothing is a mistake, not a permit.
Method Route Purpose
GET /api/v1/genie/auth/export-rbac download the current state as rbac.xml
POST /api/v1/genie/auth/import-rbac/preview?prune=false report what importing a file would do
POST /api/v1/genie/auth/import-rbac?prune=false import a file

All three are System-only: the file describes global definitions, so a company-scoped administrator must not be able to rewrite them wholesale.

Baseline import (prune=false) upserts resources, capabilities and roles, and fully syncs each named role’s grants — a grant the file drops for a role it names is removed. Roles the file does not name are untouched.

prune=true additionally soft-deletes orphan resources, their capabilities and their role grants, plus orphan roles. The System, Admin and AccessManager roles are never deleted, and a role that still has user assignments is reported rather than removed.

Export writes role grants only, for the reason above.

*.rbac.xml is not part of the default startup model sweep. Opt in per host:

"Genie": { "Migration": { "FilePatterns":
[ "*.entity.xml", "*.view.xml", "*.sql", "*.navbar.xml", "*.rbac.xml" ] } }

Two more things a file has to match, both of which fail at query time rather than import:

  • A <Filter> may only reference columns the view’s <Sql> projects under Builder placement, where it is AND-appended to the outer query. A predicate on an un-projected column fails with Invalid column name. Use {{permission-filter}} inside the view’s own SQL with FilterMode="Inline" to scope on a column the view keeps to itself.
  • A <Constraint Mode="Sql"> runs one query per write. Prefer the default Expression mode unless the answer genuinely depends on data the submission does not carry.

import-rbac/preview returns the same change set an apply would produce — every row created, updated or removed, each removal with its reason — and changes nothing. It is not a second implementation of the merge: the server runs the real one inside a transaction and rolls it back, so a preview cannot disagree with the apply that follows it.

Use it for anything with prune=true. That flag soft-deletes orphan resources, their capabilities, role grants and whole roles, and until the preview existed it was a single button with nothing to inspect first. The board’s Transfer tab keeps Apply disabled until a preview has been taken, and discards the preview if the file or the prune setting changes.

Everything about access lives on one page: the Access Dashboard at /authorization, in the System module. Open to System, Admin and AccessManager — the same three roles the nav offers it to.

Two panes, sharing one height so neither can push the other off screen; each scrolls on its own.

Principals on the left — roles or people, whichever the Principal type switch is set to, each with where it belongs and how many people its access reaches. Ten at a time, with Show more and a count of what is not shown.

Resource permissions on the right — the resources this principal actually holds a grant on, and each row’s grant shown as the capabilities it confers:

Column What it says
Resource the name, its category, and how many capabilities are conferred
Granted access one tag per conferred capability, verbs before business operations, then + Add
Window Always, or Pending / Active / Expired for a time-bounded grant
(last) opens the grant itself — its description and access window

The list is what this principal holds, ten/twenty/fifty per page. That is the question the page answers; a catalogue of every resource in the tenant with “No capabilities granted” against most of it buries it. Clear Granted only to browse the whole catalogue — which is how you grant a capability on a resource the principal has no grant on yet. (The pane’s + does the same thing from the other end: it creates the grant, with a description and an access window.)

A tag says three things at once:

  • Green — granted, with no further restriction.
  • Amber — granted but narrowed by a filter, condition or check, so restricted access cannot be mistaken for full access.
  • Dashed — staged, not yet applied (see below).

Hovering a tag names each rule and whether it is set, so which of them narrows the grant is readable without opening anything. The predicates themselves are not there — see the disclosure note at the end of this section — and clicking the tag opens the PermissionCapabilities form to read and edit them.

The tooltip lists only the rules that capability can carry, the same set the form offers, so the two describe one model:

Hovering Rows Row condition Submitted values
List View Export ✓ — —
Update Delete — ✓ ✓
Create Import Execute — — ✓
a row action — ✓ ✓

Rows reads back all four of its answers, not two: Filtered, Every row (ticked deliberately), Scoped by List for a silent View or Export, and Every row the view returns when nothing scopes it. The third is the one worth having — a bare “not set” there reads as unrestricted, which is the opposite of what the engine does with it.

Inheriting does not turn the tag amber. The predicate belongs to the capability it is borrowed from, so colouring it here would report one filter twice under two names; the tooltip says where the scope comes from instead.

Clicking a tag’s × stages a revoke. + Add lists what this resource declares and this principal lacks — a capability the resource never declared is absent rather than disabled, because the grant references it by foreign key and so it was never a choice an administrator had.

Above the panes, Company scope narrows the resource list to the shared (global) resources or to one company. It only ever narrows: naming a company outside your own scope is refused, not honoured.

Clicking a capability tag opens the PermissionCapabilities form for that grant. It shows one rule section, chosen from the capability itself, because a rule the engine would never read is not an option worth offering:

Field Shown for rbac.xml What goes in it
Capability always the Name what this row narrows. Offers only what the resource declares and this grant lacks; locked once saved, since repointing it would orphan the rule
Note always Description why this grant is narrowed — read by people, never by the engine
Allow every row List View Export Unrestricted="true" reach every row deliberately. Leaving it clear is not the same thing
Filter (SQL predicate) List View Export <Filter> SQL over session parameters. Disabled while Allow every row is ticked
Filter placement List View Export FilterMode Builder — append to the WHERE, or Inline — the view embeds {{permission-filter}}. Required once a filter is present
Condition (on the stored row) Update Delete, row actions <Condition> an expression over the row → Permit__<Capability>
Check runs Create Import Execute, row actions Mode on <Constraint> In process — over the submitted values, or In the database — may reach other tables
Check (on the submitted values) Create Import Execute, row actions <Constraint> a predicate over what is about to be written

Capability group is derived from the capability’s name and stays hidden: it is the consequence that decides which section you see, never a choice. Pick List and only Which rows is there; pick Update and only Which rows qualify; pick Create and only What may be submitted. A row action shows the last two, because it names a row and can collect input.

Three walkthroughs, one per shape:

Scope a grid. Supervisor should see only their own company’s open orders. Tag List → Filter CompanyId = @SessionCompanyId AND Status <> 'Closed', Filter placement Builder. Leave View and Export untouched and they inherit it.

Gate an edit on row state. Supervisor may edit only drafts. Tag Update → Condition Status == 'Draft'. Each row now carries Permit__Update, the grid’s Update control follows it, and a hand-crafted POST /object/submit against a non-draft is refused by the same rule.

Cap what may be written. Nobody on this role raises an order over 50,000. Tag Create → Check runs In process, Check TotalAmount <= 50000. Submitting 60,000 is a 400 naming Create and writes nothing. Leave Import alone and the same ceiling applies to every imported row. Switch to In the database when the answer needs another table: EXISTS (SELECT 1 FROM Customers c WHERE c.Id = @CustomerId AND c.CreditHold = 0).

Two refusals worth recognising, both enforced server-side rather than in the browser:

  • A contradiction — “Allow every row” means all of them; a filter means some. Pick one. Clear whichever one you did not mean.
  • Filter placement left empty with a filter present. The mismatch check has nothing to check without it, so the form requires it.

Whichever way a rule was authored, it reads back the same: export-rbac writes the form’s values as the <Filter> / <Condition> / <Constraint> elements above, and an import fills the same fields.

A toggle does not write. It joins a batch, the header’s Review count goes up, and nothing reaches the database until Apply changes. That buys three things:

  • Granting Approve across six resources is one decision, reviewed as a list of sentences rather than as six tags that changed colour.
  • A mis-click is free. Toggling a staged capability again drops the staged change; it never issues a second write.
  • A refusal is legible. Apply reports per change, so seven of eight can succeed; the one the server refused stays staged, carrying its reason, instead of vanishing.

A staged batch belongs to one principal, so switching principal — or principal type — asks before discarding it.

The Manage menu holds the destinations beside the workspace: the Users, Roles, Resources and Companies grids with their own sub-views (a role’s members and grants; a person’s roles, direct grants and effective access), and the read-only Access explorer trees (what a person can reach across every route, and who can reach a resource).

The RBAC menu holds the definition file. Export rbac.xml downloads it there and then, because exporting only reads. Import rbac.xml… does not import: it opens the transfer panel, where the file is previewed before anything is written — the diff is the point, and a menu item that silently merged someone’s file would be the one dangerous button on the page. Both are System-only server-side, so a caller who lacks it sees them greyed out with the reason rather than an item that 403s.

Granting a capability on a resource with no grant yet creates one conferring exactly that capability. (Adding a grant from the Roles or Users grid still starts from everything the resource offers — that is what “add a grant” means there, and narrowing is then a deliberate act in the Capabilities sub-view.)

Every picker offers only what is not already assigned. The Resource picker on a role’s grant omits resources that role already holds; the Role picker on a person omits roles they already hold; likewise the capability, member and company-member pickers. All of these views refuse a duplicate on submit, so offering one only ever produced an error to read. The exclusion is gated on the caller being able to administer the parent, so it cannot be used to read someone else’s assignments off what is missing from a list.

Every change the board makes goes through the same management-view SQL the standalone grids use, so the company guard, the System-role protection and the “capability belongs to this resource” check are enforced once, server-side, in both dialects. The board cannot be more permissive than the grid because it is the grid’s write path. Rule text never travels as board data either — the tags carry only whether a rule exists, and the predicates are edited through the model-driven form, which passes the ordinary admin disclosure gate. The System role is absent from the board entirely: its access is not grant-driven, so a workspace for it would show nothing granted while the role can do everything.