Skip to main content

Authentication & RBAC

Mahresources ships with optional user accounts and role-based access control (RBAC). It is off by default -- the application is designed for private, trusted networks, and out of the box it has no login screen at all. When you need accountability, multiple users, or scoped read-only access, you can turn authentication on with a single flag.

How it works when auth is off

By default (-auth not set), there are no users, no login page, and no permission checks. Every request runs as an implicit administrator with full access. This is the historical Mahresources behavior, and it keeps existing deployments, the mr CLI, and the test suite working unchanged.

Put a reverse proxy in front if it is exposed

With auth off, Mahresources has no login and no permission checks: anyone who can reach it has full administrative access. If this instance is reachable from outside your trusted network, front it with a reverse proxy that enforces authentication. See Reverse Proxy Configuration. Turning -auth on later does not remove this need; built-in auth and a reverse proxy are complementary.

Enabling authentication

Turn auth on with the -auth flag or AUTH_ENABLED=1:

./mahresources \
-db-type=SQLITE \
-db-dsn=./mahresources.db \
-file-save-path=./files \
-bind-address=:8181 \
-auth

Once enabled, every request must authenticate. Unauthenticated browser requests are redirected to /login; unauthenticated API requests are rejected.

Bootstrapping the first admin

You cannot log in until at least one account exists. Create the first administrator at startup with -create-admin-user and -create-admin-password:

./mahresources \
-auth \
-create-admin-user admin \
-create-admin-password 'choose-a-strong-password' \
-db-type=SQLITE -db-dsn=./mahresources.db -file-save-path=./files

This step is idempotent: on each startup it creates the account if it is missing, or resets the named account to an enabled administrator if it already exists, overwriting its password, re-enabling it if it was disabled, clearing any group scope, and revoking all of its sessions and API tokens each time. -create-admin-user requires -create-admin-password, or startup fails.

The bootstrap runs whether or not -auth is set, so you can seed an administrator on a running no-auth instance before turning authentication on.

Rotate the bootstrap credentials out of your launch command

Once the admin account exists and you have logged in, remove -create-admin-password from your start command (and your shell history / process list) and manage further accounts through the UI or the mr user CLI commands.

The root administrator

Startup guarantees that an enabled administrator exists, in both auth modes. If none does, Mahresources creates one named root with a crypto-random password. That password is never printed and nobody knows it, so it does not let anyone in; it exists so the instance is never left with no administrator at all. An existing non-admin account named root is not hijacked: the name is suffixed to root2, root3 and so on until an unused one is found.

Under -auth, if every enabled administrator still holds an auto-generated password, a warning is logged on every boot naming -create-admin-user and -create-admin-password as the remedy. It repeats until an operator sets a real admin password.

The four roles

Every account has exactly one role. Capabilities are cumulative from guest up to admin.

RoleCan doCannot do
adminEverything: full CRUD, plus system settings, plugin management, Categories, Resource Categories and Template Partials, and user administration (/admin/users).--
editorFull CRUD on entities (resources, notes, groups, tags, note types, series, relations, saved queries).Create or edit Categories, Resource Categories or Template Partials; change system settings; manage users or plugins.
userCRUD on resources and notes, plus subgroups, tagging, note sharing, group import/export, and running plugin actions (for a group-limited user, only for plugins an operator has opened to scoped accounts). May optionally be confined to a single Group's subtree.Edit Categories or Resource Categories; edit note types, relations, series, or saved queries; system administration.
guestRead-only access. Always confined to a single Group's subtree.Any write. Anything outside its scope group.

Group-subtree scoping

Accounts with the user or guest role can be confined to a single Group and everything beneath it. A guest is always scoped; a user is scoped optionally. The scope is set per account via the ScopeGroupId field on the user.

Scoping is enforced consistently and fail-closed across the entire surface: list pages, single-item reads, full-text search, MRQL queries, file and thumbnail serving, group export, and all writes. What it confines are the entities that carry an owner, Groups, Resources and Notes, and every path that reads them.

