Add shared credential envelope infrastructure

This commit is contained in:
2026-07-28 19:32:41 +02:00
parent 3f5870281a
commit 13bc3d3b4e
17 changed files with 2094 additions and 4 deletions

View File

@@ -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")

View File

@@ -12,6 +12,7 @@ except ModuleNotFoundError as exc:
raise
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
from govoplan_core.core.migrations import migration_metadata_plan
from govoplan_core.db.base import Base
from govoplan_core.server.default_config import get_server_config

View 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")

View File

@@ -32,6 +32,7 @@ def create_all_tables() -> None:
# model metadata with the shared SQLAlchemy base before create_all runs.
from govoplan_core.admin import models as core_admin_models # noqa: F401
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)

View File

@@ -18,6 +18,7 @@ from sqlalchemy import create_engine, inspect, text
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult

View 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",
]

View File

@@ -8,6 +8,7 @@ from govoplan_core.db.session import configure_database
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.credentials import router as credential_router
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import validate_no_route_collisions
@@ -67,6 +68,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
for router in server_config.base_routers:
api_router.include_router(router)
api_router.include_router(create_platform_router(settings=server_config.settings))
api_router.include_router(credential_router)
for router in server_config.post_module_routers:
api_router.include_router(router)
for contribution in server_config.extra_routers:

View 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"]

View 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()

View File

@@ -232,7 +232,7 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertEqual(current, configured_migration_heads(url, migration_track="dev"))
self.assertEqual(result.current_revision, ",".join(sorted(current)))
self.assertIn("0f1e2d3c4b5a", current)
self.assertIn("core_credential_envelopes", tables)
self.assertIn("audit_outbox_events", tables)
self.assertIn("calendar_outbox_operations", tables)
self.assertIn("file_connector_profiles", tables)

View 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" }
);
}

View File

@@ -1,4 +1,5 @@
import type {
MailCredentialEnvelope,
MailImapTransportSettings,
MailProfilePatternKey,
MailProfilePolicy,
@@ -11,11 +12,13 @@ import type {
} from "../types";
export type {
MailCredentialEnvelope,
MailCredentialPolicy,
MailProfilePatternKey,
MailProfilePolicy,
MailProfileScope,
MailSecurity,
MailServerEndpoint,
MailServerProfile
} from "../types";
@@ -24,6 +27,7 @@ export type MailImapTestPayload = MailImapTransportSettings;
export type MailTransportCredentialsPayload = MailTransportCredentials;
export type MailServerProfileCredentialsPayload = MailServerProfileCredentials;
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
export type MailCredentialListResponse = { credentials: MailCredentialEnvelope[] };
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export type MailConnectionTestResponse = {
@@ -110,6 +114,7 @@ export type MailServerProfilePayload = {
slug?: string | null;
description?: string | null;
is_active?: boolean;
inherit_to_lower_scopes?: boolean;
scope_type?: MailProfileScope;
scope_id?: string | null;
smtp: MailSmtpTestPayload;

View 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);
}

View File

@@ -16,6 +16,7 @@ import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/Unsave
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
import { hasAnyScope, hasScope } from "../../utils/permissions";
import { usePlatformLanguage } from "../../i18n/LanguageContext";
import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager";
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
@@ -33,14 +34,15 @@ const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
{ value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" }
];
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, canUseCredentials: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
const groups: ModuleSubnavGroup<SettingsSection>[] = [
{
title: "i18n:govoplan-core.account.f967543b",
items: [
{ id: "profile", label: "i18n:govoplan-core.my_profile.2f3df0a9" },
...(canUseMailProfiles ? [{ id: "mail-profiles" as const, label: "i18n:govoplan-core.mail_profiles.8a8018b7" }] : []),
...(canUseFileConnectors ? [{ id: "file-connectors" as const, label: "i18n:govoplan-core.file_connections.1e362326" }] : [])]
...(canUseFileConnectors ? [{ id: "file-connectors" as const, label: "i18n:govoplan-core.file_connections.1e362326" }] : []),
...(canUseCredentials ? [{ id: "credentials" as const, label: "i18n:govoplan-core.credentials.dd097a22" }] : [])]
},
{
@@ -102,11 +104,19 @@ export default function SettingsPage({
"admin:policies:write"
]);
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
const canUseCredentials = hasAnyScope(auth, [
"access:credential:read",
"access:credential:write",
"access:credential:manage_own",
"mail:secret:manage_own",
"admin:settings:read",
"admin:settings:write"
]);
const contributedSections = useMemo(
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
[auth, settingsSectionCapabilities]
);
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, contributedSections), [canUseFileConnectors, canUseMailProfiles, contributedSections]);
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, canUseCredentials, contributedSections), [canUseCredentials, canUseFileConnectors, canUseMailProfiles, contributedSections]);
const availableSectionIds = useMemo(() => new Set(settingsSubnav.flatMap((group) => group.items.flatMap((item) => "id" in item ? [item.id] : []))), [settingsSubnav]);
const requestedSection = searchParams.get("section");
const active: SettingsSection = settingsSectionAvailable(availableSectionIds, requestedSection) ? requestedSection : "interface";
@@ -343,6 +353,21 @@ export default function SettingsPage({
}
{active === "credentials" &&
<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" &&
<div className="dashboard-grid settings-dashboard-grid">
<Card title="i18n:govoplan-core.interface_preferences.b82b39a7">
@@ -505,6 +530,7 @@ function sectionOrder(sectionId: string, contributedSections: SettingsSectionCon
profile: 10,
"mail-profiles": 20,
"file-connectors": 30,
"credentials": 40,
interface: 10,
workspace: 20,
"local-connection": 30

View File

@@ -57,6 +57,15 @@ export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/Con
export { default as ColorPickerField } from "./components/ColorPickerField";
export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel";
export type { CredentialFieldsProps, CredentialPanelProps, CredentialValues } from "./components/CredentialPanel";
export { default as CredentialEnvelopeManager } from "./components/CredentialEnvelopeManager";
export type { CredentialEnvelopeManagerProps, CredentialEnvelopeTargetOption } from "./components/CredentialEnvelopeManager";
export {
createCredentialEnvelope,
deleteCredentialEnvelope,
listCredentialEnvelopes,
updateCredentialEnvelope
} from "./api/credentials";
export type { CredentialEnvelopePayload, CredentialEnvelopeUpdatePayload } from "./api/credentials";
export { default as DateField, TimeField, DateTimeField } from "./components/DateTimeField";
export { default as Dialog } from "./components/Dialog";
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";

View File

@@ -464,6 +464,51 @@ export type MailServerProfileCredentials = {
imap?: MailTransportCredentials | null;
};
export type CredentialEnvelopeSummary = {
id: string;
tenant_id?: string | null;
scope_type: MailProfileScope;
scope_id?: string | null;
name: string;
description?: string | null;
credential_kind: string;
public_data: Record<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 = {
id: string;
tenant_id?: string | null;
@@ -473,11 +518,13 @@ export type MailServerProfile = {
slug: string;
description?: string | null;
is_active: boolean;
inherit_to_lower_scopes?: boolean;
smtp: MailTransportSettings;
imap?: MailImapTransportSettings | null;
credentials?: MailServerProfileCredentials | null;
smtp_password_configured?: boolean;
imap_password_configured?: boolean;
servers?: MailServerEndpoint[];
created_at?: string;
updated_at?: string;
};

View File

@@ -161,5 +161,7 @@ export const adminReadScopes = [
"access:system_role:read",
"access:audit:read",
"access:system_setting:read",
"access:system_credential:read",
"access:credential:read",
"access:governance:read"
];