Add shared credential envelope infrastructure
This commit is contained in:
@@ -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")
|
||||||
@@ -12,6 +12,7 @@ except ModuleNotFoundError as exc:
|
|||||||
raise
|
raise
|
||||||
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
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.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.core.migrations import migration_metadata_plan
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.server.default_config import get_server_config
|
from govoplan_core.server.default_config import get_server_config
|
||||||
|
|||||||
119
alembic/versions/c91f0a72be34_core_credential_envelopes.py
Normal file
119
alembic/versions/c91f0a72be34_core_credential_envelopes.py
Normal file
@@ -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")
|
||||||
@@ -32,6 +32,7 @@ def create_all_tables() -> None:
|
|||||||
# model metadata with the shared SQLAlchemy base before create_all runs.
|
# 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.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.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)
|
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
|
||||||
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
|
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
|
||||||
|
|||||||
@@ -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.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.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.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
|
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
|
||||||
|
|||||||
563
src/govoplan_core/security/credential_envelopes.py
Normal file
563
src/govoplan_core/security/credential_envelopes.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
@@ -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.config import GovoplanServerConfig, load_server_config
|
||||||
from govoplan_core.server.fastapi import create_govoplan_app
|
from govoplan_core.server.fastapi import create_govoplan_app
|
||||||
from govoplan_core.server.platform import create_platform_router
|
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.registry import available_module_manifests, build_platform_registry
|
||||||
from govoplan_core.server.route_validation import validate_no_route_collisions
|
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:
|
for router in server_config.base_routers:
|
||||||
api_router.include_router(router)
|
api_router.include_router(router)
|
||||||
api_router.include_router(create_platform_router(settings=server_config.settings))
|
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:
|
for router in server_config.post_module_routers:
|
||||||
api_router.include_router(router)
|
api_router.include_router(router)
|
||||||
for contribution in server_config.extra_routers:
|
for contribution in server_config.extra_routers:
|
||||||
|
|||||||
380
src/govoplan_core/server/credentials.py
Normal file
380
src/govoplan_core/server/credentials.py
Normal file
@@ -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"]
|
||||||
245
tests/test_credential_envelopes.py
Normal file
245
tests/test_credential_envelopes.py
Normal file
@@ -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()
|
||||||
@@ -232,7 +232,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(current, configured_migration_heads(url, migration_track="dev"))
|
self.assertEqual(current, configured_migration_heads(url, migration_track="dev"))
|
||||||
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
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("audit_outbox_events", tables)
|
||||||
self.assertIn("calendar_outbox_operations", tables)
|
self.assertIn("calendar_outbox_operations", tables)
|
||||||
self.assertIn("file_connector_profiles", tables)
|
self.assertIn("file_connector_profiles", tables)
|
||||||
|
|||||||
76
webui/src/api/credentials.ts
Normal file
76
webui/src/api/credentials.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
secret_data?: Record<string, string>;
|
||||||
|
allowed_modules?: string[];
|
||||||
|
allowed_server_refs?: string[];
|
||||||
|
inherit_to_lower_scopes?: boolean;
|
||||||
|
is_active?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CredentialEnvelopeUpdatePayload = Partial<
|
||||||
|
Omit<CredentialEnvelopePayload, "scope_type" | "scope_id">
|
||||||
|
> & {
|
||||||
|
clear_secret?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listCredentialEnvelopes(
|
||||||
|
settings: ApiSettings,
|
||||||
|
params: {
|
||||||
|
scope_type: MailProfileScope;
|
||||||
|
scope_id?: string | null;
|
||||||
|
include_inactive?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<CredentialEnvelopeSummary[]> {
|
||||||
|
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<CredentialEnvelopeSummary> {
|
||||||
|
return apiFetch<CredentialEnvelopeSummary>(settings, "/api/v1/credentials", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCredentialEnvelope(
|
||||||
|
settings: ApiSettings,
|
||||||
|
credentialId: string,
|
||||||
|
payload: CredentialEnvelopeUpdatePayload
|
||||||
|
): Promise<CredentialEnvelopeSummary> {
|
||||||
|
return apiFetch<CredentialEnvelopeSummary>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/credentials/${encodeURIComponent(credentialId)}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCredentialEnvelope(
|
||||||
|
settings: ApiSettings,
|
||||||
|
credentialId: string
|
||||||
|
): Promise<CredentialEnvelopeSummary> {
|
||||||
|
return apiFetch<CredentialEnvelopeSummary>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/credentials/${encodeURIComponent(credentialId)}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
MailCredentialEnvelope,
|
||||||
MailImapTransportSettings,
|
MailImapTransportSettings,
|
||||||
MailProfilePatternKey,
|
MailProfilePatternKey,
|
||||||
MailProfilePolicy,
|
MailProfilePolicy,
|
||||||
@@ -11,11 +12,13 @@ import type {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
MailCredentialEnvelope,
|
||||||
MailCredentialPolicy,
|
MailCredentialPolicy,
|
||||||
MailProfilePatternKey,
|
MailProfilePatternKey,
|
||||||
MailProfilePolicy,
|
MailProfilePolicy,
|
||||||
MailProfileScope,
|
MailProfileScope,
|
||||||
MailSecurity,
|
MailSecurity,
|
||||||
|
MailServerEndpoint,
|
||||||
MailServerProfile
|
MailServerProfile
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
@@ -24,6 +27,7 @@ export type MailImapTestPayload = MailImapTransportSettings;
|
|||||||
export type MailTransportCredentialsPayload = MailTransportCredentials;
|
export type MailTransportCredentialsPayload = MailTransportCredentials;
|
||||||
export type MailServerProfileCredentialsPayload = MailServerProfileCredentials;
|
export type MailServerProfileCredentialsPayload = MailServerProfileCredentials;
|
||||||
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
|
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
|
||||||
|
export type MailCredentialListResponse = { credentials: MailCredentialEnvelope[] };
|
||||||
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
||||||
|
|
||||||
export type MailConnectionTestResponse = {
|
export type MailConnectionTestResponse = {
|
||||||
@@ -110,6 +114,7 @@ export type MailServerProfilePayload = {
|
|||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
|
inherit_to_lower_scopes?: boolean;
|
||||||
scope_type?: MailProfileScope;
|
scope_type?: MailProfileScope;
|
||||||
scope_id?: string | null;
|
scope_id?: string | null;
|
||||||
smtp: MailSmtpTestPayload;
|
smtp: MailSmtpTestPayload;
|
||||||
|
|||||||
526
webui/src/components/CredentialEnvelopeManager.tsx
Normal file
526
webui/src/components/CredentialEnvelopeManager.tsx
Normal file
@@ -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<MailProfileScope, "system" | "tenant" | "user" | "group">;
|
||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<CredentialEnvelopeSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const [editing, setEditing] = useState<CredentialEnvelopeSummary | "new" | null>(null);
|
||||||
|
const [draft, setDraft] = useState<CredentialDraft>(EMPTY_DRAFT);
|
||||||
|
const [savedDraftKey, setSavedDraftKey] = useState("");
|
||||||
|
const [deleting, setDeleting] = useState<CredentialEnvelopeSummary | null>(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<boolean> {
|
||||||
|
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<ConnectionTreeColumn<CredentialEnvelopeSummary>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "credential",
|
||||||
|
header: "Credential",
|
||||||
|
width: "minmax(240px, 1.4fr)",
|
||||||
|
render: (credential) => (
|
||||||
|
<div className="connection-tree-main">
|
||||||
|
<strong>{credential.name}</strong>
|
||||||
|
<div className="connection-tree-muted-line">
|
||||||
|
{credential.description || credential.public_data.username
|
||||||
|
? String(credential.description || credential.public_data.username)
|
||||||
|
: credential.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => (
|
||||||
|
<StatusBadge
|
||||||
|
status={credential.is_active && credential.secret_configured ? "success" : "inactive"}
|
||||||
|
label={
|
||||||
|
credential.is_active
|
||||||
|
? credential.secret_configured
|
||||||
|
? "Ready"
|
||||||
|
: "No secret"
|
||||||
|
: "Inactive"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="credential-envelope-manager">
|
||||||
|
{targetOptions.length > 0 && (
|
||||||
|
<FormField label={targetLabel}>
|
||||||
|
<select
|
||||||
|
value={selectedTargetId}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) => setSelectedTargetId(event.target.value)}
|
||||||
|
>
|
||||||
|
{targetOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.label}{option.secondary ? ` - ${option.secondary}` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||||
|
<Card
|
||||||
|
title={title}
|
||||||
|
actions={
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
title="Reload credentials"
|
||||||
|
aria-label="Reload credentials"
|
||||||
|
onClick={() => void loadCredentials()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
onClick={openCreate}
|
||||||
|
disabled={!canWrite || !scopeReady || loading}
|
||||||
|
>
|
||||||
|
<Plus size={16} /> Add credential
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LoadingFrame loading={loading}>
|
||||||
|
<ConnectionTree
|
||||||
|
rows={credentials}
|
||||||
|
columns={columns}
|
||||||
|
getRowKey={(credential) => credential.id}
|
||||||
|
emptyText="No credentials are configured for this scope."
|
||||||
|
renderActions={(credential) => (
|
||||||
|
<TableActionGroup
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
id: "edit",
|
||||||
|
label: `Edit ${credential.name}`,
|
||||||
|
icon: <Pencil size={15} />,
|
||||||
|
onClick: () => openEdit(credential),
|
||||||
|
disabled: !canWrite || saving
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
label: `Delete ${credential.name}`,
|
||||||
|
icon: <Trash2 size={15} />,
|
||||||
|
onClick: () => setDeleting(credential),
|
||||||
|
disabled: !canWrite || saving,
|
||||||
|
variant: "danger"
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</LoadingFrame>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(editing)}
|
||||||
|
title={editing === "new" ? "Add reusable credential" : "Edit reusable credential"}
|
||||||
|
onClose={closeEditor}
|
||||||
|
closeDisabled={saving}
|
||||||
|
className="admin-dialog admin-dialog-wide adaptive-config-dialog"
|
||||||
|
footerClassName="button-row compact-actions"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={closeEditor} disabled={saving}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={() => void saveDraft()} disabled={saveDisabled}>
|
||||||
|
<KeyRound size={16} /> {saving ? "Saving" : "Save credential"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="adaptive-config-form">
|
||||||
|
<section className="adaptive-config-section">
|
||||||
|
<header>
|
||||||
|
<h3>Identity</h3>
|
||||||
|
<p>The name and type are visible; secret values are never returned by the API.</p>
|
||||||
|
</header>
|
||||||
|
<div className="form-grid two">
|
||||||
|
<FormField label="Name">
|
||||||
|
<input value={draft.name} disabled={saving} onChange={(event) => setDraft({ ...draft, name: event.target.value })} autoFocus />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Type">
|
||||||
|
<select value={draft.credentialKind} disabled={saving} onChange={(event) => setDraft({ ...draft, credentialKind: event.target.value as CredentialKind, secret: "", clearSecret: false })}>
|
||||||
|
<option value="username_password">Username and password</option>
|
||||||
|
<option value="token">Access token</option>
|
||||||
|
<option value="api_key">API key</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Description">
|
||||||
|
<input value={draft.description} disabled={saving} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label={draft.credentialKind === "username_password" ? "Username" : "Account or key label"}>
|
||||||
|
<input value={draft.username} disabled={saving} onChange={(event) => setDraft({ ...draft, username: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
label={secretFieldLabel(draft.credentialKind)}
|
||||||
|
help={editing !== "new" ? "Leave blank to retain the configured secret." : undefined}
|
||||||
|
>
|
||||||
|
<PasswordField value={draft.secret} onValueChange={(secret) => setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" />
|
||||||
|
</FormField>
|
||||||
|
{editing !== "new" && (
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={draft.clearSecret}
|
||||||
|
disabled={saving || Boolean(draft.secret)}
|
||||||
|
onChange={(clearSecret) => setDraft({ ...draft, clearSecret })}
|
||||||
|
label="Remove configured secret"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="adaptive-config-section">
|
||||||
|
<header>
|
||||||
|
<h3>Availability</h3>
|
||||||
|
<p>Empty module or server lists mean every module or server allowed by scope.</p>
|
||||||
|
</header>
|
||||||
|
<div className="form-grid two">
|
||||||
|
<FormField label="Modules" help="Comma-separated module IDs, for example mail, files, calendar, addresses.">
|
||||||
|
<input value={draft.allowedModules} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedModules: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Servers" help="Optional references such as mail:server-id, files:connection-id, calendar:source-id, or addresses:source-id.">
|
||||||
|
<input value={draft.allowedServerRefs} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedServerRefs: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" />
|
||||||
|
<ToggleSwitch checked={draft.isActive} disabled={saving} onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleting)}
|
||||||
|
title="Delete credential"
|
||||||
|
message={`Delete ${deleting?.name ?? "this credential"}? Connections that reference it will stop authenticating; the secret cannot be recovered.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
tone="danger"
|
||||||
|
busy={saving}
|
||||||
|
onCancel={() => setDeleting(null)}
|
||||||
|
onConfirm={() => void confirmDelete()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/Unsave
|
|||||||
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
|
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||||
|
import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager";
|
||||||
|
|
||||||
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
|
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" }
|
{ value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" }
|
||||||
];
|
];
|
||||||
|
|
||||||
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
|
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, canUseCredentials: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
|
||||||
const groups: ModuleSubnavGroup<SettingsSection>[] = [
|
const groups: ModuleSubnavGroup<SettingsSection>[] = [
|
||||||
{
|
{
|
||||||
title: "i18n:govoplan-core.account.f967543b",
|
title: "i18n:govoplan-core.account.f967543b",
|
||||||
items: [
|
items: [
|
||||||
{ id: "profile", label: "i18n:govoplan-core.my_profile.2f3df0a9" },
|
{ id: "profile", label: "i18n:govoplan-core.my_profile.2f3df0a9" },
|
||||||
...(canUseMailProfiles ? [{ id: "mail-profiles" as const, label: "i18n:govoplan-core.mail_profiles.8a8018b7" }] : []),
|
...(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"
|
"admin:policies:write"
|
||||||
]);
|
]);
|
||||||
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings: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(
|
const contributedSections = useMemo(
|
||||||
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
||||||
[auth, settingsSectionCapabilities]
|
[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 availableSectionIds = useMemo(() => new Set(settingsSubnav.flatMap((group) => group.items.flatMap((item) => "id" in item ? [item.id] : []))), [settingsSubnav]);
|
||||||
const requestedSection = searchParams.get("section");
|
const requestedSection = searchParams.get("section");
|
||||||
const active: SettingsSection = settingsSectionAvailable(availableSectionIds, requestedSection) ? requestedSection : "interface";
|
const active: SettingsSection = settingsSectionAvailable(availableSectionIds, requestedSection) ? requestedSection : "interface";
|
||||||
@@ -343,6 +353,21 @@ export default function SettingsPage({
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{active === "credentials" &&
|
||||||
|
<CredentialEnvelopeManager
|
||||||
|
settings={settings}
|
||||||
|
scopeType="user"
|
||||||
|
scopeId={auth.user.id}
|
||||||
|
title="My reusable credentials"
|
||||||
|
canWrite={hasAnyScope(auth, [
|
||||||
|
"access:credential:write",
|
||||||
|
"access:credential:manage_own",
|
||||||
|
"mail:secret:manage_own",
|
||||||
|
"admin:settings:write"
|
||||||
|
])} />
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
{active === "interface" &&
|
{active === "interface" &&
|
||||||
<div className="dashboard-grid settings-dashboard-grid">
|
<div className="dashboard-grid settings-dashboard-grid">
|
||||||
<Card title="i18n:govoplan-core.interface_preferences.b82b39a7">
|
<Card title="i18n:govoplan-core.interface_preferences.b82b39a7">
|
||||||
@@ -505,6 +530,7 @@ function sectionOrder(sectionId: string, contributedSections: SettingsSectionCon
|
|||||||
profile: 10,
|
profile: 10,
|
||||||
"mail-profiles": 20,
|
"mail-profiles": 20,
|
||||||
"file-connectors": 30,
|
"file-connectors": 30,
|
||||||
|
"credentials": 40,
|
||||||
interface: 10,
|
interface: 10,
|
||||||
workspace: 20,
|
workspace: 20,
|
||||||
"local-connection": 30
|
"local-connection": 30
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/Con
|
|||||||
export { default as ColorPickerField } from "./components/ColorPickerField";
|
export { default as ColorPickerField } from "./components/ColorPickerField";
|
||||||
export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel";
|
export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel";
|
||||||
export type { CredentialFieldsProps, CredentialPanelProps, CredentialValues } 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 DateField, TimeField, DateTimeField } from "./components/DateTimeField";
|
||||||
export { default as Dialog } from "./components/Dialog";
|
export { default as Dialog } from "./components/Dialog";
|
||||||
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
|
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
|
||||||
|
|||||||
@@ -464,6 +464,51 @@ export type MailServerProfileCredentials = {
|
|||||||
imap?: MailTransportCredentials | null;
|
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<string, unknown>;
|
||||||
|
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 = {
|
export type MailServerProfile = {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id?: string | null;
|
tenant_id?: string | null;
|
||||||
@@ -473,11 +518,13 @@ export type MailServerProfile = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
inherit_to_lower_scopes?: boolean;
|
||||||
smtp: MailTransportSettings;
|
smtp: MailTransportSettings;
|
||||||
imap?: MailImapTransportSettings | null;
|
imap?: MailImapTransportSettings | null;
|
||||||
credentials?: MailServerProfileCredentials | null;
|
credentials?: MailServerProfileCredentials | null;
|
||||||
smtp_password_configured?: boolean;
|
smtp_password_configured?: boolean;
|
||||||
imap_password_configured?: boolean;
|
imap_password_configured?: boolean;
|
||||||
|
servers?: MailServerEndpoint[];
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -161,5 +161,7 @@ export const adminReadScopes = [
|
|||||||
"access:system_role:read",
|
"access:system_role:read",
|
||||||
"access:audit:read",
|
"access:audit:read",
|
||||||
"access:system_setting:read",
|
"access:system_setting:read",
|
||||||
|
"access:system_credential:read",
|
||||||
|
"access:credential:read",
|
||||||
"access:governance:read"
|
"access:governance:read"
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user