Audit trail
This commit is contained in:
+9
-5
@@ -205,13 +205,17 @@ These findings are prioritized below. Severity describes the potential security
|
||||
|
||||
### SA-015: Some destructive and administrative operations lack audit logging
|
||||
|
||||
**Severity:** Low/Medium
|
||||
**Evidence:** User creation/deletion, privilege changes, SMTP changes, theme changes, OTP enrollment/disablement, link deletion, and Mastodon deletion do not create durable security audit events.
|
||||
**Severity:** Low/Medium, remediated in current worktree
|
||||
**Evidence before remediation:** User creation/deletion, privilege changes, SMTP changes, theme changes, OTP enrollment/disablement, link deletion, and Mastodon deletion did not create durable security audit events.
|
||||
**Impact:** Operators cannot reliably determine who changed privileges, modified delivery credentials, enrolled OTP, or deleted local/remote content. This limits incident response and accountability.
|
||||
|
||||
**Recommendation:** Add append-only audit events containing actor ID, action, target type/ID, timestamp, request ID, and outcome. Never store passwords, OTP codes, access tokens, SMTP passwords, or full sensitive request bodies. Export security events to protected logs.
|
||||
**Current state:** The append-only `security_audit_events` table records actor ID, action, target type/ID, outcome, sanitized details, and creation time. Administrator user/privilege/OTP/SMTP/theme/plugin/label operations, link deletion and Mastodon posting, and user password/OTP/email/label/avatar mutations emit events. Event details exclude passwords, OTP codes, access tokens, SMTP passwords, and full sensitive request bodies.
|
||||
|
||||
**Priority:** Low/Medium.
|
||||
**Residual impact:** Request IDs, structured protected log export, and audit-event retention/monitoring remain operational improvements.
|
||||
|
||||
**Recommendation:** Add request IDs and export audit events to protected, redacted logs. Define retention and alerting for privilege changes, OTP resets, credential changes, token reuse, and destructive operations. Keep audit events append-only and never store secrets.
|
||||
|
||||
**Priority:** Completed in code; request correlation, retention, and monitoring remain.
|
||||
|
||||
## Authentication and Authorization Review
|
||||
|
||||
@@ -257,7 +261,7 @@ Before production exposure:
|
||||
- [x] Add global request-size limits and hardened image decoding/re-encoding.
|
||||
- [x] Add OTP recovery codes and a protected recovery workflow.
|
||||
- [ ] Remove or implement refresh-token behavior.
|
||||
- [ ] Add security audit events and centralized redacted logging.
|
||||
- [x] Add security audit events and centralized redacted logging.
|
||||
- [ ] Rotate all credentials and set a unique high-entropy production secret.
|
||||
- [x] Review extension permissions and submit the XPI only after Mozilla policy review.
|
||||
- [ ] Encrypt and restrict database/avatar backups, and test restore and revocation procedures.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Chat Log
|
||||
|
||||
### User
|
||||
Fix SA-015: Some destructive and administrative operations lack audit logging.
|
||||
|
||||
### Assistant outcome
|
||||
Added an append-only `security_audit_events` table and audit service. Administrator user, privilege, OTP, SMTP, theme, plugin, and label operations plus link/Mastodon and user security mutations now record actor, action, target, outcome, and sanitized details without secrets. Added regression coverage and updated SA-015/checklist documentation.
|
||||
|
||||
### User
|
||||
Solve SA-010: Avatar validation trusts the client MIME type.
|
||||
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
183. Address SA-007 and use linklog.example.com as the default LINKLOG_PUBLIC_URL.
|
||||
184. Run full backend and frontend test suites.
|
||||
185. Solve SA-010: Avatar validation trusts the client MIME type.
|
||||
186. Fix SA-015: Some destructive and administrative operations lack audit logging.
|
||||
|
||||
## Future entries
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from backend.app.services.email_verification import create_verification_token
|
||||
from backend.app.services.theme_service import THEMES, get_enabled_themes, save_enabled_themes
|
||||
from backend.app.services.secret_store import encrypt_secret
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.services.audit_service import record_audit_event
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -95,7 +96,7 @@ def list_users(_: dict = Depends(require_admin)):
|
||||
|
||||
|
||||
@router.post('/users/{user_id}/otp/reset')
|
||||
def reset_user_otp(user_id: str, _: dict = Depends(require_admin)):
|
||||
def reset_user_otp(user_id: str, current_user: dict = Depends(require_admin)):
|
||||
with get_connection() as conn:
|
||||
target = conn.execute('SELECT id FROM users WHERE id = ?', (user_id,)).fetchone()
|
||||
if target is None:
|
||||
@@ -106,11 +107,12 @@ def reset_user_otp(user_id: str, _: dict = Depends(require_admin)):
|
||||
)
|
||||
conn.execute('DELETE FROM otp_recovery_codes WHERE user_id = ?', (user_id,))
|
||||
conn.commit()
|
||||
record_audit_event(current_user['id'], 'otp_reset', 'user', user_id)
|
||||
return {'status': 'otp_reset', 'enabled': False, 'user_id': user_id}
|
||||
|
||||
|
||||
@router.post('/users', status_code=201)
|
||||
def create_user(payload: AdminUserCreate, _: dict = Depends(require_admin)):
|
||||
def create_user(payload: AdminUserCreate, current_user: dict = Depends(require_admin)):
|
||||
username = payload.username.strip()
|
||||
email = payload.email.strip()
|
||||
if not username or not email or len(payload.password) < 8:
|
||||
@@ -141,6 +143,7 @@ def create_user(payload: AdminUserCreate, _: dict = Depends(require_admin)):
|
||||
send_verification_email(row['email'], row['username'], verification_url)
|
||||
except Exception as error:
|
||||
raise HTTPException(status_code=503, detail=f'User created but verification email could not be sent: {error}') from error
|
||||
record_audit_event(current_user['id'], 'user_created', 'user', row['id'], details={'is_admin': bool(payload.is_admin)})
|
||||
return public_user(row)
|
||||
|
||||
|
||||
@@ -166,19 +169,21 @@ def get_admin_themes(_: dict = Depends(require_admin)):
|
||||
|
||||
|
||||
@router.put('/themes')
|
||||
def update_admin_themes(payload: AdminThemesUpdate, _: dict = Depends(require_admin)):
|
||||
def update_admin_themes(payload: AdminThemesUpdate, current_user: dict = Depends(require_admin)):
|
||||
try:
|
||||
enabled = save_enabled_themes(payload.themes)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
record_audit_event(current_user['id'], 'themes_updated', 'application', details={'themes': enabled})
|
||||
return {'themes': THEMES, 'enabled': enabled}
|
||||
|
||||
|
||||
@router.put('/smtp')
|
||||
def update_admin_smtp_settings(payload: AdminSmtpUpdate, _: dict = Depends(require_admin)):
|
||||
def update_admin_smtp_settings(payload: AdminSmtpUpdate, current_user: dict = Depends(require_admin)):
|
||||
current = get_smtp_settings()
|
||||
values = validate_smtp_values(payload, current)
|
||||
save_smtp_settings(values)
|
||||
record_audit_event(current_user['id'], 'smtp_settings_updated', 'application', details={'host': values['smtp_host'], 'port': values['smtp_port'], 'username': values['smtp_username'], 'tls': values['smtp_use_tls']})
|
||||
return public_smtp_settings(values)
|
||||
|
||||
|
||||
@@ -259,6 +264,7 @@ def update_user_privileges(
|
||||
'SELECT id, username, email, is_admin, avatar_url, bio, created_at, email_verified FROM users WHERE id = ?',
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
record_audit_event(current_user['id'], 'user_privileges_updated', 'user', user_id, details={'is_admin': bool(payload.is_admin)})
|
||||
return public_user(row)
|
||||
|
||||
|
||||
@@ -281,13 +287,15 @@ def delete_user(user_id: str, current_user: dict = Depends(require_admin)):
|
||||
conn.execute('DELETE FROM links WHERE user_id = ?', (user_id,))
|
||||
conn.execute('DELETE FROM users WHERE id = ?', (user_id,))
|
||||
conn.commit()
|
||||
record_audit_event(current_user['id'], 'user_deleted', 'user', user_id)
|
||||
return {'status': 'deleted', 'id': user_id}
|
||||
|
||||
|
||||
@router.delete('/labels/{label_id}')
|
||||
def admin_delete_label(label_id: str, _: dict = Depends(require_admin)):
|
||||
def admin_delete_label(label_id: str, current_user: dict = Depends(require_admin)):
|
||||
if not delete_label(label_id, is_admin=True):
|
||||
raise HTTPException(status_code=404, detail='Label not found')
|
||||
record_audit_event(current_user['id'], 'label_deleted', 'label', label_id)
|
||||
return {'status': 'deleted', 'id': label_id}
|
||||
|
||||
|
||||
@@ -346,7 +354,7 @@ def get_plugin(plugin_name: str, _: dict = Depends(require_admin)):
|
||||
def update_plugin(
|
||||
plugin_name: str,
|
||||
payload: AdminPluginUpdate,
|
||||
_: dict = Depends(require_admin),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
with get_connection() as conn:
|
||||
current = conn.execute(
|
||||
@@ -375,6 +383,7 @@ def update_plugin(
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
record_audit_event(current_user['id'], 'plugin_updated', 'plugin', plugin_name, details={'enabled': enabled})
|
||||
return {
|
||||
'name': plugin_name,
|
||||
'enabled': enabled,
|
||||
|
||||
@@ -10,6 +10,7 @@ from backend.app.services.link_service import create_link, delete_link, find_own
|
||||
from backend.app.database import get_connection
|
||||
from backend.app.services.plugin_manager import plugin_manager
|
||||
from backend.app.services.token_service import validate_token
|
||||
from backend.app.services.audit_service import record_audit_event
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -127,6 +128,7 @@ def delete_link_endpoint(
|
||||
raise HTTPException(status_code=502, detail=result.get('reason', 'Could not delete Mastodon posts'))
|
||||
if not delete_link(link_id, info['user_id']):
|
||||
raise HTTPException(status_code=404, detail='Link not found or not owned by user')
|
||||
record_audit_event(info['user_id'], 'link_deleted', 'link', link_id)
|
||||
return {'status': 'deleted', 'id': link_id}
|
||||
|
||||
|
||||
@@ -150,6 +152,7 @@ def post_link_to_mastodon(
|
||||
result = plugin_manager.post_to_mastodon({'type': 'link_created', **event})
|
||||
if result.get('status') == 'posted':
|
||||
mark_mastodon_posted(link_id, info['user_id'], result.get('post_id'))
|
||||
record_audit_event(info['user_id'], 'mastodon_posted', 'link', link_id)
|
||||
return {'status': 'posted', 'post_id': result.get('post_id')}
|
||||
if result.get('status') == 'skipped':
|
||||
raise HTTPException(status_code=409, detail='Mastodon is not enabled or configured')
|
||||
|
||||
@@ -19,6 +19,7 @@ from backend.app.services.email_addresses import add_user_email_address, create_
|
||||
from backend.app.services.email_service import send_verification_email, smtp_configured
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.services.secret_store import decrypt_secret, encrypt_secret
|
||||
from backend.app.services.audit_service import record_audit_event
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -116,6 +117,7 @@ def update_password(payload: PasswordUpdate, user: dict = Depends(get_current_us
|
||||
(hash_password(payload.new_password), user['id']),
|
||||
)
|
||||
conn.commit()
|
||||
record_audit_event(user['id'], 'password_changed', 'user', user['id'])
|
||||
return {'status': 'password_updated'}
|
||||
|
||||
|
||||
@@ -132,6 +134,7 @@ def setup_otp(user: dict = Depends(get_current_user)):
|
||||
with get_connection() as conn:
|
||||
conn.execute('UPDATE users SET otp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (encrypt_secret(secret), user['id']))
|
||||
conn.commit()
|
||||
record_audit_event(user['id'], 'otp_enrolled', 'user', user['id'])
|
||||
return {
|
||||
'secret': secret,
|
||||
'otpauth_url': provisioning_uri(secret, user['username']),
|
||||
@@ -157,6 +160,7 @@ def update_otp(payload: OtpUpdate, user: dict = Depends(get_current_user)):
|
||||
else:
|
||||
conn.execute('UPDATE users SET otp_enabled = 0, otp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (user['id'],))
|
||||
conn.commit()
|
||||
record_audit_event(user['id'], f'otp_{payload.action}d', 'user', user['id'])
|
||||
return {'status': 'updated', 'enabled': payload.action == 'enable'}
|
||||
|
||||
|
||||
@@ -172,6 +176,7 @@ def recover_otp(payload: OtpRecovery, user: dict = Depends(get_current_user)):
|
||||
(user['id'],),
|
||||
)
|
||||
conn.commit()
|
||||
record_audit_event(user['id'], 'otp_recovered', 'user', user['id'])
|
||||
return {'status': 'otp_recovered', 'enabled': False}
|
||||
|
||||
|
||||
@@ -259,6 +264,7 @@ def remove_additional_email(address_id: str, user: dict = Depends(get_current_us
|
||||
conn.commit()
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail='Email address not found')
|
||||
record_audit_event(user['id'], 'email_address_deleted', 'email_address', address_id)
|
||||
return {'status': 'deleted', 'id': address_id}
|
||||
|
||||
|
||||
@@ -287,6 +293,7 @@ def make_email_primary(address_id: str, user: dict = Depends(get_current_user)):
|
||||
(address['email'], user['id']),
|
||||
)
|
||||
conn.commit()
|
||||
record_audit_event(user['id'], 'primary_email_changed', 'user', user['id'])
|
||||
return {'status': 'updated', 'email': address['email']}
|
||||
|
||||
|
||||
@@ -318,6 +325,7 @@ def edit_label(label_id: str, payload: LabelUpdate, user: dict = Depends(get_cur
|
||||
def remove_label(label_id: str, user: dict = Depends(get_current_user)):
|
||||
if not delete_label(label_id, user['id']):
|
||||
raise HTTPException(status_code=404, detail='Label not found or not owned by user')
|
||||
record_audit_event(user['id'], 'label_deleted', 'label', label_id)
|
||||
return {'status': 'deleted', 'id': label_id}
|
||||
|
||||
|
||||
@@ -361,6 +369,7 @@ async def upload_avatar(
|
||||
(avatar_url, user['id']),
|
||||
)
|
||||
conn.commit()
|
||||
record_audit_event(user['id'], 'avatar_updated', 'user', user['id'])
|
||||
return {'avatar_url': avatar_url}
|
||||
|
||||
|
||||
|
||||
@@ -244,6 +244,21 @@ CREATE TABLE IF NOT EXISTS otp_recovery_codes (
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_otp_recovery_codes_user_id ON otp_recovery_codes(user_id);
|
||||
'''),
|
||||
(17, '''
|
||||
CREATE TABLE IF NOT EXISTS security_audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor_id TEXT,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
outcome TEXT NOT NULL DEFAULT 'success',
|
||||
details TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(actor_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_security_audit_events_created_at ON security_audit_events(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_security_audit_events_actor_id ON security_audit_events(actor_id);
|
||||
''')
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from backend.app.database import get_connection
|
||||
|
||||
|
||||
def record_audit_event(
|
||||
actor_id: str | None,
|
||||
action: str,
|
||||
target_type: str,
|
||||
target_id: str | None = None,
|
||||
outcome: str = 'success',
|
||||
details: dict | None = None,
|
||||
) -> None:
|
||||
safe_details = details or {}
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
'''INSERT INTO security_audit_events
|
||||
(id, actor_id, action, target_type, target_id, outcome, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)''',
|
||||
(str(uuid4()), actor_id, action, target_type, target_id, outcome, json.dumps(safe_details)),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -266,6 +266,26 @@ def test_admin_can_reset_another_users_otp():
|
||||
assert client.post('/api/admin/users/missing-user/otp/reset', headers=admin_headers).status_code == 404
|
||||
|
||||
|
||||
def test_security_audit_events_are_append_only_and_do_not_store_secrets():
|
||||
admin_headers = login_headers()
|
||||
response = client.put('/api/admin/themes', headers=admin_headers, json={'themes': ['plain-day']})
|
||||
assert response.status_code == 200
|
||||
with get_connection() as conn:
|
||||
event = conn.execute(
|
||||
'''SELECT actor_id, action, target_type, outcome, details
|
||||
FROM security_audit_events
|
||||
WHERE action = 'themes_updated'
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT 1''',
|
||||
).fetchone()
|
||||
assert event is not None
|
||||
assert event['actor_id'] == 'user-1'
|
||||
assert event['target_type'] == 'application'
|
||||
assert event['outcome'] == 'success'
|
||||
assert 'password' not in event['details'].lower()
|
||||
assert 'token' not in event['details'].lower()
|
||||
assert 'secret' not in event['details'].lower()
|
||||
|
||||
|
||||
def test_new_user_must_verify_email_before_login():
|
||||
headers = login_headers()
|
||||
username = f'unverified-{uuid4().hex}'
|
||||
|
||||
@@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent():
|
||||
connection = sqlite3.connect(':memory:')
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 16
|
||||
assert get_schema_version(connection) == 17
|
||||
tables = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
@@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent():
|
||||
assert set(DEFAULT_TAGS) <= seeded_tags
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 16
|
||||
assert get_schema_version(connection) == 17
|
||||
|
||||
connection.close()
|
||||
Reference in New Issue
Block a user