initial commit after split
This commit is contained in:
138
.gitignore
vendored
138
.gitignore
vendored
@@ -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
|
# ---> Python
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -1,2 +1,32 @@
|
|||||||
# govoplan-core
|
# 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.
|
||||||
|
|||||||
38
alembic.ini
Normal file
38
alembic.ini
Normal file
@@ -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
|
||||||
61
alembic/env.py
Normal file
61
alembic/env.py
Normal file
@@ -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()
|
||||||
24
alembic/script.py.mako
Normal file
24
alembic/script.py.mako
Normal file
@@ -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"}
|
||||||
58
alembic/versions/1f8d4c2a0b7e_editable_campaign_versions.py
Normal file
58
alembic/versions/1f8d4c2a0b7e_editable_campaign_versions.py
Normal file
@@ -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")
|
||||||
130
alembic/versions/2c3d4e5f6a7b_auth_sessions_and_rbac.py
Normal file
130
alembic/versions/2c3d4e5f6a7b_auth_sessions_and_rbac.py
Normal file
@@ -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")
|
||||||
152
alembic/versions/3d4e5f6a7b8c_file_storage_backend.py
Normal file
152
alembic/versions/3d4e5f6a7b8c_file_storage_backend.py
Normal file
@@ -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")
|
||||||
45
alembic/versions/4e5f6a7b8c9d_file_folders.py
Normal file
45
alembic/versions/4e5f6a7b8c9d_file_folders.py
Normal file
@@ -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")
|
||||||
55
alembic/versions/5f6a7b8c9d0e_campaign_version_user_locks.py
Normal file
55
alembic/versions/5f6a7b8c9d0e_campaign_version_user_locks.py
Normal file
@@ -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")
|
||||||
73
alembic/versions/6a7b8c9d0e1f_file_folder_uniqueness.py
Normal file
73
alembic/versions/6a7b8c9d0e1f_file_folder_uniqueness.py
Normal file
@@ -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")
|
||||||
64
alembic/versions/7b8c9d0e1f2a_safe_delivery_execution.py
Normal file
64
alembic/versions/7b8c9d0e1f2a_safe_delivery_execution.py
Normal file
@@ -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")
|
||||||
323
alembic/versions/8c9d0e1f2a3b_accounts_tenant_admin_rbac.py
Normal file
323
alembic/versions/8c9d0e1f2a3b_accounts_tenant_admin_rbac.py
Normal file
@@ -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")
|
||||||
163
alembic/versions/9d0e1f2a3b4c_system_governance_templates.py
Normal file
163
alembic/versions/9d0e1f2a3b4c_system_governance_templates.py
Normal file
@@ -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")
|
||||||
216
alembic/versions/a0b1c2d3e4f5_permission_catalogue_refinement.py
Normal file
216
alembic/versions/a0b1c2d3e4f5_permission_catalogue_refinement.py
Normal file
@@ -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
|
||||||
@@ -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")
|
||||||
346
alembic/versions/b57c5b216bce_initial_persistence_models.py
Normal file
346
alembic/versions/b57c5b216bce_initial_persistence_models.py
Normal file
@@ -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 ###
|
||||||
42
alembic/versions/c2d3e4f5a6b7_audit_pagination_indexes.py
Normal file
42
alembic/versions/c2d3e4f5a6b7_audit_pagination_indexes.py
Normal file
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
41
alembic/versions/f5a6b7c8d9e0_hierarchical_settings.py
Normal file
41
alembic/versions/f5a6b7c8d9e0_hierarchical_settings.py
Normal file
@@ -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")
|
||||||
39
pyproject.toml
Normal file
39
pyproject.toml
Normal file
@@ -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",
|
||||||
|
]
|
||||||
5
requirements-dev.txt
Normal file
5
requirements-dev.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
-e ../govoplan-files
|
||||||
|
-e ../govoplan-mail
|
||||||
|
-e ../govoplan-campaign
|
||||||
|
httpx==0.28.1
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
-e .[server]
|
||||||
3
src/govoplan_core/__init__.py
Normal file
3
src/govoplan_core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Reusable GovOPlaN platform core components."""
|
||||||
|
|
||||||
|
__all__ = ["access", "core"]
|
||||||
2
src/govoplan_core/access/__init__.py
Normal file
2
src/govoplan_core/access/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Tenant, identity, auth, RBAC, and datastore access platform module."""
|
||||||
|
|
||||||
2
src/govoplan_core/access/auth/__init__.py
Normal file
2
src/govoplan_core/access/auth/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Authentication and principal helpers for platform access."""
|
||||||
|
|
||||||
28
src/govoplan_core/access/auth/principals.py
Normal file
28
src/govoplan_core/access/auth/principals.py
Normal file
@@ -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)
|
||||||
|
|
||||||
82
src/govoplan_core/access/auth/roles.py
Normal file
82
src/govoplan_core/access/auth/roles.py
Normal file
@@ -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)
|
||||||
|
|
||||||
21
src/govoplan_core/access/auth/tokens.py
Normal file
21
src/govoplan_core/access/auth/tokens.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_RANDOM_BYTES = 32
|
||||||
|
|
||||||
|
|
||||||
|
def generate_secret(prefix: str, *, random_bytes: int = DEFAULT_RANDOM_BYTES) -> str:
|
||||||
|
return prefix + secrets.token_urlsafe(random_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_secret(secret: str) -> str:
|
||||||
|
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_secret(secret: str, expected_hash: str) -> bool:
|
||||||
|
return hmac.compare_digest(hash_secret(secret), expected_hash)
|
||||||
|
|
||||||
2
src/govoplan_core/access/db/__init__.py
Normal file
2
src/govoplan_core/access/db/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Access-platform SQLAlchemy metadata and models."""
|
||||||
|
|
||||||
21
src/govoplan_core/access/db/base.py
Normal file
21
src/govoplan_core/access/db/base.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, MetaData
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from govoplan_core.db.base import NAMING_CONVENTION, TimestampMixin, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
class AccessBase(DeclarativeBase):
|
||||||
|
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||||
|
|
||||||
|
type_annotation_map = {
|
||||||
|
dict[str, Any]: JSON,
|
||||||
|
list[dict[str, Any]]: JSON,
|
||||||
|
list[str]: JSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AccessBase", "NAMING_CONVENTION", "TimestampMixin", "utcnow"]
|
||||||
237
src/govoplan_core/access/db/models.py
Normal file
237
src/govoplan_core/access/db/models.py
Normal file
@@ -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)
|
||||||
|
|
||||||
22
src/govoplan_core/access/db/session.py
Normal file
22
src/govoplan_core/access/db/session.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.db.session import create_database_engine
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_engine(database_url: str) -> Engine:
|
||||||
|
return create_database_engine(database_url)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_session_factory(database_url: str) -> sessionmaker[Session]:
|
||||||
|
engine = create_access_engine(database_url)
|
||||||
|
return sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
def session_scope(session_factory: sessionmaker[Session]) -> Generator[Session, None, None]:
|
||||||
|
with session_factory() as session:
|
||||||
|
yield session
|
||||||
119
src/govoplan_core/access/manifest.py
Normal file
119
src/govoplan_core/access/manifest.py
Normal file
@@ -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
|
||||||
|
|
||||||
2
src/govoplan_core/access/permissions/__init__.py
Normal file
2
src/govoplan_core/access/permissions/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Permission definitions, evaluation, and registry helpers."""
|
||||||
|
|
||||||
6
src/govoplan_core/access/permissions/definitions.py
Normal file
6
src/govoplan_core/access/permissions/definitions.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
|
||||||
|
|
||||||
|
__all__ = ["PermissionDefinition", "PermissionLevel", "RoleTemplate"]
|
||||||
|
|
||||||
52
src/govoplan_core/access/permissions/evaluator.py
Normal file
52
src/govoplan_core/access/permissions/evaluator.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import PermissionDefinition
|
||||||
|
|
||||||
|
|
||||||
|
def scope_grants(granted: str, required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
|
||||||
|
if granted == required or granted == "*":
|
||||||
|
return True
|
||||||
|
if granted == "tenant:*":
|
||||||
|
if catalog is None:
|
||||||
|
return not required.startswith("system:")
|
||||||
|
definition = catalog.get(required)
|
||||||
|
return definition is not None and definition.level == "tenant"
|
||||||
|
if granted == "system:*":
|
||||||
|
if catalog is None:
|
||||||
|
return required.startswith("system:")
|
||||||
|
definition = catalog.get(required)
|
||||||
|
return definition is not None and definition.level == "system"
|
||||||
|
if granted.endswith(":*"):
|
||||||
|
return required.startswith(granted[:-1])
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def scopes_grant(
|
||||||
|
scopes: Iterable[str],
|
||||||
|
required: str,
|
||||||
|
*,
|
||||||
|
catalog: Mapping[str, PermissionDefinition] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
return any(scope_grants(scope, required, catalog=catalog) for scope in scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def expand_scopes(
|
||||||
|
scopes: Iterable[str],
|
||||||
|
*,
|
||||||
|
catalog: Mapping[str, PermissionDefinition],
|
||||||
|
include_unknown: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
expanded: set[str] = set()
|
||||||
|
for scope in {str(scope) for scope in scopes if scope}:
|
||||||
|
matched = {candidate for candidate in catalog if scope_grants(scope, candidate, catalog=catalog)}
|
||||||
|
if matched:
|
||||||
|
expanded.update(matched)
|
||||||
|
if scope.endswith(":*") or scope in {"*", "tenant:*", "system:*"}:
|
||||||
|
expanded.add(scope)
|
||||||
|
continue
|
||||||
|
if include_unknown:
|
||||||
|
expanded.add(scope)
|
||||||
|
return sorted(expanded)
|
||||||
|
|
||||||
38
src/govoplan_core/access/permissions/registry.py
Normal file
38
src/govoplan_core/access/permissions/registry.py
Normal file
@@ -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))
|
||||||
|
|
||||||
2
src/govoplan_core/access/tenancy/__init__.py
Normal file
2
src/govoplan_core/access/tenancy/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Tenant datastore routing abstractions."""
|
||||||
|
|
||||||
54
src/govoplan_core/access/tenancy/datastore.py
Normal file
54
src/govoplan_core/access/tenancy/datastore.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal, Protocol
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
DatastoreMode = Literal["shared", "schema", "database"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TenantDatastoreRef:
|
||||||
|
tenant_id: str
|
||||||
|
mode: DatastoreMode = "shared"
|
||||||
|
datastore_id: str | None = None
|
||||||
|
schema_name: str | None = None
|
||||||
|
database_url_secret_ref: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TenantDatastore(Protocol):
|
||||||
|
ref: TenantDatastoreRef
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session_for(self, module_id: str) -> Generator[Session, None, None]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class DatastoreResolver(Protocol):
|
||||||
|
def for_tenant(self, tenant_id: str) -> TenantDatastore:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class SharedTenantDatastore:
|
||||||
|
def __init__(self, ref: TenantDatastoreRef, session_factory: sessionmaker[Session]) -> None:
|
||||||
|
self.ref = ref
|
||||||
|
self._session_factory = session_factory
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session_for(self, module_id: str) -> Generator[Session, None, None]:
|
||||||
|
del module_id
|
||||||
|
with self._session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
class SharedDatastoreResolver:
|
||||||
|
def __init__(self, session_factory: sessionmaker[Session]) -> None:
|
||||||
|
self._session_factory = session_factory
|
||||||
|
|
||||||
|
def for_tenant(self, tenant_id: str) -> TenantDatastore:
|
||||||
|
return SharedTenantDatastore(TenantDatastoreRef(tenant_id=tenant_id), self._session_factory)
|
||||||
|
|
||||||
0
src/govoplan_core/admin/__init__.py
Normal file
0
src/govoplan_core/admin/__init__.py
Normal file
313
src/govoplan_core/admin/governance.py
Normal file
313
src/govoplan_core/admin/governance.py
Normal file
@@ -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()
|
||||||
588
src/govoplan_core/admin/service.py
Normal file
588
src/govoplan_core/admin/service.py
Normal file
@@ -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 [])
|
||||||
1
src/govoplan_core/api/__init__.py
Normal file
1
src/govoplan_core/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from __future__ import annotations
|
||||||
1
src/govoplan_core/api/v1/__init__.py
Normal file
1
src/govoplan_core/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from __future__ import annotations
|
||||||
1642
src/govoplan_core/api/v1/admin.py
Normal file
1642
src/govoplan_core/api/v1/admin.py
Normal file
File diff suppressed because it is too large
Load Diff
486
src/govoplan_core/api/v1/admin_schemas.py
Normal file
486
src/govoplan_core/api/v1/admin_schemas.py
Normal file
@@ -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
|
||||||
32
src/govoplan_core/api/v1/audit.py
Normal file
32
src/govoplan_core/api/v1/audit.py
Normal file
@@ -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])
|
||||||
293
src/govoplan_core/api/v1/auth.py
Normal file
293
src/govoplan_core/api/v1/auth.py
Normal file
@@ -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}
|
||||||
111
src/govoplan_core/api/v1/schemas.py
Normal file
111
src/govoplan_core/api/v1/schemas.py
Normal file
@@ -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)
|
||||||
29
src/govoplan_core/api/v1/system.py
Normal file
29
src/govoplan_core/api/v1/system.py
Normal file
@@ -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()
|
||||||
0
src/govoplan_core/audit/__init__.py
Normal file
0
src/govoplan_core/audit/__init__.py
Normal file
99
src/govoplan_core/audit/logging.py
Normal file
99
src/govoplan_core/audit/logging.py
Normal file
@@ -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] = "<redacted>"
|
||||||
|
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,
|
||||||
|
)
|
||||||
0
src/govoplan_core/auth/__init__.py
Normal file
0
src/govoplan_core/auth/__init__.py
Normal file
223
src/govoplan_core/auth/dependencies.py
Normal file
223
src/govoplan_core/auth/dependencies.py
Normal file
@@ -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
|
||||||
63
src/govoplan_core/celery_app.py
Normal file
63
src/govoplan_core/celery_app.py
Normal file
@@ -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
|
||||||
1
src/govoplan_core/commands/__init__.py
Normal file
1
src/govoplan_core/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from __future__ import annotations
|
||||||
36
src/govoplan_core/commands/init_db.py
Normal file
36
src/govoplan_core/commands/init_db.py
Normal file
@@ -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()
|
||||||
2
src/govoplan_core/core/__init__.py
Normal file
2
src/govoplan_core/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Core platform contracts and runtime registry."""
|
||||||
|
|
||||||
42
src/govoplan_core/core/discovery.py
Normal file
42
src/govoplan_core/core/discovery.py
Normal file
@@ -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))
|
||||||
33
src/govoplan_core/core/events.py
Normal file
33
src/govoplan_core/core/events.py
Normal file
@@ -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)
|
||||||
|
|
||||||
29
src/govoplan_core/core/migrations.py
Normal file
29
src/govoplan_core/core/migrations.py
Normal file
@@ -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))
|
||||||
|
|
||||||
125
src/govoplan_core/core/modules.py
Normal file
125
src/govoplan_core/core/modules.py
Normal file
@@ -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)
|
||||||
|
|
||||||
155
src/govoplan_core/core/registry.py
Normal file
155
src/govoplan_core/core/registry.py
Normal file
@@ -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 <module>:<resource>:<action>: {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)
|
||||||
|
|
||||||
14
src/govoplan_core/db/__init__.py
Normal file
14
src/govoplan_core/db/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
39
src/govoplan_core/db/base.py
Normal file
39
src/govoplan_core/db/base.py
Normal file
@@ -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)
|
||||||
120
src/govoplan_core/db/bootstrap.py
Normal file
120
src/govoplan_core/db/bootstrap.py
Normal file
@@ -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)
|
||||||
14
src/govoplan_core/db/migrate.py
Normal file
14
src/govoplan_core/db/migrate.py
Normal file
@@ -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()
|
||||||
187
src/govoplan_core/db/migrations.py
Normal file
187
src/govoplan_core/db/migrations.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
271
src/govoplan_core/db/models.py
Normal file
271
src/govoplan_core/db/models.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
66
src/govoplan_core/db/session.py
Normal file
66
src/govoplan_core/db/session.py
Normal file
@@ -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()
|
||||||
0
src/govoplan_core/privacy/__init__.py
Normal file
0
src/govoplan_core/privacy/__init__.py
Normal file
664
src/govoplan_core/privacy/retention.py
Normal file
664
src/govoplan_core/privacy/retention.py
Normal file
@@ -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] = "<redacted>"
|
||||||
|
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
|
||||||
0
src/govoplan_core/py.typed
Normal file
0
src/govoplan_core/py.typed
Normal file
0
src/govoplan_core/security/__init__.py
Normal file
0
src/govoplan_core/security/__init__.py
Normal file
80
src/govoplan_core/security/api_keys.py
Normal file
80
src/govoplan_core/security/api_keys.py
Normal file
@@ -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
|
||||||
68
src/govoplan_core/security/module_permissions.py
Normal file
68
src/govoplan_core/security/module_permissions.py
Normal file
@@ -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)
|
||||||
|
|
||||||
37
src/govoplan_core/security/passwords.py
Normal file
37
src/govoplan_core/security/passwords.py
Normal file
@@ -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)
|
||||||
299
src/govoplan_core/security/permissions.py
Normal file
299
src/govoplan_core/security/permissions.py
Normal file
@@ -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
|
||||||
58
src/govoplan_core/security/secrets.py
Normal file
58
src/govoplan_core/security/secrets.py
Normal file
@@ -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
|
||||||
220
src/govoplan_core/security/sessions.py
Normal file
220
src/govoplan_core/security/sessions.py
Normal file
@@ -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()
|
||||||
|
)
|
||||||
21
src/govoplan_core/security/time.py
Normal file
21
src/govoplan_core/security/time.py
Normal file
@@ -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)
|
||||||
1
src/govoplan_core/server/__init__.py
Normal file
1
src/govoplan_core/server/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""GovOPlaN server runner package."""
|
||||||
56
src/govoplan_core/server/app.py
Normal file
56
src/govoplan_core/server/app.py
Normal file
@@ -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()
|
||||||
73
src/govoplan_core/server/config.py
Normal file
73
src/govoplan_core/server/config.py
Normal file
@@ -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
|
||||||
89
src/govoplan_core/server/default_config.py
Normal file
89
src/govoplan_core/server/default_config.py
Normal file
@@ -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,),
|
||||||
|
)
|
||||||
48
src/govoplan_core/server/fastapi.py
Normal file
48
src/govoplan_core/server/fastapi.py
Normal file
@@ -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
|
||||||
100
src/govoplan_core/server/platform.py
Normal file
100
src/govoplan_core/server/platform.py
Normal file
@@ -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
|
||||||
43
src/govoplan_core/server/registry.py
Normal file
43
src/govoplan_core/server/registry.py
Normal file
@@ -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
|
||||||
62
src/govoplan_core/settings.py
Normal file
62
src/govoplan_core/settings.py
Normal file
@@ -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()
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
2821
tests/test_api_smoke.py
Normal file
2821
tests/test_api_smoke.py
Normal file
File diff suppressed because it is too large
Load Diff
332
tests/test_database_migrations.py
Normal file
332
tests/test_database_migrations.py
Normal file
@@ -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()
|
||||||
|
|
||||||
12
webui/index.html
Normal file
12
webui/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>GovOPlaN</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1999
webui/package-lock.json
generated
Normal file
1999
webui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
webui/package.json
Normal file
49
webui/package.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
205
webui/src/App.tsx
Normal file
205
webui/src/App.tsx
Normal file
@@ -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<ApiSettings>(() => loadApiSettings());
|
||||||
|
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||||
|
const [checkingSession, setCheckingSession] = useState(true);
|
||||||
|
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(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 (
|
||||||
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
||||||
|
<div className="public-landing">
|
||||||
|
<section className="public-card">
|
||||||
|
<div className="public-kicker">GovOPlaN</div>
|
||||||
|
<h1>Checking session...</h1>
|
||||||
|
<p>Please wait while the local session is verified.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth) {
|
||||||
|
return (
|
||||||
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
||||||
|
<PublicLandingPage settings={settings} onLogin={handlePublicLogin} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRoute = firstAccessibleRoute(auth, webModules);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems}>
|
||||||
|
<Suspense fallback={<div className="content-pad"><p className="muted">Loading module...</p></div>}>
|
||||||
|
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||||
|
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||||
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="/campaigns" element={
|
||||||
|
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:read"]} fallback={defaultRoute}>
|
||||||
|
<CampaignListPage settings={settings} />
|
||||||
|
</PermissionBoundary>
|
||||||
|
} />
|
||||||
|
<Route path="/campaigns/:campaignId/*" element={
|
||||||
|
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:read"]} fallback={defaultRoute}>
|
||||||
|
<CampaignResourceRoute settings={settings} auth={auth} />
|
||||||
|
</PermissionBoundary>
|
||||||
|
} />
|
||||||
|
<Route path="/templates" element={<TemplatesPage />} />
|
||||||
|
<Route path="/files" element={
|
||||||
|
<PermissionBoundary auth={auth} anyOf={["files:file:read"]} fallback={defaultRoute}>
|
||||||
|
<FilesPage settings={settings} auth={auth} />
|
||||||
|
</PermissionBoundary>
|
||||||
|
} />
|
||||||
|
<Route path="/address-book" element={<AddressBookPage />} />
|
||||||
|
<Route path="/operator" element={
|
||||||
|
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send"]} fallback={defaultRoute}>
|
||||||
|
<OperatorQueuePage settings={settings} />
|
||||||
|
</PermissionBoundary>
|
||||||
|
} />
|
||||||
|
<Route path="/reports" element={
|
||||||
|
<PermissionBoundary auth={auth} anyOf={["campaigns:report:read"]} fallback={defaultRoute}>
|
||||||
|
<PlaceholderPage title="Reports" />
|
||||||
|
</PermissionBoundary>
|
||||||
|
} />
|
||||||
|
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
|
||||||
|
<Route path="/admin" element={
|
||||||
|
<PermissionBoundary auth={auth} anyOf={adminReadScopes} fallback={defaultRoute}>
|
||||||
|
<AdminPage settings={settings} auth={auth} onAuthChange={updateAuth} />
|
||||||
|
</PermissionBoundary>
|
||||||
|
} />
|
||||||
|
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ResourceAccessBoundary probe={probe} resetKey={`${tenantId}:${campaignId}`} fallback="/campaigns">
|
||||||
|
<CampaignWorkspace settings={settings} auth={auth} />
|
||||||
|
</ResourceAccessBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
519
webui/src/api/admin.ts
Normal file
519
webui/src/api/admin.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
allow_custom_groups?: boolean | null;
|
||||||
|
allow_custom_roles?: boolean | null;
|
||||||
|
allow_api_keys?: boolean | null;
|
||||||
|
effective_governance: Record<string, boolean>;
|
||||||
|
is_active: boolean;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
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<PrivacyRetentionPolicyFieldKey, boolean>;
|
||||||
|
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
|
||||||
|
|
||||||
|
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<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
|
||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RetentionRunResponse = {
|
||||||
|
result: {
|
||||||
|
dry_run: boolean;
|
||||||
|
policy: PrivacyRetentionPolicy;
|
||||||
|
cutoffs: Record<string, string | null>;
|
||||||
|
effective_policy_scope?: string;
|
||||||
|
counts: Record<string, Record<string, number>>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/overview");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
|
||||||
|
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
|
||||||
|
return response.permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
|
||||||
|
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
|
||||||
|
return response.tenants;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
|
||||||
|
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<string, unknown>;
|
||||||
|
allow_custom_groups?: boolean | null;
|
||||||
|
allow_custom_roles?: boolean | null;
|
||||||
|
allow_api_keys?: boolean | null;
|
||||||
|
}): Promise<TenantAdminItem> {
|
||||||
|
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<string, unknown>;
|
||||||
|
allow_custom_groups?: boolean | null;
|
||||||
|
allow_custom_roles?: boolean | null;
|
||||||
|
allow_api_keys?: boolean | null;
|
||||||
|
effective_governance: Record<string, boolean>;
|
||||||
|
is_active: boolean;
|
||||||
|
}>): Promise<TenantAdminItem> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
|
||||||
|
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<UserAdminItem> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
|
||||||
|
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<GroupSummary> {
|
||||||
|
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<GroupSummary> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/groups/${groupId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
||||||
|
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<RoleSummary> {
|
||||||
|
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<RoleSummary> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRole(settings: ApiSettings, roleId: string): Promise<void> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
||||||
|
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<RoleSummary> {
|
||||||
|
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<RoleSummary> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSystemRole(settings: ApiSettings, roleId: string): Promise<void> {
|
||||||
|
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<SystemAccountItem> {
|
||||||
|
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<SystemAccountItem> {
|
||||||
|
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<ApiKeyAdminItem[]> {
|
||||||
|
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<ApiKeyAdminItem & { secret: string }> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/api-keys", { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiKeyAdminItem> {
|
||||||
|
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<Record<"time" | "actor" | "action" | "object" | "tenant", string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<SystemAccountItem> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/memberships`, {
|
||||||
|
method: "PUT", body: JSON.stringify({ memberships })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
|
||||||
|
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<SystemSettingsItem> {
|
||||||
|
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<PrivacyRetentionPolicyScopeResponse> {
|
||||||
|
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<PrivacyRetentionPolicyScopeResponse> {
|
||||||
|
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<RetentionRunResponse> {
|
||||||
|
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<GovernanceTemplateItem[]> {
|
||||||
|
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<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise<void> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
37
webui/src/api/auth.ts
Normal file
37
webui/src/api/auth.ts
Normal file
@@ -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<LoginResponse> {
|
||||||
|
return apiFetch<LoginResponse>({ ...settings, accessToken: "", apiKey: "" }, "/api/v1/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMe(settings: ApiSettings): Promise<AuthInfo> {
|
||||||
|
return apiFetch<AuthInfo>(settings, "/api/v1/auth/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function switchTenant(settings: ApiSettings, tenantId: string): Promise<AuthInfo> {
|
||||||
|
return apiFetch<AuthInfo>(settings, "/api/v1/auth/switch-tenant", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ tenant_id: tenantId })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(settings: ApiSettings): Promise<void> {
|
||||||
|
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<AuthInfo> {
|
||||||
|
return apiFetch<AuthInfo>(settings, "/api/v1/auth/profile", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user