From efb69b3d2d653590366a00b425dff7c6ea791702 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 7 Jul 2026 15:49:06 +0200 Subject: [PATCH] Release v0.1.5 --- README.md | 37 +- package.json | 32 + pyproject.toml | 5 +- src/govoplan_access/backend/admin/__init__.py | 2 + .../backend/admin/governance.py | 43 + src/govoplan_access/backend/admin/service.py | 567 +++++++++++++ src/govoplan_access/backend/administration.py | 57 ++ src/govoplan_access/backend/api/__init__.py | 2 + .../backend/api/v1/__init__.py | 2 + src/govoplan_access/backend/api/v1/admin.py | 11 + .../backend/api/v1/admin_common.py | 251 ++++++ .../backend/api/v1/admin_schemas.py | 465 +++++++++++ src/govoplan_access/backend/api/v1/auth.py | 300 +++++++ src/govoplan_access/backend/api/v1/routes.py | 779 ++++++++++++++++++ .../backend/auth/dependencies.py | 351 ++++++++ src/govoplan_access/backend/auth/roles.py | 65 +- src/govoplan_access/backend/db/base.py | 16 +- src/govoplan_access/backend/db/models.py | 262 +++--- src/govoplan_access/backend/directory.py | 125 +++ .../backend/governance_materializer.py | 130 +++ src/govoplan_access/backend/manifest.py | 107 ++- src/govoplan_access/backend/runtime.py | 5 + .../backend/security/__init__.py | 2 + .../backend/security/api_keys.py | 81 ++ .../backend/security/passwords.py | 40 + .../backend/security/sessions.py | 221 +++++ .../backend/tenancy/provisioning.py | 82 ++ webui/package.json | 30 + webui/src/api/admin.ts | 545 ++++++++++++ webui/src/features/admin/AdminAuditPanel.tsx | 121 +++ webui/src/features/admin/AdminPage.tsx | 221 +++++ webui/src/features/admin/ApiKeysPanel.tsx | 124 +++ webui/src/features/admin/GroupsPanel.tsx | 139 ++++ .../src/features/admin/MailProfilesPanel.tsx | 122 +++ .../features/admin/RetentionPoliciesPanel.tsx | 142 ++++ webui/src/features/admin/RolesPanel.tsx | 131 +++ webui/src/features/admin/SystemRolesPanel.tsx | 254 ++++++ webui/src/features/admin/SystemUsersPanel.tsx | 224 +++++ .../features/admin/TenantSettingsPanel.tsx | 85 ++ webui/src/features/admin/TenantsPanel.tsx | 258 ++++++ webui/src/features/admin/UsersPanel.tsx | 189 +++++ webui/src/index.ts | 5 + webui/src/module.ts | 26 + 43 files changed, 6464 insertions(+), 192 deletions(-) create mode 100644 package.json create mode 100644 src/govoplan_access/backend/admin/__init__.py create mode 100644 src/govoplan_access/backend/admin/governance.py create mode 100644 src/govoplan_access/backend/admin/service.py create mode 100644 src/govoplan_access/backend/administration.py create mode 100644 src/govoplan_access/backend/api/__init__.py create mode 100644 src/govoplan_access/backend/api/v1/__init__.py create mode 100644 src/govoplan_access/backend/api/v1/admin.py create mode 100644 src/govoplan_access/backend/api/v1/admin_common.py create mode 100644 src/govoplan_access/backend/api/v1/admin_schemas.py create mode 100644 src/govoplan_access/backend/api/v1/auth.py create mode 100644 src/govoplan_access/backend/api/v1/routes.py create mode 100644 src/govoplan_access/backend/auth/dependencies.py create mode 100644 src/govoplan_access/backend/directory.py create mode 100644 src/govoplan_access/backend/governance_materializer.py create mode 100644 src/govoplan_access/backend/runtime.py create mode 100644 src/govoplan_access/backend/security/__init__.py create mode 100644 src/govoplan_access/backend/security/api_keys.py create mode 100644 src/govoplan_access/backend/security/passwords.py create mode 100644 src/govoplan_access/backend/security/sessions.py create mode 100644 src/govoplan_access/backend/tenancy/provisioning.py create mode 100644 webui/package.json create mode 100644 webui/src/api/admin.ts create mode 100644 webui/src/features/admin/AdminAuditPanel.tsx create mode 100644 webui/src/features/admin/AdminPage.tsx create mode 100644 webui/src/features/admin/ApiKeysPanel.tsx create mode 100644 webui/src/features/admin/GroupsPanel.tsx create mode 100644 webui/src/features/admin/MailProfilesPanel.tsx create mode 100644 webui/src/features/admin/RetentionPoliciesPanel.tsx create mode 100644 webui/src/features/admin/RolesPanel.tsx create mode 100644 webui/src/features/admin/SystemRolesPanel.tsx create mode 100644 webui/src/features/admin/SystemUsersPanel.tsx create mode 100644 webui/src/features/admin/TenantSettingsPanel.tsx create mode 100644 webui/src/features/admin/TenantsPanel.tsx create mode 100644 webui/src/features/admin/UsersPanel.tsx create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts diff --git a/README.md b/README.md index 9b1c178..b411bef 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,21 @@ authentication, sessions, API keys, RBAC, groups, users, and access administration. The repository contains the extracted access seed implementation under -`src/govoplan_access/backend`. Some live product routes and legacy ORM models -still reside in `govoplan-core` as compatibility code while the staged -extraction continues. The staged extraction path is documented in: +`src/govoplan_access/backend`. Session, API-key, and password helper services, +interactive auth routes, FastAPI auth dependencies, and the legacy +administration router are owned here. Access-side admin service helpers remain +here for users, groups, roles, system accounts, sessions, API keys, tenant +access enforcement, admin/audit lookup capabilities, tenant owner +provisioning, and governance-template materialization into access-owned groups +and roles. Governance-template metadata CRUD lives in `govoplan-admin`. The +transitional administration WebUI route shell and +access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic +system administration panels are contributed by `@govoplan/admin-webui` through +core's `admin.sections` UI capability. Live access ORM models are defined here +while retaining their historical table names; tenant records live in +`govoplan-tenancy`, governance templates in `govoplan-admin`, audit logs in +`govoplan-audit`, and system settings in `govoplan-core`. The staged +extraction path is documented in: - `/mnt/DATA/git/govoplan-core/docs/ACCESS_EXTRACTION_PLAN.md` - `/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md` @@ -19,6 +31,7 @@ This module will own: - accounts and login identity - authentication routes and session lifecycle - API keys +- legacy administration route contribution during the transition - tenant-local users - groups and memberships - roles and role assignments @@ -26,8 +39,26 @@ This module will own: - permission evaluation - access administration backend routes - access administration WebUI route contributions +- published FastAPI auth dependency API +- access administration, tenant provisioning, and governance materializer + capabilities - access-owned migrations +The governance-template routes under `/admin/system/governance-templates` are +contributed by `govoplan-admin`; access must not register those routes. + +`govoplan-access` depends on `govoplan-tenancy`; the registry loads tenancy +before access for authenticated platform composition. + +## WebUI Package + +The repository root and `webui/` directory both expose the package +`@govoplan/access-webui`. The package contributes the `/admin` route and admin +nav item through core's module WebUI contract. The route shell consumes +module-owned `admin.sections` contributions from installed platform modules and +continues to render access-owned tenant/user/group/role panels locally. Core +supplies shared admin components, route rendering, and icon resolution. + The kernel remains responsible for module discovery, route aggregation, database/session lifecycle, migration orchestration, capability registry, and stable contracts. diff --git a/package.json b/package.json new file mode 100644 index 0000000..d6822b6 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "@govoplan/access-webui", + "version": "0.1.5", + "private": true, + "type": "module", + "main": "webui/src/index.ts", + "module": "webui/src/index.ts", + "types": "webui/src/index.ts", + "exports": { + ".": { + "types": "./webui/src/index.ts", + "import": "./webui/src/index.ts" + } + }, + "files": [ + "webui/src", + "README.md", + "LICENSE" + ], + "peerDependencies": { + "@govoplan/core-webui": "^0.1.5", + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/pyproject.toml b/pyproject.toml index da85327..f2c8ed7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,15 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-access" -version = "0.1.4" +version = "0.1.5" description = "GovOPlaN access platform module with identity, auth, RBAC, and tenancy primitives." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.4", + "govoplan-core>=0.1.5", + "govoplan-tenancy>=0.1.5", "SQLAlchemy>=2,<3", ] diff --git a/src/govoplan_access/backend/admin/__init__.py b/src/govoplan_access/backend/admin/__init__.py new file mode 100644 index 0000000..942ef5c --- /dev/null +++ b/src/govoplan_access/backend/admin/__init__.py @@ -0,0 +1,2 @@ +"""Access-owned legacy admin helper services.""" + diff --git a/src/govoplan_access/backend/admin/governance.py b/src/govoplan_access/backend/admin/governance.py new file mode 100644 index 0000000..1b80fbe --- /dev/null +++ b/src/govoplan_access/backend/admin/governance.py @@ -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() diff --git a/src/govoplan_access/backend/admin/service.py b/src/govoplan_access/backend/admin/service.py new file mode 100644 index 0000000..3fb1a76 --- /dev/null +++ b/src/govoplan_access/backend/admin/service.py @@ -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 []) diff --git a/src/govoplan_access/backend/administration.py b/src/govoplan_access/backend/administration.py new file mode 100644 index 0000000..dae17fe --- /dev/null +++ b/src/govoplan_access/backend/administration.py @@ -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 diff --git a/src/govoplan_access/backend/api/__init__.py b/src/govoplan_access/backend/api/__init__.py new file mode 100644 index 0000000..ea43174 --- /dev/null +++ b/src/govoplan_access/backend/api/__init__.py @@ -0,0 +1,2 @@ +"""Access-owned backend API routes.""" + diff --git a/src/govoplan_access/backend/api/v1/__init__.py b/src/govoplan_access/backend/api/v1/__init__.py new file mode 100644 index 0000000..33157c9 --- /dev/null +++ b/src/govoplan_access/backend/api/v1/__init__.py @@ -0,0 +1,2 @@ +"""Access-owned API v1 routes.""" + diff --git a/src/govoplan_access/backend/api/v1/admin.py b/src/govoplan_access/backend/api/v1/admin.py new file mode 100644 index 0000000..3a0b1e4 --- /dev/null +++ b/src/govoplan_access/backend/api/v1/admin.py @@ -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"] diff --git a/src/govoplan_access/backend/api/v1/admin_common.py b/src/govoplan_access/backend/api/v1/admin_common.py new file mode 100644 index 0000000..0cf7fa6 --- /dev/null +++ b/src/govoplan_access/backend/api/v1/admin_common.py @@ -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) diff --git a/src/govoplan_access/backend/api/v1/admin_schemas.py b/src/govoplan_access/backend/api/v1/admin_schemas.py new file mode 100644 index 0000000..545fd24 --- /dev/null +++ b/src/govoplan_access/backend/api/v1/admin_schemas.py @@ -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 diff --git a/src/govoplan_access/backend/api/v1/auth.py b/src/govoplan_access/backend/api/v1/auth.py new file mode 100644 index 0000000..888090b --- /dev/null +++ b/src/govoplan_access/backend/api/v1/auth.py @@ -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} diff --git a/src/govoplan_access/backend/api/v1/routes.py b/src/govoplan_access/backend/api/v1/routes.py new file mode 100644 index 0000000..3e3962f --- /dev/null +++ b/src/govoplan_access/backend/api/v1/routes.py @@ -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) diff --git a/src/govoplan_access/backend/auth/dependencies.py b/src/govoplan_access/backend/auth/dependencies.py new file mode 100644 index 0000000..c12fe7a --- /dev/null +++ b/src/govoplan_access/backend/auth/dependencies.py @@ -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 diff --git a/src/govoplan_access/backend/auth/roles.py b/src/govoplan_access/backend/auth/roles.py index 20cc8d8..8025d3a 100644 --- a/src/govoplan_access/backend/auth/roles.py +++ b/src/govoplan_access/backend/auth/roles.py @@ -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]: diff --git a/src/govoplan_access/backend/db/base.py b/src/govoplan_access/backend/db/base.py index 2bfbd5f..c279daf 100644 --- a/src/govoplan_access/backend/db/base.py +++ b/src/govoplan_access/backend/db/base.py @@ -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"] diff --git a/src/govoplan_access/backend/db/models.py b/src/govoplan_access/backend/db/models.py index 31ede2d..f86eb63 100644 --- a/src/govoplan_access/backend/db/models.py +++ b/src/govoplan_access/backend/db/models.py @@ -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", +] diff --git a/src/govoplan_access/backend/directory.py b/src/govoplan_access/backend/directory.py new file mode 100644 index 0000000..7be6fd4 --- /dev/null +++ b/src/govoplan_access/backend/directory.py @@ -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 diff --git a/src/govoplan_access/backend/governance_materializer.py b/src/govoplan_access/backend/governance_materializer.py new file mode 100644 index 0000000..60877ce --- /dev/null +++ b/src/govoplan_access/backend/governance_materializer.py @@ -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 diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py index ccfc479..1b04e88 100644 --- a/src/govoplan_access/backend/manifest.py +++ b/src/govoplan_access/backend/manifest.py @@ -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, }, ) diff --git a/src/govoplan_access/backend/runtime.py b/src/govoplan_access/backend/runtime.py new file mode 100644 index 0000000..ebb5824 --- /dev/null +++ b/src/govoplan_access/backend/runtime.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from govoplan_core.core.runtime import configure_runtime, get_registry + +__all__ = ["configure_runtime", "get_registry"] diff --git a/src/govoplan_access/backend/security/__init__.py b/src/govoplan_access/backend/security/__init__.py new file mode 100644 index 0000000..6d507b1 --- /dev/null +++ b/src/govoplan_access/backend/security/__init__.py @@ -0,0 +1,2 @@ +"""Access-owned security helper services.""" + diff --git a/src/govoplan_access/backend/security/api_keys.py b/src/govoplan_access/backend/security/api_keys.py new file mode 100644 index 0000000..90a4623 --- /dev/null +++ b/src/govoplan_access/backend/security/api_keys.py @@ -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 + diff --git a/src/govoplan_access/backend/security/passwords.py b/src/govoplan_access/backend/security/passwords.py new file mode 100644 index 0000000..7d140f9 --- /dev/null +++ b/src/govoplan_access/backend/security/passwords.py @@ -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) + diff --git a/src/govoplan_access/backend/security/sessions.py b/src/govoplan_access/backend/security/sessions.py new file mode 100644 index 0000000..e4c738d --- /dev/null +++ b/src/govoplan_access/backend/security/sessions.py @@ -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() + ) + diff --git a/src/govoplan_access/backend/tenancy/provisioning.py b/src/govoplan_access/backend/tenancy/provisioning.py new file mode 100644 index 0000000..82bbbb4 --- /dev/null +++ b/src/govoplan_access/backend/tenancy/provisioning.py @@ -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 diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..9358432 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,30 @@ +{ + "name": "@govoplan/access-webui", + "version": "0.1.5", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.5", + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + }, + "devDependencies": { + "typescript": "^5.7.2" + } +} diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts new file mode 100644 index 0000000..af40293 --- /dev/null +++ b/webui/src/api/admin.ts @@ -0,0 +1,545 @@ +import type { ApiSettings } from "@govoplan/core-webui"; +import { apiFetch } from "@govoplan/core-webui"; + +export type PermissionItem = { + scope: string; + label: string; + description: string; + category: string; + level: "tenant" | "system"; + system_template_id?: string | null; + system_required?: boolean; +}; + +export type AdminOverview = { + active_tenant_id: string; + active_tenant_name: string; + tenant_count?: number | null; + system_account_count?: number | null; + system_group_template_count?: number | null; + system_role_template_count?: number | null; + user_count: number; + active_user_count: number; + group_count: number; + role_count: number; + active_api_key_count: number; + capabilities: string[]; +}; + +export type TenantAdminItem = { + id: string; + slug: string; + name: string; + description?: string | null; + default_locale: string; + settings: Record; + allow_custom_groups?: boolean | null; + allow_custom_roles?: boolean | null; + allow_api_keys?: boolean | null; + effective_governance: Record; + is_active: boolean; + counts: Record; + created_at: string; + updated_at: string; +}; + +export type TenantOwnerCandidate = { + account_id: string; + email: string; + display_name?: string | null; +}; + +export type RoleSummary = { + id: string; + slug: string; + name: string; + description?: string | null; + permissions: string[]; + effective_permission_count: number; + is_builtin: boolean; + is_assignable: boolean; + user_assignments: number; + group_assignments: number; + level: "tenant" | "system"; + system_template_id?: string | null; + system_required?: boolean; +}; + +export type GroupSummary = { + id: string; + slug: string; + name: string; + description?: string | null; + is_active: boolean; + member_count: number; + member_ids: string[]; + roles: RoleSummary[]; + created_at: string; + updated_at: string; + system_template_id?: string | null; + system_required?: boolean; +}; + +export type UserAdminItem = { + id: string; + account_id: string; + tenant_id: string; + email: string; + display_name?: string | null; + is_active: boolean; + account_is_active: boolean; + password_reset_required: boolean; + last_login_at?: string | null; + groups: GroupSummary[]; + roles: RoleSummary[]; + effective_scopes: string[]; + is_owner: boolean; + is_last_active_owner: boolean; + created_at: string; + updated_at: string; +}; + +export type SystemAccountItem = { + account_id: string; + email: string; + display_name?: string | null; + is_active: boolean; + memberships: Array<{ tenant_id: string; tenant_name: string; user_id: string; is_active: boolean; role_ids: string[]; group_ids: string[]; is_owner: boolean; is_last_active_owner: boolean }>; + roles: RoleSummary[]; + last_login_at?: string | null; +}; + + +export type SystemMembershipDraft = { + tenant_id: string; + is_active: boolean; + role_ids: string[]; + group_ids: string[]; + is_owner?: boolean; + is_last_active_owner?: boolean; +}; + +export type PrivacyRetentionPolicyFieldKey = + | "store_raw_campaign_json" + | "raw_campaign_json_retention_days" + | "generated_eml_retention_days" + | "stored_report_detail_retention_days" + | "mock_mailbox_retention_days" + | "audit_detail_retention_days" + | "audit_detail_level"; + +export type PrivacyRetentionLimitPermissions = Record; +export type PrivacyRetentionLimitPermissionPatch = Partial; + +export type PrivacyRetentionPolicy = { + store_raw_campaign_json: boolean; + raw_campaign_json_retention_days?: number | null; + generated_eml_retention_days?: number | null; + stored_report_detail_retention_days?: number | null; + mock_mailbox_retention_days?: number | null; + audit_detail_retention_days?: number | null; + audit_detail_level: "full" | "redacted" | "minimal"; + allow_lower_level_limits: PrivacyRetentionLimitPermissions; +}; + +export type PrivacyRetentionPolicyPatch = Partial> & { + allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch; +}; +export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign"; + +export type PolicySourceStep = { + scope_type: string; + scope_id?: string | null; + label: string; + applied_fields?: string[]; + policy?: PrivacyRetentionPolicyPatch | PrivacyRetentionPolicy | null; +}; + +export type PrivacyRetentionPolicyScopeResponse = { + scope_type: PrivacyRetentionPolicyScope; + scope_id?: string | null; + policy: PrivacyRetentionPolicyPatch; + effective_policy: PrivacyRetentionPolicy; + parent_policy?: PrivacyRetentionPolicy | null; + effective_policy_sources?: PolicySourceStep[]; + parent_policy_sources?: PolicySourceStep[]; +}; + +export type SystemSettingsItem = { + default_locale: string; + allow_tenant_custom_groups: boolean; + allow_tenant_custom_roles: boolean; + allow_tenant_api_keys: boolean; + privacy_retention_policy: PrivacyRetentionPolicy; + settings: Record; +}; + +export type TenantSettingsItem = { + id: string; + slug: string; + name: string; + default_locale: string; + settings: Record; +}; + +export type RetentionRunResponse = { + result: { + dry_run: boolean; + policy: PrivacyRetentionPolicy; + cutoffs: Record; + effective_policy_scope?: string; + counts: Record>; + }; +}; + +export type GovernanceAssignment = { + tenant_id: string; + mode: "available" | "required"; +}; + +export type GovernanceTemplateItem = { + id: string; + kind: "group" | "role"; + slug: string; + name: string; + description?: string | null; + permissions: string[]; + effective_permission_count: number; + is_active: boolean; + assignments: GovernanceAssignment[]; + created_at: string; + updated_at: string; +}; + +export type ApiKeyAdminItem = { + id: string; + user_id: string; + user_email: string; + name: string; + prefix: string; + scopes: string[]; + expires_at?: string | null; + last_used_at?: string | null; + revoked_at?: string | null; + created_at: string; +}; + +export type AuditAdminItem = { + id: string; + scope: "tenant" | "system"; + tenant_id?: string | null; + actor_email?: string | null; + action: string; + object_type?: string | null; + object_id?: string | null; + details: Record; + created_at: string; +}; + + + +export function fetchAdminOverview(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/overview"); +} + +export async function fetchPermissionCatalog(settings: ApiSettings): Promise { + const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions"); + return response.permissions; +} + +export async function fetchTenants(settings: ApiSettings): Promise { + const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants"); + return response.tenants; +} + +export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise { + const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates"); + return response.accounts; +} + +export function createTenant(settings: ApiSettings, payload: { + slug: string; + name: string; + owner_account_id?: string | null; + description?: string | null; + default_locale?: string; + settings?: Record; + allow_custom_groups?: boolean | null; + allow_custom_roles?: boolean | null; + allow_api_keys?: boolean | null; +}): Promise { + return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{ + name: string; + description: string | null; + default_locale: string; + settings: Record; + allow_custom_groups?: boolean | null; + allow_custom_roles?: boolean | null; + allow_api_keys?: boolean | null; + effective_governance: Record; + is_active: boolean; +}>): Promise { + return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function fetchTenantSettings(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/tenant/settings"); +} + +export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string }): Promise { + return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) }); +} + +export async function fetchUsers(settings: ApiSettings): Promise { + const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users"); + return response.users; +} + +export function createUser(settings: ApiSettings, payload: { + email: string; + display_name?: string | null; + password?: string | null; + password_reset_required?: boolean; + is_active?: boolean; + group_ids?: string[]; + role_ids?: string[]; +}): Promise<{ user: UserAdminItem; account_created: boolean; temporary_password?: string | null }> { + return apiFetch(settings, "/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateUser(settings: ApiSettings, userId: string, payload: Partial<{ + display_name: string | null; + is_active: boolean; + group_ids: string[]; + role_ids: string[]; +}>): Promise { + return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export async function fetchGroups(settings: ApiSettings): Promise { + const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups"); + return response.groups; +} + +export function createGroup(settings: ApiSettings, payload: { + slug: string; + name: string; + description?: string | null; + is_active?: boolean; + member_ids?: string[]; + role_ids?: string[]; +}): Promise { + return apiFetch(settings, "/api/v1/admin/groups", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateGroup(settings: ApiSettings, groupId: string, payload: Partial<{ + name: string; + description: string | null; + is_active: boolean; + member_ids: string[]; + role_ids: string[]; +}>): Promise { + return apiFetch(settings, `/api/v1/admin/groups/${groupId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export async function fetchRoles(settings: ApiSettings): Promise { + const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles"); + return response.roles; +} + +export function createRole(settings: ApiSettings, payload: { + slug: string; + name: string; + description?: string | null; + permissions: string[]; +}): Promise { + return apiFetch(settings, "/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateRole(settings: ApiSettings, roleId: string, payload: { + name: string; + description?: string | null; + permissions: string[]; + is_assignable: boolean; +}): Promise { + return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function deleteRole(settings: ApiSettings, roleId: string): Promise { + return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" }); +} + +export async function fetchSystemRoles(settings: ApiSettings): Promise { + const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles"); + return response.roles; +} + +export function createSystemRole(settings: ApiSettings, payload: { + slug: string; + name: string; + description?: string | null; + permissions: string[]; +}): Promise { + return apiFetch(settings, "/api/v1/admin/system/roles", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateSystemRole(settings: ApiSettings, roleId: string, payload: { + name: string; + description?: string | null; + permissions: string[]; + is_assignable: boolean; +}): Promise { + return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function deleteSystemRole(settings: ApiSettings, roleId: string): Promise { + return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "DELETE" }); +} + +export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ accounts: SystemAccountItem[]; roles: RoleSummary[] }> { + return apiFetch(settings, "/api/v1/admin/system/accounts"); +} + +export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: { + display_name?: string | null; + is_active?: boolean; + role_ids?: string[]; +}): Promise { + return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function updateSystemAccountRoles(settings: ApiSettings, accountId: string, roleIds: string[]): Promise { + return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/roles`, { + method: "PUT", + body: JSON.stringify({ role_ids: roleIds }) + }); +} + +export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise { + const params = new URLSearchParams(); + if (includeRevoked) params.set("include_revoked", "true"); + const suffix = params.toString() ? `?${params.toString()}` : ""; + const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`); + return response.api_keys; +} + +export function createApiKey(settings: ApiSettings, payload: { + name: string; + user_id?: string | null; + scopes: string[]; + expires_at?: string | null; +}): Promise { + return apiFetch(settings, "/api/v1/admin/api-keys", { method: "POST", body: JSON.stringify(payload) }); +} + +export function revokeApiKey(settings: ApiSettings, keyId: string): Promise { + return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" }); +} + +export type AuditQueryOptions = { + tenantId?: string | null; + allTenants?: boolean; + scope?: "tenant" | "system"; + limit?: number; + offset?: number; + page?: number; + pageSize?: number; + sortBy?: "time" | "actor" | "action" | "object" | "tenant"; + sortDirection?: "asc" | "desc"; + filters?: Partial>; +}; + +export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> { + const params = new URLSearchParams(); + if (options.tenantId) params.set("tenant_id", options.tenantId); + if (options.allTenants) params.set("all_tenants", "true"); + if (options.scope) params.set("scope", options.scope); + if (options.limit) params.set("limit", String(options.limit)); + if (options.offset) params.set("offset", String(options.offset)); + if (options.page) params.set("page", String(options.page)); + if (options.pageSize) params.set("page_size", String(options.pageSize)); + if (options.sortBy) params.set("sort_by", options.sortBy); + if (options.sortDirection) params.set("sort_direction", options.sortDirection); + for (const [column, value] of Object.entries(options.filters ?? {})) { + if (value?.trim()) params.set(`filter_${column}`, value); + } + const suffix = params.toString() ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/admin/audit${suffix}`); +} + + +export function createSystemAccount(settings: ApiSettings, payload: { + email: string; + display_name?: string | null; + password?: string | null; + password_reset_required?: boolean; + is_active?: boolean; + role_ids?: string[]; + memberships?: SystemMembershipDraft[]; +}): Promise<{ account: SystemAccountItem; temporary_password?: string | null }> { + return apiFetch(settings, "/api/v1/admin/system/accounts", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateSystemMemberships(settings: ApiSettings, accountId: string, memberships: SystemMembershipDraft[]): Promise { + return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/memberships`, { + method: "PUT", body: JSON.stringify({ memberships }) + }); +} + +export function fetchSystemSettings(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/settings"); +} + +export type SystemSettingsUpdatePayload = { + default_locale: string; + allow_tenant_custom_groups: boolean; + allow_tenant_custom_roles: boolean; + allow_tenant_api_keys: boolean; + privacy_retention_policy?: PrivacyRetentionPolicy | null; +}; + +export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise { + return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise { + const params = new URLSearchParams(); + if (scopeId) params.set("scope_id", scopeId); + const suffix = params.toString() ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`); +} + +export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null): Promise { + const params = new URLSearchParams(); + if (scopeId) params.set("scope_id", scopeId); + const suffix = params.toString() ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy }) }); +} + +export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise { + return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) }); +} + +export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise { + const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; + const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`); + return response.templates; +} + +export function createGovernanceTemplate(settings: ApiSettings, payload: Omit): Promise { + return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit): Promise { + return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise { + return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" }); +} diff --git a/webui/src/features/admin/AdminAuditPanel.tsx b/webui/src/features/admin/AdminAuditPanel.tsx new file mode 100644 index 0000000..6891a6d --- /dev/null +++ b/webui/src/features/admin/AdminAuditPanel.tsx @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Search } from "lucide-react"; +import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; +import { fetchAdminAudit, type AuditAdminItem } from "../../api/admin"; +import { Button } from "@govoplan/core-webui"; +import { DataGrid, type DataGridColumn, type DataGridQueryState } from "@govoplan/core-webui"; +import { Dialog } from "@govoplan/core-webui"; +import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui"; + +const DEFAULT_QUERY: DataGridQueryState = { + sort: { columnId: "time", direction: "desc" }, + filters: {} +}; + +export default function AdminAuditPanel({ + settings, + auth, + systemMode = false +}: { + settings: ApiSettings; + auth: AuthInfo; + systemMode?: boolean; +}) { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [query, setQuery] = useState(DEFAULT_QUERY); + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [reloadToken, setReloadToken] = useState(0); + const tenantId = (auth.active_tenant ?? auth.tenant).id; + + const load = useCallback(async () => { + setLoading(true); + setError(""); + try { + const sortColumn = query.sort?.columnId; + const response = await fetchAdminAudit(settings, { + scope: systemMode ? "system" : "tenant", + page, + pageSize, + sortBy: sortColumn && ["time", "actor", "action", "object", "tenant"].includes(sortColumn) + ? sortColumn as "time" | "actor" | "action" | "object" | "tenant" + : "time", + sortDirection: query.sort?.direction ?? "desc", + filters: query.filters + }); + setItems(response.items); + setTotal(response.total); + if (response.page !== page) setPage(response.page); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setLoading(false); + } + }, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, page, pageSize, query, reloadToken]); + + useEffect(() => { void load(); }, [load]); + + const handleQueryChange = useCallback((next: DataGridQueryState) => { + setQuery((current) => { + if (JSON.stringify(current) === JSON.stringify(next)) return current; + setPage(1); + return next; + }); + }, []); + + const columns = useMemo[]>(() => [ + { id: "time", header: "Time", width: 190, minWidth: 150, maxWidth: 260, resizable: true, sticky: "start", sortable: true, filterable: true, filterType: "date", value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) }, + { id: "actor", header: "Actor", width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "System" }, + { id: "action", header: "Action", width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action }, + { id: "object", header: "Object", width: 300, minWidth: 180, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => `${row.object_type || "—"} ${row.object_id || ""}`.trim() }, + ...(systemMode ? [{ id: "tenant", header: "Tenant context", width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "—" }] : []), + { id: "actions", header: "Actions", width: 70, sticky: "end", resizable: false, align: "right", render: (row) =>
} onClick={() => setSelected(row)} />
} + ], [systemMode]); + + const firstShown = total === 0 ? 0 : (page - 1) * pageSize + 1; + const lastShown = Math.min(total, page * pageSize); + + return ( + <> + setReloadToken((value) => value + 1)} disabled={loading}>Reload} + > +
+ row.id} + emptyText="No administrative audit records found." + className="admin-audit-grid" + initialSort={{ columnId: "time", direction: "desc" }} + pagination={{ + mode: "server", + page, + pageSize, + totalRows: total, + pageSizeOptions: [10, 25, 50, 100, 250], + disabled: loading, + onPageChange: setPage, + onPageSizeChange: (next) => { setPageSize(next); setPage(1); } + }} + onQueryChange={handleQueryChange} + /> +
+
+ setSelected(null)} className="admin-dialog admin-dialog-wide" footer={}> + {selected && <>
Scope
{selected.scope}
Action
{selected.action}
Actor
{selected.actor_email || "System"}
Object
{selected.object_type || "—"} {selected.object_id || ""}
Tenant context
{selected.tenant_id || "—"}
Time
{formatDateTime(selected.created_at)}
{JSON.stringify(selected.details, null, 2)}
} +
+ + ); +} diff --git a/webui/src/features/admin/AdminPage.tsx b/webui/src/features/admin/AdminPage.tsx new file mode 100644 index 0000000..078063e --- /dev/null +++ b/webui/src/features/admin/AdminPage.tsx @@ -0,0 +1,221 @@ +import { useEffect, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; +import type { AdminSectionContribution, AdminSectionsUiCapability, ApiSettings, AuthInfo, MailProfilesUiCapability } from "@govoplan/core-webui"; +import { fetchMe } from "@govoplan/core-webui"; +import { Card } from "@govoplan/core-webui"; +import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui"; +import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui"; +import SystemUsersPanel from "./SystemUsersPanel"; +import TenantSettingsPanel from "./TenantSettingsPanel"; +import SystemRolesPanel from "./SystemRolesPanel"; +import TenantsPanel from "./TenantsPanel"; +import UsersPanel from "./UsersPanel"; +import GroupsPanel from "./GroupsPanel"; +import RolesPanel from "./RolesPanel"; +import ApiKeysPanel from "./ApiKeysPanel"; +import AdminAuditPanel from "./AdminAuditPanel"; +import MailProfilesPanel from "./MailProfilesPanel"; +import RetentionPoliciesPanel from "./RetentionPoliciesPanel"; +import { usePlatformModuleInstalled, usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui"; + +type AdminSection = string; +type OrderedAdminNavItem = { id: AdminSection; label: string; order: number }; + +export default function AdminPage({ + settings, + auth, + onAuthChange +}: { + settings: ApiSettings; + auth: AuthInfo; + onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void; +}) { + const mailProfilesUi = usePlatformUiCapability("mail.profiles"); + const adminSectionCapabilities = usePlatformUiCapabilities("admin.sections"); + const mailProfilesAvailable = Boolean(mailProfilesUi); + const auditAvailable = usePlatformModuleInstalled("audit"); + const policyAvailable = usePlatformModuleInstalled("policy"); + const tenancyAvailable = usePlatformModuleInstalled("tenancy"); + const contributedSections = useMemo(() => ( + adminSectionCapabilities + .flatMap((capability) => capability.sections) + .sort((left, right) => (left.order ?? 100) - (right.order ?? 100)) + ), [adminSectionCapabilities]); + const contributionById = useMemo(() => { + const mapped = new Map(); + for (const section of contributedSections) { + if (!mapped.has(section.id)) mapped.set(section.id, section); + } + return mapped; + }, [contributedSections]); + + const available = useMemo(() => { + const sections = new Set(); + for (const section of contributedSections) { + if (canUseContributedSection(auth, section)) sections.add(section.id); + } + if (hasScope(auth, "system:settings:read")) { + if (policyAvailable) sections.add("system-retention"); + if (mailProfilesAvailable) sections.add("system-mail-servers"); + } + if (tenancyAvailable && hasScope(auth, "system:tenants:read")) sections.add("system-tenants"); + if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users"); + if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles"); + if (auditAvailable && hasScope(auth, "system:audit:read")) sections.add("system-audit"); + if (hasScope(auth, "admin:users:read")) sections.add("tenant-users"); + if (hasScope(auth, "admin:groups:read")) sections.add("tenant-groups"); + if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles"); + if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys"); + if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) { + sections.add("tenant-mail-servers"); + if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-mail-servers"); + if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-mail-servers"); + } + if (policyAvailable && hasScope(auth, "admin:policies:read")) { + sections.add("tenant-retention"); + if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-retention"); + if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-retention"); + } + if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings"); + if (auditAvailable && hasScope(auth, "audit:read")) sections.add("tenant-audit"); + return sections; + }, [auth, auditAvailable, contributedSections, mailProfilesAvailable, policyAvailable, tenancyAvailable]); + const [searchParams, setSearchParams] = useSearchParams(); + const requestedSection = searchParams.get("section") as AdminSection | null; + const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview"); + const active: AdminSection = requestedSection && available.has(requestedSection) ? requestedSection : fallbackSection; + + function selectSection(section: AdminSection) { + const next = new URLSearchParams(searchParams); + if (section === "overview") next.delete("section"); + else next.set("section", section); + setSearchParams(next, { replace: true }); + } + + useEffect(() => { + if (requestedSection && !available.has(requestedSection)) selectSection(fallbackSection); + }, [requestedSection, available, fallbackSection]); + + async function refreshAuth() { + onAuthChange(await fetchMe(settings)); + } + + useEffect(() => { + void refreshAuth().catch(() => undefined); + }, [settings.accessToken, settings.apiBaseUrl]); + + if (!hasAnyScope(auth, adminReadScopes)) { + return

Your current roles do not grant administrative access.

; + } + + const rootItems = contributedNavItems(contributedSections, available, "ROOT"); + const adminSubnav: ModuleSubnavGroup[] = [ + { items: asSubnavItems(rootItems) }, + { + title: "SYSTEM", + items: asSubnavItems(sortNavItems([ + ...contributedNavItems(contributedSections, available, "SYSTEM"), + visibleNavItem(available, "system-tenants", "Tenants", 20), + visibleNavItem(available, "system-roles", "System roles", 30), + visibleNavItem(available, "system-users", "Users", 60), + visibleNavItem(available, "system-mail-servers", "Mail servers", 70), + visibleNavItem(available, "system-retention", "Retention", 80), + visibleNavItem(available, "system-audit", "Audit", 90) + ])) + }, + { + title: "TENANT", + items: [ + ...(available.has("tenant-settings") ? [{ id: "tenant-settings" as const, label: "General" }] : []), + ...(available.has("tenant-roles") ? [{ id: "tenant-roles" as const, label: "Roles" }] : []), + ...(available.has("tenant-groups") ? [{ id: "tenant-groups" as const, label: "Groups" }] : []), + ...(available.has("tenant-users") ? [{ id: "tenant-users" as const, label: "Users" }] : []), + ...(available.has("tenant-mail-servers") ? [{ id: "tenant-mail-servers" as const, label: "Mail servers" }] : []), + ...(available.has("tenant-retention") ? [{ id: "tenant-retention" as const, label: "Retention" }] : []), + ...(available.has("tenant-api-keys") ? [{ id: "tenant-api-keys" as const, label: "API keys" }] : []), + ...(available.has("tenant-audit") ? [{ id: "tenant-audit" as const, label: "Audit" }] : []) + ] + }, + { + title: "GROUP", + items: [ + ...(available.has("tenant-group-mail-servers") ? [{ id: "tenant-group-mail-servers" as const, label: "Mail servers" }] : []), + ...(available.has("tenant-group-retention") ? [{ id: "tenant-group-retention" as const, label: "Retention" }] : []), + ] + }, + { + title: "USER", + items: [ + ...(available.has("tenant-user-mail-servers") ? [{ id: "tenant-user-mail-servers" as const, label: "Mail servers" }] : []), + ...(available.has("tenant-user-retention") ? [{ id: "tenant-user-retention" as const, label: "Retention" }] : []), + ] + } + ].filter((group) => group.items.length > 0); + const contributedSection = contributionById.get(active); + const contributionContext = { settings, auth, onAuthChange, refreshAuth, availableSections: available, selectSection }; + + return ( +
+ +
+
+ {contributedSection && contributedSection.render(contributionContext)} + {!contributedSection && active === "system-retention" && } + {!contributedSection && active === "system-mail-servers" && } + {!contributedSection && active === "system-tenants" && } + {!contributedSection && active === "system-users" && } + {!contributedSection && active === "system-roles" && } + {!contributedSection && active === "system-audit" && } + {!contributedSection && active === "tenant-users" && } + {!contributedSection && active === "tenant-groups" && } + {!contributedSection && active === "tenant-roles" && } + {!contributedSection && active === "tenant-api-keys" && } + {!contributedSection && active === "tenant-mail-servers" && } + {!contributedSection && active === "tenant-user-mail-servers" && } + {!contributedSection && active === "tenant-group-mail-servers" && } + {!contributedSection && active === "tenant-retention" && } + {!contributedSection && active === "tenant-user-retention" && } + {!contributedSection && active === "tenant-group-retention" && } + {!contributedSection && active === "tenant-settings" && } + {!contributedSection && active === "tenant-audit" && } +
+
+
+ ); +} + +function canUseContributedSection(auth: AuthInfo, section: AdminSectionContribution): boolean { + if (section.allOf?.length && !section.allOf.every((scope) => hasScope(auth, scope))) return false; + if (section.anyOf?.length && !hasAnyScope(auth, section.anyOf)) return false; + return true; +} + +function contributedNavItems(sections: AdminSectionContribution[], available: ReadonlySet, group: string): OrderedAdminNavItem[] { + return sections + .filter((section) => (section.group ?? "SYSTEM") === group && available.has(section.id)) + .map((section) => ({ id: section.id, label: section.label, order: section.order ?? 100 })); +} + +function visibleNavItem(available: ReadonlySet, id: AdminSection, label: string, order: number): OrderedAdminNavItem | null { + return available.has(id) ? { id, label, order } : null; +} + +function sortNavItems(items: Array): OrderedAdminNavItem[] { + return items.filter((item): item is OrderedAdminNavItem => item !== null).sort((left, right) => left.order - right.order); +} + +function asSubnavItems(items: OrderedAdminNavItem[]) { + return items.map(({ id, label }) => ({ id, label })); +} diff --git a/webui/src/features/admin/ApiKeysPanel.tsx b/webui/src/features/admin/ApiKeysPanel.tsx new file mode 100644 index 0000000..e029494 --- /dev/null +++ b/webui/src/features/admin/ApiKeysPanel.tsx @@ -0,0 +1,124 @@ +import { useEffect, useMemo, useState } from "react"; +import { Pencil, Plus, Search, Trash2 } from "lucide-react"; +import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; +import { createApiKey, fetchApiKeys, fetchPermissionCatalog, fetchUsers, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin"; +import { Button } from "@govoplan/core-webui"; +import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; +import { Dialog } from "@govoplan/core-webui"; +import { FormField } from "@govoplan/core-webui"; +import { StatusBadge } from "@govoplan/core-webui"; +import { ConfirmDialog } from "@govoplan/core-webui"; +import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui"; +import { scopeGrants } from "@govoplan/core-webui"; + +export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: { settings: ApiSettings; auth: AuthInfo; canCreate: boolean; canRevoke: boolean }) { + const [keys, setKeys] = useState([]); + const [users, setUsers] = useState([]); + const [permissions, setPermissions] = useState([]); + const [showRevoked, setShowRevoked] = useState(false); + const [creating, setCreating] = useState(false); + const [viewing, setViewing] = useState(null); + const [draft, setDraft] = useState({ name: "", userId: auth.user.id, scopes: ["campaign:read"], expiresAt: "" }); + const [secret, setSecret] = useState<{ name: string; value: string } | null>(null); + const [revoking, setRevoking] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + async function load() { + setLoading(true); + setError(""); + try { + const [nextKeys, nextUsers, nextPermissions] = await Promise.all([fetchApiKeys(settings, showRevoked), fetchUsers(settings), fetchPermissionCatalog(settings)]); + setKeys(nextKeys); + setUsers(nextUsers.filter((user) => user.is_active && user.account_is_active)); + setPermissions(nextPermissions.filter((permission) => permission.level === "tenant")); + } catch (err) { setError(adminErrorMessage(err)); } + finally { setLoading(false); } + } + + useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, showRevoked]); + + const selectedUser = users.find((user) => user.id === draft.userId); + const allowedPermissions = permissions.filter((permission) => selectedUser?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))); + + const columns = useMemo[]>(() => [ + { id: "name", header: "Name", width: "minmax(190px, 1fr)", minWidth: 170, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => row.name, render: (row) =>
{row.name}
{row.prefix}…
}, + { id: "owner", header: "Owner", width: 250, minWidth: 180, maxWidth: 480, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.user_email }, + { id: "scopes", header: "Scopes", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.scopes.length, render: (row) => String(row.scopes.length) }, + { id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => }, + { id: "last_used", header: "Last used", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) }, + { id: "expires", header: "Expires", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "No expiry" }, + { id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) =>
+ } onClick={() => setViewing(row)} /> + } disabled /> + } variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} /> +
} + ], [canRevoke]); + + function openCreate() { + const defaultUser = users.find((user) => user.id === auth.user.id) ?? users[0]; + setDraft({ name: "", userId: defaultUser?.id || "", scopes: ["campaign:read"], expiresAt: "" }); + setCreating(true); + setError(""); + } + + async function save() { + setBusy(true); + setError(""); + try { + const created = await createApiKey(settings, { name: draft.name, user_id: draft.userId, scopes: draft.scopes, expires_at: draft.expiresAt ? new Date(draft.expiresAt).toISOString() : null }); + setSecret({ name: created.name, value: created.secret }); + setCreating(false); + setSuccess(`API key ${created.name} created.`); + await load(); + } catch (err) { setError(adminErrorMessage(err)); } + finally { setBusy(false); } + } + + async function revoke() { + if (!revoking) return; + setBusy(true); + setError(""); + try { + await revokeApiKey(settings, revoking.id); + setSuccess(`API key ${revoking.name} revoked.`); + setRevoking(null); + await load(); + } catch (err) { setError(adminErrorMessage(err)); } + finally { setBusy(false); } + } + + return ( + <> + } variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} />}> +
row.id} emptyText="No API keys found." />
+
+ + !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<>}> +
+ setDraft({ ...draft, name: event.target.value })} /> + + setDraft({ ...draft, expiresAt: event.target.value })} /> +
+
Allowed scopes ({ id: permission.scope, label: permission.label, description: `${permission.scope} — ${permission.description}` }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="The selected user has no tenant permissions available to an API key." />
+
+ + setViewing(null)} className="admin-dialog admin-dialog-wide" footer={}> + {viewing && <>
+
Name
{viewing.name}
Prefix
{viewing.prefix}…
+
Owner
{viewing.user_email}
Status
{viewing.revoked_at ? "Revoked" : "Active"}
+
Created
{formatDateTime(viewing.created_at)}
Last used
{formatDateTime(viewing.last_used_at)}
+
Expires
{viewing.expires_at ? formatDateTime(viewing.expires_at) : "No expiry"}
Revoked
{formatDateTime(viewing.revoked_at)}
+

Scopes

{viewing.scopes.map((scope) => {scope})}
} +
+ + setSecret(null)} className="admin-dialog" footer={}> + {secret && <>

The secret for {secret.name} is shown once.

{secret.value}

Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.

} +
+ + setRevoking(null)} onConfirm={() => void revoke()} /> + + ); +} diff --git a/webui/src/features/admin/GroupsPanel.tsx b/webui/src/features/admin/GroupsPanel.tsx new file mode 100644 index 0000000..e17d10e --- /dev/null +++ b/webui/src/features/admin/GroupsPanel.tsx @@ -0,0 +1,139 @@ +import { useEffect, useMemo, useState } from "react"; +import { Pencil, Plus, Search, Trash2 } from "lucide-react"; +import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; +import { createGroup, fetchGroups, fetchRoles, fetchUsers, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin"; +import { Button } from "@govoplan/core-webui"; +import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; +import { Dialog } from "@govoplan/core-webui"; +import { FormField } from "@govoplan/core-webui"; +import { StatusBadge } from "@govoplan/core-webui"; +import { ConfirmDialog } from "@govoplan/core-webui"; +import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui"; + +const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] }; + +export default function GroupsPanel({ settings, auth, canDefine, canManageMembers, canAssignRoles, onAuthRefresh }: { + settings: ApiSettings; + auth: AuthInfo; + canDefine: boolean; + canManageMembers: boolean; + canAssignRoles: boolean; + onAuthRefresh: () => Promise; +}) { + const [groups, setGroups] = useState([]); + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [editing, setEditing] = useState(null); + const [viewing, setViewing] = useState(null); + const [deactivating, setDeactivating] = useState(null); + const [draft, setDraft] = useState(emptyDraft); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + async function load() { + setLoading(true); + setError(""); + try { + const [nextGroups, nextUsers, nextRoles] = await Promise.all([fetchGroups(settings), fetchUsers(settings), fetchRoles(settings)]); + setGroups(nextGroups); + setUsers(nextUsers); + setRoles(nextRoles.filter((role) => role.is_assignable)); + } catch (err) { setError(adminErrorMessage(err)); } + finally { setLoading(false); } + } + + useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]); + + function openCreate() { setDraft(emptyDraft); setEditing("new"); setError(""); } + function openEdit(group: GroupSummary) { + setDraft({ slug: group.slug, name: group.name, description: group.description || "", isActive: group.is_active, memberIds: group.member_ids, roleIds: group.roles.map((role) => role.id) }); + setEditing(group); + setError(""); + } + + async function save() { + setBusy(true); + setError(""); + try { + if (editing === "new") { + await createGroup(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, is_active: draft.isActive, member_ids: canManageMembers ? draft.memberIds : [], role_ids: canAssignRoles ? draft.roleIds : [] }); + setSuccess(`Group ${draft.name} created.`); + } else if (editing) { + const managed = Boolean(editing.system_template_id); + await updateGroup(settings, editing.id, { + ...(canDefine && !managed ? { name: draft.name, description: draft.description || null } : {}), + ...(canDefine && !editing.system_required ? { is_active: draft.isActive } : {}), + ...(canManageMembers ? { member_ids: draft.memberIds } : {}), + ...(canAssignRoles ? { role_ids: draft.roleIds } : {}) + }); + setSuccess(`Group ${draft.name} updated.`); + } + setEditing(null); + await onAuthRefresh(); + await load(); + } catch (err) { setError(adminErrorMessage(err)); } + finally { setBusy(false); } + } + + async function deactivate() { + if (!deactivating) return; + setBusy(true); + setError(""); + try { + await updateGroup(settings, deactivating.id, { is_active: false }); + setSuccess(`Group ${deactivating.name} deactivated.`); + setDeactivating(null); + if (deactivating.member_ids.includes(auth.user.id)) await onAuthRefresh(); + await load(); + } catch (err) { setError(adminErrorMessage(err)); } + finally { setBusy(false); } + } + + const columns = useMemo[]>(() => [ + { id: "group", header: "Group", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) =>
{row.name}
{row.slug} {row.system_template_id && System{row.system_required ? " · required" : ""}}
}, + { id: "members", header: "Members", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count }, + { id: "roles", header: "Inherited roles", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) }, + { id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => }, + { id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) =>
+ } onClick={() => setViewing(row)} /> + } onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} /> + } variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} /> +
} + ], [canAssignRoles, canDefine, canManageMembers]); + + return ( + <> + } variant="primary" onClick={openCreate} disabled={!canDefine} />}> +
row.id} emptyText="No groups found." />
+
+ + !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<>}> + {editing !== "new" && editing?.system_template_id &&

This group definition is managed by the system. Name, description and required availability are read-only here; membership and inherited roles remain tenant-specific.

} +
+ setDraft({ ...draft, name: event.target.value })} /> + setDraft({ ...draft, slug: event.target.value })} /> + +