Skip to main content
The admin panel is the operator console at /admin. Its API lives at /v1/panel and is authenticated with a session cookie, not the APP_OPERATOR_TOKEN bearer token used by /v1/admin. The two surfaces are deliberately separate: they have different trust models, and sharing a prefix would let one inherit the other’s middleware.

First-owner setup

A fresh installation has no operator account, so nothing can sign in yet. On first start the API mints a one-time setup token and writes it to its log:
Open /admin/setup, paste the token, and create the first owner. The token is valid for 30 minutes and can be redeemed once. The bootstrap path closes permanently as soon as any operator account exists. A token that leaked before that point cannot be redeemed afterwards, and requesting a new token retires any outstanding one.
The setup token grants owner access to the whole installation. Read it from the server log directly rather than forwarding the log to a chat or ticket.

Roles and permissions

Roles are built in. The permission catalogue and the role-to-permission mapping are compiled into the application, so a database edit cannot widen access; the database records only which roles an operator holds. Only an owner may create or invite an operator, change role grants, or grant the owner role. Keeping that owner-only means a compromised administrator session cannot mint itself a second account or widen its own privileges. The installation always keeps at least one active owner. Revoking the last owner’s role, or suspending the last owner, is refused rather than leaving the panel unadministrable. Permissions are enforced in the Go API on every request. The panel renders from the same permission set it receives, so hiding a control is presentation only — reaching a hidden route grants nothing. v0.7 adds risk, gifts, and goods to the catalogue. What they cover and why they are separate capabilities is described in Admin operations.

Inviting operators and changing roles

An owner adds an operator from /admin/operators with Invite operator: an email address and one or more roles. The API answers with a single-use link, /admin/invite/<token>, and the panel shows it once with a copy button. Omniflow has no email transport, so the owner sends the link themselves, the same way they would hand over any other credential.
  • The link works once and expires after 72 hours. Only the SHA-256 of its token is stored; the panel cannot show it again.
  • Inviting the same address again retires the earlier link, so a lost or intercepted link is replaced rather than left usable.
  • An address that already has an operator account cannot be invited; change that operator’s roles instead.
  • Pending invitations lists every link not yet used, marks the expired ones, and offers Revoke.
The invitee opens the link outside the panel shell, sees the address and roles they were invited with, and chooses a display name and a password (at least 12 characters, with the same strength meter as the security page). They are signed in straight away and offered /admin/security to enrol two-factor or a passkey; the installation does not force either. Change roles on an operator’s row opens the role editor. The API enforces these rules on every change, and the editor greys out what it would refuse:
  • Nobody can change their own roles.
  • Every role added or removed must carry only permissions the person making the change already holds, and only an owner can grant or remove the owner role.
  • An operator keeps at least one role; suspension is how access is taken away.
  • The installation keeps at least one active owner. Role changes that could remove an owner are serialised, so two owners cannot demote each other at once.
  • Saving an unchanged set is a no-op and is not recorded.
The Roles and permissions table on the same screen is read from the compiled-in catalogue the API enforces, one row per area. Invitation issue, revocation, and acceptance are audited as admin.invitation.created, admin.invitation.revoked, and admin.invitation.accepted; role changes as admin.account.roles_changed with the before and after sets.

Sessions

Only the SHA-256 digest of a session token is stored, so a database read or an offline backup never yields a usable cookie. Two expiries run at once:
  • Inactivity — 30 minutes, slid forward on each authenticated request.
  • Absolute — 12 hours, never extended.
