Authentication RBAC - sparkycz1/honeypot-shelf GitHub Wiki
Every login is checked twice: once to prove who you are, once to decide what you're allowed to touch.
Login/session/TOTP/WebAuthn/LDAP/OIDC/rate-limiting/API-token machinery is copied from debcontrol nearly unchanged — see that project's own wiki for the exhaustive version (session rows vs. signed cookies, brute-force lockout, TOTP recovery codes, the WebAuthn ceremony, OIDC's own session, the bootstrap script). What's genuinely different here is the authorization model on top of it.
Every User row is created inside this app first
(scripts/create_admin.py for the first superadmin, the Users page
after that) — never by LDAP or OIDC. auth_provider only decides how
an existing account proves who it is.
Still no custom roles — but not single-company either. Every user is either:
-
A superadmin (
User.is_superadmin = True) — no memberships at all. Sees and manages every company. -
A company user — zero or more
CompanyMembershiprows, each naming oneCompanyand oneAccessLevel(READorREAD_WRITE), independent per company. The same person can beREAD_WRITEat one company andREAD-only at another. Zero memberships means logged in but scoped to nothing.
Enforced at two independent layers:
-
require_write— is this user allowed to write at all (superadmin, orREAD_WRITEon at least one company)? The generic nav-level gate for pages not scoped to one company (Initialize, Scheduling's landing page). -
ensure_company_access(user, company_id, write=...)/visible_company_ids(user)— which company/companies' data may they touch (a set, not a single id —Nonefor a superadmin means "no filter")? Out-of-scope reads 404, never 403 — a 403 would itself confirm the company/honeypot exists, which an outsider shouldn't learn for free.
No per-honeypot grant, no custom-role editor — see app/db/models/ user.py's docstring for why this is deliberately flatter than
debcontrol's Role/Permission matrix.
What a READ company user sees today: Dashboard, the Honeypots
list, and per honeypot — Overview, Monitoring, Activity (read-only:
what OpenCanary caught, no management implied). Scheduling, /api, and
a honeypot's Updates/Terminal/Logs/Config/Settings tabs are all
write-tier — hidden from nav and 403 if reached directly.
READ_WRITE gets all of it.
Unchanged in mechanism from debcontrol — UserSession rows (not signed
cookies), self-service TOTP with recovery codes, WebAuthn/passkeys,
per-user API tokens (prefixed hhpat_) scoped to whatever the owning
account currently permits, the same per-IP login rate limiter. One
simplification: no per-role mandatory TOTP (there are no roles) —
TOTP/passkeys are purely self-service; a fleet-wide "require 2FA"
toggle would live on AppSettings instead.
A token can be narrowed when it is created (never widened, and not changed later — revoke and create a new one):
-
Read-only (
ApiToken.read_only): onlyGET/HEADare accepted; anything else is a 403. Enforced inget_api_token_user, so it covers every API route. -
Only these companies (
ApiToken.company_ids): the request is scoped to those companies — for a member, the overlap with the memberships; for a superadmin, as if the account belonged to just those companies (User.sees_every_companyis what the superadmin shortcuts inapp.auth.scopecheck). Such a token is refused by every superadmin-only API route (users, companies, settings, backup, audit). A token whose companies have all been deleted sees nothing. - Neither kind can register a honeypot through
POST /api/inform.
A local account created by a superadmin, or one whose password a
superadmin reset, carries User.must_change_password. It can sign in
with that password and then do exactly one thing: choose its own on
/account/password. app.auth.middleware sends every other page there
(a JSON request gets 403), and the terminal and live-update WebSockets
refuse the session (app.auth.sessions.must_change_password_first). The
new password has to differ from the old one. Not enforced while a
superadmin is impersonating the account; API tokens are unaffected.
Superadmins set, on Settings → Security:
-
Sign out after inactivity (default 720 minutes) and maximum
session length (default 720 hours) — the sliding idle window and the
absolute cap every
UserSessiongets. - Failed attempts before lockout (default 5) and lockout duration (default 15 minutes) — wrong passwords or second-factor codes in a row on one account. The per-IP rate limit (30 sign-in attempts per 5 minutes) applies independently.
-
Allow access only from these networks — IPs/CIDRs, one per line;
empty = from anywhere. Enforced before any session lookup on the web UI,
the REST API and every WebSocket (terminal, Initialize, live updates);
/healthz,/api/inform(honeypot self-registration) and static assets stay reachable. A list that excludes the address saving it is refused, so you can't lock yourself out. The client address is the oneProxyHeadersMiddlewareresolves — behind a reverse proxy, setTRUSTED_PROXY_IPS(andTRUST_FORWARDED_FOR) first.
The policy is cached in each process for 10 s; a save applies at once in
the process that handled it. GET /api/v1/settings reports it (read-only —
a wrong allowlist would lock every API token out too).
WebAuthn's origin check and OIDC's redirect_uri both need
request.url.scheme to be correct — behind a TLS-terminating reverse
proxy, that needs ProxyHeadersMiddleware to have fixed it from
X-Forwarded-Proto first, or both fail with "Unexpected client data
origin". See Installation for TRUSTED_PROXY_IPS.
Two OIDC setup mistakes that look like unrelated errors, both now surfaced with an in-app hint (Settings → Integrations → OIDC):
-
Issuer URL includes
/.well-known/openid-configuration.app.auth.oidc._build_clientalways appends that suffix itself ({issuer_url}/.well-known/openid-configuration) — pasting the provider's own "OpenID Endpoint Configuration"/discovery link instead of the plain issuer (a Keycloak realm's own URL, e.g.https://idp.example.com/realms/yours) doubles the suffix and the provider 404s. This used to crash/auth/oidc/loginwith an uncaughthttpx2.HTTPStatusError(500) instead of the same gracefuloidc_errorredirect a failed callback already got — see_OIDC_ERROR_MESSAGES["discovery_failed"]. -
The provider rejects the login with "Invalid redirect_uri" /
"Invalid parameter: redirect_uri". The provider's client
registration (Keycloak: "Valid redirect URIs") must contain the
exact URL
redirect_to_providersends —request.url_for ("oidc_callback"), i.e.<scheme>://<host>/auth/oidc/callbackas seen from wherever the login was started, port included. Settings now prints this exact URL next to the Issuer URL field so there's no guessing.
Login is two steps: GET /login collects only the username (plus
an OIDC button if enabled, labeled with AppSettings.oidc_provider_name
if set), then /login/password?username=... offers a passkey or a
password — a passkey signs straight in, no password ever submitted,
the same GitHub/Google-style flow. The passkey button always shows on
step two regardless of whether the account has one or even exists
(enumeration-resistance — a nonexistent username and a real one with no
passkey get an identical error).
Real client IP behind a proxy. Audit logging and the rate limiter
both read request.client.host — correct only if the proxy shares this
app's network namespace. TRUST_FORWARDED_FOR (off by default) tells
ProxyHeadersMiddleware to also trust X-Forwarded-For — off by
default because trusting it from anyone lets an attacker spoof a new
"source" every attempt and defeat the rate limiter entirely. Only turn
it on once TRUSTED_PROXY_IPS is narrowed to your real proxy.
POST /users/{id}/impersonate (Users list, "Sign in as") — for support/
debugging, a superadmin can take over another account's session without
knowing their password. Ported from an identical debcontrol feature
(user.impersonate, its own permission there); this app has no roles/
permissions at all, so the whole app.web.routes.impersonation router
is gated simply on "you must already be a superadmin," and the
guardrail against admin-on-admin impersonation becomes "the target may
never itself be a superadmin."
Mechanism: starting an impersonation does not touch the superadmin's
own session row — it creates a brand-new UserSession for the target
account (UserSession.impersonator_id tags who started it), swaps the
browser's session cookie over to that new session, and stashes the
superadmin's own raw session token in a second, signed, httponly cookie
(impersonation_return) so it can be handed back later. "Logging out" of
an impersonated session (the same /logout route, no separate endpoint)
restores the superadmin's own session instead of a full sign-out — like
closing a su shell — falling back to an ordinary logout only if that
return cookie is missing/expired or the original session no longer
validates.
Guardrails: can't impersonate yourself, can't impersonate a disabled account, can't impersonate another superadmin (no chains). Every start/ stop is its own audit log entry naming both accounts; actions taken during the impersonated session are audit-logged as usual under the impersonated account. The topbar shows the impersonated account's name with the superadmin's own name in a colored tag so it's never ambiguous who "you" are while it's active.
Deliberately web-only, not in the REST API — swapping a session cookie has no meaningful translation to a stateless bearer-token call, which already scopes to one fixed account by design.