From 13bc3d3b4e977a906278887c603eb0a31464893b Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 28 Jul 2026 19:32:41 +0200 Subject: [PATCH] Add shared credential envelope infrastructure --- .../c91f0a72be34_core_credential_envelopes.py | 87 +++ alembic/env.py | 1 + .../c91f0a72be34_core_credential_envelopes.py | 119 ++++ src/govoplan_core/db/bootstrap.py | 1 + src/govoplan_core/db/migrations.py | 1 + .../security/credential_envelopes.py | 563 ++++++++++++++++++ src/govoplan_core/server/app.py | 2 + src/govoplan_core/server/credentials.py | 380 ++++++++++++ tests/test_credential_envelopes.py | 245 ++++++++ tests/test_database_migrations.py | 2 +- webui/src/api/credentials.ts | 76 +++ webui/src/api/mailContracts.ts | 5 + .../components/CredentialEnvelopeManager.tsx | 526 ++++++++++++++++ webui/src/features/settings/SettingsPage.tsx | 32 +- webui/src/index.ts | 9 + webui/src/types.ts | 47 ++ webui/src/utils/permissions.ts | 2 + 17 files changed, 2094 insertions(+), 4 deletions(-) create mode 100644 alembic/dev_versions/c91f0a72be34_core_credential_envelopes.py create mode 100644 alembic/versions/c91f0a72be34_core_credential_envelopes.py create mode 100644 src/govoplan_core/security/credential_envelopes.py create mode 100644 src/govoplan_core/server/credentials.py create mode 100644 tests/test_credential_envelopes.py create mode 100644 webui/src/api/credentials.ts create mode 100644 webui/src/components/CredentialEnvelopeManager.tsx diff --git a/alembic/dev_versions/c91f0a72be34_core_credential_envelopes.py b/alembic/dev_versions/c91f0a72be34_core_credential_envelopes.py new file mode 100644 index 0000000..08110f0 --- /dev/null +++ b/alembic/dev_versions/c91f0a72be34_core_credential_envelopes.py @@ -0,0 +1,87 @@ +"""add reusable core credential envelopes + +Revision ID: c91f0a72be34 +Revises: 0f1e2d3c4b5a +Create Date: 2026-07-23 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "c91f0a72be34" +down_revision = "0f1e2d3c4b5a" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "core_credential_envelopes" in inspector.get_table_names(): + return + op.create_table( + "core_credential_envelopes", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=True), + sa.Column("scope_type", sa.String(length=20), nullable=False), + sa.Column("scope_id", sa.String(length=255), nullable=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("credential_kind", sa.String(length=40), nullable=False), + sa.Column("public_data", sa.JSON(), nullable=False), + sa.Column("secret_data_encrypted", sa.Text(), nullable=True), + sa.Column("secret_keys", sa.JSON(), nullable=False), + sa.Column("allowed_modules", sa.JSON(), nullable=False), + sa.Column("allowed_server_refs", sa.JSON(), nullable=False), + sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("revision", sa.String(length=36), nullable=False), + 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("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( + ["tenant_id"], + ["core_scopes.id"], + name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")), + ) + op.create_index( + "ix_core_credential_envelopes_scope", + "core_credential_envelopes", + ["tenant_id", "scope_type", "scope_id"], + unique=False, + ) + op.create_index( + "ix_core_credential_envelopes_active", + "core_credential_envelopes", + ["tenant_id", "is_active", "deleted_at"], + unique=False, + ) + for column in ( + "tenant_id", + "scope_type", + "scope_id", + "credential_kind", + "is_active", + "created_by_user_id", + "updated_by_user_id", + "deleted_at", + ): + op.create_index( + op.f(f"ix_core_credential_envelopes_{column}"), + "core_credential_envelopes", + [column], + unique=False, + ) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "core_credential_envelopes" in inspector.get_table_names(): + op.drop_table("core_credential_envelopes") diff --git a/alembic/env.py b/alembic/env.py index 3356dba..65cd967 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -12,6 +12,7 @@ except ModuleNotFoundError as exc: raise from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata +from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata from govoplan_core.core.migrations import migration_metadata_plan from govoplan_core.db.base import Base from govoplan_core.server.default_config import get_server_config diff --git a/alembic/versions/c91f0a72be34_core_credential_envelopes.py b/alembic/versions/c91f0a72be34_core_credential_envelopes.py new file mode 100644 index 0000000..56c26d4 --- /dev/null +++ b/alembic/versions/c91f0a72be34_core_credential_envelopes.py @@ -0,0 +1,119 @@ +"""add reusable core credential envelopes + +Revision ID: c91f0a72be34 +Revises: 4f2a9c8e7b6d +Create Date: 2026-07-23 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "c91f0a72be34" +down_revision = "4f2a9c8e7b6d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "core_credential_envelopes" in inspector.get_table_names(): + return + op.create_table( + "core_credential_envelopes", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=True), + sa.Column("scope_type", sa.String(length=20), nullable=False), + sa.Column("scope_id", sa.String(length=255), nullable=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("credential_kind", sa.String(length=40), nullable=False), + sa.Column("public_data", sa.JSON(), nullable=False), + sa.Column("secret_data_encrypted", sa.Text(), nullable=True), + sa.Column("secret_keys", sa.JSON(), nullable=False), + sa.Column("allowed_modules", sa.JSON(), nullable=False), + sa.Column("allowed_server_refs", sa.JSON(), nullable=False), + sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("revision", sa.String(length=36), nullable=False), + 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("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( + ["tenant_id"], + ["core_scopes.id"], + name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")), + ) + op.create_index( + "ix_core_credential_envelopes_scope", + "core_credential_envelopes", + ["tenant_id", "scope_type", "scope_id"], + unique=False, + ) + op.create_index( + "ix_core_credential_envelopes_active", + "core_credential_envelopes", + ["tenant_id", "is_active", "deleted_at"], + unique=False, + ) + op.create_index( + op.f("ix_core_credential_envelopes_tenant_id"), + "core_credential_envelopes", + ["tenant_id"], + unique=False, + ) + op.create_index( + op.f("ix_core_credential_envelopes_scope_type"), + "core_credential_envelopes", + ["scope_type"], + unique=False, + ) + op.create_index( + op.f("ix_core_credential_envelopes_scope_id"), + "core_credential_envelopes", + ["scope_id"], + unique=False, + ) + op.create_index( + op.f("ix_core_credential_envelopes_credential_kind"), + "core_credential_envelopes", + ["credential_kind"], + unique=False, + ) + op.create_index( + op.f("ix_core_credential_envelopes_is_active"), + "core_credential_envelopes", + ["is_active"], + unique=False, + ) + op.create_index( + op.f("ix_core_credential_envelopes_created_by_user_id"), + "core_credential_envelopes", + ["created_by_user_id"], + unique=False, + ) + op.create_index( + op.f("ix_core_credential_envelopes_updated_by_user_id"), + "core_credential_envelopes", + ["updated_by_user_id"], + unique=False, + ) + op.create_index( + op.f("ix_core_credential_envelopes_deleted_at"), + "core_credential_envelopes", + ["deleted_at"], + unique=False, + ) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "core_credential_envelopes" in inspector.get_table_names(): + op.drop_table("core_credential_envelopes") diff --git a/src/govoplan_core/db/bootstrap.py b/src/govoplan_core/db/bootstrap.py index 4a0fbcc..9be4e7f 100644 --- a/src/govoplan_core/db/bootstrap.py +++ b/src/govoplan_core/db/bootstrap.py @@ -32,6 +32,7 @@ def create_all_tables() -> None: # model metadata with the shared SQLAlchemy base before create_all runs. from govoplan_core.admin import models as core_admin_models # noqa: F401 from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 + from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules) candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules) diff --git a/src/govoplan_core/db/migrations.py b/src/govoplan_core/db/migrations.py index b681d79..70d7904 100644 --- a/src/govoplan_core/db/migrations.py +++ b/src/govoplan_core/db/migrations.py @@ -18,6 +18,7 @@ from sqlalchemy import create_engine, inspect, text from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata +from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult diff --git a/src/govoplan_core/security/credential_envelopes.py b/src/govoplan_core/security/credential_envelopes.py new file mode 100644 index 0000000..c9e10fc --- /dev/null +++ b/src/govoplan_core/security/credential_envelopes.py @@ -0,0 +1,563 @@ +from __future__ import annotations + +import json +import re +import uuid +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Mapping + +from sqlalchemy import Boolean, DateTime, Index, JSON, String, Text, select +from sqlalchemy.orm import Mapped, Session, mapped_column + +from govoplan_core.audit.logging import audit_event +from govoplan_core.core.change_sequence import record_change +from govoplan_core.db.base import Base, TimestampMixin, utcnow +from govoplan_core.security.redaction import is_sensitive_key, redact_secret_values +from govoplan_core.security.secrets import decrypt_secret, encrypt_secret + + +CREDENTIAL_SCOPE_TYPES = frozenset({"system", "tenant", "group", "user", "campaign"}) +CREDENTIAL_KINDS = frozenset( + { + "username_password", + "token", + "oauth2", + "api_key", + "client_secret", + "aws", + "custom", + "external_secret", + } +) +_SECRET_PUBLIC_DATA_KEYS = frozenset( + { + "access_token", + "api_key", + "authorization", + "bearer_token", + "client_secret", + "password", + "passphrase", + "private_key", + "refresh_token", + "secret", + "secret_access_key", + "session_token", + "token", + } +) + + +class CredentialEnvelopeError(RuntimeError): + pass + + +class CredentialEnvelope(Base, TimestampMixin): + __tablename__ = "core_credential_envelopes" + __table_args__ = ( + Index("ix_core_credential_envelopes_scope", "tenant_id", "scope_type", "scope_id"), + Index("ix_core_credential_envelopes_active", "tenant_id", "is_active", "deleted_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + scope_type: Mapped[str] = mapped_column(String(20), nullable=False, default="tenant", index=True) + scope_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + credential_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + public_data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + secret_data_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True) + secret_keys: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + allowed_modules: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + allowed_server_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + inherit_to_lower_scopes: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True) + revision: Mapped[str] = mapped_column(String(36), default=lambda: str(uuid.uuid4()), nullable=False) + created_by_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + updated_by_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + +@dataclass(frozen=True, slots=True) +class CredentialAccessContext: + tenant_id: str | None + user_id: str | None = None + group_ids: frozenset[str] = frozenset() + target_scope_type: str = "tenant" + target_scope_id: str | None = None + module_id: str | None = None + server_ref: str | None = None + administrative: bool = False + + +@dataclass(frozen=True, slots=True) +class ResolvedCredentialEnvelope: + id: str + name: str + credential_kind: str + public_data: Mapping[str, Any] + secret_data: Mapping[str, Any] + revision: str + + +def normalize_credential_scope( + *, + tenant_id: str | None, + scope_type: str, + scope_id: str | None, +) -> tuple[str | None, str, str | None]: + normalized_type = str(scope_type or "tenant").strip().casefold() + if normalized_type not in CREDENTIAL_SCOPE_TYPES: + raise CredentialEnvelopeError(f"Unsupported credential scope: {scope_type!r}") + normalized_id = _optional_text(scope_id) + if normalized_type == "system": + return None, "system", None + normalized_tenant = _required_text(tenant_id, "Credential tenant_id is required outside system scope") + if normalized_type == "tenant": + return normalized_tenant, "tenant", normalized_id or normalized_tenant + if not normalized_id: + raise CredentialEnvelopeError(f"{normalized_type.capitalize()} credentials require scope_id") + return normalized_tenant, normalized_type, normalized_id + + +def create_credential_envelope( + session: Session, + *, + tenant_id: str | None, + scope_type: str, + scope_id: str | None, + name: str, + credential_kind: str, + public_data: Mapping[str, Any] | None = None, + secret_data: Mapping[str, Any] | None = None, + description: str | None = None, + allowed_modules: list[str] | tuple[str, ...] = (), + allowed_server_refs: list[str] | tuple[str, ...] = (), + inherit_to_lower_scopes: bool = False, + is_active: bool = True, + user_id: str | None = None, + metadata: Mapping[str, Any] | None = None, +) -> CredentialEnvelope: + row_tenant_id, row_scope_type, row_scope_id = normalize_credential_scope( + tenant_id=tenant_id, + scope_type=scope_type, + scope_id=scope_id, + ) + clean_kind = _normalize_kind(credential_kind) + clean_public = _safe_public_mapping(public_data) + clean_secret = _json_mapping(secret_data) + row = CredentialEnvelope( + tenant_id=row_tenant_id, + scope_type=row_scope_type, + scope_id=row_scope_id, + name=_required_text(name, "Credential name is required"), + description=_optional_text(description), + credential_kind=clean_kind, + public_data=clean_public, + secret_data_encrypted=_encrypt_secret_mapping(clean_secret), + secret_keys=sorted(clean_secret), + allowed_modules=_normalized_values(allowed_modules), + allowed_server_refs=_normalized_values(allowed_server_refs), + inherit_to_lower_scopes=bool(inherit_to_lower_scopes), + is_active=bool(is_active), + created_by_user_id=_optional_text(user_id), + updated_by_user_id=_optional_text(user_id), + metadata_=_json_mapping(metadata) or None, + ) + session.add(row) + session.flush() + _record_credential_change(session, row=row, operation="created", user_id=user_id) + return row + + +def update_credential_envelope( + session: Session, + row: CredentialEnvelope, + *, + name: str | None = None, + description: str | None = None, + description_supplied: bool = False, + credential_kind: str | None = None, + public_data: Mapping[str, Any] | None = None, + secret_data: Mapping[str, Any] | None = None, + clear_secret: bool = False, + allowed_modules: list[str] | tuple[str, ...] | None = None, + allowed_server_refs: list[str] | tuple[str, ...] | None = None, + inherit_to_lower_scopes: bool | None = None, + is_active: bool | None = None, + user_id: str | None = None, + metadata: Mapping[str, Any] | None = None, +) -> CredentialEnvelope: + if row.deleted_at is not None: + raise CredentialEnvelopeError("Deleted credentials cannot be updated") + if name is not None: + row.name = _required_text(name, "Credential name is required") + if description_supplied or description is not None: + row.description = _optional_text(description) + if credential_kind is not None: + clean_kind = _normalize_kind(credential_kind) + if clean_kind != row.credential_kind and secret_data is None and not clear_secret: + raise CredentialEnvelopeError( + "Changing credential kind requires replacing or clearing its secret" + ) + row.credential_kind = clean_kind + if public_data is not None: + row.public_data = _safe_public_mapping(public_data) + if secret_data is not None: + clean_secret = _json_mapping(secret_data) + row.secret_data_encrypted = _encrypt_secret_mapping(clean_secret) + row.secret_keys = sorted(clean_secret) + elif clear_secret: + row.secret_data_encrypted = None + row.secret_keys = [] + if allowed_modules is not None: + row.allowed_modules = _normalized_values(allowed_modules) + if allowed_server_refs is not None: + row.allowed_server_refs = _normalized_values(allowed_server_refs) + if inherit_to_lower_scopes is not None: + row.inherit_to_lower_scopes = bool(inherit_to_lower_scopes) + if is_active is not None: + row.is_active = bool(is_active) + if metadata is not None: + row.metadata_ = _json_mapping(metadata) or None + row.updated_by_user_id = _optional_text(user_id) + row.revision = str(uuid.uuid4()) + session.flush() + _record_credential_change(session, row=row, operation="updated", user_id=user_id) + return row + + +def retire_credential_envelope( + session: Session, + row: CredentialEnvelope, + *, + user_id: str | None = None, +) -> CredentialEnvelope: + if row.deleted_at is not None: + return row + row.secret_data_encrypted = None + row.secret_keys = [] + row.is_active = False + row.deleted_at = utcnow() + row.updated_by_user_id = _optional_text(user_id) + row.revision = str(uuid.uuid4()) + session.flush() + _record_credential_change(session, row=row, operation="deleted", user_id=user_id) + return row + + +def get_credential_envelope( + session: Session, + *, + credential_id: str, + context: CredentialAccessContext, + require_active: bool = True, + for_update: bool = False, +) -> CredentialEnvelope: + statement = select(CredentialEnvelope).where(CredentialEnvelope.id == _required_text(credential_id, "Credential id is required")) + if for_update: + statement = statement.with_for_update() + row = session.execute(statement).scalar_one_or_none() + if row is None or row.deleted_at is not None or not credential_visible_to_context(row, context): + raise CredentialEnvelopeError("Credential envelope not found") + if require_active and not row.is_active: + raise CredentialEnvelopeError("Credential envelope is inactive") + return row + + +def list_credential_envelopes( + session: Session, + *, + context: CredentialAccessContext, + include_inactive: bool = False, +) -> list[CredentialEnvelope]: + statement = select(CredentialEnvelope).where(CredentialEnvelope.deleted_at.is_(None)) + if context.tenant_id is None: + statement = statement.where(CredentialEnvelope.tenant_id.is_(None)) + else: + statement = statement.where( + (CredentialEnvelope.tenant_id == context.tenant_id) + | (CredentialEnvelope.tenant_id.is_(None)) + ) + if not include_inactive: + statement = statement.where(CredentialEnvelope.is_active.is_(True)) + rows = session.execute(statement.order_by(CredentialEnvelope.name, CredentialEnvelope.id)).scalars() + return [row for row in rows if credential_visible_to_context(row, context)] + + +def list_managed_credential_envelopes( + session: Session, + *, + tenant_id: str | None, + include_inactive: bool = False, +) -> list[CredentialEnvelope]: + """List envelopes for an administrative surface without applying use-site limits.""" + + statement = select(CredentialEnvelope).where(CredentialEnvelope.deleted_at.is_(None)) + if tenant_id is None: + statement = statement.where(CredentialEnvelope.tenant_id.is_(None)) + else: + statement = statement.where(CredentialEnvelope.tenant_id == tenant_id) + if not include_inactive: + statement = statement.where(CredentialEnvelope.is_active.is_(True)) + return list( + session.execute( + statement.order_by( + CredentialEnvelope.scope_type, + CredentialEnvelope.name, + CredentialEnvelope.id, + ) + ).scalars() + ) + + +def get_managed_credential_envelope( + session: Session, + *, + credential_id: str, + tenant_id: str | None, + for_update: bool = False, +) -> CredentialEnvelope: + statement = select(CredentialEnvelope).where( + CredentialEnvelope.id + == _required_text(credential_id, "Credential id is required"), + CredentialEnvelope.deleted_at.is_(None), + ) + if tenant_id is None: + statement = statement.where(CredentialEnvelope.tenant_id.is_(None)) + else: + statement = statement.where(CredentialEnvelope.tenant_id == tenant_id) + if for_update: + statement = statement.with_for_update() + row = session.execute(statement).scalar_one_or_none() + if row is None: + raise CredentialEnvelopeError("Credential envelope not found") + return row + + +def credential_visible_to_context(row: CredentialEnvelope, context: CredentialAccessContext) -> bool: + if row.deleted_at is not None: + return False + if not _scope_visible(row, context): + return False + if row.allowed_modules and (not context.module_id or context.module_id not in row.allowed_modules): + return False + if row.allowed_server_refs and (not context.server_ref or context.server_ref not in row.allowed_server_refs): + return False + return True + + +def resolve_credential_envelope( + session: Session, + *, + credential_id: str, + context: CredentialAccessContext, +) -> ResolvedCredentialEnvelope: + row = get_credential_envelope( + session, + credential_id=credential_id, + context=context, + require_active=True, + ) + return ResolvedCredentialEnvelope( + id=row.id, + name=row.name, + credential_kind=row.credential_kind, + public_data=dict(row.public_data or {}), + secret_data=_decrypt_secret_mapping(row.secret_data_encrypted), + revision=row.revision, + ) + + +def credential_envelope_summary(row: CredentialEnvelope) -> dict[str, Any]: + public_data = redact_secret_values(dict(row.public_data or {})) + return { + "id": row.id, + "tenant_id": row.tenant_id, + "scope_type": row.scope_type, + "scope_id": row.scope_id, + "name": row.name, + "description": row.description, + "credential_kind": row.credential_kind, + "public_data": public_data, + "secret_keys": sorted(str(key) for key in (row.secret_keys or [])), + "secret_configured": bool(row.secret_data_encrypted), + "allowed_modules": sorted(str(item) for item in (row.allowed_modules or [])), + "allowed_server_refs": sorted(str(item) for item in (row.allowed_server_refs or [])), + "inherit_to_lower_scopes": bool(row.inherit_to_lower_scopes), + "is_active": bool(row.is_active), + "revision": row.revision, + "created_at": row.created_at, + "updated_at": row.updated_at, + "deleted_at": row.deleted_at, + } + + +def _scope_visible(row: CredentialEnvelope, context: CredentialAccessContext) -> bool: + target_type = str(context.target_scope_type or "tenant").strip().casefold() + target_id = _optional_text(context.target_scope_id) + if context.administrative: + return row.scope_type == "system" or row.tenant_id == context.tenant_id + if row.scope_type == "system": + return target_type == "system" or bool(row.inherit_to_lower_scopes) + if row.tenant_id != context.tenant_id: + return False + if row.scope_type == "tenant": + if target_type == "tenant": + return row.scope_id in {None, context.tenant_id, target_id} + return bool(row.inherit_to_lower_scopes) + if row.scope_type == "user": + return row.scope_id == context.user_id or (target_type == "user" and row.scope_id == target_id) + if row.scope_type == "group": + exact = row.scope_id in context.group_ids or (target_type == "group" and row.scope_id == target_id) + return exact and (target_type == "group" or bool(row.inherit_to_lower_scopes)) + if row.scope_type == "campaign": + return target_type == "campaign" and row.scope_id == target_id + return False + + +def _record_credential_change( + session: Session, + *, + row: CredentialEnvelope, + operation: str, + user_id: str | None, +) -> None: + scope = "system" if row.scope_type == "system" else "tenant" + details = { + "scope_type": row.scope_type, + "scope_id": row.scope_id, + "credential_kind": row.credential_kind, + "allowed_modules": list(row.allowed_modules or []), + "allowed_server_refs": list(row.allowed_server_refs or []), + "secret_configured": bool(row.secret_data_encrypted), + } + record_change( + session, + module_id="core", + collection="core.security.credentials", + resource_type="credential_envelope", + resource_id=row.id, + operation=operation, + tenant_id=row.tenant_id, + actor_type="user" if user_id else None, + actor_id=user_id, + payload={"scope_type": row.scope_type, "credential_kind": row.credential_kind}, + ) + audit_event( + session, + tenant_id=row.tenant_id, + action=f"credential.{operation}", + scope=scope, + user_id=user_id, + object_type="credential_envelope", + object_id=row.id, + details=details, + ) + + +def _encrypt_secret_mapping(value: Mapping[str, Any]) -> str | None: + if not value: + return None + return encrypt_secret(json.dumps(dict(value), separators=(",", ":"), sort_keys=True)) + + +def _decrypt_secret_mapping(value: str | None) -> dict[str, Any]: + decrypted = decrypt_secret(value) + if not decrypted: + return {} + try: + parsed = json.loads(decrypted) + except json.JSONDecodeError as exc: + raise CredentialEnvelopeError("Stored credential payload is invalid") from exc + if not isinstance(parsed, dict): + raise CredentialEnvelopeError("Stored credential payload is invalid") + return parsed + + +def _normalize_kind(value: str) -> str: + normalized = _required_text(value, "Credential kind is required").casefold() + if normalized not in CREDENTIAL_KINDS: + raise CredentialEnvelopeError(f"Unsupported credential kind: {value!r}") + return normalized + + +def _normalized_values(values: list[str] | tuple[str, ...]) -> list[str]: + return sorted({_required_text(value, "Credential policy values cannot be blank") for value in values}) + + +def _json_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]: + return dict(value or {}) + + +def _safe_public_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]: + payload = _json_mapping(value) + unsafe_path = _secret_public_data_path(payload) + if unsafe_path: + raise CredentialEnvelopeError( + f"Credential public_data must not contain secret field {unsafe_path!r}" + ) + return payload + + +def _secret_public_data_path(value: object, path: str = "public_data") -> str | None: + if isinstance(value, Mapping): + for key, item in value.items(): + item_path = f"{path}.{key}" + if _is_secret_public_key(key): + return item_path + nested_path = _secret_public_data_path(item, item_path) + if nested_path: + return nested_path + elif isinstance(value, list): + for index, item in enumerate(value): + nested_path = _secret_public_data_path(item, f"{path}[{index}]") + if nested_path: + return nested_path + return None + + +def _is_secret_public_key(key: object) -> bool: + clean_key = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(key).strip()) + clean_key = re.sub(r"[^A-Za-z0-9]+", "_", clean_key).strip("_").casefold() + if clean_key.endswith(("_id", "_ref", "_reference", "_type")): + return False + return clean_key in _SECRET_PUBLIC_DATA_KEYS or is_sensitive_key(key) + + +def _required_text(value: object | None, message: str) -> str: + clean = _optional_text(value) + if not clean: + raise CredentialEnvelopeError(message) + return clean + + +def _optional_text(value: object | None) -> str | None: + if value is None: + return None + clean = str(value).strip() + return clean or None + + +__all__ = [ + "CREDENTIAL_KINDS", + "CREDENTIAL_SCOPE_TYPES", + "CredentialAccessContext", + "CredentialEnvelope", + "CredentialEnvelopeError", + "ResolvedCredentialEnvelope", + "create_credential_envelope", + "credential_envelope_summary", + "credential_visible_to_context", + "get_credential_envelope", + "get_managed_credential_envelope", + "list_credential_envelopes", + "list_managed_credential_envelopes", + "normalize_credential_scope", + "resolve_credential_envelope", + "retire_credential_envelope", + "update_credential_envelope", +] diff --git a/src/govoplan_core/server/app.py b/src/govoplan_core/server/app.py index d728ef6..98cdd9e 100644 --- a/src/govoplan_core/server/app.py +++ b/src/govoplan_core/server/app.py @@ -8,6 +8,7 @@ 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.credentials import router as credential_router from govoplan_core.server.registry import available_module_manifests, build_platform_registry from govoplan_core.server.route_validation import validate_no_route_collisions @@ -67,6 +68,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout for router in server_config.base_routers: api_router.include_router(router) api_router.include_router(create_platform_router(settings=server_config.settings)) + api_router.include_router(credential_router) for router in server_config.post_module_routers: api_router.include_router(router) for contribution in server_config.extra_routers: diff --git a/src/govoplan_core/server/credentials.py b/src/govoplan_core/server/credentials.py new file mode 100644 index 0000000..731ec9d --- /dev/null +++ b/src/govoplan_core/server/credentials.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field, SecretStr +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.db.session import get_session +from govoplan_core.security.credential_envelopes import ( + CredentialEnvelope, + CredentialEnvelopeError, + create_credential_envelope, + credential_envelope_summary, + get_managed_credential_envelope, + list_managed_credential_envelopes, + normalize_credential_scope, + retire_credential_envelope, + update_credential_envelope, +) + + +CredentialScopeType = Literal["system", "tenant", "group", "user", "campaign"] + + +class CredentialEnvelopeResponse(BaseModel): + id: str + tenant_id: str | None = None + scope_type: CredentialScopeType + scope_id: str | None = None + name: str + description: str | None = None + credential_kind: str + public_data: dict[str, Any] = Field(default_factory=dict) + secret_keys: list[str] = Field(default_factory=list) + secret_configured: bool = False + allowed_modules: list[str] = Field(default_factory=list) + allowed_server_refs: list[str] = Field(default_factory=list) + inherit_to_lower_scopes: bool = False + is_active: bool = True + revision: str + created_at: datetime | None = None + updated_at: datetime | None = None + deleted_at: datetime | None = None + + +class CredentialEnvelopeListResponse(BaseModel): + credentials: list[CredentialEnvelopeResponse] = Field(default_factory=list) + + +class CredentialEnvelopeCreateRequest(BaseModel): + scope_type: CredentialScopeType = "tenant" + scope_id: str | None = Field(default=None, max_length=255) + name: str = Field(min_length=1, max_length=255) + description: str | None = None + credential_kind: str = Field(default="username_password", max_length=40) + public_data: dict[str, Any] = Field(default_factory=dict) + secret_data: dict[str, SecretStr] = Field(default_factory=dict) + allowed_modules: list[str] = Field(default_factory=list) + allowed_server_refs: list[str] = Field(default_factory=list) + inherit_to_lower_scopes: bool = False + is_active: bool = True + + +class CredentialEnvelopeUpdateRequest(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = None + credential_kind: str | None = Field(default=None, max_length=40) + public_data: dict[str, Any] | None = None + secret_data: dict[str, SecretStr] | None = None + clear_secret: bool = False + allowed_modules: list[str] | None = None + allowed_server_refs: list[str] | None = None + inherit_to_lower_scopes: bool | None = None + is_active: bool | None = None + + +router = APIRouter(prefix="/credentials", tags=["credentials"]) + + +@router.get("", response_model=CredentialEnvelopeListResponse) +def list_credentials( + scope_type: CredentialScopeType = Query(default="tenant"), + scope_id: str | None = Query(default=None), + include_inactive: bool = Query(default=False), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +) -> CredentialEnvelopeListResponse: + tenant_id, normalized_scope_type, normalized_scope_id = _target_scope( + principal, + scope_type=scope_type, + scope_id=scope_id, + write=False, + ) + rows = list_managed_credential_envelopes( + session, + tenant_id=tenant_id, + include_inactive=include_inactive, + ) + return CredentialEnvelopeListResponse( + credentials=[ + _response(row) + for row in rows + if row.scope_type == normalized_scope_type + and row.scope_id == normalized_scope_id + ] + ) + + +@router.post( + "", + response_model=CredentialEnvelopeResponse, + status_code=status.HTTP_201_CREATED, +) +def create_credential( + payload: CredentialEnvelopeCreateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +) -> CredentialEnvelopeResponse: + tenant_id, scope_type, scope_id = _target_scope( + principal, + scope_type=payload.scope_type, + scope_id=payload.scope_id, + write=True, + ) + try: + row = create_credential_envelope( + session, + tenant_id=tenant_id, + scope_type=scope_type, + scope_id=scope_id, + name=payload.name, + description=payload.description, + credential_kind=payload.credential_kind, + public_data=payload.public_data, + secret_data=_secret_values(payload.secret_data), + allowed_modules=payload.allowed_modules, + allowed_server_refs=payload.allowed_server_refs, + inherit_to_lower_scopes=payload.inherit_to_lower_scopes, + is_active=payload.is_active, + user_id=principal.membership_id, + metadata={"created_by_module": "core"}, + ) + session.commit() + session.refresh(row) + return _response(row) + except CredentialEnvelopeError as exc: + session.rollback() + raise _credential_error(exc) from exc + + +@router.patch("/{credential_id}", response_model=CredentialEnvelopeResponse) +def update_credential( + credential_id: str, + payload: CredentialEnvelopeUpdateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +) -> CredentialEnvelopeResponse: + try: + row = _managed_row_for_write(session, principal, credential_id) + row = update_credential_envelope( + session, + row, + name=payload.name, + description=payload.description, + description_supplied="description" in payload.model_fields_set, + credential_kind=payload.credential_kind, + public_data=payload.public_data, + secret_data=( + _secret_values(payload.secret_data) + if payload.secret_data is not None + else None + ), + clear_secret=payload.clear_secret, + allowed_modules=payload.allowed_modules, + allowed_server_refs=payload.allowed_server_refs, + inherit_to_lower_scopes=payload.inherit_to_lower_scopes, + is_active=payload.is_active, + user_id=principal.membership_id, + ) + session.commit() + session.refresh(row) + return _response(row) + except CredentialEnvelopeError as exc: + session.rollback() + raise _credential_error(exc) from exc + + +@router.delete("/{credential_id}", response_model=CredentialEnvelopeResponse) +def delete_credential( + credential_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +) -> CredentialEnvelopeResponse: + try: + row = _managed_row_for_write(session, principal, credential_id) + retire_credential_envelope( + session, + row, + user_id=principal.membership_id, + ) + response = _response(row) + session.commit() + return response + except CredentialEnvelopeError as exc: + session.rollback() + raise _credential_error(exc) from exc + + +def _managed_row_for_write( + session: Session, + principal: ApiPrincipal, + credential_id: str, +) -> CredentialEnvelope: + system_row = None + if _can_manage_system_credentials(principal): + try: + system_row = get_managed_credential_envelope( + session, + credential_id=credential_id, + tenant_id=None, + for_update=True, + ) + except CredentialEnvelopeError: + pass + row = system_row or get_managed_credential_envelope( + session, + credential_id=credential_id, + tenant_id=principal.tenant_id, + for_update=True, + ) + _target_scope( + principal, + scope_type=row.scope_type, + scope_id=row.scope_id, + write=True, + ) + return row + + +def _target_scope( + principal: ApiPrincipal, + *, + scope_type: str, + scope_id: str | None, + write: bool, +) -> tuple[str | None, str, str | None]: + requested_type = str(scope_type or "tenant").strip().casefold() + requested_id = str(scope_id).strip() if scope_id else None + if requested_type == "system": + if not ( + _can_manage_system_credentials(principal) + if write + else _can_read_system_credentials(principal) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="System credential permission is required.", + ) + return _normalize_target_scope( + tenant_id=None, + scope_type="system", + scope_id=None, + ) + + if requested_type == "user" and requested_id == principal.membership_id: + own_scope = ( + has_scope(principal, "access:credential:manage_own") + or has_scope(principal, "mail:secret:manage_own") + ) + if own_scope: + return _normalize_target_scope( + tenant_id=principal.tenant_id, + scope_type=requested_type, + scope_id=requested_id, + ) + + allowed = ( + _can_manage_tenant_credentials(principal) + if write + else _can_read_tenant_credentials(principal) + ) + if not allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Tenant credential permission is required.", + ) + return _normalize_target_scope( + tenant_id=principal.tenant_id, + scope_type=requested_type, + scope_id=requested_id, + ) + + +def _normalize_target_scope( + *, + tenant_id: str | None, + scope_type: str, + scope_id: str | None, +) -> tuple[str | None, str, str | None]: + try: + return normalize_credential_scope( + tenant_id=tenant_id, + scope_type=scope_type, + scope_id=scope_id, + ) + except CredentialEnvelopeError as exc: + raise _credential_error(exc) from exc + + +def _can_read_system_credentials(principal: ApiPrincipal) -> bool: + return any( + has_scope(principal, scope) + for scope in ( + "access:system_credential:read", + "access:system_setting:read", + "system:settings:read", + ) + ) + + +def _can_manage_system_credentials(principal: ApiPrincipal) -> bool: + return any( + has_scope(principal, scope) + for scope in ( + "access:system_credential:write", + "access:system_setting:write", + "system:settings:write", + ) + ) + + +def _can_read_tenant_credentials(principal: ApiPrincipal) -> bool: + return any( + has_scope(principal, scope) + for scope in ( + "access:credential:read", + "access:setting:read", + "admin:settings:read", + "mail_servers:read", + ) + ) + + +def _can_manage_tenant_credentials(principal: ApiPrincipal) -> bool: + return any( + has_scope(principal, scope) + for scope in ( + "access:credential:write", + "access:setting:write", + "admin:settings:write", + "mail_servers:manage_credentials", + ) + ) + + +def _secret_values(values: dict[str, SecretStr]) -> dict[str, str]: + return { + str(key): value.get_secret_value() + for key, value in values.items() + if str(key).strip() and value.get_secret_value() + } + + +def _response(row: CredentialEnvelope) -> CredentialEnvelopeResponse: + return CredentialEnvelopeResponse.model_validate( + credential_envelope_summary(row) + ) + + +def _credential_error(exc: CredentialEnvelopeError) -> HTTPException: + return HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + + +__all__ = ["router"] diff --git a/tests/test_credential_envelopes.py b/tests/test_credential_envelopes.py new file mode 100644 index 0000000..5f5c088 --- /dev/null +++ b/tests/test_credential_envelopes.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import json +import unittest + +from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.change_sequence import ChangeSequenceEntry +from govoplan_core.security.credential_envelopes import ( + CredentialAccessContext, + CredentialEnvelope, + CredentialEnvelopeError, + create_credential_envelope, + credential_envelope_summary, + credential_visible_to_context, + list_managed_credential_envelopes, + resolve_credential_envelope, + update_credential_envelope, +) +from govoplan_core.security.secrets import encrypt_secret +from govoplan_core.server.credentials import _target_scope + + +def _principal(scopes: set[str]) -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="user-1", + tenant_id="tenant-1", + scopes=frozenset(scopes), + ), + account=object(), + user=object(), + ) + + +class CredentialEnvelopeTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + ChangeSequenceEntry.__table__.create(self.engine) + CredentialEnvelope.__table__.create(self.engine) + + def tearDown(self) -> None: + self.engine.dispose() + + def test_inherited_credential_is_filtered_by_module_and_server(self) -> None: + row = CredentialEnvelope( + id="credential-1", + tenant_id="tenant-1", + scope_type="tenant", + scope_id="tenant-1", + name="Shared account", + credential_kind="username_password", + public_data={"username": "service@example.org"}, + secret_data_encrypted=encrypt_secret(json.dumps({"password": "not-returned"})), + secret_keys=["password"], + allowed_modules=["mail"], + allowed_server_refs=["mail:server-1"], + inherit_to_lower_scopes=True, + is_active=True, + revision="revision-1", + ) + allowed = CredentialAccessContext( + tenant_id="tenant-1", + user_id="user-1", + target_scope_type="user", + target_scope_id="user-1", + module_id="mail", + server_ref="mail:server-1", + ) + wrong_module = CredentialAccessContext( + tenant_id="tenant-1", + user_id="user-1", + target_scope_type="user", + target_scope_id="user-1", + module_id="calendar", + server_ref="mail:server-1", + ) + wrong_server = CredentialAccessContext( + tenant_id="tenant-1", + user_id="user-1", + target_scope_type="user", + target_scope_id="user-1", + module_id="mail", + server_ref="mail:server-2", + ) + + self.assertTrue(credential_visible_to_context(row, allowed)) + self.assertFalse(credential_visible_to_context(row, wrong_module)) + self.assertFalse(credential_visible_to_context(row, wrong_server)) + self.assertNotIn("secret_data_encrypted", credential_envelope_summary(row)) + + def test_resolution_returns_secret_only_after_access_check(self) -> None: + with Session(self.engine) as session: + session.add( + CredentialEnvelope( + id="credential-1", + tenant_id="tenant-1", + scope_type="tenant", + scope_id="tenant-1", + name="Shared account", + credential_kind="username_password", + public_data={"username": "service@example.org"}, + secret_data_encrypted=encrypt_secret(json.dumps({"password": "secret"})), + secret_keys=["password"], + allowed_modules=["mail"], + allowed_server_refs=[], + inherit_to_lower_scopes=True, + is_active=True, + revision="revision-1", + ) + ) + session.commit() + + resolved = resolve_credential_envelope( + session, + credential_id="credential-1", + context=CredentialAccessContext( + tenant_id="tenant-1", + user_id="user-1", + target_scope_type="user", + target_scope_id="user-1", + module_id="mail", + ), + ) + self.assertEqual(resolved.public_data["username"], "service@example.org") + self.assertEqual(resolved.secret_data["password"], "secret") + + with self.assertRaises(CredentialEnvelopeError): + resolve_credential_envelope( + session, + credential_id="credential-1", + context=CredentialAccessContext( + tenant_id="tenant-2", + user_id="user-2", + target_scope_type="user", + target_scope_id="user-2", + module_id="mail", + ), + ) + + def test_management_listing_stays_in_tenant_but_ignores_use_site_limits(self) -> None: + with Session(self.engine) as session: + session.add_all( + [ + CredentialEnvelope( + id="tenant-credential", + tenant_id="tenant-1", + scope_type="tenant", + scope_id="tenant-1", + name="Tenant restricted", + credential_kind="username_password", + public_data={}, + secret_keys=[], + allowed_modules=["calendar"], + allowed_server_refs=["calendar:source-1"], + inherit_to_lower_scopes=True, + is_active=True, + revision="revision-1", + ), + CredentialEnvelope( + id="other-credential", + tenant_id="tenant-2", + scope_type="tenant", + scope_id="tenant-2", + name="Other tenant", + credential_kind="username_password", + public_data={}, + secret_keys=[], + allowed_modules=[], + allowed_server_refs=[], + inherit_to_lower_scopes=False, + is_active=True, + revision="revision-2", + ), + ] + ) + session.commit() + + rows = list_managed_credential_envelopes( + session, + tenant_id="tenant-1", + ) + + self.assertEqual([row.id for row in rows], ["tenant-credential"]) + + def test_tenant_credential_permission_cannot_manage_system_scope(self) -> None: + tenant_principal = _principal({"access:credential:write"}) + with self.assertRaises(HTTPException) as raised: + _target_scope( + tenant_principal, + scope_type="system", + scope_id=None, + write=True, + ) + self.assertEqual(raised.exception.status_code, 403) + + system_principal = _principal({"access:system_credential:write"}) + self.assertEqual( + _target_scope( + system_principal, + scope_type="system", + scope_id=None, + write=True, + ), + (None, "system", None), + ) + + def test_public_data_rejects_secrets_and_kind_changes_require_secret_decision(self) -> None: + with Session(self.engine) as session: + with self.assertRaisesRegex(CredentialEnvelopeError, "public_data"): + create_credential_envelope( + session, + tenant_id="tenant-1", + scope_type="tenant", + scope_id="tenant-1", + name="Unsafe", + credential_kind="username_password", + public_data={"nested": [{"clientSecret": "not-public"}]}, + ) + + row = create_credential_envelope( + session, + tenant_id="tenant-1", + scope_type="tenant", + scope_id="tenant-1", + name="Safe", + credential_kind="username_password", + public_data={"username": "service@example.org"}, + secret_data={"password": "secret"}, + ) + with self.assertRaisesRegex(CredentialEnvelopeError, "requires replacing"): + update_credential_envelope( + session, + row, + credential_kind="token", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_database_migrations.py b/tests/test_database_migrations.py index a98784c..ebef5db 100644 --- a/tests/test_database_migrations.py +++ b/tests/test_database_migrations.py @@ -232,7 +232,7 @@ class DatabaseMigrationTests(unittest.TestCase): self.assertEqual(current, configured_migration_heads(url, migration_track="dev")) self.assertEqual(result.current_revision, ",".join(sorted(current))) - self.assertIn("0f1e2d3c4b5a", current) + self.assertIn("core_credential_envelopes", tables) self.assertIn("audit_outbox_events", tables) self.assertIn("calendar_outbox_operations", tables) self.assertIn("file_connector_profiles", tables) diff --git a/webui/src/api/credentials.ts b/webui/src/api/credentials.ts new file mode 100644 index 0000000..faf8b98 --- /dev/null +++ b/webui/src/api/credentials.ts @@ -0,0 +1,76 @@ +import type { ApiSettings, CredentialEnvelopeSummary, MailProfileScope } from "../types"; +import { apiFetch } from "./client"; + +export type CredentialEnvelopePayload = { + scope_type: MailProfileScope; + scope_id?: string | null; + name: string; + description?: string | null; + credential_kind: string; + public_data?: Record; + secret_data?: Record; + allowed_modules?: string[]; + allowed_server_refs?: string[]; + inherit_to_lower_scopes?: boolean; + is_active?: boolean; +}; + +export type CredentialEnvelopeUpdatePayload = Partial< + Omit +> & { + clear_secret?: boolean; +}; + +export async function listCredentialEnvelopes( + settings: ApiSettings, + params: { + scope_type: MailProfileScope; + scope_id?: string | null; + include_inactive?: boolean; + } +): Promise { + const search = new URLSearchParams({ scope_type: params.scope_type }); + if (params.scope_id) search.set("scope_id", params.scope_id); + if (params.include_inactive) search.set("include_inactive", "true"); + const response = await apiFetch<{ credentials: CredentialEnvelopeSummary[] }>( + settings, + `/api/v1/credentials?${search.toString()}` + ); + return response.credentials; +} + +export function createCredentialEnvelope( + settings: ApiSettings, + payload: CredentialEnvelopePayload +): Promise { + return apiFetch(settings, "/api/v1/credentials", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function updateCredentialEnvelope( + settings: ApiSettings, + credentialId: string, + payload: CredentialEnvelopeUpdatePayload +): Promise { + return apiFetch( + settings, + `/api/v1/credentials/${encodeURIComponent(credentialId)}`, + { + method: "PATCH", + body: JSON.stringify(payload) + } + ); +} + +export function deleteCredentialEnvelope( + settings: ApiSettings, + credentialId: string +): Promise { + return apiFetch( + settings, + `/api/v1/credentials/${encodeURIComponent(credentialId)}`, + { method: "DELETE" } + ); +} diff --git a/webui/src/api/mailContracts.ts b/webui/src/api/mailContracts.ts index 838ebcd..6ba53a7 100644 --- a/webui/src/api/mailContracts.ts +++ b/webui/src/api/mailContracts.ts @@ -1,4 +1,5 @@ import type { + MailCredentialEnvelope, MailImapTransportSettings, MailProfilePatternKey, MailProfilePolicy, @@ -11,11 +12,13 @@ import type { } from "../types"; export type { + MailCredentialEnvelope, MailCredentialPolicy, MailProfilePatternKey, MailProfilePolicy, MailProfileScope, MailSecurity, + MailServerEndpoint, MailServerProfile } from "../types"; @@ -24,6 +27,7 @@ export type MailImapTestPayload = MailImapTransportSettings; export type MailTransportCredentialsPayload = MailTransportCredentials; export type MailServerProfileCredentialsPayload = MailServerProfileCredentials; export type MailServerProfileListResponse = { profiles: MailServerProfile[] }; +export type MailCredentialListResponse = { credentials: MailCredentialEnvelope[] }; export type MailProfilePatternRules = Partial>; export type MailConnectionTestResponse = { @@ -110,6 +114,7 @@ export type MailServerProfilePayload = { slug?: string | null; description?: string | null; is_active?: boolean; + inherit_to_lower_scopes?: boolean; scope_type?: MailProfileScope; scope_id?: string | null; smtp: MailSmtpTestPayload; diff --git a/webui/src/components/CredentialEnvelopeManager.tsx b/webui/src/components/CredentialEnvelopeManager.tsx new file mode 100644 index 0000000..6a44f34 --- /dev/null +++ b/webui/src/components/CredentialEnvelopeManager.tsx @@ -0,0 +1,526 @@ +import { useEffect, useMemo, useState } from "react"; +import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; +import type { + ApiSettings, + CredentialEnvelopeSummary, + MailProfileScope +} from "../types"; +import { + createCredentialEnvelope, + deleteCredentialEnvelope, + listCredentialEnvelopes, + updateCredentialEnvelope +} from "../api/credentials"; +import Button from "./Button"; +import Card from "./Card"; +import ConfirmDialog from "./ConfirmDialog"; +import ConnectionTree, { type ConnectionTreeColumn } from "./ConnectionTree"; +import Dialog from "./Dialog"; +import DismissibleAlert from "./DismissibleAlert"; +import FormField from "./FormField"; +import LoadingFrame from "./LoadingFrame"; +import PasswordField from "./PasswordField"; +import StatusBadge from "./StatusBadge"; +import TableActionGroup from "./table/TableActionGroup"; +import ToggleSwitch from "./ToggleSwitch"; +import { useUnsavedDraftGuard } from "./UnsavedChangesGuard"; + +export type CredentialEnvelopeTargetOption = { + id: string; + label: string; + secondary?: string | null; +}; + +export type CredentialEnvelopeManagerProps = { + settings: ApiSettings; + scopeType: Extract; + scopeId?: string | null; + targetOptions?: CredentialEnvelopeTargetOption[]; + targetLabel?: string; + title?: string; + canWrite: boolean; +}; + +type CredentialKind = "username_password" | "token" | "api_key"; + +type CredentialDraft = { + name: string; + description: string; + credentialKind: CredentialKind; + username: string; + secret: string; + allowedModules: string; + allowedServerRefs: string; + inheritToLowerScopes: boolean; + isActive: boolean; + clearSecret: boolean; + retainedPublicData: Record; +}; + +const EMPTY_DRAFT: CredentialDraft = { + name: "", + description: "", + credentialKind: "username_password", + username: "", + secret: "", + allowedModules: "", + allowedServerRefs: "", + inheritToLowerScopes: false, + isActive: true, + clearSecret: false, + retainedPublicData: {} +}; + +export default function CredentialEnvelopeManager({ + settings, + scopeType, + scopeId, + targetOptions = [], + targetLabel = "Target", + title = "Credential envelopes", + canWrite +}: CredentialEnvelopeManagerProps) { + const [selectedTargetId, setSelectedTargetId] = useState(scopeId ?? targetOptions[0]?.id ?? ""); + const [credentials, setCredentials] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [editing, setEditing] = useState(null); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [savedDraftKey, setSavedDraftKey] = useState(""); + const [deleting, setDeleting] = useState(null); + const activeScopeId = scopeType === "system" || scopeType === "tenant" + ? scopeId ?? null + : selectedTargetId || null; + const scopeReady = scopeType === "system" || scopeType === "tenant" || Boolean(activeScopeId); + const draftDirty = Boolean(editing) && credentialDraftKey(draft) !== savedDraftKey; + + useUnsavedDraftGuard({ + dirty: draftDirty, + onSave: saveDraft, + onDiscard: closeEditor + }); + + useEffect(() => { + if (scopeId) { + setSelectedTargetId(scopeId); + return; + } + if ( + targetOptions.length > 0 && + !targetOptions.some((option) => option.id === selectedTargetId) + ) { + setSelectedTargetId(targetOptions[0].id); + } + }, [scopeId, selectedTargetId, targetOptions]); + + useEffect(() => { + void loadCredentials(); + }, [ + activeScopeId, + scopeReady, + scopeType, + settings.accessToken, + settings.apiBaseUrl, + settings.apiKey + ]); + + async function loadCredentials() { + if (!scopeReady) { + setCredentials([]); + return; + } + setLoading(true); + setError(""); + try { + setCredentials( + await listCredentialEnvelopes(settings, { + scope_type: scopeType, + scope_id: activeScopeId, + include_inactive: true + }) + ); + } catch (err) { + setCredentials([]); + setError(errorMessage(err)); + } finally { + setLoading(false); + } + } + + function openCreate() { + const next = { ...EMPTY_DRAFT, retainedPublicData: {} }; + setEditing("new"); + setDraft(next); + setSavedDraftKey(credentialDraftKey(next)); + setError(""); + setNotice(""); + } + + function openEdit(credential: CredentialEnvelopeSummary) { + const next = credentialDraft(credential); + setEditing(credential); + setDraft(next); + setSavedDraftKey(credentialDraftKey(next)); + setError(""); + setNotice(""); + } + + function closeEditor() { + if (saving) return; + setEditing(null); + setDraft(EMPTY_DRAFT); + setSavedDraftKey(""); + } + + async function saveDraft(): Promise { + if (!editing || !scopeReady || !draft.name.trim() || !canWrite) return false; + if (editing === "new" && !draft.secret.trim()) { + setError("Enter a secret before creating the credential."); + return false; + } + const kindChanged = editing !== "new" && draft.credentialKind !== editing.credential_kind; + if (kindChanged && !draft.secret.trim()) { + setError("Enter a replacement secret when changing the credential type."); + return false; + } + setSaving(true); + setError(""); + try { + const publicData = { + ...draft.retainedPublicData, + username: draft.username.trim() || undefined + }; + if (!draft.username.trim()) delete publicData.username; + const shared = { + name: draft.name.trim(), + description: draft.description.trim() || null, + credential_kind: draft.credentialKind, + public_data: publicData, + allowed_modules: splitValues(draft.allowedModules), + allowed_server_refs: splitValues(draft.allowedServerRefs), + inherit_to_lower_scopes: draft.inheritToLowerScopes, + is_active: draft.isActive + }; + if (editing === "new") { + await createCredentialEnvelope(settings, { + scope_type: scopeType, + scope_id: activeScopeId, + ...shared, + secret_data: secretPayload(draft) + }); + } else { + await updateCredentialEnvelope(settings, editing.id, { + ...shared, + ...(draft.secret.trim() ? { secret_data: secretPayload(draft) } : {}), + clear_secret: draft.clearSecret + }); + } + setNotice(editing === "new" ? "Credential created." : "Credential saved."); + closeEditor(); + await loadCredentials(); + return true; + } catch (err) { + setError(errorMessage(err)); + return false; + } finally { + setSaving(false); + } + } + + async function confirmDelete() { + if (!deleting) return; + setSaving(true); + setError(""); + try { + await deleteCredentialEnvelope(settings, deleting.id); + setNotice("Credential deleted. Connections using it will remain configured but cannot authenticate."); + setDeleting(null); + await loadCredentials(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + const columns = useMemo[]>( + () => [ + { + id: "credential", + header: "Credential", + width: "minmax(240px, 1.4fr)", + render: (credential) => ( +
+ {credential.name} +
+ {credential.description || credential.public_data.username + ? String(credential.description || credential.public_data.username) + : credential.id} +
+
+ ) + }, + { + id: "kind", + header: "Type", + width: "minmax(150px, 0.7fr)", + render: (credential) => credentialKindLabel(credential.credential_kind) + }, + { + id: "availability", + header: "Availability", + width: "minmax(220px, 1fr)", + render: (credential) => availabilityLabel(credential) + }, + { + id: "status", + header: "Status", + width: "120px", + render: (credential) => ( + + ) + } + ], + [] + ); + + const kindChanged = editing !== null && editing !== "new" && + draft.credentialKind !== editing.credential_kind; + const saveDisabled = saving || !canWrite || !draft.name.trim() || + (editing === "new" && !draft.secret.trim()) || + (kindChanged && !draft.secret.trim()); + + return ( +
+ {targetOptions.length > 0 && ( + + + + )} + {error && {error}} + {notice && !error && {notice}} + + + +
+ } + > + + credential.id} + emptyText="No credentials are configured for this scope." + renderActions={(credential) => ( + , + onClick: () => openEdit(credential), + disabled: !canWrite || saving + }, + { + id: "delete", + label: `Delete ${credential.name}`, + icon: , + onClick: () => setDeleting(credential), + disabled: !canWrite || saving, + variant: "danger" + } + ]} + /> + )} + /> + + + + + + + + } + > +
+
+
+

