feat(FN-007): implement credential storage with Fernet encryption
- Add DatabaseCredentialStorage with async CRUD operations - Create Credential SQLAlchemy model with encrypted values - Update GitCredential and AccessTokenCredential to support async - Fix SSH key encryption to use Fernet instead of base64 placeholder
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.git.credentials import CredentialStorage, GitCredential
|
||||
from app.models.credential import Credential
|
||||
|
||||
|
||||
class DatabaseCredentialStorage(CredentialStorage):
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def create(self, credential: GitCredential) -> uuid.UUID:
|
||||
row = Credential(
|
||||
id=credential.id,
|
||||
kind=str(credential.kind),
|
||||
encrypted_payload=credential.encrypted_payload,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return row.id
|
||||
|
||||
async def get(self, credential_id: uuid.UUID) -> GitCredential | None:
|
||||
row = await self.session.get(Credential, credential_id)
|
||||
if row is None:
|
||||
return None
|
||||
return GitCredential(
|
||||
id=row.id,
|
||||
kind=row.kind,
|
||||
encrypted_payload=row.encrypted_payload,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
async def delete(self, credential_id: uuid.UUID) -> None:
|
||||
row = await self.session.get(Credential, credential_id)
|
||||
if row is not None:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
@@ -40,13 +40,13 @@ class CredentialStorage(abc.ABC):
|
||||
"""Abstract storage backend for :class:`GitCredential` records."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def create(self, credential: GitCredential) -> uuid.UUID:
|
||||
async def create(self, credential: GitCredential) -> uuid.UUID:
|
||||
"""Persist *credential* and return its ID."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get(self, credential_id: uuid.UUID) -> GitCredential | None:
|
||||
async def get(self, credential_id: uuid.UUID) -> GitCredential | None:
|
||||
"""Retrieve a credential by ID, or ``None`` if not found."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def delete(self, credential_id: uuid.UUID) -> None:
|
||||
async def delete(self, credential_id: uuid.UUID) -> None:
|
||||
"""Remove a credential by ID."""
|
||||
|
||||
@@ -6,7 +6,6 @@ Security rules:
|
||||
- The ``encrypted_private_key`` field uses ``repr=False``.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
@@ -16,11 +15,9 @@ from app.git.types import SshKeyStatus
|
||||
|
||||
|
||||
def encrypt_private_key(raw: bytes) -> str:
|
||||
"""Placeholder encryption helper.
|
||||
from app.encryption import encrypt_value
|
||||
|
||||
base64-encodes *raw* until FN-009 delivers the real encryption backend.
|
||||
"""
|
||||
return base64.b64encode(raw).decode("ascii")
|
||||
return encrypt_value(raw.decode("utf-8"))
|
||||
|
||||
|
||||
class SshKeyPair(BaseModel):
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class Credential(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "credential"
|
||||
|
||||
kind: Mapped[str] = mapped_column(String(50))
|
||||
encrypted_payload: Mapped[str] = mapped_column(Text)
|
||||
Reference in New Issue
Block a user