Access Control
A deep technical overview of the DMART Access Control system. The system implements a hybrid model combining Role-Based Access Control (RBAC) for broad permissions and Access Control Lists (ACLs) for fine-grained, resource-specific overrides.
Core Architecture
The access control logic is centralized in the PermissionService class. Data models are defined in Dmart.Models.Core.
Key Components
Permission The atomic unit of access. Defines what can be done on which resources under what conditions, and optionally which rows of them.
Role A named collection of Permission shortnames. Its grantable_by lists the roles whose holders may give it to a user.
Group A named set of users. Groups carry no roles: they matter for shared ownership (an entry's owner_group_shortname satisfies the own condition for its members), for query policies, and for a group's own grantable_by, which lists the groups whose members may add a user to it.
User The actor. Holds Roles directly; every signed-in user also gets the implicit logged_in role. Its type (web, mobile, bot or admin) decides how it signs in (see Accounts and Sign-in), and whether its grants on users, roles, permissions and groups count (see The Management Space).
ACL Optional list embedded in a Resource that grants specific users specific actions, or denies them, before standard RBAC is consulted.
Global admin An account holding an active permission over __all_spaces__ / __all_subpaths__ that grants create and update on every resource type (or at least on users, roles, permissions and groups), with no conditions, field limits or row filter. The bootstrapped super_manager permission is one. Only an account of type admin, the built-in dmart, an exempt account, or one holding that permission through an exempt role can be a global admin, since nobody else's grants reach users, roles, permissions and groups (see The Management Space). Several rules below leave a decision to a global admin alone.
The Authorization Algorithm
The CanAsync method is the gatekeeper. It evaluates requests based on the following precedence order:
graph TD
A["Request: User, Action, Resource"] --> U{"Account active and<br/>not deleted?<br/>(anonymous skips this)"}
U -- No --> O[Access Denied]
U -- Yes --> D{"Item ACL names<br/>the user?"}
D -- "action denied<br/>(not a global admin)" --> O
D -- "action allowed" --> F[Access Granted]
D -- "no ACL or no match" --> H["Load the user's permissions<br/>(roles + logged_in)"]
H --> I["Determine context conditions<br/>(is_active, own)"]
I --> J[Traverse path hierarchy]
J --> K{"Permission matches<br/>action, type and path?"}
K -- Yes --> L{"Conditions and<br/>field rules pass?"}
L -- Yes --> F
L -- No --> M[Continue traversal]
K -- No --> M
M --> N{"Every level<br/>tried?"}
N -- No --> J
N -- Yes --> O
Detailed Evaluation Steps
1. ACL Evaluation (Short-Circuit)
Before evaluating roles, the system checks if the specific resource instance has an Access Control List (ACL) defined. An account that is inactive or deleted is refused before that, whatever an ACL says.
- Check: Per-entry
acllist match. Each entry names a user, with the actions it is allowed and/or denied:
{ "acl": [
{"user_shortname": "alice", "allowed_actions": ["view", "update"]},
{"user_shortname": "carol", "denied": ["query", "view"]}
] }
-
Logic: The first entry naming the user that lists the requested action decides. If it lists the action in
denied, access is refused at once, whatever the user's roles allow, owner or not. If it lists it inallowed_actions, access is granted at once, and no grant's conditions, field limits or row filter apply. -
Note: ACLs are checked before Roles, so a
deniedentry overrides any role. Only a global admin is exempt from a deny, so whoever writes an ACL cannot shut one out. A write a deny refuses answers as one of a record that is not there wherever the record's address alone would admit it and the caller cannot read the record; a caller that can read it is refusedNOT_ALLOWED(see What a Refusal Says). -
Listings: A deny of
queryorviewalso keeps the record out of listings: query pages and totals, counters, tags, aggregations, joins, CSV and ZIP / Parquet exports, live notifications, and the attachments and history that hang off it. -
Writing an ACL: An entry's ACL is set when it is created, or later with an
update_aclrequest, which needsupdateon the entry. It may hand out only what its writer holds: granting a user an action, or placing or lifting a deny, needs the writer to hold that action on the record through its roles, on a grant with no field limits or row filter. A writer the ACL itself denies an action can neither grant that action nor lift the deny. A global admin may write any ACL.
2. Permission Aggregation
If no ACL decides, the system compiles the user's effective permissions.
-
Source: the user's Roles, plus the implicit
logged_inrole for every signed-in user. Inactive roles and permissions are skipped. The anonymous user also gets theworldpermission, once it holds at least one role. What these reach in the management space is then limited (see The Management Space). -
Defaults: A new install creates an empty
logged_inrole (attach a permission to it to grant it to every signed-in user, anywhere but the management space), and an inactive, credential-lessanonymoususer holding aworldrole. Itsworldpermission grants nothing until itssubpathsare filled in; it then lets anonymous callersviewandqueryactivecontent,folder,media,json,schemaandcommentrecords there. Later starts never change these rows once they exist. -
The sample seed (
dmart seed) fillslogged_inwith grants, three of them on each user's own tree in thepersonalspace.access_personalreaches the records inside the user'sprivate,protected,public,messages,inboxandnotificationsfolders (personal:/people/<me>/…): content, folders, media, tickets, comments and the like, but no users, roles, groups or permissions.own_personal_foldersgives the user its own folder,people/<me>, and every folder beneath it: folders only,query,view,create,updateanddelete, nothing in another user's tree.own_personal_rootlists the user's own folder inpersonal:/people: folders only,queryandviewunderown, read-only so that nobody creates folders beside their own. The folders made for a new account belong to the account, whoever created it (see resource_folders_creation), soown_personal_rootlists them. Without--force,dmart seedleaves a role or permission that already exists as it is, so an install seeded before these grants existed adds them to itslogged_inrole by hand. -
Storage: Permissions are stored in the SQL database for fast lookup. Each server caches a user's resolved permissions for
AUTHZ_CACHE_TTLseconds (60 by default); writing a role, group or permission clears that server's cache at once, andGET /managed/reload-security-dataclears it by hand.
3. Contextual Conditions
The system determines the "state" of the request to match against Permission conditions.
-
is_active: Set if the resource'sis_activeflag is True. -
own: Set ifresource.owner_shortname == user.shortnameORresource.owner_group_shortnameis inuser.groups. An account never owns its own user record, whatever itsowner_shortnameor owner group says, and the anonymous user owns nothing, though anonymous submissions name it as their owner. -
Owner groups. A record's owner group hands it to the group's members wherever
ownapplies, so a create may name asowner_group_shortnameonly a group its creator belongs to. Any other group needsassignat the record's address, through a grant with no row filter and no condition, since there is no record yet to judge them on (a global admin's carries neither); otherwise the create is refused withNOT_ALLOWED. The group judged is the one the record is written with, so the rule holds for an entry created from a CSV row, MCP or a plugin, for a user, role, group or permission created through/managed/request, and for an attachment create that names one. A/public/submitbody field namedowner_group_shortnameis the submission's data, not an owner group.
4. Hierarchy Traversal & Global Wildcards
The system checks the record's folder and every folder above it, up to the space root. A permission on a folder therefore covers everything below it, and one on / covers the whole space. Listings agree: a grant lists at its own folder and every folder below it, and a grant on / lists the whole space, with its conditions and row filter applied in every folder.
/space/folder/subfolder/resource
Traversal Order: (a match at any level grants, so the order changes nothing)
-
Root:
space:/ -
space:/folder -
...
-
The record's own folder:
space:/folder/subfolder
For a folder record, the folder's own name joins the walk: the subfolder record, which sits at /folder, is also covered by a permission on folder/subfolder.
Wildcard Checks:
-
__all_spaces__— As asubpathskey, grants access across all spaces. -
__all_subpaths__— As a whole subpath, grants access to any subpath. It also stands for one segment: each step of the walk is also tried with its second-to-last segment replaced by__all_subpaths__, sopeople/__all_subpaths__/protectedcoverspeople/<any one name>/protectedand everything below it. -
__current_user__— Inside a subpath, stands for the caller's shortname (for examplepeople/__current_user__/protected). -
__current_user__owner__— Inside a subpath, stands for the shortname of the account that created the caller, its user record'sowner_shortname. It is one word, never__current_user__followed byowner__.
The two words are resolved the same way in a subpath and in a row filter, for listings, reads and writes by name, and the permissions /user/profile reports. A word that stands for no one is not resolved, and a subpath holding one reaches nothing: either word for the anonymous caller or for a name of the form __name__, and __current_user__owner__ for an account no one owns or one whose record names itself as its owner.
A permission's subpaths are compared without their leading and trailing slashes, and with // read as /, so /orders/ and orders name the same folder. An empty list ({"sales": []}) names no subpath and reaches nothing, for listings as for reads; write "/" for the space's root.
5. Constraint Validation
When a matching Permission key is found, three checks must pass:
-
Action Check: Is
action_type(e.g.,view,create) in the permission'sactions, and the resource type in itsresource_types? -
Condition Check: Does the Permission require conditions (e.g.,
own)? If yes, does the current Context satisfy them? -
Field Check: For
createandupdateonly:-
Restricted Fields: Ensures the request writes no field listed in
restricted_fields, nor any field below one (payload.bodycoverspayload.body.price). An update also counts every stored field it would delete, such as the fields of a body sent as[]. A field of the objects in an array (payload.body.items.price) is each element's field (see Review Statuses). -
Allowed Values: Checks if the values being assigned to fields match
allowed_fields_values(see Review Statuses).
-
A permission that fails a check is passed over, and the next one is tried. A permission with a row filter adds one more test when a stored record is read or written by name, and on the record a create or update would leave (see Row Filters).
6. User Profile Protection
Users edit themselves through POST /user/profile, not through their permissions: an account never owns its own user record, and nobody changes their own sign-in or access fields through /managed/request (see Managing User Accounts). The profile endpoint changes the display name, description, language and payload, and the password only against the current one; email and phone change through POST /user/verify-contact. The USER_PROFILE_PAYLOAD_PROTECTED_FIELDS setting names payload fields a user may not set on their own profile, or at self-registration.
Nor does a user choose their record's payload schema, which decides how its payload is checked. A user's own request that names a schema_shortname other than the one the record has, sends an empty one, or changes the payload's content_type is refused with PROTECTED_FIELD (210): on /user/profile, at /user/create, and on their own record through /managed/request, a global admin aside. A record with no schema may take only USER_PAYLOAD_SCHEMA, which every self-registered account gets when it is set; while it is empty, only an admin gives a user a schema. An account a code sign-in creates has no payload body, so nothing is checked then: its first profile save that brings one is checked against the schema in full, required fields included. The account's create event carries the schema, so a plugin filtered on it hears the sign-up.
Permission JSON Structure
Permissions are defined as JSON objects. Understanding these keys is critical for configuring access.
| Key | Type | Description |
|---|---|---|
subpaths |
Dictionary<string, List<string>> |
Target Scope. Maps Space names to subpaths. A subpath covers that folder and everything below it; / covers the whole space. Use __all_subpaths__ for any subpath, and __all_spaces__ as the key for every space. An empty list reaches nothing. /managed/request refuses a value that is not an object mapping each space to a list of strings (INVALID_DATA). |
resource_types |
List<string> |
Target Resources. The types of objects this permission applies to — one or more resource types (see Allowed Values below). An empty list applies to all types. |
actions |
List<string> |
Allowed Operations. What the user can do — one or more of the 11 action types (see Allowed Values below). An empty list grants nothing. |
conditions |
List<string> |
Contextual Requirements. own and/or is_active (see Allowed Values below). An empty list = no conditions. |
restricted_fields |
List<string> |
Field Protection. Fields that cannot be modified in create or update requests. |
allowed_fields_values |
Dictionary<string, object> |
Value Constraints. Enforces that specific fields can only take specific values. |
filter_fields_values |
string |
Row Filter. A search, in the query syntax, that limits which records the permission covers, for reads and writes alike, and may name the caller (see Row Filters). Empty = every record in scope. |
Allowed Values
These are the values DMART checks for. Other strings in actions, conditions or resource_types are stored as given, but never match anything.
Actions — the 11 operation types
| Action | Authorizes |
|---|---|
view |
Read a single entry's metadata and payload. |
query |
Search / list entries via the /query endpoint. |
create |
Create a new entry. |
update |
Modify an existing entry's metadata or payload, or set its ACL (update_acl). |
delete |
Remove an entry. |
attach |
Not checked by the server. Adding an attachment (comment, media, reaction, relationship) needs create on the attachment's own resource type at the entry's path. Reading one needs view or query on that type, and fetching one also needs view on the record it hangs off (see Row Filters). |
assign |
Change an entry's owner (the assign request). Also what changing an attachment's owner group takes, and what a create takes to name an owner group its creator is not in. |
move |
Not checked by the server. A move needs update on the source and create at the destination. |
progress_ticket |
Advance a ticket through its workflow states. |
lock |
Place a lock on an entry to block concurrent edits. update allows it too. |
unlock |
Release a lock another user holds. A holder releases its own lock without it. |
Conditions — the 2 contextual gates
| Condition | Requirement |
|---|---|
own |
The requesting user must own the entry — its owner_shortname equals the user, or the entry's owning group is one of the user's groups. Never true for the user's own user record, nor for the anonymous user. |
is_active |
The entry's is_active flag must be true. |
Conditions gate access to existing entries, so the create and query actions are exempt from condition checks. A listing still returns only the rows a grant's conditions admit: active ones under is_active, the caller's own under own.
Owning a row does not list it on its own. A listing follows the grants as a read by name does: a row is listed only through a grant whose resource types and conditions admit it (an own grant lists the caller's rows), or through an ACL that grants the caller query. A user's own user record is never listed through an own grant alone, and its attachments, a profile picture say, are listed only through a grant on user records that reaches it.
The spaces listing (type: spaces on /managed/query and /public/query, MCP's dmart_spaces and dmart://spaces) lists a space through an active grant that holds view or query and reaches something there, by the space's name or __all_spaces__, unless the space's ACL denies the caller view or query. A grant to create alone, such as a submission form's world grant, lists no space. Nor does a grant reach anything under an empty subpaths list, a subpath whose magic word does not resolve for the caller (an anonymous world grant on people/__current_user__), or a row filter that cannot be resolved. A query at a space's root is let in on the same terms when no grant admits it there directly.
Resource types — what the permission targets
Any of the platform's resource types may be listed. An empty resource_types array applies to all types. The full set, grouped:
Identity user, group
Structure folder, space
Content content, schema, data_asset, csv, jsonl, sqlite, parquet
Workflow ticket
Social comment, reply, post, reaction, notification, share
Attachments media, log, relationship, alteration, history, lock
Management role, permission, acl
Extensions locator, json, plugin_wrapper
Row Filters
A permission's filter_fields_values is a search, written in the query syntax, that narrows the records the permission covers, for reads and writes alike:
-
Listings return, of the grant's types, only the rows that pass it. Several grants on one type widen each other: a row is listed when any one of them lists it, and a grant with no filter or condition over the whole query lists every row.
-
A read by name (
/managed/entry,/public/entry,byuuid,byslugand the payload routes) is held to it too. When every grant that admits theviewcarries a filter, the record must pass one of them, or the read is refused as if the record were not there. -
A write of a stored record is held to it the same way, by the grants that hold the write's action: update, patch, assign, update_acl, delete, move, progress-ticket, lock and unlock of an entry; update and delete of a user, role, group or permission, and a user's rename; and update, delete and change of owner group of an attachment, judged by the record it hangs off (below). When every grant that admits the write carries a filter, the record must pass one of them, checked with one query against the stored row. This holds on every route:
/managed/requestwith one record or many, CSV import, MCP, alterations, the multipart upload, and a hard self-delete, which leaves the records a delete request from the account would be refused. -
A record the filters hide from a caller that cannot read it answers exactly as the same write of a record that is not there (see What a Refusal Says), and nothing in it changes. A lock of it answers as a lock of an empty address, reported taken, and takes no lease, which would hold off writers the caller cannot see; an unlock of it, as one of an address with no lock. A caller that can read the record, through a
viewgrant without the filter say, already knows it is there: its write, lock or unlock is refusedNOT_ALLOWED, and nothing changes. A field limit does not hide a record: one the action's grants reach, their limits aside, is refusedNOT_ALLOWED, an answer that turns on what the caller sent. -
A create or update may not leave the filter. When only filtered grants admit a create, the record as it will be written (its owner, a ticket's initial state and the rest) must pass one of their filters; an update admitted that way must leave the record passing one. A caller held to
@payload.body.region:southcan neither create anorthrecord nor turn asouthone intonorth: either is refused withNOT_ALLOWEDbefore anything is written. The record is judged by the same SQL that filters listings, on PostgreSQL and SQLite alike. A move is held to the filters of thecreategrants at its destination, and a user's rename likewise. Assign and progress-ticket are not: handing a record to someone else is what assign is for, and a grant filtered by state must still move a ticket out of that state. -
Names still say a record exists. A create under a name that a record hidden from the caller holds answers
SHORTNAME_ALREADY_EXIST, and a create or update that would break a folder'sunique_fieldswith a hidden record's values says so. Names and unique values are unique across the rows a caller cannot see as well, so this cannot be hidden. -
Cost. A caller admitted by a grant with no filter, an ACL allow, or global admin rights runs no row-filter query. A caller held to filters runs one per record judged, and one more for what a create or update would leave.
-
Attachments and history follow the record they hang off: they are listed, and an attachment is read, only where that record would be. A grant's row filter narrows that record, not its attachments. An attachment's update, delete or change of owner group holds that record to the filters of the action's grants in the same way: a record they hide, or one that cannot be found, takes the attachment with it, answered as missing to a caller that cannot read the attachment and refused
NOT_ALLOWEDto one that can. Creating an attachment is judged at its own address. -
An attachment follows its record's ACL on every read. Where the record it hangs off denies the caller
vieworqueryin its ACL, the attachment is refused: in listings, inretrieve_attachmentson/managed/entryand/public/entry, on the payload routes, in an MCP download, and in WebSocket and MCP event notices. So is an attachment whose record's address two records share (a role created at/usersunder a user's name, say). A global admin is exempt, as from any deny. -
An attachment also needs a grant of its own, on top of that rule. Reading one, through
retrieve_attachments, anattachmentslisting,attachments_aggregationor an attachment's history, takes a grant withvieworqueryon the attachment's own resource type (comment,media,reactionand so on) at its subpath, held to that grant'sownandis_activeconditions. An attachment whose own ACL denies the caller is left out. Aworldgrant that names onlycontenttherefore shows visitors a public entry but none of its comments, reactions or media. A global admin reads every attachment. -
Fetching an attachment needs
viewon its record, with that grant's conditions and row filter, whatever the record's type: an entry's media, an attachment of a folder, the avatar on a user record. This holds on the payload routes, inretrieve_attachments, in an MCP download and for live notices. A listing asks onlyqueryon the record, so a reader withqueryoncontentandviewonmediasees an entry's media listed, and is answered not found when it fetches one. -
History needs a
historygrant, on top of reading the record. A diff holds a record's old values, even ones since removed from it, so a history query and MCP'sdmart_historytake a grant withvieworqueryon thehistorytype at the folder queried or above it; a query at a space's root needs the grant at the root. Without one, the query returns nothing, with no error. The events feed shows an event without one, but not the old and new values of its change, and its search does not reach them. -
Folders and spaces are not held to a row filter. A filter narrows the records in a folder, and the folder is their container.
-
An ACL allow on a record admits it whatever the filters say.
Naming the Caller in a Filter
A filter may say __current_user__, the caller's shortname, and __current_user__owner__, the account that created the caller, as a subpath may. Both are resolved per caller wherever the filter applies: listings, reads and writes by name, and the permissions /user/profile reports. @payload.body.parent_shortname:__current_user__ covers the accounts whose parent is whoever asks, and follows a change of parent at once.
A filter with a word that cannot be resolved takes the grant away whole: it lists nothing, a read through it is refused, a write of a stored record through it alone answers as for a record that is not there (or is refused NOT_ALLOWED, when another grant lets the caller read the record), a create through it alone is refused, and it is left out of the profile's permissions. A word is not resolved:
- for the anonymous caller, either word;
- for an account no one owns, or one whose record names itself as its owner,
__current_user__owner__; - when the name has the form
__name__, which every magic word and wildcard has; - when the name would read as more than one value in the search grammar: one holding a space,
|,:,*,@,!,<,>,=, a bracket, a brace, a quote or a backslash; - when the name is one of the grammar's words, in any case:
null,true,false,or,and. A user namedNullwould otherwise match every row without the field.
What a Refusal Says
A write the caller may not make is refused in the same words whether or not its record exists, so a refusal does not tell the caller which records are there that it cannot read.
-
A record that is not there is judged on its address alone, with no owner,
is_activeor ACL to lend it. A caller the address refuses getsNOT_ALLOWED("no update access", "no move access", "not allowed to update attachment", …), as it would of a record that exists; one it admits hears the record is missing (OBJECT_NOT_FOUNDorSHORTNAME_DOES_NOT_EXIST, or an empty success for a delete). This holds for update, patch, assign, update_acl, move and delete of an entry, update and delete of an attachment, update and delete of a user, role, group, permission or space, a dry-run delete included, and a user's rename. A CSV update row that names a missing entry reportsNOT_ALLOWEDto a caller withoutupdatethere. -
A record refused for what is stored answers the same way to a caller that cannot read it. One whose ACL denies the caller the action, one stored with another type than the request names (a ticket where it says
content), and one the write's row filters hide get what a missing record at that address gets, whenever the address alone would admit the write; only a caller the address refuses too hearsNOT_ALLOWED. Nothing is written either way. -
A record the caller can read is refused
NOT_ALLOWEDinstead. "Can read" is the judgement a read of the record by name gets, row filters and ACL included: a caller that can read the record already knows it is there, and answered as missing it would be misled (a delete would report success while the record stayed). It holds for every write above and for lock, unlock and progress-ticket, on/managed/request,/managed/lock, CSV imports and MCP alike. The read is judged only when a write is refused for what is stored, so an allowed write costs nothing more. A refusal by a condition (own,is_active) or a field limit staysNOT_ALLOWED, since a missing record is refused the same way. A global admin, whom no deny binds, and an ACL allow are unaffected. -
Users, roles, groups, permissions and spaces are judged where they live. Each is found by its shortname alone, so an update or delete of one, and a user's rename, is judged at its type's folder in the management space (
management:/users,/roles,/groups,/permissions), or a space at its own root, whatever space and subpath the request names and whether or not it is there. An account stored outside the management space is judged atmanagement:/userstoo. A space whose ACL refuses the caller its update or delete, where its address admits it, answers as a missing space ("space not found", or an empty success for a delete) only when its ACL also keeps the caller from reading it. -
Creating a user, role, group or permission through
/managed/requestis judged there too: it needscreateat the type's folder inmanagement, or global admin rights, whatever space and subpath the request names. The record is still stored where the request names (a pack may keep its permissions in its own space). Acreategrant elsewhere, such as one in the caller's own personal space, creates none of these, and a caller without the right hearsNOT_ALLOWEDwhether or not the name is taken. A new account's ACL may hold only actions its creator holds inmanagement, and a user's rename is judged as a create of the new name there. -
Assign checks
assignon the record before it looks up the new owner. Unlock, past releasing the caller's own lock, needslock,updateorunlockon the entry before it answers, and refuses alike whether the entry is locked, unlocked or missing. -
A refused attachment payload, on
/managed/payloadand/public/payload, is an empty 404, as a missing one is, and MCPdmart_downloadanswers a refused attachment with the "attachment not found" error a missing one gets.
The Management Space
The management space holds the accounts and the rows that grant access, so the server limits what grants reach there. The limits are applied where a caller's permissions are resolved, so every check, listing, search, history read and export follows them. There is no switch to turn them off; an exemption is the way to keep access.
-
What every caller holds reaches nothing in management. That is whatever the anonymous caller holds (its roles and
world), and a permission held only throughlogged_inor the sign-up default role (USER_CREATE_DEFAULT_ROLE). Through__all_spaces__such a grant still reaches every other space. A permission another of the caller's roles also grants counts as that role's. Data that every user reads, and its schemas, belong in a space of their own. -
Users, roles, permissions and groups are for admins. A grant on them counts only for an account of type
admin, for the built-indmartwhatever its type, for an account listed inMANAGEMENT_EXEMPT_ACCOUNTS, and for the grants that come through a role listed inMANAGEMENT_EXEMPT_ROLES(the holder's other roles are not exempted with it). Any other account's grants lose those four types, whatever its roles say: it cannot read, list, search or change those records, nor their attachments and history, which are judged by the record they hang off. The rest of each grant stands. Any other web, mobile or bot account holdingsuper_adminstill reaches every other record, in management too, but is not a global admin: exports,/infoand the other decisions this page leaves to a global admin are not its. -
Every signed-in account reads the core schemas in
management:/schema:meta_schema,folder_rendering,workflow, and the schema its own user record names. It reads no other schema there unless a grant says so, and this does not list the management space among its spaces. The sample seed'sview_management_schema, alogged_ingrant on the first three, reaches nothing in management like any otherlogged_ingrant there, sodmart check managementnames it and removing it changes nothing. -
A grant on one's own record works for any account type: a row filter
@shortname:__current_user__onuserrecords atmanagement:/userslets the caller read or write its own record by name, and a subpathusers/__current_user__reaches that record's attachments. -
Self-service needs no grant, so it is unaffected: sign-in,
/user/profile, password reset, codes and registration. So are the directory replica's feed, LDAP service accounts, the OpenID Connect provider, and the commands run on the host, such asdmart check,dmart passwdanddmart import-ldif.dmart cliis not one of them: it signs in as a user, and is held to these limits like any other client. -
What a client is told follows these limits too. The permissions map that
/user/profile,/user/permissionsand MCP return lists what the server allows, a grant on the caller's own record and the core schemas included, so an app that builds its menus from it offers no screen the server then refuses.
Both exemptions are comma-separated shortnames in config.env, empty by default (see Settings). An exemption naming what every caller holds, anonymous in MANAGEMENT_EXEMPT_ACCOUNTS or world, logged_in or the sign-up default role in MANAGEMENT_EXEMPT_ROLES, stops the server from starting. At every start the server logs MANAGEMENT_ADMIN_ONLY with the roles whose grants on users, roles, permissions or groups count only for admins, and dmart check management hard says how many accounts each one limits, by type.
Checking What an Instance Grants
dmart access report prints what an instance grants, as JSON with sorted keys and lists, so two reports compare line by line. It only reads, and it reads no user but anonymous, whose roles decide what the public reaches. GET /info/access (global admins only) returns the same report from the running server. Ask each node when nodes may differ, since a node's settings come from its service environment as well as its config.env.
| Section | What it holds |
|---|---|
settings |
The settings that decide who may sign up or sign in and what the public reaches, such as sign_up_open (IS_REGISTRABLE with at least one channel), allowed_submit_models, registration_email_domains, email_contact_only_roles and the two exemptions, and whether Google, Facebook and Apple sign-in are set up. No secret is shown. |
public |
The anonymous account's roles, and the permissions anonymous callers reach through them (with world once it holds one). |
signed_in |
The permissions every signed-in account reaches through logged_in. |
roles, permissions, groups |
Each row's access fields, with a checksum of them. |
spaces |
Each space, and whether it is active. |
warnings |
What the checks below find, each line led by the check's name. |
A checksum covers the access fields alone, with lists sorted: not the timestamps, uuid, owner or display name. The same grant has the same checksum on every instance.
To catch drift, save a report you have checked and compare later ones with it:
dmart access report > access-approved.json
dmart access report --compare access-approved.json
--compare prints a line for each setting that changed and each role, permission, group or space added, removed or changed, naming the fields that changed. It exits 1 if anything changed, 0 if nothing did ("No changes."), and 2 if it cannot read the saved report, so a cron job or a release checklist can run it. Two nodes, or a test instance and production, compare the same way. A saved report is how an admin keeps core roles such as world and logged_in in check.
dmart check management, and GET /managed/health/{type}/management, add these checks to the health checks; hard and all name the rows found:
| Check | Finds |
|---|---|
everyone_grant_in_management |
A grant anonymous callers, logged_in or the sign-up default role hold in management, which reaches nothing there. |
management_admin_only |
A role that is not exempt and reaches users, roles, permissions or groups, which therefore counts there only for admins. A grant that reaches only the caller's own record through __current_user__ is left out, since it keeps working; an own grant is not, as an account never owns its own user record. hard counts the holders it limits by type, leaving out admins, dmart and the exempt accounts. super_admin shows here unless it is exempt. |
broad_user_read |
A grant that reads every user record: query or view on users with no condition, row filter or restricted field. A leak of the user list takes this shape. |
public_broad_grant |
A permission anonymous callers or every signed-in account reach that names __all_spaces__, __all_subpaths__ or a space's root, or the user, role, permission, group or history type. |
role_missing_permission |
A role naming a permission that does not exist. |
permission_missing_space |
A permission naming a space that does not exist. |
grant_at_space_root |
An active grant at a space's root, which lists the whole space. |
empty_subpaths_grant |
An active grant with an empty subpath list for a space, which reaches nothing. |
Permission Examples
The grants below on users count only for accounts of type admin, the built-in dmart and exemptions (see The Management Space).
Super Manager — Full Access
{
"shortname": "super_manager",
"subpaths": {
"__all_spaces__": ["__all_subpaths__"]
},
"resource_types": [
"schema", "space", "content", "user", "..."
],
"actions": [
"delete", "update", "query", "create", "view", "attach",
"assign", "progress_ticket", "lock", "unlock"
],
"conditions": [],
"restricted_fields": []
}
Grants unrestricted access to all resources in all spaces. No ownership requirement, no field restrictions. Every start creates it when it is missing, held by the super_admin role that the built-in dmart account holds; it lists every action but move, which no request checks. It makes a global admin only of an account of type admin, of dmart, of an exempt account, and of anyone holding it through an exempt role; any other holder reaches everything but users, roles, permissions and groups.
View Users — Read-Only Scope
{
"shortname": "view_users",
"subpaths": {
"management": ["users"]
},
"resource_types": [
"user", "folder"
],
"actions": ["view", "query"],
"conditions": []
}
Read-only access to the accounts in the users subpath of the management space (user) and to the users folder itself (folder). A permission that lists only content, ticket or folder there covers no account: accounts are records of type user.
Edit Owned Accounts — Restricted Update
{
"shortname": "edit_owned_users",
"subpaths": {
"management": ["users"]
},
"resource_types": ["user"],
"actions": ["update"],
"conditions": ["own"],
"restricted_fields": ["roles", "is_active"],
"allowed_fields_values": {}
}
Can only edit user records it owns. Cannot modify roles or is_active fields — prevents self-promotion or account manipulation. A permission needs subpaths: without them it never matches. An account never owns its own user record, so this does not let anyone edit themselves; users do that through POST /user/profile. The sign-in fields of an owned account are further held to Managing User Accounts.
Regional Orders — Row Filter
{
"shortname": "read_north_orders",
"subpaths": {
"sales": ["orders"]
},
"resource_types": ["content"],
"actions": ["view", "query"],
"conditions": [],
"filter_fields_values": "@payload.body.region:north"
}
Lists and opens only the orders whose body says region: north. Another order in the same folder is left out of its holder's listings, and answers 404 when read by name.
Child Accounts — Row Filter Naming the Caller
{
"shortname": "manage_child_accounts",
"subpaths": {
"management": ["users"]
},
"resource_types": ["user"],
"actions": ["view", "query", "create", "update"],
"conditions": [],
"filter_fields_values": "@payload.body.parent_shortname:__current_user__"
}
Each holder lists, opens and updates only the accounts whose body names it as parent_shortname, and creates only such accounts. A create or update that would name another parent is refused with NOT_ALLOWED; an account with another parent answers an update as one that does not exist. The rules of Managing User Accounts apply on top.
Review Statuses — Allowed Field Values
{
"shortname": "write_reviews",
"subpaths": {
"reviews": ["/"]
},
"resource_types": ["content"],
"actions": ["create", "update"],
"conditions": [],
"allowed_fields_values": {
"payload.body.status": ["draft", "submitted"],
"tags": [["public"], ["internal", "draft"]]
}
}
Lets its holder write reviews whose status is draft or submitted, never approved, and whose tags are either public alone or drawn from internal and draft.
- Keys are field paths, flattened with dots (
payload.body.status,is_active). Each value lists what the field may take. - A single value must equal one in the list. An array field passes when every element is in the list. A list of lists allows an array whose elements all fit any one of the sublists.
- An empty list limits nothing: the field is not considered, whatever is written. To forbid writing a field, list it in
restricted_fields. - Only what the request writes is checked. A create or update that leaves
payload.body.statusout passes. A field an update deletes, such as one under a body sent as[], is checked asnull, so it passes only when the list holdsnull. - A field inside an array's objects binds every element. For
payload.body.items: [{"name": "a", "price": 5}, …], the keypayload.body.items.priceis each item's price:allowed_fields_valuesneeds an allowed value in every element that has the field (an element without it is not limited), andrestricted_fieldsrefuses a write that sets it in any element. An array inside an array is read through. An update writes an array whole, so sending it back writes every element's fields again, and dropping a field from every element, or sending[], deletes the stored ones: underrestricted_fields: ["payload.body.items.price"], an update that namesitemsat all is refused while any item, stored or sent, has a price. - Like
restricted_fields, this applies tocreateandupdateonly.
Managing User Accounts
Users, roles, groups and permissions are records in the management space, under the subpaths users, roles, groups and permissions, and are created, updated and deleted through /managed/request, by admins and exemptions alone (see The Management Space). Creating a user:
{
"space_name": "management",
"request_type": "create",
"records": [
{
"resource_type": "user",
"subpath": "/users",
"shortname": "jdoe",
"attributes": {
"displayname": {"en": "Jane Doe"},
"email": "jane@example.com",
"roles": ["editor"],
"groups": ["sales"]
}
}
]
}
A role, group or permission takes the same request, with resource_type role, group or permission and its own subpath. Each is found by its shortname, so an update or delete of one, and a user's rename, is judged where it lives, whatever coordinates the request names (see What a Refusal Says). On a user:
-
No admin sets a password. Whoever knows a password can sign in with it, so a
createorupdatecarryingpasswordis refused withINVALID_DATA, for every account type; an empty one counts as absent. An account/managed/requestcreates has no password and hasforce_password_changeon. Its owner signs in with a code, and sets a password with a reset by code (/user/otp-requestwithpurpose: reset, then/user/password-reset-confirm). A bot receives no code, so its password is set on the host withdmart passwd, which clearsforce_password_changeas a reset does. Catalog's create-user form works this way: it sets no password, and offers to send the new user the code by email or phone at once, with the reset-password link to pass on. To get someone back into an account, setforce_password_changeand have them reset the password by code, or rundmart passwdon the host. -
A contact
emailis unique, whatever its case. A create or update whoseemailanother account in the same folder holds, in any case, is refused withDATA_SHOULD_BE_UNIQUE(415, naming@email); one held by an account in another folder, by the database, withINVALID_DATA("… already in use"). An update that keeps the account's own email, in any case, is not checked for it. -
An admin's request never verifies an address.
/managed/requestignoresis_email_verifiedandis_msisdn_verified, since admin screens send them back on every save: a new account starts with neither, and an update leaves both as stored. Until an address is verified, a password sign-in that names that email or phone is refused withUSER_ISNT_VERIFIED(11); one by shortname is not. A sign-in by code needs no verified address: the code proves it, and the sign-in marks the address it went to verified, as a password reset by code does. So an account an admin created signs in by code whichever identifier it names. -
Changing an email or phone unverifies it. An update that changes an account's email or phone (a change of case alone does not count) clears that address's verified flag, so only a code sent to the new address verifies it again. A
mobileaccount, and awebaccount underWEB_DEVICE_OTP, also loses its trusted device, so its next sign-in asks for a code. An account locked to its device keeps the device. A change the user makes through/user/verify-contactis proved by its code and stays verified. -
Without a code, three things mark an address verified:
dmart import-ldif, for the email the directory gives; an import that restores accounts, which keeps their flags as exported; anddmart mark-verified, run on the host, for a server with no mail or SMS channel to send the code.
User managers are also held to these rules there. A global admin is held to none of them.
-
Giving roles and groups. A role may be given to a user only by someone holding a role its
grantable_bylists, and a group only by a member of a group itsgrantable_bylists. A role or group that is inactive, missing (a name with noroleorgrouprecord), or has nograntable_byis a global admin's to give. Holding a role does not by itself let you give it. -
What roles and permissions grant. Setting a role's
permissionsorgrantable_by, a group'sgrantable_by, or a permission'ssubpaths,resource_types,actions,conditions,restricted_fields,allowed_fields_valuesorfilter_fields_valuestakes a global admin, as does switching a role, group or permission back on. -
Nobody changes their own account's sign-in or access fields there:
email,msisdn,force_password_change,type,is_active,roles,groups,locked_to_device(anddevice_idwhere it decides the sign-in: a mobile account's, and a web account's underWEB_DEVICE_OTP), the hostedmailboxandmail_aliases, nor anunlock. Posting a record back unchanged is fine. -
On another account, the same fields need a global admin when the account holds a role or group the actor could not give. On an admin account they need a global admin, or the admin that owns the account while that owner is an active admin able to give every role and group the account holds.
-
An account an ACL grants an action (an entry on any record naming it with a non-empty
allowed_actions) has its sign-in fields, every field above butis_active,rolesandgroups, changed only by a global admin, since whoever reset it would gain what the ACL opens. A name an ACL grants, a name an account had before, and a directory feed reader's name are given to an account, by a create or a rename, only by a global admin. A name of the form__name__is given to no account, by a global admin's create or rename included (NOT_ALLOWED).
Accounts and Sign-in
Account Types
| Type | How it signs in |
|---|---|
web |
A password, or a one-time code. With WEB_DEVICE_OTP on, a password or Google, Facebook or Apple sign-in works only from the account's trusted browser (see Trusted Browser). |
mobile |
As web, naming its device in device_id on every sign-in (or in an X-DeviceId header, when the body has none); one that names none is refused (INVALID_DATA, 402). A password or provider sign-in from a device other than the account's trusted one, or while it has none (its very first sign-in included), is asked for a code (OTP_NEEDED, 115), and the sign-in by code then trusts that device. |
bot |
For integrations. Never locked by failed attempts and outside the session limit; its token works until it expires or is ended by a sign-out. |
admin |
A password and a one-time code, both checked. Never through Google, Facebook, Apple, an LDAP bind, or the MCP and OpenID Connect sign-in forms. With ADMIN_IP_ALLOWLIST set, served only from those addresses, at sign-in and on every request. |
The type grants no permissions; roles do. What it decides is whether grants on users, roles, permissions and groups count: for an admin account they do, and for other types only through an exemption, the built-in dmart aside (see The Management Space). An admin account needs a phone or an email for its codes, which /managed/request enforces. It creates an admin without a password and with force_password_change on, so the admin sets its first password with a reset code sent there. Such an admin can be updated before its first reset, so a mistyped phone or email is corrected before the code goes out; what /managed/request refuses is making an existing account that has no password an admin. Making an account an admin ends its sessions. An admin resets its password by code only while a global admin, or the active admin that owns the account, has set force_password_change on it; the reset clears it. The built-in dmart account is held at every start to ADMIN_USER_TYPE: web (the default) or admin.
Trusted Browser (WEB_DEVICE_OTP)
Off by default, for tightly secured instances. With WEB_DEVICE_OTP=true, an account of type web gets what a mobile account has, a trusted device, except that the device is a browser and the server names it. Mobile, admin and bot accounts, and LDAP binds, are unaffected.
- The browser's identity is a random token the server sets in the
dmart_devicecookie (HttpOnly,SameSite=Lax,Path=/,Secureover HTTPS, for 400 days). Adevice_idin the request body counts for nothing for a web account. Only the token's SHA-256 fingerprint is stored, in the account'sdevice_id: one trusted browser per account, with no expiry. - Password and provider sign-ins (
/user/loginwith a password; Google, Facebook, Apple) succeed only from the trusted browser. From any other browser, the first one included, the right password answersOTP_NEEDED(115), which cxb's and catalog's sign-in forms turn into a code step. A wrong password is still just a wrong password. - A sign-in by code makes its browser the trusted one, with a new token. The browser trusted before needs a code next time.
- A verified email or phone is required. A web account with neither cannot sign in by password or provider: once the credential checks out it gets
USER_ISNT_VERIFIED(11), before the trusted-browser check. A code is the way in: a sign-in by code, or a password reset by code, marks the address the code went to verified, and the sign-in by code also trusts the browser. Where no code can be sent,dmart mark-verifiedon the host marks an address verified. - The stored browser is guarded: nobody sets their own account's
device_idthrough/managed/request, and the profile does not change it. - Other sign-in forms. The MCP and OpenID Connect sign-in forms take a password only, so a web account signs in there only from its trusted browser. Apple's web sign-in always asks for a code, since its callback carries no
SameSite=Laxcookie. A script or thedmartCLI signing in to a web account with a password must keep the cookie and handle the code step; abotaccount is the type for those. - The built-in
dmartaccount is a web account unlessADMIN_USER_TYPE=admin, so give it a verified email or phone the server can send to before turning this on.WEB_DEVICE_OTP=falseand a restart is the way back in.
Locked to a device. locked_to_device works on an account of any type, and on password, code and Google, Facebook and Apple sign-in alike. The first sign-in that sends a device_id (in the body, or an X-DeviceId header) claims the device. After that, a sign-in with no device_id, or with another one, is refused with USER_ACCOUNT_LOCKED (110); unlike a mobile account's new device, it is not offered a code. A user who replaces the phone needs an admin to clear the account's device_id with a user update.
Password Rule
Every new password must meet one rule: at registration, on /user/profile, at password reset and in dmart passwd. The built-in rule asks for 8–64 characters with at least one digit and one uppercase letter (Latin or Arabic), from a set of allowed symbols. A deployment sets its own with a password pattern and a password_hint, the message a refused password gets, in the regex file (REGEX_CONFIG_PATH, ~/.dmart/regex.json by default). Existing passwords are never re-checked.
The same file overrides the email and msisdn formats, and holds the username rule. The built-in formats are ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z and ^\+?[0-9]{6,15}\z.
- End a pattern with
\z, not$, which also matches before a final newline. The file is JSON, so it writes each backslash twice:\\z. - A pattern that does not compile is logged and its default applies. A file that is not valid JSON is ignored whole, so every default stays.
- A match is cut off after 100 ms, and counts as a refusal.
- The file is read at start, so a change needs a restart.
Usernames
A user's shortname is its username. Self-registration (POST /user/create) takes one as a top-level shortname; without one, or with auto, the server makes an 8-character name. A chosen name must be free and meet dmart's shortname rule: up to 64 letters, digits and _, Arabic ones included. A taken name is refused before any code is spent.
A deployment narrows that with a username pattern in the regex file, and a username_hint, what a refused name is told. A name must then match both rules. The same rules hold for a name an admin chooses in a create through /managed/request, and for the names dmart import-ldif brings in, which skips any it refuses. A name the server makes is never held to the narrower rule.
- Before a code is sent.
GET /user/check-existing?shortname=answersunique: false, with amessage(the hint, when one is set), for a name the rules refuse. The client config cxb and catalog read, and/public/client-config, carryusername_hintandusername_rule_custom, so a sign-up form can show the rule. - Matrix IDs. Where dmart's names become Matrix IDs (
preferred_username), use a Matrix-safe rule, such as^[a-z][a-z0-9_]{2,31}\z.
Email Domains and Contact-Only Email
Two settings limit what a user's own email may be and do (see Settings). Both are empty by default, which limits nothing.
-
REGISTRATION_EMAIL_DOMAINS(a.com,b.org) lists the only domains a user may give their own account: at/user/create, in the account a sign-in by code creates, and when changing their email on/user/verify-contact. A domain matches exactly, somail.a.commust be listed on its own. A code asked for to register, or to verify a new address, at another domain is refused with the list of allowed domains, and none is sent; the account a sign-in by code would create is refused quietly, as the rest of that gate is. Admins creating or editing users are not limited, and an account keeps the email it has: it can still confirm it, and still gets codes there. -
EMAIL_CONTACT_ONLY_ROLESnames roles whose accounts sign in by phone alone. On an account that holds one, has a phone and no password, and is not of typeadmin, the email is for contact only:- no sign-in or reset code goes to it, whether the request names the email or the shortname; a request naming the email answers as usual and sends nothing;
- a Google, Facebook or Apple sign-in is never linked to the account through it;
- adding or changing it needs only a code sent to the new address, with no
current_code; - changing the phone still needs a code sent to the current phone, and a code sent to the email does not count.
Someone who loses the phone then cannot recover the account through the email, so check that nothing signs those accounts in by email before turning it on.
Forced Password Change
force_password_change marks an account whose password is missing or temporary. It is set on every account created without a password: every account an admin creates, a self-registration with only an email or phone, and a code sign-in that registers the account (ENABLE_OTP_IMPLICIT_REGISTRATION). An admin may also set it. Setting a password on /user/profile, through a reset or with dmart passwd clears it, and GET /user/profile returns it.
/user/login does not stop on it, so a client reads the flag and asks the user for a new password. The MCP and OpenID Connect sign-in forms stop such a user with a page asking them to choose their own password in dmart first, and their token endpoints refuse the account (invalid_grant) until then.
Lockout
Wrong passwords and codes add to one counter per account: at sign-in, at password reset, on a profile password change, at /user/validate_password, on an email or phone change at /user/verify-contact, at the MCP and OpenID Connect sign-in forms, and on an LDAP bind. At MAX_FAILED_LOGIN_ATTEMPTS (5) the account is locked and refuses even the right password, until no attempt has been made for LOCKOUT_COOLDOWN_SECONDS (900) or a user update carrying unlock: true lifts it.
The lock stops guessing at the password. Anyone who knows a username can keep it in force, so it does not shut the owner out: a one-time code sent to the account's own phone or email still signs it in and still resets its password. While the cool-down has not run out:
| Path | While locked |
|---|---|
Password sign-in: /user/login with a password, the MCP and OpenID Connect sign-in forms |
Refused before the password is checked, the right one included (USER_ACCOUNT_LOCKED, 110, at /user/login). The attempt restarts the cool-down. |
Code sign-in: /user/login with otp |
A right code signs in and clears the counter. A wrong code answers 110, is not counted again, and restarts the cool-down; the expired code itself answers OTP_EXPIRED (308). An admin still needs its password with the code, and a wrong one answers 110. |
/user/otp-request for login or reset |
The code is sent as for any account; a deactivated or deleted one gets none. An admin gets no login code here, since only a password sign-in sends it, so a locked admin waits out the cool-down, or resets once a global admin or its owner has set force_password_change. |
/user/otp-request for verify-contact |
Nothing is sent; the answer is the same 200. |
/user/password-reset-confirm |
A right code sets the new password and clears the counter. A wrong code counts and answers OTP_INVALID (307); the expired code itself counts and answers OTP_EXPIRED (308). |
A password a session presents: /user/validate_password, old_password on /user/profile, password on /user/verify-contact |
Refused as locked; the password is not checked. |
| LDAP bind | Refused as a wrong password is (invalidCredentials); the password is not checked. |
/oauth/token, both grants |
invalid_grant. |
| A token issued before the lock | Works until it expires. |
Guesses at a code are capped by that code's own MAX_OTP_VERIFY_ATTEMPTS, lock or no lock. The one session a lock ends is the one whose own wrong password tripped it, on /user/validate_password, /user/profile or /user/verify-contact. The counter is the server's own: an attempt_count sent in an update is ignored. Who may unlock an account follows Managing User Accounts, and an unlock that clears the counter writes a history row naming who lifted it. The settings page draws the lock over time.
Sessions
Each sign-in issues a token valid for JWT_ACCESS_EXPIRES (30 days) and records a session, and every request checks that the session is still there. Signing out ends it. An account keeps at most MAX_SESSIONS_PER_USER (5) sessions; a sign-in past that ends the oldest, and 0 means no limit. With SESSION_INACTIVITY_TTL set, a session unused for that many seconds ends. A bot keeps no session, so neither limit applies to it. A password change or reset ends the account's sessions while LOGOUT_ON_PWD_CHANGE is on (the default), and so do deactivating the account, making it an admin, and switching it to or from bot.
An Account's History
An account's history (a history query under management:/users) holds the edits made to it through /managed/request, /user/profile and /user/verify-contact, by whoever made them, and these rows, each in the {field: {old, new}} shape of every history row:
- Each sign-in, by the account:
last_login, from the previous sign-in's time to this one's, andlogin_method, from the previous sign-in's way to this one's:password,code,password+code(an admin),provider(Google, Facebook or Apple) orregistration(the sign-in that ends a self-registration). When the sign-in made a device the trusted one,device_id, masked********, with nooldvalue for the account's first trusted device: the row says that it changed, never to what. When its code verified an address,is_email_verifiedoris_msisdn_verified. - A password reset by code, by the account:
password, masked, with the verified flag andforce_password_changeit changed. dmart mark-verified, bydmart: the flag it set.- An import that overwrites the account (
dmart import,/managed/import, a pack install), by the account that ran it,dmartfor the commands run on the host: what it changed. Roles, groups and permissions get the same row (see History & alterations).
No row holds a code or a password. prune-login-histories removes old rows that record only a sign-in, and keeps one that trusted a device or verified an address.
Mailbox, Aliases and Services
A user carries three directory fields beside its contact email, for the mail, chat and code services that sign in against dmart over LDAP and OpenID Connect:
| Field | Holds |
|---|---|
mailbox |
The address this deployment hosts for the user. The contact email stays where codes and resets go. |
mail_aliases |
Up to 100 further addresses delivered to the mailbox. |
services |
Up to 64 services the user may use, such as mail or gitea. |
-
Addresses are trimmed, lowercased, and must pass the email format. Each mailbox and alias is unique across all live users, whatever its case. The database enforces it, and a clash is
INVALID_DATA("… is already in use"), without naming the holder. WithUSER_MAIL_DOMAINSset, each must be in one of those domains. An address may not be another user's contactemail, and a contactemailmay not be another user's mailbox or alias. Aliases need a mailbox, and may not repeat it. -
Services must match
^[a-z][a-z0-9_-]{0,63}$, and be listed inUSER_SERVICESwhen that is set. Only a change is checked againstUSER_SERVICE_GRANTERS: a user holding the role paired withmailcan add or removemailon a user who also hasgitea. A change the actor may not make is refused withNOT_ALLOWED. A global admin may change any. -
Who sets them. They are set through
/managed/request, never through/user/profile, and nobody changes their ownmailboxormail_aliases(see Managing User Accounts). An update checks only the fields it changes. A soft delete clears all three, which frees the addresses. -
Sign-in and reset. A user signs in with the mailbox in
emailand a password, never with a one-time code. A password reset that names the mailbox sends its code where a code asked for by shortname would go, in the orderOTP_SHORTNAME_CHANNELSsets (see Settings; withnone, the default order, a verified phone first), and never to the mailbox. -
Where they show. LDAP serves
mailas the mailbox, else the contact email once it is verified, withmailAliasandauthorizedService. OpenID Connect asserts the mailbox, else the contact email, asemail.