A session kept warm by a background tab still ends at the absolute horizon. Tokens also rotate every 15 minutes, which bounds how long a token captured from a log or a proxy stays replayable. Each operator can review their live sessions at /admin/security, end any one of them, or sign out everywhere else. Changing a password ends every other session; completing a password reset ends all of them. The same screen registers and revokes passkeys. A passkey signs in on its own — the authenticator proves possession and verifies the person, so the sign-in screen goes straight in with no password and no code. Each key carries a name the operator chooses, which is what makes revoking the right one possible months later; the list also shows when each was last used. Keys never replace the password, so removing the last one locks nobody out. The button is absent when the installation has no APP_PUBLIC_URL to bind credentials to, or when the browser cannot run the ceremony. See Security. The new-password field carries a strength meter. It estimates entropy over the alphabet the password actually uses and then subtracts for the patterns that make that alphabet a lie — too few distinct characters, a run from the keyboard or the alphabet, a single unbroken word — and names the one or two things that would help most. It is deliberately not a character-class checklist: “one upper, one digit, one symbol” rewards Password1!, which is among the first guesses any real attack makes, and penalises a long passphrase that is genuinely hard. The meter never refuses a password. The minimum length is the API’s rule and is enforced there; a meter that blocked would be a second place deciding what a valid password is.

Two-factor authentication

TOTP follows RFC 6238 with 6-digit codes on a 30-second step, and works with any standard authenticator app. Secrets are sealed with APP_DATA_ENCRYPTION_KEY before storage. Enrolment is two steps. Adding the secret stores it unconfirmed; it satisfies no login challenge until the first code is verified. An abandoned enrolment leaves the account exactly as it was. The enrolment screen shows a QR code beside the secret. It is drawn in the browser from the otpauth:// URI the API returns, never fetched as an image: that URI carries the secret itself, so requesting it from an image endpoint — including this application’s own — would put a credential into a request line, a proxy log, and a cache. The secret is also printed as text, because an authenticator running on the same device cannot photograph the screen it is on. Confirming enrolment issues ten single-use recovery codes, shown once. Only their digests are stored. Regenerating the set invalidates every unused code. Turning two-factor off requires re-entering the password, because removing a second factor is exactly what an attacker on a hijacked session would attempt.

Sign-in protection

  • Sign-in answers identically for a wrong password, an unknown address, and a disabled account, and an unknown address is verified against a decoy hash so it costs the same. Operator accounts therefore cannot be enumerated.
  • Five consecutive failures lock an account, with the wait doubling on each further failure up to one hour.
  • Requests are rate limited per address and per source address, so neither a single account nor a spray across many can be ground down.
  • Password reset always answers 202, whether or not the address matched an account.
There is no operator email transport, so the public reset request issues no token: it records the request and posts a notice to the 🔐 Admin security topic. A token is issued only on the server, by the API binary’s admin reset-password command, which prints it to that terminal:
See Security.

Security notifications

Security-relevant events are posted to the 🔐 Admin security topic in the operator Telegram group, alongside the purchase and incident streams:
  • a sign-in from an address the account has not used before
  • a password change or a completed password reset
  • two-factor being turned off
  • the owner role being granted
  • any other change to an operator’s roles
  • an invitation being accepted, which means a new person can now sign in
A notice names the event and the operator account and nothing else. An address, a session token, and a client IP all stay out of the group chat; that detail lives in the audit trail, behind the permissions that govern it. Notices are written in the same transaction as the change they describe, so one can never announce something that was rolled back, and they are deduplicated, so a retried transaction produces a single message. Ctrl+K (⌘K on a Mac), the search field in the header, or the search icon on a phone opens the command palette. It jumps to any screen the operator may open, starts a common action (inviting an operator, setting up two-factor), and finds records. Record search asks GET /v1/panel/search?q= and shows up to five results per group, each with a preview line and what it matched on: A group the operator’s roles cannot read is not queried and does not appear in the response, so the palette never shows a record its link would refuse to open. Emails are matched exactly, through the keyed fingerprint stored beside the encrypted address, and the address is never returned. Omniflow does not store Telegram @usernames, so a query that starts with @ finds no customer and the palette says so; use the numeric Telegram ID instead. Every lookup is an index probe — an ID prefix is a primary-key range scan, and ticket subjects use a trigram index from the pg_trgm extension, which the migration enables (it is a trusted extension, so the database owner can create it without superuser). Search is limited to 60 requests a minute per operator and, like every other read in the panel, is not written to the audit trail.

