diff --git a/apps/api/app/git/credential_storage.py b/apps/api/app/git/credential_storage.py new file mode 100644 index 0000000..6159a37 --- /dev/null +++ b/apps/api/app/git/credential_storage.py @@ -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() diff --git a/apps/api/app/git/credentials.py b/apps/api/app/git/credentials.py index 52c9eff..f93dd1e 100644 --- a/apps/api/app/git/credentials.py +++ b/apps/api/app/git/credentials.py @@ -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.""" diff --git a/apps/api/app/git/ssh_key.py b/apps/api/app/git/ssh_key.py index 6b26367..af7ceb5 100644 --- a/apps/api/app/git/ssh_key.py +++ b/apps/api/app/git/ssh_key.py @@ -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): diff --git a/apps/api/app/models/credential.py b/apps/api/app/models/credential.py new file mode 100644 index 0000000..b9a1545 --- /dev/null +++ b/apps/api/app/models/credential.py @@ -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)