Release v0.1.5

This commit is contained in:
2026-07-07 15:49:06 +02:00
parent f37c6668e5
commit efb69b3d2d
43 changed files with 6464 additions and 192 deletions

View File

@@ -0,0 +1,2 @@
"""Access-owned legacy admin helper services."""

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from govoplan_core.admin.common import AdminConflictError
from govoplan_access.backend.db.models import (
ApiKey,
Group,
Role,
Tenant,
)
from govoplan_core.tenancy.service import effective_tenant_governance
def ensure_group_mutation_allowed(group: Group, *, requested_active: bool | None = None) -> None:
if group.system_template_id and group.system_required and requested_active is False:
raise AdminConflictError("This centrally required group cannot be deactivated by the tenant.")
def ensure_role_mutation_allowed(role: Role, *, requested_assignable: bool | None = None) -> None:
if role.system_template_id:
if role.system_required and requested_assignable is False:
raise AdminConflictError("This centrally required role cannot be made unavailable by the tenant.")
raise AdminConflictError("Centrally managed role definitions can only be changed in System administration.")
def assert_api_keys_allowed(session: Session, tenant: Tenant) -> None:
if not effective_tenant_governance(session, tenant).allow_api_keys:
raise AdminConflictError("API-key creation is disabled by system or tenant governance.")
def assert_custom_groups_allowed(session: Session, tenant: Tenant) -> None:
if not effective_tenant_governance(session, tenant).allow_custom_groups:
raise AdminConflictError("Custom tenant groups are disabled by system or tenant governance.")
def assert_custom_roles_allowed(session: Session, tenant: Tenant) -> None:
if not effective_tenant_governance(session, tenant).allow_custom_roles:
raise AdminConflictError("Custom tenant roles are disabled by system or tenant governance.")
def active_api_key_count(session: Session, tenant_id: str) -> int:
return session.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count()

View File

@@ -0,0 +1,567 @@
from __future__ import annotations
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.admin.common import AdminConflictError, AdminValidationError, slugify
from govoplan_access.backend.db.models import (
Account,
Group,
GroupRoleAssignment,
Role,
SystemRoleAssignment,
Tenant,
User,
UserGroupMembership,
UserRoleAssignment,
)
from govoplan_access.backend.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,
)
from govoplan_core.tenancy.service import tenant_counts # re-exported compatibility helper
_TEMP_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-_!@#"
@dataclass(slots=True)
class MembershipCreationResult:
user: User
account: Account
temporary_password: str | None = None
account_created: bool = False
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 assert_can_delegate_tenant_permissions(actor_scopes: Iterable[str], permissions: Iterable[str]) -> None:
"""Prevent administrators from defining or assigning roles beyond their own effective tenant powers."""
from govoplan_core.security.permissions import delegateable_tenant_scopes
requested = set(validate_tenant_permissions(permissions))
if "tenant:*" in requested:
# Only a tenant owner-equivalent can delegate the wildcard.
if not scopes_grant(actor_scopes, "tenant:*"):
raise AdminValidationError("You cannot delegate full tenant access unless you already have it.")
return
allowed = delegateable_tenant_scopes(actor_scopes)
missing = sorted(scope for scope in requested if scope not in allowed)
if missing:
raise AdminValidationError("You cannot delegate permissions you do not currently hold: " + ", ".join(missing))
def assert_can_delegate_roles(session: Session, *, actor_scopes: Iterable[str], role_ids: Iterable[str], tenant_id: str) -> None:
ids = sorted(set(role_ids))
if not ids:
return
roles = session.query(Role).filter(Role.tenant_id == tenant_id, Role.id.in_(ids)).all()
if len(roles) != len(ids):
raise AdminValidationError("One or more selected roles do not belong to the tenant.")
for role in roles:
assert_can_delegate_tenant_permissions(actor_scopes, role.permissions or [])

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, ApiKey, Role, User
from govoplan_core.core.access import AccessAdministration
class SqlAccessAdministration(AccessAdministration):
def system_account_count(self, session: object) -> int:
db = _session(session)
return db.query(Account).count()
def role_count_for_tenant(self, session: object, tenant_id: str) -> int:
db = _session(session)
return db.query(Role).filter(Role.tenant_id == tenant_id).count()
def active_api_key_count_for_tenant(self, session: object, tenant_id: str) -> int:
db = _session(session)
return db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count()
def actor_email_by_user_id(self, session: object, user_ids: Iterable[str]) -> Mapping[str, str | None]:
ids = {str(user_id) for user_id in user_ids if user_id}
if not ids:
return {}
db = _session(session)
rows = (
db.query(User.id, Account.email)
.join(Account, Account.id == User.account_id)
.filter(User.id.in_(ids))
.all()
)
return {user_id: email for user_id, email in rows}
def user_ids_for_actor_filter(self, session: object, *, operator: str, value: str) -> Sequence[str]:
normalized = value.casefold().strip()
if not normalized:
return ()
db = _session(session)
text = func.lower(func.coalesce(Account.email, ""))
condition = text == normalized if operator == "eq" else text.contains(normalized)
rows = (
db.query(User.id)
.join(Account, Account.id == User.account_id)
.filter(condition)
.all()
)
return tuple(user_id for (user_id,) in rows)
def _session(session: object) -> Session:
if not isinstance(session, Session):
raise TypeError("Access administration requires a SQLAlchemy Session")
return session

View File

@@ -0,0 +1,2 @@
"""Access-owned backend API routes."""

View File

@@ -0,0 +1,2 @@
"""Access-owned API v1 routes."""

View File

