Spaces:
Running
Running
Upload 158 files
Browse files- .dockerignore +6 -0
- .env.example +16 -0
- .gitignore +6 -0
- Dockerfile +3 -1
- README.md +184 -17
- app/__init__.py +1 -1
- app/api/api_keys.py +171 -0
- app/container.py +28 -12
- app/core/config.py +35 -2
- app/mcp/registry.py +103 -5
- app/mcp/server.py +50 -4
- app/security/__init__.py +6 -0
- app/security/audit.py +57 -0
- app/security/cli.py +64 -0
- app/security/context.py +35 -0
- app/security/database.py +49 -0
- app/security/errors.py +24 -0
- app/security/middleware.py +193 -0
- app/security/migrations/0001_api_key_security.sql +58 -0
- app/security/models.py +97 -0
- app/security/policy.py +89 -0
- app/security/rate_limit.py +132 -0
- app/security/schemas.py +151 -0
- app/security/scopes.py +94 -0
- app/security/service.py +375 -0
- main.py +51 -1
- requirements.txt +2 -0
- tests/conftest.py +2 -0
- tests/test_authentication.py +307 -0
.dockerignore
CHANGED
|
@@ -2,12 +2,18 @@
|
|
| 2 |
.venv
|
| 3 |
.github
|
| 4 |
.env
|
|
|
|
|
|
|
| 5 |
.pytest_cache
|
| 6 |
.ruff_cache
|
| 7 |
__pycache__
|
| 8 |
*.py[cod]
|
| 9 |
*.log
|
| 10 |
*.zip
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
outputs/*
|
| 12 |
!outputs/.gitkeep
|
| 13 |
temp/*
|
|
|
|
| 2 |
.venv
|
| 3 |
.github
|
| 4 |
.env
|
| 5 |
+
.env.*
|
| 6 |
+
!.env.example
|
| 7 |
.pytest_cache
|
| 8 |
.ruff_cache
|
| 9 |
__pycache__
|
| 10 |
*.py[cod]
|
| 11 |
*.log
|
| 12 |
*.zip
|
| 13 |
+
*.db
|
| 14 |
+
*.sqlite
|
| 15 |
+
*.sqlite3
|
| 16 |
+
data
|
| 17 |
outputs/*
|
| 18 |
!outputs/.gitkeep
|
| 19 |
temp/*
|
.env.example
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
TEMP_DIR=./temp
|
| 2 |
OUTPUT_DIR=./outputs
|
| 3 |
TEMPLATE_DIR=./app/templates/categories
|
|
@@ -14,3 +15,18 @@ ALLOW_PRIVATE_URLS=false
|
|
| 14 |
BASE_URL=
|
| 15 |
FFMPEG_BINARY=ffmpeg
|
| 16 |
FFPROBE_BINARY=ffprobe
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
APP_NAME=MediaRouter
|
| 2 |
TEMP_DIR=./temp
|
| 3 |
OUTPUT_DIR=./outputs
|
| 4 |
TEMPLATE_DIR=./app/templates/categories
|
|
|
|
| 15 |
BASE_URL=
|
| 16 |
FFMPEG_BINARY=ffmpeg
|
| 17 |
FFPROBE_BINARY=ffprobe
|
| 18 |
+
AUTH_ENABLED=true
|
| 19 |
+
DATABASE_URL=sqlite+aiosqlite:///./data/mediarouter.db
|
| 20 |
+
AUTH_ROLE_SCOPES={}
|
| 21 |
+
AUTH_BOOTSTRAP_KEY_HASH=
|
| 22 |
+
AUTH_BOOTSTRAP_KEY_PREFIX=
|
| 23 |
+
AUTH_BOOTSTRAP_KEY_NAME=Bootstrap Administrator
|
| 24 |
+
AUTH_BOOTSTRAP_ENVIRONMENT=live
|
| 25 |
+
AUTH_LAST_USED_UPDATE_SECONDS=60
|
| 26 |
+
AUTH_DEFAULT_REQUESTS_PER_MINUTE=100
|
| 27 |
+
AUTH_DEFAULT_CONCURRENT_JOBS=10
|
| 28 |
+
AUTH_DEFAULT_UPLOADS_PER_HOUR=20
|
| 29 |
+
AUTH_DEFAULT_PROCESSING_BYTES_PER_DAY=107374182400
|
| 30 |
+
AUTH_TRUST_PROXY_HEADERS=true
|
| 31 |
+
# Required only when running the standalone stdio MCP transport with auth enabled.
|
| 32 |
+
MCP_STDIO_API_KEY=
|
.gitignore
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
.env
|
|
|
|
|
|
|
| 2 |
.venv/
|
| 3 |
venv/
|
| 4 |
__pycache__/
|
|
@@ -14,3 +16,7 @@ outputs/*
|
|
| 14 |
temp/*
|
| 15 |
!temp/.gitkeep
|
| 16 |
*.zip
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
.env
|
| 2 |
+
.env.*
|
| 3 |
+
!.env.example
|
| 4 |
.venv/
|
| 5 |
venv/
|
| 6 |
__pycache__/
|
|
|
|
| 16 |
temp/*
|
| 17 |
!temp/.gitkeep
|
| 18 |
*.zip
|
| 19 |
+
*.db
|
| 20 |
+
*.sqlite
|
| 21 |
+
*.sqlite3
|
| 22 |
+
data/
|
Dockerfile
CHANGED
|
@@ -5,8 +5,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
| 5 |
PIP_NO_CACHE_DIR=1 \
|
| 6 |
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 7 |
HF_HOME=/home/user/.cache/huggingface \
|
|
|
|
| 8 |
TEMP_DIR=/app/temp \
|
| 9 |
OUTPUT_DIR=/app/outputs \
|
|
|
|
| 10 |
PORT=7860
|
| 11 |
|
| 12 |
RUN apt-get update \
|
|
@@ -26,7 +28,7 @@ RUN python -m pip install --upgrade pip \
|
|
| 26 |
&& python -m pip install -r requirements.txt
|
| 27 |
|
| 28 |
RUN useradd --create-home --uid 1000 user \
|
| 29 |
-
&& mkdir -p /app/temp /app/outputs /home/user/.cache/huggingface \
|
| 30 |
&& chown -R user:user /app /home/user
|
| 31 |
|
| 32 |
COPY --chown=user:user app ./app
|
|
|
|
| 5 |
PIP_NO_CACHE_DIR=1 \
|
| 6 |
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 7 |
HF_HOME=/home/user/.cache/huggingface \
|
| 8 |
+
APP_NAME=MediaRouter \
|
| 9 |
TEMP_DIR=/app/temp \
|
| 10 |
OUTPUT_DIR=/app/outputs \
|
| 11 |
+
DATABASE_URL=sqlite+aiosqlite:////app/data/mediarouter.db \
|
| 12 |
PORT=7860
|
| 13 |
|
| 14 |
RUN apt-get update \
|
|
|
|
| 28 |
&& python -m pip install -r requirements.txt
|
| 29 |
|
| 30 |
RUN useradd --create-home --uid 1000 user \
|
| 31 |
+
&& mkdir -p /app/temp /app/outputs /app/data /home/user/.cache/huggingface \
|
| 32 |
&& chown -R user:user /app /home/user
|
| 33 |
|
| 34 |
COPY --chown=user:user app ./app
|
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: 🎬
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: purple
|
|
@@ -8,9 +8,9 @@ app_port: 7860
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
-
#
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
The service accepts multipart uploads, JSON URLs, JSON Base64, n8n binary objects, and streamed raw request bodies. Every input becomes an `InputMedia` before it enters the operation layer, so operations never need to know how media arrived.
|
| 16 |
|
|
@@ -21,6 +21,9 @@ REST /v1/* MCP stdio or /mcp/
|
|
| 21 |
│ │
|
| 22 |
├───────────────┬──────────────────┘
|
| 23 |
▼
|
|
|
|
|
|
|
|
|
|
| 24 |
request-ID + JSON logging
|
| 25 |
│
|
| 26 |
InputResolver
|
|
@@ -57,6 +60,7 @@ media-api/
|
|
| 57 |
│ ├── mcp/ # MCP server, registry, tools, resources, prompts
|
| 58 |
│ ├── models/ # InputMedia and request/result models
|
| 59 |
│ ├── operations/ # reusable FFmpeg operation functions
|
|
|
|
| 60 |
│ ├── services/ # FFmpeg, FFprobe, resolver, downloads, Whisper
|
| 61 |
│ ├── templates/ # YAML schemas, loader, registry, executor, categories
|
| 62 |
│ ├── workers/ # asynchronous expiry cleanup worker
|
|
@@ -90,6 +94,8 @@ python3.10 -m venv .venv
|
|
| 90 |
. .venv/bin/activate
|
| 91 |
pip install -r requirements.txt
|
| 92 |
cp .env.example .env
|
|
|
|
|
|
|
| 93 |
uvicorn main:app --host 0.0.0.0 --port 7860
|
| 94 |
```
|
| 95 |
|
|
@@ -108,14 +114,139 @@ The image uses `python:3.10-slim`, installs FFmpeg, FFprobe, ImageMagick and the
|
|
| 108 |
|
| 109 |
1. Create a new Space and select **Docker** as the SDK.
|
| 110 |
2. Push the contents of this directory to the Space repository. Keep the YAML block at the top of this README; `app_port` is already `7860`.
|
| 111 |
-
3.
|
| 112 |
4. Wait for the Docker build. The first Whisper call downloads the selected model to the Hugging Face cache. Persistent storage is optional, but avoids downloading models again after a cold rebuild.
|
| 113 |
-
5. Check `https://<owner>-<space>.hf.space/health`, then use
|
| 114 |
|
| 115 |
If using the supplied `media-api-huggingface.zip`, extract it first and push the extracted files so `Dockerfile` and this `README.md` are at the Space repository root. Do not commit the ZIP as the only repository file.
|
| 116 |
|
| 117 |
Use one Uvicorn process in a CPU Space. FFmpeg and Whisper concurrency is managed in-process by `MAX_WORKERS`; multiple Uvicorn workers duplicate Whisper models and memory.
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
## Model Context Protocol (MCP)
|
| 120 |
|
| 121 |
MCP is an open protocol that lets an AI client discover and call typed tools, read application resources, and use reusable prompts. This project uses the official Python MCP SDK and exposes the same processing implementation through both interfaces:
|
|
@@ -128,7 +259,7 @@ MCP tools ───────────┘ ├─ FF
|
|
| 128 |
└─ faster-whisper
|
| 129 |
```
|
| 130 |
|
| 131 |
-
No FFmpeg command, download implementation, transcription implementation, input parser, logger, or
|
| 132 |
|
| 133 |
### MCP transports
|
| 134 |
|
|
@@ -142,7 +273,7 @@ The trailing slash on `/mcp/` is recommended. Streamable HTTP is stateless and r
|
|
| 142 |
For a local stdio client, run from the project directory:
|
| 143 |
|
| 144 |
```bash
|
| 145 |
-
python -m app.mcp.server
|
| 146 |
```
|
| 147 |
|
| 148 |
Structured logs are sent to stderr in stdio mode so JSON-RPC messages on stdout are never corrupted. A standalone MCP-only HTTP process is also available for development:
|
|
@@ -165,6 +296,7 @@ Claude Desktop and other clients that accept the conventional `mcpServers` JSON
|
|
| 165 |
"args": ["-m", "app.mcp.server"],
|
| 166 |
"cwd": "/absolute/path/media-api",
|
| 167 |
"env": {
|
|
|
|
| 168 |
"TEMP_DIR": "/absolute/path/media-api/temp",
|
| 169 |
"OUTPUT_DIR": "/absolute/path/media-api/outputs",
|
| 170 |
"WHISPER_MODEL": "small",
|
|
@@ -181,7 +313,10 @@ Clients that support remote Streamable HTTP can use:
|
|
| 181 |
{
|
| 182 |
"mcpServers": {
|
| 183 |
"enterprise-media": {
|
| 184 |
-
"url": "https://OWNER-SPACE.hf.space/mcp/"
|
|
|
|
|
|
|
|
|
|
| 185 |
}
|
| 186 |
}
|
| 187 |
}
|
|
@@ -199,7 +334,7 @@ Common client locations and connection choices are:
|
|
| 199 |
| Cline | Open **MCP Servers → Configure** and add the `mcpServers` JSON entry. |
|
| 200 |
| Windsurf | Add the same entry in Windsurf MCP settings (`mcp_config.json`). |
|
| 201 |
|
| 202 |
-
Client configuration keys can vary between releases; select **Streamable HTTP**, not legacy SSE,
|
| 203 |
|
| 204 |
For a Docker-based stdio client, override the image command and keep stdin open:
|
| 205 |
|
|
@@ -207,6 +342,7 @@ For a Docker-based stdio client, override the image command and keep stdin open:
|
|
| 207 |
docker run --rm -i \
|
| 208 |
-v "$PWD/temp:/app/temp" \
|
| 209 |
-v "$PWD/outputs:/app/outputs" \
|
|
|
|
| 210 |
media-api python -m app.mcp.server
|
| 211 |
```
|
| 212 |
|
|
@@ -612,14 +748,15 @@ Keep old YAML documents when introducing a new version so existing automations r
|
|
| 612 |
List and inspect:
|
| 613 |
|
| 614 |
```bash
|
| 615 |
-
curl http://localhost:7860/v1/templates
|
| 616 |
-
curl http://localhost:7860/v1/templates/instagram_reel@1
|
| 617 |
```
|
| 618 |
|
| 619 |
Run with a direct or yt-dlp-supported URL:
|
| 620 |
|
| 621 |
```bash
|
| 622 |
curl -X POST http://localhost:7860/v1/templates/run \
|
|
|
|
| 623 |
-H 'Content-Type: application/json' \
|
| 624 |
-d '{
|
| 625 |
"template":"instagram_reel@latest",
|
|
@@ -632,6 +769,7 @@ Run with multipart media; `parameters` is a JSON form field:
|
|
| 632 |
|
| 633 |
```bash
|
| 634 |
curl -X POST http://localhost:7860/v1/templates/run \
|
|
|
|
| 635 |
-F 'file=@input.mp4' \
|
| 636 |
-F 'template=youtube_shorts@1' \
|
| 637 |
-F 'parameters={"crf":23,"max_duration":60}'
|
|
@@ -717,6 +855,7 @@ All media-processing and probe endpoints use the same resolver. Operation parame
|
|
| 717 |
|
| 718 |
```bash
|
| 719 |
curl -X POST http://localhost:7860/v1/video/compress \
|
|
|
|
| 720 |
-F 'file=@input.mp4' \
|
| 721 |
-F 'crf=28' \
|
| 722 |
-F 'preset=veryfast'
|
|
@@ -726,6 +865,7 @@ Multiple-input operations accept repeated or differently named file fields; ever
|
|
| 726 |
|
| 727 |
```bash
|
| 728 |
curl -X POST http://localhost:7860/v1/video/watermark \
|
|
|
|
| 729 |
-F 'video=@input.mp4' \
|
| 730 |
-F 'watermark=@logo.png' \
|
| 731 |
-F 'position=bottom-right' \
|
|
@@ -736,6 +876,7 @@ curl -X POST http://localhost:7860/v1/video/watermark \
|
|
| 736 |
|
| 737 |
```bash
|
| 738 |
curl -X POST http://localhost:7860/v1/video/resize \
|
|
|
|
| 739 |
-H 'Content-Type: application/json' \
|
| 740 |
-d '{"url":"https://cdn.example.com/video.mp4","width":1280,"height":720,"fit":"contain"}'
|
| 741 |
```
|
|
@@ -784,6 +925,7 @@ The resolver recognizes n8n properties named `binary.data`, `binary.file`, `bina
|
|
| 784 |
|
| 785 |
```bash
|
| 786 |
curl -X POST 'http://localhost:7860/v1/probe' \
|
|
|
|
| 787 |
-H 'Content-Type: application/octet-stream' \
|
| 788 |
-H 'X-Filename: input.mp4' \
|
| 789 |
--data-binary '@input.mp4'
|
|
@@ -793,11 +935,13 @@ Raw and multipart uploads are written to disk in 1 MiB chunks. Downloads are als
|
|
| 793 |
|
| 794 |
## Endpoints
|
| 795 |
|
| 796 |
-
All
|
| 797 |
|
| 798 |
| Group | Endpoints |
|
| 799 |
|---|---|
|
| 800 |
-
|
|
|
|
|
|
|
|
| 801 |
| Video basics | `/v1/video/compress`, `resize`, `crop`, `trim`, `rotate`, `reverse`, `convert`, `merge`, `concat` |
|
| 802 |
| Video composition | `/v1/video/overlay`, `watermark`, `replace-audio`, `subtitles/burn`, `subtitles/soft` |
|
| 803 |
| Video outputs | `/v1/video/frames`, `gif`, `thumbnail`, `remove-audio`, `mute` |
|
|
@@ -815,7 +959,9 @@ Common video parameters include `format`, `width`, `height`, `fit`, `crf`, `pres
|
|
| 815 |
`POST /v1/probe` returns normalized duration, resolution, FPS, bitrate, primary codec, video/audio/subtitle stream summaries, rotation, container, creation date, size, and tags. The same probe data is included under `metadata.inputs` for processing responses.
|
| 816 |
|
| 817 |
```bash
|
| 818 |
-
curl -X POST http://localhost:7860/v1/probe
|
|
|
|
|
|
|
| 819 |
```
|
| 820 |
|
| 821 |
### yt-dlp
|
|
@@ -824,12 +970,14 @@ curl -X POST http://localhost:7860/v1/probe -F 'file=@input.mp4'
|
|
| 824 |
|
| 825 |
```bash
|
| 826 |
curl -X POST http://localhost:7860/v1/ytdlp/download \
|
|
|
|
| 827 |
-H 'Content-Type: application/json' \
|
| 828 |
-d '{"url":"https://www.youtube.com/watch?v=VIDEO_ID","mode":"audio","audio_format":"mp3"}'
|
| 829 |
```
|
| 830 |
|
| 831 |
```bash
|
| 832 |
curl -X POST http://localhost:7860/v1/ytdlp/download \
|
|
|
|
| 833 |
-H 'Content-Type: application/json' \
|
| 834 |
-d '{"url":"https://vimeo.com/VIDEO_ID","mode":"metadata"}'
|
| 835 |
```
|
|
@@ -840,6 +988,7 @@ Models: `tiny`, `base`, `small`, `medium`, and `large-v3`. The default is `small
|
|
| 840 |
|
| 841 |
```bash
|
| 842 |
curl -X POST http://localhost:7860/v1/whisper/transcribe \
|
|
|
|
| 843 |
-F 'file=@meeting.mp3' \
|
| 844 |
-F 'model=small' \
|
| 845 |
-F 'task=transcribe' \
|
|
@@ -849,6 +998,7 @@ curl -X POST http://localhost:7860/v1/whisper/transcribe \
|
|
| 849 |
|
| 850 |
```bash
|
| 851 |
curl -X POST http://localhost:7860/v1/whisper/subtitles \
|
|
|
|
| 852 |
-F 'file=@interview.mp4' \
|
| 853 |
-F 'task=translate' \
|
| 854 |
-F 'output_format=vtt'
|
|
@@ -894,6 +1044,7 @@ In an **HTTP Request** node:
|
|
| 894 |
|
| 895 |
- Method: `POST`
|
| 896 |
- URL: `https://<space>.hf.space/v1/video/compress`
|
|
|
|
| 897 |
- Send Body: on
|
| 898 |
- Body Content Type: `Form-Data`
|
| 899 |
- Add a **n8n Binary File** parameter named `file`, selecting the incoming binary property (usually `data`)
|
|
@@ -953,10 +1104,24 @@ For large files, prefer n8n's multipart binary option or raw binary body; JSON B
|
|
| 953 |
| `BASE_URL` | empty | Optional public origin for absolute download URLs |
|
| 954 |
| `FFMPEG_BINARY` | `ffmpeg` | FFmpeg executable name/path |
|
| 955 |
| `FFPROBE_BINARY` | `ffprobe` | FFprobe executable name/path |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 956 |
|
| 957 |
## Logging and safety
|
| 958 |
|
| 959 |
-
Logs are one JSON object per line. Request completion and operation records include UUID, operation, input/output bytes, wall time, CPU percentage, RSS memory, status, and errors. FFmpeg records include the exact argument array, bounded stdout/stderr, exit status, and duration.
|
| 960 |
|
| 961 |
Safety controls include filename normalization, path containment checks, extension/MIME/size validation, duration and pixel limits, HTTP scheme and redirect validation, SSRF address filtering, bounded concurrency, bounded output capture, and subprocess arrays with `shell=False` semantics. User filenames never select output paths.
|
| 962 |
|
|
@@ -969,7 +1134,7 @@ ruff check app tests main.py
|
|
| 969 |
black --check app tests main.py
|
| 970 |
```
|
| 971 |
|
| 972 |
-
The suite covers health, Base64/n8n resolution, streamed URL download, real FFprobe metadata, a real FFmpeg conversion, lazy mocked Whisper transcription, MCP registration, YAML loading and versioning, parameter substitution, template execution, cleanup behavior, and safe error envelopes. Binary integration tests skip only when the respective system executable is absent.
|
| 973 |
|
| 974 |
## Extending the API
|
| 975 |
|
|
@@ -991,5 +1156,7 @@ This contract keeps new operations independent of multipart, URLs, Base64, n8n,
|
|
| 991 |
- **Upload receives 413/422:** increase `MAX_UPLOAD_SIZE` only after checking Space disk and RAM. Prefer multipart/raw streaming over Base64.
|
| 992 |
- **ImageMagick policy error:** media operations use FFmpeg for image transforms; ImageMagick is installed for extension use but is not required by the built-in image routes.
|
| 993 |
- **No audio after merging videos:** if any input lacks an audio stream, the merge deliberately emits video-only output instead of failing the whole request. Add silent audio before merging if a continuous audio track is required.
|
| 994 |
-
- **
|
|
|
|
|
|
|
| 995 |
- **MCP output URL is relative:** set `BASE_URL` to the public Space origin. A managed `output_file` can also be passed directly into a later MCP call as `temp_path` before cleanup expires.
|
|
|
|
| 1 |
---
|
| 2 |
+
title: MediaRouter
|
| 3 |
emoji: 🎬
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: purple
|
|
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# MediaRouter
|
| 12 |
|
| 13 |
+
MediaRouter is a production-oriented, CPU-optimized REST, Model Context Protocol (MCP), and YAML workflow API for FFmpeg, FFprobe, yt-dlp, and faster-whisper. It is designed to run unchanged as a Hugging Face Docker Space and to act as a reusable media backend for AI clients, n8n, and other automation systems.
|
| 14 |
|
| 15 |
The service accepts multipart uploads, JSON URLs, JSON Base64, n8n binary objects, and streamed raw request bodies. Every input becomes an `InputMedia` before it enters the operation layer, so operations never need to know how media arrived.
|
| 16 |
|
|
|
|
| 21 |
│ │
|
| 22 |
├───────────────┬──────────────────┘
|
| 23 |
▼
|
| 24 |
+
API-key authentication + scopes
|
| 25 |
+
rate limits + audit logging
|
| 26 |
+
│
|
| 27 |
request-ID + JSON logging
|
| 28 |
│
|
| 29 |
InputResolver
|
|
|
|
| 60 |
│ ├── mcp/ # MCP server, registry, tools, resources, prompts
|
| 61 |
│ ├── models/ # InputMedia and request/result models
|
| 62 |
│ ├── operations/ # reusable FFmpeg operation functions
|
| 63 |
+
│ ├── security/ # API keys, scopes, roles, limits, audit, migrations
|
| 64 |
│ ├── services/ # FFmpeg, FFprobe, resolver, downloads, Whisper
|
| 65 |
│ ├── templates/ # YAML schemas, loader, registry, executor, categories
|
| 66 |
│ ├── workers/ # asynchronous expiry cleanup worker
|
|
|
|
| 94 |
. .venv/bin/activate
|
| 95 |
pip install -r requirements.txt
|
| 96 |
cp .env.example .env
|
| 97 |
+
python -m app.security.cli generate-bootstrap --environment test
|
| 98 |
+
# Put the printed AUTH_BOOTSTRAP_* values in .env and save API_KEY securely.
|
| 99 |
uvicorn main:app --host 0.0.0.0 --port 7860
|
| 100 |
```
|
| 101 |
|
|
|
|
| 114 |
|
| 115 |
1. Create a new Space and select **Docker** as the SDK.
|
| 116 |
2. Push the contents of this directory to the Space repository. Keep the YAML block at the top of this README; `app_port` is already `7860`.
|
| 117 |
+
3. Generate the initial administrator key locally with `python -m app.security.cli generate-bootstrap --environment live`. Store `API_KEY` in a password manager. Add only the printed `AUTH_BOOTSTRAP_KEY_HASH`, `AUTH_BOOTSTRAP_KEY_PREFIX`, and `AUTH_BOOTSTRAP_ENVIRONMENT` under **Settings → Secrets**. Do not commit them.
|
| 118 |
4. Wait for the Docker build. The first Whisper call downloads the selected model to the Hugging Face cache. Persistent storage is optional, but avoids downloading models again after a cold rebuild.
|
| 119 |
+
5. Check the public `https://<owner>-<space>.hf.space/health`, then use the saved key for every `/v1/*` or `/mcp/` request.
|
| 120 |
|
| 121 |
If using the supplied `media-api-huggingface.zip`, extract it first and push the extracted files so `Dockerfile` and this `README.md` are at the Space repository root. Do not commit the ZIP as the only repository file.
|
| 122 |
|
| 123 |
Use one Uvicorn process in a CPU Space. FFmpeg and Whisper concurrency is managed in-process by `MAX_WORKERS`; multiple Uvicorn workers duplicate Whisper models and memory.
|
| 124 |
|
| 125 |
+
For durable keys, audit records, and rate aggregates, attach Hugging Face persistent storage and set `DATABASE_URL=sqlite+aiosqlite:////data/mediarouter.db`. The default `./data/mediarouter.db` is appropriate locally but follows the Space filesystem lifecycle. The security layer uses SQLAlchemy so a future external database migration does not change authentication contracts; this release includes and supports the `aiosqlite` driver.
|
| 126 |
+
|
| 127 |
+
## Authentication and authorization
|
| 128 |
+
|
| 129 |
+
MediaRouter uses stateless opaque API keys. There are no passwords, login sessions, cookies, or JWTs. Except for the public endpoints below, every REST and MCP request must send:
|
| 130 |
+
|
| 131 |
+
```http
|
| 132 |
+
Authorization: Bearer mp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
Keys contain at least 256 bits of cryptographically secure entropy and use an environment prefix (`mp_live_` or `mp_test_`). The database stores only a SHA-256 hash, a short display prefix, lifecycle state, scopes, role, limits, timestamps, and operator metadata. A plaintext secret is returned once at creation or rotation and cannot be recovered later.
|
| 136 |
+
|
| 137 |
+
The only unauthenticated endpoints are `GET /`, `GET /health`, `GET /version`, `GET /docs`, `GET /openapi.json`, and `GET /redoc`. `GET /v1/auth/context` is protected but requires no product scope, allowing a client to validate a key and retrieve its safe effective authorization context.
|
| 138 |
+
|
| 139 |
+
### Bootstrap and key creation
|
| 140 |
+
|
| 141 |
+
Generate a first administrator without putting plaintext key material in the server configuration:
|
| 142 |
+
|
| 143 |
+
```bash
|
| 144 |
+
python -m app.security.cli generate-bootstrap --environment live
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
The command prints `API_KEY` once and hash-only `AUTH_BOOTSTRAP_*` values. Store `API_KEY` in a secret manager; configure the hash, prefix, and environment on the server. On first startup, and only when the `api_keys` table is empty, MediaRouter inserts that bootstrap administrator. Removing the bootstrap variables after the first successful startup is recommended. If direct database access is available, `python -m app.security.cli create --name "Recovery Admin" --role admin` can create a recovery key and likewise prints its secret once.
|
| 148 |
+
|
| 149 |
+
The idempotent migration is documented in `app/security/migrations/0001_api_key_security.sql`. Startup applies equivalent SQLAlchemy metadata for `api_keys`, `audit_logs`, and `rate_limits`, including indexes on key prefix/hash, lifecycle state, expiration, audit request/key timestamps, and rate buckets. Back up the database before changing schema or moving persistent storage.
|
| 150 |
+
|
| 151 |
+
An administrator can then create a narrower key:
|
| 152 |
+
|
| 153 |
+
```bash
|
| 154 |
+
curl -X POST "$MEDIAROUTER_URL/v1/api-keys" \
|
| 155 |
+
-H "Authorization: Bearer $MEDIAROUTER_API_KEY" \
|
| 156 |
+
-H 'Content-Type: application/json' \
|
| 157 |
+
-d '{
|
| 158 |
+
"name":"Production n8n",
|
| 159 |
+
"environment":"live",
|
| 160 |
+
"role":"operator",
|
| 161 |
+
"expires_in_seconds":2592000,
|
| 162 |
+
"notes":"Media publishing workflow"
|
| 163 |
+
}'
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
The `api_key` property in the response is the only copy of the new secret. The lifecycle API is:
|
| 167 |
+
|
| 168 |
+
| Method | Endpoint | Purpose |
|
| 169 |
+
|---|---|---|
|
| 170 |
+
| `GET` | `/v1/api-keys` | List safe key metadata |
|
| 171 |
+
| `POST` | `/v1/api-keys` | Create a key and return its secret once |
|
| 172 |
+
| `GET` / `PATCH` / `DELETE` | `/v1/api-keys/{id}` | Read, rename/update, or irrevocably revoke |
|
| 173 |
+
| `POST` | `/v1/api-keys/{id}/rotate` | Create a replacement; optional grace period up to 24 hours |
|
| 174 |
+
| `POST` | `/v1/api-keys/{id}/disable` | Temporarily reject an active key |
|
| 175 |
+
| `POST` | `/v1/api-keys/{id}/enable` | Re-enable a disabled, non-revoked key |
|
| 176 |
+
| `GET` | `/v1/api-keys/capabilities` | Discover scopes, configured roles, and limit defaults |
|
| 177 |
+
| `GET` | `/v1/audit-logs` | Read authenticated request audit records |
|
| 178 |
+
|
| 179 |
+
Revocation is immediate and irreversible without restarting the service. Rotation creates a distinct record and secret. With `{"grace_period_seconds":300}`, both keys work for five minutes; without a grace period, the old key is rejected immediately. Expired keys always fail, including disabled keys that are later enabled.
|
| 180 |
+
|
| 181 |
+
### Scopes and roles
|
| 182 |
+
|
| 183 |
+
Scopes are enforced before route execution. Explicit scopes are combined with the selected role; `admin` grants all scopes.
|
| 184 |
+
|
| 185 |
+
| Domain | Scopes |
|
| 186 |
+
|---|---|
|
| 187 |
+
| Templates | `templates:read`, `templates:run` |
|
| 188 |
+
| Operations | `operations:read`, `operations:execute` |
|
| 189 |
+
| Jobs | `jobs:read`, `jobs:create`, `jobs:cancel` |
|
| 190 |
+
| Assets | `assets:read`, `assets:write`, `assets:delete` |
|
| 191 |
+
| MCP | `mcp:read`, `mcp:execute` |
|
| 192 |
+
| System | `system:read` |
|
| 193 |
+
| Administration | `admin` |
|
| 194 |
+
|
| 195 |
+
Built-in roles are `admin` (all access), `developer` (read/run/execute, job control, and asset writes), `operator` (read/run/execute and job control), and `viewer` (read-only). Override or add roles with an `AUTH_ROLE_SCOPES` JSON object, for example `{"publisher":["templates:read","templates:run","assets:read"]}`. Unknown scopes are rejected at startup or key validation rather than silently ignored.
|
| 196 |
+
|
| 197 |
+
### Limits, auditing, and error contracts
|
| 198 |
+
|
| 199 |
+
Each key has independently configurable requests per minute, concurrent processing jobs, uploads per hour, and processing bytes per UTC day. Defaults are 100, 10, 20, and 100 GiB. A limit failure returns `429 Too Many Requests` with a `Retry-After` header. Hugging Face Spaces should run one Uvicorn process, which also prevents duplicate in-memory concurrency and Whisper state.
|
| 200 |
+
|
| 201 |
+
Every authenticated HTTP request records its request ID, key ID/name, trusted client IP, user agent, method, endpoint, status, processing time, and upload/download byte counts. Standalone stdio MCP calls generate equivalent tool audit records. Logs never contain plaintext API keys.
|
| 202 |
+
|
| 203 |
+
Authentication failures are intentionally indistinguishable:
|
| 204 |
+
|
| 205 |
+
```json
|
| 206 |
+
{"error":"Unauthorized","message":"Invalid or expired API key."}
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
Missing scopes return `403` with `{"error":"Forbidden","message":"Missing required scope."}`. Limit failures return `429` with `{"error":"Rate limit exceeded","message":"Retry later."}`. Clients must never use response differences to infer whether a key ID or hash exists.
|
| 210 |
+
|
| 211 |
+
### REST, SDK, and n8n clients
|
| 212 |
+
|
| 213 |
+
All SDKs use the same bearer header. A Python SDK constructor can expose `MediaPlatform(base_url="...", api_key="mp_live_...")`; a JavaScript SDK should accept the same two values. Neither needs a token refresh flow.
|
| 214 |
+
|
| 215 |
+
```python
|
| 216 |
+
import os
|
| 217 |
+
|
| 218 |
+
import httpx
|
| 219 |
+
|
| 220 |
+
api_key = os.environ["MEDIAROUTER_API_KEY"]
|
| 221 |
+
client = httpx.Client(
|
| 222 |
+
base_url="https://OWNER-SPACE.hf.space",
|
| 223 |
+
headers={"Authorization": f"Bearer {api_key}"},
|
| 224 |
+
)
|
| 225 |
+
templates = client.get("/v1/templates").raise_for_status().json()
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
```javascript
|
| 229 |
+
const { MEDIAROUTER_URL: baseUrl, MEDIAROUTER_API_KEY: apiKey } = process.env;
|
| 230 |
+
const response = await fetch(`${baseUrl}/v1/templates`, {
|
| 231 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 232 |
+
});
|
| 233 |
+
if (!response.ok) throw new Error(`MediaRouter returned ${response.status}`);
|
| 234 |
+
const templates = await response.json();
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
In n8n, create a reusable **Header Auth** credential with header name `Authorization` and value `Bearer mp_live_...`. Each HTTP Request node then needs only the Base URL/path and that credential. The future official MediaRouter node uses two credential fields—Base URL and API Key—and sends this header automatically.
|
| 238 |
+
|
| 239 |
+
Security recommendations: give each automation its own least-privilege key; use `mp_test_` outside production; prefer short expirations for temporary agents; rotate on a schedule; revoke on suspected exposure; never put keys in URLs, client logs, Git, `NEXT_PUBLIC_*` variables, or media metadata; and treat frontend validation only as UX because the backend remains authoritative.
|
| 240 |
+
|
| 241 |
+
## Official SDKs
|
| 242 |
+
|
| 243 |
+
Publish-ready clients live under [`sdk/`](sdk/):
|
| 244 |
+
|
| 245 |
+
- [`@mediarouter/media-platform`](sdk/typescript/) — strict TypeScript with ESM and CommonJS builds, typed resources, retries, uploads, downloads, polling, and MCP helpers.
|
| 246 |
+
- [`media-platform`](sdk/python/) — typed Python resource client with stdlib-only runtime dependencies, streamed downloads, multipart uploads, retries, polling, and typed exceptions.
|
| 247 |
+
|
| 248 |
+
Both clients use the existing `/openapi.json` as their discovery source and keep FFmpeg, Whisper, yt-dlp, and all business logic on the backend. Run the SDK workflow for type checks, mocked transport tests, Python tests, and package builds before publishing a semantic-versioned release.
|
| 249 |
+
|
| 250 |
## Model Context Protocol (MCP)
|
| 251 |
|
| 252 |
MCP is an open protocol that lets an AI client discover and call typed tools, read application resources, and use reusable prompts. This project uses the official Python MCP SDK and exposes the same processing implementation through both interfaces:
|
|
|
|
| 259 |
└─ faster-whisper
|
| 260 |
```
|
| 261 |
|
| 262 |
+
No FFmpeg command, download implementation, transcription implementation, input parser, logger, cleanup implementation, or authentication implementation is duplicated in `app/mcp`. Mounted and standalone Streamable HTTP pass through the same API-key middleware as REST. The transport-neutral registry also enforces MCP scopes, limits, and audit logging for stdio calls.
|
| 263 |
|
| 264 |
### MCP transports
|
| 265 |
|
|
|
|
| 273 |
For a local stdio client, run from the project directory:
|
| 274 |
|
| 275 |
```bash
|
| 276 |
+
MCP_STDIO_API_KEY="$MEDIAROUTER_API_KEY" python -m app.mcp.server
|
| 277 |
```
|
| 278 |
|
| 279 |
Structured logs are sent to stderr in stdio mode so JSON-RPC messages on stdout are never corrupted. A standalone MCP-only HTTP process is also available for development:
|
|
|
|
| 296 |
"args": ["-m", "app.mcp.server"],
|
| 297 |
"cwd": "/absolute/path/media-api",
|
| 298 |
"env": {
|
| 299 |
+
"MCP_STDIO_API_KEY": "mp_live_REPLACE_WITH_KEY",
|
| 300 |
"TEMP_DIR": "/absolute/path/media-api/temp",
|
| 301 |
"OUTPUT_DIR": "/absolute/path/media-api/outputs",
|
| 302 |
"WHISPER_MODEL": "small",
|
|
|
|
| 313 |
{
|
| 314 |
"mcpServers": {
|
| 315 |
"enterprise-media": {
|
| 316 |
+
"url": "https://OWNER-SPACE.hf.space/mcp/",
|
| 317 |
+
"headers": {
|
| 318 |
+
"Authorization": "Bearer mp_live_REPLACE_WITH_KEY"
|
| 319 |
+
}
|
| 320 |
}
|
| 321 |
}
|
| 322 |
}
|
|
|
|
| 334 |
| Cline | Open **MCP Servers → Configure** and add the `mcpServers` JSON entry. |
|
| 335 |
| Windsurf | Add the same entry in Windsurf MCP settings (`mcp_config.json`). |
|
| 336 |
|
| 337 |
+
Client configuration keys can vary between releases; select **Streamable HTTP**, not legacy SSE, and configure an `Authorization: Bearer <api_key>` header. A client without custom-header support cannot connect to a protected remote MCP endpoint; use its stdio mode with `MCP_STDIO_API_KEY` instead.
|
| 338 |
|
| 339 |
For a Docker-based stdio client, override the image command and keep stdin open:
|
| 340 |
|
|
|
|
| 342 |
docker run --rm -i \
|
| 343 |
-v "$PWD/temp:/app/temp" \
|
| 344 |
-v "$PWD/outputs:/app/outputs" \
|
| 345 |
+
-e MCP_STDIO_API_KEY="$MEDIAROUTER_API_KEY" \
|
| 346 |
media-api python -m app.mcp.server
|
| 347 |
```
|
| 348 |
|
|
|
|
| 748 |
List and inspect:
|
| 749 |
|
| 750 |
```bash
|
| 751 |
+
curl -H "Authorization: Bearer $MEDIAROUTER_API_KEY" http://localhost:7860/v1/templates
|
| 752 |
+
curl -H "Authorization: Bearer $MEDIAROUTER_API_KEY" http://localhost:7860/v1/templates/instagram_reel@1
|
| 753 |
```
|
| 754 |
|
| 755 |
Run with a direct or yt-dlp-supported URL:
|
| 756 |
|
| 757 |
```bash
|
| 758 |
curl -X POST http://localhost:7860/v1/templates/run \
|
| 759 |
+
-H "Authorization: Bearer $MEDIAROUTER_API_KEY" \
|
| 760 |
-H 'Content-Type: application/json' \
|
| 761 |
-d '{
|
| 762 |
"template":"instagram_reel@latest",
|
|
|
|
| 769 |
|
| 770 |
```bash
|
| 771 |
curl -X POST http://localhost:7860/v1/templates/run \
|
| 772 |
+
-H "Authorization: Bearer $MEDIAROUTER_API_KEY" \
|
| 773 |
-F 'file=@input.mp4' \
|
| 774 |
-F 'template=youtube_shorts@1' \
|
| 775 |
-F 'parameters={"crf":23,"max_duration":60}'
|
|
|
|
| 855 |
|
| 856 |
```bash
|
| 857 |
curl -X POST http://localhost:7860/v1/video/compress \
|
| 858 |
+
-H "Authorization: Bearer $MEDIAROUTER_API_KEY" \
|
| 859 |
-F 'file=@input.mp4' \
|
| 860 |
-F 'crf=28' \
|
| 861 |
-F 'preset=veryfast'
|
|
|
|
| 865 |
|
| 866 |
```bash
|
| 867 |
curl -X POST http://localhost:7860/v1/video/watermark \
|
| 868 |
+
-H "Authorization: Bearer $MEDIAROUTER_API_KEY" \
|
| 869 |
-F 'video=@input.mp4' \
|
| 870 |
-F 'watermark=@logo.png' \
|
| 871 |
-F 'position=bottom-right' \
|
|
|
|
| 876 |
|
| 877 |
```bash
|
| 878 |
curl -X POST http://localhost:7860/v1/video/resize \
|
| 879 |
+
-H "Authorization: Bearer $MEDIAROUTER_API_KEY" \
|
| 880 |
-H 'Content-Type: application/json' \
|
| 881 |
-d '{"url":"https://cdn.example.com/video.mp4","width":1280,"height":720,"fit":"contain"}'
|
| 882 |
```
|
|
|
|
| 925 |
|
| 926 |
```bash
|
| 927 |
curl -X POST 'http://localhost:7860/v1/probe' \
|
| 928 |
+
-H "Authorization: Bearer $MEDIAROUTER_API_KEY" \
|
| 929 |
-H 'Content-Type: application/octet-stream' \
|
| 930 |
-H 'X-Filename: input.mp4' \
|
| 931 |
--data-binary '@input.mp4'
|
|
|
|
| 935 |
|
| 936 |
## Endpoints
|
| 937 |
|
| 938 |
+
All protected examples below assume `MEDIAROUTER_API_KEY` is set and include `Authorization: Bearer $MEDIAROUTER_API_KEY`. Processing routes use `POST`. Health and output downloads use `GET`.
|
| 939 |
|
| 940 |
| Group | Endpoints |
|
| 941 |
|---|---|
|
| 942 |
+
| Public | `/`, `/health`, `/version`, `/docs`, `/openapi.json`, `/redoc` |
|
| 943 |
+
| Authentication | `/v1/auth/context`, `/v1/api-keys/*`, `/v1/audit-logs` |
|
| 944 |
+
| System | `/v1/probe`, `/v1/media/{request_id}/{filename}` |
|
| 945 |
| Video basics | `/v1/video/compress`, `resize`, `crop`, `trim`, `rotate`, `reverse`, `convert`, `merge`, `concat` |
|
| 946 |
| Video composition | `/v1/video/overlay`, `watermark`, `replace-audio`, `subtitles/burn`, `subtitles/soft` |
|
| 947 |
| Video outputs | `/v1/video/frames`, `gif`, `thumbnail`, `remove-audio`, `mute` |
|
|
|
|
| 959 |
`POST /v1/probe` returns normalized duration, resolution, FPS, bitrate, primary codec, video/audio/subtitle stream summaries, rotation, container, creation date, size, and tags. The same probe data is included under `metadata.inputs` for processing responses.
|
| 960 |
|
| 961 |
```bash
|
| 962 |
+
curl -X POST http://localhost:7860/v1/probe \
|
| 963 |
+
-H "Authorization: Bearer $MEDIAROUTER_API_KEY" \
|
| 964 |
+
-F 'file=@input.mp4'
|
| 965 |
```
|
| 966 |
|
| 967 |
### yt-dlp
|
|
|
|
| 970 |
|
| 971 |
```bash
|
| 972 |
curl -X POST http://localhost:7860/v1/ytdlp/download \
|
| 973 |
+
-H "Authorization: Bearer $MEDIAROUTER_API_KEY" \
|
| 974 |
-H 'Content-Type: application/json' \
|
| 975 |
-d '{"url":"https://www.youtube.com/watch?v=VIDEO_ID","mode":"audio","audio_format":"mp3"}'
|
| 976 |
```
|
| 977 |
|
| 978 |
```bash
|
| 979 |
curl -X POST http://localhost:7860/v1/ytdlp/download \
|
| 980 |
+
-H "Authorization: Bearer $MEDIAROUTER_API_KEY" \
|
| 981 |
-H 'Content-Type: application/json' \
|
| 982 |
-d '{"url":"https://vimeo.com/VIDEO_ID","mode":"metadata"}'
|
| 983 |
```
|
|
|
|
| 988 |
|
| 989 |
```bash
|
| 990 |
curl -X POST http://localhost:7860/v1/whisper/transcribe \
|
| 991 |
+
-H "Authorization: Bearer $MEDIAROUTER_API_KEY" \
|
| 992 |
-F 'file=@meeting.mp3' \
|
| 993 |
-F 'model=small' \
|
| 994 |
-F 'task=transcribe' \
|
|
|
|
| 998 |
|
| 999 |
```bash
|
| 1000 |
curl -X POST http://localhost:7860/v1/whisper/subtitles \
|
| 1001 |
+
-H "Authorization: Bearer $MEDIAROUTER_API_KEY" \
|
| 1002 |
-F 'file=@interview.mp4' \
|
| 1003 |
-F 'task=translate' \
|
| 1004 |
-F 'output_format=vtt'
|
|
|
|
| 1044 |
|
| 1045 |
- Method: `POST`
|
| 1046 |
- URL: `https://<space>.hf.space/v1/video/compress`
|
| 1047 |
+
- Authentication: reusable Header Auth credential, `Authorization = Bearer <api_key>`
|
| 1048 |
- Send Body: on
|
| 1049 |
- Body Content Type: `Form-Data`
|
| 1050 |
- Add a **n8n Binary File** parameter named `file`, selecting the incoming binary property (usually `data`)
|
|
|
|
| 1104 |
| `BASE_URL` | empty | Optional public origin for absolute download URLs |
|
| 1105 |
| `FFMPEG_BINARY` | `ffmpeg` | FFmpeg executable name/path |
|
| 1106 |
| `FFPROBE_BINARY` | `ffprobe` | FFprobe executable name/path |
|
| 1107 |
+
| `AUTH_ENABLED` | `true` | Fail-closed API-key enforcement; disable only for isolated development/tests |
|
| 1108 |
+
| `DATABASE_URL` | `sqlite+aiosqlite:///./data/mediarouter.db` | Hash-only keys, audit records, and rate aggregates |
|
| 1109 |
+
| `AUTH_ROLE_SCOPES` | `{}` | JSON custom role-to-scope mappings |
|
| 1110 |
+
| `AUTH_BOOTSTRAP_KEY_HASH` | empty | SHA-256 hash for first-start administrator |
|
| 1111 |
+
| `AUTH_BOOTSTRAP_KEY_PREFIX` | empty | Safe display prefix matching the bootstrap key |
|
| 1112 |
+
| `AUTH_BOOTSTRAP_KEY_NAME` | `Bootstrap Administrator` | Bootstrap record label |
|
| 1113 |
+
| `AUTH_BOOTSTRAP_ENVIRONMENT` | `live` | `live` or `test`; must match the prefix |
|
| 1114 |
+
| `AUTH_LAST_USED_UPDATE_SECONDS` | `60` | Throttle for database last-used writes |
|
| 1115 |
+
| `AUTH_DEFAULT_REQUESTS_PER_MINUTE` | `100` | Default per-key request window |
|
| 1116 |
+
| `AUTH_DEFAULT_CONCURRENT_JOBS` | `10` | Default processing request concurrency |
|
| 1117 |
+
| `AUTH_DEFAULT_UPLOADS_PER_HOUR` | `20` | Default upload request window |
|
| 1118 |
+
| `AUTH_DEFAULT_PROCESSING_BYTES_PER_DAY` | `107374182400` | Default daily uploaded processing bytes |
|
| 1119 |
+
| `AUTH_TRUST_PROXY_HEADERS` | `true` | Use first `X-Forwarded-For` address for audits behind HF/Vercel |
|
| 1120 |
+
| `MCP_STDIO_API_KEY` | empty | Existing API key required by authenticated standalone stdio MCP |
|
| 1121 |
|
| 1122 |
## Logging and safety
|
| 1123 |
|
| 1124 |
+
Logs are one JSON object per line. Request completion and operation records include UUID, API key ID/name (never the secret), operation, input/output bytes, wall time, CPU percentage, RSS memory, status, and errors. FFmpeg records include the exact argument array, bounded stdout/stderr, exit status, and duration. Structured audit rows preserve authenticated request metadata independently of application logs.
|
| 1125 |
|
| 1126 |
Safety controls include filename normalization, path containment checks, extension/MIME/size validation, duration and pixel limits, HTTP scheme and redirect validation, SSRF address filtering, bounded concurrency, bounded output capture, and subprocess arrays with `shell=False` semantics. User filenames never select output paths.
|
| 1127 |
|
|
|
|
| 1134 |
black --check app tests main.py
|
| 1135 |
```
|
| 1136 |
|
| 1137 |
+
The suite covers health, Base64/n8n resolution, streamed URL download, real FFprobe metadata, a real FFmpeg conversion, lazy mocked Whisper transcription, MCP registration and authorization, key entropy/hash-only storage, invalid/expired/revoked keys, scopes, rotation grace, disable/enable, request and concurrent limits, audit persistence, middleware contracts, YAML loading and versioning, parameter substitution, template execution, cleanup behavior, and safe error envelopes. Binary integration tests skip only when the respective system executable is absent.
|
| 1138 |
|
| 1139 |
## Extending the API
|
| 1140 |
|
|
|
|
| 1156 |
- **Upload receives 413/422:** increase `MAX_UPLOAD_SIZE` only after checking Space disk and RAM. Prefer multipart/raw streaming over Base64.
|
| 1157 |
- **ImageMagick policy error:** media operations use FFmpeg for image transforms; ImageMagick is installed for extension use but is not required by the built-in image routes.
|
| 1158 |
- **No audio after merging videos:** if any input lacks an audio stream, the merge deliberately emits video-only output instead of failing the whole request. Add silent audio before merging if a continuous audio track is required.
|
| 1159 |
+
- **Every protected request returns 401 after first deployment:** the database has no administrator. Generate a bootstrap key, configure its matching hash/prefix/environment secrets, and restart once. Ensure persistent storage contains the expected database.
|
| 1160 |
+
- **Hugging Face restarted and keys disappeared:** the default relative SQLite file was on ephemeral storage. Attach persistent storage and use `sqlite+aiosqlite:////data/mediarouter.db`, then create/rotate keys again.
|
| 1161 |
+
- **MCP client cannot connect:** use the trailing-slash URL `/mcp/`, select Streamable HTTP rather than legacy SSE, include the bearer header, and verify `/health` first. Stdio clients must set `MCP_STDIO_API_KEY` and launch with the project directory as their working directory.
|
| 1162 |
- **MCP output URL is relative:** set `BASE_URL` to the public Space origin. A managed `output_file` can also be passed directly into a later MCP call as `temp_path` before cleanup expires.
|
app/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
__version__ = "1.0.0"
|
|
|
|
| 1 |
+
"""MediaRouter media automation API package."""
|
| 2 |
|
| 3 |
__version__ = "1.0.0"
|
app/api/api_keys.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, HTTPException, Query, Request, Response, status
|
| 4 |
+
|
| 5 |
+
from app.security.errors import APIKeyConflictError, APIKeyNotFoundError
|
| 6 |
+
from app.security.schemas import (
|
| 7 |
+
APIKeyCreate,
|
| 8 |
+
APIKeyCreated,
|
| 9 |
+
APIKeyList,
|
| 10 |
+
APIKeyPatch,
|
| 11 |
+
APIKeyRotate,
|
| 12 |
+
APIKeyView,
|
| 13 |
+
AuthContextView,
|
| 14 |
+
AuditLogView,
|
| 15 |
+
)
|
| 16 |
+
from app.security.scopes import ALL_SCOPES
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/v1", tags=["authentication"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _view(record: object) -> APIKeyView:
|
| 22 |
+
return APIKeyView.model_validate(record)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _not_found() -> HTTPException:
|
| 26 |
+
return HTTPException(status_code=404, detail="API key was not found")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.get("/auth/context", response_model=AuthContextView)
|
| 30 |
+
async def current_auth_context(request: Request) -> AuthContextView:
|
| 31 |
+
"""Validate a key and return only its safe, non-secret authorization context."""
|
| 32 |
+
context = request.state.auth
|
| 33 |
+
return AuthContextView(
|
| 34 |
+
id=context.api_key_id,
|
| 35 |
+
name=context.key_name,
|
| 36 |
+
key_prefix=context.key_prefix,
|
| 37 |
+
environment=context.environment,
|
| 38 |
+
role=context.role,
|
| 39 |
+
scopes=sorted(context.scopes),
|
| 40 |
+
expires_at=context.expires_at,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@router.get("/api-keys/capabilities")
|
| 45 |
+
async def api_key_capabilities(request: Request) -> dict[str, object]:
|
| 46 |
+
container = request.app.state.container
|
| 47 |
+
return {
|
| 48 |
+
"scopes": sorted(ALL_SCOPES),
|
| 49 |
+
"roles": {
|
| 50 |
+
role: sorted(scopes)
|
| 51 |
+
for role, scopes in container.api_keys.roles.items()
|
| 52 |
+
},
|
| 53 |
+
"defaults": {
|
| 54 |
+
"requests_per_minute": container.settings.auth_default_requests_per_minute,
|
| 55 |
+
"concurrent_jobs": container.settings.auth_default_concurrent_jobs,
|
| 56 |
+
"uploads_per_hour": container.settings.auth_default_uploads_per_hour,
|
| 57 |
+
"processing_bytes_per_day": (
|
| 58 |
+
container.settings.auth_default_processing_bytes_per_day
|
| 59 |
+
),
|
| 60 |
+
},
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.get("/api-keys", response_model=APIKeyList)
|
| 65 |
+
async def list_api_keys(
|
| 66 |
+
request: Request,
|
| 67 |
+
offset: int = Query(default=0, ge=0),
|
| 68 |
+
limit: int = Query(default=100, ge=1, le=500),
|
| 69 |
+
) -> APIKeyList:
|
| 70 |
+
records, total = await request.app.state.container.api_keys.list(
|
| 71 |
+
offset=offset, limit=limit
|
| 72 |
+
)
|
| 73 |
+
return APIKeyList(items=[_view(record) for record in records], total=total)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.post(
|
| 77 |
+
"/api-keys", response_model=APIKeyCreated, status_code=status.HTTP_201_CREATED
|
| 78 |
+
)
|
| 79 |
+
async def create_api_key(request: Request, payload: APIKeyCreate) -> APIKeyCreated:
|
| 80 |
+
context = request.state.auth
|
| 81 |
+
try:
|
| 82 |
+
record, secret = await request.app.state.container.api_keys.create(
|
| 83 |
+
payload, created_by=context.api_key_id
|
| 84 |
+
)
|
| 85 |
+
except APIKeyConflictError as exc:
|
| 86 |
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
| 87 |
+
return APIKeyCreated(**_view(record).model_dump(), api_key=secret)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@router.get("/api-keys/{key_id}", response_model=APIKeyView)
|
| 91 |
+
async def get_api_key(request: Request, key_id: str) -> APIKeyView:
|
| 92 |
+
try:
|
| 93 |
+
return _view(await request.app.state.container.api_keys.get(key_id))
|
| 94 |
+
except APIKeyNotFoundError as exc:
|
| 95 |
+
raise _not_found() from exc
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@router.patch("/api-keys/{key_id}", response_model=APIKeyView)
|
| 99 |
+
async def patch_api_key(
|
| 100 |
+
request: Request, key_id: str, payload: APIKeyPatch
|
| 101 |
+
) -> APIKeyView:
|
| 102 |
+
try:
|
| 103 |
+
return _view(await request.app.state.container.api_keys.patch(key_id, payload))
|
| 104 |
+
except APIKeyNotFoundError as exc:
|
| 105 |
+
raise _not_found() from exc
|
| 106 |
+
except APIKeyConflictError as exc:
|
| 107 |
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 111 |
+
async def revoke_api_key(request: Request, key_id: str) -> Response:
|
| 112 |
+
try:
|
| 113 |
+
await request.app.state.container.api_keys.set_status(key_id, "revoked")
|
| 114 |
+
except APIKeyNotFoundError as exc:
|
| 115 |
+
raise _not_found() from exc
|
| 116 |
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@router.post(
|
| 120 |
+
"/api-keys/{key_id}/rotate",
|
| 121 |
+
response_model=APIKeyCreated,
|
| 122 |
+
status_code=status.HTTP_201_CREATED,
|
| 123 |
+
)
|
| 124 |
+
async def rotate_api_key(
|
| 125 |
+
request: Request, key_id: str, payload: APIKeyRotate
|
| 126 |
+
) -> APIKeyCreated:
|
| 127 |
+
try:
|
| 128 |
+
record, secret = await request.app.state.container.api_keys.rotate(
|
| 129 |
+
key_id,
|
| 130 |
+
payload.grace_period_seconds,
|
| 131 |
+
created_by=request.state.auth.api_key_id,
|
| 132 |
+
)
|
| 133 |
+
except APIKeyNotFoundError as exc:
|
| 134 |
+
raise _not_found() from exc
|
| 135 |
+
except APIKeyConflictError as exc:
|
| 136 |
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
| 137 |
+
return APIKeyCreated(**_view(record).model_dump(), api_key=secret)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@router.post("/api-keys/{key_id}/disable", response_model=APIKeyView)
|
| 141 |
+
async def disable_api_key(request: Request, key_id: str) -> APIKeyView:
|
| 142 |
+
try:
|
| 143 |
+
return _view(
|
| 144 |
+
await request.app.state.container.api_keys.set_status(key_id, "disabled")
|
| 145 |
+
)
|
| 146 |
+
except APIKeyNotFoundError as exc:
|
| 147 |
+
raise _not_found() from exc
|
| 148 |
+
except APIKeyConflictError as exc:
|
| 149 |
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@router.post("/api-keys/{key_id}/enable", response_model=APIKeyView)
|
| 153 |
+
async def enable_api_key(request: Request, key_id: str) -> APIKeyView:
|
| 154 |
+
try:
|
| 155 |
+
return _view(
|
| 156 |
+
await request.app.state.container.api_keys.set_status(key_id, "active")
|
| 157 |
+
)
|
| 158 |
+
except APIKeyNotFoundError as exc:
|
| 159 |
+
raise _not_found() from exc
|
| 160 |
+
except APIKeyConflictError as exc:
|
| 161 |
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@router.get("/audit-logs", response_model=list[AuditLogView])
|
| 165 |
+
async def list_audit_logs(
|
| 166 |
+
request: Request,
|
| 167 |
+
offset: int = Query(default=0, ge=0),
|
| 168 |
+
limit: int = Query(default=100, ge=1, le=500),
|
| 169 |
+
) -> list[AuditLogView]:
|
| 170 |
+
records = await request.app.state.container.audit.list(offset=offset, limit=limit)
|
| 171 |
+
return [AuditLogView.model_validate(record) for record in records]
|
app/container.py
CHANGED
|
@@ -12,6 +12,10 @@ from app.services.media_service import MediaProcessor
|
|
| 12 |
from app.services.validator import MediaValidator
|
| 13 |
from app.services.whisper_service import WhisperService
|
| 14 |
from app.services.ytdlp_service import YTDLPService
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from app.templates.executor import OperationExecutor, TemplateExecutor
|
| 16 |
from app.templates.loader import TemplateLoader
|
| 17 |
from app.templates.registry import TemplateRegistry
|
|
@@ -32,9 +36,17 @@ class Container:
|
|
| 32 |
processor: MediaProcessor
|
| 33 |
template_registry: TemplateRegistry
|
| 34 |
template_executor: TemplateExecutor
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
def build_container(settings: Settings) -> Container:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
cleanup = CleanupService(settings)
|
| 39 |
validator = MediaValidator(settings)
|
| 40 |
downloader = Downloader(settings, validator)
|
|
@@ -53,16 +65,20 @@ def build_container(settings: Settings) -> Container:
|
|
| 53 |
)
|
| 54 |
template_executor = TemplateExecutor(template_registry, operation_executor, processor)
|
| 55 |
return Container(
|
| 56 |
-
settings,
|
| 57 |
-
cleanup,
|
| 58 |
-
validator,
|
| 59 |
-
downloader,
|
| 60 |
-
ytdlp,
|
| 61 |
-
ffmpeg,
|
| 62 |
-
ffprobe,
|
| 63 |
-
whisper,
|
| 64 |
-
resolver,
|
| 65 |
-
processor,
|
| 66 |
-
template_registry,
|
| 67 |
-
template_executor,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
)
|
|
|
|
| 12 |
from app.services.validator import MediaValidator
|
| 13 |
from app.services.whisper_service import WhisperService
|
| 14 |
from app.services.ytdlp_service import YTDLPService
|
| 15 |
+
from app.security.audit import AuditService
|
| 16 |
+
from app.security.database import SecurityDatabase
|
| 17 |
+
from app.security.rate_limit import APIKeyRateLimiter
|
| 18 |
+
from app.security.service import APIKeyService
|
| 19 |
from app.templates.executor import OperationExecutor, TemplateExecutor
|
| 20 |
from app.templates.loader import TemplateLoader
|
| 21 |
from app.templates.registry import TemplateRegistry
|
|
|
|
| 36 |
processor: MediaProcessor
|
| 37 |
template_registry: TemplateRegistry
|
| 38 |
template_executor: TemplateExecutor
|
| 39 |
+
security_database: SecurityDatabase
|
| 40 |
+
api_keys: APIKeyService
|
| 41 |
+
rate_limiter: APIKeyRateLimiter
|
| 42 |
+
audit: AuditService
|
| 43 |
|
| 44 |
|
| 45 |
def build_container(settings: Settings) -> Container:
|
| 46 |
+
security_database = SecurityDatabase(settings.database_url)
|
| 47 |
+
api_keys = APIKeyService(security_database, settings)
|
| 48 |
+
rate_limiter = APIKeyRateLimiter(security_database)
|
| 49 |
+
audit = AuditService(security_database)
|
| 50 |
cleanup = CleanupService(settings)
|
| 51 |
validator = MediaValidator(settings)
|
| 52 |
downloader = Downloader(settings, validator)
|
|
|
|
| 65 |
)
|
| 66 |
template_executor = TemplateExecutor(template_registry, operation_executor, processor)
|
| 67 |
return Container(
|
| 68 |
+
settings=settings,
|
| 69 |
+
cleanup=cleanup,
|
| 70 |
+
validator=validator,
|
| 71 |
+
downloader=downloader,
|
| 72 |
+
ytdlp=ytdlp,
|
| 73 |
+
ffmpeg=ffmpeg,
|
| 74 |
+
ffprobe=ffprobe,
|
| 75 |
+
whisper=whisper,
|
| 76 |
+
resolver=resolver,
|
| 77 |
+
processor=processor,
|
| 78 |
+
template_registry=template_registry,
|
| 79 |
+
template_executor=template_executor,
|
| 80 |
+
security_database=security_database,
|
| 81 |
+
api_keys=api_keys,
|
| 82 |
+
rate_limiter=rate_limiter,
|
| 83 |
+
audit=audit,
|
| 84 |
)
|
app/core/config.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
| 3 |
from functools import lru_cache
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
-
from pydantic import Field, field_validator
|
| 7 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 8 |
|
| 9 |
DEFAULT_TEMPLATE_DIR = Path(__file__).resolve().parents[1] / "templates" / "categories"
|
|
@@ -16,7 +16,7 @@ class Settings(BaseSettings):
|
|
| 16 |
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
|
| 17 |
)
|
| 18 |
|
| 19 |
-
app_name: str = "
|
| 20 |
app_version: str = "1.0.0"
|
| 21 |
host: str = "0.0.0.0"
|
| 22 |
port: int = 7860
|
|
@@ -36,6 +36,22 @@ class Settings(BaseSettings):
|
|
| 36 |
base_url: str = ""
|
| 37 |
ffmpeg_binary: str = "ffmpeg"
|
| 38 |
ffprobe_binary: str = "ffprobe"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
@field_validator("whisper_model")
|
| 41 |
@classmethod
|
|
@@ -57,6 +73,23 @@ class Settings(BaseSettings):
|
|
| 57 |
def ensure_directories(self) -> None:
|
| 58 |
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
| 59 |
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
@lru_cache
|
|
|
|
| 3 |
from functools import lru_cache
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
+
from pydantic import Field, SecretStr, field_validator
|
| 7 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 8 |
|
| 9 |
DEFAULT_TEMPLATE_DIR = Path(__file__).resolve().parents[1] / "templates" / "categories"
|
|
|
|
| 16 |
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
|
| 17 |
)
|
| 18 |
|
| 19 |
+
app_name: str = "MediaRouter"
|
| 20 |
app_version: str = "1.0.0"
|
| 21 |
host: str = "0.0.0.0"
|
| 22 |
port: int = 7860
|
|
|
|
| 36 |
base_url: str = ""
|
| 37 |
ffmpeg_binary: str = "ffmpeg"
|
| 38 |
ffprobe_binary: str = "ffprobe"
|
| 39 |
+
auth_enabled: bool = True
|
| 40 |
+
database_url: str = "sqlite+aiosqlite:///./data/mediarouter.db"
|
| 41 |
+
auth_role_scopes: dict[str, list[str]] = Field(default_factory=dict)
|
| 42 |
+
auth_bootstrap_key_hash: str = ""
|
| 43 |
+
auth_bootstrap_key_prefix: str = ""
|
| 44 |
+
auth_bootstrap_key_name: str = "Bootstrap Administrator"
|
| 45 |
+
auth_bootstrap_environment: str = "live"
|
| 46 |
+
auth_last_used_update_seconds: int = Field(default=60, ge=0, le=3600)
|
| 47 |
+
auth_default_requests_per_minute: int = Field(default=100, ge=1, le=1_000_000)
|
| 48 |
+
auth_default_concurrent_jobs: int = Field(default=10, ge=1, le=10_000)
|
| 49 |
+
auth_default_uploads_per_hour: int = Field(default=20, ge=1, le=1_000_000)
|
| 50 |
+
auth_default_processing_bytes_per_day: int = Field(
|
| 51 |
+
default=107_374_182_400, ge=1_048_576
|
| 52 |
+
)
|
| 53 |
+
auth_trust_proxy_headers: bool = True
|
| 54 |
+
mcp_stdio_api_key: SecretStr | None = None
|
| 55 |
|
| 56 |
@field_validator("whisper_model")
|
| 57 |
@classmethod
|
|
|
|
| 73 |
def ensure_directories(self) -> None:
|
| 74 |
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
| 75 |
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 76 |
+
sqlite_prefixes = ("sqlite+aiosqlite:///", "sqlite:///")
|
| 77 |
+
for prefix in sqlite_prefixes:
|
| 78 |
+
if self.database_url.startswith(prefix):
|
| 79 |
+
database_path = self.database_url.removeprefix(prefix)
|
| 80 |
+
if database_path and database_path != ":memory:":
|
| 81 |
+
Path(database_path).expanduser().resolve().parent.mkdir(
|
| 82 |
+
parents=True, exist_ok=True
|
| 83 |
+
)
|
| 84 |
+
break
|
| 85 |
+
|
| 86 |
+
@field_validator("auth_bootstrap_environment")
|
| 87 |
+
@classmethod
|
| 88 |
+
def validate_auth_environment(cls, value: str) -> str:
|
| 89 |
+
normalized = value.strip().lower()
|
| 90 |
+
if normalized not in {"live", "test"}:
|
| 91 |
+
raise ValueError("AUTH_BOOTSTRAP_ENVIRONMENT must be live or test")
|
| 92 |
+
return normalized
|
| 93 |
|
| 94 |
|
| 95 |
@lru_cache
|
app/mcp/registry.py
CHANGED
|
@@ -20,6 +20,8 @@ from app.core.logger import get_logger, request_id_context
|
|
| 20 |
from app.core.response import SuccessResponse
|
| 21 |
from app.models.media import ResolvedRequest
|
| 22 |
from app.operations.common import AUDIO_FORMATS, IMAGE_FORMATS, VIDEO_FORMATS
|
|
|
|
|
|
|
| 23 |
from app.services.media_service import Operation
|
| 24 |
|
| 25 |
logger = get_logger(__name__)
|
|
@@ -232,7 +234,13 @@ class MCPRegistry:
|
|
| 232 |
|
| 233 |
return await self._execute("run_template", action)
|
| 234 |
|
| 235 |
-
async def run_metadata_tool(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
"""Run a non-media utility with the same logging and error contract."""
|
| 237 |
|
| 238 |
async def wrapped(request_id: str) -> SuccessResponse:
|
|
@@ -244,7 +252,7 @@ class MCPRegistry:
|
|
| 244 |
metadata=metadata,
|
| 245 |
)
|
| 246 |
|
| 247 |
-
return await self._execute(tool_name, wrapped)
|
| 248 |
|
| 249 |
async def health_data(self) -> dict[str, Any]:
|
| 250 |
"""Return dependency health without loading the Whisper model."""
|
|
@@ -347,7 +355,9 @@ class MCPRegistry:
|
|
| 347 |
|
| 348 |
async def safe_resource(self, name: str, action: MetadataAction) -> dict[str, Any]:
|
| 349 |
"""Return resource data without exposing raw exceptions."""
|
| 350 |
-
response = await self.run_metadata_tool(
|
|
|
|
|
|
|
| 351 |
if response["success"]:
|
| 352 |
return {"success": True, **response["metadata"]}
|
| 353 |
return {
|
|
@@ -374,13 +384,64 @@ class MCPRegistry:
|
|
| 374 |
ytdlp_options=ytdlp_options,
|
| 375 |
)
|
| 376 |
|
| 377 |
-
async def _execute(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
request_id = str(uuid4())
|
| 379 |
-
token = request_id_context.set(request_id)
|
| 380 |
started = time.monotonic()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
cpu_started = time.process_time()
|
|
|
|
| 382 |
try:
|
|
|
|
|
|
|
| 383 |
response = await action(request_id)
|
|
|
|
| 384 |
result = self._tool_success(response)
|
| 385 |
logger.info(
|
| 386 |
"MCP tool completed",
|
|
@@ -394,6 +455,7 @@ class MCPRegistry:
|
|
| 394 |
)
|
| 395 |
return result
|
| 396 |
except MediaAPIError as exc:
|
|
|
|
| 397 |
logger.warning(
|
| 398 |
"MCP tool failed",
|
| 399 |
extra={
|
|
@@ -424,6 +486,42 @@ class MCPRegistry:
|
|
| 424 |
)
|
| 425 |
finally:
|
| 426 |
request_id_context.reset(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
|
| 428 |
def _tool_success(self, response: SuccessResponse) -> dict[str, Any]:
|
| 429 |
output_file: str | None = None
|
|
|
|
| 20 |
from app.core.response import SuccessResponse
|
| 21 |
from app.models.media import ResolvedRequest
|
| 22 |
from app.operations.common import AUDIO_FORMATS, IMAGE_FORMATS, VIDEO_FORMATS
|
| 23 |
+
from app.security.context import AuthContext, auth_context, http_auth_applied
|
| 24 |
+
from app.security.errors import RateLimitError
|
| 25 |
from app.services.media_service import Operation
|
| 26 |
|
| 27 |
logger = get_logger(__name__)
|
|
|
|
| 234 |
|
| 235 |
return await self._execute("run_template", action)
|
| 236 |
|
| 237 |
+
async def run_metadata_tool(
|
| 238 |
+
self,
|
| 239 |
+
tool_name: str,
|
| 240 |
+
action: MetadataAction,
|
| 241 |
+
*,
|
| 242 |
+
required_scope: str = "mcp:execute",
|
| 243 |
+
) -> dict[str, Any]:
|
| 244 |
"""Run a non-media utility with the same logging and error contract."""
|
| 245 |
|
| 246 |
async def wrapped(request_id: str) -> SuccessResponse:
|
|
|
|
| 252 |
metadata=metadata,
|
| 253 |
)
|
| 254 |
|
| 255 |
+
return await self._execute(tool_name, wrapped, required_scope=required_scope)
|
| 256 |
|
| 257 |
async def health_data(self) -> dict[str, Any]:
|
| 258 |
"""Return dependency health without loading the Whisper model."""
|
|
|
|
| 355 |
|
| 356 |
async def safe_resource(self, name: str, action: MetadataAction) -> dict[str, Any]:
|
| 357 |
"""Return resource data without exposing raw exceptions."""
|
| 358 |
+
response = await self.run_metadata_tool(
|
| 359 |
+
f"resource.{name}", action, required_scope="mcp:read"
|
| 360 |
+
)
|
| 361 |
if response["success"]:
|
| 362 |
return {"success": True, **response["metadata"]}
|
| 363 |
return {
|
|
|
|
| 384 |
ytdlp_options=ytdlp_options,
|
| 385 |
)
|
| 386 |
|
| 387 |
+
async def _execute(
|
| 388 |
+
self,
|
| 389 |
+
tool_name: str,
|
| 390 |
+
action: Action,
|
| 391 |
+
*,
|
| 392 |
+
required_scope: str = "mcp:execute",
|
| 393 |
+
) -> dict[str, Any]:
|
| 394 |
request_id = str(uuid4())
|
|
|
|
| 395 |
started = time.monotonic()
|
| 396 |
+
context = auth_context.get()
|
| 397 |
+
via_http = http_auth_applied.get()
|
| 398 |
+
if self.container.settings.auth_enabled and context is None:
|
| 399 |
+
return self._tool_failure(
|
| 400 |
+
request_id,
|
| 401 |
+
started,
|
| 402 |
+
"UNAUTHORIZED",
|
| 403 |
+
"Invalid or expired API key.",
|
| 404 |
+
None,
|
| 405 |
+
)
|
| 406 |
+
lease = None
|
| 407 |
+
if context is not None and not via_http:
|
| 408 |
+
try:
|
| 409 |
+
lease = await self.container.rate_limiter.acquire(
|
| 410 |
+
context,
|
| 411 |
+
is_job=required_scope == "mcp:execute",
|
| 412 |
+
is_upload=False,
|
| 413 |
+
uploaded_bytes=0,
|
| 414 |
+
)
|
| 415 |
+
except RateLimitError as exc:
|
| 416 |
+
await self._audit_mcp(context, request_id, tool_name, 429, started)
|
| 417 |
+
return self._tool_failure(
|
| 418 |
+
request_id,
|
| 419 |
+
started,
|
| 420 |
+
"RATE_LIMIT_EXCEEDED",
|
| 421 |
+
"Retry later.",
|
| 422 |
+
{"retry_after": exc.retry_after},
|
| 423 |
+
)
|
| 424 |
+
if context is not None and not context.allows(required_scope):
|
| 425 |
+
if lease is not None:
|
| 426 |
+
await lease.release()
|
| 427 |
+
result = self._tool_failure(
|
| 428 |
+
request_id,
|
| 429 |
+
started,
|
| 430 |
+
"FORBIDDEN",
|
| 431 |
+
"Missing required scope.",
|
| 432 |
+
None,
|
| 433 |
+
)
|
| 434 |
+
if not via_http:
|
| 435 |
+
await self._audit_mcp(context, request_id, tool_name, 403, started)
|
| 436 |
+
return result
|
| 437 |
+
token = request_id_context.set(request_id)
|
| 438 |
cpu_started = time.process_time()
|
| 439 |
+
response_code = 500
|
| 440 |
try:
|
| 441 |
+
if context is not None and not via_http:
|
| 442 |
+
await self.container.api_keys.mark_used(context)
|
| 443 |
response = await action(request_id)
|
| 444 |
+
response_code = 200
|
| 445 |
result = self._tool_success(response)
|
| 446 |
logger.info(
|
| 447 |
"MCP tool completed",
|
|
|
|
| 455 |
)
|
| 456 |
return result
|
| 457 |
except MediaAPIError as exc:
|
| 458 |
+
response_code = exc.status_code
|
| 459 |
logger.warning(
|
| 460 |
"MCP tool failed",
|
| 461 |
extra={
|
|
|
|
| 486 |
)
|
| 487 |
finally:
|
| 488 |
request_id_context.reset(token)
|
| 489 |
+
if lease is not None:
|
| 490 |
+
await lease.release()
|
| 491 |
+
if context is not None and not via_http:
|
| 492 |
+
await self._audit_mcp(
|
| 493 |
+
context, request_id, tool_name, response_code, started
|
| 494 |
+
)
|
| 495 |
+
|
| 496 |
+
async def _audit_mcp(
|
| 497 |
+
self,
|
| 498 |
+
context: AuthContext,
|
| 499 |
+
request_id: str,
|
| 500 |
+
tool_name: str,
|
| 501 |
+
response_code: int,
|
| 502 |
+
started: float,
|
| 503 |
+
) -> None:
|
| 504 |
+
data = {
|
| 505 |
+
"request_id": request_id,
|
| 506 |
+
"api_key_id": context.api_key_id,
|
| 507 |
+
"key_name": context.key_name,
|
| 508 |
+
"ip_address": None,
|
| 509 |
+
"user_agent": "mcp-stdio",
|
| 510 |
+
"endpoint": f"mcp://tools/{tool_name}",
|
| 511 |
+
"http_method": "CALL",
|
| 512 |
+
"response_code": response_code,
|
| 513 |
+
"processing_time_ms": max(0, round((time.monotonic() - started) * 1000)),
|
| 514 |
+
"bytes_uploaded": 0,
|
| 515 |
+
"bytes_downloaded": 0,
|
| 516 |
+
}
|
| 517 |
+
try:
|
| 518 |
+
await self.container.audit.record(**data)
|
| 519 |
+
except Exception:
|
| 520 |
+
logger.exception(
|
| 521 |
+
"MCP authentication audit persistence failed",
|
| 522 |
+
extra={"api_key_id": context.api_key_id, "tool": tool_name},
|
| 523 |
+
)
|
| 524 |
+
logger.info("authenticated MCP call", extra=data)
|
| 525 |
|
| 526 |
def _tool_success(self, response: SuccessResponse) -> dict[str, Any]:
|
| 527 |
output_file: str | None = None
|
app/mcp/server.py
CHANGED
|
@@ -5,7 +5,10 @@ import asyncio
|
|
| 5 |
import sys
|
| 6 |
from typing import Any, Literal, cast
|
| 7 |
|
|
|
|
| 8 |
from mcp.server.fastmcp import FastMCP
|
|
|
|
|
|
|
| 9 |
|
| 10 |
from app.container import Container, build_container
|
| 11 |
from app.core.config import get_settings
|
|
@@ -21,6 +24,8 @@ from app.mcp.tools.templates import register_template_tools
|
|
| 21 |
from app.mcp.tools.video import register_video_tools
|
| 22 |
from app.mcp.tools.whisper import register_whisper_tools
|
| 23 |
from app.mcp.tools.ytdlp import register_ytdlp_tools
|
|
|
|
|
|
|
| 24 |
from app.workers.cleanup_worker import CleanupWorker
|
| 25 |
|
| 26 |
MCPLogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
@@ -30,7 +35,7 @@ def create_mcp_server(container: Container) -> FastMCP[Any]:
|
|
| 30 |
"""Create a fully registered MCP server over an existing service container."""
|
| 31 |
settings = container.settings
|
| 32 |
server: FastMCP[Any] = FastMCP(
|
| 33 |
-
name=
|
| 34 |
instructions=(
|
| 35 |
"Use the registered tools for safe media processing. All media inputs accept URL, Base64, "
|
| 36 |
"n8n binary objects, or managed temp_path values. Read media://operations for capabilities."
|
|
@@ -57,20 +62,61 @@ def create_mcp_server(container: Container) -> FastMCP[Any]:
|
|
| 57 |
|
| 58 |
|
| 59 |
async def run_server(transport: Literal["stdio", "streamable-http"] = "stdio") -> None:
|
| 60 |
-
"""Run MCP
|
| 61 |
configure_logging(stream=sys.stderr if transport == "stdio" else None)
|
| 62 |
settings = get_settings()
|
| 63 |
container = build_container(settings)
|
| 64 |
worker = CleanupWorker(container.cleanup, settings.cleanup_interval_seconds)
|
| 65 |
server = create_mcp_server(container)
|
|
|
|
|
|
|
| 66 |
await worker.start()
|
| 67 |
try:
|
| 68 |
if transport == "stdio":
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
else:
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
finally:
|
| 73 |
await worker.stop()
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
def main() -> None:
|
|
|
|
| 5 |
import sys
|
| 6 |
from typing import Any, Literal, cast
|
| 7 |
|
| 8 |
+
import uvicorn
|
| 9 |
from mcp.server.fastmcp import FastMCP
|
| 10 |
+
from starlette.applications import Starlette
|
| 11 |
+
from starlette.routing import Mount
|
| 12 |
|
| 13 |
from app.container import Container, build_container
|
| 14 |
from app.core.config import get_settings
|
|
|
|
| 24 |
from app.mcp.tools.video import register_video_tools
|
| 25 |
from app.mcp.tools.whisper import register_whisper_tools
|
| 26 |
from app.mcp.tools.ytdlp import register_ytdlp_tools
|
| 27 |
+
from app.security.context import auth_context
|
| 28 |
+
from app.security.middleware import APIKeyAuthenticationMiddleware
|
| 29 |
from app.workers.cleanup_worker import CleanupWorker
|
| 30 |
|
| 31 |
MCPLogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
|
|
| 35 |
"""Create a fully registered MCP server over an existing service container."""
|
| 36 |
settings = container.settings
|
| 37 |
server: FastMCP[Any] = FastMCP(
|
| 38 |
+
name=settings.app_name,
|
| 39 |
instructions=(
|
| 40 |
"Use the registered tools for safe media processing. All media inputs accept URL, Base64, "
|
| 41 |
"n8n binary objects, or managed temp_path values. Read media://operations for capabilities."
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
async def run_server(transport: Literal["stdio", "streamable-http"] = "stdio") -> None:
|
| 65 |
+
"""Run standalone MCP with the same database, keys, scopes, and limits as REST."""
|
| 66 |
configure_logging(stream=sys.stderr if transport == "stdio" else None)
|
| 67 |
settings = get_settings()
|
| 68 |
container = build_container(settings)
|
| 69 |
worker = CleanupWorker(container.cleanup, settings.cleanup_interval_seconds)
|
| 70 |
server = create_mcp_server(container)
|
| 71 |
+
await container.security_database.initialize()
|
| 72 |
+
await container.api_keys.ensure_bootstrap_admin()
|
| 73 |
await worker.start()
|
| 74 |
try:
|
| 75 |
if transport == "stdio":
|
| 76 |
+
context_token = None
|
| 77 |
+
if settings.auth_enabled:
|
| 78 |
+
configured_key = (
|
| 79 |
+
settings.mcp_stdio_api_key.get_secret_value()
|
| 80 |
+
if settings.mcp_stdio_api_key is not None
|
| 81 |
+
else ""
|
| 82 |
+
)
|
| 83 |
+
if not configured_key:
|
| 84 |
+
raise RuntimeError(
|
| 85 |
+
"MCP_STDIO_API_KEY is required when AUTH_ENABLED=true"
|
| 86 |
+
)
|
| 87 |
+
context = await container.api_keys.authenticate(configured_key)
|
| 88 |
+
if not context.allows("mcp:read"):
|
| 89 |
+
raise RuntimeError("MCP_STDIO_API_KEY is missing the mcp:read scope")
|
| 90 |
+
await container.api_keys.mark_used(context)
|
| 91 |
+
context_token = auth_context.set(context)
|
| 92 |
+
try:
|
| 93 |
+
await server.run_stdio_async()
|
| 94 |
+
finally:
|
| 95 |
+
if context_token is not None:
|
| 96 |
+
auth_context.reset(context_token)
|
| 97 |
else:
|
| 98 |
+
mcp_application = server.streamable_http_app()
|
| 99 |
+
application = Starlette(routes=[Mount("/mcp", app=mcp_application)])
|
| 100 |
+
application.state.container = container
|
| 101 |
+
application.state.mcp_server = server
|
| 102 |
+
application.add_middleware(
|
| 103 |
+
APIKeyAuthenticationMiddleware,
|
| 104 |
+
settings=settings,
|
| 105 |
+
api_keys=container.api_keys,
|
| 106 |
+
rate_limiter=container.rate_limiter,
|
| 107 |
+
audit=container.audit,
|
| 108 |
+
)
|
| 109 |
+
config = uvicorn.Config(
|
| 110 |
+
application,
|
| 111 |
+
host=settings.host,
|
| 112 |
+
port=settings.port,
|
| 113 |
+
log_level=settings.log_level.lower(),
|
| 114 |
+
)
|
| 115 |
+
async with server.session_manager.run():
|
| 116 |
+
await uvicorn.Server(config).serve()
|
| 117 |
finally:
|
| 118 |
await worker.stop()
|
| 119 |
+
await container.security_database.close()
|
| 120 |
|
| 121 |
|
| 122 |
def main() -> None:
|
app/security/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stateless API-key authentication, authorization, rate limiting, and auditing."""
|
| 2 |
+
|
| 3 |
+
from app.security.context import AuthContext
|
| 4 |
+
from app.security.scopes import ALL_SCOPES, DEFAULT_ROLE_SCOPES
|
| 5 |
+
|
| 6 |
+
__all__ = ["ALL_SCOPES", "DEFAULT_ROLE_SCOPES", "AuthContext"]
|
app/security/audit.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import select
|
| 4 |
+
|
| 5 |
+
from app.security.database import SecurityDatabase
|
| 6 |
+
from app.security.models import AuditLog
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class AuditService:
|
| 10 |
+
def __init__(self, database: SecurityDatabase) -> None:
|
| 11 |
+
self.database = database
|
| 12 |
+
|
| 13 |
+
async def record(
|
| 14 |
+
self,
|
| 15 |
+
*,
|
| 16 |
+
request_id: str,
|
| 17 |
+
api_key_id: str,
|
| 18 |
+
key_name: str,
|
| 19 |
+
ip_address: str | None,
|
| 20 |
+
user_agent: str | None,
|
| 21 |
+
endpoint: str,
|
| 22 |
+
http_method: str,
|
| 23 |
+
response_code: int,
|
| 24 |
+
processing_time_ms: int,
|
| 25 |
+
bytes_uploaded: int,
|
| 26 |
+
bytes_downloaded: int,
|
| 27 |
+
) -> None:
|
| 28 |
+
async with self.database.session() as session:
|
| 29 |
+
session.add(
|
| 30 |
+
AuditLog(
|
| 31 |
+
request_id=request_id,
|
| 32 |
+
api_key_id=api_key_id,
|
| 33 |
+
key_name=key_name,
|
| 34 |
+
ip_address=ip_address,
|
| 35 |
+
user_agent=user_agent,
|
| 36 |
+
endpoint=endpoint,
|
| 37 |
+
http_method=http_method,
|
| 38 |
+
response_code=response_code,
|
| 39 |
+
processing_time_ms=processing_time_ms,
|
| 40 |
+
bytes_uploaded=bytes_uploaded,
|
| 41 |
+
bytes_downloaded=bytes_downloaded,
|
| 42 |
+
)
|
| 43 |
+
)
|
| 44 |
+
await session.commit()
|
| 45 |
+
|
| 46 |
+
async def list(self, *, offset: int = 0, limit: int = 100) -> list[AuditLog]:
|
| 47 |
+
async with self.database.session() as session:
|
| 48 |
+
return list(
|
| 49 |
+
(
|
| 50 |
+
await session.scalars(
|
| 51 |
+
select(AuditLog)
|
| 52 |
+
.order_by(AuditLog.created_at.desc())
|
| 53 |
+
.offset(offset)
|
| 54 |
+
.limit(limit)
|
| 55 |
+
)
|
| 56 |
+
).all()
|
| 57 |
+
)
|
app/security/cli.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import asyncio
|
| 5 |
+
import json
|
| 6 |
+
|
| 7 |
+
from app.core.config import get_settings
|
| 8 |
+
from app.security.database import SecurityDatabase
|
| 9 |
+
from app.security.schemas import APIKeyCreate, APIKeyView
|
| 10 |
+
from app.security.service import APIKeyService
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def _create(arguments: argparse.Namespace) -> None:
|
| 14 |
+
settings = get_settings()
|
| 15 |
+
database = SecurityDatabase(settings.database_url)
|
| 16 |
+
service = APIKeyService(database, settings)
|
| 17 |
+
await database.initialize()
|
| 18 |
+
try:
|
| 19 |
+
record, secret = await service.create(
|
| 20 |
+
APIKeyCreate(
|
| 21 |
+
name=arguments.name,
|
| 22 |
+
environment=arguments.environment,
|
| 23 |
+
role=arguments.role,
|
| 24 |
+
expires_in_seconds=arguments.expires_in,
|
| 25 |
+
),
|
| 26 |
+
created_by="security-cli",
|
| 27 |
+
)
|
| 28 |
+
payload = APIKeyView.model_validate(record).model_dump(mode="json")
|
| 29 |
+
payload["api_key"] = secret
|
| 30 |
+
print(json.dumps(payload, indent=2))
|
| 31 |
+
finally:
|
| 32 |
+
await database.close()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _bootstrap(arguments: argparse.Namespace) -> None:
|
| 36 |
+
material = APIKeyService.generate_material(arguments.environment)
|
| 37 |
+
print("Store the API key in your password manager. It will not be shown again.\n")
|
| 38 |
+
print(f"API_KEY={material.api_key}")
|
| 39 |
+
print(f"AUTH_BOOTSTRAP_KEY_HASH={material.key_hash}")
|
| 40 |
+
print(f"AUTH_BOOTSTRAP_KEY_PREFIX={material.key_prefix}")
|
| 41 |
+
print(f"AUTH_BOOTSTRAP_ENVIRONMENT={material.environment}")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def main() -> None:
|
| 45 |
+
parser = argparse.ArgumentParser(description="MediaRouter API-key administration")
|
| 46 |
+
commands = parser.add_subparsers(dest="command", required=True)
|
| 47 |
+
bootstrap = commands.add_parser(
|
| 48 |
+
"generate-bootstrap", help="Generate a key and hash-only bootstrap settings"
|
| 49 |
+
)
|
| 50 |
+
bootstrap.add_argument("--environment", choices=("live", "test"), default="live")
|
| 51 |
+
create = commands.add_parser("create", help="Create a key directly in the configured database")
|
| 52 |
+
create.add_argument("--name", required=True)
|
| 53 |
+
create.add_argument("--environment", choices=("live", "test"), default="live")
|
| 54 |
+
create.add_argument("--role", default="admin")
|
| 55 |
+
create.add_argument("--expires-in", type=int, default=None)
|
| 56 |
+
arguments = parser.parse_args()
|
| 57 |
+
if arguments.command == "generate-bootstrap":
|
| 58 |
+
_bootstrap(arguments)
|
| 59 |
+
else:
|
| 60 |
+
asyncio.run(_create(arguments))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
if __name__ == "__main__":
|
| 64 |
+
main()
|
app/security/context.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import contextvars
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(frozen=True, slots=True)
|
| 9 |
+
class AuthContext:
|
| 10 |
+
api_key_id: str
|
| 11 |
+
key_name: str
|
| 12 |
+
key_prefix: str
|
| 13 |
+
environment: str
|
| 14 |
+
role: str | None
|
| 15 |
+
scopes: frozenset[str]
|
| 16 |
+
requests_per_minute: int
|
| 17 |
+
concurrent_jobs: int
|
| 18 |
+
uploads_per_hour: int
|
| 19 |
+
processing_bytes_per_day: int
|
| 20 |
+
expires_at: datetime | None
|
| 21 |
+
|
| 22 |
+
def allows(self, required_scope: str) -> bool:
|
| 23 |
+
return "admin" in self.scopes or required_scope in self.scopes
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
auth_context: contextvars.ContextVar[AuthContext | None] = contextvars.ContextVar(
|
| 27 |
+
"auth_context", default=None
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# HTTP authentication already applies request and concurrency limits before the
|
| 31 |
+
# MCP ASGI application runs. Standalone stdio MCP calls use this marker to apply
|
| 32 |
+
# the same limiter exactly once inside the transport-neutral registry.
|
| 33 |
+
http_auth_applied: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
| 34 |
+
"http_auth_applied", default=False
|
| 35 |
+
)
|
app/security/database.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import AsyncIterator
|
| 4 |
+
from contextlib import asynccontextmanager
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import event
|
| 7 |
+
from sqlalchemy.ext.asyncio import (
|
| 8 |
+
AsyncEngine,
|
| 9 |
+
AsyncSession,
|
| 10 |
+
async_sessionmaker,
|
| 11 |
+
create_async_engine,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
from app.security.models import Base
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class SecurityDatabase:
|
| 18 |
+
"""Owns the authentication database engine and short-lived async sessions."""
|
| 19 |
+
|
| 20 |
+
def __init__(self, database_url: str) -> None:
|
| 21 |
+
self.engine: AsyncEngine = create_async_engine(
|
| 22 |
+
database_url,
|
| 23 |
+
pool_pre_ping=True,
|
| 24 |
+
)
|
| 25 |
+
if database_url.startswith("sqlite"):
|
| 26 |
+
event.listen(self.engine.sync_engine, "connect", self._configure_sqlite)
|
| 27 |
+
self.session_factory = async_sessionmaker(
|
| 28 |
+
self.engine, expire_on_commit=False, class_=AsyncSession
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
@staticmethod
|
| 32 |
+
def _configure_sqlite(dbapi_connection: object, _record: object) -> None:
|
| 33 |
+
cursor = dbapi_connection.cursor() # type: ignore[attr-defined]
|
| 34 |
+
cursor.execute("PRAGMA foreign_keys=ON")
|
| 35 |
+
cursor.execute("PRAGMA journal_mode=WAL")
|
| 36 |
+
cursor.execute("PRAGMA busy_timeout=5000")
|
| 37 |
+
cursor.close()
|
| 38 |
+
|
| 39 |
+
async def initialize(self) -> None:
|
| 40 |
+
async with self.engine.begin() as connection:
|
| 41 |
+
await connection.run_sync(Base.metadata.create_all)
|
| 42 |
+
|
| 43 |
+
async def close(self) -> None:
|
| 44 |
+
await self.engine.dispose()
|
| 45 |
+
|
| 46 |
+
@asynccontextmanager
|
| 47 |
+
async def session(self) -> AsyncIterator[AsyncSession]:
|
| 48 |
+
async with self.session_factory() as session:
|
| 49 |
+
yield session
|
app/security/errors.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class SecurityError(Exception):
|
| 2 |
+
"""Base class for deliberately generic authentication failures."""
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class UnauthorizedError(SecurityError):
|
| 6 |
+
pass
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ForbiddenError(SecurityError):
|
| 10 |
+
pass
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class RateLimitError(SecurityError):
|
| 14 |
+
def __init__(self, retry_after: int) -> None:
|
| 15 |
+
super().__init__("Rate limit exceeded")
|
| 16 |
+
self.retry_after = max(1, retry_after)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class APIKeyNotFoundError(SecurityError):
|
| 20 |
+
pass
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class APIKeyConflictError(SecurityError):
|
| 24 |
+
pass
|
app/security/middleware.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from typing import Any
|
| 5 |
+
from uuid import uuid4
|
| 6 |
+
|
| 7 |
+
from fastapi import Request
|
| 8 |
+
from fastapi.responses import JSONResponse
|
| 9 |
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
| 10 |
+
from starlette.responses import Response
|
| 11 |
+
|
| 12 |
+
from app.core.config import Settings
|
| 13 |
+
from app.core.logger import get_logger
|
| 14 |
+
from app.security.audit import AuditService
|
| 15 |
+
from app.security.context import AuthContext, auth_context, http_auth_applied
|
| 16 |
+
from app.security.errors import ForbiddenError, RateLimitError, UnauthorizedError
|
| 17 |
+
from app.security.policy import ScopePolicy
|
| 18 |
+
from app.security.rate_limit import APIKeyRateLimiter, RateLimitLease
|
| 19 |
+
from app.security.service import APIKeyService
|
| 20 |
+
|
| 21 |
+
logger = get_logger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class APIKeyAuthenticationMiddleware(BaseHTTPMiddleware):
|
| 25 |
+
"""Authenticates, authorizes, rate-limits, and audits protected HTTP requests."""
|
| 26 |
+
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
app: Any,
|
| 30 |
+
*,
|
| 31 |
+
settings: Settings,
|
| 32 |
+
api_keys: APIKeyService,
|
| 33 |
+
rate_limiter: APIKeyRateLimiter,
|
| 34 |
+
audit: AuditService,
|
| 35 |
+
) -> None:
|
| 36 |
+
super().__init__(app)
|
| 37 |
+
self.settings = settings
|
| 38 |
+
self.api_keys = api_keys
|
| 39 |
+
self.rate_limiter = rate_limiter
|
| 40 |
+
self.audit = audit
|
| 41 |
+
self.policy = ScopePolicy()
|
| 42 |
+
|
| 43 |
+
async def dispatch(
|
| 44 |
+
self, request: Request, call_next: RequestResponseEndpoint
|
| 45 |
+
) -> Response:
|
| 46 |
+
if not getattr(request.state, "request_id", None):
|
| 47 |
+
request.state.request_id = str(uuid4())
|
| 48 |
+
if not self.settings.auth_enabled or self.policy.is_public(request):
|
| 49 |
+
return await call_next(request)
|
| 50 |
+
started = time.monotonic()
|
| 51 |
+
context: AuthContext | None = None
|
| 52 |
+
lease: RateLimitLease | None = None
|
| 53 |
+
context_token = None
|
| 54 |
+
http_auth_token = None
|
| 55 |
+
response_code = 500
|
| 56 |
+
bytes_uploaded = self._content_length(request.headers.get("content-length"))
|
| 57 |
+
bytes_downloaded = 0
|
| 58 |
+
try:
|
| 59 |
+
api_key = self._bearer_token(request.headers.get("authorization"))
|
| 60 |
+
context = await self.api_keys.authenticate(api_key)
|
| 61 |
+
request.state.auth = context
|
| 62 |
+
context_token = auth_context.set(context)
|
| 63 |
+
required_scope = await self.policy.required_scope(request)
|
| 64 |
+
lease = await self.rate_limiter.acquire(
|
| 65 |
+
context,
|
| 66 |
+
is_job=self.policy.is_job(required_scope),
|
| 67 |
+
is_upload=self.policy.is_upload(request, required_scope),
|
| 68 |
+
uploaded_bytes=bytes_uploaded,
|
| 69 |
+
)
|
| 70 |
+
self.api_keys.authorize(context, required_scope)
|
| 71 |
+
await self.api_keys.mark_used(context)
|
| 72 |
+
http_auth_token = http_auth_applied.set(True)
|
| 73 |
+
response = await call_next(request)
|
| 74 |
+
response_code = response.status_code
|
| 75 |
+
bytes_downloaded = self._content_length(response.headers.get("content-length"))
|
| 76 |
+
response.headers.setdefault("X-Request-ID", request.state.request_id)
|
| 77 |
+
return response
|
| 78 |
+
except UnauthorizedError:
|
| 79 |
+
response_code = 401
|
| 80 |
+
return self._error(
|
| 81 |
+
401,
|
| 82 |
+
"Unauthorized",
|
| 83 |
+
"Invalid or expired API key.",
|
| 84 |
+
request,
|
| 85 |
+
{"WWW-Authenticate": "Bearer"},
|
| 86 |
+
)
|
| 87 |
+
except ForbiddenError:
|
| 88 |
+
response_code = 403
|
| 89 |
+
response = self._error(
|
| 90 |
+
403, "Forbidden", "Missing required scope.", request
|
| 91 |
+
)
|
| 92 |
+
bytes_downloaded = len(response.body)
|
| 93 |
+
return response
|
| 94 |
+
except RateLimitError as exc:
|
| 95 |
+
response_code = 429
|
| 96 |
+
response = self._error(
|
| 97 |
+
429,
|
| 98 |
+
"Rate limit exceeded",
|
| 99 |
+
"Retry later.",
|
| 100 |
+
request,
|
| 101 |
+
{"Retry-After": str(exc.retry_after)},
|
| 102 |
+
)
|
| 103 |
+
bytes_downloaded = len(response.body)
|
| 104 |
+
return response
|
| 105 |
+
finally:
|
| 106 |
+
if lease is not None:
|
| 107 |
+
await lease.release()
|
| 108 |
+
if http_auth_token is not None:
|
| 109 |
+
http_auth_applied.reset(http_auth_token)
|
| 110 |
+
if context_token is not None:
|
| 111 |
+
auth_context.reset(context_token)
|
| 112 |
+
if context is not None:
|
| 113 |
+
elapsed_ms = max(0, round((time.monotonic() - started) * 1000))
|
| 114 |
+
await self._audit_request(
|
| 115 |
+
request,
|
| 116 |
+
context,
|
| 117 |
+
response_code,
|
| 118 |
+
elapsed_ms,
|
| 119 |
+
bytes_uploaded,
|
| 120 |
+
bytes_downloaded,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
@staticmethod
|
| 124 |
+
def _bearer_token(header: str | None) -> str:
|
| 125 |
+
if not header:
|
| 126 |
+
raise UnauthorizedError
|
| 127 |
+
parts = header.strip().split()
|
| 128 |
+
if len(parts) != 2 or parts[0].casefold() != "bearer" or not parts[1]:
|
| 129 |
+
raise UnauthorizedError
|
| 130 |
+
return parts[1]
|
| 131 |
+
|
| 132 |
+
@staticmethod
|
| 133 |
+
def _content_length(value: str | None) -> int:
|
| 134 |
+
try:
|
| 135 |
+
return max(0, int(value or 0))
|
| 136 |
+
except ValueError:
|
| 137 |
+
return 0
|
| 138 |
+
|
| 139 |
+
@staticmethod
|
| 140 |
+
def _error(
|
| 141 |
+
status_code: int,
|
| 142 |
+
error: str,
|
| 143 |
+
message: str,
|
| 144 |
+
request: Request,
|
| 145 |
+
headers: dict[str, str] | None = None,
|
| 146 |
+
) -> JSONResponse:
|
| 147 |
+
response_headers = dict(headers or {})
|
| 148 |
+
request_id = getattr(request.state, "request_id", None)
|
| 149 |
+
if request_id:
|
| 150 |
+
response_headers["X-Request-ID"] = request_id
|
| 151 |
+
return JSONResponse(
|
| 152 |
+
{"error": error, "message": message},
|
| 153 |
+
status_code=status_code,
|
| 154 |
+
headers=response_headers,
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
def _client_ip(self, request: Request) -> str | None:
|
| 158 |
+
if self.settings.auth_trust_proxy_headers:
|
| 159 |
+
forwarded = request.headers.get("x-forwarded-for")
|
| 160 |
+
if forwarded:
|
| 161 |
+
return forwarded.split(",", 1)[0].strip()[:64]
|
| 162 |
+
return request.client.host[:64] if request.client else None
|
| 163 |
+
|
| 164 |
+
async def _audit_request(
|
| 165 |
+
self,
|
| 166 |
+
request: Request,
|
| 167 |
+
context: AuthContext,
|
| 168 |
+
response_code: int,
|
| 169 |
+
elapsed_ms: int,
|
| 170 |
+
bytes_uploaded: int,
|
| 171 |
+
bytes_downloaded: int,
|
| 172 |
+
) -> None:
|
| 173 |
+
data = {
|
| 174 |
+
"request_id": getattr(request.state, "request_id", "-"),
|
| 175 |
+
"api_key_id": context.api_key_id,
|
| 176 |
+
"key_name": context.key_name,
|
| 177 |
+
"ip_address": self._client_ip(request),
|
| 178 |
+
"user_agent": request.headers.get("user-agent", "")[:512] or None,
|
| 179 |
+
"endpoint": request.url.path,
|
| 180 |
+
"http_method": request.method,
|
| 181 |
+
"response_code": response_code,
|
| 182 |
+
"processing_time_ms": elapsed_ms,
|
| 183 |
+
"bytes_uploaded": bytes_uploaded,
|
| 184 |
+
"bytes_downloaded": bytes_downloaded,
|
| 185 |
+
}
|
| 186 |
+
try:
|
| 187 |
+
await self.audit.record(**data)
|
| 188 |
+
except Exception:
|
| 189 |
+
logger.exception(
|
| 190 |
+
"authentication audit persistence failed",
|
| 191 |
+
extra={"api_key_id": context.api_key_id},
|
| 192 |
+
)
|
| 193 |
+
logger.info("authenticated request", extra=data)
|
app/security/migrations/0001_api_key_security.sql
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- MediaRouter security schema migration 0001 (SQLite).
|
| 2 |
+
-- Production startup applies the equivalent SQLAlchemy metadata transactionally.
|
| 3 |
+
CREATE TABLE IF NOT EXISTS api_keys (
|
| 4 |
+
id VARCHAR(36) PRIMARY KEY,
|
| 5 |
+
name VARCHAR(120) NOT NULL,
|
| 6 |
+
key_prefix VARCHAR(40) NOT NULL,
|
| 7 |
+
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
| 8 |
+
environment VARCHAR(16) NOT NULL,
|
| 9 |
+
status VARCHAR(16) NOT NULL,
|
| 10 |
+
role VARCHAR(64),
|
| 11 |
+
scopes JSON NOT NULL,
|
| 12 |
+
created_at DATETIME NOT NULL,
|
| 13 |
+
last_used_at DATETIME,
|
| 14 |
+
expires_at DATETIME,
|
| 15 |
+
grace_expires_at DATETIME,
|
| 16 |
+
created_by VARCHAR(120),
|
| 17 |
+
notes TEXT,
|
| 18 |
+
rotated_from_id VARCHAR(36) REFERENCES api_keys(id) ON DELETE SET NULL,
|
| 19 |
+
requests_per_minute INTEGER NOT NULL,
|
| 20 |
+
concurrent_jobs INTEGER NOT NULL,
|
| 21 |
+
uploads_per_hour INTEGER NOT NULL,
|
| 22 |
+
processing_bytes_per_day BIGINT NOT NULL
|
| 23 |
+
);
|
| 24 |
+
CREATE INDEX IF NOT EXISTS ix_api_keys_key_prefix ON api_keys(key_prefix);
|
| 25 |
+
CREATE UNIQUE INDEX IF NOT EXISTS ix_api_keys_key_hash ON api_keys(key_hash);
|
| 26 |
+
CREATE INDEX IF NOT EXISTS ix_api_keys_status ON api_keys(status);
|
| 27 |
+
CREATE INDEX IF NOT EXISTS ix_api_keys_expires_at ON api_keys(expires_at);
|
| 28 |
+
|
| 29 |
+
CREATE TABLE IF NOT EXISTS audit_logs (
|
| 30 |
+
id VARCHAR(36) PRIMARY KEY,
|
| 31 |
+
request_id VARCHAR(36) NOT NULL,
|
| 32 |
+
api_key_id VARCHAR(36) REFERENCES api_keys(id) ON DELETE SET NULL,
|
| 33 |
+
key_name VARCHAR(120),
|
| 34 |
+
ip_address VARCHAR(64),
|
| 35 |
+
user_agent VARCHAR(512),
|
| 36 |
+
endpoint VARCHAR(1024) NOT NULL,
|
| 37 |
+
http_method VARCHAR(16) NOT NULL,
|
| 38 |
+
response_code INTEGER NOT NULL,
|
| 39 |
+
processing_time_ms INTEGER NOT NULL,
|
| 40 |
+
bytes_uploaded BIGINT NOT NULL DEFAULT 0,
|
| 41 |
+
bytes_downloaded BIGINT NOT NULL DEFAULT 0,
|
| 42 |
+
created_at DATETIME NOT NULL
|
| 43 |
+
);
|
| 44 |
+
CREATE INDEX IF NOT EXISTS ix_audit_logs_api_key_id ON audit_logs(api_key_id);
|
| 45 |
+
CREATE INDEX IF NOT EXISTS ix_audit_logs_created_at ON audit_logs(created_at);
|
| 46 |
+
CREATE INDEX IF NOT EXISTS ix_audit_logs_request_id ON audit_logs(request_id);
|
| 47 |
+
|
| 48 |
+
CREATE TABLE IF NOT EXISTS rate_limits (
|
| 49 |
+
api_key_id VARCHAR(36) NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
|
| 50 |
+
bucket_type VARCHAR(32) NOT NULL,
|
| 51 |
+
bucket_start DATETIME NOT NULL,
|
| 52 |
+
count BIGINT NOT NULL DEFAULT 0,
|
| 53 |
+
units BIGINT NOT NULL DEFAULT 0,
|
| 54 |
+
updated_at DATETIME NOT NULL,
|
| 55 |
+
PRIMARY KEY(api_key_id, bucket_type, bucket_start)
|
| 56 |
+
);
|
| 57 |
+
CREATE INDEX IF NOT EXISTS ix_rate_limits_api_key_id ON rate_limits(api_key_id);
|
| 58 |
+
CREATE INDEX IF NOT EXISTS ix_rate_limits_bucket_start ON rate_limits(bucket_start);
|
app/security/models.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String, Text
|
| 7 |
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def utcnow() -> datetime:
|
| 11 |
+
return datetime.now(timezone.utc)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Base(DeclarativeBase):
|
| 15 |
+
pass
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class APIKey(Base):
|
| 19 |
+
__tablename__ = "api_keys"
|
| 20 |
+
__table_args__ = (
|
| 21 |
+
Index("ix_api_keys_key_prefix", "key_prefix"),
|
| 22 |
+
Index("ix_api_keys_key_hash", "key_hash", unique=True),
|
| 23 |
+
Index("ix_api_keys_status", "status"),
|
| 24 |
+
Index("ix_api_keys_expires_at", "expires_at"),
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 28 |
+
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
| 29 |
+
key_prefix: Mapped[str] = mapped_column(String(40), nullable=False)
|
| 30 |
+
key_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
| 31 |
+
environment: Mapped[str] = mapped_column(String(16), nullable=False)
|
| 32 |
+
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
| 33 |
+
role: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
| 34 |
+
scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
| 35 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 36 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 37 |
+
)
|
| 38 |
+
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 39 |
+
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 40 |
+
grace_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 41 |
+
created_by: Mapped[str | None] = mapped_column(String(120))
|
| 42 |
+
notes: Mapped[str | None] = mapped_column(Text)
|
| 43 |
+
rotated_from_id: Mapped[str | None] = mapped_column(
|
| 44 |
+
String(36), ForeignKey("api_keys.id", ondelete="SET NULL")
|
| 45 |
+
)
|
| 46 |
+
requests_per_minute: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
|
| 47 |
+
concurrent_jobs: Mapped[int] = mapped_column(Integer, nullable=False, default=10)
|
| 48 |
+
uploads_per_hour: Mapped[int] = mapped_column(Integer, nullable=False, default=20)
|
| 49 |
+
processing_bytes_per_day: Mapped[int] = mapped_column(
|
| 50 |
+
BigInteger, nullable=False, default=107_374_182_400
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class AuditLog(Base):
|
| 55 |
+
__tablename__ = "audit_logs"
|
| 56 |
+
__table_args__ = (
|
| 57 |
+
Index("ix_audit_logs_api_key_id", "api_key_id"),
|
| 58 |
+
Index("ix_audit_logs_created_at", "created_at"),
|
| 59 |
+
Index("ix_audit_logs_request_id", "request_id"),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 63 |
+
request_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
| 64 |
+
api_key_id: Mapped[str | None] = mapped_column(
|
| 65 |
+
String(36), ForeignKey("api_keys.id", ondelete="SET NULL")
|
| 66 |
+
)
|
| 67 |
+
key_name: Mapped[str | None] = mapped_column(String(120))
|
| 68 |
+
ip_address: Mapped[str | None] = mapped_column(String(64))
|
| 69 |
+
user_agent: Mapped[str | None] = mapped_column(String(512))
|
| 70 |
+
endpoint: Mapped[str] = mapped_column(String(1024), nullable=False)
|
| 71 |
+
http_method: Mapped[str] = mapped_column(String(16), nullable=False)
|
| 72 |
+
response_code: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 73 |
+
processing_time_ms: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 74 |
+
bytes_uploaded: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
| 75 |
+
bytes_downloaded: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
| 76 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 77 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class RateLimit(Base):
|
| 82 |
+
__tablename__ = "rate_limits"
|
| 83 |
+
__table_args__ = (
|
| 84 |
+
Index("ix_rate_limits_api_key_id", "api_key_id"),
|
| 85 |
+
Index("ix_rate_limits_bucket_start", "bucket_start"),
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
api_key_id: Mapped[str] = mapped_column(
|
| 89 |
+
String(36), ForeignKey("api_keys.id", ondelete="CASCADE"), primary_key=True
|
| 90 |
+
)
|
| 91 |
+
bucket_type: Mapped[str] = mapped_column(String(32), primary_key=True)
|
| 92 |
+
bucket_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True)
|
| 93 |
+
count: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
| 94 |
+
units: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
| 95 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 96 |
+
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
|
| 97 |
+
)
|
app/security/policy.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
from fastapi import Request
|
| 6 |
+
|
| 7 |
+
PUBLIC_GET_PATHS = frozenset(
|
| 8 |
+
{"/", "/health", "/version", "/docs", "/docs/oauth2-redirect", "/openapi.json", "/redoc"}
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ScopePolicy:
|
| 13 |
+
"""Maps existing and future API route families to stable authorization scopes."""
|
| 14 |
+
|
| 15 |
+
@staticmethod
|
| 16 |
+
def is_public(request: Request) -> bool:
|
| 17 |
+
return request.method == "GET" and request.url.path in PUBLIC_GET_PATHS
|
| 18 |
+
|
| 19 |
+
async def required_scope(self, request: Request) -> str | None:
|
| 20 |
+
path = request.url.path
|
| 21 |
+
method = request.method
|
| 22 |
+
if path == "/v1/auth/context":
|
| 23 |
+
return None
|
| 24 |
+
if path.startswith("/mcp"):
|
| 25 |
+
return await self._mcp_scope(request)
|
| 26 |
+
if path.startswith("/v1/api-keys") or path.startswith("/v1/audit-logs"):
|
| 27 |
+
return "admin"
|
| 28 |
+
if path == "/v1/templates" or path == "/v1/templates/categories" or (
|
| 29 |
+
path.startswith("/v1/templates/") and path != "/v1/templates/run"
|
| 30 |
+
):
|
| 31 |
+
return "templates:read" if method == "GET" else "admin"
|
| 32 |
+
if path == "/v1/templates/run":
|
| 33 |
+
return "templates:run"
|
| 34 |
+
if path.startswith("/v1/jobs"):
|
| 35 |
+
if method == "GET":
|
| 36 |
+
return "jobs:read"
|
| 37 |
+
if method == "DELETE" or path.endswith("/cancel"):
|
| 38 |
+
return "jobs:cancel"
|
| 39 |
+
return "jobs:create"
|
| 40 |
+
if path.startswith("/v1/assets"):
|
| 41 |
+
if method == "GET":
|
| 42 |
+
return "assets:read"
|
| 43 |
+
if method == "DELETE":
|
| 44 |
+
return "assets:delete"
|
| 45 |
+
return "assets:write"
|
| 46 |
+
if path.startswith("/v1/media/") and method == "GET":
|
| 47 |
+
return "operations:read"
|
| 48 |
+
if path.startswith(
|
| 49 |
+
("/v1/video", "/v1/audio", "/v1/image", "/v1/whisper", "/v1/ytdlp")
|
| 50 |
+
) or path == "/v1/probe":
|
| 51 |
+
return "operations:execute"
|
| 52 |
+
if method == "GET":
|
| 53 |
+
return "system:read"
|
| 54 |
+
return "admin"
|
| 55 |
+
|
| 56 |
+
@staticmethod
|
| 57 |
+
async def _mcp_scope(request: Request) -> str:
|
| 58 |
+
if request.method != "POST":
|
| 59 |
+
return "mcp:read"
|
| 60 |
+
try:
|
| 61 |
+
payload = json.loads(await request.body())
|
| 62 |
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
| 63 |
+
return "mcp:read"
|
| 64 |
+
messages = payload if isinstance(payload, list) else [payload]
|
| 65 |
+
methods = {
|
| 66 |
+
str(message.get("method", ""))
|
| 67 |
+
for message in messages
|
| 68 |
+
if isinstance(message, dict)
|
| 69 |
+
}
|
| 70 |
+
return "mcp:execute" if "tools/call" in methods else "mcp:read"
|
| 71 |
+
|
| 72 |
+
@staticmethod
|
| 73 |
+
def is_job(required_scope: str | None) -> bool:
|
| 74 |
+
return required_scope in {
|
| 75 |
+
"templates:run",
|
| 76 |
+
"operations:execute",
|
| 77 |
+
"jobs:create",
|
| 78 |
+
"mcp:execute",
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
@staticmethod
|
| 82 |
+
def is_upload(request: Request, required_scope: str | None) -> bool:
|
| 83 |
+
return request.method in {"POST", "PUT", "PATCH"} and required_scope in {
|
| 84 |
+
"templates:run",
|
| 85 |
+
"operations:execute",
|
| 86 |
+
"jobs:create",
|
| 87 |
+
"assets:write",
|
| 88 |
+
"mcp:execute",
|
| 89 |
+
}
|
app/security/rate_limit.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import math
|
| 5 |
+
import time
|
| 6 |
+
from collections import defaultdict, deque
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from datetime import datetime, timedelta, timezone
|
| 9 |
+
|
| 10 |
+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
| 11 |
+
|
| 12 |
+
from app.security.context import AuthContext
|
| 13 |
+
from app.security.database import SecurityDatabase
|
| 14 |
+
from app.security.errors import RateLimitError
|
| 15 |
+
from app.security.models import RateLimit, utcnow
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(slots=True)
|
| 19 |
+
class RateLimitLease:
|
| 20 |
+
limiter: APIKeyRateLimiter
|
| 21 |
+
api_key_id: str
|
| 22 |
+
concurrent: bool
|
| 23 |
+
|
| 24 |
+
async def release(self) -> None:
|
| 25 |
+
if self.concurrent:
|
| 26 |
+
await self.limiter.release_job(self.api_key_id)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class APIKeyRateLimiter:
|
| 30 |
+
"""Low-latency per-key windows with durable aggregate counters for auditing."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, database: SecurityDatabase) -> None:
|
| 33 |
+
self.database = database
|
| 34 |
+
self._lock = asyncio.Lock()
|
| 35 |
+
self._requests: dict[str, deque[float]] = defaultdict(deque)
|
| 36 |
+
self._uploads: dict[str, deque[float]] = defaultdict(deque)
|
| 37 |
+
self._concurrent: dict[str, int] = defaultdict(int)
|
| 38 |
+
self._daily_bytes: dict[tuple[str, str], int] = defaultdict(int)
|
| 39 |
+
|
| 40 |
+
async def acquire(
|
| 41 |
+
self,
|
| 42 |
+
context: AuthContext,
|
| 43 |
+
*,
|
| 44 |
+
is_job: bool,
|
| 45 |
+
is_upload: bool,
|
| 46 |
+
uploaded_bytes: int,
|
| 47 |
+
) -> RateLimitLease:
|
| 48 |
+
now = time.time()
|
| 49 |
+
today = datetime.now(timezone.utc).date().isoformat()
|
| 50 |
+
retry_after = 0
|
| 51 |
+
async with self._lock:
|
| 52 |
+
requests = self._requests[context.api_key_id]
|
| 53 |
+
self._prune(requests, now - 60)
|
| 54 |
+
if len(requests) >= context.requests_per_minute:
|
| 55 |
+
retry_after = max(1, math.ceil(requests[0] + 60 - now))
|
| 56 |
+
uploads = self._uploads[context.api_key_id]
|
| 57 |
+
self._prune(uploads, now - 3600)
|
| 58 |
+
if not retry_after and is_upload and len(uploads) >= context.uploads_per_hour:
|
| 59 |
+
retry_after = max(1, math.ceil(uploads[0] + 3600 - now))
|
| 60 |
+
daily_key = (context.api_key_id, today)
|
| 61 |
+
daily_total = self._daily_bytes[daily_key]
|
| 62 |
+
if (
|
| 63 |
+
not retry_after
|
| 64 |
+
and uploaded_bytes
|
| 65 |
+
and daily_total + uploaded_bytes > context.processing_bytes_per_day
|
| 66 |
+
):
|
| 67 |
+
tomorrow = datetime.now(timezone.utc).replace(
|
| 68 |
+
hour=0, minute=0, second=0, microsecond=0
|
| 69 |
+
) + timedelta(days=1)
|
| 70 |
+
retry_after = max(1, int((tomorrow - datetime.now(timezone.utc)).total_seconds()))
|
| 71 |
+
if (
|
| 72 |
+
not retry_after
|
| 73 |
+
and is_job
|
| 74 |
+
and self._concurrent[context.api_key_id] >= context.concurrent_jobs
|
| 75 |
+
):
|
| 76 |
+
retry_after = 1
|
| 77 |
+
if retry_after:
|
| 78 |
+
raise RateLimitError(retry_after)
|
| 79 |
+
requests.append(now)
|
| 80 |
+
if is_upload:
|
| 81 |
+
uploads.append(now)
|
| 82 |
+
if uploaded_bytes:
|
| 83 |
+
self._daily_bytes[daily_key] += uploaded_bytes
|
| 84 |
+
if is_job:
|
| 85 |
+
self._concurrent[context.api_key_id] += 1
|
| 86 |
+
await self._record(context.api_key_id, "requests_minute", 1, 0, 60)
|
| 87 |
+
if is_upload:
|
| 88 |
+
await self._record(context.api_key_id, "uploads_hour", 1, 0, 3600)
|
| 89 |
+
if uploaded_bytes:
|
| 90 |
+
await self._record(
|
| 91 |
+
context.api_key_id, "processing_bytes_day", 0, uploaded_bytes, 86_400
|
| 92 |
+
)
|
| 93 |
+
return RateLimitLease(self, context.api_key_id, is_job)
|
| 94 |
+
|
| 95 |
+
async def release_job(self, api_key_id: str) -> None:
|
| 96 |
+
async with self._lock:
|
| 97 |
+
self._concurrent[api_key_id] = max(0, self._concurrent[api_key_id] - 1)
|
| 98 |
+
|
| 99 |
+
@staticmethod
|
| 100 |
+
def _prune(values: deque[float], cutoff: float) -> None:
|
| 101 |
+
while values and values[0] <= cutoff:
|
| 102 |
+
values.popleft()
|
| 103 |
+
|
| 104 |
+
async def _record(
|
| 105 |
+
self, api_key_id: str, bucket_type: str, count: int, units: int, seconds: int
|
| 106 |
+
) -> None:
|
| 107 |
+
now = datetime.now(timezone.utc)
|
| 108 |
+
epoch = int(now.timestamp())
|
| 109 |
+
bucket_start = datetime.fromtimestamp(epoch - (epoch % seconds), timezone.utc)
|
| 110 |
+
async with self.database.session() as session:
|
| 111 |
+
statement = sqlite_insert(RateLimit).values(
|
| 112 |
+
api_key_id=api_key_id,
|
| 113 |
+
bucket_type=bucket_type,
|
| 114 |
+
bucket_start=bucket_start,
|
| 115 |
+
count=count,
|
| 116 |
+
units=units,
|
| 117 |
+
updated_at=utcnow(),
|
| 118 |
+
)
|
| 119 |
+
statement = statement.on_conflict_do_update(
|
| 120 |
+
index_elements=[
|
| 121 |
+
RateLimit.api_key_id,
|
| 122 |
+
RateLimit.bucket_type,
|
| 123 |
+
RateLimit.bucket_start,
|
| 124 |
+
],
|
| 125 |
+
set_={
|
| 126 |
+
"count": RateLimit.count + statement.excluded.count,
|
| 127 |
+
"units": RateLimit.units + statement.excluded.units,
|
| 128 |
+
"updated_at": utcnow(),
|
| 129 |
+
},
|
| 130 |
+
)
|
| 131 |
+
await session.execute(statement)
|
| 132 |
+
await session.commit()
|
app/security/schemas.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Literal
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
| 7 |
+
|
| 8 |
+
from app.security.scopes import ALL_SCOPES
|
| 9 |
+
|
| 10 |
+
Environment = Literal["live", "test"]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class APIKeyCreate(BaseModel):
|
| 14 |
+
name: str = Field(min_length=1, max_length=120)
|
| 15 |
+
environment: Environment = "live"
|
| 16 |
+
role: str | None = Field(default="viewer", min_length=1, max_length=64)
|
| 17 |
+
scopes: list[str] = Field(default_factory=list)
|
| 18 |
+
expires_at: datetime | None = None
|
| 19 |
+
expires_in_seconds: int | None = Field(default=None, ge=60, le=31_536_000)
|
| 20 |
+
notes: str | None = Field(default=None, max_length=4000)
|
| 21 |
+
requests_per_minute: int | None = Field(default=None, ge=1, le=1_000_000)
|
| 22 |
+
concurrent_jobs: int | None = Field(default=None, ge=1, le=10_000)
|
| 23 |
+
uploads_per_hour: int | None = Field(default=None, ge=1, le=1_000_000)
|
| 24 |
+
processing_bytes_per_day: int | None = Field(default=None, ge=1_048_576)
|
| 25 |
+
|
| 26 |
+
@field_validator("name")
|
| 27 |
+
@classmethod
|
| 28 |
+
def validate_name(cls, value: str) -> str:
|
| 29 |
+
normalized = value.strip()
|
| 30 |
+
if not normalized:
|
| 31 |
+
raise ValueError("API key name cannot be blank")
|
| 32 |
+
return normalized
|
| 33 |
+
|
| 34 |
+
@field_validator("scopes")
|
| 35 |
+
@classmethod
|
| 36 |
+
def validate_scopes(cls, values: list[str]) -> list[str]:
|
| 37 |
+
normalized = list(dict.fromkeys(value.strip().lower() for value in values))
|
| 38 |
+
unknown = set(normalized) - ALL_SCOPES
|
| 39 |
+
if unknown:
|
| 40 |
+
raise ValueError(f"Unsupported scopes: {sorted(unknown)}")
|
| 41 |
+
return normalized
|
| 42 |
+
|
| 43 |
+
@model_validator(mode="after")
|
| 44 |
+
def validate_expiration(self) -> APIKeyCreate:
|
| 45 |
+
if self.expires_at is not None and self.expires_in_seconds is not None:
|
| 46 |
+
raise ValueError("Use either expires_at or expires_in_seconds, not both")
|
| 47 |
+
return self
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class APIKeyPatch(BaseModel):
|
| 51 |
+
name: str | None = Field(default=None, min_length=1, max_length=120)
|
| 52 |
+
role: str | None = Field(default=None, min_length=1, max_length=64)
|
| 53 |
+
scopes: list[str] | None = None
|
| 54 |
+
expires_at: datetime | None = None
|
| 55 |
+
clear_expiration: bool = False
|
| 56 |
+
notes: str | None = Field(default=None, max_length=4000)
|
| 57 |
+
requests_per_minute: int | None = Field(default=None, ge=1, le=1_000_000)
|
| 58 |
+
concurrent_jobs: int | None = Field(default=None, ge=1, le=10_000)
|
| 59 |
+
uploads_per_hour: int | None = Field(default=None, ge=1, le=1_000_000)
|
| 60 |
+
processing_bytes_per_day: int | None = Field(default=None, ge=1_048_576)
|
| 61 |
+
|
| 62 |
+
@field_validator("name")
|
| 63 |
+
@classmethod
|
| 64 |
+
def validate_name(cls, value: str | None) -> str | None:
|
| 65 |
+
if value is None:
|
| 66 |
+
return None
|
| 67 |
+
normalized = value.strip()
|
| 68 |
+
if not normalized:
|
| 69 |
+
raise ValueError("API key name cannot be blank")
|
| 70 |
+
return normalized
|
| 71 |
+
|
| 72 |
+
@field_validator("scopes")
|
| 73 |
+
@classmethod
|
| 74 |
+
def validate_scopes(cls, values: list[str] | None) -> list[str] | None:
|
| 75 |
+
if values is None:
|
| 76 |
+
return None
|
| 77 |
+
normalized = list(dict.fromkeys(value.strip().lower() for value in values))
|
| 78 |
+
unknown = set(normalized) - ALL_SCOPES
|
| 79 |
+
if unknown:
|
| 80 |
+
raise ValueError(f"Unsupported scopes: {sorted(unknown)}")
|
| 81 |
+
return normalized
|
| 82 |
+
|
| 83 |
+
@model_validator(mode="after")
|
| 84 |
+
def validate_expiration(self) -> APIKeyPatch:
|
| 85 |
+
if self.clear_expiration and "expires_at" in self.model_fields_set:
|
| 86 |
+
raise ValueError("Use either expires_at or clear_expiration, not both")
|
| 87 |
+
return self
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class APIKeyRotate(BaseModel):
|
| 91 |
+
grace_period_seconds: int = Field(default=0, ge=0, le=86_400)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class APIKeyView(BaseModel):
|
| 95 |
+
model_config = ConfigDict(from_attributes=True)
|
| 96 |
+
|
| 97 |
+
id: str
|
| 98 |
+
name: str
|
| 99 |
+
key_prefix: str
|
| 100 |
+
environment: str
|
| 101 |
+
status: str
|
| 102 |
+
role: str | None
|
| 103 |
+
scopes: list[str]
|
| 104 |
+
created_at: datetime
|
| 105 |
+
last_used_at: datetime | None
|
| 106 |
+
expires_at: datetime | None
|
| 107 |
+
grace_expires_at: datetime | None
|
| 108 |
+
created_by: str | None
|
| 109 |
+
notes: str | None
|
| 110 |
+
rotated_from_id: str | None
|
| 111 |
+
requests_per_minute: int
|
| 112 |
+
concurrent_jobs: int
|
| 113 |
+
uploads_per_hour: int
|
| 114 |
+
processing_bytes_per_day: int
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class APIKeyCreated(APIKeyView):
|
| 118 |
+
api_key: str
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class APIKeyList(BaseModel):
|
| 122 |
+
items: list[APIKeyView]
|
| 123 |
+
total: int
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class AuthContextView(BaseModel):
|
| 127 |
+
id: str
|
| 128 |
+
name: str
|
| 129 |
+
key_prefix: str
|
| 130 |
+
environment: str
|
| 131 |
+
role: str | None
|
| 132 |
+
scopes: list[str]
|
| 133 |
+
expires_at: datetime | None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class AuditLogView(BaseModel):
|
| 137 |
+
model_config = ConfigDict(from_attributes=True)
|
| 138 |
+
|
| 139 |
+
id: str
|
| 140 |
+
request_id: str
|
| 141 |
+
api_key_id: str | None
|
| 142 |
+
key_name: str | None
|
| 143 |
+
ip_address: str | None
|
| 144 |
+
user_agent: str | None
|
| 145 |
+
endpoint: str
|
| 146 |
+
http_method: str
|
| 147 |
+
response_code: int
|
| 148 |
+
processing_time_ms: int
|
| 149 |
+
bytes_uploaded: int
|
| 150 |
+
bytes_downloaded: int
|
| 151 |
+
created_at: datetime
|
app/security/scopes.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Mapping, Sequence
|
| 4 |
+
|
| 5 |
+
ALL_SCOPES = frozenset(
|
| 6 |
+
{
|
| 7 |
+
"templates:read",
|
| 8 |
+
"templates:run",
|
| 9 |
+
"operations:read",
|
| 10 |
+
"operations:execute",
|
| 11 |
+
"jobs:read",
|
| 12 |
+
"jobs:create",
|
| 13 |
+
"jobs:cancel",
|
| 14 |
+
"assets:read",
|
| 15 |
+
"assets:write",
|
| 16 |
+
"assets:delete",
|
| 17 |
+
"mcp:read",
|
| 18 |
+
"mcp:execute",
|
| 19 |
+
"system:read",
|
| 20 |
+
"admin",
|
| 21 |
+
}
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
DEFAULT_ROLE_SCOPES: dict[str, frozenset[str]] = {
|
| 25 |
+
"admin": frozenset({"admin"}),
|
| 26 |
+
"developer": frozenset(
|
| 27 |
+
{
|
| 28 |
+
"templates:read",
|
| 29 |
+
"templates:run",
|
| 30 |
+
"operations:read",
|
| 31 |
+
"operations:execute",
|
| 32 |
+
"jobs:read",
|
| 33 |
+
"jobs:create",
|
| 34 |
+
"jobs:cancel",
|
| 35 |
+
"assets:read",
|
| 36 |
+
"assets:write",
|
| 37 |
+
"mcp:read",
|
| 38 |
+
"mcp:execute",
|
| 39 |
+
"system:read",
|
| 40 |
+
}
|
| 41 |
+
),
|
| 42 |
+
"operator": frozenset(
|
| 43 |
+
{
|
| 44 |
+
"templates:read",
|
| 45 |
+
"templates:run",
|
| 46 |
+
"operations:read",
|
| 47 |
+
"operations:execute",
|
| 48 |
+
"jobs:read",
|
| 49 |
+
"jobs:create",
|
| 50 |
+
"jobs:cancel",
|
| 51 |
+
"assets:read",
|
| 52 |
+
"mcp:read",
|
| 53 |
+
"mcp:execute",
|
| 54 |
+
"system:read",
|
| 55 |
+
}
|
| 56 |
+
),
|
| 57 |
+
"viewer": frozenset(
|
| 58 |
+
{
|
| 59 |
+
"templates:read",
|
| 60 |
+
"operations:read",
|
| 61 |
+
"jobs:read",
|
| 62 |
+
"assets:read",
|
| 63 |
+
"mcp:read",
|
| 64 |
+
"system:read",
|
| 65 |
+
}
|
| 66 |
+
),
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def configured_roles(
|
| 71 |
+
overrides: Mapping[str, Sequence[str]] | None = None,
|
| 72 |
+
) -> dict[str, frozenset[str]]:
|
| 73 |
+
roles = dict(DEFAULT_ROLE_SCOPES)
|
| 74 |
+
for role, scopes in (overrides or {}).items():
|
| 75 |
+
normalized_role = role.strip().lower()
|
| 76 |
+
normalized_scopes = frozenset(scope.strip().lower() for scope in scopes)
|
| 77 |
+
unknown = normalized_scopes - ALL_SCOPES
|
| 78 |
+
if unknown:
|
| 79 |
+
raise ValueError(f"Role '{normalized_role}' has unsupported scopes: {sorted(unknown)}")
|
| 80 |
+
roles[normalized_role] = normalized_scopes
|
| 81 |
+
return roles
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def effective_scopes(
|
| 85 |
+
role: str | None,
|
| 86 |
+
explicit_scopes: Sequence[str],
|
| 87 |
+
overrides: Mapping[str, Sequence[str]] | None = None,
|
| 88 |
+
) -> frozenset[str]:
|
| 89 |
+
scopes = set(scope.strip().lower() for scope in explicit_scopes)
|
| 90 |
+
if role:
|
| 91 |
+
scopes.update(configured_roles(overrides).get(role.strip().lower(), ()))
|
| 92 |
+
if "admin" in scopes:
|
| 93 |
+
return ALL_SCOPES
|
| 94 |
+
return frozenset(scopes)
|
app/security/service.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import hashlib
|
| 5 |
+
import hmac
|
| 6 |
+
import re
|
| 7 |
+
import secrets
|
| 8 |
+
import time
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from datetime import datetime, timedelta, timezone
|
| 11 |
+
|
| 12 |
+
from sqlalchemy import func, select, update
|
| 13 |
+
|
| 14 |
+
from app.core.config import Settings
|
| 15 |
+
from app.security.context import AuthContext
|
| 16 |
+
from app.security.database import SecurityDatabase
|
| 17 |
+
from app.security.errors import (
|
| 18 |
+
APIKeyConflictError,
|
| 19 |
+
APIKeyNotFoundError,
|
| 20 |
+
ForbiddenError,
|
| 21 |
+
UnauthorizedError,
|
| 22 |
+
)
|
| 23 |
+
from app.security.models import APIKey
|
| 24 |
+
from app.security.schemas import APIKeyCreate, APIKeyPatch
|
| 25 |
+
from app.security.scopes import configured_roles, effective_scopes
|
| 26 |
+
|
| 27 |
+
KEY_PATTERN = re.compile(r"^mp_([a-z][a-z0-9]{1,15})_([A-Za-z0-9_-]{43,})$")
|
| 28 |
+
HASH_PATTERN = re.compile(r"^[a-f0-9]{64}$")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def utcnow() -> datetime:
|
| 32 |
+
return datetime.now(timezone.utc)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def aware(value: datetime | None) -> datetime | None:
|
| 36 |
+
if value is None:
|
| 37 |
+
return None
|
| 38 |
+
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@dataclass(frozen=True, slots=True)
|
| 42 |
+
class KeyMaterial:
|
| 43 |
+
api_key: str
|
| 44 |
+
key_prefix: str
|
| 45 |
+
key_hash: str
|
| 46 |
+
environment: str
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class APIKeyService:
|
| 50 |
+
"""Creates and validates opaque API keys without retaining plaintext secrets."""
|
| 51 |
+
|
| 52 |
+
def __init__(self, database: SecurityDatabase, settings: Settings) -> None:
|
| 53 |
+
self.database = database
|
| 54 |
+
self.settings = settings
|
| 55 |
+
self.roles = configured_roles(settings.auth_role_scopes)
|
| 56 |
+
self._last_used_cache: dict[str, float] = {}
|
| 57 |
+
self._last_used_lock = asyncio.Lock()
|
| 58 |
+
|
| 59 |
+
@staticmethod
|
| 60 |
+
def generate_material(environment: str) -> KeyMaterial:
|
| 61 |
+
normalized = environment.strip().lower()
|
| 62 |
+
if normalized not in {"live", "test"}:
|
| 63 |
+
raise ValueError("API key environment must be live or test")
|
| 64 |
+
secret = secrets.token_urlsafe(32)
|
| 65 |
+
api_key = f"mp_{normalized}_{secret}"
|
| 66 |
+
return KeyMaterial(
|
| 67 |
+
api_key=api_key,
|
| 68 |
+
key_prefix=f"mp_{normalized}_{secret[:8]}",
|
| 69 |
+
key_hash=hashlib.sha256(api_key.encode("utf-8")).hexdigest(),
|
| 70 |
+
environment=normalized,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
@staticmethod
|
| 74 |
+
def hash_key(api_key: str) -> str:
|
| 75 |
+
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
|
| 76 |
+
|
| 77 |
+
@staticmethod
|
| 78 |
+
def parse_key(api_key: str) -> tuple[str, str]:
|
| 79 |
+
match = KEY_PATTERN.fullmatch(api_key)
|
| 80 |
+
if not match:
|
| 81 |
+
raise UnauthorizedError
|
| 82 |
+
environment, secret = match.groups()
|
| 83 |
+
return environment, f"mp_{environment}_{secret[:8]}"
|
| 84 |
+
|
| 85 |
+
async def ensure_bootstrap_admin(self) -> None:
|
| 86 |
+
key_hash = self.settings.auth_bootstrap_key_hash.strip().lower()
|
| 87 |
+
key_prefix = self.settings.auth_bootstrap_key_prefix.strip()
|
| 88 |
+
if not key_hash and not key_prefix:
|
| 89 |
+
return
|
| 90 |
+
if not HASH_PATTERN.fullmatch(key_hash) or not re.fullmatch(
|
| 91 |
+
r"mp_[a-z][a-z0-9]{1,15}_[A-Za-z0-9_-]{8}", key_prefix
|
| 92 |
+
):
|
| 93 |
+
raise ValueError("Bootstrap key hash or prefix is malformed")
|
| 94 |
+
prefix_environment = key_prefix.split("_", 2)[1]
|
| 95 |
+
if prefix_environment != self.settings.auth_bootstrap_environment:
|
| 96 |
+
raise ValueError("Bootstrap key prefix and environment do not match")
|
| 97 |
+
async with self.database.session() as session:
|
| 98 |
+
count = await session.scalar(select(func.count()).select_from(APIKey))
|
| 99 |
+
if count:
|
| 100 |
+
return
|
| 101 |
+
session.add(
|
| 102 |
+
APIKey(
|
| 103 |
+
name=self.settings.auth_bootstrap_key_name,
|
| 104 |
+
key_prefix=key_prefix,
|
| 105 |
+
key_hash=key_hash,
|
| 106 |
+
environment=self.settings.auth_bootstrap_environment,
|
| 107 |
+
status="active",
|
| 108 |
+
role="admin",
|
| 109 |
+
scopes=["admin"],
|
| 110 |
+
created_by="bootstrap",
|
| 111 |
+
notes="Hash-only bootstrap administrator",
|
| 112 |
+
requests_per_minute=self.settings.auth_default_requests_per_minute,
|
| 113 |
+
concurrent_jobs=self.settings.auth_default_concurrent_jobs,
|
| 114 |
+
uploads_per_hour=self.settings.auth_default_uploads_per_hour,
|
| 115 |
+
processing_bytes_per_day=(
|
| 116 |
+
self.settings.auth_default_processing_bytes_per_day
|
| 117 |
+
),
|
| 118 |
+
)
|
| 119 |
+
)
|
| 120 |
+
await session.commit()
|
| 121 |
+
|
| 122 |
+
async def create(
|
| 123 |
+
self, payload: APIKeyCreate, *, created_by: str | None
|
| 124 |
+
) -> tuple[APIKey, str]:
|
| 125 |
+
role = payload.role.strip().lower() if payload.role else None
|
| 126 |
+
if role and role not in self.roles:
|
| 127 |
+
raise APIKeyConflictError(f"Unknown role '{role}'")
|
| 128 |
+
material = self.generate_material(payload.environment)
|
| 129 |
+
expires_at = aware(payload.expires_at)
|
| 130 |
+
if payload.expires_in_seconds is not None:
|
| 131 |
+
expires_at = utcnow() + timedelta(seconds=payload.expires_in_seconds)
|
| 132 |
+
record = APIKey(
|
| 133 |
+
name=payload.name.strip(),
|
| 134 |
+
key_prefix=material.key_prefix,
|
| 135 |
+
key_hash=material.key_hash,
|
| 136 |
+
environment=material.environment,
|
| 137 |
+
status="active",
|
| 138 |
+
role=role,
|
| 139 |
+
scopes=payload.scopes,
|
| 140 |
+
expires_at=expires_at,
|
| 141 |
+
created_by=created_by,
|
| 142 |
+
notes=payload.notes,
|
| 143 |
+
requests_per_minute=(
|
| 144 |
+
payload.requests_per_minute
|
| 145 |
+
or self.settings.auth_default_requests_per_minute
|
| 146 |
+
),
|
| 147 |
+
concurrent_jobs=(
|
| 148 |
+
payload.concurrent_jobs or self.settings.auth_default_concurrent_jobs
|
| 149 |
+
),
|
| 150 |
+
uploads_per_hour=(
|
| 151 |
+
payload.uploads_per_hour or self.settings.auth_default_uploads_per_hour
|
| 152 |
+
),
|
| 153 |
+
processing_bytes_per_day=(
|
| 154 |
+
payload.processing_bytes_per_day
|
| 155 |
+
or self.settings.auth_default_processing_bytes_per_day
|
| 156 |
+
),
|
| 157 |
+
)
|
| 158 |
+
async with self.database.session() as session:
|
| 159 |
+
session.add(record)
|
| 160 |
+
await session.commit()
|
| 161 |
+
await session.refresh(record)
|
| 162 |
+
return record, material.api_key
|
| 163 |
+
|
| 164 |
+
async def authenticate(self, api_key: str) -> AuthContext:
|
| 165 |
+
environment, key_prefix = self.parse_key(api_key)
|
| 166 |
+
supplied_hash = self.hash_key(api_key)
|
| 167 |
+
async with self.database.session() as session:
|
| 168 |
+
candidates = list(
|
| 169 |
+
(
|
| 170 |
+
await session.scalars(
|
| 171 |
+
select(APIKey).where(APIKey.key_prefix == key_prefix)
|
| 172 |
+
)
|
| 173 |
+
).all()
|
| 174 |
+
)
|
| 175 |
+
record: APIKey | None = None
|
| 176 |
+
for candidate in candidates:
|
| 177 |
+
if hmac.compare_digest(candidate.key_hash, supplied_hash):
|
| 178 |
+
record = candidate
|
| 179 |
+
now = utcnow()
|
| 180 |
+
if record is None or record.environment != environment:
|
| 181 |
+
raise UnauthorizedError
|
| 182 |
+
expires_at = aware(record.expires_at)
|
| 183 |
+
grace_expires_at = aware(record.grace_expires_at)
|
| 184 |
+
if record.status == "rotating":
|
| 185 |
+
if grace_expires_at is None or grace_expires_at <= now:
|
| 186 |
+
await self._finalize_rotation(record.id)
|
| 187 |
+
raise UnauthorizedError
|
| 188 |
+
elif record.status != "active":
|
| 189 |
+
raise UnauthorizedError
|
| 190 |
+
if expires_at is not None and expires_at <= now:
|
| 191 |
+
raise UnauthorizedError
|
| 192 |
+
scopes = effective_scopes(record.role, record.scopes or [], self.settings.auth_role_scopes)
|
| 193 |
+
return AuthContext(
|
| 194 |
+
api_key_id=record.id,
|
| 195 |
+
key_name=record.name,
|
| 196 |
+
key_prefix=record.key_prefix,
|
| 197 |
+
environment=record.environment,
|
| 198 |
+
role=record.role,
|
| 199 |
+
scopes=scopes,
|
| 200 |
+
requests_per_minute=record.requests_per_minute,
|
| 201 |
+
concurrent_jobs=record.concurrent_jobs,
|
| 202 |
+
uploads_per_hour=record.uploads_per_hour,
|
| 203 |
+
processing_bytes_per_day=record.processing_bytes_per_day,
|
| 204 |
+
expires_at=expires_at,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
@staticmethod
|
| 208 |
+
def authorize(context: AuthContext, required_scope: str | None) -> None:
|
| 209 |
+
if required_scope is not None and not context.allows(required_scope):
|
| 210 |
+
raise ForbiddenError
|
| 211 |
+
|
| 212 |
+
async def mark_used(self, context: AuthContext) -> None:
|
| 213 |
+
await self._touch_last_used(context.api_key_id)
|
| 214 |
+
|
| 215 |
+
async def list(self, *, offset: int = 0, limit: int = 100) -> tuple[list[APIKey], int]:
|
| 216 |
+
await self._finalize_expired_rotations()
|
| 217 |
+
async with self.database.session() as session:
|
| 218 |
+
total = int(await session.scalar(select(func.count()).select_from(APIKey)) or 0)
|
| 219 |
+
records = list(
|
| 220 |
+
(
|
| 221 |
+
await session.scalars(
|
| 222 |
+
select(APIKey)
|
| 223 |
+
.order_by(APIKey.created_at.desc())
|
| 224 |
+
.offset(offset)
|
| 225 |
+
.limit(limit)
|
| 226 |
+
)
|
| 227 |
+
).all()
|
| 228 |
+
)
|
| 229 |
+
return records, total
|
| 230 |
+
|
| 231 |
+
async def get(self, key_id: str) -> APIKey:
|
| 232 |
+
await self._finalize_expired_rotations()
|
| 233 |
+
async with self.database.session() as session:
|
| 234 |
+
record = await session.get(APIKey, key_id)
|
| 235 |
+
if record is None:
|
| 236 |
+
raise APIKeyNotFoundError
|
| 237 |
+
return record
|
| 238 |
+
|
| 239 |
+
async def patch(self, key_id: str, payload: APIKeyPatch) -> APIKey:
|
| 240 |
+
async with self.database.session() as session:
|
| 241 |
+
record = await session.get(APIKey, key_id)
|
| 242 |
+
if record is None:
|
| 243 |
+
raise APIKeyNotFoundError
|
| 244 |
+
fields = payload.model_fields_set
|
| 245 |
+
if payload.name is not None:
|
| 246 |
+
record.name = payload.name.strip()
|
| 247 |
+
if "role" in fields:
|
| 248 |
+
role = payload.role.strip().lower() if payload.role else None
|
| 249 |
+
if role and role not in self.roles:
|
| 250 |
+
raise APIKeyConflictError(f"Unknown role '{role}'")
|
| 251 |
+
record.role = role
|
| 252 |
+
if payload.scopes is not None:
|
| 253 |
+
record.scopes = payload.scopes
|
| 254 |
+
if payload.clear_expiration:
|
| 255 |
+
record.expires_at = None
|
| 256 |
+
elif "expires_at" in fields:
|
| 257 |
+
record.expires_at = aware(payload.expires_at)
|
| 258 |
+
if "notes" in fields:
|
| 259 |
+
record.notes = payload.notes
|
| 260 |
+
for field in (
|
| 261 |
+
"requests_per_minute",
|
| 262 |
+
"concurrent_jobs",
|
| 263 |
+
"uploads_per_hour",
|
| 264 |
+
"processing_bytes_per_day",
|
| 265 |
+
):
|
| 266 |
+
value = getattr(payload, field)
|
| 267 |
+
if value is not None:
|
| 268 |
+
setattr(record, field, value)
|
| 269 |
+
await session.commit()
|
| 270 |
+
await session.refresh(record)
|
| 271 |
+
return record
|
| 272 |
+
|
| 273 |
+
async def set_status(self, key_id: str, status: str) -> APIKey:
|
| 274 |
+
if status not in {"active", "disabled", "revoked"}:
|
| 275 |
+
raise APIKeyConflictError("Unsupported API key status transition")
|
| 276 |
+
async with self.database.session() as session:
|
| 277 |
+
record = await session.get(APIKey, key_id)
|
| 278 |
+
if record is None:
|
| 279 |
+
raise APIKeyNotFoundError
|
| 280 |
+
expires_at = aware(record.expires_at)
|
| 281 |
+
if record.status == "revoked" and status != "revoked":
|
| 282 |
+
raise APIKeyConflictError("Revoked API keys cannot be changed")
|
| 283 |
+
if status == "active" and record.status != "disabled":
|
| 284 |
+
raise APIKeyConflictError("Only disabled API keys can be enabled")
|
| 285 |
+
if (
|
| 286 |
+
status == "active"
|
| 287 |
+
and expires_at is not None
|
| 288 |
+
and expires_at <= utcnow()
|
| 289 |
+
):
|
| 290 |
+
raise APIKeyConflictError("Expired API keys cannot be enabled")
|
| 291 |
+
if status == "disabled" and record.status != "active":
|
| 292 |
+
raise APIKeyConflictError("Only active API keys can be disabled")
|
| 293 |
+
record.status = status
|
| 294 |
+
if status != "rotating":
|
| 295 |
+
record.grace_expires_at = None
|
| 296 |
+
await session.commit()
|
| 297 |
+
await session.refresh(record)
|
| 298 |
+
return record
|
| 299 |
+
|
| 300 |
+
async def rotate(
|
| 301 |
+
self, key_id: str, grace_period_seconds: int, *, created_by: str | None
|
| 302 |
+
) -> tuple[APIKey, str]:
|
| 303 |
+
async with self.database.session() as session:
|
| 304 |
+
old = await session.get(APIKey, key_id)
|
| 305 |
+
if old is None:
|
| 306 |
+
raise APIKeyNotFoundError
|
| 307 |
+
if old.status != "active":
|
| 308 |
+
raise APIKeyConflictError("Only active API keys can be rotated")
|
| 309 |
+
old_expires_at = aware(old.expires_at)
|
| 310 |
+
if old_expires_at is not None and old_expires_at <= utcnow():
|
| 311 |
+
raise APIKeyConflictError("Expired API keys cannot be rotated")
|
| 312 |
+
material = self.generate_material(old.environment)
|
| 313 |
+
replacement = APIKey(
|
| 314 |
+
name=old.name,
|
| 315 |
+
key_prefix=material.key_prefix,
|
| 316 |
+
key_hash=material.key_hash,
|
| 317 |
+
environment=old.environment,
|
| 318 |
+
status="active",
|
| 319 |
+
role=old.role,
|
| 320 |
+
scopes=list(old.scopes or []),
|
| 321 |
+
expires_at=old.expires_at,
|
| 322 |
+
created_by=created_by,
|
| 323 |
+
notes=old.notes,
|
| 324 |
+
rotated_from_id=old.id,
|
| 325 |
+
requests_per_minute=old.requests_per_minute,
|
| 326 |
+
concurrent_jobs=old.concurrent_jobs,
|
| 327 |
+
uploads_per_hour=old.uploads_per_hour,
|
| 328 |
+
processing_bytes_per_day=old.processing_bytes_per_day,
|
| 329 |
+
)
|
| 330 |
+
session.add(replacement)
|
| 331 |
+
old.status = "rotating" if grace_period_seconds else "revoked"
|
| 332 |
+
old.grace_expires_at = (
|
| 333 |
+
utcnow() + timedelta(seconds=grace_period_seconds)
|
| 334 |
+
if grace_period_seconds
|
| 335 |
+
else None
|
| 336 |
+
)
|
| 337 |
+
await session.commit()
|
| 338 |
+
await session.refresh(replacement)
|
| 339 |
+
return replacement, material.api_key
|
| 340 |
+
|
| 341 |
+
async def _touch_last_used(self, key_id: str) -> None:
|
| 342 |
+
interval = self.settings.auth_last_used_update_seconds
|
| 343 |
+
current = time.monotonic()
|
| 344 |
+
async with self._last_used_lock:
|
| 345 |
+
previous = self._last_used_cache.get(key_id)
|
| 346 |
+
if previous is not None and current - previous < interval:
|
| 347 |
+
return
|
| 348 |
+
self._last_used_cache[key_id] = current
|
| 349 |
+
async with self.database.session() as session:
|
| 350 |
+
record = await session.get(APIKey, key_id)
|
| 351 |
+
if record is not None:
|
| 352 |
+
record.last_used_at = utcnow()
|
| 353 |
+
await session.commit()
|
| 354 |
+
|
| 355 |
+
async def _finalize_rotation(self, key_id: str) -> None:
|
| 356 |
+
async with self.database.session() as session:
|
| 357 |
+
await session.execute(
|
| 358 |
+
update(APIKey)
|
| 359 |
+
.where(APIKey.id == key_id, APIKey.status == "rotating")
|
| 360 |
+
.values(status="revoked", grace_expires_at=None)
|
| 361 |
+
)
|
| 362 |
+
await session.commit()
|
| 363 |
+
|
| 364 |
+
async def _finalize_expired_rotations(self) -> None:
|
| 365 |
+
async with self.database.session() as session:
|
| 366 |
+
await session.execute(
|
| 367 |
+
update(APIKey)
|
| 368 |
+
.where(
|
| 369 |
+
APIKey.status == "rotating",
|
| 370 |
+
APIKey.grace_expires_at.is_not(None),
|
| 371 |
+
APIKey.grace_expires_at <= utcnow(),
|
| 372 |
+
)
|
| 373 |
+
.values(status="revoked", grace_expires_at=None)
|
| 374 |
+
)
|
| 375 |
+
await session.commit()
|
main.py
CHANGED
|
@@ -11,13 +11,14 @@ from fastapi.responses import JSONResponse, ORJSONResponse
|
|
| 11 |
from starlette.middleware.base import RequestResponseEndpoint
|
| 12 |
from starlette.responses import Response
|
| 13 |
|
| 14 |
-
from app.api import audio, health, image, media, probe, templates, video, whisper, ytdlp
|
| 15 |
from app.container import build_container
|
| 16 |
from app.core.config import Settings, get_settings
|
| 17 |
from app.core.exceptions import MediaAPIError
|
| 18 |
from app.core.logger import configure_logging, get_logger, request_id_context
|
| 19 |
from app.core.response import ErrorBody, ErrorResponse
|
| 20 |
from app.mcp.server import create_mcp_server
|
|
|
|
| 21 |
from app.workers.cleanup_worker import CleanupWorker
|
| 22 |
|
| 23 |
configure_logging()
|
|
@@ -46,6 +47,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
| 46 |
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
|
| 47 |
application.state.container = container
|
| 48 |
application.state.mcp_server = mcp_server
|
|
|
|
|
|
|
| 49 |
async with mcp_server.session_manager.run():
|
| 50 |
await cleanup_worker.start()
|
| 51 |
logger.info(
|
|
@@ -56,6 +59,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
| 56 |
yield
|
| 57 |
finally:
|
| 58 |
await cleanup_worker.stop()
|
|
|
|
| 59 |
logger.info("media API stopped")
|
| 60 |
|
| 61 |
application = FastAPI(
|
|
@@ -74,6 +78,15 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
| 74 |
application.state.container = container
|
| 75 |
application.state.mcp_server = mcp_server
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
@application.middleware("http")
|
| 78 |
async def request_context(request: Request, call_next: RequestResponseEndpoint) -> Response:
|
| 79 |
request_id = str(uuid4())
|
|
@@ -152,6 +165,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
| 152 |
return DefaultJSONResponse(body.model_dump(), status_code=500)
|
| 153 |
|
| 154 |
application.include_router(health.router)
|
|
|
|
| 155 |
application.include_router(media.router)
|
| 156 |
application.include_router(video.router)
|
| 157 |
application.include_router(audio.router)
|
|
@@ -161,6 +175,42 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
| 161 |
application.include_router(whisper.router)
|
| 162 |
application.include_router(templates.router)
|
| 163 |
application.mount("/mcp", mcp_http_app, name="mcp")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
return application
|
| 165 |
|
| 166 |
|
|
|
|
| 11 |
from starlette.middleware.base import RequestResponseEndpoint
|
| 12 |
from starlette.responses import Response
|
| 13 |
|
| 14 |
+
from app.api import api_keys, audio, health, image, media, probe, templates, video, whisper, ytdlp
|
| 15 |
from app.container import build_container
|
| 16 |
from app.core.config import Settings, get_settings
|
| 17 |
from app.core.exceptions import MediaAPIError
|
| 18 |
from app.core.logger import configure_logging, get_logger, request_id_context
|
| 19 |
from app.core.response import ErrorBody, ErrorResponse
|
| 20 |
from app.mcp.server import create_mcp_server
|
| 21 |
+
from app.security.middleware import APIKeyAuthenticationMiddleware
|
| 22 |
from app.workers.cleanup_worker import CleanupWorker
|
| 23 |
|
| 24 |
configure_logging()
|
|
|
|
| 47 |
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
|
| 48 |
application.state.container = container
|
| 49 |
application.state.mcp_server = mcp_server
|
| 50 |
+
await container.security_database.initialize()
|
| 51 |
+
await container.api_keys.ensure_bootstrap_admin()
|
| 52 |
async with mcp_server.session_manager.run():
|
| 53 |
await cleanup_worker.start()
|
| 54 |
logger.info(
|
|
|
|
| 59 |
yield
|
| 60 |
finally:
|
| 61 |
await cleanup_worker.stop()
|
| 62 |
+
await container.security_database.close()
|
| 63 |
logger.info("media API stopped")
|
| 64 |
|
| 65 |
application = FastAPI(
|
|
|
|
| 78 |
application.state.container = container
|
| 79 |
application.state.mcp_server = mcp_server
|
| 80 |
|
| 81 |
+
# Added before request_context so the request-ID/logging middleware remains outermost.
|
| 82 |
+
application.add_middleware(
|
| 83 |
+
APIKeyAuthenticationMiddleware,
|
| 84 |
+
settings=active_settings,
|
| 85 |
+
api_keys=container.api_keys,
|
| 86 |
+
rate_limiter=container.rate_limiter,
|
| 87 |
+
audit=container.audit,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
@application.middleware("http")
|
| 91 |
async def request_context(request: Request, call_next: RequestResponseEndpoint) -> Response:
|
| 92 |
request_id = str(uuid4())
|
|
|
|
| 165 |
return DefaultJSONResponse(body.model_dump(), status_code=500)
|
| 166 |
|
| 167 |
application.include_router(health.router)
|
| 168 |
+
application.include_router(api_keys.router)
|
| 169 |
application.include_router(media.router)
|
| 170 |
application.include_router(video.router)
|
| 171 |
application.include_router(audio.router)
|
|
|
|
| 175 |
application.include_router(whisper.router)
|
| 176 |
application.include_router(templates.router)
|
| 177 |
application.mount("/mcp", mcp_http_app, name="mcp")
|
| 178 |
+
|
| 179 |
+
@application.get("/", tags=["public"], include_in_schema=False)
|
| 180 |
+
async def root() -> dict[str, str]:
|
| 181 |
+
return {
|
| 182 |
+
"name": active_settings.app_name,
|
| 183 |
+
"version": active_settings.app_version,
|
| 184 |
+
"status": "healthy",
|
| 185 |
+
"docs": "/docs",
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
@application.get("/version", tags=["public"], include_in_schema=False)
|
| 189 |
+
async def version() -> dict[str, str]:
|
| 190 |
+
return {"version": active_settings.app_version}
|
| 191 |
+
|
| 192 |
+
original_openapi = application.openapi
|
| 193 |
+
|
| 194 |
+
def secure_openapi() -> dict[str, object]:
|
| 195 |
+
if application.openapi_schema:
|
| 196 |
+
return application.openapi_schema # type: ignore[return-value]
|
| 197 |
+
schema = original_openapi()
|
| 198 |
+
components = schema.setdefault("components", {})
|
| 199 |
+
components.setdefault("securitySchemes", {})["APIKeyBearer"] = {
|
| 200 |
+
"type": "http",
|
| 201 |
+
"scheme": "bearer",
|
| 202 |
+
"bearerFormat": "mp_live_…",
|
| 203 |
+
"description": "MediaRouter API key",
|
| 204 |
+
}
|
| 205 |
+
schema["security"] = [{"APIKeyBearer": []}]
|
| 206 |
+
for path in ("/health",):
|
| 207 |
+
for operation in schema.get("paths", {}).get(path, {}).values():
|
| 208 |
+
if isinstance(operation, dict):
|
| 209 |
+
operation["security"] = []
|
| 210 |
+
application.openapi_schema = schema
|
| 211 |
+
return schema
|
| 212 |
+
|
| 213 |
+
application.openapi = secure_openapi # type: ignore[method-assign]
|
| 214 |
return application
|
| 215 |
|
| 216 |
|
requirements.txt
CHANGED
|
@@ -12,3 +12,5 @@ yt-dlp==2026.7.4
|
|
| 12 |
faster-whisper==1.2.1
|
| 13 |
mcp==1.28.1
|
| 14 |
PyYAML==6.0.3
|
|
|
|
|
|
|
|
|
| 12 |
faster-whisper==1.2.1
|
| 13 |
mcp==1.28.1
|
| 14 |
PyYAML==6.0.3
|
| 15 |
+
SQLAlchemy==2.0.43
|
| 16 |
+
aiosqlite==0.21.0
|
tests/conftest.py
CHANGED
|
@@ -19,4 +19,6 @@ def settings(tmp_path: Path) -> Settings:
|
|
| 19 |
whisper_model="tiny",
|
| 20 |
max_workers=1,
|
| 21 |
allow_private_urls=True,
|
|
|
|
|
|
|
| 22 |
)
|
|
|
|
| 19 |
whisper_model="tiny",
|
| 20 |
max_workers=1,
|
| 21 |
allow_private_urls=True,
|
| 22 |
+
auth_enabled=False,
|
| 23 |
+
database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
|
| 24 |
)
|
tests/test_authentication.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import hashlib
|
| 5 |
+
from datetime import datetime, timedelta, timezone
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from fastapi.testclient import TestClient
|
| 10 |
+
from sqlalchemy import select
|
| 11 |
+
|
| 12 |
+
from app.container import build_container
|
| 13 |
+
from app.core.config import Settings
|
| 14 |
+
from app.mcp.registry import MCPRegistry
|
| 15 |
+
from app.security.context import auth_context
|
| 16 |
+
from app.security.errors import APIKeyConflictError, ForbiddenError, RateLimitError, UnauthorizedError
|
| 17 |
+
from app.security.models import APIKey, AuditLog
|
| 18 |
+
from app.security.schemas import APIKeyCreate
|
| 19 |
+
from app.security.service import APIKeyService
|
| 20 |
+
from main import create_app
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def security_settings(tmp_path: Path, **overrides: object) -> Settings:
|
| 24 |
+
values: dict[str, object] = {
|
| 25 |
+
"_env_file": None,
|
| 26 |
+
"temp_dir": tmp_path / "temp",
|
| 27 |
+
"output_dir": tmp_path / "outputs",
|
| 28 |
+
"database_url": f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
|
| 29 |
+
"auth_enabled": True,
|
| 30 |
+
"auth_last_used_update_seconds": 0,
|
| 31 |
+
"cleanup_interval_seconds": 3600,
|
| 32 |
+
"whisper_model": "tiny",
|
| 33 |
+
"max_workers": 1,
|
| 34 |
+
}
|
| 35 |
+
values.update(overrides)
|
| 36 |
+
return Settings(**values)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@pytest.fixture
|
| 40 |
+
async def security_container(tmp_path: Path):
|
| 41 |
+
container = build_container(security_settings(tmp_path))
|
| 42 |
+
await container.security_database.initialize()
|
| 43 |
+
try:
|
| 44 |
+
yield container
|
| 45 |
+
finally:
|
| 46 |
+
await container.security_database.close()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def create_key(container, **overrides: object) -> tuple[APIKey, str]:
|
| 50 |
+
values: dict[str, object] = {
|
| 51 |
+
"name": "Automation",
|
| 52 |
+
"environment": "test",
|
| 53 |
+
"role": None,
|
| 54 |
+
"scopes": ["templates:read"],
|
| 55 |
+
}
|
| 56 |
+
values.update(overrides)
|
| 57 |
+
return await container.api_keys.create(APIKeyCreate(**values), created_by="tests")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def test_key_generation_has_256_bits_and_database_never_stores_secret(
|
| 61 |
+
security_container,
|
| 62 |
+
) -> None:
|
| 63 |
+
record, secret = await create_key(security_container)
|
| 64 |
+
|
| 65 |
+
environment, encoded_secret = secret.split("_", 2)[1:]
|
| 66 |
+
raw_secret = base64.urlsafe_b64decode(encoded_secret + "=")
|
| 67 |
+
assert environment == "test"
|
| 68 |
+
assert len(raw_secret) == 32
|
| 69 |
+
assert record.key_prefix == f"mp_test_{encoded_secret[:8]}"
|
| 70 |
+
assert record.key_hash == hashlib.sha256(secret.encode()).hexdigest()
|
| 71 |
+
|
| 72 |
+
async with security_container.security_database.session() as session:
|
| 73 |
+
stored = await session.get(APIKey, record.id)
|
| 74 |
+
assert stored is not None
|
| 75 |
+
assert secret not in vars(stored).values()
|
| 76 |
+
assert not hasattr(stored, "api_key")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def test_authentication_rejects_invalid_expired_disabled_and_revoked_keys(
|
| 80 |
+
security_container,
|
| 81 |
+
) -> None:
|
| 82 |
+
active, active_secret = await create_key(security_container)
|
| 83 |
+
assert (await security_container.api_keys.authenticate(active_secret)).api_key_id == active.id
|
| 84 |
+
|
| 85 |
+
replacement = "A" if active_secret[-1] != "A" else "B"
|
| 86 |
+
with pytest.raises(UnauthorizedError):
|
| 87 |
+
await security_container.api_keys.authenticate(active_secret[:-1] + replacement)
|
| 88 |
+
|
| 89 |
+
_, expired_secret = await create_key(
|
| 90 |
+
security_container,
|
| 91 |
+
name="Expired",
|
| 92 |
+
expires_at=datetime.now(timezone.utc) - timedelta(seconds=1),
|
| 93 |
+
)
|
| 94 |
+
with pytest.raises(UnauthorizedError):
|
| 95 |
+
await security_container.api_keys.authenticate(expired_secret)
|
| 96 |
+
|
| 97 |
+
await security_container.api_keys.set_status(active.id, "disabled")
|
| 98 |
+
with pytest.raises(UnauthorizedError):
|
| 99 |
+
await security_container.api_keys.authenticate(active_secret)
|
| 100 |
+
await security_container.api_keys.set_status(active.id, "active")
|
| 101 |
+
assert (await security_container.api_keys.authenticate(active_secret)).api_key_id == active.id
|
| 102 |
+
|
| 103 |
+
await security_container.api_keys.set_status(active.id, "revoked")
|
| 104 |
+
with pytest.raises(UnauthorizedError):
|
| 105 |
+
await security_container.api_keys.authenticate(active_secret)
|
| 106 |
+
with pytest.raises(APIKeyConflictError):
|
| 107 |
+
await security_container.api_keys.set_status(active.id, "disabled")
|
| 108 |
+
with pytest.raises(APIKeyConflictError):
|
| 109 |
+
await security_container.api_keys.set_status(active.id, "active")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
async def test_scope_enforcement_and_rotation_grace_period(security_container) -> None:
|
| 113 |
+
old, old_secret = await create_key(security_container)
|
| 114 |
+
context = await security_container.api_keys.authenticate(old_secret)
|
| 115 |
+
security_container.api_keys.authorize(context, "templates:read")
|
| 116 |
+
with pytest.raises(ForbiddenError):
|
| 117 |
+
security_container.api_keys.authorize(context, "operations:execute")
|
| 118 |
+
|
| 119 |
+
replacement, replacement_secret = await security_container.api_keys.rotate(
|
| 120 |
+
old.id, 60, created_by="tests"
|
| 121 |
+
)
|
| 122 |
+
assert replacement.rotated_from_id == old.id
|
| 123 |
+
assert (await security_container.api_keys.authenticate(old_secret)).api_key_id == old.id
|
| 124 |
+
assert (
|
| 125 |
+
await security_container.api_keys.authenticate(replacement_secret)
|
| 126 |
+
).api_key_id == replacement.id
|
| 127 |
+
with pytest.raises(APIKeyConflictError):
|
| 128 |
+
await security_container.api_keys.set_status(old.id, "disabled")
|
| 129 |
+
|
| 130 |
+
async with security_container.security_database.session() as session:
|
| 131 |
+
rotating = await session.get(APIKey, old.id)
|
| 132 |
+
assert rotating is not None
|
| 133 |
+
rotating.grace_expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
| 134 |
+
await session.commit()
|
| 135 |
+
with pytest.raises(UnauthorizedError):
|
| 136 |
+
await security_container.api_keys.authenticate(old_secret)
|
| 137 |
+
assert (await security_container.api_keys.get(old.id)).status == "revoked"
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def test_per_key_request_and_concurrent_job_limits(security_container) -> None:
|
| 141 |
+
_, request_secret = await create_key(
|
| 142 |
+
security_container, name="RPM", requests_per_minute=1
|
| 143 |
+
)
|
| 144 |
+
request_context = await security_container.api_keys.authenticate(request_secret)
|
| 145 |
+
lease = await security_container.rate_limiter.acquire(
|
| 146 |
+
request_context, is_job=False, is_upload=False, uploaded_bytes=0
|
| 147 |
+
)
|
| 148 |
+
await lease.release()
|
| 149 |
+
with pytest.raises(RateLimitError) as rate_error:
|
| 150 |
+
await security_container.rate_limiter.acquire(
|
| 151 |
+
request_context, is_job=False, is_upload=False, uploaded_bytes=0
|
| 152 |
+
)
|
| 153 |
+
assert rate_error.value.retry_after >= 1
|
| 154 |
+
|
| 155 |
+
_, job_secret = await create_key(
|
| 156 |
+
security_container, name="Concurrency", concurrent_jobs=1
|
| 157 |
+
)
|
| 158 |
+
job_context = await security_container.api_keys.authenticate(job_secret)
|
| 159 |
+
running = await security_container.rate_limiter.acquire(
|
| 160 |
+
job_context, is_job=True, is_upload=False, uploaded_bytes=0
|
| 161 |
+
)
|
| 162 |
+
with pytest.raises(RateLimitError):
|
| 163 |
+
await security_container.rate_limiter.acquire(
|
| 164 |
+
job_context, is_job=True, is_upload=False, uploaded_bytes=0
|
| 165 |
+
)
|
| 166 |
+
await running.release()
|
| 167 |
+
next_job = await security_container.rate_limiter.acquire(
|
| 168 |
+
job_context, is_job=True, is_upload=False, uploaded_bytes=0
|
| 169 |
+
)
|
| 170 |
+
await next_job.release()
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
async def test_stdio_mcp_uses_shared_context_scopes_rate_limits_and_audit(
|
| 174 |
+
security_container,
|
| 175 |
+
) -> None:
|
| 176 |
+
_, secret = await create_key(
|
| 177 |
+
security_container, name="MCP Reader", scopes=["mcp:read"]
|
| 178 |
+
)
|
| 179 |
+
context = await security_container.api_keys.authenticate(secret)
|
| 180 |
+
registry = MCPRegistry(security_container)
|
| 181 |
+
unauthorized = await registry.run_metadata_tool("system_info", registry.system_info_data)
|
| 182 |
+
token = auth_context.set(context)
|
| 183 |
+
try:
|
| 184 |
+
resource = await registry.safe_resource("version", registry.version_data)
|
| 185 |
+
forbidden = await registry.run_metadata_tool("system_info", registry.system_info_data)
|
| 186 |
+
finally:
|
| 187 |
+
auth_context.reset(token)
|
| 188 |
+
|
| 189 |
+
assert unauthorized["success"] is False
|
| 190 |
+
assert unauthorized["error"]["code"] == "UNAUTHORIZED"
|
| 191 |
+
assert resource["success"] is True
|
| 192 |
+
assert forbidden["success"] is False
|
| 193 |
+
assert forbidden["error"]["code"] == "FORBIDDEN"
|
| 194 |
+
async with security_container.security_database.session() as session:
|
| 195 |
+
logs = list((await session.scalars(select(AuditLog))).all())
|
| 196 |
+
assert {log.endpoint for log in logs} >= {
|
| 197 |
+
"mcp://tools/resource.version",
|
| 198 |
+
"mcp://tools/system_info",
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def test_http_middleware_public_and_authentication_contracts(tmp_path: Path) -> None:
|
| 203 |
+
material = APIKeyService.generate_material("test")
|
| 204 |
+
settings = security_settings(
|
| 205 |
+
tmp_path,
|
| 206 |
+
auth_bootstrap_key_hash=material.key_hash,
|
| 207 |
+
auth_bootstrap_key_prefix=material.key_prefix,
|
| 208 |
+
auth_bootstrap_environment="test",
|
| 209 |
+
auth_default_requests_per_minute=1000,
|
| 210 |
+
)
|
| 211 |
+
application = create_app(settings)
|
| 212 |
+
authorization = {"Authorization": f"Bearer {material.api_key}"}
|
| 213 |
+
|
| 214 |
+
with TestClient(application) as client:
|
| 215 |
+
for path in ("/", "/health", "/version", "/docs", "/openapi.json", "/redoc"):
|
| 216 |
+
assert client.get(path).status_code == 200
|
| 217 |
+
|
| 218 |
+
missing = client.get("/v1/auth/context")
|
| 219 |
+
malformed = client.get(
|
| 220 |
+
"/v1/auth/context", headers={"Authorization": "Basic not-a-mediarouter-key"}
|
| 221 |
+
)
|
| 222 |
+
invalid = client.get(
|
| 223 |
+
"/v1/auth/context", headers={"Authorization": "Bearer mp_test_invalid"}
|
| 224 |
+
)
|
| 225 |
+
for response in (missing, malformed, invalid):
|
| 226 |
+
assert response.status_code == 401
|
| 227 |
+
assert response.json() == {
|
| 228 |
+
"error": "Unauthorized",
|
| 229 |
+
"message": "Invalid or expired API key.",
|
| 230 |
+
}
|
| 231 |
+
assert response.headers["www-authenticate"] == "Bearer"
|
| 232 |
+
|
| 233 |
+
mcp_missing = client.post("/mcp/", json={"jsonrpc": "2.0", "id": 1})
|
| 234 |
+
assert mcp_missing.status_code == 401
|
| 235 |
+
|
| 236 |
+
identity = client.get("/v1/auth/context", headers=authorization)
|
| 237 |
+
assert identity.status_code == 200
|
| 238 |
+
assert identity.json()["key_prefix"] == material.key_prefix
|
| 239 |
+
assert "admin" in identity.json()["scopes"]
|
| 240 |
+
|
| 241 |
+
created = client.post(
|
| 242 |
+
"/v1/api-keys",
|
| 243 |
+
headers=authorization,
|
| 244 |
+
json={
|
| 245 |
+
"name": "Template Reader",
|
| 246 |
+
"environment": "test",
|
| 247 |
+
"role": None,
|
| 248 |
+
"scopes": ["templates:read"],
|
| 249 |
+
},
|
| 250 |
+
)
|
| 251 |
+
assert created.status_code == 201
|
| 252 |
+
limited_authorization = {
|
| 253 |
+
"Authorization": f"Bearer {created.json()['api_key']}"
|
| 254 |
+
}
|
| 255 |
+
assert client.get("/v1/auth/context", headers=limited_authorization).status_code == 200
|
| 256 |
+
forbidden = client.get("/v1/health", headers=limited_authorization)
|
| 257 |
+
assert forbidden.status_code == 403
|
| 258 |
+
assert forbidden.json() == {
|
| 259 |
+
"error": "Forbidden",
|
| 260 |
+
"message": "Missing required scope.",
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
mcp_forbidden = client.post(
|
| 264 |
+
"/mcp/",
|
| 265 |
+
headers=limited_authorization,
|
| 266 |
+
json={
|
| 267 |
+
"jsonrpc": "2.0",
|
| 268 |
+
"id": 1,
|
| 269 |
+
"method": "tools/call",
|
| 270 |
+
"params": {"name": "health", "arguments": {}},
|
| 271 |
+
},
|
| 272 |
+
)
|
| 273 |
+
assert mcp_forbidden.status_code == 403
|
| 274 |
+
|
| 275 |
+
audit_logs = client.get("/v1/audit-logs", headers=authorization)
|
| 276 |
+
assert audit_logs.status_code == 200
|
| 277 |
+
entries = audit_logs.json()
|
| 278 |
+
assert any(
|
| 279 |
+
entry["endpoint"] == "/v1/auth/context"
|
| 280 |
+
and entry["api_key_id"] == identity.json()["id"]
|
| 281 |
+
and entry["response_code"] == 200
|
| 282 |
+
for entry in entries
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def test_http_rate_limit_returns_retry_after(tmp_path: Path) -> None:
|
| 287 |
+
material = APIKeyService.generate_material("test")
|
| 288 |
+
application = create_app(
|
| 289 |
+
security_settings(
|
| 290 |
+
tmp_path,
|
| 291 |
+
auth_bootstrap_key_hash=material.key_hash,
|
| 292 |
+
auth_bootstrap_key_prefix=material.key_prefix,
|
| 293 |
+
auth_bootstrap_environment="test",
|
| 294 |
+
auth_default_requests_per_minute=1,
|
| 295 |
+
)
|
| 296 |
+
)
|
| 297 |
+
headers = {"Authorization": f"Bearer {material.api_key}"}
|
| 298 |
+
with TestClient(application) as client:
|
| 299 |
+
assert client.get("/v1/auth/context", headers=headers).status_code == 200
|
| 300 |
+
limited = client.get("/v1/auth/context", headers=headers)
|
| 301 |
+
|
| 302 |
+
assert limited.status_code == 429
|
| 303 |
+
assert limited.json() == {
|
| 304 |
+
"error": "Rate limit exceeded",
|
| 305 |
+
"message": "Retry later.",
|
| 306 |
+
}
|
| 307 |
+
assert int(limited.headers["retry-after"]) >= 1
|