diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index a7a4f91d..253a34fe 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -289,7 +289,14 @@ ftp = ftplib.FTP() # nosec B321 - Plaintext FTP intentional when configured - Applied to `app/api/files.py` (file list sort + search query parameters) - Applied to `app/api/logs.py` (task_id query filter and path parameter) - 30 unit tests added in `tests/test_input_validation.py` -- ⏳ **TODO:** Implement proper API key rotation mechanisms ([#168](https://github.com/christianlouis/DocuElevate/issues/168)) +- ✅ **COMPLETED:** Implement proper API key rotation mechanisms ([#168](https://github.com/christianlouis/DocuElevate/issues/168)) + - `docs/CredentialRotationGuide.md` — comprehensive rotation guide covering: + - Recommended rotation schedule for all credential types + - Per-credential rotation procedures for OpenAI, Azure, AWS S3, Dropbox, Google Drive, OneDrive, Authentik, Paperless-ngx, SMTP, IMAP, Nextcloud, FTP, SFTP, WebDAV, and admin credentials + - Onboarding instructions (creating service-specific credentials with minimal permissions) + - Offboarding instructions (revocation, rotation of shared credentials, audit log review) + - Emergency revocation procedure + - `GET /api/settings/credentials` — admin-only endpoint listing all sensitive credential settings with configured/unconfigured status and source (`env` vs `db`), enabling credential rotation audits without exposing secret values ### Infrastructure Security - ✅ TrustedHostMiddleware configured (restricts valid hosts) @@ -328,7 +335,7 @@ ftp = ftplib.FTP() # nosec B321 - Plaintext FTP intentional when configured 1. **Security training documentation** - For contributors 2. **Penetration testing** - Professional security assessment 3. **Bug bounty program** - Community security contributions -4. **API key rotation** - Automated credential rotation ([#168](https://github.com/christianlouis/DocuElevate/issues/168)) +4. ~~**API key rotation**~~ ✅ Implemented — `docs/CredentialRotationGuide.md` documents rotation procedures, onboarding/offboarding, and emergency revocation; `GET /api/settings/credentials` provides a credential audit endpoint ([#168](https://github.com/christianlouis/DocuElevate/issues/168)) ## Security Contact diff --git a/app/api/settings.py b/app/api/settings.py index 16155cb1..e5b92e7e 100644 --- a/app/api/settings.py +++ b/app/api/settings.py @@ -187,6 +187,60 @@ async def delete_setting(key: str, request: Request, db: DbSession, admin: Admin ) +@router.get("/credentials") +async def list_credentials(request: Request, db: DbSession, admin: AdminUser): + """ + List all sensitive credential settings with their configured/unconfigured status. + + Returns a credential audit report indicating which credentials are set and whether + each value originates from the database or an environment variable. + This endpoint is intended to support credential rotation workflows. + Admin only. + """ + try: + db_settings = get_all_settings_from_db(db) + credentials = [] + + for key, meta in SETTING_METADATA.items(): + if not meta.get("sensitive", False): + continue + + env_value = getattr(settings, key, None) + in_db = key in db_settings and db_settings[key] + + if in_db: + source = "db" + configured = True + elif env_value: + source = "env" + configured = True + else: + source = None + configured = False + + credentials.append( + { + "key": key, + "category": meta.get("category", "Other"), + "description": meta.get("description", ""), + "configured": configured, + "source": source, + "restart_required": meta.get("restart_required", False), + } + ) + + configured_count = sum(1 for c in credentials if c["configured"]) + return { + "credentials": credentials, + "total": len(credentials), + "configured_count": configured_count, + "unconfigured_count": len(credentials) - configured_count, + } + except Exception as e: + logger.error(f"Error retrieving credential list: {e}") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve credentials") + + @router.post("/bulk-update") async def bulk_update_settings(updates: list[SettingUpdate], request: Request, db: DbSession, admin: AdminUser): """ diff --git a/docs/CredentialRotationGuide.md b/docs/CredentialRotationGuide.md new file mode 100644 index 00000000..bb911752 --- /dev/null +++ b/docs/CredentialRotationGuide.md @@ -0,0 +1,260 @@ +# Credential Rotation Guide + +This guide documents how to rotate API keys and credentials used by DocuElevate, along with onboarding and offboarding procedures for team members and service accounts. + +## Overview + +DocuElevate integrates with several external services that require API keys, tokens, or passwords. Regularly rotating these credentials limits the blast radius of a potential leak and is a security best practice. + +Credentials fall into two categories: + +| Category | Examples | +|---|---| +| **API keys** | OpenAI API key, Azure AI key, Paperless-ngx API token, AWS access keys | +| **OAuth tokens / secrets** | Dropbox, Google Drive, OneDrive, Authentik client secrets and refresh tokens | +| **Passwords** | Admin password, Nextcloud, Email (SMTP), IMAP, FTP, SFTP, WebDAV | +| **Private keys** | SFTP private key and passphrase | + +All credentials are stored either in environment variables or, when set via the Settings UI, encrypted in the database using Fernet symmetric encryption (keyed from `SESSION_SECRET`). + +--- + +## Recommended Rotation Schedule + +| Credential Type | Recommended Rotation Interval | +|---|---| +| API keys (OpenAI, Azure, AWS, Paperless) | Every 90 days | +| OAuth client secrets | Every 180 days | +| OAuth refresh tokens | Rotate on revocation / after each re-authorization | +| Passwords (SMTP, IMAP, FTP, SFTP, Nextcloud, WebDAV) | Every 90 days or on personnel change | +| Admin password | Every 90 days or on personnel change | +| `SESSION_SECRET` | On suspected compromise; note all active sessions will be invalidated | + +--- + +## How to Rotate a Credential + +DocuElevate supports two rotation methods: + +### Method 1 — Settings API (recommended, zero-downtime) + +Use the Settings REST API to update individual credentials while the application is running. No restart is needed for most credentials (check `restart_required` in the response). + +```bash +# Rotate the OpenAI API key +curl -X POST https:///api/settings/openai_api_key \ + -H "Content-Type: application/json" \ + -b "session=" \ + -d '{"key": "openai_api_key", "value": "sk-new-key-here"}' +``` + +The endpoint returns `"restart_required": true` for settings that require an application restart to take effect (e.g., database URL, session secret). All AI-service and storage-provider credentials take effect immediately without a restart. + +### Method 2 — Environment variable / `.env` file + +1. Update the relevant variable in your `.env` file (or your container/Kubernetes secret). +2. Restart the application so the new value is loaded: + +```bash +docker compose restart api worker +``` + +> **Note:** Settings stored in the database take precedence over environment variables. If you previously set a credential via the Settings UI, you must also update or delete it from the database (via the Settings API) to have the environment variable take effect. + +--- + +## Per-Credential Rotation Procedures + +### OpenAI API Key + +1. Log in to [platform.openai.com](https://platform.openai.com) → **API keys**. +2. Create a new secret key and copy it. +3. Update in DocuElevate: + ``` + POST /api/settings/openai_api_key {"key": "openai_api_key", "value": ""} + ``` +4. Verify document processing still works (upload a test file). +5. Delete the old key in the OpenAI dashboard. + +### Azure Document Intelligence Key + +1. Open [portal.azure.com](https://portal.azure.com) → your Document Intelligence resource → **Keys and Endpoint**. +2. Regenerate **Key 2** while **Key 1** is still live (avoids downtime). +3. Update `azure_ai_key` in DocuElevate with the new key. +4. Verify connectivity, then regenerate **Key 1** and optionally update again. + +### AWS S3 Access Keys + +1. In the **IAM Console**, create a new access key for the service user. +2. Update both `aws_access_key_id` and `aws_secret_access_key` in DocuElevate together (use the bulk-update endpoint or update both settings in sequence before verifying). +3. Test an S3 upload from DocuElevate. +4. Deactivate the old IAM access key, then delete it after 24 hours. + +### Dropbox App Credentials & Refresh Token + +Dropbox refresh tokens are long-lived; rotate them by re-authorizing the application: + +1. In [dropbox.com/developers](https://www.dropbox.com/developers), revoke the existing token. +2. Follow the Dropbox OAuth flow documented in `docs/DropboxSetup.md` to obtain a new refresh token. +3. Update `dropbox_refresh_token` (and `dropbox_app_key` / `dropbox_app_secret` if also rotating those). + +### Google Drive OAuth Credentials + +1. In the [Google Cloud Console](https://console.cloud.google.com), navigate to **APIs & Services → Credentials**. +2. Rotate the OAuth client secret: delete the old secret and create a new one. +3. Update `google_drive_client_secret` in DocuElevate. +4. Re-authorize to obtain a fresh refresh token and update `google_drive_refresh_token`. + +For service-account credentials (`google_drive_credentials_json`): + +1. Create a new service-account key in the Google Cloud Console. +2. Update `google_drive_credentials_json` with the new JSON. +3. Verify access, then delete the old key. + +### OneDrive (Microsoft OAuth) + +1. In **Azure App Registrations**, navigate to **Certificates & secrets** for your app. +2. Add a new client secret. +3. Update `onedrive_client_secret` in DocuElevate. +4. Re-authorize via the OAuth flow to get a fresh `onedrive_refresh_token`. +5. Delete the old client secret in Azure. + +### Authentik (OIDC) + +1. In your Authentik admin panel, navigate to the DocuElevate application and regenerate the client secret. +2. Update `authentik_client_secret` in DocuElevate. +3. Restart the application (this setting requires a restart: `restart_required: true`). + +### Paperless-ngx API Token + +1. Log in to your Paperless-ngx instance → **Settings → API Tokens**. +2. Create a new token. +3. Update `paperless_ngx_api_token` in DocuElevate. +4. Verify document routing works, then revoke the old token. + +### SMTP / Email Password + +1. Rotate the password in your mail server or email provider. +2. Update `email_password` in DocuElevate. + +### IMAP Passwords + +Update `imap1_password` and/or `imap2_password` after rotating the credentials with your email provider. + +### Nextcloud Password / App Password + +1. In Nextcloud → **Settings → Security**, revoke the existing app password and create a new one. +2. Update `nextcloud_password` in DocuElevate. + +### FTP / SFTP / WebDAV Passwords + +1. Rotate the credential on the respective server. +2. Update `ftp_password`, `sftp_password`, or `webdav_password` in DocuElevate. + +### SFTP Private Key + +1. Generate a new key pair: + ```bash + ssh-keygen -t ed25519 -f ~/.ssh/docuelevate_sftp -C "docuelevate-sftp" + ``` +2. Install the new public key on the SFTP server. +3. Update `sftp_private_key` (and `sftp_private_key_passphrase` if encrypted) in DocuElevate. +4. Verify connectivity, then remove the old public key from the SFTP server. + +### Admin Password + +1. Update `admin_password` in DocuElevate (via the Settings UI or API). +2. Communicate the new password to any users who share it (discouraged; prefer individual accounts via OAuth). +3. Requires application restart. + +### `SESSION_SECRET` + +> **Warning:** Rotating `SESSION_SECRET` invalidates all active user sessions. All logged-in users will be signed out immediately. + +1. Generate a new secret (minimum 32 characters): + ```bash + python -c "import secrets; print(secrets.token_hex(32))" + ``` +2. Update the environment variable or `.env` file. +3. Restart the application. +4. Existing encrypted settings stored in the database **will no longer be readable** because the encryption key is derived from `SESSION_SECRET`. You must re-enter all sensitive settings that were stored via the UI after rotating this value. + +--- + +## Bulk Credential Audit + +Use the dedicated endpoint to list all credential settings and their configured/unconfigured status: + +```bash +GET /api/settings/credentials +``` + +Example response: + +```json +{ + "credentials": [ + { + "key": "openai_api_key", + "category": "AI Services", + "description": "OpenAI API key for metadata extraction", + "configured": true, + "source": "env" + }, + { + "key": "azure_ai_key", + "category": "AI Services", + "description": "Azure AI key for document intelligence", + "configured": true, + "source": "db" + }, + ... + ], + "total": 24, + "configured_count": 8, + "unconfigured_count": 16 +} +``` + +The `source` field indicates whether the value comes from the **database** (`db`) or an **environment variable** (`env`). + +--- + +## Onboarding a New Team Member or Service Account + +1. **Identify required credentials** – Use `GET /api/settings/credentials` to see which credentials are active in the deployment. +2. **Create service-specific credentials** – For each external service (OpenAI, AWS, etc.) create a new API key or sub-account rather than sharing the existing one. This enables individual revocation without disrupting others. +3. **Set credentials via the Settings API** – Provide the new credential via `POST /api/settings/{key}`. The value is encrypted at rest. +4. **Restrict access** – Ensure the new service account has only the minimum permissions needed (e.g., an S3 IAM user with write access to the specific bucket only). +5. **Document the credential** – Record *which* system generated the credential and *when* it was created, so it can be identified during offboarding. + +--- + +## Offboarding a Team Member or Decommissioning a Service Account + +1. **Identify credentials tied to the departing user** – Review all third-party services for keys or OAuth authorizations issued under their account. +2. **Revoke credentials** – Delete or disable the API key/token in each third-party service immediately. +3. **Rotate shared credentials** – If any credential was shared (e.g., a team-wide admin password), rotate it now using the procedures above. +4. **Update DocuElevate** – Set the new credential via `POST /api/settings/{key}` or delete the old entry via `DELETE /api/settings/{key}` if the service is no longer used. +5. **Verify operations** – Trigger a test document processing run to confirm all integrations still work. +6. **Audit logs** – Review audit logs for any anomalous activity by the departing user before revoking access. + +--- + +## Emergency Credential Revocation + +If a credential is believed to be compromised: + +1. **Revoke immediately** in the external service (do not wait to have a replacement ready). +2. **Review audit logs** for unauthorized usage. +3. **Generate and deploy a replacement** credential as soon as possible. +4. **Notify stakeholders** per your incident response plan (see [SECURITY.md](../SECURITY.md)). + +--- + +## Related Documentation + +- [Configuration Guide](ConfigurationGuide.md) — Full list of environment variables +- [Deployment Guide](DeploymentGuide.md) — Deployment and restart procedures +- [SECURITY_AUDIT.md](../SECURITY_AUDIT.md) — Security audit findings and status +- [SECURITY.md](../SECURITY.md) — Security contact and disclosure policy diff --git a/tests/test_api_settings.py b/tests/test_api_settings.py index b31e0d07..e61df569 100644 --- a/tests/test_api_settings.py +++ b/tests/test_api_settings.py @@ -203,3 +203,177 @@ class TestSettingModels: assert "test_key" in response.settings assert "General" in response.categories assert "test_key" in response.db_settings + + +@pytest.mark.unit +class TestListCredentials: + """Tests for the list_credentials function (GET /api/settings/credentials).""" + + @patch("app.api.settings.get_all_settings_from_db") + def test_list_credentials_returns_sensitive_keys_only(self, mock_db_settings): + """Test that list_credentials only includes keys marked sensitive in SETTING_METADATA.""" + import asyncio + + from app.api.settings import list_credentials + from app.utils.settings_service import SETTING_METADATA + + mock_db_settings.return_value = {} + mock_request = MagicMock() + mock_db = MagicMock() + mock_admin = {"is_admin": True} + + with patch("app.api.settings.settings") as mock_settings: + for key in SETTING_METADATA: + setattr(mock_settings, key, None) + + result = asyncio.get_event_loop().run_until_complete( + list_credentials(mock_request, mock_db, mock_admin) + ) + + returned_keys = {c["key"] for c in result["credentials"]} + sensitive_keys = {k for k, v in SETTING_METADATA.items() if v.get("sensitive")} + assert returned_keys == sensitive_keys + + @patch("app.api.settings.get_all_settings_from_db") + def test_list_credentials_source_db_when_in_database(self, mock_db_settings): + """Test that credentials stored in the database report source='db'.""" + import asyncio + + from app.api.settings import list_credentials + + mock_db_settings.return_value = {"openai_api_key": "sk-db-key"} + mock_request = MagicMock() + mock_db = MagicMock() + mock_admin = {"is_admin": True} + + with patch("app.api.settings.settings") as mock_settings: + mock_settings.openai_api_key = "sk-env-key" + + result = asyncio.get_event_loop().run_until_complete( + list_credentials(mock_request, mock_db, mock_admin) + ) + + openai_entry = next(c for c in result["credentials"] if c["key"] == "openai_api_key") + assert openai_entry["source"] == "db" + assert openai_entry["configured"] is True + + @patch("app.api.settings.get_all_settings_from_db") + def test_list_credentials_source_env_when_only_in_env(self, mock_db_settings): + """Test that credentials only in env report source='env'.""" + import asyncio + + from app.api.settings import list_credentials + + mock_db_settings.return_value = {} + mock_request = MagicMock() + mock_db = MagicMock() + mock_admin = {"is_admin": True} + + with patch("app.api.settings.settings") as mock_settings: + mock_settings.openai_api_key = "sk-env-key" + + result = asyncio.get_event_loop().run_until_complete( + list_credentials(mock_request, mock_db, mock_admin) + ) + + openai_entry = next(c for c in result["credentials"] if c["key"] == "openai_api_key") + assert openai_entry["source"] == "env" + assert openai_entry["configured"] is True + + @patch("app.api.settings.get_all_settings_from_db") + def test_list_credentials_unconfigured_when_no_value(self, mock_db_settings): + """Test that credentials with no value are marked as unconfigured.""" + import asyncio + + from app.api.settings import list_credentials + + mock_db_settings.return_value = {} + mock_request = MagicMock() + mock_db = MagicMock() + mock_admin = {"is_admin": True} + + with patch("app.api.settings.settings") as mock_settings: + mock_settings.openai_api_key = None + + result = asyncio.get_event_loop().run_until_complete( + list_credentials(mock_request, mock_db, mock_admin) + ) + + openai_entry = next(c for c in result["credentials"] if c["key"] == "openai_api_key") + assert openai_entry["configured"] is False + assert openai_entry["source"] is None + + @patch("app.api.settings.get_all_settings_from_db") + def test_list_credentials_counts_are_accurate(self, mock_db_settings): + """Test that configured_count and unconfigured_count are correct.""" + import asyncio + + from app.api.settings import list_credentials + + mock_db_settings.return_value = {"openai_api_key": "sk-db-key"} + mock_request = MagicMock() + mock_db = MagicMock() + mock_admin = {"is_admin": True} + + with patch("app.api.settings.settings") as mock_settings: + # Most keys will be None, one will be set via db_settings mock + mock_settings.openai_api_key = "sk-key" + + result = asyncio.get_event_loop().run_until_complete( + list_credentials(mock_request, mock_db, mock_admin) + ) + + assert result["total"] == len(result["credentials"]) + assert result["configured_count"] + result["unconfigured_count"] == result["total"] + + @patch("app.api.settings.get_all_settings_from_db") + def test_list_credentials_raises_500_on_exception(self, mock_db_settings): + """Test that list_credentials raises HTTP 500 on unexpected errors.""" + import asyncio + + from fastapi import HTTPException + + from app.api.settings import list_credentials + + mock_db_settings.side_effect = Exception("DB failure") + mock_request = MagicMock() + mock_db = MagicMock() + mock_admin = {"is_admin": True} + + with pytest.raises(HTTPException) as exc_info: + asyncio.get_event_loop().run_until_complete( + list_credentials(mock_request, mock_db, mock_admin) + ) + assert exc_info.value.status_code == 500 + + def test_list_credentials_endpoint_requires_admin(self, client): + """Test that GET /api/settings/credentials requires admin access.""" + response = client.get("/api/settings/credentials") + assert response.status_code in [302, 401, 403] + + @patch("app.api.settings.get_all_settings_from_db") + def test_list_credentials_response_has_required_fields(self, mock_db_settings): + """Test that each credential entry has the required fields.""" + import asyncio + + from app.api.settings import list_credentials + + mock_db_settings.return_value = {} + mock_request = MagicMock() + mock_db = MagicMock() + mock_admin = {"is_admin": True} + + with patch("app.api.settings.settings") as mock_settings: + mock_settings.openai_api_key = None + + result = asyncio.get_event_loop().run_until_complete( + list_credentials(mock_request, mock_db, mock_admin) + ) + + for cred in result["credentials"]: + assert "key" in cred + assert "category" in cred + assert "description" in cred + assert "configured" in cred + assert "source" in cred + assert "restart_required" in cred