Files
shorefront/docs/plans/2026-03-01-config-download-token.md

14 KiB

Config Download Token Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add a per-config download_token field that allows downloading the generated ZIP without OIDC auth by POSTing the token in the request body.

Architecture: A download_token (random 43-char URL-safe string) is added to the Config model and auto-generated on creation. The existing POST /configs/{id}/generate endpoint accepts an optional JSON body; if the token matches, access is granted regardless of OIDC session. A new POST /configs/{id}/regenerate-token endpoint (OIDC-only) replaces the token. The frontend shows the token on the Config Detail page with copy and regenerate buttons.

Tech Stack: Python secrets.token_urlsafe, FastAPI optional body with Optional dependency, SQLAlchemy, Alembic, React + MUI.


Task 1: Alembic migration — add download_token to configs

Files:

  • Create: backend/alembic/versions/0012_config_add_download_token.py

Step 1: Create the migration file

"""add download_token to configs

Revision ID: 0012
Revises: 0011
Create Date: 2026-03-01
"""
import secrets
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column

revision = "0012"
down_revision = "0011"
branch_labels = None
depends_on = None


def upgrade() -> None:
    op.add_column(
        "configs",
        sa.Column("download_token", sa.String(64), nullable=False, server_default=""),
    )
    # Backfill existing rows with unique tokens
    configs = table("configs", column("id", sa.Integer), column("download_token", sa.String(64)))
    conn = op.get_bind()
    for row in conn.execute(sa.select(configs.c.id)):
        conn.execute(
            configs.update()
            .where(configs.c.id == row.id)
            .values(download_token=secrets.token_urlsafe(32))
        )


def downgrade() -> None:
    op.drop_column("configs", "download_token")

Step 2: Verify migration runs (if you have a local DB)

cd backend && alembic upgrade head

Expected: no errors, configs table gains a download_token column with non-empty values.

Step 3: Commit

git add backend/alembic/versions/0012_config_add_download_token.py
git commit -m "feat: migration 0012 — add download_token to configs"

Task 2: SQLAlchemy model — add download_token field

Files:

  • Modify: backend/app/models.py:21-39

Step 1: Add the field to the Config model

In backend/app/models.py, add this import at the top alongside existing imports:

import secrets

Then add download_token to the Config class after updated_at:

download_token: Mapped[str] = mapped_column(
    String(64),
    nullable=False,
    default=lambda: secrets.token_urlsafe(32),
)

The default=lambda: secrets.token_urlsafe(32) ensures new configs created via SQLAlchemy get a token automatically.

Step 2: Commit

git add backend/app/models.py
git commit -m "feat: add download_token field to Config model"

Task 3: Pydantic schemas — expose token and add request body

Files:

  • Modify: backend/app/schemas.py

Step 1: Add download_token to ConfigOut

In backend/app/schemas.py, update ConfigOut (currently lines 29-38) to add:

class ConfigOut(BaseModel):
    id: int
    name: str
    description: str
    is_active: bool
    created_at: datetime
    updated_at: datetime
    owner_id: int
    download_token: str          # ← add this line

    model_config = {"from_attributes": True}

Step 2: Add GenerateRequest body schema

Add this new class near the bottom of backend/app/schemas.py, before GenerateOut:

# --- Generate request ---
class GenerateRequest(BaseModel):
    token: Optional[str] = None

Optional is already imported at line 3.

Step 3: Add RegenerateTokenOut response schema

class RegenerateTokenOut(BaseModel):
    download_token: str

Step 4: Commit

git add backend/app/schemas.py
git commit -m "feat: add download_token to ConfigOut and GenerateRequest schema"

Task 4: API — modify generate endpoint and add regenerate-token endpoint

Files:

  • Modify: backend/app/api/configs.py

Step 1: Update imports

At the top of backend/app/api/configs.py, add Optional to the imports:

from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session, selectinload
from app import models, schemas
from app.auth import get_current_user
from app.database import get_db
from app.shorewall_generator import ShorewallGenerator
import io
import secrets

Step 2: Replace the generate_config endpoint

Replace the entire generate_config function (lines 79-115) with:

