feat: 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.user # noqa: E402, F401
|
||||||
import app.models.webhook # noqa: E402, F401
|
import app.models.webhook # noqa: E402, F401
|
||||||
import app.models.workspace # 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
|
# Import all models so that autogenerate can detect them
|
||||||
from app.core.database import Base # noqa: E402
|
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 (
|
from app.api.api_v1.endpoints import (
|
||||||
api_tokens,
|
api_tokens,
|
||||||
|
audit,
|
||||||
auth,
|
auth,
|
||||||
domains,
|
domains,
|
||||||
forensics,
|
forensics,
|
||||||
@@ -24,6 +25,7 @@ api_router = APIRouter()
|
|||||||
# Include all endpoint routers
|
# Include all endpoint routers
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
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(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(health.router, tags=["health"])
|
||||||
api_router.include_router(public.router, prefix="/public", tags=["public-api"])
|
api_router.include_router(public.router, prefix="/public", tags=["public-api"])
|
||||||
api_router.include_router(domains.router, prefix="/domains", tags=["domains"])
|
api_router.include_router(domains.router, prefix="/domains", tags=["domains"])
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from typing import List, Optional
|
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 pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -15,6 +15,8 @@ from app.services.api_tokens import (
|
|||||||
revoke_api_token,
|
revoke_api_token,
|
||||||
token_to_dict,
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -71,10 +73,12 @@ async def list_api_tokens(
|
|||||||
@router.post("", response_model=APITokenCreateResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=APITokenCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_public_api_token(
|
async def create_public_api_token(
|
||||||
payload: APITokenCreateRequest,
|
payload: APITokenCreateRequest,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
):
|
):
|
||||||
"""Create a scoped API token for read-only automation."""
|
"""Create a scoped API token for read-only automation."""
|
||||||
|
workspace = assign_default_workspace_to_unscoped_rows(db)
|
||||||
try:
|
try:
|
||||||
created = create_api_token(db, name=payload.name, scopes=payload.scopes)
|
created = create_api_token(db, name=payload.name, scopes=payload.scopes)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -82,6 +86,21 @@ async def create_public_api_token(
|
|||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
) from 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(
|
return APITokenCreateResponse(
|
||||||
token=created.secret,
|
token=created.secret,
|
||||||
metadata=APITokenResponse(**token_to_dict(created.token)),
|
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)
|
@router.delete("/{token_id}", status_code=status.HTTP_200_OK)
|
||||||
async def revoke_public_api_token(
|
async def revoke_public_api_token(
|
||||||
token_id: int,
|
token_id: int,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
):
|
):
|
||||||
"""Revoke a scoped API token."""
|
"""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):
|
if not revoke_api_token(db, token_id):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="API token 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}
|
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 datetime import date, datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
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 fastapi.responses import Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -36,6 +36,7 @@ from app.services.report_persistence import (
|
|||||||
hydrate_report_store_from_db,
|
hydrate_report_store_from_db,
|
||||||
)
|
)
|
||||||
from app.services.report_store import ReportStore
|
from app.services.report_store import ReportStore
|
||||||
|
from app.services.workspace_audit import record_workspace_audit_log
|
||||||
from app.services.workspaces import (
|
from app.services.workspaces import (
|
||||||
assign_default_workspace_to_unscoped_rows,
|
assign_default_workspace_to_unscoped_rows,
|
||||||
workspace_domain_query,
|
workspace_domain_query,
|
||||||
@@ -1930,8 +1931,10 @@ async def get_domain_selectors(
|
|||||||
@router.post("/{domain_id}/selectors", status_code=status.HTTP_201_CREATED)
|
@router.post("/{domain_id}/selectors", status_code=status.HTTP_201_CREATED)
|
||||||
async def add_domain_selector(
|
async def add_domain_selector(
|
||||||
selector_data: SelectorRequest,
|
selector_data: SelectorRequest,
|
||||||
|
request: Request,
|
||||||
domain_id: str = Path(..., title="The domain ID or name"),
|
domain_id: str = Path(..., title="The domain ID or name"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
_auth: dict = Depends(require_admin_auth),
|
||||||
):
|
):
|
||||||
"""Add a DKIM selector to the manual list for a domain.
|
"""Add a DKIM selector to the manual list for a domain.
|
||||||
|
|
||||||
@@ -1965,15 +1968,29 @@ async def add_domain_selector(
|
|||||||
existing.append(selector)
|
existing.append(selector)
|
||||||
domain_db.dkim_selectors = ",".join(existing)
|
domain_db.dkim_selectors = ",".join(existing)
|
||||||
db.commit()
|
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}
|
return {"selectors": existing}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{domain_id}/selectors/{selector}", status_code=status.HTTP_200_OK)
|
@router.delete("/{domain_id}/selectors/{selector}", status_code=status.HTTP_200_OK)
|
||||||
async def delete_domain_selector(
|
async def delete_domain_selector(
|
||||||
|
request: Request,
|
||||||
domain_id: str = Path(..., title="The domain ID or name"),
|
domain_id: str = Path(..., title="The domain ID or name"),
|
||||||
selector: str = Path(..., title="The DKIM selector to remove"),
|
selector: str = Path(..., title="The DKIM selector to remove"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
_auth: dict = Depends(require_admin_auth),
|
||||||
):
|
):
|
||||||
"""Remove a manually configured DKIM selector from a domain."""
|
"""Remove a manually configured DKIM selector from a domain."""
|
||||||
workspace = assign_default_workspace_to_unscoped_rows(db)
|
workspace = assign_default_workspace_to_unscoped_rows(db)
|
||||||
@@ -1994,6 +2011,18 @@ async def delete_domain_selector(
|
|||||||
existing.remove(selector)
|
existing.remove(selector)
|
||||||
domain_db.dkim_selectors = ",".join(existing)
|
domain_db.dkim_selectors = ",".join(existing)
|
||||||
db.commit()
|
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}
|
return {"selectors": existing}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ from app.services.gmail_client import GmailClient
|
|||||||
from app.services.imap_client import IMAPClient
|
from app.services.imap_client import IMAPClient
|
||||||
from app.services.import_history import record_import_attempt
|
from app.services.import_history import record_import_attempt
|
||||||
from app.services.microsoft_graph_client import MicrosoftGraphClient
|
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()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -331,8 +336,11 @@ def _connection_test_response(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_source_or_404(source_id: int, db: Session) -> MailSource:
|
def _get_source_or_404(source_id: int, db: Session, workspace=None) -> MailSource:
|
||||||
source = db.query(MailSource).filter(MailSource.id == source_id).first()
|
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:
|
if source is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
@@ -341,6 +349,30 @@ def _get_source_or_404(source_id: int, db: Session) -> MailSource:
|
|||||||
return source
|
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:
|
def _safe_attr(source: MailSource, name: str, default: Any = None) -> Any:
|
||||||
"""Read optional source attributes without letting test doubles invent fields."""
|
"""Read optional source attributes without letting test doubles invent fields."""
|
||||||
value = getattr(source, name, default)
|
value = getattr(source, name, default)
|
||||||
@@ -572,18 +604,22 @@ async def list_mail_sources(
|
|||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> List[MailSourceResponse]:
|
) -> List[MailSourceResponse]:
|
||||||
"""Return all configured mail sources (passwords redacted)."""
|
"""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]
|
return [_source_to_response(s) for s in sources]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=MailSourceResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=MailSourceResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_mail_source(
|
async def create_mail_source(
|
||||||
payload: MailSourceCreate,
|
payload: MailSourceCreate,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> MailSourceResponse:
|
) -> MailSourceResponse:
|
||||||
"""Create a new mail source."""
|
"""Create a new mail source."""
|
||||||
|
workspace = assign_default_workspace_to_unscoped_rows(db)
|
||||||
source = MailSource(
|
source = MailSource(
|
||||||
|
workspace_id=workspace.id,
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
method=payload.method.upper(),
|
method=payload.method.upper(),
|
||||||
server=payload.server,
|
server=payload.server,
|
||||||
@@ -605,6 +641,15 @@ async def create_mail_source(
|
|||||||
db.add(source)
|
db.add(source)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(source)
|
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(
|
logger.info(
|
||||||
"Created mail source id=%d name=%r method=%r", source.id, source.name, source.method
|
"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),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> MailSourceResponse:
|
) -> MailSourceResponse:
|
||||||
"""Return a single mail source by ID (password redacted)."""
|
"""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)
|
return _source_to_response(source)
|
||||||
|
|
||||||
|
|
||||||
@@ -630,7 +676,8 @@ async def list_mail_source_imports(
|
|||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> List[MailSourceImportResponse]:
|
) -> List[MailSourceImportResponse]:
|
||||||
"""Return recent sanitized import attempts for one mail source."""
|
"""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)
|
safe_limit = min(max(limit, 1), 100)
|
||||||
rows = (
|
rows = (
|
||||||
db.query(MailSourceImport)
|
db.query(MailSourceImport)
|
||||||
@@ -653,7 +700,8 @@ async def fetch_mail_source(
|
|||||||
if days < 1 or days > 365:
|
if days < 1 or days > 365:
|
||||||
raise HTTPException(status_code=400, detail="Days parameter must be between 1 and 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)
|
results = _fetch_source(source, db, days)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Manual fetch for source id=%d: processed=%d reports_found=%d "
|
"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(
|
async def update_mail_source(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
payload: MailSourceUpdate,
|
payload: MailSourceUpdate,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> MailSourceResponse:
|
) -> MailSourceResponse:
|
||||||
"""Update one or more fields of an existing mail source."""
|
"""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)
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
if "method" in update_data and update_data["method"]:
|
if "method" in update_data and update_data["method"]:
|
||||||
@@ -694,6 +744,15 @@ async def update_mail_source(
|
|||||||
source.updated_at = datetime.utcnow()
|
source.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(source)
|
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)
|
logger.info("Updated mail source id=%d", source.id)
|
||||||
return _source_to_response(source)
|
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)
|
@router.delete("/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_mail_source(
|
async def delete_mail_source(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a mail source permanently."""
|
"""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.delete(source)
|
||||||
db.commit()
|
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))
|
logger.info("Deleted mail source id=%s", _sanitize_for_log(source_id))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{source_id}/toggle", response_model=MailSourceResponse)
|
@router.post("/{source_id}/toggle", response_model=MailSourceResponse)
|
||||||
async def toggle_mail_source(
|
async def toggle_mail_source(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> MailSourceResponse:
|
) -> MailSourceResponse:
|
||||||
"""Toggle the *enabled* flag of a mail source."""
|
"""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.enabled = not source.enabled
|
||||||
source.updated_at = datetime.utcnow()
|
source.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(source)
|
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)
|
return _source_to_response(source)
|
||||||
|
|
||||||
|
|
||||||
@@ -733,7 +819,8 @@ async def test_stored_mail_source( # noqa: C901
|
|||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Test the connection for an already-stored mail source using its saved credentials."""
|
"""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 source.method == "GMAIL_API":
|
||||||
if not source.gmail_access_token:
|
if not source.gmail_access_token:
|
||||||
@@ -877,7 +964,8 @@ async def m365_authorize_url(
|
|||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Return a Microsoft identity platform authorization URL for M365_GRAPH."""
|
"""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":
|
if source.method != "M365_GRAPH":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -908,7 +996,8 @@ async def m365_list_folders(
|
|||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Return selectable Microsoft 365 mail folders for this source."""
|
"""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":
|
if source.method != "M365_GRAPH":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1051,11 +1140,13 @@ async def m365_oauth_callback(
|
|||||||
async def m365_oauth_callback_post(
|
async def m365_oauth_callback_post(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
payload: M365CallbackRequest,
|
payload: M365CallbackRequest,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> MailSourceResponse:
|
) -> MailSourceResponse:
|
||||||
"""Exchange a Microsoft OAuth2 authorization code for Graph tokens."""
|
"""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":
|
if source.method != "M365_GRAPH":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1102,6 +1193,15 @@ async def m365_oauth_callback_post(
|
|||||||
source.updated_at = datetime.utcnow()
|
source.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(source)
|
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(
|
logger.info(
|
||||||
"Microsoft 365 OAuth2 tokens saved for source id=%d (account=%s)",
|
"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),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Manually trigger a Microsoft 365 Graph DMARC report fetch."""
|
"""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":
|
if source.method != "M365_GRAPH":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1147,11 +1248,13 @@ async def m365_fetch_reports(
|
|||||||
@router.delete("/{source_id}/m365/connection", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{source_id}/m365/connection", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def m365_disconnect(
|
async def m365_disconnect(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Clear the stored Microsoft Graph OAuth2 tokens for this source."""
|
"""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":
|
if source.method != "M365_GRAPH":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1164,6 +1267,14 @@ async def m365_disconnect(
|
|||||||
source.m365_email = None
|
source.m365_email = None
|
||||||
source.updated_at = datetime.utcnow()
|
source.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
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))
|
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
|
grants access Google redirects back to
|
||||||
``<origin>/mail-sources/<id>/gmail/callback`` with a ``code`` parameter.
|
``<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":
|
if source.method != "GMAIL_API":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1311,6 +1423,7 @@ async def gmail_oauth_callback(
|
|||||||
async def gmail_oauth_callback_post(
|
async def gmail_oauth_callback_post(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
payload: GmailCallbackRequest,
|
payload: GmailCallbackRequest,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> MailSourceResponse:
|
) -> MailSourceResponse:
|
||||||
@@ -1321,7 +1434,8 @@ async def gmail_oauth_callback_post(
|
|||||||
themselves and post the code here as JSON. Requires the standard
|
themselves and post the code here as JSON. Requires the standard
|
||||||
admin authentication.
|
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":
|
if source.method != "GMAIL_API":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1366,6 +1480,15 @@ async def gmail_oauth_callback_post(
|
|||||||
source.updated_at = datetime.utcnow()
|
source.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(source)
|
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(
|
logger.info(
|
||||||
"Gmail OAuth2 tokens saved for source id=%d (account=%s)",
|
"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
|
Searches Gmail for emails matching the DMARC report heuristic, ingests
|
||||||
any attachments not yet seen, and returns a summary.
|
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":
|
if source.method != "GMAIL_API":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1458,11 +1582,13 @@ async def gmail_fetch_reports(
|
|||||||
@router.delete("/{source_id}/gmail/connection", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{source_id}/gmail/connection", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def gmail_disconnect(
|
async def gmail_disconnect(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Revoke / clear the stored Gmail OAuth2 tokens for this source."""
|
"""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":
|
if source.method != "GMAIL_API":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1475,4 +1601,12 @@ async def gmail_disconnect(
|
|||||||
source.gmail_email = None
|
source.gmail_email = None
|
||||||
source.updated_at = datetime.utcnow()
|
source.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
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))
|
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
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
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 pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
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.notifications import send_notification
|
||||||
from app.services.summary_notifications import build_summary, send_summary_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()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -342,6 +344,7 @@ def _audit_setting_change(
|
|||||||
old_plain: Optional[str],
|
old_plain: Optional[str],
|
||||||
new_plain: Optional[str],
|
new_plain: Optional[str],
|
||||||
auth_context: Optional[Dict[str, Any]],
|
auth_context: Optional[Dict[str, Any]],
|
||||||
|
request: Optional[Request] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not _should_audit_setting(key) or old_plain == new_plain:
|
if not _should_audit_setting(key) or old_plain == new_plain:
|
||||||
return
|
return
|
||||||
@@ -352,6 +355,22 @@ def _audit_setting_change(
|
|||||||
new_value=_audit_value_for_setting(key, new_plain),
|
new_value=_audit_value_for_setting(key, new_plain),
|
||||||
auth_context=auth_context,
|
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]:
|
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(
|
async def update_setting(
|
||||||
key: str,
|
key: str,
|
||||||
payload: SettingUpdate,
|
payload: SettingUpdate,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> SettingResponse:
|
) -> SettingResponse:
|
||||||
@@ -629,6 +649,7 @@ async def update_setting(
|
|||||||
old_plain=None,
|
old_plain=None,
|
||||||
new_plain=new_plain,
|
new_plain=new_plain,
|
||||||
auth_context=_auth,
|
auth_context=_auth,
|
||||||
|
request=request,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# For secret keys, only update if not the redacted placeholder
|
# For secret keys, only update if not the redacted placeholder
|
||||||
@@ -644,6 +665,7 @@ async def update_setting(
|
|||||||
old_plain=old_plain,
|
old_plain=old_plain,
|
||||||
new_plain=new_plain,
|
new_plain=new_plain,
|
||||||
auth_context=_auth,
|
auth_context=_auth,
|
||||||
|
request=request,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
@@ -653,6 +675,7 @@ async def update_setting(
|
|||||||
@router.post("/bulk", response_model=List[SettingResponse])
|
@router.post("/bulk", response_model=List[SettingResponse])
|
||||||
async def bulk_update_settings(
|
async def bulk_update_settings(
|
||||||
payload: BulkSettingsUpdate,
|
payload: BulkSettingsUpdate,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> List[SettingResponse]:
|
) -> List[SettingResponse]:
|
||||||
@@ -681,6 +704,7 @@ async def bulk_update_settings(
|
|||||||
old_plain=None,
|
old_plain=None,
|
||||||
new_plain=new_plain,
|
new_plain=new_plain,
|
||||||
auth_context=_auth,
|
auth_context=_auth,
|
||||||
|
request=request,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Skip secret placeholder updates
|
# Skip secret placeholder updates
|
||||||
@@ -696,6 +720,7 @@ async def bulk_update_settings(
|
|||||||
old_plain=old_plain,
|
old_plain=old_plain,
|
||||||
new_plain=new_plain,
|
new_plain=new_plain,
|
||||||
auth_context=_auth,
|
auth_context=_auth,
|
||||||
|
request=request,
|
||||||
)
|
)
|
||||||
results.append(_row_to_dict(row))
|
results.append(_row_to_dict(row))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
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 pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ from app.services.webhook_events import (
|
|||||||
queue_test_webhook,
|
queue_test_webhook,
|
||||||
update_webhook_endpoint,
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -121,14 +123,28 @@ async def list_webhook_endpoints(
|
|||||||
@router.post("", response_model=WebhookEndpointResponse)
|
@router.post("", response_model=WebhookEndpointResponse)
|
||||||
async def create_webhook(
|
async def create_webhook(
|
||||||
payload: WebhookEndpointCreate,
|
payload: WebhookEndpointCreate,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Create an outbound webhook endpoint."""
|
"""Create an outbound webhook endpoint."""
|
||||||
|
workspace = assign_default_workspace_to_unscoped_rows(db)
|
||||||
try:
|
try:
|
||||||
endpoint, raw_secret = create_webhook_endpoint(db, **payload.model_dump())
|
endpoint, raw_secret = create_webhook_endpoint(db, **payload.model_dump())
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from 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 = endpoint_to_dict(endpoint)
|
||||||
body["secret"] = raw_secret
|
body["secret"] = raw_secret
|
||||||
return body
|
return body
|
||||||
@@ -138,10 +154,12 @@ async def create_webhook(
|
|||||||
async def update_webhook(
|
async def update_webhook(
|
||||||
endpoint_id: int,
|
endpoint_id: int,
|
||||||
payload: WebhookEndpointUpdate,
|
payload: WebhookEndpointUpdate,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Update an outbound webhook endpoint."""
|
"""Update an outbound webhook endpoint."""
|
||||||
|
workspace = assign_default_workspace_to_unscoped_rows(db)
|
||||||
endpoint = db.query(WebhookEndpoint).filter(WebhookEndpoint.id == endpoint_id).first()
|
endpoint = db.query(WebhookEndpoint).filter(WebhookEndpoint.id == endpoint_id).first()
|
||||||
if endpoint is None:
|
if endpoint is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -155,6 +173,18 @@ async def update_webhook(
|
|||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from 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 = endpoint_to_dict(endpoint)
|
||||||
body["secret"] = raw_secret
|
body["secret"] = raw_secret
|
||||||
return body
|
return body
|
||||||
@@ -163,10 +193,12 @@ async def update_webhook(
|
|||||||
@router.delete("/{endpoint_id}", response_model=WebhookEndpointResponse)
|
@router.delete("/{endpoint_id}", response_model=WebhookEndpointResponse)
|
||||||
async def disable_webhook(
|
async def disable_webhook(
|
||||||
endpoint_id: int,
|
endpoint_id: int,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Disable a webhook endpoint without deleting delivery history."""
|
"""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()
|
endpoint = db.query(WebhookEndpoint).filter(WebhookEndpoint.id == endpoint_id).first()
|
||||||
if endpoint is None:
|
if endpoint is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -175,6 +207,17 @@ async def disable_webhook(
|
|||||||
endpoint.enabled = False
|
endpoint.enabled = False
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(endpoint)
|
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 = endpoint_to_dict(endpoint)
|
||||||
body["secret"] = None
|
body["secret"] = None
|
||||||
return body
|
return body
|
||||||
@@ -205,15 +248,28 @@ async def list_webhook_deliveries(
|
|||||||
@router.post("/{endpoint_id}/test", response_model=WebhookTestResponse)
|
@router.post("/{endpoint_id}/test", response_model=WebhookTestResponse)
|
||||||
async def test_webhook(
|
async def test_webhook(
|
||||||
endpoint_id: int,
|
endpoint_id: int,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Queue and immediately attempt a test delivery for an endpoint."""
|
"""Queue and immediately attempt a test delivery for an endpoint."""
|
||||||
|
workspace = assign_default_workspace_to_unscoped_rows(db)
|
||||||
try:
|
try:
|
||||||
delivery = queue_test_webhook(db, endpoint_id)
|
delivery = queue_test_webhook(db, endpoint_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from 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)
|
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)}
|
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.user # noqa: F401 – ensure User table is registered
|
||||||
import app.models.webhook # noqa: F401 – ensure webhook tables are 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 # 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.api.api_v1.api import api_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.database import Base, SessionLocal, engine
|
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.user # noqa: F401 # pylint: disable=unused-import
|
||||||
import app.models.webhook # 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 # 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.database import Base, get_db
|
||||||
from app.core.security import require_admin_auth
|
from app.core.security import require_admin_auth
|
||||||
from app.main import create_app
|
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."""
|
"""Adding a selector persists it and returns the updated list."""
|
||||||
response = client.post(
|
response = authed_client.post(
|
||||||
f"/api/v1/domains/{DOMAIN}/selectors",
|
f"/api/v1/domains/{DOMAIN}/selectors",
|
||||||
json={"selector": "mysel"},
|
json={"selector": "mysel"},
|
||||||
)
|
)
|
||||||
@@ -118,39 +118,39 @@ def test_add_selector(client: TestClient):
|
|||||||
assert "mysel" in data["selectors"]
|
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."""
|
"""Adding the same selector twice should not create duplicates."""
|
||||||
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "dup"})
|
authed_client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "dup"})
|
||||||
response = 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.status_code == 201
|
||||||
assert response.json()["selectors"].count("dup") == 1
|
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."""
|
"""An empty selector string should be rejected."""
|
||||||
response = client.post(
|
response = authed_client.post(
|
||||||
f"/api/v1/domains/{DOMAIN}/selectors",
|
f"/api/v1/domains/{DOMAIN}/selectors",
|
||||||
json={"selector": " "},
|
json={"selector": " "},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
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."""
|
"""Adding a selector to an unknown domain returns 404."""
|
||||||
response = client.post(
|
response = authed_client.post(
|
||||||
"/api/v1/domains/unknown.example.com/selectors",
|
"/api/v1/domains/unknown.example.com/selectors",
|
||||||
json={"selector": "google"},
|
json={"selector": "google"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 404
|
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."""
|
"""Multiple distinct selectors can be added and all are returned."""
|
||||||
for sel in ("sel1", "sel2", "sel3"):
|
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
|
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
|
assert response.status_code == 200
|
||||||
selectors = response.json()["selectors"]
|
selectors = response.json()["selectors"]
|
||||||
assert "sel1" in 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."""
|
"""Deleting a selector removes it from the persisted list."""
|
||||||
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "todelete"})
|
authed_client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "todelete"})
|
||||||
response = client.delete(f"/api/v1/domains/{DOMAIN}/selectors/todelete")
|
response = authed_client.delete(f"/api/v1/domains/{DOMAIN}/selectors/todelete")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "todelete" not in response.json()["selectors"]
|
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."""
|
"""Deleting a selector that was never added returns 404."""
|
||||||
# Ensure the domain exists in DB (via add then delete)
|
# Ensure the domain exists in DB (via add then delete)
|
||||||
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "dummy"})
|
authed_client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "dummy"})
|
||||||
response = client.delete(f"/api/v1/domains/{DOMAIN}/selectors/ghost")
|
response = authed_client.delete(f"/api/v1/domains/{DOMAIN}/selectors/ghost")
|
||||||
assert response.status_code == 404
|
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."""
|
"""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
|
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"]
|
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."""
|
"""A selector discovered from reports should appear only in 'selectors' once added manually."""
|
||||||
# Confirm it's in report_selectors before adding
|
# 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"]
|
assert "google" in r1.json()["report_selectors"]
|
||||||
|
|
||||||
# Add it as a manual selector
|
# 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()
|
data = r2.json()
|
||||||
assert "google" in data["selectors"]
|
assert "google" in data["selectors"]
|
||||||
# It must not appear in both lists
|
# 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
|
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."""
|
"""Manually added selectors should be forwarded to check_domain."""
|
||||||
# Add a custom selector
|
# 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 = []
|
captured_selectors = []
|
||||||
|
|
||||||
@@ -383,7 +383,7 @@ def test_dns_endpoint_uses_manual_selectors(client: TestClient):
|
|||||||
"app.api.api_v1.endpoints.domains.get_default_provider",
|
"app.api.api_v1.endpoints.domains.get_default_provider",
|
||||||
return_value=AsyncMock(check_domain=_fake_check_domain),
|
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
|
assert "customsel" in captured_selectors
|
||||||
|
|
||||||
@@ -752,9 +752,9 @@ def test_summary_dns_failure_defaults_false(client: TestClient):
|
|||||||
assert domain["dkim_status"] is False
|
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."""
|
"""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 = []
|
captured_selectors = []
|
||||||
|
|
||||||
async def _fake_check_domain(domain, selectors=None):
|
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",
|
"app.api.api_v1.endpoints.domains.get_default_provider",
|
||||||
return_value=AsyncMock(check_domain=_fake_check_domain),
|
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 response.status_code == 200
|
||||||
assert "manualsel" in captured_selectors
|
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:
|
- Workspace/tenant concept with clear domain ownership. Delivered in M15.1:
|
||||||
default workspace migration, ownership columns for domains/users/mail sources,
|
default workspace migration, ownership columns for domains/users/mail sources,
|
||||||
and scoped query helpers.
|
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).
|
- Templates for onboarding new workspaces (domains + mail sources + notifications).
|
||||||
- Cross-workspace operator views for MSP admins, without weakening tenant isolation.
|
- 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
|
Deactivates a token immediately. Revoked tokens can no longer access public API
|
||||||
endpoints.
|
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
|
### Domains
|
||||||
|
|
||||||
#### List Domains
|
#### List Domains
|
||||||
|
|||||||
@@ -28,6 +28,40 @@ workspace during migration.
|
|||||||
| created_at | TIMESTAMP | When the workspace was created |
|
| created_at | TIMESTAMP | When the workspace was created |
|
||||||
| updated_at | TIMESTAMP | When the workspace was last updated |
|
| 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
|
### Domains
|
||||||
|
|
||||||
The `domains` table stores information about the domains being monitored.
|
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
|
default. This prevents cross-tenant reads in new M15 surfaces and gives later
|
||||||
RBAC work a single ownership field to enforce.
|
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
|
## Migration Story
|
||||||
|
|
||||||
The migration creates the `workspaces` table, inserts the default workspace, and
|
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
|
for import paths that may be backfilled in stages; runtime helpers attach
|
||||||
legacy rows to the default workspace when needed.
|
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
|
The current implementation keeps domain names globally unique. That matches the
|
||||||
existing single-domain ownership model and avoids ambiguous ownership while MSP
|
existing single-domain ownership model and avoids ambiguous ownership while MSP
|
||||||
RBAC and onboarding controls are built out.
|
RBAC and onboarding controls are built out.
|
||||||
|
|||||||
Reference in New Issue
Block a user