Audit trail

Every authentication, authorization, and configuration action is appended to audit_events, which exposes reads only. Each row carries the actor, target, action, category, outcome, reason, request ID, timestamp, and safe before/after metadata. Events are classified on two axes the panel filters and exports on:
  • Category — authentication, authorization, configuration, customer, financial, support, marketing, system
  • Outcome — success, failure, denied
A permission denial is recorded as well as refused: an operator repeatedly reaching for something they cannot do is exactly what an audit review looks for. Browse the trail at /admin/audit. Filters are held in the URL, so a view can be bookmarked and shared. Anyone with audit.export can download the filtered trail as CSV.

OIDC sign-in

OIDC is configuration, not code: supply a discovery document and client credentials under Settings → OIDC, and nothing provider-specific exists in the application. Anything that speaks OpenID Connect Discovery works, and an installation with no configured provider authenticates with passwords alone. The flow is authorization code with PKCE. PKCE is used even though this is a confidential client, because it binds the code to the originating request, so a code intercepted at the redirect cannot be redeemed by anyone else. The anti-forgery state and the PKCE verifier travel in a short-lived encrypted cookie rather than the database, so an abandoned sign-in leaves nothing behind. Every ID token is verified against the provider’s published JWKS, issuer, and audience before it is trusted. A linked identity records the issuer it was linked at and matches only a token from that same issuer, so editing a provider’s issuer cannot hand its linked operators to somebody else. Changing an operator provider is owner-only (settings.write plus admins.roles), and a provider with linked operators cannot be repointed at another issuer at all — see OIDC sign-in. Two policies govern which account a subject reaches:
  • Verified email required (default on). A provider may assert an address it has not verified; requiring the claim stops it from minting access to an account it does not control.
  • Auto-provisioning (default off). A subject that matches no linked account is refused. With auto-provisioning on, a subject whose address is not already in use gets a fresh account in the configured role. An address that already belongs to an operator is never silently adopted — linking has to be a deliberate act by someone already signed in. The owner role can never be auto-provisioned.
Redirect URI to register with the provider:

Configuration

Running behind a reverse proxy

X-Forwarded-For is trivially forged, so it is honoured only when the immediate peer is listed in APP_TRUSTED_PROXIES. Leave the variable empty when the API is exposed directly — the socket address is then used, which is correct. The address feeds rate limiting and the audit trail, so getting this wrong in either direction matters: trust nothing and every request behind a proxy shares one address; trust too much and an attacker chooses their own.
APP_ADMIN_COOKIE_SECURE=false is for local development over plain HTTP only. Without Secure, the session cookie can be captured from any unencrypted request to the same host.

Security headers

Every panel response carries nosniff, X-Frame-Options: DENY, a strict Content-Security-Policy with frame-ancestors 'none', Referrer-Policy: no-referrer, and Cache-Control: no-store. The session cookie is named __Host-omniflow_admin when it carries Secure, which binds it to this exact origin: a browser refuses such a cookie with a Domain attribute, so a sibling subdomain cannot set or overwrite the operator’s session. That refusal is unconditional, and it includes the cookie’s own arrival. A browser rejects a __Host- cookie sent without Secure outright rather than merely treating it as weaker, so with APP_ADMIN_COOKIE_SECURE=false the name would make sign-in impossible instead of insecure — the cookie would be discarded on arrival and every attempt would come back to the sign-in screen. The prefix therefore follows the attribute it depends on: the cookie is __Host-omniflow_admin when it is Secure and omniflow_admin when it is not. The production name is the same as it has always been; only the plain-HTTP local stack differs, and there the prefix would have been a claim the cookie could not support anyway. Every unsafe method additionally requires a per-session CSRF token echoed in X-CSRF-Token; it is derived from a secret held with the session, so a token minted for one session cannot be replayed in another.