Tags, Categories, Note Types, Series, Saved Queries and Relation Types carry no owner. They are global, and every authenticated account can read them; what limits a scoped account there is its role, not its scope group.

Group-limited accounts are also refused a plugin's own surfaces by default: its pages, its /v1/plugins/... endpoints, and block and display rendering. An operator opens one plugin at a time with Allow limited users on /plugins/manage, or mr plugin scoped-access <name> --allowed=true. Opening a plugin does not widen it, because a confined caller's mah.db stays bound to that caller's own subtree and role. Unscoped roles (admin, editor, and an unscoped user) are unaffected. See Plugin Permissions.

How to authenticate

There are two ways to present an identity.

Browser session (login page)

Visit /login and sign in with a username and password. On success the server sets a session cookie, and the browser carries it on subsequent requests. Sign out at /logout.

API token (Bearer)

Programmatic clients authenticate with a per-user API token in the Authorization header:

Authorization: Bearer <token>

The mr CLI uses this mechanism. Mint and store a token with mr auth login, which saves it to the CLI credentials file; subsequent commands read it automatically. You can also supply a token directly through the MR_TOKEN environment variable, which overrides the stored credential. Manage your own tokens from the command line with the mr token commands.

Sessions

Browser login sessions are governed by two settings:

  • SESSION_TTL (-session-ttl) controls how long a session stays valid. The default is 720h -- 30 days.
  • SESSION_COOKIE_SECURE (-session-cookie-secure) marks the session cookie Secure, so the browser only sends it over HTTPS. Enable this whenever Mahresources is served behind TLS.

CSRF protection

The session cookie is set SameSite=Lax, which by itself blocks cross-site state-changing requests (POST / PUT / DELETE). On top of that baseline, each session carries a random synchronizer token (defense-in-depth):

  • The token is published to the page in a <meta name="csrf-token"> tag and is also returned by /v1/auth/me.
  • State-changing, cookie-authenticated requests must echo it. The built-in JavaScript fetch wrapper adds the X-CSRF-Token header automatically, so the UI just works. Native multipart upload forms pass the token as a csrf_token query parameter (their body is never parsed here, which preserves the per-upload size limits); other native forms send it as a csrf_token form field.
  • The check is a no-op when auth is disabled, and it never applies to Bearer (API-token) requests, which carry no ambient cookie and are not CSRF-exposed.

You normally do not need to think about CSRF. It is handled for you. It matters only if you are scripting state-changing requests with a session cookie instead of a Bearer token.

Login rate-limiting

To slow down password guessing, you can throttle failed logins:

  • LOGIN_MAX_ATTEMPTS (-login-max-attempts) is the number of failed attempts allowed within the window before further attempts are refused: API callers get HTTP 429, and the browser form is redirected to /login?error=rate. The default is 0, which disables rate-limiting. Only a real credential rejection counts against the limit; contention answers HTTP 503 with Retry-After and any other failure answers HTTP 500, and neither is charged.
  • LOGIN_ATTEMPT_WINDOW (-login-attempt-window) is the sliding window for counting attempts, and also the lockout duration once the limit is hit. The default is 15m.

Throttling is keyed on both the client IP and the target username, so neither a single IP nor a single account can be brute-forced past the limit. Counters are in-memory and per-process: they reset when the server restarts.

Only trust proxy headers behind a trusted proxy

By default Mahresources derives the client IP from the connection itself. If it sits behind a reverse proxy, the connection IP is the proxy, so per-IP throttling needs the real client IP from X-Forwarded-For. Set -trust-proxy-headers (TRUST_PROXY_HEADERS=1) to use that header.

Do not enable it on a directly-exposed server: a client can forge X-Forwarded-For to give itself a fresh apparent IP on every request and defeat per-IP throttling entirely. Turn it on only when a trusted proxy sets the header for you.

Resource limits

Two limits help contain abuse by an authenticated account.

  • MAX_USER_TOKENS (-max-user-tokens) caps how many API tokens a single user may hold. The default is 100; 0 disables the cap. This bounds the self-service token table so one account cannot exhaust it. Hitting the cap returns HTTP 409; revoke an existing token to free a slot.
