initial commit after split

This commit is contained in:
2026-06-24 01:43:10 +02:00
parent b1d6c0150f
commit 30c11a6dcf
173 changed files with 25380 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
"""Reusable GovOPlaN platform core components."""
__all__ = ["access", "core"]

View File

@@ -0,0 +1,2 @@
"""Tenant, identity, auth, RBAC, and datastore access platform module."""

View File

@@ -0,0 +1,2 @@
"""Authentication and principal helpers for platform access."""

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from govoplan_core.access.permissions.evaluator import scopes_grant
AuthMethod = Literal["session", "api_key", "service_account"]
@dataclass(frozen=True, slots=True)
class Principal:
account_id: str
membership_id: str | None
tenant_id: str | None
scopes: frozenset[str]
group_ids: frozenset[str]
auth_method: AuthMethod
api_key_id: str | None = None
session_id: str | None = None
service_account_id: str | None = None
email: str | None = None
display_name: str | None = None
def has(self, required: str) -> bool:
return scopes_grant(self.scopes, required)

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from govoplan_core.access.db.models import Account, Group, GroupMembership, Membership, Role, RoleBinding
from govoplan_core.access.permissions.evaluator import expand_scopes
from govoplan_core.core.modules import PermissionDefinition, SubjectType
def membership_group_ids(session: Session, *, tenant_id: str, membership_id: str) -> frozenset[str]:
rows = (
session.query(Group.id)
.join(GroupMembership, GroupMembership.group_id == Group.id)
.filter(
GroupMembership.tenant_id == tenant_id,
GroupMembership.membership_id == membership_id,
Group.is_active.is_(True),
)
.all()
)
return frozenset(row[0] for row in rows)
def roles_for_subject(
session: Session,
*,
subject_type: SubjectType,
subject_id: str,
tenant_id: str | None,
) -> list[Role]:
query = (
session.query(Role)
.join(RoleBinding, RoleBinding.role_id == Role.id)
.filter(
RoleBinding.subject_type == subject_type,
RoleBinding.subject_id == subject_id,
)
)
if tenant_id is None:
query = query.filter(RoleBinding.tenant_id.is_(None), Role.tenant_id.is_(None))
else:
query = query.filter(RoleBinding.tenant_id == tenant_id, Role.tenant_id == tenant_id)
return query.order_by(Role.name.asc()).all()
def membership_roles(session: Session, membership: Membership) -> list[Role]:
roles_by_id = {
role.id: role
for role in roles_for_subject(
session,
subject_type="membership",
subject_id=membership.id,
tenant_id=membership.tenant_id,
)
}
for group_id in membership_group_ids(session, tenant_id=membership.tenant_id, membership_id=membership.id):
for role in roles_for_subject(session, subject_type="group", subject_id=group_id, tenant_id=membership.tenant_id):
roles_by_id[role.id] = role
return list(roles_by_id.values())
def account_system_roles(session: Session, account: Account) -> list[Role]:
return roles_for_subject(session, subject_type="account", subject_id=account.id, tenant_id=None)
def principal_scopes(
session: Session,
*,
account: Account,
membership: Membership | None,
include_system: bool,
catalog: dict[str, PermissionDefinition],
) -> list[str]:
scopes: set[str] = set()
if membership is not None:
for role in membership_roles(session, membership):
scopes.update(role.permissions or [])
if include_system:
for role in account_system_roles(session, account):
scopes.update(role.permissions or [])
return expand_scopes(scopes, catalog=catalog)

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
DEFAULT_RANDOM_BYTES = 32
def generate_secret(prefix: str, *, random_bytes: int = DEFAULT_RANDOM_BYTES) -> str:
return prefix + secrets.token_urlsafe(random_bytes)
def hash_secret(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
def verify_secret(secret: str, expected_hash: str) -> bool:
return hmac.compare_digest(hash_secret(secret), expected_hash)

View File

@@ -0,0 +1,2 @@
"""Access-platform SQLAlchemy metadata and models."""

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import JSON, MetaData
from sqlalchemy.orm import DeclarativeBase
from govoplan_core.db.base import NAMING_CONVENTION, TimestampMixin, utcnow
class AccessBase(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)
type_annotation_map = {
dict[str, Any]: JSON,
list[dict[str, Any]]: JSON,
list[str]: JSON,
}
__all__ = ["AccessBase", "NAMING_CONVENTION", "TimestampMixin", "utcnow"]

View File

@@ -0,0 +1,237 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.access.db.base import AccessBase, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class Account(AccessBase, TimestampMixin):
__tablename__ = "access_accounts"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
email: Mapped[str] = mapped_column(String(320), nullable=False)
normalized_email: Mapped[str] = mapped_column(String(320), nullable=False, unique=True, index=True)
display_name: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
auth_provider: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
password_hash: Mapped[str | None] = mapped_column(String(500))
password_reset_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
memberships: Mapped[list[Membership]] = relationship(back_populates="account", cascade="all, delete-orphan")
class Tenant(AccessBase, TimestampMixin):
__tablename__ = "access_tenants"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
memberships: Mapped[list[Membership]] = relationship(back_populates="tenant", cascade="all, delete-orphan")
class Membership(AccessBase, TimestampMixin):
__tablename__ = "access_memberships"
__table_args__ = (UniqueConstraint("tenant_id", "account_id", name="uq_access_memberships_tenant_account"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
email_snapshot: Mapped[str | None] = mapped_column(String(320), index=True)
display_name: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
account: Mapped[Account] = relationship(back_populates="memberships")
tenant: Mapped[Tenant] = relationship(back_populates="memberships")
class Group(AccessBase, TimestampMixin):
__tablename__ = "access_groups"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_access_groups_tenant_slug"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
template_id: Mapped[str | None] = mapped_column(String(100), index=True)
is_protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class GroupMembership(AccessBase, TimestampMixin):
__tablename__ = "access_group_memberships"
__table_args__ = (
UniqueConstraint("tenant_id", "membership_id", "group_id", name="uq_access_group_memberships_member_group"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
membership_id: Mapped[str] = mapped_column(ForeignKey("access_memberships.id", ondelete="CASCADE"), nullable=False, index=True)
group_id: Mapped[str] = mapped_column(ForeignKey("access_groups.id", ondelete="CASCADE"), nullable=False, index=True)
class Role(AccessBase, TimestampMixin):
__tablename__ = "access_roles"
__table_args__ = (
UniqueConstraint("tenant_id", "slug", name="uq_access_roles_tenant_slug"),
Index(
"uq_access_roles_system_slug",
"slug",
unique=True,
sqlite_where=text("tenant_id IS NULL"),
postgresql_where=text("tenant_id IS NULL"),
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=True, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
level: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_assignable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
template_id: Mapped[str | None] = mapped_column(String(100), index=True)
is_protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
class RoleBinding(AccessBase, TimestampMixin):
__tablename__ = "access_role_bindings"
__table_args__ = (
Index("ix_access_role_bindings_subject", "subject_type", "subject_id"),
Index("ix_access_role_bindings_tenant_subject", "tenant_id", "subject_type", "subject_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=True, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
subject_type: Mapped[str] = mapped_column(String(50), nullable=False)
subject_id: Mapped[str] = mapped_column(String(36), nullable=False)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), index=True)
created_by_membership_id: Mapped[str | None] = mapped_column(String(36), index=True)
class PermissionCatalogEntry(AccessBase, TimestampMixin):
__tablename__ = "access_permission_catalog"
scope: Mapped[str] = mapped_column(String(255), primary_key=True)
module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
resource: Mapped[str] = mapped_column(String(100), nullable=False)
action: Mapped[str] = mapped_column(String(100), nullable=False)
label: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
category: Mapped[str] = mapped_column(String(100), nullable=False)
level: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
is_deprecated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
class RoleTemplateModel(AccessBase, TimestampMixin):
__tablename__ = "access_role_templates"
__table_args__ = (UniqueConstraint("module_id", "level", "slug", name="uq_access_role_templates_module_level_slug"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
level: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False)
managed: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
class ModuleInstallation(AccessBase, TimestampMixin):
__tablename__ = "access_module_installations"
module_id: Mapped[str] = mapped_column(String(100), primary_key=True)
version: Mapped[str] = mapped_column(String(50), nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class AuthSession(AccessBase, TimestampMixin):
__tablename__ = "access_auth_sessions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=True, index=True)
membership_id: Mapped[str | None] = mapped_column(ForeignKey("access_memberships.id", ondelete="CASCADE"), nullable=True, index=True)
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
token_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
csrf_token_hash: Mapped[str | None] = mapped_column(String(255))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
user_agent: Mapped[str | None] = mapped_column(String(500))
ip_address: Mapped[str | None] = mapped_column(String(100))
class ApiKey(AccessBase, TimestampMixin):
__tablename__ = "access_api_keys"
__table_args__ = (Index("ix_access_api_keys_owner", "owner_subject_type", "owner_subject_id"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
owner_subject_type: Mapped[str] = mapped_column(String(50), nullable=False)
owner_subject_id: Mapped[str] = mapped_column(String(36), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
prefix: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
key_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
scopes: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
class TenantDatastore(AccessBase, TimestampMixin):
__tablename__ = "access_tenant_datastores"
__table_args__ = (UniqueConstraint("tenant_id", "module_id", name="uq_access_tenant_datastores_tenant_module"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
module_id: Mapped[str] = mapped_column(String(100), default="*", nullable=False)
mode: Mapped[str] = mapped_column(String(20), default="shared", nullable=False)
database_url_secret_ref: Mapped[str | None] = mapped_column(String(255))
schema_name: Mapped[str | None] = mapped_column(String(100))
storage_bucket: Mapped[str | None] = mapped_column(String(255))
region: Mapped[str | None] = mapped_column(String(100))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class SystemSettings(AccessBase, TimestampMixin):
__tablename__ = "access_system_settings"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global")
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
allow_tenant_custom_groups: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_tenant_custom_roles: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_tenant_api_keys: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class TenantSettings(AccessBase, TimestampMixin):
__tablename__ = "access_tenant_settings"
tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), primary_key=True)
allow_custom_groups: Mapped[bool | None] = mapped_column(Boolean)
allow_custom_roles: Mapped[bool | None] = mapped_column(Boolean)
allow_api_keys: Mapped[bool | None] = mapped_column(Boolean)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from collections.abc import Generator
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.session import create_database_engine
def create_access_engine(database_url: str) -> Engine:
return create_database_engine(database_url)
def create_access_session_factory(database_url: str) -> sessionmaker[Session]:
engine = create_access_engine(database_url)
return sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
def session_scope(session_factory: sessionmaker[Session]) -> Generator[Session, None, None]:
with session_factory() as session:
yield session

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
from govoplan_core.access.db.base import AccessBase
from govoplan_core.access.db import models as access_models # noqa: F401 - populate access metadata
from govoplan_core.core.modules import MigrationSpec, ModuleManifest, PermissionDefinition, RoleTemplate
def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category=category,
level=level, # type: ignore[arg-type]
module_id=module_id,
resource=resource,
action=action,
)
ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
_permission("access:tenant:read", "View tenants", "List and inspect tenant registry entries.", "Access", "system"),
_permission("access:tenant:create", "Create tenants", "Create tenant registry entries.", "Access", "system"),
_permission("access:tenant:update", "Update tenants", "Update tenant metadata and activation state.", "Access", "system"),
_permission("access:account:read", "View accounts", "List and inspect global login accounts.", "Access", "system"),
_permission("access:account:create", "Create accounts", "Create global login accounts.", "Access", "system"),
_permission("access:account:update", "Update accounts", "Update or suspend global login accounts.", "Access", "system"),
_permission("access:membership:read", "View memberships", "List tenant memberships and effective access.", "Tenant access", "tenant"),
_permission("access:membership:create", "Create memberships", "Create tenant-local account memberships.", "Tenant access", "tenant"),
_permission("access:membership:update", "Update memberships", "Update or suspend tenant memberships.", "Tenant access", "tenant"),
_permission("access:group:read", "View groups", "List tenant groups and members.", "Tenant access", "tenant"),
_permission("access:group:write", "Manage groups", "Create and update tenant groups.", "Tenant access", "tenant"),
_permission("access:group:manage_members", "Manage group members", "Add and remove memberships from groups.", "Tenant access", "tenant"),
_permission("access:role:read", "View roles", "Inspect tenant and system role definitions.", "Tenant access", "tenant"),
_permission("access:role:write", "Manage roles", "Create and update assignable roles.", "Tenant access", "tenant"),
_permission("access:role:assign", "Assign roles", "Bind roles to memberships, groups, accounts or services.", "Tenant access", "tenant"),
_permission("access:api_key:read", "View API keys", "List API keys without revealing secrets.", "Tenant access", "tenant"),
_permission("access:api_key:create", "Create API keys", "Create tenant API keys within delegation limits.", "Tenant access", "tenant"),
_permission("access:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "Tenant access", "tenant"),
_permission("access:setting:read", "View settings", "Read access and governance settings.", "Tenant access", "tenant"),
_permission("access:setting:write", "Manage settings", "Update access and governance settings.", "Tenant access", "tenant"),
_permission("access:governance:read", "View governance", "Inspect managed role and group templates.", "Access", "system"),
_permission("access:governance:write", "Manage governance", "Create and assign managed role and group templates.", "Access", "system"),
)
ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
RoleTemplate(
slug="system_owner",
name="System owner",
description="Protected full instance-wide administration.",
permissions=("system:*",),
level="system",
managed=True,
protected=True,
),
RoleTemplate(
slug="system_admin",
name="System administrator",
description="Manage tenants, accounts, settings, and governance without protected owner status.",
permissions=(
"access:tenant:read",
"access:tenant:create",
"access:tenant:update",
"access:account:read",
"access:account:create",
"access:account:update",
"access:governance:read",
"access:governance:write",
),
level="system",
managed=True,
protected=False,
),
RoleTemplate(
slug="tenant_owner",
name="Tenant owner",
description="Protected full tenant administration and module access.",
permissions=("tenant:*",),
level="tenant",
managed=True,
protected=True,
),
RoleTemplate(
slug="access_admin",
name="Access administrator",
description="Manage memberships, groups, roles, and API keys within delegation limits.",
permissions=(
"access:membership:read",
"access:membership:create",
"access:membership:update",
"access:group:read",
"access:group:write",
"access:group:manage_members",
"access:role:read",
"access:role:assign",
"access:api_key:read",
"access:api_key:create",
"access:api_key:revoke",
),
level="tenant",
managed=True,
protected=False,
),
)
manifest = ModuleManifest(
id="access",
name="Access",
version="1.0.0",
permissions=ACCESS_PERMISSIONS,
role_templates=ACCESS_ROLE_TEMPLATES,
migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata),
)
def get_manifest() -> ModuleManifest:
return manifest

View File

@@ -0,0 +1,2 @@
"""Permission definitions, evaluation, and registry helpers."""

View File

@@ -0,0 +1,6 @@
from __future__ import annotations
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
__all__ = ["PermissionDefinition", "PermissionLevel", "RoleTemplate"]

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from govoplan_core.core.modules import PermissionDefinition
def scope_grants(granted: str, required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
if granted == required or granted == "*":
return True
if granted == "tenant:*":
if catalog is None:
return not required.startswith("system:")
definition = catalog.get(required)
return definition is not None and definition.level == "tenant"
if granted == "system:*":
if catalog is None:
return required.startswith("system:")
definition = catalog.get(required)
return definition is not None and definition.level == "system"
if granted.endswith(":*"):
return required.startswith(granted[:-1])
return False
def scopes_grant(
scopes: Iterable[str],
required: str,
*,
catalog: Mapping[str, PermissionDefinition] | None = None,
) -> bool:
return any(scope_grants(scope, required, catalog=catalog) for scope in scopes)
def expand_scopes(
scopes: Iterable[str],
*,
catalog: Mapping[str, PermissionDefinition],
include_unknown: bool = False,
) -> list[str]:
expanded: set[str] = set()
for scope in {str(scope) for scope in scopes if scope}:
matched = {candidate for candidate in catalog if scope_grants(scope, candidate, catalog=catalog)}
if matched:
expanded.update(matched)
if scope.endswith(":*") or scope in {"*", "tenant:*", "system:*"}:
expanded.add(scope)
continue
if include_unknown:
expanded.add(scope)
return sorted(expanded)

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from collections.abc import Iterable
from govoplan_core.access.permissions.evaluator import expand_scopes, scopes_grant
from govoplan_core.core.modules import PermissionDefinition
class PermissionRegistry:
def __init__(self, permissions: Iterable[PermissionDefinition] = ()) -> None:
self._catalog: dict[str, PermissionDefinition] = {}
for permission in permissions:
self.register(permission)
@property
def catalog(self) -> dict[str, PermissionDefinition]:
return dict(self._catalog)
def register(self, permission: PermissionDefinition) -> None:
if permission.scope in self._catalog:
raise ValueError(f"Duplicate permission scope: {permission.scope}")
self._catalog[permission.scope] = permission
def has(self, scope: str) -> bool:
return scope in self._catalog
def grants(self, scopes: Iterable[str], required: str) -> bool:
return scopes_grant(scopes, required, catalog=self._catalog)
def expand(self, scopes: Iterable[str], *, include_unknown: bool = False) -> list[str]:
return expand_scopes(scopes, catalog=self._catalog, include_unknown=include_unknown)
def validate_known(self, scopes: Iterable[str]) -> list[str]:
unknown = sorted(scope for scope in scopes if scope not in self._catalog and not scope.endswith(":*") and scope not in {"*", "tenant:*", "system:*"})
if unknown:
raise ValueError("Unknown permission scope(s): " + ", ".join(unknown))
return sorted(set(scopes))

View File

@@ -0,0 +1,2 @@
"""Tenant datastore routing abstractions."""

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Literal, Protocol
from sqlalchemy.orm import Session, sessionmaker
DatastoreMode = Literal["shared", "schema", "database"]
@dataclass(frozen=True, slots=True)
class TenantDatastoreRef:
tenant_id: str
mode: DatastoreMode = "shared"
datastore_id: str | None = None
schema_name: str | None = None
database_url_secret_ref: str | None = None
class TenantDatastore(Protocol):
ref: TenantDatastoreRef
@contextmanager
def session_for(self, module_id: str) -> Generator[Session, None, None]:
...
class DatastoreResolver(Protocol):
def for_tenant(self, tenant_id: str) -> TenantDatastore:
...
class SharedTenantDatastore:
def __init__(self, ref: TenantDatastoreRef, session_factory: sessionmaker[Session]) -> None:
self.ref = ref
self._session_factory = session_factory
@contextmanager
def session_for(self, module_id: str) -> Generator[Session, None, None]:
del module_id
with self._session_factory() as session:
yield session
class SharedDatastoreResolver:
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
def for_tenant(self, tenant_id: str) -> TenantDatastore:
return SharedTenantDatastore(TenantDatastoreRef(tenant_id=tenant_id), self._session_factory)

View File

View File

@@ -0,0 +1,313 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from govoplan_core.admin.service import AdminConflictError, AdminValidationError, slugify
from govoplan_core.db.models import (
ApiKey,
GovernanceTemplate,
GovernanceTemplateAssignment,
Group,
GroupRoleAssignment,
Role,
SystemSettings,
Tenant,
UserGroupMembership,
UserRoleAssignment,
)
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
from govoplan_core.security.permissions import validate_tenant_permissions
SYSTEM_SETTINGS_ID = "global"
TEMPLATE_KINDS = {"group", "role"}
ASSIGNMENT_MODES = {"available", "required"}
@dataclass(frozen=True)
class EffectiveTenantGovernance:
allow_custom_groups: bool
allow_custom_roles: bool
allow_api_keys: bool
def get_system_settings(session: Session) -> SystemSettings:
item = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
if item is None:
item = SystemSettings(id=SYSTEM_SETTINGS_ID)
session.add(item)
session.flush()
return item
def _narrowing_bool(system_allows: bool, tenant_override: bool | None) -> bool:
if not system_allows:
return False
return tenant_override is not False
def effective_tenant_governance(session: Session, tenant: Tenant) -> EffectiveTenantGovernance:
defaults = get_system_settings(session)
return EffectiveTenantGovernance(
allow_custom_groups=_narrowing_bool(defaults.allow_tenant_custom_groups, tenant.allow_custom_groups),
allow_custom_roles=_narrowing_bool(defaults.allow_tenant_custom_roles, tenant.allow_custom_roles),
allow_api_keys=_narrowing_bool(defaults.allow_tenant_api_keys, tenant.allow_api_keys),
)
def assert_tenant_governance_override_allowed(session: Session, *, field: str, value: bool | None) -> None:
if value is not True:
return
defaults = get_system_settings(session)
blocked = {
"allow_custom_groups": not defaults.allow_tenant_custom_groups,
"allow_custom_roles": not defaults.allow_tenant_custom_roles,
"allow_api_keys": not defaults.allow_tenant_api_keys,
}
if blocked.get(field):
raise AdminValidationError("Tenant governance cannot explicitly allow a capability denied by system settings.")
def validate_template(kind: str, permissions: list[str]) -> list[str]:
if kind not in TEMPLATE_KINDS:
raise AdminValidationError("Template kind must be group or role.")
if kind == "group":
if permissions:
raise AdminValidationError("Group templates do not contain permissions; assign roles to groups inside each tenant.")
return []
try:
return validate_tenant_permissions(permissions)
except ValueError as exc:
raise AdminValidationError(str(exc)) from exc
def create_template(
session: Session,
*,
kind: str,
slug: str,
name: str,
description: str | None,
permissions: list[str],
is_active: bool,
assignments: list[dict[str, str]],
) -> GovernanceTemplate:
normalized_slug = slugify(slug)
permissions = validate_template(kind, permissions)
exists = session.query(GovernanceTemplate).filter(
GovernanceTemplate.kind == kind,
GovernanceTemplate.slug == normalized_slug,
).first()
if exists:
raise AdminConflictError(f"A {kind} template with slug {normalized_slug!r} already exists.")
item = GovernanceTemplate(
kind=kind,
slug=normalized_slug,
name=name.strip(),
description=description,
permissions=permissions,
is_active=is_active,
)
session.add(item)
session.flush()
set_template_assignments(session, item, assignments)
return item
def update_template(
session: Session,
item: GovernanceTemplate,
*,
name: str,
description: str | None,
permissions: list[str],
is_active: bool,
assignments: list[dict[str, str]],
) -> GovernanceTemplate:
item.name = name.strip()
item.description = description
item.permissions = validate_template(item.kind, permissions)
item.is_active = is_active
set_template_assignments(session, item, assignments)
sync_template(session, item)
return item
def set_template_assignments(
session: Session,
item: GovernanceTemplate,
assignments: list[dict[str, str]],
) -> None:
desired: dict[str, str] = {}
for assignment in assignments:
tenant_id = assignment.get("tenant_id", "")
mode = assignment.get("mode", "available")
if mode not in ASSIGNMENT_MODES:
raise AdminValidationError("Template assignment mode must be available or required.")
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise AdminValidationError(f"Unknown tenant: {tenant_id}")
desired[tenant_id] = mode
existing = {
row.tenant_id: row
for row in session.query(GovernanceTemplateAssignment)
.filter(GovernanceTemplateAssignment.template_id == item.id)
.all()
}
for tenant_id, row in list(existing.items()):
if tenant_id in desired:
row.mode = desired[tenant_id]
continue
_remove_materialized_template(session, item, tenant_id)
session.delete(row)
for tenant_id, mode in desired.items():
if tenant_id not in existing:
session.add(GovernanceTemplateAssignment(template_id=item.id, tenant_id=tenant_id, mode=mode))
session.flush()
sync_template(session, item)
def sync_template(session: Session, item: GovernanceTemplate) -> None:
assignments = session.query(GovernanceTemplateAssignment).filter(
GovernanceTemplateAssignment.template_id == item.id
).all()
for assignment in assignments:
required = assignment.mode == "required"
if item.kind == "group":
group = session.query(Group).filter(
Group.tenant_id == assignment.tenant_id,
Group.system_template_id == item.id,
).first()
if group is None:
group = Group(
tenant_id=assignment.tenant_id,
slug=_available_slug(session, Group, assignment.tenant_id, item.slug),
name=item.name,
description=item.description,
is_active=item.is_active,
system_template_id=item.id,
system_required=required,
)
session.add(group)
else:
group.name = item.name
group.description = item.description
group.system_required = required
if required:
group.is_active = item.is_active
else:
role = session.query(Role).filter(
Role.tenant_id == assignment.tenant_id,
Role.system_template_id == item.id,
).first()
if role is None:
role = Role(
tenant_id=assignment.tenant_id,
slug=_available_slug(session, Role, assignment.tenant_id, item.slug),
name=item.name,
description=item.description,
permissions=item.permissions,
is_builtin=False,
is_assignable=item.is_active,
system_template_id=item.id,
system_required=required,
)
session.add(role)
else:
role.name = item.name
role.description = item.description
role.permissions = item.permissions
role.system_required = required
if required:
role.is_assignable = item.is_active
session.flush()
def delete_template(session: Session, item: GovernanceTemplate) -> None:
assignments = session.query(GovernanceTemplateAssignment).filter(
GovernanceTemplateAssignment.template_id == item.id
).all()
for assignment in assignments:
_remove_materialized_template(session, item, assignment.tenant_id)
session.delete(item)
def _remove_materialized_template(session: Session, item: GovernanceTemplate, tenant_id: str) -> None:
if item.kind == "group":
group = session.query(Group).filter(
Group.tenant_id == tenant_id,
Group.system_template_id == item.id,
).first()
if group is None:
return
membership_count = session.query(UserGroupMembership).filter(UserGroupMembership.group_id == group.id).count()
role_count = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.group_id == group.id).count()
owned_asset_count = session.query(FileAsset).filter(FileAsset.owner_group_id == group.id).count()
owned_folder_count = session.query(FileFolder).filter(FileFolder.owner_group_id == group.id).count()
shared_asset_count = session.query(FileShare).filter(
FileShare.target_type == "group", FileShare.target_id == group.id
).count()
if membership_count or role_count or owned_asset_count or owned_folder_count or shared_asset_count:
raise AdminConflictError(
f"Cannot remove {item.name!r} from the tenant while its managed group has members, roles, files, folders or shares."
)
session.delete(group)
return
role = session.query(Role).filter(
Role.tenant_id == tenant_id,
Role.system_template_id == item.id,
).first()
if role is None:
return
user_count = session.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role.id).count()
group_count = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role.id).count()
if user_count or group_count:
raise AdminConflictError(
f"Cannot remove {item.name!r} from the tenant while its managed role is assigned to users or groups."
)
session.delete(role)
def _available_slug(session: Session, model: type[Group] | type[Role], tenant_id: str, base: str) -> str:
candidate = base
suffix = 2
while session.query(model).filter(model.tenant_id == tenant_id, model.slug == candidate).first():
candidate = f"{base}-{suffix}"
suffix += 1
return candidate
def ensure_group_mutation_allowed(group: Group, *, requested_active: bool | None = None) -> None:
if group.system_template_id and group.system_required and requested_active is False:
raise AdminConflictError("This centrally required group cannot be deactivated by the tenant.")
def ensure_role_mutation_allowed(role: Role, *, requested_assignable: bool | None = None) -> None:
if role.system_template_id:
if role.system_required and requested_assignable is False:
raise AdminConflictError("This centrally required role cannot be made unavailable by the tenant.")
raise AdminConflictError("Centrally managed role definitions can only be changed in System administration.")
def assert_api_keys_allowed(session: Session, tenant: Tenant) -> None:
if not effective_tenant_governance(session, tenant).allow_api_keys:
raise AdminConflictError("API-key creation is disabled by system or tenant governance.")
def assert_custom_groups_allowed(session: Session, tenant: Tenant) -> None:
if not effective_tenant_governance(session, tenant).allow_custom_groups:
raise AdminConflictError("Custom tenant groups are disabled by system or tenant governance.")
def assert_custom_roles_allowed(session: Session, tenant: Tenant) -> None:
if not effective_tenant_governance(session, tenant).allow_custom_roles:
raise AdminConflictError("Custom tenant roles are disabled by system or tenant governance.")
def active_api_key_count(session: Session, tenant_id: str) -> int:
return session.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count()

View File

@@ -0,0 +1,588 @@
from __future__ import annotations
import re
import secrets
import string
from collections.abc import Iterable
from dataclasses import dataclass
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.db.models import (
Account,
ApiKey,
Group,
GroupRoleAssignment,
Role,
SystemRoleAssignment,
Tenant,
User,
UserGroupMembership,
UserRoleAssignment,
)
from govoplan_core.security.passwords import hash_password
from govoplan_core.security.permissions import (
DEFAULT_SYSTEM_ROLES,
DEFAULT_TENANT_ROLES,
normalize_email,
scopes_grant,
validate_system_permissions,
validate_tenant_permissions,
)
_SLUG_RE = re.compile(r"[^a-z0-9]+")
_TEMP_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-_!@#"
class AdminConflictError(ValueError):
pass
class AdminValidationError(ValueError):
pass
@dataclass(slots=True)
class MembershipCreationResult:
user: User
account: Account
temporary_password: str | None = None
account_created: bool = False
def slugify(value: str) -> str:
slug = _SLUG_RE.sub("-", value.strip().casefold()).strip("-")
if not slug:
raise AdminValidationError("A slug must contain at least one letter or number.")
return slug[:100]
def generate_temporary_password(length: int = 20) -> str:
return "".join(secrets.choice(_TEMP_PASSWORD_ALPHABET) for _ in range(length))
def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict[str, Role]:
definitions = DEFAULT_TENANT_ROLES if tenant is not None else DEFAULT_SYSTEM_ROLES
roles: dict[str, Role] = {}
for slug, definition in definitions.items():
query = session.query(Role).filter(Role.slug == slug)
query = query.filter(Role.tenant_id == tenant.id) if tenant is not None else query.filter(Role.tenant_id.is_(None))
role = query.one_or_none()
if role is None:
role = Role(
tenant_id=tenant.id if tenant is not None else None,
slug=slug,
name=str(definition["name"]),
description=str(definition.get("description") or "") or None,
permissions=list(definition["permissions"]),
is_builtin=bool(definition.get("is_builtin", True)),
is_assignable=bool(definition.get("is_assignable", True)),
)
session.add(role)
session.flush()
else:
# Tenant built-ins and explicitly managed system roles remain
# code-defined. Seeded, non-protected system roles are only created
# here and can subsequently be administered in the System roles UI.
managed = tenant is not None or bool(definition.get("managed", False))
if managed:
role.name = str(definition["name"])
role.description = str(definition.get("description") or "") or None
role.permissions = list(definition["permissions"])
role.is_builtin = bool(definition.get("is_builtin", True))
role.is_assignable = bool(definition.get("is_assignable", True))
session.add(role)
roles[slug] = role
return roles
def get_or_create_account(
session: Session,
*,
email: str,
display_name: str | None = None,
password: str | None = None,
password_reset_required: bool = False,
) -> tuple[Account, bool, str | None]:
normalized = normalize_email(email)
if not normalized or "@" not in normalized:
raise AdminValidationError("Enter a valid email address.")
account = session.query(Account).filter(Account.normalized_email == normalized).one_or_none()
if account is not None:
if not account.is_active:
raise AdminConflictError(
"The global account is disabled. A system administrator must reactivate it before adding a tenant membership."
)
return account, False, None
temporary_password = password or generate_temporary_password()
account = Account(
email=email.strip(),
normalized_email=normalized,
display_name=display_name.strip() if display_name else None,
is_active=True,
auth_provider="local",
password_hash=hash_password(temporary_password),
password_reset_required=password_reset_required or password is None,
)
session.add(account)
session.flush()
return account, True, temporary_password
def create_membership(
session: Session,
*,
tenant: Tenant,
email: str,
display_name: str | None = None,
password: str | None = None,
password_reset_required: bool = False,
is_active: bool = True,
) -> MembershipCreationResult:
account, account_created, temporary_password = get_or_create_account(
session,
email=email,
display_name=display_name,
password=password,
password_reset_required=password_reset_required,
)
existing = (
session.query(User)
.filter(User.tenant_id == tenant.id, User.account_id == account.id)
.one_or_none()
)
if existing is not None:
raise AdminConflictError("This account already belongs to the tenant.")
user = User(
tenant_id=tenant.id,
account_id=account.id,
email=account.email,
display_name=display_name.strip() if display_name else account.display_name,
is_active=is_active,
is_tenant_admin=False,
auth_provider=account.auth_provider,
password_hash=account.password_hash,
)
session.add(user)
session.flush()
return MembershipCreationResult(
user=user,
account=account,
temporary_password=temporary_password if account_created else None,
account_created=account_created,
)
def set_user_groups(session: Session, *, user: User, group_ids: Iterable[str]) -> None:
ids = sorted(set(group_ids))
groups = (
session.query(Group)
.filter(Group.tenant_id == user.tenant_id, Group.id.in_(ids))
.all()
if ids else []
)
if len(groups) != len(ids):
raise AdminValidationError("One or more selected groups do not belong to the tenant.")
session.query(UserGroupMembership).filter(
UserGroupMembership.tenant_id == user.tenant_id,
UserGroupMembership.user_id == user.id,
).delete(synchronize_session=False)
for group in groups:
session.add(UserGroupMembership(tenant_id=user.tenant_id, user_id=user.id, group_id=group.id))
session.flush()
def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None:
ids = sorted(set(role_ids))
roles = (
session.query(Role)
.filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True))
.all()
if ids else []
)
if len(roles) != len(ids):
raise AdminValidationError("One or more selected roles are invalid or not assignable.")
session.query(UserRoleAssignment).filter(
UserRoleAssignment.tenant_id == user.tenant_id,
UserRoleAssignment.user_id == user.id,
).delete(synchronize_session=False)
for role in roles:
session.add(UserRoleAssignment(tenant_id=user.tenant_id, user_id=user.id, role_id=role.id))
session.flush()
def set_group_members(session: Session, *, group: Group, user_ids: Iterable[str]) -> None:
ids = sorted(set(user_ids))
users = (
session.query(User)
.filter(User.tenant_id == group.tenant_id, User.id.in_(ids))
.all()
if ids else []
)
if len(users) != len(ids):
raise AdminValidationError("One or more selected users do not belong to the tenant.")
session.query(UserGroupMembership).filter(
UserGroupMembership.tenant_id == group.tenant_id,
UserGroupMembership.group_id == group.id,
).delete(synchronize_session=False)
for user in users:
session.add(UserGroupMembership(tenant_id=group.tenant_id, user_id=user.id, group_id=group.id))
session.flush()
def set_group_roles(session: Session, *, group: Group, role_ids: Iterable[str]) -> None:
ids = sorted(set(role_ids))
roles = (
session.query(Role)
.filter(Role.tenant_id == group.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True))
.all()
if ids else []
)
if len(roles) != len(ids):
raise AdminValidationError("One or more selected roles are invalid or not assignable.")
session.query(GroupRoleAssignment).filter(
GroupRoleAssignment.tenant_id == group.tenant_id,
GroupRoleAssignment.group_id == group.id,
).delete(synchronize_session=False)
for role in roles:
session.add(GroupRoleAssignment(tenant_id=group.tenant_id, group_id=group.id, role_id=role.id))
session.flush()
def create_custom_role(
session: Session,
*,
tenant: Tenant,
slug: str,
name: str,
description: str | None,
permissions: Iterable[str],
) -> Role:
normalized_slug = slugify(slug)
if session.query(Role).filter(Role.tenant_id == tenant.id, Role.slug == normalized_slug).count():
raise AdminConflictError("A role with this slug already exists in the tenant.")
try:
validated = validate_tenant_permissions(permissions)
except ValueError as exc:
raise AdminValidationError(str(exc)) from exc
role = Role(
tenant_id=tenant.id,
slug=normalized_slug,
name=name.strip(),
description=description.strip() if description else None,
permissions=validated,
is_builtin=False,
is_assignable=True,
)
session.add(role)
session.flush()
return role
def update_custom_role(
session: Session,
*,
role: Role,
name: str,
description: str | None,
permissions: Iterable[str],
is_assignable: bool,
) -> None:
if role.is_builtin:
raise AdminConflictError("Built-in roles are managed by the application and cannot be edited.")
try:
validated = validate_tenant_permissions(permissions)
except ValueError as exc:
raise AdminValidationError(str(exc)) from exc
role.name = name.strip()
role.description = description.strip() if description else None
role.permissions = validated
role.is_assignable = is_assignable
session.add(role)
session.flush()
def delete_custom_role(session: Session, role: Role) -> None:
if role.is_builtin:
raise AdminConflictError("Built-in roles cannot be deleted.")
assigned = session.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role.id).count()
assigned += session.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role.id).count()
if assigned:
raise AdminConflictError("Remove all user and group assignments before deleting this role.")
session.delete(role)
session.flush()
def create_system_role(
session: Session,
*,
slug: str,
name: str,
description: str | None,
permissions: Iterable[str],
) -> Role:
normalized_slug = slugify(slug)
if session.query(Role).filter(Role.tenant_id.is_(None), Role.slug == normalized_slug).count():
raise AdminConflictError("A system role with this slug already exists.")
try:
validated = validate_system_permissions(permissions)
except ValueError as exc:
raise AdminValidationError(str(exc)) from exc
if "system:*" in validated:
raise AdminValidationError("Only the protected System owner role may contain system:*.")
role = Role(
tenant_id=None,
slug=normalized_slug,
name=name.strip(),
description=description.strip() if description else None,
permissions=validated,
is_builtin=False,
is_assignable=True,
)
session.add(role)
session.flush()
return role
def update_system_role(
session: Session,
*,
role: Role,
name: str,
description: str | None,
permissions: Iterable[str],
is_assignable: bool,
) -> None:
if role.tenant_id is not None:
raise AdminValidationError("This is not a system role.")
if role.slug == "system_owner":
raise AdminConflictError("The protected System owner role cannot be edited.")
try:
validated = validate_system_permissions(permissions)
except ValueError as exc:
raise AdminValidationError(str(exc)) from exc
if "system:*" in validated:
raise AdminValidationError("Only the protected System owner role may contain system:*.")
role.name = name.strip()
role.description = description.strip() if description else None
role.permissions = validated
role.is_assignable = is_assignable
role.is_builtin = False
session.add(role)
session.flush()
def delete_system_role(session: Session, role: Role) -> None:
if role.tenant_id is not None:
raise AdminValidationError("This is not a system role.")
if role.slug == "system_owner":
raise AdminConflictError("The protected System owner role cannot be deleted.")
assigned = session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count()
if assigned:
raise AdminConflictError("Remove all account assignments before deleting this system role.")
session.delete(role)
session.flush()
def assert_can_delegate_system_permissions(actor_scopes: Iterable[str], permissions: Iterable[str]) -> None:
"""Prevent a system administrator from defining stronger roles than they hold."""
from govoplan_core.security.permissions import delegateable_system_scopes
requested = set(validate_system_permissions(permissions))
if "system:*" in requested:
if not scopes_grant(actor_scopes, "system:*"):
raise AdminValidationError("Only a System owner may delegate full system access.")
return
allowed = delegateable_system_scopes(actor_scopes)
missing = sorted(scope for scope in requested if scope not in allowed)
if missing:
raise AdminValidationError("You cannot delegate system permissions you do not currently hold: " + ", ".join(missing))
def assert_can_delegate_system_roles(
session: Session,
*,
actor_scopes: Iterable[str],
role_ids: Iterable[str],
) -> None:
ids = sorted(set(role_ids))
if not ids:
return
roles = session.query(Role).filter(Role.tenant_id.is_(None), Role.id.in_(ids)).all()
if len(roles) != len(ids):
raise AdminValidationError("One or more selected system roles are invalid.")
for role in roles:
assert_can_delegate_system_permissions(actor_scopes, role.permissions or [])
def set_system_roles(session: Session, *, account: Account, role_ids: Iterable[str]) -> None:
ids = sorted(set(role_ids))
roles = (
session.query(Role)
.filter(Role.tenant_id.is_(None), Role.id.in_(ids), Role.is_assignable.is_(True))
.all()
if ids else []
)
if len(roles) != len(ids):
raise AdminValidationError("One or more system roles are invalid or not assignable.")
for role in roles:
try:
validate_system_permissions(role.permissions or [])
except ValueError as exc:
raise AdminValidationError(str(exc)) from exc
session.query(SystemRoleAssignment).filter(SystemRoleAssignment.account_id == account.id).delete(synchronize_session=False)
for role in roles:
session.add(SystemRoleAssignment(account_id=account.id, role_id=role.id))
session.flush()
assert_system_owner_exists(session)
def account_has_system_scope(session: Session, account: Account, required: str) -> bool:
roles = (
session.query(Role)
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
.filter(SystemRoleAssignment.account_id == account.id, Role.tenant_id.is_(None))
.all()
)
return any(scopes_grant(role.permissions or [], required) for role in roles)
def tenant_owner_user_ids(session: Session, tenant_id: str) -> set[str]:
"""Return active tenant memberships that currently satisfy the operational-owner invariant."""
users = (
session.query(User)
.join(Account, Account.id == User.account_id)
.filter(
User.tenant_id == tenant_id,
User.is_active.is_(True),
Account.is_active.is_(True),
)
.all()
)
owner_ids: set[str] = set()
for user in users:
direct_roles = (
session.query(Role)
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
.filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.user_id == user.id,
Role.tenant_id == tenant_id,
)
.all()
)
group_roles = (
session.query(Role)
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.join(UserGroupMembership, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.tenant_id == tenant_id,
UserGroupMembership.user_id == user.id,
Group.is_active.is_(True),
Role.tenant_id == tenant_id,
)
.all()
)
effective = [
scope
for role in direct_roles + group_roles
for scope in (role.permissions or [])
]
if scopes_grant(effective, "admin:roles:write") and scopes_grant(effective, "campaign:send"):
owner_ids.add(user.id)
return owner_ids
def tenant_has_owner(session: Session, tenant_id: str) -> bool:
return bool(tenant_owner_user_ids(session, tenant_id))
def assert_tenant_owner_exists(session: Session, tenant_id: str) -> None:
session.flush()
tenant = session.get(Tenant, tenant_id)
if tenant is not None and tenant.is_active and not tenant_has_owner(session, tenant_id):
raise AdminConflictError("An active tenant must retain at least one active owner or administrator with full operational access.")
def assert_system_owner_exists(session: Session) -> None:
"""Keep one active assignment of the protected System owner role.
Other roles may hold broad system permissions, but they are intentionally
not substitutes for the protected break-glass owner identity.
"""
session.flush()
owner_role = (
session.query(Role)
.filter(Role.tenant_id.is_(None), Role.slug == "system_owner")
.one_or_none()
)
if owner_role is None:
raise AdminConflictError("The protected System owner role is missing.")
count = (
session.query(func.count(SystemRoleAssignment.id))
.join(Account, Account.id == SystemRoleAssignment.account_id)
.filter(SystemRoleAssignment.role_id == owner_role.id, Account.is_active.is_(True))
.scalar()
)
if not count:
raise AdminConflictError("At least one active account must retain the protected System owner role.")
def role_assignment_counts(session: Session, role_id: str) -> tuple[int, int]:
return (
session.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role_id).count(),
session.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role_id).count(),
)
def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]:
from govoplan_campaign.backend.db.models import Campaign
from govoplan_files.backend.db.models import FileAsset
return {
"users": session.query(User).filter(User.tenant_id == tenant_id).count(),
"active_users": session.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(),
"groups": session.query(Group).filter(Group.tenant_id == tenant_id).count(),
"campaigns": session.query(Campaign).filter(Campaign.tenant_id == tenant_id).count(),
"files": session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id).count(),
"api_keys": session.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(),
}
def assert_can_delegate_tenant_permissions(actor_scopes: Iterable[str], permissions: Iterable[str]) -> None:
"""Prevent administrators from defining or assigning roles beyond their own effective tenant powers."""
from govoplan_core.security.permissions import delegateable_tenant_scopes
requested = set(validate_tenant_permissions(permissions))
if "tenant:*" in requested:
# Only a tenant owner-equivalent can delegate the wildcard.
if not scopes_grant(actor_scopes, "tenant:*"):
raise AdminValidationError("You cannot delegate full tenant access unless you already have it.")
return
allowed = delegateable_tenant_scopes(actor_scopes)
missing = sorted(scope for scope in requested if scope not in allowed)
if missing:
raise AdminValidationError("You cannot delegate permissions you do not currently hold: " + ", ".join(missing))
def assert_can_delegate_roles(session: Session, *, actor_scopes: Iterable[str], role_ids: Iterable[str], tenant_id: str) -> None:
ids = sorted(set(role_ids))
if not ids:
return
roles = session.query(Role).filter(Role.tenant_id == tenant_id, Role.id.in_(ids)).all()
if len(roles) != len(ids):
raise AdminValidationError("One or more selected roles do not belong to the tenant.")
for role in roles:
assert_can_delegate_tenant_permissions(actor_scopes, role.permissions or [])

