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
+153 -19
View File
@@ -25,6 +25,11 @@ from app.services.gmail_client import GmailClient
from app.services.imap_client import IMAPClient
from app.services.import_history import record_import_attempt
from app.services.microsoft_graph_client import MicrosoftGraphClient
from app.services.workspace_audit import changed_fields, record_workspace_audit_log
from app.services.workspaces import (
assign_default_workspace_to_unscoped_rows,
workspace_mail_source_query,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -331,8 +336,11 @@ def _connection_test_response(
}
def _get_source_or_404(source_id: int, db: Session) -> MailSource:
source = db.query(MailSource).filter(MailSource.id == source_id).first()
def _get_source_or_404(source_id: int, db: Session, workspace=None) -> MailSource:
query = db.query(MailSource).filter(MailSource.id == source_id)
if workspace is not None:
query = query.filter(MailSource.workspace_id == workspace.id)
source = query.first()
if source is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -341,6 +349,30 @@ def _get_source_or_404(source_id: int, db: Session) -> MailSource:
return source
def _audit_mail_source_change(
db: Session,
*,
workspace,
source: MailSource,
action: str,
auth_context: Dict[str, Any],
request: Request,
details: Optional[Dict[str, Any]] = None,
) -> None:
record_workspace_audit_log(
db,
workspace=workspace,
action=action,
entity_type="mail_source",
entity_id=source.id,
entity_name=source.name,
details=details or {"method": source.method},
auth_context=auth_context,
request=request,
commit=True,
)
def _safe_attr(source: MailSource, name: str, default: Any = None) -> Any:
"""Read optional source attributes without letting test doubles invent fields."""
value = getattr(source, name, default)
@@ -572,18 +604,22 @@ async def list_mail_sources(
_auth: dict = Depends(require_admin_auth),
) -> List[MailSourceResponse]:
"""Return all configured mail sources (passwords redacted)."""
sources = db.query(MailSource).order_by(MailSource.id).all()
workspace = assign_default_workspace_to_unscoped_rows(db)
sources = workspace_mail_source_query(db, workspace).order_by(MailSource.id).all()
return [_source_to_response(s) for s in sources]
@router.post("", response_model=MailSourceResponse, status_code=status.HTTP_201_CREATED)
async def create_mail_source(
payload: MailSourceCreate,
request: Request,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> MailSourceResponse:
"""Create a new mail source."""
workspace = assign_default_workspace_to_unscoped_rows(db)
source = MailSource(
workspace_id=workspace.id,
name=payload.name,
method=payload.method.upper(),
server=payload.server,
@@ -605,6 +641,15 @@ async def create_mail_source(
db.add(source)
db.commit()
db.refresh(source)
_audit_mail_source_change(
db,
workspace=workspace,
source=source,
action="mail_source.created",
auth_context=_auth,
request=request,
details={"method": source.method, "enabled": source.enabled},
)
logger.info(
"Created mail source id=%d name=%r method=%r", source.id, source.name, source.method
)
@@ -618,7 +663,8 @@ async def get_mail_source(
_auth: dict = Depends(require_admin_auth),
) -> MailSourceResponse:
"""Return a single mail source by ID (password redacted)."""
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
return _source_to_response(source)
@@ -630,7 +676,8 @@ async def list_mail_source_imports(
_auth: dict = Depends(require_admin_auth),
) -> List[MailSourceImportResponse]:
"""Return recent sanitized import attempts for one mail source."""
_get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
_get_source_or_404(source_id, db, workspace)
safe_limit = min(max(limit, 1), 100)
rows = (
db.query(MailSourceImport)
@@ -653,7 +700,8 @@ async def fetch_mail_source(
if days < 1 or days > 365:
raise HTTPException(status_code=400, detail="Days parameter must be between 1 and 365")
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
results = _fetch_source(source, db, days)
logger.info(
"Manual fetch for source id=%d: processed=%d reports_found=%d "
@@ -678,11 +726,13 @@ async def fetch_mail_source(
async def update_mail_source(
source_id: int,
payload: MailSourceUpdate,
request: Request,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> MailSourceResponse:
"""Update one or more fields of an existing mail source."""
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
update_data = payload.model_dump(exclude_unset=True)
if "method" in update_data and update_data["method"]:
@@ -694,6 +744,15 @@ async def update_mail_source(
source.updated_at = datetime.utcnow()
db.commit()
db.refresh(source)
_audit_mail_source_change(
db,
workspace=workspace,
source=source,
action="mail_source.updated",
auth_context=_auth,
request=request,
details={"changed_fields": changed_fields(update_data), "method": source.method},
)
logger.info("Updated mail source id=%d", source.id)
return _source_to_response(source)
@@ -701,28 +760,55 @@ async def update_mail_source(
@router.delete("/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_mail_source(
source_id: int,
request: Request,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> None:
"""Delete a mail source permanently."""
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
source_name = source.name
source_method = source.method
db.delete(source)
db.commit()
record_workspace_audit_log(
db,
workspace=workspace,
action="mail_source.deleted",
entity_type="mail_source",
entity_id=source_id,
entity_name=source_name,
details={"method": source_method},
auth_context=_auth,
request=request,
commit=True,
)
logger.info("Deleted mail source id=%s", _sanitize_for_log(source_id))
@router.post("/{source_id}/toggle", response_model=MailSourceResponse)
async def toggle_mail_source(
source_id: int,
request: Request,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> MailSourceResponse:
"""Toggle the *enabled* flag of a mail source."""
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
source.enabled = not source.enabled
source.updated_at = datetime.utcnow()
db.commit()
db.refresh(source)
_audit_mail_source_change(
db,
workspace=workspace,
source=source,
action="mail_source.toggled",
auth_context=_auth,
request=request,
details={"enabled": source.enabled},
)
return _source_to_response(source)
@@ -733,7 +819,8 @@ async def test_stored_mail_source( # noqa: C901
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Test the connection for an already-stored mail source using its saved credentials."""
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
if source.method == "GMAIL_API":
if not source.gmail_access_token:
@@ -877,7 +964,8 @@ async def m365_authorize_url(
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Return a Microsoft identity platform authorization URL for M365_GRAPH."""
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
if source.method != "M365_GRAPH":
raise HTTPException(
@@ -908,7 +996,8 @@ async def m365_list_folders(
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Return selectable Microsoft 365 mail folders for this source."""
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
if source.method != "M365_GRAPH":
raise HTTPException(
@@ -1051,11 +1140,13 @@ async def m365_oauth_callback(
async def m365_oauth_callback_post(
source_id: int,
payload: M365CallbackRequest,
request: Request,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> MailSourceResponse:
"""Exchange a Microsoft OAuth2 authorization code for Graph tokens."""
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
if source.method != "M365_GRAPH":
raise HTTPException(
@@ -1102,6 +1193,15 @@ async def m365_oauth_callback_post(
source.updated_at = datetime.utcnow()
db.commit()
db.refresh(source)
_audit_mail_source_change(
db,
workspace=workspace,
source=source,
action="mail_source.m365_connected",
auth_context=_auth,
request=request,
details={"account": m365_email or "unknown"},
)
logger.info(
"Microsoft 365 OAuth2 tokens saved for source id=%d (account=%s)",
@@ -1119,7 +1219,8 @@ async def m365_fetch_reports(
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Manually trigger a Microsoft 365 Graph DMARC report fetch."""
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
if source.method != "M365_GRAPH":
raise HTTPException(
@@ -1147,11 +1248,13 @@ async def m365_fetch_reports(
@router.delete("/{source_id}/m365/connection", status_code=status.HTTP_204_NO_CONTENT)
async def m365_disconnect(
source_id: int,
request: Request,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> None:
"""Clear the stored Microsoft Graph OAuth2 tokens for this source."""
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
if source.method != "M365_GRAPH":
raise HTTPException(
@@ -1164,6 +1267,14 @@ async def m365_disconnect(
source.m365_email = None
source.updated_at = datetime.utcnow()
db.commit()
_audit_mail_source_change(
db,
workspace=workspace,
source=source,
action="mail_source.m365_disconnected",
auth_context=_auth,
request=request,
)
logger.info("Microsoft 365 tokens cleared for source id=%d", int(source_id))
@@ -1186,7 +1297,8 @@ async def gmail_authorize_url(
grants access Google redirects back to
``<origin>/mail-sources/<id>/gmail/callback`` with a ``code`` parameter.
"""
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
if source.method != "GMAIL_API":
raise HTTPException(
@@ -1311,6 +1423,7 @@ async def gmail_oauth_callback(
async def gmail_oauth_callback_post(
source_id: int,
payload: GmailCallbackRequest,
request: Request,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> MailSourceResponse:
@@ -1321,7 +1434,8 @@ async def gmail_oauth_callback_post(
themselves and post the code here as JSON. Requires the standard
admin authentication.
"""
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
if source.method != "GMAIL_API":
raise HTTPException(
@@ -1366,6 +1480,15 @@ async def gmail_oauth_callback_post(
source.updated_at = datetime.utcnow()
db.commit()
db.refresh(source)
_audit_mail_source_change(
db,
workspace=workspace,
source=source,
action="mail_source.gmail_connected",
auth_context=_auth,
request=request,
details={"account": gmail_email or "unknown"},
)
logger.info(
"Gmail OAuth2 tokens saved for source id=%d (account=%s)",
@@ -1387,7 +1510,8 @@ async def gmail_fetch_reports(
Searches Gmail for emails matching the DMARC report heuristic, ingests
any attachments not yet seen, and returns a summary.
"""
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
if source.method != "GMAIL_API":
raise HTTPException(
@@ -1458,11 +1582,13 @@ async def gmail_fetch_reports(
@router.delete("/{source_id}/gmail/connection", status_code=status.HTTP_204_NO_CONTENT)
async def gmail_disconnect(
source_id: int,
request: Request,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> None:
"""Revoke / clear the stored Gmail OAuth2 tokens for this source."""
source = _get_source_or_404(source_id, db)
workspace = assign_default_workspace_to_unscoped_rows(db)
source = _get_source_or_404(source_id, db, workspace)
if source.method != "GMAIL_API":
raise HTTPException(
@@ -1475,4 +1601,12 @@ async def gmail_disconnect(
source.gmail_email = None
source.updated_at = datetime.utcnow()
db.commit()
_audit_mail_source_change(
db,
workspace=workspace,
source=source,
action="mail_source.gmail_disconnected",
auth_context=_auth,
request=request,
)
logger.info("Gmail tokens cleared for source id=%d", int(source_id))