Wire DocumentNumberSequence allocator, ledger-based subsidiary/detail reports, and posted-voucher reopen flow without mock ops forms. Co-authored-by: Cursor <cursoragent@cursor.com>
446 lines
13 KiB
Python
446 lines
13 KiB
Python
"""Phase 5.11 — Compliance, Audit & Governance API."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_db, get_pagination, require_tenant
|
|
from app.core.security import get_current_user
|
|
from app.models.compliance import ApprovalRequest, ApprovalWorkflow, CompliancePolicy, ControlDefinition, GovernanceRule, PolicyViolation
|
|
from app.models.types import PolicyViolationSeverity, RiskLevel
|
|
from app.repositories.base import TenantBaseRepository
|
|
from app.services.compliance_service import AuditFramework, ComplianceEngine, GovernanceService
|
|
from shared.security import CurrentUser
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class AuditRecordCreate(BaseModel):
|
|
action: str
|
|
resource_type: str
|
|
resource_id: str
|
|
source_module: str | None = None
|
|
before_value: str | None = None
|
|
after_value: str | None = None
|
|
reason: str | None = None
|
|
|
|
|
|
class CompliancePolicyCreate(BaseModel):
|
|
code: str
|
|
name: str
|
|
policy_type: str
|
|
rules_config: str
|
|
country_code: str | None = None
|
|
|
|
|
|
class ApprovalRequestCreate(BaseModel):
|
|
workflow_id: UUID
|
|
resource_type: str
|
|
resource_id: str
|
|
|
|
|
|
class RejectRequest(BaseModel):
|
|
reason: str
|
|
|
|
|
|
class RiskCreate(BaseModel):
|
|
code: str
|
|
title: str
|
|
risk_category: str
|
|
risk_level: RiskLevel
|
|
risk_score: int = 0
|
|
description: str | None = None
|
|
|
|
|
|
class CompliancePolicyRepo(TenantBaseRepository[CompliancePolicy]):
|
|
model = CompliancePolicy
|
|
|
|
|
|
@router.get("/audit-records")
|
|
async def list_audit_records(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
pagination=Depends(get_pagination),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
from app.services.compliance_service import AuditRecordRepo
|
|
|
|
repo = AuditRecordRepo(db)
|
|
records = await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
|
return [
|
|
{
|
|
"id": str(r.id),
|
|
"action": r.action,
|
|
"resource_type": r.resource_type,
|
|
"actor_user_id": r.actor_user_id,
|
|
"created_at": r.created_at.isoformat(),
|
|
}
|
|
for r in records
|
|
]
|
|
|
|
|
|
@router.post("/audit-records", status_code=status.HTTP_201_CREATED)
|
|
async def create_audit_record(
|
|
body: AuditRecordCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
audit = AuditFramework(db)
|
|
record = await audit.record(
|
|
tenant_id,
|
|
actor_user_id=user.user_id,
|
|
action=body.action,
|
|
resource_type=body.resource_type,
|
|
resource_id=body.resource_id,
|
|
source_module=body.source_module,
|
|
before_value=body.before_value,
|
|
after_value=body.after_value,
|
|
reason=body.reason,
|
|
)
|
|
await db.commit()
|
|
return {"id": str(record.id)}
|
|
|
|
|
|
@router.get("/policies")
|
|
async def list_compliance_policies(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
rows = await CompliancePolicyRepo(db).list_by_tenant(tenant_id, limit=200)
|
|
return [
|
|
{
|
|
"id": str(p.id),
|
|
"code": p.code,
|
|
"name": p.name,
|
|
"policy_type": p.policy_type,
|
|
"is_active": getattr(p, "is_active", True),
|
|
}
|
|
for p in rows
|
|
]
|
|
|
|
|
|
@router.post("/policies", status_code=status.HTTP_201_CREATED)
|
|
async def create_compliance_policy(
|
|
body: CompliancePolicyCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
repo = CompliancePolicyRepo(db)
|
|
entity = CompliancePolicy(tenant_id=tenant_id, **body.model_dump())
|
|
await repo.add(entity)
|
|
await db.commit()
|
|
return {"id": str(entity.id)}
|
|
|
|
|
|
@router.post("/approvals")
|
|
async def request_approval(
|
|
body: ApprovalRequestCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
gov = GovernanceService(db)
|
|
request = await gov.request_approval(
|
|
tenant_id,
|
|
workflow_id=body.workflow_id,
|
|
resource_type=body.resource_type,
|
|
resource_id=body.resource_id,
|
|
requested_by=user.user_id,
|
|
)
|
|
await db.commit()
|
|
return {"id": str(request.id), "status": request.status.value}
|
|
|
|
|
|
@router.post("/approvals/{request_id}/approve")
|
|
async def approve_request(
|
|
request_id: UUID,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
gov = GovernanceService(db)
|
|
request = await gov.approve(tenant_id, request_id, approver_user_id=user.user_id)
|
|
await db.commit()
|
|
return {"id": str(request.id), "status": request.status.value}
|
|
|
|
|
|
@router.post("/approvals/{request_id}/reject")
|
|
async def reject_request(
|
|
request_id: UUID,
|
|
body: RejectRequest,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
gov = GovernanceService(db)
|
|
request = await gov.reject(tenant_id, request_id, approver_user_id=user.user_id, reason=body.reason)
|
|
await db.commit()
|
|
return {"id": str(request.id), "status": request.status.value}
|
|
|
|
|
|
@router.get("/risks")
|
|
async def list_risks(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
from app.models.compliance import RiskRecord
|
|
from sqlalchemy import select
|
|
|
|
stmt = (
|
|
select(RiskRecord)
|
|
.where(RiskRecord.tenant_id == tenant_id)
|
|
.order_by(RiskRecord.created_at.desc())
|
|
.limit(200)
|
|
)
|
|
rows = list((await db.execute(stmt)).scalars().all())
|
|
return [
|
|
{
|
|
"id": str(r.id),
|
|
"code": r.code,
|
|
"title": r.title,
|
|
"risk_category": r.risk_category,
|
|
"risk_level": r.risk_level.value,
|
|
"risk_score": r.risk_score,
|
|
"is_resolved": r.is_resolved,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.post("/risks", status_code=status.HTTP_201_CREATED)
|
|
async def create_risk(
|
|
body: RiskCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
gov = GovernanceService(db)
|
|
risk = await gov.create_risk(tenant_id, **body.model_dump())
|
|
await db.commit()
|
|
return {"id": str(risk.id), "risk_level": risk.risk_level.value}
|
|
|
|
|
|
class WorkflowCreate(BaseModel):
|
|
name: str
|
|
resource_type: str
|
|
min_amount: str | None = None
|
|
max_amount: str | None = None
|
|
|
|
|
|
class GovernanceRuleCreate(BaseModel):
|
|
code: str
|
|
name: str
|
|
rule_type: str = "sod"
|
|
condition_config: str
|
|
action_config: str | None = None
|
|
priority: int = 0
|
|
|
|
|
|
class ControlCreate(BaseModel):
|
|
code: str
|
|
name: str
|
|
control_type: str
|
|
description: str | None = None
|
|
validation_config: str | None = None
|
|
|
|
|
|
class ViolationCreate(BaseModel):
|
|
policy_id: UUID
|
|
resource_type: str
|
|
resource_id: str
|
|
severity: PolicyViolationSeverity
|
|
description: str
|
|
|
|
|
|
class WorkflowRepo(TenantBaseRepository[ApprovalWorkflow]):
|
|
model = ApprovalWorkflow
|
|
|
|
|
|
class ApprovalRequestRepo(TenantBaseRepository[ApprovalRequest]):
|
|
model = ApprovalRequest
|
|
|
|
|
|
class GovernanceRuleRepo(TenantBaseRepository[GovernanceRule]):
|
|
model = GovernanceRule
|
|
|
|
|
|
class ControlRepo(TenantBaseRepository[ControlDefinition]):
|
|
model = ControlDefinition
|
|
|
|
|
|
class ViolationRepo(TenantBaseRepository[PolicyViolation]):
|
|
model = PolicyViolation
|
|
|
|
|
|
@router.get("/workflows")
|
|
async def list_workflows(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
rows = await WorkflowRepo(db).list_by_tenant(tenant_id, limit=100)
|
|
return [
|
|
{
|
|
"id": str(r.id),
|
|
"name": r.name,
|
|
"resource_type": r.resource_type,
|
|
"min_amount": r.min_amount,
|
|
"max_amount": r.max_amount,
|
|
"is_active": r.is_active,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.post("/workflows", status_code=status.HTTP_201_CREATED)
|
|
async def create_workflow(
|
|
body: WorkflowCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
entity = ApprovalWorkflow(tenant_id=tenant_id, **body.model_dump())
|
|
await WorkflowRepo(db).add(entity)
|
|
await db.commit()
|
|
return {"id": str(entity.id)}
|
|
|
|
|
|
@router.get("/approvals")
|
|
async def list_approvals(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
rows = await ApprovalRequestRepo(db).list_by_tenant(tenant_id, limit=200)
|
|
return [
|
|
{
|
|
"id": str(r.id),
|
|
"workflow_id": str(r.workflow_id),
|
|
"resource_type": r.resource_type,
|
|
"resource_id": r.resource_id,
|
|
"requested_by": r.requested_by,
|
|
"status": r.status.value,
|
|
"current_step": r.current_step,
|
|
"rejection_reason": r.rejection_reason,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.get("/governance-rules")
|
|
async def list_governance_rules(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
rows = await GovernanceRuleRepo(db).list_by_tenant(tenant_id, limit=200)
|
|
return [
|
|
{
|
|
"id": str(r.id),
|
|
"code": r.code,
|
|
"name": r.name,
|
|
"rule_type": r.rule_type,
|
|
"condition_config": r.condition_config,
|
|
"priority": r.priority,
|
|
"is_active": r.is_active,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.post("/governance-rules", status_code=status.HTTP_201_CREATED)
|
|
async def create_governance_rule(
|
|
body: GovernanceRuleCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
entity = GovernanceRule(tenant_id=tenant_id, **body.model_dump())
|
|
await GovernanceRuleRepo(db).add(entity)
|
|
await db.commit()
|
|
return {"id": str(entity.id)}
|
|
|
|
|
|
@router.get("/controls")
|
|
async def list_controls(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
rows = await ControlRepo(db).list_by_tenant(tenant_id, limit=200)
|
|
return [
|
|
{
|
|
"id": str(r.id),
|
|
"code": r.code,
|
|
"name": r.name,
|
|
"control_type": r.control_type,
|
|
"description": r.description,
|
|
"is_active": r.is_active,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.post("/controls", status_code=status.HTTP_201_CREATED)
|
|
async def create_control(
|
|
body: ControlCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
entity = ControlDefinition(tenant_id=tenant_id, **body.model_dump())
|
|
await ControlRepo(db).add(entity)
|
|
await db.commit()
|
|
return {"id": str(entity.id)}
|
|
|
|
|
|
@router.get("/violations")
|
|
async def list_violations(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
rows = await ViolationRepo(db).list_by_tenant(tenant_id, limit=200)
|
|
return [
|
|
{
|
|
"id": str(r.id),
|
|
"policy_id": str(r.policy_id),
|
|
"resource_type": r.resource_type,
|
|
"resource_id": r.resource_id,
|
|
"severity": r.severity.value,
|
|
"description": r.description,
|
|
"detected_at": r.detected_at.isoformat(),
|
|
"is_resolved": r.is_resolved,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.post("/violations", status_code=status.HTTP_201_CREATED)
|
|
async def create_violation(
|
|
body: ViolationCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
entity = PolicyViolation(
|
|
tenant_id=tenant_id,
|
|
policy_id=body.policy_id,
|
|
resource_type=body.resource_type,
|
|
resource_id=body.resource_id,
|
|
severity=body.severity,
|
|
description=body.description,
|
|
detected_at=datetime.now(timezone.utc),
|
|
)
|
|
await ViolationRepo(db).add(entity)
|
|
await db.commit()
|
|
return {"id": str(entity.id)}
|