View File

@@ -0,0 +1 @@
from __future__ import annotations

View File

@@ -0,0 +1 @@
from __future__ import annotations

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,486 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
class PermissionItem(BaseModel):
scope: str
label: str
description: str
category: str
level: Literal["tenant", "system"]
class PermissionCatalogResponse(BaseModel):
permissions: list[PermissionItem]
class AdminOverviewResponse(BaseModel):
active_tenant_id: str
active_tenant_name: str
tenant_count: int | None = None
system_account_count: int | None = None
system_group_template_count: int | None = None
system_role_template_count: int | None = None
user_count: int
active_user_count: int
group_count: int
role_count: int
active_api_key_count: int
capabilities: list[str] = Field(default_factory=list)
class TenantAdminItem(BaseModel):
id: str
slug: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
default_locale: str = Field(default="en", min_length=1, max_length=20)
settings: dict[str, Any] = Field(default_factory=dict)
allow_custom_groups: bool | None = None
allow_custom_roles: bool | None = None
allow_api_keys: bool | None = None
effective_governance: dict[str, bool] = Field(default_factory=dict)
is_active: bool
counts: dict[str, int] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class TenantListResponse(BaseModel):
tenants: list[TenantAdminItem]
class TenantOwnerCandidate(BaseModel):
account_id: str
email: str
display_name: str | None = None
class TenantOwnerCandidateListResponse(BaseModel):
accounts: list[TenantOwnerCandidate]
class TenantCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
slug: str
name: str
owner_account_id: str | None = None
description: str | None = None
default_locale: str = "en"
settings: dict[str, Any] = Field(default_factory=dict)
allow_custom_groups: bool | None = None
allow_custom_roles: bool | None = None
allow_api_keys: bool | None = None
class TenantUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
default_locale: str | None = Field(default=None, min_length=1, max_length=20)
settings: dict[str, Any] | None = None
allow_custom_groups: bool | None = None
allow_custom_roles: bool | None = None
allow_api_keys: bool | None = None
is_active: bool | None = None
class RoleSummary(BaseModel):
id: str
slug: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
permissions: list[str] = Field(default_factory=list)
effective_permission_count: int = 0
is_builtin: bool = False
is_assignable: bool = True
user_assignments: int = 0
group_assignments: int = 0
level: Literal["tenant", "system"] = "tenant"
system_template_id: str | None = None
system_required: bool = False
class GroupSummary(BaseModel):
id: str
slug: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
is_active: bool = True
member_count: int = 0
member_ids: list[str] = Field(default_factory=list)
roles: list[RoleSummary] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
system_template_id: str | None = None
system_required: bool = False
class UserAdminItem(BaseModel):
id: str
account_id: str
tenant_id: str
email: str = Field(min_length=3, max_length=320)
display_name: str | None = Field(default=None, max_length=255)
is_active: bool
account_is_active: bool
password_reset_required: bool = False
last_login_at: datetime | None = None
groups: list[GroupSummary] = Field(default_factory=list)
roles: list[RoleSummary] = Field(default_factory=list)
effective_scopes: list[str] = Field(default_factory=list)
is_owner: bool = False
is_last_active_owner: bool = False
created_at: datetime
updated_at: datetime
class UserListResponse(BaseModel):
users: list[UserAdminItem]
class UserCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
email: str
display_name: str | None = None
password: str | None = Field(default=None, min_length=10)
password_reset_required: bool = True
is_active: bool = True
group_ids: list[str] = Field(default_factory=list)
role_ids: list[str] = Field(default_factory=list)
class UserCreateResponse(BaseModel):
user: UserAdminItem
account_created: bool
temporary_password: str | None = None
class UserUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, max_length=255)
is_active: bool | None = None
group_ids: list[str] | None = None
role_ids: list[str] | None = None
class GroupListResponse(BaseModel):
groups: list[GroupSummary]
class GroupCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
slug: str
name: str
description: str | None = None
is_active: bool = True
member_ids: list[str] = Field(default_factory=list)
role_ids: list[str] = Field(default_factory=list)
class GroupUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
is_active: bool | None = None
member_ids: list[str] | None = None
role_ids: list[str] | None = None
class RoleListResponse(BaseModel):
roles: list[RoleSummary]
class RoleCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
slug: str
name: str = Field(min_length=1, max_length=255)
description: str | None = None
permissions: list[str] = Field(default_factory=list)
class RoleUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
description: str | None = None
permissions: list[str] = Field(default_factory=list)
is_assignable: bool = True
class SystemAccountItem(BaseModel):
account_id: str
email: str
display_name: str | None = None
is_active: bool
memberships: list[dict[str, Any]] = Field(default_factory=list)
roles: list[RoleSummary] = Field(default_factory=list)
last_login_at: datetime | None = None
class SystemAccountListResponse(BaseModel):
accounts: list[SystemAccountItem]
roles: list[RoleSummary]
class SystemAccountUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, max_length=255)
is_active: bool | None = None
role_ids: list[str] | None = None
class SystemAccountRolesUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
role_ids: list[str] = Field(default_factory=list)
class SystemMembershipUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_id: str
is_active: bool = True
role_ids: list[str] = Field(default_factory=list)
group_ids: list[str] = Field(default_factory=list)
class SystemAccountMembershipsUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
memberships: list[SystemMembershipUpdate] = Field(default_factory=list)
class SystemAccountCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
email: str
display_name: str | None = None
password: str | None = Field(default=None, min_length=10)
password_reset_required: bool = True
is_active: bool = True
role_ids: list[str] = Field(default_factory=list)
memberships: list[SystemMembershipUpdate] = Field(default_factory=list)
class SystemAccountCreateResponse(BaseModel):
account: SystemAccountItem
temporary_password: str | None = None
class PrivacyRetentionPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatchItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=False)
class PrivacyRetentionPolicyScopeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
policy: PrivacyRetentionPolicyPatchItem = Field(default_factory=PrivacyRetentionPolicyPatchItem)
class PrivacyRetentionPolicyScopeResponse(BaseModel):
scope_type: Literal["system", "tenant", "user", "group", "campaign"]
scope_id: str | None = None
policy: dict[str, Any]
effective_policy: PrivacyRetentionPolicyItem
parent_policy: PrivacyRetentionPolicyItem | None = None
class RetentionRunRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
dry_run: bool = True
class RetentionRunResponse(BaseModel):
result: dict[str, Any]
class SystemSettingsItem(BaseModel):
default_locale: str = "en"
allow_tenant_custom_groups: bool = True
allow_tenant_custom_roles: bool = True
allow_tenant_api_keys: bool = True
privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem)
settings: dict[str, Any] = Field(default_factory=dict)
class SystemSettingsUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
default_locale: str = Field(min_length=1, max_length=20)
allow_tenant_custom_groups: bool
allow_tenant_custom_roles: bool
allow_tenant_api_keys: bool
privacy_retention_policy: PrivacyRetentionPolicyItem | None = None
class GovernanceAssignment(BaseModel):
tenant_id: str
mode: Literal["available", "required"] = "available"
class GovernanceTemplateItem(BaseModel):
id: str
kind: Literal["group", "role"]
slug: str
name: str
description: str | None = None
permissions: list[str] = Field(default_factory=list)
effective_permission_count: int = 0
is_active: bool = True
assignments: list[GovernanceAssignment] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class GovernanceTemplateListResponse(BaseModel):
templates: list[GovernanceTemplateItem]
class GovernanceTemplateCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal["group", "role"]
slug: str
name: str = Field(min_length=1, max_length=255)
description: str | None = None
permissions: list[str] = Field(default_factory=list)
is_active: bool = True
assignments: list[GovernanceAssignment] = Field(default_factory=list)
class GovernanceTemplateUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
description: str | None = None
permissions: list[str] = Field(default_factory=list)
is_active: bool = True
assignments: list[GovernanceAssignment] = Field(default_factory=list)
class ApiKeyAdminItem(BaseModel):
id: str
user_id: str
user_email: str
name: str
prefix: str
scopes: list[str] = Field(default_factory=list)
expires_at: datetime | None = None
last_used_at: datetime | None = None
revoked_at: datetime | None = None
created_at: datetime
class ApiKeyListResponse(BaseModel):
api_keys: list[ApiKeyAdminItem]
class AdminApiKeyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
user_id: str | None = None
scopes: list[str] = Field(default_factory=list)
expires_at: datetime | None = None
class AdminApiKeyCreateResponse(ApiKeyAdminItem):
secret: str
class AuditAdminItem(BaseModel):
id: str
scope: Literal["tenant", "system"] = "tenant"
tenant_id: str | None = None
actor_email: str | None = None
action: str
object_type: str | None = None
object_id: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
class AuditAdminListResponse(BaseModel):
items: list[AuditAdminItem]
total: int
page: int = 1
page_size: int = 100
pages: int = 1

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from govoplan_core.api.v1.schemas import AuditLogItemResponse, AuditLogListResponse
from govoplan_core.auth.dependencies import ApiPrincipal, require_scope
from govoplan_core.db.models import AuditLog
from govoplan_core.db.session import get_session
router = APIRouter(prefix="/audit", tags=["audit"])
@router.get("", response_model=AuditLogListResponse)
def list_audit_log(
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
action: str | None = None,
object_type: str | None = None,
object_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("audit:read")),
):
query = session.query(AuditLog).filter(AuditLog.tenant_id == principal.tenant_id)
if action:
query = query.filter(AuditLog.action == action)
if object_type:
query = query.filter(AuditLog.object_type == object_type)
if object_id:
query = query.filter(AuditLog.object_id == object_id)
items = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
return AuditLogListResponse(items=[AuditLogItemResponse.model_validate(item) for item in items])

