feat: add workspace RBAC audit foundations

This commit is contained in:
Christian Krakau-Louis
2026-05-23 19:22:56 +02:00
parent 6ddd42bb3e
commit d180f984a6
20 changed files with 1199 additions and 54 deletions
+1
View File
@@ -17,6 +17,7 @@ import app.models.setting # noqa: F401 # pylint: disable=unused-import
import app.models.user # noqa: F401 # pylint: disable=unused-import
import app.models.webhook # noqa: F401 # pylint: disable=unused-import
import app.models.workspace # noqa: F401 # pylint: disable=unused-import
import app.models.workspace_access # noqa: F401 # pylint: disable=unused-import
from app.core.database import Base, get_db
from app.core.security import require_admin_auth
from app.main import create_app
+30 -30
View File
@@ -107,9 +107,9 @@ def test_get_selectors_unknown_domain(client: TestClient):
# ---------------------------------------------------------------------------
def test_add_selector(client: TestClient):
def test_add_selector(authed_client: TestClient):
"""Adding a selector persists it and returns the updated list."""
response = client.post(
response = authed_client.post(
f"/api/v1/domains/{DOMAIN}/selectors",
json={"selector": "mysel"},
)
@@ -118,39 +118,39 @@ def test_add_selector(client: TestClient):
assert "mysel" in data["selectors"]
def test_add_selector_deduplication(client: TestClient):
def test_add_selector_deduplication(authed_client: TestClient):
"""Adding the same selector twice should not create duplicates."""
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "dup"})
response = client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "dup"})
authed_client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "dup"})
response = authed_client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "dup"})
assert response.status_code == 201
assert response.json()["selectors"].count("dup") == 1
def test_add_selector_invalid_empty(client: TestClient):
def test_add_selector_invalid_empty(authed_client: TestClient):
"""An empty selector string should be rejected."""
response = client.post(
response = authed_client.post(
f"/api/v1/domains/{DOMAIN}/selectors",
json={"selector": " "},
)
assert response.status_code == 422
def test_add_selector_unknown_domain(client: TestClient):
def test_add_selector_unknown_domain(authed_client: TestClient):
"""Adding a selector to an unknown domain returns 404."""
response = client.post(
response = authed_client.post(
"/api/v1/domains/unknown.example.com/selectors",
json={"selector": "google"},
)
assert response.status_code == 404
def test_add_multiple_selectors(client: TestClient):
def test_add_multiple_selectors(authed_client: TestClient):
"""Multiple distinct selectors can be added and all are returned."""
for sel in ("sel1", "sel2", "sel3"):
r = client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": sel})
r = authed_client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": sel})
assert r.status_code == 201
response = client.get(f"/api/v1/domains/{DOMAIN}/selectors")
response = authed_client.get(f"/api/v1/domains/{DOMAIN}/selectors")
assert response.status_code == 200
selectors = response.json()["selectors"]
assert "sel1" in selectors
@@ -163,25 +163,25 @@ def test_add_multiple_selectors(client: TestClient):
# ---------------------------------------------------------------------------
def test_delete_selector(client: TestClient):
def test_delete_selector(authed_client: TestClient):
"""Deleting a selector removes it from the persisted list."""
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "todelete"})
response = client.delete(f"/api/v1/domains/{DOMAIN}/selectors/todelete")
authed_client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "todelete"})
response = authed_client.delete(f"/api/v1/domains/{DOMAIN}/selectors/todelete")
assert response.status_code == 200
assert "todelete" not in response.json()["selectors"]
def test_delete_nonexistent_selector(client: TestClient):
def test_delete_nonexistent_selector(authed_client: TestClient):
"""Deleting a selector that was never added returns 404."""
# Ensure the domain exists in DB (via add then delete)
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "dummy"})
response = client.delete(f"/api/v1/domains/{DOMAIN}/selectors/ghost")
authed_client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "dummy"})
response = authed_client.delete(f"/api/v1/domains/{DOMAIN}/selectors/ghost")
assert response.status_code == 404
def test_delete_selector_unknown_domain(client: TestClient):
def test_delete_selector_unknown_domain(authed_client: TestClient):
"""Deleting from an unknown domain returns 404."""
response = client.delete("/api/v1/domains/unknown.example.com/selectors/google")
response = authed_client.delete("/api/v1/domains/unknown.example.com/selectors/google")
assert response.status_code == 404
@@ -225,16 +225,16 @@ def test_get_selectors_ignores_missing_dkim_detail_lists(client: TestClient):
assert "mail" in response.json()["report_selectors"]
def test_get_selectors_report_selector_moves_to_manual_when_added(client: TestClient):
def test_get_selectors_report_selector_moves_to_manual_when_added(authed_client: TestClient):
"""A selector discovered from reports should appear only in 'selectors' once added manually."""
# Confirm it's in report_selectors before adding
r1 = client.get(f"/api/v1/domains/{DOMAIN}/selectors")
r1 = authed_client.get(f"/api/v1/domains/{DOMAIN}/selectors")
assert "google" in r1.json()["report_selectors"]
# Add it as a manual selector
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "google"})
authed_client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "google"})
r2 = client.get(f"/api/v1/domains/{DOMAIN}/selectors")
r2 = authed_client.get(f"/api/v1/domains/{DOMAIN}/selectors")
data = r2.json()
assert "google" in data["selectors"]
# It must not appear in both lists
@@ -368,10 +368,10 @@ def test_dns_endpoint_refresh_bypasses_cache(client: TestClient):
assert mock_provider.check_domain.await_count == 2
def test_dns_endpoint_uses_manual_selectors(client: TestClient):
def test_dns_endpoint_uses_manual_selectors(authed_client: TestClient):
"""Manually added selectors should be forwarded to check_domain."""
# Add a custom selector
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "customsel"})
authed_client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "customsel"})
captured_selectors = []
@@ -383,7 +383,7 @@ def test_dns_endpoint_uses_manual_selectors(client: TestClient):
"app.api.api_v1.endpoints.domains.get_default_provider",
return_value=AsyncMock(check_domain=_fake_check_domain),
):
client.get(f"/api/v1/domains/{DOMAIN}/dns")
authed_client.get(f"/api/v1/domains/{DOMAIN}/dns")
assert "customsel" in captured_selectors
@@ -752,9 +752,9 @@ def test_summary_dns_failure_defaults_false(client: TestClient):
assert domain["dkim_status"] is False
def test_summary_endpoint_uses_manual_selectors(client: TestClient):
def test_summary_endpoint_uses_manual_selectors(authed_client: TestClient):
"""Manually configured selectors are forwarded by the summary endpoint."""
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "manualsel"})
authed_client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "manualsel"})
captured_selectors = []
async def _fake_check_domain(domain, selectors=None):
@@ -765,7 +765,7 @@ def test_summary_endpoint_uses_manual_selectors(client: TestClient):
"app.api.api_v1.endpoints.domains.get_default_provider",
return_value=AsyncMock(check_domain=_fake_check_domain),
):
response = client.get("/api/v1/domains/summary")
response = authed_client.get("/api/v1/domains/summary")
assert response.status_code == 200
assert "manualsel" in captured_selectors
+234
View File
@@ -0,0 +1,234 @@
import json
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.workspace_access import WorkspaceAuditLog
from app.services.workspace_access import (
PERMISSION_WORKSPACE_ADMIN,
ROLE_DOMAIN_ADMIN,
ROLE_WORKSPACE_OWNER,
require_workspace_permission,
role_for_auth_context,
)
from app.services.workspace_audit import (
actor_from_auth,
audit_log_to_dict,
list_workspace_audit_logs,
record_workspace_audit_log,
sanitize_audit_details,
)
from app.services.workspaces import get_or_create_default_workspace
def test_workspace_roles_endpoint_lists_permissions(authed_client: TestClient):
"""Operators can discover the workspace RBAC vocabulary."""
response = authed_client.get("/api/v1/audit/roles")
assert response.status_code == 200
roles = {item["role"]: set(item["permissions"]) for item in response.json()["roles"]}
assert ROLE_WORKSPACE_OWNER in roles
assert ROLE_DOMAIN_ADMIN in roles
assert "workspace:admin" in roles[ROLE_WORKSPACE_OWNER]
assert "mail_sources:write" in roles[ROLE_DOMAIN_ADMIN]
def test_audit_details_sanitize_secret_like_fields():
"""Audit helper redacts nested secret-shaped fields."""
details = sanitize_audit_details(
{
"name": "mailbox",
"password": "super-secret",
"nested": {"refresh_token": "token-secret"},
}
)
assert details["name"] == "mailbox"
assert details["password"] == "[redacted]"
assert details["nested"]["refresh_token"] == "[redacted]"
assert "super-secret" not in json.dumps(details)
assert "token-secret" not in json.dumps(details)
def test_workspace_permission_denial_and_actor_variants(db_session: Session):
"""RBAC and audit helpers cover fallback actors and denial paths."""
workspace = get_or_create_default_workspace(db_session)
assert role_for_auth_context({"auth_type": "unexpected"}) == "auditor"
try:
require_workspace_permission({"auth_type": "unexpected"}, PERMISSION_WORKSPACE_ADMIN)
except Exception as exc: # pylint: disable=broad-exception-caught
assert getattr(exc, "status_code", None) == 403
else:
raise AssertionError("permission denial was not raised")
assert (
actor_from_auth({"auth_type": "jwt", "payload": {"sub": "user-123"}})["actor_id"]
== "user-123"
)
assert actor_from_auth({"auth_type": "api_token", "token_id": 42})["actor_id"] == "42"
assert sanitize_audit_details({"object": object()})["object"].startswith("<object object")
row = record_workspace_audit_log(
db_session,
workspace=workspace,
action="workspace.test",
entity_type="workspace",
entity_id=workspace.id,
details={"client_secret": "hidden"},
auth_context={"auth_type": "jwt", "payload": {"sub": "user-123"}},
)
db_session.commit()
db_session.refresh(row)
assert audit_log_to_dict(row)["details"]["client_secret"] == "[redacted]"
row.details = "{not-json"
assert audit_log_to_dict(row)["details"] == {}
filtered = list_workspace_audit_logs(
db_session,
workspace=workspace,
action="workspace.test",
entity_type="workspace",
)
assert filtered[0]["action"] == "workspace.test"
def test_mail_source_changes_create_workspace_audit_without_secret_values(
authed_client: TestClient,
db_session: Session,
):
"""Mail source create/update actions are auditable without leaking credentials."""
created = authed_client.post(
"/api/v1/mail-sources",
json={
"name": "DMARC Inbox",
"method": "IMAP",
"server": "imap.example.com",
"username": "reports@example.com",
"password": "super-secret-password",
},
)
assert created.status_code == 201
source_id = created.json()["id"]
updated = authed_client.put(
f"/api/v1/mail-sources/{source_id}",
json={"password": "new-secret-password", "folder": "Reports"},
)
assert updated.status_code == 200
toggled = authed_client.post(f"/api/v1/mail-sources/{source_id}/toggle")
assert toggled.status_code == 200
deleted = authed_client.delete(f"/api/v1/mail-sources/{source_id}")
assert deleted.status_code == 204
rows = (
db_session.query(WorkspaceAuditLog)
.filter(WorkspaceAuditLog.entity_type == "mail_source")
.order_by(WorkspaceAuditLog.id)
.all()
)
assert [row.action for row in rows] == [
"mail_source.created",
"mail_source.updated",
"mail_source.toggled",
"mail_source.deleted",
]
serialized = "\n".join(row.details or "" for row in rows)
assert "super-secret-password" not in serialized
assert "new-secret-password" not in serialized
assert "changed_fields" in serialized
def test_notification_setting_audit_is_workspace_scoped_and_redacted(
authed_client: TestClient,
):
"""Notification setting changes appear in workspace audit logs with redacted secrets."""
response = authed_client.put(
"/api/v1/settings/notifications.apprise_urls",
json={"value": "mailto://user:password@example.com"},
headers={"x-forwarded-for": "203.0.113.5, 10.0.0.1"},
)
assert response.status_code == 200
audit = authed_client.get("/api/v1/audit/logs?entity_type=setting")
assert audit.status_code == 200
events = audit.json()["audit"]
assert events[0]["action"] == "setting.changed"
assert events[0]["ip_address"] == "203.0.113.5"
assert events[0]["details"]["new_value"] == "[redacted]"
assert "password@example.com" not in str(events)
def test_api_token_create_and_revoke_are_audited_without_raw_token(
authed_client: TestClient,
):
"""API token lifecycle records expose metadata but not raw secrets."""
created = authed_client.post(
"/api/v1/api-tokens",
json={"name": "SIEM exporter", "scopes": ["reports:read"]},
)
assert created.status_code == 201
body = created.json()
token_id = body["metadata"]["id"]
raw_token = body["token"]
revoked = authed_client.delete(f"/api/v1/api-tokens/{token_id}")
assert revoked.status_code == 200
audit = authed_client.get("/api/v1/audit/logs?entity_type=api_token")
assert audit.status_code == 200
actions = [item["action"] for item in audit.json()["audit"]]
assert actions[:2] == ["api_token.revoked", "api_token.created"]
assert raw_token not in str(audit.json())
def test_webhook_changes_are_audited_without_signing_secret(authed_client: TestClient):
"""Webhook lifecycle changes write sanitized audit events."""
created = authed_client.post(
"/api/v1/webhooks",
json={
"name": "SIEM receiver",
"url": "https://example.com/dmarq",
"secret": "very-secret-webhook-signing-value",
},
)
assert created.status_code == 200
endpoint_id = created.json()["id"]
updated = authed_client.put(
f"/api/v1/webhooks/{endpoint_id}",
json={"name": "Updated receiver", "secret": "another-secret-webhook-value"},
)
assert updated.status_code == 200
disabled = authed_client.delete(f"/api/v1/webhooks/{endpoint_id}")
assert disabled.status_code == 200
audit = authed_client.get("/api/v1/audit/logs?entity_type=webhook_endpoint")
assert audit.status_code == 200
actions = [event["action"] for event in audit.json()["audit"]]
assert actions[:3] == ["webhook.disabled", "webhook.updated", "webhook.created"]
serialized = str(audit.json())
assert "very-secret-webhook-signing-value" not in serialized
assert "another-secret-webhook-value" not in serialized
def test_manual_selector_changes_are_audited(authed_client: TestClient):
"""Manual DKIM selector changes create audit entries."""
created = authed_client.post(
"/api/v1/domains/domains",
json={"name": "selector-audit.example"},
)
assert created.status_code == 201
added = authed_client.post(
"/api/v1/domains/selector-audit.example/selectors",
json={"selector": "s2026"},
)
assert added.status_code == 201
removed = authed_client.delete("/api/v1/domains/selector-audit.example/selectors/s2026")
assert removed.status_code == 200
audit = authed_client.get("/api/v1/audit/logs?entity_type=domain")
assert audit.status_code == 200
actions = [event["action"] for event in audit.json()["audit"]]
assert actions[:2] == ["domain.selector_removed", "domain.selector_added"]