@router.post("/{config_id}/generate")
def generate_config(
    config_id: int,
    format: str = "json",
    body: Optional[schemas.GenerateRequest] = None,
    db: Session = Depends(get_db),
    current_user: Optional[models.User] = Depends(get_current_user),
):
    token = body.token if body else None

    # Determine access: OIDC session or matching download token
    if current_user is not None:
        # Authenticated via OIDC — enforce owner check
        config = (
            db.query(models.Config)
            .filter(models.Config.id == config_id, models.Config.owner_id == current_user.id)
            .first()
        )
        if not config:
            raise HTTPException(status_code=404, detail="Config not found")
    elif token:
        # Token auth — no owner filter, just match token
        config = (
            db.query(models.Config)
            .filter(models.Config.id == config_id, models.Config.download_token == token)
            .first()
        )
        if not config:
            raise HTTPException(status_code=401, detail="Invalid token")
    else:
        raise HTTPException(status_code=401, detail="Authentication required")

    # Eagerly load relationships
    config = (
        db.query(models.Config)
        .options(
            selectinload(models.Config.zones),
            selectinload(models.Config.interfaces).selectinload(models.Interface.zone),
            selectinload(models.Config.policies).selectinload(models.Policy.src_zone),
            selectinload(models.Config.policies).selectinload(models.Policy.dst_zone),
            selectinload(models.Config.rules).selectinload(models.Rule.src_zone),
            selectinload(models.Config.rules).selectinload(models.Rule.dst_zone),
            selectinload(models.Config.snat_entries),
            selectinload(models.Config.host_entries).selectinload(models.Host.zone),
            selectinload(models.Config.params),
        )
        .filter(models.Config.id == config_id)
        .first()
    )

    generator = ShorewallGenerator(config)

    if format == "zip":
        zip_bytes = generator.as_zip()
        return StreamingResponse(
            io.BytesIO(zip_bytes),
            media_type="application/zip",
            headers={"Content-Disposition": f"attachment; filename={config.name}-shorewall.zip"},
        )

    return generator.as_json()

Important: get_current_user currently raises a 401 if no cookie is present. You need to make it return None instead of raising when there is no cookie, so the generate endpoint can fall back to token auth. See Task 5 for that change — do Task 5 before testing this.

Step 3: Add the regenerate_token endpoint

Add this after generate_config:

@router.post("/{config_id}/regenerate-token", response_model=schemas.RegenerateTokenOut)
def regenerate_token(
    config_id: int,
    db: Session = Depends(get_db),
    current_user: models.User = Depends(get_current_user),
) -> dict:
    config = _get_config_or_404(config_id, db, current_user)
    config.download_token = secrets.token_urlsafe(32)
    db.commit()
    db.refresh(config)
    return {"download_token": config.download_token}

Step 4: Commit

git add backend/app/api/configs.py
git commit -m "feat: token auth on generate endpoint and regenerate-token endpoint"

Task 5: Auth — make get_current_user optional for the generate endpoint

Files:

  • Modify: backend/app/auth.py

The generate_config endpoint needs to accept requests with no OIDC cookie (token-only auth). Currently get_current_user raises 401 when the cookie is missing. Add an optional variant that returns None instead.

Step 1: Read current get_current_user

Open backend/app/auth.py and find the get_current_user function. It currently looks roughly like:

async def get_current_user(
    access_token: Optional[str] = Cookie(default=None),
    db: Session = Depends(get_db),
) -> models.User:
    if not access_token:
        raise HTTPException(status_code=401, ...)
    ...
    return user

Step 2: Add get_optional_user that returns None when unauthenticated

Add this function after get_current_user:

async def get_optional_user(
    access_token: Optional[str] = Cookie(default=None),
    db: Session = Depends(get_db),
) -> Optional[models.User]:
    if not access_token:
        return None
    try:
        return await get_current_user(access_token=access_token, db=db)
    except HTTPException:
        return None

Step 3: Update generate_config to use get_optional_user

In backend/app/api/configs.py, update the import and the dependency in generate_config:

from app.auth import get_current_user, get_optional_user

And change the dependency in generate_config:

current_user: Optional[models.User] = Depends(get_optional_user),

Step 4: Commit

git add backend/app/auth.py backend/app/api/configs.py
git commit -m "feat: add get_optional_user for unauthenticated generate access"

Task 6: Frontend API client — add regenerateToken

Files:

  • Modify: frontend/src/api.ts:31-41

Step 1: Add regenerateToken to configsApi

