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."""
|
"""Abstract storage backend for :class:`GitCredential` records."""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def create(self, credential: GitCredential) -> uuid.UUID:
|
async def create(self, credential: GitCredential) -> uuid.UUID:
|
||||||
"""Persist *credential* and return its ID."""
|
"""Persist *credential* and return its ID."""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@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."""
|
"""Retrieve a credential by ID, or ``None`` if not found."""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def delete(self, credential_id: uuid.UUID) -> None:
|
async def delete(self, credential_id: uuid.UUID) -> None:
|
||||||
"""Remove a credential by ID."""
|
"""Remove a credential by ID."""
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Security rules:
|
|||||||
- The ``encrypted_private_key`` field uses ``repr=False``.
|
- The ``encrypted_private_key`` field uses ``repr=False``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
@@ -16,11 +15,9 @@ from app.git.types import SshKeyStatus
|
|||||||
|
|
||||||
|
|
||||||
def encrypt_private_key(raw: bytes) -> str:
|
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 encrypt_value(raw.decode("utf-8"))
|
||||||
"""
|
|
||||||
return base64.b64encode(raw).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
class SshKeyPair(BaseModel):
|
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