Consider setting a JSON body limit

The shared JSON request path is unbounded by default, which predates authentication and assumed a trusted single user. With -auth enabled, any authenticated account (including a read-only guest) can POST an arbitrarily large JSON body. Set MAX_JSON_BODY (-max-json-body) to a byte limit to cap it. It is keyed on the application/json content type, so multipart uploads (bounded separately by -max-upload-size) are unaffected. The default 0 leaves it unlimited.

Password policy

Passwords must contain at least 8 Unicode code points and occupy at most 72 UTF-8 bytes (bcrypt's input limit). These are different units: non-ASCII characters can use more than one byte. The policy is enforced when an account is created, when a password is changed, and for the -create-admin-password bootstrap. Existing accounts are not re-validated on login, so changing the policy later does not lock anyone out until their next password change.

Managing users and your own account

  • Administrators manage all accounts from /admin/users. Each row links to /admin/users/edit?id=N, where an existing account's username, display name, role, scope group and disabled state can be changed, and its password reset. The same operations are available from the mr user CLI commands.
  • The user-update API is partial: omitted properties are preserved. Send JSON scopeGroupId: null (or an empty/zero scopeGroupId in form or query input) to explicitly clear an optional user scope; omitting scopeGroupId leaves it unchanged. JSON password: null is rejected. Omitting password, or leaving the HTML password field blank, keeps the current password.
  • Saving a disabled account revokes all of that account's browser sessions and API tokens. An administrator password reset also revokes all of the target account's sessions and tokens. Changing your own password signs out other browser sessions while keeping the browser session that submitted the change active; existing API tokens remain valid.
  • The last enabled administrator cannot be deleted, demoted, or disabled: the save is refused with 409 Conflict, and the edit page comes back with the message and the values you typed. Renaming that account or setting a new password is allowed.
  • A group that is still used as an account scope cannot be deleted. The delete is refused with 409 Conflict; move or clear each affected account scope first. This prevents a scoped account from becoming unrestricted because its scope disappeared.
  • Both /admin/users and /admin/users/edit are admin-only. Editors, users and guests receive 403.
  • Every signed-in user has a self-service account page at /account where they can change their own password and manage their own API tokens.

One-time API token handling

A newly created API token is shown exactly once. Copy it before dismissing the success message: Mahresources stores only a hash and cannot display the raw token again. While a raw token is waiting to be copied, the account page blocks another token creation so a second credential cannot overwrite the first. Token lists show only metadata and a non-secret prefix. If a raw token is lost, revoke that token row and create a replacement.

Configuration flags reference

FlagEnv VariableDescriptionDefault
-authAUTH_ENABLED=1Enable user accounts + RBACfalse (off)
-create-admin-userCREATE_ADMIN_USERBootstrap: create or reset this username to an enabled admin at startup (idempotent)-
-create-admin-passwordCREATE_ADMIN_PASSWORDPassword for -create-admin-user (required with it)-
-session-ttlSESSION_TTLHow long a browser login session stays valid720h (30 days)
-session-cookie-secureSESSION_COOKIE_SECURE=1Mark the session cookie Secure (HTTPS-only)false
-login-max-attemptsLOGIN_MAX_ATTEMPTSFailed logins per window before HTTP 429; 0 disables0 (disabled)
-login-attempt-windowLOGIN_ATTEMPT_WINDOWSliding window for failed logins, and the lockout duration15m
-trust-proxy-headersTRUST_PROXY_HEADERS=1Trust X-Forwarded-For for the client IP in login rate-limiting (only behind a trusted proxy)false
-max-user-tokensMAX_USER_TOKENSMaximum API tokens a single user may hold; 0 disables the cap100
-max-json-bodyMAX_JSON_BODYMaximum application/json request body size in bytes; 0 disables the limit0 (unlimited)

Next steps

  • Reverse Proxy Configuration -- set -session-cookie-secure behind TLS and configure proxy headers safely.
  • mr auth -- authenticate the CLI and inspect the current identity.
  • mr user -- administer accounts from the command line.
  • mr token -- manage your own API tokens.