feat(auth): provision proxy users as non-admin once an admin exists (#1356)

With `AUTH_METHOD=proxy` and no admin group configured, every user the
proxy authenticates for the first time is provisioned as an admin
(`is_admin = True` unless the user already exists in `users.db`). The
intent to never lock an instance out makes sense, but the effect is that
anyone the SSO gate lets through becomes an administrator. On an
instance shared with family or a small community that is a footgun; I
hit it when the first invited reader landed as an admin.

This keeps the guarantee and removes the footgun: the first account is
still provisioned as an admin while the instance has no admin at all,
and later first-time users follow a new `PROXY_AUTH_DEFAULT_ROLE`
setting (Security tab / env), default `user`. Known users keep their
stored role; the `PROXY_AUTH_ADMIN_GROUP_NAME` path is unchanged and
still takes precedence. I couldn't find a way with Cloudflare access to
pass this along.

Changes: `UserDB.has_admin()`, `_proxy_default_is_admin()` in the proxy
middleware, the new `SelectField` beside the other proxy settings, the
regenerated `docs/environment-variables.md` entry and a row in
`docs/reverse-proxy.md`.

Compatibility: the default moves from "everyone admin" to "first admin,
then users". Accounts already in `users.db` are unaffected; new SSO
users on an existing instance become regular users unless
`PROXY_AUTH_DEFAULT_ROLE=admin` is set. If you would rather ship this
purely opt-in I can flip the default to `admin`.

## Verification

- `tests/core/test_auth_api.py::TestProxyProvisioningRole`: first user
admin / second user not; `PROXY_AUTH_DEFAULT_ROLE=admin` restores the
old behaviour; an admin from another auth source counts as "an admin
exists"; a known user keeps their role whatever the default.
- Full suite (3094), ruff, ruff format, basedpyright, vulture green.
- Running on my own instance since 2026-09-19.
This commit is contained in:
splitsec2
2026-09-19 23:27:13 -04:00
committed by GitHub
parent c42edac363
commit acd59f7cbb
6 changed files with 115 additions and 2 deletions
+11
View File
@@ -794,6 +794,7 @@ How long to keep completed/failed downloads in the queue display.
| `PROXY_AUTH_USER_HEADER` | The HTTP header your proxy uses to pass the authenticated username. | string | `X-Auth-User` |
| `PROXY_AUTH_LOGOUT_URL` | The URL to redirect users to for logging out. Leave empty to disable logout functionality. | string | _empty string_ |
| `PROXY_AUTH_ADMIN_GROUP_HEADER` | Optional: header your proxy uses to pass user groups/roles. | string | `X-Auth-Groups` |
| `PROXY_AUTH_DEFAULT_ROLE` | Role for users the proxy authenticates for the first time when no admin group is configured. The first account is always an admin so the instance is never left without one. | string (choice) | `user` |
| `PROXY_AUTH_ADMIN_GROUP_NAME` | Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection. | string | _empty string_ |
| `OIDC_DISCOVERY_URL` | OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration. | string | _none_ |
| `OIDC_CLIENT_ID` | OAuth2 client ID from your identity provider. | string | _none_ |
@@ -853,6 +854,16 @@ Optional: users in this group are treated as admins. Leave blank to skip group-b
- **Type:** string
- **Default:** _empty string_
#### `PROXY_AUTH_DEFAULT_ROLE`
**Proxy Auth Default Role**
Role for users the proxy authenticates for the first time when no admin group is configured. The first account is always an admin so the instance is never left without one.
- **Type:** string (choice)
- **Default:** `user`
- **Options:** `user` (User), `admin` (Admin)
#### `OIDC_DISCOVERY_URL`
+1
View File
@@ -87,6 +87,7 @@ Configure in Settings → Security:
| Proxy Auth Logout URL | `https://auth.example.com/logout` |
| Proxy Auth Admin Group Header | `Remote-Groups` |
| Proxy Auth Admin Group Name | `admins` (or your admin group) |
| Proxy Auth Default Role | `User` — first-time users are regular users; the very first account is still made admin. Only consulted when no admin group is set |
#### Nginx Configuration with Authelia
+12
View File
@@ -179,6 +179,18 @@ def security_settings() -> list[SettingsField]:
placeholder="e.g. admins",
default="",
),
_auth_field(
SelectField,
"proxy",
key="PROXY_AUTH_DEFAULT_ROLE",
label="Proxy Auth Default Role",
description="Role for users the proxy authenticates for the first time when no admin group is configured. The first account is always an admin so the instance is never left without one.",
options=[
{"value": "user", "label": "User"},
{"value": "admin", "label": "Admin"},
],
default="user",
),
]
fields.append(
+9
View File
@@ -433,6 +433,15 @@ class UserDB:
finally:
conn.close()
def has_admin(self) -> bool:
"""Return True when at least one admin user exists."""
conn = self._connect()
try:
row = conn.execute("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").fetchone()
return row is not None
finally:
conn.close()
def has_admin_with_password(self) -> bool:
"""Return True when at least one admin user with a password hash exists."""
conn = self._connect()
+17 -2
View File
@@ -658,6 +658,18 @@ logger.info(
logger.info("Session cookie name: %s", SESSION_COOKIE_NAME)
def _proxy_default_is_admin(db: UserDB) -> bool:
"""Role for a proxy user seen for the first time when no admin group is configured.
The first account ever provisioned is an admin so the instance is never left without
one; later accounts follow PROXY_AUTH_DEFAULT_ROLE (default: user).
"""
if not db.has_admin():
return True
role = str(app_config.get("PROXY_AUTH_DEFAULT_ROLE", "user") or "user").strip().lower()
return role == "admin"
@app.before_request
def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
"""Middleware to handle proxy authentication.
@@ -710,8 +722,9 @@ def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
# Resolve admin role for proxy sessions.
# If an admin group is configured, derive from groups header.
# Otherwise preserve existing DB role for known users and default
# first-time users to admin (to avoid lockouts).
# Otherwise preserve the existing DB role for known users; a first-time user
# is an admin only while the instance has none (so nobody is locked out),
# after that PROXY_AUTH_DEFAULT_ROLE decides (default: user).
admin_group_header = (
normalize_optional_text(
app_config.get("PROXY_AUTH_ADMIN_GROUP_HEADER", "X-Auth-Groups")
@@ -734,6 +747,8 @@ def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
existing_db_user = user_db.get_user(username=username)
if existing_db_user:
is_admin = existing_db_user.get("role") == "admin"
else:
is_admin = _proxy_default_is_admin(user_db)
# Create or update session
previous_username = session.get("user_id")
+65
View File
@@ -221,6 +221,71 @@ class TestLoginSemantics:
assert "is_admin" not in sess
class TestProxyProvisioningRole:
def _check(self, main_module, username, settings=None):
values = {"PROXY_AUTH_USER_HEADER": "X-Auth-User", **(settings or {})}
fresh_client = main_module.app.test_client()
with (
patch.object(main_module, "get_auth_mode", return_value="proxy"),
patch.object(
main_module.app_config,
"get",
side_effect=lambda key, default=None, user_id=None: values.get(key, default),
),
):
response = fresh_client.get("/api/auth/check", headers={"X-Auth-User": username})
assert response.status_code == 200
return response.get_json()
def test_first_proxy_user_is_admin_and_later_users_are_not(
self, main_module, temp_user_db, monkeypatch
):
monkeypatch.setattr(main_module, "user_db", temp_user_db)
first = self._check(main_module, "alice")
second = self._check(main_module, "bob")
assert first["is_admin"] is True
assert second["is_admin"] is False
assert temp_user_db.get_user(username="alice")["role"] == "admin"
assert temp_user_db.get_user(username="bob")["role"] == "user"
def test_default_role_admin_restores_admin_for_everyone(
self, main_module, temp_user_db, monkeypatch
):
monkeypatch.setattr(main_module, "user_db", temp_user_db)
settings = {"PROXY_AUTH_DEFAULT_ROLE": "admin"}
self._check(main_module, "alice", settings)
second = self._check(main_module, "bob", settings)
assert second["is_admin"] is True
assert temp_user_db.get_user(username="bob")["role"] == "admin"
def test_existing_admin_from_another_auth_source_counts_as_the_first_admin(
self, main_module, temp_user_db, monkeypatch
):
monkeypatch.setattr(main_module, "user_db", temp_user_db)
temp_user_db.create_user(username="local_admin", role="admin", auth_source="builtin")
first_proxy_user = self._check(main_module, "alice")
assert first_proxy_user["is_admin"] is False
assert temp_user_db.get_user(username="alice")["role"] == "user"
def test_known_user_keeps_their_role_whatever_the_default(
self, main_module, temp_user_db, monkeypatch
):
monkeypatch.setattr(main_module, "user_db", temp_user_db)
temp_user_db.create_user(username="ops", role="admin", auth_source="proxy")
temp_user_db.create_user(username="bob", role="user", auth_source="proxy")
bob = self._check(main_module, "bob", {"PROXY_AUTH_DEFAULT_ROLE": "admin"})
assert bob["is_admin"] is False
assert temp_user_db.get_user(username="bob")["role"] == "user"
class TestLoginLockoutRepair:
def test_is_account_locked_repairs_missing_timestamp(self, main_module):
main_module.failed_login_attempts.clear()