Short-Lived, Per-Project Storage Credentials — Phase 1: StorageBackend model + bootstrap migration

Part of short-lived-storage-credentials-00-overview.md — read that first for goal, background, and architecture summary. This is Phase 1 of 4; no dependencies on other phases.

Pure schema + data migration; no behavior change (everything still resolves to the one bootstrap row created here).

1.1 Model

New file: backend/models/storage_backend.py

from sqlalchemy import Column, String, DateTime, JSON
from datetime import datetime
import uuid

from backend.database import Base


class StorageBackend(Base):
    __tablename__ = "storage_backends"

    id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
    name = Column(String(255), nullable=False)
    protocol = Column(String(32), nullable=False)          # s3, gcs, az, file
    endpoint = Column(String(255), nullable=True)           # MinIO URL; empty for real cloud
    bucket_prefix = Column(String(255), nullable=False)
    credential_strategy = Column(String(32), nullable=False, default="static-key")
    # Strategy-specific connection config (e.g. MinIO admin alias, GCP SA email to
    # impersonate, AWS role ARN). Opaque to everything except the strategy implementation.
    config = Column(JSON, nullable=False, default=dict)
    created_at = Column(DateTime, default=datetime.utcnow, nullable=False)

    def to_dict(self):
        return {
            "id": self.id,
            "name": self.name,
            "protocol": self.protocol,
            "endpoint": self.endpoint,
            "bucket_prefix": self.bucket_prefix,
            "credential_strategy": self.credential_strategy,
            "created_at": self.created_at.isoformat(),
        }

Add Project.storage_backend_id (nullable FK, backend/models/project.py):

storage_backend_id = Column(String(36), ForeignKey("storage_backends.id"), nullable=True)
storage_backend = relationship("StorageBackend")

Nullable because historical rows are backfilled in a second step (1.2), not because it's ever expected to be unset for a project going forward.

1.2 Bootstrap migration

Follows the same pattern as 3e9d7f5a8c2d_add_bootstrap_environment.py (fixed well-known UUID, idempotency check) and e2f3a4b5c6d7_seed_initial_admin.py (from backend.config import settings read live inside upgrade()):

"""seed default storage backend from config.env"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
import os

revision = '<new>'
down_revision = '<add_storage_backends_table>'

DEFAULT_ID = 'default-storage-backend-00000000-0000-0000-0000-000000000000'


def upgrade() -> None:
    from backend.config import settings
    conn = op.get_bind()

    exists = conn.execute(
        sa.text("SELECT COUNT(*) FROM storage_backends WHERE id = :id"),
        {"id": DEFAULT_ID},
    ).scalar()

    if not exists:
        conn.execute(sa.text("""
            INSERT INTO storage_backends
                (id, name, protocol, endpoint, bucket_prefix, credential_strategy, config, created_at)
            VALUES
                (:id, 'Default Storage Backend', :protocol, :endpoint, :bucket_prefix,
                 'static-key', '{}', :created_at)
        """), {
            "id": DEFAULT_ID,
            "protocol": settings.storage_protocol,
            "endpoint": settings.storage_endpoint,
            "bucket_prefix": settings.storage_bucket_prefix,
            "created_at": datetime.utcnow().isoformat(),
        })

    conn.execute(sa.text("""
        UPDATE projects SET storage_backend_id = :id WHERE storage_backend_id IS NULL
    """), {"id": DEFAULT_ID})


def downgrade() -> None:
    pass  # cannot cleanly undo a backfill

This matches config.env in every environment because settings reads real environment variables (with config.env only as local-dev fallback) — the same values the running backend already uses today, in dev and in prod-minikube (where the k8s ConfigMap overrides STORAGE_ENDPOINT / STORAGE_PROTOCOL).

Existing projects are backfilled to this row in the same migrationstorage_backend_id is a new FK on a table with live data, unlike per-job fields, so it cannot be left NULL for rows that already have a working bucket.

Next

Once this phase lands, continue with short-lived-storage-credentials-02-hooks-run-first-select-storage.md.