In frontend/src/api.ts, update the configsApi object:

export const configsApi = {
  list: () => api.get('/configs'),
  create: (data: object) => api.post('/configs', data),
  get: (id: number) => api.get(`/configs/${id}`),
  update: (id: number, data: object) => api.put(`/configs/${id}`, data),
  delete: (id: number) => api.delete(`/configs/${id}`),
  generate: (id: number, format: 'json' | 'zip' = 'json') =>
    api.post(`/configs/${id}/generate?format=${format}`, null, {
      responseType: format === 'zip' ? 'blob' : 'json',
    }),
  regenerateToken: (id: number) =>
    api.post<{ download_token: string }>(`/configs/${id}/regenerate-token`),
}

Step 2: Commit

git add frontend/src/api.ts
git commit -m "feat: add regenerateToken to configsApi"

Task 7: Frontend — display token with copy and regenerate on Config Detail

Files:

  • Modify: frontend/src/routes/ConfigDetail.tsx

Step 1: Add state for downloadToken

In ConfigDetail.tsx, add a state variable near the other useState calls (around line 32):

const [downloadToken, setDownloadToken] = useState('')

Step 2: Load token in useEffect

The existing useEffect already fetches the config name. Update it to also set the token:

useEffect(() => {
  configsApi.get(configId).then((r) => {
    setConfigName(r.data.name)
    setDownloadToken(r.data.download_token)
  })
  // ... rest unchanged
}, [configId])

Step 3: Add the token UI block

Add these MUI imports at the top of the file (alongside existing imports):

import IconButton from '@mui/material/IconButton'
import InputAdornment from '@mui/material/InputAdornment'
import OutlinedInput from '@mui/material/OutlinedInput'
import FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import Tooltip from '@mui/material/Tooltip'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import RefreshIcon from '@mui/icons-material/Refresh'

Add a handleRegenerate function after handleDelete:

const handleRegenerate = async () => {
  if (!confirm('Regenerate the download token? The old token will stop working.')) return
  const res = await configsApi.regenerateToken(configId)
  setDownloadToken(res.data.download_token)
}

In the JSX, insert this block between the breadcrumbs/generate-button row and the tabs card (i.e., after the closing </Box> of the top row, before the <Box sx={{ bgcolor: 'white' ...):

<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
  <FormControl size="small" sx={{ flex: 1 }}>
    <InputLabel>Download Token</InputLabel>
    <OutlinedInput
      label="Download Token"
      value={downloadToken}
      readOnly
      endAdornment={
        <InputAdornment position="end">
          <Tooltip title="Copy token">
            <IconButton onClick={() => navigator.clipboard.writeText(downloadToken)} edge="end">
              <ContentCopyIcon fontSize="small" />
            </IconButton>
          </Tooltip>
        </InputAdornment>
      }
      inputProps={{ style: { fontFamily: 'monospace', fontSize: 12 } }}
    />
  </FormControl>
  <Tooltip title="Regenerate token">
    <IconButton onClick={handleRegenerate} color="warning">
      <RefreshIcon />
    </IconButton>
  </Tooltip>
</Box>

Step 4: Commit

git add frontend/src/routes/ConfigDetail.tsx
git commit -m "feat: show download token with copy and regenerate on Config Detail"

Task 8: Manual end-to-end verification

Step 1: Rebuild and restart

docker compose up --build -d

Step 2: Verify token appears in UI

Log in, open a config. You should see a "Download Token" field with a 43-character token.

Step 3: Test copy button

Click the copy icon — paste somewhere to confirm the token was copied.

Step 4: Test ZIP download with token

curl -X POST "http://localhost/api/configs/1/generate?format=zip" \
  -H 'Content-Type: application/json' \
  -d '{"token": "<paste-token>"}' \
  -o /tmp/shorewall.zip
file /tmp/shorewall.zip   # should say: Zip archive data

Step 5: Test invalid token returns 401

curl -s -o /dev/null -w "%{http_code}" -X POST \
  "http://localhost/api/configs/1/generate?format=zip" \
  -H 'Content-Type: application/json' \
  -d '{"token": "wrong"}'
# Expected: 401

Step 6: Test regenerate

Click "Regenerate" in the UI, confirm the old token stops working and the new one works.

Step 7: Commit any fixups found during testing