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
|
||||
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
|
||||
|
||||
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.
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
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.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:
|
||||
|
||||
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(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)
|
||||
|
||||
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 {
|
||||
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;
|
||||
|
||||
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 { 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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user