@@ -0,0 +1,11 @@
"""Compatibility import for access-owned admin routes.
The remaining platform admin routes are contributed by the `admin`, `tenancy`,
`policy`, and `audit` modules through their own manifests.
"""
from __future__ import annotations
from govoplan_access.backend.api.v1.routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,251 @@
from __future__ import annotations
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from govoplan_access.backend.admin.service import (
AdminConflictError,
AdminValidationError,
assert_tenant_owner_exists,
role_assignment_counts,
set_user_groups,
set_user_roles,
tenant_owner_user_ids,
)
from govoplan_access.backend.api.v1.admin_schemas import (
ApiKeyAdminItem,
GroupSummary,
RoleSummary,
SystemAccountItem,
UserAdminItem,
)
from govoplan_access.backend.security.sessions import (
collect_direct_user_roles,
collect_system_roles,
collect_user_groups,
collect_user_scopes,
)
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope
from govoplan_access.backend.db.models import (
Account,
ApiKey,
Group,
GroupRoleAssignment,
Role,
SystemRoleAssignment,
Tenant,
User,
UserGroupMembership,
)
from govoplan_core.security.permissions import effective_permission_count
def _http_admin_error(exc: Exception) -> HTTPException:
if isinstance(exc, AdminConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, AdminValidationError):
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
def _require_system_role_assignment(principal: ApiPrincipal) -> None:
if not (has_scope(principal, "system:roles:assign") or has_scope(principal, "system:access:assign")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:roles:assign")
def _require_permission(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _resolve_tenant(
session: Session,
principal: ApiPrincipal,
tenant_id: str | None,
) -> Tenant:
target_id = tenant_id or principal.tenant_id
if target_id != principal.tenant_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Switch to the target tenant before using tenant-administration endpoints.",
)
tenant = session.get(Tenant, target_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
return tenant
def _role_summary(session: Session, role: Role) -> RoleSummary:
group_count = 0
if role.tenant_id is None:
user_count = session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count()
permission_level = "system"
else:
user_count, group_count = role_assignment_counts(session, role.id)
permission_level = "tenant"
permissions = list(role.permissions or [])
return RoleSummary(
id=role.id,
slug=role.slug,
name=role.name,
description=role.description,
permissions=permissions,
effective_permission_count=effective_permission_count(permissions, level=permission_level),
is_builtin=role.is_builtin,
is_assignable=role.is_assignable,
user_assignments=user_count,
group_assignments=group_count,
level="system" if role.tenant_id is None else "tenant",
system_template_id=role.system_template_id,
system_required=role.system_required,
)
def _group_summary(session: Session, group: Group, *, include_members: bool = True) -> GroupSummary:
member_ids = [
row[0]
for row in session.query(UserGroupMembership.user_id)
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id)
.all()
]
roles = (
session.query(Role)
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id)
.order_by(Role.name.asc())
.all()
)
return GroupSummary(
id=group.id,
slug=group.slug,
name=group.name,
description=group.description,
is_active=group.is_active,
member_count=len(member_ids),
member_ids=member_ids if include_members else [],
roles=[_role_summary(session, role) for role in roles],
created_at=group.created_at,
updated_at=group.updated_at,
system_template_id=group.system_template_id,
system_required=group.system_required,
)
def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = None) -> UserAdminItem:
account = session.get(Account, user.account_id)
if account is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
groups = collect_user_groups(session, user)
roles = collect_direct_user_roles(session, user)
effective_owner_ids = owner_ids if owner_ids is not None else tenant_owner_user_ids(session, user.tenant_id)
return UserAdminItem(
id=user.id,
account_id=account.id,
tenant_id=user.tenant_id,
email=account.email,
display_name=user.display_name or account.display_name,
is_active=user.is_active,
account_is_active=account.is_active,
password_reset_required=account.password_reset_required,
last_login_at=account.last_login_at,
groups=[_group_summary(session, group, include_members=False) for group in groups],
roles=[_role_summary(session, role) for role in roles],
effective_scopes=collect_user_scopes(session, user, include_system=False),
is_owner=user.id in effective_owner_ids,
is_last_active_owner=user.id in effective_owner_ids and len(effective_owner_ids) == 1,
created_at=user.created_at,
updated_at=user.updated_at,
)
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
memberships = (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.account_id == account.id)
.order_by(Tenant.name.asc())
.all()
)
owner_ids_by_tenant = {
tenant.id: tenant_owner_user_ids(session, tenant.id)
for _, tenant in memberships
}
return SystemAccountItem(
account_id=account.id,
email=account.email,
display_name=account.display_name,
is_active=account.is_active,
memberships=[
{
"tenant_id": tenant.id,
"tenant_name": tenant.name,
"user_id": user.id,
"is_active": user.is_active and tenant.is_active,
"role_ids": [role.id for role in collect_direct_user_roles(session, user)],
"group_ids": [group.id for group in collect_user_groups(session, user)],
"is_owner": user.id in owner_ids_by_tenant[tenant.id],
"is_last_active_owner": (
user.id in owner_ids_by_tenant[tenant.id]
and len(owner_ids_by_tenant[tenant.id]) == 1
),
}
for user, tenant in memberships
],
roles=[_role_summary(session, role) for role in collect_system_roles(session, account)],
last_login_at=account.last_login_at,
)
def _api_key_item(session: Session, item: ApiKey) -> ApiKeyAdminItem:
user = session.get(User, item.user_id)
account = session.get(Account, user.account_id) if user else None
return ApiKeyAdminItem(
id=item.id,
user_id=item.user_id,
user_email=account.email if account else "Unknown account",
name=item.name,
prefix=item.prefix,
scopes=item.scopes or [],
expires_at=item.expires_at,
last_used_at=item.last_used_at,
revoked_at=item.revoked_at,
created_at=item.created_at,
)
def _set_system_memberships(session: Session, account: Account, requested: list[dict]) -> None:
desired = {item["tenant_id"]: item for item in requested}
existing = {item.tenant_id: item for item in session.query(User).filter(User.account_id == account.id).all()}
affected = set(existing) | set(desired)
for tenant_id, user in existing.items():
if tenant_id not in desired:
user.is_active = False
session.add(user)
for tenant_id, item in desired.items():
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise AdminValidationError(f"Unknown tenant: {tenant_id}")
user = existing.get(tenant_id)
if user is None:
user = User(
tenant_id=tenant.id,
account_id=account.id,
email=account.email,
display_name=account.display_name,
is_active=bool(item.get("is_active", True)),
auth_provider=account.auth_provider,
password_hash=account.password_hash,
)
session.add(user)
session.flush()
else:
user.is_active = bool(item.get("is_active", True))
user.email = account.email
session.add(user)
set_user_roles(session, user=user, role_ids=item.get("role_ids", []))
set_user_groups(session, user=user, group_ids=item.get("group_ids", []))
session.flush()
for tenant_id in affected:
tenant = session.get(Tenant, tenant_id)
if tenant and tenant.is_active:
assert_tenant_owner_exists(session, tenant_id)

View File

@@ -0,0 +1,465 @@
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 ApiKeyAdminItem(BaseModel):
id: str
user_id: str
user_email: str
name: str
prefix: str
scopes: list[str] = Field(default_factory=list)
expires_at: datetime | None = None
last_used_at: datetime | None = None
revoked_at: datetime | None = None
created_at: datetime
class ApiKeyListResponse(BaseModel):
api_keys: list[ApiKeyAdminItem]
class AdminApiKeyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
user_id: str | None = None
scopes: list[str] = Field(default_factory=list)
expires_at: datetime | None = None
class AdminApiKeyCreateResponse(ApiKeyAdminItem):
secret: str
class AuditAdminItem(BaseModel):
id: str
scope: Literal["tenant", "system"] = "tenant"
tenant_id: str | None = None
actor_email: str | None = None
action: str
object_type: str | None = None
object_id: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
class AuditAdminListResponse(BaseModel):
items: list[AuditAdminItem]
total: int
page: int = 1
page_size: int = 100
pages: int = 1

View File