Identity

+

The name and type are visible; secret values are never returned by the API.

+
+
+ + setDraft({ ...draft, name: event.target.value })} autoFocus /> + + + + + + setDraft({ ...draft, description: event.target.value })} /> + + + setDraft({ ...draft, username: event.target.value })} /> + + + setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" /> + + {editing !== "new" && ( + setDraft({ ...draft, clearSecret })} + label="Remove configured secret" + /> + )} +
+
+
+
+

Availability

+

Empty module or server lists mean every module or server allowed by scope.

+
+
+ + setDraft({ ...draft, allowedModules: event.target.value })} /> + + + setDraft({ ...draft, allowedServerRefs: event.target.value })} /> + + setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" /> + setDraft({ ...draft, isActive })} label="Active" /> +
+
+
+
+ + setDeleting(null)} + onConfirm={() => void confirmDelete()} + /> + + ); +} + +function credentialDraft(credential: CredentialEnvelopeSummary): CredentialDraft { + return { + ...EMPTY_DRAFT, + name: credential.name, + description: credential.description ?? "", + credentialKind: supportedCredentialKind(credential.credential_kind), + username: String(credential.public_data.username ?? ""), + allowedModules: credential.allowed_modules.join(", "), + allowedServerRefs: credential.allowed_server_refs.join(", "), + inheritToLowerScopes: credential.inherit_to_lower_scopes, + isActive: credential.is_active, + retainedPublicData: { ...credential.public_data } + }; +} + +function credentialDraftKey(draft: CredentialDraft): string { + return JSON.stringify(draft); +} + +function splitValues(value: string): string[] { + return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))]; +} + +function secretPayload(draft: CredentialDraft): Record { + const secret = draft.secret.trim(); + if (!secret) return {}; + if (draft.credentialKind === "token") return { token: secret }; + if (draft.credentialKind === "api_key") return { api_key: secret }; + return { password: secret }; +} + +function supportedCredentialKind(value: string): CredentialKind { + return value === "token" || value === "api_key" ? value : "username_password"; +} + +function secretFieldLabel(kind: CredentialKind): string { + if (kind === "token") return "Access token"; + if (kind === "api_key") return "API key"; + return "Password"; +} + +function credentialKindLabel(kind: string): string { + if (kind === "username_password") return "Username and password"; + if (kind === "api_key") return "API key"; + if (kind === "token") return "Access token"; + return kind.split("_").join(" "); +} + +function availabilityLabel(credential: CredentialEnvelopeSummary): string { + const modules = credential.allowed_modules.length + ? credential.allowed_modules.join(", ") + : "All modules"; + const servers = credential.allowed_server_refs.length + ? `${credential.allowed_server_refs.length} server restriction${credential.allowed_server_refs.length === 1 ? "" : "s"}` + : "all servers"; + return `${modules} · ${servers}${credential.inherit_to_lower_scopes ? " · inherited" : ""}`; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/webui/src/features/settings/SettingsPage.tsx b/webui/src/features/settings/SettingsPage.tsx index b9096ee..de01aed 100644 --- a/webui/src/features/settings/SettingsPage.tsx +++ b/webui/src/features/settings/SettingsPage.tsx @@ -16,6 +16,7 @@ import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/Unsave import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext"; import { hasAnyScope, hasScope } from "../../utils/permissions"; import { usePlatformLanguage } from "../../i18n/LanguageContext"; +import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager"; type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string; @@ -33,14 +34,15 @@ const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [ { value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" } ]; -function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup[] { +function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, canUseCredentials: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup[] { const groups: ModuleSubnavGroup[] = [ { title: "i18n:govoplan-core.account.f967543b", items: [ { id: "profile", label: "i18n:govoplan-core.my_profile.2f3df0a9" }, ...(canUseMailProfiles ? [{ id: "mail-profiles" as const, label: "i18n:govoplan-core.mail_profiles.8a8018b7" }] : []), - ...(canUseFileConnectors ? [{ id: "file-connectors" as const, label: "i18n:govoplan-core.file_connections.1e362326" }] : [])] + ...(canUseFileConnectors ? [{ id: "file-connectors" as const, label: "i18n:govoplan-core.file_connections.1e362326" }] : []), + ...(canUseCredentials ? [{ id: "credentials" as const, label: "i18n:govoplan-core.credentials.dd097a22" }] : [])] }, { @@ -102,11 +104,19 @@ export default function SettingsPage({ "admin:policies:write" ]); const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]); + const canUseCredentials = hasAnyScope(auth, [ + "access:credential:read", + "access:credential:write", + "access:credential:manage_own", + "mail:secret:manage_own", + "admin:settings:read", + "admin:settings:write" + ]); const contributedSections = useMemo( () => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)), [auth, settingsSectionCapabilities] ); - const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, contributedSections), [canUseFileConnectors, canUseMailProfiles, contributedSections]); + const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, canUseCredentials, contributedSections), [canUseCredentials, canUseFileConnectors, canUseMailProfiles, contributedSections]); const availableSectionIds = useMemo(() => new Set(settingsSubnav.flatMap((group) => group.items.flatMap((item) => "id" in item ? [item.id] : []))), [settingsSubnav]); const requestedSection = searchParams.get("section"); const active: SettingsSection = settingsSectionAvailable(availableSectionIds, requestedSection) ? requestedSection : "interface"; @@ -343,6 +353,21 @@ export default function SettingsPage({ } + {active === "credentials" && + + + } + {active === "interface" &&
@@ -505,6 +530,7 @@ function sectionOrder(sectionId: string, contributedSections: SettingsSectionCon profile: 10, "mail-profiles": 20, "file-connectors": 30, + "credentials": 40, interface: 10, workspace: 20, "local-connection": 30 diff --git a/webui/src/index.ts b/webui/src/index.ts index 0e90af7..1dac630 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -57,6 +57,15 @@ export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/Con export { default as ColorPickerField } from "./components/ColorPickerField"; export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel"; export type { CredentialFieldsProps, CredentialPanelProps, CredentialValues } from "./components/CredentialPanel"; +export { default as CredentialEnvelopeManager } from "./components/CredentialEnvelopeManager"; +export type { CredentialEnvelopeManagerProps, CredentialEnvelopeTargetOption } from "./components/CredentialEnvelopeManager"; +export { + createCredentialEnvelope, + deleteCredentialEnvelope, + listCredentialEnvelopes, + updateCredentialEnvelope +} from "./api/credentials"; +export type { CredentialEnvelopePayload, CredentialEnvelopeUpdatePayload } from "./api/credentials"; export { default as DateField, TimeField, DateTimeField } from "./components/DateTimeField"; export { default as Dialog } from "./components/Dialog"; export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip"; diff --git a/webui/src/types.ts b/webui/src/types.ts index 062596a..3f550ec 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -464,6 +464,51 @@ export type MailServerProfileCredentials = { imap?: MailTransportCredentials | null; }; +export type CredentialEnvelopeSummary = { + id: string; + tenant_id?: string | null; + scope_type: MailProfileScope; + scope_id?: string | null; + name: string; + description?: string | null; + credential_kind: string; + public_data: Record; + secret_keys: string[]; + secret_configured: boolean; + allowed_modules: string[]; + allowed_server_refs: string[]; + inherit_to_lower_scopes: boolean; + is_active: boolean; + revision: string; + created_at?: string; + updated_at?: string; + deleted_at?: string | null; +}; + +export type MailCredentialEnvelope = CredentialEnvelopeSummary & { + binding_id?: string | null; + server_id?: string | null; + is_default: boolean; +}; + +export type MailServerEndpoint = { + id: string; + profile_id: string; + tenant_id?: string | null; + protocol: "smtp" | "imap"; + name: string; + config: MailTransportSettings | MailImapTransportSettings; + scope_type: MailProfileScope; + scope_id?: string | null; + inherit_to_lower_scopes: boolean; + is_default: boolean; + is_active: boolean; + transport_revision: string; + credentials: MailCredentialEnvelope[]; + created_at?: string; + updated_at?: string; +}; + export type MailServerProfile = { id: string; tenant_id?: string | null; @@ -473,11 +518,13 @@ export type MailServerProfile = { slug: string; description?: string | null; is_active: boolean; + inherit_to_lower_scopes?: boolean; smtp: MailTransportSettings; imap?: MailImapTransportSettings | null; credentials?: MailServerProfileCredentials | null; smtp_password_configured?: boolean; imap_password_configured?: boolean; + servers?: MailServerEndpoint[]; created_at?: string; updated_at?: string; }; diff --git a/webui/src/utils/permissions.ts b/webui/src/utils/permissions.ts index 19094a3..cce0733 100644 --- a/webui/src/utils/permissions.ts +++ b/webui/src/utils/permissions.ts @@ -161,5 +161,7 @@ export const adminReadScopes = [ "access:system_role:read", "access:audit:read", "access:system_setting:read", + "access:system_credential:read", + "access:credential:read", "access:governance:read" ];