Users, roles and permissions
How staff users, groups, built-in and custom roles, and scoped grants control what each person may do in Meridian; how to create users and explain a decision; and how members of the public realm differ from staff.
People who sign in to Meridian are in one of two separate realms. Staff sign in to the admin and act through roles granted to their groups. Members (on engine-dmo) sign in to the member portal on behalf of organisations and never hold staff permissions. The engine’s own background work runs as the system.
Staff access is configuration: roles and grants are YAML in config/access/, reviewed and versioned like the rest of the project. Which groups a user is in is data, managed from the command line, the admin or your identity provider.
A staff user’s permission comes through a chain: the user is in groups, a grant gives a group a role under a scope, and the role lists the permissions:
flowchart LR
user["Staff user"] -->|is in| group["Group<br/>such as editors"]
group -->|grants.yaml| grant["Grant"]
grant --> role["Role<br/>built in or roles.yaml"]
grant --> scope["Scope<br/>site, type, locale, state, owner, tag"]
role --> perm["Permissions<br/>such as content.publish"]
perm --> decision{"Allowed on<br/>this resource?"}
scope --> decisionThe user may act when any one grant’s role holds the permission and that grant’s scope covers the resource.
A minimal example
# config/access/grants.yaml
grants:
- { group: administrators, role: owner }
- { group: editors, role: editor }
- group: welsh-team
role: translator
scope: { locale: [cy] }
- group: town-editors
role: editor
scope: { site: [town] }
- group: newsroom
role: news-writer
scope: { type: [article], state: [draft, in_review], owner: self } # config/access/roles.yaml
roles:
- key: news-writer
name: { en: News writer }
permissions: [content.view, content.create, content.edit, media.view, media.upload] Run engine-dmo check --fix to give the new role its id. A member of newsroom can create articles and edit their own drafts and articles in review, but cannot publish, see other people’s drafts or touch other types. A member of welsh-team can edit and publish Welsh on any document. A member of town-editors has the editor role on the town site only.
Staff users and groups
A staff user has an email address, a display name, a status (active or suspended) and a list of groups. Groups are plain keys such as editors or welsh-team; they need no declaration, and exist because a user is in them and a grant names them.
| Way in | How |
|---|---|
engine-dmo user create --email <address> --name "<name>" --group <key> | Creates a local user and prints a single-use enrolment link, valid for 24 hours. The first user ever created goes into administrators when no --group is given. --group is repeatable |
| The admin’s Users screen | An administrator invites a user and sets their groups (needs users.manage) |
| Single sign-on (OIDC) | A user signing in through your identity provider is created on first sign-in and put in groups mapped from a claim |
seed/users/*.yaml | Development users with a password and an authenticator secret; never created in production |
Opening an enrolment link, the user sets a password and enrols an authenticator app in one step, then sees their recovery codes once.
engine-dmo user list # every user, --group <key> for one group
engine-dmo user suspend --email <address> # suspend and revoke every session
engine-dmo user reset-mfa --email <address> --confirm # clear the second factor, revoke sessions, print a new enrolment link Local accounts
# config/engine.yaml
auth:
staff:
local:
enabled: true
require_mfa: true
password_min_length: 12
breach_check: local
allowlist: ${LOCAL_ACCOUNT_ALLOWLIST:-} | Key | Default | Meaning |
|---|---|---|
enabled | true | Allow local accounts (email and password) |
require_mfa | true | Require an authenticator code at sign-in. false is refused in production |
password_min_length | 12 | At least 12 |
breach_check | local | local checks passwords against a list of breached passwords shipped in the binary; hibp also asks the Have I Been Pwned range API; off |
allowlist | empty | Comma-separated email addresses allowed to sign in locally; empty allows any |
argon2 | { memory_kb: 65536, time: 3, threads: 2 } | Password hashing cost; refused below a floor |
Single sign-on
With auth.staff.oidc.enabled: true, staff sign in through an OpenID Connect provider. The provider says who the user is; your grants say what their groups may do. group_claim names the claim that lists a user’s groups, and group_map maps each value in it to one or more Meridian group keys:
auth:
staff:
oidc:
enabled: true
issuer: ${OIDC_ISSUER}
client_id: ${OIDC_CLIENT_ID}
client_secret: ${OIDC_CLIENT_SECRET}
group_claim: groups
group_map:
"Web-Editors": editors
"Web-Admins": administrators
"Comms": [newsroom, welsh-team] Each value is compared exactly. Every Meridian group a mapping names must be one grants.yaml grants a role to (or one provision.groups gives every user); check refuses a mapping that would grant nothing.
| Key | Default | Meaning |
|---|---|---|
issuer, client_id, client_secret | required | Your provider’s issuer URL and this client’s credentials. client_secret is a secret: a bare ${VAR} |
scopes | [openid, profile, email] | Scopes requested |
required_acr | none | An authentication context the provider must assert, such as one that requires a second factor |
group_claim | groups | The claim listing the user’s groups, read from the ID token, then from userinfo |
group_map | none | Provider group value to one Meridian group key, or a list of them |
unmatched_groups | ignore | ignore or warn for groups in the claim with no mapping |
no_mapped_group | refuse | refuse or provision a user whose groups map to nothing |
auto_provision | true | Create the user on their first successful sign-in |
link_existing_by_email | verified_only | verified_only or never: adopt a local account with the same email address |
provision.display_name_claim, provision.locale_claim | name, locale | Claims read when a user is created |
provision.groups | none | Groups every provisioned user gets, whatever their claims |
deprovision_on | absent | absent or never |
Custom fields on the staff user record are declared in config/access/users.yaml.
Roles
A role is a named list of permissions. Eight roles are built in, and a grant can name any of them without roles.yaml declaring it:
| Role | Holds |
|---|---|
owner | Every permission, including those of every compiled module and any added in later releases. Its list cannot be edited |
administrator | All content, media, CRM records, mail, analytics, settings, users, audit, cache, search, navigation, webhooks, API keys and the content MCP; not content.legal_hold, media.rights, media.delete, the CRM purges or member.impersonate |
editor | View, create, edit, publish, delete, restore and clone content, content health, media view, upload and edit, CRM organisation view, claim review, analytics view, navigation and the content MCP |
author | View, create and edit content; view and upload media |
translator | View, edit and publish content; view media |
partnership-manager | The CRM’s organisations, people, memberships, tags, smart lists and claims; analytics view and export |
analyst | Analytics view, export and entered figures; content and CRM organisation view |
auditor | Read-only: content, media, CRM records, analytics, settings, users, the audit log, cache, search, webhooks, API keys and diagnostics |
Every list except owner’s is written out, so a permission added in a later release is denied to these roles until your roles.yaml grants it.
Held by owner alone
No built-in role but owner holds media.rights (recording an image’s licence), media.delete, or any AI verb (ai.use, ai.bulk, ai.generate_image, ai.configure). Until roles.yaml grants them, administrators cannot record image rights or delete assets, and nobody but an owner sees the AI assistance. Grant them with a small role of your own beside the built-in one, rather than restating the built-in role’s whole list:
# config/access/roles.yaml
roles:
- key: media-steward
name: { en: Media steward }
permissions: [media.view, media.rights, media.delete, ai.use] # config/access/grants.yaml
grants:
- { group: administrators, role: owner }
- { group: editors, role: editor }
- { group: editors, role: media-steward } Modules add their verbs to some built-in roles; see Modules and bundles.
Your own roles
config/access/roles.yaml declares roles of your own, and replaces built-in ones:
roles:
# A new role, with an id written by check --fix.
- key: news-writer
name: { en: News writer }
permissions: [content.view, content.create, content.edit, media.view, media.upload]
# Replaces the built-in editor role: this list is the whole of it.
- key: editor
name: { en: Editor }
permissions: [content.view, content.create, content.edit, content.publish, media.view, media.upload, media.edit, nav.manage] | Key | Meaning |
|---|---|
key | Lower-case letters, digits and hyphens. The same key as a built-in role replaces it; that role needs no id and keeps the built-in one |
id | Written by the tooling for a new role |
name | The role’s name per locale |
permissions | The verbs it holds. An unknown verb is an error at its line with the nearest known one suggested |
The owner role can be renamed (its name) but given no permissions list.
The permissions
Core’s verbs, in both bundles:
| Area | Verbs |
|---|---|
| Content | content.view, content.create, content.edit, content.publish, content.delete, content.restore, content.clone, content.purge, content.legal_hold, content.export, content.health |
| Media | media.view, media.upload, media.edit, media.rights, media.delete |
| CRM records | crm.organisation.view, crm.organisation.edit, crm.organisation.delete, crm.organisation.purge, crm.person.view, crm.person.edit, crm.person.delete, crm.person.purge, crm.membership.view, crm.membership.edit, crm.tag.manage, crm.smartlist.manage, crm.claim.review |
mail.manage | |
| Analytics | analytics.view, analytics.export, analytics.enter, analytics.configure |
| Members | member.impersonate |
| Settings and users | settings.view, settings.edit, users.view, users.manage |
| API keys | api.keys.view, api.keys.manage |
| Audit and system | audit.view, system.maintenance, system.diagnostics |
| Cache and search | cache.view, cache.purge, search.view, search.rebuild, search.purge, search.synonyms |
| Webhooks | webhooks.view, webhooks.replay |
| Navigation | nav.manage |
| Content MCP | mcp.connect |
Each module the binary compiles adds its own verbs (Modules and bundles). engine-dmo access matrix lists every active user’s effective permissions, by group (--site for one site’s view).
Grants
A grant gives a group a role under a scope. Every grant is in config/access/grants.yaml:
| Key | Meaning |
|---|---|
group | The group key. Required |
role | A built-in role or one from roles.yaml. An unknown role is an error with the nearest suggested |
scope | What the grant covers. Omitted, it covers everything |
The scope’s dimensions:
| Dimension | Values | Covers |
|---|---|---|
site | Site keys | Resources on these sites |
type | Type keys | Documents of these types |
locale | Locale codes | These locales of a document |
state | draft, in_review, published, scheduled, archived | Locales in these workflow states |
owner | any (the default), self, own_group | self: documents the user created. own_group: documents created by anyone in the grant’s group |
tag | Tag keys, or root.* for every tag under root | Documents carrying one of these tags |
A dimension left out, empty or containing "*" means any value. The dimensions of one grant combine with AND; a user with several grants (through several groups, or several grants to one group) may do whatever any one of them allows.
Scope is applied inside the database queries, so a list in the admin or the content MCP shows only what the user’s grants reach, and counts and pages are right.
Applying role and grant changes
Roles and grants are part of the schema plan. A change appears in engine-dmo schema plan and takes effect when it is applied (dev up applies it as you save, unless it is destructive):
- Removing or narrowing a grant that takes a permission from active users is destructive, marked with the number of users who lose it, and needs approval like any destructive change.
- A plan that would leave no active user able to manage users or holding the
ownerrole is refused, even with--allow-destructive.
The admin’s Access screen can draft a grant for you: it renders the YAML and a diff against grants.yaml for you to commit. It never writes the file itself.
Explaining a decision
engine-dmo user grant-explain --email sam@example.org --action content.publish --resource article:<document id> grant-explain says whether the user may do it and why: each grant they hold, each dimension of its scope, and which one passed or failed. With --resource type:<id> it reads the site, type, locale, state, owner and tags from the document; without it, describe a hypothetical resource with --site, --type, --locale, --state, --owner and --tag. The admin’s Access screen answers the same question.
Members
On engine-dmo, the people behind organisations (a business owner, its marketing contact) sign in to the member portal, not the admin. Members are a separate realm:
- They never hold staff grants; nothing in
grants.yamlapplies to them. - They sign in with a magic link or a passkey (
auth.memberinengine.yaml). Member passwords are not available. - They join by a staff invitation or by claiming an organisation (
auth.member.registration: claim, the default), or by invitation only (invite_only). - What a member may do for an organisation is set by their partner role on it.
owner,manager,contactandbillingare built in;config/portal/roles.yamladds your own or restates a built-in one.
A staff user with member.impersonate can see the portal as a member sees it. Partners and the portal covers members, partner roles and the portal in full.
Stable ids
The id written beside every named element in Meridian configuration, who writes it, why a rename keeps all stored data, how renames and aliases work, and how to fix a missing, copied or wrong id.
Content modelling
How a content type is declared in YAML under config/schema, with a complete worked example from an empty file to a rendered page.