Access control
Content access control: CRUD RBAC + field/document sensitivity
The admin UI this note plans for is barakoBrew, now in its own repository (BaryoDev/barakoBrew). The API side is unchanged.
What exists today vs what needs building, for per-role CRUD, row-level scope, and granular field sensitivity on content types. All of this is core (content types + RBAC already live in core), not a module.
There are three layers, and all three are built. Layer 3 (sensitivity) started as a hardcoded proof of concept and now reads each field's sensitivity off the content type's schema; the layer 3 section below has both.
All three are enforced in C#, and that is a decision rather than an accident: DECISIONS.md D11.
Layers 1 and 2 are IPermissionResolver; layer 3 is SensitivityService, which is the reason for
the decision. Row-level security filters rows and has nothing to say about which fields inside one a
caller may see, so moving the boundary into Postgres would leave the most sensitive layer behind.
The layer 2 conditions compile to a SQL predicate where they can (#445), so GET /api/contents for
a named content type pages and counts in the database instead of loading the collection. That
changed where the rules are evaluated, not where they are enforced: the predicate is built from
the same rules IPermissionResolver reads, and the per-item check still runs over the page it
returns.
Where a rule cannot be compiled faithfully the compiler declines and the endpoint loads everything
and checks per item, exactly as it did before. PermissionPredicateCompiler lists what it refuses
and why. Declining costs a slow query; guessing would cost somebody a row they may not read, so the
refusals are deliberate and the list is meant to grow slowly.
PermissionPredicateAgreementTests runs generated rules and generated content through both
evaluators and asserts they select the same rows. Two evaluators for one condition language drift,
and the drift appears as rows silently appearing or vanishing rather than as an error, so that test
is the only thing that can tell you it has happened. Deleting it turns the compiler into a
liability.
Layer 1: CRUD per content type per role (already works)
Each role grants create, read, update and delete per content type:
Role.Permissionsis aList<ContentTypePermission>.ContentTypePermission=ContentTypeSlug+Create/Read/Update/Delete, each aPermissionRule { Enabled, Conditions }, with optional field sets (see "which fields a rule shows" below).PermissionResolverenforces it: additive union across a user's roles (granted if ANY role allows), SuperAdmin bypasses, conditions evaluated per row.- Content endpoints (Create/List/Get/Update/Delete/ChangeStatus) call
CanPerformActionAsync(user, contentTypeSlug, action, content).
So a role set such as treasurer, secretary and admin is configuration, no code:
| Role | payment (content type) |
|---|---|
| Treasurer | Read ✔, Create ✘, Update ✘, Delete ✘ |
| Secretary | Read ✔, Create ✔, Update ✔, Delete ✘ |
| Admin | Read ✔, Create ✔, Update ✔, Delete ✔ |
| Member | Read ✔ (own only, see Layer 2), rest ✘ |
Set these via the Roles endpoints / admin UI. Because logic is additive, a user with both Treasurer and Secretary gets the union.
ContentTypePermission.Transitions sits alongside the four CRUD rules, keyed by
transition name, for a content type that declares its own lifecycle. Update
does not imply a transition and a transition does not imply update, with one
stated exception: a transition that declares requiredFields or
optionalFields lets whoever may perform it write those fields, and no
others, with the move.
Approval by configuration walks an invoice
through Submit and Approve against the API, with every request as a curl and the
status code each one answers.
Layer 2: Row-level scope (already works)
PermissionRule.Conditions holds JSON predicates (operators such as _eq) evaluated by
IConditionEvaluator, e.g. a Member reads only their own rows:
{ "Read": { "Enabled": true, "Conditions": { "memberId": { "_eq": "$CURRENT_USER" } } } }
A write is checked against the entry before and after it. An update, a collection push, a
rollback and a transition carrying values need the granting rule to hold for the stored entry and
for the entry as it will be stored. A create needs a Create rule to hold for the entry as it will
be stored, with the caller as its creator, after sensitivity, validation and lifecycle hooks have
run. So a rule limited to Branch A cannot create an entry in B, or move one there. A rule
with no conditions grants every entry, as before. A public form submission and a portability
import are not asked the Create rule: the form's own settings and the import capability decide
those.
Something about the caller other than their id
$CURRENT_USER is the caller's user id. $CURRENT_USER.<name> is a value from the caller's member
profile in the current tenant, so a branch manager reads their branch's orders without having
created them:
{ "Read": { "Enabled": true, "Conditions": { "Branch": { "_eq": "$CURRENT_USER.branch" } } } }
The profile is Membership.Profile, a map of text values. It is written with the member, by
POST /api/tenants/members and PUT /api/tenants/members/{userId} (profile), both behind
manage_tenant_members, and by nothing else. A member holding that capability can set their own
profile, which is within what they can already do by assigning themselves a role. A member without
it cannot. Both routes replace the whole profile when the request sends one and keep the stored one
when it does not, except that a member who had been removed and is added again starts with what the
request carries, or none. A profile holds at most 32 attributes; a name starts with a letter, holds
letters, digits and underscores, and is at most 64 characters; a value is at most 256 and holds no
control character. The audit entry for a member write records the names added, removed and
changed, never the values.
What a rule author needs to know:
- The variable is the whole of the value of
_eqor_ne. Under_inor_ninthe rule denies, and inside a list it is plain text, as$CURRENT_USERis. - Names are case sensitive.
$CURRENT_USER.branchdoes not readBranch. - A caller with no value matches nothing, whatever the operator: no active membership in this
tenant, no attribute of that name, or an empty value.
_nedoes not grant in that case. - The value is compared as text, the way a value written into the rule is, so a profile value of
42matches a number field holding 42. A field that holds a list or an object matches nothing. - It is read from the membership on every request and is not in the token. A change applies to the member's next request.
- A user whose roles come only from
User.RoleIds, with no membership row in the tenant, has no profile. Add them as a member of the tenant to give them one.
Something about the entry a row points at
An enrollment names a class, and only the class names its instructor. A condition key written
Reference.Field follows the row's reference field to the entry it points at and compares a field
of that entry, so an instructor reads the enrollments of the classes they teach without the
instructor's id being copied onto every enrollment:
{ "Read": { "Enabled": true, "Conditions": { "Class.InstructorUser": { "_eq": "$CURRENT_USER" } } } }
Class is a field of the enrollment type declared as reference, and InstructorUser is a field
of the type it points at. The same key works on a Read, an Update and a transition rule, with
_eq, _ne, _in and _nin, and beside conditions on the row itself.
It denies unless all of this holds, whatever the operator is:
- The first name is a field the row's type declares as a
referenceholding one id, spelled as declared. A reference withmultipleis not followed, since a list has no one answer: "any entry matches" grants more than one id did, and "every entry matches" grants on an empty list. If the field's type is changed later, the condition stops granting. - The row holds an id there, as text in the hyphenated form and no other: eight, four, four, four and twelve hexadecimal digits. Upper or lower case both read.
- The id is an entry in this tenant, of the type the reference declares, whose document sensitivity is Public. An erased entry and one in another tenant are both not there.
- The caller may read that entry under their own Read rules for its type. The instructor's role needs a Read rule on classes that covers the class.
- The second name is a field the referenced type declares with Public sensitivity, and the entry holds it.
- The comparison is on text:
_eqand_neagainst a text value ($CURRENT_USERand$CURRENT_USER.<name>included),_inand_ninagainst a list of text holding at least one. A number or a true/false written into the rule denies here, where on a field of the row itself it is compared as text.
One reference is followed, not two. Class.Teacher.Email is refused when the role is saved, and
if the Read rule on classes itself follows a reference, that rule grants nothing to a condition
arriving through an enrollment (the class can still be read directly under it).
A key holding a dot is never looked up in the row's own data. An entry write keeps keys its type
does not declare, so before this a rule naming Class.InstructorUser matched a row carrying a key
spelled exactly that. It no longer does.
POST /api/roles and PUT /api/roles/{id} check such a condition and answer 400 for a key that is
not two names around one dot, a first name that is not a reference field of the rule's content type
(a reference with multiple counts as not one, with the same message),
a second name that is not a Public field of the referenced type, an operator outside the four, a
comparison that is not on text, a content type the tenant does not define, and a condition on a
Create rule (Create has no stored entry to follow a reference from). One write checks such
conditions on at most 50 content types.
The check is against the content types of the tenant the request is made in. Roles are stored once for all tenants, so in another tenant the same condition resolves against that tenant's types and denies where they do not declare it. An update passes over a condition the stored role already holds unchanged (same content type, same rule, same key, same operators and values), so a role holding a condition written for another tenant's types, or one stored before this check existed, can still be renamed or given another permission from here. A condition the update adds, edits or moves is checked.
A role stored with a key the check would refuse still reads back, and the condition denies. At start the API logs one warning naming the roles, by name and id, that hold a dotted condition following a reference in no tenant, so the roles an upgrade changed can be found.
What it costs, and where it stops:
- A check on one entry (get, update, status change, transition, preview) loads the entry each of its references points at: one read for each. The same holds for the first ten rows a request asks about, which covers the candidates of a get by slug and a push of up to ten entries. A row holding two references into one type is one row.
- A pass over more rows than that resolves the condition once, to the ids of the referenced
entries that satisfy it and that the caller may read, and tests each row's reference against
that set. That is one query per condition, whoever else's rows the pass walks over.
GET /api/contentswith acontentTypebuilds its query from the same ids, so it filters, pages and counts in the database. - The set holds at most 1,000 ids: referenced entries that satisfy the condition and that the
caller may read. A condition keyed on the caller (
_eq $CURRENT_USER) rarely reaches that. One that is not, such asClass.Visibility _eq publicor any_neor_nin, reaches it as the tenant grows. What happens then depends on the pass:GET /api/contentswith acontentTypekeeps working when the database can answer the whole condition, which is when the caller's Read rules for the referenced type compile to SQL. The list is filtered by a subquery in place of the ids, with no bound on how many entries it selects, and the rows of each page load the entries they point at.- A check on up to ten rows keeps working, as above.
- Every other pass over many rows is refused with a 403 whose reason names the condition and
the bound:
GET /api/contentswith nocontentType, the export, the page tree, a push of more than ten existing entries, and a list of a named type when the caller's Read rules for the referenced type do not compile (a$statusrule, for one). It is refused at the eleventh row, or before the list loads anything, and one warning is logged. It is not answered with the rows it reached, because nothing in such a list would say it was short. The cure is on the role: narrow the condition.
- When the caller's Read rules for the referenced type do not compile, the matches are read as whole entries, 500 to a statement, each starting after the last id the one before it read, and each match is asked the read rules in memory. So the 1,000 counts what the caller may read. At most 2,000 matches are read this way, in four statements, and more than 2,000 matches leaves the condition with no set however few of them the caller may read. Nothing is kept between requests, so every page of a named list and every pass over more than ten rows pays this again, and a request that is then refused has paid it first. A Read rule on the referenced type that compiles costs one query for ids.
- What a request has read this way is dropped when its session commits, so a write followed by a check in the same request reads again.
- A refusal by id is still 403, as it is for every other condition.
Layer 3: Field + document sensitivity
This is the "Employee has SIN + birthday sensitive, rest viewable" ask, plus the
Off / SensitiveOnly / All mode. It is built. SensitivityService reads each
field's Sensitivity off the content type's own schema, masks on read and
reverts on write, and Sensitivity:Mode selects the mode. See "What it does now"
below for the shipped behaviour.
What it started as, kept for the history (a hardcoded proof of concept)
The section below describes the state this replaced. SensitivityFilter no
longer exists and nothing names AttendanceRecord in the filtering path.
- Document-level works:
Content.Sensitivityenum (Public/Sensitive/Hidden). But the role mapping is hardcoded in the filter:Hidden=> only SuperAdmin,Sensitive=> only SuperAdmin or HR. - Field-level is hardcoded in
SensitivityFilterfor exactly one content type:AttendanceRecord, whereSSNis removed unless SuperAdmin andBirthDayis masked to***unless HR. There isConsole.WriteLinedebug spam and a duplicate, unusedSensitivityService. - Enforced as an
IGlobalPostProcessorthat scrubs the responseDataafter the endpoint runs.
It demonstrates the idea but is not data-driven, not configurable, and roles are baked in.
Target design (make it generic + granular + role-based)
1. Put sensitivity on the field schema. Extend FieldDefinition:
public SensitivityLevel Sensitivity { get; set; } = SensitivityLevel.Public;
public List<string> VisibleToRoles { get; set; } = new(); // who may see it when not Public
public FieldMask Mask { get; set; } = FieldMask.Remove; // Remove | Redact("***") | Last4
So the Employee content type declares, per field:
| Field | Sensitivity | VisibleToRoles | Mask |
|---|---|---|---|
| SIN | Hidden | [Admin] | Last4 |
| Birthday | Sensitive | [Admin, HR] | Redact |
| Name | Public | n/a | n/a |
| Public | n/a | n/a |
Result: a Treasurer who can Read Employee sees Name/Email but SIN as ***-6789
and Birthday as ***; HR also sees Birthday; Admin sees everything.
2. Rewrite the filter to be data-driven. Load the ContentTypeDefinition
for content.ContentType (cached), apply document-level (using a configurable
level->roles policy, not hardcoded SuperAdmin/HR), then per-field masking from
the schema. Delete the AttendanceRecord hardcode, the debug lines, and the
duplicate service.
3. Mode switch Sensitivity:Mode:
- Off: no scrubbing (dev / fully trusted internal tools).
- SensitiveOnly: scrub only fields/docs marked Sensitive or Hidden; Public flows through. This is the normal production mode.
- All: intended as strict lockdown, where any field not explicitly visible to
the caller's role is withheld. Not implemented. Scrubbing branches on
Offand nothing else, soAllwould behave exactly asSensitiveOnly. Setting it is refused at startup rather than accepted silently, because an operator who asks for lockdown is the one who cannot afford to getSensitiveOnlyinstead.
4. Role mapping is data-driven. A field's VisibleToRoles gives granular
control; when empty, fall back to a configurable default policy
(Sensitive -> [HR, Admin], Hidden -> [Admin]). SuperAdmin always sees all.
5. Protect the write path too. Sensitivity today only scrubs reads. A non-privileged role that can Update the content could still overwrite SIN. The create/update validators must drop or reject sensitive fields the caller is not allowed to see, so masking cannot be bypassed by writing.
6. Admin UI. The content-type editor gets per-field sensitivity controls (level, roles, mask); the role editor already has the CRUD matrix.
Where it lives
Core. It extends the existing content schema (FieldDefinition), the existing
RBAC (Role / PermissionResolver), and the existing SensitivityFilter. Not
a module.
Update (2026-07-16): field-level sensitivity FIXED
The bug below is fixed. Sensitivity is now one schema-driven service
(SensitivityService) that Get, List, and History all call explicitly; the
broken global post-processor and the duplicate document-only service are gone.
Field masking is declared on the schema (FieldDefinition.Sensitivity +
VisibleToRoles + Mask) and driven by a Sensitivity:Mode config
(Off/SensitiveOnly). List no longer leaks (it scrubbed nothing before).
Proven by SensitivityIntegrationTests (real HTTP, 7/7): SuperAdmin sees all;
HR sees BirthDay but not SSN; a plain reader gets SSN removed and BirthDay
redacted on a Public doc, and nothing on a Sensitive/Hidden doc; List masks too.
Full suite: 189 passed, 0 failed. The fake SensitivityTests.cs (which
re-implemented the logic inline) was deleted.
Update (2026-08-30): the write path is protected
Features/Content/Update/Endpoint.cs calls ISensitivityService.ApplyWriteAsync
before validation, reverting any field the caller may not see back to its stored
value. A role that can Update a record can no longer overwrite a field masked
out of its own reads (#371).
Still open: the admin UI has no per-field sensitivity controls, so a content type's field sensitivity is set through the API.
Update (2026-09-01): changing a field's sensitivity after the fact
PUT /api/content-types/{name}/fields/{field}/sensitivity, admin only. Until
this there was no update path on a content type at all, so the level a field was
created with was the level it kept.
It is deliberately not a general content-type update. Field names, field types and a reference target are load-bearing once entries exist, and changing those is a separate decision (#163).
The two directions are not the same operation.
Raising (Public towards Hidden) stops the value being served, and that is
all it does. The value is still in the entry's JSONB data, in every backup and
in the event stream. Nothing here erases it, and a document saying otherwise
would be wrong. Erasure is DELETE /api/contents/{id}/erase.
Raising is not finished when the definition changes, though. Anonymous search
matches against Content.SearchText, a column derived from whichever fields
were Public the last time each entry was written, so updating the definition and
stopping there would change what is returned and not what is matched: a
caller could still search for a value they may no longer read and learn which
entries contain it, one guess at a time. The endpoint rebuilds the search text
for every existing entry of the type, before the definition changes, and it does
so through an event (ContentFieldSensitivityChanged) so a later projection
rebuild cannot replay the old text back over it.
Lowering is a disclosure. Every value written while the field was masked
becomes readable to everyone who can read the type, retroactively, and to
anonymous callers as well when the type is publicly deliverable. It is allowed,
because refusing it would make raising a one-way door and a field marked
Sensitive by mistake would need direct database access to recover. It is allowed
only when the request sets acknowledgeDisclosure; the refusal names how many
existing entries the decision covers. It is recorded as
contenttype.field.sensitivity.lowered in the audit log, an action of its own so
it can be alerted on without reading the metadata of every change. Everything
else is contenttype.field.sensitivity.changed.
VisibleToRoles and Mask are replaced with the level rather than carried over
from the level being left behind, and a Public target clears both. A Public field
listing the roles that may see it reads as a restriction that is not there, and
leaving the list in place would silently reinstate a stale allowlist the next
time somebody raised the level.
From 5.0.0 the rebuild and the definition change are one field change (see
field-changes.md), in the order above: raising rebuilds the
search text before the definition changes, lowering changes the definition
first. A type of up to 200 entries is changed before the answer, as it always
was, and change in the response is completed. A larger type is changed by the
job queue, so the request no longer holds open for the whole rebuild: the answer
is 200 with change still running and entriesReindexed 0, the definition
takes the new level when the change completes, and
GET /api/content-types/field-changes/{id} reports its progress. Until then the
field is served at its old level, which is what the synchronous rebuild did for
as long as it ran, and entry writes to the type wait.
Still no admin UI for it: the endpoint is called directly.
Update (2026-10-02): who may see a value is a capability and a role id
Until 4.6.0 two role names decided it, read back from the token: HR saw a
Sensitive field that listed no roles of its own, SuperAdmin saw everything,
and a field's visibleToRoles held role names. A clinic's Nurse role could be
granted nothing that opened a Sensitive field, and renaming a role changed who
could read the fields that listed it (#883).
What decides it now, in order:
- The holder of the seeded SuperAdmin role, by its id, sees everything.
- A field with a
visibleToRoleslist is seen by the holders of those roles and nobody else. The list replaces the default, it does not add to it. - A field with no list is seen by a role holding
view_sensitivewhen the field is Sensitive, andview_hiddenwhen it is Hidden. The two are separate: holding one does not give the other.*satisfies both. - An entry whose own level is Sensitive or Hidden follows rule 3.
The same rules decide who may set a field: a caller who may not see it may not write it.
The caller's roles are read from the store on each request, the roles they hold
in the current tenant, not from the token's role claims. GET /api/me answers
the same reading for the caller, so a console can decide the way the API does:
{
"userId": "8c1e...",
"username": "ana",
"tenant": "clinic",
"roles": [ { "id": "4f2a...", "name": "Nurse" } ],
"capabilities": ["view_sensitive"],
"legacyRoleFallback": false
}
roles is the caller's global roles and their active membership's roles in the
tenant, and capabilities is every capability those roles carry, with *
reported as * rather than expanded. legacyRoleFallback is true when the
deployment runs with Auth:LegacyRoleFallback on (see below), where the legacy role
names still open the gates they used to whatever capabilities the role carries, so a
console that decides its navigation by capability should also offer those screens to
an Admin-named role. It describes the deployment, not the caller. The tenant is the token's own, or the
resolved one for a token without a tenant claim. The route takes no parameters,
so it only ever describes the caller. It is sent Cache-Control: no-store, and
an API key gets a 403, as on every route outside the content API. Taking a capability off
a role, or a role off a user, applies on the next request rather than when the
token expires. A read or a write costs up to three small queries, once per
request, when the entry or its type is restricted. A Public entry of a type with
no restricted field costs one read of the caller's account per request, which
the field sets below need; their roles are the ones the permission check in the
same request already read.
The rule has one home, ISensitivityService.MaySeeFieldAsync and
MaySeeDocumentAsync. The scrub, the write guard and the entries list's field
filters all ask those two, so what a caller may filter on, what they are shown
and what they may set cannot drift apart.
visibleToRoles is stored as role ids and is still names on the wire. The
content type endpoints, a blueprint and the import accept names and store the id
of the role that carries each one. GET /api/content-types, the sensitivity endpoint's
answer and an export give the names back, as the roles are called now, so a
client reads and sends what it always did and a rename shows up without changing
who can read the field. A name no role carries is stored as it is and matches a
role of exactly that name on read, which is also how a definition stored before
4.6.0 keeps working until it is migrated.
Upgrading. migrations/4.6.0/sensitivity-by-capability.sql gives the seeded
HR role (id 00000000-0000-0000-0000-000000000003, while it is still named
HR) the view_sensitive capability and rewrites the names in every stored
visibleToRoles to ids. The seeder grants the same capability to the same role
on every start, so a host that runs the seeder keeps that role's access even
where the file was skipped. A role named HR under any other id is left alone
by both, since from 4.6.0 that can be a role an operator made, and the file
names it in a notice. Stop the API before running the file and start 4.6.0
after: an earlier release serving a migrated database masks every listed field
for the roles on its list until 4.6.0 is up. Nobody else gains anything: Admin
never read a Sensitive value and does not start to.
Who may hand the capabilities out. A role carrying view_hidden is assigned
only by a SuperAdmin, on /api/users/{id}/roles and on /api/tenants/members,
the rule a role carrying manage_roles already follows. Only SuperAdmin read a
Hidden value before, and no role an Admin could assign opened one. A role
carrying view_sensitive is assigned like any other, as HR was.
Only SuperAdmin, Admin and User are seeded now. HR comes with the demo content
(Seed:DemoContent), and HR is no longer a reserved role name (#884). A
database that already holds the HR role keeps it, and it still cannot be deleted.
Proven by SensitivityByCapabilityTests, HiddenCapabilityGrantTests,
BlueprintRoleReferenceTests, SensitivityIntegrationTests,
RoleReferencePortabilityTests, SeededRolesTests and
SensitivityByCapabilityMigrationTests.
Update (2026-10-03): which fields a rule shows and lets the caller set
A teacher updates attendance and not grades on the same record. A student reads their own notes and nobody else's. Neither is a sensitivity level, because both depend on the role and, for the student, on the entry. A permission rule carries two optional lists for this (#917):
{
"contentTypeSlug": "record",
"read": { "enabled": true, "readableFields": ["Name", "Attendance", "Grade"] },
"update": { "enabled": true, "writableFields": ["Attendance"] }
}
readableFieldsis accepted on a Read rule,writableFieldson a Create or Update rule. A role write answers 400 for either anywhere else. A transition writes the fields its content type declares for it, which is its own set, so neither applies to a transition rule.- No list, which is every rule stored before this, means every field, so such a role reads and writes exactly as it did.
How a set and sensitivity combine
Sensitivity is the ceiling and a set narrows below it. A caller reads a field when its sensitivity
lets them see it and a Read rule that grants the entry shows it. A set never opens a Sensitive
or Hidden field to a role that lacks view_sensitive, view_hidden or a place on the field's
visibleToRoles: naming it in readableFields does nothing for that role. This is the reading of
"sensitivity as audience defaults" that grants less. Letting a set widen past sensitivity would
also let a tenant administrator hand out a Hidden field by assigning a role, which #883 keeps for a
SuperAdmin, so it would need that rule extended first.
Across a caller's roles the sets are joined, rule by rule, for the rules that grant the entry being
read. A granting rule with no set shows every field. So a student holding "everyone's names" and
"my own record, names and notes" reads notes on their own entry and not on anyone else's. A field
the rules do not show is left out of the response, as a field masked with Remove is.
When there is no single entry to ask about, the answer is the fields shown on every entry the caller may read: the joined sets of their rules with no condition when there are any, otherwise only the fields every conditional rule shows. That is what decides a field filter, a search of a named type, a reference condition, and the reads that pass sensitivity a type and not an entry (the page tree, file usage and the export).
ISensitivityService.MayReadFieldAsync is the read rule for one field, and the scrub applies the
same rule, reading the sets once for the entry. The write rule has one home too, the write guard in
SensitivityService, which every write path goes through.
Writes
- Update. A field the caller may not read on the entry is put back to its stored value, as a
masked field is. A field they may read and the Update rule granting the entry does not let them
set is refused with 403 when the request changes it. Sent with its stored value, it is not a
change, so a console that sends the whole entry back keeps working. Left out, it keeps its stored
value: leaving a field out is not a way to delete it. Values are compared as JSON
(
JsonElement.DeepEquals), not as text. - Update of an entry the caller may not read. No Read rule grants it, or its own level is
Sensitive or Hidden and the caller may not see that level. Nothing is compared, since a 403 for a
wrong value and a 200 for the right one would tell the caller what the entry holds. A field the
rule granting the write lets the caller set is written blind, as an Update rule without a Read
rule always could: the fields its
writableFieldsnames, or every field when it holds no set. Every other field is put back. Sensitivity still puts back what the caller may not see. A transition carrying values on such an entry writes the fields its type declares for it, which are its own set. - Create. A field outside every Create rule's set that the request gives a value other than
nullis refused with 403. - The 403 names the fields by their declared spelling and counts keys the type does not declare. Nothing of the request is written: the check runs before anything is staged, and a batch (an import, a bulk create, a push) is refused whole.
- A key the type does not declare is outside every set, so a role holding a set cannot write one.
What each path does
| Path | Read set | Write set |
|---|---|---|
GET /api/contents/{id}, by slug |
per entry | |
GET /api/contents rows |
per entry | |
GET /api/contents field filters |
every entry of the type: 400 for a field not shown on all of them | |
GET /api/contents search, named type |
searches only fields shown on every entry | |
GET /api/contents search, across types |
a row is kept only when a value it shows holds the term | |
GET /api/contents/{id}/history |
per entry, the current entry's rules | |
PUT /api/contents/{id} |
unreadable fields put back | 403 on a change |
POST /api/contents, bulk create, Portability import |
403 on a value outside the Create set | |
POST /api/collections/{type}/push |
unreadable fields put back on an existing entry | 403 on a change, or on create |
POST /api/contents/{id}/rollback/{versionId} |
unreadable fields put back; the response carries the read rules' fields when a Read rule grants the entry, and no data when none does | 403 on a change |
| A transition carrying data | per entry: unreadable fields put back; on an entry the caller may not read, the declared fields are written | none: its declared fields are its set |
| Pages tree, file usage, export | every entry of the type | |
A condition Reference.Field |
the referenced field must be shown on every entry of its type, or the condition denies | |
| Public delivery | none: an anonymous caller holds no rule | |
| Workflow actions, collection sync | none: they act as the system, not as a user | none |
Saving a role
POST /api/roles and PUT /api/roles/{id} answer 400 for a set on the wrong rule, a name that is
not a field name, a set of more than 200 names, a set on a content type this tenant does not define,
and a name the type does not declare, spelled as declared (grade is not Grade). One write checks
sets on at most 50 content types.
Roles are stored once for all tenants. As with reference conditions, an update passes over a set the stored role already holds unchanged, so a role carrying a set written for another tenant's type can still be renamed here. In a tenant whose type does not declare a name, that name shows nothing.
A PUT that leaves a set out, or sends null, keeps the set the stored role holds on that rule of
that content type. A console that does not know the members sends the role back without them, and
it does not drop them. An empty list removes the set, and is stored as no set.
The audit row for a role lists each rule's set under fieldSets in permissions,
permissionsBefore and permissionsAfter, by name, when a rule holds one.
Caches
Nothing about a set is cached across requests. The sets are read off the caller's roles, which the resolver reads once per request, so a role change applies to the next request as every other permission does.
Asking which rules grant an entry reads the same rows the permission check already asked about, so it adds nothing to the bound a condition following a reference has. One path asks a new question: an update by a role holding a set asks the Read rules whether the caller may read the entry. When those rules follow a reference, a push of more than ten existing entries resolves that condition the way a list does, and past its bound answers 403 with the condition's reason. A role with no set asks nothing new.
Deleting a role or a group
DELETE /api/roles/{id} and DELETE /api/user-groups/{id} mark the item deleted. They do not remove
it, and the refusals are unchanged: a role still held by a user or membership, or named as a
tenant's sign-up or MFA role, answers 409 as before.
What a deleted role does:
- It grants nothing. The permission resolver, token issue, API key sign-in, field sensitivity and
/api/meall read live roles only, and the delete evicts cached permission decisions, so a holder loses it on the next request. - It is not listed, read, edited or assigned (404). It cannot be named as a sign-up role.
- It keeps its name until it is erased. Creating or renaming another role to that name still works: it erases the deleted role first, the same outcome a delete always had.
GET /api/roles/deleted and GET /api/user-groups/deleted list what can be restored.
POST .../{id}/restore brings an item back exactly as it was deleted, with the permissions and
capabilities it had then. Nothing held it at the time (the delete refuses a held role), and a
restore assigns it to nobody, so a restored role grants nothing until someone is given it. Restoring
a role that is a platform role, or carries a platform capability, needs manage_platform_roles,
which is what creating one needs. DELETE .../{id}/erase removes a deleted item for good and
refuses a live one with 404. All three routes need the capability the delete needs, and the system
roles cannot be deleted so they cannot reach any of this.
Rolling back. A release before this one does not know the deleted marker, so it lists, reads and assigns a deleted role or group as if it were live. Erase what should stay gone before rolling back.
There is no automatic purge window yet. That waits for the retention primitive in #622.
Tested (2026-07-16): the bug this replaced
Ran as real HTTP integration tests (Testcontainers Postgres, role tokens,
GET /api/contents/{id}). See BarakoCMS.Tests/SensitivityIntegrationTests.cs
and ContentPermissionTests.cs.
- CRUD RBAC: PASS (5/5). Create/read with and without permission return 200/403 correctly. Per-content-type, per-role, additive. Solid.
- Document-level sensitivity: PASS.
Hiddenblanks data for non-SuperAdmin (contentTypebecomesHIDDEN);Sensitiveclears data for non-HR/ SuperAdmin; SuperAdmin sees everything. - Field-level sensitivity: FAIL (real bug). HR and a plain reader both still
receive
SSNover the wire, deterministically, across two runs, even though the filter's own debug log prints "Removing SSN". The existingSensitivityTests.cshid this by re-implementing the masking inline instead of calling the real code.
Root cause
Content/Get/Endpoint builds the response and then calls
ISensitivityService.Apply(...), which only does document-level
scrubbing. The field-level logic lives in a different class, the
SensitivityFilter global post-processor, whose edits do not reach the
serialized body for this endpoint. Two competing implementations; only the
document-level one is wired into the response. So field masking is dead code in
practice.
(One test, SuperAdmin reading an AttendanceRecord, fails with a Postgres error
rather than an assertion, a separate issue to dig into, not the masking bug.)
Fix direction (folds into the design below)
Collapse to one path: apply sensitivity (document + field) in one service the endpoints call before sending, driven by the field schema. Delete the post-processor and the duplicate service. Then the granular design below is both real and testable, and these red tests go green.
How to verify what works today
With Seed:DemoContent on, the seeder creates an AttendanceRecord content type
(fields incl. SSN, BirthDay), three records marked Sensitivity = Sensitive,
and an HR role holding view_sensitive beside SuperAdmin. Sign in as each
and GET /api/contents/{id}:
- SuperAdmin sees
SSNandBirthDay. - HR sees
BirthDay, notSSN. - Plain user sees neither (
SSNremoved,BirthDay=***).
That proves the mechanism, and the work below made it configurable per field and per type instead of hardcoded.
Phasing, and where it got to
- Done. Field sensitivity lives on
FieldDefinitionand the filter is data driven. The AttendanceRecord hardcode and the duplicate service are gone. - Partly done.
Sensitivity:ModeselectsOfforSensitiveOnly.Allis declared but not implemented, so it is refused at startup rather than accepted and left inert. - Write-path protection done, in
ISensitivityService.ApplyWriteAsync: a caller who cannot see a field cannot set it, and omitting it is not a way to delete it. Admin UI per-field toggles are still outstanding.
API key scopes
An API key is confined to the content surface, and its scopes narrow that further. The key still acts
as its owner, so it can never do more than the owner's own roles and capabilities allow.
ApiKeyScopeProcessor enforces the scopes; ApiKeyIntegrationTests covers them.
| Scope | What it allows |
|---|---|
content:read |
GET under /api/contents |
content:write |
POST, PUT, PATCH and DELETE under /api/contents, except erase and rollback, and POST /api/collections/{type}/push |
content:destructive |
DELETE /api/contents/{id}/erase and POST /api/contents/{id}/rollback/{versionId} |
contenttype:read |
GET under /api/content-types and /api/schemas |
contenttype:write |
POST, PUT, PATCH and DELETE under /api/content-types and /api/schemas |
files:read |
GET under /api/files (list, metadata, download, usage) |
files:write |
POST, PUT, PATCH and DELETE under /api/files (upload, change, delete) |
* |
all of the above except files:read and files:write |
content:destructive does not include content:write, and content:write does not include it. Before
#653 a content:write key could erase and roll back; such a key now gets 403 on both routes and needs
content:destructive added. A * key is unchanged.
The files scopes are never implied by *, so a key made before they existed does not gain file access.
Name files:read or files:write on the key to allow it.
A key can also name content types (contentTypes on POST /api/api-keys). That makes it a push key:
it reaches POST /api/collections/{type}/push for the types it names and gets 403 everywhere else,
whatever its scopes. A key that names none, which is every key made before this existed, is not
limited by type. See collection-push.md.
Rotating a key
POST /api/api-keys/{id}/rotate with { "overlapMinutes": 60 } (0 to 10080, default 60) issues a new
key with the same owner, tenant, scopes and content types, and returns its secret once. The old key's
expiry is set to the end of the overlap window, so both work until then and only the new one after.
A key that already expires sooner keeps the earlier expiry. A revoked or expired key cannot be rotated.
See api-key-service-principals.md for the design and what is left.
Administrative endpoints: system capabilities
Everything above is about content. Administrative endpoints (roles, tenants, users, settings) are a separate surface, and they used to gate on hardcoded role names:
Roles("SuperAdmin", "Admin");
Roles are runtime data, so that could not work in either direction. A role created
through POST /api/roles could never be granted anything without a code change and a
release, and a role someone named Editor silently picked up whatever Editor was
written into. See issue #272.
Role.SystemCapabilities is what those endpoints ask for now.
Declaring a gate
In Configure(), instead of Roles(...):
Definition.RequireCapability(SystemCapabilities.ManageRoles, "SuperAdmin");
The first argument is the capability a caller needs. The rest are the role names the endpoint gated on before, kept as a fallback so an existing deployment keeps working across the upgrade (see below).
This replaces the role gate rather than adding to it. FastEndpoints combines role gates
with AND, so keeping Roles("SuperAdmin") alongside a capability would mean a caller
needed both, and a role created at runtime still could not reach anything.
Authentication is unchanged: an anonymous caller is refused 401 before any of this runs.
Granting a capability
A capability is a string on the role, so this is configuration:
PUT /api/roles/{id}
{ "name": "Auditor", "systemCapabilities": ["manage_roles"] }
* satisfies everything, including capabilities added later. SuperAdmin bypasses, the
same way it does for content permissions.
SystemCapabilities.Known is the vocabulary. It is deliberately short and grows one
area at a time, because the role gates it replaces are not uniform: GET /api/settings
is Roles("SuperAdmin", "Admin") while PUT /api/settings/email is Roles("SuperAdmin"),
so a single manage_settings invented ahead of the migration would have to pick one of
those and would hand out access it was meant to preserve. Users split the same way, which
is why that area has three names and not one (see below).
Discovering the vocabulary
GET /api/capabilities lists every name this instance understands. It is gated on manage_roles,
the same as reading roles, and it answers with the usual list envelope:
{ "items": [
{ "name": "*", "source": "core", "note": "Satisfies every capability, including ones added after the role was written. A role holding it reaches every gated endpoint on this instance." },
{ "name": "manage_roles", "source": "core", "note": null },
{ "name": "view_ledger", "source": "Accounting", "note": null }
], "page": 1, "pageSize": 100, "totalItems": 38 }
The list is SystemCapabilities.Known plus every name a served endpoint declares through
Definition.RequireCapability(...), read off the routing table at first use. That is the same
metadata CapabilityGateProcessor enforces, so a name is listed exactly when some endpoint asks for
it. A module you have not installed contributes nothing, and a module needs no new contract member to
be listed. source is core or the registered module's Name; a module whose endpoints are served
without the module itself being registered is named by its assembly instead.
The describe document
GET /api/meta/describe answers any signed-in caller with what this instance accepts, read from the
same registries the API checks requests against, so a client does not keep its own copy:
{
"apiContractVersion": 9,
"fieldTypes": [
{ "name": "int", "aliases": ["integer", "number"], "editorHint": "number", "ruleNames": ["min", "max", "requiredWhen"] }
],
"rules": [ { "name": "pattern", "aliases": ["regex"] } ],
"fieldEditors": [ { "name": "blocks", "fieldTypes": ["json", "array"] } ],
"fieldRoles": [ { "name": "title", "fieldTypes": ["string", "text"] } ],
"structuredDataTypes": ["Article", "NewsArticle", "BlogPosting", "Event", "Product", "WebPage"],
"credentialNameParts": ["secret", "password", "token"],
"capabilities": [ { "name": "manage_roles", "source": "core", "note": null } ],
"workflowActions": [ { "type": "Webhook", "requiredParameters": ["Url"], "optionalParameters": ["Secret"], "secretParameters": ["Secret"] } ],
"modules": [ { "name": "Pages", "httpContractVersion": 2 } ],
"jsonSchemaKeywords": ["type", "properties", "required", "items", "enum"],
"arrayItemTypes": ["string", "int", "url"]
}
fieldTypes, rules, fieldEditors, fieldRoles, structuredDataTypes, credentialNameParts,
jsonSchemaKeywords and arrayItemTypes go to every signed-in caller. jsonSchemaKeywords is
every keyword a json field's jsonSchema may use (see json-field-schemas.md)
and arrayItemTypes every itemType an array field may declare; both examples are shortened. fieldEditors and fieldRoles are the values a field's editor and role may hold, see
field-hints-and-roles.md. credentialNameParts is the whole list of
words that make a setting key or a workflow action parameter read as a credential when its name
contains one, ignoring case (the example above is shortened). It is read from the list the API
checks with, so a console masks its inputs by it instead of keeping a copy. The other three repeat what an endpoint with
a gate of its own already lists, so each is null for a caller that endpoint would refuse, and a
list, possibly empty, for one it would serve. So null means withheld, and an empty list means none:
| Part | Same as | Needs |
|---|---|---|
capabilities |
GET /api/capabilities |
manage_roles |
workflowActions |
GET /api/workflows/actions |
manage_workflows |
modules |
the enabled entries of GET /api/modules, by name, each with its httpContractVersion |
view_modules |
A workflow action carries the fields GET /api/workflows/actions returns for it. A module the
enabled list left off is not in modules, and since it serves no endpoint and registers no action
it adds nothing to the other two either.
A module's httpContractVersion is the version of that module's own endpoints, wherever they are
mounted, and 0 means the module states none. It is not the contractVersion in
GET /api/modules, which is the module contract the module was compiled against.
workflowActions is also null when the action registry cannot be read, which is what happens when
a registered action fails to construct. The rest of the document still answers 200, and the server
logs the type of the exception. So for that one part null means withheld or unavailable.
Under a field type, ruleNames holds the name of entries in the top-level rules.
Every response on this route, a refusal included, is sent Cache-Control: no-store and
Pragma: no-cache, because the body differs by what the caller holds. An API key cannot read it:
keys are confined to the content API and get 403.
Unknown names on a role write
POST /api/roles and PUT /api/roles/{id} check systemCapabilities against that list. A name no
endpoint asks for grants nothing, since Satisfies is an exact match, so this is about telling the
operator rather than about access. * is always known.
Roles:RefuseUnknownCapabilities (env Roles__RefuseUnknownCapabilities) decides what happens to an
unknown name. Off, the default, is what always happened: the role saves. What is new is that the
unknown names are logged at warning and returned in the response as unknownCapabilities, so a
console can show them. Saving rather than refusing is deliberate: a module installed later that
declares the name starts working without the role being edited again.
On, the write is refused with a 400 whose entries name each unknown capability and point at
GET /api/capabilities. Nothing is saved.
It is a lookup, not a claim
Capabilities are resolved per request from the caller's roles, not baked into the token.
A claim would be stale for as long as the token lives, so revoking a capability during
an incident would not take for up to 15 minutes and nothing would say so.
CachedPermissionResolver absorbs the cost, keyed per user and per tenant, and evicts on
the role and membership changes that can alter the answer.
Upgrading
Two things keep an existing deployment working:
The seeder backfills the capabilities its four system roles already had the access for, on the next start, so they are visible and editable rather than showing as roles with nothing. It adds whatever is missing from the default set rather than filling only an empty list, so a capability added to the vocabulary after your deployment upgraded still reaches your Admin.
The cost of that, plainly: a default you have deliberately removed from a seeded system role comes back on the next restart, because nothing records that the removal was deliberate. If you need one gone for good, do not run the seeder. A role you created is untouched, since the defaults are keyed on the roles the seeder creates.
The seeder finds each system role by its fixed id, and by its name only where no role holds the id. A seeded role you renamed keeps its new name, its permissions and its defaults. Before 4.6.0 the seeder looked by name, found nothing after a rename, and stored a new role under the same id on the next start, which put the old name back and emptied the role's permissions. A renamed role's holders reach what its capabilities open; a gate's legacy role fallback and any check of the role name in a token see the new name.
The gate can also honour the role names it replaced, which is what makes access survive on a host that never calls the seeder. From 4.0 that is off unless you ask for it.
Auth:LegacyRoleFallback (env Auth__LegacyRoleFallback) decides whether a role name
still opens the gate it used to. It was true through 3.x so an upgrade kept working
while roles had no capabilities yet. From 4.0 it defaults to false, because every
core and module endpoint now gates on a capability and the seeder gives a role the
capabilities it is missing rather than only filling an empty list. A seeded deployment
reaches everything it used to without the fallback.
Set it back to true if your roles are curated by hand, or while an upgrade is in
progress. The flag has not gone anywhere; what changed is which way it points when nobody
says.
What is migrated so far
| Area | Capability | Routes | Seeded roles holding it |
|---|---|---|---|
Features/Roles/* |
manage_roles |
/api/roles |
SuperAdmin |
Features/Tenants/* |
manage_tenants |
/api/tenants |
SuperAdmin |
Features/Tenants/Members/* |
manage_tenant_members |
/api/tenants/members |
SuperAdmin, Admin |
Features/Users/* |
manage_users |
GET /api/users, POST /api/users/{id}/password |
SuperAdmin |
Features/Users/* |
manage_user_membership |
/api/users/{id}/roles, /api/users/{id}/groups |
SuperAdmin, Admin |
Features/UserGroups/* |
manage_user_groups |
/api/user-groups and everything under it |
SuperAdmin, Admin |
Features/ApiKeys/* |
manage_api_keys |
/api/api-keys |
SuperAdmin, Admin |
Features/Audit/* |
view_audit_log |
GET /api/audit |
SuperAdmin, Admin |
Features/Settings/* |
manage_settings |
/api/settings, GET /api/settings/email |
SuperAdmin, Admin |
Features/Settings/Email/* |
manage_email_settings |
PUT /api/settings/email, POST /api/settings/email/test |
SuperAdmin |
Features/ContentType/* |
manage_content_types |
/api/content-types (and its /api/schemas alias), POST /api/content-types/{name}/rebuild, POST /api/content-types/{name}/seo-fields, PUT /api/content-types/{name}/fields/{field}/presentation, PUT /api/content-types/{name}/route-template |
SuperAdmin, Admin |
Features/Modules/* |
view_modules |
GET /api/modules |
SuperAdmin, Admin |
Features/Modules/Configuration/* |
view_module_configuration |
GET /api/modules/{name}/configuration |
SuperAdmin |
Features/ContentType/* |
manage_public_delivery |
PUT /api/content-types/{name}/public-delivery, PUT /api/content-types/{name}/fields/{field}/sensitivity |
SuperAdmin, Admin |
Features/Monitoring/* |
view_monitoring |
GET /api/monitoring/health, /k8s, /metrics |
SuperAdmin, Admin |
Features/Redirects/* |
manage_redirects |
/api/redirects, DELETE /api/redirects/{id}, POST /api/redirects/import |
SuperAdmin, Admin |
Features/Queries/* |
manage_queries |
/api/queries, /api/queries/{slug}, POST /api/queries/{slug}/preview |
SuperAdmin, Admin |
Features/Requests/* |
manage_requests |
/api/requests, /api/requests/{slug}, POST /api/requests/{slug}/dry-run/{contentId} |
SuperAdmin, Admin |
Features/Connectors/* |
view_connectors |
GET /api/connectors, GET /api/connectors/{slug} |
SuperAdmin, Admin |
Features/Connectors/* |
manage_connectors |
POST /api/connectors, PUT and DELETE /api/connectors/{slug}, POST /api/connectors/{slug}/test |
SuperAdmin, Admin |
Features/Workflows/* |
manage_workflows |
/api/workflows, /api/workflows/actions, /variables, /validate, /dry-run, PUT /api/workflows/{id}, PUT /api/workflows/{id}/enabled, DELETE /api/workflows/{id}, POST /api/workflow-runs/{id}/cancel |
SuperAdmin, Admin |
Features/EmailTemplates/* |
manage_workflows |
POST /api/email-templates/{id}/preview, which also needs read on the template and the entry (email-templates.md) |
SuperAdmin, Admin |
Features/WorkflowRuns/* |
view_workflow_runs |
GET /api/workflow-runs, GET /api/workflow-runs/{id}, GET /api/workflows/health, GET /api/workflows/{id}/debug, GET /api/webhook-deliveries, GET /api/connector-deliveries |
SuperAdmin, Admin |
Features/Workflows/RunWorkflow |
run_workflows |
POST /api/workflows/{id}/run. Also needs read on the entry. Given once to a role of your own that holds manage_workflows, and not again after you remove it. The seeded Admin and SuperAdmin get it back on every start, like their other defaults |
SuperAdmin, Admin |
Features/WorkflowRuns/* |
retry_workflow_actions |
POST /api/workflow-runs/{id}/actions/{ordinal}/retry |
SuperAdmin, Admin |
Features/WebhookDeliveries/*, Features/ConnectorDeliveries/* |
view_webhook_response_bodies |
The responseBody field on GET /api/webhook-deliveries, and the responseBody and requestHeaders fields on GET /api/connector-deliveries, nothing else on the row |
SuperAdmin |
Features/Content/History/* |
rollback_content |
POST /api/contents/{id}/rollback/{versionId} |
SuperAdmin, Admin |
Features/Content/Erase/* |
erase_content |
DELETE /api/contents/{id}/erase |
SuperAdmin |
Features/Jobs/* |
view_jobs |
GET /api/jobs |
SuperAdmin, Admin |
BarakoCMS.Files/Features/StorageSettings |
manage_storage |
GET and PUT /api/settings/storage, POST /api/settings/storage/test |
SuperAdmin, Admin |
Features/Collections/Endpoints.cs |
manage_collection_syncs |
/api/collection-syncs, /api/collection-syncs/{slug}, POST /api/collection-syncs/{slug}/run |
SuperAdmin, Admin |
Infrastructure/Services/SensitivityService.cs |
view_sensitive |
No route. Sensitive fields that list no roles, and Sensitive entries, wherever content is read or written | SuperAdmin, and HR where it is seeded |
Infrastructure/Services/SensitivityService.cs |
view_hidden |
No route. Hidden fields that list no roles, and Hidden entries, wherever content is read or written | SuperAdmin |
Users is two capabilities because its old gates were two: listing accounts and resetting
someone's password were Roles("SuperAdmin"), while changing a user's roles and groups
was Roles("SuperAdmin", "Admin"). manage_users is the narrow one. Giving it to Admin
would have handed every Admin the user list, so Admin's defaults carry
manage_user_membership and manage_user_groups and not manage_users. See issue #443.
/api/users/{id}/roles changes the roles a user holds in every tenant, so the caller's
manage_user_membership has to come from one of their own global roles. An Admin whose role comes
from a tenant membership gets 403 there and manages roles inside that tenant through
/api/tenants/members. Granting or removing SuperAdmin takes a SuperAdmin, and removing it from
the last user who holds it answers 409. A role carrying *, manage_roles, manage_tenants,
manage_users or manage_email_settings reaches past any one tenant, so only a SuperAdmin grants
it, on either surface, or removes it from a user's global roles; /api/tenants/members/roles leaves
it out for anyone else. A tenant admin editing a member who already holds one may keep it or drop
it, but not add another. Each refusal is
a 403 with a message saying why.
API keys and the audit log are two capabilities for the opposite reason: their old gates were
identical, so one name would have covered both and no seeded role would have noticed. They are
split because a role that reads the audit trail without being able to mint credentials is the
ordinary auditor case, and a single name makes it unexpressible. view_audit_log is named for
reading because the surface is one GET: entries are append-only and the chain is tamper-evident,
so there is nothing to manage.
Settings splits for the same reason Users does: reading settings and reading the email summary were
Roles("SuperAdmin", "Admin"), while changing the email settings and sending a test through them
were Roles("SuperAdmin"). Changing where the deployment's mail comes from redirects every password
reset and every verification token in it, so it is a takeover rather than an administrative tweak,
and it is exactly the change a compromised admin account makes. One manage_settings covering both
would have handed that to every Admin, so Admin's defaults carry manage_settings alone.
Connectors split read from write, and the argument is the surface's own. A connector is the only document in core holding somebody else's credentials. The two GETs return the configuration and the names of the secrets, never a value; the writes take secret values, and the probe spends them against the configured base URL. Writing is credential handling twice over: it is where a token enters the system, and where a base URL can be repointed, which redirects every request built on that connector without touching a single request definition. The case against splitting is that nothing about a connector is harmless, since the list alone says which third parties this deployment talks to. That is why reading is gated too, at its own name. It is not a reason to make whoever answers "where does the invoicing connector point" hold the grant that can repoint it.
Workflows split three ways. Authoring is one job: creating a workflow, reading the registered actions
and template variables, validating a definition and dry-running one. The dry run stays with authoring
because it executes nothing, and withholding the simulation from whoever wrote the workflow leaves
production as the only way to see what it does. Reading runs is a second job, and
GET /api/workflows/{id}/debug is in that half rather than with authoring, because what it returns
is the execution log of what already ran. Retrying is the third: the runner picks the attempt up and
the action happens for real, so "did the notification go out" needs the run list and must not carry
the ability to send it again. Stopping is not a fourth. Switching a workflow off, deleting it and
cancelling one of its runs all sit with authoring: whoever can switch a workflow off already stops
its runs that way, and none of the three makes an action happen, which is what the retry grant is
for.
Queries and requests are one capability each, including the preview and the dry run. The query
preview is the closer call, since it reads content rows and does not consult the per-role content
permissions. It stays with authoring because whoever can save a definition can point one at any
content type and attach it to a request, which sends the same rows to a third party; showing them to
the author is strictly less than that. What bounds the disclosure is QueryRunner, not the gate: a
query may only filter, sort and project fields whose sensitivity is Public, re-checked on every run
rather than only when the query was saved.
The two destructive content routes are two capabilities because their gates differed:
POST /api/contents/{id}/rollback/{versionId} was Roles("SuperAdmin", "Admin") and
DELETE /api/contents/{id}/erase was Roles("SuperAdmin"). One name would have to pick one of
those, and picking the wider one hands every Admin an irreversible delete. erase_content is the
only capability in the whole of #443 that Admin's defaults do not carry.
view_webhook_response_bodies gates a field, not a route. GET /api/webhook-deliveries still asks
for view_workflow_runs alone, the same gate GET /api/workflow-runs uses, because a delivery is a
run's action seen from the wire and "did it fire, and with what status" is the same question either
way. The response body a provider sent back is different: Features/WorkflowRuns/Endpoints.cs and
Infrastructure/Connectors/ConnectorSender.cs both refuse to carry one at all, for the reason that a
401 from an OAuth provider frequently echoes the credential that was sent, and the delivery log was
the one place that reasoning had not reached (issue #607). Gating the whole route would have cost
the ordinary support case, an operator who only needs to know a delivery failed and with what
status; gating the field instead means ViewWorkflowRuns alone still answers that, and only the
body itself needs the narrower grant. Not in Admin's defaults: unlike every other capability in this
section, Admin never held this before the split, because before #607 anyone holding
view_workflow_runs could read the body outright. Granting it to Admin here would be the split
undoing itself on day one. A holder of view_workflow_runs who does not also hold
view_webhook_response_bodies loses the ability to read a delivery's response body on upgrade;
everything else on the row is unaffected. The body itself expires on its own short retention window
regardless of who can read it: see docs/webhooks.md.
Modules
Every first-party module is migrated too, and a module declares its own capability names rather than
core declaring them: core does not reference a module, and a third-party one is not in this
repository at all. A name a module declares is grantable the day the module ships: its endpoints put
it on the routing table, which is where GET /api/capabilities and the role write check read from.
| Module | Capability | Routes |
|---|---|---|
| Accounting | view_ledger |
GET /api/accounting/accounts, /balances, /accounts/{code}/ledger |
| Accounting | post_journal_entries |
POST /api/accounting/accounts, /journal-entries |
| AI | manage_search_index |
POST /api/ai/index/{type} |
| Analytics (Umami) | view_analytics |
the five read endpoints under /api/analytics |
| Analytics (Umami) | manage_analytics_websites |
POST /api/analytics/websites |
| Diagnostics | manage_client_errors |
GET /api/client-errors, POST /api/client-errors/{id}/resolve |
| Email (Resend) | view_email_events |
GET /api/email-events |
| Feature flags | manage_feature_flags |
everything under /api/feature-flags/admin |
| Files | upload_files |
POST /api/files, GET /api/files, GET /api/files/{id}/meta, PATCH /api/files/{id}, GET /api/files/{id}/usage, DELETE /api/files/{id} |
| Files | manage_all_files |
no route of its own: GET /api/files/{id} and DELETE /api/files/{id} for a private file somebody else uploaded |
| Forms | manage_forms |
GET /api/forms, PUT /api/forms/{contentType} |
| Import | analyze_spreadsheets |
POST /api/import/analyze |
| Portability | export_content |
GET /api/portability/export |
| Portability | import_content |
POST /api/portability/import |
| PWA | view_pwa_installs |
GET /api/pwa/installs |
Three of those are splits of a gate that used to be one role list. Accounting splits reading the books from writing to them, which is the separation every accounting system makes and which lets an auditor read a ledger without being able to post to it. Analytics splits reading the numbers from provisioning a website in somebody else's system using this deployment's credentials. Portability splits export from import because the risks are opposite: export reads a whole tenant out in one request, import writes a whole tenant in.
Several of these, and /api/settings in core, read tables stored once for the whole deployment,
so the tenant filter every other route gets from the session does not apply and the endpoint
applies its own. The question each one asks is whether the caller holds the capability through a
global role, which only a platform administrator grants, or only through a tenant membership.
- Client errors, email events and PWA installs each belong to one tenant. Through a global role you
see every tenant's rows; through a membership you see the current tenant's. An email event
belongs to the tenant that sent the email, recorded against Resend's id at send time; an event
with no recorded sender is visible through a global role only. Only mail sent on a tenant's
behalf through
IEmailService.SendForTenantAsync(a workflow's email) is recorded; a user's own account mail, such as a sign-in code or a lockout notice, belongs to no tenant. - Settings, feature flags and the Umami analytics routes configure or read something shared by every tenant, so they answer 403 to a caller holding the capability through a membership only.
A global role rather than SuperAdmin alone, because a single-tenant deployment's Admin holds its
role globally and can resolve to any tenant slug, a subdomain nobody registered included. The rule
lives in PlatformScope (barakoCMS/Infrastructure/Auth).
Files is two names, and the first is not uniform. upload_files opens list, describe
and edit for every file in the tenant, and delete and download for a file this account uploaded,
because none of list, describe or edit exposes bytes or destroys anything the caller could not
already see through those same routes. Delete and download are the two that leave the caller with
something they did not have (the bytes) or take something away for good, so both also need the
uploader, or a caller holding manage_all_files; upload_files on its own is not enough. Until
content can reference a file (#141) there is no richer answer than that. Before issue #547 the two
gates disagreed: download already asked for the uploader or an admin, delete asked only for
upload_files, so a media editor could delete a file they could not read. A module that reads or
deletes a file through IFileStore names the signed-in user, and the store applies these same two
rules to that user; see MODULES.md.
manage_all_files is what the role names Admin and SuperAdmin used to decide here (#886). The
seeded Admin role holds it by default and SuperAdmin satisfies it through *, so both read and
delete what they did before. A role of any name can be given it: a Site Manager role holding
manage_all_files downloads another user's private file, and with upload_files as well deletes
one. Like every capability it is answered from the caller's stored roles in the current tenant on
each request, not from the role names in the token, so an account whose token says Admin and
whose roles do not carry the capability is refused. Admin holds it only once the Files module's
seed has run: a host that never calls RunBarakoModuleSeedersAsync, the Suite started with
SKIP_SEEDER=true, and a Suite start where the Files seeder threw (logged, and the host carries
on) all leave Admin without it. The names open it again only where
Auth:LegacyRoleFallback is on. The Files module's HttpContractVersion is 2 from this change. The check also refuses an API key, a caller who is not signed in
and a token issued for another tenant, before it looks at the file. Proven by
FileOwnershipCapabilityTests and FileStoreSeamTests.
A module declares its defaults once, as a CapabilityDefaults: the capabilities, and the seeded
roles that start with them, each named by the id it is seeded under (SystemRoles.Admin for the
Admin role). The seed grants from that declaration with its GrantAsync, and the
module's gates take their legacy role list from it, so no module lists role names. The roles are
the ones its old Roles(...) gate listed. The grant finds a role by its seeded id, so a seeded
role that was renamed is still granted; where no role holds the id it falls back to the role
carrying the seeded name, which is how grants were keyed before. Where neither exists it skips the
role rather than inventing one. It only ever adds, so a capability you gave a role stays, and it
runs on every start, so a default you took off a seeded role comes back on the next one, as core's
own defaults do. SuperAdmin is not granted anything: it holds *, which satisfies a capability
from a module core has never heard of. A module you do not install grants nothing, because its
seeder never runs. Proven by ModuleCapabilityDefaultsTests.
Content types split for the audit-log reason rather than the users reason: both gates were the same role pair, so one name would have covered them and no seeded role would have noticed. They are split because designing a schema and deciding what an anonymous caller can read are different jobs. Field sensitivity is what decides whether a value is scrubbed on the way out, and public delivery decides whether the route answers at all, so both are disclosure decisions rather than modelling ones. Admin holds both by default, because Admin reached all five routes already and this narrows nothing.
What is not migrated yet
None. Every core route that gates at all gates on a capability, and RoleGateTests pins that the set
still on a role name is empty, so a new endpoint reaching for Roles(...) fails the suite.
The last two moved in the same change. GET /api/modules asks for view_modules, named for reading
because it answers with two fields per module and manages nothing. POST /api/content-types/{name}/seo-fields
asks for manage_content_types, since adding fields to a content type is exactly what that capability
is. Admin holds both by default, matching what it reached before.
Third-party modules calling Roles(...) are unaffected and compile unchanged.
Resetting a user's two-factor authentication
POST /api/users/{userId}/mfa/reset with { "Reason": "..." } removes another user's MFA
enrollment: the secret, the recovery codes and any setup still pending. Use it when someone has lost
both their authenticator and their recovery codes. The caller needs manage_users (or SuperAdmin),
the same as an administrator's password reset. MFA belongs to the user in every tenant, so the
capability has to come from one of the caller's global roles; manage_users held only through a
tenant membership answers 403. A caller who is not a SuperAdmin also gets 403 when the target holds
SuperAdmin or any role carrying a platform capability.
Reasonis required, not blank, and at most 500 characters. It is stored in the audit row.- The user's refresh tokens are revoked. At the next sign-in
GET /api/auth/mfa/statusreportsEnabled: false, and a user whose role requires MFA is asked to enroll again. - A user with no MFA still gets 200, so the call can be repeated.
- You cannot reset your own: that answers 400. Turn your own MFA off with
POST /api/auth/mfa/disable. - An unknown user, or in a registered tenant a user with no active membership there, answers 404.
- The row is
auth.mfa.reset, with the acting admin, the target user (targetTypeUser) and the reason inmetadata.
What the audit log records about grants
The requests in the table below each write one row to GET /api/audit. For those requests the row is
staged on the same session as the change and the endpoint saves once, so a failed save leaves
neither. That holds for the routes listed and for nothing else: the paths under "Grants that write no
row" change what somebody can do and record nothing. A row holds who did it, what it was done to and
names or ids for before and after. It never holds a field value, an API key, a key's hash or its
display prefix.
| Change | Action | Metadata |
|---|---|---|
| Role created | role.created |
name, capabilities, permissions |
| Role changed | role.updated |
name, nameBefore, capabilitiesBefore, capabilitiesAfter, permissionsBefore, permissionsAfter, conditionsChanged; capabilitiesAdded and capabilitiesRemoved when the lists differ; conditionsChangedIn when a condition changed |
| Role deleted | role.deleted |
name, capabilities, permissions |
| Role restored | role.restored |
name, capabilities, permissions |
| Role erased | role.erased |
name, capabilities, permissions; name, reason when a new role took the name |
| Global role given to a user | user.role.assigned |
roleId, roleName |
| Global role taken from a user | user.role.removed |
roleId, roleName |
| A user's MFA reset by an administrator | auth.mfa.reset |
reason |
| Tenant created | tenant.member.added in the new tenant's log |
invited (false), roleIds, roleNames, tenantCreated |
| Tenant status changed | tenant.deactivated, tenant.activated in that tenant's log |
from, to |
| Tenant sign-up policy changed | tenant.signup_changed in that tenant's log |
from, to, roleId |
| Tenant sign-in policy changed | tenant.signin_changed in that tenant's log |
requireMfa, mfaRoleIds, methods, sessionMinutes |
| Member added, or added again | tenant.member.added |
invited, roleIds, roleNames; previousStatus, previousRoleIds, previousRoleNames when the membership already existed |
| Member's roles or status changed (suspending is this) | tenant.member.updated |
status, roleIds, roleNames, previousStatus, previousRoleIds, previousRoleNames |
| Member removed | tenant.member.removed |
previousStatus, previousRoleIds, previousRoleNames |
| API key created | apikey.created |
name, scopes, contentTypes, actsAsUserId, expiresAt when set |
| API key revoked | apikey.revoked |
name, actsAsUserId |
| API key rotated | apikey.rotated |
name, replacesKeyId, overlapMinutes |
| Field added | contenttype.field_added |
field, type, required, sensitivity, visibleToRoleIds, visibleToRoles |
| Field's level, role list or mask changed | contenttype.field.sensitivity.changed or .lowered |
from, to, visibleToRoleIdsFrom, visibleToRolesFrom, visibleToRoleIdsTo, visibleToRolesTo, maskFrom, maskTo |
The three member rows also carry profileAdded, profileRemoved and profileChanged when a
profile changed: the attribute names, never their values.
Where a membership is written
A membership has three writers, Members.AddAsync, ChangeAsync and RemoveAsync, and each stages
its row beside the write. The member routes and tenant creation all go through them. The patch they
share, QueueWrite, is private to Members, so no other file can queue a membership write with no
row.
MembershipWriterTests reads the source for a write anywhere else. Outside the member endpoints
file and outside comment lines it fails on new Membership as a whole word, on Membership x = new(,
on a session call typed on the document (Patch<Membership>, Store<Membership>, and the insert,
update and delete spellings), and on any file that loads or queries memberships and also calls
.Store(, .Insert( or .Update(. It does not catch a membership handed to another file that
stores it, or a write through raw SQL.
Ids and names
A role is referred to by id wherever a row had one already: roleId on the user.role.* rows,
roleIds and previousRoleIds on the member rows. The id is the reference, since it survives a
rename. Beside it is the name the role had when the change was made (roleName, roleNames,
previousRoleNames, in the same order as the ids), read in the same request. A row is never
rewritten, so the two cannot come apart inside it; after a rename the row still says what the role
was called at the time. A role that no longer exists has an empty name beside its id.
The field rows follow the same rule. A field stores its role list as ids, so visibleToRoleIds,
visibleToRoleIdsFrom and visibleToRoleIdsTo hold those ids, and visibleToRoles,
visibleToRolesFrom and visibleToRolesTo hold the names beside them in the same order, resolved
through RoleReferences.ToNamesAsync when the change is made. A stored id whose role is gone has an
empty name. Text the request sent that matched no role is stored on the field as sent; in the row it
has an empty id and the text as its name. Each of these lists is the same capped object a role row
uses: the first 50 items, the full count, truncated, and a name cut at 200 characters, since
nothing limits the list a request sends.
The shape of a role row
A list on a role row is an object: items holds the first 50 entries, count the full number and
truncated whether any were left out. A name longer than 200 characters is cut to 200. A role
document has no limit of its own, so this is what bounds the row.
Each entry of permissions is an object, so nothing a request sent is joined into a sentence:
{
"contentType": "invoice",
"actions": ["read", "update"],
"transitions": { "items": ["approve"], "count": 1, "truncated": false },
"conditions": {
"items": [{ "rule": "update", "field": "department", "operators": ["_in"] }],
"count": 1,
"truncated": false
}
}
A condition is recorded as the field it tests and the operators it uses, never the value it compares
against. A value can change with the field and operators staying the same, so conditionsChanged
says whether any stored condition differs from the one it replaced, values included, and
conditionsChangedIn names the content types where it does.
Who reads what
The stored row is complete. GET /api/audit returns no more of it than the caller could read from
the route that owns the data:
- A
role.*row is returned with onlynameandnameBeforein its metadata unless the caller passes the gate onGET /api/roles(manage_roles). - An
apikey.*row is returned with onlynameunless the caller passes the gate onGET /api/api-keys(manage_api_keys).
The action, actor, target and time are always returned. This matters because roles are global
documents and a role row goes to the log of the tenant the request resolved to: without it, a
platform administrator editing a role while resolved to one tenant would show that tenant's
administrators the capabilities of platform roles and the content types other tenants' permissions
name. A tenant admin reads their own tenant's rows; a SuperAdmin reads across tenants with ?tenant=.
Before values are a read, not a lock
The "before" in a row is what the endpoint loaded at the start of the request. Two edits of the same role, membership or user arriving together can both load the same state and both record it as their "before", and two revocations of one key can both write a row. The last save wins on the document, as it did before these rows existed.
Grants that write no row
- The seeder, at startup: creating the system roles and the first users, giving a seeded role the
default capabilities it is missing, and, with demo content on, creating the HR role and giving it
view_sensitive. - A module's seeder:
ModuleCapabilities.GrantAsyncadding the module's capabilities to seeded roles, andAccountingModule.SeedAsynccreating theAccountantrole. - Self-registration giving a new account the
Userrole. - A content type created with Sensitive or Hidden fields, by
POST /api/content-typesor by applying a blueprint. The blueprint row names the types only. - A portability import adding a new Sensitive or Hidden field to an existing type. Its row holds counts only. An import cannot change the level, role list or mask of a field that already exists.
- An entry's own sensitivity level changing. That is on the entry's event stream.
What the audit log records about reads
A request to GET /api/contents/{id}, GET /api/contents/by-slug/{type}/{slug},
GET /api/contents or GET /api/contents/{id}/history, or a rollback
(POST /api/contents/{id}/rollback/{versionId}) whose response sends a Sensitive or Hidden value,
writes one content.sensitive.read row, however many entries or versions it returns. A history
read names every field that any version on the page sent. A value counts when it went out as stored: a field the caller sees
redacted, as its last four characters or not at all was not read and is not named. An entry whose
own level is Sensitive or Hidden counts when its data went out. A read that sends nothing above
Public writes no row.
| Key | Holds |
|---|---|
route |
the route template, such as /api/contents/by-slug/{type}/{slug}, never the path, since a slug can be a Sensitive value |
apiKeyId |
for a read made with an API key, the key's id; the actor is the key's owner |
entries |
items, count and truncated; each item is id, contentType, sensitivity and fields |
contentType, fields, sensitivity |
for a read of one entry, the same three again, and the row's targetId is the entry's id |
fields holds field names, never values. The row is written before the response goes out, in its
own transaction, and a read whose row cannot be written answers 500 rather than going out
unrecorded. A Sensitivity:Mode of Off masks nothing, so every Sensitive or Hidden value read is
recorded.
Public delivery sends Public fields only and writes no read row. Opening a share link does, below.
Not recorded: POST /api/email-templates/{id}/preview. It needs manage_workflows as well as read
on the entry, and renders only the placeholders the template names, but a Sensitive field the caller
may see does render into its output, and no row says so.
Share links
| Event | Action | Metadata |
|---|---|---|
| Link created | site.share_link.created |
label, expiresAt; scope and entryId for an entry or page link |
| Link revoked | site.share_link.revoked |
label; scope and entryId for an entry or page link |
| Link redeemed or opened | site.share_link.redeemed |
label, scope, via (redeem or open); entryId for an entry or page link |
A redemption row has no actor, since whoever holds a key has no account, and carries the caller's
address. It names the link by id, never by key or hash. It is written in the same transaction as
lastUsedAt, so a 404 writes neither. A preview token from POST /api/preview writes no row when
it is minted or read through ?preview=: it lasts 30 minutes and is meant to leave little behind
(see docs/site-settings.md).