/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:/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.
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.
/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.
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.
/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 withAPP_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
See Security.
admin reset-password command, which prints it to that terminal: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
Command palette and record search
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 toaudit_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
/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.
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.
Security headers
Every panel response carriesnosniff, 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.