View File

@@ -0,0 +1,293 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.orm import Session
from govoplan_core.api.v1.schemas import (
GroupInfo,
LoginRequest,
LoginResponse,
MeResponse,
ProfileUpdateRequest,
RoleInfo,
SwitchTenantRequest,
TenantInfo,
TenantMembershipInfo,
UserInfo,
)
from govoplan_core.auth.dependencies import ApiPrincipal, get_api_principal
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.db.models import Account, Tenant, User
from govoplan_core.db.session import get_session
from govoplan_core.security.passwords import verify_password
from govoplan_core.security.permissions import normalize_email
from govoplan_core.settings import settings
from govoplan_core.security.sessions import (
collect_system_roles,
collect_tenant_memberships,
collect_user_groups,
collect_user_roles,
collect_user_scopes,
create_auth_session,
switch_auth_session_tenant,
)
from govoplan_core.security.time import utc_now
router = APIRouter(prefix="/auth", tags=["auth"])
def _cookie_samesite() -> str:
value = settings.auth_cookie_samesite.lower().strip()
if value not in {"lax", "strict", "none"}:
return "lax"
if value == "none" and not settings.auth_cookie_secure:
return "lax"
return value
def _set_auth_cookies(response: Response, created) -> None:
max_age = max(0, int((created.model.expires_at - utc_now()).total_seconds()))
common = {
"secure": settings.auth_cookie_secure,
"samesite": _cookie_samesite(),
"max_age": max_age,
"path": "/",
}
if settings.auth_cookie_domain:
common["domain"] = settings.auth_cookie_domain
response.set_cookie(settings.auth_session_cookie_name, created.token, httponly=True, **common)
response.set_cookie(settings.auth_csrf_cookie_name, created.csrf_token, httponly=False, **common)
def _clear_auth_cookies(response: Response) -> None:
kwargs = {"path": "/"}
if settings.auth_cookie_domain:
kwargs["domain"] = settings.auth_cookie_domain
response.delete_cookie(settings.auth_session_cookie_name, **kwargs)
response.delete_cookie(settings.auth_csrf_cookie_name, **kwargs)
def _tenant_info(tenant: Tenant) -> TenantInfo:
return TenantInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
is_active=tenant.is_active,
default_locale=tenant.default_locale,
)
def _user_info(user: User, account: Account) -> UserInfo:
return UserInfo(
id=user.id,
account_id=account.id,
email=account.email,
display_name=account.display_name or user.display_name,
tenant_display_name=user.display_name,
is_tenant_admin=user.is_tenant_admin,
password_reset_required=account.password_reset_required,
)
def _roles_info(roles, *, level: str = "tenant") -> list[RoleInfo]:
return [
RoleInfo(
id=role.id,
slug=role.slug,
name=role.name,
permissions=role.permissions or [],
level=level,
)
for role in roles
]
def _groups_info(groups) -> list[GroupInfo]:
return [GroupInfo(id=group.id, slug=group.slug, name=group.name) for group in groups]
def _tenant_memberships(session: Session, account: Account) -> list[TenantMembershipInfo]:
memberships: list[TenantMembershipInfo] = []
for user, tenant in collect_tenant_memberships(session, account):
roles = collect_user_roles(session, user)
memberships.append(
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
is_active=tenant.is_active and user.is_active,
default_locale=tenant.default_locale,
roles=[role.slug for role in roles],
)
)
return memberships
def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Account, User, Tenant]:
account = (
session.query(Account)
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
.one_or_none()
)
if account is None or not verify_password(payload.password, account.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
query = (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(
User.account_id == account.id,
User.is_active.is_(True),
Tenant.is_active.is_(True),
)
)
if payload.tenant_slug:
query = query.filter(Tenant.slug == payload.tenant_slug)
row = query.order_by(Tenant.name.asc()).first()
if row is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No active tenant membership")
return account, row[0], row[1]
def _me_response(
session: Session,
*,
account: Account,
user: User,
tenant: Tenant,
effective_scopes: list[str] | None = None,
include_system: bool = True,
include_all_memberships: bool = True,
) -> MeResponse:
tenant_roles = collect_user_roles(session, user)
system_roles = collect_system_roles(session, account) if include_system else []
groups = collect_user_groups(session, user)
active_tenant = _tenant_info(tenant)
memberships = _tenant_memberships(session, account) if include_all_memberships else [
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
is_active=tenant.is_active and user.is_active,
default_locale=tenant.default_locale,
roles=[role.slug for role in tenant_roles],
)
]
return MeResponse(
user=_user_info(user, account),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=memberships,
scopes=effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system),
roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system"),
groups=_groups_info(groups),
)
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
account, user, tenant = _resolve_login_user(session, payload)
user_agent = request.headers.get("user-agent")
ip_address = request.client.host if request.client else None
created = create_auth_session(
session,
user=user,
hours=settings.auth_session_hours,
user_agent=user_agent,
ip_address=ip_address,
)
me_payload = _me_response(session, account=account, user=user, tenant=tenant)
session.commit()
_set_auth_cookies(response, created)
return LoginResponse(
access_token=created.token,
expires_at=created.model.expires_at,
**me_payload.model_dump(),
)
@router.get("/me", response_model=MeResponse)
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
return _me_response(
session,
account=principal.account,
user=principal.user,
tenant=tenant,
effective_scopes=principal.scopes,
include_system=principal.auth_session is not None,
include_all_memberships=principal.auth_session is not None,
)
@router.patch("/profile", response_model=MeResponse)
def update_profile(
payload: ProfileUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
if principal.auth_session is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile")
if "display_name" in payload.model_fields_set:
principal.account.display_name = payload.display_name.strip() if payload.display_name else None
session.add(principal.account)
if "tenant_display_name" in payload.model_fields_set:
principal.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None
session.add(principal.user)
audit_from_principal(
session,
principal,
action="profile.updated",
object_type="account",
object_id=principal.account.id,
details={"fields": sorted(payload.model_fields_set)},
)
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
session.commit()
return _me_response(
session,
account=principal.account,
user=principal.user,
tenant=tenant,
include_system=True,
include_all_memberships=True,
)
@router.post("/switch-tenant", response_model=MeResponse)
def switch_tenant(
payload: SwitchTenantRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
if principal.auth_session is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot switch tenant context")
try:
membership = switch_auth_session_tenant(session, principal.auth_session, payload.tenant_id)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
tenant = session.get(Tenant, membership.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
session.commit()
return _me_response(session, account=principal.account, user=membership, tenant=tenant)
@router.post("/logout")
def logout(
response: Response,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
if principal.auth_session is not None:
principal.auth_session.revoked_at = utc_now()
session.add(principal.auth_session)
session.commit()
_clear_auth_cookies(response)
return {"ok": True}

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class AuditLogItemResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str | None = None
user_id: str | None = None
api_key_id: str | None = None
action: str
object_type: str | None = None
object_id: str | None = None
details: dict[str, Any] | None = None
created_at: datetime
class AuditLogListResponse(BaseModel):
items: list[AuditLogItemResponse]
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
email: str
password: str
# Kept optional for backwards compatibility and future tenant-switch login flows.
# The WebUI no longer sends it. If omitted, the backend resolves the user by email.
tenant_slug: str | None = None
class SwitchTenantRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_id: str
class TenantInfo(BaseModel):
id: str
slug: str
name: str
is_active: bool = True
default_locale: str = "en"
class TenantMembershipInfo(TenantInfo):
roles: list[str] = Field(default_factory=list)
is_active: bool = True
class UserInfo(BaseModel):
id: str
account_id: str
email: str
# Global account identity used by the title bar and account settings.
display_name: str | None = None
# Optional tenant-local alias used in tenant administration and ownership.
tenant_display_name: str | None = None
is_tenant_admin: bool = False
password_reset_required: bool = False
class ProfileUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, max_length=255)
tenant_display_name: str | None = Field(default=None, max_length=255)
class RoleInfo(BaseModel):
id: str
slug: str
name: str
permissions: list[str] = Field(default_factory=list)
level: Literal["tenant", "system"] = "tenant"
class GroupInfo(BaseModel):
id: str
slug: str
name: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_at: datetime
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
roles: list[RoleInfo] = Field(default_factory=list)
groups: list[GroupInfo] = Field(default_factory=list)
class MeResponse(BaseModel):
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
roles: list[RoleInfo] = Field(default_factory=list)
groups: list[GroupInfo] = Field(default_factory=list)

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from govoplan_core.auth.dependencies import ApiPrincipal, require_any_scope
router = APIRouter(prefix="/schemas", tags=["schemas"])
def _load_campaign_schema() -> dict[str, Any]:
try:
from govoplan_campaign.backend.campaign.loader import load_campaign_schema
except ModuleNotFoundError as exc:
if exc.name == "govoplan_campaign" or exc.name.startswith("govoplan_campaign."):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign module is not installed") from exc
raise
return load_campaign_schema()
@router.get("/campaign")
def get_campaign_schema(
principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")),
) -> dict[str, Any]:
"""Return the authoritative campaign JSON Schema used by the backend."""
del principal
return _load_campaign_schema()

View File

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.auth.dependencies import ApiPrincipal
from govoplan_core.db.models import AuditLog
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
SENSITIVE_DETAIL_KEYS = {
"password",
"smtp_password",
"imap_password",
"secret",
"api_key",
"token",
}
def _sanitize_details(value: Any) -> Any:
if isinstance(value, dict):
sanitized: dict[str, Any] = {}
for key, item in value.items():
if str(key).lower() in SENSITIVE_DETAIL_KEYS:
sanitized[key] = "<redacted>"
else:
sanitized[key] = _sanitize_details(item)
return sanitized
if isinstance(value, list):
return [_sanitize_details(item) for item in value]
return value
def audit_event(
session: Session,
*,
tenant_id: str | None,
action: str,
scope: str = "tenant",
user_id: str | None = None,
api_key_id: str | None = None,
object_type: str | None = None,
object_id: str | None = None,
details: dict[str, Any] | None = None,
commit: bool = False,
) -> AuditLog:
"""Persist one audit event.
The function deliberately accepts primitive IDs so it can be used from API
handlers, CLI commands and worker code without coupling the lower-level
services to FastAPI request objects.
"""
if scope not in {"tenant", "system"}:
raise ValueError(f"Unsupported audit scope: {scope}")
item = AuditLog(
scope=scope,
tenant_id=tenant_id,
user_id=user_id,
api_key_id=api_key_id,
action=action,
object_type=object_type,
object_id=object_id,
details=sanitize_audit_details_for_policy(session, _sanitize_details(details or {})),
)
session.add(item)
if commit:
session.commit()
else:
session.flush()
return item
def audit_from_principal(
session: Session,
principal: ApiPrincipal,
*,
action: str,
scope: str = "tenant",
object_type: str | None = None,
object_id: str | None = None,
details: dict[str, Any] | None = None,
commit: bool = False,
) -> AuditLog:
return audit_event(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
api_key_id=principal.api_key.id if principal.api_key else None,
action=action,
scope=scope,
object_type=object_type,
object_id=object_id,
details=details,
commit=commit,
)

View File

View File

@@ -0,0 +1,223 @@
from __future__ import annotations
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.orm import Session
from govoplan_core.db.models import Account, ApiKey, AuthSession, Tenant, User
from govoplan_core.db.session import get_session
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_core.access.auth.principals import Principal
from govoplan_core.security.api_keys import authenticate_api_key
from govoplan_core.security.permissions import intersect_api_key_scopes, scopes_grant
from govoplan_core.security.sessions import authenticate_session_token, collect_user_groups, collect_user_scopes, verify_auth_session_csrf
from govoplan_core.settings import settings
@dataclass(slots=True)
class ApiPrincipal:
"""Compatibility wrapper around the platform Principal DTO.
Existing routers still expect ORM ``account`` and ``user`` objects. New
platform/module code should use the stable ID fields or
``to_platform_principal()`` instead.
"""
principal: Principal
account: Account
user: User
api_key: ApiKey | None = None
auth_session: AuthSession | None = None
@property
def account_id(self) -> str:
return self.principal.account_id
@property
def membership_id(self) -> str | None:
return self.principal.membership_id
@property
def tenant_id(self) -> str:
if self.principal.tenant_id is None:
raise RuntimeError("Tenant principal has no active tenant id.")
return self.principal.tenant_id
@property
def scopes(self) -> frozenset[str]:
return self.principal.scopes
@property
def group_ids(self) -> frozenset[str]:
return self.principal.group_ids
@property
def auth_method(self) -> str:
return self.principal.auth_method
@property
def api_key_id(self) -> str | None:
return self.principal.api_key_id
@property
def session_id(self) -> str | None:
return self.principal.session_id
@property
def email(self) -> str | None:
return self.principal.email
@property
def display_name(self) -> str | None:
return self.principal.display_name
def has(self, required_scope: str) -> bool:
# Keep legacy scope aliases alive while current routers still use the
# pre-platform permission catalogue.
return scopes_grant_compatible(self.scopes, required_scope)
def to_platform_principal(self) -> Principal:
return self.principal
def _extract_token(request: Request, authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]:
if x_api_key:
return x_api_key.strip(), "api_key"
if authorization and authorization.lower().startswith("bearer "):
return authorization[7:].strip(), "bearer"
cookie_token = request.cookies.get(settings.auth_session_cookie_name)
if cookie_token:
return cookie_token.strip(), "cookie"
return None, "none"
def _requires_csrf(request: Request) -> bool:
return request.method.upper() not in {"GET", "HEAD", "OPTIONS", "TRACE"}
def _principal_group_ids(session: Session, user: User) -> frozenset[str]:
return frozenset(group.id for group in collect_user_groups(session, user))
def _build_api_principal(
session: Session,
*,
account: Account,
user: User,
tenant_id: str,
scopes: list[str],
auth_method: str,
api_key: ApiKey | None = None,
auth_session: AuthSession | None = None,
) -> ApiPrincipal:
principal = Principal(
account_id=account.id,
membership_id=user.id,
tenant_id=tenant_id,
scopes=frozenset(scopes),
group_ids=_principal_group_ids(session, user),
auth_method=auth_method, # type: ignore[arg-type]
api_key_id=api_key.id if api_key else None,
session_id=auth_session.id if auth_session else None,
email=account.email,
display_name=account.display_name or user.display_name,
)
return ApiPrincipal(
principal=principal,
account=account,
user=user,
api_key=api_key,
auth_session=auth_session,
)
def get_api_principal(
request: Request,
session: Session = Depends(get_session),
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> ApiPrincipal:
token, source = _extract_token(request, authorization, x_api_key)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
# API keys remain supported for CLI/automation. Their permissions are the
# intersection of the key grant and the owner's current tenant roles.
api_key = authenticate_api_key(session, token)
if api_key:
user = session.get(User, api_key.user_id)
account = session.get(Account, user.account_id) if user else None
tenant = session.get(Tenant, api_key.tenant_id)
if (
not user or not account or not tenant
or not user.is_active or not account.is_active or not tenant.is_active
or user.tenant_id != api_key.tenant_id
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
user_scopes = collect_user_scopes(session, user, include_system=False)
effective_scopes = intersect_api_key_scopes(user_scopes, api_key.scopes or [])
session.commit()
return _build_api_principal(
session,
api_key=api_key,
account=account,
user=user,
tenant_id=api_key.tenant_id,
scopes=effective_scopes,
auth_method="api_key",
)
auth_session = authenticate_session_token(session, token)
if auth_session:
user = session.get(User, auth_session.user_id)
account = session.get(Account, auth_session.account_id)
tenant = session.get(Tenant, auth_session.tenant_id)
if (
not user or not account or not tenant
or not user.is_active or not account.is_active or not tenant.is_active
or user.account_id != account.id
or user.tenant_id != tenant.id
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session principal")
if source == "cookie" and _requires_csrf(request):
header_token = request.headers.get("x-csrf-token")
cookie_token = request.cookies.get(settings.auth_csrf_cookie_name)
if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token")
scopes = collect_user_scopes(session, user, include_system=True)
session.commit()
return _build_api_principal(
session,
auth_session=auth_session,
account=account,
user=user,
tenant_id=user.tenant_id,
scopes=scopes,
auth_method="session",
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
def has_scope(principal: ApiPrincipal, required_scope: str) -> bool:
return principal.has(required_scope)
def require_scope(required_scope: str):
def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal:
if not has_scope(principal, required_scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {required_scope}")
return principal
return dependency
def require_any_scope(*required_scopes: str):
def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal:
if not any(has_scope(principal, required) for required in required_scopes):
joined = ", ".join(required_scopes)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {joined}")
return principal
return dependency

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from celery import Celery
from govoplan_core.settings import settings
from govoplan_core.db.session import configure_database
configure_database(settings.database_url)
celery = Celery(
"multimailer",
broker=settings.redis_url,
backend=settings.redis_url,
)
celery.conf.update(
task_default_queue="default",
task_routes={
"multimailer.send_email": {"queue": "send_email"},
"multimailer.append_sent": {"queue": "append_sent"},
},
worker_prefetch_multiplier=1,
task_acks_late=True,
task_reject_on_worker_lost=True,
)
@celery.task(name="multimailer.ping")
def ping():
return "pong"
@celery.task(name="multimailer.send_email", bind=True, max_retries=0)
def send_email(self, job_id: str):
"""Send one explicitly queued campaign job.
SMTP failures are persisted but are not retried implicitly. A worker-loss
redelivery is safe because the delivery service converts an unfinished
SMTP attempt into ``outcome_unknown`` instead of transmitting again.
"""
from govoplan_core.db.session import get_database
from govoplan_campaign.backend.sending.jobs import send_campaign_job
with get_database().SessionLocal() as session:
return send_campaign_job(session, job_id=job_id, enqueue_imap_task=True).as_dict()
@celery.task(name="multimailer.append_sent", bind=True, max_retries=None)
def append_sent(self, job_id: str):
"""Append the exact sent MIME to the configured IMAP Sent folder."""
from govoplan_core.db.session import get_database
from govoplan_mail.backend.sending.imap import ImapAppendError
from govoplan_campaign.backend.sending.jobs import append_sent_for_job
with get_database().SessionLocal() as session:
try:
return append_sent_for_job(session, job_id=job_id).as_dict()
except ImapAppendError as exc:
if getattr(exc, "temporary", None) is True:
raise self.retry(exc=exc, countdown=300)
raise

View File

@@ -0,0 +1 @@
from __future__ import annotations

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
import argparse
from govoplan_core.db.bootstrap import bootstrap_dev_data
from govoplan_core.db.migrations import migrate_database
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.settings import settings
def main() -> None:
parser = argparse.ArgumentParser(description="Initialize the GovOPlaN database")
parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key")
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
args = parser.parse_args()
configure_database(settings.database_url)
migration = migrate_database()
if migration.reconciled_revision:
print(f"Reconciled legacy database marker to {migration.reconciled_revision}.")
print(f"Database schema upgraded to {migration.current_revision}.")
if args.with_dev_data:
with get_database().SessionLocal() as session:
result = bootstrap_dev_data(session, api_key_secret=args.dev_api_key)
print(f"Tenant: {result.tenant.slug} ({result.tenant.id})")
print(f"User: {result.user.email} ({result.user.id})")
if result.created_api_key:
print("Development API key created:")
print(result.created_api_key.secret)
else:
print("Development API key already exists or was not requested.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,2 @@
"""Core platform contracts and runtime registry."""

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from collections.abc import Iterable
from importlib.metadata import EntryPoint, entry_points
from govoplan_core.core.modules import ModuleManifest
ENTRY_POINT_GROUP = "govoplan.modules"
class ModuleDiscoveryError(RuntimeError):
pass
def _load_manifest(entry_point: EntryPoint) -> ModuleManifest:
loaded = entry_point.load()
manifest = loaded() if callable(loaded) else loaded
if not isinstance(manifest, ModuleManifest):
raise ModuleDiscoveryError(f"Entry point {entry_point.name!r} did not return a ModuleManifest")
return manifest
def iter_module_entry_points(group: str = ENTRY_POINT_GROUP) -> tuple[EntryPoint, ...]:
discovered = entry_points()
if hasattr(discovered, "select"):
return tuple(discovered.select(group=group))
return tuple(discovered.get(group, ()))
def discover_module_manifests(
*,
enabled_modules: Iterable[str] | None = None,
group: str = ENTRY_POINT_GROUP,
) -> tuple[ModuleManifest, ...]:
enabled = set(enabled_modules) if enabled_modules is not None else None
manifests: list[ModuleManifest] = []
for entry_point in iter_module_entry_points(group):
manifest = _load_manifest(entry_point)
if enabled is not None and manifest.id not in enabled:
continue
manifests.append(manifest)
return tuple(sorted(manifests, key=lambda item: item.id))

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
@dataclass(frozen=True, slots=True)
class PlatformEvent:
type: str
module_id: str
payload: Mapping[str, Any] = field(default_factory=dict)
occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
EventHandler = Callable[[PlatformEvent], None]
class EventBus:
def __init__(self) -> None:
self._subscribers: dict[str, list[EventHandler]] = defaultdict(list)
def subscribe(self, event_type: str, handler: EventHandler) -> None:
self._subscribers[event_type].append(handler)
def publish(self, event: PlatformEvent) -> None:
for handler in self._subscribers.get(event.type, ()):
handler(event)
for handler in self._subscribers.get("*", ()):
handler(event)

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from sqlalchemy import MetaData
from govoplan_core.core.registry import PlatformRegistry
@dataclass(frozen=True, slots=True)
class MigrationMetadataPlan:
metadata: tuple[MetaData, ...]
script_locations: tuple[str, ...]
def migration_metadata_plan(registry: PlatformRegistry, *, extra_metadata: Iterable[MetaData] = ()) -> MigrationMetadataPlan:
metadata = list(extra_metadata)
script_locations: list[str] = []
for manifest in registry.manifests():
spec = manifest.migration_spec
if spec is None:
continue
if isinstance(spec.metadata, MetaData):
metadata.append(spec.metadata)
if spec.script_location:
script_locations.append(spec.script_location)
return MigrationMetadataPlan(metadata=tuple(metadata), script_locations=tuple(script_locations))

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, TYPE_CHECKING
if TYPE_CHECKING:
from fastapi import APIRouter
PermissionLevel = Literal["system", "tenant"]
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
@dataclass(frozen=True, slots=True)
class PermissionDefinition:
scope: str
label: str
description: str
category: str
level: PermissionLevel
module_id: str
resource: str
action: str
deprecated: bool = False
@dataclass(frozen=True, slots=True)
class RoleTemplate:
slug: str
name: str
description: str
permissions: tuple[str, ...]
level: PermissionLevel = "tenant"
managed: bool = True
protected: bool = False
@dataclass(frozen=True, slots=True)
class NavItem:
path: str
label: str
icon: str | None = None
section: str | None = None
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
order: int = 100
@dataclass(frozen=True, slots=True)
class FrontendRoute:
path: str
component: str
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
order: int = 100
@dataclass(frozen=True, slots=True)
class FrontendModule:
module_id: str
package_name: str | None = None
asset_manifest: str | None = None
routes: tuple[FrontendRoute, ...] = ()
nav_items: tuple[NavItem, ...] = ()
settings_routes: tuple[FrontendRoute, ...] = ()
@dataclass(frozen=True, slots=True)
class MigrationSpec:
module_id: str
metadata: object | None = None
script_location: str | None = None
@dataclass(frozen=True, slots=True)
class AccessDecision:
allowed: bool
reason: str | None = None
requirements: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class ModuleContext:
registry: object
settings: object
data: Mapping[str, Any] = field(default_factory=dict)
class ResourceAclProvider(Protocol):
resource_type: str
def can_read(self, principal: object, resource_id: str) -> bool:
...
def can_write(self, principal: object, resource_id: str) -> bool:
...
def explain(self, principal: object, resource_id: str) -> AccessDecision:
...
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
DeleteVetoProvider = Callable[[object, str, str], None]
RouteFactory = Callable[[ModuleContext], "APIRouter"]
@dataclass(frozen=True, slots=True)
class ModuleManifest:
id: str
name: str
version: str
dependencies: tuple[str, ...] = ()
optional_dependencies: tuple[str, ...] = ()
permissions: tuple[PermissionDefinition, ...] = ()
role_templates: tuple[RoleTemplate, ...] = ()
route_factory: RouteFactory | None = None
migration_spec: MigrationSpec | None = None
nav_items: tuple[NavItem, ...] = ()
frontend: FrontendModule | None = None
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)

View File

@@ -0,0 +1,155 @@
from __future__ import annotations
import re
from collections import defaultdict, deque
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from govoplan_core.core.modules import (
DeleteVetoProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
ResourceAclProvider,
RoleTemplate,
TenantSummaryProvider,
)
_SCOPE_RE = re.compile(r"^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$")
_WILDCARD_RE = re.compile(r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$")
class RegistryError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class RegistrySnapshot:
manifests: tuple[ModuleManifest, ...]
permissions: tuple[PermissionDefinition, ...]
role_templates: tuple[RoleTemplate, ...]
nav_items: tuple[NavItem, ...]
class PlatformRegistry:
def __init__(self) -> None:
self._manifests: dict[str, ModuleManifest] = {}
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
self._delete_veto_providers: dict[str, list[DeleteVetoProvider]] = defaultdict(list)
def register(self, manifest: ModuleManifest) -> ModuleManifest:
if manifest.id in self._manifests:
raise RegistryError(f"Duplicate module id: {manifest.id}")
self._manifests[manifest.id] = manifest
for provider in manifest.tenant_summary_providers:
self.register_tenant_summary_provider(manifest.id, provider)
for resource_type, providers in manifest.delete_veto_providers.items():
for provider in providers:
self.register_delete_veto(resource_type, provider)
return manifest
def get(self, module_id: str) -> ModuleManifest | None:
return self._manifests.get(module_id)
def has_module(self, module_id: str) -> bool:
return module_id in self._manifests
def require_module(self, module_id: str) -> ModuleManifest:
manifest = self.get(module_id)
if manifest is None:
raise RegistryError(f"Required module is not installed: {module_id}")
return manifest
def integration_enabled(self, module_id: str, dependency_id: str) -> bool:
manifest = self.require_module(module_id)
return dependency_id in manifest.dependencies or (dependency_id in manifest.optional_dependencies and self.has_module(dependency_id))
def manifests(self) -> tuple[ModuleManifest, ...]:
return tuple(self._topologically_sorted())
def permissions(self) -> tuple[PermissionDefinition, ...]:
return tuple(permission for manifest in self.manifests() for permission in manifest.permissions)
def role_templates(self) -> tuple[RoleTemplate, ...]:
return tuple(template for manifest in self.manifests() for template in manifest.role_templates)
def nav_items(self) -> tuple[NavItem, ...]:
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
self._tenant_summary_providers[module_id] = provider
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
return dict(self._tenant_summary_providers)
def register_delete_veto(self, resource_type: str, provider: DeleteVetoProvider) -> None:
self._delete_veto_providers[resource_type].append(provider)
def delete_veto_providers(self, resource_type: str) -> tuple[DeleteVetoProvider, ...]:
return tuple(self._delete_veto_providers.get(resource_type, ()))
def validate(self) -> RegistrySnapshot:
ordered = tuple(self._topologically_sorted())
seen_permissions: dict[str, PermissionDefinition] = {}
for manifest in ordered:
for dependency in manifest.dependencies:
if dependency not in self._manifests:
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
for dependency in manifest.optional_dependencies:
if dependency == manifest.id:
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
for permission in manifest.permissions:
if permission.module_id != manifest.id:
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
if not _SCOPE_RE.match(permission.scope):
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
expected_prefix = f"{permission.module_id}:{permission.resource}:"
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
if permission.scope in seen_permissions:
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
seen_permissions[permission.scope] = permission
known_scopes = set(seen_permissions)
for manifest in ordered:
for template in manifest.role_templates:
for scope in template.permissions:
if scope in {"*", "tenant:*", "system:*"}:
continue
if _WILDCARD_RE.match(scope):
continue
if scope not in known_scopes:
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
return RegistrySnapshot(
manifests=ordered,
permissions=tuple(seen_permissions.values()),
role_templates=tuple(template for manifest in ordered for template in manifest.role_templates),
nav_items=self.nav_items(),
)
def _topologically_sorted(self) -> Iterable[ModuleManifest]:
incoming: dict[str, set[str]] = {module_id: set(manifest.dependencies) for module_id, manifest in self._manifests.items()}
dependents: dict[str, set[str]] = defaultdict(set)
for module_id, dependencies in incoming.items():
for dependency in dependencies:
dependents[dependency].add(module_id)
ready = deque(sorted(module_id for module_id, dependencies in incoming.items() if not dependencies))
ordered: list[str] = []
while ready:
module_id = ready.popleft()
ordered.append(module_id)
for dependent in sorted(dependents.get(module_id, ())):
incoming[dependent].discard(module_id)
if not incoming[dependent]:
ready.append(dependent)
if len(ordered) != len(self._manifests):
unresolved = ", ".join(sorted(module_id for module_id, dependencies in incoming.items() if dependencies))
raise RegistryError(f"Module dependency cycle or unresolved dependency: {unresolved}")
return (self._manifests[module_id] for module_id in ordered)

View File

@@ -0,0 +1,14 @@
from govoplan_core.db.base import Base, GovoplanBase, NAMING_CONVENTION, TimestampMixin, utcnow
from govoplan_core.db.session import DatabaseHandle, configure_database, get_database, get_session
__all__ = [
"Base",
"GovoplanBase",
"NAMING_CONVENTION",
"TimestampMixin",
"utcnow",
"DatabaseHandle",
"configure_database",
"get_database",
"get_session",
]

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import JSON, MetaData
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.types import DateTime
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class GovoplanBase(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)
type_annotation_map = {
dict[str, Any]: JSON,
list[dict[str, Any]]: JSON,
list[str]: JSON,
}
Base = GovoplanBase
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from govoplan_core.admin.service import ensure_default_roles, get_or_create_account
from govoplan_core.db.base import Base
from govoplan_core.db.models import Role, SystemRoleAssignment, Tenant, User, UserRoleAssignment
from govoplan_core.db.session import get_database
from govoplan_core.security.api_keys import CreatedApiKey, create_api_key
from govoplan_core.security.permissions import TENANT_SCOPES
DEFAULT_SCOPES = sorted(TENANT_SCOPES)
@dataclass(slots=True)
class BootstrapResult:
tenant: Tenant
user: User
created_api_key: CreatedApiKey | None
def create_all_tables() -> None:
# Import models so SQLAlchemy sees all tables.
from govoplan_core.db import models # noqa: F401
from govoplan_core.access.db import models as access_models # noqa: F401
from govoplan_files.backend.db import models as file_models # noqa: F401
from govoplan_mail.backend.db import models as mail_models # noqa: F401
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401
from govoplan_core.access.db.base import AccessBase
engine = get_database().engine
Base.metadata.create_all(bind=engine)
AccessBase.metadata.create_all(bind=engine)
def bootstrap_dev_data(
session: Session,
*,
api_key_secret: str | None = None,
tenant_slug: str = "default",
user_email: str = "admin@example.local",
user_password: str = "dev-admin",
) -> BootstrapResult:
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
if tenant is None:
tenant = Tenant(slug=tenant_slug, name="Default Tenant", default_locale="en", settings={})
session.add(tenant)
session.flush()
tenant_roles = ensure_default_roles(session, tenant)
system_roles = ensure_default_roles(session, None)
account, _, _ = get_or_create_account(
session,
email=user_email,
display_name="Development Admin",
password=user_password,
password_reset_required=False,
)
# Keep development credentials deterministic when an old create_all database
# is reused. This is guarded by the explicit dev bootstrap setting.
from govoplan_core.security.passwords import hash_password
if not account.password_hash:
account.password_hash = hash_password(user_password)
account.is_active = True
session.add(account)
user = session.query(User).filter(User.tenant_id == tenant.id, User.account_id == account.id).one_or_none()
if user is None:
user = User(
tenant_id=tenant.id,
account_id=account.id,
email=account.email,
display_name="Development Admin",
is_tenant_admin=True, # legacy presentation flag; RBAC uses the owner role below.
auth_provider=account.auth_provider,
password_hash=account.password_hash,
)
session.add(user)
session.flush()
else:
user.email = account.email
user.password_hash = account.password_hash
user.is_active = True
session.add(user)
owner_role = tenant_roles["owner"]
existing_assignment = session.query(UserRoleAssignment).filter(
UserRoleAssignment.tenant_id == tenant.id,
UserRoleAssignment.user_id == user.id,
UserRoleAssignment.role_id == owner_role.id,
).one_or_none()
if existing_assignment is None:
session.add(UserRoleAssignment(tenant_id=tenant.id, user_id=user.id, role_id=owner_role.id))
system_owner = system_roles["system_owner"]
existing_system_assignment = session.query(SystemRoleAssignment).filter(
SystemRoleAssignment.account_id == account.id,
SystemRoleAssignment.role_id == system_owner.id,
).one_or_none()
if existing_system_assignment is None:
session.add(SystemRoleAssignment(account_id=account.id, role_id=system_owner.id))
created_api_key = None
if api_key_secret:
existing = [key for key in user.api_keys if key.name == "Development API key" and key.revoked_at is None]
if not existing:
created_api_key = create_api_key(
session,
user=user,
name="Development API key",
scopes=DEFAULT_SCOPES,
secret=api_key_secret,
)
session.commit()
return BootstrapResult(tenant=tenant, user=user, created_api_key=created_api_key)

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from govoplan_core.db.migrations import migrate_database
def main() -> None:
result = migrate_database()
if result.reconciled_revision:
print(f"Reconciled legacy database marker to {result.reconciled_revision}.")
print(f"Database schema upgraded to {result.current_revision}.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,187 @@
from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
from alembic import command
from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
from govoplan_core.server.default_config import get_server_config
from govoplan_core.server.registry import build_platform_registry
from govoplan_core.settings import settings
# Historic development databases could be created partly through Alembic and
# partly through Base.metadata.create_all(). In that state Alembic still says
# "2c..." while the 3d/4e file-storage tables already exist, so a normal
# upgrade attempts to create file_blobs again. This reconciliation is kept
# deliberately narrow and only advances the marker when the complete expected
# schema for the skipped revisions is already present.
REVISION_AUTH_RBAC = "2c3d4e5f6a7b"
REVISION_FILE_STORAGE = "3d4e5f6a7b8c"
REVISION_FILE_FOLDERS = "4e5f6a7b8c9d"
_FILE_STORAGE_TABLES = {
"file_blobs",
"file_assets",
"file_versions",
"file_shares",
"campaign_attachment_uses",
}
_FILE_FOLDER_TABLES = {"file_folders"}
_FILE_STORAGE_COLUMNS = {
"file_blobs": {
"id",
"tenant_id",
"storage_backend",
"storage_key",
"checksum_sha256",
"size_bytes",
},
"file_assets": {
"id",
"tenant_id",
"owner_type",
"display_path",
"filename",
"current_version_id",
},
"file_versions": {
"id",
"file_asset_id",
"blob_id",
"version_number",
"checksum_sha256",
},
"file_shares": {"id", "file_asset_id", "target_type", "target_id", "permission"},
"campaign_attachment_uses": {
"id",
"campaign_id",
"campaign_version_id",
"file_asset_id",
"file_version_id",
"file_blob_id",
},
"file_folders": {"id", "tenant_id", "owner_type", "path"},
}
@dataclass(frozen=True, slots=True)
class MigrationResult:
previous_revision: str | None
reconciled_revision: str | None
current_revision: str | None
def registered_module_migration_plan() -> MigrationMetadataPlan:
server_config = get_server_config()
registry = build_platform_registry(
server_config.enabled_modules,
manifest_factories=server_config.manifest_factories,
)
return migration_metadata_plan(registry)
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
def alembic_config(*, database_url: str | None = None) -> Config:
root = _repo_root()
config = Config(str(root / "alembic.ini"))
config.set_main_option("script_location", str(root / "alembic"))
plan = registered_module_migration_plan()
version_locations = [str(root / "alembic" / "versions")]
version_locations.extend(location for location in plan.script_locations if location)
config.set_main_option("version_locations", os.pathsep.join(dict.fromkeys(version_locations)))
config.set_main_option("version_path_separator", "os")
config.attributes["database_url"] = database_url or settings.database_url
return config
def database_revision(database_url: str | None = None) -> str | None:
url = database_url or settings.database_url
engine = create_engine(url)
try:
with engine.connect() as connection:
return MigrationContext.configure(connection).get_current_revision()
finally:
engine.dispose()
def _has_columns(inspector, table_name: str, required: set[str]) -> bool:
try:
actual = {column["name"] for column in inspector.get_columns(table_name)}
except Exception:
return False
return required.issubset(actual)
def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str | None:
"""Repair the known Alembic/create_all drift without modifying table data.
Returns the revision stamped during reconciliation, or ``None`` when no
repair was necessary. A partial/unknown schema is intentionally left alone
so Alembic can fail visibly instead of guessing.
"""
url = database_url or settings.database_url
engine = create_engine(url)
try:
with engine.connect() as connection:
current = MigrationContext.configure(connection).get_current_revision()
schema = inspect(connection)
tables = set(schema.get_table_names())
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
for table in _FILE_STORAGE_TABLES
)
has_file_folders = _FILE_FOLDER_TABLES.issubset(tables) and _has_columns(
schema,
"file_folders",
_FILE_STORAGE_COLUMNS["file_folders"],
)
finally:
engine.dispose()
target: str | None = None
if current == REVISION_AUTH_RBAC and has_file_storage and has_file_folders:
target = REVISION_FILE_FOLDERS
elif current == REVISION_AUTH_RBAC and has_file_storage:
target = REVISION_FILE_STORAGE
elif current == REVISION_FILE_STORAGE and has_file_folders:
target = REVISION_FILE_FOLDERS
elif current is None and has_file_storage and has_file_folders:
# This is the other create_all-only development shape. The strict
# column checks above ensure that we only stamp a complete known schema.
target = REVISION_FILE_FOLDERS
if target is None:
return None
command.stamp(alembic_config(database_url=url), target)
return target
def migrate_database(
*,
database_url: str | None = None,
reconcile_legacy_schema: bool = True,
) -> MigrationResult:
url = database_url or settings.database_url
previous = database_revision(url)
reconciled = reconcile_legacy_create_all_schema(url) if reconcile_legacy_schema else None
command.upgrade(alembic_config(database_url=url), "head")
current = database_revision(url)
return MigrationResult(
previous_revision=previous,
reconciled_revision=reconciled,
current_revision=current,
)

View File

@@ -0,0 +1,271 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, JSON, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class Account(Base, TimestampMixin):
"""Global login identity shared by one or more tenant memberships."""
__tablename__ = "accounts"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
email: Mapped[str] = mapped_column(String(320), nullable=False)
normalized_email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
display_name: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
auth_provider: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
password_hash: Mapped[str | None] = mapped_column(String(500))
password_reset_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
memberships: Mapped[list[User]] = relationship(back_populates="account")
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="account", cascade="all, delete-orphan")
system_role_assignments: Mapped[list[SystemRoleAssignment]] = relationship(
back_populates="account", cascade="all, delete-orphan"
)
class Tenant(Base, TimestampMixin):
__tablename__ = "tenants"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
allow_custom_groups: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
allow_custom_roles: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
allow_api_keys: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
users: Mapped[list[User]] = relationship(back_populates="tenant", cascade="all, delete-orphan")
class User(Base, TimestampMixin):
__tablename__ = "users"
__table_args__ = (
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
UniqueConstraint("tenant_id", "account_id", name="uq_users_tenant_account"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
display_name: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_tenant_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
auth_provider: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
password_hash: Mapped[str | None] = mapped_column(String(500))
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
tenant: Mapped[Tenant] = relationship(back_populates="users")
account: Mapped[Account] = relationship(back_populates="memberships")
api_keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan")
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="user", cascade="all, delete-orphan")
class Group(Base, TimestampMixin):
__tablename__ = "groups"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_groups_tenant_slug"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
system_template_id: Mapped[str | None] = mapped_column(ForeignKey("governance_templates.id", ondelete="SET NULL"), nullable=True, index=True)
system_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class Role(Base, TimestampMixin):
__tablename__ = "roles"
__table_args__ = (
UniqueConstraint("tenant_id", "slug", name="uq_roles_tenant_slug"),
Index(
"uq_roles_system_slug",
"slug",
unique=True,
sqlite_where=text("tenant_id IS NULL"),
postgresql_where=text("tenant_id IS NULL"),
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_assignable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
system_template_id: Mapped[str | None] = mapped_column(ForeignKey("governance_templates.id", ondelete="SET NULL"), nullable=True, index=True)
system_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
class SystemSettings(Base, TimestampMixin):
__tablename__ = "system_settings"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global")
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
allow_tenant_custom_groups: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_tenant_custom_roles: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_tenant_api_keys: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class GovernanceTemplate(Base, TimestampMixin):
__tablename__ = "governance_templates"
__table_args__ = (UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class GovernanceTemplateAssignment(Base, TimestampMixin):
__tablename__ = "governance_template_assignments"
__table_args__ = (UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
template_id: Mapped[str] = mapped_column(ForeignKey("governance_templates.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
mode: Mapped[str] = mapped_column(String(20), default="available", nullable=False)
class SystemRoleAssignment(Base, TimestampMixin):
__tablename__ = "system_role_assignments"
__table_args__ = (UniqueConstraint("account_id", "role_id", name="uq_system_role_assignments"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
account: Mapped[Account] = relationship(back_populates="system_role_assignments")
role: Mapped[Role] = relationship()
class UserGroupMembership(Base, TimestampMixin):
__tablename__ = "user_group_memberships"
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True)
class UserRoleAssignment(Base, TimestampMixin):
__tablename__ = "user_role_assignments"
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_role_assignments"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
class GroupRoleAssignment(Base, TimestampMixin):
__tablename__ = "group_role_assignments"
__table_args__ = (UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_role_assignments"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
class ApiKey(Base, TimestampMixin):
__tablename__ = "api_keys"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
prefix: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
key_hash: Mapped[str] = mapped_column(String(128), nullable=False)
scopes: Mapped[list[str]] = mapped_column(JSON, default=list)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
user: Mapped[User] = relationship(back_populates="api_keys")
class AuthSession(Base, TimestampMixin):
__tablename__ = "auth_sessions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
csrf_token_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
user_agent: Mapped[str | None] = mapped_column(String(500))
ip_address: Mapped[str | None] = mapped_column(String(100))
user: Mapped[User] = relationship(back_populates="auth_sessions")
account: Mapped[Account] = relationship(back_populates="auth_sessions")
class AuditLog(Base, TimestampMixin):
__tablename__ = "audit_log"
__table_args__ = (
Index("ix_audit_log_scope_created_at", "scope", "created_at"),
Index("ix_audit_log_tenant_scope_created_at", "tenant_id", "scope", "created_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
scope: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
api_key_id: Mapped[str | None] = mapped_column(ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True, index=True)
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
object_type: Mapped[str | None] = mapped_column(String(100), index=True)
object_id: Mapped[str | None] = mapped_column(String(100), index=True)
details: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
__all__ = [
"Account",
"ApiKey",
"AuditLog",
"AuthSession",
"GovernanceTemplate",
"GovernanceTemplateAssignment",
"Group",
"GroupRoleAssignment",
"Role",
"SystemRoleAssignment",
"SystemSettings",
"Tenant",
"User",
"UserGroupMembership",
"UserRoleAssignment",
]

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from collections.abc import Generator, Mapping
from typing import Any
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
def default_connect_args(database_url: str) -> dict[str, Any]:
return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
def create_database_engine(
database_url: str,
*,
pool_pre_ping: bool = True,
connect_args: Mapping[str, Any] | None = None,
**kwargs: Any,
) -> Engine:
merged_connect_args = dict(default_connect_args(database_url))
if connect_args:
merged_connect_args.update(connect_args)
return create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs)
class DatabaseHandle:
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
self.database_url = database_url
self.engine = engine or create_database_engine(database_url)
self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, expire_on_commit=False)
def session(self) -> Session:
return self.SessionLocal()
def dependency(self) -> Generator[Session, None, None]:
with self.SessionLocal() as session:
yield session
_default_database: DatabaseHandle | None = None
def configure_database(database_url: str, *, engine: Engine | None = None) -> DatabaseHandle:
global _default_database
if engine is None and _default_database is not None and _default_database.database_url == database_url:
return _default_database
_default_database = DatabaseHandle(database_url, engine=engine)
return _default_database
def set_database(handle: DatabaseHandle) -> DatabaseHandle:
global _default_database
_default_database = handle
return handle
def get_database() -> DatabaseHandle:
if _default_database is None:
raise RuntimeError("GovOPlaN database is not configured")
return _default_database
def get_session() -> Generator[Session, None, None]:
yield from get_database().dependency()

View File

View File

@@ -0,0 +1,664 @@
from __future__ import annotations
import copy
import hashlib
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy.orm import Session
from govoplan_core.admin.governance import get_system_settings
from govoplan_core.db.models import AuditLog, Group, SystemSettings, Tenant, User
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus, JobSendStatus
from govoplan_core.settings import settings
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
AUDIT_DETAIL_LEVEL_ORDER = {"full": 0, "redacted": 1, "minimal": 2}
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
FINAL_VERSION_STATES = {
"completed",
"partially_completed",
"outcome_unknown",
"failed",
"cancelled",
"archived",
}
FINAL_EML_SEND_STATUSES = {
JobSendStatus.SMTP_ACCEPTED.value,
JobSendStatus.SENT.value,
JobSendStatus.FAILED_PERMANENT.value,
JobSendStatus.CANCELLED.value,
JobSendStatus.OUTCOME_UNKNOWN.value,
}
AUDIT_DETAIL_REDACTION_KEYS = {
"attachments",
"bcc",
"body",
"campaign_json",
"cc",
"content",
"entries",
"html",
"imap",
"message",
"password",
"raw_eml",
"raw_json",
"recipients",
"secret",
"smtp",
"template",
"text",
"token",
"to",
}
class PrivacyRetentionPolicy(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator(
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
mode="before",
)
@classmethod
def _blank_string_is_none(cls, value: Any) -> Any:
if value == "":
return None
return value
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return _normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator(*RETENTION_DAY_KEYS, mode="before")
@classmethod
def _blank_string_is_none(cls, value: Any) -> Any:
if value == "":
return None
return value
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return _normalize_allow_lower_level_limits(value, fill_defaults=False)
class PrivacyPolicyError(RuntimeError):
pass
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _cutoff(days: int | None, *, now: datetime) -> datetime | None:
if days is None:
return None
return now - timedelta(days=days)
def _json_sha256(value: Any) -> str:
payload = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _privacy_policy_data(settings_payload: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(settings_payload, dict):
return {}
data = settings_payload.get(PRIVACY_POLICY_SETTINGS_KEY, {})
return data if isinstance(data, dict) else {}
def _privacy_policy_patch_from_settings(settings_payload: dict[str, Any] | None) -> dict[str, Any]:
data = _privacy_policy_data(settings_payload)
if not data:
return {}
return PrivacyRetentionPolicyPatch.model_validate(data).model_dump(mode="json", exclude_none=True)
def _merge_privacy_policy(parent: PrivacyRetentionPolicy, patch: dict[str, Any]) -> PrivacyRetentionPolicy:
payload = parent.model_dump(mode="json")
parent_allow = {**default_allow_lower_level_limits(), **(payload.get("allow_lower_level_limits") or {})}
if parent_allow["store_raw_campaign_json"] and patch.get("store_raw_campaign_json") is False:
payload["store_raw_campaign_json"] = False
for key in RETENTION_DAY_KEYS:
value = patch.get(key)
if value is None or not parent_allow[key]:
continue
current = payload.get(key)
payload[key] = int(value) if current is None else min(int(current), int(value))
detail_level = patch.get("audit_detail_level")
if parent_allow["audit_detail_level"] and detail_level and AUDIT_DETAIL_LEVEL_ORDER[detail_level] > AUDIT_DETAIL_LEVEL_ORDER[payload["audit_detail_level"]]:
payload["audit_detail_level"] = detail_level
patch_allow = patch.get("allow_lower_level_limits") or {}
payload["allow_lower_level_limits"] = {
key: parent_allow[key] and bool(patch_allow.get(key, parent_allow[key]))
for key in RETENTION_POLICY_FIELD_KEYS
}
return PrivacyRetentionPolicy.model_validate(payload)
def _validate_privacy_patch_against_parent(parent: PrivacyRetentionPolicy, patch: dict[str, Any]) -> None:
parent_payload = parent.model_dump(mode="json")
parent_allow = {**default_allow_lower_level_limits(), **(parent_payload.get("allow_lower_level_limits") or {})}
for key in RETENTION_POLICY_FIELD_KEYS:
if key in patch and patch.get(key) is not None and not parent_allow[key]:
raise PrivacyPolicyError(f"{key} is locked by the parent retention policy.")
patch_allow = patch.get("allow_lower_level_limits") or {}
for key, allowed in patch_allow.items():
if allowed and not parent_allow[key]:
raise PrivacyPolicyError(f"{key} limiting cannot be re-enabled below a parent retention policy lock.")
if patch.get("store_raw_campaign_json") is True and not parent.store_raw_campaign_json:
raise PrivacyPolicyError("Raw campaign JSON storage cannot be re-enabled below a parent policy that disables it.")
for key in RETENTION_DAY_KEYS:
value = patch.get(key)
parent_value = parent_payload.get(key)
if value is not None and parent_value is not None and int(value) > int(parent_value):
raise PrivacyPolicyError(f"{key} cannot be less restrictive than the parent retention policy.")
detail_level = patch.get("audit_detail_level")
if detail_level and AUDIT_DETAIL_LEVEL_ORDER[detail_level] < AUDIT_DETAIL_LEVEL_ORDER[parent.audit_detail_level]:
raise PrivacyPolicyError("Audit detail level cannot be less restrictive than the parent retention policy.")
def _set_settings_privacy_policy(settings_payload: dict[str, Any] | None, policy: dict[str, Any]) -> dict[str, Any]:
payload = dict(settings_payload or {})
payload[PRIVACY_POLICY_SETTINGS_KEY] = policy
return payload
def privacy_policy_from_settings(item: SystemSettings) -> PrivacyRetentionPolicy:
return PrivacyRetentionPolicy.model_validate(_privacy_policy_data(item.settings or {}))
def privacy_policy_from_session(session: Session) -> PrivacyRetentionPolicy:
return privacy_policy_from_settings(get_system_settings(session))
def set_privacy_policy(item: SystemSettings, policy: PrivacyRetentionPolicy | dict[str, Any]) -> PrivacyRetentionPolicy:
validated = policy if isinstance(policy, PrivacyRetentionPolicy) else PrivacyRetentionPolicy.model_validate(policy)
item.settings = _set_settings_privacy_policy(item.settings, validated.model_dump(mode="json"))
return validated
def effective_privacy_policy(
session: Session,
*,
tenant_id: str | None = None,
owner_user_id: str | None = None,
owner_group_id: str | None = None,
campaign_id: str | None = None,
) -> PrivacyRetentionPolicy:
policy = privacy_policy_from_session(session)
campaign: Campaign | None = None
if campaign_id:
campaign = session.get(Campaign, campaign_id)
if campaign is None:
raise PrivacyPolicyError("Campaign not found for privacy policy")
tenant_id = campaign.tenant_id
owner_user_id = campaign.owner_user_id
owner_group_id = campaign.owner_group_id
if tenant_id:
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise PrivacyPolicyError("Tenant not found for privacy policy")
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(tenant.settings or {}))
if owner_user_id:
user = session.get(User, owner_user_id)
if user and (not tenant_id or user.tenant_id == tenant_id):
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(user.settings or {}))
if owner_group_id:
group = session.get(Group, owner_group_id)
if group and (not tenant_id or group.tenant_id == tenant_id):
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(group.settings or {}))
if campaign is not None:
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(campaign.settings or {}))
return policy
def parent_privacy_policy(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None = None) -> PrivacyRetentionPolicy:
clean_scope = scope_type.strip().casefold()
policy = privacy_policy_from_session(session)
if clean_scope == "tenant":
return policy
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise PrivacyPolicyError("Tenant not found for privacy policy")
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(tenant.settings or {}))
if clean_scope in {"user", "group"}:
return policy
if clean_scope != "campaign" or not scope_id:
return policy
campaign = session.get(Campaign, scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise PrivacyPolicyError("Campaign not found for privacy policy")
if campaign.owner_user_id:
user = session.get(User, campaign.owner_user_id)
if user and user.tenant_id == tenant_id:
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(user.settings or {}))
if campaign.owner_group_id:
group = session.get(Group, campaign.owner_group_id)
if group and group.tenant_id == tenant_id:
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(group.settings or {}))
return policy
def get_privacy_policy_for_scope(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None = None) -> dict[str, Any]:
clean_scope = scope_type.strip().casefold()
if clean_scope == "system":
return privacy_policy_from_session(session).model_dump(mode="json")
if clean_scope == "tenant":
tenant = session.get(Tenant, scope_id or tenant_id)
if tenant is None or tenant.id != tenant_id:
raise PrivacyPolicyError("Tenant privacy policy not found")
return _privacy_policy_patch_from_settings(tenant.settings or {})
if not scope_id:
raise PrivacyPolicyError(f"{clean_scope.capitalize()} privacy policy requires scope_id")
if clean_scope == "user":
user = session.get(User, scope_id)
if user is None or user.tenant_id != tenant_id:
raise PrivacyPolicyError("User privacy policy not found")
return _privacy_policy_patch_from_settings(user.settings or {})
if clean_scope == "group":
group = session.get(Group, scope_id)
if group is None or group.tenant_id != tenant_id:
raise PrivacyPolicyError("Group privacy policy not found")
return _privacy_policy_patch_from_settings(group.settings or {})
if clean_scope == "campaign":
campaign = session.get(Campaign, scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise PrivacyPolicyError("Campaign privacy policy not found")
return _privacy_policy_patch_from_settings(campaign.settings or {})
raise PrivacyPolicyError("Privacy policy scope must be system, tenant, user, group or campaign")
def set_privacy_policy_for_scope(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
policy: dict[str, Any] | None = None,
) -> dict[str, Any]:
clean_scope = scope_type.strip().casefold()
if clean_scope == "system":
item = get_system_settings(session)
validated = set_privacy_policy(item, PrivacyRetentionPolicy.model_validate(policy or {}))
session.add(item)
return validated.model_dump(mode="json")
patch = PrivacyRetentionPolicyPatch.model_validate(policy or {}).model_dump(mode="json", exclude_none=True)
_validate_privacy_patch_against_parent(parent_privacy_policy(session, tenant_id=tenant_id, scope_type=clean_scope, scope_id=scope_id or (tenant_id if clean_scope == "tenant" else None)), patch)
if clean_scope == "tenant":
tenant = session.get(Tenant, scope_id or tenant_id)
if tenant is None or tenant.id != tenant_id:
raise PrivacyPolicyError("Tenant privacy policy not found")
tenant.settings = _set_settings_privacy_policy(tenant.settings, patch)
session.add(tenant)
return patch
if not scope_id:
raise PrivacyPolicyError(f"{clean_scope.capitalize()} privacy policy requires scope_id")
if clean_scope == "user":
user = session.get(User, scope_id)
if user is None or user.tenant_id != tenant_id:
raise PrivacyPolicyError("User privacy policy not found")
user.settings = _set_settings_privacy_policy(user.settings, patch)
session.add(user)
return patch
if clean_scope == "group":
group = session.get(Group, scope_id)
if group is None or group.tenant_id != tenant_id:
raise PrivacyPolicyError("Group privacy policy not found")
group.settings = _set_settings_privacy_policy(group.settings, patch)
session.add(group)
return patch
if clean_scope == "campaign":
campaign = session.get(Campaign, scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise PrivacyPolicyError("Campaign privacy policy not found")
campaign.settings = _set_settings_privacy_policy(campaign.settings, patch)
session.add(campaign)
return patch
raise PrivacyPolicyError("Privacy policy scope must be system, tenant, user, group or campaign")
def sanitize_audit_details_for_policy(session: Session, details: dict[str, Any]) -> dict[str, Any]:
policy = privacy_policy_from_session(session)
if policy.audit_detail_level == "full":
return details
if policy.audit_detail_level == "minimal":
return {
"_privacy": {"detail_level": "minimal"},
"keys": sorted(str(key) for key in details.keys()),
}
return _redact_audit_value(details)
def _redact_audit_value(value: Any) -> Any:
if isinstance(value, dict):
redacted: dict[str, Any] = {}
for key, item in value.items():
if str(key).lower() in AUDIT_DETAIL_REDACTION_KEYS:
redacted[key] = "<redacted>"
else:
redacted[key] = _redact_audit_value(item)
return redacted
if isinstance(value, list):
return [_redact_audit_value(item) for item in value]
return value
def _is_before_cutoff(value: datetime | None, cutoff: datetime | None) -> bool:
if value is None or cutoff is None:
return False
candidate = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
return candidate < cutoff
def _system_cutoffs(policy: PrivacyRetentionPolicy, *, now: datetime) -> dict[str, datetime | None]:
return {
"raw_campaign_json": _cutoff(0 if not policy.store_raw_campaign_json else policy.raw_campaign_json_retention_days, now=now),
"generated_eml": _cutoff(policy.generated_eml_retention_days, now=now),
"stored_report_detail": _cutoff(policy.stored_report_detail_retention_days, now=now),
"mock_mailbox": _cutoff(policy.mock_mailbox_retention_days, now=now),
"audit_detail": _cutoff(policy.audit_detail_retention_days, now=now),
}
def apply_retention_policy(session: Session, *, dry_run: bool = True) -> dict[str, Any]:
now = _utcnow()
policy = privacy_policy_from_session(session)
cutoffs = _system_cutoffs(policy, now=now)
counts = {
"raw_campaign_json": _apply_raw_json_retention(session, dry_run=dry_run, now=now),
"generated_eml": _apply_eml_retention(session, dry_run=dry_run, now=now),
"stored_report_detail": _apply_report_detail_retention(session, dry_run=dry_run, now=now),
"mock_mailbox": _apply_mock_mailbox_retention(cutoffs["mock_mailbox"], dry_run=dry_run),
"audit_detail": _apply_audit_detail_retention(session, dry_run=dry_run, now=now),
}
return {
"dry_run": dry_run,
"policy": policy.model_dump(mode="json"),
"cutoffs": {key: value.isoformat() if value else None for key, value in cutoffs.items()},
"effective_policy_scope": "per-object",
"counts": counts,
}
def _campaign_policy_for_id(session: Session, campaign_id: str | None, cache: dict[str, PrivacyRetentionPolicy]) -> PrivacyRetentionPolicy:
if not campaign_id:
return privacy_policy_from_session(session)
if campaign_id not in cache:
cache[campaign_id] = effective_privacy_policy(session, campaign_id=campaign_id)
return cache[campaign_id]
def _apply_raw_json_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]:
result = {"eligible": 0, "redacted": 0, "skipped_not_final": 0, "already_redacted": 0}
policy_cache: dict[str, PrivacyRetentionPolicy] = {}
versions = (
session.query(CampaignVersion)
.order_by(CampaignVersion.updated_at.asc())
.all()
)
for version in versions:
policy = _campaign_policy_for_id(session, version.campaign_id, policy_cache)
cutoff = _cutoff(0 if not policy.store_raw_campaign_json else policy.raw_campaign_json_retention_days, now=now)
if not _is_before_cutoff(version.updated_at, cutoff):
continue
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
if raw_json.get("_retention", {}).get("raw_json_redacted"):
result["already_redacted"] += 1
continue
if version.workflow_state not in FINAL_VERSION_STATES:
result["skipped_not_final"] += 1
continue
result["eligible"] += 1
if dry_run:
continue
snapshot = version.execution_snapshot if isinstance(version.execution_snapshot, dict) else {}
version.raw_json = {
"version": version.schema_version or "1.0",
"_retention": {
"raw_json_redacted": True,
"redacted_at": now.isoformat(),
"original_sha256": snapshot.get("campaign_json_sha256") or _json_sha256(raw_json),
},
}
session.add(version)
result["redacted"] += 1
return result
def _apply_eml_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]:
result = {"eligible": 0, "metadata_cleared": 0, "files_deleted": 0, "files_missing": 0, "skipped_not_final": 0}
policy_cache: dict[str, PrivacyRetentionPolicy] = {}
jobs = (
session.query(CampaignJob)
.filter((CampaignJob.eml_local_path.is_not(None)) | (CampaignJob.eml_storage_key.is_not(None)))
.order_by(CampaignJob.updated_at.asc())
.all()
)
for job in jobs:
policy = _campaign_policy_for_id(session, job.campaign_id, policy_cache)
cutoff = _cutoff(policy.generated_eml_retention_days, now=now)
if not _is_before_cutoff(job.updated_at, cutoff):
continue
if job.queue_status in {JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value} or job.send_status not in FINAL_EML_SEND_STATUSES:
result["skipped_not_final"] += 1
continue
if job.imap_status == JobImapStatus.PENDING.value:
result["skipped_not_final"] += 1
continue
result["eligible"] += 1
if dry_run:
continue
if job.eml_local_path:
path = Path(job.eml_local_path)
if path.exists():
path.unlink()
result["files_deleted"] += 1
else:
result["files_missing"] += 1
job.eml_local_path = None
job.eml_storage_key = None
session.add(job)
result["metadata_cleared"] += 1
return result
def _apply_report_detail_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]:
result = {"eligible_versions": 0, "summaries_redacted": 0, "already_redacted": 0}
policy_cache: dict[str, PrivacyRetentionPolicy] = {}
versions = (
session.query(CampaignVersion)
.order_by(CampaignVersion.updated_at.asc())
.all()
)
for version in versions:
policy = _campaign_policy_for_id(session, version.campaign_id, policy_cache)
cutoff = _cutoff(policy.stored_report_detail_retention_days, now=now)
if not _is_before_cutoff(version.updated_at, cutoff):
continue
next_validation, validation_changed = _redacted_report_summary(version.validation_summary, now=now)
next_build, build_changed = _redacted_report_summary(version.build_summary, now=now)
if not validation_changed and not build_changed:
if _summary_was_redacted(version.validation_summary) or _summary_was_redacted(version.build_summary):
result["already_redacted"] += 1
continue
result["eligible_versions"] += 1
if dry_run:
continue
version.validation_summary = next_validation
version.build_summary = next_build
session.add(version)
result["summaries_redacted"] += int(validation_changed) + int(build_changed)
return result
def _summary_was_redacted(summary: Any) -> bool:
return isinstance(summary, dict) and bool(summary.get("_retention", {}).get("report_detail_redacted"))
def _redacted_report_summary(summary: Any, *, now: datetime) -> tuple[dict[str, Any] | None, bool]:
if not isinstance(summary, dict):
return summary, False
if _summary_was_redacted(summary):
return summary, False
detail_keys = {"messages", "issues", "recent_failures", "jobs"}
if not any(key in summary for key in detail_keys):
return summary, False
next_summary = copy.deepcopy(summary)
for key in detail_keys:
next_summary.pop(key, None)
retention = dict(next_summary.get("_retention") or {})
retention.update({"report_detail_redacted": True, "redacted_at": now.isoformat()})
next_summary["_retention"] = retention
return next_summary, True
def _apply_mock_mailbox_retention(cutoff: datetime | None, *, dry_run: bool) -> dict[str, int]:
result = {"eligible_records": 0, "json_deleted": 0, "eml_deleted": 0}
if cutoff is None:
return result
message_dir = Path(settings.mock_mailbox_dir) / "messages"
if not message_dir.exists():
return result
for path in message_dir.glob("*.json"):
try:
record = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
continue
created_at = _parse_datetime(record.get("created_at"))
if created_at and created_at >= cutoff:
continue
result["eligible_records"] += 1
if dry_run:
continue
raw_filename = record.get("raw_filename")
if raw_filename:
raw_path = message_dir / str(raw_filename)
if raw_path.exists():
raw_path.unlink()
result["eml_deleted"] += 1
if path.exists():
path.unlink()
result["json_deleted"] += 1
return result
def _parse_datetime(value: Any) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _privacy_policy_for_audit_item(session: Session, item: AuditLog, campaign_cache: dict[str, PrivacyRetentionPolicy], tenant_cache: dict[str, PrivacyRetentionPolicy]) -> PrivacyRetentionPolicy:
if item.object_type == "campaign" and item.object_id:
campaign = session.get(Campaign, str(item.object_id))
if campaign is not None:
return _campaign_policy_for_id(session, campaign.id, campaign_cache)
if item.tenant_id:
if item.tenant_id not in tenant_cache:
tenant_cache[item.tenant_id] = effective_privacy_policy(session, tenant_id=item.tenant_id)
return tenant_cache[item.tenant_id]
return privacy_policy_from_session(session)
def _apply_audit_detail_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]:
result = {"eligible": 0, "redacted": 0, "already_redacted": 0}
campaign_cache: dict[str, PrivacyRetentionPolicy] = {}
tenant_cache: dict[str, PrivacyRetentionPolicy] = {}
items = (
session.query(AuditLog)
.order_by(AuditLog.created_at.asc())
.all()
)
for item in items:
policy = _privacy_policy_for_audit_item(session, item, campaign_cache, tenant_cache)
cutoff = _cutoff(policy.audit_detail_retention_days, now=now)
if not _is_before_cutoff(item.created_at, cutoff):
continue
details = item.details if isinstance(item.details, dict) else {}
if details.get("_retention", {}).get("audit_detail_redacted"):
result["already_redacted"] += 1
continue
result["eligible"] += 1
if dry_run:
continue
item.details = {
"_retention": {
"audit_detail_redacted": True,
"redacted_at": now.isoformat(),
"original_detail_sha256": _json_sha256(details),
}
}
session.add(item)
result["redacted"] += 1
return result

View File

View File

View File

@@ -0,0 +1,80 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy.orm import Session
from govoplan_core.db.models import ApiKey, User
from govoplan_core.access.auth.tokens import generate_secret, hash_secret, verify_secret
from govoplan_core.security.time import ensure_aware_utc, utc_now
API_KEY_PREFIX_LENGTH = 12
API_KEY_RANDOM_BYTES = 32
@dataclass(slots=True)
class CreatedApiKey:
model: ApiKey
secret: str
def hash_api_key(secret: str) -> str:
return hash_secret(secret)
def verify_api_key(secret: str, expected_hash: str) -> bool:
return verify_secret(secret, expected_hash)
def generate_api_key_secret() -> str:
return generate_secret("mm_", random_bytes=API_KEY_RANDOM_BYTES)
def api_key_prefix(secret: str) -> str:
# Prefix is only a lookup helper and must not be enough to authenticate.
return secret[:API_KEY_PREFIX_LENGTH]
def create_api_key(
session: Session,
*,
user: User,
name: str,
scopes: list[str],
secret: str | None = None,
expires_at: datetime | None = None,
) -> CreatedApiKey:
secret = secret or generate_api_key_secret()
model = ApiKey(
tenant_id=user.tenant_id,
user_id=user.id,
name=name,
prefix=api_key_prefix(secret),
key_hash=hash_api_key(secret),
scopes=scopes,
expires_at=expires_at,
)
session.add(model)
session.flush()
return CreatedApiKey(model=model, secret=secret)
def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
prefix = api_key_prefix(secret)
candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all()
now = utc_now()
for candidate in candidates:
expires_at = ensure_aware_utc(candidate.expires_at)
if expires_at and expires_at < now:
continue
if verify_api_key(secret, candidate.key_hash):
candidate.last_used_at = now
session.add(candidate)
return candidate
return None
def has_scope(api_key: ApiKey, required_scope: str) -> bool:
scopes = set(api_key.scopes or [])
return "*" in scopes or required_scope in scopes

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from collections.abc import Iterable
from govoplan_core.security.permissions import scopes_grant
LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
"campaign:read": "campaigns:campaign:read",
"campaign:create": "campaigns:campaign:create",
"campaign:update": "campaigns:campaign:update",
"campaign:copy": "campaigns:campaign:copy",
"campaign:archive": "campaigns:campaign:archive",
"campaign:delete": "campaigns:campaign:delete",
"campaign:share": "campaigns:campaign:share",
"campaign:validate": "campaigns:campaign:validate",
"campaign:build": "campaigns:campaign:build",
"campaign:review": "campaigns:campaign:review",
"campaign:send_test": "campaigns:campaign:send_test",
"campaign:queue": "campaigns:campaign:queue",
"campaign:control": "campaigns:campaign:control",
"campaign:send": "campaigns:campaign:send",
"campaign:retry": "campaigns:campaign:retry",
"campaign:reconcile": "campaigns:campaign:reconcile",
"recipients:read": "campaigns:recipient:read",
"recipients:write": "campaigns:recipient:write",
"recipients:import": "campaigns:recipient:import",
"recipients:export": "campaigns:recipient:export",
"reports:read": "campaigns:report:read",
"reports:export": "campaigns:report:export",
"reports:send": "campaigns:report:send",
"files:read": "files:file:read",
"files:download": "files:file:download",
"files:upload": "files:file:upload",
"files:organize": "files:file:organize",
"files:share": "files:file:share",
"files:delete": "files:file:delete",
"files:admin": "files:file:admin",
"mail_servers:read": "mail:profile:read",
"mail_servers:use": "mail:profile:use",
"mail_servers:test": "mail:profile:test",
"mail_servers:write": "mail:profile:write",
"mail_servers:manage_credentials": "mail:secret:manage",
}
MODULE_TO_LEGACY_SCOPES = {module: legacy for legacy, module in LEGACY_TO_MODULE_SCOPES.items()}
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values())
def compatible_required_scopes(required: str) -> tuple[str, ...]:
legacy = MODULE_TO_LEGACY_SCOPES.get(required)
if legacy:
return (required, legacy)
module = LEGACY_TO_MODULE_SCOPES.get(required)
if module:
return (required, module)
return (required,)
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
granted = list(scopes)
if scopes_grant(granted, required):
return True
if "tenant:*" in granted or "*" in granted:
if required in MODULE_TENANT_SCOPES:
return True
return any(scopes_grant(granted, alias) for alias in compatible_required_scopes(required) if alias != required)

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import os
_ALGORITHM = "pbkdf2_sha256"
_DEFAULT_ITERATIONS = 260_000
_SALT_BYTES = 16
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
salt = os.urandom(_SALT_BYTES)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return "$".join([
_ALGORITHM,
str(iterations),
base64.b64encode(salt).decode("ascii"),
base64.b64encode(digest).decode("ascii"),
])
def verify_password(password: str, encoded: str | None) -> bool:
if not encoded:
return False
try:
algorithm, iterations_text, salt_b64, digest_b64 = encoded.split("$", 3)
if algorithm != _ALGORITHM:
return False
iterations = int(iterations_text)
salt = base64.b64decode(salt_b64.encode("ascii"))
expected = base64.b64decode(digest_b64.encode("ascii"))
except Exception:
return False
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(actual, expected)

View File

@@ -0,0 +1,299 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
@dataclass(frozen=True, slots=True)
class PermissionDefinition:
scope: str
label: str
description: str
category: str
level: str = "tenant"
TENANT_PERMISSIONS: tuple[PermissionDefinition, ...] = (
PermissionDefinition("campaign:read", "View campaigns", "Open campaign metadata, versions and permitted message summaries.", "Campaigns"),
PermissionDefinition("campaign:create", "Create campaigns", "Create new campaigns in an accessible tenant scope.", "Campaigns"),
PermissionDefinition("campaign:update", "Edit campaigns", "Edit current working campaign versions.", "Campaigns"),
PermissionDefinition("campaign:copy", "Copy campaigns", "Create a new campaign or working version from an existing campaign.", "Campaigns"),
PermissionDefinition("campaign:archive", "Archive campaigns", "Archive campaigns without destroying audit evidence.", "Campaigns"),
PermissionDefinition("campaign:delete", "Delete campaigns", "Delete draft-only campaigns where retention policy allows it.", "Campaigns"),
PermissionDefinition("campaign:share", "Share campaigns", "Grant or revoke explicit campaign access for users and groups.", "Campaigns"),
PermissionDefinition("campaign:validate", "Validate campaigns", "Run technical validation and manage validation locks.", "Campaigns"),
PermissionDefinition("campaign:build", "Build campaigns", "Build exact messages and attachment evidence.", "Campaigns"),
PermissionDefinition("campaign:review", "Approve campaign review", "Approve or reject built messages and non-blocking review conditions.", "Campaigns"),
PermissionDefinition("campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "Campaigns"),
PermissionDefinition("campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"),
PermissionDefinition("campaign:control", "Control delivery", "Pause, resume or cancel queued/sending jobs.", "Campaigns"),
PermissionDefinition("campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"),
PermissionDefinition("campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
PermissionDefinition("campaign:reconcile", "Reconcile uncertain delivery", "Resolve outcome-unknown SMTP attempts after operational inspection.", "Campaigns"),
PermissionDefinition("recipients:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
PermissionDefinition("recipients:write", "Edit recipients", "Create and edit recipient rows and recipient field values.", "Recipients"),
PermissionDefinition("recipients:import", "Import recipients", "Bulk-import recipient lists from files or reusable lists.", "Recipients"),
PermissionDefinition("recipients:export", "Export recipients", "Export recipient data or recipient-complete reports.", "Recipients"),
PermissionDefinition("files:read", "View files", "List, search and preview managed files.", "Files"),
PermissionDefinition("files:download", "Download files", "Download managed files and generated ZIP archives.", "Files"),
PermissionDefinition("files:upload", "Upload files", "Upload new managed file versions.", "Files"),
PermissionDefinition("files:organize", "Organize files", "Create folders, rename, move or copy managed files.", "Files"),
PermissionDefinition("files:share", "Share files", "Grant or revoke managed file shares.", "Files"),
PermissionDefinition("files:delete", "Delete files", "Delete or hide managed files and folders where retention policy allows it.", "Files"),
PermissionDefinition("files:admin", "Administer file spaces", "Access and administer all user and group file spaces in the tenant.", "Files"),
PermissionDefinition("reports:read", "View reports", "View campaign delivery reports and aggregate outcome data.", "Reports and audit"),
PermissionDefinition("reports:export", "Export reports", "Download detailed campaign reports such as CSV exports.", "Reports and audit"),
PermissionDefinition("reports:send", "Send reports", "Email campaign reports to configured recipients.", "Reports and audit"),
PermissionDefinition("audit:read", "View audit log", "Read tenant and campaign audit records.", "Reports and audit"),
PermissionDefinition("mail_servers:read", "View mail servers", "Inspect approved reusable SMTP/IMAP server profile metadata.", "Mail servers"),
PermissionDefinition("mail_servers:use", "Use mail servers", "Select an approved mail-server profile for a campaign without reading secrets.", "Mail servers"),
PermissionDefinition("mail_servers:test", "Test mail servers", "Run SMTP/IMAP connection tests.", "Mail servers"),
PermissionDefinition("mail_servers:write", "Manage mail servers", "Create and edit reusable mail-server profiles.", "Mail servers"),
PermissionDefinition("mail_servers:manage_credentials", "Manage mail credentials", "Create or replace stored SMTP/IMAP credentials.", "Mail servers"),
PermissionDefinition("admin:users:read", "View users", "List tenant users and their effective access.", "Tenant administration"),
PermissionDefinition("admin:users:create", "Create users", "Create tenant memberships for accounts.", "Tenant administration"),
PermissionDefinition("admin:users:update", "Update users", "Edit non-access membership metadata.", "Tenant administration"),
PermissionDefinition("admin:users:suspend", "Suspend users", "Activate or suspend tenant memberships.", "Tenant administration"),
PermissionDefinition("admin:groups:read", "View groups", "List tenant groups, members and assigned roles.", "Tenant administration"),
PermissionDefinition("admin:groups:write", "Manage groups", "Create or edit tenant group definitions.", "Tenant administration"),
PermissionDefinition("admin:groups:manage_members", "Manage group members", "Add and remove users from tenant groups.", "Tenant administration"),
PermissionDefinition("admin:roles:read", "View roles", "Inspect role definitions and the permission catalogue.", "Tenant administration"),
PermissionDefinition("admin:roles:write", "Define roles", "Create and edit assignable tenant role definitions.", "Tenant administration"),
PermissionDefinition("admin:roles:assign", "Assign roles", "Assign tenant roles to users or groups within delegation limits.", "Tenant administration"),
PermissionDefinition("admin:api_keys:read", "View API keys", "List tenant API keys without revealing their secret.", "Tenant administration"),
PermissionDefinition("admin:api_keys:create", "Create API keys", "Create tenant-local API keys within the owner's delegation limits.", "Tenant administration"),
PermissionDefinition("admin:api_keys:revoke", "Revoke API keys", "Revoke tenant-local API keys.", "Tenant administration"),
PermissionDefinition("admin:settings:read", "View tenant settings", "Read tenant defaults and non-policy settings.", "Tenant administration"),
PermissionDefinition("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration"),
PermissionDefinition("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration"),
PermissionDefinition("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration"),
)
SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
PermissionDefinition("system:tenants:read", "View all tenants", "List tenants and system-wide membership counts.", "System administration", "system"),
PermissionDefinition("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
PermissionDefinition("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
PermissionDefinition("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
PermissionDefinition("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
PermissionDefinition("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
PermissionDefinition("system:accounts:suspend", "Suspend accounts", "Activate or suspend global login accounts while preserving a system owner.", "System administration", "system"),
PermissionDefinition("system:roles:read", "View system roles", "Inspect instance-wide role definitions and their permissions.", "System administration", "system"),
PermissionDefinition("system:roles:write", "Define system roles", "Create and edit instance-wide role definitions within delegation limits.", "System administration", "system"),
PermissionDefinition("system:roles:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "System administration", "system"),
PermissionDefinition("system:access:read", "View system access (legacy)", "Compatibility scope for listing accounts with instance-wide roles.", "System administration", "system"),
PermissionDefinition("system:access:assign", "Assign system access (legacy)", "Compatibility scope for assigning instance-wide roles.", "System administration", "system"),
PermissionDefinition("system:audit:read", "View system audit", "Read audit records across tenants.", "System administration", "system"),
PermissionDefinition("system:settings:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "System administration", "system"),
PermissionDefinition("system:settings:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "System administration", "system"),
PermissionDefinition("system:governance:read", "View governance templates", "Inspect centrally managed group and role definitions.", "System administration", "system"),
PermissionDefinition("system:governance:write", "Manage governance templates", "Create and assign centrally managed group and role definitions.", "System administration", "system"),
)
ALL_PERMISSIONS: tuple[PermissionDefinition, ...] = TENANT_PERMISSIONS + SYSTEM_PERMISSIONS
KNOWN_SCOPES = frozenset(item.scope for item in ALL_PERMISSIONS)
TENANT_SCOPES = frozenset(item.scope for item in TENANT_PERMISSIONS)
SYSTEM_SCOPES = frozenset(item.scope for item in SYSTEM_PERMISSIONS)
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
# Only names that are no longer canonical remain runtime aliases. Canonical
# permissions keep their narrow meaning; the Alembic migration expands old
# role records once so upgraded installations do not lose prior access.
"campaign:write": frozenset({
"campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share",
"recipients:read", "recipients:write", "recipients:import",
}),
"attachments:read": frozenset({"files:read", "files:download"}),
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
"admin:users": frozenset({
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
}),
"admin:users:write": frozenset({"admin:users:create", "admin:users:update", "admin:users:suspend", "admin:roles:assign", "admin:groups:manage_members"}),
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
"admin:settings": frozenset({"admin:settings:read", "admin:settings:write", "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke"}),
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
"system:access:write": frozenset({"system:access:assign", "system:roles:assign", "system:accounts:create", "system:accounts:update", "system:accounts:suspend"}),
}
DEFAULT_TENANT_ROLES: dict[str, dict[str, object]] = {
"owner": {"name": "Owner", "description": "Full tenant access, including administration and delivery.", "permissions": ["tenant:*"], "is_builtin": True, "is_assignable": True},
"tenant_admin": {"name": "Tenant administrator", "description": "Manage tenant settings, users, groups and roles. Real delivery remains separately delegable.", "permissions": [
"campaign:read", "files:read", "reports:read", "audit:read",
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
"admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke",
"admin:settings:read", "admin:settings:write", "admin:policies:read", "admin:policies:write",
], "is_builtin": True, "is_assignable": True},
"admin": {"name": "Administrator (legacy)", "description": "Legacy broad tenant role retained for upgraded installations.", "permissions": sorted(TENANT_SCOPES), "is_builtin": True, "is_assignable": True},
"access_admin": {"name": "Access administrator", "description": "Manage memberships and assignments within delegation limits.", "permissions": [
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:manage_members", "admin:roles:read", "admin:roles:assign",
], "is_builtin": True, "is_assignable": True},
"campaign_manager": {"name": "Campaign manager", "description": "Prepare, validate and build campaigns, but do not approve or start real delivery.", "permissions": [
"campaign:read", "campaign:create", "campaign:update", "campaign:copy", "campaign:validate", "campaign:build",
"recipients:read", "recipients:write", "recipients:import", "files:read", "files:download", "files:upload", "files:organize", "reports:read",
], "is_builtin": True, "is_assignable": True},
"reviewer": {"name": "Reviewer", "description": "Inspect and approve prepared campaign messages.", "permissions": ["campaign:read", "campaign:validate", "campaign:review", "recipients:read", "files:read", "reports:read"], "is_builtin": True, "is_assignable": True},
"sender": {"name": "Sender", "description": "Queue, test, control, send and reconcile prepared campaigns.", "permissions": [
"campaign:read", "campaign:send_test", "campaign:queue", "campaign:control", "campaign:send", "campaign:retry", "campaign:reconcile",
"recipients:read", "files:read", "reports:read", "reports:send", "mail_servers:use", "mail_servers:test",
], "is_builtin": True, "is_assignable": True},
"file_manager": {"name": "File manager", "description": "Manage tenant file spaces without campaign delivery rights.", "permissions": ["files:read", "files:download", "files:upload", "files:organize", "files:share", "files:delete"], "is_builtin": True, "is_assignable": True},
"viewer": {"name": "Viewer", "description": "Read campaigns, recipient data, files and reports without changing them.", "permissions": ["campaign:read", "recipients:read", "files:read", "reports:read"], "is_builtin": True, "is_assignable": True},
"auditor": {"name": "Auditor", "description": "Read campaigns, recipient evidence, reports and audit records.", "permissions": ["campaign:read", "recipients:read", "recipients:export", "reports:read", "reports:export", "audit:read"], "is_builtin": True, "is_assignable": True},
}
DEFAULT_SYSTEM_ROLES: dict[str, dict[str, object]] = {
"system_owner": {
"name": "System owner",
"description": "Protected full instance-wide administration. At least one active account must retain this role.",
"permissions": ["system:*"],
"is_builtin": True,
"is_assignable": True,
"managed": True,
},
"system_admin": {
"name": "System administrator",
"description": "Manage tenants, accounts, system roles, settings and governance without being able to grant the protected System owner role.",
"permissions": sorted(SYSTEM_SCOPES),
"is_builtin": False,
"is_assignable": True,
"managed": False,
},
"system_auditor": {
"name": "System auditor",
"description": "Read tenant registry, accounts, system roles, settings, governance and cross-tenant audit records.",
"permissions": [
"system:tenants:read", "system:accounts:read", "system:roles:read", "system:access:read",
"system:audit:read", "system:settings:read", "system:governance:read",
],
"is_builtin": False,
"is_assignable": True,
"managed": False,
},
}
def normalize_email(value: str) -> str:
return value.strip().casefold()
def scope_grants(granted: str, required: str) -> bool:
if granted == required:
return True
if required in LEGACY_SCOPE_ALIASES.get(granted, frozenset()):
return True
if granted in {"*", "tenant:*"}:
return required in {"*", "tenant:*"} or (required in TENANT_SCOPES) or required in {"attachments:read", "attachments:write"}
if granted == "system:*":
return required.startswith("system:")
if granted.endswith(":*") and required.startswith(granted[:-1]):
return True
return any(scope_grants(alias, required) for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()) if alias != granted)
def scopes_grant(scopes: Iterable[str], required: str) -> bool:
return any(scope_grants(scope, required) for scope in scopes)
def effective_permission_scopes(scopes: Iterable[str], *, level: str | None = None) -> set[str]:
"""Return canonical permissions granted by a possibly wildcarded scope set.
Wildcards and compatibility aliases are presentation/assignment shorthand;
counts and access summaries should describe the canonical capabilities they
grant rather than counting the shorthand token itself.
"""
if level == "tenant":
candidates = TENANT_SCOPES
elif level == "system":
candidates = SYSTEM_SCOPES
else:
candidates = KNOWN_SCOPES
granted = list(scopes)
return {scope for scope in candidates if scopes_grant(granted, scope)}
def effective_permission_count(scopes: Iterable[str], *, level: str | None = None) -> int:
return len(effective_permission_scopes(scopes, level=level))
def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]:
raw = {str(scope) for scope in scopes if scope}
expanded: set[str] = set()
for scope in raw:
if scope in {"*", "tenant:*"}:
expanded.add("tenant:*")
expanded.update(TENANT_SCOPES)
continue
if scope == "system:*":
expanded.add("system:*")
expanded.update(SYSTEM_SCOPES)
continue
aliases = LEGACY_SCOPE_ALIASES.get(scope, frozenset())
expanded.update(aliases)
for alias in aliases:
if alias.endswith(":*"):
expanded.update(item for item in KNOWN_SCOPES if scope_grants(alias, item))
if scope.endswith(":*"):
expanded.update(item for item in KNOWN_SCOPES if scope_grants(scope, item))
expanded.add(scope)
elif include_unknown or scope in KNOWN_SCOPES:
expanded.add(scope)
return sorted(expanded)
def delegateable_tenant_scopes(scopes: Iterable[str]) -> set[str]:
expanded = set(expand_scopes(scopes, include_unknown=False))
if "tenant:*" in expanded:
return set(TENANT_SCOPES)
return {scope for scope in expanded if scope in TENANT_SCOPES}
def delegateable_system_scopes(scopes: Iterable[str]) -> set[str]:
expanded = set(expand_scopes(scopes, include_unknown=False))
if "system:*" in expanded:
return set(SYSTEM_SCOPES)
return {scope for scope in expanded if scope in SYSTEM_SCOPES}
def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]:
user = list(user_scopes)
key = list(key_scopes)
allowed = {scope for scope in TENANT_SCOPES if scopes_grant(user, scope) and scopes_grant(key, scope)}
user_raw = set(expand_scopes(user))
key_raw = set(expand_scopes(key))
allowed.update(scope for scope in user_raw.intersection(key_raw) if not scope.startswith("system:") and scope not in {"*", "tenant:*"})
return sorted(allowed)
def validate_tenant_permissions(scopes: Iterable[str]) -> list[str]:
normalized = {str(scope) for scope in scopes if scope}
if normalized.intersection({"*", "tenant:*"}):
return ["tenant:*"]
expanded: set[str] = set()
invalid: list[str] = []
for scope in sorted(normalized):
if scope in TENANT_SCOPES:
expanded.add(scope)
continue
aliases = {item for item in LEGACY_SCOPE_ALIASES.get(scope, frozenset()) if item in TENANT_SCOPES}
if aliases:
expanded.update(aliases)
continue
invalid.append(scope)
if invalid:
raise ValueError(f"Unsupported tenant permissions: {', '.join(invalid)}")
return sorted(expanded)
def validate_system_permissions(scopes: Iterable[str]) -> list[str]:
normalized = sorted({str(scope) for scope in scopes if scope})
invalid = [scope for scope in normalized if scope not in SYSTEM_SCOPES and scope != "system:*"]
if invalid:
raise ValueError(f"Unsupported system permissions: {', '.join(invalid)}")
if "system:*" in normalized:
return ["system:*"]
return normalized

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
import base64
import hashlib
from functools import lru_cache
from cryptography.fernet import Fernet, InvalidToken
from govoplan_core.settings import settings
class SecretConfigurationError(RuntimeError):
pass
class SecretDecryptionError(RuntimeError):
pass
def _normalize_fernet_key(value: str) -> bytes:
candidate = value.strip().encode("utf-8")
try:
Fernet(candidate)
return candidate
except Exception:
pass
try:
raw = base64.b64decode(candidate)
except Exception as exc:
raise SecretConfigurationError("MASTER_KEY_B64 must be a Fernet key or base64-encoded 32-byte key") from exc
if len(raw) != 32:
raise SecretConfigurationError("MASTER_KEY_B64 must decode to exactly 32 bytes")
return base64.urlsafe_b64encode(raw)
@lru_cache(maxsize=1)
def _fernet() -> Fernet:
if settings.master_key_b64:
return Fernet(_normalize_fernet_key(settings.master_key_b64))
if settings.app_env.lower() in {"dev", "test", "local"}:
key = base64.urlsafe_b64encode(hashlib.sha256(b"multi-seal-mail-development-master-key").digest())
return Fernet(key)
raise SecretConfigurationError("MASTER_KEY_B64 is required outside dev/test/local environments")
def encrypt_secret(value: str | None) -> str | None:
if value in (None, ""):
return None
return _fernet().encrypt(str(value).encode("utf-8")).decode("utf-8")
def decrypt_secret(value: str | None) -> str | None:
if not value:
return None
try:
return _fernet().decrypt(value.encode("utf-8")).decode("utf-8")
except InvalidToken as exc:
raise SecretDecryptionError("Stored secret cannot be decrypted with the configured master key") from exc

View File

@@ -0,0 +1,220 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from sqlalchemy.orm import Session
from govoplan_core.db.models import (
Account,
AuthSession,
Group,
GroupRoleAssignment,
Role,
SystemRoleAssignment,
Tenant,
User,
UserGroupMembership,
UserRoleAssignment,
)
from govoplan_core.access.auth.tokens import generate_secret, hash_secret, verify_secret
from govoplan_core.security.permissions import expand_scopes
from govoplan_core.security.time import ensure_aware_utc, utc_now
SESSION_RANDOM_BYTES = 32
DEFAULT_SESSION_HOURS = 12
@dataclass(slots=True)
class CreatedSession:
model: AuthSession
token: str
csrf_token: str
def generate_session_token() -> str:
return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES)
def hash_session_token(token: str) -> str:
return hash_secret(token)
def generate_csrf_token() -> str:
return generate_secret("", random_bytes=SESSION_RANDOM_BYTES)
def hash_csrf_token(token: str) -> str:
return hash_secret(token)
def verify_session_token(token: str, expected_hash: str) -> bool:
return verify_secret(token, expected_hash)
def verify_auth_session_csrf(auth_session: AuthSession, csrf_token: str | None) -> bool:
if not csrf_token or not auth_session.csrf_token_hash:
return False
return verify_secret(csrf_token, auth_session.csrf_token_hash)
def create_auth_session(
session: Session,
*,
user: User,
hours: int = DEFAULT_SESSION_HOURS,
user_agent: str | None = None,
ip_address: str | None = None,
) -> CreatedSession:
if not user.account_id:
raise ValueError("Tenant membership has no global account.")
token = generate_session_token()
csrf_token = generate_csrf_token()
now = utc_now()
model = AuthSession(
tenant_id=user.tenant_id,
user_id=user.id,
account_id=user.account_id,
token_hash=hash_session_token(token),
csrf_token_hash=hash_csrf_token(csrf_token),
expires_at=now + timedelta(hours=hours),
last_seen_at=now,
user_agent=user_agent,
ip_address=ip_address,
)
user.last_login_at = now
if user.account:
user.account.last_login_at = now
session.add(user.account)
session.add(model)
session.add(user)
session.flush()
return CreatedSession(model=model, token=token, csrf_token=csrf_token)
def authenticate_session_token(session: Session, token: str) -> AuthSession | None:
token_hash = hash_session_token(token)
model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none()
if not model:
return None
now = utc_now()
expires_at = ensure_aware_utc(model.expires_at)
if expires_at is None or expires_at < now:
return None
model.last_seen_at = now
session.add(model)
return model
def revoke_auth_session(session: Session, token: str) -> bool:
model = authenticate_session_token(session, token)
if not model:
return False
model.revoked_at = utc_now()
session.add(model)
return True
def switch_auth_session_tenant(session: Session, auth_session: AuthSession, tenant_id: str) -> User:
membership = (
session.query(User)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(
User.account_id == auth_session.account_id,
User.tenant_id == tenant_id,
User.is_active.is_(True),
Tenant.is_active.is_(True),
)
.one_or_none()
)
if membership is None:
raise LookupError("The account does not have an active membership in this tenant.")
auth_session.tenant_id = membership.tenant_id
auth_session.user_id = membership.id
auth_session.last_seen_at = utc_now()
session.add(auth_session)
session.flush()
return membership
def collect_direct_user_roles(session: Session, user: User) -> list[Role]:
return (
session.query(Role)
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
.filter(
UserRoleAssignment.user_id == user.id,
UserRoleAssignment.tenant_id == user.tenant_id,
Role.tenant_id == user.tenant_id,
)
.order_by(Role.name.asc())
.all()
)
def collect_user_roles(session: Session, user: User) -> list[Role]:
roles_by_id: dict[str, Role] = {role.id: role for role in collect_direct_user_roles(session, user)}
group_roles = (
session.query(Role)
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.join(UserGroupMembership, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.user_id == user.id,
UserGroupMembership.tenant_id == user.tenant_id,
Group.is_active.is_(True),
Role.tenant_id == user.tenant_id,
)
.all()
)
for role in group_roles:
roles_by_id[role.id] = role
return list(roles_by_id.values())
def collect_system_roles(session: Session, account: Account) -> list[Role]:
return (
session.query(Role)
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
.filter(SystemRoleAssignment.account_id == account.id, Role.tenant_id.is_(None))
.order_by(Role.name.asc())
.all()
)
def collect_user_groups(session: Session, user: User) -> list[Group]:
return (
session.query(Group)
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
.filter(
UserGroupMembership.user_id == user.id,
UserGroupMembership.tenant_id == user.tenant_id,
Group.is_active.is_(True),
)
.order_by(Group.name.asc())
.all()
)
def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]:
scopes: set[str] = set()
for role in collect_user_roles(session, user):
scopes.update(role.permissions or [])
if include_system and user.account:
for role in collect_system_roles(session, user.account):
scopes.update(role.permissions or [])
return expand_scopes(scopes)
def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]:
return (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(
User.account_id == account.id,
User.is_active.is_(True),
Tenant.is_active.is_(True),
)
.order_by(Tenant.name.asc())
.all()
)

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from datetime import datetime, timezone
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def ensure_aware_utc(value: datetime | None) -> datetime | None:
"""Return a timezone-aware UTC datetime.
SQLite and some DB drivers may return naive datetimes even when SQLAlchemy
columns are declared as DateTime(timezone=True). Treat naive values as UTC
so comparisons with aware `datetime.now(timezone.utc)` do not crash.
"""
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)

View File

@@ -0,0 +1 @@
"""GovOPlaN server runner package."""

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from fastapi import APIRouter
from govoplan_core.core.modules import ModuleContext
from govoplan_core.db.session import configure_database
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.registry import build_platform_registry
def create_app(config: GovoplanServerConfig | str | None = None):
if isinstance(config, str) or config is None:
server_config = load_server_config(config)
else:
server_config = config
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
if database_url:
configure_database(str(database_url))
registry = build_platform_registry(server_config.enabled_modules, manifest_factories=server_config.manifest_factories)
api_router = APIRouter(prefix=server_config.api_prefix)
for router in server_config.base_routers:
api_router.include_router(router)
module_context = ModuleContext(registry=registry, settings=server_config.settings, data=server_config.module_context_data)
for manifest in registry.manifests():
if manifest.route_factory is not None:
api_router.include_router(manifest.route_factory(module_context))
api_router.include_router(create_platform_router())
for router in server_config.post_module_routers:
api_router.include_router(router)
for contribution in server_config.extra_routers:
if contribution.should_include(server_config.settings, registry):
api_router.include_router(contribution.router)
app = create_govoplan_app(
title=server_config.title,
version=server_config.version,
registry=registry,
api_router=api_router,
lifespan=server_config.lifespan,
cors_origins=server_config.cors_origins,
)
for configurator in server_config.app_configurators:
configurator(app, registry, server_config.settings)
return app
app = create_app()

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
import os
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from importlib import import_module
from typing import Any
from fastapi import APIRouter, FastAPI
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.server.fastapi import LifespanFactory
ManifestFactory = Callable[[], ModuleManifest]
RouterEnabled = Callable[[object | None, PlatformRegistry], bool]
AppConfigurator = Callable[[FastAPI, PlatformRegistry, object | None], None]
@dataclass(frozen=True, slots=True)
class RouterContribution:
router: APIRouter
required_module: str | None = None
enabled: RouterEnabled | None = None
def should_include(self, settings: object | None, registry: PlatformRegistry) -> bool:
if self.required_module and not registry.has_module(self.required_module):
return False
if self.enabled is not None and not self.enabled(settings, registry):
return False
return True
@dataclass(frozen=True, slots=True)
class GovoplanServerConfig:
title: str = "GovOPlaN Server"
version: str = "0.1.0"
settings: object | None = None
enabled_modules: str | Iterable[str] = ("access",)
api_prefix: str = "/api/v1"
base_routers: Sequence[APIRouter] = ()
post_module_routers: Sequence[APIRouter] = ()
extra_routers: Sequence[RouterContribution] = ()
lifespan: LifespanFactory | None = None
cors_origins: Iterable[str] = ()
manifest_factories: Sequence[ManifestFactory] = ()
module_context_data: Mapping[str, Any] = field(default_factory=dict)
app_configurators: Sequence[AppConfigurator] = ()
def import_object(path: str) -> Any:
module_name, separator, attribute = path.partition(":")
if not separator:
raise ValueError(f"Object path must use module:attribute syntax: {path!r}")
module = import_module(module_name)
value: Any = module
for part in attribute.split("."):
value = getattr(value, part)
return value
def load_server_config(path: str | None = None) -> GovoplanServerConfig:
config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config"
try:
loaded = import_object(config_path)
except ModuleNotFoundError:
if path or os.getenv("GOVOPLAN_SERVER_CONFIG"):
raise
return GovoplanServerConfig()
config = loaded() if callable(loaded) else loaded
if not isinstance(config, GovoplanServerConfig):
raise TypeError(f"{config_path!r} did not return a GovoplanServerConfig")
return config

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from importlib import import_module
from fastapi import Depends, FastAPI
from govoplan_core.auth.dependencies import ApiPrincipal, require_scope
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.bootstrap import bootstrap_dev_data, create_all_tables
from govoplan_core.db.session import get_database
from govoplan_core.server.config import GovoplanServerConfig, RouterContribution
from govoplan_core.settings import Settings, settings
@asynccontextmanager
async def lifespan(app: FastAPI):
if settings.app_env.lower() == "dev" and settings.dev_bootstrap_enabled:
create_all_tables()
with get_database().SessionLocal() as session:
bootstrap_dev_data(
session,
api_key_secret=settings.dev_bootstrap_api_key,
user_password=settings.dev_bootstrap_password,
)
yield
def _cors_origins(value: str) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()]
def _settings_module_enabled(module_id: str) -> bool:
return module_id in {item.strip() for item in settings.enabled_modules.split(",") if item.strip()}
def _dev_mail_enabled(config_settings: object | None, registry: PlatformRegistry) -> bool:
active_settings = config_settings if isinstance(config_settings, Settings) else settings
return active_settings.app_env == "dev" and active_settings.dev_mailbox_api_enabled and registry.has_module("mail")
def _dev_mail_router_contributions() -> tuple[RouterContribution, ...]:
if not (settings.app_env == "dev" and settings.dev_mailbox_api_enabled and _settings_module_enabled("mail")):
return ()
module = import_module("govoplan_mail.backend.dev_router")
return (RouterContribution(module.router, required_module="mail", enabled=_dev_mail_enabled),)
def register_health_details(app: FastAPI, registry: PlatformRegistry, config_settings: object | None) -> None:
active_settings = config_settings if isinstance(config_settings, Settings) else settings
@app.get("/health/details")
def health_details(
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
return {
"status": "ok",
"env": active_settings.app_env,
"api": {"version": "v1", "auth": "api-key-or-session"},
"modules": [manifest.id for manifest in registry.manifests()],
"storage": {
"backend": active_settings.file_storage_backend,
"local_root": active_settings.file_storage_local_root if active_settings.file_storage_backend == "local" else None,
"endpoint": active_settings.file_storage_s3_endpoint_url or active_settings.s3_endpoint_url,
"bucket": active_settings.file_storage_s3_bucket or active_settings.s3_bucket,
"region": active_settings.file_storage_s3_region or active_settings.s3_region,
},
}
def get_server_config() -> GovoplanServerConfig:
from govoplan_core.api.v1.admin import router as admin_router
from govoplan_core.api.v1.audit import router as audit_router
from govoplan_core.api.v1.auth import router as auth_router
from govoplan_core.api.v1.system import router as system_router
return GovoplanServerConfig(
title="GovOPlaN Server",
version="0.3.0",
settings=settings,
enabled_modules=settings.enabled_modules,
api_prefix="/api/v1",
base_routers=(auth_router, admin_router, audit_router, system_router),
extra_routers=_dev_mail_router_contributions(),
lifespan=lifespan,
cors_origins=_cors_origins(settings.cors_origins),
app_configurators=(register_health_details,),
)

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import AbstractAsyncContextManager
from typing import Any
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from govoplan_core.core.registry import PlatformRegistry
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
def create_govoplan_app(
*,
title: str,
version: str,
registry: PlatformRegistry,
api_router: APIRouter | None = None,
lifespan: LifespanFactory | None = None,
cors_origins: Iterable[str] = (),
health_payload: dict[str, Any] | None = None,
) -> FastAPI:
app = FastAPI(title=title, version=version, lifespan=lifespan)
app.state.govoplan_registry = registry
origins = [item.strip() for item in cors_origins if item.strip()]
if origins:
app.add_middleware(
CORSMiddleware,
allow_origins=["*"] if "*" in origins else origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
if api_router is not None:
app.include_router(api_router)
@app.get("/health")
def health():
payload = {"status": "ok"}
if health_payload:
payload.update(health_payload)
return payload
return app

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem
from govoplan_core.core.registry import PlatformRegistry
def _registry(request: Request) -> PlatformRegistry:
registry = getattr(request.app.state, "govoplan_registry", None)
if not isinstance(registry, PlatformRegistry):
raise HTTPException(status_code=500, detail="GovOPlaN module registry is not configured")
return registry
def _nav_item_payload(item: NavItem) -> dict[str, object]:
return {
"path": item.path,
"label": item.label,
"icon": item.icon,
"section": item.section,
"required_all": list(item.required_all),
"required_any": list(item.required_any),
"order": item.order,
}
def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
return {
"path": route.path,
"component": route.component,
"required_all": list(route.required_all),
"required_any": list(route.required_any),
"order": route.order,
}
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
if frontend is None:
return None
return {
"module_id": frontend.module_id,
"package_name": frontend.package_name,
"asset_manifest": frontend.asset_manifest,
"routes": [_frontend_route_payload(route) for route in frontend.routes],
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
}
def create_platform_router() -> APIRouter:
router = APIRouter(prefix="/platform", tags=["platform"])
@router.get("/modules")
def modules(request: Request):
registry = _registry(request)
return {
"modules": [
{
"id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"dependencies": list(manifest.dependencies),
"optional_dependencies": list(manifest.optional_dependencies),
"enabled": True,
"nav": [_nav_item_payload(item) for item in manifest.nav_items],
"frontend": _frontend_payload(manifest.frontend),
}
for manifest in registry.manifests()
]
}
@router.get("/permissions")
def permissions(request: Request):
registry = _registry(request)
return {
"permissions": [
{
"scope": item.scope,
"module_id": item.module_id,
"resource": item.resource,
"action": item.action,
"label": item.label,
"description": item.description,
"category": item.category,
"level": item.level,
"deprecated": item.deprecated,
}
for item in registry.permissions()
]
}
@router.get("/navigation")
def navigation(request: Request):
registry = _registry(request)
return {
"items": [_nav_item_payload(item) for item in registry.nav_items()]
}
return router

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from collections.abc import Callable, Iterable, Sequence
from govoplan_core.access.manifest import get_manifest as get_access_manifest
from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry, RegistryError
ManifestFactory = Callable[[], ModuleManifest]
def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
if isinstance(value, str):
items = [item.strip() for item in value.split(",")]
else:
items = [str(item).strip() for item in value]
return [item for item in items if item]
def available_module_manifests(manifest_factories: Sequence[ManifestFactory] = ()) -> dict[str, ModuleManifest]:
manifests = {manifest.id: manifest for manifest in discover_module_manifests()}
manifests.setdefault("access", get_access_manifest())
for factory in manifest_factories:
manifest = factory()
manifests[manifest.id] = manifest
return manifests
def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_factories: Sequence[ManifestFactory] = ()) -> PlatformRegistry:
requested = parse_enabled_modules(enabled_modules)
if "access" not in requested:
requested.insert(0, "access")
available = available_module_manifests(manifest_factories)
registry = PlatformRegistry()
for module_id in requested:
manifest = available.get(module_id)
if manifest is None:
raise RegistryError(f"Enabled module is not available: {module_id}")
registry.register(manifest)
registry.validate()
return registry

View File

@@ -0,0 +1,62 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=None, extra="ignore")
app_env: str = Field(default="dev", alias="APP_ENV")
database_url: str = Field(
default="sqlite:///./multimailer-dev.db",
alias="DATABASE_URL",
)
access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL")
access_db_schema: str | None = Field(default=None, alias="ACCESS_DB_SCHEMA")
access_table_prefix: str = Field(default="access_", alias="ACCESS_TABLE_PREFIX")
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
enabled_modules: str = Field(default="access,campaigns,files,mail", alias="ENABLED_MODULES")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
s3_region: str = Field(default="garage", alias="S3_REGION")
s3_access_key_id: str = Field(default="GKmultimailerdev0000000000000000", alias="S3_ACCESS_KEY_ID")
s3_secret_access_key: str = Field(default="multimailer-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY")
s3_bucket: str = Field(default="attachments", alias="S3_BUCKET")
# Managed file storage. Development defaults to local filesystem storage;
# production can switch to Garage/S3 without changing API contracts.
file_storage_backend: str = Field(default="local", alias="FILE_STORAGE_BACKEND")
file_storage_local_root: str = Field(default="runtime/files", alias="FILE_STORAGE_LOCAL_ROOT")
file_storage_s3_endpoint_url: str | None = Field(default=None, alias="FILE_STORAGE_S3_ENDPOINT_URL")
file_storage_s3_region: str | None = Field(default=None, alias="FILE_STORAGE_S3_REGION")
file_storage_s3_access_key_id: str | None = Field(default=None, alias="FILE_STORAGE_S3_ACCESS_KEY_ID")
file_storage_s3_secret_access_key: str | None = Field(default=None, alias="FILE_STORAGE_S3_SECRET_ACCESS_KEY")
file_storage_s3_bucket: str | None = Field(default="files", alias="FILE_STORAGE_S3_BUCKET")
file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES")
file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES")
auth_session_cookie_name: str = Field(default="msm_session", alias="AUTH_SESSION_COOKIE_NAME")
auth_csrf_cookie_name: str = Field(default="msm_csrf", alias="AUTH_CSRF_COOKIE_NAME")
auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE")
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
celery_queues: str = Field(default="send_email,append_sent,default", alias="CELERY_QUEUES")
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
# Development bootstrap only. Do not use this in production.
dev_bootstrap_api_key: str | None = Field(default="dev-multimailer-api-key", alias="DEV_BOOTSTRAP_API_KEY")
dev_bootstrap_enabled: bool = Field(default=False, alias="DEV_BOOTSTRAP_ENABLED")
dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD")
dev_mailbox_api_enabled: bool = Field(default=False, alias="DEV_MAILBOX_API_ENABLED")
# Comma-separated list. Use * only for local development.
cors_origins: str = Field(default="http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080", alias="CORS_ORIGINS")
settings = Settings()