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,000Everything below follows from that split. The server re-validates on every operation; anything a response carries about permissions is a UI hint.
Capabilities
Section titled “Capabilities”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.
Resource categories
Section titled “Resource categories”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.
Who a grant is made to
Section titled “Who a grant is made to”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 rulesTenant scope
Section titled “Tenant scope”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.
Access window
Section titled “Access window”StartTime / EndTime bound a grant in time. Outside the window it confers nothing — no capabilities
and no rules. Leave both blank for permanent access.
The rules
Section titled “The rules”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.
Filters
Section titled “Filters”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.
ViewandExportrun the same authored SQL asList, so either one that is silent about rows takes theListfilter. 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"/>Where the predicate lands — FilterMode
Section titled “Where the predicate lands — FilterMode”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.
Conditions
Section titled “Conditions”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 NULLand no string functions. UseCol == null, andLIKE. - A Condition only narrows. It never grants what the capability itself withholds.
Checks — the pre-write rule
Section titled “Checks — the pre-write rule”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.
Letting SQL answer
Section titled “Letting SQL answer”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 oWHERE {{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.
The Permit__ prefix is reserved
Section titled “The Permit__ prefix is reserved”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.
Row actions
Section titled “Row actions”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.
Reading capabilities in view SQL
Section titled “Reading capabilities in view SQL”@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.
Authoring in .rbac.xml
Section titled “Authoring in .rbac.xml”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.
Direct user grants stay out of the file
Section titled “Direct user grants stay out of the file”.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.
Import errors
Section titled “Import errors”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
CategoryorFilterMode. FilterModewith no<Filter>, orUnrestrictedtogether 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.
Export & import
Section titled “Export & import”| 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.
Applying it at deploy time
Section titled “Applying it at deploy time”*.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 underBuilderplacement, where it is AND-appended to the outer query. A predicate on an un-projected column fails withInvalid column name. Use{{permission-filter}}inside the view’s own SQL withFilterMode="Inline"to scope on a column the view keeps to itself. - A
<Constraint Mode="Sql">runs one query per write. Prefer the defaultExpressionmode unless the answer genuinely depends on data the submission does not carry.
Preview before applying
Section titled “Preview before applying”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.
Managing it in the UI
Section titled “Managing it in the UI”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.
The workspace
Section titled “The workspace”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.
Setting a rule — the Capabilities form
Section titled “Setting a rule — the Capabilities form”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.
Changes are staged, then applied
Section titled “Changes are staged, then applied”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
Approveacross 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 rest
Section titled “The rest”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.
Related
Section titled “Related”- Row-level security — company scoping
- Field security —
EditorFieldAllow/Deny - Disclosure gate — structure vs data
- GenieClient —
export-rbac/import-rbac