Merge pull request #196 from christianlouis/codex/m15-rbac-audit-foundations
[codex] Add workspace RBAC audit foundations
This commit is contained in:
@@ -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,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"])
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user