Release v0.1.6
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
"""Tenant, identity, auth, RBAC, and datastore access platform module."""
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Authentication and principal helpers for platform access."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Access-platform SQLAlchemy metadata and models."""
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Permission definitions, evaluation, and registry helpers."""
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
|
||||
|
||||
__all__ = ["PermissionDefinition", "PermissionLevel", "RoleTemplate"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Tenant datastore routing abstractions."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
21
src/govoplan_core/admin/common.py
Normal file
21
src/govoplan_core/admin/common.py
Normal 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]
|
||||
|
||||
@@ -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()
|
||||
23
src/govoplan_core/admin/models.py
Normal file
23
src/govoplan_core/admin/models.py
Normal 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"]
|
||||
|
||||
@@ -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 [])
|
||||
16
src/govoplan_core/admin/settings.py
Normal file
16
src/govoplan_core/admin/settings.py
Normal 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
@@ -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
|
||||
@@ -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])
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}.")
|
||||
|
||||
41
src/govoplan_core/commands/module_install_plan.py
Normal file
41
src/govoplan_core/commands/module_install_plan.py
Normal 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()
|
||||
470
src/govoplan_core/commands/module_installer.py
Normal file
470
src/govoplan_core/commands/module_installer.py
Normal 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())
|
||||
37
src/govoplan_core/commands/module_verify.py
Normal file
37
src/govoplan_core/commands/module_verify.py
Normal 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())
|
||||
272
src/govoplan_core/core/access.py
Normal file
272
src/govoplan_core/core/access.py
Normal 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:
|
||||
...
|
||||
115
src/govoplan_core/core/campaigns.py
Normal file
115
src/govoplan_core/core/campaigns.py
Normal 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]]:
|
||||
...
|
||||
@@ -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)
|
||||
|
||||
145
src/govoplan_core/core/lifecycle.py
Normal file
145
src/govoplan_core/core/lifecycle.py
Normal 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,
|
||||
)
|
||||
67
src/govoplan_core/core/maintenance.py
Normal file
67
src/govoplan_core/core/maintenance.py
Normal 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
|
||||
@@ -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))
|
||||
|
||||
|
||||
117
src/govoplan_core/core/module_guards.py
Normal file
117
src/govoplan_core/core/module_guards.py
Normal 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)
|
||||
1667
src/govoplan_core/core/module_installer.py
Normal file
1667
src/govoplan_core/core/module_installer.py
Normal file
File diff suppressed because it is too large
Load Diff
225
src/govoplan_core/core/module_license.py
Normal file
225
src/govoplan_core/core/module_license.py
Normal 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()]
|
||||
421
src/govoplan_core/core/module_management.py
Normal file
421
src/govoplan_core/core/module_management.py
Normal 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."
|
||||
)
|
||||
651
src/govoplan_core/core/module_package_catalog.py
Normal file
651
src/govoplan_core/core/module_package_catalog.py
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
19
src/govoplan_core/core/runtime.py
Normal file
19
src/govoplan_core/core/runtime.py
Normal 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]):
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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,),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
2
src/govoplan_core/tenancy/__init__.py
Normal file
2
src/govoplan_core/tenancy/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Tenant administration helpers shared by platform route modules."""
|
||||
|
||||
70
src/govoplan_core/tenancy/service.py
Normal file
70
src/govoplan_core/tenancy/service.py
Normal 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(),
|
||||
}
|
||||
Reference in New Issue
Block a user