Add shared credential envelope infrastructure
This commit is contained in:
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user