diff --git a/docs/plans/2026-03-01-config-download-token.md b/docs/plans/2026-03-01-config-download-token.md new file mode 100644 index 0000000..62bcaa5 --- /dev/null +++ b/docs/plans/2026-03-01-config-download-token.md @@ -0,0 +1,506 @@ +# 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** + +```python +"""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)** + +```bash +cd backend && alembic upgrade head +``` + +Expected: no errors, `configs` table gains a `download_token` column with non-empty values. + +**Step 3: Commit** + +```bash +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: + +```python +import secrets +``` + +Then add `download_token` to the `Config` class after `updated_at`: + +```python +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** + +```bash +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: + +```python +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`: + +```python +# --- Generate request --- +class GenerateRequest(BaseModel): + token: Optional[str] = None +``` + +`Optional` is already imported at line 3. + +**Step 3: Add `RegenerateTokenOut` response schema** + +```python +class RegenerateTokenOut(BaseModel): + download_token: str +``` + +**Step 4: Commit** + +```bash +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: + +```python +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: + +```python +@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`: + +```python +@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** + +```bash +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: + +```python +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`: + +```python +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`: + +```python +from app.auth import get_current_user, get_optional_user +``` + +And change the dependency in `generate_config`: + +```python +current_user: Optional[models.User] = Depends(get_optional_user), +``` + +**Step 4: Commit** + +```bash +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: + +```typescript +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** + +```bash +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): + +```typescript +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: + +```typescript +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): + +```typescript +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`: + +```typescript +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 `` of the top row, before the ` + + Download Token + + + navigator.clipboard.writeText(downloadToken)} edge="end"> + + + + + } + inputProps={{ style: { fontFamily: 'monospace', fontSize: 12 } }} + /> + + + + + + + +``` + +**Step 4: Commit** + +```bash +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** + +```bash +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** + +```bash +curl -X POST "http://localhost/api/configs/1/generate?format=zip" \ + -H 'Content-Type: application/json' \ + -d '{"token": ""}' \ + -o /tmp/shorewall.zip +file /tmp/shorewall.zip # should say: Zip archive data +``` + +**Step 5: Test invalid token returns 401** + +```bash +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**