initial commit after split
This commit is contained in:
3
src/govoplan_core/__init__.py
Normal file
3
src/govoplan_core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Reusable GovOPlaN platform core components."""
|
||||
|
||||
__all__ = ["access", "core"]
|
||||
2
src/govoplan_core/access/__init__.py
Normal file
2
src/govoplan_core/access/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Tenant, identity, auth, RBAC, and datastore access platform module."""
|
||||
|
||||
2
src/govoplan_core/access/auth/__init__.py
Normal file
2
src/govoplan_core/access/auth/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Authentication and principal helpers for platform access."""
|
||||
|
||||
28
src/govoplan_core/access/auth/principals.py
Normal file
28
src/govoplan_core/access/auth/principals.py
Normal 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)
|
||||
|
||||
82
src/govoplan_core/access/auth/roles.py
Normal file
82
src/govoplan_core/access/auth/roles.py
Normal 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)
|
||||
|
||||
21
src/govoplan_core/access/auth/tokens.py
Normal file
21
src/govoplan_core/access/auth/tokens.py
Normal 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)
|
||||
|
||||
2
src/govoplan_core/access/db/__init__.py
Normal file
2
src/govoplan_core/access/db/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Access-platform SQLAlchemy metadata and models."""
|
||||
|
||||
21
src/govoplan_core/access/db/base.py
Normal file
21
src/govoplan_core/access/db/base.py
Normal 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"]
|
||||
237
src/govoplan_core/access/db/models.py
Normal file
237
src/govoplan_core/access/db/models.py
Normal 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)
|
||||
|
||||
22
src/govoplan_core/access/db/session.py
Normal file
22
src/govoplan_core/access/db/session.py
Normal 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
|
||||
119
src/govoplan_core/access/manifest.py
Normal file
119
src/govoplan_core/access/manifest.py
Normal 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
|
||||
|
||||
2
src/govoplan_core/access/permissions/__init__.py
Normal file
2
src/govoplan_core/access/permissions/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Permission definitions, evaluation, and registry helpers."""
|
||||
|
||||
6
src/govoplan_core/access/permissions/definitions.py
Normal file
6
src/govoplan_core/access/permissions/definitions.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
|
||||
|
||||
__all__ = ["PermissionDefinition", "PermissionLevel", "RoleTemplate"]
|
||||
|
||||
52
src/govoplan_core/access/permissions/evaluator.py
Normal file
52
src/govoplan_core/access/permissions/evaluator.py
Normal 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)
|
||||
|
||||
38
src/govoplan_core/access/permissions/registry.py
Normal file
38
src/govoplan_core/access/permissions/registry.py
Normal 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))
|
||||
|
||||
2
src/govoplan_core/access/tenancy/__init__.py
Normal file
2
src/govoplan_core/access/tenancy/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Tenant datastore routing abstractions."""
|
||||
|
||||
54
src/govoplan_core/access/tenancy/datastore.py
Normal file
54
src/govoplan_core/access/tenancy/datastore.py
Normal 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)
|
||||
|
||||
0
src/govoplan_core/admin/__init__.py
Normal file
0
src/govoplan_core/admin/__init__.py
Normal file
313
src/govoplan_core/admin/governance.py
Normal file
313
src/govoplan_core/admin/governance.py
Normal 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()
|
||||
588
src/govoplan_core/admin/service.py
Normal file
588
src/govoplan_core/admin/service.py
Normal 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 [])
|
||||
1
src/govoplan_core/api/__init__.py
Normal file
1
src/govoplan_core/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
1
src/govoplan_core/api/v1/__init__.py
Normal file
1
src/govoplan_core/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
1642
src/govoplan_core/api/v1/admin.py
Normal file
1642
src/govoplan_core/api/v1/admin.py
Normal file
File diff suppressed because it is too large
Load Diff
486
src/govoplan_core/api/v1/admin_schemas.py
Normal file
486
src/govoplan_core/api/v1/admin_schemas.py
Normal 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
|
||||
32
src/govoplan_core/api/v1/audit.py
Normal file
32
src/govoplan_core/api/v1/audit.py
Normal 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])
|
||||
293
src/govoplan_core/api/v1/auth.py
Normal file
293
src/govoplan_core/api/v1/auth.py
Normal 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}
|
||||
111
src/govoplan_core/api/v1/schemas.py
Normal file
111
src/govoplan_core/api/v1/schemas.py
Normal 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)
|
||||
29
src/govoplan_core/api/v1/system.py
Normal file
29
src/govoplan_core/api/v1/system.py
Normal 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()
|
||||
0
src/govoplan_core/audit/__init__.py
Normal file
0
src/govoplan_core/audit/__init__.py
Normal file
99
src/govoplan_core/audit/logging.py
Normal file
99
src/govoplan_core/audit/logging.py
Normal 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,
|
||||
)
|
||||
0
src/govoplan_core/auth/__init__.py
Normal file
0
src/govoplan_core/auth/__init__.py
Normal file
223
src/govoplan_core/auth/dependencies.py
Normal file
223
src/govoplan_core/auth/dependencies.py
Normal 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
|
||||
63
src/govoplan_core/celery_app.py
Normal file
63
src/govoplan_core/celery_app.py
Normal 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
|
||||
1
src/govoplan_core/commands/__init__.py
Normal file
1
src/govoplan_core/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
36
src/govoplan_core/commands/init_db.py
Normal file
36
src/govoplan_core/commands/init_db.py
Normal 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()
|
||||
2
src/govoplan_core/core/__init__.py
Normal file
2
src/govoplan_core/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Core platform contracts and runtime registry."""
|
||||
|
||||
42
src/govoplan_core/core/discovery.py
Normal file
42
src/govoplan_core/core/discovery.py
Normal 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))
|
||||
33
src/govoplan_core/core/events.py
Normal file
33
src/govoplan_core/core/events.py
Normal 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)
|
||||
|
||||
29
src/govoplan_core/core/migrations.py
Normal file
29
src/govoplan_core/core/migrations.py
Normal 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))
|
||||
|
||||
125
src/govoplan_core/core/modules.py
Normal file
125
src/govoplan_core/core/modules.py
Normal 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)
|
||||
|
||||
155
src/govoplan_core/core/registry.py
Normal file
155
src/govoplan_core/core/registry.py
Normal 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)
|
||||
|
||||
14
src/govoplan_core/db/__init__.py
Normal file
14
src/govoplan_core/db/__init__.py
Normal 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",
|
||||
]
|
||||
39
src/govoplan_core/db/base.py
Normal file
39
src/govoplan_core/db/base.py
Normal 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)
|
||||
120
src/govoplan_core/db/bootstrap.py
Normal file
120
src/govoplan_core/db/bootstrap.py
Normal 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)
|
||||
14
src/govoplan_core/db/migrate.py
Normal file
14
src/govoplan_core/db/migrate.py
Normal 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()
|
||||
187
src/govoplan_core/db/migrations.py
Normal file
187
src/govoplan_core/db/migrations.py
Normal 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,
|
||||
)
|
||||
271
src/govoplan_core/db/models.py
Normal file
271
src/govoplan_core/db/models.py
Normal 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",
|
||||
]
|
||||
66
src/govoplan_core/db/session.py
Normal file
66
src/govoplan_core/db/session.py
Normal 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()
|
||||
0
src/govoplan_core/privacy/__init__.py
Normal file
0
src/govoplan_core/privacy/__init__.py
Normal file
664
src/govoplan_core/privacy/retention.py
Normal file
664
src/govoplan_core/privacy/retention.py
Normal 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
|
||||
0
src/govoplan_core/py.typed
Normal file
0
src/govoplan_core/py.typed
Normal file
0
src/govoplan_core/security/__init__.py
Normal file
0
src/govoplan_core/security/__init__.py
Normal file
80
src/govoplan_core/security/api_keys.py
Normal file
80
src/govoplan_core/security/api_keys.py
Normal 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
|
||||
68
src/govoplan_core/security/module_permissions.py
Normal file
68
src/govoplan_core/security/module_permissions.py
Normal 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)
|
||||
|
||||
37
src/govoplan_core/security/passwords.py
Normal file
37
src/govoplan_core/security/passwords.py
Normal 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)
|
||||
299
src/govoplan_core/security/permissions.py
Normal file
299
src/govoplan_core/security/permissions.py
Normal 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
|
||||
58
src/govoplan_core/security/secrets.py
Normal file
58
src/govoplan_core/security/secrets.py
Normal 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
|
||||
220
src/govoplan_core/security/sessions.py
Normal file
220
src/govoplan_core/security/sessions.py
Normal 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()
|
||||
)
|
||||
21
src/govoplan_core/security/time.py
Normal file
21
src/govoplan_core/security/time.py
Normal 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)
|
||||
1
src/govoplan_core/server/__init__.py
Normal file
1
src/govoplan_core/server/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""GovOPlaN server runner package."""
|
||||
56
src/govoplan_core/server/app.py
Normal file
56
src/govoplan_core/server/app.py
Normal 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()
|
||||
73
src/govoplan_core/server/config.py
Normal file
73
src/govoplan_core/server/config.py
Normal 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
|
||||
89
src/govoplan_core/server/default_config.py
Normal file
89
src/govoplan_core/server/default_config.py
Normal 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,),
|
||||
)
|
||||
48
src/govoplan_core/server/fastapi.py
Normal file
48
src/govoplan_core/server/fastapi.py
Normal 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
|
||||
100
src/govoplan_core/server/platform.py
Normal file
100
src/govoplan_core/server/platform.py
Normal 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
|
||||
43
src/govoplan_core/server/registry.py
Normal file
43
src/govoplan_core/server/registry.py
Normal 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
|
||||
62
src/govoplan_core/settings.py
Normal file
62
src/govoplan_core/settings.py
Normal 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()
|
||||
Reference in New Issue
Block a user