Create access module package

This commit is contained in:
2026-07-06 11:38:26 +02:00
commit f37c6668e5
31 changed files with 1199 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
"""GovOPlaN access platform module."""
__version__ = "0.1.4"

View File

@@ -0,0 +1 @@
"""Backend integration for the GovOPlaN access module."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,138 @@
from __future__ import annotations
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
from govoplan_core.core.access import CAPABILITY_ACCESS_PERMISSION_EVALUATOR, CAPABILITY_ACCESS_PRINCIPAL_RESOLVER
from govoplan_core.core.modules import MigrationSpec, ModuleContext, 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,
),
)
def _legacy_principal_resolver(context: ModuleContext) -> object:
del context
from govoplan_core.auth.dependencies import LegacyPrincipalResolver
return LegacyPrincipalResolver()
def _legacy_permission_evaluator(context: ModuleContext) -> object:
del context
from govoplan_core.auth.dependencies import LegacyPermissionEvaluator
return LegacyPermissionEvaluator()
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),
capability_factories={
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
},
)
def get_manifest() -> ModuleManifest:
return manifest

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@