@@ -0,0 +1,300 @@
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_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
from govoplan_access.backend.db.models import Account, Tenant, User
from govoplan_core.db.session import get_session
from govoplan_core.security.permissions import normalize_email, scopes_grant
from govoplan_core.security.time import utc_now
from govoplan_core.settings import settings
from govoplan_access.backend.security.passwords import verify_password
from govoplan_access.backend.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,
)
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)
me_payload = _me_response(session, account=account, user=user, tenant=tenant)
maintenance_mode = saved_maintenance_mode(session)
if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=maintenance_response_detail(maintenance_mode),
)
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,
)
session.commit()
_set_auth_cookies(response, created)
return LoginResponse(
access_token=created.token,
expires_at=created.model.expires_at,
**me_payload.model_dump(),
)
@router.get("/me", response_model=MeResponse)
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
return _me_response(
session,
account=principal.account,
user=principal.user,
tenant=tenant,
effective_scopes=principal.scopes,
include_system=principal.auth_session is not None,
include_all_memberships=principal.auth_session is not None,
)
@router.patch("/profile", response_model=MeResponse)
def update_profile(
payload: ProfileUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
if principal.auth_session is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile")
if "display_name" in payload.model_fields_set:
principal.account.display_name = payload.display_name.strip() if payload.display_name else None
session.add(principal.account)
if "tenant_display_name" in payload.model_fields_set:
principal.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None
session.add(principal.user)
audit_from_principal(
session,
principal,
action="profile.updated",
object_type="account",
object_id=principal.account.id,
details={"fields": sorted(payload.model_fields_set)},
)
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
session.commit()
return _me_response(
session,
account=principal.account,
user=principal.user,
tenant=tenant,
include_system=True,
include_all_memberships=True,
)
@router.post("/switch-tenant", response_model=MeResponse)
def switch_tenant(
payload: SwitchTenantRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
if principal.auth_session is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot switch tenant context")
try:
membership = switch_auth_session_tenant(session, principal.auth_session, payload.tenant_id)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
tenant = session.get(Tenant, membership.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
session.commit()
return _me_response(session, account=principal.account, user=membership, tenant=tenant)
@router.post("/logout")
def logout(
response: Response,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
if principal.auth_session is not None:
principal.auth_session.revoked_at = utc_now()
session.add(principal.auth_session)
session.commit()
_clear_auth_cookies(response)
return {"ok": True}

View File

@@ -0,0 +1,779 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_access.backend.admin.governance import (
assert_api_keys_allowed,
assert_custom_groups_allowed,
assert_custom_roles_allowed,
ensure_group_mutation_allowed,
ensure_role_mutation_allowed,
)
from govoplan_access.backend.admin.service import (
AdminConflictError,
AdminValidationError,
assert_can_delegate_roles,
assert_can_delegate_system_permissions,
assert_can_delegate_system_roles,
assert_can_delegate_tenant_permissions,
assert_system_owner_exists,
assert_tenant_owner_exists,
create_custom_role,
create_membership,
create_system_role,
delete_custom_role,
delete_system_role,
ensure_default_roles,
get_or_create_account,
set_group_members,
set_group_roles,
set_system_roles,
set_user_groups,
set_user_roles,
slugify,
tenant_owner_user_ids,
update_custom_role,
update_system_role,
)
from govoplan_access.backend.api.v1.admin_common import (
_api_key_item,
_group_summary,
_http_admin_error,
_require_permission,
_require_system_role_assignment,
_resolve_tenant,
_role_summary,
_set_system_memberships,
_system_account_item,
_user_item,
)
from govoplan_access.backend.api.v1.admin_schemas import (
AdminApiKeyCreateRequest,
AdminApiKeyCreateResponse,
ApiKeyAdminItem,
ApiKeyListResponse,
GroupCreateRequest,
GroupListResponse,
GroupSummary,
GroupUpdateRequest,
PermissionCatalogResponse,
PermissionItem,
RoleCreateRequest,
RoleListResponse,
RoleSummary,
RoleUpdateRequest,
SystemAccountCreateRequest,
SystemAccountCreateResponse,
SystemAccountItem,
SystemAccountListResponse,
SystemAccountMembershipsUpdateRequest,
SystemAccountRolesUpdateRequest,
SystemAccountUpdateRequest,
UserCreateRequest,
UserCreateResponse,
UserAdminItem,
UserListResponse,
UserUpdateRequest,
)
from govoplan_access.backend.security.api_keys import create_api_key
from govoplan_access.backend.security.sessions import collect_user_scopes
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope
from govoplan_core.audit.logging import audit_event, audit_from_principal
from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, Tenant, User
from govoplan_core.db.session import get_session
from govoplan_core.security.permissions import ALL_PERMISSIONS, normalize_email, scopes_grant
from govoplan_core.security.time import utc_now
router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/permissions", response_model=PermissionCatalogResponse)
def permission_catalog(
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "system:roles:read", "system:access:read", "system:governance:read")),
):
items = [
PermissionItem(
scope=item.scope,
label=item.label,
description=item.description,
category=item.category,
level=item.level,
)
for item in ALL_PERMISSIONS
if item.level == "tenant" or has_scope(principal, "system:roles:read") or has_scope(principal, "system:access:read") or has_scope(principal, "system:governance:read")
]
return PermissionCatalogResponse(permissions=items)
@router.get("/users", response_model=UserListResponse)
def list_users(
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:users:read")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all()
owner_ids = tenant_owner_user_ids(session, tenant.id)
return UserListResponse(users=[_user_item(session, user, owner_ids=owner_ids) for user in users])
@router.post("/users", response_model=UserCreateResponse, status_code=status.HTTP_201_CREATED)
def create_user(
payload: UserCreateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
_require_permission(principal, "admin:users:create")
if payload.group_ids:
_require_permission(principal, "admin:groups:manage_members")
if payload.role_ids:
_require_permission(principal, "admin:roles:assign")
tenant = _resolve_tenant(session, principal, tenant_id)
try:
result = create_membership(
session,
tenant=tenant,
email=payload.email,
display_name=payload.display_name,
password=payload.password,
password_reset_required=payload.password_reset_required,
is_active=payload.is_active,
)
set_user_groups(session, user=result.user, group_ids=payload.group_ids)
assert_can_delegate_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids, tenant_id=tenant.id)
set_user_roles(session, user=result.user, role_ids=payload.role_ids)
assert_tenant_owner_exists(session, tenant.id)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="user.created",
object_type="user",
object_id=result.user.id,
details={
"email": result.account.email,
"account_created": result.account_created,
"group_ids": payload.group_ids,
"role_ids": payload.role_ids,
},
)
session.commit()
return UserCreateResponse(
user=_user_item(session, result.user),
account_created=result.account_created,
temporary_password=result.temporary_password,
)
@router.patch("/users/{user_id}", response_model=UserAdminItem)
def update_user(
user_id: str,
payload: UserUpdateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
if "display_name" in payload.model_fields_set:
_require_permission(principal, "admin:users:update")
if payload.is_active is not None:
_require_permission(principal, "admin:users:suspend")
if payload.group_ids is not None:
_require_permission(principal, "admin:groups:manage_members")
if payload.role_ids is not None:
_require_permission(principal, "admin:roles:assign")
tenant = _resolve_tenant(session, principal, tenant_id)
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
try:
if payload.display_name is not None:
user.display_name = payload.display_name.strip() or None
if payload.is_active is not None:
user.is_active = payload.is_active
session.add(user)
if payload.group_ids is not None:
set_user_groups(session, user=user, group_ids=payload.group_ids)
if payload.role_ids is not None:
assert_can_delegate_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids, tenant_id=tenant.id)
set_user_roles(session, user=user, role_ids=payload.role_ids)
assert_tenant_owner_exists(session, tenant.id)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="user.updated",
object_type="user",
object_id=user.id,
details=payload.model_dump(exclude_none=True),
)
session.commit()
return _user_item(session, user)
@router.get("/groups", response_model=GroupListResponse)
def list_groups(
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:groups:read")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
groups = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc()).all()
return GroupListResponse(groups=[_group_summary(session, group) for group in groups])
@router.post("/groups", response_model=GroupSummary, status_code=status.HTTP_201_CREATED)
def create_group(
payload: GroupCreateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
_require_permission(principal, "admin:groups:write")
if payload.member_ids:
_require_permission(principal, "admin:groups:manage_members")
if payload.role_ids:
_require_permission(principal, "admin:roles:assign")
tenant = _resolve_tenant(session, principal, tenant_id)
try:
assert_custom_groups_allowed(session, tenant)
except AdminConflictError as exc:
raise _http_admin_error(exc) from exc
group_slug = slugify(payload.slug)
if session.query(Group).filter(Group.tenant_id == tenant.id, Group.slug == group_slug).count():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A group with this slug already exists")
group = Group(
tenant_id=tenant.id,
slug=group_slug,
name=payload.name.strip(),
description=payload.description.strip() if payload.description else None,
is_active=payload.is_active,
)
session.add(group)
session.flush()
try:
set_group_members(session, group=group, user_ids=payload.member_ids)
assert_can_delegate_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids, tenant_id=tenant.id)
set_group_roles(session, group=group, role_ids=payload.role_ids)
assert_tenant_owner_exists(session, tenant.id)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="group.created",
object_type="group",
object_id=group.id,
details={"slug": group.slug, "member_ids": payload.member_ids, "role_ids": payload.role_ids},
)
session.commit()
return _group_summary(session, group)
@router.patch("/groups/{group_id}", response_model=GroupSummary)
def update_group(
group_id: str,
payload: GroupUpdateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
if any(field in payload.model_fields_set for field in ("name", "description", "is_active")):
_require_permission(principal, "admin:groups:write")
if payload.member_ids is not None:
_require_permission(principal, "admin:groups:manage_members")
if payload.role_ids is not None:
_require_permission(principal, "admin:roles:assign")
tenant = _resolve_tenant(session, principal, tenant_id)
group = session.query(Group).filter(Group.id == group_id, Group.tenant_id == tenant.id).one_or_none()
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found")
try:
ensure_group_mutation_allowed(group, requested_active=payload.is_active)
if group.system_template_id is None and payload.name is not None:
group.name = payload.name.strip()
if group.system_template_id is None and "description" in payload.model_fields_set:
group.description = payload.description.strip() if payload.description and payload.description.strip() else None
if payload.is_active is not None:
group.is_active = payload.is_active
session.add(group)
if payload.member_ids is not None:
set_group_members(session, group=group, user_ids=payload.member_ids)
if payload.role_ids is not None:
assert_can_delegate_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids, tenant_id=tenant.id)
set_group_roles(session, group=group, role_ids=payload.role_ids)
assert_tenant_owner_exists(session, tenant.id)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="group.updated",
object_type="group",
object_id=group.id,
details=payload.model_dump(exclude_unset=True),
)
session.commit()
return _group_summary(session, group)
@router.get("/roles", response_model=RoleListResponse)
def list_roles(
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:roles:read")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
ensure_default_roles(session, tenant)
session.commit()
roles = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc()).all()
return RoleListResponse(roles=[_role_summary(session, role) for role in roles])
@router.post("/roles", response_model=RoleSummary, status_code=status.HTTP_201_CREATED)
def create_role(
payload: RoleCreateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:roles:write")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
try:
assert_custom_roles_allowed(session, tenant)
assert_can_delegate_tenant_permissions(principal.scopes, payload.permissions)
role = create_custom_role(
session,
tenant=tenant,
slug=payload.slug,
name=payload.name,
description=payload.description,
permissions=payload.permissions,
)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="role.created",
object_type="role",
object_id=role.id,
details={"slug": role.slug, "permissions": role.permissions},
)
session.commit()
return _role_summary(session, role)
@router.patch("/roles/{role_id}", response_model=RoleSummary)
def update_role(
role_id: str,
payload: RoleUpdateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:roles:write")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
role = session.query(Role).filter(Role.id == role_id, Role.tenant_id == tenant.id).one_or_none()
if role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
try:
ensure_role_mutation_allowed(role, requested_assignable=payload.is_assignable)
if payload.permissions is not None:
assert_can_delegate_tenant_permissions(principal.scopes, payload.permissions)
update_custom_role(
session,
role=role,
name=payload.name,
description=payload.description,
permissions=payload.permissions,
is_assignable=payload.is_assignable,
)
assert_tenant_owner_exists(session, tenant.id)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="role.updated",
object_type="role",
object_id=role.id,
details=payload.model_dump(),
)
session.commit()
return _role_summary(session, role)
@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_role(
role_id: str,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:roles:write")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
role = session.query(Role).filter(Role.id == role_id, Role.tenant_id == tenant.id).one_or_none()
if role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
try:
if role.system_template_id:
raise AdminConflictError("Centrally managed roles can only be removed from System administration.")
role_slug = role.slug
delete_custom_role(session, role)
assert_tenant_owner_exists(session, tenant.id)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="role.deleted",
object_type="role",
object_id=role_id,
details={"slug": role_slug},
)
session.commit()
return None
@router.get("/system/roles", response_model=RoleListResponse)
def list_system_roles(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")),
):
ensure_default_roles(session, None)
session.commit()
roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
return RoleListResponse(roles=[_role_summary(session, role) for role in roles])
@router.post("/system/roles", response_model=RoleSummary, status_code=status.HTTP_201_CREATED)
def create_system_role_endpoint(
payload: RoleCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:roles:write")),
):
try:
assert_can_delegate_system_permissions(principal.scopes, payload.permissions)
role = create_system_role(
session,
slug=payload.slug,
name=payload.name,
description=payload.description,
permissions=payload.permissions,
)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_from_principal(
session, principal, action="system_role.created", scope="system", object_type="role", object_id=role.id,
details={"slug": role.slug, "permissions": role.permissions},
)
session.commit()
return _role_summary(session, role)
@router.patch("/system/roles/{role_id}", response_model=RoleSummary)
def update_system_role_endpoint(
role_id: str,
payload: RoleUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:roles:write")),
):
role = session.query(Role).filter(Role.id == role_id, Role.tenant_id.is_(None)).one_or_none()
if role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="System role not found")
try:
assert_can_delegate_system_permissions(principal.scopes, payload.permissions)
update_system_role(
session,
role=role,
name=payload.name,
description=payload.description,
permissions=payload.permissions,
is_assignable=payload.is_assignable,
)
assert_system_owner_exists(session)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_from_principal(
session, principal, action="system_role.updated", scope="system", object_type="role", object_id=role.id,
details=payload.model_dump(),
)
session.commit()
return _role_summary(session, role)
@router.delete("/system/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_system_role_endpoint(
role_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:roles:write")),
):
role = session.query(Role).filter(Role.id == role_id, Role.tenant_id.is_(None)).one_or_none()
if role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="System role not found")
try:
role_slug = role.slug
delete_system_role(session, role)
assert_system_owner_exists(session)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_from_principal(
session, principal, action="system_role.deleted", scope="system", object_type="role", object_id=role_id,
details={"slug": role_slug},
)
session.commit()
return None
@router.get("/system/accounts", response_model=SystemAccountListResponse)
def list_system_accounts(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")),
):
ensure_default_roles(session, None)
session.commit()
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
accounts = session.query(Account).order_by(Account.email.asc()).all()
return SystemAccountListResponse(
accounts=[_system_account_item(session, account) for account in accounts],
roles=[_role_summary(session, role) for role in system_roles],
)
@router.patch("/system/accounts/{account_id}", response_model=SystemAccountItem)
def update_system_account(
account_id: str,
payload: SystemAccountUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
if "display_name" in payload.model_fields_set and not has_scope(principal, "system:accounts:update"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:accounts:update")
if payload.is_active is not None and not has_scope(principal, "system:accounts:suspend"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:accounts:suspend")
if payload.role_ids is not None:
_require_system_role_assignment(principal)
account = session.get(Account, account_id)
if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
if "display_name" in payload.model_fields_set:
account.display_name = payload.display_name.strip() if payload.display_name else None
if payload.is_active is not None:
account.is_active = payload.is_active
session.add(account)
try:
if payload.role_ids is not None:
_require_system_role_assignment(principal)
assert_can_delegate_system_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids)
set_system_roles(session, account=account, role_ids=payload.role_ids)
else:
session.flush()
assert_system_owner_exists(session)
if not account.is_active:
tenant_ids = [
row[0]
for row in session.query(User.tenant_id)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.account_id == account.id, User.is_active.is_(True), Tenant.is_active.is_(True))
.distinct()
.all()
]
for tenant_id in tenant_ids:
assert_tenant_owner_exists(session, tenant_id)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_from_principal(
session,
principal,
action="system_account.updated",
scope="system",
object_type="account",
object_id=account.id,
details=payload.model_dump(exclude_unset=True),
)
session.commit()
return _system_account_item(session, account)
@router.put("/system/accounts/{account_id}/roles", response_model=SystemAccountItem)
def update_system_account_roles(
account_id: str,
payload: SystemAccountRolesUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("system:roles:assign", "system:access:assign")),
):
account = session.get(Account, account_id)
if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
try:
_require_system_role_assignment(principal)
assert_can_delegate_system_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids)
set_system_roles(session, account=account, role_ids=payload.role_ids)
assert_system_owner_exists(session)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_from_principal(
session,
principal,
action="system_access.updated",
scope="system",
object_type="account",
object_id=account.id,
details={"role_ids": payload.role_ids},
)
session.commit()
return _system_account_item(session, account)
@router.post("/system/accounts", response_model=SystemAccountCreateResponse, status_code=status.HTTP_201_CREATED)
def create_system_account(
payload: SystemAccountCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:accounts:create")),
):
try:
account, created, temporary_password = get_or_create_account(
session,
email=payload.email,
display_name=payload.display_name,
password=payload.password,
password_reset_required=payload.password_reset_required,
)
if not created:
raise AdminConflictError("A global account with this email already exists.")
account.is_active = payload.is_active
if payload.role_ids:
_require_system_role_assignment(principal)
assert_can_delegate_system_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids)
set_system_roles(session, account=account, role_ids=payload.role_ids)
if payload.memberships:
_require_permission(principal, "system:access:assign")
_set_system_memberships(session, account, [item.model_dump() for item in payload.memberships])
assert_system_owner_exists(session)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_from_principal(
session, principal, action="system_account.created", scope="system", object_type="account", object_id=account.id,
details={"email": account.email, "tenant_ids": [item.tenant_id for item in payload.memberships], "role_ids": payload.role_ids},
)
session.commit()
return SystemAccountCreateResponse(account=_system_account_item(session, account), temporary_password=temporary_password)
@router.put("/system/accounts/{account_id}/memberships", response_model=SystemAccountItem)
def update_system_account_memberships(
account_id: str,
payload: SystemAccountMembershipsUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:accounts:update")),
):
account = session.get(Account, account_id)
if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
try:
_require_permission(principal, "system:access:assign")
_set_system_memberships(session, account, [item.model_dump() for item in payload.memberships])
for tenant_id in {item.tenant_id for item in payload.memberships}:
assert_tenant_owner_exists(session, tenant_id)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_from_principal(
session, principal, action="system_memberships.updated", scope="system", object_type="account", object_id=account.id,
details={"tenant_ids": [item.tenant_id for item in payload.memberships]},
)
session.commit()
return _system_account_item(session, account)
@router.get("/api-keys", response_model=ApiKeyListResponse)
def list_api_keys(
tenant_id: str | None = Query(default=None),
include_revoked: bool = Query(default=False),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
if not include_revoked:
query = query.filter(ApiKey.revoked_at.is_(None))
keys = query.order_by(ApiKey.created_at.desc()).all()
return ApiKeyListResponse(api_keys=[_api_key_item(session, item) for item in keys])
@router.post("/api-keys", response_model=AdminApiKeyCreateResponse, status_code=status.HTTP_201_CREATED)
def create_tenant_api_key(
payload: AdminApiKeyCreateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:create")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
try:
assert_api_keys_allowed(session, tenant)
except AdminConflictError as exc:
raise _http_admin_error(exc) from exc
user_id = payload.user_id or principal.user.id
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id, User.is_active.is_(True)).one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found")
user_scopes = collect_user_scopes(session, user, include_system=False)
requested = payload.scopes or ["campaign:read"]
invalid = [scope for scope in requested if scope.startswith("system:") or not scopes_grant(user_scopes, scope)]
if invalid:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"API key scopes exceed the user's tenant permissions: {', '.join(invalid)}",
)
created = create_api_key(
session,
user=user,
name=payload.name.strip(),
scopes=sorted(set(requested)),
expires_at=payload.expires_at,
)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="api_key.created",
object_type="api_key",
object_id=created.model.id,
details={"name": created.model.name, "prefix": created.model.prefix, "scopes": created.model.scopes, "owner_user_id": user.id},
)
session.commit()
return AdminApiKeyCreateResponse(**_api_key_item(session, created.model).model_dump(), secret=created.secret)
@router.post("/api-keys/{api_key_id}/revoke", response_model=ApiKeyAdminItem)
def revoke_api_key(
api_key_id: str,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:revoke")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
item = session.query(ApiKey).filter(ApiKey.id == api_key_id, ApiKey.tenant_id == tenant.id).one_or_none()
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
if item.revoked_at is None:
item.revoked_at = utc_now()
session.add(item)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="api_key.revoked",
object_type="api_key",
object_id=item.id,
details={"name": item.name, "prefix": item.prefix},
)
session.commit()
return _api_key_item(session, item)

