Merge pull request #196 from christianlouis/codex/m15-rbac-audit-foundations

[codex] Add workspace RBAC audit foundations
This commit is contained in:
Christian Krakau-Louis
2026-05-23 19:42:50 +02:00
committed by GitHub
20 changed files with 1199 additions and 54 deletions
+1
View File
@@ -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
@@ -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")
+2
View File
@@ -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"])
+35 -1
View File
@@ -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}
+61
View File
@@ -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,
)
}
+30 -1
View File
@@ -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}
+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))
+26 -1
View File
@@ -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()
+57 -1
View File
@@ -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)}
+1
View File
@@ -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
+60
View File
@@ -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"<WorkspaceMembership workspace={self.workspace_id} user={self.user_id}>"
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"<WorkspaceAuditLog {self.action} {self.entity_type}>"
+97
View File
@@ -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}",
)
+162
View File
@@ -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})
+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"]
+4 -1
View File
@@ -254,7 +254,10 @@ Planned:
- Workspace/tenant concept with clear domain ownership. Delivered in M15.1:
default workspace migration, ownership columns for domains/users/mail sources,
and scoped query helpers.
- Workspace-scoped RBAC and audit logs.
- Workspace-scoped RBAC and audit logs. Delivered in M15.2: role and permission
definitions, workspace membership/audit tables, sanitized audit APIs, and
audit records for sensitive API-token, mail-source, notification, webhook,
and selector changes.
- Templates for onboarding new workspaces (domains + mail sources + notifications).
- Cross-workspace operator views for MSP admins, without weakening tenant isolation.
+27
View File
@@ -94,6 +94,33 @@ DELETE /api-tokens/{token_id}
Deactivates a token immediately. Revoked tokens can no longer access public API
endpoints.
### Workspace Audit
#### List Workspace Roles
```text
GET /audit/roles
```
Returns the supported workspace RBAC roles and their permission strings.
#### List Workspace Audit Logs
```text
GET /audit/logs
```
Returns recent sanitized audit events for the default workspace. Optional
filters:
| Query parameter | Purpose |
| --- | --- |
| `limit` | Number of rows to return, from 1 to 200 |
| `action` | Restrict to one action key |
| `entity_type` | Restrict to one entity category |
Audit details redact secret-like fields before they are stored.
### Domains
#### List Domains
+34
View File
@@ -28,6 +28,40 @@ workspace during migration.
| created_at | TIMESTAMP | When the workspace was created |
| updated_at | TIMESTAMP | When the workspace was last updated |
### Workspace_Memberships
The `workspace_memberships` table stores user role assignments for workspace
RBAC.
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER | Primary key |
| workspace_id | INTEGER | Foreign key to workspaces.id |
| user_id | INTEGER | Foreign key to users.id |
| role | VARCHAR(50) | Workspace role such as workspace_owner or analyst |
| active | BOOLEAN | Whether the membership can be used |
| created_at | TIMESTAMP | When the membership was created |
| updated_at | TIMESTAMP | When the membership was last updated |
### Workspace_Audit_Logs
The `workspace_audit_logs` table records sanitized sensitive actions per
workspace so operators can answer who changed what and when.
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER | Primary key |
| workspace_id | INTEGER | Foreign key to workspaces.id |
| actor_type | VARCHAR(50) | Authentication type, such as session or api_key |
| actor_id | VARCHAR(120) | User, token, or auth actor identifier |
| action | VARCHAR(100) | Stable action key, such as mail_source.updated |
| entity_type | VARCHAR(80) | Entity category affected |
| entity_id | VARCHAR(120) | Affected entity identifier |
| entity_name | VARCHAR(255) | Optional display name for the entity |
| details | TEXT | Sanitized JSON details with secret fields redacted |
| ip_address | VARCHAR(64) | Client IP when available |
| created_at | TIMESTAMP | When the action happened |
### Domains
The `domains` table stores information about the domains being monitored.
+37
View File
@@ -29,6 +29,40 @@ Domain, mail-source, and user query helpers scope reads to a workspace by
default. This prevents cross-tenant reads in new M15 surfaces and gives later
RBAC work a single ownership field to enforce.
## Roles And Permissions
DMARQ defines these workspace roles as the RBAC vocabulary for MSP mode:
| Role | Intended operator |
|------|-------------------|
| `workspace_owner` | Full workspace administrator |
| `domain_admin` | Domain and mail-source administrator |
| `operator` | Day-to-day operations and notification management |
| `analyst` | Reporting and posture reader |
| `auditor` | Audit and report reader |
The role catalog is available from `GET /api/v1/audit/roles`. Current admin
sessions and admin API keys map to `workspace_owner` until membership management
screens are added.
## Audit Logs
The `workspace_audit_logs` table stores sanitized records for sensitive
workspace actions. `GET /api/v1/audit/logs` returns recent audit events for the
default workspace and can filter by `action` or `entity_type`.
Current audit coverage includes:
- API token creation and revocation
- mail-source creation, update, deletion, enable/disable toggles, and OAuth
connect/disconnect actions
- notification and forensic setting changes
- webhook creation, update, disable, and test actions
- manual DKIM selector add/remove actions
Audit details redact secret-like fields such as passwords, OAuth tokens, API
keys, and webhook signing secrets.
## Migration Story
The migration creates the `workspaces` table, inserts the default workspace, and
@@ -37,6 +71,9 @@ mail-source tables. Nullable columns keep upgrades safe for older databases and
for import paths that may be backfilled in stages; runtime helpers attach
legacy rows to the default workspace when needed.
The RBAC/audit migration adds `workspace_memberships` for role assignments and
`workspace_audit_logs` for workspace-scoped change history.
The current implementation keeps domain names globally unique. That matches the
existing single-domain ownership model and avoids ambiguous ownership while MSP
RBAC and onboarding controls are built out.