Release v0.1.6

This commit is contained in:
2026-07-07 16:00:38 +02:00
parent a2053518d1
commit 150b720f12
149 changed files with 14311 additions and 8005 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,118 +0,0 @@
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="0.1.4",
permissions=ACCESS_PERMISSIONS,
role_templates=ACCESS_ROLE_TEMPLATES,
migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata),
)
def get_manifest() -> ModuleManifest:
return manifest

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
import re
_SLUG_RE = re.compile(r"[^a-z0-9]+")
class AdminConflictError(ValueError):
pass
class AdminValidationError(ValueError):
pass
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]

View File

@@ -1,328 +0,0 @@
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.core.optional import reraise_unless_missing_package
from govoplan_core.db.models import (
ApiKey,
GovernanceTemplate,
GovernanceTemplateAssignment,
Group,
GroupRoleAssignment,
Role,
SystemSettings,
Tenant,
UserGroupMembership,
UserRoleAssignment,
)
from govoplan_core.security.permissions import validate_tenant_permissions
SYSTEM_SETTINGS_ID = "global"
TEMPLATE_KINDS = {"group", "role"}
ASSIGNMENT_MODES = {"available", "required"}
def _file_models():
try:
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
except ModuleNotFoundError as exc:
reraise_unless_missing_package(exc, "govoplan_files")
return None
return FileAsset, FileFolder, FileShare
@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()
file_models = _file_models()
owned_asset_count = 0
owned_folder_count = 0
shared_asset_count = 0
if file_models is not None:
FileAsset, FileFolder, FileShare = file_models
owned_asset_count = session.query(FileAsset).filter(FileAsset.owner_group_id == group.id).count()
owned_folder_count = session.query(FileFolder).filter(FileFolder.owner_group_id == group.id).count()
shared_asset_count = session.query(FileShare).filter(
FileShare.target_type == "group", FileShare.target_id == group.id
).count()
if membership_count or role_count or owned_asset_count or owned_folder_count or shared_asset_count:
raise AdminConflictError(
f"Cannot remove {item.name!r} from the tenant while its managed group has members, roles, files, folders or shares."
)
session.delete(group)
return
role = session.query(Role).filter(
Role.tenant_id == tenant_id,
Role.system_template_id == item.id,
).first()
if role is None:
return
user_count = session.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role.id).count()
group_count = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role.id).count()
if user_count or group_count:
raise AdminConflictError(
f"Cannot remove {item.name!r} from the tenant while its managed role is assigned to users or groups."
)
session.delete(role)
def _available_slug(session: Session, model: type[Group] | type[Role], tenant_id: str, base: str) -> str:
candidate = base
suffix = 2
while session.query(model).filter(model.tenant_id == tenant_id, model.slug == candidate).first():
candidate = f"{base}-{suffix}"
suffix += 1
return candidate
def ensure_group_mutation_allowed(group: Group, *, requested_active: bool | None = None) -> None:
if group.system_template_id and group.system_required and requested_active is False:
raise AdminConflictError("This centrally required group cannot be deactivated by the tenant.")
def ensure_role_mutation_allowed(role: Role, *, requested_assignable: bool | None = None) -> None:
if role.system_template_id:
if role.system_required and requested_assignable is False:
raise AdminConflictError("This centrally required role cannot be made unavailable by the tenant.")
raise AdminConflictError("Centrally managed role definitions can only be changed in System administration.")
def assert_api_keys_allowed(session: Session, tenant: Tenant) -> None:
if not effective_tenant_governance(session, tenant).allow_api_keys:
raise AdminConflictError("API-key creation is disabled by system or tenant governance.")
def assert_custom_groups_allowed(session: Session, tenant: Tenant) -> None:
if not effective_tenant_governance(session, tenant).allow_custom_groups:
raise AdminConflictError("Custom tenant groups are disabled by system or tenant governance.")
def assert_custom_roles_allowed(session: Session, tenant: Tenant) -> None:
if not effective_tenant_governance(session, tenant).allow_custom_roles:
raise AdminConflictError("Custom tenant roles are disabled by system or tenant governance.")
def active_api_key_count(session: Session, tenant_id: str) -> int:
return session.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count()

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import Boolean, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
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)
__all__ = ["SystemSettings"]

View File

@@ -1,609 +0,0 @@
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.core.optional import reraise_unless_missing_package
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()
user_ids = [user.id for user in users]
if not user_ids:
return owner_ids
roles_by_user_id: dict[str, list[Role]] = {user_id: [] for user_id in user_ids}
direct_role_rows = (
session.query(UserRoleAssignment.user_id, Role)
.join(Role, UserRoleAssignment.role_id == Role.id)
.filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.user_id.in_(user_ids),
Role.tenant_id == tenant_id,
)
.all()
)
for user_id, role in direct_role_rows:
roles_by_user_id.setdefault(user_id, []).append(role)
group_role_rows = (
session.query(UserGroupMembership.user_id, Role)
.join(GroupRoleAssignment, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
.join(Role, GroupRoleAssignment.role_id == Role.id)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.tenant_id == tenant_id,
UserGroupMembership.user_id.in_(user_ids),
Group.is_active.is_(True),
Role.tenant_id == tenant_id,
)
.all()
)
for user_id, role in group_role_rows:
roles_by_user_id.setdefault(user_id, []).append(role)
for user in users:
effective = [
scope
for role in roles_by_user_id.get(user.id, [])
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 _optional_model(module_name: str, model_name: str):
try:
module = __import__(module_name, fromlist=[model_name])
except ModuleNotFoundError as exc:
reraise_unless_missing_package(exc, module_name.split(".", 1)[0])
return None
return getattr(module, model_name)
def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]:
Campaign = _optional_model("govoplan_campaign.backend.db.models", "Campaign")
FileAsset = _optional_model("govoplan_files.backend.db.models", "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() if Campaign is not None else 0,
"files": session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id).count() if FileAsset is not None else 0,
"api_keys": session.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(),
}
def assert_can_delegate_tenant_permissions(actor_scopes: Iterable[str], permissions: Iterable[str]) -> None:
"""Prevent administrators from defining or assigning roles beyond their own effective tenant powers."""
from govoplan_core.security.permissions import delegateable_tenant_scopes
requested = set(validate_tenant_permissions(permissions))
if "tenant:*" in requested:
# Only a tenant owner-equivalent can delegate the wildcard.
if not scopes_grant(actor_scopes, "tenant:*"):
raise AdminValidationError("You cannot delegate full tenant access unless you already have it.")
return
allowed = delegateable_tenant_scopes(actor_scopes)
missing = sorted(scope for scope in requested if scope not in allowed)
if missing:
raise AdminValidationError("You cannot delegate permissions you do not currently hold: " + ", ".join(missing))
def assert_can_delegate_roles(session: Session, *, actor_scopes: Iterable[str], role_ids: Iterable[str], tenant_id: str) -> None:
ids = sorted(set(role_ids))
if not ids:
return
roles = session.query(Role).filter(Role.tenant_id == tenant_id, Role.id.in_(ids)).all()
if len(roles) != len(ids):
raise AdminValidationError("One or more selected roles do not belong to the tenant.")
for role in roles:
assert_can_delegate_tenant_permissions(actor_scopes, role.permissions or [])

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings
SYSTEM_SETTINGS_ID = "global"
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

File diff suppressed because it is too large Load Diff

View File

@@ -1,510 +0,0 @@
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 TenantSettingsItem(BaseModel):
id: str
slug: str
name: str
default_locale: str = Field(default="en", min_length=1, max_length=20)
settings: dict[str, Any] = Field(default_factory=dict)
class TenantSettingsUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
default_locale: str = Field(min_length=1, max_length=20)
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 PolicySourceStepItem(BaseModel):
scope_type: str
scope_id: str | None = None
label: str
applied_fields: list[str] = Field(default_factory=list)
policy: dict[str, Any] = Field(default_factory=dict)
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
effective_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list)
parent_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list)
class RetentionRunRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
dry_run: bool = True
class RetentionRunResponse(BaseModel):
result: dict[str, Any]
class SystemSettingsItem(BaseModel):
default_locale: str = "en"
allow_tenant_custom_groups: bool = True
allow_tenant_custom_roles: bool = True
allow_tenant_api_keys: bool = True
privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem)
settings: dict[str, Any] = Field(default_factory=dict)
class SystemSettingsUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
default_locale: str = Field(min_length=1, max_length=20)
allow_tenant_custom_groups: bool
allow_tenant_custom_roles: bool
allow_tenant_api_keys: bool
privacy_retention_policy: PrivacyRetentionPolicyItem | None = None
class GovernanceAssignment(BaseModel):
tenant_id: str
mode: Literal["available", "required"] = "available"
class GovernanceTemplateItem(BaseModel):
id: str
kind: Literal["group", "role"]
slug: str
name: str
description: str | None = None
permissions: list[str] = Field(default_factory=list)
effective_permission_count: int = 0
is_active: bool = True
assignments: list[GovernanceAssignment] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class GovernanceTemplateListResponse(BaseModel):
templates: list[GovernanceTemplateItem]
class GovernanceTemplateCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal["group", "role"]
slug: str
name: str = Field(min_length=1, max_length=255)
description: str | None = None
permissions: list[str] = Field(default_factory=list)
is_active: bool = True
assignments: list[GovernanceAssignment] = Field(default_factory=list)
class GovernanceTemplateUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
description: str | None = None
permissions: list[str] = Field(default_factory=list)
is_active: bool = True
assignments: list[GovernanceAssignment] = Field(default_factory=list)
class ApiKeyAdminItem(BaseModel):
id: str
user_id: str
user_email: str
name: str
prefix: str
scopes: list[str] = Field(default_factory=list)
expires_at: datetime | None = None
last_used_at: datetime | None = None
revoked_at: datetime | None = None
created_at: datetime
class ApiKeyListResponse(BaseModel):
api_keys: list[ApiKeyAdminItem]
class AdminApiKeyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
user_id: str | None = None
scopes: list[str] = Field(default_factory=list)
expires_at: datetime | None = None
class AdminApiKeyCreateResponse(ApiKeyAdminItem):
secret: str
class AuditAdminItem(BaseModel):
id: str
scope: Literal["tenant", "system"] = "tenant"
tenant_id: str | None = None
actor_email: str | None = None
action: str
object_type: str | None = None
object_id: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
class AuditAdminListResponse(BaseModel):
items: list[AuditAdminItem]
total: int
page: int = 1
page_size: int = 100
pages: int = 1

View File

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

View File

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

View File

