Meridian handbook
Concepts

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 --> decision

The 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 inHow
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 screenAn 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/*.yamlDevelopment 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:-}
KeyDefaultMeaning
enabledtrueAllow local accounts (email and password)
require_mfatrueRequire an authenticator code at sign-in. false is refused in production
password_min_length12At least 12
breach_checklocallocal checks passwords against a list of breached passwords shipped in the binary; hibp also asks the Have I Been Pwned range API; off
allowlistemptyComma-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.

KeyDefaultMeaning
issuer, client_id, client_secretrequiredYour provider’s issuer URL and this client’s credentials. client_secret is a secret: a bare ${VAR}
scopes[openid, profile, email]Scopes requested
required_acrnoneAn authentication context the provider must assert, such as one that requires a second factor
group_claimgroupsThe claim listing the user’s groups, read from the ID token, then from userinfo
group_mapnoneProvider group value to one Meridian group key, or a list of them
unmatched_groupsignoreignore or warn for groups in the claim with no mapping
no_mapped_grouprefuserefuse or provision a user whose groups map to nothing
auto_provisiontrueCreate the user on their first successful sign-in
link_existing_by_emailverified_onlyverified_only or never: adopt a local account with the same email address
provision.display_name_claim, provision.locale_claimname, localeClaims read when a user is created
provision.groupsnoneGroups every provisioned user gets, whatever their claims
deprovision_onabsentabsent 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:

RoleHolds
ownerEvery permission, including those of every compiled module and any added in later releases. Its list cannot be edited
administratorAll 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
editorView, 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
authorView, create and edit content; view and upload media
translatorView, edit and publish content; view media
partnership-managerThe CRM’s organisations, people, memberships, tags, smart lists and claims; analytics view and export
analystAnalytics view, export and entered figures; content and CRM organisation view
auditorRead-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]
KeyMeaning
keyLower-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
idWritten by the tooling for a new role
nameThe role’s name per locale
permissionsThe 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:

AreaVerbs
Contentcontent.view, content.create, content.edit, content.publish, content.delete, content.restore, content.clone, content.purge, content.legal_hold, content.export, content.health
Mediamedia.view, media.upload, media.edit, media.rights, media.delete
CRM recordscrm.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
Mailmail.manage
Analyticsanalytics.view, analytics.export, analytics.enter, analytics.configure
Membersmember.impersonate
Settings and userssettings.view, settings.edit, users.view, users.manage
API keysapi.keys.view, api.keys.manage
Audit and systemaudit.view, system.maintenance, system.diagnostics
Cache and searchcache.view, cache.purge, search.view, search.rebuild, search.purge, search.synonyms
Webhookswebhooks.view, webhooks.replay
Navigationnav.manage
Content MCPmcp.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:

KeyMeaning
groupThe group key. Required
roleA built-in role or one from roles.yaml. An unknown role is an error with the nearest suggested
scopeWhat the grant covers. Omitted, it covers everything

The scope’s dimensions:

DimensionValuesCovers
siteSite keysResources on these sites
typeType keysDocuments of these types
localeLocale codesThese locales of a document
statedraft, in_review, published, scheduled, archivedLocales in these workflow states
ownerany (the default), self, own_groupself: documents the user created. own_group: documents created by anyone in the grant’s group
tagTag keys, or root.* for every tag under rootDocuments 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 owner role 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.yaml applies to them.
  • They sign in with a magic link or a passkey (auth.member in engine.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, contact and billing are built in; config/portal/roles.yaml adds 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.