Release v0.1.5
This commit is contained in:
2
src/govoplan_access/backend/admin/__init__.py
Normal file
2
src/govoplan_access/backend/admin/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Access-owned legacy admin helper services."""
|
||||
|
||||
43
src/govoplan_access/backend/admin/governance.py
Normal file
43
src/govoplan_access/backend/admin/governance.py
Normal 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()
|
||||
567
src/govoplan_access/backend/admin/service.py
Normal file
567
src/govoplan_access/backend/admin/service.py
Normal 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 [])
|
||||
57
src/govoplan_access/backend/administration.py
Normal file
57
src/govoplan_access/backend/administration.py
Normal 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
|
||||
2
src/govoplan_access/backend/api/__init__.py
Normal file
2
src/govoplan_access/backend/api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Access-owned backend API routes."""
|
||||
|
||||
2
src/govoplan_access/backend/api/v1/__init__.py
Normal file
2
src/govoplan_access/backend/api/v1/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Access-owned API v1 routes."""
|
||||
|
||||
11
src/govoplan_access/backend/api/v1/admin.py
Normal file
11
src/govoplan_access/backend/api/v1/admin.py
Normal 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"]
|
||||
251
src/govoplan_access/backend/api/v1/admin_common.py
Normal file
251
src/govoplan_access/backend/api/v1/admin_common.py
Normal 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)
|
||||
465
src/govoplan_access/backend/api/v1/admin_schemas.py
Normal file
465
src/govoplan_access/backend/api/v1/admin_schemas.py
Normal 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
|
||||
300
src/govoplan_access/backend/api/v1/auth.py
Normal file
300
src/govoplan_access/backend/api/v1/auth.py
Normal 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}
|
||||
779
src/govoplan_access/backend/api/v1/routes.py
Normal file
779
src/govoplan_access/backend/api/v1/routes.py
Normal 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)
|
||||
351
src/govoplan_access/backend/auth/dependencies.py
Normal file
351
src/govoplan_access/backend/auth/dependencies.py
Normal 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
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
125
src/govoplan_access/backend/directory.py
Normal file
125
src/govoplan_access/backend/directory.py
Normal 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
|
||||
130
src/govoplan_access/backend/governance_materializer.py
Normal file
130
src/govoplan_access/backend/governance_materializer.py
Normal 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
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
5
src/govoplan_access/backend/runtime.py
Normal file
5
src/govoplan_access/backend/runtime.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.runtime import configure_runtime, get_registry
|
||||
|
||||
__all__ = ["configure_runtime", "get_registry"]
|
||||
2
src/govoplan_access/backend/security/__init__.py
Normal file
2
src/govoplan_access/backend/security/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Access-owned security helper services."""
|
||||
|
||||
81
src/govoplan_access/backend/security/api_keys.py
Normal file
81
src/govoplan_access/backend/security/api_keys.py
Normal 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
|
||||
|
||||
40
src/govoplan_access/backend/security/passwords.py
Normal file
40
src/govoplan_access/backend/security/passwords.py
Normal 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)
|
||||
|
||||
221
src/govoplan_access/backend/security/sessions.py
Normal file
221
src/govoplan_access/backend/security/sessions.py
Normal 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()
|
||||
)
|
||||
|
||||
82
src/govoplan_access/backend/tenancy/provisioning.py
Normal file
82
src/govoplan_access/backend/tenancy/provisioning.py
Normal 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
|
||||
Reference in New Issue
Block a user