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