diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 0975c56..cb69269 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -31,6 +31,7 @@ import app.models.setting # noqa: E402, F401 import app.models.user # noqa: E402, F401 import app.models.webhook # noqa: E402, F401 import app.models.workspace # noqa: E402, F401 +import app.models.workspace_access # noqa: E402, F401 # Import all models so that autogenerate can detect them from app.core.database import Base # noqa: E402 diff --git a/backend/alembic/versions/3d4e5f6a7b8c_add_workspace_rbac_audit.py b/backend/alembic/versions/3d4e5f6a7b8c_add_workspace_rbac_audit.py new file mode 100644 index 0000000..af3d0a2 --- /dev/null +++ b/backend/alembic/versions/3d4e5f6a7b8c_add_workspace_rbac_audit.py @@ -0,0 +1,147 @@ +"""add workspace rbac audit foundations + +Revision ID: 3d4e5f6a7b8c +Revises: 2c3d4e5f6a7b +Create Date: 2026-05-23 19:20:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "3d4e5f6a7b8c" +down_revision = "2c3d4e5f6a7b" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create workspace membership and sanitized audit log tables.""" + op.create_table( + "workspace_memberships", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("workspace_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("role", sa.String(length=50), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_workspace_memberships_id"), "workspace_memberships", ["id"]) + op.create_index( + op.f("ix_workspace_memberships_workspace_id"), + "workspace_memberships", + ["workspace_id"], + ) + op.create_index(op.f("ix_workspace_memberships_user_id"), "workspace_memberships", ["user_id"]) + op.create_index(op.f("ix_workspace_memberships_role"), "workspace_memberships", ["role"]) + op.create_index(op.f("ix_workspace_memberships_active"), "workspace_memberships", ["active"]) + op.create_index( + op.f("ix_workspace_memberships_created_at"), + "workspace_memberships", + ["created_at"], + ) + op.create_index( + "ix_workspace_memberships_workspace_user", + "workspace_memberships", + ["workspace_id", "user_id"], + unique=True, + ) + op.create_index( + "ix_workspace_memberships_workspace_role", + "workspace_memberships", + ["workspace_id", "role"], + ) + + op.create_table( + "workspace_audit_logs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("workspace_id", sa.Integer(), nullable=False), + sa.Column("actor_type", sa.String(length=50), nullable=False), + sa.Column("actor_id", sa.String(length=120), nullable=True), + sa.Column("action", sa.String(length=100), nullable=False), + sa.Column("entity_type", sa.String(length=80), nullable=False), + sa.Column("entity_id", sa.String(length=120), nullable=True), + sa.Column("entity_name", sa.String(length=255), nullable=True), + sa.Column("details", sa.Text(), nullable=True), + sa.Column("ip_address", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_workspace_audit_logs_id"), "workspace_audit_logs", ["id"]) + op.create_index( + op.f("ix_workspace_audit_logs_workspace_id"), + "workspace_audit_logs", + ["workspace_id"], + ) + op.create_index( + op.f("ix_workspace_audit_logs_actor_type"), + "workspace_audit_logs", + ["actor_type"], + ) + op.create_index( + op.f("ix_workspace_audit_logs_actor_id"), + "workspace_audit_logs", + ["actor_id"], + ) + op.create_index(op.f("ix_workspace_audit_logs_action"), "workspace_audit_logs", ["action"]) + op.create_index( + op.f("ix_workspace_audit_logs_entity_type"), + "workspace_audit_logs", + ["entity_type"], + ) + op.create_index( + op.f("ix_workspace_audit_logs_entity_id"), + "workspace_audit_logs", + ["entity_id"], + ) + op.create_index( + op.f("ix_workspace_audit_logs_created_at"), + "workspace_audit_logs", + ["created_at"], + ) + op.create_index( + "ix_workspace_audit_workspace_created", + "workspace_audit_logs", + ["workspace_id", "created_at"], + ) + op.create_index( + "ix_workspace_audit_workspace_action", + "workspace_audit_logs", + ["workspace_id", "action"], + ) + op.create_index( + "ix_workspace_audit_entity", + "workspace_audit_logs", + ["entity_type", "entity_id"], + ) + + +def downgrade() -> None: + """Remove workspace RBAC and audit foundations.""" + op.drop_index("ix_workspace_audit_entity", table_name="workspace_audit_logs") + op.drop_index("ix_workspace_audit_workspace_action", table_name="workspace_audit_logs") + op.drop_index("ix_workspace_audit_workspace_created", table_name="workspace_audit_logs") + op.drop_index(op.f("ix_workspace_audit_logs_created_at"), table_name="workspace_audit_logs") + op.drop_index(op.f("ix_workspace_audit_logs_entity_id"), table_name="workspace_audit_logs") + op.drop_index(op.f("ix_workspace_audit_logs_entity_type"), table_name="workspace_audit_logs") + op.drop_index(op.f("ix_workspace_audit_logs_action"), table_name="workspace_audit_logs") + op.drop_index(op.f("ix_workspace_audit_logs_actor_id"), table_name="workspace_audit_logs") + op.drop_index(op.f("ix_workspace_audit_logs_actor_type"), table_name="workspace_audit_logs") + op.drop_index(op.f("ix_workspace_audit_logs_workspace_id"), table_name="workspace_audit_logs") + op.drop_index(op.f("ix_workspace_audit_logs_id"), table_name="workspace_audit_logs") + op.drop_table("workspace_audit_logs") + + op.drop_index("ix_workspace_memberships_workspace_role", table_name="workspace_memberships") + op.drop_index("ix_workspace_memberships_workspace_user", table_name="workspace_memberships") + op.drop_index(op.f("ix_workspace_memberships_created_at"), table_name="workspace_memberships") + op.drop_index(op.f("ix_workspace_memberships_active"), table_name="workspace_memberships") + op.drop_index(op.f("ix_workspace_memberships_role"), table_name="workspace_memberships") + op.drop_index(op.f("ix_workspace_memberships_user_id"), table_name="workspace_memberships") + op.drop_index(op.f("ix_workspace_memberships_workspace_id"), table_name="workspace_memberships") + op.drop_index(op.f("ix_workspace_memberships_id"), table_name="workspace_memberships") + op.drop_table("workspace_memberships") diff --git a/backend/app/api/api_v1/api.py b/backend/app/api/api_v1/api.py index e606313..84afa61 100644 --- a/backend/app/api/api_v1/api.py +++ b/backend/app/api/api_v1/api.py @@ -2,6 +2,7 @@ from fastapi import APIRouter from app.api.api_v1.endpoints import ( api_tokens, + audit, auth, domains, forensics, @@ -24,6 +25,7 @@ api_router = APIRouter() # Include all endpoint routers api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(api_tokens.router, prefix="/api-tokens", tags=["api-tokens"]) +api_router.include_router(audit.router, prefix="/audit", tags=["audit"]) api_router.include_router(health.router, tags=["health"]) api_router.include_router(public.router, prefix="/public", tags=["public-api"]) api_router.include_router(domains.router, prefix="/domains", tags=["domains"]) diff --git a/backend/app/api/api_v1/endpoints/api_tokens.py b/backend/app/api/api_v1/endpoints/api_tokens.py index f2308ca..70e5fde 100644 --- a/backend/app/api/api_v1/endpoints/api_tokens.py +++ b/backend/app/api/api_v1/endpoints/api_tokens.py @@ -2,7 +2,7 @@ from typing import List, Optional -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session @@ -15,6 +15,8 @@ from app.services.api_tokens import ( revoke_api_token, token_to_dict, ) +from app.services.workspace_audit import record_workspace_audit_log +from app.services.workspaces import assign_default_workspace_to_unscoped_rows router = APIRouter() @@ -71,10 +73,12 @@ async def list_api_tokens( @router.post("", response_model=APITokenCreateResponse, status_code=status.HTTP_201_CREATED) async def create_public_api_token( payload: APITokenCreateRequest, + request: Request, db: Session = Depends(get_db), _auth: dict = Depends(require_admin_auth), ): """Create a scoped API token for read-only automation.""" + workspace = assign_default_workspace_to_unscoped_rows(db) try: created = create_api_token(db, name=payload.name, scopes=payload.scopes) except ValueError as exc: @@ -82,6 +86,21 @@ async def create_public_api_token( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc), ) from exc + record_workspace_audit_log( + db, + workspace=workspace, + action="api_token.created", + entity_type="api_token", + entity_id=created.token.id, + entity_name=created.token.name, + details={ + "scopes": sorted(created.token.scopes.split(",")), + "key_prefix": created.token.key_prefix, + }, + auth_context=_auth, + request=request, + commit=True, + ) return APITokenCreateResponse( token=created.secret, metadata=APITokenResponse(**token_to_dict(created.token)), @@ -91,13 +110,28 @@ async def create_public_api_token( @router.delete("/{token_id}", status_code=status.HTTP_200_OK) async def revoke_public_api_token( token_id: int, + request: Request, db: Session = Depends(get_db), _auth: dict = Depends(require_admin_auth), ): """Revoke a scoped API token.""" + workspace = assign_default_workspace_to_unscoped_rows(db) + token = db.query(APIToken).filter(APIToken.id == token_id).first() if not revoke_api_token(db, token_id): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="API token not found", ) + record_workspace_audit_log( + db, + workspace=workspace, + action="api_token.revoked", + entity_type="api_token", + entity_id=token_id, + entity_name=token.name if token else None, + details={"key_prefix": token.key_prefix if token else None}, + auth_context=_auth, + request=request, + commit=True, + ) return {"revoked": True} diff --git a/backend/app/api/api_v1/endpoints/audit.py b/backend/app/api/api_v1/endpoints/audit.py new file mode 100644 index 0000000..6e6c207 --- /dev/null +++ b/backend/app/api/api_v1/endpoints/audit.py @@ -0,0 +1,61 @@ +"""Workspace RBAC and audit endpoints.""" + +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.security import require_admin_auth +from app.services.workspace_access import ( + PERMISSION_AUDIT_READ, + list_workspace_roles, + require_workspace_permission, +) +from app.services.workspace_audit import list_workspace_audit_logs +from app.services.workspaces import assign_default_workspace_to_unscoped_rows + +router = APIRouter() + + +class WorkspaceRoleResponse(BaseModel): + """Workspace role and permission definitions.""" + + roles: List[Dict[str, Any]] + + +class WorkspaceAuditLogResponse(BaseModel): + """Workspace audit log list response.""" + + audit: List[Dict[str, Any]] + + +@router.get("/roles", response_model=WorkspaceRoleResponse) +async def get_workspace_roles( + _auth: dict = Depends(require_admin_auth), +) -> WorkspaceRoleResponse: + """Return the supported workspace role definitions.""" + return {"roles": list_workspace_roles()} + + +@router.get("/logs", response_model=WorkspaceAuditLogResponse) +async def get_workspace_audit_logs( + limit: int = Query(50, ge=1, le=200), + action: Optional[str] = None, + entity_type: Optional[str] = None, + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> WorkspaceAuditLogResponse: + """Return recent sanitized audit events for the default workspace.""" + require_workspace_permission(_auth, PERMISSION_AUDIT_READ) + workspace = assign_default_workspace_to_unscoped_rows(db) + return { + "audit": list_workspace_audit_logs( + db, + workspace=workspace, + limit=limit, + action=action, + entity_type=entity_type, + ) + } diff --git a/backend/app/api/api_v1/endpoints/domains.py b/backend/app/api/api_v1/endpoints/domains.py index 4e25e97..8aaa707 100644 --- a/backend/app/api/api_v1/endpoints/domains.py +++ b/backend/app/api/api_v1/endpoints/domains.py @@ -6,7 +6,7 @@ import logging from datetime import date, datetime, timezone from typing import Any, Dict, List, Optional -from fastapi import APIRouter, Depends, HTTPException, Path, Query, status +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status from fastapi.responses import Response from pydantic import BaseModel, Field from sqlalchemy.exc import IntegrityError @@ -36,6 +36,7 @@ from app.services.report_persistence import ( hydrate_report_store_from_db, ) from app.services.report_store import ReportStore +from app.services.workspace_audit import record_workspace_audit_log from app.services.workspaces import ( assign_default_workspace_to_unscoped_rows, workspace_domain_query, @@ -1930,8 +1931,10 @@ async def get_domain_selectors( @router.post("/{domain_id}/selectors", status_code=status.HTTP_201_CREATED) async def add_domain_selector( selector_data: SelectorRequest, + request: Request, domain_id: str = Path(..., title="The domain ID or name"), db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), ): """Add a DKIM selector to the manual list for a domain. @@ -1965,15 +1968,29 @@ async def add_domain_selector( existing.append(selector) domain_db.dkim_selectors = ",".join(existing) db.commit() + record_workspace_audit_log( + db, + workspace=workspace, + action="domain.selector_added", + entity_type="domain", + entity_id=domain_db.id, + entity_name=domain_db.name, + details={"selector": selector}, + auth_context=_auth, + request=request, + commit=True, + ) return {"selectors": existing} @router.delete("/{domain_id}/selectors/{selector}", status_code=status.HTTP_200_OK) async def delete_domain_selector( + request: Request, domain_id: str = Path(..., title="The domain ID or name"), selector: str = Path(..., title="The DKIM selector to remove"), db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), ): """Remove a manually configured DKIM selector from a domain.""" workspace = assign_default_workspace_to_unscoped_rows(db) @@ -1994,6 +2011,18 @@ async def delete_domain_selector( existing.remove(selector) domain_db.dkim_selectors = ",".join(existing) db.commit() + record_workspace_audit_log( + db, + workspace=workspace, + action="domain.selector_removed", + entity_type="domain", + entity_id=domain_db.id, + entity_name=domain_db.name, + details={"selector": selector}, + auth_context=_auth, + request=request, + commit=True, + ) return {"selectors": existing} diff --git a/backend/app/api/api_v1/endpoints/mail_sources.py b/backend/app/api/api_v1/endpoints/mail_sources.py index 4a3ed15..24accd8 100644 --- a/backend/app/api/api_v1/endpoints/mail_sources.py +++ b/backend/app/api/api_v1/endpoints/mail_sources.py @@ -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 ``/mail-sources//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)) diff --git a/backend/app/api/api_v1/endpoints/settings.py b/backend/app/api/api_v1/endpoints/settings.py index ffc5d61..0523119 100644 --- a/backend/app/api/api_v1/endpoints/settings.py +++ b/backend/app/api/api_v1/endpoints/settings.py @@ -15,7 +15,7 @@ in the ``settings`` database table. Settings are organised into categories: import logging from typing import Any, Dict, List, Optional -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel from sqlalchemy.orm import Session @@ -36,6 +36,8 @@ from app.services.alert_rules import ( ) from app.services.notifications import send_notification from app.services.summary_notifications import build_summary, send_summary_notification +from app.services.workspace_audit import record_workspace_audit_log +from app.services.workspaces import assign_default_workspace_to_unscoped_rows router = APIRouter() logger = logging.getLogger(__name__) @@ -342,6 +344,7 @@ def _audit_setting_change( old_plain: Optional[str], new_plain: Optional[str], auth_context: Optional[Dict[str, Any]], + request: Optional[Request] = None, ) -> None: if not _should_audit_setting(key) or old_plain == new_plain: return @@ -352,6 +355,22 @@ def _audit_setting_change( new_value=_audit_value_for_setting(key, new_plain), auth_context=auth_context, ) + workspace = assign_default_workspace_to_unscoped_rows(db, commit=False) + record_workspace_audit_log( + db, + workspace=workspace, + action="setting.changed", + entity_type="setting", + entity_id=key, + entity_name=key, + details={ + "key": key, + "old_value": _audit_value_for_setting(key, old_plain), + "new_value": _audit_value_for_setting(key, new_plain), + }, + auth_context=auth_context, + request=request, + ) def _row_to_dict(row: Setting, redact_secrets: bool = True) -> Dict[str, Any]: @@ -605,6 +624,7 @@ async def get_setting( async def update_setting( key: str, payload: SettingUpdate, + request: Request, db: Session = Depends(get_db), _auth: dict = Depends(require_admin_auth), ) -> SettingResponse: @@ -629,6 +649,7 @@ async def update_setting( old_plain=None, new_plain=new_plain, auth_context=_auth, + request=request, ) else: # For secret keys, only update if not the redacted placeholder @@ -644,6 +665,7 @@ async def update_setting( old_plain=old_plain, new_plain=new_plain, auth_context=_auth, + request=request, ) db.commit() db.refresh(row) @@ -653,6 +675,7 @@ async def update_setting( @router.post("/bulk", response_model=List[SettingResponse]) async def bulk_update_settings( payload: BulkSettingsUpdate, + request: Request, db: Session = Depends(get_db), _auth: dict = Depends(require_admin_auth), ) -> List[SettingResponse]: @@ -681,6 +704,7 @@ async def bulk_update_settings( old_plain=None, new_plain=new_plain, auth_context=_auth, + request=request, ) else: # Skip secret placeholder updates @@ -696,6 +720,7 @@ async def bulk_update_settings( old_plain=old_plain, new_plain=new_plain, auth_context=_auth, + request=request, ) results.append(_row_to_dict(row)) db.commit() diff --git a/backend/app/api/api_v1/endpoints/webhooks.py b/backend/app/api/api_v1/endpoints/webhooks.py index aac61ae..bd1171d 100644 --- a/backend/app/api/api_v1/endpoints/webhooks.py +++ b/backend/app/api/api_v1/endpoints/webhooks.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List, Optional -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel from sqlalchemy.orm import Session @@ -18,6 +18,8 @@ from app.services.webhook_events import ( queue_test_webhook, update_webhook_endpoint, ) +from app.services.workspace_audit import changed_fields, record_workspace_audit_log +from app.services.workspaces import assign_default_workspace_to_unscoped_rows router = APIRouter() @@ -121,14 +123,28 @@ async def list_webhook_endpoints( @router.post("", response_model=WebhookEndpointResponse) async def create_webhook( payload: WebhookEndpointCreate, + request: Request, db: Session = Depends(get_db), _auth: dict = Depends(require_admin_auth), ) -> Dict[str, Any]: """Create an outbound webhook endpoint.""" + workspace = assign_default_workspace_to_unscoped_rows(db) try: endpoint, raw_secret = create_webhook_endpoint(db, **payload.model_dump()) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + record_workspace_audit_log( + db, + workspace=workspace, + action="webhook.created", + entity_type="webhook_endpoint", + entity_id=endpoint.id, + entity_name=endpoint.name, + details={"event_types": payload.event_types, "enabled": endpoint.enabled}, + auth_context=_auth, + request=request, + commit=True, + ) body = endpoint_to_dict(endpoint) body["secret"] = raw_secret return body @@ -138,10 +154,12 @@ async def create_webhook( async def update_webhook( endpoint_id: int, payload: WebhookEndpointUpdate, + request: Request, db: Session = Depends(get_db), _auth: dict = Depends(require_admin_auth), ) -> Dict[str, Any]: """Update an outbound webhook endpoint.""" + workspace = assign_default_workspace_to_unscoped_rows(db) endpoint = db.query(WebhookEndpoint).filter(WebhookEndpoint.id == endpoint_id).first() if endpoint is None: raise HTTPException( @@ -155,6 +173,18 @@ async def update_webhook( ) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + record_workspace_audit_log( + db, + workspace=workspace, + action="webhook.updated", + entity_type="webhook_endpoint", + entity_id=endpoint.id, + entity_name=endpoint.name, + details={"changed_fields": changed_fields(payload.model_dump(exclude_unset=True))}, + auth_context=_auth, + request=request, + commit=True, + ) body = endpoint_to_dict(endpoint) body["secret"] = raw_secret return body @@ -163,10 +193,12 @@ async def update_webhook( @router.delete("/{endpoint_id}", response_model=WebhookEndpointResponse) async def disable_webhook( endpoint_id: int, + request: Request, db: Session = Depends(get_db), _auth: dict = Depends(require_admin_auth), ) -> Dict[str, Any]: """Disable a webhook endpoint without deleting delivery history.""" + workspace = assign_default_workspace_to_unscoped_rows(db) endpoint = db.query(WebhookEndpoint).filter(WebhookEndpoint.id == endpoint_id).first() if endpoint is None: raise HTTPException( @@ -175,6 +207,17 @@ async def disable_webhook( endpoint.enabled = False db.commit() db.refresh(endpoint) + record_workspace_audit_log( + db, + workspace=workspace, + action="webhook.disabled", + entity_type="webhook_endpoint", + entity_id=endpoint.id, + entity_name=endpoint.name, + auth_context=_auth, + request=request, + commit=True, + ) body = endpoint_to_dict(endpoint) body["secret"] = None return body @@ -205,15 +248,28 @@ async def list_webhook_deliveries( @router.post("/{endpoint_id}/test", response_model=WebhookTestResponse) async def test_webhook( endpoint_id: int, + request: Request, db: Session = Depends(get_db), _auth: dict = Depends(require_admin_auth), ) -> Dict[str, Any]: """Queue and immediately attempt a test delivery for an endpoint.""" + workspace = assign_default_workspace_to_unscoped_rows(db) try: delivery = queue_test_webhook(db, endpoint_id) except ValueError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc delivered = deliver_due_webhooks(db, endpoint_id=endpoint_id, limit=1) + record_workspace_audit_log( + db, + workspace=workspace, + action="webhook.tested", + entity_type="webhook_endpoint", + entity_id=endpoint_id, + details={"delivery_id": delivery.id}, + auth_context=_auth, + request=request, + commit=True, + ) return {"delivery": delivery_to_dict(delivered[0] if delivered else delivery)} diff --git a/backend/app/main.py b/backend/app/main.py index fa9e170..9a20e84 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,6 +21,7 @@ import app.models.setting # noqa: F401 – ensure Setting table is registered import app.models.user # noqa: F401 – ensure User table is registered import app.models.webhook # noqa: F401 – ensure webhook tables are registered import app.models.workspace # noqa: F401 – ensure workspace table is registered +import app.models.workspace_access # noqa: F401 – ensure RBAC/audit tables are registered from app.api.api_v1.api import api_router from app.core.config import get_settings from app.core.database import Base, SessionLocal, engine diff --git a/backend/app/models/workspace_access.py b/backend/app/models/workspace_access.py new file mode 100644 index 0000000..2a25f82 --- /dev/null +++ b/backend/app/models/workspace_access.py @@ -0,0 +1,60 @@ +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.core.database import Base + + +class WorkspaceMembership(Base): + """User role assignment inside one workspace.""" + + __tablename__ = "workspace_memberships" + + id = Column(Integer, primary_key=True, index=True) + workspace_id = Column(Integer, ForeignKey("workspaces.id"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + role = Column(String(50), nullable=False, index=True) + active = Column(Boolean, default=True, nullable=False, index=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + workspace = relationship("Workspace") + user = relationship("User") + + __table_args__ = ( + Index("ix_workspace_memberships_workspace_user", "workspace_id", "user_id", unique=True), + Index("ix_workspace_memberships_workspace_role", "workspace_id", "role"), + ) + + def __repr__(self): + return f"" + + +class WorkspaceAuditLog(Base): + """Sanitized audit trail for sensitive workspace-scoped changes.""" + + __tablename__ = "workspace_audit_logs" + + id = Column(Integer, primary_key=True, index=True) + workspace_id = Column(Integer, ForeignKey("workspaces.id"), nullable=False, index=True) + actor_type = Column(String(50), nullable=False, index=True) + actor_id = Column(String(120), nullable=True, index=True) + action = Column(String(100), nullable=False, index=True) + entity_type = Column(String(80), nullable=False, index=True) + entity_id = Column(String(120), nullable=True, index=True) + entity_name = Column(String(255), nullable=True) + details = Column(Text, nullable=True) + ip_address = Column(String(64), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + + workspace = relationship("Workspace") + + __table_args__ = ( + Index("ix_workspace_audit_workspace_created", "workspace_id", "created_at"), + Index("ix_workspace_audit_workspace_action", "workspace_id", "action"), + Index("ix_workspace_audit_entity", "entity_type", "entity_id"), + ) + + def __repr__(self): + return f"" diff --git a/backend/app/services/workspace_access.py b/backend/app/services/workspace_access.py new file mode 100644 index 0000000..a8c91c2 --- /dev/null +++ b/backend/app/services/workspace_access.py @@ -0,0 +1,97 @@ +"""Workspace role and permission foundations.""" + +from __future__ import annotations + +from typing import Dict, List, Set + +from fastapi import HTTPException, status + +ROLE_WORKSPACE_OWNER = "workspace_owner" +ROLE_DOMAIN_ADMIN = "domain_admin" +ROLE_OPERATOR = "operator" +ROLE_ANALYST = "analyst" +ROLE_AUDITOR = "auditor" + +PERMISSION_WORKSPACE_ADMIN = "workspace:admin" +PERMISSION_DOMAINS_WRITE = "domains:write" +PERMISSION_MAIL_SOURCES_WRITE = "mail_sources:write" +PERMISSION_NOTIFICATIONS_WRITE = "notifications:write" +PERMISSION_INTEGRATIONS_WRITE = "integrations:write" +PERMISSION_AUDIT_READ = "audit:read" +PERMISSION_REPORTS_READ = "reports:read" + +ROLE_PERMISSIONS: Dict[str, Set[str]] = { + ROLE_WORKSPACE_OWNER: { + PERMISSION_WORKSPACE_ADMIN, + PERMISSION_DOMAINS_WRITE, + PERMISSION_MAIL_SOURCES_WRITE, + PERMISSION_NOTIFICATIONS_WRITE, + PERMISSION_INTEGRATIONS_WRITE, + PERMISSION_AUDIT_READ, + PERMISSION_REPORTS_READ, + }, + ROLE_DOMAIN_ADMIN: { + PERMISSION_DOMAINS_WRITE, + PERMISSION_MAIL_SOURCES_WRITE, + PERMISSION_NOTIFICATIONS_WRITE, + PERMISSION_AUDIT_READ, + PERMISSION_REPORTS_READ, + }, + ROLE_OPERATOR: { + PERMISSION_MAIL_SOURCES_WRITE, + PERMISSION_NOTIFICATIONS_WRITE, + PERMISSION_AUDIT_READ, + PERMISSION_REPORTS_READ, + }, + ROLE_ANALYST: { + PERMISSION_REPORTS_READ, + }, + ROLE_AUDITOR: { + PERMISSION_AUDIT_READ, + PERMISSION_REPORTS_READ, + }, +} + + +def permissions_for_role(role: str) -> Set[str]: + """Return normalized permissions for a workspace role.""" + return set(ROLE_PERMISSIONS.get((role or "").strip().lower(), set())) + + +def role_allows(role: str, permission: str) -> bool: + """Return True when a role grants a permission.""" + return permission in permissions_for_role(role) + + +def list_workspace_roles() -> List[dict]: + """Return API-safe role definitions for operator documentation and UI use.""" + return [ + { + "role": role, + "permissions": sorted(permissions), + } + for role, permissions in sorted(ROLE_PERMISSIONS.items()) + ] + + +def role_for_auth_context(auth_context: dict) -> str: + """Map current admin auth into an initial workspace role. + + Logto users and static admin credentials keep owner-level access until + membership assignment and enforcement screens are added. + """ + auth_type = (auth_context or {}).get("auth_type") + if auth_type in {"session", "bearer", "jwt", "api_key", "disabled"}: + return ROLE_WORKSPACE_OWNER + return ROLE_AUDITOR + + +def require_workspace_permission(auth_context: dict, permission: str) -> None: + """Raise HTTP 403 when the current role does not grant a permission.""" + role = role_for_auth_context(auth_context) + if role_allows(role, permission): + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Workspace permission required: {permission}", + ) diff --git a/backend/app/services/workspace_audit.py b/backend/app/services/workspace_audit.py new file mode 100644 index 0000000..424374f --- /dev/null +++ b/backend/app/services/workspace_audit.py @@ -0,0 +1,162 @@ +"""Sanitized workspace audit logging helpers.""" + +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any, Dict, Iterable, List, Optional + +from fastapi import Request +from sqlalchemy.orm import Session + +from app.models.workspace import Workspace +from app.models.workspace_access import WorkspaceAuditLog + +SECRET_FIELD_MARKERS = ( + "password", + "secret", + "token", + "credential", + "authorization", + "api_key", + "access_token", + "refresh_token", + "client_secret", + "webhook_secret", +) + + +def _is_secret_key(key: str) -> bool: + normalized = key.lower() + return any(marker in normalized for marker in SECRET_FIELD_MARKERS) + + +def sanitize_audit_details(value: Any) -> Any: + """Return a JSON-serializable value with secret-like fields redacted.""" + if isinstance(value, dict): + return { + str(key): "[redacted]" if _is_secret_key(str(key)) else sanitize_audit_details(item) + for key, item in value.items() + } + if isinstance(value, (list, tuple, set)): + return [sanitize_audit_details(item) for item in value] + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return str(value) + + +def actor_from_auth(auth_context: Optional[Dict[str, Any]]) -> Dict[str, Optional[str]]: + """Normalize an auth context into audit actor fields.""" + auth_context = auth_context or {} + auth_type = str(auth_context.get("auth_type") or "unknown") + actor_id: Optional[str] = None + if auth_context.get("user_id") is not None: + actor_id = str(auth_context["user_id"]) + elif auth_context.get("payload", {}).get("sub"): + actor_id = str(auth_context["payload"]["sub"]) + elif auth_context.get("token_id") is not None: + actor_id = str(auth_context["token_id"]) + elif auth_type: + actor_id = auth_type + return {"actor_type": auth_type, "actor_id": actor_id} + + +def client_ip_from_request(request: Optional[Request]) -> Optional[str]: + """Return the client IP address from a FastAPI request, if available.""" + if request is None: + return None + forwarded_for = (request.headers.get("x-forwarded-for") or "").split(",", maxsplit=1)[0] + if forwarded_for.strip(): + return forwarded_for.strip() + real_ip = request.headers.get("x-real-ip") + if real_ip and real_ip.strip(): + return real_ip.strip() + if request.client is None: + return None + return request.client.host + + +def record_workspace_audit_log( + db: Session, + *, + workspace: Workspace, + action: str, + entity_type: str, + auth_context: Optional[Dict[str, Any]] = None, + entity_id: Optional[Any] = None, + entity_name: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + request: Optional[Request] = None, + created_at: Optional[datetime] = None, + commit: bool = False, +) -> WorkspaceAuditLog: + """Persist one sanitized workspace audit event.""" + actor = actor_from_auth(auth_context) + safe_details = sanitize_audit_details(details or {}) + row = WorkspaceAuditLog( + workspace_id=workspace.id, + actor_type=actor["actor_type"] or "unknown", + actor_id=actor["actor_id"], + action=action, + entity_type=entity_type, + entity_id=str(entity_id) if entity_id is not None else None, + entity_name=entity_name, + details=json.dumps(safe_details, sort_keys=True), + ip_address=client_ip_from_request(request), + created_at=created_at or datetime.utcnow(), + ) + db.add(row) + if commit: + db.commit() + db.refresh(row) + else: + db.flush() + return row + + +def audit_log_to_dict(row: WorkspaceAuditLog) -> Dict[str, Any]: + """Return an API-safe audit row.""" + try: + details = json.loads(row.details or "{}") + except (json.JSONDecodeError, TypeError): + details = {} + return { + "id": row.id, + "workspace_id": row.workspace_id, + "actor_type": row.actor_type, + "actor_id": row.actor_id, + "action": row.action, + "entity_type": row.entity_type, + "entity_id": row.entity_id, + "entity_name": row.entity_name, + "details": details, + "ip_address": row.ip_address, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + +def list_workspace_audit_logs( + db: Session, + *, + workspace: Workspace, + limit: int = 50, + action: Optional[str] = None, + entity_type: Optional[str] = None, +) -> List[Dict[str, Any]]: + """Return recent audit logs for a workspace.""" + query = db.query(WorkspaceAuditLog).filter(WorkspaceAuditLog.workspace_id == workspace.id) + if action: + query = query.filter(WorkspaceAuditLog.action == action) + if entity_type: + query = query.filter(WorkspaceAuditLog.entity_type == entity_type) + rows = ( + query.order_by(WorkspaceAuditLog.created_at.desc(), WorkspaceAuditLog.id.desc()) + .limit(max(1, min(limit, 200))) + .all() + ) + return [audit_log_to_dict(row) for row in rows] + + +def changed_fields(fields: Iterable[str]) -> List[str]: + """Return sorted field names safe for audit details.""" + return sorted({str(field) for field in fields}) diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 1f23347..7ffb27d 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -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 diff --git a/backend/app/tests/test_dns_endpoints.py b/backend/app/tests/test_dns_endpoints.py index 630d11d..d56bd62 100644 --- a/backend/app/tests/test_dns_endpoints.py +++ b/backend/app/tests/test_dns_endpoints.py @@ -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 diff --git a/backend/app/tests/test_workspace_audit.py b/backend/app/tests/test_workspace_audit.py new file mode 100644 index 0000000..5507464 --- /dev/null +++ b/backend/app/tests/test_workspace_audit.py @@ -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("