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

507 lines
14 KiB
Markdown

# 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 `</Box>` of the top row, before the `<Box sx={{ bgcolor: 'white' ...`):
```tsx
<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**
```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": "<paste-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**