mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 19:40:26 +01:00
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:
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user