-
Notifications
You must be signed in to change notification settings - Fork 2
feat(management): add Fernet encrypted credential storage (AIHCM-182) #295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ceeed41
5649c65
4542957
227570b
5e20c34
431e0d9
efc8d04
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| """create encrypted_credentials table | ||
|
|
||
| Adds the encrypted_credentials table for Fernet-encrypted credential | ||
| storage in the Management bounded context. | ||
|
|
||
| Revision ID: d5e6f7a8b9c0 | ||
| Revises: c4d5e6f7a8b9 | ||
| Create Date: 2026-03-17 | ||
| """ | ||
|
|
||
| from typing import Sequence, Union | ||
|
|
||
| import sqlalchemy as sa | ||
| from alembic import op | ||
|
|
||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision: str = "d5e6f7a8b9c0" | ||
| down_revision: Union[str, Sequence[str], None] = "c4d5e6f7a8b9" | ||
| branch_labels: Union[str, Sequence[str], None] = None | ||
| depends_on: Union[str, Sequence[str], None] = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| """Create encrypted_credentials table with composite PK (path, tenant_id).""" | ||
| op.create_table( | ||
| "encrypted_credentials", | ||
| sa.Column("path", sa.String(500), primary_key=True), | ||
| sa.Column("tenant_id", sa.String(26), primary_key=True), | ||
| sa.Column("encrypted_value", sa.LargeBinary, nullable=False), | ||
| sa.Column("key_version", sa.Integer, nullable=False, server_default="0"), | ||
| sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), | ||
| sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), | ||
| ) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| """Drop encrypted_credentials table.""" | ||
| op.drop_table("encrypted_credentials") |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,35 @@ | ||||||||||||||||||||
| """SQLAlchemy ORM model for the encrypted_credentials table. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Stores Fernet-encrypted credentials in PostgreSQL, scoped by path | ||||||||||||||||||||
| and tenant_id. Part of the Management bounded context. | ||||||||||||||||||||
| """ | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from sqlalchemy import Integer, LargeBinary, String | ||||||||||||||||||||
| from sqlalchemy.orm import Mapped, mapped_column | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from infrastructure.database.models import Base, TimestampMixin | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| class EncryptedCredentialModel(Base, TimestampMixin): | ||||||||||||||||||||
| """ORM model for encrypted_credentials table. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Stores encrypted credential blobs keyed by (path, tenant_id). | ||||||||||||||||||||
| The key_version column tracks which Fernet key was used for | ||||||||||||||||||||
| encryption to support key rotation. | ||||||||||||||||||||
| """ | ||||||||||||||||||||
|
|
||||||||||||||||||||
| __tablename__ = "encrypted_credentials" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| path: Mapped[str] = mapped_column(String(500), primary_key=True) | ||||||||||||||||||||
| tenant_id: Mapped[str] = mapped_column(String(26), primary_key=True) | ||||||||||||||||||||
| encrypted_value: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) | ||||||||||||||||||||
| key_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| def __repr__(self) -> str: | ||||||||||||||||||||
| """Return string representation.""" | ||||||||||||||||||||
| return ( | ||||||||||||||||||||
| f"<EncryptedCredentialModel(path={self.path}, " | ||||||||||||||||||||
| f"tenant_id={self.tenant_id}, key_version={self.key_version})>" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
Comment on lines
+30
to
+35
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Model repr values often end up in logs and tracebacks. Exposing credential path and tenant identifier here creates unnecessary metadata leakage risk. Suggested hardening def __repr__(self) -> str:
"""Return string representation."""
- return (
- f"<EncryptedCredentialModel(path={self.path}, "
- f"tenant_id={self.tenant_id}, key_version={self.key_version})>"
- )
+ return f"<EncryptedCredentialModel(key_version={self.key_version})>"As per coding guidelines, "Focus on major issues impacting performance, readability, maintainability and security. Avoid nitpicks and avoid verbosity." 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,90 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Domain probes for secret store operations. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Following Domain-Oriented Observability patterns, these probes capture | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| domain-significant events related to credential storage operations. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from typing import TYPE_CHECKING, Any, Protocol | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import structlog | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if TYPE_CHECKING: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from shared_kernel.observability_context import ObservationContext | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class SecretStoreProbe(Protocol): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Domain probe for secret store operations.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def credential_stored(self, path: str, tenant_id: str) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Record that credentials were successfully stored.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ... | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def credential_retrieved(self, path: str, tenant_id: str) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Record that credentials were successfully retrieved.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ... | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def credential_not_found(self, path: str, tenant_id: str) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Record that credentials were not found.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ... | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def credential_deleted(self, path: str, tenant_id: str) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Record that credentials were successfully deleted.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ... | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def with_context(self, context: ObservationContext) -> SecretStoreProbe: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Create a new probe with observation context bound.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ... | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class DefaultSecretStoreProbe: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Default implementation of SecretStoreProbe using structlog.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def __init__( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger: structlog.stdlib.BoundLogger | None = None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| context: ObservationContext | None = None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._logger = logger or structlog.get_logger() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._context = context | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _get_context_kwargs(self) -> dict[str, Any]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if self._context is None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return {} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return self._context.as_dict() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def with_context(self, context: ObservationContext) -> DefaultSecretStoreProbe: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return DefaultSecretStoreProbe(logger=self._logger, context=context) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def credential_stored(self, path: str, tenant_id: str) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._logger.info( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "credential_stored", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| path=path, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tenant_id=tenant_id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| **self._get_context_kwargs(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def credential_retrieved(self, path: str, tenant_id: str) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._logger.debug( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "credential_retrieved", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| path=path, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tenant_id=tenant_id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| **self._get_context_kwargs(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def credential_not_found(self, path: str, tenant_id: str) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._logger.debug( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "credential_not_found", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| path=path, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tenant_id=tenant_id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| **self._get_context_kwargs(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def credential_deleted(self, path: str, tenant_id: str) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._logger.info( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "credential_deleted", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| path=path, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tenant_id=tenant_id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| **self._get_context_kwargs(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+60
to
+90
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probe logs currently expose sensitive identifiers ( Credential operation telemetry should avoid raw identifiers. Emitting these fields directly increases leakage risk in centralized logs. Minimize logged sensitive metadata def credential_stored(self, path: str, tenant_id: str) -> None:
self._logger.info(
"credential_stored",
- path=path,
- tenant_id=tenant_id,
**self._get_context_kwargs(),
)
@@
def credential_retrieved(self, path: str, tenant_id: str) -> None:
self._logger.debug(
"credential_retrieved",
- path=path,
- tenant_id=tenant_id,
**self._get_context_kwargs(),
)
@@
def credential_not_found(self, path: str, tenant_id: str) -> None:
self._logger.debug(
"credential_not_found",
- path=path,
- tenant_id=tenant_id,
**self._get_context_kwargs(),
)
@@
def credential_deleted(self, path: str, tenant_id: str) -> None:
self._logger.info(
"credential_deleted",
- path=path,
- tenant_id=tenant_id,
**self._get_context_kwargs(),
)As per coding guidelines, "Focus on major issues impacting performance, readability, maintainability and security. Avoid nitpicks and avoid verbosity." 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| """Fernet-encrypted secret store implementation. | ||
|
|
||
| Uses cryptography.fernet.MultiFernet to encrypt credentials at rest | ||
| in PostgreSQL, supporting key rotation via multiple Fernet keys. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
|
|
||
| from cryptography.fernet import Fernet, MultiFernet | ||
| from sqlalchemy import select | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
|
|
||
| from management.infrastructure.models.encrypted_credential import ( | ||
| EncryptedCredentialModel, | ||
| ) | ||
| from management.infrastructure.observability.secret_store_probe import ( | ||
| DefaultSecretStoreProbe, | ||
| SecretStoreProbe, | ||
| ) | ||
|
|
||
|
|
||
| def _validate_inputs(path: str, tenant_id: str) -> None: | ||
| """Validate that path and tenant_id are non-empty.""" | ||
| if not path or not path.strip(): | ||
| raise ValueError("path must not be empty or whitespace-only") | ||
| if not tenant_id or not tenant_id.strip(): | ||
| raise ValueError("tenant_id must not be empty or whitespace-only") | ||
|
|
||
|
|
||
| class FernetSecretStore: | ||
| """Fernet-encrypted credential storage backed by PostgreSQL. | ||
|
|
||
| Implements both ISecretStoreRepository (management port) and | ||
| ICredentialReader (shared kernel) for a single implementation | ||
| that satisfies both read-write and read-only consumers. | ||
|
|
||
| Uses MultiFernet to support key rotation: the first key in the | ||
| list is used for encryption, and all keys are tried for decryption. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| session: AsyncSession, | ||
| encryption_keys: list[str], | ||
| probe: SecretStoreProbe | None = None, | ||
| ) -> None: | ||
| self._session = session | ||
| self._multi_fernet = MultiFernet([Fernet(key) for key in encryption_keys]) | ||
| self._probe: SecretStoreProbe = probe or DefaultSecretStoreProbe() | ||
|
|
||
| async def store( | ||
| self, path: str, tenant_id: str, credentials: dict[str, str] | ||
| ) -> None: | ||
| """Encrypt and persist credentials via upsert.""" | ||
| _validate_inputs(path, tenant_id) | ||
|
|
||
| plaintext = json.dumps(credentials).encode("utf-8") | ||
| encrypted = self._multi_fernet.encrypt(plaintext) | ||
|
|
||
| model = EncryptedCredentialModel( | ||
| path=path, | ||
| tenant_id=tenant_id, | ||
| encrypted_value=encrypted, | ||
| key_version=0, | ||
| ) | ||
| await self._session.merge(model) | ||
| await self._session.flush() | ||
| self._probe.credential_stored(path, tenant_id) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid sending raw These values are forwarded as-is and Proposed fix+import hashlib
import json
@@
+def _safe_identifier(value: str) -> str:
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
+
@@
- self._probe.credential_stored(path, tenant_id)
+ self._probe.credential_stored(
+ _safe_identifier(path), _safe_identifier(tenant_id)
+ )
@@
- self._probe.credential_not_found(path, tenant_id)
+ self._probe.credential_not_found(
+ _safe_identifier(path), _safe_identifier(tenant_id)
+ )
raise KeyError("Credentials not found")
@@
- self._probe.credential_retrieved(path, tenant_id)
+ self._probe.credential_retrieved(
+ _safe_identifier(path), _safe_identifier(tenant_id)
+ )
@@
- self._probe.credential_deleted(path, tenant_id)
+ self._probe.credential_deleted(
+ _safe_identifier(path), _safe_identifier(tenant_id)
+ )As per coding guidelines, "Focus on major issues impacting performance, readability, maintainability and security. Avoid nitpicks and avoid verbosity." Also applies to: 88-89, 92-93, 111-111 🤖 Prompt for AI Agents |
||
|
|
||
| async def retrieve(self, path: str, tenant_id: str) -> dict[str, str]: | ||
| """Decrypt and return stored credentials. | ||
|
|
||
| Raises: | ||
| KeyError: If no credentials exist at the given path for the tenant. | ||
| """ | ||
| _validate_inputs(path, tenant_id) | ||
|
|
||
| stmt = select(EncryptedCredentialModel).where( | ||
| EncryptedCredentialModel.path == path, | ||
| EncryptedCredentialModel.tenant_id == tenant_id, | ||
| ) | ||
| result = await self._session.execute(stmt) | ||
| model = result.scalar_one_or_none() | ||
|
|
||
| if model is None: | ||
| self._probe.credential_not_found(path, tenant_id) | ||
| raise KeyError("Credentials not found") | ||
|
|
||
| decrypted = self._multi_fernet.decrypt(model.encrypted_value) | ||
| self._probe.credential_retrieved(path, tenant_id) | ||
| return json.loads(decrypted.decode("utf-8")) | ||
|
|
||
| async def delete(self, path: str, tenant_id: str) -> bool: | ||
| """Remove stored credentials. Returns True if deleted.""" | ||
| _validate_inputs(path, tenant_id) | ||
|
|
||
| stmt = select(EncryptedCredentialModel).where( | ||
| EncryptedCredentialModel.path == path, | ||
| EncryptedCredentialModel.tenant_id == tenant_id, | ||
| ) | ||
| result = await self._session.execute(stmt) | ||
| model = result.scalar_one_or_none() | ||
|
|
||
| if model is None: | ||
| return False | ||
|
|
||
| await self._session.delete(model) | ||
| await self._session.flush() | ||
| self._probe.credential_deleted(path, tenant_id) | ||
| return True | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 5912
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 16174
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 146
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 161
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 105
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 1977
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 2379
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 585
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 4065
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 10423
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 57
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 1685
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 57
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 4559
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 57
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 6913
🏁 Script executed:
Repository: openshift-hyperfleet/kartograph
Length of output: 50451
Add validation to
encryption_keyfield to fail fast on misconfiguration.The
encryption_keyfield accepts any string without validation. Empty strings, extra commas, or malformed Fernet keys will only fail later when cryptographic operations are attempted. Validate the field at settings load time to surface configuration errors at startup.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents