diff --git a/docs/environment-variables.md b/docs/environment-variables.md index dd98c1c..faddb33 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -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` diff --git a/docs/reverse-proxy.md b/docs/reverse-proxy.md index 5282bbc..f712c4d 100644 --- a/docs/reverse-proxy.md +++ b/docs/reverse-proxy.md @@ -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 diff --git a/shelfmark/config/security.py b/shelfmark/config/security.py index 10f4cba..d347655 100644 --- a/shelfmark/config/security.py +++ b/shelfmark/config/security.py @@ -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( diff --git a/shelfmark/core/user_db.py b/shelfmark/core/user_db.py index 0df517d..de92819 100644 --- a/shelfmark/core/user_db.py +++ b/shelfmark/core/user_db.py @@ -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() diff --git a/shelfmark/main.py b/shelfmark/main.py index f5359b0..376d941 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -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") diff --git a/tests/core/test_auth_api.py b/tests/core/test_auth_api.py index dd30fc3..f7075e9 100644 --- a/tests/core/test_auth_api.py +++ b/tests/core/test_auth_api.py @@ -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()