View File

@@ -0,0 +1,351 @@
from __future__ import annotations
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.orm import Session
from govoplan_core.core.access import (
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
PermissionEvaluator,
PrincipalRef,
PrincipalResolver,
)
from govoplan_core.core.modules import AccessDecision
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
from govoplan_core.db.session import get_database, get_session
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, User
from govoplan_access.backend.security.api_keys import authenticate_api_key
from govoplan_access.backend.security.sessions import authenticate_session_token, collect_user_groups, collect_user_scopes, verify_auth_session_csrf
from govoplan_tenancy.backend.db.models import Tenant
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_core.security.permissions import intersect_api_key_scopes
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: PrincipalRef
account: Account
user: User
api_key: ApiKey | None = None
auth_session: AuthSession | None = None
permission_evaluator: PermissionEvaluator | 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:
if self.permission_evaluator is not None:
return self.permission_evaluator.has_scope(self.principal, required_scope)
# 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) -> PrincipalRef:
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_principal_ref(
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,
) -> PrincipalRef:
return PrincipalRef(
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,
)
def _api_principal_from_ref(
session: Session,
principal: PrincipalRef,
*,
permission_evaluator: PermissionEvaluator | None = None,
) -> ApiPrincipal:
account = session.get(Account, principal.account_id)
user = session.get(User, principal.membership_id) if principal.membership_id else None
tenant = session.get(Tenant, principal.tenant_id) if principal.tenant_id else None
if (
not account
or not user
or not tenant
or not account.is_active
or not user.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 API principal")
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
auth_session = session.get(AuthSession, principal.session_id) if principal.session_id else None
return ApiPrincipal(
principal=principal,
account=account,
user=user,
api_key=api_key,
auth_session=auth_session,
permission_evaluator=permission_evaluator,
)
def _resolve_legacy_principal_ref(
request: Request,
session: Session,
*,
authorization: str | None,
x_api_key: str | None,
) -> PrincipalRef:
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_principal_ref(
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_principal_ref(
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 _registry_from_request(request: Request) -> PlatformRegistry | None:
registry = getattr(request.app.state, "govoplan_registry", None)
return registry if isinstance(registry, PlatformRegistry) else None
def _principal_resolver_from_request(request: Request) -> PrincipalResolver | None:
registry = _registry_from_request(request)
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_PRINCIPAL_RESOLVER):
return None
capability = registry.require_capability(CAPABILITY_ACCESS_PRINCIPAL_RESOLVER)
if not isinstance(capability, PrincipalResolver):
raise HTTPException(status_code=500, detail=f"Invalid capability: {CAPABILITY_ACCESS_PRINCIPAL_RESOLVER}")
return capability
def _permission_evaluator_from_request(request: Request) -> PermissionEvaluator | None:
registry = _registry_from_request(request)
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_PERMISSION_EVALUATOR):
return None
capability = registry.require_capability(CAPABILITY_ACCESS_PERMISSION_EVALUATOR)
if not isinstance(capability, PermissionEvaluator):
raise HTTPException(status_code=500, detail=f"Invalid capability: {CAPABILITY_ACCESS_PERMISSION_EVALUATOR}")
return capability
class LegacyPrincipalResolver:
def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef:
if not isinstance(request, Request):
raise TypeError("LegacyPrincipalResolver requires a FastAPI request")
authorization = request.headers.get("authorization")
x_api_key = request.headers.get("x-api-key")
if isinstance(session, Session):
return _resolve_legacy_principal_ref(request, session, authorization=authorization, x_api_key=x_api_key)
with get_database().session() as managed_session:
return _resolve_legacy_principal_ref(request, managed_session, authorization=authorization, x_api_key=x_api_key)
class LegacyPermissionEvaluator:
def scopes_grant(self, scopes, required_scope: str) -> bool:
return scopes_grant_compatible(scopes, required_scope)
def has_scope(self, principal: PrincipalRef, required_scope: str) -> bool:
return self.scopes_grant(principal.scopes, required_scope)
def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision:
allowed = self.has_scope(principal, required_scope)
return AccessDecision(
allowed=allowed,
reason=None if allowed else f"Missing scope: {required_scope}",
requirements=(required_scope,),
)
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:
resolver = _principal_resolver_from_request(request)
permission_evaluator = _permission_evaluator_from_request(request)
if resolver is not None:
principal = resolver.resolve_request(request, session=session)
api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator)
_enforce_maintenance_mode(session, api_principal)
return api_principal
principal = _resolve_legacy_principal_ref(
request,
session,
authorization=authorization,
x_api_key=x_api_key,
)
api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator)
_enforce_maintenance_mode(session, api_principal)
return api_principal
def _enforce_maintenance_mode(session: Session, principal: ApiPrincipal) -> None:
mode = saved_maintenance_mode(session)
if not mode.enabled or principal.has(MAINTENANCE_ACCESS_SCOPE):
return
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=maintenance_response_detail(mode),
)
def has_scope(principal: ApiPrincipal, required_scope: str) -> bool:
return principal.has(required_scope)
def require_scope(required_scope: str):
def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal:
if not has_scope(principal, required_scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {required_scope}")
return principal
return dependency
def require_any_scope(*required_scopes: str):
def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal:
if not any(has_scope(principal, required) for required in required_scopes):
joined = ", ".join(required_scopes)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {joined}")
return principal
return dependency

View File

@@ -2,7 +2,16 @@ 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.db.models import (
Account,
Group,
GroupRoleAssignment,
Role,
SystemRoleAssignment,
User,
UserGroupMembership,
UserRoleAssignment,
)
from govoplan_access.backend.permissions.evaluator import expand_scopes
from govoplan_core.core.modules import PermissionDefinition, SubjectType
@@ -10,10 +19,10 @@ 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)
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
.filter(
GroupMembership.tenant_id == tenant_id,
GroupMembership.membership_id == membership_id,
UserGroupMembership.tenant_id == tenant_id,
UserGroupMembership.user_id == membership_id,
Group.is_active.is_(True),
)
.all()
@@ -28,22 +37,44 @@ def roles_for_subject(
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 and subject_type == "account":
return (
session.query(Role)
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
.filter(SystemRoleAssignment.account_id == subject_id, Role.tenant_id.is_(None))
.order_by(Role.name.asc())
.all()
)
)
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()
return []
if subject_type == "membership":
return (
session.query(Role)
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
.filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.user_id == subject_id,
Role.tenant_id == tenant_id,
)
.order_by(Role.name.asc())
.all()
)
if subject_type == "group":
return (
session.query(Role)
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.filter(
GroupRoleAssignment.tenant_id == tenant_id,
GroupRoleAssignment.group_id == subject_id,
Role.tenant_id == tenant_id,
)
.order_by(Role.name.asc())
.all()
)
return []
def membership_roles(session: Session, membership: Membership) -> list[Role]:
def membership_roles(session: Session, membership: User) -> list[Role]:
roles_by_id = {
role.id: role
for role in roles_for_subject(
@@ -67,7 +98,7 @@ def principal_scopes(
session: Session,
*,
account: Account,
membership: Membership | None,
membership: User | None,
include_system: bool,
catalog: dict[str, PermissionDefinition],
) -> list[str]:

View File

@@ -1,21 +1,7 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import JSON, MetaData
from sqlalchemy.orm import DeclarativeBase
from govoplan_core.db.base import Base as AccessBase
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

@@ -4,10 +4,11 @@ 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 import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, JSON, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_access.backend.db.base import AccessBase, TimestampMixin
from govoplan_tenancy.backend.db.models import Tenant
def new_uuid() -> str:
@@ -15,11 +16,13 @@ def new_uuid() -> str:
class Account(AccessBase, TimestampMixin):
__tablename__ = "access_accounts"
"""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), nullable=False, unique=True, index=True)
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)
@@ -27,72 +30,61 @@ class Account(AccessBase, TimestampMixin):
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")
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(AccessBase, TimestampMixin):
__tablename__ = "access_tenants"
class User(AccessBase, 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)
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)
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")
tenant: Mapped[Tenant] = 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(AccessBase, TimestampMixin):
__tablename__ = "access_groups"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_access_groups_tenant_slug"),)
__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("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
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)
template_id: Mapped[str | None] = mapped_column(String(100), index=True)
is_protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
system_template_id: Mapped[str | None] = mapped_column(String(36), 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)
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)
mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class Role(AccessBase, TimestampMixin):
__tablename__ = "access_roles"
__tablename__ = "roles"
__table_args__ = (
UniqueConstraint("tenant_id", "slug", name="uq_access_roles_tenant_slug"),
UniqueConstraint("tenant_id", "slug", name="uq_roles_tenant_slug"),
Index(
"uq_access_roles_system_slug",
"uq_roles_system_slug",
"slug",
unique=True,
sqlite_where=text("tenant_id IS NULL"),
@@ -101,136 +93,106 @@ class Role(AccessBase, TimestampMixin):
)
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)
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, nullable=False)
level: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
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)
template_id: Mapped[str | None] = mapped_column(String(100), index=True)
is_protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
system_template_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
system_required: 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"),
)
class SystemRoleAssignment(AccessBase, 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)
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)
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 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"),)
class UserGroupMembership(AccessBase, 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)
module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
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(AccessBase, 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(AccessBase, 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(AccessBase, 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)
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)
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))
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)
user: Mapped[User] = relationship(back_populates="api_keys")
class AuthSession(AccessBase, TimestampMixin):
__tablename__ = "access_auth_sessions"
__tablename__ = "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))
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))
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)
user: Mapped[User] = relationship(back_populates="auth_sessions")
account: Mapped[Account] = relationship(back_populates="auth_sessions")
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)
__all__ = [
"Account",
"ApiKey",
"AuthSession",
"Group",
"GroupRoleAssignment",
"Role",
"SystemRoleAssignment",
"Tenant",
"User",
"UserGroupMembership",
"UserRoleAssignment",
"new_uuid",
]

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from govoplan_core.core.access import AccessDirectory, AccessSubjectRef, AccountRef, GroupRef, UserRef
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_core.db.session import get_database
def _status(active: bool) -> str:
return "active" if active else "inactive"
def _account_ref(account: Account) -> AccountRef:
return AccountRef(
id=account.id,
email=account.email,
display_name=account.display_name,
status=_status(account.is_active), # type: ignore[arg-type]
)
def _user_ref(user: User, account: Account | None = None) -> UserRef:
return UserRef(
id=user.id,
account_id=user.account_id,
tenant_id=user.tenant_id,
email=account.email if account else user.email,
display_name=user.display_name or (account.display_name if account else None),
status=_status(user.is_active), # type: ignore[arg-type]
)
def _group_ref(group: Group) -> GroupRef:
return GroupRef(
id=group.id,
tenant_id=group.tenant_id,
name=group.name,
status=_status(group.is_active), # type: ignore[arg-type]
)
class SqlAccessDirectory(AccessDirectory):
def get_account(self, account_id: str) -> AccountRef | None:
with get_database().session() as session:
account = session.get(Account, account_id)
return _account_ref(account) if account is not None else None
def get_user(self, user_id: str) -> UserRef | None:
with get_database().session() as session:
user = session.get(User, user_id)
if user is None:
return None
account = session.get(Account, user.account_id)
return _user_ref(user, account)
def get_users(self, user_ids: Iterable[str]) -> Mapping[str, UserRef]:
ids = {str(user_id) for user_id in user_ids if user_id}
if not ids:
return {}
with get_database().session() as session:
users = session.query(User).filter(User.id.in_(ids)).all()
account_ids = {user.account_id for user in users}
accounts = {
account.id: account
for account in session.query(Account).filter(Account.id.in_(account_ids)).all()
} if account_ids else {}
return {user.id: _user_ref(user, accounts.get(user.account_id)) for user in users}
def users_for_tenant(self, tenant_id: str) -> tuple[UserRef, ...]:
with get_database().session() as session:
users = session.query(User).filter(User.tenant_id == tenant_id).order_by(User.display_name.asc(), User.email.asc()).all()
account_ids = {user.account_id for user in users}
accounts = {
account.id: account
for account in session.query(Account).filter(Account.id.in_(account_ids)).all()
} if account_ids else {}
return tuple(_user_ref(user, accounts.get(user.account_id)) for user in users)
def get_group(self, group_id: str) -> GroupRef | None:
with get_database().session() as session:
group = session.get(Group, group_id)
return _group_ref(group) if group is not None else None
def get_groups(self, group_ids: Iterable[str]) -> Mapping[str, GroupRef]:
ids = {str(group_id) for group_id in group_ids if group_id}
if not ids:
return {}
with get_database().session() as session:
groups = session.query(Group).filter(Group.id.in_(ids)).all()
return {group.id: _group_ref(group) for group in groups}
def groups_for_tenant(self, tenant_id: str) -> tuple[GroupRef, ...]:
with get_database().session() as session:
groups = session.query(Group).filter(Group.tenant_id == tenant_id).order_by(Group.name.asc()).all()
return tuple(_group_ref(group) for group in groups)
def groups_for_user(self, user_id: str, *, tenant_id: str) -> tuple[GroupRef, ...]:
with get_database().session() as session:
from govoplan_access.backend.db.models import UserGroupMembership
groups = (
session.query(Group)
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
.filter(
UserGroupMembership.user_id == user_id,
UserGroupMembership.tenant_id == tenant_id,
Group.tenant_id == tenant_id,
)
.order_by(Group.name.asc())
.all()
)
return tuple(_group_ref(group) for group in groups)
def display_label(self, subject: AccessSubjectRef) -> str | None:
if subject.kind == "account":
account = self.get_account(subject.id)
return account.display_name or account.email if account else subject.label
if subject.kind == "user":
user = self.get_user(subject.id)
return user.display_name or user.email if user else subject.label
if subject.kind == "group":
group = self.get_group(subject.id)
return group.name if group else subject.label
return subject.label or subject.id

View File

@@ -0,0 +1,130 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Group, GroupRoleAssignment, Role, UserGroupMembership, UserRoleAssignment
from govoplan_core.admin.common import AdminConflictError
from govoplan_core.core.access import AccessGovernanceMaterializer, GovernanceTemplateMaterialization
from govoplan_core.core.runtime import get_registry
class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
db = _session(session)
if template.kind == "group":
group = (
db.query(Group)
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id)
.first()
)
if group is None:
group = Group(
tenant_id=template.tenant_id,
slug=_available_slug(db, Group, template.tenant_id, template.slug),
name=template.name,
description=template.description,
is_active=template.is_active,
system_template_id=template.template_id,
system_required=template.required,
)
db.add(group)
else:
group.name = template.name
group.description = template.description
group.system_required = template.required
if template.required:
group.is_active = template.is_active
db.flush()
return
role = (
db.query(Role)
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
.first()
)
if role is None:
role = Role(
tenant_id=template.tenant_id,
slug=_available_slug(db, Role, template.tenant_id, template.slug),
name=template.name,
description=template.description,
permissions=list(template.permissions),
is_builtin=False,
is_assignable=template.is_active,
system_template_id=template.template_id,
system_required=template.required,
)
db.add(role)
else:
role.name = template.name
role.description = template.description
role.permissions = list(template.permissions)
role.system_required = template.required
if template.required:
role.is_assignable = template.is_active
db.flush()
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
db = _session(session)
if template.kind == "group":
group = (
db.query(Group)
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id)
.first()
)
if group is None:
return
membership_count = db.query(UserGroupMembership).filter(UserGroupMembership.group_id == group.id).count()
role_count = db.query(GroupRoleAssignment).filter(GroupRoleAssignment.group_id == group.id).count()
if membership_count or role_count:
raise AdminConflictError(
f"Cannot remove {template.name!r} from the tenant while its managed group has members or roles."
)
_run_delete_vetoes(db, "group", template.tenant_id, group.id)
db.delete(group)
db.flush()
return
role = (
db.query(Role)
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
.first()
)
if role is None:
return
user_count = db.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role.id).count()
group_count = db.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role.id).count()
if user_count or group_count:
raise AdminConflictError(
f"Cannot remove {template.name!r} from the tenant while its managed role is assigned to users or groups."
)
db.delete(role)
db.flush()
def _session(session: object) -> Session:
if not isinstance(session, Session):
raise TypeError("Access governance materializer requires a SQLAlchemy Session")
return session
def _run_delete_vetoes(session: Session, resource_type: str, tenant_id: str, resource_id: str) -> None:
registry = get_registry()
if registry is None or not hasattr(registry, "delete_veto_providers"):
return
for provider in registry.delete_veto_providers(resource_type):
try:
provider(session, tenant_id, resource_id)
except AdminConflictError:
raise
except Exception as exc:
raise AdminConflictError(str(exc)) from exc
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

View File

@@ -2,8 +2,16 @@ 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
from govoplan_core.core.access import (
CAPABILITY_ACCESS_ADMINISTRATION,
CAPABILITY_ACCESS_DIRECTORY,
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
CAPABILITY_ACCESS_TENANT_PROVISIONER,
)
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition:
@@ -105,31 +113,120 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
),
)
ADMIN_READ_SCOPES = (
"admin:users:read",
"admin:groups:read",
"admin:roles:read",
"admin:api_keys:read",
"admin:settings:read",
"system:tenants:read",
"system:accounts:read",
"system:roles:read",
"system:settings:read",
"system:governance:read",
"system:audit:read",
"access:tenant:read",
"access:account:read",
"access:governance:read",
)
def _legacy_principal_resolver(context: ModuleContext) -> object:
del context
from govoplan_core.auth.dependencies import LegacyPrincipalResolver
from govoplan_access.backend.auth.dependencies import LegacyPrincipalResolver
return LegacyPrincipalResolver()
def _legacy_permission_evaluator(context: ModuleContext) -> object:
del context
from govoplan_core.auth.dependencies import LegacyPermissionEvaluator
from govoplan_access.backend.auth.dependencies import LegacyPermissionEvaluator
return LegacyPermissionEvaluator()
def _access_directory(context: ModuleContext) -> object:
del context
from govoplan_access.backend.directory import SqlAccessDirectory
return SqlAccessDirectory()
def _tenant_provisioner(context: ModuleContext) -> object:
del context
from govoplan_access.backend.tenancy.provisioning import LegacyTenantAccessProvisioner
return LegacyTenantAccessProvisioner()
def _access_administration(context: ModuleContext) -> object:
del context
from govoplan_access.backend.administration import SqlAccessAdministration
return SqlAccessAdministration()
def _governance_materializer(context: ModuleContext) -> object:
del context
from govoplan_access.backend.governance_materializer import SqlAccessGovernanceMaterializer
return SqlAccessGovernanceMaterializer()
def _route_factory(context: ModuleContext):
from fastapi import APIRouter
from govoplan_access.backend.runtime import configure_runtime
configure_runtime(context)
from govoplan_access.backend.api.v1.auth import router as auth_router
from govoplan_access.backend.api.v1.routes import router as access_admin_router
router = APIRouter()
router.include_router(auth_router)
router.include_router(access_admin_router)
return router
manifest = ModuleManifest(
id="access",
name="Access",
version="0.1.4",
version="0.1.5",
dependencies=("tenancy",),
permissions=ACCESS_PERMISSIONS,
role_templates=ACCESS_ROLE_TEMPLATES,
route_factory=_route_factory,
migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
access_models.Account,
access_models.User,
access_models.Group,
access_models.Role,
access_models.SystemRoleAssignment,
access_models.UserGroupMembership,
access_models.UserRoleAssignment,
access_models.GroupRoleAssignment,
access_models.ApiKey,
access_models.AuthSession,
label="Access",
),
),
nav_items=(NavItem(path="/admin", label="Admin", icon="admin", required_any=ADMIN_READ_SCOPES, order=900),),
frontend=FrontendModule(
module_id="access",
package_name="@govoplan/access-webui",
routes=(FrontendRoute(path="/admin", component="AdminPage", required_any=ADMIN_READ_SCOPES, order=900),),
nav_items=(NavItem(path="/admin", label="Admin", icon="admin", required_any=ADMIN_READ_SCOPES, order=900),),
),
capability_factories={
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
CAPABILITY_ACCESS_DIRECTORY: _access_directory,
CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner,
CAPABILITY_ACCESS_ADMINISTRATION: _access_administration,
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer,
},
)

View File

@@ -0,0 +1,5 @@
from __future__ import annotations
from govoplan_core.core.runtime import configure_runtime, get_registry
__all__ = ["configure_runtime", "get_registry"]

View File

@@ -0,0 +1,2 @@
"""Access-owned security helper services."""

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from collections.abc import Mapping
from sqlalchemy.orm import Session
from govoplan_access.backend.admin.service import ensure_default_roles
from govoplan_access.backend.db.models import Account, User, UserRoleAssignment
from govoplan_core.admin.common import AdminValidationError
from govoplan_core.core.access import TenantAccessProvisioner, TenantOwnerCandidateRef
class LegacyTenantAccessProvisioner(TenantAccessProvisioner):
def ensure_default_roles(self, session: object, tenant: object | None = None) -> Mapping[str, object]:
return ensure_default_roles(session, tenant) # type: ignore[arg-type]
def tenant_owner_candidates(self, session: object) -> tuple[TenantOwnerCandidateRef, ...]:
db = _session(session)
accounts = db.query(Account).filter(Account.is_active.is_(True)).order_by(Account.email.asc()).all()
return tuple(
TenantOwnerCandidateRef(account_id=account.id, email=account.email, display_name=account.display_name)
for account in accounts
)
def ensure_tenant_owner_membership(
self,
session: object,
*,
tenant: object,
owner_account_id: str,
) -> str:
db = _session(session)
tenant_id = getattr(tenant, "id", None)
if not tenant_id:
raise AdminValidationError("Tenant owner membership requires a persisted tenant.")
roles = ensure_default_roles(db, tenant) # type: ignore[arg-type]
owner_role = roles.get("owner")
if owner_role is None:
raise AdminValidationError("Tenant owner role is not available.")
owner_account = db.get(Account, owner_account_id)
if owner_account is None or not owner_account.is_active:
raise AdminValidationError("The selected tenant owner account is missing or inactive.")
membership = (
db.query(User)
.filter(User.tenant_id == tenant_id, User.account_id == owner_account.id)
.one_or_none()
)
if membership is None:
membership = User(
tenant_id=tenant_id,
account_id=owner_account.id,
email=owner_account.email,
display_name=owner_account.display_name,
is_active=True,
auth_provider=owner_account.auth_provider,
password_hash=owner_account.password_hash,
)
db.add(membership)
db.flush()
existing_assignment = (
db.query(UserRoleAssignment)
.filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.user_id == membership.id,
UserRoleAssignment.role_id == owner_role.id,
)
.one_or_none()
)
if existing_assignment is None:
db.add(UserRoleAssignment(tenant_id=tenant_id, user_id=membership.id, role_id=owner_role.id))
db.flush()
return membership.id
def _session(session: object) -> Session:
if not isinstance(session, Session):
raise TypeError("Tenant access provisioner requires a SQLAlchemy Session")
return session