From 30c11a6dcfc97e34b8cd8cfac9630d02ece0d29d Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 24 Jun 2026 01:43:10 +0200 Subject: [PATCH] initial commit after split --- .gitignore | 138 + README.md | 30 + alembic.ini | 38 + alembic/env.py | 61 + alembic/script.py.mako | 24 + ...1f8d4c2a0b7e_editable_campaign_versions.py | 58 + .../2c3d4e5f6a7b_auth_sessions_and_rbac.py | 130 + .../3d4e5f6a7b8c_file_storage_backend.py | 152 + alembic/versions/4e5f6a7b8c9d_file_folders.py | 45 + ...f6a7b8c9d0e_campaign_version_user_locks.py | 55 + .../6a7b8c9d0e1f_file_folder_uniqueness.py | 73 + .../7b8c9d0e1f2a_safe_delivery_execution.py | 64 + ...8c9d0e1f2a3b_accounts_tenant_admin_rbac.py | 323 ++ ...d0e1f2a3b4c_system_governance_templates.py | 163 + ...2d3e4f5_permission_catalogue_refinement.py | 216 ++ ..._audit_scope_and_tenant_owner_selection.py | 69 + ...b57c5b216bce_initial_persistence_models.py | 346 ++ .../c2d3e4f5a6b7_audit_pagination_indexes.py | 42 + ...4f5a6b7c8_mail_profiles_and_cookie_csrf.py | 73 + ...5a6b7c8d9_mail_profile_policy_hierarchy.py | 88 + .../f5a6b7c8d9e0_hierarchical_settings.py | 41 + pyproject.toml | 39 + requirements-dev.txt | 5 + requirements.txt | 1 + src/govoplan_core/__init__.py | 3 + src/govoplan_core/access/__init__.py | 2 + src/govoplan_core/access/auth/__init__.py | 2 + src/govoplan_core/access/auth/principals.py | 28 + src/govoplan_core/access/auth/roles.py | 82 + src/govoplan_core/access/auth/tokens.py | 21 + src/govoplan_core/access/db/__init__.py | 2 + src/govoplan_core/access/db/base.py | 21 + src/govoplan_core/access/db/models.py | 237 ++ src/govoplan_core/access/db/session.py | 22 + src/govoplan_core/access/manifest.py | 119 + .../access/permissions/__init__.py | 2 + .../access/permissions/definitions.py | 6 + .../access/permissions/evaluator.py | 52 + .../access/permissions/registry.py | 38 + src/govoplan_core/access/tenancy/__init__.py | 2 + src/govoplan_core/access/tenancy/datastore.py | 54 + src/govoplan_core/admin/__init__.py | 0 src/govoplan_core/admin/governance.py | 313 ++ src/govoplan_core/admin/service.py | 588 ++++ src/govoplan_core/api/__init__.py | 1 + src/govoplan_core/api/v1/__init__.py | 1 + src/govoplan_core/api/v1/admin.py | 1642 ++++++++++ src/govoplan_core/api/v1/admin_schemas.py | 486 +++ src/govoplan_core/api/v1/audit.py | 32 + src/govoplan_core/api/v1/auth.py | 293 ++ src/govoplan_core/api/v1/schemas.py | 111 + src/govoplan_core/api/v1/system.py | 29 + src/govoplan_core/audit/__init__.py | 0 src/govoplan_core/audit/logging.py | 99 + src/govoplan_core/auth/__init__.py | 0 src/govoplan_core/auth/dependencies.py | 223 ++ src/govoplan_core/celery_app.py | 63 + src/govoplan_core/commands/__init__.py | 1 + src/govoplan_core/commands/init_db.py | 36 + src/govoplan_core/core/__init__.py | 2 + src/govoplan_core/core/discovery.py | 42 + src/govoplan_core/core/events.py | 33 + src/govoplan_core/core/migrations.py | 29 + src/govoplan_core/core/modules.py | 125 + src/govoplan_core/core/registry.py | 155 + src/govoplan_core/db/__init__.py | 14 + src/govoplan_core/db/base.py | 39 + src/govoplan_core/db/bootstrap.py | 120 + src/govoplan_core/db/migrate.py | 14 + src/govoplan_core/db/migrations.py | 187 ++ src/govoplan_core/db/models.py | 271 ++ src/govoplan_core/db/session.py | 66 + src/govoplan_core/privacy/__init__.py | 0 src/govoplan_core/privacy/retention.py | 664 ++++ src/govoplan_core/py.typed | 0 src/govoplan_core/security/__init__.py | 0 src/govoplan_core/security/api_keys.py | 80 + .../security/module_permissions.py | 68 + src/govoplan_core/security/passwords.py | 37 + src/govoplan_core/security/permissions.py | 299 ++ src/govoplan_core/security/secrets.py | 58 + src/govoplan_core/security/sessions.py | 220 ++ src/govoplan_core/security/time.py | 21 + src/govoplan_core/server/__init__.py | 1 + src/govoplan_core/server/app.py | 56 + src/govoplan_core/server/config.py | 73 + src/govoplan_core/server/default_config.py | 89 + src/govoplan_core/server/fastapi.py | 48 + src/govoplan_core/server/platform.py | 100 + src/govoplan_core/server/registry.py | 43 + src/govoplan_core/settings.py | 62 + tests/__init__.py | 0 tests/test_api_smoke.py | 2821 +++++++++++++++++ tests/test_database_migrations.py | 332 ++ webui/index.html | 12 + webui/package-lock.json | 1999 ++++++++++++ webui/package.json | 49 + webui/src/App.tsx | 205 ++ webui/src/api/admin.ts | 519 +++ webui/src/api/auth.ts | 37 + webui/src/api/client.ts | 155 + webui/src/api/platform.ts | 27 + webui/src/app.ts | 13 + webui/src/components/AccessBoundary.tsx | 57 + webui/src/components/Button.tsx | 5 + webui/src/components/Card.tsx | 44 + webui/src/components/ConfirmDialog.tsx | 50 + webui/src/components/Dialog.tsx | 96 + webui/src/components/DismissibleAlert.tsx | 73 + webui/src/components/FormField.tsx | 12 + webui/src/components/LoadingFrame.tsx | 26 + webui/src/components/LoadingIndicator.tsx | 12 + webui/src/components/MetricCard.tsx | 9 + webui/src/components/PageTitle.tsx | 15 + webui/src/components/StatusBadge.tsx | 3 + webui/src/components/Stepper.tsx | 19 + webui/src/components/ToggleSwitch.tsx | 29 + .../components/email/EmailAddressInput.tsx | 246 ++ webui/src/components/help/FieldLabel.tsx | 17 + webui/src/components/help/InlineHelp.tsx | 186 ++ webui/src/components/table/DataGrid.tsx | 1648 ++++++++++ webui/src/features/PlaceholderPage.tsx | 15 + webui/src/features/admin/AdminAuditPanel.tsx | 123 + .../src/features/admin/AdminOverviewPanel.tsx | 94 + webui/src/features/admin/AdminPage.tsx | 206 ++ webui/src/features/admin/ApiKeysPanel.tsx | 127 + .../admin/GovernanceTemplatesPanel.tsx | 232 ++ webui/src/features/admin/GroupsPanel.tsx | 142 + .../src/features/admin/MailProfilesPanel.tsx | 103 + .../features/admin/RetentionPoliciesPanel.tsx | 143 + webui/src/features/admin/RolesPanel.tsx | 133 + webui/src/features/admin/SystemRolesPanel.tsx | 257 ++ .../features/admin/SystemSettingsPanel.tsx | 100 + webui/src/features/admin/SystemUsersPanel.tsx | 217 ++ webui/src/features/admin/TenantsPanel.tsx | 248 ++ webui/src/features/admin/UsersPanel.tsx | 182 ++ webui/src/features/admin/adminUtils.ts | 27 + .../admin/components/AdminIconButton.tsx | 26 + .../admin/components/AdminPageLayout.tsx | 45 + .../components/AdminPlaceholderTable.tsx | 53 + .../admin/components/AdminSelectionList.tsx | 44 + webui/src/features/auth/LoginModal.tsx | 60 + webui/src/features/auth/PublicLandingPage.tsx | 45 + .../src/features/dashboard/DashboardPage.tsx | 22 + .../privacy/RetentionPolicyManagement.tsx | 493 +++ webui/src/features/settings/SettingsPage.tsx | 290 ++ webui/src/index.ts | 42 + webui/src/layout/AppShell.tsx | 50 + webui/src/layout/BreadcrumbBar.tsx | 71 + webui/src/layout/HelpMenu.tsx | 122 + webui/src/layout/IconRail.tsx | 52 + webui/src/layout/ModuleSubnav.tsx | 62 + webui/src/layout/Titlebar.tsx | 168 + webui/src/main.tsx | 12 + webui/src/platform/modules.ts | 89 + webui/src/styles/auth-gate.css | 164 + webui/src/styles/badges.css | 5 + webui/src/styles/components.css | 629 ++++ webui/src/styles/dialogs.css | 165 + webui/src/styles/forms.css | 12 + webui/src/styles/layout.css | 144 + webui/src/styles/retention-policies.css | 124 + webui/src/styles/tables.css | 712 +++++ webui/src/styles/tokens.css | 50 + webui/src/types.ts | 187 ++ webui/src/utils/arrayOrder.ts | 14 + webui/src/utils/emailAddresses.ts | 107 + webui/src/utils/fieldHelp.ts | 86 + webui/src/utils/helpContext.ts | 66 + webui/src/utils/permissions.ts | 130 + webui/src/vite-env.d.ts | 1 + webui/tsconfig.json | 37 + webui/vite.config.ts | 35 + 173 files changed, 25380 insertions(+) create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/1f8d4c2a0b7e_editable_campaign_versions.py create mode 100644 alembic/versions/2c3d4e5f6a7b_auth_sessions_and_rbac.py create mode 100644 alembic/versions/3d4e5f6a7b8c_file_storage_backend.py create mode 100644 alembic/versions/4e5f6a7b8c9d_file_folders.py create mode 100644 alembic/versions/5f6a7b8c9d0e_campaign_version_user_locks.py create mode 100644 alembic/versions/6a7b8c9d0e1f_file_folder_uniqueness.py create mode 100644 alembic/versions/7b8c9d0e1f2a_safe_delivery_execution.py create mode 100644 alembic/versions/8c9d0e1f2a3b_accounts_tenant_admin_rbac.py create mode 100644 alembic/versions/9d0e1f2a3b4c_system_governance_templates.py create mode 100644 alembic/versions/a0b1c2d3e4f5_permission_catalogue_refinement.py create mode 100644 alembic/versions/b1c2d3e4f5a6_audit_scope_and_tenant_owner_selection.py create mode 100644 alembic/versions/b57c5b216bce_initial_persistence_models.py create mode 100644 alembic/versions/c2d3e4f5a6b7_audit_pagination_indexes.py create mode 100644 alembic/versions/d3e4f5a6b7c8_mail_profiles_and_cookie_csrf.py create mode 100644 alembic/versions/e4f5a6b7c8d9_mail_profile_policy_hierarchy.py create mode 100644 alembic/versions/f5a6b7c8d9e0_hierarchical_settings.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 src/govoplan_core/__init__.py create mode 100644 src/govoplan_core/access/__init__.py create mode 100644 src/govoplan_core/access/auth/__init__.py create mode 100644 src/govoplan_core/access/auth/principals.py create mode 100644 src/govoplan_core/access/auth/roles.py create mode 100644 src/govoplan_core/access/auth/tokens.py create mode 100644 src/govoplan_core/access/db/__init__.py create mode 100644 src/govoplan_core/access/db/base.py create mode 100644 src/govoplan_core/access/db/models.py create mode 100644 src/govoplan_core/access/db/session.py create mode 100644 src/govoplan_core/access/manifest.py create mode 100644 src/govoplan_core/access/permissions/__init__.py create mode 100644 src/govoplan_core/access/permissions/definitions.py create mode 100644 src/govoplan_core/access/permissions/evaluator.py create mode 100644 src/govoplan_core/access/permissions/registry.py create mode 100644 src/govoplan_core/access/tenancy/__init__.py create mode 100644 src/govoplan_core/access/tenancy/datastore.py create mode 100644 src/govoplan_core/admin/__init__.py create mode 100644 src/govoplan_core/admin/governance.py create mode 100644 src/govoplan_core/admin/service.py create mode 100644 src/govoplan_core/api/__init__.py create mode 100644 src/govoplan_core/api/v1/__init__.py create mode 100644 src/govoplan_core/api/v1/admin.py create mode 100644 src/govoplan_core/api/v1/admin_schemas.py create mode 100644 src/govoplan_core/api/v1/audit.py create mode 100644 src/govoplan_core/api/v1/auth.py create mode 100644 src/govoplan_core/api/v1/schemas.py create mode 100644 src/govoplan_core/api/v1/system.py create mode 100644 src/govoplan_core/audit/__init__.py create mode 100644 src/govoplan_core/audit/logging.py create mode 100644 src/govoplan_core/auth/__init__.py create mode 100644 src/govoplan_core/auth/dependencies.py create mode 100644 src/govoplan_core/celery_app.py create mode 100644 src/govoplan_core/commands/__init__.py create mode 100644 src/govoplan_core/commands/init_db.py create mode 100644 src/govoplan_core/core/__init__.py create mode 100644 src/govoplan_core/core/discovery.py create mode 100644 src/govoplan_core/core/events.py create mode 100644 src/govoplan_core/core/migrations.py create mode 100644 src/govoplan_core/core/modules.py create mode 100644 src/govoplan_core/core/registry.py create mode 100644 src/govoplan_core/db/__init__.py create mode 100644 src/govoplan_core/db/base.py create mode 100644 src/govoplan_core/db/bootstrap.py create mode 100644 src/govoplan_core/db/migrate.py create mode 100644 src/govoplan_core/db/migrations.py create mode 100644 src/govoplan_core/db/models.py create mode 100644 src/govoplan_core/db/session.py create mode 100644 src/govoplan_core/privacy/__init__.py create mode 100644 src/govoplan_core/privacy/retention.py create mode 100644 src/govoplan_core/py.typed create mode 100644 src/govoplan_core/security/__init__.py create mode 100644 src/govoplan_core/security/api_keys.py create mode 100644 src/govoplan_core/security/module_permissions.py create mode 100644 src/govoplan_core/security/passwords.py create mode 100644 src/govoplan_core/security/permissions.py create mode 100644 src/govoplan_core/security/secrets.py create mode 100644 src/govoplan_core/security/sessions.py create mode 100644 src/govoplan_core/security/time.py create mode 100644 src/govoplan_core/server/__init__.py create mode 100644 src/govoplan_core/server/app.py create mode 100644 src/govoplan_core/server/config.py create mode 100644 src/govoplan_core/server/default_config.py create mode 100644 src/govoplan_core/server/fastapi.py create mode 100644 src/govoplan_core/server/platform.py create mode 100644 src/govoplan_core/server/registry.py create mode 100644 src/govoplan_core/settings.py create mode 100644 tests/__init__.py create mode 100644 tests/test_api_smoke.py create mode 100644 tests/test_database_migrations.py create mode 100644 webui/index.html create mode 100644 webui/package-lock.json create mode 100644 webui/package.json create mode 100644 webui/src/App.tsx create mode 100644 webui/src/api/admin.ts create mode 100644 webui/src/api/auth.ts create mode 100644 webui/src/api/client.ts create mode 100644 webui/src/api/platform.ts create mode 100644 webui/src/app.ts create mode 100644 webui/src/components/AccessBoundary.tsx create mode 100644 webui/src/components/Button.tsx create mode 100644 webui/src/components/Card.tsx create mode 100644 webui/src/components/ConfirmDialog.tsx create mode 100644 webui/src/components/Dialog.tsx create mode 100644 webui/src/components/DismissibleAlert.tsx create mode 100644 webui/src/components/FormField.tsx create mode 100644 webui/src/components/LoadingFrame.tsx create mode 100644 webui/src/components/LoadingIndicator.tsx create mode 100644 webui/src/components/MetricCard.tsx create mode 100644 webui/src/components/PageTitle.tsx create mode 100644 webui/src/components/StatusBadge.tsx create mode 100644 webui/src/components/Stepper.tsx create mode 100644 webui/src/components/ToggleSwitch.tsx create mode 100644 webui/src/components/email/EmailAddressInput.tsx create mode 100644 webui/src/components/help/FieldLabel.tsx create mode 100644 webui/src/components/help/InlineHelp.tsx create mode 100644 webui/src/components/table/DataGrid.tsx create mode 100644 webui/src/features/PlaceholderPage.tsx create mode 100644 webui/src/features/admin/AdminAuditPanel.tsx create mode 100644 webui/src/features/admin/AdminOverviewPanel.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/GovernanceTemplatesPanel.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/SystemSettingsPanel.tsx create mode 100644 webui/src/features/admin/SystemUsersPanel.tsx create mode 100644 webui/src/features/admin/TenantsPanel.tsx create mode 100644 webui/src/features/admin/UsersPanel.tsx create mode 100644 webui/src/features/admin/adminUtils.ts create mode 100644 webui/src/features/admin/components/AdminIconButton.tsx create mode 100644 webui/src/features/admin/components/AdminPageLayout.tsx create mode 100644 webui/src/features/admin/components/AdminPlaceholderTable.tsx create mode 100644 webui/src/features/admin/components/AdminSelectionList.tsx create mode 100644 webui/src/features/auth/LoginModal.tsx create mode 100644 webui/src/features/auth/PublicLandingPage.tsx create mode 100644 webui/src/features/dashboard/DashboardPage.tsx create mode 100644 webui/src/features/privacy/RetentionPolicyManagement.tsx create mode 100644 webui/src/features/settings/SettingsPage.tsx create mode 100644 webui/src/index.ts create mode 100644 webui/src/layout/AppShell.tsx create mode 100644 webui/src/layout/BreadcrumbBar.tsx create mode 100644 webui/src/layout/HelpMenu.tsx create mode 100644 webui/src/layout/IconRail.tsx create mode 100644 webui/src/layout/ModuleSubnav.tsx create mode 100644 webui/src/layout/Titlebar.tsx create mode 100644 webui/src/main.tsx create mode 100644 webui/src/platform/modules.ts create mode 100644 webui/src/styles/auth-gate.css create mode 100644 webui/src/styles/badges.css create mode 100644 webui/src/styles/components.css create mode 100644 webui/src/styles/dialogs.css create mode 100644 webui/src/styles/forms.css create mode 100644 webui/src/styles/layout.css create mode 100644 webui/src/styles/retention-policies.css create mode 100644 webui/src/styles/tables.css create mode 100644 webui/src/styles/tokens.css create mode 100644 webui/src/types.ts create mode 100644 webui/src/utils/arrayOrder.ts create mode 100644 webui/src/utils/emailAddresses.ts create mode 100644 webui/src/utils/fieldHelp.ts create mode 100644 webui/src/utils/helpContext.ts create mode 100644 webui/src/utils/permissions.ts create mode 100644 webui/src/vite-env.d.ts create mode 100644 webui/tsconfig.json create mode 100644 webui/vite.config.ts diff --git a/.gitignore b/.gitignore index 555e90e..3c46444 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,141 @@ +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + # ---> Python # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md index 48fe12b..2c3b5c8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,32 @@ # govoplan-core +Reusable core components for the Government Operational Platform GovOPlaN. + +This repository owns platform-level contracts and infrastructure that should not be coupled to a consuming product module: + +- backend module manifests and registry validation +- server runner integration for routes, metadata and module availability +- database session/base primitives and module migration registration +- tenant/access/RBAC models and permission evaluation +- stable principal DTOs, token helpers and tenant datastore abstractions +- shared WebUI package `@govoplan/core-webui` for API/CSRF/auth helpers, core types, generic UI components, login UI and shell layout primitives + +Modules register backend routes, permissions, nav items, frontend package metadata, SQLAlchemy metadata and migration locations through their `govoplan.modules` manifest. The core runner aggregates those contributions and exposes the combined platform. + +## Development + +Install the backend core and module checkouts through the core development environment: + +```bash +cd /mnt/DATA/git/govoplan-core +./.venv/bin/python -m pip install -r requirements-dev.txt +``` + +Install WebUI dependencies from the core WebUI runner, which links the module WebUI packages from the neighboring module repositories: + +```bash +cd /mnt/DATA/git/govoplan-core/webui +PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm install +``` + +Production deployments should use tagged git dependencies or published package versions, not runtime file copying. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..df23acf --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = sqlite:///./multimailer-dev.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..3f98c16 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from govoplan_core.access.db import models as access_models # noqa: F401 - populate access metadata +from govoplan_core.core.migrations import migration_metadata_plan +from govoplan_core.db.base import Base +from govoplan_core.db import models # noqa: F401 - populate core metadata +from govoplan_core.server.default_config import get_server_config +from govoplan_core.server.registry import build_platform_registry +from govoplan_core.settings import settings + +config = context.config +database_url = config.attributes.get("database_url") or settings.database_url +config.set_main_option("sqlalchemy.url", database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + + +def _target_metadata(): + server_config = get_server_config() + registry = build_platform_registry( + server_config.enabled_modules, + manifest_factories=server_config.manifest_factories, + ) + plan = migration_metadata_plan(registry, extra_metadata=(Base.metadata,)) + return tuple(dict.fromkeys(plan.metadata)) + + +target_metadata = _target_metadata() + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..c6284cb --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/1f8d4c2a0b7e_editable_campaign_versions.py b/alembic/versions/1f8d4c2a0b7e_editable_campaign_versions.py new file mode 100644 index 0000000..33a085d --- /dev/null +++ b/alembic/versions/1f8d4c2a0b7e_editable_campaign_versions.py @@ -0,0 +1,58 @@ +"""editable campaign versions + +Revision ID: 1f8d4c2a0b7e +Revises: b57c5b216bce +Create Date: 2026-06-08 08:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "1f8d4c2a0b7e" +down_revision = "b57c5b216bce" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("campaign_versions") as batch_op: + batch_op.add_column(sa.Column("source_base_path", sa.String(length=1000), nullable=True)) + batch_op.add_column(sa.Column("workflow_state", sa.String(length=50), nullable=False, server_default="editing")) + batch_op.add_column(sa.Column("current_flow", sa.String(length=50), nullable=False, server_default="manual")) + batch_op.add_column(sa.Column("current_step", sa.String(length=100), nullable=True)) + batch_op.add_column(sa.Column("is_complete", sa.Boolean(), nullable=False, server_default=sa.false())) + batch_op.add_column(sa.Column("editor_state", sa.JSON(), nullable=False, server_default=sa.text("'{}'"))) + batch_op.add_column(sa.Column("autosaved_at", sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column("published_at", sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column("locked_at", sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column("locked_by_user_id", sa.String(length=36), nullable=True)) + batch_op.create_foreign_key( + op.f("fk_campaign_versions_locked_by_user_id_users"), + "users", + ["locked_by_user_id"], + ["id"], + ondelete="SET NULL", + ) + batch_op.create_index(op.f("ix_campaign_versions_workflow_state"), ["workflow_state"], unique=False) + batch_op.create_index(op.f("ix_campaign_versions_current_flow"), ["current_flow"], unique=False) + batch_op.create_index(op.f("ix_campaign_versions_locked_by_user_id"), ["locked_by_user_id"], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table("campaign_versions") as batch_op: + batch_op.drop_index(op.f("ix_campaign_versions_locked_by_user_id")) + batch_op.drop_index(op.f("ix_campaign_versions_current_flow")) + batch_op.drop_index(op.f("ix_campaign_versions_workflow_state")) + batch_op.drop_constraint(op.f("fk_campaign_versions_locked_by_user_id_users"), type_="foreignkey") + batch_op.drop_column("locked_by_user_id") + batch_op.drop_column("locked_at") + batch_op.drop_column("published_at") + batch_op.drop_column("autosaved_at") + batch_op.drop_column("editor_state") + batch_op.drop_column("is_complete") + batch_op.drop_column("current_step") + batch_op.drop_column("current_flow") + batch_op.drop_column("workflow_state") + batch_op.drop_column("source_base_path") diff --git a/alembic/versions/2c3d4e5f6a7b_auth_sessions_and_rbac.py b/alembic/versions/2c3d4e5f6a7b_auth_sessions_and_rbac.py new file mode 100644 index 0000000..d3756a9 --- /dev/null +++ b/alembic/versions/2c3d4e5f6a7b_auth_sessions_and_rbac.py @@ -0,0 +1,130 @@ +"""auth sessions and RBAC assignments + +Revision ID: 2c3d4e5f6a7b +Revises: 1f8d4c2a0b7e +Create Date: 2026-06-08 10:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "2c3d4e5f6a7b" +down_revision = "1f8d4c2a0b7e" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("users") as batch_op: + batch_op.add_column(sa.Column("auth_provider", sa.String(length=50), nullable=False, server_default="local")) + batch_op.add_column(sa.Column("password_hash", sa.String(length=500), nullable=True)) + batch_op.add_column(sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True)) + + op.create_table( + "user_group_memberships", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("group_id", sa.String(length=36), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["group_id"], ["groups.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"), + ) + op.create_index(op.f("ix_user_group_memberships_tenant_id"), "user_group_memberships", ["tenant_id"]) + op.create_index(op.f("ix_user_group_memberships_user_id"), "user_group_memberships", ["user_id"]) + op.create_index(op.f("ix_user_group_memberships_group_id"), "user_group_memberships", ["group_id"]) + + op.create_table( + "user_role_assignments", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("role_id", sa.String(length=36), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_role_assignments"), + ) + op.create_index(op.f("ix_user_role_assignments_tenant_id"), "user_role_assignments", ["tenant_id"]) + op.create_index(op.f("ix_user_role_assignments_user_id"), "user_role_assignments", ["user_id"]) + op.create_index(op.f("ix_user_role_assignments_role_id"), "user_role_assignments", ["role_id"]) + + op.create_table( + "group_role_assignments", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("group_id", sa.String(length=36), nullable=False), + sa.Column("role_id", sa.String(length=36), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["group_id"], ["groups.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_role_assignments"), + ) + op.create_index(op.f("ix_group_role_assignments_tenant_id"), "group_role_assignments", ["tenant_id"]) + op.create_index(op.f("ix_group_role_assignments_group_id"), "group_role_assignments", ["group_id"]) + op.create_index(op.f("ix_group_role_assignments_role_id"), "group_role_assignments", ["role_id"]) + + op.create_table( + "auth_sessions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("token_hash", sa.String(length=128), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("user_agent", sa.String(length=500), nullable=True), + sa.Column("ip_address", sa.String(length=100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token_hash"), + ) + op.create_index(op.f("ix_auth_sessions_tenant_id"), "auth_sessions", ["tenant_id"]) + op.create_index(op.f("ix_auth_sessions_user_id"), "auth_sessions", ["user_id"]) + op.create_index(op.f("ix_auth_sessions_token_hash"), "auth_sessions", ["token_hash"]) + op.create_index(op.f("ix_auth_sessions_expires_at"), "auth_sessions", ["expires_at"]) + op.create_index(op.f("ix_auth_sessions_revoked_at"), "auth_sessions", ["revoked_at"]) + + +def downgrade() -> None: + op.drop_index(op.f("ix_auth_sessions_revoked_at"), table_name="auth_sessions") + op.drop_index(op.f("ix_auth_sessions_expires_at"), table_name="auth_sessions") + op.drop_index(op.f("ix_auth_sessions_token_hash"), table_name="auth_sessions") + op.drop_index(op.f("ix_auth_sessions_user_id"), table_name="auth_sessions") + op.drop_index(op.f("ix_auth_sessions_tenant_id"), table_name="auth_sessions") + op.drop_table("auth_sessions") + + op.drop_index(op.f("ix_group_role_assignments_role_id"), table_name="group_role_assignments") + op.drop_index(op.f("ix_group_role_assignments_group_id"), table_name="group_role_assignments") + op.drop_index(op.f("ix_group_role_assignments_tenant_id"), table_name="group_role_assignments") + op.drop_table("group_role_assignments") + + op.drop_index(op.f("ix_user_role_assignments_role_id"), table_name="user_role_assignments") + op.drop_index(op.f("ix_user_role_assignments_user_id"), table_name="user_role_assignments") + op.drop_index(op.f("ix_user_role_assignments_tenant_id"), table_name="user_role_assignments") + op.drop_table("user_role_assignments") + + op.drop_index(op.f("ix_user_group_memberships_group_id"), table_name="user_group_memberships") + op.drop_index(op.f("ix_user_group_memberships_user_id"), table_name="user_group_memberships") + op.drop_index(op.f("ix_user_group_memberships_tenant_id"), table_name="user_group_memberships") + op.drop_table("user_group_memberships") + + with op.batch_alter_table("users") as batch_op: + batch_op.drop_column("last_login_at") + batch_op.drop_column("password_hash") + batch_op.drop_column("auth_provider") diff --git a/alembic/versions/3d4e5f6a7b8c_file_storage_backend.py b/alembic/versions/3d4e5f6a7b8c_file_storage_backend.py new file mode 100644 index 0000000..862da74 --- /dev/null +++ b/alembic/versions/3d4e5f6a7b8c_file_storage_backend.py @@ -0,0 +1,152 @@ +"""file storage backend + +Revision ID: 3d4e5f6a7b8c +Revises: 2c3d4e5f6a7b +Create Date: 2026-06-12 00:00:00.000000 +""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "3d4e5f6a7b8c" +down_revision: Union[str, None] = "2c3d4e5f6a7b" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "file_blobs", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("storage_backend", sa.String(length=50), nullable=False), + sa.Column("storage_bucket", sa.String(length=255), nullable=True), + sa.Column("storage_key", sa.String(length=1000), nullable=False), + sa.Column("checksum_sha256", sa.String(length=64), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("content_type", sa.String(length=255), nullable=True), + sa.Column("ref_count", sa.Integer(), nullable=False, server_default="1"), + sa.Column("retained_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "checksum_sha256", "size_bytes", name="uq_file_blobs_tenant_checksum_size"), + ) + op.create_index(op.f("ix_file_blobs_tenant_id"), "file_blobs", ["tenant_id"]) + op.create_index(op.f("ix_file_blobs_checksum_sha256"), "file_blobs", ["checksum_sha256"]) + + op.create_table( + "file_assets", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("owner_type", sa.String(length=20), nullable=False), + sa.Column("owner_user_id", sa.String(length=36), nullable=True), + sa.Column("owner_group_id", sa.String(length=36), nullable=True), + sa.Column("current_version_id", sa.String(length=36), nullable=True), + sa.Column("display_path", sa.String(length=1000), nullable=False), + sa.Column("filename", sa.String(length=500), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.String(length=36), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["owner_group_id"], ["groups.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + for col in ["tenant_id", "owner_type", "owner_user_id", "owner_group_id", "current_version_id", "display_path", "filename", "created_by_user_id", "deleted_at"]: + op.create_index(op.f(f"ix_file_assets_{col}"), "file_assets", [col]) + + op.create_table( + "file_versions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("file_asset_id", sa.String(length=36), nullable=False), + sa.Column("blob_id", sa.String(length=36), nullable=False), + sa.Column("version_number", sa.Integer(), nullable=False), + sa.Column("filename_at_upload", sa.String(length=500), nullable=False), + sa.Column("display_path_at_upload", sa.String(length=1000), nullable=False), + sa.Column("content_type", sa.String(length=255), nullable=True), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("checksum_sha256", sa.String(length=64), nullable=False), + sa.Column("created_by_user_id", sa.String(length=36), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["blob_id"], ["file_blobs.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("file_asset_id", "version_number", name="uq_file_versions_asset_number"), + ) + for col in ["tenant_id", "file_asset_id", "blob_id", "checksum_sha256", "created_by_user_id"]: + op.create_index(op.f(f"ix_file_versions_{col}"), "file_versions", [col]) + + op.create_table( + "file_shares", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("file_asset_id", sa.String(length=36), nullable=False), + sa.Column("target_type", sa.String(length=20), nullable=False), + sa.Column("target_id", sa.String(length=36), nullable=False), + sa.Column("permission", sa.String(length=20), nullable=False, server_default="read"), + sa.Column("created_by_user_id", sa.String(length=36), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("file_asset_id", "target_type", "target_id", "revoked_at", name="uq_file_shares_active_target"), + ) + for col in ["tenant_id", "file_asset_id", "target_type", "target_id", "created_by_user_id", "revoked_at"]: + op.create_index(op.f(f"ix_file_shares_{col}"), "file_shares", [col]) + + op.create_table( + "campaign_attachment_uses", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("campaign_id", sa.String(length=36), nullable=False), + sa.Column("campaign_version_id", sa.String(length=36), nullable=False), + sa.Column("campaign_job_id", sa.String(length=36), nullable=True), + sa.Column("entry_index", sa.Integer(), nullable=True), + sa.Column("entry_id", sa.String(length=255), nullable=True), + sa.Column("file_asset_id", sa.String(length=36), nullable=False), + sa.Column("file_version_id", sa.String(length=36), nullable=False), + sa.Column("file_blob_id", sa.String(length=36), nullable=False), + sa.Column("filename_used", sa.String(length=500), nullable=False), + sa.Column("checksum_sha256", sa.String(length=64), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("content_type", sa.String(length=255), nullable=True), + sa.Column("use_stage", sa.String(length=20), nullable=False, server_default="built"), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["campaign_job_id"], ["campaign_jobs.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["campaign_version_id"], ["campaign_versions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["file_blob_id"], ["file_blobs.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["file_version_id"], ["file_versions.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("campaign_job_id", "file_version_id", "filename_used", "use_stage", name="uq_campaign_attachment_uses_job_file_stage"), + ) + for col in ["tenant_id", "campaign_id", "campaign_version_id", "campaign_job_id", "entry_id", "file_asset_id", "file_version_id", "file_blob_id", "use_stage", "used_at"]: + op.create_index(op.f(f"ix_campaign_attachment_uses_{col}"), "campaign_attachment_uses", [col]) + + +def downgrade() -> None: + op.drop_table("campaign_attachment_uses") + op.drop_table("file_shares") + op.drop_table("file_versions") + op.drop_table("file_assets") + op.drop_table("file_blobs") diff --git a/alembic/versions/4e5f6a7b8c9d_file_folders.py b/alembic/versions/4e5f6a7b8c9d_file_folders.py new file mode 100644 index 0000000..afed0f3 --- /dev/null +++ b/alembic/versions/4e5f6a7b8c9d_file_folders.py @@ -0,0 +1,45 @@ +"""file folders + +Revision ID: 4e5f6a7b8c9d +Revises: 3d4e5f6a7b8c +Create Date: 2026-06-12 00:30:00.000000 +""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "4e5f6a7b8c9d" +down_revision: Union[str, None] = "3d4e5f6a7b8c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "file_folders", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("owner_type", sa.String(length=20), nullable=False), + sa.Column("owner_user_id", sa.String(length=36), nullable=True), + sa.Column("owner_group_id", sa.String(length=36), nullable=True), + sa.Column("path", sa.String(length=1000), nullable=False), + sa.Column("created_by_user_id", sa.String(length=36), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["owner_group_id"], ["groups.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + for col in ["tenant_id", "owner_type", "owner_user_id", "owner_group_id", "path", "created_by_user_id", "deleted_at"]: + op.create_index(op.f(f"ix_file_folders_{col}"), "file_folders", [col]) + + +def downgrade() -> None: + op.drop_table("file_folders") diff --git a/alembic/versions/5f6a7b8c9d0e_campaign_version_user_locks.py b/alembic/versions/5f6a7b8c9d0e_campaign_version_user_locks.py new file mode 100644 index 0000000..55ec532 --- /dev/null +++ b/alembic/versions/5f6a7b8c9d0e_campaign_version_user_locks.py @@ -0,0 +1,55 @@ +"""explicit temporary and permanent user locks + +Revision ID: 5f6a7b8c9d0e +Revises: 4e5f6a7b8c9d +Create Date: 2026-06-13 18:00:00.000000 +""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "5f6a7b8c9d0e" +down_revision: Union[str, None] = "4e5f6a7b8c9d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("campaign_versions") as batch_op: + batch_op.add_column(sa.Column("user_lock_state", sa.String(length=20), nullable=True)) + batch_op.add_column(sa.Column("user_locked_at", sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column("user_locked_by_user_id", sa.String(length=36), nullable=True)) + batch_op.create_foreign_key( + "fk_campaign_versions_user_locked_by_user_id_users", + "users", + ["user_locked_by_user_id"], + ["id"], + ondelete="SET NULL", + ) + batch_op.create_index("ix_campaign_versions_user_lock_state", ["user_lock_state"]) + batch_op.create_index("ix_campaign_versions_user_locked_by_user_id", ["user_locked_by_user_id"]) + + # Existing published snapshots were the former irreversible user lock. + op.execute( + """ + UPDATE campaign_versions + SET user_lock_state = 'permanent', + user_locked_at = published_at, + user_locked_by_user_id = NULL + WHERE published_at IS NOT NULL + AND user_lock_state IS NULL + """ + ) + + +def downgrade() -> None: + with op.batch_alter_table("campaign_versions") as batch_op: + batch_op.drop_index("ix_campaign_versions_user_locked_by_user_id") + batch_op.drop_index("ix_campaign_versions_user_lock_state") + batch_op.drop_constraint("fk_campaign_versions_user_locked_by_user_id_users", type_="foreignkey") + batch_op.drop_column("user_locked_by_user_id") + batch_op.drop_column("user_locked_at") + batch_op.drop_column("user_lock_state") diff --git a/alembic/versions/6a7b8c9d0e1f_file_folder_uniqueness.py b/alembic/versions/6a7b8c9d0e1f_file_folder_uniqueness.py new file mode 100644 index 0000000..6a1f754 --- /dev/null +++ b/alembic/versions/6a7b8c9d0e1f_file_folder_uniqueness.py @@ -0,0 +1,73 @@ +"""Prevent duplicate active file-folder paths. + +Revision ID: 6a7b8c9d0e1f +Revises: 5f6a7b8c9d0e +""" + +from __future__ import annotations + +from collections import defaultdict +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "6a7b8c9d0e1f" +down_revision = "5f6a7b8c9d0e" +branch_labels = None +depends_on = None + +USER_INDEX = "uq_file_folders_active_user_path" +GROUP_INDEX = "uq_file_folders_active_group_path" + + +def _deduplicate_active_folders() -> None: + bind = op.get_bind() + rows = bind.execute(sa.text(""" + SELECT id, tenant_id, owner_type, owner_user_id, owner_group_id, path, created_at + FROM file_folders + WHERE deleted_at IS NULL + ORDER BY created_at ASC, id ASC + """)).mappings().all() + grouped: dict[tuple[object, ...], list[str]] = defaultdict(list) + for row in rows: + owner_id = row["owner_user_id"] if row["owner_type"] == "user" else row["owner_group_id"] + grouped[(row["tenant_id"], row["owner_type"], owner_id, row["path"])].append(row["id"]) + duplicate_ids = [folder_id for ids in grouped.values() for folder_id in ids[1:]] + if duplicate_ids: + bind.execute( + sa.text("UPDATE file_folders SET deleted_at = CURRENT_TIMESTAMP WHERE id IN :ids").bindparams( + sa.bindparam("ids", expanding=True) + ), + {"ids": duplicate_ids}, + ) + + +def upgrade() -> None: + _deduplicate_active_folders() + existing = {item["name"] for item in inspect(op.get_bind()).get_indexes("file_folders")} + if USER_INDEX not in existing: + op.create_index( + USER_INDEX, + "file_folders", + ["tenant_id", "owner_user_id", "path"], + unique=True, + sqlite_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"), + postgresql_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"), + ) + if GROUP_INDEX not in existing: + op.create_index( + GROUP_INDEX, + "file_folders", + ["tenant_id", "owner_group_id", "path"], + unique=True, + sqlite_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"), + postgresql_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"), + ) + + +def downgrade() -> None: + existing = {item["name"] for item in inspect(op.get_bind()).get_indexes("file_folders")} + if GROUP_INDEX in existing: + op.drop_index(GROUP_INDEX, table_name="file_folders") + if USER_INDEX in existing: + op.drop_index(USER_INDEX, table_name="file_folders") diff --git a/alembic/versions/7b8c9d0e1f2a_safe_delivery_execution.py b/alembic/versions/7b8c9d0e1f2a_safe_delivery_execution.py new file mode 100644 index 0000000..a1bf7b1 --- /dev/null +++ b/alembic/versions/7b8c9d0e1f2a_safe_delivery_execution.py @@ -0,0 +1,64 @@ +"""Safe delivery lifecycle and immutable execution snapshot. + +Revision ID: 7b8c9d0e1f2a +Revises: 6a7b8c9d0e1f +Create Date: 2026-06-14 12:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "7b8c9d0e1f2a" +down_revision = "6a7b8c9d0e1f" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("campaign_versions") as batch_op: + batch_op.add_column(sa.Column("execution_snapshot", sa.JSON(), nullable=True)) + batch_op.add_column(sa.Column("execution_snapshot_hash", sa.String(length=64), nullable=True)) + batch_op.add_column(sa.Column("execution_snapshot_at", sa.DateTime(timezone=True), nullable=True)) + batch_op.create_index("ix_campaign_versions_execution_snapshot_hash", ["execution_snapshot_hash"], unique=False) + + with op.batch_alter_table("campaign_jobs") as batch_op: + batch_op.add_column(sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column("claim_token", sa.String(length=36), nullable=True)) + batch_op.add_column(sa.Column("smtp_started_at", sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column("outcome_unknown_at", sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column("eml_sha256", sa.String(length=64), nullable=True)) + batch_op.create_index("ix_campaign_jobs_claim_token", ["claim_token"], unique=False) + batch_op.create_index("ix_campaign_jobs_eml_sha256", ["eml_sha256"], unique=False) + + with op.batch_alter_table("send_attempts") as batch_op: + batch_op.add_column(sa.Column("status", sa.String(length=50), nullable=False, server_default="started")) + batch_op.add_column(sa.Column("claim_token", sa.String(length=36), nullable=True)) + batch_op.create_index("ix_send_attempts_status", ["status"], unique=False) + batch_op.create_index("ix_send_attempts_claim_token", ["claim_token"], unique=False) + + # Existing successful rows remain readable through the legacy 'sent' value. + # No data rewrite is required; new deliveries use 'smtp_accepted'. + + +def downgrade() -> None: + with op.batch_alter_table("send_attempts") as batch_op: + batch_op.drop_index("ix_send_attempts_claim_token") + batch_op.drop_index("ix_send_attempts_status") + batch_op.drop_column("claim_token") + batch_op.drop_column("status") + + with op.batch_alter_table("campaign_jobs") as batch_op: + batch_op.drop_index("ix_campaign_jobs_eml_sha256") + batch_op.drop_index("ix_campaign_jobs_claim_token") + batch_op.drop_column("eml_sha256") + batch_op.drop_column("outcome_unknown_at") + batch_op.drop_column("smtp_started_at") + batch_op.drop_column("claim_token") + batch_op.drop_column("claimed_at") + + with op.batch_alter_table("campaign_versions") as batch_op: + batch_op.drop_index("ix_campaign_versions_execution_snapshot_hash") + batch_op.drop_column("execution_snapshot_at") + batch_op.drop_column("execution_snapshot_hash") + batch_op.drop_column("execution_snapshot") diff --git a/alembic/versions/8c9d0e1f2a3b_accounts_tenant_admin_rbac.py b/alembic/versions/8c9d0e1f2a3b_accounts_tenant_admin_rbac.py new file mode 100644 index 0000000..6c90506 --- /dev/null +++ b/alembic/versions/8c9d0e1f2a3b_accounts_tenant_admin_rbac.py @@ -0,0 +1,323 @@ +"""global accounts and operational tenant administration + +Revision ID: 8c9d0e1f2a3b +Revises: 7b8c9d0e1f2a +Create Date: 2026-06-14 18:00:00.000000 +""" +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from alembic import op +import sqlalchemy as sa + + +revision = "8c9d0e1f2a3b" +down_revision = "7b8c9d0e1f2a" +branch_labels = None +depends_on = None + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _normalize_email(value: str) -> str: + return value.strip().casefold() + + +def _coerce_datetime(value: object) -> datetime | None: + if value is None or isinstance(value, datetime): + return value + if isinstance(value, str): + normalized = value.strip().replace("Z", "+00:00") + parsed = datetime.fromisoformat(normalized) + return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed + raise TypeError(f"Unsupported datetime value during account migration: {value!r}") + + +def _role_definitions() -> dict[str, dict[str, object]]: + # Kept in the migration so historic upgrades remain deterministic even if + # application role presets evolve later. + tenant_admin = [ + "admin:api_keys:read", "admin:api_keys:write", "admin:groups:read", "admin:groups:write", + "admin:roles:read", "admin:roles:write", "admin:settings:read", "admin:settings:write", + "admin:users:read", "admin:users:write", "attachments:read", "attachments:write", + "audit:read", "campaign:build", "campaign:queue", "campaign:read", "campaign:send", + "campaign:send_test", "campaign:validate", "campaign:write", "reports:read", "reports:send", + ] + return { + "owner": {"name": "Owner", "permissions": ["tenant:*"], "description": "Full tenant access, including administration and delivery."}, + "admin": {"name": "Administrator", "permissions": tenant_admin, "description": "Tenant administration and full campaign operation without system access."}, + "campaign_manager": {"name": "Campaign manager", "permissions": ["campaign:read", "campaign:write", "campaign:validate", "campaign:build", "attachments:read", "attachments:write", "reports:read"], "description": "Prepare, validate and build campaigns, but do not start real delivery."}, + "sender": {"name": "Sender", "permissions": ["campaign:read", "campaign:queue", "campaign:send_test", "campaign:send", "attachments:read", "reports:read", "reports:send"], "description": "Review, queue and send prepared campaigns."}, + "reviewer": {"name": "Reviewer", "permissions": ["campaign:read", "campaign:validate", "attachments:read", "reports:read"], "description": "Inspect campaigns, validate them and view delivery reports."}, + "viewer": {"name": "Viewer", "permissions": ["campaign:read", "attachments:read", "reports:read"], "description": "Read campaigns, files and reports without changing them."}, + "auditor": {"name": "Auditor", "permissions": ["campaign:read", "reports:read", "audit:read"], "description": "Read campaigns, reports and audit records."}, + } + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + user_columns = {column["name"] for column in inspector.get_columns("users")} + # Base.metadata.create_all() from a newer application can create brand-new + # tables while leaving existing tables unaltered. Repair that known drift by + # removing only empty, unreferenced administration tables before applying the + # real migration. A non-empty table is never guessed at or discarded. + if "account_id" not in user_columns and "system_role_assignments" in tables: + count = bind.execute(sa.text("SELECT COUNT(*) FROM system_role_assignments")).scalar_one() + if count: + raise RuntimeError("Cannot reconcile non-empty create_all system_role_assignments table") + op.drop_table("system_role_assignments") + tables.remove("system_role_assignments") + if "account_id" not in user_columns and "accounts" in tables: + count = bind.execute(sa.text("SELECT COUNT(*) FROM accounts")).scalar_one() + if count: + raise RuntimeError("Cannot reconcile non-empty create_all accounts table") + op.drop_table("accounts") + + op.create_table( + "accounts", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("normalized_email", sa.String(length=320), nullable=False), + sa.Column("display_name", sa.String(length=255), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("auth_provider", sa.String(length=50), nullable=False, server_default="local"), + sa.Column("password_hash", sa.String(length=500), nullable=True), + sa.Column("password_reset_required", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("normalized_email", name="uq_accounts_normalized_email"), + ) + op.create_index(op.f("ix_accounts_normalized_email"), "accounts", ["normalized_email"]) + + with op.batch_alter_table("tenants") as batch_op: + batch_op.add_column(sa.Column("description", sa.Text(), nullable=True)) + batch_op.add_column(sa.Column("default_locale", sa.String(length=20), nullable=False, server_default="en")) + batch_op.add_column(sa.Column("settings", sa.JSON(), nullable=False, server_default="{}")) + + with op.batch_alter_table("groups") as batch_op: + batch_op.add_column(sa.Column("description", sa.Text(), nullable=True)) + batch_op.add_column(sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true())) + + with op.batch_alter_table("roles") as batch_op: + batch_op.add_column(sa.Column("description", sa.Text(), nullable=True)) + batch_op.add_column(sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.false())) + batch_op.add_column(sa.Column("is_assignable", sa.Boolean(), nullable=False, server_default=sa.true())) + + with op.batch_alter_table("users") as batch_op: + batch_op.add_column(sa.Column("account_id", sa.String(length=36), nullable=True)) + with op.batch_alter_table("auth_sessions") as batch_op: + batch_op.add_column(sa.Column("account_id", sa.String(length=36), nullable=True)) + + users = bind.execute(sa.text( + "SELECT id, tenant_id, email, display_name, is_active, is_tenant_admin, auth_provider, password_hash, last_login_at, created_at, updated_at FROM users ORDER BY created_at, id" + )).mappings().all() + + accounts_by_email: dict[str, str] = {} + account_rows: dict[str, dict[str, object]] = {} + first_system_owner_account_id: str | None = None + for row in users: + normalized = _normalize_email(row["email"]) + account_id = accounts_by_email.get(normalized) + if account_id is None: + account_id = str(uuid.uuid4()) + accounts_by_email[normalized] = account_id + account_rows[account_id] = { + "id": account_id, + "email": row["email"], + "normalized_email": normalized, + "display_name": row["display_name"], + "is_active": bool(row["is_active"]), + "auth_provider": row["auth_provider"] or "local", + "password_hash": row["password_hash"], + "password_reset_required": False, + "last_login_at": _coerce_datetime(row["last_login_at"]), + "created_at": _coerce_datetime(row["created_at"]) or _now(), + "updated_at": _coerce_datetime(row["updated_at"]) or _now(), + } + else: + account = account_rows[account_id] + if not account["password_hash"] and row["password_hash"]: + account["password_hash"] = row["password_hash"] + elif account["password_hash"] and row["password_hash"] and account["password_hash"] != row["password_hash"]: + account["password_reset_required"] = True + if not account["display_name"] and row["display_name"]: + account["display_name"] = row["display_name"] + account["is_active"] = bool(account["is_active"] or row["is_active"]) + if first_system_owner_account_id is None and row["is_active"] and row["is_tenant_admin"]: + first_system_owner_account_id = account_id + + accounts_table = sa.table( + "accounts", + sa.column("id", sa.String), sa.column("email", sa.String), sa.column("normalized_email", sa.String), + sa.column("display_name", sa.String), sa.column("is_active", sa.Boolean), sa.column("auth_provider", sa.String), + sa.column("password_hash", sa.String), sa.column("password_reset_required", sa.Boolean), + sa.column("last_login_at", sa.DateTime(timezone=True)), sa.column("created_at", sa.DateTime(timezone=True)), + sa.column("updated_at", sa.DateTime(timezone=True)), + ) + if account_rows: + bind.execute(accounts_table.insert(), list(account_rows.values())) + for row in users: + bind.execute( + sa.text("UPDATE users SET account_id = :account_id WHERE id = :user_id"), + {"account_id": accounts_by_email[_normalize_email(row["email"])], "user_id": row["id"]}, + ) + bind.execute(sa.text( + "UPDATE auth_sessions SET account_id = (SELECT users.account_id FROM users WHERE users.id = auth_sessions.user_id)" + )) + + with op.batch_alter_table("users") as batch_op: + batch_op.alter_column("account_id", existing_type=sa.String(length=36), nullable=False) + batch_op.create_foreign_key("fk_users_account_id_accounts", "accounts", ["account_id"], ["id"], ondelete="CASCADE") + batch_op.create_unique_constraint("uq_users_tenant_account", ["tenant_id", "account_id"]) + op.create_index(op.f("ix_users_account_id"), "users", ["account_id"]) + + with op.batch_alter_table("auth_sessions") as batch_op: + batch_op.alter_column("account_id", existing_type=sa.String(length=36), nullable=False) + batch_op.create_foreign_key("fk_auth_sessions_account_id_accounts", "accounts", ["account_id"], ["id"], ondelete="CASCADE") + op.create_index(op.f("ix_auth_sessions_account_id"), "auth_sessions", ["account_id"]) + + op.create_table( + "system_role_assignments", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("account_id", sa.String(length=36), nullable=False), + sa.Column("role_id", sa.String(length=36), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("account_id", "role_id", name="uq_system_role_assignments"), + ) + op.create_index(op.f("ix_system_role_assignments_account_id"), "system_role_assignments", ["account_id"]) + op.create_index(op.f("ix_system_role_assignments_role_id"), "system_role_assignments", ["role_id"]) + + roles_table = sa.table( + "roles", + sa.column("id", sa.String), sa.column("tenant_id", sa.String), sa.column("slug", sa.String), + sa.column("name", sa.String), sa.column("description", sa.Text), sa.column("permissions", sa.JSON), + sa.column("is_builtin", sa.Boolean), sa.column("is_assignable", sa.Boolean), + sa.column("created_at", sa.DateTime(timezone=True)), sa.column("updated_at", sa.DateTime(timezone=True)), + ) + now = _now() + tenant_ids = [row[0] for row in bind.execute(sa.text("SELECT id FROM tenants")).all()] + definitions = _role_definitions() + tenant_role_ids: dict[tuple[str, str], str] = {} + for tenant_id in tenant_ids: + for slug, definition in definitions.items(): + existing = bind.execute( + sa.text("SELECT id FROM roles WHERE tenant_id = :tenant_id AND slug = :slug"), + {"tenant_id": tenant_id, "slug": slug}, + ).scalar_one_or_none() + if existing: + role_id = existing + bind.execute( + roles_table.update().where(roles_table.c.id == role_id).values( + name=definition["name"], description=definition["description"], + permissions=definition["permissions"], is_builtin=True, is_assignable=True, + updated_at=now, + ) + ) + else: + role_id = str(uuid.uuid4()) + bind.execute(roles_table.insert().values( + id=role_id, tenant_id=tenant_id, slug=slug, name=definition["name"], + description=definition["description"], permissions=definition["permissions"], + is_builtin=True, is_assignable=True, created_at=now, updated_at=now, + )) + tenant_role_ids[(tenant_id, slug)] = role_id + + system_owner_role_id = str(uuid.uuid4()) + system_auditor_role_id = str(uuid.uuid4()) + bind.execute(roles_table.insert(), [ + { + "id": system_owner_role_id, "tenant_id": None, "slug": "system_owner", "name": "System owner", + "description": "Full instance-wide administration. At least one active account must retain this role.", + "permissions": ["system:*"], "is_builtin": True, "is_assignable": True, + "created_at": now, "updated_at": now, + }, + { + "id": system_auditor_role_id, "tenant_id": None, "slug": "system_auditor", "name": "System auditor", + "description": "Read tenant registry, system access and cross-tenant audit records.", + "permissions": ["system:tenants:read", "system:access:read", "system:audit:read"], + "is_builtin": True, "is_assignable": True, "created_at": now, "updated_at": now, + }, + ]) + + op.create_index( + "uq_roles_system_slug", + "roles", + ["slug"], + unique=True, + sqlite_where=sa.text("tenant_id IS NULL"), + postgresql_where=sa.text("tenant_id IS NULL"), + ) + + # Preserve the old tenant-admin shortcut by assigning the canonical owner + # role. Authorization no longer reads users.is_tenant_admin after upgrade. + for row in users: + if not row["is_tenant_admin"]: + continue + owner_role_id = tenant_role_ids[(row["tenant_id"], "owner")] + exists = bind.execute(sa.text( + "SELECT 1 FROM user_role_assignments WHERE tenant_id = :tenant_id AND user_id = :user_id AND role_id = :role_id" + ), {"tenant_id": row["tenant_id"], "user_id": row["id"], "role_id": owner_role_id}).first() + if not exists: + bind.execute(sa.text( + "INSERT INTO user_role_assignments (id, tenant_id, user_id, role_id, created_at, updated_at) VALUES (:id, :tenant_id, :user_id, :role_id, :created_at, :updated_at)" + ), {"id": str(uuid.uuid4()), "tenant_id": row["tenant_id"], "user_id": row["id"], "role_id": owner_role_id, "created_at": now, "updated_at": now}) + + # Bootstrap rule for existing installations: the earliest active legacy + # tenant administrator becomes the initial system owner. This prevents an + # upgrade from producing an instance with no actor able to create tenants or + # delegate system access. It can be changed immediately through Admin. + if first_system_owner_account_id is None and account_rows: + first_system_owner_account_id = next(( + account_id for account_id, account in account_rows.items() if account["is_active"] + ), None) + if first_system_owner_account_id: + bind.execute(sa.text( + "INSERT INTO system_role_assignments (id, account_id, role_id, created_at, updated_at) VALUES (:id, :account_id, :role_id, :created_at, :updated_at)" + ), {"id": str(uuid.uuid4()), "account_id": first_system_owner_account_id, "role_id": system_owner_role_id, "created_at": now, "updated_at": now}) + + +def downgrade() -> None: + op.drop_index("uq_roles_system_slug", table_name="roles") + op.drop_index(op.f("ix_system_role_assignments_role_id"), table_name="system_role_assignments") + op.drop_index(op.f("ix_system_role_assignments_account_id"), table_name="system_role_assignments") + op.drop_table("system_role_assignments") + + op.drop_index(op.f("ix_auth_sessions_account_id"), table_name="auth_sessions") + with op.batch_alter_table("auth_sessions") as batch_op: + batch_op.drop_constraint("fk_auth_sessions_account_id_accounts", type_="foreignkey") + batch_op.drop_column("account_id") + + op.drop_index(op.f("ix_users_account_id"), table_name="users") + with op.batch_alter_table("users") as batch_op: + batch_op.drop_constraint("uq_users_tenant_account", type_="unique") + batch_op.drop_constraint("fk_users_account_id_accounts", type_="foreignkey") + batch_op.drop_column("account_id") + + with op.batch_alter_table("roles") as batch_op: + batch_op.drop_column("is_assignable") + batch_op.drop_column("is_builtin") + batch_op.drop_column("description") + + with op.batch_alter_table("groups") as batch_op: + batch_op.drop_column("is_active") + batch_op.drop_column("description") + + with op.batch_alter_table("tenants") as batch_op: + batch_op.drop_column("settings") + batch_op.drop_column("default_locale") + batch_op.drop_column("description") + + op.drop_index(op.f("ix_accounts_normalized_email"), table_name="accounts") + op.drop_table("accounts") diff --git a/alembic/versions/9d0e1f2a3b4c_system_governance_templates.py b/alembic/versions/9d0e1f2a3b4c_system_governance_templates.py new file mode 100644 index 0000000..ce06118 --- /dev/null +++ b/alembic/versions/9d0e1f2a3b4c_system_governance_templates.py @@ -0,0 +1,163 @@ +"""system governance settings and reusable tenant templates + +Revision ID: 9d0e1f2a3b4c +Revises: 8c9d0e1f2a3b +Create Date: 2026-06-15 00:00:00.000000 +""" +from __future__ import annotations + +from datetime import datetime, timezone +import json + +from alembic import op +import sqlalchemy as sa + +revision = "9d0e1f2a3b4c" +down_revision = "8c9d0e1f2a3b" +branch_labels = None +depends_on = None + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + + # Reconcile only the empty create_all shape for the newly introduced tables. + for table_name in ("governance_template_assignments", "governance_templates", "system_settings"): + if table_name in tables: + count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one() + if count: + raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}") + op.drop_table(table_name) + + op.create_table( + "system_settings", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("default_locale", sa.String(length=20), nullable=False, server_default="en"), + sa.Column("allow_tenant_custom_groups", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("allow_tenant_custom_roles", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("allow_tenant_api_keys", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("settings", sa.JSON(), nullable=False, server_default="{}"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + now = _now() + bind.execute(sa.text( + """ + INSERT INTO system_settings + (id, default_locale, allow_tenant_custom_groups, allow_tenant_custom_roles, + allow_tenant_api_keys, settings, created_at, updated_at) + VALUES + ('global', 'en', 1, 1, 1, '{}', :created_at, :updated_at) + """ + ), {"created_at": now, "updated_at": now}) + + op.create_table( + "governance_templates", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("kind", sa.String(length=20), nullable=False), + sa.Column("slug", sa.String(length=100), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("permissions", sa.JSON(), nullable=False, server_default="[]"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"), + ) + op.create_index(op.f("ix_governance_templates_kind"), "governance_templates", ["kind"]) + + op.create_table( + "governance_template_assignments", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("template_id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("mode", sa.String(length=20), nullable=False, server_default="available"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["template_id"], ["governance_templates.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"), + ) + op.create_index(op.f("ix_governance_template_assignments_template_id"), "governance_template_assignments", ["template_id"]) + op.create_index(op.f("ix_governance_template_assignments_tenant_id"), "governance_template_assignments", ["tenant_id"]) + + with op.batch_alter_table("tenants") as batch_op: + batch_op.add_column(sa.Column("allow_custom_groups", sa.Boolean(), nullable=True)) + batch_op.add_column(sa.Column("allow_custom_roles", sa.Boolean(), nullable=True)) + batch_op.add_column(sa.Column("allow_api_keys", sa.Boolean(), nullable=True)) + + with op.batch_alter_table("groups") as batch_op: + batch_op.add_column(sa.Column("system_template_id", sa.String(length=36), nullable=True)) + batch_op.add_column(sa.Column("system_required", sa.Boolean(), nullable=False, server_default=sa.false())) + batch_op.create_foreign_key( + "fk_groups_system_template_id_governance_templates", + "governance_templates", ["system_template_id"], ["id"], ondelete="SET NULL", + ) + op.create_index(op.f("ix_groups_system_template_id"), "groups", ["system_template_id"]) + + with op.batch_alter_table("roles") as batch_op: + batch_op.add_column(sa.Column("system_template_id", sa.String(length=36), nullable=True)) + batch_op.add_column(sa.Column("system_required", sa.Boolean(), nullable=False, server_default=sa.false())) + batch_op.create_foreign_key( + "fk_roles_system_template_id_governance_templates", + "governance_templates", ["system_template_id"], ["id"], ondelete="SET NULL", + ) + op.create_index(op.f("ix_roles_system_template_id"), "roles", ["system_template_id"]) + + # Existing system owners use system:* and need no data change. Extend the + # read-only built-in auditor role to the newly introduced read scopes. + auditor = bind.execute(sa.text( + "SELECT id, permissions FROM roles WHERE tenant_id IS NULL AND slug = 'system_auditor'" + )).mappings().first() + if auditor: + raw_permissions = auditor["permissions"] or [] + permissions = json.loads(raw_permissions) if isinstance(raw_permissions, str) else list(raw_permissions) + for scope in ("system:settings:read", "system:governance:read"): + if scope not in permissions: + permissions.append(scope) + roles_table = sa.table( + "roles", + sa.column("id", sa.String), + sa.column("permissions", sa.JSON), + sa.column("updated_at", sa.DateTime(timezone=True)), + ) + bind.execute( + roles_table.update().where(roles_table.c.id == auditor["id"]).values( + permissions=permissions, updated_at=now + ) + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_roles_system_template_id"), table_name="roles") + with op.batch_alter_table("roles") as batch_op: + batch_op.drop_constraint("fk_roles_system_template_id_governance_templates", type_="foreignkey") + batch_op.drop_column("system_required") + batch_op.drop_column("system_template_id") + + op.drop_index(op.f("ix_groups_system_template_id"), table_name="groups") + with op.batch_alter_table("groups") as batch_op: + batch_op.drop_constraint("fk_groups_system_template_id_governance_templates", type_="foreignkey") + batch_op.drop_column("system_required") + batch_op.drop_column("system_template_id") + + with op.batch_alter_table("tenants") as batch_op: + batch_op.drop_column("allow_api_keys") + batch_op.drop_column("allow_custom_roles") + batch_op.drop_column("allow_custom_groups") + + op.drop_index(op.f("ix_governance_template_assignments_tenant_id"), table_name="governance_template_assignments") + op.drop_index(op.f("ix_governance_template_assignments_template_id"), table_name="governance_template_assignments") + op.drop_table("governance_template_assignments") + op.drop_index(op.f("ix_governance_templates_kind"), table_name="governance_templates") + op.drop_table("governance_templates") + op.drop_table("system_settings") diff --git a/alembic/versions/a0b1c2d3e4f5_permission_catalogue_refinement.py b/alembic/versions/a0b1c2d3e4f5_permission_catalogue_refinement.py new file mode 100644 index 0000000..b199b6c --- /dev/null +++ b/alembic/versions/a0b1c2d3e4f5_permission_catalogue_refinement.py @@ -0,0 +1,216 @@ +"""refine permission catalogue, campaign ACLs and system-role model + +Revision ID: a0b1c2d3e4f5 +Revises: 9d0e1f2a3b4c +Create Date: 2026-06-15 10:30:00.000000 +""" +from __future__ import annotations + +from datetime import datetime, timezone +import json +from uuid import uuid4 + +from alembic import op +import sqlalchemy as sa + +revision = "a0b1c2d3e4f5" +down_revision = "9d0e1f2a3b4c" +branch_labels = None +depends_on = None + +TENANT_ROLE_PERMISSIONS = { + "owner": ["tenant:*"], + "tenant_admin": [ + "campaign:read", "files:read", "reports:read", "audit:read", + "admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend", + "admin:groups:read", "admin:groups:write", "admin:groups:manage_members", + "admin:roles:read", "admin:roles:write", "admin:roles:assign", + "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke", + "admin:settings:read", "admin:settings:write", "admin:policies:read", "admin:policies:write", + ], + "admin": ["tenant:*"], + "access_admin": [ + "admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend", + "admin:groups:read", "admin:groups:manage_members", "admin:roles:read", "admin:roles:assign", + ], + "campaign_manager": [ + "campaign:read", "campaign:create", "campaign:update", "campaign:copy", "campaign:validate", "campaign:build", + "recipients:read", "recipients:write", "recipients:import", "files:read", "files:download", "files:upload", "files:organize", "reports:read", + ], + "reviewer": ["campaign:read", "campaign:validate", "campaign:review", "recipients:read", "files:read", "reports:read"], + "sender": [ + "campaign:read", "campaign:send_test", "campaign:queue", "campaign:control", "campaign:send", "campaign:retry", "campaign:reconcile", + "recipients:read", "files:read", "reports:read", "reports:send", "mail_servers:use", "mail_servers:test", + ], + "file_manager": ["files:read", "files:download", "files:upload", "files:organize", "files:share", "files:delete"], + "viewer": ["campaign:read", "recipients:read", "files:read", "reports:read"], + "auditor": ["campaign:read", "recipients:read", "recipients:export", "reports:read", "reports:export", "audit:read"], +} + +SYSTEM_ALL = [ + "system:tenants:read", "system:tenants:create", "system:tenants:update", "system:tenants:suspend", + "system:accounts:read", "system:accounts:create", "system:accounts:update", "system:accounts:suspend", + "system:roles:read", "system:roles:write", "system:roles:assign", + "system:access:read", "system:access:assign", + "system:audit:read", "system:settings:read", "system:settings:write", + "system:governance:read", "system:governance:write", +] +SYSTEM_ROLE_PERMISSIONS = { + "system_owner": ["system:*"], + "system_admin": SYSTEM_ALL, + "system_auditor": [ + "system:tenants:read", "system:accounts:read", "system:roles:read", "system:access:read", + "system:audit:read", "system:settings:read", "system:governance:read", + ], +} + +# Some names were canonical in the old catalogue but represented a wider bundle. +# Expand every existing role once during upgrade; runtime authorization then treats +# the new canonical names narrowly. +LEGACY_EXPANSIONS = { + "campaign:write": { + "campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share", + "recipients:read", "recipients:write", "recipients:import", + }, + "campaign:queue": {"campaign:queue", "campaign:control", "campaign:retry"}, + "campaign:send": {"campaign:send", "campaign:reconcile"}, + "attachments:read": {"files:read", "files:download"}, + "attachments:write": {"files:upload", "files:organize", "files:share", "files:delete"}, + "reports:read": {"reports:read", "reports:export"}, + "admin:users": { + "admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend", + "admin:groups:read", "admin:groups:write", "admin:groups:manage_members", + "admin:roles:read", "admin:roles:write", "admin:roles:assign", + }, + "admin:users:write": {"admin:users:create", "admin:users:update", "admin:users:suspend", "admin:roles:assign", "admin:groups:manage_members"}, + "admin:groups:write": {"admin:groups:write", "admin:groups:manage_members", "admin:roles:assign"}, + "admin:roles:write": {"admin:roles:write", "admin:roles:assign"}, + "admin:api_keys:write": {"admin:api_keys:create", "admin:api_keys:revoke"}, + "admin:settings": {"admin:settings:read", "admin:settings:write", "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke"}, + "system:tenants:write": {"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}, + "system:access:read": {"system:access:read", "system:accounts:read", "system:roles:read"}, + "system:access:write": {"system:access:assign", "system:roles:assign", "system:accounts:create", "system:accounts:update", "system:accounts:suspend"}, +} + + +def _json(value: list[str]) -> str: + return json.dumps(value, separators=(",", ":")) + + +def _decode(value) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + try: + decoded = json.loads(value) + return [str(item) for item in decoded] if isinstance(decoded, list) else [] + except json.JSONDecodeError: + return [] + return [] + + +def _expand_legacy(scopes: list[str]) -> list[str]: + expanded: set[str] = set() + for scope in scopes: + replacement = LEGACY_EXPANSIONS.get(scope) + if replacement: + expanded.update(replacement) + else: + expanded.add(scope) + return sorted(expanded) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + if "campaigns" in tables: + columns = {col["name"] for col in inspector.get_columns("campaigns")} + if "owner_user_id" not in columns: + op.add_column("campaigns", sa.Column("owner_user_id", sa.String(length=36), nullable=True)) + op.create_index("ix_campaigns_owner_user_id", "campaigns", ["owner_user_id"]) + if "owner_group_id" not in columns: + op.add_column("campaigns", sa.Column("owner_group_id", sa.String(length=36), nullable=True)) + op.create_index("ix_campaigns_owner_group_id", "campaigns", ["owner_group_id"]) + bind.execute(sa.text("UPDATE campaigns SET owner_user_id = created_by_user_id WHERE owner_user_id IS NULL")) + if "campaign_shares" not in tables: + op.create_table( + "campaign_shares", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("campaign_id", sa.String(length=36), nullable=False), + sa.Column("target_type", sa.String(length=20), nullable=False), + sa.Column("target_id", sa.String(length=36), nullable=False), + sa.Column("permission", sa.String(length=20), nullable=False, server_default="read"), + sa.Column("created_by_user_id", sa.String(length=36), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"), + ) + op.create_index("ix_campaign_shares_tenant_id", "campaign_shares", ["tenant_id"]) + op.create_index("ix_campaign_shares_campaign_id", "campaign_shares", ["campaign_id"]) + op.create_index("ix_campaign_shares_target_type", "campaign_shares", ["target_type"]) + op.create_index("ix_campaign_shares_target_id", "campaign_shares", ["target_id"]) + op.create_index("ix_campaign_shares_created_by_user_id", "campaign_shares", ["created_by_user_id"]) + op.create_index("ix_campaign_shares_revoked_at", "campaign_shares", ["revoked_at"]) + if "roles" not in tables: + return + + rows = bind.execute(sa.text("SELECT id, permissions FROM roles")).mappings().all() + for row in rows: + bind.execute( + sa.text("UPDATE roles SET permissions = :permissions WHERE id = :id"), + {"id": row["id"], "permissions": _json(_expand_legacy(_decode(row["permissions"])))}, + ) + + for slug, permissions in TENANT_ROLE_PERMISSIONS.items(): + bind.execute( + sa.text("UPDATE roles SET permissions = :permissions WHERE tenant_id IS NOT NULL AND slug = :slug AND is_builtin = TRUE"), + {"slug": slug, "permissions": _json(permissions)}, + ) + + now = datetime.now(timezone.utc) + for slug, permissions in SYSTEM_ROLE_PERMISSIONS.items(): + existing = bind.execute( + sa.text("SELECT id FROM roles WHERE tenant_id IS NULL AND slug = :slug"), {"slug": slug} + ).first() + is_protected = slug == "system_owner" + if existing: + bind.execute( + sa.text( + "UPDATE roles SET permissions = :permissions, is_builtin = :is_builtin, is_assignable = TRUE " + "WHERE tenant_id IS NULL AND slug = :slug" + ), + {"slug": slug, "permissions": _json(permissions), "is_builtin": is_protected}, + ) + else: + names = { + "system_owner": ("System owner", "Protected full instance-wide administration."), + "system_admin": ("System administrator", "Manage the instance without granting the protected System owner role."), + "system_auditor": ("System auditor", "Read-only access to system administration and audit."), + } + name, description = names[slug] + bind.execute( + sa.text( + "INSERT INTO roles (id, tenant_id, slug, name, description, permissions, is_builtin, is_assignable, " + "system_template_id, system_required, created_at, updated_at) " + "VALUES (:id, NULL, :slug, :name, :description, :permissions, :is_builtin, TRUE, NULL, FALSE, :created_at, :updated_at)" + ), + { + "id": str(uuid4()), "slug": slug, "name": name, "description": description, + "permissions": _json(permissions), "is_builtin": is_protected, + "created_at": now, "updated_at": now, + }, + ) + + +def downgrade() -> None: + # ACL/evidence-bearing columns are intentionally retained on downgrade. + pass diff --git a/alembic/versions/b1c2d3e4f5a6_audit_scope_and_tenant_owner_selection.py b/alembic/versions/b1c2d3e4f5a6_audit_scope_and_tenant_owner_selection.py new file mode 100644 index 0000000..5632603 --- /dev/null +++ b/alembic/versions/b1c2d3e4f5a6_audit_scope_and_tenant_owner_selection.py @@ -0,0 +1,69 @@ +"""separate system and tenant audit scopes + +Revision ID: b1c2d3e4f5a6 +Revises: a0b1c2d3e4f5 +Create Date: 2026-06-15 13:30:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "b1c2d3e4f5a6" +down_revision = "a0b1c2d3e4f5" +branch_labels = None +depends_on = None + +SYSTEM_ACTIONS = ( + "tenant.created", + "tenant.updated", + "system_role.created", + "system_role.updated", + "system_role.deleted", + "system_account.created", + "system_account.updated", + "system_access.updated", + "system_memberships.updated", + "system_settings.updated", + "governance_template.created", + "governance_template.updated", + "governance_template.deleted", +) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "audit_log" not in inspector.get_table_names(): + return + columns = {column["name"] for column in inspector.get_columns("audit_log")} + if "scope" not in columns: + op.add_column( + "audit_log", + sa.Column("scope", sa.String(length=20), nullable=False, server_default="tenant"), + ) + indexes = {index["name"] for index in sa.inspect(bind).get_indexes("audit_log")} + if "ix_audit_log_scope" not in indexes: + op.create_index("ix_audit_log_scope", "audit_log", ["scope"]) + + placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS)) + params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)} + bind.execute( + sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"), + params, + ) + bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')")) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "audit_log" not in inspector.get_table_names(): + return + indexes = {index["name"] for index in inspector.get_indexes("audit_log")} + if "ix_audit_log_scope" in indexes: + op.drop_index("ix_audit_log_scope", table_name="audit_log") + columns = {column["name"] for column in sa.inspect(bind).get_columns("audit_log")} + if "scope" in columns: + with op.batch_alter_table("audit_log") as batch_op: + batch_op.drop_column("scope") diff --git a/alembic/versions/b57c5b216bce_initial_persistence_models.py b/alembic/versions/b57c5b216bce_initial_persistence_models.py new file mode 100644 index 0000000..3f4f933 --- /dev/null +++ b/alembic/versions/b57c5b216bce_initial_persistence_models.py @@ -0,0 +1,346 @@ +"""initial persistence models + +Revision ID: b57c5b216bce +Revises: +Create Date: 2026-06-07 19:29:10.222504 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = 'b57c5b216bce' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tenants', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('slug', sa.String(length=100), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_tenants')) + ) + op.create_index(op.f('ix_tenants_slug'), 'tenants', ['slug'], unique=True) + op.create_table('attachment_blobs', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('sha256', sa.String(length=64), nullable=False), + sa.Column('size_bytes', sa.Integer(), nullable=False), + sa.Column('mime_type', sa.String(length=255), nullable=True), + sa.Column('storage_bucket', sa.String(length=255), nullable=False), + sa.Column('storage_key', sa.String(length=1000), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_attachment_blobs_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_attachment_blobs')), + sa.UniqueConstraint('tenant_id', 'sha256', name='uq_attachment_blobs_tenant_sha256') + ) + op.create_index(op.f('ix_attachment_blobs_sha256'), 'attachment_blobs', ['sha256'], unique=False) + op.create_index(op.f('ix_attachment_blobs_tenant_id'), 'attachment_blobs', ['tenant_id'], unique=False) + op.create_table('groups', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('slug', sa.String(length=100), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_groups_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_groups')), + sa.UniqueConstraint('tenant_id', 'slug', name='uq_groups_tenant_slug') + ) + op.create_index(op.f('ix_groups_tenant_id'), 'groups', ['tenant_id'], unique=False) + op.create_table('roles', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=True), + sa.Column('slug', sa.String(length=100), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('permissions', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_roles_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_roles')), + sa.UniqueConstraint('tenant_id', 'slug', name='uq_roles_tenant_slug') + ) + op.create_index(op.f('ix_roles_tenant_id'), 'roles', ['tenant_id'], unique=False) + op.create_table('users', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('email', sa.String(length=320), nullable=False), + sa.Column('display_name', sa.String(length=255), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_tenant_admin', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_users_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_users')), + sa.UniqueConstraint('tenant_id', 'email', name='uq_users_tenant_email') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=False) + op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False) + op.create_table('api_keys', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('prefix', sa.String(length=16), nullable=False), + sa.Column('key_hash', sa.String(length=128), nullable=False), + sa.Column('scopes', sa.JSON(), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_api_keys_tenant_id_tenants'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_api_keys_user_id_users'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_api_keys')) + ) + op.create_index(op.f('ix_api_keys_prefix'), 'api_keys', ['prefix'], unique=False) + op.create_index(op.f('ix_api_keys_tenant_id'), 'api_keys', ['tenant_id'], unique=False) + op.create_index(op.f('ix_api_keys_user_id'), 'api_keys', ['user_id'], unique=False) + op.create_table('campaigns', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('created_by_user_id', sa.String(length=36), nullable=True), + sa.Column('external_id', sa.String(length=255), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('current_version_id', sa.String(length=36), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], name=op.f('fk_campaigns_created_by_user_id_users'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_campaigns_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_campaigns')), + sa.UniqueConstraint('tenant_id', 'external_id', name='uq_campaigns_tenant_external_id') + ) + op.create_index(op.f('ix_campaigns_created_by_user_id'), 'campaigns', ['created_by_user_id'], unique=False) + op.create_index(op.f('ix_campaigns_external_id'), 'campaigns', ['external_id'], unique=False) + op.create_index(op.f('ix_campaigns_status'), 'campaigns', ['status'], unique=False) + op.create_index(op.f('ix_campaigns_tenant_id'), 'campaigns', ['tenant_id'], unique=False) + op.create_table('attachment_instances', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('owner_user_id', sa.String(length=36), nullable=True), + sa.Column('campaign_id', sa.String(length=36), nullable=True), + sa.Column('blob_id', sa.String(length=36), nullable=False), + sa.Column('logical_name', sa.String(length=500), nullable=True), + sa.Column('filename', sa.String(length=500), nullable=False), + sa.Column('tags', sa.JSON(), nullable=False), + sa.Column('metadata', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['blob_id'], ['attachment_blobs.id'], name=op.f('fk_attachment_instances_blob_id_attachment_blobs'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_attachment_instances_campaign_id_campaigns'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], name=op.f('fk_attachment_instances_owner_user_id_users'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_attachment_instances_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_attachment_instances')) + ) + op.create_index(op.f('ix_attachment_instances_blob_id'), 'attachment_instances', ['blob_id'], unique=False) + op.create_index(op.f('ix_attachment_instances_campaign_id'), 'attachment_instances', ['campaign_id'], unique=False) + op.create_index(op.f('ix_attachment_instances_owner_user_id'), 'attachment_instances', ['owner_user_id'], unique=False) + op.create_index(op.f('ix_attachment_instances_tenant_id'), 'attachment_instances', ['tenant_id'], unique=False) + op.create_table('audit_log', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=True), + sa.Column('user_id', sa.String(length=36), nullable=True), + sa.Column('api_key_id', sa.String(length=36), nullable=True), + sa.Column('action', sa.String(length=100), nullable=False), + sa.Column('object_type', sa.String(length=100), nullable=True), + sa.Column('object_id', sa.String(length=100), nullable=True), + sa.Column('details', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], name=op.f('fk_audit_log_api_key_id_api_keys'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_audit_log_tenant_id_tenants'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_audit_log_user_id_users'), ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_audit_log')) + ) + op.create_index(op.f('ix_audit_log_action'), 'audit_log', ['action'], unique=False) + op.create_index(op.f('ix_audit_log_api_key_id'), 'audit_log', ['api_key_id'], unique=False) + op.create_index(op.f('ix_audit_log_object_id'), 'audit_log', ['object_id'], unique=False) + op.create_index(op.f('ix_audit_log_object_type'), 'audit_log', ['object_type'], unique=False) + op.create_index(op.f('ix_audit_log_tenant_id'), 'audit_log', ['tenant_id'], unique=False) + op.create_index(op.f('ix_audit_log_user_id'), 'audit_log', ['user_id'], unique=False) + op.create_table('campaign_versions', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('campaign_id', sa.String(length=36), nullable=False), + sa.Column('version_number', sa.Integer(), nullable=False), + sa.Column('raw_json', sa.JSON(), nullable=False), + sa.Column('schema_version', sa.String(length=50), nullable=False), + sa.Column('source_filename', sa.String(length=500), nullable=True), + sa.Column('validation_summary', sa.JSON(), nullable=True), + sa.Column('build_summary', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_versions_campaign_id_campaigns'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_versions')), + sa.UniqueConstraint('campaign_id', 'version_number', name='uq_campaign_versions_campaign_number') + ) + op.create_index(op.f('ix_campaign_versions_campaign_id'), 'campaign_versions', ['campaign_id'], unique=False) + op.create_table('campaign_jobs', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('campaign_id', sa.String(length=36), nullable=False), + sa.Column('campaign_version_id', sa.String(length=36), nullable=False), + sa.Column('entry_index', sa.Integer(), nullable=False), + sa.Column('entry_id', sa.String(length=255), nullable=True), + sa.Column('recipient_email', sa.String(length=320), nullable=True), + sa.Column('subject', sa.String(length=998), nullable=True), + sa.Column('message_id_header', sa.String(length=255), nullable=True), + sa.Column('eml_storage_key', sa.String(length=1000), nullable=True), + sa.Column('eml_local_path', sa.String(length=1000), nullable=True), + sa.Column('eml_size_bytes', sa.Integer(), nullable=True), + sa.Column('build_status', sa.String(length=50), nullable=False), + sa.Column('validation_status', sa.String(length=50), nullable=False), + sa.Column('queue_status', sa.String(length=50), nullable=False), + sa.Column('send_status', sa.String(length=50), nullable=False), + sa.Column('imap_status', sa.String(length=50), nullable=False), + sa.Column('attempt_count', sa.Integer(), nullable=False), + sa.Column('last_error', sa.Text(), nullable=True), + sa.Column('queued_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('resolved_recipients', sa.JSON(), nullable=True), + sa.Column('resolved_attachments', sa.JSON(), nullable=False), + sa.Column('issues_snapshot', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_jobs_campaign_id_campaigns'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['campaign_version_id'], ['campaign_versions.id'], name=op.f('fk_campaign_jobs_campaign_version_id_campaign_versions'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_campaign_jobs_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_jobs')), + sa.UniqueConstraint('campaign_version_id', 'entry_index', name='uq_campaign_jobs_version_entry') + ) + op.create_index(op.f('ix_campaign_jobs_build_status'), 'campaign_jobs', ['build_status'], unique=False) + op.create_index(op.f('ix_campaign_jobs_campaign_id'), 'campaign_jobs', ['campaign_id'], unique=False) + op.create_index(op.f('ix_campaign_jobs_campaign_version_id'), 'campaign_jobs', ['campaign_version_id'], unique=False) + op.create_index(op.f('ix_campaign_jobs_entry_id'), 'campaign_jobs', ['entry_id'], unique=False) + op.create_index(op.f('ix_campaign_jobs_imap_status'), 'campaign_jobs', ['imap_status'], unique=False) + op.create_index(op.f('ix_campaign_jobs_queue_status'), 'campaign_jobs', ['queue_status'], unique=False) + op.create_index(op.f('ix_campaign_jobs_recipient_email'), 'campaign_jobs', ['recipient_email'], unique=False) + op.create_index(op.f('ix_campaign_jobs_send_status'), 'campaign_jobs', ['send_status'], unique=False) + op.create_index(op.f('ix_campaign_jobs_tenant_id'), 'campaign_jobs', ['tenant_id'], unique=False) + op.create_index(op.f('ix_campaign_jobs_validation_status'), 'campaign_jobs', ['validation_status'], unique=False) + op.create_table('campaign_issues', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('campaign_id', sa.String(length=36), nullable=False), + sa.Column('campaign_version_id', sa.String(length=36), nullable=True), + sa.Column('job_id', sa.String(length=36), nullable=True), + sa.Column('severity', sa.String(length=20), nullable=False), + sa.Column('code', sa.String(length=100), nullable=False), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('source', sa.String(length=255), nullable=True), + sa.Column('behavior', sa.String(length=50), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_issues_campaign_id_campaigns'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['campaign_version_id'], ['campaign_versions.id'], name=op.f('fk_campaign_issues_campaign_version_id_campaign_versions'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['job_id'], ['campaign_jobs.id'], name=op.f('fk_campaign_issues_job_id_campaign_jobs'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_campaign_issues_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_issues')) + ) + op.create_index(op.f('ix_campaign_issues_campaign_id'), 'campaign_issues', ['campaign_id'], unique=False) + op.create_index(op.f('ix_campaign_issues_campaign_version_id'), 'campaign_issues', ['campaign_version_id'], unique=False) + op.create_index(op.f('ix_campaign_issues_code'), 'campaign_issues', ['code'], unique=False) + op.create_index(op.f('ix_campaign_issues_job_id'), 'campaign_issues', ['job_id'], unique=False) + op.create_index(op.f('ix_campaign_issues_severity'), 'campaign_issues', ['severity'], unique=False) + op.create_index(op.f('ix_campaign_issues_tenant_id'), 'campaign_issues', ['tenant_id'], unique=False) + op.create_table('imap_append_attempts', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('job_id', sa.String(length=36), nullable=False), + sa.Column('attempt_number', sa.Integer(), nullable=False), + sa.Column('folder', sa.String(length=500), nullable=True), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['job_id'], ['campaign_jobs.id'], name=op.f('fk_imap_append_attempts_job_id_campaign_jobs'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_imap_append_attempts')) + ) + op.create_index(op.f('ix_imap_append_attempts_job_id'), 'imap_append_attempts', ['job_id'], unique=False) + op.create_table('send_attempts', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('job_id', sa.String(length=36), nullable=False), + sa.Column('attempt_number', sa.Integer(), nullable=False), + sa.Column('smtp_status_code', sa.Integer(), nullable=True), + sa.Column('smtp_response', sa.Text(), nullable=True), + sa.Column('error_type', sa.String(length=255), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['job_id'], ['campaign_jobs.id'], name=op.f('fk_send_attempts_job_id_campaign_jobs'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_send_attempts')) + ) + op.create_index(op.f('ix_send_attempts_job_id'), 'send_attempts', ['job_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_send_attempts_job_id'), table_name='send_attempts') + op.drop_table('send_attempts') + op.drop_index(op.f('ix_imap_append_attempts_job_id'), table_name='imap_append_attempts') + op.drop_table('imap_append_attempts') + op.drop_index(op.f('ix_campaign_issues_tenant_id'), table_name='campaign_issues') + op.drop_index(op.f('ix_campaign_issues_severity'), table_name='campaign_issues') + op.drop_index(op.f('ix_campaign_issues_job_id'), table_name='campaign_issues') + op.drop_index(op.f('ix_campaign_issues_code'), table_name='campaign_issues') + op.drop_index(op.f('ix_campaign_issues_campaign_version_id'), table_name='campaign_issues') + op.drop_index(op.f('ix_campaign_issues_campaign_id'), table_name='campaign_issues') + op.drop_table('campaign_issues') + op.drop_index(op.f('ix_campaign_jobs_validation_status'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_tenant_id'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_send_status'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_recipient_email'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_queue_status'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_imap_status'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_entry_id'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_campaign_version_id'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_campaign_id'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_build_status'), table_name='campaign_jobs') + op.drop_table('campaign_jobs') + op.drop_index(op.f('ix_campaign_versions_campaign_id'), table_name='campaign_versions') + op.drop_table('campaign_versions') + op.drop_index(op.f('ix_audit_log_user_id'), table_name='audit_log') + op.drop_index(op.f('ix_audit_log_tenant_id'), table_name='audit_log') + op.drop_index(op.f('ix_audit_log_object_type'), table_name='audit_log') + op.drop_index(op.f('ix_audit_log_object_id'), table_name='audit_log') + op.drop_index(op.f('ix_audit_log_api_key_id'), table_name='audit_log') + op.drop_index(op.f('ix_audit_log_action'), table_name='audit_log') + op.drop_table('audit_log') + op.drop_index(op.f('ix_attachment_instances_tenant_id'), table_name='attachment_instances') + op.drop_index(op.f('ix_attachment_instances_owner_user_id'), table_name='attachment_instances') + op.drop_index(op.f('ix_attachment_instances_campaign_id'), table_name='attachment_instances') + op.drop_index(op.f('ix_attachment_instances_blob_id'), table_name='attachment_instances') + op.drop_table('attachment_instances') + op.drop_index(op.f('ix_campaigns_tenant_id'), table_name='campaigns') + op.drop_index(op.f('ix_campaigns_status'), table_name='campaigns') + op.drop_index(op.f('ix_campaigns_external_id'), table_name='campaigns') + op.drop_index(op.f('ix_campaigns_created_by_user_id'), table_name='campaigns') + op.drop_table('campaigns') + op.drop_index(op.f('ix_api_keys_user_id'), table_name='api_keys') + op.drop_index(op.f('ix_api_keys_tenant_id'), table_name='api_keys') + op.drop_index(op.f('ix_api_keys_prefix'), table_name='api_keys') + op.drop_table('api_keys') + op.drop_index(op.f('ix_users_tenant_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + op.drop_index(op.f('ix_roles_tenant_id'), table_name='roles') + op.drop_table('roles') + op.drop_index(op.f('ix_groups_tenant_id'), table_name='groups') + op.drop_table('groups') + op.drop_index(op.f('ix_attachment_blobs_tenant_id'), table_name='attachment_blobs') + op.drop_index(op.f('ix_attachment_blobs_sha256'), table_name='attachment_blobs') + op.drop_table('attachment_blobs') + op.drop_index(op.f('ix_tenants_slug'), table_name='tenants') + op.drop_table('tenants') + # ### end Alembic commands ### diff --git a/alembic/versions/c2d3e4f5a6b7_audit_pagination_indexes.py b/alembic/versions/c2d3e4f5a6b7_audit_pagination_indexes.py new file mode 100644 index 0000000..bcd0e6d --- /dev/null +++ b/alembic/versions/c2d3e4f5a6b7_audit_pagination_indexes.py @@ -0,0 +1,42 @@ +"""add composite indexes for paginated audit queries + +Revision ID: c2d3e4f5a6b7 +Revises: b1c2d3e4f5a6 +Create Date: 2026-06-15 17:30:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "c2d3e4f5a6b7" +down_revision = "b1c2d3e4f5a6" +branch_labels = None +depends_on = None + +INDEXES: tuple[tuple[str, list[str]], ...] = ( + ("ix_audit_log_scope_created_at", ["scope", "created_at"]), + ("ix_audit_log_tenant_scope_created_at", ["tenant_id", "scope", "created_at"]), +) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "audit_log" not in inspector.get_table_names(): + return + existing = {index["name"] for index in inspector.get_indexes("audit_log")} + for name, columns in INDEXES: + if name not in existing: + op.create_index(name, "audit_log", columns) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "audit_log" not in inspector.get_table_names(): + return + existing = {index["name"] for index in inspector.get_indexes("audit_log")} + for name, _columns in reversed(INDEXES): + if name in existing: + op.drop_index(name, table_name="audit_log") diff --git a/alembic/versions/d3e4f5a6b7c8_mail_profiles_and_cookie_csrf.py b/alembic/versions/d3e4f5a6b7c8_mail_profiles_and_cookie_csrf.py new file mode 100644 index 0000000..5b8985a --- /dev/null +++ b/alembic/versions/d3e4f5a6b7c8_mail_profiles_and_cookie_csrf.py @@ -0,0 +1,73 @@ +"""add encrypted mail profiles and session csrf hashes + +Revision ID: d3e4f5a6b7c8 +Revises: c2d3e4f5a6b7 +Create Date: 2026-06-16 11:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "d3e4f5a6b7c8" +down_revision = "c2d3e4f5a6b7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + if "auth_sessions" in tables: + columns = {column["name"] for column in inspector.get_columns("auth_sessions")} + if "csrf_token_hash" not in columns: + op.add_column("auth_sessions", sa.Column("csrf_token_hash", sa.String(length=128), nullable=True)) + if "mail_server_profiles" not in tables: + op.create_table( + "mail_server_profiles", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("slug", sa.String(length=100), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("smtp_config", sa.JSON(), nullable=False), + sa.Column("smtp_password_encrypted", sa.Text(), nullable=True), + sa.Column("imap_config", sa.JSON(), nullable=True), + sa.Column("imap_password_encrypted", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.String(length=36), nullable=True), + sa.Column("updated_by_user_id", sa.String(length=36), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "slug", name="uq_mail_server_profiles_tenant_slug"), + ) + op.create_index(op.f("ix_mail_server_profiles_tenant_id"), "mail_server_profiles", ["tenant_id"]) + op.create_index(op.f("ix_mail_server_profiles_is_active"), "mail_server_profiles", ["is_active"]) + op.create_index(op.f("ix_mail_server_profiles_created_by_user_id"), "mail_server_profiles", ["created_by_user_id"]) + op.create_index(op.f("ix_mail_server_profiles_updated_by_user_id"), "mail_server_profiles", ["updated_by_user_id"]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "mail_server_profiles" in inspector.get_table_names(): + for name in ( + op.f("ix_mail_server_profiles_updated_by_user_id"), + op.f("ix_mail_server_profiles_created_by_user_id"), + op.f("ix_mail_server_profiles_is_active"), + op.f("ix_mail_server_profiles_tenant_id"), + ): + try: + op.drop_index(name, table_name="mail_server_profiles") + except Exception: + pass + op.drop_table("mail_server_profiles") + if "auth_sessions" in inspector.get_table_names(): + columns = {column["name"] for column in inspector.get_columns("auth_sessions")} + if "csrf_token_hash" in columns: + op.drop_column("auth_sessions", "csrf_token_hash") diff --git a/alembic/versions/e4f5a6b7c8d9_mail_profile_policy_hierarchy.py b/alembic/versions/e4f5a6b7c8d9_mail_profile_policy_hierarchy.py new file mode 100644 index 0000000..d51bf62 --- /dev/null +++ b/alembic/versions/e4f5a6b7c8d9_mail_profile_policy_hierarchy.py @@ -0,0 +1,88 @@ +"""add mail profile scope and policy hierarchy + +Revision ID: e4f5a6b7c8d9 +Revises: d3e4f5a6b7c8 +Create Date: 2026-06-16 13:30:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "e4f5a6b7c8d9" +down_revision = "d3e4f5a6b7c8" +branch_labels = None +depends_on = None + + +_POLICY_DEFAULT = sa.text("'{}'") + + +def _columns(inspector: sa.Inspector, table_name: str) -> set[str]: + return {column["name"] for column in inspector.get_columns(table_name)} + + +def _indexes(inspector: sa.Inspector, table_name: str) -> set[str]: + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + + for table_name in ("users", "groups", "campaigns"): + if table_name not in tables: + continue + columns = _columns(inspector, table_name) + if "mail_profile_policy" not in columns: + op.add_column( + table_name, + sa.Column("mail_profile_policy", sa.JSON(), nullable=False, server_default=_POLICY_DEFAULT), + ) + + if "mail_server_profiles" not in tables: + return + + columns = _columns(inspector, "mail_server_profiles") + with op.batch_alter_table("mail_server_profiles") as batch: + if "scope_type" not in columns: + batch.add_column(sa.Column("scope_type", sa.String(length=20), nullable=False, server_default="tenant")) + if "scope_id" not in columns: + batch.add_column(sa.Column("scope_id", sa.String(length=36), nullable=True)) + batch.alter_column("tenant_id", existing_type=sa.String(length=36), nullable=True) + + op.execute("UPDATE mail_server_profiles SET scope_type = 'tenant' WHERE scope_type IS NULL OR scope_type = ''") + op.execute("UPDATE mail_server_profiles SET scope_id = tenant_id WHERE scope_id IS NULL AND tenant_id IS NOT NULL") + + inspector = sa.inspect(bind) + indexes = _indexes(inspector, "mail_server_profiles") + if "ix_mail_server_profiles_scope_type" not in indexes: + op.create_index(op.f("ix_mail_server_profiles_scope_type"), "mail_server_profiles", ["scope_type"]) + if "ix_mail_server_profiles_scope_id" not in indexes: + op.create_index(op.f("ix_mail_server_profiles_scope_id"), "mail_server_profiles", ["scope_id"]) + if "ix_mail_server_profiles_scope" not in indexes: + op.create_index("ix_mail_server_profiles_scope", "mail_server_profiles", ["scope_type", "scope_id"]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + + if "mail_server_profiles" in tables: + indexes = _indexes(inspector, "mail_server_profiles") + for name in ("ix_mail_server_profiles_scope", op.f("ix_mail_server_profiles_scope_id"), op.f("ix_mail_server_profiles_scope_type")): + if name in indexes: + op.drop_index(name, table_name="mail_server_profiles") + columns = _columns(inspector, "mail_server_profiles") + with op.batch_alter_table("mail_server_profiles") as batch: + if "scope_id" in columns: + batch.drop_column("scope_id") + if "scope_type" in columns: + batch.drop_column("scope_type") + batch.alter_column("tenant_id", existing_type=sa.String(length=36), nullable=False) + + for table_name in ("campaigns", "groups", "users"): + if table_name in tables and "mail_profile_policy" in _columns(inspector, table_name): + op.drop_column(table_name, "mail_profile_policy") diff --git a/alembic/versions/f5a6b7c8d9e0_hierarchical_settings.py b/alembic/versions/f5a6b7c8d9e0_hierarchical_settings.py new file mode 100644 index 0000000..5cc99f0 --- /dev/null +++ b/alembic/versions/f5a6b7c8d9e0_hierarchical_settings.py @@ -0,0 +1,41 @@ +"""add generic settings hierarchy columns + +Revision ID: f5a6b7c8d9e0 +Revises: e4f5a6b7c8d9 +Create Date: 2026-06-16 15:15:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "f5a6b7c8d9e0" +down_revision = "e4f5a6b7c8d9" +branch_labels = None +depends_on = None + +_SETTINGS_DEFAULT = sa.text("'{}'") + + +def _columns(inspector: sa.Inspector, table_name: str) -> set[str]: + return {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + for table_name in ("users", "groups", "campaigns"): + if table_name not in tables: + continue + if "settings" not in _columns(inspector, table_name): + op.add_column(table_name, sa.Column("settings", sa.JSON(), nullable=False, server_default=_SETTINGS_DEFAULT)) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + for table_name in ("campaigns", "groups", "users"): + if table_name in tables and "settings" in _columns(inspector, table_name): + op.drop_column(table_name, "settings") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a9e73d0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-core" +version = "0.1.0" +description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." +readme = "README.md" +requires-python = ">=3.12" +license = { file = "LICENSE" } +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "SQLAlchemy>=2.0,<3", + "fastapi>=0.115,<1", + "pydantic>=2,<3", + "pydantic-settings>=2,<3", + "cryptography>=44,<45", + "celery>=5,<6", + "redis>=5,<6", + "alembic>=1,<2", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_core = ["py.typed"] + +[project.entry-points."govoplan.modules"] +access = "govoplan_core.access.manifest:get_manifest" + +[project.optional-dependencies] +server = [ + "uvicorn[standard]>=0.32,<1", +] +dev = [ + "httpx==0.28.1", +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..6244d6a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +-e ../govoplan-files +-e ../govoplan-mail +-e ../govoplan-campaign +httpx==0.28.1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bfd97f6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +-e .[server] diff --git a/src/govoplan_core/__init__.py b/src/govoplan_core/__init__.py new file mode 100644 index 0000000..2173665 --- /dev/null +++ b/src/govoplan_core/__init__.py @@ -0,0 +1,3 @@ +"""Reusable GovOPlaN platform core components.""" + +__all__ = ["access", "core"] diff --git a/src/govoplan_core/access/__init__.py b/src/govoplan_core/access/__init__.py new file mode 100644 index 0000000..3c69ae1 --- /dev/null +++ b/src/govoplan_core/access/__init__.py @@ -0,0 +1,2 @@ +"""Tenant, identity, auth, RBAC, and datastore access platform module.""" + diff --git a/src/govoplan_core/access/auth/__init__.py b/src/govoplan_core/access/auth/__init__.py new file mode 100644 index 0000000..3e58a06 --- /dev/null +++ b/src/govoplan_core/access/auth/__init__.py @@ -0,0 +1,2 @@ +"""Authentication and principal helpers for platform access.""" + diff --git a/src/govoplan_core/access/auth/principals.py b/src/govoplan_core/access/auth/principals.py new file mode 100644 index 0000000..eec1834 --- /dev/null +++ b/src/govoplan_core/access/auth/principals.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from govoplan_core.access.permissions.evaluator import scopes_grant + + +AuthMethod = Literal["session", "api_key", "service_account"] + + +@dataclass(frozen=True, slots=True) +class Principal: + account_id: str + membership_id: str | None + tenant_id: str | None + scopes: frozenset[str] + group_ids: frozenset[str] + auth_method: AuthMethod + api_key_id: str | None = None + session_id: str | None = None + service_account_id: str | None = None + email: str | None = None + display_name: str | None = None + + def has(self, required: str) -> bool: + return scopes_grant(self.scopes, required) + diff --git a/src/govoplan_core/access/auth/roles.py b/src/govoplan_core/access/auth/roles.py new file mode 100644 index 0000000..1d66f1f --- /dev/null +++ b/src/govoplan_core/access/auth/roles.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from govoplan_core.access.db.models import Account, Group, GroupMembership, Membership, Role, RoleBinding +from govoplan_core.access.permissions.evaluator import expand_scopes +from govoplan_core.core.modules import PermissionDefinition, SubjectType + + +def membership_group_ids(session: Session, *, tenant_id: str, membership_id: str) -> frozenset[str]: + rows = ( + session.query(Group.id) + .join(GroupMembership, GroupMembership.group_id == Group.id) + .filter( + GroupMembership.tenant_id == tenant_id, + GroupMembership.membership_id == membership_id, + Group.is_active.is_(True), + ) + .all() + ) + return frozenset(row[0] for row in rows) + + +def roles_for_subject( + session: Session, + *, + subject_type: SubjectType, + subject_id: str, + tenant_id: str | None, +) -> list[Role]: + query = ( + session.query(Role) + .join(RoleBinding, RoleBinding.role_id == Role.id) + .filter( + RoleBinding.subject_type == subject_type, + RoleBinding.subject_id == subject_id, + ) + ) + if tenant_id is None: + query = query.filter(RoleBinding.tenant_id.is_(None), Role.tenant_id.is_(None)) + else: + query = query.filter(RoleBinding.tenant_id == tenant_id, Role.tenant_id == tenant_id) + return query.order_by(Role.name.asc()).all() + + +def membership_roles(session: Session, membership: Membership) -> list[Role]: + roles_by_id = { + role.id: role + for role in roles_for_subject( + session, + subject_type="membership", + subject_id=membership.id, + tenant_id=membership.tenant_id, + ) + } + for group_id in membership_group_ids(session, tenant_id=membership.tenant_id, membership_id=membership.id): + for role in roles_for_subject(session, subject_type="group", subject_id=group_id, tenant_id=membership.tenant_id): + roles_by_id[role.id] = role + return list(roles_by_id.values()) + + +def account_system_roles(session: Session, account: Account) -> list[Role]: + return roles_for_subject(session, subject_type="account", subject_id=account.id, tenant_id=None) + + +def principal_scopes( + session: Session, + *, + account: Account, + membership: Membership | None, + include_system: bool, + catalog: dict[str, PermissionDefinition], +) -> list[str]: + scopes: set[str] = set() + if membership is not None: + for role in membership_roles(session, membership): + scopes.update(role.permissions or []) + if include_system: + for role in account_system_roles(session, account): + scopes.update(role.permissions or []) + return expand_scopes(scopes, catalog=catalog) + diff --git a/src/govoplan_core/access/auth/tokens.py b/src/govoplan_core/access/auth/tokens.py new file mode 100644 index 0000000..a1bf8e1 --- /dev/null +++ b/src/govoplan_core/access/auth/tokens.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import hashlib +import hmac +import secrets + + +DEFAULT_RANDOM_BYTES = 32 + + +def generate_secret(prefix: str, *, random_bytes: int = DEFAULT_RANDOM_BYTES) -> str: + return prefix + secrets.token_urlsafe(random_bytes) + + +def hash_secret(secret: str) -> str: + return hashlib.sha256(secret.encode("utf-8")).hexdigest() + + +def verify_secret(secret: str, expected_hash: str) -> bool: + return hmac.compare_digest(hash_secret(secret), expected_hash) + diff --git a/src/govoplan_core/access/db/__init__.py b/src/govoplan_core/access/db/__init__.py new file mode 100644 index 0000000..81aa90c --- /dev/null +++ b/src/govoplan_core/access/db/__init__.py @@ -0,0 +1,2 @@ +"""Access-platform SQLAlchemy metadata and models.""" + diff --git a/src/govoplan_core/access/db/base.py b/src/govoplan_core/access/db/base.py new file mode 100644 index 0000000..2bfbd5f --- /dev/null +++ b/src/govoplan_core/access/db/base.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy import JSON, MetaData +from sqlalchemy.orm import DeclarativeBase + +from govoplan_core.db.base import NAMING_CONVENTION, TimestampMixin, utcnow + + +class AccessBase(DeclarativeBase): + metadata = MetaData(naming_convention=NAMING_CONVENTION) + + type_annotation_map = { + dict[str, Any]: JSON, + list[dict[str, Any]]: JSON, + list[str]: JSON, + } + + +__all__ = ["AccessBase", "NAMING_CONVENTION", "TimestampMixin", "utcnow"] diff --git a/src/govoplan_core/access/db/models.py b/src/govoplan_core/access/db/models.py new file mode 100644 index 0000000..f77ed8b --- /dev/null +++ b/src/govoplan_core/access/db/models.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from govoplan_core.access.db.base import AccessBase, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class Account(AccessBase, TimestampMixin): + __tablename__ = "access_accounts" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + email: Mapped[str] = mapped_column(String(320), nullable=False) + normalized_email: Mapped[str] = mapped_column(String(320), nullable=False, unique=True, index=True) + display_name: Mapped[str | None] = mapped_column(String(255)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + auth_provider: Mapped[str] = mapped_column(String(50), default="local", nullable=False) + password_hash: Mapped[str | None] = mapped_column(String(500)) + password_reset_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + memberships: Mapped[list[Membership]] = relationship(back_populates="account", cascade="all, delete-orphan") + + +class Tenant(AccessBase, TimestampMixin): + __tablename__ = "access_tenants" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + memberships: Mapped[list[Membership]] = relationship(back_populates="tenant", cascade="all, delete-orphan") + + +class Membership(AccessBase, TimestampMixin): + __tablename__ = "access_memberships" + __table_args__ = (UniqueConstraint("tenant_id", "account_id", name="uq_access_memberships_tenant_account"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True) + account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True) + email_snapshot: Mapped[str | None] = mapped_column(String(320), index=True) + display_name: Mapped[str | None] = mapped_column(String(255)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + account: Mapped[Account] = relationship(back_populates="memberships") + tenant: Mapped[Tenant] = relationship(back_populates="memberships") + + +class Group(AccessBase, TimestampMixin): + __tablename__ = "access_groups" + __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_access_groups_tenant_slug"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + template_id: Mapped[str | None] = mapped_column(String(100), index=True) + is_protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class GroupMembership(AccessBase, TimestampMixin): + __tablename__ = "access_group_memberships" + __table_args__ = ( + UniqueConstraint("tenant_id", "membership_id", "group_id", name="uq_access_group_memberships_member_group"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True) + membership_id: Mapped[str] = mapped_column(ForeignKey("access_memberships.id", ondelete="CASCADE"), nullable=False, index=True) + group_id: Mapped[str] = mapped_column(ForeignKey("access_groups.id", ondelete="CASCADE"), nullable=False, index=True) + + +class Role(AccessBase, TimestampMixin): + __tablename__ = "access_roles" + __table_args__ = ( + UniqueConstraint("tenant_id", "slug", name="uq_access_roles_tenant_slug"), + Index( + "uq_access_roles_system_slug", + "slug", + unique=True, + sqlite_where=text("tenant_id IS NULL"), + postgresql_where=text("tenant_id IS NULL"), + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=True, index=True) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + level: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True) + is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_assignable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + template_id: Mapped[str | None] = mapped_column(String(100), index=True) + is_protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + +class RoleBinding(AccessBase, TimestampMixin): + __tablename__ = "access_role_bindings" + __table_args__ = ( + Index("ix_access_role_bindings_subject", "subject_type", "subject_id"), + Index("ix_access_role_bindings_tenant_subject", "tenant_id", "subject_type", "subject_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=True, index=True) + role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True) + subject_type: Mapped[str] = mapped_column(String(50), nullable=False) + subject_id: Mapped[str] = mapped_column(String(36), nullable=False) + created_by_account_id: Mapped[str | None] = mapped_column(String(36), index=True) + created_by_membership_id: Mapped[str | None] = mapped_column(String(36), index=True) + + +class PermissionCatalogEntry(AccessBase, TimestampMixin): + __tablename__ = "access_permission_catalog" + + scope: Mapped[str] = mapped_column(String(255), primary_key=True) + module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + resource: Mapped[str] = mapped_column(String(100), nullable=False) + action: Mapped[str] = mapped_column(String(100), nullable=False) + label: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str] = mapped_column(Text, default="", nullable=False) + category: Mapped[str] = mapped_column(String(100), nullable=False) + level: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + is_deprecated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + +class RoleTemplateModel(AccessBase, TimestampMixin): + __tablename__ = "access_role_templates" + __table_args__ = (UniqueConstraint("module_id", "level", "slug", name="uq_access_role_templates_module_level_slug"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + level: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False) + managed: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + +class ModuleInstallation(AccessBase, TimestampMixin): + __tablename__ = "access_module_installations" + + module_id: Mapped[str] = mapped_column(String(100), primary_key=True) + version: Mapped[str] = mapped_column(String(50), nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class AuthSession(AccessBase, TimestampMixin): + __tablename__ = "access_auth_sessions" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=True, index=True) + membership_id: Mapped[str | None] = mapped_column(ForeignKey("access_memberships.id", ondelete="CASCADE"), nullable=True, index=True) + account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True) + token_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) + csrf_token_hash: Mapped[str | None] = mapped_column(String(255)) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + user_agent: Mapped[str | None] = mapped_column(String(500)) + ip_address: Mapped[str | None] = mapped_column(String(100)) + + +class ApiKey(AccessBase, TimestampMixin): + __tablename__ = "access_api_keys" + __table_args__ = (Index("ix_access_api_keys_owner", "owner_subject_type", "owner_subject_id"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True) + owner_subject_type: Mapped[str] = mapped_column(String(50), nullable=False) + owner_subject_id: Mapped[str] = mapped_column(String(36), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + prefix: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + key_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + scopes: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + + +class TenantDatastore(AccessBase, TimestampMixin): + __tablename__ = "access_tenant_datastores" + __table_args__ = (UniqueConstraint("tenant_id", "module_id", name="uq_access_tenant_datastores_tenant_module"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True) + module_id: Mapped[str] = mapped_column(String(100), default="*", nullable=False) + mode: Mapped[str] = mapped_column(String(20), default="shared", nullable=False) + database_url_secret_ref: Mapped[str | None] = mapped_column(String(255)) + schema_name: Mapped[str | None] = mapped_column(String(100)) + storage_bucket: Mapped[str | None] = mapped_column(String(255)) + region: Mapped[str | None] = mapped_column(String(100)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + +class SystemSettings(AccessBase, TimestampMixin): + __tablename__ = "access_system_settings" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global") + default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False) + allow_tenant_custom_groups: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + allow_tenant_custom_roles: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + allow_tenant_api_keys: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class TenantSettings(AccessBase, TimestampMixin): + __tablename__ = "access_tenant_settings" + + tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), primary_key=True) + allow_custom_groups: Mapped[bool | None] = mapped_column(Boolean) + allow_custom_roles: Mapped[bool | None] = mapped_column(Boolean) + allow_api_keys: Mapped[bool | None] = mapped_column(Boolean) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + diff --git a/src/govoplan_core/access/db/session.py b/src/govoplan_core/access/db/session.py new file mode 100644 index 0000000..4977466 --- /dev/null +++ b/src/govoplan_core/access/db/session.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from collections.abc import Generator + +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from govoplan_core.db.session import create_database_engine + + +def create_access_engine(database_url: str) -> Engine: + return create_database_engine(database_url) + + +def create_access_session_factory(database_url: str) -> sessionmaker[Session]: + engine = create_access_engine(database_url) + return sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False) + + +def session_scope(session_factory: sessionmaker[Session]) -> Generator[Session, None, None]: + with session_factory() as session: + yield session diff --git a/src/govoplan_core/access/manifest.py b/src/govoplan_core/access/manifest.py new file mode 100644 index 0000000..37c5ae7 --- /dev/null +++ b/src/govoplan_core/access/manifest.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from govoplan_core.access.db.base import AccessBase +from govoplan_core.access.db import models as access_models # noqa: F401 - populate access metadata +from govoplan_core.core.modules import MigrationSpec, ModuleManifest, PermissionDefinition, RoleTemplate + + +def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category=category, + level=level, # type: ignore[arg-type] + module_id=module_id, + resource=resource, + action=action, + ) + + +ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = ( + _permission("access:tenant:read", "View tenants", "List and inspect tenant registry entries.", "Access", "system"), + _permission("access:tenant:create", "Create tenants", "Create tenant registry entries.", "Access", "system"), + _permission("access:tenant:update", "Update tenants", "Update tenant metadata and activation state.", "Access", "system"), + _permission("access:account:read", "View accounts", "List and inspect global login accounts.", "Access", "system"), + _permission("access:account:create", "Create accounts", "Create global login accounts.", "Access", "system"), + _permission("access:account:update", "Update accounts", "Update or suspend global login accounts.", "Access", "system"), + _permission("access:membership:read", "View memberships", "List tenant memberships and effective access.", "Tenant access", "tenant"), + _permission("access:membership:create", "Create memberships", "Create tenant-local account memberships.", "Tenant access", "tenant"), + _permission("access:membership:update", "Update memberships", "Update or suspend tenant memberships.", "Tenant access", "tenant"), + _permission("access:group:read", "View groups", "List tenant groups and members.", "Tenant access", "tenant"), + _permission("access:group:write", "Manage groups", "Create and update tenant groups.", "Tenant access", "tenant"), + _permission("access:group:manage_members", "Manage group members", "Add and remove memberships from groups.", "Tenant access", "tenant"), + _permission("access:role:read", "View roles", "Inspect tenant and system role definitions.", "Tenant access", "tenant"), + _permission("access:role:write", "Manage roles", "Create and update assignable roles.", "Tenant access", "tenant"), + _permission("access:role:assign", "Assign roles", "Bind roles to memberships, groups, accounts or services.", "Tenant access", "tenant"), + _permission("access:api_key:read", "View API keys", "List API keys without revealing secrets.", "Tenant access", "tenant"), + _permission("access:api_key:create", "Create API keys", "Create tenant API keys within delegation limits.", "Tenant access", "tenant"), + _permission("access:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "Tenant access", "tenant"), + _permission("access:setting:read", "View settings", "Read access and governance settings.", "Tenant access", "tenant"), + _permission("access:setting:write", "Manage settings", "Update access and governance settings.", "Tenant access", "tenant"), + _permission("access:governance:read", "View governance", "Inspect managed role and group templates.", "Access", "system"), + _permission("access:governance:write", "Manage governance", "Create and assign managed role and group templates.", "Access", "system"), +) + +ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = ( + RoleTemplate( + slug="system_owner", + name="System owner", + description="Protected full instance-wide administration.", + permissions=("system:*",), + level="system", + managed=True, + protected=True, + ), + RoleTemplate( + slug="system_admin", + name="System administrator", + description="Manage tenants, accounts, settings, and governance without protected owner status.", + permissions=( + "access:tenant:read", + "access:tenant:create", + "access:tenant:update", + "access:account:read", + "access:account:create", + "access:account:update", + "access:governance:read", + "access:governance:write", + ), + level="system", + managed=True, + protected=False, + ), + RoleTemplate( + slug="tenant_owner", + name="Tenant owner", + description="Protected full tenant administration and module access.", + permissions=("tenant:*",), + level="tenant", + managed=True, + protected=True, + ), + RoleTemplate( + slug="access_admin", + name="Access administrator", + description="Manage memberships, groups, roles, and API keys within delegation limits.", + permissions=( + "access:membership:read", + "access:membership:create", + "access:membership:update", + "access:group:read", + "access:group:write", + "access:group:manage_members", + "access:role:read", + "access:role:assign", + "access:api_key:read", + "access:api_key:create", + "access:api_key:revoke", + ), + level="tenant", + managed=True, + protected=False, + ), +) + +manifest = ModuleManifest( + id="access", + name="Access", + version="1.0.0", + permissions=ACCESS_PERMISSIONS, + role_templates=ACCESS_ROLE_TEMPLATES, + migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata), +) + + +def get_manifest() -> ModuleManifest: + return manifest + diff --git a/src/govoplan_core/access/permissions/__init__.py b/src/govoplan_core/access/permissions/__init__.py new file mode 100644 index 0000000..74957d6 --- /dev/null +++ b/src/govoplan_core/access/permissions/__init__.py @@ -0,0 +1,2 @@ +"""Permission definitions, evaluation, and registry helpers.""" + diff --git a/src/govoplan_core/access/permissions/definitions.py b/src/govoplan_core/access/permissions/definitions.py new file mode 100644 index 0000000..f3ebd78 --- /dev/null +++ b/src/govoplan_core/access/permissions/definitions.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate + +__all__ = ["PermissionDefinition", "PermissionLevel", "RoleTemplate"] + diff --git a/src/govoplan_core/access/permissions/evaluator.py b/src/govoplan_core/access/permissions/evaluator.py new file mode 100644 index 0000000..823f997 --- /dev/null +++ b/src/govoplan_core/access/permissions/evaluator.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from collections.abc import Iterable, Mapping + +from govoplan_core.core.modules import PermissionDefinition + + +def scope_grants(granted: str, required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool: + if granted == required or granted == "*": + return True + if granted == "tenant:*": + if catalog is None: + return not required.startswith("system:") + definition = catalog.get(required) + return definition is not None and definition.level == "tenant" + if granted == "system:*": + if catalog is None: + return required.startswith("system:") + definition = catalog.get(required) + return definition is not None and definition.level == "system" + if granted.endswith(":*"): + return required.startswith(granted[:-1]) + return False + + +def scopes_grant( + scopes: Iterable[str], + required: str, + *, + catalog: Mapping[str, PermissionDefinition] | None = None, +) -> bool: + return any(scope_grants(scope, required, catalog=catalog) for scope in scopes) + + +def expand_scopes( + scopes: Iterable[str], + *, + catalog: Mapping[str, PermissionDefinition], + include_unknown: bool = False, +) -> list[str]: + expanded: set[str] = set() + for scope in {str(scope) for scope in scopes if scope}: + matched = {candidate for candidate in catalog if scope_grants(scope, candidate, catalog=catalog)} + if matched: + expanded.update(matched) + if scope.endswith(":*") or scope in {"*", "tenant:*", "system:*"}: + expanded.add(scope) + continue + if include_unknown: + expanded.add(scope) + return sorted(expanded) + diff --git a/src/govoplan_core/access/permissions/registry.py b/src/govoplan_core/access/permissions/registry.py new file mode 100644 index 0000000..f9efad0 --- /dev/null +++ b/src/govoplan_core/access/permissions/registry.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from collections.abc import Iterable + +from govoplan_core.access.permissions.evaluator import expand_scopes, scopes_grant +from govoplan_core.core.modules import PermissionDefinition + + +class PermissionRegistry: + def __init__(self, permissions: Iterable[PermissionDefinition] = ()) -> None: + self._catalog: dict[str, PermissionDefinition] = {} + for permission in permissions: + self.register(permission) + + @property + def catalog(self) -> dict[str, PermissionDefinition]: + return dict(self._catalog) + + def register(self, permission: PermissionDefinition) -> None: + if permission.scope in self._catalog: + raise ValueError(f"Duplicate permission scope: {permission.scope}") + self._catalog[permission.scope] = permission + + def has(self, scope: str) -> bool: + return scope in self._catalog + + def grants(self, scopes: Iterable[str], required: str) -> bool: + return scopes_grant(scopes, required, catalog=self._catalog) + + def expand(self, scopes: Iterable[str], *, include_unknown: bool = False) -> list[str]: + return expand_scopes(scopes, catalog=self._catalog, include_unknown=include_unknown) + + def validate_known(self, scopes: Iterable[str]) -> list[str]: + unknown = sorted(scope for scope in scopes if scope not in self._catalog and not scope.endswith(":*") and scope not in {"*", "tenant:*", "system:*"}) + if unknown: + raise ValueError("Unknown permission scope(s): " + ", ".join(unknown)) + return sorted(set(scopes)) + diff --git a/src/govoplan_core/access/tenancy/__init__.py b/src/govoplan_core/access/tenancy/__init__.py new file mode 100644 index 0000000..abea063 --- /dev/null +++ b/src/govoplan_core/access/tenancy/__init__.py @@ -0,0 +1,2 @@ +"""Tenant datastore routing abstractions.""" + diff --git a/src/govoplan_core/access/tenancy/datastore.py b/src/govoplan_core/access/tenancy/datastore.py new file mode 100644 index 0000000..28550f5 --- /dev/null +++ b/src/govoplan_core/access/tenancy/datastore.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Literal, Protocol + +from sqlalchemy.orm import Session, sessionmaker + + +DatastoreMode = Literal["shared", "schema", "database"] + + +@dataclass(frozen=True, slots=True) +class TenantDatastoreRef: + tenant_id: str + mode: DatastoreMode = "shared" + datastore_id: str | None = None + schema_name: str | None = None + database_url_secret_ref: str | None = None + + +class TenantDatastore(Protocol): + ref: TenantDatastoreRef + + @contextmanager + def session_for(self, module_id: str) -> Generator[Session, None, None]: + ... + + +class DatastoreResolver(Protocol): + def for_tenant(self, tenant_id: str) -> TenantDatastore: + ... + + +class SharedTenantDatastore: + def __init__(self, ref: TenantDatastoreRef, session_factory: sessionmaker[Session]) -> None: + self.ref = ref + self._session_factory = session_factory + + @contextmanager + def session_for(self, module_id: str) -> Generator[Session, None, None]: + del module_id + with self._session_factory() as session: + yield session + + +class SharedDatastoreResolver: + def __init__(self, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + def for_tenant(self, tenant_id: str) -> TenantDatastore: + return SharedTenantDatastore(TenantDatastoreRef(tenant_id=tenant_id), self._session_factory) + diff --git a/src/govoplan_core/admin/__init__.py b/src/govoplan_core/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_core/admin/governance.py b/src/govoplan_core/admin/governance.py new file mode 100644 index 0000000..290325c --- /dev/null +++ b/src/govoplan_core/admin/governance.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from govoplan_core.admin.service import AdminConflictError, AdminValidationError, slugify +from govoplan_core.db.models import ( + ApiKey, + GovernanceTemplate, + GovernanceTemplateAssignment, + Group, + GroupRoleAssignment, + Role, + SystemSettings, + Tenant, + UserGroupMembership, + UserRoleAssignment, +) +from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare +from govoplan_core.security.permissions import validate_tenant_permissions + +SYSTEM_SETTINGS_ID = "global" +TEMPLATE_KINDS = {"group", "role"} +ASSIGNMENT_MODES = {"available", "required"} + + +@dataclass(frozen=True) +class EffectiveTenantGovernance: + allow_custom_groups: bool + allow_custom_roles: bool + allow_api_keys: bool + + +def get_system_settings(session: Session) -> SystemSettings: + item = session.get(SystemSettings, SYSTEM_SETTINGS_ID) + if item is None: + item = SystemSettings(id=SYSTEM_SETTINGS_ID) + session.add(item) + session.flush() + return item + + +def _narrowing_bool(system_allows: bool, tenant_override: bool | None) -> bool: + if not system_allows: + return False + return tenant_override is not False + + +def effective_tenant_governance(session: Session, tenant: Tenant) -> EffectiveTenantGovernance: + defaults = get_system_settings(session) + return EffectiveTenantGovernance( + allow_custom_groups=_narrowing_bool(defaults.allow_tenant_custom_groups, tenant.allow_custom_groups), + allow_custom_roles=_narrowing_bool(defaults.allow_tenant_custom_roles, tenant.allow_custom_roles), + allow_api_keys=_narrowing_bool(defaults.allow_tenant_api_keys, tenant.allow_api_keys), + ) + + +def assert_tenant_governance_override_allowed(session: Session, *, field: str, value: bool | None) -> None: + if value is not True: + return + defaults = get_system_settings(session) + blocked = { + "allow_custom_groups": not defaults.allow_tenant_custom_groups, + "allow_custom_roles": not defaults.allow_tenant_custom_roles, + "allow_api_keys": not defaults.allow_tenant_api_keys, + } + if blocked.get(field): + raise AdminValidationError("Tenant governance cannot explicitly allow a capability denied by system settings.") + + +def validate_template(kind: str, permissions: list[str]) -> list[str]: + if kind not in TEMPLATE_KINDS: + raise AdminValidationError("Template kind must be group or role.") + if kind == "group": + if permissions: + raise AdminValidationError("Group templates do not contain permissions; assign roles to groups inside each tenant.") + return [] + try: + return validate_tenant_permissions(permissions) + except ValueError as exc: + raise AdminValidationError(str(exc)) from exc + + +def create_template( + session: Session, + *, + kind: str, + slug: str, + name: str, + description: str | None, + permissions: list[str], + is_active: bool, + assignments: list[dict[str, str]], +) -> GovernanceTemplate: + normalized_slug = slugify(slug) + permissions = validate_template(kind, permissions) + exists = session.query(GovernanceTemplate).filter( + GovernanceTemplate.kind == kind, + GovernanceTemplate.slug == normalized_slug, + ).first() + if exists: + raise AdminConflictError(f"A {kind} template with slug {normalized_slug!r} already exists.") + item = GovernanceTemplate( + kind=kind, + slug=normalized_slug, + name=name.strip(), + description=description, + permissions=permissions, + is_active=is_active, + ) + session.add(item) + session.flush() + set_template_assignments(session, item, assignments) + return item + + +def update_template( + session: Session, + item: GovernanceTemplate, + *, + name: str, + description: str | None, + permissions: list[str], + is_active: bool, + assignments: list[dict[str, str]], +) -> GovernanceTemplate: + item.name = name.strip() + item.description = description + item.permissions = validate_template(item.kind, permissions) + item.is_active = is_active + set_template_assignments(session, item, assignments) + sync_template(session, item) + return item + + +def set_template_assignments( + session: Session, + item: GovernanceTemplate, + assignments: list[dict[str, str]], +) -> None: + desired: dict[str, str] = {} + for assignment in assignments: + tenant_id = assignment.get("tenant_id", "") + mode = assignment.get("mode", "available") + if mode not in ASSIGNMENT_MODES: + raise AdminValidationError("Template assignment mode must be available or required.") + tenant = session.get(Tenant, tenant_id) + if tenant is None: + raise AdminValidationError(f"Unknown tenant: {tenant_id}") + desired[tenant_id] = mode + + existing = { + row.tenant_id: row + for row in session.query(GovernanceTemplateAssignment) + .filter(GovernanceTemplateAssignment.template_id == item.id) + .all() + } + for tenant_id, row in list(existing.items()): + if tenant_id in desired: + row.mode = desired[tenant_id] + continue + _remove_materialized_template(session, item, tenant_id) + session.delete(row) + + for tenant_id, mode in desired.items(): + if tenant_id not in existing: + session.add(GovernanceTemplateAssignment(template_id=item.id, tenant_id=tenant_id, mode=mode)) + session.flush() + sync_template(session, item) + + +def sync_template(session: Session, item: GovernanceTemplate) -> None: + assignments = session.query(GovernanceTemplateAssignment).filter( + GovernanceTemplateAssignment.template_id == item.id + ).all() + for assignment in assignments: + required = assignment.mode == "required" + if item.kind == "group": + group = session.query(Group).filter( + Group.tenant_id == assignment.tenant_id, + Group.system_template_id == item.id, + ).first() + if group is None: + group = Group( + tenant_id=assignment.tenant_id, + slug=_available_slug(session, Group, assignment.tenant_id, item.slug), + name=item.name, + description=item.description, + is_active=item.is_active, + system_template_id=item.id, + system_required=required, + ) + session.add(group) + else: + group.name = item.name + group.description = item.description + group.system_required = required + if required: + group.is_active = item.is_active + else: + role = session.query(Role).filter( + Role.tenant_id == assignment.tenant_id, + Role.system_template_id == item.id, + ).first() + if role is None: + role = Role( + tenant_id=assignment.tenant_id, + slug=_available_slug(session, Role, assignment.tenant_id, item.slug), + name=item.name, + description=item.description, + permissions=item.permissions, + is_builtin=False, + is_assignable=item.is_active, + system_template_id=item.id, + system_required=required, + ) + session.add(role) + else: + role.name = item.name + role.description = item.description + role.permissions = item.permissions + role.system_required = required + if required: + role.is_assignable = item.is_active + session.flush() + + + +def delete_template(session: Session, item: GovernanceTemplate) -> None: + assignments = session.query(GovernanceTemplateAssignment).filter( + GovernanceTemplateAssignment.template_id == item.id + ).all() + for assignment in assignments: + _remove_materialized_template(session, item, assignment.tenant_id) + session.delete(item) + + +def _remove_materialized_template(session: Session, item: GovernanceTemplate, tenant_id: str) -> None: + if item.kind == "group": + group = session.query(Group).filter( + Group.tenant_id == tenant_id, + Group.system_template_id == item.id, + ).first() + if group is None: + return + membership_count = session.query(UserGroupMembership).filter(UserGroupMembership.group_id == group.id).count() + role_count = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.group_id == group.id).count() + owned_asset_count = session.query(FileAsset).filter(FileAsset.owner_group_id == group.id).count() + owned_folder_count = session.query(FileFolder).filter(FileFolder.owner_group_id == group.id).count() + shared_asset_count = session.query(FileShare).filter( + FileShare.target_type == "group", FileShare.target_id == group.id + ).count() + if membership_count or role_count or owned_asset_count or owned_folder_count or shared_asset_count: + raise AdminConflictError( + f"Cannot remove {item.name!r} from the tenant while its managed group has members, roles, files, folders or shares." + ) + session.delete(group) + return + + role = session.query(Role).filter( + Role.tenant_id == tenant_id, + Role.system_template_id == item.id, + ).first() + if role is None: + return + user_count = session.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role.id).count() + group_count = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role.id).count() + if user_count or group_count: + raise AdminConflictError( + f"Cannot remove {item.name!r} from the tenant while its managed role is assigned to users or groups." + ) + session.delete(role) + + +def _available_slug(session: Session, model: type[Group] | type[Role], tenant_id: str, base: str) -> str: + candidate = base + suffix = 2 + while session.query(model).filter(model.tenant_id == tenant_id, model.slug == candidate).first(): + candidate = f"{base}-{suffix}" + suffix += 1 + return candidate + + +def ensure_group_mutation_allowed(group: Group, *, requested_active: bool | None = None) -> None: + if group.system_template_id and group.system_required and requested_active is False: + raise AdminConflictError("This centrally required group cannot be deactivated by the tenant.") + + +def ensure_role_mutation_allowed(role: Role, *, requested_assignable: bool | None = None) -> None: + if role.system_template_id: + if role.system_required and requested_assignable is False: + raise AdminConflictError("This centrally required role cannot be made unavailable by the tenant.") + raise AdminConflictError("Centrally managed role definitions can only be changed in System administration.") + + +def assert_api_keys_allowed(session: Session, tenant: Tenant) -> None: + if not effective_tenant_governance(session, tenant).allow_api_keys: + raise AdminConflictError("API-key creation is disabled by system or tenant governance.") + + +def assert_custom_groups_allowed(session: Session, tenant: Tenant) -> None: + if not effective_tenant_governance(session, tenant).allow_custom_groups: + raise AdminConflictError("Custom tenant groups are disabled by system or tenant governance.") + + +def assert_custom_roles_allowed(session: Session, tenant: Tenant) -> None: + if not effective_tenant_governance(session, tenant).allow_custom_roles: + raise AdminConflictError("Custom tenant roles are disabled by system or tenant governance.") + + +def active_api_key_count(session: Session, tenant_id: str) -> int: + return session.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count() diff --git a/src/govoplan_core/admin/service.py b/src/govoplan_core/admin/service.py new file mode 100644 index 0000000..57c3787 --- /dev/null +++ b/src/govoplan_core/admin/service.py @@ -0,0 +1,588 @@ +from __future__ import annotations + +import re +import secrets +import string +from collections.abc import Iterable +from dataclasses import dataclass + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from govoplan_core.db.models import ( + Account, + ApiKey, + Group, + GroupRoleAssignment, + Role, + SystemRoleAssignment, + Tenant, + User, + UserGroupMembership, + UserRoleAssignment, +) +from govoplan_core.security.passwords import hash_password +from govoplan_core.security.permissions import ( + DEFAULT_SYSTEM_ROLES, + DEFAULT_TENANT_ROLES, + normalize_email, + scopes_grant, + validate_system_permissions, + validate_tenant_permissions, +) + +_SLUG_RE = re.compile(r"[^a-z0-9]+") +_TEMP_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-_!@#" + + +class AdminConflictError(ValueError): + pass + + + + +class AdminValidationError(ValueError): + pass + + +@dataclass(slots=True) +class MembershipCreationResult: + user: User + account: Account + temporary_password: str | None = None + account_created: bool = False + + +def slugify(value: str) -> str: + slug = _SLUG_RE.sub("-", value.strip().casefold()).strip("-") + if not slug: + raise AdminValidationError("A slug must contain at least one letter or number.") + return slug[:100] + + +def generate_temporary_password(length: int = 20) -> str: + return "".join(secrets.choice(_TEMP_PASSWORD_ALPHABET) for _ in range(length)) + + +def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict[str, Role]: + definitions = DEFAULT_TENANT_ROLES if tenant is not None else DEFAULT_SYSTEM_ROLES + roles: dict[str, Role] = {} + for slug, definition in definitions.items(): + query = session.query(Role).filter(Role.slug == slug) + query = query.filter(Role.tenant_id == tenant.id) if tenant is not None else query.filter(Role.tenant_id.is_(None)) + role = query.one_or_none() + if role is None: + role = Role( + tenant_id=tenant.id if tenant is not None else None, + slug=slug, + name=str(definition["name"]), + description=str(definition.get("description") or "") or None, + permissions=list(definition["permissions"]), + is_builtin=bool(definition.get("is_builtin", True)), + is_assignable=bool(definition.get("is_assignable", True)), + ) + session.add(role) + session.flush() + else: + # Tenant built-ins and explicitly managed system roles remain + # code-defined. Seeded, non-protected system roles are only created + # here and can subsequently be administered in the System roles UI. + managed = tenant is not None or bool(definition.get("managed", False)) + if managed: + role.name = str(definition["name"]) + role.description = str(definition.get("description") or "") or None + role.permissions = list(definition["permissions"]) + role.is_builtin = bool(definition.get("is_builtin", True)) + role.is_assignable = bool(definition.get("is_assignable", True)) + session.add(role) + roles[slug] = role + return roles + + +def get_or_create_account( + session: Session, + *, + email: str, + display_name: str | None = None, + password: str | None = None, + password_reset_required: bool = False, +) -> tuple[Account, bool, str | None]: + normalized = normalize_email(email) + if not normalized or "@" not in normalized: + raise AdminValidationError("Enter a valid email address.") + account = session.query(Account).filter(Account.normalized_email == normalized).one_or_none() + if account is not None: + if not account.is_active: + raise AdminConflictError( + "The global account is disabled. A system administrator must reactivate it before adding a tenant membership." + ) + return account, False, None + + temporary_password = password or generate_temporary_password() + account = Account( + email=email.strip(), + normalized_email=normalized, + display_name=display_name.strip() if display_name else None, + is_active=True, + auth_provider="local", + password_hash=hash_password(temporary_password), + password_reset_required=password_reset_required or password is None, + ) + session.add(account) + session.flush() + return account, True, temporary_password + + +def create_membership( + session: Session, + *, + tenant: Tenant, + email: str, + display_name: str | None = None, + password: str | None = None, + password_reset_required: bool = False, + is_active: bool = True, +) -> MembershipCreationResult: + account, account_created, temporary_password = get_or_create_account( + session, + email=email, + display_name=display_name, + password=password, + password_reset_required=password_reset_required, + ) + existing = ( + session.query(User) + .filter(User.tenant_id == tenant.id, User.account_id == account.id) + .one_or_none() + ) + if existing is not None: + raise AdminConflictError("This account already belongs to the tenant.") + + user = User( + tenant_id=tenant.id, + account_id=account.id, + email=account.email, + display_name=display_name.strip() if display_name else account.display_name, + is_active=is_active, + is_tenant_admin=False, + auth_provider=account.auth_provider, + password_hash=account.password_hash, + ) + session.add(user) + session.flush() + return MembershipCreationResult( + user=user, + account=account, + temporary_password=temporary_password if account_created else None, + account_created=account_created, + ) + + + +def set_user_groups(session: Session, *, user: User, group_ids: Iterable[str]) -> None: + ids = sorted(set(group_ids)) + groups = ( + session.query(Group) + .filter(Group.tenant_id == user.tenant_id, Group.id.in_(ids)) + .all() + if ids else [] + ) + if len(groups) != len(ids): + raise AdminValidationError("One or more selected groups do not belong to the tenant.") + + session.query(UserGroupMembership).filter( + UserGroupMembership.tenant_id == user.tenant_id, + UserGroupMembership.user_id == user.id, + ).delete(synchronize_session=False) + for group in groups: + session.add(UserGroupMembership(tenant_id=user.tenant_id, user_id=user.id, group_id=group.id)) + session.flush() + + +def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None: + ids = sorted(set(role_ids)) + roles = ( + session.query(Role) + .filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True)) + .all() + if ids else [] + ) + if len(roles) != len(ids): + raise AdminValidationError("One or more selected roles are invalid or not assignable.") + + session.query(UserRoleAssignment).filter( + UserRoleAssignment.tenant_id == user.tenant_id, + UserRoleAssignment.user_id == user.id, + ).delete(synchronize_session=False) + for role in roles: + session.add(UserRoleAssignment(tenant_id=user.tenant_id, user_id=user.id, role_id=role.id)) + session.flush() + + +def set_group_members(session: Session, *, group: Group, user_ids: Iterable[str]) -> None: + ids = sorted(set(user_ids)) + users = ( + session.query(User) + .filter(User.tenant_id == group.tenant_id, User.id.in_(ids)) + .all() + if ids else [] + ) + if len(users) != len(ids): + raise AdminValidationError("One or more selected users do not belong to the tenant.") + + session.query(UserGroupMembership).filter( + UserGroupMembership.tenant_id == group.tenant_id, + UserGroupMembership.group_id == group.id, + ).delete(synchronize_session=False) + for user in users: + session.add(UserGroupMembership(tenant_id=group.tenant_id, user_id=user.id, group_id=group.id)) + session.flush() + + +def set_group_roles(session: Session, *, group: Group, role_ids: Iterable[str]) -> None: + ids = sorted(set(role_ids)) + roles = ( + session.query(Role) + .filter(Role.tenant_id == group.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True)) + .all() + if ids else [] + ) + if len(roles) != len(ids): + raise AdminValidationError("One or more selected roles are invalid or not assignable.") + + session.query(GroupRoleAssignment).filter( + GroupRoleAssignment.tenant_id == group.tenant_id, + GroupRoleAssignment.group_id == group.id, + ).delete(synchronize_session=False) + for role in roles: + session.add(GroupRoleAssignment(tenant_id=group.tenant_id, group_id=group.id, role_id=role.id)) + session.flush() + + +def create_custom_role( + session: Session, + *, + tenant: Tenant, + slug: str, + name: str, + description: str | None, + permissions: Iterable[str], +) -> Role: + normalized_slug = slugify(slug) + if session.query(Role).filter(Role.tenant_id == tenant.id, Role.slug == normalized_slug).count(): + raise AdminConflictError("A role with this slug already exists in the tenant.") + try: + validated = validate_tenant_permissions(permissions) + except ValueError as exc: + raise AdminValidationError(str(exc)) from exc + role = Role( + tenant_id=tenant.id, + slug=normalized_slug, + name=name.strip(), + description=description.strip() if description else None, + permissions=validated, + is_builtin=False, + is_assignable=True, + ) + session.add(role) + session.flush() + return role + + +def update_custom_role( + session: Session, + *, + role: Role, + name: str, + description: str | None, + permissions: Iterable[str], + is_assignable: bool, +) -> None: + if role.is_builtin: + raise AdminConflictError("Built-in roles are managed by the application and cannot be edited.") + try: + validated = validate_tenant_permissions(permissions) + except ValueError as exc: + raise AdminValidationError(str(exc)) from exc + role.name = name.strip() + role.description = description.strip() if description else None + role.permissions = validated + role.is_assignable = is_assignable + session.add(role) + session.flush() + + +def delete_custom_role(session: Session, role: Role) -> None: + if role.is_builtin: + raise AdminConflictError("Built-in roles cannot be deleted.") + assigned = session.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role.id).count() + assigned += session.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role.id).count() + if assigned: + raise AdminConflictError("Remove all user and group assignments before deleting this role.") + session.delete(role) + session.flush() + + +def create_system_role( + session: Session, + *, + slug: str, + name: str, + description: str | None, + permissions: Iterable[str], +) -> Role: + normalized_slug = slugify(slug) + if session.query(Role).filter(Role.tenant_id.is_(None), Role.slug == normalized_slug).count(): + raise AdminConflictError("A system role with this slug already exists.") + try: + validated = validate_system_permissions(permissions) + except ValueError as exc: + raise AdminValidationError(str(exc)) from exc + if "system:*" in validated: + raise AdminValidationError("Only the protected System owner role may contain system:*.") + role = Role( + tenant_id=None, + slug=normalized_slug, + name=name.strip(), + description=description.strip() if description else None, + permissions=validated, + is_builtin=False, + is_assignable=True, + ) + session.add(role) + session.flush() + return role + + +def update_system_role( + session: Session, + *, + role: Role, + name: str, + description: str | None, + permissions: Iterable[str], + is_assignable: bool, +) -> None: + if role.tenant_id is not None: + raise AdminValidationError("This is not a system role.") + if role.slug == "system_owner": + raise AdminConflictError("The protected System owner role cannot be edited.") + try: + validated = validate_system_permissions(permissions) + except ValueError as exc: + raise AdminValidationError(str(exc)) from exc + if "system:*" in validated: + raise AdminValidationError("Only the protected System owner role may contain system:*.") + role.name = name.strip() + role.description = description.strip() if description else None + role.permissions = validated + role.is_assignable = is_assignable + role.is_builtin = False + session.add(role) + session.flush() + + +def delete_system_role(session: Session, role: Role) -> None: + if role.tenant_id is not None: + raise AdminValidationError("This is not a system role.") + if role.slug == "system_owner": + raise AdminConflictError("The protected System owner role cannot be deleted.") + assigned = session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count() + if assigned: + raise AdminConflictError("Remove all account assignments before deleting this system role.") + session.delete(role) + session.flush() + + +def assert_can_delegate_system_permissions(actor_scopes: Iterable[str], permissions: Iterable[str]) -> None: + """Prevent a system administrator from defining stronger roles than they hold.""" + from govoplan_core.security.permissions import delegateable_system_scopes + + requested = set(validate_system_permissions(permissions)) + if "system:*" in requested: + if not scopes_grant(actor_scopes, "system:*"): + raise AdminValidationError("Only a System owner may delegate full system access.") + return + allowed = delegateable_system_scopes(actor_scopes) + missing = sorted(scope for scope in requested if scope not in allowed) + if missing: + raise AdminValidationError("You cannot delegate system permissions you do not currently hold: " + ", ".join(missing)) + + +def assert_can_delegate_system_roles( + session: Session, + *, + actor_scopes: Iterable[str], + role_ids: Iterable[str], +) -> None: + ids = sorted(set(role_ids)) + if not ids: + return + roles = session.query(Role).filter(Role.tenant_id.is_(None), Role.id.in_(ids)).all() + if len(roles) != len(ids): + raise AdminValidationError("One or more selected system roles are invalid.") + for role in roles: + assert_can_delegate_system_permissions(actor_scopes, role.permissions or []) + + +def set_system_roles(session: Session, *, account: Account, role_ids: Iterable[str]) -> None: + ids = sorted(set(role_ids)) + roles = ( + session.query(Role) + .filter(Role.tenant_id.is_(None), Role.id.in_(ids), Role.is_assignable.is_(True)) + .all() + if ids else [] + ) + if len(roles) != len(ids): + raise AdminValidationError("One or more system roles are invalid or not assignable.") + for role in roles: + try: + validate_system_permissions(role.permissions or []) + except ValueError as exc: + raise AdminValidationError(str(exc)) from exc + + session.query(SystemRoleAssignment).filter(SystemRoleAssignment.account_id == account.id).delete(synchronize_session=False) + for role in roles: + session.add(SystemRoleAssignment(account_id=account.id, role_id=role.id)) + session.flush() + assert_system_owner_exists(session) + + +def account_has_system_scope(session: Session, account: Account, required: str) -> bool: + roles = ( + session.query(Role) + .join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id) + .filter(SystemRoleAssignment.account_id == account.id, Role.tenant_id.is_(None)) + .all() + ) + return any(scopes_grant(role.permissions or [], required) for role in roles) + + +def tenant_owner_user_ids(session: Session, tenant_id: str) -> set[str]: + """Return active tenant memberships that currently satisfy the operational-owner invariant.""" + + users = ( + session.query(User) + .join(Account, Account.id == User.account_id) + .filter( + User.tenant_id == tenant_id, + User.is_active.is_(True), + Account.is_active.is_(True), + ) + .all() + ) + owner_ids: set[str] = set() + for user in users: + direct_roles = ( + session.query(Role) + .join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id) + .filter( + UserRoleAssignment.tenant_id == tenant_id, + UserRoleAssignment.user_id == user.id, + Role.tenant_id == tenant_id, + ) + .all() + ) + 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.tenant_id == tenant_id, + UserGroupMembership.user_id == user.id, + Group.is_active.is_(True), + Role.tenant_id == tenant_id, + ) + .all() + ) + effective = [ + scope + for role in direct_roles + group_roles + 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 tenant_counts(session: Session, tenant_id: str) -> dict[str, int]: + from govoplan_campaign.backend.db.models import Campaign + from govoplan_files.backend.db.models import FileAsset + + return { + "users": session.query(User).filter(User.tenant_id == tenant_id).count(), + "active_users": session.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(), + "groups": session.query(Group).filter(Group.tenant_id == tenant_id).count(), + "campaigns": session.query(Campaign).filter(Campaign.tenant_id == tenant_id).count(), + "files": session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id).count(), + "api_keys": session.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(), + } + + +def assert_can_delegate_tenant_permissions(actor_scopes: Iterable[str], permissions: Iterable[str]) -> None: + """Prevent administrators from defining or assigning roles beyond their own effective tenant powers.""" + from govoplan_core.security.permissions import delegateable_tenant_scopes + requested = set(validate_tenant_permissions(permissions)) + if "tenant:*" in requested: + # Only a tenant owner-equivalent can delegate the wildcard. + if not scopes_grant(actor_scopes, "tenant:*"): + raise AdminValidationError("You cannot delegate full tenant access unless you already have it.") + return + allowed = delegateable_tenant_scopes(actor_scopes) + missing = sorted(scope for scope in requested if scope not in allowed) + if missing: + raise AdminValidationError("You cannot delegate permissions you do not currently hold: " + ", ".join(missing)) + + +def assert_can_delegate_roles(session: Session, *, actor_scopes: Iterable[str], role_ids: Iterable[str], tenant_id: str) -> None: + ids = sorted(set(role_ids)) + if not ids: + return + roles = session.query(Role).filter(Role.tenant_id == tenant_id, Role.id.in_(ids)).all() + if len(roles) != len(ids): + raise AdminValidationError("One or more selected roles do not belong to the tenant.") + for role in roles: + assert_can_delegate_tenant_permissions(actor_scopes, role.permissions or []) diff --git a/src/govoplan_core/api/__init__.py b/src/govoplan_core/api/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/src/govoplan_core/api/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/src/govoplan_core/api/v1/__init__.py b/src/govoplan_core/api/v1/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/src/govoplan_core/api/v1/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/src/govoplan_core/api/v1/admin.py b/src/govoplan_core/api/v1/admin.py new file mode 100644 index 0000000..240bb4d --- /dev/null +++ b/src/govoplan_core/api/v1/admin.py @@ -0,0 +1,1642 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func +from sqlalchemy.orm import Session + +from govoplan_core.admin.governance import ( + assert_api_keys_allowed, + assert_custom_groups_allowed, + assert_custom_roles_allowed, + assert_tenant_governance_override_allowed, + create_template, + delete_template, + effective_tenant_governance, + ensure_group_mutation_allowed, + ensure_role_mutation_allowed, + get_system_settings, + update_template, +) +from govoplan_core.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, + role_assignment_counts, + set_group_members, + set_group_roles, + set_system_roles, + set_user_groups, + set_user_roles, + slugify, + tenant_counts, + tenant_owner_user_ids, + update_custom_role, + update_system_role, +) +from govoplan_core.api.v1.admin_schemas import ( + AdminApiKeyCreateRequest, + AdminApiKeyCreateResponse, + AdminOverviewResponse, + ApiKeyAdminItem, + ApiKeyListResponse, + AuditAdminItem, + AuditAdminListResponse, + GroupCreateRequest, + GroupListResponse, + GroupSummary, + GovernanceTemplateCreateRequest, + GovernanceTemplateItem, + GovernanceTemplateListResponse, + GovernanceTemplateUpdateRequest, + GroupUpdateRequest, + PermissionCatalogResponse, + PermissionItem, + PrivacyRetentionPolicyItem, + PrivacyRetentionPolicyScopeRequest, + PrivacyRetentionPolicyScopeResponse, + RetentionRunRequest, + RetentionRunResponse, + RoleCreateRequest, + RoleListResponse, + RoleSummary, + RoleUpdateRequest, + SystemAccountCreateRequest, + SystemAccountCreateResponse, + SystemAccountItem, + SystemAccountListResponse, + SystemAccountMembershipsUpdateRequest, + SystemAccountRolesUpdateRequest, + SystemAccountUpdateRequest, + SystemSettingsItem, + SystemSettingsUpdateRequest, + TenantAdminItem, + TenantCreateRequest, + TenantListResponse, + TenantOwnerCandidate, + TenantOwnerCandidateListResponse, + TenantUpdateRequest, + UserAdminItem, + UserCreateRequest, + UserCreateResponse, + UserListResponse, + UserUpdateRequest, +) +from govoplan_core.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_core.db.models import ( + Account, + ApiKey, + AuditLog, + Group, + GovernanceTemplate, + GovernanceTemplateAssignment, + GroupRoleAssignment, + Role, + SystemRoleAssignment, + Tenant, + User, + UserGroupMembership, + UserRoleAssignment, +) +from govoplan_core.db.session import get_session +from govoplan_core.privacy.retention import ( + PrivacyPolicyError, + apply_retention_policy, + effective_privacy_policy, + get_privacy_policy_for_scope, + parent_privacy_policy, + privacy_policy_from_settings, + set_privacy_policy, + set_privacy_policy_for_scope, +) +from govoplan_core.security.api_keys import create_api_key +from govoplan_core.security.permissions import ALL_PERMISSIONS, effective_permission_count, normalize_email, scopes_grant +from govoplan_core.security.sessions import collect_direct_user_roles, collect_system_roles, collect_user_groups, collect_user_roles, collect_user_scopes +from govoplan_core.security.time import utc_now + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +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 _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem: + governance = effective_tenant_governance(session, tenant) + return TenantAdminItem( + id=tenant.id, + slug=tenant.slug, + name=tenant.name, + description=tenant.description, + default_locale=tenant.default_locale, + settings=tenant.settings or {}, + allow_custom_groups=tenant.allow_custom_groups, + allow_custom_roles=tenant.allow_custom_roles, + allow_api_keys=tenant.allow_api_keys, + effective_governance={ + "allow_custom_groups": governance.allow_custom_groups, + "allow_custom_roles": governance.allow_custom_roles, + "allow_api_keys": governance.allow_api_keys, + }, + is_active=tenant.is_active, + counts=tenant_counts(session, tenant.id), + created_at=tenant.created_at, + updated_at=tenant.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 _governance_template_item(session: Session, item: GovernanceTemplate) -> GovernanceTemplateItem: + assignments = session.query(GovernanceTemplateAssignment).filter( + GovernanceTemplateAssignment.template_id == item.id + ).order_by(GovernanceTemplateAssignment.tenant_id.asc()).all() + return GovernanceTemplateItem( + id=item.id, + kind=item.kind, + slug=item.slug, + name=item.name, + description=item.description, + permissions=item.permissions or [], + effective_permission_count=effective_permission_count(item.permissions or [], level="tenant") if item.kind == "role" else 0, + is_active=item.is_active, + assignments=[{"tenant_id": row.tenant_id, "mode": row.mode} for row in assignments], + created_at=item.created_at, + updated_at=item.updated_at, + ) + + +@router.get("/overview", response_model=AdminOverviewResponse) +def admin_overview( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope( + "admin:users:read", "admin:groups:read", "admin:roles:read", "admin:settings:read", + "admin:api_keys:read", "system:tenants:read", "system:accounts:read", "system:roles:read", "system:access:read", + "system:settings:read", "system:governance:read", "system:audit:read", + )), +): + tenant = session.get(Tenant, principal.tenant_id) + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + counts = tenant_counts(session, tenant.id) + capabilities = [item.scope for item in ALL_PERMISSIONS if has_scope(principal, item.scope)] + return AdminOverviewResponse( + active_tenant_id=tenant.id, + active_tenant_name=tenant.name, + tenant_count=session.query(Tenant).count() if has_scope(principal, "system:tenants:read") else None, + system_account_count=session.query(Account).count() if has_scope(principal, "system:accounts:read") or has_scope(principal, "system:access:read") else None, + system_group_template_count=session.query(GovernanceTemplate).filter(GovernanceTemplate.kind == "group").count() if has_scope(principal, "system:governance:read") else None, + system_role_template_count=session.query(GovernanceTemplate).filter(GovernanceTemplate.kind == "role").count() if has_scope(principal, "system:governance:read") else None, + user_count=counts["users"], + active_user_count=counts["active_users"], + group_count=counts["groups"], + role_count=session.query(Role).filter(Role.tenant_id == tenant.id).count(), + active_api_key_count=counts["api_keys"], + capabilities=capabilities, + ) + + +@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("/tenants", response_model=TenantListResponse) +def list_tenants( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:tenants:read")), +): + tenants = session.query(Tenant).order_by(Tenant.name.asc()).all() + return TenantListResponse(tenants=[_tenant_item(session, tenant) for tenant in tenants]) + + +@router.get("/tenants/owner-candidates", response_model=TenantOwnerCandidateListResponse) +def list_tenant_owner_candidates( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:tenants:create")), +): + accounts = session.query(Account).filter(Account.is_active.is_(True)).order_by(Account.email.asc()).all() + return TenantOwnerCandidateListResponse( + accounts=[ + TenantOwnerCandidate(account_id=account.id, email=account.email, display_name=account.display_name) + for account in accounts + ] + ) + + +@router.post("/tenants", response_model=TenantAdminItem, status_code=status.HTTP_201_CREATED) +def create_tenant( + payload: TenantCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:tenants:create")), +): + tenant_slug = slugify(payload.slug) + if session.query(Tenant).filter(Tenant.slug == tenant_slug).count(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A tenant with this slug already exists") + system_defaults = get_system_settings(session) + try: + assert_tenant_governance_override_allowed(session, field="allow_custom_groups", value=payload.allow_custom_groups) + assert_tenant_governance_override_allowed(session, field="allow_custom_roles", value=payload.allow_custom_roles) + assert_tenant_governance_override_allowed(session, field="allow_api_keys", value=payload.allow_api_keys) + except AdminValidationError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + tenant = Tenant( + slug=tenant_slug, + name=payload.name.strip(), + description=payload.description.strip() if payload.description else None, + default_locale=payload.default_locale.strip() or system_defaults.default_locale, + settings=payload.settings, + allow_custom_groups=payload.allow_custom_groups, + allow_custom_roles=payload.allow_custom_roles, + allow_api_keys=payload.allow_api_keys, + is_active=True, + ) + session.add(tenant) + session.flush() + roles = ensure_default_roles(session, tenant) + + owner_account = session.get(Account, payload.owner_account_id or principal.account.id) + if owner_account is None or not owner_account.is_active: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="The selected tenant owner account is missing or inactive.") + membership = session.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, + ) + session.add(membership) + session.flush() + session.add(UserRoleAssignment(tenant_id=tenant.id, user_id=membership.id, role_id=roles["owner"].id)) + audit_event( + session, + tenant_id=tenant.id, + user_id=principal.user.id, + action="tenant.created", + object_type="tenant", + object_id=tenant.id, + scope="system", + details={"slug": tenant.slug, "name": tenant.name, "creator_account_id": principal.account.id, "owner_account_id": owner_account.id}, + ) + session.commit() + return _tenant_item(session, tenant) + + +@router.patch("/tenants/{tenant_id}", response_model=TenantAdminItem) +def update_tenant( + tenant_id: str, + payload: TenantUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +): + non_status_fields = {"name", "description", "default_locale", "settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"} + if payload.model_fields_set.intersection(non_status_fields): + _require_permission(principal, "system:tenants:update") + if payload.is_active is not None: + _require_permission(principal, "system:tenants:suspend") + tenant = session.get(Tenant, tenant_id) + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + if payload.name is not None: + tenant.name = payload.name.strip() + if "description" in payload.model_fields_set: + tenant.description = payload.description.strip() if payload.description and payload.description.strip() else None + if payload.default_locale is not None: + tenant.default_locale = payload.default_locale.strip() or "en" + if payload.settings is not None: + tenant.settings = payload.settings + try: + if "allow_custom_groups" in payload.model_fields_set: + assert_tenant_governance_override_allowed(session, field="allow_custom_groups", value=payload.allow_custom_groups) + tenant.allow_custom_groups = payload.allow_custom_groups + if "allow_custom_roles" in payload.model_fields_set: + assert_tenant_governance_override_allowed(session, field="allow_custom_roles", value=payload.allow_custom_roles) + tenant.allow_custom_roles = payload.allow_custom_roles + if "allow_api_keys" in payload.model_fields_set: + assert_tenant_governance_override_allowed(session, field="allow_api_keys", value=payload.allow_api_keys) + tenant.allow_api_keys = payload.allow_api_keys + except AdminValidationError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + if payload.is_active is not None: + if not payload.is_active and tenant.id == principal.tenant_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Switch to another tenant before suspending the active tenant.", + ) + tenant.is_active = payload.is_active + session.add(tenant) + audit_event( + session, + tenant_id=tenant.id, + user_id=principal.user.id, + action="tenant.updated", + scope="system", + object_type="tenant", + object_id=tenant.id, + details=payload.model_dump(exclude_unset=True), + ) + session.commit() + return _tenant_item(session, tenant) + + +@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) + + +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) + + +@router.get("/system/settings", response_model=SystemSettingsItem) +def read_system_settings( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + item = get_system_settings(session) + session.commit() + policy = privacy_policy_from_settings(item) + return SystemSettingsItem( + default_locale=item.default_locale, + allow_tenant_custom_groups=item.allow_tenant_custom_groups, + allow_tenant_custom_roles=item.allow_tenant_custom_roles, + allow_tenant_api_keys=item.allow_tenant_api_keys, + privacy_retention_policy=PrivacyRetentionPolicyItem.model_validate(policy.model_dump(mode="json")), + settings=item.settings or {}, + ) + + +@router.patch("/system/settings", response_model=SystemSettingsItem) +def write_system_settings( + payload: SystemSettingsUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + item = get_system_settings(session) + item.default_locale = payload.default_locale.strip() or "en" + item.allow_tenant_custom_groups = payload.allow_tenant_custom_groups + item.allow_tenant_custom_roles = payload.allow_tenant_custom_roles + item.allow_tenant_api_keys = payload.allow_tenant_api_keys + if payload.privacy_retention_policy is not None: + set_privacy_policy(item, payload.privacy_retention_policy.model_dump(mode="json")) + session.add(item) + audit_from_principal( + session, principal, action="system_settings.updated", scope="system", object_type="system_settings", object_id=item.id, + details=payload.model_dump(), + ) + session.commit() + return read_system_settings(session=session, principal=principal) + + +def _require_privacy_policy_read(principal: ApiPrincipal, scope_type: str) -> None: + if scope_type == "system": + _require_permission(principal, "system:settings:read") + else: + _require_permission(principal, "admin:policies:read") + + +def _require_privacy_policy_write(principal: ApiPrincipal, scope_type: str) -> None: + if scope_type == "system": + _require_permission(principal, "system:settings:write") + else: + _require_permission(principal, "admin:policies:write") + + +@router.get("/privacy-retention/policies/{scope_type}", response_model=PrivacyRetentionPolicyScopeResponse) +def read_privacy_retention_policy( + scope_type: str, + scope_id: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +): + clean_scope = scope_type.strip().casefold() + _require_privacy_policy_read(principal, clean_scope) + try: + policy = get_privacy_policy_for_scope(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) + effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) + parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) + return PrivacyRetentionPolicyScopeResponse( + scope_type=clean_scope, + scope_id=scope_id, + policy=policy, + effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")), + parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None, + ) + except PrivacyPolicyError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.put("/privacy-retention/policies/{scope_type}", response_model=PrivacyRetentionPolicyScopeResponse) +def write_privacy_retention_policy( + scope_type: str, + payload: PrivacyRetentionPolicyScopeRequest, + scope_id: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +): + clean_scope = scope_type.strip().casefold() + _require_privacy_policy_write(principal, clean_scope) + try: + policy = set_privacy_policy_for_scope( + session, + tenant_id=principal.tenant_id, + scope_type=clean_scope, + scope_id=scope_id, + policy=payload.policy.model_dump(mode="json", exclude_none=True), + ) + session.commit() + effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) + parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) + return PrivacyRetentionPolicyScopeResponse( + scope_type=clean_scope, + scope_id=scope_id, + policy=policy, + effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")), + parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None, + ) + except PrivacyPolicyError as exc: + session.rollback() + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +def _parent_privacy_policy_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): + if scope_type == "system": + return None + return parent_privacy_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id or (tenant_id if scope_type == "tenant" else None)) + + +def _effective_privacy_policy_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): + if scope_type == "system": + return effective_privacy_policy(session) + if scope_type == "tenant": + return effective_privacy_policy(session, tenant_id=scope_id or tenant_id) + if scope_type == "campaign" and scope_id: + return effective_privacy_policy(session, campaign_id=scope_id) + if scope_type == "user" and scope_id: + return effective_privacy_policy(session, tenant_id=tenant_id, owner_user_id=scope_id) + if scope_type == "group" and scope_id: + return effective_privacy_policy(session, tenant_id=tenant_id, owner_group_id=scope_id) + return effective_privacy_policy(session, tenant_id=tenant_id) + + +@router.post("/system/retention/run", response_model=RetentionRunResponse) +def run_retention_policy( + payload: RetentionRunRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + result = apply_retention_policy(session, dry_run=payload.dry_run) + audit_from_principal( + session, + principal, + action="retention_policy.run", + scope="system", + object_type="retention_policy", + object_id="global", + details={"dry_run": payload.dry_run, "counts": result.get("counts")}, + ) + session.commit() + return RetentionRunResponse(result=result) + + +@router.get("/system/governance-templates", response_model=GovernanceTemplateListResponse) +def list_governance_templates( + kind: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:governance:read")), +): + query = session.query(GovernanceTemplate) + if kind: + query = query.filter(GovernanceTemplate.kind == kind) + items = query.order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all() + return GovernanceTemplateListResponse(templates=[_governance_template_item(session, item) for item in items]) + + +@router.post("/system/governance-templates", response_model=GovernanceTemplateItem, status_code=status.HTTP_201_CREATED) +def create_governance_template( + payload: GovernanceTemplateCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:governance:write")), +): + try: + item = create_template( + session, + kind=payload.kind, + slug=payload.slug, + name=payload.name, + description=payload.description, + permissions=payload.permissions, + is_active=payload.is_active, + assignments=[assignment.model_dump() for assignment in payload.assignments], + ) + except (AdminConflictError, AdminValidationError) as exc: + raise _http_admin_error(exc) from exc + audit_from_principal( + session, principal, action="governance_template.created", scope="system", object_type=f"{payload.kind}_template", object_id=item.id, + details={"slug": item.slug, "tenant_ids": [assignment.tenant_id for assignment in payload.assignments]}, + ) + session.commit() + return _governance_template_item(session, item) + + +@router.patch("/system/governance-templates/{template_id}", response_model=GovernanceTemplateItem) +def update_governance_template( + template_id: str, + payload: GovernanceTemplateUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:governance:write")), +): + item = session.get(GovernanceTemplate, template_id) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found") + try: + update_template( + session, item, + name=payload.name, + description=payload.description, + permissions=payload.permissions, + is_active=payload.is_active, + assignments=[assignment.model_dump() for assignment in payload.assignments], + ) + except (AdminConflictError, AdminValidationError) as exc: + raise _http_admin_error(exc) from exc + audit_from_principal( + session, principal, action="governance_template.updated", scope="system", object_type=f"{item.kind}_template", object_id=item.id, + details={"tenant_ids": [assignment.tenant_id for assignment in payload.assignments]}, + ) + session.commit() + return _governance_template_item(session, item) + + +@router.delete("/system/governance-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +def remove_governance_template( + template_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:governance:write")), +): + item = session.get(GovernanceTemplate, template_id) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found") + kind = item.kind + try: + delete_template(session, item) + except (AdminConflictError, AdminValidationError) as exc: + raise _http_admin_error(exc) from exc + audit_from_principal( + session, principal, action="governance_template.deleted", scope="system", object_type=f"{kind}_template", object_id=template_id, + details={}, + ) + session.commit() + return None + + +@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) + + +def _parse_audit_filter(value: str | None) -> tuple[str, str]: + if not value: + return "contains", "" + if ":" not in value: + return "contains", value.strip() + operator, raw = value.split(":", 1) + if operator not in {"contains", "eq", "before", "after", "gt", "gte", "lt", "lte"}: + return "contains", value.strip() + return operator, raw.strip() + + +def _audit_text_filter(column, raw: str | None): + operator, value = _parse_audit_filter(raw) + if not value: + return None + normalized = value.casefold() + text = func.lower(func.coalesce(column, "")) + return text == normalized if operator == "eq" else text.contains(normalized) + + +def _audit_time_filter(raw: str | None): + operator, value = _parse_audit_filter(raw) + if not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid audit date filter.") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + else: + parsed = parsed.astimezone(timezone.utc) + if operator == "eq": + if "T" not in value and " " not in value: + return (AuditLog.created_at >= parsed) & (AuditLog.created_at < parsed + timedelta(days=1)) + return AuditLog.created_at == parsed + if operator in {"before", "lt"}: + return AuditLog.created_at < parsed + if operator == "lte": + return AuditLog.created_at <= parsed + if operator in {"after", "gt"}: + return AuditLog.created_at > parsed + if operator == "gte": + return AuditLog.created_at >= parsed + return AuditLog.created_at == parsed + + +@router.get("/audit", response_model=AuditAdminListResponse) +def list_admin_audit( + tenant_id: str | None = Query(default=None), + all_tenants: bool = Query(default=False), + audit_scope: str | None = Query(default=None, alias="scope"), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + page: int | None = Query(default=None, ge=1), + page_size: int | None = Query(default=None, ge=1, le=500), + sort_by: str = Query(default="time"), + sort_direction: str = Query(default="desc"), + filter_time: str | None = Query(default=None), + filter_actor: str | None = Query(default=None), + filter_action: str | None = Query(default=None), + filter_object: str | None = Query(default=None), + filter_tenant: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope("audit:read", "system:audit:read")), +): + effective_scope = audit_scope or ("all" if all_tenants else "tenant") + if effective_scope not in {"tenant", "system", "all"}: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Audit scope must be tenant, system or all.") + if sort_by not in {"time", "actor", "action", "object", "tenant"}: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unsupported audit sort column.") + if sort_direction not in {"asc", "desc"}: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Audit sort direction must be asc or desc.") + + query = ( + session.query(AuditLog) + .outerjoin(User, AuditLog.user_id == User.id) + .outerjoin(Account, User.account_id == Account.id) + ) + if effective_scope != "all": + query = query.filter(AuditLog.scope == effective_scope) + if effective_scope == "system": + if not has_scope(principal, "system:audit:read"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:audit:read") + elif effective_scope == "all" or all_tenants: + if not has_scope(principal, "system:audit:read"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:audit:read") + else: + if not has_scope(principal, "audit:read"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: audit:read") + tenant = _resolve_tenant(session, principal, tenant_id) + query = query.filter(AuditLog.tenant_id == tenant.id) + + object_text = func.coalesce(AuditLog.object_type, "") + " " + func.coalesce(AuditLog.object_id, "") + filters = [ + _audit_time_filter(filter_time), + _audit_text_filter(func.coalesce(Account.email, "System"), filter_actor), + _audit_text_filter(AuditLog.action, filter_action), + _audit_text_filter(object_text, filter_object), + _audit_text_filter(AuditLog.tenant_id, filter_tenant), + ] + for condition in filters: + if condition is not None: + query = query.filter(condition) + + total = query.count() + effective_page_size = page_size or limit + pages = max(1, (total + effective_page_size - 1) // effective_page_size) + if page is not None or page_size is not None: + effective_page = min(page or 1, pages) + effective_offset = (effective_page - 1) * effective_page_size + else: + effective_page = offset // effective_page_size + 1 + effective_offset = offset + + sort_columns = { + "time": AuditLog.created_at, + "actor": func.coalesce(Account.email, "System"), + "action": AuditLog.action, + "object": object_text, + "tenant": func.coalesce(AuditLog.tenant_id, ""), + } + sort_column = sort_columns[sort_by] + order = sort_column.asc() if sort_direction == "asc" else sort_column.desc() + rows = query.order_by(order, AuditLog.id.desc()).offset(effective_offset).limit(effective_page_size).all() + account_by_user_id = { + user.id: session.get(Account, user.account_id) + for user in session.query(User).filter(User.id.in_({row.user_id for row in rows if row.user_id})).all() + } if rows else {} + + return AuditAdminListResponse( + total=total, + page=effective_page, + page_size=effective_page_size, + pages=pages, + items=[ + AuditAdminItem( + id=row.id, + scope=row.scope, + tenant_id=row.tenant_id, + actor_email=account_by_user_id[row.user_id].email if row.user_id in account_by_user_id and account_by_user_id[row.user_id] else None, + action=row.action, + object_type=row.object_type, + object_id=row.object_id, + details=row.details or {}, + created_at=row.created_at, + ) + for row in rows + ], + ) diff --git a/src/govoplan_core/api/v1/admin_schemas.py b/src/govoplan_core/api/v1/admin_schemas.py new file mode 100644 index 0000000..3932e9d --- /dev/null +++ b/src/govoplan_core/api/v1/admin_schemas.py @@ -0,0 +1,486 @@ +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 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 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 + + +class RetentionRunRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + dry_run: bool = True + + +class RetentionRunResponse(BaseModel): + result: dict[str, Any] + + +class SystemSettingsItem(BaseModel): + default_locale: str = "en" + allow_tenant_custom_groups: bool = True + allow_tenant_custom_roles: bool = True + allow_tenant_api_keys: bool = True + privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem) + settings: dict[str, Any] = Field(default_factory=dict) + + +class SystemSettingsUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + default_locale: str = Field(min_length=1, max_length=20) + allow_tenant_custom_groups: bool + allow_tenant_custom_roles: bool + allow_tenant_api_keys: bool + privacy_retention_policy: PrivacyRetentionPolicyItem | None = None + + +class GovernanceAssignment(BaseModel): + tenant_id: str + mode: Literal["available", "required"] = "available" + + +class GovernanceTemplateItem(BaseModel): + id: str + kind: Literal["group", "role"] + slug: str + name: str + description: str | None = None + permissions: list[str] = Field(default_factory=list) + effective_permission_count: int = 0 + is_active: bool = True + assignments: list[GovernanceAssignment] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + + +class GovernanceTemplateListResponse(BaseModel): + templates: list[GovernanceTemplateItem] + + +class GovernanceTemplateCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + kind: Literal["group", "role"] + slug: str + name: str = Field(min_length=1, max_length=255) + description: str | None = None + permissions: list[str] = Field(default_factory=list) + is_active: bool = True + assignments: list[GovernanceAssignment] = Field(default_factory=list) + + +class GovernanceTemplateUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + description: str | None = None + permissions: list[str] = Field(default_factory=list) + is_active: bool = True + assignments: list[GovernanceAssignment] = Field(default_factory=list) + + +class ApiKeyAdminItem(BaseModel): + id: str + user_id: str + user_email: str + name: str + prefix: str + scopes: list[str] = Field(default_factory=list) + expires_at: datetime | None = None + last_used_at: datetime | None = None + revoked_at: datetime | None = None + created_at: datetime + + +class ApiKeyListResponse(BaseModel): + api_keys: list[ApiKeyAdminItem] + + +class AdminApiKeyCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + user_id: str | None = None + scopes: list[str] = Field(default_factory=list) + expires_at: datetime | None = None + + +class AdminApiKeyCreateResponse(ApiKeyAdminItem): + secret: str + + +class AuditAdminItem(BaseModel): + id: str + scope: Literal["tenant", "system"] = "tenant" + tenant_id: str | None = None + actor_email: str | None = None + action: str + object_type: str | None = None + object_id: str | None = None + details: dict[str, Any] = Field(default_factory=dict) + created_at: datetime + + +class AuditAdminListResponse(BaseModel): + items: list[AuditAdminItem] + total: int + page: int = 1 + page_size: int = 100 + pages: int = 1 diff --git a/src/govoplan_core/api/v1/audit.py b/src/govoplan_core/api/v1/audit.py new file mode 100644 index 0000000..63eee57 --- /dev/null +++ b/src/govoplan_core/api/v1/audit.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from govoplan_core.api.v1.schemas import AuditLogItemResponse, AuditLogListResponse +from govoplan_core.auth.dependencies import ApiPrincipal, require_scope +from govoplan_core.db.models import AuditLog +from govoplan_core.db.session import get_session + +router = APIRouter(prefix="/audit", tags=["audit"]) + + +@router.get("", response_model=AuditLogListResponse) +def list_audit_log( + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + action: str | None = None, + object_type: str | None = None, + object_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("audit:read")), +): + query = session.query(AuditLog).filter(AuditLog.tenant_id == principal.tenant_id) + if action: + query = query.filter(AuditLog.action == action) + if object_type: + query = query.filter(AuditLog.object_type == object_type) + if object_id: + query = query.filter(AuditLog.object_id == object_id) + items = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all() + return AuditLogListResponse(items=[AuditLogItemResponse.model_validate(item) for item in items]) diff --git a/src/govoplan_core/api/v1/auth.py b/src/govoplan_core/api/v1/auth.py new file mode 100644 index 0000000..3f35b37 --- /dev/null +++ b/src/govoplan_core/api/v1/auth.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from sqlalchemy.orm import Session + +from govoplan_core.api.v1.schemas import ( + GroupInfo, + LoginRequest, + LoginResponse, + MeResponse, + ProfileUpdateRequest, + RoleInfo, + SwitchTenantRequest, + TenantInfo, + TenantMembershipInfo, + UserInfo, +) +from govoplan_core.auth.dependencies import ApiPrincipal, get_api_principal +from govoplan_core.audit.logging import audit_from_principal +from govoplan_core.db.models import Account, Tenant, User +from govoplan_core.db.session import get_session +from govoplan_core.security.passwords import verify_password +from govoplan_core.security.permissions import normalize_email +from govoplan_core.settings import settings +from govoplan_core.security.sessions import ( + collect_system_roles, + collect_tenant_memberships, + collect_user_groups, + collect_user_roles, + collect_user_scopes, + create_auth_session, + switch_auth_session_tenant, +) +from govoplan_core.security.time import utc_now + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def _cookie_samesite() -> str: + value = settings.auth_cookie_samesite.lower().strip() + if value not in {"lax", "strict", "none"}: + return "lax" + if value == "none" and not settings.auth_cookie_secure: + return "lax" + return value + + +def _set_auth_cookies(response: Response, created) -> None: + max_age = max(0, int((created.model.expires_at - utc_now()).total_seconds())) + common = { + "secure": settings.auth_cookie_secure, + "samesite": _cookie_samesite(), + "max_age": max_age, + "path": "/", + } + if settings.auth_cookie_domain: + common["domain"] = settings.auth_cookie_domain + response.set_cookie(settings.auth_session_cookie_name, created.token, httponly=True, **common) + response.set_cookie(settings.auth_csrf_cookie_name, created.csrf_token, httponly=False, **common) + + +def _clear_auth_cookies(response: Response) -> None: + kwargs = {"path": "/"} + if settings.auth_cookie_domain: + kwargs["domain"] = settings.auth_cookie_domain + response.delete_cookie(settings.auth_session_cookie_name, **kwargs) + response.delete_cookie(settings.auth_csrf_cookie_name, **kwargs) + + +def _tenant_info(tenant: Tenant) -> TenantInfo: + return TenantInfo( + id=tenant.id, + slug=tenant.slug, + name=tenant.name, + is_active=tenant.is_active, + default_locale=tenant.default_locale, + ) + + +def _user_info(user: User, account: Account) -> UserInfo: + return UserInfo( + id=user.id, + account_id=account.id, + email=account.email, + display_name=account.display_name or user.display_name, + tenant_display_name=user.display_name, + is_tenant_admin=user.is_tenant_admin, + password_reset_required=account.password_reset_required, + ) + + +def _roles_info(roles, *, level: str = "tenant") -> list[RoleInfo]: + return [ + RoleInfo( + id=role.id, + slug=role.slug, + name=role.name, + permissions=role.permissions or [], + level=level, + ) + for role in roles + ] + + +def _groups_info(groups) -> list[GroupInfo]: + return [GroupInfo(id=group.id, slug=group.slug, name=group.name) for group in groups] + + +def _tenant_memberships(session: Session, account: Account) -> list[TenantMembershipInfo]: + memberships: list[TenantMembershipInfo] = [] + for user, tenant in collect_tenant_memberships(session, account): + roles = collect_user_roles(session, user) + memberships.append( + TenantMembershipInfo( + id=tenant.id, + slug=tenant.slug, + name=tenant.name, + is_active=tenant.is_active and user.is_active, + default_locale=tenant.default_locale, + roles=[role.slug for role in roles], + ) + ) + return memberships + + +def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Account, User, Tenant]: + account = ( + session.query(Account) + .filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True)) + .one_or_none() + ) + if account is None or not verify_password(payload.password, account.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login") + + query = ( + session.query(User, Tenant) + .join(Tenant, Tenant.id == User.tenant_id) + .filter( + User.account_id == account.id, + User.is_active.is_(True), + Tenant.is_active.is_(True), + ) + ) + if payload.tenant_slug: + query = query.filter(Tenant.slug == payload.tenant_slug) + row = query.order_by(Tenant.name.asc()).first() + if row is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No active tenant membership") + return account, row[0], row[1] + + +def _me_response( + session: Session, + *, + account: Account, + user: User, + tenant: Tenant, + effective_scopes: list[str] | None = None, + include_system: bool = True, + include_all_memberships: bool = True, +) -> MeResponse: + tenant_roles = collect_user_roles(session, user) + system_roles = collect_system_roles(session, account) if include_system else [] + groups = collect_user_groups(session, user) + active_tenant = _tenant_info(tenant) + memberships = _tenant_memberships(session, account) if include_all_memberships else [ + TenantMembershipInfo( + id=tenant.id, + slug=tenant.slug, + name=tenant.name, + is_active=tenant.is_active and user.is_active, + default_locale=tenant.default_locale, + roles=[role.slug for role in tenant_roles], + ) + ] + return MeResponse( + user=_user_info(user, account), + tenant=active_tenant, + active_tenant=active_tenant, + tenants=memberships, + scopes=effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system), + roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system"), + groups=_groups_info(groups), + ) + + +@router.post("/login", response_model=LoginResponse) +def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)): + account, user, tenant = _resolve_login_user(session, payload) + user_agent = request.headers.get("user-agent") + ip_address = request.client.host if request.client else None + created = create_auth_session( + session, + user=user, + hours=settings.auth_session_hours, + user_agent=user_agent, + ip_address=ip_address, + ) + me_payload = _me_response(session, account=account, user=user, tenant=tenant) + session.commit() + _set_auth_cookies(response, created) + return LoginResponse( + access_token=created.token, + expires_at=created.model.expires_at, + **me_payload.model_dump(), + ) + + +@router.get("/me", response_model=MeResponse) +def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)): + tenant = session.get(Tenant, principal.tenant_id) + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found") + return _me_response( + session, + account=principal.account, + user=principal.user, + tenant=tenant, + effective_scopes=principal.scopes, + include_system=principal.auth_session is not None, + include_all_memberships=principal.auth_session is not None, + ) + + +@router.patch("/profile", response_model=MeResponse) +def update_profile( + payload: ProfileUpdateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + if principal.auth_session is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile") + + if "display_name" in payload.model_fields_set: + principal.account.display_name = payload.display_name.strip() if payload.display_name else None + session.add(principal.account) + if "tenant_display_name" in payload.model_fields_set: + principal.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None + session.add(principal.user) + + audit_from_principal( + session, + principal, + action="profile.updated", + object_type="account", + object_id=principal.account.id, + details={"fields": sorted(payload.model_fields_set)}, + ) + tenant = session.get(Tenant, principal.tenant_id) + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found") + session.commit() + return _me_response( + session, + account=principal.account, + user=principal.user, + tenant=tenant, + include_system=True, + include_all_memberships=True, + ) + + +@router.post("/switch-tenant", response_model=MeResponse) +def switch_tenant( + payload: SwitchTenantRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + if principal.auth_session is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot switch tenant context") + try: + membership = switch_auth_session_tenant(session, principal.auth_session, payload.tenant_id) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + tenant = session.get(Tenant, membership.tenant_id) + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + session.commit() + return _me_response(session, account=principal.account, user=membership, tenant=tenant) + + +@router.post("/logout") +def logout( + response: Response, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + if principal.auth_session is not None: + principal.auth_session.revoked_at = utc_now() + session.add(principal.auth_session) + session.commit() + _clear_auth_cookies(response) + return {"ok": True} diff --git a/src/govoplan_core/api/v1/schemas.py b/src/govoplan_core/api/v1/schemas.py new file mode 100644 index 0000000..027776b --- /dev/null +++ b/src/govoplan_core/api/v1/schemas.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class AuditLogItemResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + tenant_id: str | None = None + user_id: str | None = None + api_key_id: str | None = None + action: str + object_type: str | None = None + object_id: str | None = None + details: dict[str, Any] | None = None + created_at: datetime + + +class AuditLogListResponse(BaseModel): + items: list[AuditLogItemResponse] + + +class LoginRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + email: str + password: str + # Kept optional for backwards compatibility and future tenant-switch login flows. + # The WebUI no longer sends it. If omitted, the backend resolves the user by email. + tenant_slug: str | None = None + + +class SwitchTenantRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + tenant_id: str + + +class TenantInfo(BaseModel): + id: str + slug: str + name: str + is_active: bool = True + default_locale: str = "en" + + +class TenantMembershipInfo(TenantInfo): + roles: list[str] = Field(default_factory=list) + is_active: bool = True + + +class UserInfo(BaseModel): + id: str + account_id: str + email: str + # Global account identity used by the title bar and account settings. + display_name: str | None = None + # Optional tenant-local alias used in tenant administration and ownership. + tenant_display_name: str | None = None + is_tenant_admin: bool = False + password_reset_required: bool = False + + +class ProfileUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + display_name: str | None = Field(default=None, max_length=255) + tenant_display_name: str | None = Field(default=None, max_length=255) + + +class RoleInfo(BaseModel): + id: str + slug: str + name: str + permissions: list[str] = Field(default_factory=list) + level: Literal["tenant", "system"] = "tenant" + + +class GroupInfo(BaseModel): + id: str + slug: str + name: str + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_at: datetime + user: UserInfo + # Backwards-compatible alias for the active tenant. + tenant: TenantInfo + active_tenant: TenantInfo + tenants: list[TenantMembershipInfo] = Field(default_factory=list) + scopes: list[str] + roles: list[RoleInfo] = Field(default_factory=list) + groups: list[GroupInfo] = Field(default_factory=list) + + +class MeResponse(BaseModel): + user: UserInfo + # Backwards-compatible alias for the active tenant. + tenant: TenantInfo + active_tenant: TenantInfo + tenants: list[TenantMembershipInfo] = Field(default_factory=list) + scopes: list[str] + roles: list[RoleInfo] = Field(default_factory=list) + groups: list[GroupInfo] = Field(default_factory=list) diff --git a/src/govoplan_core/api/v1/system.py b/src/govoplan_core/api/v1/system.py new file mode 100644 index 0000000..9e2ec35 --- /dev/null +++ b/src/govoplan_core/api/v1/system.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status + +from govoplan_core.auth.dependencies import ApiPrincipal, require_any_scope + +router = APIRouter(prefix="/schemas", tags=["schemas"]) + + +def _load_campaign_schema() -> dict[str, Any]: + try: + from govoplan_campaign.backend.campaign.loader import load_campaign_schema + except ModuleNotFoundError as exc: + if exc.name == "govoplan_campaign" or exc.name.startswith("govoplan_campaign."): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign module is not installed") from exc + raise + return load_campaign_schema() + + +@router.get("/campaign") +def get_campaign_schema( + principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")), +) -> dict[str, Any]: + """Return the authoritative campaign JSON Schema used by the backend.""" + + del principal + return _load_campaign_schema() diff --git a/src/govoplan_core/audit/__init__.py b/src/govoplan_core/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_core/audit/logging.py b/src/govoplan_core/audit/logging.py new file mode 100644 index 0000000..54e3850 --- /dev/null +++ b/src/govoplan_core/audit/logging.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session + +from govoplan_core.auth.dependencies import ApiPrincipal +from govoplan_core.db.models import AuditLog +from govoplan_core.privacy.retention import sanitize_audit_details_for_policy + + +SENSITIVE_DETAIL_KEYS = { + "password", + "smtp_password", + "imap_password", + "secret", + "api_key", + "token", +} + + +def _sanitize_details(value: Any) -> Any: + if isinstance(value, dict): + sanitized: dict[str, Any] = {} + for key, item in value.items(): + if str(key).lower() in SENSITIVE_DETAIL_KEYS: + sanitized[key] = "" + else: + sanitized[key] = _sanitize_details(item) + return sanitized + if isinstance(value, list): + return [_sanitize_details(item) for item in value] + return value + + +def audit_event( + session: Session, + *, + tenant_id: str | None, + action: str, + scope: str = "tenant", + user_id: str | None = None, + api_key_id: str | None = None, + object_type: str | None = None, + object_id: str | None = None, + details: dict[str, Any] | None = None, + commit: bool = False, +) -> AuditLog: + """Persist one audit event. + + The function deliberately accepts primitive IDs so it can be used from API + handlers, CLI commands and worker code without coupling the lower-level + services to FastAPI request objects. + """ + + if scope not in {"tenant", "system"}: + raise ValueError(f"Unsupported audit scope: {scope}") + + item = AuditLog( + scope=scope, + tenant_id=tenant_id, + user_id=user_id, + api_key_id=api_key_id, + action=action, + object_type=object_type, + object_id=object_id, + details=sanitize_audit_details_for_policy(session, _sanitize_details(details or {})), + ) + session.add(item) + if commit: + session.commit() + else: + session.flush() + return item + + +def audit_from_principal( + session: Session, + principal: ApiPrincipal, + *, + action: str, + scope: str = "tenant", + object_type: str | None = None, + object_id: str | None = None, + details: dict[str, Any] | None = None, + commit: bool = False, +) -> AuditLog: + return audit_event( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + api_key_id=principal.api_key.id if principal.api_key else None, + action=action, + scope=scope, + object_type=object_type, + object_id=object_id, + details=details, + commit=commit, + ) diff --git a/src/govoplan_core/auth/__init__.py b/src/govoplan_core/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_core/auth/dependencies.py b/src/govoplan_core/auth/dependencies.py new file mode 100644 index 0000000..4cc9d51 --- /dev/null +++ b/src/govoplan_core/auth/dependencies.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from fastapi import Depends, Header, HTTPException, Request, status +from sqlalchemy.orm import Session + +from govoplan_core.db.models import Account, ApiKey, AuthSession, Tenant, User +from govoplan_core.db.session import get_session +from govoplan_core.security.module_permissions import scopes_grant_compatible +from govoplan_core.access.auth.principals import Principal +from govoplan_core.security.api_keys import authenticate_api_key +from govoplan_core.security.permissions import intersect_api_key_scopes, scopes_grant +from govoplan_core.security.sessions import authenticate_session_token, collect_user_groups, collect_user_scopes, verify_auth_session_csrf +from govoplan_core.settings import settings + + +@dataclass(slots=True) +class ApiPrincipal: + """Compatibility wrapper around the platform Principal DTO. + + Existing routers still expect ORM ``account`` and ``user`` objects. New + platform/module code should use the stable ID fields or + ``to_platform_principal()`` instead. + """ + + principal: Principal + account: Account + user: User + api_key: ApiKey | None = None + auth_session: AuthSession | None = None + + @property + def account_id(self) -> str: + return self.principal.account_id + + @property + def membership_id(self) -> str | None: + return self.principal.membership_id + + @property + def tenant_id(self) -> str: + if self.principal.tenant_id is None: + raise RuntimeError("Tenant principal has no active tenant id.") + return self.principal.tenant_id + + @property + def scopes(self) -> frozenset[str]: + return self.principal.scopes + + @property + def group_ids(self) -> frozenset[str]: + return self.principal.group_ids + + @property + def auth_method(self) -> str: + return self.principal.auth_method + + @property + def api_key_id(self) -> str | None: + return self.principal.api_key_id + + @property + def session_id(self) -> str | None: + return self.principal.session_id + + @property + def email(self) -> str | None: + return self.principal.email + + @property + def display_name(self) -> str | None: + return self.principal.display_name + + def has(self, required_scope: str) -> bool: + # Keep legacy scope aliases alive while current routers still use the + # pre-platform permission catalogue. + return scopes_grant_compatible(self.scopes, required_scope) + + def to_platform_principal(self) -> Principal: + return self.principal + + +def _extract_token(request: Request, authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]: + if x_api_key: + return x_api_key.strip(), "api_key" + if authorization and authorization.lower().startswith("bearer "): + return authorization[7:].strip(), "bearer" + cookie_token = request.cookies.get(settings.auth_session_cookie_name) + if cookie_token: + return cookie_token.strip(), "cookie" + return None, "none" + + +def _requires_csrf(request: Request) -> bool: + return request.method.upper() not in {"GET", "HEAD", "OPTIONS", "TRACE"} + + +def _principal_group_ids(session: Session, user: User) -> frozenset[str]: + return frozenset(group.id for group in collect_user_groups(session, user)) + + +def _build_api_principal( + session: Session, + *, + account: Account, + user: User, + tenant_id: str, + scopes: list[str], + auth_method: str, + api_key: ApiKey | None = None, + auth_session: AuthSession | None = None, +) -> ApiPrincipal: + principal = Principal( + account_id=account.id, + membership_id=user.id, + tenant_id=tenant_id, + scopes=frozenset(scopes), + group_ids=_principal_group_ids(session, user), + auth_method=auth_method, # type: ignore[arg-type] + api_key_id=api_key.id if api_key else None, + session_id=auth_session.id if auth_session else None, + email=account.email, + display_name=account.display_name or user.display_name, + ) + return ApiPrincipal( + principal=principal, + account=account, + user=user, + api_key=api_key, + auth_session=auth_session, + ) + + +def get_api_principal( + request: Request, + session: Session = Depends(get_session), + authorization: str | None = Header(default=None), + x_api_key: str | None = Header(default=None, alias="X-API-Key"), +) -> ApiPrincipal: + token, source = _extract_token(request, authorization, x_api_key) + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token") + + # API keys remain supported for CLI/automation. Their permissions are the + # intersection of the key grant and the owner's current tenant roles. + api_key = authenticate_api_key(session, token) + if api_key: + user = session.get(User, api_key.user_id) + account = session.get(Account, user.account_id) if user else None + tenant = session.get(Tenant, api_key.tenant_id) + if ( + not user or not account or not tenant + or not user.is_active or not account.is_active or not tenant.is_active + or user.tenant_id != api_key.tenant_id + ): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal") + user_scopes = collect_user_scopes(session, user, include_system=False) + effective_scopes = intersect_api_key_scopes(user_scopes, api_key.scopes or []) + session.commit() + return _build_api_principal( + session, + api_key=api_key, + account=account, + user=user, + tenant_id=api_key.tenant_id, + scopes=effective_scopes, + auth_method="api_key", + ) + + auth_session = authenticate_session_token(session, token) + if auth_session: + user = session.get(User, auth_session.user_id) + account = session.get(Account, auth_session.account_id) + tenant = session.get(Tenant, auth_session.tenant_id) + if ( + not user or not account or not tenant + or not user.is_active or not account.is_active or not tenant.is_active + or user.account_id != account.id + or user.tenant_id != tenant.id + ): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session principal") + if source == "cookie" and _requires_csrf(request): + header_token = request.headers.get("x-csrf-token") + cookie_token = request.cookies.get(settings.auth_csrf_cookie_name) + if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token") + scopes = collect_user_scopes(session, user, include_system=True) + session.commit() + return _build_api_principal( + session, + auth_session=auth_session, + account=account, + user=user, + tenant_id=user.tenant_id, + scopes=scopes, + auth_method="session", + ) + + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token") + + +def has_scope(principal: ApiPrincipal, required_scope: str) -> bool: + return principal.has(required_scope) + + +def require_scope(required_scope: str): + def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal: + if not has_scope(principal, required_scope): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {required_scope}") + return principal + + return dependency + + +def require_any_scope(*required_scopes: str): + def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal: + if not any(has_scope(principal, required) for required in required_scopes): + joined = ", ".join(required_scopes) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {joined}") + return principal + + return dependency diff --git a/src/govoplan_core/celery_app.py b/src/govoplan_core/celery_app.py new file mode 100644 index 0000000..8886fbc --- /dev/null +++ b/src/govoplan_core/celery_app.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from celery import Celery + +from govoplan_core.settings import settings +from govoplan_core.db.session import configure_database + +configure_database(settings.database_url) + +celery = Celery( + "multimailer", + broker=settings.redis_url, + backend=settings.redis_url, +) + +celery.conf.update( + task_default_queue="default", + task_routes={ + "multimailer.send_email": {"queue": "send_email"}, + "multimailer.append_sent": {"queue": "append_sent"}, + }, + worker_prefetch_multiplier=1, + task_acks_late=True, + task_reject_on_worker_lost=True, +) + + +@celery.task(name="multimailer.ping") +def ping(): + return "pong" + + +@celery.task(name="multimailer.send_email", bind=True, max_retries=0) +def send_email(self, job_id: str): + """Send one explicitly queued campaign job. + + SMTP failures are persisted but are not retried implicitly. A worker-loss + redelivery is safe because the delivery service converts an unfinished + SMTP attempt into ``outcome_unknown`` instead of transmitting again. + """ + + from govoplan_core.db.session import get_database + from govoplan_campaign.backend.sending.jobs import send_campaign_job + + with get_database().SessionLocal() as session: + return send_campaign_job(session, job_id=job_id, enqueue_imap_task=True).as_dict() + + +@celery.task(name="multimailer.append_sent", bind=True, max_retries=None) +def append_sent(self, job_id: str): + """Append the exact sent MIME to the configured IMAP Sent folder.""" + + from govoplan_core.db.session import get_database + from govoplan_mail.backend.sending.imap import ImapAppendError + from govoplan_campaign.backend.sending.jobs import append_sent_for_job + + with get_database().SessionLocal() as session: + try: + return append_sent_for_job(session, job_id=job_id).as_dict() + except ImapAppendError as exc: + if getattr(exc, "temporary", None) is True: + raise self.retry(exc=exc, countdown=300) + raise diff --git a/src/govoplan_core/commands/__init__.py b/src/govoplan_core/commands/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/src/govoplan_core/commands/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/src/govoplan_core/commands/init_db.py b/src/govoplan_core/commands/init_db.py new file mode 100644 index 0000000..19a379a --- /dev/null +++ b/src/govoplan_core/commands/init_db.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import argparse + +from govoplan_core.db.bootstrap import bootstrap_dev_data +from govoplan_core.db.migrations import migrate_database +from govoplan_core.db.session import configure_database, get_database +from govoplan_core.settings import settings + + +def main() -> None: + parser = argparse.ArgumentParser(description="Initialize the GovOPlaN database") + parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key") + parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create") + args = parser.parse_args() + + configure_database(settings.database_url) + migration = migrate_database() + if migration.reconciled_revision: + print(f"Reconciled legacy database marker to {migration.reconciled_revision}.") + print(f"Database schema upgraded to {migration.current_revision}.") + + if args.with_dev_data: + with get_database().SessionLocal() as session: + result = bootstrap_dev_data(session, api_key_secret=args.dev_api_key) + print(f"Tenant: {result.tenant.slug} ({result.tenant.id})") + print(f"User: {result.user.email} ({result.user.id})") + if result.created_api_key: + print("Development API key created:") + print(result.created_api_key.secret) + else: + print("Development API key already exists or was not requested.") + + +if __name__ == "__main__": + main() diff --git a/src/govoplan_core/core/__init__.py b/src/govoplan_core/core/__init__.py new file mode 100644 index 0000000..e02adc7 --- /dev/null +++ b/src/govoplan_core/core/__init__.py @@ -0,0 +1,2 @@ +"""Core platform contracts and runtime registry.""" + diff --git a/src/govoplan_core/core/discovery.py b/src/govoplan_core/core/discovery.py new file mode 100644 index 0000000..c766ecc --- /dev/null +++ b/src/govoplan_core/core/discovery.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from collections.abc import Iterable +from importlib.metadata import EntryPoint, entry_points + +from govoplan_core.core.modules import ModuleManifest + +ENTRY_POINT_GROUP = "govoplan.modules" + + +class ModuleDiscoveryError(RuntimeError): + pass + + +def _load_manifest(entry_point: EntryPoint) -> ModuleManifest: + loaded = entry_point.load() + manifest = loaded() if callable(loaded) else loaded + if not isinstance(manifest, ModuleManifest): + raise ModuleDiscoveryError(f"Entry point {entry_point.name!r} did not return a ModuleManifest") + return manifest + + +def iter_module_entry_points(group: str = ENTRY_POINT_GROUP) -> tuple[EntryPoint, ...]: + discovered = entry_points() + if hasattr(discovered, "select"): + return tuple(discovered.select(group=group)) + return tuple(discovered.get(group, ())) + + +def discover_module_manifests( + *, + enabled_modules: Iterable[str] | None = None, + group: str = ENTRY_POINT_GROUP, +) -> tuple[ModuleManifest, ...]: + enabled = set(enabled_modules) if enabled_modules is not None else None + manifests: list[ModuleManifest] = [] + for entry_point in iter_module_entry_points(group): + manifest = _load_manifest(entry_point) + if enabled is not None and manifest.id not in enabled: + continue + manifests.append(manifest) + return tuple(sorted(manifests, key=lambda item: item.id)) diff --git a/src/govoplan_core/core/events.py b/src/govoplan_core/core/events.py new file mode 100644 index 0000000..cfcad49 --- /dev/null +++ b/src/govoplan_core/core/events.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + + +@dataclass(frozen=True, slots=True) +class PlatformEvent: + type: str + module_id: str + payload: Mapping[str, Any] = field(default_factory=dict) + occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +EventHandler = Callable[[PlatformEvent], None] + + +class EventBus: + def __init__(self) -> None: + self._subscribers: dict[str, list[EventHandler]] = defaultdict(list) + + def subscribe(self, event_type: str, handler: EventHandler) -> None: + self._subscribers[event_type].append(handler) + + def publish(self, event: PlatformEvent) -> None: + for handler in self._subscribers.get(event.type, ()): + handler(event) + for handler in self._subscribers.get("*", ()): + handler(event) + diff --git a/src/govoplan_core/core/migrations.py b/src/govoplan_core/core/migrations.py new file mode 100644 index 0000000..c5dec7e --- /dev/null +++ b/src/govoplan_core/core/migrations.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass + +from sqlalchemy import MetaData + +from govoplan_core.core.registry import PlatformRegistry + + +@dataclass(frozen=True, slots=True) +class MigrationMetadataPlan: + metadata: tuple[MetaData, ...] + script_locations: tuple[str, ...] + + +def migration_metadata_plan(registry: PlatformRegistry, *, extra_metadata: Iterable[MetaData] = ()) -> MigrationMetadataPlan: + metadata = list(extra_metadata) + script_locations: list[str] = [] + for manifest in registry.manifests(): + spec = manifest.migration_spec + if spec is None: + continue + if isinstance(spec.metadata, MetaData): + metadata.append(spec.metadata) + if spec.script_location: + script_locations.append(spec.script_location) + return MigrationMetadataPlan(metadata=tuple(metadata), script_locations=tuple(script_locations)) + diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py new file mode 100644 index 0000000..f8ca625 --- /dev/null +++ b/src/govoplan_core/core/modules.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, Literal, Protocol, TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi import APIRouter + + +PermissionLevel = Literal["system", "tenant"] +SubjectType = Literal["account", "membership", "group", "service_account", "tenant"] + + +@dataclass(frozen=True, slots=True) +class PermissionDefinition: + scope: str + label: str + description: str + category: str + level: PermissionLevel + module_id: str + resource: str + action: str + deprecated: bool = False + + +@dataclass(frozen=True, slots=True) +class RoleTemplate: + slug: str + name: str + description: str + permissions: tuple[str, ...] + level: PermissionLevel = "tenant" + managed: bool = True + protected: bool = False + + +@dataclass(frozen=True, slots=True) +class NavItem: + path: str + label: str + icon: str | None = None + section: str | None = None + required_all: tuple[str, ...] = () + required_any: tuple[str, ...] = () + order: int = 100 + + + + +@dataclass(frozen=True, slots=True) +class FrontendRoute: + path: str + component: str + required_all: tuple[str, ...] = () + required_any: tuple[str, ...] = () + order: int = 100 + + +@dataclass(frozen=True, slots=True) +class FrontendModule: + module_id: str + package_name: str | None = None + asset_manifest: str | None = None + routes: tuple[FrontendRoute, ...] = () + nav_items: tuple[NavItem, ...] = () + settings_routes: tuple[FrontendRoute, ...] = () + +@dataclass(frozen=True, slots=True) +class MigrationSpec: + module_id: str + metadata: object | None = None + script_location: str | None = None + + +@dataclass(frozen=True, slots=True) +class AccessDecision: + allowed: bool + reason: str | None = None + requirements: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class ModuleContext: + registry: object + settings: object + data: Mapping[str, Any] = field(default_factory=dict) + + +class ResourceAclProvider(Protocol): + resource_type: str + + def can_read(self, principal: object, resource_id: str) -> bool: + ... + + def can_write(self, principal: object, resource_id: str) -> bool: + ... + + def explain(self, principal: object, resource_id: str) -> AccessDecision: + ... + + +TenantSummaryProvider = Callable[[object, str], Mapping[str, int]] +DeleteVetoProvider = Callable[[object, str, str], None] +RouteFactory = Callable[[ModuleContext], "APIRouter"] + + +@dataclass(frozen=True, slots=True) +class ModuleManifest: + id: str + name: str + version: str + dependencies: tuple[str, ...] = () + optional_dependencies: tuple[str, ...] = () + permissions: tuple[PermissionDefinition, ...] = () + role_templates: tuple[RoleTemplate, ...] = () + route_factory: RouteFactory | None = None + migration_spec: MigrationSpec | None = None + nav_items: tuple[NavItem, ...] = () + frontend: FrontendModule | None = None + resource_acl_providers: tuple[ResourceAclProvider, ...] = () + tenant_summary_providers: tuple[TenantSummaryProvider, ...] = () + delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict) + diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py new file mode 100644 index 0000000..38d7909 --- /dev/null +++ b/src/govoplan_core/core/registry.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import re +from collections import defaultdict, deque +from collections.abc import Iterable, Mapping +from dataclasses import dataclass + +from govoplan_core.core.modules import ( + DeleteVetoProvider, + ModuleManifest, + NavItem, + PermissionDefinition, + ResourceAclProvider, + RoleTemplate, + TenantSummaryProvider, +) + +_SCOPE_RE = re.compile(r"^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$") +_WILDCARD_RE = re.compile(r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$") + + +class RegistryError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class RegistrySnapshot: + manifests: tuple[ModuleManifest, ...] + permissions: tuple[PermissionDefinition, ...] + role_templates: tuple[RoleTemplate, ...] + nav_items: tuple[NavItem, ...] + + +class PlatformRegistry: + def __init__(self) -> None: + self._manifests: dict[str, ModuleManifest] = {} + self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {} + self._delete_veto_providers: dict[str, list[DeleteVetoProvider]] = defaultdict(list) + + def register(self, manifest: ModuleManifest) -> ModuleManifest: + if manifest.id in self._manifests: + raise RegistryError(f"Duplicate module id: {manifest.id}") + self._manifests[manifest.id] = manifest + for provider in manifest.tenant_summary_providers: + self.register_tenant_summary_provider(manifest.id, provider) + for resource_type, providers in manifest.delete_veto_providers.items(): + for provider in providers: + self.register_delete_veto(resource_type, provider) + return manifest + + def get(self, module_id: str) -> ModuleManifest | None: + return self._manifests.get(module_id) + + def has_module(self, module_id: str) -> bool: + return module_id in self._manifests + + def require_module(self, module_id: str) -> ModuleManifest: + manifest = self.get(module_id) + if manifest is None: + raise RegistryError(f"Required module is not installed: {module_id}") + return manifest + + def integration_enabled(self, module_id: str, dependency_id: str) -> bool: + manifest = self.require_module(module_id) + return dependency_id in manifest.dependencies or (dependency_id in manifest.optional_dependencies and self.has_module(dependency_id)) + + def manifests(self) -> tuple[ModuleManifest, ...]: + return tuple(self._topologically_sorted()) + + def permissions(self) -> tuple[PermissionDefinition, ...]: + return tuple(permission for manifest in self.manifests() for permission in manifest.permissions) + + def role_templates(self) -> tuple[RoleTemplate, ...]: + return tuple(template for manifest in self.manifests() for template in manifest.role_templates) + + def nav_items(self) -> tuple[NavItem, ...]: + return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order)) + + def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]: + return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers) + + def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None: + self._tenant_summary_providers[module_id] = provider + + def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]: + return dict(self._tenant_summary_providers) + + def register_delete_veto(self, resource_type: str, provider: DeleteVetoProvider) -> None: + self._delete_veto_providers[resource_type].append(provider) + + def delete_veto_providers(self, resource_type: str) -> tuple[DeleteVetoProvider, ...]: + return tuple(self._delete_veto_providers.get(resource_type, ())) + + def validate(self) -> RegistrySnapshot: + ordered = tuple(self._topologically_sorted()) + seen_permissions: dict[str, PermissionDefinition] = {} + for manifest in ordered: + for dependency in manifest.dependencies: + if dependency not in self._manifests: + raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}") + for dependency in manifest.optional_dependencies: + if dependency == manifest.id: + raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency") + for permission in manifest.permissions: + if permission.module_id != manifest.id: + raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}") + if not _SCOPE_RE.match(permission.scope): + raise RegistryError(f"Permission scope must be ::: {permission.scope!r}") + expected_prefix = f"{permission.module_id}:{permission.resource}:" + if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action: + raise RegistryError(f"Permission fields do not match scope {permission.scope!r}") + if permission.scope in seen_permissions: + raise RegistryError(f"Duplicate permission scope: {permission.scope}") + seen_permissions[permission.scope] = permission + + known_scopes = set(seen_permissions) + for manifest in ordered: + for template in manifest.role_templates: + for scope in template.permissions: + if scope in {"*", "tenant:*", "system:*"}: + continue + if _WILDCARD_RE.match(scope): + continue + if scope not in known_scopes: + raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}") + + return RegistrySnapshot( + manifests=ordered, + permissions=tuple(seen_permissions.values()), + role_templates=tuple(template for manifest in ordered for template in manifest.role_templates), + nav_items=self.nav_items(), + ) + + def _topologically_sorted(self) -> Iterable[ModuleManifest]: + incoming: dict[str, set[str]] = {module_id: set(manifest.dependencies) for module_id, manifest in self._manifests.items()} + dependents: dict[str, set[str]] = defaultdict(set) + for module_id, dependencies in incoming.items(): + for dependency in dependencies: + dependents[dependency].add(module_id) + + ready = deque(sorted(module_id for module_id, dependencies in incoming.items() if not dependencies)) + ordered: list[str] = [] + while ready: + module_id = ready.popleft() + ordered.append(module_id) + for dependent in sorted(dependents.get(module_id, ())): + incoming[dependent].discard(module_id) + if not incoming[dependent]: + ready.append(dependent) + + if len(ordered) != len(self._manifests): + unresolved = ", ".join(sorted(module_id for module_id, dependencies in incoming.items() if dependencies)) + raise RegistryError(f"Module dependency cycle or unresolved dependency: {unresolved}") + return (self._manifests[module_id] for module_id in ordered) + diff --git a/src/govoplan_core/db/__init__.py b/src/govoplan_core/db/__init__.py new file mode 100644 index 0000000..4325d31 --- /dev/null +++ b/src/govoplan_core/db/__init__.py @@ -0,0 +1,14 @@ +from govoplan_core.db.base import Base, GovoplanBase, NAMING_CONVENTION, TimestampMixin, utcnow +from govoplan_core.db.session import DatabaseHandle, configure_database, get_database, get_session + +__all__ = [ + "Base", + "GovoplanBase", + "NAMING_CONVENTION", + "TimestampMixin", + "utcnow", + "DatabaseHandle", + "configure_database", + "get_database", + "get_session", +] diff --git a/src/govoplan_core/db/base.py b/src/govoplan_core/db/base.py new file mode 100644 index 0000000..3ba50be --- /dev/null +++ b/src/govoplan_core/db/base.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import JSON, MetaData +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy.types import DateTime + + +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class GovoplanBase(DeclarativeBase): + metadata = MetaData(naming_convention=NAMING_CONVENTION) + + type_annotation_map = { + dict[str, Any]: JSON, + list[dict[str, Any]]: JSON, + list[str]: JSON, + } + + +Base = GovoplanBase + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) diff --git a/src/govoplan_core/db/bootstrap.py b/src/govoplan_core/db/bootstrap.py new file mode 100644 index 0000000..7056df2 --- /dev/null +++ b/src/govoplan_core/db/bootstrap.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from govoplan_core.admin.service import ensure_default_roles, get_or_create_account +from govoplan_core.db.base import Base +from govoplan_core.db.models import Role, SystemRoleAssignment, Tenant, User, UserRoleAssignment +from govoplan_core.db.session import get_database +from govoplan_core.security.api_keys import CreatedApiKey, create_api_key +from govoplan_core.security.permissions import TENANT_SCOPES + +DEFAULT_SCOPES = sorted(TENANT_SCOPES) + + +@dataclass(slots=True) +class BootstrapResult: + tenant: Tenant + user: User + created_api_key: CreatedApiKey | None + + +def create_all_tables() -> None: + # Import models so SQLAlchemy sees all tables. + from govoplan_core.db import models # noqa: F401 + from govoplan_core.access.db import models as access_models # noqa: F401 + from govoplan_files.backend.db import models as file_models # noqa: F401 + from govoplan_mail.backend.db import models as mail_models # noqa: F401 + from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 + from govoplan_core.access.db.base import AccessBase + + engine = get_database().engine + Base.metadata.create_all(bind=engine) + AccessBase.metadata.create_all(bind=engine) + + +def bootstrap_dev_data( + session: Session, + *, + api_key_secret: str | None = None, + tenant_slug: str = "default", + user_email: str = "admin@example.local", + user_password: str = "dev-admin", +) -> BootstrapResult: + tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none() + if tenant is None: + tenant = Tenant(slug=tenant_slug, name="Default Tenant", default_locale="en", settings={}) + session.add(tenant) + session.flush() + + tenant_roles = ensure_default_roles(session, tenant) + system_roles = ensure_default_roles(session, None) + + account, _, _ = get_or_create_account( + session, + email=user_email, + display_name="Development Admin", + password=user_password, + password_reset_required=False, + ) + # Keep development credentials deterministic when an old create_all database + # is reused. This is guarded by the explicit dev bootstrap setting. + from govoplan_core.security.passwords import hash_password + + if not account.password_hash: + account.password_hash = hash_password(user_password) + account.is_active = True + session.add(account) + + user = session.query(User).filter(User.tenant_id == tenant.id, User.account_id == account.id).one_or_none() + if user is None: + user = User( + tenant_id=tenant.id, + account_id=account.id, + email=account.email, + display_name="Development Admin", + is_tenant_admin=True, # legacy presentation flag; RBAC uses the owner role below. + auth_provider=account.auth_provider, + password_hash=account.password_hash, + ) + session.add(user) + session.flush() + else: + user.email = account.email + user.password_hash = account.password_hash + user.is_active = True + session.add(user) + + owner_role = tenant_roles["owner"] + existing_assignment = session.query(UserRoleAssignment).filter( + UserRoleAssignment.tenant_id == tenant.id, + UserRoleAssignment.user_id == user.id, + UserRoleAssignment.role_id == owner_role.id, + ).one_or_none() + if existing_assignment is None: + session.add(UserRoleAssignment(tenant_id=tenant.id, user_id=user.id, role_id=owner_role.id)) + + system_owner = system_roles["system_owner"] + existing_system_assignment = session.query(SystemRoleAssignment).filter( + SystemRoleAssignment.account_id == account.id, + SystemRoleAssignment.role_id == system_owner.id, + ).one_or_none() + if existing_system_assignment is None: + session.add(SystemRoleAssignment(account_id=account.id, role_id=system_owner.id)) + + created_api_key = None + if api_key_secret: + existing = [key for key in user.api_keys if key.name == "Development API key" and key.revoked_at is None] + if not existing: + created_api_key = create_api_key( + session, + user=user, + name="Development API key", + scopes=DEFAULT_SCOPES, + secret=api_key_secret, + ) + + session.commit() + return BootstrapResult(tenant=tenant, user=user, created_api_key=created_api_key) diff --git a/src/govoplan_core/db/migrate.py b/src/govoplan_core/db/migrate.py new file mode 100644 index 0000000..4647774 --- /dev/null +++ b/src/govoplan_core/db/migrate.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from govoplan_core.db.migrations import migrate_database + + +def main() -> None: + result = migrate_database() + if result.reconciled_revision: + print(f"Reconciled legacy database marker to {result.reconciled_revision}.") + print(f"Database schema upgraded to {result.current_revision}.") + + +if __name__ == "__main__": + main() diff --git a/src/govoplan_core/db/migrations.py b/src/govoplan_core/db/migrations.py new file mode 100644 index 0000000..871b51c --- /dev/null +++ b/src/govoplan_core/db/migrations.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from dataclasses import dataclass +import os +from pathlib import Path + +from alembic import command +from alembic.config import Config +from alembic.runtime.migration import MigrationContext +from sqlalchemy import create_engine, inspect + +from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan +from govoplan_core.server.default_config import get_server_config +from govoplan_core.server.registry import build_platform_registry +from govoplan_core.settings import settings + +# Historic development databases could be created partly through Alembic and +# partly through Base.metadata.create_all(). In that state Alembic still says +# "2c..." while the 3d/4e file-storage tables already exist, so a normal +# upgrade attempts to create file_blobs again. This reconciliation is kept +# deliberately narrow and only advances the marker when the complete expected +# schema for the skipped revisions is already present. +REVISION_AUTH_RBAC = "2c3d4e5f6a7b" +REVISION_FILE_STORAGE = "3d4e5f6a7b8c" +REVISION_FILE_FOLDERS = "4e5f6a7b8c9d" + +_FILE_STORAGE_TABLES = { + "file_blobs", + "file_assets", + "file_versions", + "file_shares", + "campaign_attachment_uses", +} +_FILE_FOLDER_TABLES = {"file_folders"} + +_FILE_STORAGE_COLUMNS = { + "file_blobs": { + "id", + "tenant_id", + "storage_backend", + "storage_key", + "checksum_sha256", + "size_bytes", + }, + "file_assets": { + "id", + "tenant_id", + "owner_type", + "display_path", + "filename", + "current_version_id", + }, + "file_versions": { + "id", + "file_asset_id", + "blob_id", + "version_number", + "checksum_sha256", + }, + "file_shares": {"id", "file_asset_id", "target_type", "target_id", "permission"}, + "campaign_attachment_uses": { + "id", + "campaign_id", + "campaign_version_id", + "file_asset_id", + "file_version_id", + "file_blob_id", + }, + "file_folders": {"id", "tenant_id", "owner_type", "path"}, +} + + +@dataclass(frozen=True, slots=True) +class MigrationResult: + previous_revision: str | None + reconciled_revision: str | None + current_revision: str | None + + +def registered_module_migration_plan() -> MigrationMetadataPlan: + server_config = get_server_config() + registry = build_platform_registry( + server_config.enabled_modules, + manifest_factories=server_config.manifest_factories, + ) + return migration_metadata_plan(registry) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def alembic_config(*, database_url: str | None = None) -> Config: + root = _repo_root() + config = Config(str(root / "alembic.ini")) + config.set_main_option("script_location", str(root / "alembic")) + + plan = registered_module_migration_plan() + version_locations = [str(root / "alembic" / "versions")] + version_locations.extend(location for location in plan.script_locations if location) + config.set_main_option("version_locations", os.pathsep.join(dict.fromkeys(version_locations))) + config.set_main_option("version_path_separator", "os") + + config.attributes["database_url"] = database_url or settings.database_url + return config + + +def database_revision(database_url: str | None = None) -> str | None: + url = database_url or settings.database_url + engine = create_engine(url) + try: + with engine.connect() as connection: + return MigrationContext.configure(connection).get_current_revision() + finally: + engine.dispose() + + +def _has_columns(inspector, table_name: str, required: set[str]) -> bool: + try: + actual = {column["name"] for column in inspector.get_columns(table_name)} + except Exception: + return False + return required.issubset(actual) + + +def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str | None: + """Repair the known Alembic/create_all drift without modifying table data. + + Returns the revision stamped during reconciliation, or ``None`` when no + repair was necessary. A partial/unknown schema is intentionally left alone + so Alembic can fail visibly instead of guessing. + """ + + url = database_url or settings.database_url + engine = create_engine(url) + try: + with engine.connect() as connection: + current = MigrationContext.configure(connection).get_current_revision() + schema = inspect(connection) + tables = set(schema.get_table_names()) + + has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all( + _has_columns(schema, table, _FILE_STORAGE_COLUMNS[table]) + for table in _FILE_STORAGE_TABLES + ) + has_file_folders = _FILE_FOLDER_TABLES.issubset(tables) and _has_columns( + schema, + "file_folders", + _FILE_STORAGE_COLUMNS["file_folders"], + ) + finally: + engine.dispose() + + target: str | None = None + if current == REVISION_AUTH_RBAC and has_file_storage and has_file_folders: + target = REVISION_FILE_FOLDERS + elif current == REVISION_AUTH_RBAC and has_file_storage: + target = REVISION_FILE_STORAGE + elif current == REVISION_FILE_STORAGE and has_file_folders: + target = REVISION_FILE_FOLDERS + elif current is None and has_file_storage and has_file_folders: + # This is the other create_all-only development shape. The strict + # column checks above ensure that we only stamp a complete known schema. + target = REVISION_FILE_FOLDERS + + if target is None: + return None + + command.stamp(alembic_config(database_url=url), target) + return target + + +def migrate_database( + *, + database_url: str | None = None, + reconcile_legacy_schema: bool = True, +) -> MigrationResult: + url = database_url or settings.database_url + previous = database_revision(url) + reconciled = reconcile_legacy_create_all_schema(url) if reconcile_legacy_schema else None + command.upgrade(alembic_config(database_url=url), "head") + current = database_revision(url) + return MigrationResult( + previous_revision=previous, + reconciled_revision=reconciled, + current_revision=current, + ) diff --git a/src/govoplan_core/db/models.py b/src/govoplan_core/db/models.py new file mode 100644 index 0000000..cca07a1 --- /dev/null +++ b/src/govoplan_core/db/models.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, JSON, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class Account(Base, TimestampMixin): + """Global login identity shared by one or more tenant memberships.""" + + __tablename__ = "accounts" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + email: Mapped[str] = mapped_column(String(320), nullable=False) + normalized_email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True) + display_name: Mapped[str | None] = mapped_column(String(255)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + auth_provider: Mapped[str] = mapped_column(String(50), default="local", nullable=False) + password_hash: Mapped[str | None] = mapped_column(String(500)) + password_reset_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + memberships: Mapped[list[User]] = relationship(back_populates="account") + auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="account", cascade="all, delete-orphan") + system_role_assignments: Mapped[list[SystemRoleAssignment]] = relationship( + back_populates="account", cascade="all, delete-orphan" + ) + + +class Tenant(Base, TimestampMixin): + __tablename__ = "tenants" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + allow_custom_groups: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + allow_custom_roles: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + allow_api_keys: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + users: Mapped[list[User]] = relationship(back_populates="tenant", cascade="all, delete-orphan") + + +class User(Base, TimestampMixin): + __tablename__ = "users" + __table_args__ = ( + UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"), + UniqueConstraint("tenant_id", "account_id", name="uq_users_tenant_account"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True) + email: Mapped[str] = mapped_column(String(320), nullable=False, index=True) + display_name: Mapped[str | None] = mapped_column(String(255)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_tenant_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + auth_provider: Mapped[str] = mapped_column(String(50), default="local", nullable=False) + password_hash: Mapped[str | None] = mapped_column(String(500)) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + tenant: Mapped[Tenant] = relationship(back_populates="users") + account: Mapped[Account] = relationship(back_populates="memberships") + api_keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan") + auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="user", cascade="all, delete-orphan") + + +class Group(Base, TimestampMixin): + __tablename__ = "groups" + __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_groups_tenant_slug"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + system_template_id: Mapped[str | None] = mapped_column(ForeignKey("governance_templates.id", ondelete="SET NULL"), nullable=True, index=True) + system_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class Role(Base, TimestampMixin): + __tablename__ = "roles" + __table_args__ = ( + UniqueConstraint("tenant_id", "slug", name="uq_roles_tenant_slug"), + Index( + "uq_roles_system_slug", + "slug", + unique=True, + sqlite_where=text("tenant_id IS NULL"), + postgresql_where=text("tenant_id IS NULL"), + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + permissions: Mapped[list[str]] = mapped_column(JSON, default=list) + is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_assignable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + system_template_id: Mapped[str | None] = mapped_column(ForeignKey("governance_templates.id", ondelete="SET NULL"), nullable=True, index=True) + system_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + +class SystemSettings(Base, TimestampMixin): + __tablename__ = "system_settings" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global") + default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False) + allow_tenant_custom_groups: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + allow_tenant_custom_roles: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + allow_tenant_api_keys: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class GovernanceTemplate(Base, TimestampMixin): + __tablename__ = "governance_templates" + __table_args__ = (UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + +class GovernanceTemplateAssignment(Base, TimestampMixin): + __tablename__ = "governance_template_assignments" + __table_args__ = (UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + template_id: Mapped[str] = mapped_column(ForeignKey("governance_templates.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + mode: Mapped[str] = mapped_column(String(20), default="available", nullable=False) + + +class SystemRoleAssignment(Base, TimestampMixin): + __tablename__ = "system_role_assignments" + __table_args__ = (UniqueConstraint("account_id", "role_id", name="uq_system_role_assignments"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True) + role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True) + + account: Mapped[Account] = relationship(back_populates="system_role_assignments") + role: Mapped[Role] = relationship() + + +class UserGroupMembership(Base, TimestampMixin): + __tablename__ = "user_group_memberships" + __table_args__ = (UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True) + + +class UserRoleAssignment(Base, TimestampMixin): + __tablename__ = "user_role_assignments" + __table_args__ = (UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_role_assignments"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True) + + +class GroupRoleAssignment(Base, TimestampMixin): + __tablename__ = "group_role_assignments" + __table_args__ = (UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_role_assignments"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True) + role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True) + + +class ApiKey(Base, TimestampMixin): + __tablename__ = "api_keys" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + prefix: Mapped[str] = mapped_column(String(16), nullable=False, index=True) + key_hash: Mapped[str] = mapped_column(String(128), nullable=False) + scopes: Mapped[list[str]] = mapped_column(JSON, default=list) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + user: Mapped[User] = relationship(back_populates="api_keys") + + + + +class AuthSession(Base, TimestampMixin): + __tablename__ = "auth_sessions" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True) + token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True) + csrf_token_hash: Mapped[str | None] = mapped_column(String(128), nullable=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + user_agent: Mapped[str | None] = mapped_column(String(500)) + ip_address: Mapped[str | None] = mapped_column(String(100)) + + user: Mapped[User] = relationship(back_populates="auth_sessions") + account: Mapped[Account] = relationship(back_populates="auth_sessions") + + +class AuditLog(Base, TimestampMixin): + __tablename__ = "audit_log" + __table_args__ = ( + Index("ix_audit_log_scope_created_at", "scope", "created_at"), + Index("ix_audit_log_tenant_scope_created_at", "tenant_id", "scope", "created_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + scope: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) + user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + api_key_id: Mapped[str | None] = mapped_column(ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True, index=True) + action: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + object_type: Mapped[str | None] = mapped_column(String(100), index=True) + object_id: Mapped[str | None] = mapped_column(String(100), index=True) + details: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + + +__all__ = [ + "Account", + "ApiKey", + "AuditLog", + "AuthSession", + "GovernanceTemplate", + "GovernanceTemplateAssignment", + "Group", + "GroupRoleAssignment", + "Role", + "SystemRoleAssignment", + "SystemSettings", + "Tenant", + "User", + "UserGroupMembership", + "UserRoleAssignment", +] diff --git a/src/govoplan_core/db/session.py b/src/govoplan_core/db/session.py new file mode 100644 index 0000000..04217c5 --- /dev/null +++ b/src/govoplan_core/db/session.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from collections.abc import Generator, Mapping +from typing import Any + +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + + +def default_connect_args(database_url: str) -> dict[str, Any]: + return {"check_same_thread": False} if database_url.startswith("sqlite") else {} + + +def create_database_engine( + database_url: str, + *, + pool_pre_ping: bool = True, + connect_args: Mapping[str, Any] | None = None, + **kwargs: Any, +) -> Engine: + merged_connect_args = dict(default_connect_args(database_url)) + if connect_args: + merged_connect_args.update(connect_args) + return create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs) + + +class DatabaseHandle: + def __init__(self, database_url: str, *, engine: Engine | None = None) -> None: + self.database_url = database_url + self.engine = engine or create_database_engine(database_url) + self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, expire_on_commit=False) + + def session(self) -> Session: + return self.SessionLocal() + + def dependency(self) -> Generator[Session, None, None]: + with self.SessionLocal() as session: + yield session + + +_default_database: DatabaseHandle | None = None + + +def configure_database(database_url: str, *, engine: Engine | None = None) -> DatabaseHandle: + global _default_database + if engine is None and _default_database is not None and _default_database.database_url == database_url: + return _default_database + _default_database = DatabaseHandle(database_url, engine=engine) + return _default_database + + +def set_database(handle: DatabaseHandle) -> DatabaseHandle: + global _default_database + _default_database = handle + return handle + + +def get_database() -> DatabaseHandle: + if _default_database is None: + raise RuntimeError("GovOPlaN database is not configured") + return _default_database + + +def get_session() -> Generator[Session, None, None]: + yield from get_database().dependency() diff --git a/src/govoplan_core/privacy/__init__.py b/src/govoplan_core/privacy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_core/privacy/retention.py b/src/govoplan_core/privacy/retention.py new file mode 100644 index 0000000..834b4c7 --- /dev/null +++ b/src/govoplan_core/privacy/retention.py @@ -0,0 +1,664 @@ +from __future__ import annotations + +import copy +import hashlib +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from sqlalchemy.orm import Session + +from govoplan_core.admin.governance import get_system_settings +from govoplan_core.db.models import AuditLog, Group, SystemSettings, Tenant, User +from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus, JobSendStatus +from govoplan_core.settings import settings + +PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy" +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", +) +AUDIT_DETAIL_LEVEL_ORDER = {"full": 0, "redacted": 1, "minimal": 2} + + +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 + +FINAL_VERSION_STATES = { + "completed", + "partially_completed", + "outcome_unknown", + "failed", + "cancelled", + "archived", +} +FINAL_EML_SEND_STATUSES = { + JobSendStatus.SMTP_ACCEPTED.value, + JobSendStatus.SENT.value, + JobSendStatus.FAILED_PERMANENT.value, + JobSendStatus.CANCELLED.value, + JobSendStatus.OUTCOME_UNKNOWN.value, +} +AUDIT_DETAIL_REDACTION_KEYS = { + "attachments", + "bcc", + "body", + "campaign_json", + "cc", + "content", + "entries", + "html", + "imap", + "message", + "password", + "raw_eml", + "raw_json", + "recipients", + "secret", + "smtp", + "template", + "text", + "token", + "to", +} + + +class PrivacyRetentionPolicy(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( + "raw_campaign_json_retention_days", + "generated_eml_retention_days", + "stored_report_detail_retention_days", + "mock_mailbox_retention_days", + "audit_detail_retention_days", + mode="before", + ) + @classmethod + def _blank_string_is_none(cls, value: Any) -> Any: + if value == "": + return None + return value + + @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 PrivacyRetentionPolicyPatch(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(*RETENTION_DAY_KEYS, mode="before") + @classmethod + def _blank_string_is_none(cls, value: Any) -> Any: + if value == "": + return None + return value + + @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 PrivacyPolicyError(RuntimeError): + pass + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _cutoff(days: int | None, *, now: datetime) -> datetime | None: + if days is None: + return None + return now - timedelta(days=days) + + +def _json_sha256(value: Any) -> str: + payload = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _privacy_policy_data(settings_payload: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(settings_payload, dict): + return {} + data = settings_payload.get(PRIVACY_POLICY_SETTINGS_KEY, {}) + return data if isinstance(data, dict) else {} + + +def _privacy_policy_patch_from_settings(settings_payload: dict[str, Any] | None) -> dict[str, Any]: + data = _privacy_policy_data(settings_payload) + if not data: + return {} + return PrivacyRetentionPolicyPatch.model_validate(data).model_dump(mode="json", exclude_none=True) + + +def _merge_privacy_policy(parent: PrivacyRetentionPolicy, patch: dict[str, Any]) -> PrivacyRetentionPolicy: + payload = parent.model_dump(mode="json") + parent_allow = {**default_allow_lower_level_limits(), **(payload.get("allow_lower_level_limits") or {})} + if parent_allow["store_raw_campaign_json"] and patch.get("store_raw_campaign_json") is False: + payload["store_raw_campaign_json"] = False + for key in RETENTION_DAY_KEYS: + value = patch.get(key) + if value is None or not parent_allow[key]: + continue + current = payload.get(key) + payload[key] = int(value) if current is None else min(int(current), int(value)) + detail_level = patch.get("audit_detail_level") + if parent_allow["audit_detail_level"] and detail_level and AUDIT_DETAIL_LEVEL_ORDER[detail_level] > AUDIT_DETAIL_LEVEL_ORDER[payload["audit_detail_level"]]: + payload["audit_detail_level"] = detail_level + + patch_allow = patch.get("allow_lower_level_limits") or {} + payload["allow_lower_level_limits"] = { + key: parent_allow[key] and bool(patch_allow.get(key, parent_allow[key])) + for key in RETENTION_POLICY_FIELD_KEYS + } + return PrivacyRetentionPolicy.model_validate(payload) + + +def _validate_privacy_patch_against_parent(parent: PrivacyRetentionPolicy, patch: dict[str, Any]) -> None: + parent_payload = parent.model_dump(mode="json") + parent_allow = {**default_allow_lower_level_limits(), **(parent_payload.get("allow_lower_level_limits") or {})} + for key in RETENTION_POLICY_FIELD_KEYS: + if key in patch and patch.get(key) is not None and not parent_allow[key]: + raise PrivacyPolicyError(f"{key} is locked by the parent retention policy.") + patch_allow = patch.get("allow_lower_level_limits") or {} + for key, allowed in patch_allow.items(): + if allowed and not parent_allow[key]: + raise PrivacyPolicyError(f"{key} limiting cannot be re-enabled below a parent retention policy lock.") + if patch.get("store_raw_campaign_json") is True and not parent.store_raw_campaign_json: + raise PrivacyPolicyError("Raw campaign JSON storage cannot be re-enabled below a parent policy that disables it.") + for key in RETENTION_DAY_KEYS: + value = patch.get(key) + parent_value = parent_payload.get(key) + if value is not None and parent_value is not None and int(value) > int(parent_value): + raise PrivacyPolicyError(f"{key} cannot be less restrictive than the parent retention policy.") + detail_level = patch.get("audit_detail_level") + if detail_level and AUDIT_DETAIL_LEVEL_ORDER[detail_level] < AUDIT_DETAIL_LEVEL_ORDER[parent.audit_detail_level]: + raise PrivacyPolicyError("Audit detail level cannot be less restrictive than the parent retention policy.") + + +def _set_settings_privacy_policy(settings_payload: dict[str, Any] | None, policy: dict[str, Any]) -> dict[str, Any]: + payload = dict(settings_payload or {}) + payload[PRIVACY_POLICY_SETTINGS_KEY] = policy + return payload + + +def privacy_policy_from_settings(item: SystemSettings) -> PrivacyRetentionPolicy: + return PrivacyRetentionPolicy.model_validate(_privacy_policy_data(item.settings or {})) + + +def privacy_policy_from_session(session: Session) -> PrivacyRetentionPolicy: + return privacy_policy_from_settings(get_system_settings(session)) + + +def set_privacy_policy(item: SystemSettings, policy: PrivacyRetentionPolicy | dict[str, Any]) -> PrivacyRetentionPolicy: + validated = policy if isinstance(policy, PrivacyRetentionPolicy) else PrivacyRetentionPolicy.model_validate(policy) + item.settings = _set_settings_privacy_policy(item.settings, validated.model_dump(mode="json")) + return validated + + +def effective_privacy_policy( + session: Session, + *, + tenant_id: str | None = None, + owner_user_id: str | None = None, + owner_group_id: str | None = None, + campaign_id: str | None = None, +) -> PrivacyRetentionPolicy: + policy = privacy_policy_from_session(session) + campaign: Campaign | None = None + if campaign_id: + campaign = session.get(Campaign, campaign_id) + if campaign is None: + raise PrivacyPolicyError("Campaign not found for privacy policy") + tenant_id = campaign.tenant_id + owner_user_id = campaign.owner_user_id + owner_group_id = campaign.owner_group_id + if tenant_id: + tenant = session.get(Tenant, tenant_id) + if tenant is None: + raise PrivacyPolicyError("Tenant not found for privacy policy") + policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(tenant.settings or {})) + if owner_user_id: + user = session.get(User, owner_user_id) + if user and (not tenant_id or user.tenant_id == tenant_id): + policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(user.settings or {})) + if owner_group_id: + group = session.get(Group, owner_group_id) + if group and (not tenant_id or group.tenant_id == tenant_id): + policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(group.settings or {})) + if campaign is not None: + policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(campaign.settings or {})) + return policy + + +def parent_privacy_policy(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None = None) -> PrivacyRetentionPolicy: + clean_scope = scope_type.strip().casefold() + policy = privacy_policy_from_session(session) + if clean_scope == "tenant": + return policy + tenant = session.get(Tenant, tenant_id) + if tenant is None: + raise PrivacyPolicyError("Tenant not found for privacy policy") + policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(tenant.settings or {})) + if clean_scope in {"user", "group"}: + return policy + if clean_scope != "campaign" or not scope_id: + return policy + campaign = session.get(Campaign, scope_id) + if campaign is None or campaign.tenant_id != tenant_id: + raise PrivacyPolicyError("Campaign not found for privacy policy") + if campaign.owner_user_id: + user = session.get(User, campaign.owner_user_id) + if user and user.tenant_id == tenant_id: + policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(user.settings or {})) + if campaign.owner_group_id: + group = session.get(Group, campaign.owner_group_id) + if group and group.tenant_id == tenant_id: + policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(group.settings or {})) + return policy + + +def get_privacy_policy_for_scope(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None = None) -> dict[str, Any]: + clean_scope = scope_type.strip().casefold() + if clean_scope == "system": + return privacy_policy_from_session(session).model_dump(mode="json") + if clean_scope == "tenant": + tenant = session.get(Tenant, scope_id or tenant_id) + if tenant is None or tenant.id != tenant_id: + raise PrivacyPolicyError("Tenant privacy policy not found") + return _privacy_policy_patch_from_settings(tenant.settings or {}) + if not scope_id: + raise PrivacyPolicyError(f"{clean_scope.capitalize()} privacy policy requires scope_id") + if clean_scope == "user": + user = session.get(User, scope_id) + if user is None or user.tenant_id != tenant_id: + raise PrivacyPolicyError("User privacy policy not found") + return _privacy_policy_patch_from_settings(user.settings or {}) + if clean_scope == "group": + group = session.get(Group, scope_id) + if group is None or group.tenant_id != tenant_id: + raise PrivacyPolicyError("Group privacy policy not found") + return _privacy_policy_patch_from_settings(group.settings or {}) + if clean_scope == "campaign": + campaign = session.get(Campaign, scope_id) + if campaign is None or campaign.tenant_id != tenant_id: + raise PrivacyPolicyError("Campaign privacy policy not found") + return _privacy_policy_patch_from_settings(campaign.settings or {}) + raise PrivacyPolicyError("Privacy policy scope must be system, tenant, user, group or campaign") + + +def set_privacy_policy_for_scope( + session: Session, + *, + tenant_id: str, + scope_type: str, + scope_id: str | None = None, + policy: dict[str, Any] | None = None, +) -> dict[str, Any]: + clean_scope = scope_type.strip().casefold() + if clean_scope == "system": + item = get_system_settings(session) + validated = set_privacy_policy(item, PrivacyRetentionPolicy.model_validate(policy or {})) + session.add(item) + return validated.model_dump(mode="json") + patch = PrivacyRetentionPolicyPatch.model_validate(policy or {}).model_dump(mode="json", exclude_none=True) + _validate_privacy_patch_against_parent(parent_privacy_policy(session, tenant_id=tenant_id, scope_type=clean_scope, scope_id=scope_id or (tenant_id if clean_scope == "tenant" else None)), patch) + if clean_scope == "tenant": + tenant = session.get(Tenant, scope_id or tenant_id) + if tenant is None or tenant.id != tenant_id: + raise PrivacyPolicyError("Tenant privacy policy not found") + tenant.settings = _set_settings_privacy_policy(tenant.settings, patch) + session.add(tenant) + return patch + if not scope_id: + raise PrivacyPolicyError(f"{clean_scope.capitalize()} privacy policy requires scope_id") + if clean_scope == "user": + user = session.get(User, scope_id) + if user is None or user.tenant_id != tenant_id: + raise PrivacyPolicyError("User privacy policy not found") + user.settings = _set_settings_privacy_policy(user.settings, patch) + session.add(user) + return patch + if clean_scope == "group": + group = session.get(Group, scope_id) + if group is None or group.tenant_id != tenant_id: + raise PrivacyPolicyError("Group privacy policy not found") + group.settings = _set_settings_privacy_policy(group.settings, patch) + session.add(group) + return patch + if clean_scope == "campaign": + campaign = session.get(Campaign, scope_id) + if campaign is None or campaign.tenant_id != tenant_id: + raise PrivacyPolicyError("Campaign privacy policy not found") + campaign.settings = _set_settings_privacy_policy(campaign.settings, patch) + session.add(campaign) + return patch + raise PrivacyPolicyError("Privacy policy scope must be system, tenant, user, group or campaign") + + +def sanitize_audit_details_for_policy(session: Session, details: dict[str, Any]) -> dict[str, Any]: + policy = privacy_policy_from_session(session) + if policy.audit_detail_level == "full": + return details + if policy.audit_detail_level == "minimal": + return { + "_privacy": {"detail_level": "minimal"}, + "keys": sorted(str(key) for key in details.keys()), + } + return _redact_audit_value(details) + + +def _redact_audit_value(value: Any) -> Any: + if isinstance(value, dict): + redacted: dict[str, Any] = {} + for key, item in value.items(): + if str(key).lower() in AUDIT_DETAIL_REDACTION_KEYS: + redacted[key] = "" + else: + redacted[key] = _redact_audit_value(item) + return redacted + if isinstance(value, list): + return [_redact_audit_value(item) for item in value] + return value + + +def _is_before_cutoff(value: datetime | None, cutoff: datetime | None) -> bool: + if value is None or cutoff is None: + return False + candidate = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value + return candidate < cutoff + + +def _system_cutoffs(policy: PrivacyRetentionPolicy, *, now: datetime) -> dict[str, datetime | None]: + return { + "raw_campaign_json": _cutoff(0 if not policy.store_raw_campaign_json else policy.raw_campaign_json_retention_days, now=now), + "generated_eml": _cutoff(policy.generated_eml_retention_days, now=now), + "stored_report_detail": _cutoff(policy.stored_report_detail_retention_days, now=now), + "mock_mailbox": _cutoff(policy.mock_mailbox_retention_days, now=now), + "audit_detail": _cutoff(policy.audit_detail_retention_days, now=now), + } + + +def apply_retention_policy(session: Session, *, dry_run: bool = True) -> dict[str, Any]: + now = _utcnow() + policy = privacy_policy_from_session(session) + cutoffs = _system_cutoffs(policy, now=now) + counts = { + "raw_campaign_json": _apply_raw_json_retention(session, dry_run=dry_run, now=now), + "generated_eml": _apply_eml_retention(session, dry_run=dry_run, now=now), + "stored_report_detail": _apply_report_detail_retention(session, dry_run=dry_run, now=now), + "mock_mailbox": _apply_mock_mailbox_retention(cutoffs["mock_mailbox"], dry_run=dry_run), + "audit_detail": _apply_audit_detail_retention(session, dry_run=dry_run, now=now), + } + return { + "dry_run": dry_run, + "policy": policy.model_dump(mode="json"), + "cutoffs": {key: value.isoformat() if value else None for key, value in cutoffs.items()}, + "effective_policy_scope": "per-object", + "counts": counts, + } + + +def _campaign_policy_for_id(session: Session, campaign_id: str | None, cache: dict[str, PrivacyRetentionPolicy]) -> PrivacyRetentionPolicy: + if not campaign_id: + return privacy_policy_from_session(session) + if campaign_id not in cache: + cache[campaign_id] = effective_privacy_policy(session, campaign_id=campaign_id) + return cache[campaign_id] + + +def _apply_raw_json_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]: + result = {"eligible": 0, "redacted": 0, "skipped_not_final": 0, "already_redacted": 0} + policy_cache: dict[str, PrivacyRetentionPolicy] = {} + versions = ( + session.query(CampaignVersion) + .order_by(CampaignVersion.updated_at.asc()) + .all() + ) + for version in versions: + policy = _campaign_policy_for_id(session, version.campaign_id, policy_cache) + cutoff = _cutoff(0 if not policy.store_raw_campaign_json else policy.raw_campaign_json_retention_days, now=now) + if not _is_before_cutoff(version.updated_at, cutoff): + continue + raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} + if raw_json.get("_retention", {}).get("raw_json_redacted"): + result["already_redacted"] += 1 + continue + if version.workflow_state not in FINAL_VERSION_STATES: + result["skipped_not_final"] += 1 + continue + result["eligible"] += 1 + if dry_run: + continue + snapshot = version.execution_snapshot if isinstance(version.execution_snapshot, dict) else {} + version.raw_json = { + "version": version.schema_version or "1.0", + "_retention": { + "raw_json_redacted": True, + "redacted_at": now.isoformat(), + "original_sha256": snapshot.get("campaign_json_sha256") or _json_sha256(raw_json), + }, + } + session.add(version) + result["redacted"] += 1 + return result + + +def _apply_eml_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]: + result = {"eligible": 0, "metadata_cleared": 0, "files_deleted": 0, "files_missing": 0, "skipped_not_final": 0} + policy_cache: dict[str, PrivacyRetentionPolicy] = {} + jobs = ( + session.query(CampaignJob) + .filter((CampaignJob.eml_local_path.is_not(None)) | (CampaignJob.eml_storage_key.is_not(None))) + .order_by(CampaignJob.updated_at.asc()) + .all() + ) + for job in jobs: + policy = _campaign_policy_for_id(session, job.campaign_id, policy_cache) + cutoff = _cutoff(policy.generated_eml_retention_days, now=now) + if not _is_before_cutoff(job.updated_at, cutoff): + continue + if job.queue_status in {JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value} or job.send_status not in FINAL_EML_SEND_STATUSES: + result["skipped_not_final"] += 1 + continue + if job.imap_status == JobImapStatus.PENDING.value: + result["skipped_not_final"] += 1 + continue + result["eligible"] += 1 + if dry_run: + continue + if job.eml_local_path: + path = Path(job.eml_local_path) + if path.exists(): + path.unlink() + result["files_deleted"] += 1 + else: + result["files_missing"] += 1 + job.eml_local_path = None + job.eml_storage_key = None + session.add(job) + result["metadata_cleared"] += 1 + return result + + +def _apply_report_detail_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]: + result = {"eligible_versions": 0, "summaries_redacted": 0, "already_redacted": 0} + policy_cache: dict[str, PrivacyRetentionPolicy] = {} + versions = ( + session.query(CampaignVersion) + .order_by(CampaignVersion.updated_at.asc()) + .all() + ) + for version in versions: + policy = _campaign_policy_for_id(session, version.campaign_id, policy_cache) + cutoff = _cutoff(policy.stored_report_detail_retention_days, now=now) + if not _is_before_cutoff(version.updated_at, cutoff): + continue + next_validation, validation_changed = _redacted_report_summary(version.validation_summary, now=now) + next_build, build_changed = _redacted_report_summary(version.build_summary, now=now) + if not validation_changed and not build_changed: + if _summary_was_redacted(version.validation_summary) or _summary_was_redacted(version.build_summary): + result["already_redacted"] += 1 + continue + result["eligible_versions"] += 1 + if dry_run: + continue + version.validation_summary = next_validation + version.build_summary = next_build + session.add(version) + result["summaries_redacted"] += int(validation_changed) + int(build_changed) + return result + + +def _summary_was_redacted(summary: Any) -> bool: + return isinstance(summary, dict) and bool(summary.get("_retention", {}).get("report_detail_redacted")) + + +def _redacted_report_summary(summary: Any, *, now: datetime) -> tuple[dict[str, Any] | None, bool]: + if not isinstance(summary, dict): + return summary, False + if _summary_was_redacted(summary): + return summary, False + detail_keys = {"messages", "issues", "recent_failures", "jobs"} + if not any(key in summary for key in detail_keys): + return summary, False + next_summary = copy.deepcopy(summary) + for key in detail_keys: + next_summary.pop(key, None) + retention = dict(next_summary.get("_retention") or {}) + retention.update({"report_detail_redacted": True, "redacted_at": now.isoformat()}) + next_summary["_retention"] = retention + return next_summary, True + + +def _apply_mock_mailbox_retention(cutoff: datetime | None, *, dry_run: bool) -> dict[str, int]: + result = {"eligible_records": 0, "json_deleted": 0, "eml_deleted": 0} + if cutoff is None: + return result + message_dir = Path(settings.mock_mailbox_dir) / "messages" + if not message_dir.exists(): + return result + for path in message_dir.glob("*.json"): + try: + record = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + created_at = _parse_datetime(record.get("created_at")) + if created_at and created_at >= cutoff: + continue + result["eligible_records"] += 1 + if dry_run: + continue + raw_filename = record.get("raw_filename") + if raw_filename: + raw_path = message_dir / str(raw_filename) + if raw_path.exists(): + raw_path.unlink() + result["eml_deleted"] += 1 + if path.exists(): + path.unlink() + result["json_deleted"] += 1 + return result + + +def _parse_datetime(value: Any) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _privacy_policy_for_audit_item(session: Session, item: AuditLog, campaign_cache: dict[str, PrivacyRetentionPolicy], tenant_cache: dict[str, PrivacyRetentionPolicy]) -> PrivacyRetentionPolicy: + if item.object_type == "campaign" and item.object_id: + campaign = session.get(Campaign, str(item.object_id)) + if campaign is not None: + return _campaign_policy_for_id(session, campaign.id, campaign_cache) + if item.tenant_id: + if item.tenant_id not in tenant_cache: + tenant_cache[item.tenant_id] = effective_privacy_policy(session, tenant_id=item.tenant_id) + return tenant_cache[item.tenant_id] + return privacy_policy_from_session(session) + + +def _apply_audit_detail_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]: + result = {"eligible": 0, "redacted": 0, "already_redacted": 0} + campaign_cache: dict[str, PrivacyRetentionPolicy] = {} + tenant_cache: dict[str, PrivacyRetentionPolicy] = {} + items = ( + session.query(AuditLog) + .order_by(AuditLog.created_at.asc()) + .all() + ) + for item in items: + policy = _privacy_policy_for_audit_item(session, item, campaign_cache, tenant_cache) + cutoff = _cutoff(policy.audit_detail_retention_days, now=now) + if not _is_before_cutoff(item.created_at, cutoff): + continue + details = item.details if isinstance(item.details, dict) else {} + if details.get("_retention", {}).get("audit_detail_redacted"): + result["already_redacted"] += 1 + continue + result["eligible"] += 1 + if dry_run: + continue + item.details = { + "_retention": { + "audit_detail_redacted": True, + "redacted_at": now.isoformat(), + "original_detail_sha256": _json_sha256(details), + } + } + session.add(item) + result["redacted"] += 1 + return result diff --git a/src/govoplan_core/py.typed b/src/govoplan_core/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_core/security/__init__.py b/src/govoplan_core/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_core/security/api_keys.py b/src/govoplan_core/security/api_keys.py new file mode 100644 index 0000000..b274d80 --- /dev/null +++ b/src/govoplan_core/security/api_keys.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +from sqlalchemy.orm import Session + +from govoplan_core.db.models import ApiKey, User +from govoplan_core.access.auth.tokens import generate_secret, hash_secret, verify_secret +from govoplan_core.security.time import ensure_aware_utc, utc_now + +API_KEY_PREFIX_LENGTH = 12 +API_KEY_RANDOM_BYTES = 32 + + +@dataclass(slots=True) +class CreatedApiKey: + model: ApiKey + secret: str + + +def hash_api_key(secret: str) -> str: + return hash_secret(secret) + + +def verify_api_key(secret: str, expected_hash: str) -> bool: + return verify_secret(secret, expected_hash) + + +def generate_api_key_secret() -> str: + return generate_secret("mm_", random_bytes=API_KEY_RANDOM_BYTES) + + +def api_key_prefix(secret: str) -> str: + # Prefix is only a lookup helper and must not be enough to authenticate. + return secret[:API_KEY_PREFIX_LENGTH] + + +def create_api_key( + session: Session, + *, + user: User, + name: str, + scopes: list[str], + secret: str | None = None, + expires_at: datetime | None = None, +) -> CreatedApiKey: + secret = secret or generate_api_key_secret() + model = ApiKey( + tenant_id=user.tenant_id, + user_id=user.id, + name=name, + prefix=api_key_prefix(secret), + key_hash=hash_api_key(secret), + scopes=scopes, + expires_at=expires_at, + ) + session.add(model) + session.flush() + return CreatedApiKey(model=model, secret=secret) + + +def authenticate_api_key(session: Session, secret: str) -> ApiKey | None: + prefix = api_key_prefix(secret) + candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all() + now = utc_now() + for candidate in candidates: + expires_at = ensure_aware_utc(candidate.expires_at) + if expires_at and expires_at < now: + continue + if verify_api_key(secret, candidate.key_hash): + candidate.last_used_at = now + session.add(candidate) + return candidate + return None + + +def has_scope(api_key: ApiKey, required_scope: str) -> bool: + scopes = set(api_key.scopes or []) + return "*" in scopes or required_scope in scopes diff --git a/src/govoplan_core/security/module_permissions.py b/src/govoplan_core/security/module_permissions.py new file mode 100644 index 0000000..1d14a58 --- /dev/null +++ b/src/govoplan_core/security/module_permissions.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from collections.abc import Iterable + +from govoplan_core.security.permissions import scopes_grant + + +LEGACY_TO_MODULE_SCOPES: dict[str, str] = { + "campaign:read": "campaigns:campaign:read", + "campaign:create": "campaigns:campaign:create", + "campaign:update": "campaigns:campaign:update", + "campaign:copy": "campaigns:campaign:copy", + "campaign:archive": "campaigns:campaign:archive", + "campaign:delete": "campaigns:campaign:delete", + "campaign:share": "campaigns:campaign:share", + "campaign:validate": "campaigns:campaign:validate", + "campaign:build": "campaigns:campaign:build", + "campaign:review": "campaigns:campaign:review", + "campaign:send_test": "campaigns:campaign:send_test", + "campaign:queue": "campaigns:campaign:queue", + "campaign:control": "campaigns:campaign:control", + "campaign:send": "campaigns:campaign:send", + "campaign:retry": "campaigns:campaign:retry", + "campaign:reconcile": "campaigns:campaign:reconcile", + "recipients:read": "campaigns:recipient:read", + "recipients:write": "campaigns:recipient:write", + "recipients:import": "campaigns:recipient:import", + "recipients:export": "campaigns:recipient:export", + "reports:read": "campaigns:report:read", + "reports:export": "campaigns:report:export", + "reports:send": "campaigns:report:send", + "files:read": "files:file:read", + "files:download": "files:file:download", + "files:upload": "files:file:upload", + "files:organize": "files:file:organize", + "files:share": "files:file:share", + "files:delete": "files:file:delete", + "files:admin": "files:file:admin", + "mail_servers:read": "mail:profile:read", + "mail_servers:use": "mail:profile:use", + "mail_servers:test": "mail:profile:test", + "mail_servers:write": "mail:profile:write", + "mail_servers:manage_credentials": "mail:secret:manage", +} + +MODULE_TO_LEGACY_SCOPES = {module: legacy for legacy, module in LEGACY_TO_MODULE_SCOPES.items()} +MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) + + +def compatible_required_scopes(required: str) -> tuple[str, ...]: + legacy = MODULE_TO_LEGACY_SCOPES.get(required) + if legacy: + return (required, legacy) + module = LEGACY_TO_MODULE_SCOPES.get(required) + if module: + return (required, module) + return (required,) + + +def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool: + granted = list(scopes) + if scopes_grant(granted, required): + return True + if "tenant:*" in granted or "*" in granted: + if required in MODULE_TENANT_SCOPES: + return True + return any(scopes_grant(granted, alias) for alias in compatible_required_scopes(required) if alias != required) + diff --git a/src/govoplan_core/security/passwords.py b/src/govoplan_core/security/passwords.py new file mode 100644 index 0000000..da2ca73 --- /dev/null +++ b/src/govoplan_core/security/passwords.py @@ -0,0 +1,37 @@ +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_core/security/permissions.py b/src/govoplan_core/security/permissions.py new file mode 100644 index 0000000..64bd4d2 --- /dev/null +++ b/src/govoplan_core/security/permissions.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + + +@dataclass(frozen=True, slots=True) +class PermissionDefinition: + scope: str + label: str + description: str + category: str + level: str = "tenant" + + +TENANT_PERMISSIONS: tuple[PermissionDefinition, ...] = ( + PermissionDefinition("campaign:read", "View campaigns", "Open campaign metadata, versions and permitted message summaries.", "Campaigns"), + PermissionDefinition("campaign:create", "Create campaigns", "Create new campaigns in an accessible tenant scope.", "Campaigns"), + PermissionDefinition("campaign:update", "Edit campaigns", "Edit current working campaign versions.", "Campaigns"), + PermissionDefinition("campaign:copy", "Copy campaigns", "Create a new campaign or working version from an existing campaign.", "Campaigns"), + PermissionDefinition("campaign:archive", "Archive campaigns", "Archive campaigns without destroying audit evidence.", "Campaigns"), + PermissionDefinition("campaign:delete", "Delete campaigns", "Delete draft-only campaigns where retention policy allows it.", "Campaigns"), + PermissionDefinition("campaign:share", "Share campaigns", "Grant or revoke explicit campaign access for users and groups.", "Campaigns"), + PermissionDefinition("campaign:validate", "Validate campaigns", "Run technical validation and manage validation locks.", "Campaigns"), + PermissionDefinition("campaign:build", "Build campaigns", "Build exact messages and attachment evidence.", "Campaigns"), + PermissionDefinition("campaign:review", "Approve campaign review", "Approve or reject built messages and non-blocking review conditions.", "Campaigns"), + PermissionDefinition("campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "Campaigns"), + PermissionDefinition("campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"), + PermissionDefinition("campaign:control", "Control delivery", "Pause, resume or cancel queued/sending jobs.", "Campaigns"), + PermissionDefinition("campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"), + PermissionDefinition("campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"), + PermissionDefinition("campaign:reconcile", "Reconcile uncertain delivery", "Resolve outcome-unknown SMTP attempts after operational inspection.", "Campaigns"), + PermissionDefinition("recipients:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"), + PermissionDefinition("recipients:write", "Edit recipients", "Create and edit recipient rows and recipient field values.", "Recipients"), + PermissionDefinition("recipients:import", "Import recipients", "Bulk-import recipient lists from files or reusable lists.", "Recipients"), + PermissionDefinition("recipients:export", "Export recipients", "Export recipient data or recipient-complete reports.", "Recipients"), + PermissionDefinition("files:read", "View files", "List, search and preview managed files.", "Files"), + PermissionDefinition("files:download", "Download files", "Download managed files and generated ZIP archives.", "Files"), + PermissionDefinition("files:upload", "Upload files", "Upload new managed file versions.", "Files"), + PermissionDefinition("files:organize", "Organize files", "Create folders, rename, move or copy managed files.", "Files"), + PermissionDefinition("files:share", "Share files", "Grant or revoke managed file shares.", "Files"), + PermissionDefinition("files:delete", "Delete files", "Delete or hide managed files and folders where retention policy allows it.", "Files"), + PermissionDefinition("files:admin", "Administer file spaces", "Access and administer all user and group file spaces in the tenant.", "Files"), + PermissionDefinition("reports:read", "View reports", "View campaign delivery reports and aggregate outcome data.", "Reports and audit"), + PermissionDefinition("reports:export", "Export reports", "Download detailed campaign reports such as CSV exports.", "Reports and audit"), + PermissionDefinition("reports:send", "Send reports", "Email campaign reports to configured recipients.", "Reports and audit"), + PermissionDefinition("audit:read", "View audit log", "Read tenant and campaign audit records.", "Reports and audit"), + PermissionDefinition("mail_servers:read", "View mail servers", "Inspect approved reusable SMTP/IMAP server profile metadata.", "Mail servers"), + PermissionDefinition("mail_servers:use", "Use mail servers", "Select an approved mail-server profile for a campaign without reading secrets.", "Mail servers"), + PermissionDefinition("mail_servers:test", "Test mail servers", "Run SMTP/IMAP connection tests.", "Mail servers"), + PermissionDefinition("mail_servers:write", "Manage mail servers", "Create and edit reusable mail-server profiles.", "Mail servers"), + PermissionDefinition("mail_servers:manage_credentials", "Manage mail credentials", "Create or replace stored SMTP/IMAP credentials.", "Mail servers"), + PermissionDefinition("admin:users:read", "View users", "List tenant users and their effective access.", "Tenant administration"), + PermissionDefinition("admin:users:create", "Create users", "Create tenant memberships for accounts.", "Tenant administration"), + PermissionDefinition("admin:users:update", "Update users", "Edit non-access membership metadata.", "Tenant administration"), + PermissionDefinition("admin:users:suspend", "Suspend users", "Activate or suspend tenant memberships.", "Tenant administration"), + PermissionDefinition("admin:groups:read", "View groups", "List tenant groups, members and assigned roles.", "Tenant administration"), + PermissionDefinition("admin:groups:write", "Manage groups", "Create or edit tenant group definitions.", "Tenant administration"), + PermissionDefinition("admin:groups:manage_members", "Manage group members", "Add and remove users from tenant groups.", "Tenant administration"), + PermissionDefinition("admin:roles:read", "View roles", "Inspect role definitions and the permission catalogue.", "Tenant administration"), + PermissionDefinition("admin:roles:write", "Define roles", "Create and edit assignable tenant role definitions.", "Tenant administration"), + PermissionDefinition("admin:roles:assign", "Assign roles", "Assign tenant roles to users or groups within delegation limits.", "Tenant administration"), + PermissionDefinition("admin:api_keys:read", "View API keys", "List tenant API keys without revealing their secret.", "Tenant administration"), + PermissionDefinition("admin:api_keys:create", "Create API keys", "Create tenant-local API keys within the owner's delegation limits.", "Tenant administration"), + PermissionDefinition("admin:api_keys:revoke", "Revoke API keys", "Revoke tenant-local API keys.", "Tenant administration"), + PermissionDefinition("admin:settings:read", "View tenant settings", "Read tenant defaults and non-policy settings.", "Tenant administration"), + PermissionDefinition("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration"), + PermissionDefinition("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration"), + PermissionDefinition("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration"), +) + +SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = ( + PermissionDefinition("system:tenants:read", "View all tenants", "List tenants and system-wide membership counts.", "System administration", "system"), + PermissionDefinition("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"), + PermissionDefinition("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"), + PermissionDefinition("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"), + PermissionDefinition("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"), + PermissionDefinition("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"), + PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"), + PermissionDefinition("system:accounts:suspend", "Suspend accounts", "Activate or suspend global login accounts while preserving a system owner.", "System administration", "system"), + PermissionDefinition("system:roles:read", "View system roles", "Inspect instance-wide role definitions and their permissions.", "System administration", "system"), + PermissionDefinition("system:roles:write", "Define system roles", "Create and edit instance-wide role definitions within delegation limits.", "System administration", "system"), + PermissionDefinition("system:roles:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "System administration", "system"), + PermissionDefinition("system:access:read", "View system access (legacy)", "Compatibility scope for listing accounts with instance-wide roles.", "System administration", "system"), + PermissionDefinition("system:access:assign", "Assign system access (legacy)", "Compatibility scope for assigning instance-wide roles.", "System administration", "system"), + PermissionDefinition("system:audit:read", "View system audit", "Read audit records across tenants.", "System administration", "system"), + PermissionDefinition("system:settings:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "System administration", "system"), + PermissionDefinition("system:settings:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "System administration", "system"), + PermissionDefinition("system:governance:read", "View governance templates", "Inspect centrally managed group and role definitions.", "System administration", "system"), + PermissionDefinition("system:governance:write", "Manage governance templates", "Create and assign centrally managed group and role definitions.", "System administration", "system"), +) + +ALL_PERMISSIONS: tuple[PermissionDefinition, ...] = TENANT_PERMISSIONS + SYSTEM_PERMISSIONS +KNOWN_SCOPES = frozenset(item.scope for item in ALL_PERMISSIONS) +TENANT_SCOPES = frozenset(item.scope for item in TENANT_PERMISSIONS) +SYSTEM_SCOPES = frozenset(item.scope for item in SYSTEM_PERMISSIONS) + +LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = { + # Only names that are no longer canonical remain runtime aliases. Canonical + # permissions keep their narrow meaning; the Alembic migration expands old + # role records once so upgraded installations do not lose prior access. + "campaign:write": frozenset({ + "campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share", + "recipients:read", "recipients:write", "recipients:import", + }), + "attachments:read": frozenset({"files:read", "files:download"}), + "attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}), + "admin:users": frozenset({ + "admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend", + "admin:groups:read", "admin:groups:write", "admin:groups:manage_members", + "admin:roles:read", "admin:roles:write", "admin:roles:assign", + }), + "admin:users:write": frozenset({"admin:users:create", "admin:users:update", "admin:users:suspend", "admin:roles:assign", "admin:groups:manage_members"}), + "admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}), + "admin:settings": frozenset({"admin:settings:read", "admin:settings:write", "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke"}), + "system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}), + "system:access:write": frozenset({"system:access:assign", "system:roles:assign", "system:accounts:create", "system:accounts:update", "system:accounts:suspend"}), +} + +DEFAULT_TENANT_ROLES: dict[str, dict[str, object]] = { + "owner": {"name": "Owner", "description": "Full tenant access, including administration and delivery.", "permissions": ["tenant:*"], "is_builtin": True, "is_assignable": True}, + "tenant_admin": {"name": "Tenant administrator", "description": "Manage tenant settings, users, groups and roles. Real delivery remains separately delegable.", "permissions": [ + "campaign:read", "files:read", "reports:read", "audit:read", + "admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend", + "admin:groups:read", "admin:groups:write", "admin:groups:manage_members", + "admin:roles:read", "admin:roles:write", "admin:roles:assign", + "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke", + "admin:settings:read", "admin:settings:write", "admin:policies:read", "admin:policies:write", + ], "is_builtin": True, "is_assignable": True}, + "admin": {"name": "Administrator (legacy)", "description": "Legacy broad tenant role retained for upgraded installations.", "permissions": sorted(TENANT_SCOPES), "is_builtin": True, "is_assignable": True}, + "access_admin": {"name": "Access administrator", "description": "Manage memberships and assignments within delegation limits.", "permissions": [ + "admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend", + "admin:groups:read", "admin:groups:manage_members", "admin:roles:read", "admin:roles:assign", + ], "is_builtin": True, "is_assignable": True}, + "campaign_manager": {"name": "Campaign manager", "description": "Prepare, validate and build campaigns, but do not approve or start real delivery.", "permissions": [ + "campaign:read", "campaign:create", "campaign:update", "campaign:copy", "campaign:validate", "campaign:build", + "recipients:read", "recipients:write", "recipients:import", "files:read", "files:download", "files:upload", "files:organize", "reports:read", + ], "is_builtin": True, "is_assignable": True}, + "reviewer": {"name": "Reviewer", "description": "Inspect and approve prepared campaign messages.", "permissions": ["campaign:read", "campaign:validate", "campaign:review", "recipients:read", "files:read", "reports:read"], "is_builtin": True, "is_assignable": True}, + "sender": {"name": "Sender", "description": "Queue, test, control, send and reconcile prepared campaigns.", "permissions": [ + "campaign:read", "campaign:send_test", "campaign:queue", "campaign:control", "campaign:send", "campaign:retry", "campaign:reconcile", + "recipients:read", "files:read", "reports:read", "reports:send", "mail_servers:use", "mail_servers:test", + ], "is_builtin": True, "is_assignable": True}, + "file_manager": {"name": "File manager", "description": "Manage tenant file spaces without campaign delivery rights.", "permissions": ["files:read", "files:download", "files:upload", "files:organize", "files:share", "files:delete"], "is_builtin": True, "is_assignable": True}, + "viewer": {"name": "Viewer", "description": "Read campaigns, recipient data, files and reports without changing them.", "permissions": ["campaign:read", "recipients:read", "files:read", "reports:read"], "is_builtin": True, "is_assignable": True}, + "auditor": {"name": "Auditor", "description": "Read campaigns, recipient evidence, reports and audit records.", "permissions": ["campaign:read", "recipients:read", "recipients:export", "reports:read", "reports:export", "audit:read"], "is_builtin": True, "is_assignable": True}, +} + +DEFAULT_SYSTEM_ROLES: dict[str, dict[str, object]] = { + "system_owner": { + "name": "System owner", + "description": "Protected full instance-wide administration. At least one active account must retain this role.", + "permissions": ["system:*"], + "is_builtin": True, + "is_assignable": True, + "managed": True, + }, + "system_admin": { + "name": "System administrator", + "description": "Manage tenants, accounts, system roles, settings and governance without being able to grant the protected System owner role.", + "permissions": sorted(SYSTEM_SCOPES), + "is_builtin": False, + "is_assignable": True, + "managed": False, + }, + "system_auditor": { + "name": "System auditor", + "description": "Read tenant registry, accounts, system roles, settings, governance and cross-tenant audit records.", + "permissions": [ + "system:tenants:read", "system:accounts:read", "system:roles:read", "system:access:read", + "system:audit:read", "system:settings:read", "system:governance:read", + ], + "is_builtin": False, + "is_assignable": True, + "managed": False, + }, +} + + +def normalize_email(value: str) -> str: + return value.strip().casefold() + + +def scope_grants(granted: str, required: str) -> bool: + if granted == required: + return True + if required in LEGACY_SCOPE_ALIASES.get(granted, frozenset()): + return True + if granted in {"*", "tenant:*"}: + return required in {"*", "tenant:*"} or (required in TENANT_SCOPES) or required in {"attachments:read", "attachments:write"} + if granted == "system:*": + return required.startswith("system:") + if granted.endswith(":*") and required.startswith(granted[:-1]): + return True + return any(scope_grants(alias, required) for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()) if alias != granted) + + +def scopes_grant(scopes: Iterable[str], required: str) -> bool: + return any(scope_grants(scope, required) for scope in scopes) + + +def effective_permission_scopes(scopes: Iterable[str], *, level: str | None = None) -> set[str]: + """Return canonical permissions granted by a possibly wildcarded scope set. + + Wildcards and compatibility aliases are presentation/assignment shorthand; + counts and access summaries should describe the canonical capabilities they + grant rather than counting the shorthand token itself. + """ + if level == "tenant": + candidates = TENANT_SCOPES + elif level == "system": + candidates = SYSTEM_SCOPES + else: + candidates = KNOWN_SCOPES + granted = list(scopes) + return {scope for scope in candidates if scopes_grant(granted, scope)} + + +def effective_permission_count(scopes: Iterable[str], *, level: str | None = None) -> int: + return len(effective_permission_scopes(scopes, level=level)) + + +def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]: + raw = {str(scope) for scope in scopes if scope} + expanded: set[str] = set() + for scope in raw: + if scope in {"*", "tenant:*"}: + expanded.add("tenant:*") + expanded.update(TENANT_SCOPES) + continue + if scope == "system:*": + expanded.add("system:*") + expanded.update(SYSTEM_SCOPES) + continue + aliases = LEGACY_SCOPE_ALIASES.get(scope, frozenset()) + expanded.update(aliases) + for alias in aliases: + if alias.endswith(":*"): + expanded.update(item for item in KNOWN_SCOPES if scope_grants(alias, item)) + if scope.endswith(":*"): + expanded.update(item for item in KNOWN_SCOPES if scope_grants(scope, item)) + expanded.add(scope) + elif include_unknown or scope in KNOWN_SCOPES: + expanded.add(scope) + return sorted(expanded) + + +def delegateable_tenant_scopes(scopes: Iterable[str]) -> set[str]: + expanded = set(expand_scopes(scopes, include_unknown=False)) + if "tenant:*" in expanded: + return set(TENANT_SCOPES) + return {scope for scope in expanded if scope in TENANT_SCOPES} + + +def delegateable_system_scopes(scopes: Iterable[str]) -> set[str]: + expanded = set(expand_scopes(scopes, include_unknown=False)) + if "system:*" in expanded: + return set(SYSTEM_SCOPES) + return {scope for scope in expanded if scope in SYSTEM_SCOPES} + + +def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]: + user = list(user_scopes) + key = list(key_scopes) + allowed = {scope for scope in TENANT_SCOPES if scopes_grant(user, scope) and scopes_grant(key, scope)} + user_raw = set(expand_scopes(user)) + key_raw = set(expand_scopes(key)) + allowed.update(scope for scope in user_raw.intersection(key_raw) if not scope.startswith("system:") and scope not in {"*", "tenant:*"}) + return sorted(allowed) + + +def validate_tenant_permissions(scopes: Iterable[str]) -> list[str]: + normalized = {str(scope) for scope in scopes if scope} + if normalized.intersection({"*", "tenant:*"}): + return ["tenant:*"] + expanded: set[str] = set() + invalid: list[str] = [] + for scope in sorted(normalized): + if scope in TENANT_SCOPES: + expanded.add(scope) + continue + aliases = {item for item in LEGACY_SCOPE_ALIASES.get(scope, frozenset()) if item in TENANT_SCOPES} + if aliases: + expanded.update(aliases) + continue + invalid.append(scope) + if invalid: + raise ValueError(f"Unsupported tenant permissions: {', '.join(invalid)}") + return sorted(expanded) + + +def validate_system_permissions(scopes: Iterable[str]) -> list[str]: + normalized = sorted({str(scope) for scope in scopes if scope}) + invalid = [scope for scope in normalized if scope not in SYSTEM_SCOPES and scope != "system:*"] + if invalid: + raise ValueError(f"Unsupported system permissions: {', '.join(invalid)}") + if "system:*" in normalized: + return ["system:*"] + return normalized diff --git a/src/govoplan_core/security/secrets.py b/src/govoplan_core/security/secrets.py new file mode 100644 index 0000000..8d08138 --- /dev/null +++ b/src/govoplan_core/security/secrets.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import base64 +import hashlib +from functools import lru_cache + +from cryptography.fernet import Fernet, InvalidToken + +from govoplan_core.settings import settings + + +class SecretConfigurationError(RuntimeError): + pass + + +class SecretDecryptionError(RuntimeError): + pass + + +def _normalize_fernet_key(value: str) -> bytes: + candidate = value.strip().encode("utf-8") + try: + Fernet(candidate) + return candidate + except Exception: + pass + try: + raw = base64.b64decode(candidate) + except Exception as exc: + raise SecretConfigurationError("MASTER_KEY_B64 must be a Fernet key or base64-encoded 32-byte key") from exc + if len(raw) != 32: + raise SecretConfigurationError("MASTER_KEY_B64 must decode to exactly 32 bytes") + return base64.urlsafe_b64encode(raw) + + +@lru_cache(maxsize=1) +def _fernet() -> Fernet: + if settings.master_key_b64: + return Fernet(_normalize_fernet_key(settings.master_key_b64)) + if settings.app_env.lower() in {"dev", "test", "local"}: + key = base64.urlsafe_b64encode(hashlib.sha256(b"multi-seal-mail-development-master-key").digest()) + return Fernet(key) + raise SecretConfigurationError("MASTER_KEY_B64 is required outside dev/test/local environments") + + +def encrypt_secret(value: str | None) -> str | None: + if value in (None, ""): + return None + return _fernet().encrypt(str(value).encode("utf-8")).decode("utf-8") + + +def decrypt_secret(value: str | None) -> str | None: + if not value: + return None + try: + return _fernet().decrypt(value.encode("utf-8")).decode("utf-8") + except InvalidToken as exc: + raise SecretDecryptionError("Stored secret cannot be decrypted with the configured master key") from exc diff --git a/src/govoplan_core/security/sessions.py b/src/govoplan_core/security/sessions.py new file mode 100644 index 0000000..3ce6681 --- /dev/null +++ b/src/govoplan_core/security/sessions.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from sqlalchemy.orm import Session + +from govoplan_core.db.models import ( + Account, + AuthSession, + Group, + GroupRoleAssignment, + Role, + SystemRoleAssignment, + Tenant, + User, + UserGroupMembership, + UserRoleAssignment, +) +from govoplan_core.access.auth.tokens import generate_secret, hash_secret, verify_secret +from govoplan_core.security.permissions import expand_scopes +from govoplan_core.security.time import ensure_aware_utc, utc_now + +SESSION_RANDOM_BYTES = 32 +DEFAULT_SESSION_HOURS = 12 + + +@dataclass(slots=True) +class CreatedSession: + model: AuthSession + token: str + csrf_token: str + + +def generate_session_token() -> str: + return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES) + + +def hash_session_token(token: str) -> str: + return hash_secret(token) + + +def generate_csrf_token() -> str: + return generate_secret("", random_bytes=SESSION_RANDOM_BYTES) + + +def hash_csrf_token(token: str) -> str: + return hash_secret(token) + + +def verify_session_token(token: str, expected_hash: str) -> bool: + return verify_secret(token, expected_hash) + + +def verify_auth_session_csrf(auth_session: AuthSession, csrf_token: str | None) -> bool: + if not csrf_token or not auth_session.csrf_token_hash: + return False + return verify_secret(csrf_token, auth_session.csrf_token_hash) + + +def create_auth_session( + session: Session, + *, + user: User, + hours: int = DEFAULT_SESSION_HOURS, + user_agent: str | None = None, + ip_address: str | None = None, +) -> CreatedSession: + if not user.account_id: + raise ValueError("Tenant membership has no global account.") + token = generate_session_token() + csrf_token = generate_csrf_token() + now = utc_now() + model = AuthSession( + tenant_id=user.tenant_id, + user_id=user.id, + account_id=user.account_id, + token_hash=hash_session_token(token), + csrf_token_hash=hash_csrf_token(csrf_token), + expires_at=now + timedelta(hours=hours), + last_seen_at=now, + user_agent=user_agent, + ip_address=ip_address, + ) + user.last_login_at = now + if user.account: + user.account.last_login_at = now + session.add(user.account) + session.add(model) + session.add(user) + session.flush() + return CreatedSession(model=model, token=token, csrf_token=csrf_token) + + +def authenticate_session_token(session: Session, token: str) -> AuthSession | None: + token_hash = hash_session_token(token) + model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none() + if not model: + return None + now = utc_now() + expires_at = ensure_aware_utc(model.expires_at) + if expires_at is None or expires_at < now: + return None + model.last_seen_at = now + session.add(model) + return model + + +def revoke_auth_session(session: Session, token: str) -> bool: + model = authenticate_session_token(session, token) + if not model: + return False + model.revoked_at = utc_now() + session.add(model) + return True + + +def switch_auth_session_tenant(session: Session, auth_session: AuthSession, tenant_id: str) -> User: + membership = ( + session.query(User) + .join(Tenant, Tenant.id == User.tenant_id) + .filter( + User.account_id == auth_session.account_id, + User.tenant_id == tenant_id, + User.is_active.is_(True), + Tenant.is_active.is_(True), + ) + .one_or_none() + ) + if membership is None: + raise LookupError("The account does not have an active membership in this tenant.") + auth_session.tenant_id = membership.tenant_id + auth_session.user_id = membership.id + auth_session.last_seen_at = utc_now() + session.add(auth_session) + session.flush() + return membership + + +def collect_direct_user_roles(session: Session, user: User) -> list[Role]: + return ( + session.query(Role) + .join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id) + .filter( + UserRoleAssignment.user_id == user.id, + UserRoleAssignment.tenant_id == user.tenant_id, + Role.tenant_id == user.tenant_id, + ) + .order_by(Role.name.asc()) + .all() + ) + + +def collect_user_roles(session: Session, user: User) -> list[Role]: + roles_by_id: dict[str, Role] = {role.id: role for role in collect_direct_user_roles(session, user)} + + group_roles = ( + session.query(Role) + .join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id) + .join(UserGroupMembership, UserGroupMembership.group_id == GroupRoleAssignment.group_id) + .join(Group, Group.id == UserGroupMembership.group_id) + .filter( + UserGroupMembership.user_id == user.id, + UserGroupMembership.tenant_id == user.tenant_id, + Group.is_active.is_(True), + Role.tenant_id == user.tenant_id, + ) + .all() + ) + for role in group_roles: + roles_by_id[role.id] = role + return list(roles_by_id.values()) + + +def collect_system_roles(session: Session, account: Account) -> list[Role]: + return ( + session.query(Role) + .join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id) + .filter(SystemRoleAssignment.account_id == account.id, Role.tenant_id.is_(None)) + .order_by(Role.name.asc()) + .all() + ) + + +def collect_user_groups(session: Session, user: User) -> list[Group]: + return ( + session.query(Group) + .join(UserGroupMembership, UserGroupMembership.group_id == Group.id) + .filter( + UserGroupMembership.user_id == user.id, + UserGroupMembership.tenant_id == user.tenant_id, + Group.is_active.is_(True), + ) + .order_by(Group.name.asc()) + .all() + ) + + +def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]: + scopes: set[str] = set() + for role in collect_user_roles(session, user): + scopes.update(role.permissions or []) + if include_system and user.account: + for role in collect_system_roles(session, user.account): + scopes.update(role.permissions or []) + return expand_scopes(scopes) + + +def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]: + return ( + session.query(User, Tenant) + .join(Tenant, Tenant.id == User.tenant_id) + .filter( + User.account_id == account.id, + User.is_active.is_(True), + Tenant.is_active.is_(True), + ) + .order_by(Tenant.name.asc()) + .all() + ) diff --git a/src/govoplan_core/security/time.py b/src/govoplan_core/security/time.py new file mode 100644 index 0000000..163654b --- /dev/null +++ b/src/govoplan_core/security/time.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from datetime import datetime, timezone + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def ensure_aware_utc(value: datetime | None) -> datetime | None: + """Return a timezone-aware UTC datetime. + + SQLite and some DB drivers may return naive datetimes even when SQLAlchemy + columns are declared as DateTime(timezone=True). Treat naive values as UTC + so comparisons with aware `datetime.now(timezone.utc)` do not crash. + """ + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) diff --git a/src/govoplan_core/server/__init__.py b/src/govoplan_core/server/__init__.py new file mode 100644 index 0000000..53f8a17 --- /dev/null +++ b/src/govoplan_core/server/__init__.py @@ -0,0 +1 @@ +"""GovOPlaN server runner package.""" diff --git a/src/govoplan_core/server/app.py b/src/govoplan_core/server/app.py new file mode 100644 index 0000000..6ac8df4 --- /dev/null +++ b/src/govoplan_core/server/app.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from govoplan_core.core.modules import ModuleContext +from govoplan_core.db.session import configure_database +from govoplan_core.server.config import GovoplanServerConfig, load_server_config +from govoplan_core.server.fastapi import create_govoplan_app +from govoplan_core.server.platform import create_platform_router +from govoplan_core.server.registry import build_platform_registry + + +def create_app(config: GovoplanServerConfig | str | None = None): + if isinstance(config, str) or config is None: + server_config = load_server_config(config) + else: + server_config = config + + database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None + if database_url: + configure_database(str(database_url)) + + registry = build_platform_registry(server_config.enabled_modules, manifest_factories=server_config.manifest_factories) + api_router = APIRouter(prefix=server_config.api_prefix) + + for router in server_config.base_routers: + api_router.include_router(router) + + module_context = ModuleContext(registry=registry, settings=server_config.settings, data=server_config.module_context_data) + for manifest in registry.manifests(): + if manifest.route_factory is not None: + api_router.include_router(manifest.route_factory(module_context)) + + api_router.include_router(create_platform_router()) + + for router in server_config.post_module_routers: + api_router.include_router(router) + + for contribution in server_config.extra_routers: + if contribution.should_include(server_config.settings, registry): + api_router.include_router(contribution.router) + + app = create_govoplan_app( + title=server_config.title, + version=server_config.version, + registry=registry, + api_router=api_router, + lifespan=server_config.lifespan, + cors_origins=server_config.cors_origins, + ) + for configurator in server_config.app_configurators: + configurator(app, registry, server_config.settings) + return app + + +app = create_app() diff --git a/src/govoplan_core/server/config.py b/src/govoplan_core/server/config.py new file mode 100644 index 0000000..f1cf4c6 --- /dev/null +++ b/src/govoplan_core/server/config.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import os +from collections.abc import Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from importlib import import_module +from typing import Any + +from fastapi import APIRouter, FastAPI + +from govoplan_core.core.modules import ModuleManifest +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.server.fastapi import LifespanFactory + +ManifestFactory = Callable[[], ModuleManifest] +RouterEnabled = Callable[[object | None, PlatformRegistry], bool] +AppConfigurator = Callable[[FastAPI, PlatformRegistry, object | None], None] + + +@dataclass(frozen=True, slots=True) +class RouterContribution: + router: APIRouter + required_module: str | None = None + enabled: RouterEnabled | None = None + + def should_include(self, settings: object | None, registry: PlatformRegistry) -> bool: + if self.required_module and not registry.has_module(self.required_module): + return False + if self.enabled is not None and not self.enabled(settings, registry): + return False + return True + + +@dataclass(frozen=True, slots=True) +class GovoplanServerConfig: + title: str = "GovOPlaN Server" + version: str = "0.1.0" + settings: object | None = None + enabled_modules: str | Iterable[str] = ("access",) + api_prefix: str = "/api/v1" + base_routers: Sequence[APIRouter] = () + post_module_routers: Sequence[APIRouter] = () + extra_routers: Sequence[RouterContribution] = () + lifespan: LifespanFactory | None = None + cors_origins: Iterable[str] = () + manifest_factories: Sequence[ManifestFactory] = () + module_context_data: Mapping[str, Any] = field(default_factory=dict) + app_configurators: Sequence[AppConfigurator] = () + + +def import_object(path: str) -> Any: + module_name, separator, attribute = path.partition(":") + if not separator: + raise ValueError(f"Object path must use module:attribute syntax: {path!r}") + module = import_module(module_name) + value: Any = module + for part in attribute.split("."): + value = getattr(value, part) + return value + + +def load_server_config(path: str | None = None) -> GovoplanServerConfig: + config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config" + try: + loaded = import_object(config_path) + except ModuleNotFoundError: + if path or os.getenv("GOVOPLAN_SERVER_CONFIG"): + raise + return GovoplanServerConfig() + config = loaded() if callable(loaded) else loaded + if not isinstance(config, GovoplanServerConfig): + raise TypeError(f"{config_path!r} did not return a GovoplanServerConfig") + return config diff --git a/src/govoplan_core/server/default_config.py b/src/govoplan_core/server/default_config.py new file mode 100644 index 0000000..ea12176 --- /dev/null +++ b/src/govoplan_core/server/default_config.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from importlib import import_module + +from fastapi import Depends, FastAPI + +from govoplan_core.auth.dependencies import ApiPrincipal, require_scope +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.db.bootstrap import bootstrap_dev_data, create_all_tables +from govoplan_core.db.session import get_database +from govoplan_core.server.config import GovoplanServerConfig, RouterContribution +from govoplan_core.settings import Settings, settings + + +@asynccontextmanager +async def lifespan(app: FastAPI): + if settings.app_env.lower() == "dev" and settings.dev_bootstrap_enabled: + create_all_tables() + with get_database().SessionLocal() as session: + bootstrap_dev_data( + session, + api_key_secret=settings.dev_bootstrap_api_key, + user_password=settings.dev_bootstrap_password, + ) + yield + + +def _cors_origins(value: str) -> list[str]: + return [item.strip() for item in value.split(",") if item.strip()] + + +def _settings_module_enabled(module_id: str) -> bool: + return module_id in {item.strip() for item in settings.enabled_modules.split(",") if item.strip()} + + +def _dev_mail_enabled(config_settings: object | None, registry: PlatformRegistry) -> bool: + active_settings = config_settings if isinstance(config_settings, Settings) else settings + return active_settings.app_env == "dev" and active_settings.dev_mailbox_api_enabled and registry.has_module("mail") + + +def _dev_mail_router_contributions() -> tuple[RouterContribution, ...]: + if not (settings.app_env == "dev" and settings.dev_mailbox_api_enabled and _settings_module_enabled("mail")): + return () + module = import_module("govoplan_mail.backend.dev_router") + return (RouterContribution(module.router, required_module="mail", enabled=_dev_mail_enabled),) + + +def register_health_details(app: FastAPI, registry: PlatformRegistry, config_settings: object | None) -> None: + active_settings = config_settings if isinstance(config_settings, Settings) else settings + + @app.get("/health/details") + def health_details( + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), + ): + del principal + return { + "status": "ok", + "env": active_settings.app_env, + "api": {"version": "v1", "auth": "api-key-or-session"}, + "modules": [manifest.id for manifest in registry.manifests()], + "storage": { + "backend": active_settings.file_storage_backend, + "local_root": active_settings.file_storage_local_root if active_settings.file_storage_backend == "local" else None, + "endpoint": active_settings.file_storage_s3_endpoint_url or active_settings.s3_endpoint_url, + "bucket": active_settings.file_storage_s3_bucket or active_settings.s3_bucket, + "region": active_settings.file_storage_s3_region or active_settings.s3_region, + }, + } + + +def get_server_config() -> GovoplanServerConfig: + from govoplan_core.api.v1.admin import router as admin_router + from govoplan_core.api.v1.audit import router as audit_router + from govoplan_core.api.v1.auth import router as auth_router + from govoplan_core.api.v1.system import router as system_router + + return GovoplanServerConfig( + title="GovOPlaN Server", + version="0.3.0", + settings=settings, + enabled_modules=settings.enabled_modules, + api_prefix="/api/v1", + base_routers=(auth_router, admin_router, audit_router, system_router), + extra_routers=_dev_mail_router_contributions(), + lifespan=lifespan, + cors_origins=_cors_origins(settings.cors_origins), + app_configurators=(register_health_details,), + ) diff --git a/src/govoplan_core/server/fastapi.py b/src/govoplan_core/server/fastapi.py new file mode 100644 index 0000000..19b98e0 --- /dev/null +++ b/src/govoplan_core/server/fastapi.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Iterable +from contextlib import AbstractAsyncContextManager +from typing import Any + +from fastapi import APIRouter, FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from govoplan_core.core.registry import PlatformRegistry + +LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]] + + +def create_govoplan_app( + *, + title: str, + version: str, + registry: PlatformRegistry, + api_router: APIRouter | None = None, + lifespan: LifespanFactory | None = None, + cors_origins: Iterable[str] = (), + health_payload: dict[str, Any] | None = None, +) -> FastAPI: + app = FastAPI(title=title, version=version, lifespan=lifespan) + app.state.govoplan_registry = registry + + origins = [item.strip() for item in cors_origins if item.strip()] + if origins: + app.add_middleware( + CORSMiddleware, + allow_origins=["*"] if "*" in origins else origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + if api_router is not None: + app.include_router(api_router) + + @app.get("/health") + def health(): + payload = {"status": "ok"} + if health_payload: + payload.update(health_payload) + return payload + + return app diff --git a/src/govoplan_core/server/platform.py b/src/govoplan_core/server/platform.py new file mode 100644 index 0000000..d8f3652 --- /dev/null +++ b/src/govoplan_core/server/platform.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request + +from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem +from govoplan_core.core.registry import PlatformRegistry + + +def _registry(request: Request) -> PlatformRegistry: + registry = getattr(request.app.state, "govoplan_registry", None) + if not isinstance(registry, PlatformRegistry): + raise HTTPException(status_code=500, detail="GovOPlaN module registry is not configured") + return registry + + +def _nav_item_payload(item: NavItem) -> dict[str, object]: + return { + "path": item.path, + "label": item.label, + "icon": item.icon, + "section": item.section, + "required_all": list(item.required_all), + "required_any": list(item.required_any), + "order": item.order, + } + + +def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]: + return { + "path": route.path, + "component": route.component, + "required_all": list(route.required_all), + "required_any": list(route.required_any), + "order": route.order, + } + + +def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None: + if frontend is None: + return None + return { + "module_id": frontend.module_id, + "package_name": frontend.package_name, + "asset_manifest": frontend.asset_manifest, + "routes": [_frontend_route_payload(route) for route in frontend.routes], + "nav": [_nav_item_payload(item) for item in frontend.nav_items], + "settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes], + } + + +def create_platform_router() -> APIRouter: + router = APIRouter(prefix="/platform", tags=["platform"]) + + @router.get("/modules") + def modules(request: Request): + registry = _registry(request) + return { + "modules": [ + { + "id": manifest.id, + "name": manifest.name, + "version": manifest.version, + "dependencies": list(manifest.dependencies), + "optional_dependencies": list(manifest.optional_dependencies), + "enabled": True, + "nav": [_nav_item_payload(item) for item in manifest.nav_items], + "frontend": _frontend_payload(manifest.frontend), + } + for manifest in registry.manifests() + ] + } + + @router.get("/permissions") + def permissions(request: Request): + registry = _registry(request) + return { + "permissions": [ + { + "scope": item.scope, + "module_id": item.module_id, + "resource": item.resource, + "action": item.action, + "label": item.label, + "description": item.description, + "category": item.category, + "level": item.level, + "deprecated": item.deprecated, + } + for item in registry.permissions() + ] + } + + @router.get("/navigation") + def navigation(request: Request): + registry = _registry(request) + return { + "items": [_nav_item_payload(item) for item in registry.nav_items()] + } + + return router diff --git a/src/govoplan_core/server/registry.py b/src/govoplan_core/server/registry.py new file mode 100644 index 0000000..0565c41 --- /dev/null +++ b/src/govoplan_core/server/registry.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterable, Sequence + +from govoplan_core.access.manifest import get_manifest as get_access_manifest +from govoplan_core.core.discovery import discover_module_manifests +from govoplan_core.core.modules import ModuleManifest +from govoplan_core.core.registry import PlatformRegistry, RegistryError + +ManifestFactory = Callable[[], ModuleManifest] + + +def parse_enabled_modules(value: str | Iterable[str]) -> list[str]: + if isinstance(value, str): + items = [item.strip() for item in value.split(",")] + else: + items = [str(item).strip() for item in value] + return [item for item in items if item] + + +def available_module_manifests(manifest_factories: Sequence[ManifestFactory] = ()) -> dict[str, ModuleManifest]: + manifests = {manifest.id: manifest for manifest in discover_module_manifests()} + manifests.setdefault("access", get_access_manifest()) + for factory in manifest_factories: + manifest = factory() + manifests[manifest.id] = manifest + return manifests + + +def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_factories: Sequence[ManifestFactory] = ()) -> PlatformRegistry: + requested = parse_enabled_modules(enabled_modules) + if "access" not in requested: + requested.insert(0, "access") + + available = available_module_manifests(manifest_factories) + registry = PlatformRegistry() + for module_id in requested: + manifest = available.get(module_id) + if manifest is None: + raise RegistryError(f"Enabled module is not available: {module_id}") + registry.register(manifest) + registry.validate() + return registry diff --git a/src/govoplan_core/settings.py b/src/govoplan_core/settings.py new file mode 100644 index 0000000..4b61d03 --- /dev/null +++ b/src/govoplan_core/settings.py @@ -0,0 +1,62 @@ +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=None, extra="ignore") + + app_env: str = Field(default="dev", alias="APP_ENV") + + database_url: str = Field( + default="sqlite:///./multimailer-dev.db", + alias="DATABASE_URL", + ) + access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL") + access_db_schema: str | None = Field(default=None, alias="ACCESS_DB_SCHEMA") + access_table_prefix: str = Field(default="access_", alias="ACCESS_TABLE_PREFIX") + tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE") + tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE") + tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE") + enabled_modules: str = Field(default="access,campaigns,files,mail", alias="ENABLED_MODULES") + redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL") + + s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL") + s3_region: str = Field(default="garage", alias="S3_REGION") + s3_access_key_id: str = Field(default="GKmultimailerdev0000000000000000", alias="S3_ACCESS_KEY_ID") + s3_secret_access_key: str = Field(default="multimailer-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY") + s3_bucket: str = Field(default="attachments", alias="S3_BUCKET") + + # Managed file storage. Development defaults to local filesystem storage; + # production can switch to Garage/S3 without changing API contracts. + file_storage_backend: str = Field(default="local", alias="FILE_STORAGE_BACKEND") + file_storage_local_root: str = Field(default="runtime/files", alias="FILE_STORAGE_LOCAL_ROOT") + file_storage_s3_endpoint_url: str | None = Field(default=None, alias="FILE_STORAGE_S3_ENDPOINT_URL") + file_storage_s3_region: str | None = Field(default=None, alias="FILE_STORAGE_S3_REGION") + file_storage_s3_access_key_id: str | None = Field(default=None, alias="FILE_STORAGE_S3_ACCESS_KEY_ID") + file_storage_s3_secret_access_key: str | None = Field(default=None, alias="FILE_STORAGE_S3_SECRET_ACCESS_KEY") + file_storage_s3_bucket: str | None = Field(default="files", alias="FILE_STORAGE_S3_BUCKET") + file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES") + file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES") + + auth_session_cookie_name: str = Field(default="msm_session", alias="AUTH_SESSION_COOKIE_NAME") + auth_csrf_cookie_name: str = Field(default="msm_csrf", alias="AUTH_CSRF_COOKIE_NAME") + auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE") + auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE") + auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN") + auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS") + + master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64") + celery_queues: str = Field(default="send_email,append_sent,default", alias="CELERY_QUEUES") + mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR") + + # Development bootstrap only. Do not use this in production. + dev_bootstrap_api_key: str | None = Field(default="dev-multimailer-api-key", alias="DEV_BOOTSTRAP_API_KEY") + dev_bootstrap_enabled: bool = Field(default=False, alias="DEV_BOOTSTRAP_ENABLED") + dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD") + dev_mailbox_api_enabled: bool = Field(default=False, alias="DEV_MAILBOX_API_ENABLED") + + # Comma-separated list. Use * only for local development. + cors_origins: str = Field(default="http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080", alias="CORS_ORIGINS") + + +settings = Settings() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py new file mode 100644 index 0000000..3bf7c3d --- /dev/null +++ b/tests/test_api_smoke.py @@ -0,0 +1,2821 @@ +from __future__ import annotations + +import io +import json +import os +import shutil +import tempfile +import unittest +import zipfile +from datetime import datetime, timezone +from email import policy +from email.parser import BytesParser +from pathlib import Path + +import pyzipper + +# Settings are instantiated while importing the application. Configure an +# isolated test database and local storage before importing app modules. +_TEST_ROOT = Path(tempfile.mkdtemp(prefix="multi-seal-mail-tests-")) +os.environ["APP_ENV"] = "test" +os.environ["DATABASE_URL"] = f"sqlite:///{_TEST_ROOT / 'test.db'}" +os.environ["FILE_STORAGE_BACKEND"] = "local" +os.environ["FILE_STORAGE_LOCAL_ROOT"] = str(_TEST_ROOT / "files") +os.environ["MOCK_MAILBOX_DIR"] = str(_TEST_ROOT / "mock-mailbox") +os.environ["DEV_BOOTSTRAP_ENABLED"] = "false" + +from fastapi.testclient import TestClient + +from govoplan_core.db.base import Base +from govoplan_core.db.bootstrap import bootstrap_dev_data +from govoplan_core.db.session import configure_database + +_database = configure_database(os.environ["DATABASE_URL"]) +engine = _database.engine +SessionLocal = _database.SessionLocal + +from govoplan_core.server.app import app +from govoplan_core.security.permissions import SYSTEM_SCOPES + + +class ApiSmokeTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.client = TestClient(app) + + @classmethod + def tearDownClass(cls) -> None: + cls.client.close() + engine.dispose() + shutil.rmtree(_TEST_ROOT, ignore_errors=True) + + def setUp(self) -> None: + self.client.cookies.clear() + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True) + shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True) + with SessionLocal() as session: + bootstrap_dev_data( + session, + api_key_secret="test-api-key", + user_password="test-admin", + ) + + def _login(self) -> tuple[dict[str, str], dict[str, object]]: + response = self.client.post( + "/api/v1/auth/login", + json={"email": "admin@example.local", "password": "test-admin"}, + ) + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + return {"Authorization": f"Bearer {payload['access_token']}"}, payload + + def _create_built_delivery_campaign( + self, + headers: dict[str, str], + *, + external_id: str, + recipient_count: int = 1, + ) -> tuple[str, str]: + entries = [ + { + "id": f"recipient-{index + 1}", + "to": [{"email": f"recipient-{index + 1}@example.org", "type": "to"}], + "fields": {"first_name": f"Recipient {index + 1}"}, + } + for index in range(recipient_count) + ] + campaign_json = { + "version": "1.0", + "campaign": {"id": external_id, "name": external_id, "mode": "test"}, + "fields": [{"name": "first_name", "type": "string", "required": True}], + "global_values": {}, + "server": { + "smtp": { + "host": "mock.smtp", + "port": 2525, + "username": "sender@example.org", + "password": "test-secret", + "security": "starttls", + } + }, + "recipients": { + "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, + "allow_individual_to": True, + }, + "template": { + "subject": "Hello ${local::first_name}", + "text": "Hello ${local::first_name}.", + }, + "attachments": {"base_path": ".", "global": [], "allow_individual": False}, + "entries": {"inline": entries}, + "validation_policy": {"missing_email": "block", "template_error": "block"}, + "delivery": { + "rate_limit": {"messages_per_minute": 60}, + "retry": {"max_attempts": 3, "backoff_seconds": [1, 5, 30]}, + "imap_append_sent": {"enabled": False}, + }, + "status_tracking": {"enabled": True}, + } + created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + version_id = created.json()["version"]["id"] + validated = self.client.post( + f"/api/v1/campaigns/versions/{version_id}/validate", + headers=headers, + json={"check_files": False}, + ) + self.assertEqual(validated.status_code, 200, validated.text) + self.assertTrue(validated.json()["ok"], validated.text) + built = self.client.post( + f"/api/v1/campaigns/versions/{version_id}/build", + headers=headers, + json={"write_eml": True}, + ) + self.assertEqual(built.status_code, 200, built.text) + self.assertEqual(built.json()["built_count"], recipient_count, built.text) + return campaign_id, version_id + + def _create_role(self, headers: dict[str, str], *, slug: str, permissions: list[str]) -> dict[str, object]: + response = self.client.post( + "/api/v1/admin/roles", + headers=headers, + json={"slug": slug, "name": slug.replace("-", " ").title(), "description": "Test role", "permissions": permissions}, + ) + self.assertEqual(response.status_code, 201, response.text) + return response.json() + + def _create_user( + self, + headers: dict[str, str], + *, + email: str, + password: str, + role_ids: list[str], + group_ids: list[str] | None = None, + ) -> dict[str, object]: + response = self.client.post( + "/api/v1/admin/users", + headers=headers, + json={ + "email": email, + "display_name": email.split("@", 1)[0].replace("-", " ").title(), + "password": password, + "password_reset_required": False, + "is_active": True, + "group_ids": group_ids or [], + "role_ids": role_ids, + }, + ) + self.assertEqual(response.status_code, 201, response.text) + return response.json()["user"] + + def _login_as(self, *, email: str, password: str, tenant_slug: str = "default") -> tuple[dict[str, str], dict[str, object]]: + response = self.client.post( + "/api/v1/auth/login", + json={"email": email, "password": password, "tenant_slug": tenant_slug}, + ) + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + return {"Authorization": f"Bearer {payload['access_token']}"}, payload + + + def test_cookie_session_requires_csrf_for_mutations(self) -> None: + response = self.client.post( + "/api/v1/auth/login", + json={"email": "admin@example.local", "password": "test-admin"}, + ) + self.assertEqual(response.status_code, 200, response.text) + csrf = response.cookies.get("msm_csrf") + self.assertTrue(csrf) + + me = self.client.get("/api/v1/auth/me") + self.assertEqual(me.status_code, 200, me.text) + + blocked = self.client.patch("/api/v1/auth/profile", json={"display_name": "Blocked"}) + self.assertEqual(blocked.status_code, 403, blocked.text) + + allowed = self.client.patch( + "/api/v1/auth/profile", + headers={"X-CSRF-Token": csrf or ""}, + json={"display_name": "Cookie Admin"}, + ) + self.assertEqual(allowed.status_code, 200, allowed.text) + self.assertEqual(allowed.json()["user"]["display_name"], "Cookie Admin") + + def test_encrypted_mail_profile_can_back_campaign_without_exposing_secrets(self) -> None: + headers, _ = self._login() + created_profile = self.client.post( + "/api/v1/mail/profiles", + headers=headers, + json={ + "name": "Primary sender", + "smtp": { + "host": "mock.smtp", + "port": 2525, + "username": "sender@example.org", + "password": "smtp-secret", + "security": "starttls", + }, + "imap": { + "enabled": True, + "host": "mock.imap", + "port": 993, + "username": "sender@example.org", + "password": "imap-secret", + "security": "tls", + "sent_folder": "Sent", + }, + }, + ) + self.assertEqual(created_profile.status_code, 201, created_profile.text) + profile_payload = created_profile.json() + self.assertNotIn("password", profile_payload["smtp"]) + self.assertNotIn("password", profile_payload["imap"]) + profile_id = profile_payload["id"] + + campaign_json = { + "version": "1.0", + "campaign": {"id": "profile-backed", "name": "Profile backed", "mode": "test"}, + "fields": [{"name": "first_name", "type": "string", "required": True}], + "global_values": {}, + "server": {"mail_profile_id": profile_id}, + "recipients": { + "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, + "to": [{"email": "recipient@example.org", "type": "to"}], + }, + "template": {"subject": "Hello ${local::first_name}", "text": "Hello ${local::first_name}."}, + "attachments": {"base_path": ".", "global": [], "allow_individual": False}, + "entries": {"inline": [{"id": "recipient-1", "fields": {"first_name": "Recipient"}}]}, + "validation_policy": {"missing_email": "block", "template_error": "block"}, + "delivery": { + "rate_limit": {"messages_per_minute": 60}, + "retry": {"max_attempts": 3, "backoff_seconds": [1, 5, 30]}, + "imap_append_sent": {"enabled": False}, + }, + "status_tracking": {"enabled": True}, + } + created_campaign = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) + self.assertEqual(created_campaign.status_code, 200, created_campaign.text) + version_id = created_campaign.json()["version"]["id"] + + validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": False}) + self.assertEqual(validated.status_code, 200, validated.text) + self.assertTrue(validated.json()["ok"], validated.text) + built = self.client.post(f"/api/v1/campaigns/versions/{version_id}/build", headers=headers, json={"write_eml": False}) + self.assertEqual(built.status_code, 200, built.text) + + from govoplan_campaign.backend.db.models import CampaignVersion + from govoplan_mail.backend.db.models import MailServerProfile + + with SessionLocal() as session: + profile = session.get(MailServerProfile, profile_id) + self.assertIsNotNone(profile) + assert profile is not None + self.assertNotEqual(profile.smtp_password_encrypted, "smtp-secret") + self.assertNotEqual(profile.imap_password_encrypted, "imap-secret") + self.assertNotIn("password", profile.smtp_config) + self.assertNotIn("password", profile.imap_config or {}) + + version = session.get(CampaignVersion, version_id) + self.assertIsNotNone(version) + assert version is not None + self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id}) + snapshot = version.execution_snapshot or {} + self.assertIsNone(snapshot.get("smtp", {}).get("password")) + self.assertEqual(snapshot.get("smtp", {}).get("host"), "mock.smtp") + + def test_mail_profile_can_inherit_server_without_credentials(self) -> None: + headers, _ = self._login() + created_profile = self.client.post( + "/api/v1/mail/profiles", + headers=headers, + json={ + "name": "Shared host local credentials", + "smtp": { + "host": "mock.smtp", + "port": 2525, + "username": "profile-smtp@example.org", + "password": "profile-smtp-secret", + "security": "starttls", + }, + "imap": { + "enabled": True, + "host": "mock.imap", + "port": 993, + "username": "profile-imap@example.org", + "password": "profile-imap-secret", + "security": "tls", + "sent_folder": "Sent", + }, + }, + ) + self.assertEqual(created_profile.status_code, 201, created_profile.text) + profile_id = created_profile.json()["id"] + + campaign_json = { + "version": "1.0", + "campaign": {"id": "profile-local-creds", "name": "Profile local creds", "mode": "test"}, + "fields": [{"name": "first_name", "type": "string", "required": True}], + "global_values": {}, + "server": { + "mail_profile_id": profile_id, + "inherit_smtp_credentials": False, + "inherit_imap_credentials": False, + "smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"}, + "imap": {"username": "campaign-imap@example.org", "password": "campaign-imap-secret"}, + }, + "recipients": { + "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, + "to": [{"email": "recipient@example.org", "type": "to"}], + }, + "template": {"subject": "Hello ${local::first_name}", "text": "Hello ${local::first_name}."}, + "attachments": {"base_path": ".", "global": [], "allow_individual": False}, + "entries": {"inline": [{"id": "recipient-1", "fields": {"first_name": "Recipient"}}]}, + "validation_policy": {"missing_email": "block", "template_error": "block"}, + "delivery": { + "rate_limit": {"messages_per_minute": 60}, + "retry": {"max_attempts": 3, "backoff_seconds": [1, 5, 30]}, + "imap_append_sent": {"enabled": False}, + }, + "status_tracking": {"enabled": True}, + } + created_campaign = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) + self.assertEqual(created_campaign.status_code, 200, created_campaign.text) + version_id = created_campaign.json()["version"]["id"] + + validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": False}) + self.assertEqual(validated.status_code, 200, validated.text) + self.assertTrue(validated.json()["ok"], validated.text) + built = self.client.post(f"/api/v1/campaigns/versions/{version_id}/build", headers=headers, json={"write_eml": False}) + self.assertEqual(built.status_code, 200, built.text) + + from govoplan_campaign.backend.db.models import CampaignVersion + from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, runtime_imap_config, runtime_smtp_config + + with SessionLocal() as session: + version = session.get(CampaignVersion, version_id) + self.assertIsNotNone(version) + assert version is not None + snapshot_payload = version.execution_snapshot or {} + self.assertEqual(snapshot_payload.get("smtp", {}).get("host"), "mock.smtp") + self.assertEqual(snapshot_payload.get("smtp", {}).get("username"), "campaign-smtp@example.org") + self.assertIsNone(snapshot_payload.get("smtp", {}).get("password")) + self.assertEqual(snapshot_payload.get("imap", {}).get("host"), "mock.imap") + self.assertEqual(snapshot_payload.get("imap", {}).get("username"), "campaign-imap@example.org") + self.assertIsNone(snapshot_payload.get("imap", {}).get("password")) + + snapshot = ExecutionSnapshot.model_validate(snapshot_payload) + smtp_runtime = runtime_smtp_config(session, version, snapshot) + imap_runtime = runtime_imap_config(session, version, snapshot) + self.assertEqual(smtp_runtime.host, "mock.smtp") + self.assertEqual(smtp_runtime.username, "campaign-smtp@example.org") + self.assertEqual(smtp_runtime.password, "campaign-smtp-secret") + self.assertIsNotNone(imap_runtime) + assert imap_runtime is not None + self.assertEqual(imap_runtime.host, "mock.imap") + self.assertEqual(imap_runtime.username, "campaign-imap@example.org") + self.assertEqual(imap_runtime.password, "campaign-imap-secret") + + def test_health_schema_and_dev_mailbox_gates(self) -> None: + public_health = self.client.get("/health") + self.assertEqual(public_health.status_code, 200, public_health.text) + self.assertEqual(public_health.json(), {"status": "ok"}) + + unauthenticated_details = self.client.get("/health/details") + self.assertEqual(unauthenticated_details.status_code, 401, unauthenticated_details.text) + + headers, _ = self._login() + details = self.client.get("/health/details", headers=headers) + self.assertEqual(details.status_code, 200, details.text) + self.assertIn("storage", details.json()) + + schema = self.client.get("/api/v1/schemas/campaign", headers=headers) + self.assertEqual(schema.status_code, 200, schema.text) + self.assertEqual(schema.json()["title"], "MultiMailer Campaign") + + dev_mailbox = self.client.get("/api/v1/dev/mailbox/messages", headers=headers) + self.assertEqual(dev_mailbox.status_code, 404, dev_mailbox.text) + + def test_managed_file_runtime_flow(self) -> None: + headers, login = self._login() + user_id = login["user"]["id"] + + spaces = self.client.get("/api/v1/files/spaces", headers=headers) + self.assertEqual(spaces.status_code, 200, spaces.text) + self.assertEqual(spaces.json()["spaces"][0]["owner_id"], user_id) + + folder = self.client.post( + "/api/v1/files/folders", + headers=headers, + json={"owner_type": "user", "owner_id": user_id, "path": "inbox"}, + ) + self.assertEqual(folder.status_code, 200, folder.text) + + first = self.client.post( + "/api/v1/files/upload", + headers=headers, + data={"owner_type": "user", "owner_id": user_id, "path": "inbox"}, + files=[("files", ("report.txt", b"first report", ""))], + ) + self.assertEqual(first.status_code, 200, first.text) + first_file = first.json()["files"][0] + self.assertEqual(first_file["display_path"], "inbox/report.txt") + self.assertEqual(first_file["content_type"], "text/plain") + + # Exercises request-model conversion to FileConflictResolution and the + # explicit rename path rather than silently overwriting an asset. + second = self.client.post( + "/api/v1/files/upload", + headers=headers, + data={ + "owner_type": "user", + "owner_id": user_id, + "path": "inbox", + "conflict_resolutions_json": json.dumps( + [ + { + "target_path": "inbox/report.txt", + "action": "rename", + "new_path": "inbox/report-copy.txt", + } + ] + ), + }, + files=[("files", ("report.txt", b"second report", "text/plain"))], + ) + self.assertEqual(second.status_code, 200, second.text) + second_file = second.json()["files"][0] + self.assertEqual(second_file["display_path"], "inbox/report-copy.txt") + + listing = self.client.get( + "/api/v1/files", + headers=headers, + params={"owner_type": "user", "owner_id": user_id, "path_prefix": "inbox"}, + ) + self.assertEqual(listing.status_code, 200, listing.text) + self.assertEqual(len(listing.json()["files"]), 2) + + resolved = self.client.post( + "/api/v1/files/resolve-patterns", + headers=headers, + json={ + "patterns": ["**/*.txt"], + "owner_type": "user", + "owner_id": user_id, + "include_unmatched": True, + }, + ) + self.assertEqual(resolved.status_code, 200, resolved.text) + self.assertEqual(len(resolved.json()["patterns"][0]["matches"]), 2) + self.assertEqual(resolved.json()["unmatched"], []) + + # Exercises RenamePlanItem construction without mutating persisted paths. + rename_preview = self.client.post( + "/api/v1/files/bulk-rename", + headers=headers, + json={ + "file_ids": [first_file["id"]], + "mode": "prefix", + "prefix": "archived-", + "dry_run": True, + }, + ) + self.assertEqual(rename_preview.status_code, 200, rename_preview.text) + self.assertEqual(rename_preview.json()["items"][0]["new_path"], "inbox/archived-report.txt") + + invalid_share = self.client.post( + f"/api/v1/files/{first_file['id']}/shares", + headers=headers, + json={"target_type": "user", "target_id": "missing-user", "permission": "read"}, + ) + self.assertEqual(invalid_share.status_code, 400, invalid_share.text) + + download = self.client.get(f"/api/v1/files/{first_file['id']}/download", headers=headers) + self.assertEqual(download.status_code, 200, download.text) + self.assertIn("filename*=UTF-8''report.txt", download.headers.get("content-disposition", "")) + self.assertEqual(download.content, b"first report") + + archive = self.client.post( + "/api/v1/files/archive.zip", + headers=headers, + json={"file_ids": [first_file["id"], second_file["id"]], "filename": "reports.zip"}, + ) + self.assertEqual(archive.status_code, 200, archive.text) + with zipfile.ZipFile(io.BytesIO(archive.content)) as bundle: + self.assertEqual(sorted(bundle.namelist()), ["inbox/report-copy.txt", "inbox/report.txt"]) + self.assertEqual(bundle.read("inbox/report.txt"), b"first report") + + # A historical soft-deleted folder row can coexist with the active row. + # Moving/copying through that hierarchy must choose the active row rather + # than raising MultipleResultsFound. + target_folder = self.client.post( + "/api/v1/files/folders", + headers=headers, + json={"owner_type": "user", "owner_id": user_id, "path": "archive"}, + ) + self.assertEqual(target_folder.status_code, 200, target_folder.text) + from govoplan_files.backend.db.models import FileFolder + from govoplan_files.backend.storage.common import utcnow + with SessionLocal() as session: + session.add(FileFolder( + tenant_id=next(iter({folder.tenant_id for folder in session.query(FileFolder).filter(FileFolder.owner_user_id == user_id).all()})), + owner_type="user", + owner_user_id=user_id, + path="archive", + created_by_user_id=user_id, + deleted_at=utcnow(), + metadata_={}, + )) + session.commit() + transferred = self.client.post( + "/api/v1/files/transfer", + headers=headers, + json={ + "operation": "copy", + "file_ids": [first_file["id"]], + "folder_paths": [], + "source_owner_type": "user", + "source_owner_id": user_id, + "target_owner_type": "user", + "target_owner_id": user_id, + "target_folder": "archive/2026", + "conflict_strategy": "reject", + }, + ) + self.assertEqual(transferred.status_code, 200, transferred.text) + self.assertEqual(transferred.json()["files"], 1) + + def test_group_read_admin_does_not_grant_file_space_admin(self) -> None: + owner_headers, _ = self._login() + group = self.client.post( + "/api/v1/admin/groups", + headers=owner_headers, + json={ + "slug": "private-files", + "name": "Private files", + "description": "Contains files that are not shared with group readers.", + "member_ids": [], + "role_ids": [], + }, + ) + self.assertEqual(group.status_code, 201, group.text) + group_id = group.json()["id"] + + uploaded = self.client.post( + "/api/v1/files/upload", + headers=owner_headers, + data={"owner_type": "group", "owner_id": group_id, "path": ""}, + files=[("files", ("private.txt", b"private", "text/plain"))], + ) + self.assertEqual(uploaded.status_code, 200, uploaded.text) + file_id = uploaded.json()["files"][0]["id"] + + role = self._create_role( + owner_headers, + slug="group-directory-reader", + permissions=["files:read", "admin:groups:read"], + ) + self._create_user( + owner_headers, + email="group-reader@example.local", + password="group-reader-password", + role_ids=[str(role["id"])], + ) + reader_headers, _ = self._login_as(email="group-reader@example.local", password="group-reader-password") + + groups = self.client.get("/api/v1/admin/groups", headers=reader_headers) + self.assertEqual(groups.status_code, 200, groups.text) + + spaces = self.client.get("/api/v1/files/spaces", headers=reader_headers) + self.assertEqual(spaces.status_code, 200, spaces.text) + self.assertNotIn(group_id, [space["owner_id"] for space in spaces.json()["spaces"]]) + + group_listing = self.client.get( + "/api/v1/files", + headers=reader_headers, + params={"owner_type": "group", "owner_id": group_id}, + ) + self.assertEqual(group_listing.status_code, 403, group_listing.text) + self.assertEqual(self.client.get(f"/api/v1/files/{file_id}", headers=reader_headers).status_code, 404) + + def test_temporary_and_permanent_user_lock_lifecycle(self) -> None: + headers, _ = self._login() + created = self.client.post( + "/api/v1/campaigns/new", + headers=headers, + json={"external_id": "lock-lifecycle", "name": "Lock lifecycle"}, + ) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + version_id = created.json()["version"]["id"] + + temporary = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/lock-temporarily", + headers=headers, + ) + self.assertEqual(temporary.status_code, 200, temporary.text) + self.assertEqual(temporary.json()["user_lock_state"], "temporary") + self.assertTrue(temporary.json()["user_locked_at"]) + + blocked_update = self.client.put( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", + headers=headers, + json={"current_step": "fields"}, + ) + self.assertEqual(blocked_update.status_code, 409, blocked_update.text) + + unlocked = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/unlock-user-lock", + headers=headers, + ) + self.assertEqual(unlocked.status_code, 200, unlocked.text) + self.assertIsNone(unlocked.json()["user_lock_state"]) + + updated = self.client.put( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", + headers=headers, + json={"current_step": "fields"}, + ) + self.assertEqual(updated.status_code, 200, updated.text) + self.assertEqual(updated.json()["current_step"], "fields") + + relocked = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/lock-temporarily", + headers=headers, + ) + self.assertEqual(relocked.status_code, 200, relocked.text) + + permanent = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/lock-permanently", + headers=headers, + ) + self.assertEqual(permanent.status_code, 200, permanent.text) + self.assertEqual(permanent.json()["user_lock_state"], "permanent") + self.assertTrue(permanent.json()["published_at"]) + + refused_unlock = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/unlock-user-lock", + headers=headers, + ) + self.assertEqual(refused_unlock.status_code, 409, refused_unlock.text) + + + def test_only_one_campaign_version_is_writable(self) -> None: + headers, _ = self._login() + created = self.client.post( + "/api/v1/campaigns/new", + headers=headers, + json={"external_id": "single-working-version", "name": "Single working version"}, + ) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + first_version_id = created.json()["version"]["id"] + + parallel = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/fork", + headers=headers, + json={}, + ) + self.assertEqual(parallel.status_code, 409, parallel.text) + self.assertIn("active working version", parallel.text) + + permanent = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/lock-permanently", + headers=headers, + ) + self.assertEqual(permanent.status_code, 200, permanent.text) + + copied = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/fork", + headers=headers, + json={}, + ) + self.assertEqual(copied.status_code, 200, copied.text) + second_version_id = copied.json()["version"]["id"] + self.assertNotEqual(second_version_id, first_version_id) + self.assertEqual(copied.json()["campaign"]["current_version_id"], second_version_id) + + historical_update = self.client.put( + f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}", + headers=headers, + json={"current_step": "fields"}, + ) + self.assertEqual(historical_update.status_code, 409, historical_update.text) + self.assertIn("Historical campaign versions are read-only", historical_update.text) + + second_update = self.client.put( + f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}", + headers=headers, + json={"current_step": "fields"}, + ) + self.assertEqual(second_update.status_code, 200, second_update.text) + + second_parallel = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/fork", + headers=headers, + json={}, + ) + self.assertEqual(second_parallel.status_code, 409, second_parallel.text) + + second_permanent = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}/lock-permanently", + headers=headers, + ) + self.assertEqual(second_permanent.status_code, 200, second_permanent.text) + + historical_branch = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/fork", + headers=headers, + json={}, + ) + self.assertEqual(historical_branch.status_code, 409, historical_branch.text) + self.assertIn("cannot become a new branch", historical_branch.text) + + third = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}/fork", + headers=headers, + json={}, + ) + self.assertEqual(third.status_code, 200, third.text) + self.assertEqual(third.json()["version"]["version_number"], 3) + + def test_campaign_create_validate_build_and_mock_send(self) -> None: + headers, _ = self._login() + campaign_json = { + "version": "1.0", + "campaign": {"id": "api-smoke", "name": "API smoke campaign", "mode": "test"}, + "fields": [{"name": "first_name", "type": "string", "required": True}], + "global_values": {}, + "server": { + "smtp": { + "host": "smtp.example.invalid", + "port": 587, + "username": "sender@example.org", + "password": "test-secret", + "security": "starttls", + }, + "imap": { + "enabled": True, + "host": "imap.example.invalid", + "port": 993, + "username": "sender@example.org", + "password": "test-secret", + "security": "tls", + }, + }, + "recipients": { + "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, + "allow_individual_to": True, + }, + "template": { + "subject": "Hello ${local::first_name}", + "text": "Hello ${local::first_name}, this is a smoke test.", + }, + "attachments": {"base_path": ".", "global": [], "allow_individual": False}, + "entries": { + "inline": [ + { + "id": "recipient-1", + "to": [{"email": "recipient@example.org", "name": "Recipient", "type": "to"}], + "fields": {"first_name": "Ada"}, + } + ] + }, + "validation_policy": {"missing_email": "block", "template_error": "block"}, + "delivery": {"imap_append_sent": {"enabled": True, "folder": "Sent"}}, + "status_tracking": {"enabled": True}, + } + + created = self.client.post( + "/api/v1/campaigns", + headers=headers, + json={"config": campaign_json}, + ) + self.assertEqual(created.status_code, 200, created.text) + created_payload = created.json() + campaign_id = created_payload["campaign"]["id"] + version_id = created_payload["version"]["id"] + + validated = self.client.post( + f"/api/v1/campaigns/versions/{version_id}/validate", + headers=headers, + json={"check_files": False}, + ) + self.assertEqual(validated.status_code, 200, validated.text) + self.assertTrue(validated.json()["ok"]) + + built = self.client.post( + f"/api/v1/campaigns/versions/{version_id}/build", + headers=headers, + json={"write_eml": False}, + ) + self.assertEqual(built.status_code, 200, built.text) + self.assertEqual(built.json()["built_count"], 1) + + mocked = self.client.post( + f"/api/v1/campaigns/{campaign_id}/mock-send", + headers=headers, + json={ + "version_id": version_id, + "send": True, + "append_sent": True, + "clear_mailbox": True, + "check_files": False, + }, + ) + self.assertEqual(mocked.status_code, 200, mocked.text) + result = mocked.json()["result"] + self.assertTrue(result["validation"]["ok"]) + self.assertEqual(result["build"]["built_count"], 1) + self.assertEqual(result["send"]["sent_count"], 1) + self.assertEqual(result["send"]["imap_appended_count"], 1) + self.assertEqual(result["send"]["imap_failed_count"], 0) + + def test_managed_attachment_patterns_preview_build_and_mock_send(self) -> None: + headers, login = self._login() + user_id = login["user"]["id"] + campaign_json = { + "version": "1.0", + "campaign": {"id": "managed-attachments", "name": "Managed attachments", "mode": "test"}, + "fields": [{"name": "invoice_number", "type": "string", "required": True}], + "global_values": {}, + "server": { + "smtp": { + "host": "smtp.example.invalid", + "port": 587, + "username": "sender@example.org", + "password": "test-secret", + "security": "starttls", + } + }, + "recipients": { + "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, + "allow_individual_to": True, + }, + "template": {"subject": "Invoice", "text": "Please see the attached files."}, + "attachments": { + "base_path": "invoices", + "base_paths": [ + { + "id": "personal-invoices", + "name": "My invoices", + "source": f"managed:user:{user_id}", + "path": "invoices", + "allow_individual": True, + } + ], + "global": [], + "allow_individual": True, + }, + "entries": { + "inline": [ + { + "id": "recipient-1", + "to": [{"email": "recipient@example.org", "name": "Recipient", "type": "to"}], + "fields": {"invoice_number": "202605-010001"}, + "attachments": [ + { + "id": "nested-pattern", + "label": "Nested workbook", + "base_path_id": "personal-invoices", + "base_dir": "invoices", + "file_filter": "**/{{local:invoice_number}}-report.XLSX", + "required": True, + }, + { + "id": "exact-pattern", + "label": "Exact workbook", + "base_path_id": "personal-invoices", + "base_dir": "invoices", + "file_filter": "{{local:invoice_number}}-90100010-9601741.XLSX", + "required": True, + }, + ], + } + ] + }, + "validation_policy": { + "missing_email": "block", + "template_error": "block", + "missing_required_attachment": "block", + }, + "delivery": {"imap_append_sent": {"enabled": False}}, + "status_tracking": {"enabled": True}, + } + + created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + version_id = created.json()["version"]["id"] + + for path, filename, content in [ + ("invoices/archive", "202605-010001-report.XLSX", b"nested workbook"), + ("invoices", "202605-010001-90100010-9601741.XLSX", b"exact workbook"), + ]: + uploaded = self.client.post( + "/api/v1/files/upload", + headers=headers, + data={ + "owner_type": "user", + "owner_id": user_id, + "path": path, + "campaign_id": campaign_id, + }, + files=[("files", (filename, content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))], + ) + self.assertEqual(uploaded.status_code, 200, uploaded.text) + + preview = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/preview", + headers=headers, + json={"include_unmatched": True}, + ) + self.assertEqual(preview.status_code, 200, preview.text) + preview_payload = preview.json() + self.assertEqual(len(preview_payload["rules"]), 2) + self.assertEqual([rule["match_count"] for rule in preview_payload["rules"]], [1, 1]) + self.assertEqual( + {rule["matches"][0]["filename"] for rule in preview_payload["rules"]}, + {"202605-010001-report.XLSX", "202605-010001-90100010-9601741.XLSX"}, + ) + self.assertEqual(preview_payload["unused_shared_files"], []) + + validated = self.client.post( + f"/api/v1/campaigns/versions/{version_id}/validate", + headers=headers, + json={"check_files": True}, + ) + self.assertEqual(validated.status_code, 200, validated.text) + self.assertTrue(validated.json()["ok"], validated.text) + + built = self.client.post( + f"/api/v1/campaigns/versions/{version_id}/build", + headers=headers, + json={"write_eml": False}, + ) + self.assertEqual(built.status_code, 200, built.text) + self.assertEqual(built.json()["built_count"], 1) + self.assertEqual(built.json()["messages"][0]["attachment_count"], 2) + self.assertTrue(built.json().get("built_at")) + self.assertTrue(built.json().get("build_token")) + self.assertEqual( + sum(len(item["managed_matches"]) for item in built.json()["messages"][0]["attachments"]), + 2, + ) + resolved_paths = { + match + for attachment in built.json()["messages"][0]["attachments"] + for match in attachment["matches"] + } + self.assertEqual(resolved_paths, { + "invoices/archive/202605-010001-report.XLSX", + "invoices/202605-010001-90100010-9601741.XLSX", + }) + self.assertFalse(any("multimailer-managed-build" in value for value in resolved_paths)) + + jobs = self.client.get( + f"/api/v1/campaigns/{campaign_id}/jobs", + headers=headers, + params={"version_id": version_id}, + ) + self.assertEqual(jobs.status_code, 200, jobs.text) + self.assertEqual(len(jobs.json()["jobs"]), 1) + job_summary = jobs.json()["jobs"][0] + self.assertEqual(job_summary["campaign_version_id"], version_id) + self.assertNotIn("resolved_recipients", job_summary) + detail = self.client.get( + f"/api/v1/campaigns/{campaign_id}/jobs/{job_summary['id']}", + headers=headers, + ) + self.assertEqual(detail.status_code, 200, detail.text) + job = detail.json()["job"] + self.assertEqual(job["resolved_recipients"]["to"][0]["email"], "recipient@example.org") + self.assertEqual( + { + match + for attachment in job["attachments"] + for match in attachment["matches"] + }, + resolved_paths, + ) + + review_state = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/review-state", + headers=headers, + json={"inspection_complete": True, "reviewed_message_keys": ["recipient-1"]}, + ) + self.assertEqual(review_state.status_code, 200, review_state.text) + stored_review = review_state.json()["editor_state"]["review_send"] + self.assertTrue(stored_review["inspection_complete"]) + self.assertEqual(stored_review["reviewed_message_keys"], ["recipient-1"]) + self.assertEqual(stored_review["build_token"], built.json()["build_token"]) + + reloaded_version = self.client.get( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", + headers=headers, + ) + self.assertEqual(reloaded_version.status_code, 200, reloaded_version.text) + self.assertTrue(reloaded_version.json()["editor_state"]["review_send"]["inspection_complete"]) + + mocked = self.client.post( + f"/api/v1/campaigns/{campaign_id}/mock-send", + headers=headers, + json={ + "version_id": version_id, + "send": True, + "append_sent": False, + "clear_mailbox": True, + "check_files": False, + }, + ) + self.assertEqual(mocked.status_code, 200, mocked.text) + result = mocked.json()["result"] + self.assertTrue(result["validation"]["ok"], mocked.text) + self.assertEqual(result["send"]["sent_count"], 1) + self.assertEqual(result["send"]["results"][0]["attachments"][0]["status"], "ok") + + from govoplan_files.backend.db.models import CampaignAttachmentUse + + with SessionLocal() as session: + uses = session.query(CampaignAttachmentUse).filter(CampaignAttachmentUse.campaign_id == campaign_id).all() + self.assertEqual(len(uses), 2) + self.assertEqual({use.filename_used for use in uses}, { + "202605-010001-report.XLSX", + "202605-010001-90100010-9601741.XLSX", + }) + + + def test_non_blocking_review_conditions_can_be_accepted_in_bulk(self) -> None: + headers, _ = self._login() + campaign_json = { + "version": "1.0", + "campaign": {"id": "bulk-review", "name": "Bulk review", "mode": "test"}, + "fields": [], "global_values": {}, + "server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}}, + "recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True}, + "template": {"subject": "Warning test", "text": "Body"}, + "attachments": {"base_path": ".", "base_paths": [], "global": [{ + "id": "optional-missing", "base_dir": ".", "file_filter": "not-there.pdf", + "required": False, "missing_behavior": "warn", "zip": {"archive_id": "exclude"} + }], "zip": {"enabled": False, "archives": []}}, + "entries": {"inline": [{"id": "warning-entry", "to": [{"email": "recipient@example.org", "type": "to"}]}]}, + "validation_policy": {"missing_email": "block", "template_error": "block", "missing_optional_attachment": "warn"}, + "delivery": {"imap_append_sent": {"enabled": False}}, "status_tracking": {"enabled": True}, + } + created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + version_id = created.json()["version"]["id"] + validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": True}) + self.assertEqual(validated.status_code, 200, validated.text) + built = self.client.post(f"/api/v1/campaigns/versions/{version_id}/build", headers=headers, json={"write_eml": True}) + self.assertEqual(built.status_code, 200, built.text) + self.assertEqual(built.json()["warning_count"], 1) + reviewed = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/review-state", + headers=headers, + json={"inspection_complete": True, "reviewed_message_keys": []}, + ) + self.assertEqual(reviewed.status_code, 200, reviewed.text) + review_state = reviewed.json()["editor_state"]["review_send"] + self.assertTrue(review_state["inspection_complete"]) + self.assertEqual(review_state["reviewed_message_keys"], ["warning-entry"]) + + def test_inactive_recipients_are_aggregated_but_not_built_or_reviewed(self) -> None: + headers, _ = self._login() + campaign_json = { + "version": "1.0", + "campaign": {"id": "inactive-recipients", "name": "Inactive recipients", "mode": "test"}, + "fields": [], + "global_values": {}, + "server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}}, + "recipients": {"from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True}, + "template": {"subject": "Hello", "text": "Active recipient only"}, + "attachments": {"base_path": ".", "base_paths": [], "global": [], "zip": {"enabled": False, "archives": []}}, + "entries": {"inline": [ + {"id": "active", "active": True, "to": [{"email": "active@example.org", "type": "to"}]}, + {"id": "inactive", "active": False, "to": [], "attachments": [{"base_dir": "missing", "file_filter": "missing.pdf", "required": True}]}, + ]}, + "validation_policy": {"missing_email": "block", "template_error": "block", "missing_required_attachment": "block"}, + "delivery": {"imap_append_sent": {"enabled": False}}, + "status_tracking": {"enabled": True}, + } + created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + version_id = created.json()["version"]["id"] + validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": True}) + self.assertEqual(validated.status_code, 200, validated.text) + self.assertTrue(validated.json()["ok"], validated.text) + built = self.client.post(f"/api/v1/campaigns/versions/{version_id}/build", headers=headers, json={"write_eml": True}) + self.assertEqual(built.status_code, 200, built.text) + self.assertEqual(built.json()["inactive_count"], 1) + self.assertEqual(len(built.json()["messages"]), 1) + self.assertEqual(built.json()["messages"][0]["entry_id"], "active") + jobs = self.client.get(f"/api/v1/campaigns/{campaign_id}/jobs", headers=headers) + self.assertEqual(jobs.status_code, 200, jobs.text) + self.assertEqual(len(jobs.json()["jobs"]), 1) + report = self.client.get(f"/api/v1/campaigns/{campaign_id}/report", headers=headers) + self.assertEqual(report.status_code, 200, report.text) + self.assertEqual(report.json()["cards"]["inactive"], 1) + self.assertEqual(report.json()["status_counts"]["validation"]["inactive"], 1) + + + def test_recipient_address_fields_and_merge_modes(self) -> None: + headers, _ = self._login() + campaign_json = { + "version": "1.0", + "campaign": {"id": "recipient-address-fields", "name": "Recipient address fields", "mode": "test"}, + "fields": [], + "global_values": {}, + "server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}}, + "recipients": { + "from": [{"email": "global-from@example.org", "type": "to"}], + "allow_individual_from": True, + "to": [{"email": "global-to@example.org", "type": "to"}], + "allow_individual_to": True, + "cc": [{"email": "global-cc@example.org", "type": "cc"}], + "allow_individual_cc": True, + "bcc": [{"email": "global-bcc@example.org", "type": "bcc"}], + "allow_individual_bcc": True, + "reply_to": [{"email": "global-reply@example.org", "type": "reply_to"}], + "allow_individual_reply_to": True, + }, + "template": { + "subject": "{{local:from.email}} | {{local:all_to.email}} | {{local:to[2].email}} | {{local:all_cc.email}}", + "text": "{{local:all_from}} / {{local:all_reply_to.email}}", + }, + "attachments": {"base_path": ".", "base_paths": [], "global": [], "zip": {"enabled": False, "archives": []}}, + "entries": {"inline": [{ + "id": "merged-entry", + "from": [{"email": "local-from@example.org", "type": "to"}], + "merge_from": True, + "to": [{"email": "local-to@example.org", "type": "to"}], + "merge_to": True, + "cc": [{"email": "local-cc@example.org", "type": "cc"}], + "merge_cc": True, + "bcc": [{"email": "local-bcc@example.org", "type": "bcc"}], + "merge_bcc": True, + "reply_to": [{"email": "local-reply@example.org", "type": "reply_to"}], + "merge_reply_to": True, + }]}, + "validation_policy": {"missing_email": "block", "template_error": "block"}, + "delivery": {"imap_append_sent": {"enabled": False}}, + "status_tracking": {"enabled": True}, + } + created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + version_id = created.json()["version"]["id"] + validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": False}) + self.assertEqual(validated.status_code, 200, validated.text) + self.assertTrue(validated.json()["ok"], validated.text) + built = self.client.post(f"/api/v1/campaigns/versions/{version_id}/build", headers=headers, json={"write_eml": True}) + self.assertEqual(built.status_code, 200, built.text) + self.assertEqual(built.json()["messages"][0]["subject"], "local-from@example.org | global-to@example.org, local-to@example.org | local-to@example.org | global-cc@example.org, local-cc@example.org") + + from govoplan_campaign.backend.db.models import CampaignJob + with SessionLocal() as session: + job = session.query(CampaignJob).filter(CampaignJob.campaign_id == campaign_id).one() + self.assertEqual([item["email"] for item in job.resolved_recipients["from_all"]], ["local-from@example.org"]) + self.assertEqual([item["email"] for item in job.resolved_recipients["to"]], ["global-to@example.org", "local-to@example.org"]) + message = BytesParser(policy=policy.default).parsebytes(Path(job.eml_local_path).read_bytes()) + self.assertIsNone(message["Sender"]) + self.assertEqual([address.addr_spec for address in message["From"].addresses], ["local-from@example.org"]) + self.assertEqual([address.addr_spec for address in message["To"].addresses], ["global-to@example.org", "local-to@example.org"]) + self.assertEqual([address.addr_spec for address in message["Cc"].addresses], ["global-cc@example.org", "local-cc@example.org"]) + self.assertEqual([address.addr_spec for address in message["Reply-To"].addresses], ["global-reply@example.org", "local-reply@example.org"]) + self.assertIn("local-from@example.org / global-reply@example.org, local-reply@example.org", message.get_body(preferencelist=("plain",)).get_content()) + + def test_multiple_from_addresses_are_rejected(self) -> None: + headers, _ = self._login() + campaign_json = { + "version": "1.0", + "campaign": {"id": "multiple-from", "name": "Multiple From", "mode": "test"}, + "fields": [], + "global_values": {}, + "server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}}, + "recipients": { + "from": [ + {"email": "first@example.org", "type": "to"}, + {"email": "second@example.org", "type": "to"}, + ], + "to": [{"email": "recipient@example.org", "type": "to"}], + }, + "template": {"subject": "Subject", "text": "Body"}, + "attachments": {"base_path": ".", "base_paths": [], "global": [], "zip": {"enabled": False, "archives": []}}, + "entries": {"inline": [{"id": "recipient-1"}]}, + "validation_policy": {"missing_email": "block", "template_error": "block"}, + "delivery": {"imap_append_sent": {"enabled": False}}, + "status_tracking": {"enabled": True}, + } + created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) + self.assertEqual(created.status_code, 422, created.text) + + def test_duplicate_zip_archive_filenames_block_validation(self) -> None: + headers, _ = self._login() + campaign_json = { + "version": "1.0", + "campaign": {"id": "duplicate-zip-names", "name": "Duplicate ZIP names", "mode": "test"}, + "fields": [], + "global_values": {}, + "server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}}, + "recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True}, + "template": {"subject": "ZIP validation", "text": "Body"}, + "attachments": { + "base_path": ".", + "base_paths": [], + "global": [], + "zip": { + "enabled": True, + "archives": [ + {"id": "first", "name": "Recipient-Bundle", "standard": True}, + {"id": "second", "name": "recipient-bundle.zip", "standard": False}, + ], + }, + }, + "entries": {"inline": [{"id": "recipient", "to": [{"email": "recipient@example.org", "type": "to"}]}]}, + "validation_policy": {"missing_email": "block", "template_error": "block"}, + "delivery": {"imap_append_sent": {"enabled": False}}, + "status_tracking": {"enabled": True}, + } + created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) + self.assertEqual(created.status_code, 200, created.text) + version_id = created.json()["version"]["id"] + validated = self.client.post( + f"/api/v1/campaigns/versions/{version_id}/validate", + headers=headers, + json={"check_files": False}, + ) + self.assertEqual(validated.status_code, 200, validated.text) + self.assertFalse(validated.json()["ok"], validated.text) + duplicate_issues = [ + issue for issue in validated.json()["issues"] + if issue["code"] == "zip_archive_name_duplicate" + ] + self.assertEqual(len(duplicate_issues), 1, validated.text) + self.assertEqual(duplicate_issues[0]["severity"], "error") + + def test_recipient_zip_archive_with_field_password_and_rule_exclusions(self) -> None: + headers, login = self._login() + user_id = login["user"]["id"] + campaign_json = { + "version": "1.0", + "campaign": {"id": "zip-attachments", "name": "ZIP attachments", "mode": "test"}, + "fields": [ + {"name": "invoice_number", "type": "string", "required": True}, + {"name": "zip_password", "type": "password", "required": True}, + {"name": "global_zip_password", "type": "password", "required": True}, + ], + "global_values": {"global_zip_password": "campaign-secret"}, + "server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}}, + "recipients": { + "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, + "allow_individual_to": True, + }, + "template": {"subject": "Protected files", "text": "Please see the attached files."}, + "attachments": { + "base_path": "documents", + "base_paths": [{ + "id": "personal-documents", + "name": "My documents", + "source": f"managed:user:{user_id}", + "path": "documents", + "allow_individual": True, + }], + "zip": { + "enabled": True, + "archives": [ + { + "id": "recipient-bundle", + "name": "bundle-{{local:invoice_number}}.zip", + "standard": True, + "password_enabled": True, + "password_field": "zip_password", + "password_scope": "local", + "method": "aes" + }, + { + "id": "shared-bundle", + "name": "shared-files.zip", + "standard": False, + "password_enabled": True, + "password_field": "global_zip_password", + "password_scope": "global", + "method": "aes" + } + ] + }, + "global": [{ + "id": "guide", + "label": "Guide outside ZIP", + "base_path_id": "personal-documents", + "base_dir": "documents", + "file_filter": "guide.pdf", + "required": True, + "zip": {"mode": "exclude"}, + }], + "allow_individual": True, + }, + "entries": {"inline": [{ + "id": "recipient-zip", + "to": [{"email": "recipient@example.org", "name": "Recipient", "type": "to"}], + "fields": {"invoice_number": "2026-001", "zip_password": "recipient-secret"}, + "attachments": [ + { + "id": "invoice", + "label": "Invoice in ZIP", + "base_path_id": "personal-documents", + "base_dir": "documents", + "file_filter": "invoice.pdf", + "required": True, + "zip": {"mode": "inherit"}, + }, + { + "id": "invoice-outside", + "label": "Invoice outside ZIP as well", + "base_path_id": "personal-documents", + "base_dir": "documents", + "file_filter": "invoice.pdf", + "required": True, + "zip": {"archive_id": "exclude"}, + }, + { + "id": "cover", + "label": "Cover in shared ZIP", + "base_path_id": "personal-documents", + "base_dir": "documents", + "file_filter": "cover.txt", + "required": True, + "zip": {"archive_id": "shared-bundle"}, + }, + { + "id": "receipt", + "label": "Receipt outside ZIP", + "base_path_id": "personal-documents", + "base_dir": "documents", + "file_filter": "receipt.txt", + "required": True, + "zip": {"archive_id": "exclude"} + }, + ], + }]}, + "validation_policy": {"missing_email": "block", "template_error": "block", "missing_required_attachment": "block"}, + "delivery": {"imap_append_sent": {"enabled": False}}, + "status_tracking": {"enabled": True}, + } + + created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + version_id = created.json()["version"]["id"] + + for filename, content, content_type in [ + ("guide.pdf", b"global guide", "application/pdf"), + ("invoice.pdf", b"secret invoice", "application/pdf"), + ("cover.txt", b"shared cover", "text/plain"), + ("receipt.txt", b"outside receipt", "text/plain"), + ]: + uploaded = self.client.post( + "/api/v1/files/upload", + headers=headers, + data={"owner_type": "user", "owner_id": user_id, "path": "documents", "campaign_id": campaign_id}, + files=[("files", (filename, content, content_type))], + ) + self.assertEqual(uploaded.status_code, 200, uploaded.text) + + preview = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/preview", + headers=headers, + json={"include_unmatched": True}, + ) + self.assertEqual(preview.status_code, 200, preview.text) + by_id = {rule["attachment_id"]: rule for rule in preview.json()["rules"]} + self.assertFalse(by_id["guide"]["zip_included"]) + self.assertTrue(by_id["invoice"]["zip_included"]) + self.assertFalse(by_id["invoice-outside"]["zip_included"]) + self.assertTrue(by_id["cover"]["zip_included"]) + self.assertEqual(by_id["cover"]["zip_archive_id"], "shared-bundle") + self.assertFalse(by_id["receipt"]["zip_included"]) + + validated = self.client.post( + f"/api/v1/campaigns/versions/{version_id}/validate", + headers=headers, + json={"check_files": True}, + ) + self.assertEqual(validated.status_code, 200, validated.text) + self.assertTrue(validated.json()["ok"], validated.text) + + built = self.client.post( + f"/api/v1/campaigns/versions/{version_id}/build", + headers=headers, + json={"write_eml": True}, + ) + self.assertEqual(built.status_code, 200, built.text) + message_summary = built.json()["messages"][0] + self.assertEqual(message_summary["attachment_count"], 5) + summaries = {item["attachment_id"]: item for item in message_summary["attachments"]} + self.assertEqual(summaries["invoice"]["zip_filename"], "bundle-2026-001.zip") + self.assertIsNone(summaries["guide"]["zip_filename"]) + self.assertIsNone(summaries["invoice-outside"]["zip_filename"]) + self.assertEqual(summaries["cover"]["zip_filename"], "shared-files.zip") + self.assertIsNone(summaries["receipt"]["zip_filename"]) + + from govoplan_files.backend.db.models import CampaignAttachmentUse + from govoplan_campaign.backend.db.models import CampaignJob + + with SessionLocal() as session: + job = session.query(CampaignJob).filter(CampaignJob.campaign_id == campaign_id).one() + eml_path = Path(job.eml_local_path) + message = BytesParser(policy=policy.default).parsebytes(eml_path.read_bytes()) + uses = ( + session.query(CampaignAttachmentUse) + .filter( + CampaignAttachmentUse.campaign_job_id == job.id, + CampaignAttachmentUse.use_stage == "built", + ) + .all() + ) + self.assertEqual(len(uses), 4) + self.assertEqual(sum(use.filename_used == "invoice.pdf" for use in uses), 1) + + attachments = {part.get_filename(): part.get_payload(decode=True) for part in message.iter_attachments()} + self.assertEqual(set(attachments), {"guide.pdf", "invoice.pdf", "receipt.txt", "bundle-2026-001.zip", "shared-files.zip"}) + self.assertEqual(attachments["guide.pdf"], b"global guide") + self.assertEqual(attachments["invoice.pdf"], b"secret invoice") + self.assertEqual(attachments["receipt.txt"], b"outside receipt") + with pyzipper.AESZipFile(io.BytesIO(attachments["bundle-2026-001.zip"])) as archive: + archive.setpassword(b"recipient-secret") + self.assertEqual(archive.namelist(), ["invoice.pdf"]) + self.assertEqual(archive.read("invoice.pdf"), b"secret invoice") + with pyzipper.AESZipFile(io.BytesIO(attachments["shared-files.zip"])) as archive: + archive.setpassword(b"campaign-secret") + self.assertEqual(archive.namelist(), ["cover.txt"]) + self.assertEqual(archive.read("cover.txt"), b"shared cover") + + + + def test_execution_snapshot_freezes_delivery_configuration_and_job_manifest(self) -> None: + headers, _ = self._login() + campaign_id, version_id = self._create_built_delivery_campaign( + headers, + external_id="snapshot-freeze", + ) + + from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion + + with SessionLocal() as session: + version = session.get(CampaignVersion, version_id) + self.assertIsNotNone(version) + assert version is not None + snapshot = version.execution_snapshot + self.assertIsInstance(snapshot, dict) + self.assertEqual(snapshot["snapshot_version"], "3") + self.assertEqual(snapshot["job_count"], 1) + self.assertEqual(snapshot["queueable_job_count"], 1) + self.assertTrue(snapshot["job_manifest_sha256"]) + self.assertTrue(snapshot["smtp_config_fingerprint"]) + self.assertIsNone(snapshot["smtp"].get("password")) + self.assertTrue(version.execution_snapshot_hash) + job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one() + self.assertTrue(job.eml_sha256) + self.assertTrue(job.message_id_header) + + # Simulate accidental/config-drift mutation after the build. Sending + # must still use the immutable mock SMTP snapshot, not raw_json. + mutated = json.loads(json.dumps(version.raw_json)) + mutated["server"]["smtp"]["host"] = "smtp.example.invalid" + version.raw_json = mutated + session.add(version) + session.commit() + + sent = self.client.post( + f"/api/v1/campaigns/{campaign_id}/send-now", + headers=headers, + json={ + "version_id": version_id, + "validate_before_send": False, + "build_before_send": False, + "use_rate_limit": False, + "enqueue_imap_task": False, + }, + ) + self.assertEqual(sent.status_code, 200, sent.text) + self.assertEqual(sent.json()["result"]["sent_count"], 1, sent.text) + self.assertEqual(sent.json()["result"]["outcome_unknown_count"], 0, sent.text) + + with SessionLocal() as session: + job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one() + self.assertEqual(job.send_status, "smtp_accepted") + + def test_send_now_sends_exact_generated_eml_bytes(self) -> None: + headers, _ = self._login() + campaign_id, version_id = self._create_built_delivery_campaign( + headers, + external_id="exact-eml-parity", + ) + + from govoplan_campaign.backend.db.models import CampaignJob + from govoplan_mail.backend.dev.mock_mailbox import list_records + + with SessionLocal() as session: + job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one() + self.assertIsNotNone(job.eml_local_path) + generated_eml = Path(job.eml_local_path).read_bytes() + + sent = self.client.post( + f"/api/v1/campaigns/{campaign_id}/send-now", + headers=headers, + json={ + "version_id": version_id, + "validate_before_send": False, + "build_before_send": False, + "use_rate_limit": False, + "enqueue_imap_task": False, + }, + ) + self.assertEqual(sent.status_code, 200, sent.text) + + records = list_records(kind="smtp") + self.assertEqual(len(records), 1) + raw_filename = records[0].get("raw_filename") + self.assertTrue(raw_filename) + captured_eml = (_TEST_ROOT / "mock-mailbox" / "messages" / str(raw_filename)).read_bytes() + self.assertEqual(captured_eml, generated_eml) + + def test_send_now_rejects_modified_generated_eml_before_delivery(self) -> None: + headers, _ = self._login() + campaign_id, version_id = self._create_built_delivery_campaign( + headers, + external_id="tampered-eml", + ) + + from govoplan_campaign.backend.db.models import CampaignJob + from govoplan_mail.backend.dev.mock_mailbox import list_records + + with SessionLocal() as session: + job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one() + self.assertIsNotNone(job.eml_local_path) + eml_path = Path(job.eml_local_path) + eml_path.write_bytes(eml_path.read_bytes() + b"\r\nX-Tampered: true\r\n") + + sent = self.client.post( + f"/api/v1/campaigns/{campaign_id}/send-now", + headers=headers, + json={ + "version_id": version_id, + "validate_before_send": False, + "build_before_send": False, + "use_rate_limit": False, + "enqueue_imap_task": False, + }, + ) + self.assertEqual(sent.status_code, 200, sent.text) + self.assertEqual(sent.json()["result"]["failed_count"], 1) + self.assertIn("Generated EML", sent.json()["result"]["results"][0]["message"]) + self.assertEqual(list_records(kind="smtp"), []) + + def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None: + headers, _ = self._login() + from govoplan_mail.backend.dev.mock_mailbox import set_failures + + campaign_json = { + "version": "1.0", + "campaign": {"id": "partial-refusal", "name": "Partial refusal", "mode": "test"}, + "fields": [], + "global_values": {}, + "server": { + "smtp": { + "host": "mock.smtp", + "port": 2525, + "username": "sender@example.org", + "password": "test-secret", + "security": "starttls", + } + }, + "recipients": { + "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, + "allow_individual_to": True, + }, + "template": {"subject": "Partial", "text": "Partial"}, + "attachments": {"base_path": ".", "global": [], "allow_individual": False}, + "entries": { + "inline": [ + { + "id": "one", + "to": [ + {"email": "accepted@example.org", "type": "to"}, + {"email": "reject-me@example.org", "type": "to"}, + ], + "fields": {}, + } + ] + }, + "validation_policy": {"missing_email": "block", "template_error": "block"}, + "delivery": {"rate_limit": {"messages_per_minute": 60}, "imap_append_sent": {"enabled": False}}, + "status_tracking": {"enabled": True}, + } + created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + version_id = created.json()["version"]["id"] + validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": False}) + self.assertEqual(validated.status_code, 200, validated.text) + built = self.client.post(f"/api/v1/campaigns/versions/{version_id}/build", headers=headers, json={"write_eml": True}) + self.assertEqual(built.status_code, 200, built.text) + + try: + set_failures(smtp_reject_recipients_containing="reject-me") + sent = self.client.post( + f"/api/v1/campaigns/{campaign_id}/send-now", + headers=headers, + json={ + "version_id": version_id, + "validate_before_send": False, + "build_before_send": False, + "use_rate_limit": False, + "enqueue_imap_task": False, + }, + ) + self.assertEqual(sent.status_code, 200, sent.text) + result = sent.json()["result"] + self.assertEqual(result["sent_count"], 1, sent.text) + self.assertIn("refused recipients", result["results"][0]["message"]) + finally: + set_failures(smtp_reject_recipients_containing=None) + + from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt + with SessionLocal() as session: + job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one() + self.assertEqual(job.send_status, "smtp_accepted") + self.assertIn("reject-me@example.org", job.last_error or "") + attempt = session.query(SendAttempt).filter(SendAttempt.job_id == job.id).one() + self.assertEqual(attempt.status, "smtp_accepted_with_refusals") + self.assertIn("reject-me@example.org", attempt.smtp_response or "") + + def test_worker_loss_becomes_unknown_and_requires_reconciliation_before_retry(self) -> None: + headers, _ = self._login() + campaign_id, version_id = self._create_built_delivery_campaign( + headers, + external_id="worker-loss", + recipient_count=2, + ) + queued = self.client.post( + f"/api/v1/campaigns/{campaign_id}/queue", + headers=headers, + json={"version_id": version_id, "enqueue_celery": False}, + ) + self.assertEqual(queued.status_code, 200, queued.text) + self.assertEqual(queued.json()["queued_count"], 2) + + from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, SendAttempt + from govoplan_campaign.backend.sending.jobs import send_campaign_job + + with SessionLocal() as session: + jobs = ( + session.query(CampaignJob) + .filter(CampaignJob.campaign_version_id == version_id) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + uncertain_job = jobs[0] + now = datetime.now(timezone.utc) + uncertain_job.queue_status = "sending" + uncertain_job.send_status = "sending" + uncertain_job.claimed_at = now + uncertain_job.smtp_started_at = now + uncertain_job.claim_token = "worker-loss-claim" + uncertain_job.attempt_count = 1 + session.add( + SendAttempt( + job_id=uncertain_job.id, + attempt_number=1, + status="smtp_in_progress", + claim_token=uncertain_job.claim_token, + started_at=now, + ) + ) + session.add(uncertain_job) + session.commit() + uncertain_job_id = uncertain_job.id + + with SessionLocal() as session: + result = send_campaign_job(session, job_id=uncertain_job_id, use_rate_limit=False) + self.assertEqual(result.status, "outcome_unknown") + + retry_unknown = self.client.post( + f"/api/v1/campaigns/{campaign_id}/jobs/retry", + headers=headers, + json={"version_id": version_id, "job_ids": [uncertain_job_id], "enqueue_celery": False}, + ) + self.assertEqual(retry_unknown.status_code, 200, retry_unknown.text) + self.assertEqual(retry_unknown.json()["result"]["selected_count"], 0) + + cancelled = self.client.post(f"/api/v1/campaigns/{campaign_id}/cancel", headers=headers) + self.assertEqual(cancelled.status_code, 200, cancelled.text) + with SessionLocal() as session: + campaign = session.get(Campaign, campaign_id) + version = session.get(CampaignVersion, version_id) + self.assertEqual(campaign.status, "outcome_unknown") + self.assertEqual(version.workflow_state, "outcome_unknown") + + page = self.client.get( + f"/api/v1/campaigns/{campaign_id}/jobs", + headers=headers, + params={"version_id": version_id, "page": 1, "page_size": 1}, + ) + self.assertEqual(page.status_code, 200, page.text) + self.assertEqual(page.json()["total"], 2) + self.assertEqual(page.json()["pages"], 2) + self.assertEqual(page.json()["counts"]["send"]["outcome_unknown"], 1) + self.assertNotIn("resolved_recipients", page.json()["jobs"][0]) + + filtered_page = self.client.get( + f"/api/v1/campaigns/{campaign_id}/jobs", + headers=headers, + params={"version_id": version_id, "send_status": "outcome_unknown"}, + ) + self.assertEqual(filtered_page.status_code, 200, filtered_page.text) + self.assertEqual(filtered_page.json()["total"], 1) + self.assertEqual(filtered_page.json()["total_unfiltered"], 2) + self.assertEqual(filtered_page.json()["filtered_counts"]["send"]["outcome_unknown"], 1) + + detail = self.client.get( + f"/api/v1/campaigns/{campaign_id}/jobs/{uncertain_job_id}", + headers=headers, + ) + self.assertEqual(detail.status_code, 200, detail.text) + self.assertIn("resolved_recipients", detail.json()["job"]) + self.assertEqual(detail.json()["attempts"]["smtp"][0]["status"], "outcome_unknown") + + resolved = self.client.post( + f"/api/v1/campaigns/{campaign_id}/jobs/{uncertain_job_id}/resolve-outcome", + headers=headers, + json={"decision": "not_sent", "note": "Verified against the SMTP logs."}, + ) + self.assertEqual(resolved.status_code, 200, resolved.text) + self.assertEqual(resolved.json()["result"]["send_status"], "failed_temporary") + + retry = self.client.post( + f"/api/v1/campaigns/{campaign_id}/jobs/retry", + headers=headers, + json={"version_id": version_id, "job_ids": [uncertain_job_id], "enqueue_celery": False}, + ) + self.assertEqual(retry.status_code, 200, retry.text) + self.assertEqual(retry.json()["result"]["selected_count"], 1) + with SessionLocal() as session: + job = session.get(CampaignJob, uncertain_job_id) + self.assertEqual(job.send_status, "queued") + + def test_mixed_delivery_results_are_explicitly_partially_completed(self) -> None: + headers, _ = self._login() + campaign_id, version_id = self._create_built_delivery_campaign( + headers, + external_id="partial-result", + recipient_count=2, + ) + + from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion + from govoplan_campaign.backend.sending.jobs import _update_campaign_after_job + + with SessionLocal() as session: + jobs = ( + session.query(CampaignJob) + .filter(CampaignJob.campaign_version_id == version_id) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + jobs[0].send_status = "smtp_accepted" + jobs[0].sent_at = datetime.now(timezone.utc) + jobs[1].send_status = "failed_permanent" + jobs[1].last_error = "Explicit SMTP rejection" + for job in jobs: + job.queue_status = "draft" + session.add(job) + _update_campaign_after_job(session, campaign_id, version_id) + session.commit() + + campaign = session.get(Campaign, campaign_id) + version = session.get(CampaignVersion, version_id) + self.assertEqual(campaign.status, "partially_completed") + self.assertEqual(version.workflow_state, "partially_completed") + + report = self.client.get(f"/api/v1/campaigns/{campaign_id}/report", headers=headers) + self.assertEqual(report.status_code, 200, report.text) + cards = report.json()["cards"] + self.assertEqual(cards["smtp_accepted"], 1) + self.assertEqual(cards["failed"], 1) + self.assertTrue(cards["partially_completed"]) + + + def test_reports_and_job_review_are_scoped_to_the_selected_version(self) -> None: + headers, _ = self._login() + campaign_id, first_version_id = self._create_built_delivery_campaign( + headers, + external_id="version-scoped-report", + recipient_count=1, + ) + + locked = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/lock-permanently", + headers=headers, + ) + self.assertEqual(locked.status_code, 200, locked.text) + copied = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/fork", + headers=headers, + json={}, + ) + self.assertEqual(copied.status_code, 200, copied.text) + second_version_id = copied.json()["version"]["id"] + + version_response = self.client.get( + f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}", + headers=headers, + ) + self.assertEqual(version_response.status_code, 200, version_response.text) + campaign_json = version_response.json()["raw_json"] + campaign_json["entries"]["inline"].append( + { + "id": "recipient-2", + "to": [{"email": "recipient-2@example.org", "type": "to"}], + "fields": {"first_name": "Recipient 2"}, + } + ) + updated = self.client.put( + f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}", + headers=headers, + json={"campaign_json": campaign_json}, + ) + self.assertEqual(updated.status_code, 200, updated.text) + validated = self.client.post( + f"/api/v1/campaigns/versions/{second_version_id}/validate", + headers=headers, + json={"check_files": False}, + ) + self.assertEqual(validated.status_code, 200, validated.text) + self.assertTrue(validated.json()["ok"], validated.text) + built = self.client.post( + f"/api/v1/campaigns/versions/{second_version_id}/build", + headers=headers, + json={"write_eml": True}, + ) + self.assertEqual(built.status_code, 200, built.text) + self.assertEqual(built.json()["built_count"], 2, built.text) + + first_report = self.client.get( + f"/api/v1/campaigns/{campaign_id}/report", + headers=headers, + params={"version_id": first_version_id}, + ) + second_report = self.client.get( + f"/api/v1/campaigns/{campaign_id}/report", + headers=headers, + params={"version_id": second_version_id}, + ) + self.assertEqual(first_report.status_code, 200, first_report.text) + self.assertEqual(second_report.status_code, 200, second_report.text) + self.assertEqual(first_report.json()["selected_version_id"], first_version_id) + self.assertEqual(first_report.json()["cards"]["jobs_total"], 1) + self.assertEqual(second_report.json()["selected_version_id"], second_version_id) + self.assertEqual(second_report.json()["cards"]["jobs_total"], 2) + + first_jobs = self.client.get( + f"/api/v1/campaigns/{campaign_id}/jobs", + headers=headers, + params={"version_id": first_version_id, "page_size": 1}, + ) + self.assertEqual(first_jobs.status_code, 200, first_jobs.text) + self.assertEqual(first_jobs.json()["total"], 1) + self.assertEqual(first_jobs.json()["total_unfiltered"], 1) + self.assertEqual(first_jobs.json()["review"]["required_count"], 0) + self.assertIn("reviewed", first_jobs.json()["jobs"][0]) + self.assertNotIn("resolved_recipients", first_jobs.json()["jobs"][0]) + + first_csv = self.client.get( + f"/api/v1/campaigns/{campaign_id}/report/jobs.csv", + headers=headers, + params={"version_id": first_version_id}, + ) + second_csv = self.client.get( + f"/api/v1/campaigns/{campaign_id}/report/jobs.csv", + headers=headers, + params={"version_id": second_version_id}, + ) + self.assertEqual(first_csv.status_code, 200, first_csv.text) + self.assertEqual(second_csv.status_code, 200, second_csv.text) + self.assertEqual(len(first_csv.text.strip().splitlines()), 2) + self.assertEqual(len(second_csv.text.strip().splitlines()), 3) + + emailed = self.client.post( + f"/api/v1/campaigns/{campaign_id}/report/email", + headers=headers, + json={ + "version_id": first_version_id, + "to": ["auditor@example.org"], + "attach_jobs_csv": True, + "dry_run": True, + }, + ) + self.assertEqual(emailed.status_code, 200, emailed.text) + self.assertEqual(emailed.json()["result"]["version_id"], first_version_id) + self.assertTrue(emailed.json()["result"]["attached_jobs_csv"]) + self.assertFalse(emailed.json()["result"]["sent"]) + + + def test_tenant_user_group_role_and_api_key_administration(self) -> None: + headers, login = self._login() + system_headers, _ = self._login() + + overview = self.client.get("/api/v1/admin/overview", headers=headers) + self.assertEqual(overview.status_code, 200, overview.text) + self.assertEqual(overview.json()["tenant_count"], 1) + + created_tenant = self.client.post( + "/api/v1/admin/tenants", + headers=headers, + json={ + "slug": "operations", + "name": "Operations", + "description": "Operational tenant", + "default_locale": "de", + "settings": {}, + }, + ) + self.assertEqual(created_tenant.status_code, 201, created_tenant.text) + tenant_id = created_tenant.json()["id"] + + # Suspending the tenant that backs the current session is rejected to + # prevent an administrator from invalidating the only context through + # which they can repair the instance. Switch first, then suspend. + reject_active_tenant_suspend = self.client.patch( + f"/api/v1/admin/tenants/{login['tenant']['id']}", + headers=system_headers, + json={"is_active": False}, + ) + self.assertEqual(reject_active_tenant_suspend.status_code, 409, reject_active_tenant_suspend.text) + + # System roles do not bypass tenant context. Tenant administration must + # use a membership-backed active tenant selected on the session. + cross_tenant_users = self.client.get( + "/api/v1/admin/users", + headers=system_headers, + params={"tenant_id": tenant_id}, + ) + self.assertEqual(cross_tenant_users.status_code, 409, cross_tenant_users.text) + + switched = self.client.post( + "/api/v1/auth/switch-tenant", + headers=headers, + json={"tenant_id": tenant_id}, + ) + self.assertEqual(switched.status_code, 200, switched.text) + self.assertEqual(switched.json()["active_tenant"]["id"], tenant_id) + switched_scopes = switched.json()["scopes"] + self.assertIn("tenant:*", switched_scopes) + self.assertIn("system:*", switched_scopes) + + roles = self.client.get("/api/v1/admin/roles", headers=headers) + self.assertEqual(roles.status_code, 200, roles.text) + owner_role = next(role for role in roles.json()["roles"] if role["slug"] == "owner") + + custom_role = self.client.post( + "/api/v1/admin/roles", + headers=headers, + json={ + "slug": "file-reader", + "name": "File reader", + "description": "Read managed files only", + "permissions": ["files:read"], + }, + ) + self.assertEqual(custom_role.status_code, 201, custom_role.text) + custom_role_id = custom_role.json()["id"] + + group = self.client.post( + "/api/v1/admin/groups", + headers=headers, + json={ + "slug": "file-reviewers", + "name": "File reviewers", + "description": "Read-only file access", + "member_ids": [], + "role_ids": [custom_role_id], + }, + ) + self.assertEqual(group.status_code, 201, group.text) + group_id = group.json()["id"] + + created_user = self.client.post( + "/api/v1/admin/users", + headers=headers, + json={ + "email": "reader@example.local", + "display_name": "Reader", + "password": "reader-password-123", + "password_reset_required": False, + "is_active": True, + "group_ids": [group_id], + "role_ids": [], + }, + ) + self.assertEqual(created_user.status_code, 201, created_user.text) + reader_id = created_user.json()["user"]["id"] + self.assertEqual(created_user.json()["temporary_password"], "reader-password-123") + self.assertEqual(created_user.json()["user"]["roles"], []) + self.assertIn("files:read", created_user.json()["user"]["effective_scopes"]) + + reader_login = self.client.post( + "/api/v1/auth/login", + json={ + "email": "reader@example.local", + "password": "reader-password-123", + "tenant_slug": "operations", + }, + ) + self.assertEqual(reader_login.status_code, 200, reader_login.text) + reader_headers = {"Authorization": f"Bearer {reader_login.json()['access_token']}"} + + file_spaces = self.client.get("/api/v1/files/spaces", headers=reader_headers) + self.assertEqual(file_spaces.status_code, 200, file_spaces.text) + denied_admin = self.client.get("/api/v1/admin/users", headers=reader_headers) + self.assertEqual(denied_admin.status_code, 403, denied_admin.text) + + excessive_key = self.client.post( + "/api/v1/admin/api-keys", + headers=headers, + json={ + "name": "Too broad", + "user_id": reader_id, + "scopes": ["campaign:send"], + }, + ) + self.assertEqual(excessive_key.status_code, 422, excessive_key.text) + + reader_key = self.client.post( + "/api/v1/admin/api-keys", + headers=headers, + json={ + "name": "Reader key", + "user_id": reader_id, + "scopes": ["files:read"], + }, + ) + self.assertEqual(reader_key.status_code, 201, reader_key.text) + api_headers = {"X-API-Key": reader_key.json()["secret"]} + self.assertEqual(self.client.get("/api/v1/files/spaces", headers=api_headers).status_code, 200) + self.assertEqual(self.client.get("/api/v1/campaigns", headers=api_headers).status_code, 403) + + # API keys are bounded by the owning membership's live permissions. + # Removing the group's role must immediately narrow the existing key. + narrowed_group = self.client.patch( + f"/api/v1/admin/groups/{group_id}", + headers=headers, + json={"role_ids": []}, + ) + self.assertEqual(narrowed_group.status_code, 200, narrowed_group.text) + self.assertEqual(self.client.get("/api/v1/files/spaces", headers=api_headers).status_code, 403) + + # The active tenant must retain an operational owner. The only owner is + # the system administrator's automatically created membership. + remove_last_owner = self.client.patch( + f"/api/v1/admin/users/{login['user']['id']}", + headers=headers, + json={"group_ids": [], "role_ids": []}, + ) + # The original login user id belongs to the default tenant, so the + # operations tenant correctly hides it rather than crossing tenant scope. + self.assertEqual(remove_last_owner.status_code, 404, remove_last_owner.text) + + operations_users = self.client.get("/api/v1/admin/users", headers=headers) + self.assertEqual(operations_users.status_code, 200, operations_users.text) + operation_admin = next(user for user in operations_users.json()["users"] if user["email"] == "admin@example.local") + self.assertTrue(any(role["id"] == owner_role["id"] for role in operation_admin["roles"])) + remove_last_owner = self.client.patch( + f"/api/v1/admin/users/{operation_admin['id']}", + headers=headers, + json={"group_ids": [], "role_ids": []}, + ) + self.assertEqual(remove_last_owner.status_code, 409, remove_last_owner.text) + + tenant_owner = self.client.post( + "/api/v1/admin/users", + headers=headers, + json={ + "email": "tenant-owner@example.local", + "display_name": "Tenant Owner", + "password": "tenant-owner-password", + "password_reset_required": False, + "is_active": True, + "group_ids": [], + "role_ids": [owner_role["id"]], + }, + ) + self.assertEqual(tenant_owner.status_code, 201, tenant_owner.text) + tenant_owner_account_id = tenant_owner.json()["user"]["account_id"] + tenant_owner_login = self.client.post( + "/api/v1/auth/login", + json={ + "email": "tenant-owner@example.local", + "password": "tenant-owner-password", + "tenant_slug": "operations", + }, + ) + self.assertEqual(tenant_owner_login.status_code, 200, tenant_owner_login.text) + tenant_owner_scopes = tenant_owner_login.json()["scopes"] + self.assertIn("tenant:*", tenant_owner_scopes) + self.assertNotIn("system:*", tenant_owner_scopes) + tenant_owner_headers = {"Authorization": f"Bearer {tenant_owner_login.json()['access_token']}"} + self.assertEqual(self.client.get("/api/v1/admin/users", headers=tenant_owner_headers).status_code, 200) + self.assertEqual(self.client.get("/api/v1/admin/tenants", headers=tenant_owner_headers).status_code, 403) + + # Global account suspension immediately invalidates all of its tenant + # sessions. Reactivation restores access without changing memberships. + disabled_tenant_owner = self.client.patch( + f"/api/v1/admin/system/accounts/{tenant_owner_account_id}", + headers=system_headers, + json={"is_active": False}, + ) + self.assertEqual(disabled_tenant_owner.status_code, 200, disabled_tenant_owner.text) + self.assertEqual(self.client.get("/api/v1/auth/me", headers=tenant_owner_headers).status_code, 401) + reenabled_tenant_owner = self.client.patch( + f"/api/v1/admin/system/accounts/{tenant_owner_account_id}", + headers=system_headers, + json={"is_active": True}, + ) + self.assertEqual(reenabled_tenant_owner.status_code, 200, reenabled_tenant_owner.text) + self.assertEqual(self.client.get("/api/v1/auth/me", headers=tenant_owner_headers).status_code, 200) + + system_accounts = self.client.get("/api/v1/admin/system/accounts", headers=system_headers) + self.assertEqual(system_accounts.status_code, 200, system_accounts.text) + admin_account = next(account for account in system_accounts.json()["accounts"] if account["email"] == "admin@example.local") + remove_last_system_owner = self.client.put( + f"/api/v1/admin/system/accounts/{admin_account['account_id']}/roles", + headers=system_headers, + json={"role_ids": []}, + ) + self.assertEqual(remove_last_system_owner.status_code, 409, remove_last_system_owner.text) + deactivate_last_system_owner = self.client.patch( + f"/api/v1/admin/system/accounts/{admin_account['account_id']}", + headers=system_headers, + json={"is_active": False}, + ) + self.assertEqual(deactivate_last_system_owner.status_code, 409, deactivate_last_system_owner.text) + + suspended = self.client.patch( + f"/api/v1/admin/tenants/{tenant_id}", + headers=system_headers, + json={"is_active": False}, + ) + self.assertEqual(suspended.status_code, 200, suspended.text) + self.assertEqual(self.client.get("/api/v1/auth/me", headers=headers).status_code, 401) + reactivated = self.client.patch( + f"/api/v1/admin/tenants/{tenant_id}", + headers=system_headers, + json={"is_active": True}, + ) + self.assertEqual(reactivated.status_code, 200, reactivated.text) + self.assertEqual(self.client.get("/api/v1/auth/me", headers=headers).status_code, 200) + + audit = self.client.get("/api/v1/admin/audit", headers=headers) + self.assertEqual(audit.status_code, 200, audit.text) + actions = {item["action"] for item in audit.json()["items"]} + self.assertIn("user.created", actions) + self.assertIn("group.created", actions) + self.assertIn("role.created", actions) + + + + def test_system_governance_defaults_templates_and_central_accounts(self) -> None: + headers, login = self._login() + tenant_id = login["tenant"]["id"] + + settings_response = self.client.get("/api/v1/admin/system/settings", headers=headers) + self.assertEqual(settings_response.status_code, 200, settings_response.text) + self.assertTrue(settings_response.json()["allow_tenant_custom_groups"]) + self.assertEqual(settings_response.json()["privacy_retention_policy"]["audit_detail_level"], "full") + + deny_local_groups = self.client.patch( + "/api/v1/admin/system/settings", + headers=headers, + json={ + "default_locale": "en", + "allow_tenant_custom_groups": False, + "allow_tenant_custom_roles": True, + "allow_tenant_api_keys": True, + "privacy_retention_policy": { + "audit_detail_level": "redacted", + "mock_mailbox_retention_days": 0, + }, + }, + ) + self.assertEqual(deny_local_groups.status_code, 200, deny_local_groups.text) + self.assertEqual(deny_local_groups.json()["privacy_retention_policy"]["audit_detail_level"], "redacted") + self.assertEqual(deny_local_groups.json()["privacy_retention_policy"]["mock_mailbox_retention_days"], 0) + + widened_tenant = self.client.patch( + f"/api/v1/admin/tenants/{tenant_id}", + headers=headers, + json={"allow_custom_groups": True}, + ) + self.assertEqual(widened_tenant.status_code, 422, widened_tenant.text) + + locked_retention = self.client.put( + "/api/v1/admin/privacy-retention/policies/system", + headers=headers, + json={"policy": {"allow_lower_level_limits": {"raw_campaign_json_retention_days": False}}}, + ) + self.assertEqual(locked_retention.status_code, 200, locked_retention.text) + self.assertFalse(locked_retention.json()["effective_policy"]["allow_lower_level_limits"]["raw_campaign_json_retention_days"]) + tenant_retention_widen = self.client.put( + "/api/v1/admin/privacy-retention/policies/tenant", + headers=headers, + json={"policy": {"raw_campaign_json_retention_days": 1}}, + ) + self.assertEqual(tenant_retention_widen.status_code, 422, tenant_retention_widen.text) + tenant_retention_unlock = self.client.put( + "/api/v1/admin/privacy-retention/policies/tenant", + headers=headers, + json={"policy": {"allow_lower_level_limits": {"raw_campaign_json_retention_days": True}}}, + ) + self.assertEqual(tenant_retention_unlock.status_code, 422, tenant_retention_unlock.text) + + retention_dry_run = self.client.post( + "/api/v1/admin/system/retention/run", + headers=headers, + json={"dry_run": True}, + ) + self.assertEqual(retention_dry_run.status_code, 200, retention_dry_run.text) + self.assertTrue(retention_dry_run.json()["result"]["dry_run"]) + self.assertIn("raw_campaign_json", retention_dry_run.json()["result"]["counts"]) + + blocked_group = self.client.post( + "/api/v1/admin/groups", + headers=headers, + json={"slug": "blocked-local", "name": "Blocked local group", "member_ids": [], "role_ids": []}, + ) + self.assertEqual(blocked_group.status_code, 409, blocked_group.text) + + central_group = self.client.post( + "/api/v1/admin/system/governance-templates", + headers=headers, + json={ + "kind": "group", + "slug": "case-workers", + "name": "Case workers", + "description": "System-controlled group identity", + "permissions": [], + "is_active": True, + "assignments": [{"tenant_id": tenant_id, "mode": "required"}], + }, + ) + self.assertEqual(central_group.status_code, 201, central_group.text) + template_id = central_group.json()["id"] + + tenant_groups = self.client.get("/api/v1/admin/groups", headers=headers) + self.assertEqual(tenant_groups.status_code, 200, tenant_groups.text) + materialized = next(group for group in tenant_groups.json()["groups"] if group["system_template_id"] == template_id) + self.assertTrue(materialized["system_required"]) + self.assertTrue(materialized["is_active"]) + + reject_required_deactivation = self.client.patch( + f"/api/v1/admin/groups/{materialized['id']}", + headers=headers, + json={"is_active": False}, + ) + self.assertEqual(reject_required_deactivation.status_code, 409, reject_required_deactivation.text) + + created_tenant = self.client.post( + "/api/v1/admin/tenants", + headers=headers, + json={"slug": "governed", "name": "Governed", "settings": {}}, + ) + self.assertEqual(created_tenant.status_code, 201, created_tenant.text) + governed_tenant_id = created_tenant.json()["id"] + + central_account = self.client.post( + "/api/v1/admin/system/accounts", + headers=headers, + json={ + "email": "central-user@example.local", + "display_name": "Central User", + "password": "central-user-password", + "password_reset_required": False, + "is_active": True, + "role_ids": [], + "memberships": [{ + "tenant_id": governed_tenant_id, + "is_active": True, + "role_ids": [], + "group_ids": [], + }], + }, + ) + self.assertEqual(central_account.status_code, 201, central_account.text) + memberships = central_account.json()["account"]["memberships"] + self.assertEqual([item["tenant_id"] for item in memberships], [governed_tenant_id]) + + audit = self.client.get( + "/api/v1/admin/audit", + headers=headers, + params={"all_tenants": True, "limit": 500}, + ) + self.assertEqual(audit.status_code, 200, audit.text) + actions = {item["action"] for item in audit.json()["items"]} + self.assertIn("system_settings.updated", actions) + self.assertIn("governance_template.created", actions) + self.assertIn("system_account.created", actions) + + + def test_refined_permissions_are_narrow_and_enforce_delegation_ceiling(self) -> None: + owner_headers, _ = self._login() + designer_role = self._create_role( + owner_headers, + slug="role-designer", + permissions=["admin:users:read", "admin:roles:read", "admin:roles:write", "campaign:queue"], + ) + designer = self._create_user( + owner_headers, + email="designer@example.local", + password="designer-password", + role_ids=[str(designer_role["id"])], + ) + designer_headers, designer_login = self._login_as(email="designer@example.local", password="designer-password") + + self.assertIn("campaign:queue", designer_login["scopes"]) + self.assertNotIn("campaign:retry", designer_login["scopes"]) + self.assertNotIn("campaign:send", designer_login["scopes"]) + + excessive_role = self.client.post( + "/api/v1/admin/roles", + headers=designer_headers, + json={ + "slug": "sender-escalation", + "name": "Sender escalation", + "description": "Must be rejected", + "permissions": ["campaign:send"], + }, + ) + self.assertEqual(excessive_role.status_code, 422, excessive_role.text) + + assign_without_assignment_permission = self.client.patch( + f"/api/v1/admin/users/{designer['id']}", + headers=designer_headers, + json={"role_ids": []}, + ) + self.assertEqual(assign_without_assignment_permission.status_code, 403, assign_without_assignment_permission.text) + + def test_campaign_acl_separates_capability_from_object_access(self) -> None: + owner_headers, _ = self._login() + access_role = self._create_role( + owner_headers, + slug="campaign-collaborator", + permissions=["campaign:read", "campaign:update", "recipients:read"], + ) + reader = self._create_user( + owner_headers, + email="campaign-reader@example.local", + password="reader-password", + role_ids=[str(access_role["id"])], + ) + other = self._create_user( + owner_headers, + email="campaign-other@example.local", + password="other-password", + role_ids=[str(access_role["id"])], + ) + reader_headers, _ = self._login_as(email="campaign-reader@example.local", password="reader-password") + other_headers, _ = self._login_as(email="campaign-other@example.local", password="other-password") + + config = { + "version": "1.0", + "campaign": {"id": "acl-campaign", "name": "ACL campaign", "mode": "test"}, + "fields": [], + "global_values": {}, + "server": {"smtp": {"host": "mock.smtp", "port": 2525, "security": "starttls"}}, + "recipients": {"from": {"email": "sender@example.org", "type": "to"}}, + "template": {"subject": "ACL", "text": "ACL"}, + "attachments": {"base_path": ".", "global": [], "allow_individual": False}, + "entries": {"inline": [{"id": "one", "to": [{"email": "one@example.org", "type": "to"}], "fields": {}}]}, + "validation_policy": {"missing_email": "block", "template_error": "block"}, + "delivery": {"rate_limit": {"messages_per_minute": 60}, "imap_append_sent": {"enabled": False}}, + "status_tracking": {"enabled": True}, + } + created = self.client.post("/api/v1/campaigns", headers=owner_headers, json={"config": config}) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + version_id = created.json()["version"]["id"] + + self.assertEqual(self.client.get(f"/api/v1/campaigns/{campaign_id}", headers=reader_headers).status_code, 403) + self.assertEqual(self.client.get(f"/api/v1/campaigns/{campaign_id}", headers=other_headers).status_code, 403) + + read_share = self.client.post( + f"/api/v1/campaigns/{campaign_id}/shares", + headers=owner_headers, + json={"target_type": "user", "target_id": reader["id"], "permission": "read"}, + ) + self.assertEqual(read_share.status_code, 201, read_share.text) + self.assertEqual(self.client.get(f"/api/v1/campaigns/{campaign_id}", headers=reader_headers).status_code, 200) + version_detail = self.client.get(f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", headers=reader_headers) + self.assertEqual(version_detail.status_code, 200, version_detail.text) + denied_write = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/set-step", + headers=reader_headers, + json={"current_step": "template"}, + ) + self.assertEqual(denied_write.status_code, 403, denied_write.text) + + write_share = self.client.post( + f"/api/v1/campaigns/{campaign_id}/shares", + headers=owner_headers, + json={"target_type": "user", "target_id": reader["id"], "permission": "write"}, + ) + self.assertEqual(write_share.status_code, 201, write_share.text) + allowed_write = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/set-step", + headers=reader_headers, + json={"current_step": "template"}, + ) + self.assertEqual(allowed_write.status_code, 200, allowed_write.text) + changed_config = version_detail.json()["raw_json"] + changed_config["entries"]["inline"][0]["to"][0]["email"] = "changed@example.org" + denied_recipient_edit = self.client.put( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", + headers=reader_headers, + json={"campaign_json": changed_config}, + ) + self.assertEqual(denied_recipient_edit.status_code, 403, denied_recipient_edit.text) + self.assertEqual(self.client.get(f"/api/v1/campaigns/{campaign_id}", headers=other_headers).status_code, 403) + + def test_campaign_scoped_mail_profile_policy_is_enforced(self) -> None: + headers, _ = self._login() + created = self.client.post( + "/api/v1/campaigns/new", + headers=headers, + json={"external_id": "profile-policy-campaign", "name": "Profile policy campaign"}, + ) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + + profile = self.client.post( + "/api/v1/mail/profiles", + headers=headers, + json={ + "name": "Campaign SMTP", + "scope_type": "campaign", + "scope_id": campaign_id, + "smtp": {"host": "allowed.smtp.local", "port": 2525, "username": "sender@example.org", "password": "secret", "security": "starttls"}, + }, + ) + self.assertEqual(profile.status_code, 201, profile.text) + profile_id = profile.json()["id"] + self.assertEqual(profile.json()["scope_type"], "campaign") + + effective = self.client.get(f"/api/v1/mail/profiles?campaign_id={campaign_id}", headers=headers) + self.assertEqual(effective.status_code, 200, effective.text) + self.assertIn(profile_id, {item["id"] for item in effective.json()["profiles"]}) + + policy = self.client.put( + f"/api/v1/mail/policies/campaign?scope_id={campaign_id}", + headers=headers, + json={"policy": {"blacklist": {"smtp_hosts": ["blocked.smtp.local"]}}}, + ) + self.assertEqual(policy.status_code, 200, policy.text) + + blocked = self.client.post( + "/api/v1/mail/profiles", + headers=headers, + json={ + "name": "Blocked SMTP", + "scope_type": "campaign", + "scope_id": campaign_id, + "smtp": {"host": "blocked.smtp.local", "port": 2525, "username": "sender@example.org", "password": "secret", "security": "starttls"}, + }, + ) + self.assertEqual(blocked.status_code, 422, blocked.text) + self.assertIn("blacklist", blocked.json()["detail"]) + + def test_allowed_mail_profile_ids_gate_campaign_selection(self) -> None: + headers, _ = self._login() + created = self.client.post( + "/api/v1/campaigns/new", + headers=headers, + json={"external_id": "profile-selection-policy", "name": "Profile selection policy"}, + ) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + version_id = created.json()["version"]["id"] + + profile = self.client.post( + "/api/v1/mail/profiles", + headers=headers, + json={ + "name": "Tenant SMTP", + "smtp": {"host": "allowed.smtp.local", "port": 2525, "username": "sender@example.org", "password": "secret", "security": "starttls"}, + }, + ) + self.assertEqual(profile.status_code, 201, profile.text) + profile_id = profile.json()["id"] + + denied_policy = self.client.put( + f"/api/v1/mail/policies/campaign?scope_id={campaign_id}", + headers=headers, + json={"policy": {"allowed_profile_ids": ["not-the-profile"]}}, + ) + self.assertEqual(denied_policy.status_code, 200, denied_policy.text) + + detail = self.client.get(f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", headers=headers) + self.assertEqual(detail.status_code, 200, detail.text) + raw_json = detail.json()["raw_json"] + raw_json["server"] = {"mail_profile_id": profile_id} + denied_update = self.client.put( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", + headers=headers, + json={"campaign_json": raw_json}, + ) + self.assertEqual(denied_update.status_code, 422, denied_update.text) + + allowed_policy = self.client.put( + f"/api/v1/mail/policies/campaign?scope_id={campaign_id}", + headers=headers, + json={"policy": {"allowed_profile_ids": [profile_id]}}, + ) + self.assertEqual(allowed_policy.status_code, 200, allowed_policy.text) + allowed_update = self.client.put( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", + headers=headers, + json={"campaign_json": raw_json}, + ) + self.assertEqual(allowed_update.status_code, 200, allowed_update.text) + + def test_locked_mail_profile_credential_policy_blocks_campaign_override(self) -> None: + headers, _ = self._login() + profile = self.client.post( + "/api/v1/mail/profiles", + headers=headers, + json={ + "name": "Locked credential SMTP", + "smtp": { + "host": "mock.smtp", + "port": 2525, + "username": "profile-smtp@example.org", + "password": "profile-smtp-secret", + "security": "starttls", + }, + }, + ) + self.assertEqual(profile.status_code, 201, profile.text) + profile_id = profile.json()["id"] + + policy = self.client.put( + "/api/v1/mail/policies/tenant", + headers=headers, + json={"policy": {"smtp_credentials": {"inherit": True, "allow_override": False}}}, + ) + self.assertEqual(policy.status_code, 200, policy.text) + self.assertTrue(policy.json()["effective_policy"] is None) + + campaign_json = { + "version": "1.0", + "campaign": {"id": "locked-profile-creds", "name": "Locked profile creds", "mode": "test"}, + "fields": [{"name": "first_name", "type": "string", "required": True}], + "global_values": {}, + "server": { + "mail_profile_id": profile_id, + "inherit_smtp_credentials": False, + "smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"}, + }, + "recipients": { + "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, + "to": [{"email": "recipient@example.org", "type": "to"}], + }, + "template": {"subject": "Hello ${local::first_name}", "text": "Hello ${local::first_name}."}, + "attachments": {"base_path": ".", "global": [], "allow_individual": False}, + "entries": {"inline": [{"id": "recipient-1", "fields": {"first_name": "Recipient"}}]}, + "validation_policy": {"missing_email": "block", "template_error": "block"}, + "delivery": {"rate_limit": {"messages_per_minute": 60}, "retry": {"max_attempts": 3, "backoff_seconds": [1]}, "imap_append_sent": {"enabled": False}}, + "status_tracking": {"enabled": True}, + } + blocked = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) + self.assertEqual(blocked.status_code, 422, blocked.text) + self.assertIn("locked", blocked.json()["detail"]) + + campaign_json["server"] = {"mail_profile_id": profile_id} + allowed = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) + self.assertEqual(allowed.status_code, 200, allowed.text) + + def test_file_and_raw_mail_permissions_are_independently_enforced(self) -> None: + owner_headers, _ = self._login() + role = self._create_role( + owner_headers, + slug="uploader-and-mail-tester", + permissions=["files:read", "files:upload", "mail_servers:test"], + ) + self._create_user( + owner_headers, + email="uploader@example.local", + password="uploader-password", + role_ids=[str(role["id"])], + ) + headers, login = self._login_as(email="uploader@example.local", password="uploader-password") + user_id = login["user"]["id"] + uploaded = self.client.post( + "/api/v1/files/upload", + headers=headers, + data={"owner_type": "user", "owner_id": user_id, "path": ""}, + files=[("files", ("allowed.txt", b"allowed", "text/plain"))], + ) + self.assertEqual(uploaded.status_code, 200, uploaded.text) + file_id = uploaded.json()["files"][0]["id"] + self.assertEqual(self.client.get(f"/api/v1/files/{file_id}/download", headers=headers).status_code, 403) + self.assertEqual(self.client.delete(f"/api/v1/files/{file_id}", headers=headers).status_code, 403) + + raw_test = self.client.post( + "/api/v1/mail/test-smtp", + headers=headers, + json={"host": "mock.smtp", "port": 2525, "username": "u", "password": "p", "security": "starttls"}, + ) + self.assertEqual(raw_test.status_code, 403, raw_test.text) + self.assertIn("manage_credentials", raw_test.json()["detail"]) + + def test_profile_refresh_and_system_role_protection_model(self) -> None: + headers, _ = self._login() + profile = self.client.patch( + "/api/v1/auth/profile", + headers=headers, + json={"display_name": "Global Account Name", "tenant_display_name": "Tenant Alias"}, + ) + self.assertEqual(profile.status_code, 200, profile.text) + self.assertEqual(profile.json()["user"]["display_name"], "Global Account Name") + self.assertEqual(profile.json()["user"]["tenant_display_name"], "Tenant Alias") + refreshed = self.client.get("/api/v1/auth/me", headers=headers) + self.assertEqual(refreshed.status_code, 200, refreshed.text) + self.assertEqual(refreshed.json()["user"]["display_name"], "Global Account Name") + self.assertEqual(refreshed.json()["user"]["tenant_display_name"], "Tenant Alias") + + roles = self.client.get("/api/v1/admin/system/roles", headers=headers) + self.assertEqual(roles.status_code, 200, roles.text) + owner = next(item for item in roles.json()["roles"] if item["slug"] == "system_owner") + administrator = next(item for item in roles.json()["roles"] if item["slug"] == "system_admin") + auditor = next(item for item in roles.json()["roles"] if item["slug"] == "system_auditor") + self.assertTrue(owner["is_builtin"]) + self.assertEqual(owner["user_assignments"], 1) + self.assertEqual(owner["effective_permission_count"], len(SYSTEM_SCOPES)) + self.assertEqual(owner["permissions"], ["system:*"]) + self.assertFalse(administrator["is_builtin"]) + self.assertFalse(auditor["is_builtin"]) + + blocked_owner_edit = self.client.patch( + f"/api/v1/admin/system/roles/{owner['id']}", + headers=headers, + json={"name": "Changed owner"}, + ) + self.assertEqual(blocked_owner_edit.status_code, 409, blocked_owner_edit.text) + edited_admin = self.client.patch( + f"/api/v1/admin/system/roles/{administrator['id']}", + headers=headers, + json={"name": "Platform administrator"}, + ) + self.assertEqual(edited_admin.status_code, 200, edited_admin.text) + self.assertEqual(edited_admin.json()["name"], "Platform administrator") + + + + def test_admin_audit_supports_lazy_pagination_sorting_and_filters(self) -> None: + headers, _ = self._login() + for index in range(5): + updated = self.client.patch( + "/api/v1/admin/system/settings", + headers=headers, + json={ + "default_locale": f"en-{index}", + "allow_tenant_custom_groups": True, + "allow_tenant_custom_roles": True, + "allow_tenant_api_keys": True, + }, + ) + self.assertEqual(updated.status_code, 200, updated.text) + + first_page = self.client.get( + "/api/v1/admin/audit", + headers=headers, + params={ + "scope": "system", + "page": 1, + "page_size": 2, + "sort_by": "action", + "sort_direction": "asc", + "filter_action": "system_settings", + }, + ) + self.assertEqual(first_page.status_code, 200, first_page.text) + payload = first_page.json() + self.assertEqual(payload["page"], 1) + self.assertEqual(payload["page_size"], 2) + self.assertGreaterEqual(payload["total"], 5) + self.assertEqual(len(payload["items"]), 2) + self.assertTrue(all("system_settings" in item["action"] for item in payload["items"])) + + second_page = self.client.get( + "/api/v1/admin/audit", + headers=headers, + params={ + "scope": "system", + "page": 2, + "page_size": 2, + "filter_actor": "admin@example.local", + }, + ) + self.assertEqual(second_page.status_code, 200, second_page.text) + self.assertEqual(second_page.json()["page"], 2) + self.assertEqual(len(second_page.json()["items"]), 2) + + + + def test_tenant_owner_selection_audit_scope_and_last_owner_protection(self) -> None: + headers, _ = self._login() + created_account = self.client.post( + "/api/v1/admin/system/accounts", + headers=headers, + json={ + "email": "selected-owner@example.local", + "display_name": "Selected Owner", + "password": "selected-owner-password", + "password_reset_required": False, + "is_active": True, + "role_ids": [], + "memberships": [], + }, + ) + self.assertEqual(created_account.status_code, 201, created_account.text) + account_id = created_account.json()["account"]["account_id"] + + candidates = self.client.get("/api/v1/admin/tenants/owner-candidates", headers=headers) + self.assertEqual(candidates.status_code, 200, candidates.text) + self.assertIn(account_id, {item["account_id"] for item in candidates.json()["accounts"]}) + + created_tenant = self.client.post( + "/api/v1/admin/tenants", + headers=headers, + json={ + "slug": "selected-owner", + "name": "Selected owner tenant", + "owner_account_id": account_id, + "settings": {}, + }, + ) + self.assertEqual(created_tenant.status_code, 201, created_tenant.text) + tenant_id = created_tenant.json()["id"] + + system_audit = self.client.get( + "/api/v1/admin/audit?scope=system&limit=500", + headers=headers, + ) + self.assertEqual(system_audit.status_code, 200, system_audit.text) + tenant_created_rows = [ + item for item in system_audit.json()["items"] + if item["action"] == "tenant.created" and item["object_id"] == tenant_id + ] + self.assertEqual(len(tenant_created_rows), 1) + self.assertEqual(tenant_created_rows[0]["scope"], "system") + + owner_headers, _ = self._login_as( + email="selected-owner@example.local", + password="selected-owner-password", + tenant_slug="selected-owner", + ) + users = self.client.get("/api/v1/admin/users", headers=owner_headers) + self.assertEqual(users.status_code, 200, users.text) + owner = next(item for item in users.json()["users"] if item["account_id"] == account_id) + self.assertTrue(owner["is_owner"]) + self.assertTrue(owner["is_last_active_owner"]) + + tenant_audit = self.client.get( + "/api/v1/admin/audit?scope=tenant&limit=500", + headers=owner_headers, + ) + self.assertEqual(tenant_audit.status_code, 200, tenant_audit.text) + self.assertFalse(any(item["action"] == "tenant.created" for item in tenant_audit.json()["items"])) + self.assertTrue(all(item["scope"] == "tenant" for item in tenant_audit.json()["items"])) + + blocked = self.client.patch( + f"/api/v1/admin/users/{owner['id']}", + headers=owner_headers, + json={"is_active": False}, + ) + self.assertEqual(blocked.status_code, 409, blocked.text) + + accounts = self.client.get("/api/v1/admin/system/accounts", headers=headers) + self.assertEqual(accounts.status_code, 200, accounts.text) + selected = next(item for item in accounts.json()["accounts"] if item["account_id"] == account_id) + selected_membership = next(item for item in selected["memberships"] if item["tenant_id"] == tenant_id) + self.assertTrue(selected_membership["is_owner"]) + self.assertTrue(selected_membership["is_last_active_owner"]) + + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_database_migrations.py b/tests/test_database_migrations.py new file mode 100644 index 0000000..9e66024 --- /dev/null +++ b/tests/test_database_migrations.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from alembic import command +from alembic.runtime.migration import MigrationContext +from sqlalchemy import create_engine, inspect, text + +from govoplan_core.db.base import Base +from govoplan_core.db.migrations import ( + REVISION_AUTH_RBAC, + REVISION_FILE_FOLDERS, + alembic_config, + migrate_database, +) + + +class DatabaseMigrationTests(unittest.TestCase): + def test_repairs_create_all_schema_drift_and_upgrades_to_head(self) -> None: + with tempfile.TemporaryDirectory(prefix="msm-migration-test-") as directory: + database = Path(directory) / "legacy.db" + url = f"sqlite:///{database}" + + # Reproduce the historical development database: Alembic was run + # through auth/RBAC, then create_all() created later file tables + # without advancing alembic_version and without altering the + # already-existing campaign_versions table. + command.upgrade(alembic_config(database_url=url), REVISION_AUTH_RBAC) + engine = create_engine(url) + try: + Base.metadata.create_all(bind=engine) + with engine.connect() as connection: + self.assertEqual( + MigrationContext.configure(connection).get_current_revision(), + REVISION_AUTH_RBAC, + ) + self.assertIn("file_blobs", inspect(connection).get_table_names()) + self.assertNotIn( + "user_lock_state", + {column["name"] for column in inspect(connection).get_columns("campaign_versions")}, + ) + finally: + engine.dispose() + + result = migrate_database(database_url=url) + self.assertEqual(result.previous_revision, REVISION_AUTH_RBAC) + self.assertEqual(result.reconciled_revision, REVISION_FILE_FOLDERS) + + engine = create_engine(url) + try: + with engine.connect() as connection: + current = MigrationContext.configure(connection).get_current_revision() + inspector = inspect(connection) + columns = { + column["name"] + for column in inspector.get_columns("campaign_versions") + } + folder_indexes = {index["name"] for index in inspector.get_indexes("file_folders")} + job_columns = {column["name"] for column in inspector.get_columns("campaign_jobs")} + job_indexes = {index["name"] for index in inspector.get_indexes("campaign_jobs")} + attempt_columns = {column["name"] for column in inspector.get_columns("send_attempts")} + tables = set(inspector.get_table_names()) + tenant_columns = {column["name"] for column in inspector.get_columns("tenants")} + user_columns = {column["name"] for column in inspector.get_columns("users")} + session_columns = {column["name"] for column in inspector.get_columns("auth_sessions")} + group_columns = {column["name"] for column in inspector.get_columns("groups")} + role_columns = {column["name"] for column in inspector.get_columns("roles")} + role_indexes = {index["name"] for index in inspector.get_indexes("roles")} + campaign_columns = {column["name"] for column in inspector.get_columns("campaigns")} + audit_columns = {column["name"] for column in inspector.get_columns("audit_log")} + audit_indexes = {index["name"] for index in inspector.get_indexes("audit_log")} + system_role_flags = { + row["slug"]: bool(row["is_builtin"]) + for row in connection.execute(text( + "SELECT slug, is_builtin FROM roles WHERE tenant_id IS NULL AND slug IN ('system_owner', 'system_admin', 'system_auditor')" + )).mappings().all() + } + system_settings_columns = {column["name"] for column in inspector.get_columns("system_settings")} + governance_assignment_columns = {column["name"] for column in inspector.get_columns("governance_template_assignments")} + account_count = connection.execute(text("SELECT COUNT(*) FROM accounts")).scalar_one() + user_count = connection.execute(text("SELECT COUNT(*) FROM users")).scalar_one() + system_owner_count = connection.execute(text( + """ + SELECT COUNT(*) + FROM system_role_assignments assignment + JOIN roles role ON role.id = assignment.role_id + JOIN accounts account ON account.id = assignment.account_id + WHERE role.slug = 'system_owner' AND account.is_active = 1 + """ + )).scalar_one() + self.assertEqual(current, result.current_revision) + self.assertIn("user_lock_state", columns) + self.assertIn("user_locked_at", columns) + self.assertIn("user_locked_by_user_id", columns) + self.assertIn("uq_file_folders_active_user_path", folder_indexes) + self.assertIn("uq_file_folders_active_group_path", folder_indexes) + self.assertIn("execution_snapshot", columns) + self.assertIn("execution_snapshot_hash", columns) + self.assertIn("claimed_at", job_columns) + self.assertIn("claim_token", job_columns) + self.assertIn("smtp_started_at", job_columns) + self.assertIn("outcome_unknown_at", job_columns) + self.assertIn("eml_sha256", job_columns) + self.assertIn("ix_campaign_jobs_claim_token", job_indexes) + self.assertIn("ix_campaign_jobs_eml_sha256", job_indexes) + self.assertIn("status", attempt_columns) + self.assertIn("claim_token", attempt_columns) + self.assertIn("accounts", tables) + self.assertIn("system_role_assignments", tables) + self.assertIn("system_settings", tables) + self.assertIn("governance_templates", tables) + self.assertIn("governance_template_assignments", tables) + self.assertIn("campaign_shares", tables) + self.assertIn("owner_user_id", campaign_columns) + self.assertIn("owner_group_id", campaign_columns) + self.assertIn("scope", audit_columns) + self.assertIn("ix_audit_log_scope", audit_indexes) + self.assertIn("ix_audit_log_scope_created_at", audit_indexes) + self.assertIn("ix_audit_log_tenant_scope_created_at", audit_indexes) + self.assertTrue(system_role_flags["system_owner"]) + self.assertFalse(system_role_flags["system_admin"]) + self.assertFalse(system_role_flags["system_auditor"]) + self.assertIn("allow_custom_groups", tenant_columns) + self.assertIn("allow_custom_roles", tenant_columns) + self.assertIn("allow_api_keys", tenant_columns) + self.assertIn("description", tenant_columns) + self.assertIn("default_locale", tenant_columns) + self.assertIn("settings", tenant_columns) + self.assertIn("settings", user_columns) + self.assertIn("settings", group_columns) + self.assertIn("settings", campaign_columns) + self.assertIn("account_id", user_columns) + self.assertIn("account_id", session_columns) + self.assertIn("description", group_columns) + self.assertIn("is_active", group_columns) + self.assertIn("system_template_id", group_columns) + self.assertIn("system_required", group_columns) + self.assertIn("description", role_columns) + self.assertIn("is_builtin", role_columns) + self.assertIn("is_assignable", role_columns) + self.assertIn("system_template_id", role_columns) + self.assertIn("system_required", role_columns) + self.assertIn("allow_tenant_custom_groups", system_settings_columns) + self.assertIn("allow_tenant_custom_roles", system_settings_columns) + self.assertIn("allow_tenant_api_keys", system_settings_columns) + self.assertIn("mode", governance_assignment_columns) + self.assertIn("uq_roles_system_slug", role_indexes) + self.assertEqual(account_count, user_count) + self.assertEqual(system_owner_count, 1 if user_count else 0) + finally: + engine.dispose() + def test_migrates_legacy_login_identity_and_bootstraps_system_owner(self) -> None: + with tempfile.TemporaryDirectory(prefix="msm-account-migration-test-") as directory: + database = Path(directory) / "accounts.db" + url = f"sqlite:///{database}" + command.upgrade(alembic_config(database_url=url), "7b8c9d0e1f2a") + + engine = create_engine(url) + try: + with engine.begin() as connection: + timestamps = {"created_at": "2026-06-14 12:00:00", "updated_at": "2026-06-14 12:00:00"} + connection.execute(text( + """ + INSERT INTO tenants (id, slug, name, is_active, created_at, updated_at) + VALUES ('tenant-1', 'legacy', 'Legacy', 1, :created_at, :updated_at) + """ + ), timestamps) + connection.execute(text( + """ + INSERT INTO users + (id, tenant_id, email, display_name, is_active, is_tenant_admin, + auth_provider, password_hash, last_login_at, created_at, updated_at) + VALUES + ('user-1', 'tenant-1', 'Owner@Example.Local', 'Legacy Owner', 1, 1, + 'local', 'legacy-password-hash', NULL, :created_at, :updated_at) + """ + ), timestamps) + connection.execute(text( + """ + INSERT INTO auth_sessions + (id, tenant_id, user_id, token_hash, expires_at, last_seen_at, revoked_at, + user_agent, ip_address, created_at, updated_at) + VALUES + ('session-1', 'tenant-1', 'user-1', 'token-hash', '2026-06-15 12:00:00', + NULL, NULL, NULL, NULL, :created_at, :updated_at) + """ + ), timestamps) + finally: + engine.dispose() + + migrate_database(database_url=url, reconcile_legacy_schema=False) + + engine = create_engine(url) + try: + with engine.connect() as connection: + account = connection.execute(text( + "SELECT id, normalized_email, password_hash FROM accounts" + )).mappings().one() + membership = connection.execute(text( + "SELECT account_id FROM users WHERE id = 'user-1'" + )).mappings().one() + migrated_session = connection.execute(text( + "SELECT account_id FROM auth_sessions WHERE id = 'session-1'" + )).mappings().one() + owner_assignment = connection.execute(text( + """ + SELECT role.slug + FROM user_role_assignments assignment + JOIN roles role ON role.id = assignment.role_id + WHERE assignment.user_id = 'user-1' + """ + )).scalar_one() + owner_permissions = connection.execute(text( + "SELECT permissions FROM roles WHERE tenant_id = 'tenant-1' AND slug = 'owner'" + )).scalar_one() + system_assignment = connection.execute(text( + """ + SELECT role.slug + FROM system_role_assignments assignment + JOIN roles role ON role.id = assignment.role_id + WHERE assignment.account_id = :account_id + """ + ), {"account_id": account["id"]}).scalar_one() + system_role_flags = { + row["slug"]: bool(row["is_builtin"]) + for row in connection.execute(text( + "SELECT slug, is_builtin FROM roles WHERE tenant_id IS NULL AND slug IN ('system_owner', 'system_admin', 'system_auditor')" + )).mappings().all() + } + self.assertEqual(account["normalized_email"], "owner@example.local") + self.assertEqual(account["password_hash"], "legacy-password-hash") + self.assertEqual(membership["account_id"], account["id"]) + self.assertEqual(migrated_session["account_id"], account["id"]) + self.assertEqual(owner_assignment, "owner") + self.assertIn("tenant:*", owner_permissions) + self.assertEqual(system_assignment, "system_owner") + self.assertTrue(system_role_flags["system_owner"]) + self.assertFalse(system_role_flags["system_admin"]) + self.assertFalse(system_role_flags["system_auditor"]) + finally: + engine.dispose() + + def test_backfills_explicit_system_and_tenant_audit_scopes(self) -> None: + with tempfile.TemporaryDirectory(prefix="msm-audit-scope-migration-test-") as directory: + database = Path(directory) / "audit-scope.db" + url = f"sqlite:///{database}" + command.upgrade(alembic_config(database_url=url), "a0b1c2d3e4f5") + + engine = create_engine(url) + try: + with engine.begin() as connection: + timestamps = { + "created_at": "2026-06-15 12:00:00", + "updated_at": "2026-06-15 12:00:00", + } + connection.execute(text( + """ + INSERT INTO audit_log + (id, tenant_id, user_id, api_key_id, action, object_type, object_id, details, created_at, updated_at) + VALUES + ('audit-system', NULL, NULL, NULL, 'system_settings.updated', 'system_settings', 'system', '{}', :created_at, :updated_at), + ('audit-tenant', NULL, NULL, NULL, 'user.updated', 'user', 'user-1', '{}', :created_at, :updated_at) + """ + ), timestamps) + finally: + engine.dispose() + + migrate_database(database_url=url, reconcile_legacy_schema=False) + + engine = create_engine(url) + try: + with engine.connect() as connection: + scopes = dict(connection.execute(text( + "SELECT id, scope FROM audit_log WHERE id IN ('audit-system', 'audit-tenant')" + )).all()) + self.assertEqual(scopes["audit-system"], "system") + self.assertEqual(scopes["audit-tenant"], "tenant") + finally: + engine.dispose() + + def test_deduplicates_active_folder_paths_before_unique_indexes(self) -> None: + with tempfile.TemporaryDirectory(prefix="msm-folder-migration-test-") as directory: + database = Path(directory) / "duplicates.db" + url = f"sqlite:///{database}" + command.upgrade(alembic_config(database_url=url), "5f6a7b8c9d0e") + + engine = create_engine(url) + try: + with engine.begin() as connection: + parameters = { + "tenant_id": "tenant-1", + "owner_user_id": "user-1", + "path": "archive", + "created_at": "2026-06-13 20:00:00", + "updated_at": "2026-06-13 20:00:00", + } + connection.execute(text( + """ + INSERT INTO file_folders + (id, tenant_id, owner_type, owner_user_id, owner_group_id, path, + created_by_user_id, deleted_at, metadata, created_at, updated_at) + VALUES + ('folder-1', :tenant_id, 'user', :owner_user_id, NULL, :path, + NULL, NULL, '{}', :created_at, :updated_at), + ('folder-2', :tenant_id, 'user', :owner_user_id, NULL, :path, + NULL, NULL, '{}', :created_at, :updated_at) + """ + ), parameters) + finally: + engine.dispose() + + migrate_database(database_url=url, reconcile_legacy_schema=False) + + engine = create_engine(url) + try: + with engine.connect() as connection: + active_count = connection.execute(text( + "SELECT COUNT(*) FROM file_folders WHERE path = 'archive' AND deleted_at IS NULL" + )).scalar_one() + deleted_count = connection.execute(text( + "SELECT COUNT(*) FROM file_folders WHERE path = 'archive' AND deleted_at IS NOT NULL" + )).scalar_one() + folder_indexes = {index["name"] for index in inspect(connection).get_indexes("file_folders")} + self.assertEqual(active_count, 1) + self.assertEqual(deleted_count, 1) + self.assertIn("uq_file_folders_active_user_path", folder_indexes) + self.assertIn("uq_file_folders_active_group_path", folder_indexes) + finally: + engine.dispose() + diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 0000000..e21c903 --- /dev/null +++ b/webui/index.html @@ -0,0 +1,12 @@ + + + + + + GovOPlaN + + +
+ + + diff --git a/webui/package-lock.json b/webui/package-lock.json new file mode 100644 index 0000000..a534678 --- /dev/null +++ b/webui/package-lock.json @@ -0,0 +1,1999 @@ +{ + "name": "@govoplan/core-webui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@govoplan/core-webui", + "version": "0.1.0", + "dependencies": { + "@govoplan/campaign-webui": "file:../../govoplan-campaign/webui", + "@govoplan/files-webui": "file:../../govoplan-files/webui", + "@govoplan/mail-webui": "file:../../govoplan-mail/webui" + }, + "devDependencies": { + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^4.3.4", + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.6" + }, + "peerDependencies": { + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + } + }, + "../../govoplan-campaign/webui": { + "name": "@govoplan/campaign-webui", + "version": "0.1.0", + "dependencies": { + "@govoplan/files-webui": "file:../../govoplan-files/webui", + "@govoplan/mail-webui": "file:../../govoplan-mail/webui" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.0", + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + } + }, + "../../govoplan-files/webui": { + "name": "@govoplan/files-webui", + "version": "0.1.0", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.0", + "@vitejs/plugin-react": "^4.3.4", + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.6" + } + }, + "../../govoplan-mail/webui": { + "name": "@govoplan/mail-webui", + "version": "0.1.0", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.0", + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@govoplan/campaign-webui": { + "resolved": "../../govoplan-campaign/webui", + "link": true + }, + "node_modules/@govoplan/files-webui": { + "resolved": "../../govoplan-files/webui", + "link": true + }, + "node_modules/@govoplan/mail-webui": { + "resolved": "../../govoplan-mail/webui", + "link": true + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.377", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.377.tgz", + "integrity": "sha512-cH1jZgJHoezfTnKfKwnScpHywTFVnJUNITDPREFdhNjiuD502+QFpG0Qk7G8jhsV/f+CEAFlIrzP1fT+IMb92g==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.555.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz", + "integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", + "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-router": "7.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..2effe26 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,49 @@ +{ + "name": "@govoplan/core-webui", + "version": "0.1.0", + "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" + }, + "./app": { + "types": "./src/app.ts", + "import": "./src/app.ts" + } + }, + "scripts": { + "dev": "vite --host 127.0.0.1 --port 5173", + "build": "tsc && vite build", + "preview": "vite preview --host 127.0.0.1 --port 4173" + }, + "dependencies": { + "@govoplan/files-webui": "file:../../govoplan-files/webui", + "@govoplan/campaign-webui": "file:../../govoplan-campaign/webui", + "@govoplan/mail-webui": "file:../../govoplan-mail/webui" + }, + "devDependencies": { + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.6" + }, + "peerDependencies": { + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "allowScripts": { + "esbuild@0.25.12": true + } +} diff --git a/webui/src/App.tsx b/webui/src/App.tsx new file mode 100644 index 0000000..f463dbc --- /dev/null +++ b/webui/src/App.tsx @@ -0,0 +1,205 @@ +import { Navigate, Route, Routes, useParams } from "react-router-dom"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { fetchMe } from "./api/auth"; +import { fetchPlatformModules } from "./api/platform"; +import { loadApiSettings, saveApiSettings } from "./api/client"; +import type { ApiSettings, AuthInfo, LoginResponse, PlatformModuleInfo } from "./types"; +import AppShell from "./layout/AppShell"; +import PublicLandingPage from "./features/auth/PublicLandingPage"; +import { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary"; +import { adminReadScopes } from "./utils/permissions"; +import { firstAccessibleRoute, navItemsForModules, resolveInstalledWebModules } from "./platform/modules"; +import PlaceholderPage from "./features/PlaceholderPage"; +import { getCampaign } from "@govoplan/campaign-webui"; + +const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage")); +const CampaignListPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.CampaignListPage }))); +const CampaignWorkspace = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.CampaignWorkspace }))); +const SettingsPage = lazy(() => import("./features/settings/SettingsPage")); +const AdminPage = lazy(() => import("./features/admin/AdminPage")); +const TemplatesPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.TemplatesPage }))); +const FilesPage = lazy(() => import("@govoplan/files-webui").then((module) => ({ default: module.FilesPage }))); +const OperatorQueuePage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.OperatorQueuePage }))); +const AddressBookPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.AddressBookPage }))); + +export default function App() { + const [settings, setSettings] = useState(() => loadApiSettings()); + const [auth, setAuth] = useState(null); + const [checkingSession, setCheckingSession] = useState(true); + const [platformModules, setPlatformModules] = useState(null); + + const webModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]); + const navItems = useMemo(() => navItemsForModules(webModules), [webModules]); + + function updateSettings(next: ApiSettings) { + setSettings(next); + saveApiSettings(next); + } + + function updateAuth(next: AuthInfo | null, accessToken?: string) { + const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings; + setAuth(next); + if (accessToken !== undefined) { + setSettings(nextSettings); + saveApiSettings(nextSettings); + } + } + + function handlePublicLogin(response: LoginResponse) { + const active = response.active_tenant ?? response.tenant; + updateAuth( + { + user: response.user, + tenant: active, + active_tenant: active, + tenants: response.tenants ?? [active], + scopes: response.scopes, + roles: response.roles, + groups: response.groups + }, + "" + ); + } + + useEffect(() => { + let cancelled = false; + setCheckingSession(true); + fetchMe(settings) + .then((me) => { if (!cancelled) setAuth(me); }) + .catch(() => { + if (!cancelled) { + const cleared = { ...settings, accessToken: "" }; + setSettings(cleared); + saveApiSettings(cleared); + setAuth(null); + setPlatformModules(null); + } + }) + .finally(() => { + if (!cancelled) setCheckingSession(false); + }); + return () => { cancelled = true; }; + }, [settings.apiBaseUrl, settings.apiKey]); + + useEffect(() => { + if (!auth) return; + + let cancelled = false; + fetchPlatformModules(settings) + .then((response) => { if (!cancelled) setPlatformModules(response.modules); }) + .catch(() => { if (!cancelled) setPlatformModules(null); }); + + return () => { cancelled = true; }; + }, [auth?.user.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]); + + useEffect(() => { + if (!auth) return; + + let inFlight = false; + let lastRefreshAt = 0; + + async function refreshVisibleSession() { + if (document.visibilityState === "hidden" || inFlight) return; + const now = Date.now(); + if (now - lastRefreshAt < 5_000) return; + + inFlight = true; + lastRefreshAt = now; + try { + setAuth(await fetchMe(settings)); + } catch { + // A background refresh must not log the user out on a transient network error. + } finally { + inFlight = false; + } + } + + window.addEventListener("focus", refreshVisibleSession); + document.addEventListener("visibilitychange", refreshVisibleSession); + return () => { + window.removeEventListener("focus", refreshVisibleSession); + document.removeEventListener("visibilitychange", refreshVisibleSession); + }; + }, [auth?.user.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]); + + if (checkingSession) { + return ( + +
+
+
GovOPlaN
+

Checking session...

+

Please wait while the local session is verified.

+
+
+
+ ); + } + + if (!auth) { + return ( + + + + ); + } + + const defaultRoute = firstAccessibleRoute(auth, webModules); + + return ( + +

Loading module...

}> + + } /> + } /> + + + + } /> + + + + } /> + } /> + + + + } /> + } /> + + + + } /> + + + + } /> + } /> + + + + } /> + } /> + +
+
+ ); +} + +function CampaignResourceRoute({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { + const { campaignId = "" } = useParams(); + const tenantId = (auth.active_tenant ?? auth.tenant).id; + const probe = useCallback(() => getCampaign(settings, campaignId), [settings.apiBaseUrl, settings.apiKey, campaignId, tenantId]); + + return ( + + + + ); +} diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts new file mode 100644 index 0000000..2dccc16 --- /dev/null +++ b/webui/src/api/admin.ts @@ -0,0 +1,519 @@ +import type { ApiSettings } from "../types"; +import { apiFetch } from "./client"; + +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 PrivacyRetentionPolicyScopeResponse = { + scope_type: PrivacyRetentionPolicyScope; + scope_id?: string | null; + policy: PrivacyRetentionPolicyPatch; + effective_policy: PrivacyRetentionPolicy; + parent_policy?: PrivacyRetentionPolicy | null; +}; + +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 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 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/api/auth.ts b/webui/src/api/auth.ts new file mode 100644 index 0000000..9a441c5 --- /dev/null +++ b/webui/src/api/auth.ts @@ -0,0 +1,37 @@ +import type { ApiSettings, AuthInfo, LoginResponse } from "../types"; +import { apiFetch } from "./client"; + +export async function login( + settings: ApiSettings, + payload: { email: string; password: string } +): Promise { + return apiFetch({ ...settings, accessToken: "", apiKey: "" }, "/api/v1/auth/login", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function fetchMe(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/auth/me"); +} + +export async function switchTenant(settings: ApiSettings, tenantId: string): Promise { + return apiFetch(settings, "/api/v1/auth/switch-tenant", { + method: "POST", + body: JSON.stringify({ tenant_id: tenantId }) + }); +} + +export async function logout(settings: ApiSettings): Promise { + await apiFetch(settings, "/api/v1/auth/logout", { method: "POST" }); +} + +export async function updateProfile( + settings: ApiSettings, + payload: { display_name?: string | null; tenant_display_name?: string | null } +): Promise { + return apiFetch(settings, "/api/v1/auth/profile", { + method: "PATCH", + body: JSON.stringify(payload) + }); +} diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts new file mode 100644 index 0000000..080de25 --- /dev/null +++ b/webui/src/api/client.ts @@ -0,0 +1,155 @@ +import type { ApiSettings } from "../types"; + +const STORAGE_KEY = "multimailer.apiSettings"; +const SESSION_STORAGE_KEY = "multimailer.session"; +const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf"; + +export class ApiError extends Error { + readonly status: number; + readonly statusText: string; + readonly body: string; + + constructor(status: number, statusText: string, body: string) { + super(`${status} ${statusText}: ${body}`); + this.name = "ApiError"; + this.status = status; + this.statusText = statusText; + this.body = body; + } +} + +export function isApiError(error: unknown, ...statuses: number[]): error is ApiError { + return error instanceof ApiError && (statuses.length === 0 || statuses.includes(error.status)); +} + +/** + * API endpoint helpers already pass paths beginning with /api/v1. The configured + * value is therefore an origin only. Normalize legacy values that included the + * API prefix so old localStorage settings cannot produce /api/v1/api/v1 URLs. + */ +export function normalizeApiBaseUrl(value: string): string { + const withoutTrailingSlash = value.trim().replace(/\/+$/, ""); + return withoutTrailingSlash.replace(/\/api(?:\/v1)?$/i, ""); +} + +export function apiUrl(settings: ApiSettings, path: string): string { + const baseUrl = normalizeApiBaseUrl(settings.apiBaseUrl); + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath; +} + +export function loadApiSettings(): ApiSettings { + const storedBaseUrl = localStorage.getItem(`${STORAGE_KEY}.baseUrl`); + const configuredBaseUrl = storedBaseUrl !== null ? storedBaseUrl : import.meta.env.VITE_API_BASE_URL ?? ""; + const apiBaseUrl = normalizeApiBaseUrl(configuredBaseUrl); + + // Repair legacy values once loaded, while keeping an empty value for same-origin use. + if (storedBaseUrl !== null && storedBaseUrl !== apiBaseUrl) { + localStorage.setItem(`${STORAGE_KEY}.baseUrl`, apiBaseUrl); + } + + localStorage.removeItem(`${STORAGE_KEY}.accessToken`); + sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`); + + return { + // Empty base URL means "same origin". In Vite dev, /api is proxied to FastAPI. + apiBaseUrl, + apiKey: localStorage.getItem(`${STORAGE_KEY}.apiKey`) || "", + accessToken: "" + }; +} + +export function saveApiSettings(settings: ApiSettings): void { + localStorage.setItem(`${STORAGE_KEY}.baseUrl`, normalizeApiBaseUrl(settings.apiBaseUrl)); + localStorage.setItem(`${STORAGE_KEY}.apiKey`, settings.apiKey); + localStorage.removeItem(`${STORAGE_KEY}.accessToken`); + sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`); +} + +export function clearAccessToken(): void { + sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`); + localStorage.removeItem(`${STORAGE_KEY}.accessToken`); +} + + +function readCookie(name: string): string { + const prefix = `${encodeURIComponent(name)}=`; + return document.cookie + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix)) + ?.slice(prefix.length) ?? ""; +} + +export function csrfToken(): string { + const value = readCookie(CSRF_COOKIE_NAME); + return value ? decodeURIComponent(value) : ""; +} + +function isUnsafeMethod(method?: string): boolean { + const normalized = (method || "GET").toUpperCase(); + return !["GET", "HEAD", "OPTIONS", "TRACE"].includes(normalized); +} + +export function authHeaders(settings: ApiSettings): Headers { + const headers = new Headers(); + if (settings.accessToken) { + headers.set("Authorization", `Bearer ${settings.accessToken}`); + } else if (settings.apiKey) { + headers.set("X-API-Key", settings.apiKey); + } + return headers; +} + +export async function apiFetch(settings: ApiSettings, path: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers || {}); + + if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + for (const [key, value] of authHeaders(settings)) { + headers.set(key, value); + } + + const csrf = csrfToken(); + if (csrf && isUnsafeMethod(init?.method) && !headers.has("X-CSRF-Token")) { + headers.set("X-CSRF-Token", csrf); + } + + const response = await fetch(apiUrl(settings, path), { ...init, headers, credentials: init?.credentials ?? "include" }); + + if (!response.ok) { + const text = await response.text(); + throw new ApiError(response.status, response.statusText, text); + } + + if (response.status === 204) { + return undefined as T; + } + + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("application/json")) { + return (await response.text()) as T; + } + + return (await response.json()) as T; +} + + +export async function apiDownload(settings: ApiSettings, path: string, filename: string): Promise { + const response = await fetch(apiUrl(settings, path), { headers: authHeaders(settings), credentials: "include" }); + if (!response.ok) { + const text = await response.text(); + throw new ApiError(response.status, response.statusText, text); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} diff --git a/webui/src/api/platform.ts b/webui/src/api/platform.ts new file mode 100644 index 0000000..2a3de55 --- /dev/null +++ b/webui/src/api/platform.ts @@ -0,0 +1,27 @@ +import type { ApiSettings } from "../types"; +import type { PlatformModuleInfo } from "../types"; +import { apiFetch } from "./client"; + +export type PlatformModulesResponse = { modules: PlatformModuleInfo[] }; + +export type PlatformPermission = { + scope: string; + module_id: string; + resource: string; + action: string; + label: string; + description: string; + category: string; + level: "system" | "tenant"; + deprecated: boolean; +}; + +export type PlatformPermissionsResponse = { permissions: PlatformPermission[] }; + +export async function fetchPlatformModules(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/platform/modules"); +} + +export async function fetchPlatformPermissions(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/platform/permissions"); +} diff --git a/webui/src/app.ts b/webui/src/app.ts new file mode 100644 index 0000000..5ce8158 --- /dev/null +++ b/webui/src/app.ts @@ -0,0 +1,13 @@ +import "./styles/tokens.css"; +import "./styles/layout.css"; +import "./styles/forms.css"; +import "./styles/tables.css"; +import "./styles/badges.css"; +import "./styles/components.css"; +import "./styles/dialogs.css"; +import "./styles/retention-policies.css"; +import "./styles/auth-gate.css"; +import "@govoplan/campaign-webui/styles/campaign-workspace.css"; +import "@govoplan/files-webui/styles/file-manager.css"; +import "@govoplan/mail-webui/styles/mail-profiles.css"; +export { default, default as GovoplanApp } from "./App"; diff --git a/webui/src/components/AccessBoundary.tsx b/webui/src/components/AccessBoundary.tsx new file mode 100644 index 0000000..35bccc6 --- /dev/null +++ b/webui/src/components/AccessBoundary.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import type { AuthInfo } from "../types"; +import { isApiError } from "../api/client"; +import DismissibleAlert from "./DismissibleAlert"; +import { hasAnyScope } from "../utils/permissions"; + +type PermissionBoundaryProps = { + auth: AuthInfo; + anyOf: string[]; + fallback: string; + children: ReactNode; +}; + +export function PermissionBoundary({ auth, anyOf, fallback, children }: PermissionBoundaryProps) { + return hasAnyScope(auth, anyOf) ? <>{children} : ; +} + +type ResourceAccessBoundaryProps = { + probe: () => Promise; + resetKey: string; + fallback: string; + children: ReactNode; +}; + +/** + * Keeps resource routes tenant-agnostic. After a tenant switch the current URL + * is probed in the new tenant context; inaccessible or missing resources fall + * back to their parent collection instead of requiring switch-specific routing. + */ +export function ResourceAccessBoundary({ probe, resetKey, fallback, children }: ResourceAccessBoundaryProps) { + const [state, setState] = useState<"checking" | "allowed" | "denied" | "error">("checking"); + const [message, setMessage] = useState(""); + + useEffect(() => { + let cancelled = false; + setState("checking"); + setMessage(""); + probe() + .then(() => { if (!cancelled) setState("allowed"); }) + .catch((error) => { + if (cancelled) return; + if (isApiError(error, 403, 404)) { + setState("denied"); + return; + } + setMessage(error instanceof Error ? error.message : String(error)); + setState("error"); + }); + return () => { cancelled = true; }; + }, [probe, resetKey]); + + if (state === "denied") return ; + if (state === "error") return
{message || "The resource could not be loaded."}
; + if (state === "checking") return

Checking access…

; + return <>{children}; +} diff --git a/webui/src/components/Button.tsx b/webui/src/components/Button.tsx new file mode 100644 index 0000000..5ea6811 --- /dev/null +++ b/webui/src/components/Button.tsx @@ -0,0 +1,5 @@ +type Props = React.ButtonHTMLAttributes & { variant?: "primary" | "secondary" | "ghost" | "danger" }; + +export default function Button({ variant = "secondary", className = "", ...props }: Props) { + return + )} + + )} + + )} + {shouldRenderBody && (collapsible ?
{body}
: body)} + + ); +} diff --git a/webui/src/components/ConfirmDialog.tsx b/webui/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..8efe008 --- /dev/null +++ b/webui/src/components/ConfirmDialog.tsx @@ -0,0 +1,50 @@ +import Button from "./Button"; +import Dialog from "./Dialog"; + +export type ConfirmDialogTone = "default" | "danger"; + +export type ConfirmDialogProps = { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + tone?: ConfirmDialogTone; + busy?: boolean; + onConfirm: () => void; + onCancel: () => void; +}; + +export default function ConfirmDialog({ + open, + title, + message, + confirmLabel = "Confirm", + cancelLabel = "Cancel", + tone = "default", + busy = false, + onConfirm, + onCancel +}: ConfirmDialogProps) { + return ( + + + + + )} + > +

{message}

+
+ ); +} diff --git a/webui/src/components/Dialog.tsx b/webui/src/components/Dialog.tsx new file mode 100644 index 0000000..cc16866 --- /dev/null +++ b/webui/src/components/Dialog.tsx @@ -0,0 +1,96 @@ +import { useEffect, useId, type ReactNode } from "react"; + +export type DialogProps = { + open: boolean; + title: ReactNode; + children: ReactNode; + footer?: ReactNode; + role?: "dialog" | "alertdialog"; + ariaDescribedBy?: string; + closeLabel?: string; + closeOnBackdrop?: boolean; + showCloseButton?: boolean; + closeDisabled?: boolean; + onClose?: () => void; + className?: string; + backdropClassName?: string; + headerClassName?: string; + titleClassName?: string; + bodyClassName?: string; + footerClassName?: string; +}; + +function joinClasses(...classes: Array) { + return classes.filter(Boolean).join(" "); +} + +export default function Dialog({ + open, + title, + children, + footer, + role = "dialog", + ariaDescribedBy, + closeLabel = "Close", + closeOnBackdrop = true, + showCloseButton = true, + closeDisabled = false, + onClose, + className = "", + backdropClassName = "", + headerClassName = "", + titleClassName = "", + bodyClassName = "", + footerClassName = "" +}: DialogProps) { + const titleId = useId(); + const canClose = Boolean(onClose) && !closeDisabled; + + useEffect(() => { + if (!open || !canClose) return undefined; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose?.(); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [canClose, onClose, open]); + + if (!open) return null; + + return ( +
{ + if (closeOnBackdrop && canClose && event.target === event.currentTarget) onClose?.(); + }} + > +
+
+

{title}

+ {showCloseButton && onClose && ( + + )} +
+
{children}
+ {footer &&
{footer}
} +
+
+ ); +} diff --git a/webui/src/components/DismissibleAlert.tsx b/webui/src/components/DismissibleAlert.tsx new file mode 100644 index 0000000..f77cf57 --- /dev/null +++ b/webui/src/components/DismissibleAlert.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; + +type AlertTone = "success" | "info" | "warning" | "danger"; + +type DismissibleAlertProps = { + tone?: AlertTone; + children: ReactNode; + dismissible?: boolean; + className?: string; + compact?: boolean; + floating?: boolean; + resetKey?: string | number; +}; + +let floatingAlertRoot: HTMLElement | null = null; + +function getFloatingAlertRoot(): HTMLElement | null { + if (typeof document === "undefined") return null; + if (floatingAlertRoot?.isConnected) return floatingAlertRoot; + + const existing = document.getElementById("app-floating-alerts"); + if (existing) { + floatingAlertRoot = existing; + return floatingAlertRoot; + } + + floatingAlertRoot = document.createElement("div"); + floatingAlertRoot.id = "app-floating-alerts"; + floatingAlertRoot.className = "alert-floating-stack"; + floatingAlertRoot.setAttribute("aria-label", "Application notices"); + document.body.appendChild(floatingAlertRoot); + return floatingAlertRoot; +} + +export default function DismissibleAlert({ + tone = "info", + children, + dismissible = true, + className = "", + compact = false, + floating = false, + resetKey +}: DismissibleAlertProps) { + const [visible, setVisible] = useState(true); + + useEffect(() => { + setVisible(true); + }, [resetKey, children]); + + if (!visible) return null; + + const role = tone === "danger" || tone === "warning" ? "alert" : "status"; + const alert = ( +
+
{children}
+ {dismissible && ( + + )} +
+ ); + + if (!floating) return alert; + const root = getFloatingAlertRoot(); + return root ? createPortal(alert, root) : alert; +} diff --git a/webui/src/components/FormField.tsx b/webui/src/components/FormField.tsx new file mode 100644 index 0000000..f7156b5 --- /dev/null +++ b/webui/src/components/FormField.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; +import FieldLabel from "./help/FieldLabel"; +import { helpForFieldLabel } from "../utils/fieldHelp"; + +export default function FormField({ label, help, children }: { label: ReactNode; help?: ReactNode; children: ReactNode }) { + return ( + + ); +} diff --git a/webui/src/components/LoadingFrame.tsx b/webui/src/components/LoadingFrame.tsx new file mode 100644 index 0000000..faf71af --- /dev/null +++ b/webui/src/components/LoadingFrame.tsx @@ -0,0 +1,26 @@ +import LoadingIndicator from "./LoadingIndicator"; + +type LoadingFrameProps = { + children: React.ReactNode; + loading?: boolean; + label?: string; + className?: string; +}; + +export default function LoadingFrame({ children, loading = false, label = "Loading data…", className = "" }: LoadingFrameProps) { + const classNames = ["loading-frame", loading ? "is-loading" : "", className].filter(Boolean).join(" "); + + return ( +
+ {children} + {loading && ( +
+
+ + {label} +
+
+ )} +
+ ); +} diff --git a/webui/src/components/LoadingIndicator.tsx b/webui/src/components/LoadingIndicator.tsx new file mode 100644 index 0000000..fcc905b --- /dev/null +++ b/webui/src/components/LoadingIndicator.tsx @@ -0,0 +1,12 @@ +type LoadingIndicatorProps = { + label?: string; + size?: "sm" | "md"; +}; + +export default function LoadingIndicator({ label = "Loading", size = "sm" }: LoadingIndicatorProps) { + return ( + + + ); +} diff --git a/webui/src/components/MetricCard.tsx b/webui/src/components/MetricCard.tsx new file mode 100644 index 0000000..b8293d1 --- /dev/null +++ b/webui/src/components/MetricCard.tsx @@ -0,0 +1,9 @@ +export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) { + return ( +
+
{label}
+
{value}
+ {detail &&
{detail}
} +
+ ); +} diff --git a/webui/src/components/PageTitle.tsx b/webui/src/components/PageTitle.tsx new file mode 100644 index 0000000..af26dbe --- /dev/null +++ b/webui/src/components/PageTitle.tsx @@ -0,0 +1,15 @@ +import LoadingIndicator from "./LoadingIndicator"; + +type PageTitleProps = { + children: React.ReactNode; + loading?: boolean; +}; + +export default function PageTitle({ children, loading = false }: PageTitleProps) { + return ( +

+ {children} + {loading && } +

+ ); +} diff --git a/webui/src/components/StatusBadge.tsx b/webui/src/components/StatusBadge.tsx new file mode 100644 index 0000000..69efcd3 --- /dev/null +++ b/webui/src/components/StatusBadge.tsx @@ -0,0 +1,3 @@ +export default function StatusBadge({ status, label }: { status: string; label?: string }) { + return {label ?? status}; +} diff --git a/webui/src/components/Stepper.tsx b/webui/src/components/Stepper.tsx new file mode 100644 index 0000000..d726794 --- /dev/null +++ b/webui/src/components/Stepper.tsx @@ -0,0 +1,19 @@ +import type { WizardStep } from "../types"; + +export default function Stepper({ steps, activeStep, onSelect }: { steps: WizardStep[]; activeStep: string; onSelect: (id: string) => void }) { + return ( +
    + {steps.map((step, index) => ( +
  1. + +
  2. + ))} +
+ ); +} diff --git a/webui/src/components/ToggleSwitch.tsx b/webui/src/components/ToggleSwitch.tsx new file mode 100644 index 0000000..9809b27 --- /dev/null +++ b/webui/src/components/ToggleSwitch.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; +import FieldLabel from "./help/FieldLabel"; +import { helpForFieldLabel } from "../utils/fieldHelp"; + +type ToggleSwitchProps = { + label: ReactNode; + checked: boolean; + onChange?: (checked: boolean) => void; + disabled?: boolean; + help?: ReactNode; +}; + +export default function ToggleSwitch({ label, checked, onChange, disabled = false, help }: ToggleSwitchProps) { + return ( + + ); +} diff --git a/webui/src/components/email/EmailAddressInput.tsx b/webui/src/components/email/EmailAddressInput.tsx new file mode 100644 index 0000000..c9aafb1 --- /dev/null +++ b/webui/src/components/email/EmailAddressInput.tsx @@ -0,0 +1,246 @@ +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import type { CSSProperties, KeyboardEvent } from "react"; +import { createPortal } from "react-dom"; +import Button from "../Button"; +import { + addressDisplayName, + dedupeAddresses, + isValidEmailAddress, + normalizeEmailAddress, + parseMailboxAddressText, + type MailboxAddress +} from "../../utils/emailAddresses"; + +type EmailAddressInputProps = { + value: MailboxAddress[]; + onChange?: (value: MailboxAddress[]) => void; + onAddressAdded?: (address: MailboxAddress) => void; + suggestions?: MailboxAddress[]; + allowMultiple?: boolean; + clearOnAdd?: boolean; + disabled?: boolean; + addLabel?: string; + namePlaceholder?: string; + emailPlaceholder?: string; + emptyText?: string; + compact?: boolean; + showAddButton?: boolean; +}; + +export default function EmailAddressInput({ + value, + onChange, + onAddressAdded, + suggestions = [], + allowMultiple = true, + clearOnAdd = false, + disabled = false, + addLabel = "Add", + namePlaceholder = "Name", + emailPlaceholder = "email@example.org", + emptyText = "No address added yet.", + compact = false, + showAddButton +}: EmailAddressInputProps) { + const inputId = useId(); + const normalizedValue = useMemo(() => dedupeAddresses(value), [value]); + const normalizedSuggestions = useMemo(() => dedupeAddresses(suggestions), [suggestions]); + const [entryText, setEntryText] = useState(""); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogName, setDialogName] = useState(""); + const [dialogEmail, setDialogEmail] = useState(""); + const [error, setError] = useState(""); + const [popoverStyle, setPopoverStyle] = useState({}); + const addButtonRef = useRef(null); + const canUseAddButton = showAddButton ?? allowMultiple; + + const filteredSuggestions = useMemo(() => { + const query = entryText.trim().toLowerCase(); + if (!query) return normalizedSuggestions.slice(0, 6); + return normalizedSuggestions + .filter((item) => `${item.name ?? ""} ${item.email}`.toLowerCase().includes(query)) + .slice(0, 6); + }, [entryText, normalizedSuggestions]); + + useEffect(() => { + if (!dialogOpen) return; + + function updatePopoverPosition() { + const trigger = addButtonRef.current; + if (!trigger) return; + const rect = trigger.getBoundingClientRect(); + const viewportPadding = 16; + const width = Math.min(360, Math.max(280, window.innerWidth - viewportPadding * 2)); + const estimatedHeight = 250; + const left = Math.min(Math.max(viewportPadding, rect.right - width), window.innerWidth - width - viewportPadding); + const belowTop = rect.bottom + 8; + const aboveTop = rect.top - estimatedHeight - 8; + const top = belowTop + estimatedHeight > window.innerHeight && aboveTop > viewportPadding ? aboveTop : belowTop; + setPopoverStyle({ + position: "fixed", + top, + left, + width, + zIndex: 10000 + }); + } + + updatePopoverPosition(); + window.addEventListener("resize", updatePopoverPosition); + window.addEventListener("scroll", updatePopoverPosition, true); + return () => { + window.removeEventListener("resize", updatePopoverPosition); + window.removeEventListener("scroll", updatePopoverPosition, true); + }; + }, [dialogOpen]); + + function removeAddress(emailToRemove: string) { + if (disabled) return; + onChange?.(normalizedValue.filter((address) => address.email !== emailToRemove)); + setError(""); + } + + function commitAddress(candidate: MailboxAddress | null, sourceText = "") { + if (disabled) return false; + if (!candidate?.email) { + setError(sourceText ? "Use a valid address such as Name ." : "Enter an email address first."); + return false; + } + + const normalized = normalizeEmailAddress(candidate); + if (!isValidEmailAddress(normalized.email)) { + setError("Enter a valid email address."); + return false; + } + if (allowMultiple && normalizedValue.some((address) => address.email === normalized.email)) { + setError("This address is already listed."); + return false; + } + + onAddressAdded?.(normalized); + if (!clearOnAdd) { + const nextValue = allowMultiple ? dedupeAddresses([...normalizedValue, normalized]) : [normalized]; + onChange?.(nextValue); + } + setEntryText(""); + setDialogName(""); + setDialogEmail(""); + setDialogOpen(false); + setError(""); + return true; + } + + function commitTypedAddress() { + const text = entryText.trim(); + if (!text) return; + commitAddress(parseMailboxAddressText(text), text); + } + + function commitDialogAddress() { + commitAddress({ name: dialogName, email: dialogEmail }, `${dialogName} ${dialogEmail}`); + } + + function handleTextKeyDown(event: KeyboardEvent) { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + commitTypedAddress(); + return; + } + if (event.key === "Backspace" && !entryText && normalizedValue.length > 0 && !disabled) { + event.preventDefault(); + const last = normalizedValue[normalizedValue.length - 1]; + removeAddress(last.email); + } + } + + function applySuggestion(address: MailboxAddress) { + commitAddress(address); + } + + const addressDialog = dialogOpen && canUseAddButton && !disabled ? createPortal( +
{ + if (event.key === "Escape") setDialogOpen(false); + }} + > +

Add address

+ + +
+ + +
+
, + document.body + ) : null; + + return ( +
+
+
+ {normalizedValue.length === 0 && !entryText && {emptyText}} + {normalizedValue.map((address) => { + const valid = isValidEmailAddress(address.email); + return ( + + {addressDisplayName(address)} + {address.name && {address.email}} + {!disabled && ( + + )} + + ); + })} +
+ {!disabled && ( + <> +