@@ -1,51 +0,0 @@
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
from govoplan_core.core.optional import module_not_found_for
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 module_not_found_for(exc, "govoplan_campaign"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign module is not installed") from exc
raise
return load_campaign_schema()
def _load_campaign_schema_ui() -> dict[str, Any]:
try:
from govoplan_campaign.backend.campaign.loader import load_campaign_schema_ui
except ModuleNotFoundError as exc:
if module_not_found_for(exc, "govoplan_campaign"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign module is not installed") from exc
raise
return load_campaign_schema_ui()
@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()
@router.get("/campaign/ui")
def get_campaign_schema_ui(
principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")),
) -> dict[str, Any]:
"""Return UI metadata paired with the campaign JSON Schema."""
del principal
return _load_campaign_schema_ui()

View File

@@ -4,9 +4,9 @@ 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_access.backend.auth.dependencies import ApiPrincipal
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
from govoplan_audit.backend.db.models import AuditLog
SENSITIVE_DETAIL_KEYS = {

View File

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

View File

@@ -2,8 +2,13 @@ from __future__ import annotations
from celery import Celery
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.settings import settings
from govoplan_core.db.session import configure_database
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
configure_database(settings.database_url)
@@ -30,6 +35,21 @@ def ping():
return "pong"
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(settings.enabled_modules, available=available_modules)
registry = build_platform_registry(enabled_modules)
context = ModuleContext(registry=registry, settings=settings)
configure_runtime(context)
registry.configure_capability_context(context)
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
if not isinstance(capability, CampaignDeliveryTaskProvider):
raise RuntimeError("Campaign delivery task capability is invalid")
return capability
@celery.task(name="multimailer.send_email", bind=True, max_retries=0)
def send_email(self, job_id: str):
"""Send one explicitly queued campaign job.
@@ -40,10 +60,9 @@ def send_email(self, job_id: str):
"""
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()
return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True))
@celery.task(name="multimailer.append_sent", bind=True, max_retries=None)
@@ -51,13 +70,11 @@ 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:
return dict(_campaign_delivery_tasks().append_sent_for_job(session, job_id=job_id))
except Exception as exc:
if getattr(exc, "temporary", None) is True:
raise self.retry(exc=exc, countdown=300)
raise

View File

@@ -10,12 +10,13 @@ from govoplan_core.settings import settings
def main() -> None:
parser = argparse.ArgumentParser(description="Initialize the GovOPlaN database")
parser.add_argument("--database-url", default=settings.database_url, help="Database URL to migrate")
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()
configure_database(args.database_url)
migration = migrate_database(database_url=args.database_url)
if migration.reconciled_revision:
print(f"Reconciled legacy database marker to {migration.reconciled_revision}.")
print(f"Database schema upgraded to {migration.current_revision}.")

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import argparse
import json
from govoplan_core.core.module_management import module_install_plan_commands, saved_module_install_plan
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.settings import settings
def main() -> None:
parser = argparse.ArgumentParser(description="Print the GovOPlaN module package install plan.")
parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.")
parser.add_argument("--format", choices=("shell", "json"), default="shell", help="Output format.")
args = parser.parse_args()
configure_database(str(args.database_url))
with get_database().session() as session:
plan = saved_module_install_plan(session)
if args.format == "json":
print(json.dumps({
"updated_at": plan.updated_at,
"items": [item.as_dict() for item in plan.items],
"commands": list(module_install_plan_commands(plan)),
}, indent=2, sort_keys=True))
return
if not plan.items:
print("# No planned module package changes.")
return
print("# GovOPlaN module package install plan")
if plan.updated_at:
print(f"# Updated: {plan.updated_at}")
for command in module_install_plan_commands(plan):
print(command)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,470 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
import time
from typing import Any
from govoplan_core.core.module_installer import (
ModuleInstallerError,
cancel_module_installer_request,
claim_next_module_installer_request,
default_installer_runtime_dir,
list_module_installer_runs,
list_module_installer_requests,
module_installer_daemon_status,
module_install_preflight,
module_installer_lock_status,
queue_module_installer_request,
read_module_installer_run,
read_module_installer_request,
retry_module_installer_request,
rollback_module_install_run,
run_module_install_plan,
supervise_module_install_plan,
update_module_installer_request,
update_module_installer_daemon_status,
)
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
from govoplan_core.core.module_management import (
configured_enabled_modules,
saved_desired_enabled_modules,
saved_module_install_plan,
)
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.server.registry import available_module_manifests
from govoplan_core.settings import settings
def main() -> int:
parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.")
parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.")
parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.")
parser.add_argument("--webui-root", type=Path, default=_default_webui_root(), help="Core WebUI root for npm package changes.")
parser.add_argument("--npm-bin", default="npm", help="npm executable to use for WebUI package changes.")
parser.add_argument("--build-webui", action="store_true", help="Run npm run build after npm install.")
parser.add_argument("--migrate", action="store_true", help="Run core/module database migrations after package changes and before restart.")
parser.add_argument("--database-backup-command", help="Shell command that creates a database backup before --migrate for non-SQLite databases.")
parser.add_argument("--database-restore-command", help="Shell command that restores the database backup during rollback.")
parser.add_argument("--database-restore-check-command", help="Shell command that validates the created backup can be used before migrations proceed.")
parser.add_argument("--no-activate-installed-modules", action="store_true", help="Do not add successfully installed modules to saved startup state.")
parser.add_argument("--keep-uninstalled-modules-in-desired", action="store_true", help="Do not remove successfully uninstalled modules from saved startup state.")
parser.add_argument("--format", choices=("shell", "json"), default="shell", help="Output format for preflight/dry-run.")
parser.add_argument("--apply", action="store_true", help="Execute the saved plan after preflight passes.")
parser.add_argument("--dry-run", action="store_true", help="Create a run record and snapshots but do not execute commands.")
parser.add_argument("--supervise", action="store_true", help="Apply the plan, run optional restart/health checks, and roll back if the server does not recover.")
parser.add_argument("--restart-command", help="Shell command run after apply and after automatic rollback, for example a systemctl restart command.")
parser.add_argument("--health-url", help="HTTP health URL to poll after apply, for example http://127.0.0.1:8000/health.")
parser.add_argument("--health-timeout-seconds", type=float, default=60.0, help="Maximum time to wait for health after restart.")
parser.add_argument("--health-interval-seconds", type=float, default=2.0, help="Delay between health probes.")
parser.add_argument("--rollback", metavar="RUN_ID", help="Restore package snapshots from a previous installer run.")
parser.add_argument("--list-runs", action="store_true", help="List recent installer run records and lock status.")
parser.add_argument("--show-run", metavar="RUN_ID", help="Print one installer run record.")
parser.add_argument("--lock-status", action="store_true", help="Print the current installer lock status.")
parser.add_argument("--list-requests", action="store_true", help="List queued/running/completed installer requests.")
parser.add_argument("--show-request", metavar="REQUEST_ID", help="Print one installer request record.")
parser.add_argument("--cancel-request", metavar="REQUEST_ID", help="Cancel a queued installer request.")
parser.add_argument("--retry-request", metavar="REQUEST_ID", help="Queue a retry for a failed or cancelled installer request.")
parser.add_argument("--enqueue-supervised", action="store_true", help="Queue a supervised installer request for a running daemon instead of applying immediately.")
parser.add_argument("--daemon-status", action="store_true", help="Print installer daemon heartbeat/status.")
parser.add_argument("--daemon", action="store_true", help="Run the installer request daemon.")
parser.add_argument("--daemon-once", action="store_true", help="Process at most one queued request and exit.")
parser.add_argument("--poll-interval-seconds", type=float, default=2.0, help="Delay between daemon queue polls.")
parser.add_argument("--validate-package-catalog", nargs="?", const="", metavar="PATH", help="Validate a module package catalog JSON file, or the configured catalog when PATH is omitted.")
parser.add_argument("--require-signed-catalog", action="store_true", help="Require package catalog validation to trust an Ed25519 signature.")
parser.add_argument("--approved-catalog-channel", action="append", default=[], help="Approved package catalog channel; may be repeated.")
parser.add_argument("--catalog-trusted-key", action="append", default=[], metavar="KEY_ID=BASE64_PUBLIC_KEY", help="Trusted Ed25519 catalog public key; may be repeated.")
parser.add_argument("--sign-package-catalog", type=Path, metavar="PATH", help="Sign a module package catalog JSON file.")
parser.add_argument("--catalog-signing-key-id", help="Key id to record when signing a package catalog.")
parser.add_argument("--catalog-signing-private-key", type=Path, help="PEM Ed25519 private key used by --sign-package-catalog.")
parser.add_argument("--catalog-output", type=Path, help="Output path for --sign-package-catalog. Defaults to overwriting the input file.")
args = parser.parse_args()
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
try:
if args.sign_package_catalog:
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
path = sign_module_package_catalog(
path=args.sign_package_catalog,
key_id=args.catalog_signing_key_id,
private_key_path=args.catalog_signing_private_key,
output_path=args.catalog_output,
)
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
return 0
if args.validate_package_catalog is not None:
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
result = validate_module_package_catalog(
path,
require_trusted=args.require_signed_catalog,
approved_channels=tuple(args.approved_catalog_channel),
trusted_keys=_trusted_catalog_keys_from_args(args.catalog_trusted_key),
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") else 1
if args.daemon_status:
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.daemon or args.daemon_once:
return _run_daemon(args=args, runtime_dir=runtime_dir)
if args.list_requests:
_print_result({
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
"daemon": module_installer_daemon_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_request:
_print_result(read_module_installer_request(runtime_dir=runtime_dir, request_id=args.show_request), output_format=args.format)
return 0
if args.cancel_request:
_print_result(cancel_module_installer_request(runtime_dir=runtime_dir, request_id=args.cancel_request, cancelled_by="cli"), output_format=args.format)
return 0
if args.retry_request:
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
return 0
if args.enqueue_supervised:
request = queue_module_installer_request(
runtime_dir=runtime_dir,
requested_by="cli",
options=_request_options_from_args(args),
)
_print_result(request, output_format=args.format)
return 0
if args.list_runs:
_print_result({
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
"lock": module_installer_lock_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_run:
_print_result(read_module_installer_run(runtime_dir=runtime_dir, run_id=args.show_run), output_format=args.format)
return 0
if args.lock_status:
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.rollback:
result = rollback_module_install_run(
run_id=args.rollback,
runtime_dir=runtime_dir,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
database_restore_command=args.database_restore_command,
database_url=str(args.database_url),
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
configure_database(str(args.database_url))
available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session:
configured = configured_enabled_modules(settings.enabled_modules)
desired = saved_desired_enabled_modules(session, configured)
plan = saved_module_install_plan(session)
if args.supervise:
result = supervise_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
restart_command=args.restart_command,
health_url=args.health_url,
health_timeout_seconds=args.health_timeout_seconds,
health_interval_seconds=args.health_interval_seconds,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
if args.apply or args.dry_run:
result = run_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
dry_run=args.dry_run,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
from govoplan_core.core.maintenance import saved_maintenance_mode
maintenance = saved_maintenance_mode(session)
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
maintenance_mode=maintenance.enabled,
session=session,
webui_root=args.webui_root,
runtime_dir=runtime_dir,
)
_print_preflight(preflight.as_dict(), output_format=args.format)
return 0 if preflight.allowed else 1
except ModuleInstallerError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
def _default_webui_root() -> Path:
return Path(__file__).resolve().parents[3] / "webui"
def _run_daemon(*, args: argparse.Namespace, runtime_dir: Path) -> int:
update_module_installer_daemon_status(
runtime_dir=runtime_dir,
patch={
"status": "running",
"mode": "once" if args.daemon_once else "daemon",
"started_at": _now(),
"current_request_id": None,
},
)
try:
while True:
update_module_installer_daemon_status(
runtime_dir=runtime_dir,
patch={
"status": "polling",
"last_poll_at": _now(),
"current_request_id": None,
},
)
request = claim_next_module_installer_request(runtime_dir=runtime_dir)
if request is not None:
request_id = str(request["request_id"])
update_module_installer_daemon_status(
runtime_dir=runtime_dir,
patch={
"status": "processing",
"current_request_id": request_id,
"last_claimed_at": _now(),
},
)
_process_request(request=request, args=args, runtime_dir=runtime_dir)
update_module_installer_daemon_status(
runtime_dir=runtime_dir,
patch={
"status": "polling",
"current_request_id": None,
"last_completed_request_id": request_id,
},
)
if args.daemon_once:
return 0
elif args.daemon_once:
return 0
time.sleep(max(args.poll_interval_seconds, 0.2))
finally:
update_module_installer_daemon_status(
runtime_dir=runtime_dir,
patch={
"status": "stopped",
"stopped_at": _now(),
"current_request_id": None,
},
)
def _process_request(*, request: dict[str, object], args: argparse.Namespace, runtime_dir: Path) -> None:
request_id = str(request["request_id"])
options = request.get("options") if isinstance(request.get("options"), dict) else {}
configure_database(str(args.database_url))
try:
available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session:
configured = configured_enabled_modules(settings.enabled_modules)
desired = saved_desired_enabled_modules(session, configured)
plan = saved_module_install_plan(session)
result = supervise_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=_path_option(options, "webui_root") or args.webui_root,
npm_bin=_str_option(options, "npm_bin") or args.npm_bin,
build_webui=_bool_option(options, "build_webui", args.build_webui),
migrate_database=_bool_option(options, "migrate_database", args.migrate),
database_backup_command=_str_option(options, "database_backup_command") or args.database_backup_command,
database_restore_command=_str_option(options, "database_restore_command") or args.database_restore_command,
database_restore_check_command=_str_option(options, "database_restore_check_command") or args.database_restore_check_command,
activate_installed_modules=_bool_option(options, "activate_installed_modules", not args.no_activate_installed_modules),
remove_uninstalled_modules_from_desired=_bool_option(options, "remove_uninstalled_modules_from_desired", not args.keep_uninstalled_modules_in_desired),
restart_commands=_list_option(options, "restart_commands") or _list_option(options, "restart_command") or ([args.restart_command] if args.restart_command else []),
health_urls=_list_option(options, "health_urls") or _list_option(options, "health_url") or ([args.health_url] if args.health_url else []),
health_timeout_seconds=_float_option(options, "health_timeout_seconds", args.health_timeout_seconds),
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
)
update_module_installer_request(
runtime_dir=runtime_dir,
request_id=request_id,
patch={
"status": "completed" if result.return_code == 0 else "failed",
"finished_at": _now(),
"result": result.as_dict(),
},
)
except Exception as exc:
update_module_installer_request(
runtime_dir=runtime_dir,
request_id=request_id,
patch={
"status": "failed",
"finished_at": _now(),
"error": str(exc),
},
)
def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
return {
"webui_root": str(args.webui_root) if args.webui_root else None,
"npm_bin": args.npm_bin,
"build_webui": args.build_webui,
"migrate_database": args.migrate,
"database_backup_command": args.database_backup_command,
"database_restore_command": args.database_restore_command,
"database_restore_check_command": args.database_restore_check_command,
"activate_installed_modules": not args.no_activate_installed_modules,
"remove_uninstalled_modules_from_desired": not args.keep_uninstalled_modules_in_desired,
"restart_commands": [args.restart_command] if args.restart_command else [],
"health_urls": [args.health_url] if args.health_url else [],
"health_timeout_seconds": args.health_timeout_seconds,
"health_interval_seconds": args.health_interval_seconds,
}
def _str_option(options: object, key: str) -> str | None:
if not isinstance(options, dict):
return None
value = options.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _path_option(options: object, key: str) -> Path | None:
value = _str_option(options, key)
return Path(value).expanduser() if value else None
def _bool_option(options: object, key: str, default: bool) -> bool:
if not isinstance(options, dict):
return default
value = options.get(key)
return value if isinstance(value, bool) else default
def _float_option(options: object, key: str, default: float) -> float:
if not isinstance(options, dict):
return default
value = options.get(key)
if isinstance(value, int | float):
return float(value)
return default
def _list_option(options: object, key: str) -> list[str]:
if not isinstance(options, dict):
return []
value = options.get(key)
if isinstance(value, str):
return [item.strip() for item in value.splitlines() if item.strip()]
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
return []
def _trusted_catalog_keys_from_args(values: list[str]) -> dict[str, str] | None:
if not values:
return None
keys: dict[str, str] = {}
for value in values:
key_id, separator, public_key = value.partition("=")
if not separator or not key_id.strip() or not public_key.strip():
raise ModuleInstallerError("--catalog-trusted-key must use KEY_ID=BASE64_PUBLIC_KEY.")
keys[key_id.strip()] = public_key.strip()
return keys
def _now() -> str:
from datetime import UTC, datetime
return datetime.now(tz=UTC).isoformat()
def _print_preflight(payload: dict[str, object], *, output_format: str) -> None:
if output_format == "json":
print(json.dumps(payload, indent=2, sort_keys=True))
return
print(f"# Allowed: {payload['allowed']}")
print(f"# Maintenance mode: {payload['maintenance_mode']}")
print(f"# Frontend rebuild required: {payload['frontend_rebuild_required']}")
for issue in payload.get("issues", []):
if not isinstance(issue, dict):
continue
print(f"# {issue.get('severity')}: {issue.get('code')}: {issue.get('message')}")
commands = payload.get("commands")
if isinstance(commands, list) and commands:
print("")
for command in commands:
print(command)
checklist = payload.get("checklist")
if isinstance(checklist, list) and checklist:
print("")
for item in checklist:
if not isinstance(item, dict):
continue
detail = f": {item.get('detail')}" if item.get("detail") else ""
print(f"# checklist {item.get('status')}: {item.get('label')}{detail}")
def _print_result(payload: dict[str, object], *, output_format: str) -> None:
if output_format == "json":
print(json.dumps(payload, indent=2, sort_keys=True))
return
if "runs" in payload or "requests" in payload or "locked" in payload or "request_id" in payload or "run_id" not in payload:
print(json.dumps(payload, indent=2, sort_keys=True))
return
print(f"# Run: {payload['run_id']}")
print(f"# Status: {payload['status']}")
print(f"# Record: {payload['record_path']}")
if payload.get("error"):
print(f"# Error: {payload['error']}")
commands = payload.get("commands")
if isinstance(commands, list) and commands:
print("")
for command in commands:
print(command)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import argparse
import sys
from govoplan_core.core.module_installer import module_manifest_compatibility_issues
from govoplan_core.server.registry import available_module_manifests
def main() -> int:
parser = argparse.ArgumentParser(description="Verify installed GovOPlaN module manifests in a fresh interpreter.")
parser.add_argument("--installed", action="append", default=[], help="Module id expected to be discoverable.")
parser.add_argument("--absent", action="append", default=[], help="Module id expected not to be discoverable.")
args = parser.parse_args()
manifests = available_module_manifests(ignore_load_errors=True)
failed = False
for module_id in args.installed:
if module_id not in manifests:
print(f"missing expected module manifest: {module_id}", file=sys.stderr)
failed = True
for module_id in args.absent:
if module_id in manifests:
print(f"unexpected module manifest still discoverable: {module_id}", file=sys.stderr)
failed = True
for issue in module_manifest_compatibility_issues(manifests):
print(f"{issue.severity}: {issue.module_id or 'global'}: {issue.message}", file=sys.stderr)
if issue.severity == "blocker":
failed = True
if failed:
return 1
print("Module manifests verified.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,272 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.modules import AccessDecision
ACCESS_MODULE_ID = "access"
TENANCY_MODULE_ID = "tenancy"
POLICY_MODULE_ID = "policy"
AUDIT_MODULE_ID = "audit"
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER = "access.principalResolver"
CAPABILITY_ACCESS_DIRECTORY = "access.directory"
CAPABILITY_ACCESS_PERMISSION_EVALUATOR = "access.permissionEvaluator"
CAPABILITY_ACCESS_RESOURCE_ACCESS = "access.resourceAccess"
CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner"
CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider"
CAPABILITY_AUDIT_SINK = "audit.sink"
ACCESS_CAPABILITY_NAMES = frozenset(
{
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
CAPABILITY_ACCESS_DIRECTORY,
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
CAPABILITY_ACCESS_RESOURCE_ACCESS,
CAPABILITY_ACCESS_TENANT_PROVISIONER,
CAPABILITY_ACCESS_ADMINISTRATION,
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
CAPABILITY_TENANCY_TENANT_RESOLVER,
CAPABILITY_SECURITY_SECRET_PROVIDER,
CAPABILITY_AUDIT_SINK,
}
)
AuthMethod = Literal["session", "api_key", "service_account"]
AccessSubjectKind = Literal["account", "user", "group", "role", "tenant", "api_key", "service_account"]
AccessStatus = Literal["active", "inactive", "suspended"]
PermissionLevel = Literal["system", "tenant"]
AuditOutcome = Literal["success", "failure", "denied", "unknown"]
@dataclass(frozen=True, slots=True)
class PrincipalRef:
account_id: str
membership_id: str | None
tenant_id: str | None
scopes: frozenset[str] = field(default_factory=frozenset)
group_ids: frozenset[str] = field(default_factory=frozenset)
auth_method: AuthMethod = "session"
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
@dataclass(frozen=True, slots=True)
class AccountRef:
id: str
email: str
display_name: str | None = None
status: AccessStatus = "active"
@dataclass(frozen=True, slots=True)
class TenantRef:
id: str
name: str
slug: str | None = None
status: AccessStatus = "active"
@dataclass(frozen=True, slots=True)
class UserRef:
id: str
account_id: str
tenant_id: str
email: str | None = None
display_name: str | None = None
status: AccessStatus = "active"
@dataclass(frozen=True, slots=True)
class GroupRef:
id: str
tenant_id: str
name: str
status: AccessStatus = "active"
@dataclass(frozen=True, slots=True)
class RoleRef:
id: str
name: str
level: PermissionLevel
tenant_id: str | None = None
managed: bool = False
protected: bool = False
@dataclass(frozen=True, slots=True)
class AccessSubjectRef:
kind: AccessSubjectKind
id: str
tenant_id: str | None = None
label: str | None = None
@dataclass(frozen=True, slots=True)
class TenantOwnerCandidateRef:
account_id: str
email: str
display_name: str | None = None
@dataclass(frozen=True, slots=True)
class GovernanceTemplateMaterialization:
template_id: str
kind: Literal["group", "role"]
tenant_id: str
slug: str
name: str
description: str | None = None
permissions: tuple[str, ...] = ()
is_active: bool = True
required: bool = False
@dataclass(frozen=True, slots=True)
class AuditEvent:
event_type: str
action: str
outcome: AuditOutcome = "unknown"
actor: PrincipalRef | None = None
tenant_id: str | None = None
resource_type: str | None = None
resource_id: str | None = None
occurred_at: datetime | None = None
details: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class PrincipalResolver(Protocol):
def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef:
...
@runtime_checkable
class TenantResolver(Protocol):
def get_tenant(self, tenant_id: str) -> TenantRef | None:
...
def current_tenant(self, principal: PrincipalRef) -> TenantRef | None:
...
@runtime_checkable
class AccessDirectory(Protocol):
def get_account(self, account_id: str) -> AccountRef | None:
...
def get_user(self, user_id: str) -> UserRef | None:
...
def get_users(self, user_ids: Iterable[str]) -> Mapping[str, UserRef]:
...
def users_for_tenant(self, tenant_id: str) -> Sequence[UserRef]:
...
def get_group(self, group_id: str) -> GroupRef | None:
...
def get_groups(self, group_ids: Iterable[str]) -> Mapping[str, GroupRef]:
...
def groups_for_tenant(self, tenant_id: str) -> Sequence[GroupRef]:
...
def groups_for_user(self, user_id: str, *, tenant_id: str) -> Sequence[GroupRef]:
...
def display_label(self, subject: AccessSubjectRef) -> str | None:
...
@runtime_checkable
class PermissionEvaluator(Protocol):
def scopes_grant(self, scopes: Iterable[str], required_scope: str) -> bool:
...
def has_scope(self, principal: PrincipalRef, required_scope: str) -> bool:
...
def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision:
...
@runtime_checkable
class ResourceAccessService(Protocol):
def explain(self, principal: PrincipalRef, *, resource_type: str, resource_id: str, action: str) -> AccessDecision:
...
@runtime_checkable
class TenantAccessProvisioner(Protocol):
def ensure_default_roles(self, session: object, tenant: object | None = None) -> Mapping[str, object]:
...
def tenant_owner_candidates(self, session: object) -> Sequence[TenantOwnerCandidateRef]:
...
def ensure_tenant_owner_membership(
self,
session: object,
*,
tenant: object,
owner_account_id: str,
) -> str:
...
@runtime_checkable
class AccessAdministration(Protocol):
def system_account_count(self, session: object) -> int:
...
def role_count_for_tenant(self, session: object, tenant_id: str) -> int:
...
def active_api_key_count_for_tenant(self, session: object, tenant_id: str) -> int:
...
def actor_email_by_user_id(self, session: object, user_ids: Iterable[str]) -> Mapping[str, str | None]:
...
def user_ids_for_actor_filter(self, session: object, *, operator: str, value: str) -> Sequence[str]:
...
@runtime_checkable
class AccessGovernanceMaterializer(Protocol):
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
...
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
...
@runtime_checkable
class SecretProvider(Protocol):
def store_secret(self, *, scope: str, name: str, value: str) -> str:
...
def read_secret(self, secret_ref: str) -> str | None:
...
def delete_secret(self, secret_ref: str) -> None:
...
@runtime_checkable
class AuditSink(Protocol):
def record(self, event: AuditEvent) -> None:
...

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT = "campaigns.mailPolicyContext"
CAPABILITY_CAMPAIGNS_ACCESS = "campaigns.access"
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT = "campaigns.policyContext"
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS = "campaigns.deliveryTasks"
CAPABILITY_CAMPAIGNS_RETENTION = "campaigns.retention"
@dataclass(frozen=True, slots=True)
class CampaignMailPolicyContext:
id: str
tenant_id: str
owner_user_id: str | None = None
owner_group_id: str | None = None
mail_profile_policy: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class CampaignPolicyContext:
id: str
tenant_id: str
owner_user_id: str | None = None
owner_group_id: str | None = None
settings: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class CampaignMailPolicyContextProvider(Protocol):
def get_campaign_mail_policy_context(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
) -> CampaignMailPolicyContext | None:
...
def set_campaign_mail_profile_policy(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
policy: Mapping[str, object],
) -> Mapping[str, object]:
...
@runtime_checkable
class CampaignAccessProvider(Protocol):
def campaign_exists(self, session: object, *, tenant_id: str, campaign_id: str) -> bool:
...
def can_read_campaign(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
user_id: str,
group_ids: Iterable[str] = (),
tenant_admin: bool = False,
) -> bool:
...
@runtime_checkable
class CampaignPolicyContextProvider(Protocol):
def get_campaign_policy_context(
self,
session: object,
*,
tenant_id: str | None = None,
campaign_id: str,
) -> CampaignPolicyContext | None:
...
def set_campaign_settings(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
settings: Mapping[str, object],
) -> Mapping[str, object]:
...
@runtime_checkable
class CampaignDeliveryTaskProvider(Protocol):
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
...
def append_sent_for_job(self, session: object, *, job_id: str) -> Mapping[str, object]:
...
@runtime_checkable
class CampaignRetentionProvider(Protocol):
def apply_retention(
self,
session: object,
*,
dry_run: bool,
now: datetime,
policy_for_campaign_id: Callable[[str | None], object],
) -> Mapping[str, Mapping[str, int]]:
...

View File

@@ -31,13 +31,20 @@ def discover_module_manifests(
*,
enabled_modules: Iterable[str] | None = None,
group: str = ENTRY_POINT_GROUP,
ignore_load_errors: bool = False,
) -> 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):
if enabled is not None and entry_point.name not in enabled:
continue
manifest = _load_manifest(entry_point)
try:
manifest = _load_manifest(entry_point)
except ModuleNotFoundError as exc:
module_root = entry_point.module.split(".", 1)[0]
if ignore_load_errors and exc.name == module_root:
continue
raise
if enabled is not None and manifest.id not in enabled:
continue
manifests.append(manifest)

View File

@@ -0,0 +1,145 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from threading import RLock
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status
from govoplan_core.core.module_management import ModuleManagementError, REQUIRED_PLATFORM_MODULES, plan_desired_enabled_modules
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime
@dataclass(frozen=True, slots=True)
class ModuleLifecycleResult:
enabled_modules: tuple[str, ...]
activated_modules: tuple[str, ...]
deactivated_modules: tuple[str, ...]
mounted_modules: tuple[str, ...]
migrations_applied: bool = False
def require_module_active(module_id: str):
def dependency(request: Request) -> None:
registry = getattr(request.app.state, "govoplan_registry", None)
if isinstance(registry, PlatformRegistry) and registry.has_module(module_id):
return
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}")
return dependency
class ModuleLifecycleManager:
def __init__(
self,
*,
registry: PlatformRegistry,
available_modules: Mapping[str, ModuleManifest],
settings: object | None,
api_prefix: str = "/api/v1",
manifest_factories: Sequence[object] = (),
module_context_data: Mapping[str, object] | None = None,
) -> None:
self.registry = registry
self.available_modules = dict(available_modules)
self.settings = settings
self.api_prefix = api_prefix
self.manifest_factories = tuple(manifest_factories)
self.context = ModuleContext(registry=registry, settings=settings, data=dict(module_context_data or {}))
self._app: FastAPI | None = None
self._mounted_modules: set[str] = set()
self._lock = RLock()
def attach_app(self, app: FastAPI) -> None:
self._app = app
app.state.govoplan_lifecycle = self
def configure_runtime(self) -> None:
configure_runtime(self.context)
self.registry.configure_capability_context(self.context)
def active_module_ids(self) -> tuple[str, ...]:
return tuple(manifest.id for manifest in self.registry.manifests())
def mounted_module_ids(self) -> tuple[str, ...]:
return tuple(sorted(self._mounted_modules))
def apply_enabled_modules(
self,
requested_enabled: Sequence[str],
*,
migrate: bool = True,
protected_modules: Sequence[str] = REQUIRED_PLATFORM_MODULES,
) -> ModuleLifecycleResult:
with self._lock:
plan = plan_desired_enabled_modules(
requested_enabled,
self.available_modules,
protected_modules=protected_modules,
)
previous = self.active_module_ids()
previous_set = set(previous)
next_set = set(plan.enabled_modules)
activated = tuple(module_id for module_id in plan.enabled_modules if module_id not in previous_set)
deactivated = tuple(module_id for module_id in previous if module_id not in next_set)
if migrate:
self._migrate(plan.enabled_modules)
mounted = tuple(module_id for module_id in plan.enabled_modules if self._mount_module_router(module_id))
old_manifests = {manifest.id: manifest for manifest in self.registry.manifests()}
for module_id in deactivated:
hook = old_manifests[module_id].on_deactivate
if hook is not None:
hook(self.context)
self.registry.replace(self.available_modules[module_id] for module_id in plan.enabled_modules)
self.configure_runtime()
for module_id in activated:
hook = self.available_modules[module_id].on_activate
if hook is not None:
hook(self.context)
if self._app is not None:
self._app.openapi_schema = None
return ModuleLifecycleResult(
enabled_modules=plan.enabled_modules,
activated_modules=activated,
deactivated_modules=deactivated,
mounted_modules=mounted,
migrations_applied=migrate,
)
def _mount_module_router(self, module_id: str) -> bool:
if module_id in self._mounted_modules:
return False
app = self._app
if app is None:
raise ModuleManagementError("Module lifecycle manager is not attached to the FastAPI app.")
manifest = self.available_modules.get(module_id)
if manifest is None or manifest.route_factory is None:
self._mounted_modules.add(module_id)
return False
module_router = manifest.route_factory(self.context)
guarded = APIRouter(dependencies=[Depends(require_module_active(module_id))])
guarded.include_router(module_router)
app.include_router(guarded, prefix=self.api_prefix)
app.openapi_schema = None
self._mounted_modules.add(module_id)
return True
def _migrate(self, enabled_modules: Sequence[str]) -> None:
from govoplan_core.db.migrations import migrate_database
database_url = str(getattr(self.settings, "database_url", "") or "")
migrate_database(
database_url=database_url or None,
enabled_modules=enabled_modules,
manifest_factories=self.manifest_factories,
)

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
MAINTENANCE_SETTINGS_KEY = "maintenance_mode"
MAINTENANCE_ACCESS_SCOPE = "system:maintenance:access"
DEFAULT_MAINTENANCE_MESSAGE = "GovOPlaN is currently in maintenance mode."
@dataclass(frozen=True, slots=True)
class MaintenanceMode:
enabled: bool = False
message: str | None = None
def as_dict(self) -> dict[str, object]:
return {
"enabled": self.enabled,
"message": self.message,
}
def maintenance_mode_from_settings(settings: Mapping[str, object]) -> MaintenanceMode:
raw = settings.get(MAINTENANCE_SETTINGS_KEY)
if not isinstance(raw, Mapping):
return MaintenanceMode()
return MaintenanceMode(
enabled=bool(raw.get("enabled")),
message=_clean_message(raw.get("message")),
)
def saved_maintenance_mode(session: Session) -> MaintenanceMode:
item = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
if item is None:
return MaintenanceMode()
return maintenance_mode_from_settings(item.settings or {})
def save_maintenance_mode(session: Session, mode: MaintenanceMode) -> MaintenanceMode:
item = get_system_settings(session)
settings = dict(item.settings or {})
settings[MAINTENANCE_SETTINGS_KEY] = mode.as_dict()
item.settings = settings
session.add(item)
session.flush()
return mode
def maintenance_response_detail(mode: MaintenanceMode) -> dict[str, object]:
return {
"code": "maintenance_mode",
"message": mode.message or DEFAULT_MAINTENANCE_MESSAGE,
"required_scope": MAINTENANCE_ACCESS_SCOPE,
}
def _clean_message(value: object) -> str | None:
if value is None:
return None
cleaned = str(value).strip()
return cleaned or None

View File

@@ -15,15 +15,17 @@ class MigrationMetadataPlan:
def migration_metadata_plan(registry: PlatformRegistry, *, extra_metadata: Iterable[MetaData] = ()) -> MigrationMetadataPlan:
metadata = list(extra_metadata)
metadata: list[MetaData] = []
script_locations: list[str] = []
for item in extra_metadata:
if item not in metadata:
metadata.append(item)
for manifest in registry.manifests():
spec = manifest.migration_spec
if spec is None:
continue
if isinstance(spec.metadata, MetaData):
if isinstance(spec.metadata, MetaData) and spec.metadata not in metadata:
metadata.append(spec.metadata)
if spec.script_location:
script_locations.append(spec.script_location)
return MigrationMetadataPlan(metadata=tuple(metadata), script_locations=tuple(script_locations))

View File

@@ -0,0 +1,117 @@
from __future__ import annotations
from collections.abc import Callable
from sqlalchemy import func, inspect, select
from govoplan_core.core.modules import MigrationRetirementPlan, ModuleUninstallGuardResult
def persistent_table_uninstall_guard(
*models: object,
label: str | None = None,
) -> Callable[[object | None, str], tuple[ModuleUninstallGuardResult, ...]]:
def guard(session: object | None, module_id: str) -> tuple[ModuleUninstallGuardResult, ...]:
if session is None or not hasattr(session, "execute") or not hasattr(session, "get_bind"):
return (
ModuleUninstallGuardResult(
"warning",
"uninstall_guard_no_session",
"Persistent-data uninstall guard could not inspect the database without a session.",
),
)
bind = session.get_bind() # type: ignore[attr-defined]
inspector = inspect(bind)
populated_tables: list[str] = []
for model in models:
table = getattr(model, "__table__", None)
if table is None or not inspector.has_table(table.name):
continue
row_count = int(session.execute(select(func.count()).select_from(table)).scalar_one()) # type: ignore[attr-defined]
if row_count:
populated_tables.append(f"{table.name} ({row_count})")
if not populated_tables:
return ()
table_summary = ", ".join(populated_tables[:8])
if len(populated_tables) > 8:
table_summary += f", and {len(populated_tables) - 8} more"
return (
ModuleUninstallGuardResult(
"warning",
"persistent_data_present",
f"{label or module_id} owns persistent data: {table_summary}. Non-destructive uninstall leaves this data dormant until the module is reinstalled or an explicit retirement provider handles it.",
),
)
return guard
def drop_table_retirement_provider(
*models: object,
label: str | None = None,
) -> Callable[[object | None, str], MigrationRetirementPlan]:
def provider(session: object | None, module_id: str) -> MigrationRetirementPlan:
module_label = label or module_id
tables = tuple(_model_tables(models))
table_names = tuple(table.name for table in tables)
if session is None or not hasattr(session, "get_bind"):
return MigrationRetirementPlan(
supported=False,
summary=f"{module_label} table retirement could not inspect the database without a session.",
blocking_reason="No database session is available.",
)
if not tables:
return MigrationRetirementPlan(
supported=True,
summary=f"{module_label} declares no module-owned tables to retire.",
destroy_data_supported=True,
destroy_data_summary=f"{module_label} has no module-owned tables to drop.",
destroy_data_executor=lambda _session, _module_id: None,
)
bind = session.get_bind() # type: ignore[attr-defined]
inspector = inspect(bind)
existing = tuple(table for table in tables if inspector.has_table(table.name))
existing_names = tuple(table.name for table in existing)
missing_names = tuple(name for name in table_names if name not in existing_names)
summary = f"{module_label} can retire {len(existing_names)} table(s): {', '.join(existing_names) if existing_names else 'none currently present'}."
warnings: list[str] = []
if missing_names:
warnings.append("Tables not present and therefore skipped: " + ", ".join(missing_names))
def executor(execute_session: object, _module_id: str) -> None:
if not hasattr(execute_session, "get_bind"):
raise RuntimeError("No database session is available for destructive table retirement.")
execute_bind = execute_session.get_bind() # type: ignore[attr-defined]
live_inspector = inspect(execute_bind)
live_tables = [table for table in tables if live_inspector.has_table(table.name)]
if not live_tables:
return
metadata = live_tables[0].metadata
metadata.drop_all(bind=execute_bind, tables=live_tables, checkfirst=True)
return MigrationRetirementPlan(
supported=True,
summary=summary,
warnings=tuple(warnings),
destroy_data_supported=True,
destroy_data_summary=(
f"Destroying data will drop {module_label} table(s): "
+ (", ".join(existing_names) if existing_names else "none currently present")
+ "."
),
destroy_data_warnings=(
"This permanently deletes module-owned rows unless the installer rollback restores the database snapshot.",
),
destroy_data_executor=executor,
)
return provider
def _model_tables(models: tuple[object, ...]) -> tuple[object, ...]:
tables: list[object] = []
for model in models:
table = getattr(model, "__table__", None)
if table is not None:
tables.append(table)
return tuple(tables)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,225 @@
from __future__ import annotations
import base64
import binascii
from datetime import UTC, datetime
import json
import os
from pathlib import Path
from typing import Any
import urllib.error
import urllib.request
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
required = tuple(dict.fromkeys(str(item).strip() for item in required_features if str(item).strip()))
if not required:
return {"allowed": True, "required_features": [], "missing_features": [], "enforced": False}
validation = validate_module_license()
enforced = _license_enforcement_enabled()
if not validation["valid"]:
return {
"allowed": not enforced,
"required_features": list(required),
"missing_features": list(required),
"enforced": enforced,
"license": validation,
"reason": validation["error"] or "No valid license is configured.",
}
licensed_features = set(validation["features"])
missing = tuple(feature for feature in required if feature not in licensed_features)
return {
"allowed": not missing or not enforced,
"required_features": list(required),
"missing_features": list(missing),
"enforced": enforced,
"license": validation,
"reason": "Missing licensed feature(s): " + ", ".join(missing) if missing else None,
}
def validate_module_license(
path: Path | None = None,
*,
trusted_keys: dict[str, str] | None = None,
require_trusted: bool | None = None,
) -> dict[str, object]:
license_path = path or _configured_license_path()
effective_require_trusted = _license_enforcement_enabled() if require_trusted is None else require_trusted
if license_path is None:
return _license_result(valid=not effective_require_trusted, configured=False, path=None, error="No license file configured.")
if not license_path.exists():
return _license_result(valid=False, configured=True, path=license_path, error=f"License file does not exist: {license_path}")
try:
payload = json.loads(license_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError("License file must contain a JSON object.")
features = _string_list(payload.get("features"))
valid_from = _parse_datetime(payload.get("valid_from"))
valid_until = _parse_datetime(payload.get("valid_until"))
now = datetime.now(tz=UTC)
if valid_from is not None and now < valid_from:
return _license_result(valid=False, configured=True, path=license_path, payload=payload, features=features, error=f"License is not valid before {valid_from.isoformat()}.")
if valid_until is not None and now > valid_until:
return _license_result(valid=False, configured=True, path=license_path, payload=payload, features=features, error=f"License expired at {valid_until.isoformat()}.")
signature_state = _license_signature_state(payload, trusted_keys=trusted_keys if trusted_keys is not None else _configured_trusted_keys())
if signature_state.get("fatal"):
return _license_result(valid=False, configured=True, path=license_path, payload=payload, features=features, signature_state=signature_state, error=str(signature_state["error"]))
if effective_require_trusted and not signature_state["trusted"]:
return _license_result(valid=False, configured=True, path=license_path, payload=payload, features=features, signature_state=signature_state, error=str(signature_state["error"] or "License must be signed by a trusted key."))
return _license_result(valid=True, configured=True, path=license_path, payload=payload, features=features, signature_state=signature_state)
except Exception as exc:
return _license_result(valid=False, configured=True, path=license_path, error=str(exc))
def _license_result(
*,
valid: bool,
configured: bool,
path: Path | None,
error: str | None = None,
payload: dict[str, object] | None = None,
features: list[str] | None = None,
signature_state: dict[str, object] | None = None,
) -> dict[str, object]:
state = signature_state or {"signed": False, "trusted": False, "key_id": None}
return {
"valid": valid,
"configured": configured,
"path": str(path) if path is not None else None,
"license_id": str(payload.get("license_id")) if payload and payload.get("license_id") else None,
"subject": str(payload.get("subject")) if payload and payload.get("subject") else None,
"features": features or [],
"valid_from": str(payload.get("valid_from")) if payload and payload.get("valid_from") else None,
"valid_until": str(payload.get("valid_until")) if payload and payload.get("valid_until") else None,
"signed": bool(state.get("signed")),
"trusted": bool(state.get("trusted")),
"key_id": state.get("key_id") if isinstance(state.get("key_id"), str) else None,
"error": error,
}
def _configured_license_path() -> Path | None:
value = os.getenv("GOVOPLAN_LICENSE_FILE", "").strip()
return Path(value).expanduser() if value else None
def _license_enforcement_enabled() -> bool:
return os.getenv("GOVOPLAN_LICENSE_ENFORCEMENT", "").strip().lower() in {"1", "true", "yes", "on"}
def _configured_trusted_keys() -> dict[str, str]:
file_value = os.getenv("GOVOPLAN_LICENSE_TRUSTED_KEYS_FILE", "").strip()
if file_value:
path = Path(file_value).expanduser()
if not path.exists():
raise ValueError(f"Trusted license key file does not exist: {path}")
return _parse_trusted_keys(path.read_text(encoding="utf-8"))
url_value = os.getenv("GOVOPLAN_LICENSE_TRUSTED_KEYS_URL", "").strip()
if url_value:
return _parse_trusted_keys(_read_trusted_keys_url(url_value))
value = os.getenv("GOVOPLAN_LICENSE_TRUSTED_KEYS", "").strip()
return _parse_trusted_keys(value) if value else {}
def _configured_trusted_keys_cache_path() -> Path | None:
value = os.getenv("GOVOPLAN_LICENSE_TRUSTED_KEYS_CACHE", "").strip()
return Path(value).expanduser() if value else None
def _read_trusted_keys_url(url: str) -> str:
if not url.startswith(("https://", "http://")):
raise ValueError("Trusted license key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path()
try:
with urllib.request.urlopen(url, timeout=15) as response:
body = response.read().decode("utf-8")
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
return body
except (OSError, urllib.error.URLError):
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8")
raise
def _parse_trusted_keys(value: str) -> dict[str, str]:
payload = json.loads(value)
if isinstance(payload, dict) and isinstance(payload.get("keys"), list):
keys: dict[str, str] = {}
now = datetime.now(tz=UTC)
for item in payload["keys"]:
if not isinstance(item, dict):
continue
if str(item.get("status") or "active").strip().lower() in {"revoked", "disabled", "retired"}:
continue
not_before = _parse_datetime(item.get("not_before"))
not_after = _parse_datetime(item.get("not_after"))
if not_before is not None and now < not_before:
continue
if not_after is not None and now > not_after:
continue
key_id = str(item.get("key_id") or "").strip()
public_key = str(item.get("public_key") or item.get("public_key_base64") or "").strip()
if key_id and public_key:
keys[key_id] = public_key
return keys
if not isinstance(payload, dict):
raise ValueError("Trusted license keys must be a JSON object.")
return {str(key): str(item) for key, item in payload.items() if str(key).strip() and str(item).strip()}
def _license_signature_state(payload: dict[str, object], *, trusted_keys: dict[str, str]) -> dict[str, object]:
signature = payload.get("signature")
if signature is None:
return {"signed": False, "trusted": False, "key_id": None, "error": "License is unsigned.", "fatal": False}
if not isinstance(signature, dict):
return {"signed": True, "trusted": False, "key_id": None, "error": "License signature must be an object.", "fatal": True}
algorithm = str(signature.get("algorithm") or "").strip().lower()
key_id = str(signature.get("key_id") or "").strip()
value = str(signature.get("value") or "").strip()
if algorithm != "ed25519":
return {"signed": True, "trusted": False, "key_id": key_id or None, "error": f"Unsupported license signature algorithm: {algorithm!r}", "fatal": True}
if not key_id or not value:
return {"signed": True, "trusted": False, "key_id": key_id or None, "error": "License signature requires key_id and value.", "fatal": True}
trusted_key = trusted_keys.get(key_id)
if not trusted_key:
return {"signed": True, "trusted": False, "key_id": key_id, "error": f"License signature key is not trusted: {key_id}", "fatal": False}
try:
public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(trusted_key, validate=True))
signed_payload = dict(payload)
signed_payload.pop("signature", None)
public_key.verify(base64.b64decode(value, validate=True), _canonical_bytes(signed_payload))
except (ValueError, binascii.Error, InvalidSignature) as exc:
return {"signed": True, "trusted": False, "key_id": key_id, "error": f"License signature verification failed: {exc}", "fatal": True}
return {"signed": True, "trusted": True, "key_id": key_id, "error": None, "fatal": False}
def _canonical_bytes(payload: object) -> bytes:
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def _parse_datetime(value: object) -> datetime | None:
if value is None:
return None
text = str(value).strip()
if not text:
return None
if text.endswith("Z"):
text = text[:-1] + "+00:00"
parsed = datetime.fromisoformat(text)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
def _string_list(value: object) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("License features must be a list.")
return [str(item).strip() for item in value if str(item).strip()]

View File

@@ -0,0 +1,421 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
import shlex
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.db.session import get_database
from govoplan_core.server.registry import parse_enabled_modules
MODULE_SETTINGS_KEY = "module_management"
INSTALL_PLAN_KEY = "install_plan"
REQUIRED_PLATFORM_MODULES = ("tenancy", "access")
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
INSTALL_PLAN_ACTIONS = ("install", "uninstall")
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:")
class ModuleManagementError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ModuleStatePlan:
enabled_modules: tuple[str, ...]
added_dependencies: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class ModuleInstallPlanItem:
module_id: str
action: str
python_package: str | None = None
python_ref: str | None = None
webui_package: str | None = None
webui_ref: str | None = None
destroy_data: bool = False
status: str = "planned"
notes: str | None = None
def as_dict(self) -> dict[str, object]:
payload: dict[str, object] = {
"module_id": self.module_id,
"action": self.action,
"status": self.status,
}
if self.destroy_data:
payload["destroy_data"] = True
for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"):
value = getattr(self, key)
if value:
payload[key] = value
return payload
@dataclass(frozen=True, slots=True)
class ModuleInstallPlan:
items: tuple[ModuleInstallPlanItem, ...] = ()
updated_at: str | None = None
def configured_enabled_modules(value: str | Iterable[str]) -> tuple[str, ...]:
return tuple(dict.fromkeys(parse_enabled_modules(value)))
def startup_candidate_module_ids(
configured: str | Iterable[str],
desired: Iterable[str] | None = None,
) -> tuple[str, ...]:
fallback = configured_enabled_modules(configured)
candidates = [*fallback]
if desired is not None:
candidates.extend(str(item).strip() for item in desired if str(item).strip())
candidates.extend(REQUIRED_PLATFORM_MODULES)
if "admin" in fallback:
candidates.append("admin")
return tuple(dict.fromkeys(candidates))
def saved_desired_enabled_modules(session: Session, fallback: Iterable[str]) -> tuple[str, ...]:
item = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
if item is None:
return tuple(fallback)
return desired_enabled_modules_from_settings(item.settings or {}, fallback)
def desired_enabled_modules_from_settings(settings: Mapping[str, object], fallback: Iterable[str]) -> tuple[str, ...]:
raw_management = settings.get(MODULE_SETTINGS_KEY)
if not isinstance(raw_management, Mapping):
return tuple(fallback)
raw_enabled = raw_management.get("desired_enabled")
if not isinstance(raw_enabled, list | tuple):
return tuple(fallback)
normalized = [str(item).strip() for item in raw_enabled if str(item).strip()]
return tuple(dict.fromkeys(normalized)) or tuple(fallback)
def load_startup_enabled_modules(
configured: str | Iterable[str],
*,
available: Mapping[str, ModuleManifest] | None = None,
) -> tuple[str, ...]:
fallback = configured_enabled_modules(configured)
try:
with get_database().session() as session:
desired = saved_desired_enabled_modules(session, fallback)
except (RuntimeError, SQLAlchemyError):
return fallback
if available is None:
return desired
protected = list(REQUIRED_PLATFORM_MODULES)
if "admin" in fallback:
protected.append("admin")
protected = [module_id for module_id in protected if module_id in available]
requested = [module_id for module_id in desired if module_id in available]
if not requested:
requested = [module_id for module_id in fallback if module_id in available]
return plan_desired_enabled_modules(requested, available, protected_modules=protected).enabled_modules
def save_desired_enabled_modules(session: Session, enabled_modules: Iterable[str]) -> tuple[str, ...]:
item = get_system_settings(session)
settings = dict(item.settings or {})
management = _management_settings(settings)
management["desired_enabled"] = list(dict.fromkeys(enabled_modules))
settings[MODULE_SETTINGS_KEY] = management
item.settings = settings
session.add(item)
session.flush()
return tuple(management["desired_enabled"]) # type: ignore[arg-type]
def module_install_plan_from_settings(settings: Mapping[str, object]) -> ModuleInstallPlan:
raw_management = settings.get(MODULE_SETTINGS_KEY)
if not isinstance(raw_management, Mapping):
return ModuleInstallPlan()
raw_plan = raw_management.get(INSTALL_PLAN_KEY)
if not isinstance(raw_plan, Mapping):
return ModuleInstallPlan()
raw_items = raw_plan.get("items")
if not isinstance(raw_items, list | tuple):
return ModuleInstallPlan(updated_at=_clean_optional_string(raw_plan.get("updated_at")))
return ModuleInstallPlan(
items=tuple(normalize_module_install_plan_item(item) for item in raw_items),
updated_at=_clean_optional_string(raw_plan.get("updated_at")),
)
def saved_module_install_plan(session: Session) -> ModuleInstallPlan:
item = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
if item is None:
return ModuleInstallPlan()
return module_install_plan_from_settings(item.settings or {})
def save_module_install_plan(
session: Session,
items: Iterable[Mapping[str, object] | ModuleInstallPlanItem],
) -> ModuleInstallPlan:
normalized = tuple(normalize_module_install_plan_item(item) for item in items)
item = get_system_settings(session)
settings = dict(item.settings or {})
management = _management_settings(settings)
updated_at = datetime.now(tz=UTC).isoformat()
management[INSTALL_PLAN_KEY] = {
"updated_at": updated_at,
"items": [entry.as_dict() for entry in normalized],
}
settings[MODULE_SETTINGS_KEY] = management
item.settings = settings
session.add(item)
session.flush()
return ModuleInstallPlan(items=normalized, updated_at=updated_at)
def clear_module_install_plan(session: Session) -> ModuleInstallPlan:
return save_module_install_plan(session, ())
def module_install_plan_commands(
plan: ModuleInstallPlan | Iterable[ModuleInstallPlanItem],
*,
statuses: Iterable[str] = ("planned",),
) -> tuple[str, ...]:
items = plan.items if isinstance(plan, ModuleInstallPlan) else tuple(plan)
included_statuses = {status for status in statuses}
commands: list[str] = []
for item in items:
if item.status not in included_statuses:
continue
if item.action == "install":
if item.python_ref:
commands.append(f"python -m pip install {shlex.quote(item.python_ref)}")
if item.webui_package and item.webui_ref:
commands.append(
"npm pkg set "
+ shlex.quote(f"dependencies.{item.webui_package}={item.webui_ref}")
+ " && npm install"
)
elif item.action == "uninstall":
if item.python_package:
commands.append(f"python -m pip uninstall -y {shlex.quote(item.python_package)}")
if item.webui_package:
commands.append(
"npm pkg delete "
+ shlex.quote(f"dependencies.{item.webui_package}")
+ " && npm install"
)
return tuple(commands)
def installed_module_distribution_name(module_id: str) -> str | None:
clean_module_id = str(module_id).strip()
if not clean_module_id:
return None
for entry_point in iter_module_entry_points():
if entry_point.name != clean_module_id:
continue
distribution = getattr(entry_point, "dist", None)
metadata = getattr(distribution, "metadata", None)
if metadata is None:
return None
name = metadata.get("Name")
return str(name).strip() if name else None
return None
def module_uninstall_plan_item(
module_id: str,
available: Mapping[str, ModuleManifest],
) -> ModuleInstallPlanItem:
clean_module_id = str(module_id).strip()
if not clean_module_id:
raise ModuleManagementError("Uninstall plan needs a module id.")
manifest = available.get(clean_module_id)
if manifest is None:
raise ModuleManagementError(f"Module {clean_module_id!r} is not installed.")
python_package = installed_module_distribution_name(clean_module_id)
if not python_package:
raise ModuleManagementError(
f"Could not determine the installed Python distribution for module {clean_module_id!r}."
)
frontend = manifest.frontend
return ModuleInstallPlanItem(
module_id=clean_module_id,
action="uninstall",
python_package=python_package,
webui_package=frontend.package_name if frontend and frontend.package_name else None,
notes="Non-destructive uninstall leaves module data and schema dormant.",
)
def desired_modules_after_package_plan(
desired_enabled: Iterable[str],
plan: ModuleInstallPlan,
*,
activate_installed_modules: bool = True,
remove_uninstalled_modules_from_desired: bool = True,
) -> tuple[str, ...]:
desired = [str(item).strip() for item in desired_enabled if str(item).strip()]
planned_items = tuple(item for item in plan.items if item.status == "planned")
if remove_uninstalled_modules_from_desired:
removed = {item.module_id for item in planned_items if item.action == "uninstall"}
desired = [module_id for module_id in desired if module_id not in removed]
if activate_installed_modules:
desired.extend(item.module_id for item in planned_items if item.action == "install")
return tuple(dict.fromkeys(desired))
def normalize_module_install_plan_item(
item: Mapping[str, object] | ModuleInstallPlanItem,
) -> ModuleInstallPlanItem:
if isinstance(item, ModuleInstallPlanItem):
raw = item.as_dict()
elif isinstance(item, Mapping):
raw = item
else:
raise ModuleManagementError("Install plan item must be an object.")
module_id = _required_string(raw.get("module_id"), "module_id")
action = _required_string(raw.get("action"), "action")
status = _clean_optional_string(raw.get("status")) or "planned"
python_package = _clean_optional_string(raw.get("python_package"))
python_ref = _clean_optional_string(raw.get("python_ref"))
webui_package = _clean_optional_string(raw.get("webui_package"))
webui_ref = _clean_optional_string(raw.get("webui_ref"))
destroy_data = _clean_bool(raw.get("destroy_data"))
notes = _clean_optional_string(raw.get("notes"))
if action not in INSTALL_PLAN_ACTIONS:
raise ModuleManagementError(f"Unsupported install plan action for {module_id!r}: {action!r}.")
if status not in INSTALL_PLAN_STATUSES:
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
if action == "install" and not python_ref:
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
if action == "uninstall" and not python_package:
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
if action != "uninstall" and destroy_data:
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
if action == "install" and bool(webui_package) != bool(webui_ref):
raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.")
if action == "uninstall" and webui_ref and not webui_package:
raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.")
if python_ref:
_validate_dependency_ref(python_ref, field="python_ref", module_id=module_id)
if webui_ref:
_validate_dependency_ref(webui_ref, field="webui_ref", module_id=module_id)
return ModuleInstallPlanItem(
module_id=module_id,
action=action,
python_package=python_package,
python_ref=python_ref,
webui_package=webui_package,
webui_ref=webui_ref,
destroy_data=destroy_data,
status=status,
notes=notes,
)
def plan_desired_enabled_modules(
requested_enabled: Iterable[str],
available: Mapping[str, ModuleManifest],
*,
protected_modules: Iterable[str] = PROTECTED_MODULES,
) -> ModuleStatePlan:
requested = {str(item).strip() for item in requested_enabled if str(item).strip()}
protected = {str(item).strip() for item in protected_modules if str(item).strip()}
requested.update(protected)
missing = sorted(module_id for module_id in requested if module_id not in available)
if missing:
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
added_dependencies: set[str] = set()
visiting: set[str] = set()
visited: set[str] = set()
ordered: list[str] = []
def visit(module_id: str) -> None:
if module_id in visited:
return
if module_id in visiting:
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
manifest = available.get(module_id)
if manifest is None:
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
visiting.add(module_id)
for dependency_id in manifest.dependencies:
if dependency_id not in available:
raise ModuleManagementError(f"Module {module_id!r} depends on uninstalled module {dependency_id!r}.")
if dependency_id not in requested:
added_dependencies.add(dependency_id)
requested.add(dependency_id)
visit(dependency_id)
visiting.remove(module_id)
visited.add(module_id)
ordered.append(module_id)
for module_id in sorted(requested):
visit(module_id)
return ModuleStatePlan(
enabled_modules=tuple(dict.fromkeys(ordered)),
added_dependencies=tuple(sorted(added_dependencies)),
)
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
for manifest in available.values():
for dependency_id in manifest.dependencies:
dependents.setdefault(dependency_id, []).append(manifest.id)
return {module_id: tuple(sorted(items)) for module_id, items in dependents.items()}
def _management_settings(settings: Mapping[str, object]) -> dict[str, object]:
raw_management = settings.get(MODULE_SETTINGS_KEY)
if isinstance(raw_management, Mapping):
return dict(raw_management)
return {}
def _required_string(value: object, field: str) -> str:
cleaned = _clean_optional_string(value)
if not cleaned:
raise ModuleManagementError(f"Install plan item needs {field}.")
return cleaned
def _clean_optional_string(value: object) -> str | None:
if value is None:
return None
cleaned = str(value).strip()
return cleaned or None
def _clean_bool(value: object) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _validate_dependency_ref(value: str, *, field: str, module_id: str) -> None:
lowered = value.lower()
if lowered.startswith(LOCAL_DEPENDENCY_REF_PREFIXES) or value.startswith(("/", "./", "../", "~")):
raise ModuleManagementError(
f"Install plan item {module_id!r} uses a local {field}; use a tagged package or git ref instead."
)

View File

@@ -0,0 +1,651 @@
from __future__ import annotations
import base64
import binascii
from datetime import UTC, datetime
from pathlib import Path
import json
import os
from typing import Any
import urllib.error
import urllib.request
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
def module_package_catalog(
path: Path | str | None = None,
*,
require_trusted: bool | None = None,
approved_channels: tuple[str, ...] | None = None,
trusted_keys: dict[str, str] | None = None,
) -> tuple[dict[str, object], ...]:
catalog_source = _catalog_source(path)
if catalog_source is None or not _catalog_source_exists(catalog_source):
return ()
validation = validate_module_package_catalog(
catalog_source,
require_trusted=require_trusted,
approved_channels=approved_channels,
trusted_keys=trusted_keys,
)
if not validation["valid"]:
raise ValueError(str(validation["error"] or "Module package catalog is invalid."))
return tuple(item for item in validation["modules"] if isinstance(item, dict))
def validate_module_package_catalog(
path: Path | str | None = None,
*,
require_trusted: bool | None = None,
approved_channels: tuple[str, ...] | None = None,
trusted_keys: dict[str, str] | None = None,
) -> dict[str, object]:
catalog_source = _catalog_source(path)
effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
if catalog_source is not None and not _catalog_source_exists(catalog_source):
return {
"valid": False,
"configured": True,
"path": str(catalog_source),
"source": str(catalog_source),
"source_type": _catalog_source_type(catalog_source),
"modules": [],
"channel": None,
"sequence": None,
"generated_at": None,
"expires_at": None,
"signed": False,
"trusted": False,
"key_id": None,
"warnings": [],
"error": f"Module package catalog does not exist: {catalog_source}",
}
warnings: list[str] = []
try:
payload = _read_catalog_payload(catalog_source)
modules = _normalize_catalog_modules(payload)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
generated_at = _catalog_optional_text(payload, "generated_at")
expires_at = _catalog_optional_text(payload, "expires_at")
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
freshness = _catalog_freshness_state(payload)
replay = _catalog_replay_state(channel=channel, sequence=sequence)
except Exception as exc:
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"modules": [],
"channel": None,
"sequence": None,
"generated_at": None,
"expires_at": None,
"signed": False,
"trusted": False,
"key_id": None,
"warnings": [],
"error": str(exc),
}
if signature_state.get("fatal"):
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
"key_id": signature_state["key_id"],
"warnings": [],
"error": str(signature_state["error"] or "Module package catalog signature is invalid."),
}
if effective_approved_channels and channel not in effective_approved_channels:
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
"key_id": signature_state["key_id"],
"warnings": [],
"error": f"Module package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.",
}
if effective_require_trusted and not signature_state["trusted"]:
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": False,
"key_id": signature_state["key_id"],
"warnings": [],
"error": str(signature_state["error"] or "Module package catalog must be signed by a trusted key."),
}
if not freshness["valid"]:
return _invalid_catalog_result(
catalog_source,
modules=(),
channel=channel,
sequence=sequence,
generated_at=generated_at,
expires_at=expires_at,
signature_state=signature_state,
error=str(freshness["error"]),
)
if not replay["valid"]:
return _invalid_catalog_result(
catalog_source,
modules=(),
channel=channel,
sequence=sequence,
generated_at=generated_at,
expires_at=expires_at,
signature_state=signature_state,
error=str(replay["error"]),
)
warnings.extend(str(item) for item in freshness.get("warnings", ()) if item)
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
if not signature_state["signed"]:
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not signature_state["trusted"]:
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
return {
"valid": True,
"configured": catalog_source is not None and _catalog_source_exists(catalog_source),
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"modules": list(modules),
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
"key_id": signature_state["key_id"],
"warnings": warnings,
"error": None,
}
def sign_module_package_catalog(
*,
path: Path,
key_id: str,
private_key_path: Path,
output_path: Path | None = None,
) -> Path:
payload = _read_catalog_payload(path)
if not isinstance(payload, dict):
raise ValueError("Only object-style module package catalogs can be signed.")
signature_payload = dict(payload)
signature_payload.pop("signature", None)
signature_payload.pop("signatures", None)
private_key = _load_private_key(private_key_path)
signature = private_key.sign(_canonical_catalog_bytes(signature_payload))
signature_payload["signature"] = {
"algorithm": "ed25519",
"key_id": key_id,
"value": base64.b64encode(signature).decode("ascii"),
}
target = output_path or path
target.write_text(json.dumps(signature_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return target
def record_module_package_catalog_acceptance(validation: dict[str, object]) -> None:
state_path = _configured_sequence_state_path()
if state_path is None or validation.get("valid") is not True:
return
channel = validation.get("channel")
sequence = validation.get("sequence")
if not isinstance(channel, str) or not isinstance(sequence, int):
return
try:
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
except json.JSONDecodeError:
state = {}
if not isinstance(state, dict):
state = {}
channels = state.get("channels")
if not isinstance(channels, dict):
channels = {}
channel_state = channels.get(channel)
if not isinstance(channel_state, dict):
channel_state = {}
channel_state["last_sequence"] = max(int(channel_state.get("last_sequence") or 0), sequence)
channel_state["accepted_at"] = datetime.now(tz=UTC).isoformat().replace("+00:00", "Z")
channel_state["key_id"] = validation.get("key_id")
channel_state["source"] = validation.get("source") or validation.get("path")
channels[channel] = channel_state
state["channels"] = channels
state_path.parent.mkdir(parents=True, exist_ok=True)
state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _catalog_source(path: Path | str | None) -> Path | str | None:
if path is not None:
return path if isinstance(path, str) and _is_http_url(path) else Path(path).expanduser()
url = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL", "").strip()
if url:
return url
return _configured_catalog_path()
def _configured_catalog_path() -> Path | None:
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG", "").strip()
return Path(value).expanduser() if value else None
def _configured_catalog_cache_path() -> Path | None:
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE", "").strip()
return Path(value).expanduser() if value else None
def _configured_sequence_state_path() -> Path | None:
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_SEQUENCE_STATE", "").strip()
return Path(value).expanduser() if value else None
def _configured_enforce_sequence() -> bool:
return os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_ENFORCE_SEQUENCE", "").strip().lower() in {"1", "true", "yes", "on"}
def _configured_require_signature() -> bool:
return os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE", "").strip().lower() in {"1", "true", "yes", "on"}
def _configured_approved_channels() -> tuple[str, ...]:
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS", "").strip()
if not value:
return ()
return tuple(item.strip() for item in value.split(",") if item.strip())
def _configured_trusted_keys() -> dict[str, str]:
file_value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "").strip()
if file_value:
path = Path(file_value).expanduser()
if not path.exists():
raise ValueError(f"Trusted catalog key file does not exist: {path}")
return _parse_trusted_keys(path.read_text(encoding="utf-8"))
url_value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_URL", "").strip()
if url_value:
return _parse_trusted_keys(_read_trusted_keys_url(url_value))
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS", "").strip()
return _parse_trusted_keys(value) if value else {}
def _configured_trusted_keys_cache_path() -> Path | None:
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_CACHE", "").strip()
return Path(value).expanduser() if value else None
def _read_trusted_keys_url(url: str) -> str:
if not _is_http_url(url):
raise ValueError("Trusted catalog key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path()
try:
with urllib.request.urlopen(url, timeout=15) as response:
body = response.read().decode("utf-8")
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
return body
except (OSError, urllib.error.URLError):
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8")
raise
def _parse_trusted_keys(value: str) -> dict[str, str]:
payload = json.loads(value)
if isinstance(payload, dict) and isinstance(payload.get("keys"), list):
keys: dict[str, str] = {}
now = datetime.now(tz=UTC)
for item in payload["keys"]:
if not isinstance(item, dict):
continue
status = str(item.get("status") or "active").strip().lower()
if status in {"revoked", "disabled", "retired"}:
continue
not_before = _parse_datetime(item.get("not_before"))
not_after = _parse_datetime(item.get("not_after"))
if not_before is not None and now < not_before:
continue
if not_after is not None and now > not_after:
continue
key_id = str(item.get("key_id") or "").strip()
public_key = str(item.get("public_key") or item.get("public_key_base64") or "").strip()
if key_id and public_key:
keys[key_id] = public_key
return keys
if not isinstance(payload, dict):
raise ValueError("Trusted catalog keys must be a JSON object mapping key ids to base64 public keys.")
return {str(key): str(item) for key, item in payload.items() if str(key).strip() and str(item).strip()}
def _read_catalog_payload(source: Path | str | None) -> object:
if source is None:
return {"modules": []}
if isinstance(source, str) and _is_http_url(source):
return json.loads(_read_catalog_url(source))
return json.loads(Path(source).read_text(encoding="utf-8"))
def _read_catalog_url(url: str) -> str:
cache_path = _configured_catalog_cache_path()
try:
with urllib.request.urlopen(url, timeout=15) as response:
body = response.read().decode("utf-8")
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
return body
except (OSError, urllib.error.URLError):
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8")
raise
def _normalize_catalog_modules(payload: object) -> tuple[dict[str, object], ...]:
raw_items = payload.get("modules") if isinstance(payload, dict) else payload
if not isinstance(raw_items, list):
raise ValueError("Module package catalog must be a list or an object with a 'modules' list.")
return tuple(_normalize_catalog_item(item) for item in raw_items)
def _catalog_channel(payload: object) -> str | None:
if not isinstance(payload, dict):
return None
channel = payload.get("channel")
if channel is None:
return None
text = str(channel).strip()
return text or None
def _catalog_optional_text(payload: object, key: str) -> str | None:
if not isinstance(payload, dict):
return None
value = payload.get(key)
if value is None:
return None
text = str(value).strip()
return text or None
def _catalog_sequence(payload: object) -> int | None:
if not isinstance(payload, dict):
return None
value = payload.get("sequence")
if value is None:
return None
try:
sequence = int(value)
except (TypeError, ValueError):
raise ValueError("Catalog sequence must be an integer.") from None
if sequence < 0:
raise ValueError("Catalog sequence must not be negative.")
return sequence
def _catalog_signature_state(payload: object, *, trusted_keys: dict[str, str]) -> dict[str, object]:
if not isinstance(payload, dict):
return {"signed": False, "trusted": False, "key_id": None, "error": "List-style catalogs cannot be signed.", "fatal": False}
signatures = _catalog_signatures(payload)
if not signatures:
return {"signed": False, "trusted": False, "key_id": None, "error": "Catalog is unsigned.", "fatal": False}
errors: list[str] = []
first_key_id: str | None = None
for signature in signatures:
result = _verify_catalog_signature(payload, signature, trusted_keys=trusted_keys)
key_id = result.get("key_id")
if first_key_id is None and isinstance(key_id, str):
first_key_id = key_id
if result.get("trusted"):
return {"signed": True, "trusted": True, "key_id": key_id, "error": None, "fatal": False}
if result.get("fatal"):
return {"signed": True, "trusted": False, "key_id": key_id, "error": result.get("error"), "fatal": True}
if result.get("error"):
errors.append(str(result["error"]))
return {
"signed": True,
"trusted": False,
"key_id": first_key_id,
"error": "; ".join(errors) if errors else "No catalog signature was trusted.",
"fatal": False,
}
def _catalog_signatures(payload: dict[str, object]) -> list[object]:
signatures: list[object] = []
signature = payload.get("signature")
if signature is not None:
signatures.append(signature)
raw_signatures = payload.get("signatures")
if isinstance(raw_signatures, list):
signatures.extend(raw_signatures)
elif raw_signatures is not None:
signatures.append(raw_signatures)
return signatures
def _verify_catalog_signature(payload: dict[str, object], signature: object, *, trusted_keys: dict[str, str]) -> dict[str, object]:
if not isinstance(signature, dict):
return {"signed": True, "trusted": False, "key_id": None, "error": "Catalog signature must be an object.", "fatal": True}
algorithm = str(signature.get("algorithm") or "").strip().lower()
key_id = str(signature.get("key_id") or "").strip()
value = str(signature.get("value") or "").strip()
if algorithm != "ed25519":
return {"signed": True, "trusted": False, "key_id": key_id or None, "error": f"Unsupported catalog signature algorithm: {algorithm!r}", "fatal": True}
if not key_id or not value:
return {"signed": True, "trusted": False, "key_id": key_id or None, "error": "Catalog signature requires key_id and value.", "fatal": True}
trusted_key = trusted_keys.get(key_id)
if not trusted_key:
return {"signed": True, "trusted": False, "key_id": key_id, "error": f"Catalog signature key is not trusted: {key_id}", "fatal": False}
try:
public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(trusted_key, validate=True))
signed_payload = dict(payload)
signed_payload.pop("signature", None)
signed_payload.pop("signatures", None)
public_key.verify(base64.b64decode(value, validate=True), _canonical_catalog_bytes(signed_payload))
except (ValueError, binascii.Error, InvalidSignature) as exc:
return {"signed": True, "trusted": False, "key_id": key_id, "error": f"Catalog signature verification failed: {exc}", "fatal": True}
return {"signed": True, "trusted": True, "key_id": key_id, "error": None, "fatal": False}
def _catalog_freshness_state(payload: object) -> dict[str, object]:
if not isinstance(payload, dict):
return {"valid": True, "warnings": ()}
now = datetime.now(tz=UTC)
not_before = _parse_datetime(payload.get("not_before"))
generated_at = _parse_datetime(payload.get("generated_at"))
expires_at = _parse_datetime(payload.get("expires_at"))
if not_before is not None and now < not_before:
return {"valid": False, "error": f"Catalog is not valid before {not_before.isoformat()}."}
if expires_at is not None and now > expires_at:
return {"valid": False, "error": f"Catalog expired at {expires_at.isoformat()}."}
warnings: list[str] = []
if generated_at is None:
warnings.append("Catalog has no generated_at timestamp.")
if expires_at is None:
warnings.append("Catalog has no expires_at timestamp; production catalogs should expire.")
return {"valid": True, "warnings": tuple(warnings)}
def _catalog_replay_state(*, channel: str | None, sequence: int | None) -> dict[str, object]:
state_path = _configured_sequence_state_path()
if state_path is None:
return {"valid": True, "warnings": ()}
if not channel:
return {"valid": False, "error": "Catalog sequence replay protection needs a channel."}
if sequence is None:
return {"valid": False, "error": "Catalog sequence replay protection needs a sequence."}
if not state_path.exists():
return {"valid": True, "warnings": ()}
try:
state = json.loads(state_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {"valid": False, "error": f"Catalog sequence state file is not valid JSON: {state_path}"}
channels = state.get("channels") if isinstance(state, dict) else None
channel_state = channels.get(channel) if isinstance(channels, dict) else None
last_sequence = channel_state.get("last_sequence") if isinstance(channel_state, dict) else None
if last_sequence is None:
return {"valid": True, "warnings": ()}
try:
last = int(last_sequence)
except (TypeError, ValueError):
return {"valid": False, "error": f"Catalog sequence state for channel {channel!r} is invalid."}
if sequence < last:
return {"valid": False, "error": f"Catalog sequence {sequence} is older than accepted sequence {last} for channel {channel!r}."}
if sequence == last and _configured_enforce_sequence():
return {"valid": False, "error": f"Catalog sequence {sequence} was already accepted for channel {channel!r}."}
return {"valid": True, "warnings": ()}
def _canonical_catalog_bytes(payload: object) -> bytes:
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def _load_private_key(path: Path) -> Ed25519PrivateKey:
private_key = serialization.load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(private_key, Ed25519PrivateKey):
raise ValueError("Catalog signing private key must be an Ed25519 PEM private key.")
return private_key
def _normalize_catalog_item(value: Any) -> dict[str, object]:
if not isinstance(value, dict):
raise ValueError("Module package catalog entries must be objects.")
module_id = _required_str(value, "module_id")
action = str(value.get("action") or "install")
if action not in {"install", "uninstall"}:
raise ValueError(f"Unsupported catalog action for {module_id!r}: {action!r}")
return {
"module_id": module_id,
"name": str(value.get("name") or module_id),
"description": _optional_str(value, "description"),
"version": _optional_str(value, "version"),
"action": action,
"python_package": _optional_str(value, "python_package"),
"python_ref": _optional_str(value, "python_ref"),
"webui_package": _optional_str(value, "webui_package"),
"webui_ref": _optional_str(value, "webui_ref"),
"license_features": _string_list(value.get("license_features")),
"notes": _optional_str(value, "notes"),
"tags": _string_list(value.get("tags")),
}
def _required_str(value: dict[str, Any], key: str) -> str:
item = _optional_str(value, key)
if not item:
raise ValueError(f"Module package catalog entry is missing {key!r}.")
return item
def _optional_str(value: dict[str, Any], key: str) -> str | None:
item = value.get(key)
if item is None:
return None
text = str(item).strip()
return text or None
def _string_list(value: Any) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("Module package catalog list fields must be lists.")
return [str(item).strip() for item in value if str(item).strip()]
def _parse_datetime(value: object) -> datetime | None:
if value is None:
return None
text = str(value).strip()
if not text:
return None
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError as exc:
raise ValueError(f"Invalid datetime value: {value!r}") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
def _catalog_source_exists(source: Path | str) -> bool:
if isinstance(source, str) and _is_http_url(source):
return True
return Path(source).exists()
def _catalog_source_type(source: Path | str | None) -> str | None:
if source is None:
return None
return "url" if isinstance(source, str) and _is_http_url(source) else "file"
def _is_http_url(value: str) -> bool:
return value.startswith(("https://", "http://"))
def _invalid_catalog_result(
source: Path | str | None,
*,
modules: tuple[dict[str, object], ...],
channel: str | None,
sequence: int | None,
generated_at: str | None,
expires_at: str | None,
signature_state: dict[str, object],
error: str,
) -> dict[str, object]:
return {
"valid": False,
"configured": source is not None,
"path": str(source) if source is not None else None,
"source": str(source) if source is not None else None,
"source_type": _catalog_source_type(source),
"modules": list(modules),
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
"key_id": signature_state["key_id"],
"warnings": [],
"error": error,
}

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, TYPE_CHECKING
@@ -63,15 +63,56 @@ class FrontendModule:
module_id: str
package_name: str | None = None
asset_manifest: str | None = None
asset_manifest_signature: str | None = None
asset_manifest_public_key_id: str | None = None
asset_manifest_integrity: str | None = None
asset_manifest_contract_version: str = "1"
routes: tuple[FrontendRoute, ...] = ()
nav_items: tuple[NavItem, ...] = ()
settings_routes: tuple[FrontendRoute, ...] = ()
@dataclass(frozen=True, slots=True)
class MigrationRetirementPlan:
supported: bool
summary: str
warnings: tuple[str, ...] = ()
blocking_reason: str | None = None
destroy_data_supported: bool = False
destroy_data_summary: str | None = None
destroy_data_warnings: tuple[str, ...] = ()
destroy_data_executor: MigrationRetirementExecutor | None = None
MigrationRetirementExecutor = Callable[[object, str], None]
MigrationRetirementProvider = Callable[[object | None, str], MigrationRetirementPlan]
@dataclass(frozen=True, slots=True)
class MigrationSpec:
module_id: str
metadata: object | None = None
script_location: str | None = None
retirement_supported: bool = False
retirement_provider: MigrationRetirementProvider | None = None
retirement_notes: str | None = None
@dataclass(frozen=True, slots=True)
class ModuleCompatibility:
core_version_min: str | None = None
core_version_max: str | None = None
manifest_contract_version: str = "1"
@dataclass(frozen=True, slots=True)
class ModuleUninstallGuardResult:
severity: Literal["blocker", "warning", "info"]
code: str
message: str
UninstallGuardProvider = Callable[[object | None, str], Iterable[ModuleUninstallGuardResult]]
@dataclass(frozen=True, slots=True)
@@ -105,6 +146,7 @@ TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
DeleteVetoProvider = Callable[[object, str, str], None]
RouteFactory = Callable[[ModuleContext], "APIRouter"]
CapabilityFactory = Callable[[ModuleContext], object]
LifecycleHook = Callable[[ModuleContext], None]
@dataclass(frozen=True, slots=True)
@@ -123,5 +165,8 @@ class ModuleManifest:
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
on_activate: LifecycleHook | None = None
on_deactivate: LifecycleHook | None = None

View File

@@ -55,6 +55,24 @@ class PlatformRegistry:
self.register_capability_factory(manifest.id, name, factory)
return manifest
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
"""Replace the active manifest set without replacing this registry object."""
replacement = PlatformRegistry()
for manifest in manifests:
replacement.register(manifest)
snapshot = replacement.validate()
self._manifests = dict(replacement._manifests)
self._tenant_summary_providers = dict(replacement._tenant_summary_providers)
self._delete_veto_providers = defaultdict(list, {
resource_type: list(providers)
for resource_type, providers in replacement._delete_veto_providers.items()
})
self._capability_factories = dict(replacement._capability_factories)
self._capabilities.clear()
return snapshot
def get(self, module_id: str) -> ModuleManifest | None:
return self._manifests.get(module_id)
@@ -189,4 +207,3 @@ class PlatformRegistry:
unresolved = ", ".join(sorted(module_id for module_id, dependencies in incoming.items() if dependencies))
raise RegistryError(f"Module dependency cycle or unresolved dependency: {unresolved}")
return (self._manifests[module_id] for module_id in ordered)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from govoplan_core.core.modules import ModuleContext
_context: ModuleContext | None = None
def configure_runtime(context: ModuleContext) -> None:
global _context
_context = context
def get_runtime_context() -> ModuleContext | None:
return _context
def get_registry() -> object | None:
return _context.registry if _context is not None else None

View File

@@ -4,13 +4,16 @@ 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.core.optional import reraise_unless_missing_package
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.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.security.permissions import TENANT_SCOPES
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.settings import settings
from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account
from govoplan_access.backend.db.models import Role, SystemRoleAssignment, User, UserRoleAssignment
from govoplan_access.backend.security.api_keys import CreatedApiKey, create_api_key
from govoplan_tenancy.backend.db.models import Tenant
DEFAULT_SCOPES = sorted(TENANT_SCOPES)
@@ -22,29 +25,19 @@ class BootstrapResult:
created_api_key: CreatedApiKey | None
def _import_optional_models(module_name: str) -> None:
try:
__import__(module_name)
except ModuleNotFoundError as exc:
reraise_unless_missing_package(exc, module_name.split(".", 1)[0])
def create_all_tables() -> None:
# Import models so SQLAlchemy sees all tables from installed modules.
from govoplan_core.db import models # noqa: F401
from govoplan_core.access.db import models as access_models # noqa: F401
from govoplan_core.access.db.base import AccessBase
# Build the configured registry so enabled module manifests register their
# model metadata with the shared SQLAlchemy base before create_all runs.
from govoplan_core.admin import models as core_admin_models # noqa: F401
for module_name in (
"govoplan_files.backend.db.models",
"govoplan_mail.backend.db.models",
"govoplan_campaign.backend.db.models",
):
_import_optional_models(module_name)
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(settings.enabled_modules, available=available_modules)
build_platform_registry(enabled_modules)
engine = get_database().engine
Base.metadata.create_all(bind=engine)
AccessBase.metadata.create_all(bind=engine)
def bootstrap_dev_data(
@@ -73,7 +66,7 @@ def bootstrap_dev_data(
)
# 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
from govoplan_access.backend.security.passwords import hash_password
if not account.password_hash:
account.password_hash = hash_password(user_password)

View File

@@ -10,8 +10,11 @@ from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect, text
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.db.session import configure_database
from govoplan_core.server.default_config import get_server_config
from govoplan_core.server.registry import build_platform_registry
from govoplan_core.server.config import ManifestFactory
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.settings import settings
# Historic development databases could be created partly through Alembic and
@@ -133,11 +136,32 @@ class MigrationResult:
current_revision: str | None
def registered_module_migration_plan() -> MigrationMetadataPlan:
def registered_module_migration_plan(
database_url: str | None = None,
*,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> MigrationMetadataPlan:
server_config = get_server_config()
active_database_url = database_url or settings.database_url
if active_database_url:
configure_database(active_database_url)
active_manifest_factories = manifest_factories or tuple(server_config.manifest_factories)
raw_enabled_modules = tuple(enabled_modules) if enabled_modules is not None else load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
available_modules = available_module_manifests(
active_manifest_factories,
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
active_enabled_modules = (
tuple(enabled_modules)
if enabled_modules is not None
else load_startup_enabled_modules(server_config.enabled_modules, available=available_modules)
)
registry = build_platform_registry(
server_config.enabled_modules,
manifest_factories=server_config.manifest_factories,
active_enabled_modules,
manifest_factories=active_manifest_factories,
)
return migration_metadata_plan(registry)
@@ -146,18 +170,32 @@ def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
def alembic_config(*, database_url: str | None = None) -> Config:
def alembic_config(
*,
database_url: str | None = None,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> Config:
root = _repo_root()
config = Config(str(root / "alembic.ini"))
config.set_main_option("script_location", str(root / "alembic"))
plan = registered_module_migration_plan()
active_database_url = database_url or settings.database_url
plan = registered_module_migration_plan(
active_database_url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
)
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
config.attributes["database_url"] = active_database_url
if enabled_modules is not None:
config.attributes["enabled_modules"] = tuple(enabled_modules)
if manifest_factories:
config.attributes["manifest_factories"] = tuple(manifest_factories)
return config
@@ -166,7 +204,12 @@ def database_revision(database_url: str | None = None) -> str | None:
engine = create_engine(url)
try:
with engine.connect() as connection:
return MigrationContext.configure(connection).get_current_revision()
heads = MigrationContext.configure(connection).get_current_heads()
if not heads:
return None
if len(heads) == 1:
return heads[0]
return ",".join(sorted(heads))
finally:
engine.dispose()
@@ -227,7 +270,9 @@ def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str |
engine = create_engine(url)
try:
with engine.connect() as connection:
current = MigrationContext.configure(connection).get_current_revision()
heads = MigrationContext.configure(connection).get_current_heads()
current = heads[0] if len(heads) == 1 else None
has_no_revision = len(heads) == 0
schema = inspect(connection)
tables = set(schema.get_table_names())
@@ -259,10 +304,10 @@ def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str |
# run normally.
_backfill_user_lock_state_for_create_all_schema(url)
target = REVISION_HIERARCHICAL_SETTINGS
elif current is None and has_create_all_hierarchical_schema:
elif has_no_revision and has_create_all_hierarchical_schema:
_backfill_user_lock_state_for_create_all_schema(url)
target = REVISION_HIERARCHICAL_SETTINGS
elif current is None and has_file_storage and has_file_folders:
elif has_no_revision 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
@@ -278,11 +323,20 @@ def migrate_database(
*,
database_url: str | None = None,
reconcile_legacy_schema: bool = True,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> 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")
command.upgrade(
alembic_config(
database_url=url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
),
"heads",
)
current = database_revision(url)
return MigrationResult(
previous_revision=previous,

View File

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

View File

@@ -11,10 +11,12 @@ from pathlib import Path
from typing import Any
from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import configure_database
from govoplan_core.server.config import GovoplanServerConfig, import_object, load_server_config
from govoplan_core.server.registry import build_platform_registry
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
DEFAULT_APP = "govoplan_core.server.app:app"
DEFAULT_CONFIG = "govoplan_core.server.default_config:get_server_config"
@@ -251,10 +253,20 @@ def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[st
config = load_server_config(config_path)
database_url = str(getattr(config.settings, "database_url", database_url) or "")
validate_sqlite_database_url(database_url)
if database_url:
configure_database(database_url)
if bootstrap_db_path is None:
bootstrap_db_path = enable_dev_bootstrap_for_missing_sqlite(database_url)
apply_detected_dev_bootstrap(config, bootstrap_db_path)
registry = build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
raw_enabled_modules = load_startup_enabled_modules(config.enabled_modules)
candidate_modules = startup_candidate_module_ids(config.enabled_modules, raw_enabled_modules)
available_modules = available_module_manifests(
config.manifest_factories,
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
enabled_modules = load_startup_enabled_modules(config.enabled_modules, available=available_modules)
registry = build_platform_registry(enabled_modules, manifest_factories=config.manifest_factories)
reload_dirs = build_reload_dirs(config, config_path=config_path, registry=registry, extra_dirs=extra_reload_dirs)
return DevserverState(
config_path=config_path,

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import copy
import hashlib
import json
from datetime import datetime, timedelta, timezone
@@ -10,10 +9,20 @@ 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.core.optional import reraise_unless_missing_package
from govoplan_core.db.models import AuditLog, Group, SystemSettings, Tenant, User
from govoplan_core.admin.settings import get_system_settings
from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
CAPABILITY_CAMPAIGNS_RETENTION,
CampaignPolicyContext,
CampaignPolicyContextProvider,
CampaignRetentionProvider,
)
from govoplan_core.core.runtime import get_registry
from govoplan_access.backend.db.models import Group, User
from govoplan_audit.backend.db.models import AuditLog
from govoplan_core.admin.models import SystemSettings
from govoplan_core.settings import settings
from govoplan_tenancy.backend.db.models import Tenant
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
RETENTION_DAY_KEYS = (
@@ -48,21 +57,6 @@ def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> d
normalized[clean_key] = bool(allowed)
return normalized
FINAL_VERSION_STATES = {
"completed",
"partially_completed",
"outcome_unknown",
"failed",
"cancelled",
"archived",
}
FINAL_EML_SEND_STATUSES = {
"smtp_accepted",
"sent",
"failed_permanent",
"cancelled",
"outcome_unknown",
}
AUDIT_DETAIL_REDACTION_KEYS = {
"attachments",
"bcc",
@@ -148,20 +142,34 @@ class PrivacyPolicyError(RuntimeError):
pass
def _campaign_models():
try:
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
except ModuleNotFoundError as exc:
reraise_unless_missing_package(exc, "govoplan_campaign")
raise PrivacyPolicyError("Campaign module is not installed") from exc
return Campaign, CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
def _campaign_models_or_none():
try:
return _campaign_models()
except PrivacyPolicyError:
def _campaign_policy_provider() -> CampaignPolicyContextProvider | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_POLICY_CONTEXT):
return None
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_POLICY_CONTEXT)
if not isinstance(capability, CampaignPolicyContextProvider):
raise PrivacyPolicyError("Campaign policy context capability is invalid")
return capability
def _campaign_retention_provider() -> CampaignRetentionProvider | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_RETENTION):
return None
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_RETENTION)
if not isinstance(capability, CampaignRetentionProvider):
raise PrivacyPolicyError("Campaign retention capability is invalid")
return capability
def _campaign_policy_context(session: Session, *, campaign_id: str, tenant_id: str | None = None) -> CampaignPolicyContext:
provider = _campaign_policy_provider()
if provider is None:
raise PrivacyPolicyError("Campaign module is not installed")
context = provider.get_campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
if context is None:
raise PrivacyPolicyError("Campaign not found for privacy policy")
return context
def _utcnow() -> datetime:
@@ -267,12 +275,9 @@ def effective_privacy_policy(
campaign_id: str | None = None,
) -> PrivacyRetentionPolicy:
policy = privacy_policy_from_session(session)
campaign: Any | None = None
campaign: CampaignPolicyContext | None = None
if campaign_id:
Campaign, _, _, _, _ = _campaign_models()
campaign = session.get(Campaign, campaign_id)
if campaign is None:
raise PrivacyPolicyError("Campaign not found for privacy policy")
campaign = _campaign_policy_context(session, campaign_id=campaign_id)
tenant_id = campaign.tenant_id
owner_user_id = campaign.owner_user_id
owner_group_id = campaign.owner_group_id
@@ -290,7 +295,7 @@ def effective_privacy_policy(
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 {}))
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(dict(campaign.settings or {})))
return policy
@@ -307,10 +312,7 @@ def parent_privacy_policy(session: Session, *, tenant_id: str, scope_type: str,
return policy
if clean_scope != "campaign" or not scope_id:
return policy
Campaign, _, _, _, _ = _campaign_models()
campaign = session.get(Campaign, scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise PrivacyPolicyError("Campaign not found for privacy policy")
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
if campaign.owner_user_id:
user = session.get(User, campaign.owner_user_id)
if user and user.tenant_id == tenant_id:
@@ -356,12 +358,9 @@ def effective_privacy_policy_sources(
) -> list[dict[str, Any]]:
system_settings = get_system_settings(session)
sources = [_retention_policy_source_step("system", "System", None, _privacy_policy_patch_from_settings(system_settings.settings or {}), baseline=True)]
campaign: Any | None = None
campaign: CampaignPolicyContext | None = None
if campaign_id:
Campaign, _, _, _, _ = _campaign_models()
campaign = session.get(Campaign, campaign_id)
if campaign is None:
raise PrivacyPolicyError("Campaign not found for privacy policy")
campaign = _campaign_policy_context(session, campaign_id=campaign_id)
tenant_id = campaign.tenant_id
owner_user_id = campaign.owner_user_id
owner_group_id = campaign.owner_group_id
@@ -379,7 +378,7 @@ def effective_privacy_policy_sources(
if group and (not tenant_id or group.tenant_id == tenant_id):
sources.append(_retention_policy_source_step("group", "Owner group", group.id, _privacy_policy_patch_from_settings(group.settings or {})))
if campaign is not None:
sources.append(_retention_policy_source_step("campaign", "Campaign", campaign.id, _privacy_policy_patch_from_settings(campaign.settings or {})))
sources.append(_retention_policy_source_step("campaign", "Campaign", campaign.id, _privacy_policy_patch_from_settings(dict(campaign.settings or {}))))
return sources
@@ -399,10 +398,7 @@ def parent_privacy_policy_sources(session: Session, *, tenant_id: str, scope_typ
return sources
if clean_scope != "campaign" or not scope_id:
return sources
Campaign, _, _, _, _ = _campaign_models()
campaign = session.get(Campaign, scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise PrivacyPolicyError("Campaign not found for privacy policy")
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
if campaign.owner_user_id:
user = session.get(User, campaign.owner_user_id)
if user and user.tenant_id == tenant_id:
@@ -435,11 +431,8 @@ def get_privacy_policy_for_scope(session: Session, *, tenant_id: str, scope_type
raise PrivacyPolicyError("Group privacy policy not found")
return _privacy_policy_patch_from_settings(group.settings or {})
if clean_scope == "campaign":
Campaign, _, _, _, _ = _campaign_models()
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 {})
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
return _privacy_policy_patch_from_settings(dict(campaign.settings or {}))
raise PrivacyPolicyError("Privacy policy scope must be system, tenant, user, group or campaign")
@@ -483,12 +476,15 @@ def set_privacy_policy_for_scope(
session.add(group)
return patch
if clean_scope == "campaign":
Campaign, _, _, _, _ = _campaign_models()
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)
provider = _campaign_policy_provider()
if provider is None:
raise PrivacyPolicyError("Campaign module is not installed")
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
settings_payload = _set_settings_privacy_policy(dict(campaign.settings or {}), patch)
try:
provider.set_campaign_settings(session, tenant_id=tenant_id, campaign_id=scope_id, settings=settings_payload)
except ValueError as exc:
raise PrivacyPolicyError("Campaign privacy policy not found") from exc
return patch
raise PrivacyPolicyError("Privacy policy scope must be system, tenant, user, group or campaign")
@@ -540,10 +536,29 @@ def apply_retention_policy(session: Session, *, dry_run: bool = True) -> dict[st
now = _utcnow()
policy = privacy_policy_from_session(session)
cutoffs = _system_cutoffs(policy, now=now)
policy_cache: dict[str, PrivacyRetentionPolicy] = {}
campaign_retention = _campaign_retention_provider()
campaign_counts = {
"raw_campaign_json": {"eligible": 0, "redacted": 0, "skipped_not_final": 0, "already_redacted": 0},
"generated_eml": {"eligible": 0, "metadata_cleared": 0, "files_deleted": 0, "files_missing": 0, "skipped_not_final": 0},
"stored_report_detail": {"eligible_versions": 0, "summaries_redacted": 0, "already_redacted": 0},
}
if campaign_retention is not None:
campaign_counts.update(
{
key: dict(value)
for key, value in campaign_retention.apply_retention(
session,
dry_run=dry_run,
now=now,
policy_for_campaign_id=lambda campaign_id: _campaign_policy_for_id(session, campaign_id, policy_cache),
).items()
}
)
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),
"raw_campaign_json": campaign_counts["raw_campaign_json"],
"generated_eml": campaign_counts["generated_eml"],
"stored_report_detail": campaign_counts["stored_report_detail"],
"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),
}
@@ -564,142 +579,6 @@ def _campaign_policy_for_id(session: Session, campaign_id: str | None, cache: di
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}
models = _campaign_models_or_none()
if models is None:
return result
_, _, CampaignVersion, _, _ = models
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}
models = _campaign_models_or_none()
if models is None:
return result
_, CampaignJob, _, JobImapStatus, JobQueueStatus = models
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}
models = _campaign_models_or_none()
if models is None:
return result
_, _, CampaignVersion, _, _ = models
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:
@@ -744,12 +623,11 @@ def _parse_datetime(value: Any) -> datetime | None:
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:
models = _campaign_models_or_none()
if models is not None:
Campaign, _, _, _, _ = models
campaign = session.get(Campaign, str(item.object_id))
if campaign is not None:
return _campaign_policy_for_id(session, campaign.id, campaign_cache)
provider = _campaign_policy_provider()
if provider is not None:
context = provider.get_campaign_policy_context(session, campaign_id=str(item.object_id))
if context is not None:
return _campaign_policy_for_id(session, context.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)

View File

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

View File

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

View File

@@ -86,6 +86,7 @@ SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
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:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "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"),
)
@@ -174,6 +175,14 @@ DEFAULT_SYSTEM_ROLES: dict[str, dict[str, object]] = {
"is_assignable": True,
"managed": False,
},
"maintenance_operator": {
"name": "Maintenance operator",
"description": "Access the system while maintenance mode is active.",
"permissions": ["system:settings:read", "system:maintenance:access"],
"is_builtin": False,
"is_assignable": True,
"managed": False,
},
}
@@ -186,8 +195,10 @@ def scope_grants(granted: str, required: str) -> bool:
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 == "*":
return True
if granted == "tenant:*":
return required == "tenant:*" or not required.startswith("system:")
if granted == "system:*":
return required.startswith("system:")
if granted.endswith(":*") and required.startswith(granted[:-1]):

View File

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

View File

@@ -2,12 +2,13 @@ from __future__ import annotations
from fastapi import APIRouter
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.lifecycle import ModuleLifecycleManager
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
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
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
def create_app(config: GovoplanServerConfig | str | None = None):
@@ -20,17 +21,30 @@ def create_app(config: GovoplanServerConfig | str | None = None):
if database_url:
configure_database(str(database_url))
registry = build_platform_registry(server_config.enabled_modules, manifest_factories=server_config.manifest_factories)
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
startup_available_modules = available_module_manifests(
server_config.manifest_factories,
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
available_modules = available_module_manifests(server_config.manifest_factories, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(server_config.enabled_modules, available=startup_available_modules)
registry = build_platform_registry(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)
registry.configure_capability_context(module_context)
for manifest in registry.manifests():
if manifest.route_factory is not None:
api_router.include_router(manifest.route_factory(module_context))
lifecycle = ModuleLifecycleManager(
registry=registry,
available_modules=available_modules,
settings=server_config.settings,
api_prefix=server_config.api_prefix,
manifest_factories=tuple(server_config.manifest_factories),
module_context_data=server_config.module_context_data,
)
lifecycle.configure_runtime()
api_router.include_router(create_platform_router(settings=server_config.settings))
@@ -49,6 +63,8 @@ def create_app(config: GovoplanServerConfig | str | None = None):
lifespan=server_config.lifespan,
cors_origins=server_config.cors_origins,
)
lifecycle.attach_app(app)
lifecycle.apply_enabled_modules(tuple(manifest.id for manifest in registry.manifests()), migrate=False)
for configurator in server_config.app_configurators:
configurator(app, registry, server_config.settings)
return app

View File

@@ -1,20 +1,23 @@
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_access.backend.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.server.config import GovoplanServerConfig
from govoplan_core.settings import Settings, settings
@asynccontextmanager
async def lifespan(app: FastAPI):
if settings.app_env.lower() == "dev" and settings.dev_auto_migrate_enabled:
from govoplan_core.db.migrations import migrate_database
migrate_database(database_url=settings.database_url)
if settings.app_env.lower() == "dev" and settings.dev_bootstrap_enabled:
create_all_tables()
with get_database().SessionLocal() as session:
@@ -30,22 +33,6 @@ 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
@@ -70,19 +57,12 @@ def register_health_details(app: FastAPI, registry: PlatformRegistry, config_set
def get_server_config() -> GovoplanServerConfig:
from govoplan_core.api.v1.admin import router as admin_router
from govoplan_core.api.v1.audit import router as audit_router
from govoplan_core.api.v1.auth import router as auth_router
from govoplan_core.api.v1.system import router as system_router
return GovoplanServerConfig(
title="GovOPlaN Server",
version="0.3.0",
settings=settings,
enabled_modules=settings.enabled_modules,
api_prefix="/api/v1",
base_routers=(auth_router, admin_router, audit_router, system_router),
extra_routers=_dev_mail_router_contributions(),
lifespan=lifespan,
cors_origins=_cors_origins(settings.cors_origins),
app_configurators=(register_health_details,),

View File

@@ -1,9 +1,12 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from sqlalchemy.exc import SQLAlchemyError
from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import get_database
def _registry(request: Request) -> PlatformRegistry:
@@ -42,6 +45,10 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
"module_id": frontend.module_id,
"package_name": frontend.package_name,
"asset_manifest": frontend.asset_manifest,
"asset_manifest_signature": frontend.asset_manifest_signature,
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
"asset_manifest_integrity": frontend.asset_manifest_integrity,
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
"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],
@@ -60,6 +67,17 @@ def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry
def create_platform_router(settings: object | None = None) -> APIRouter:
router = APIRouter(prefix="/platform", tags=["platform"])
@router.get("/status")
def status():
try:
with get_database().session() as session:
maintenance_mode = saved_maintenance_mode(session)
except (RuntimeError, SQLAlchemyError):
maintenance_mode = None
return {
"maintenance_mode": maintenance_mode.as_dict() if maintenance_mode is not None else {"enabled": False, "message": None},
}
@router.get("/modules")
def modules(request: Request):
registry = _registry(request)

View File

@@ -2,10 +2,11 @@ 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_access.backend.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
from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest
ManifestFactory = Callable[[], ModuleManifest]
@@ -18,8 +19,20 @@ def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
return [item for item in items if item]
def available_module_manifests(manifest_factories: Sequence[ManifestFactory] = (), *, enabled_modules: Iterable[str] | None = None) -> dict[str, ModuleManifest]:
manifests = {manifest.id: manifest for manifest in discover_module_manifests(enabled_modules=enabled_modules)}
def available_module_manifests(
manifest_factories: Sequence[ManifestFactory] = (),
*,
enabled_modules: Iterable[str] | None = None,
ignore_load_errors: bool = False,
) -> dict[str, ModuleManifest]:
manifests = {
manifest.id: manifest
for manifest in discover_module_manifests(
enabled_modules=enabled_modules,
ignore_load_errors=ignore_load_errors,
)
}
manifests.setdefault("tenancy", get_tenancy_manifest())
manifests.setdefault("access", get_access_manifest())
for factory in manifest_factories:
manifest = factory()
@@ -29,8 +42,15 @@ def available_module_manifests(manifest_factories: Sequence[ManifestFactory] = (
def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_factories: Sequence[ManifestFactory] = ()) -> PlatformRegistry:
requested = parse_enabled_modules(enabled_modules)
if "tenancy" not in requested:
requested.insert(0, "tenancy")
if "access" not in requested:
requested.insert(0, "access")
tenancy_index = requested.index("tenancy")
requested.insert(tenancy_index + 1, "access")
elif requested.index("access") < requested.index("tenancy"):
requested.remove("access")
tenancy_index = requested.index("tenancy")
requested.insert(tenancy_index + 1, "access")
available = available_module_manifests(manifest_factories, enabled_modules=requested)
registry = PlatformRegistry()

View File

@@ -17,7 +17,7 @@ class Settings(BaseSettings):
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")
enabled_modules: str = Field(default="tenancy,access,admin,policy,audit,campaigns,files,mail,calendar", alias="ENABLED_MODULES")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
@@ -53,6 +53,7 @@ class Settings(BaseSettings):
# 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_auto_migrate_enabled: bool = Field(default=True, alias="DEV_AUTO_MIGRATE_ENABLED")
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")

View File

@@ -0,0 +1,2 @@
"""Tenant administration helpers shared by platform route modules."""

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from govoplan_core.admin.common import AdminValidationError
from govoplan_core.admin.settings import get_system_settings
from govoplan_core.core.runtime import get_registry
from govoplan_access.backend.db.models import ApiKey, Group, User
from govoplan_tenancy.backend.db.models import Tenant
@dataclass(frozen=True)
class EffectiveTenantGovernance:
allow_custom_groups: bool
allow_custom_roles: bool
allow_api_keys: bool
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 _tenant_module_counts(session: Session, tenant_id: str) -> dict[str, int]:
registry = get_registry()
if registry is None or not hasattr(registry, "tenant_summary_providers"):
return {}
counts: dict[str, int] = {}
for provider in registry.tenant_summary_providers().values():
provided = provider(session, tenant_id)
counts.update({str(key): int(value) for key, value in provided.items()})
return counts
def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]:
module_counts = _tenant_module_counts(session, tenant_id)
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": module_counts.get("campaigns", 0),
"files": module_counts.get("files", 0),
"api_keys": session.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(),
}