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.Permissions is a List<ContentTypePermission>.
  • ContentTypePermission = ContentTypeSlug + Create / Read / Update / Delete, each a PermissionRule { Enabled, Conditions }, with optional field sets (see "which fields a rule shows" below).
  • PermissionResolver enforces 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 _eq or _ne. Under _in or _nin the rule denies, and inside a list it is plain text, as $CURRENT_USER is.
  • Names are case sensitive. $CURRENT_USER.branch does not read Branch.
  • 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. _ne does 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 42 matches 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 reference holding one id, spelled as declared. A reference with multiple is 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: _eq and _ne against a text value ($CURRENT_USER and $CURRENT_USER.<name> included), _in and _nin against 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/contents with a contentType builds 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 as Class.Visibility _eq public or any _ne or _nin, reaches it as the tenant grows. What happens then depends on the pass:
    • GET /api/contents with a contentType keeps 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/contents with no contentType, 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 $status rule, 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.Sensitivity enum (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 SensitivityFilter for exactly one content type: AttendanceRecord, where SSN is removed unless SuperAdmin and BirthDay is masked to *** unless HR. There is Console.WriteLine debug spam and a duplicate, unused SensitivityService.
  • Enforced as an IGlobalPostProcessor that scrubs the response Data after 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
Email 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 Off and nothing else, so All would behave exactly as SensitiveOnly. Setting it is refused at startup rather than accepted silently, because an operator who asks for lockdown is the one who cannot afford to get SensitiveOnly instead.

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:

  1. The holder of the seeded SuperAdmin role, by its id, sees everything.
  2. A field with a visibleToRoles list is seen by the holders of those roles and nobody else. The list replaces the default, it does not add to it.
  3. A field with no list is seen by a role holding view_sensitive when the field is Sensitive, and view_hidden when it is Hidden. The two are separate: holding one does not give the other. * satisfies both.
  4. 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"] }
}
  • readableFields is accepted on a Read rule, writableFields on 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 writableFields names, 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 null is 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/me all 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. Hidden blanks data for non-SuperAdmin (contentType becomes HIDDEN); Sensitive clears data for non-HR/ SuperAdmin; SuperAdmin sees everything.
  • Field-level sensitivity: FAIL (real bug). HR and a plain reader both still receive SSN over the wire, deterministically, across two runs, even though the filter's own debug log prints "Removing SSN". The existing SensitivityTests.cs hid 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 SSN and BirthDay.
  • HR sees BirthDay, not SSN.
  • Plain user sees neither (SSN removed, 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

  1. Done. Field sensitivity lives on FieldDefinition and the filter is data driven. The AttendanceRecord hardcode and the duplicate service are gone.
  2. Partly done. Sensitivity:Mode selects Off or SensitiveOnly. All is declared but not implemented, so it is refused at startup rather than accepted and left inert.
  3. 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.

  • Reason is 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/status reports Enabled: 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 (targetType User) and the reason in metadata.

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 only name and nameBefore in its metadata unless the caller passes the gate on GET /api/roles (manage_roles).
  • An apikey.* row is returned with only name unless the caller passes the gate on GET /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.GrantAsync adding the module's capabilities to seeded roles, and AccountingModule.SeedAsync creating the Accountant role.
  • Self-registration giving a new account the User role.
  • A content type created with Sensitive or Hidden fields, by POST /api/content-types or 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.

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