basyx commited on
Commit
c91c7db
·
verified ·
1 Parent(s): fba6023

Upload 158 files

Browse files
.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: Enterprise Media Processing API
3
  emoji: 🎬
4
  colorFrom: blue
5
  colorTo: purple
@@ -8,9 +8,9 @@ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
- # Enterprise Media Processing API
12
 
13
- 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,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. Add environment variables under **Settings Variables and secrets** if the defaults need changing. Do not store secrets in `.env` in Git.
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 `/docs` or call `/v1/*` from n8n.
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 cleanup implementation is duplicated in `app/mcp`. The MCP registry creates a UUID for each tool call and reuses the existing request workspace, validation, structured logging, publication, and expiry cleanup behavior. MCP has no authentication, as requested; use a private Space or an external gateway if access control or quotas are required.
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, when a client asks for the transport. No authorization header is needed.
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 processing routes use `POST`. Health and output downloads use `GET`.
797
 
798
  | Group | Endpoints |
799
  |---|---|
800
- | System | `/health`, `/v1/probe`, `/v1/media/{request_id}/{filename}` |
 
 
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 -F 'file=@input.mp4'
 
 
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
- - **MCP client cannot connect:** use the trailing-slash URL `/mcp/`, select Streamable HTTP rather than legacy SSE, and verify `/health` first. Stdio clients must launch the command with the project directory as their working directory.
 
 
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
- """Enterprise Media Processing API package."""
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 = "Enterprise Media Processing API"
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(self, tool_name: str, action: MetadataAction) -> dict[str, Any]:
 
 
 
 
 
 
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(f"resource.{name}", action)
 
 
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(self, tool_name: str, action: Action) -> dict[str, Any]:
 
 
 
 
 
 
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="Enterprise Media Processing API",
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 as a standalone stdio or Streamable HTTP server."""
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
- await server.run_stdio_async()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  else:
71
- await server.run_streamable_http_async()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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