api-shield 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api_shield-0.1.0.dist-info/METADATA +744 -0
- api_shield-0.1.0.dist-info/RECORD +26 -0
- api_shield-0.1.0.dist-info/WHEEL +4 -0
- api_shield-0.1.0.dist-info/entry_points.txt +2 -0
- api_shield-0.1.0.dist-info/licenses/LICENSE +21 -0
- shield/__init__.py +0 -0
- shield/cli/__init__.py +0 -0
- shield/cli/main.py +603 -0
- shield/core/__init__.py +0 -0
- shield/core/backends/__init__.py +0 -0
- shield/core/backends/base.py +104 -0
- shield/core/backends/file.py +117 -0
- shield/core/backends/memory.py +76 -0
- shield/core/backends/redis.py +165 -0
- shield/core/config.py +203 -0
- shield/core/engine.py +545 -0
- shield/core/exceptions.py +57 -0
- shield/core/models.py +83 -0
- shield/core/scheduler.py +234 -0
- shield/core/webhooks.py +62 -0
- shield/dashboard/__init__.py +0 -0
- shield/fastapi/__init__.py +39 -0
- shield/fastapi/decorators.py +195 -0
- shield/fastapi/middleware.py +277 -0
- shield/fastapi/openapi.py +626 -0
- shield/fastapi/router.py +158 -0
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: api-shield
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Route lifecycle management for APIs — maintenance mode, env gating, deprecation, and more
|
|
5
|
+
Project-URL: Homepage, https://github.com/Attakay78/api-shield
|
|
6
|
+
Project-URL: Repository, https://github.com/Attakay78/api-shield
|
|
7
|
+
Project-URL: Issues, https://github.com/Attakay78/api-shield/issues
|
|
8
|
+
Author: Richard Quaicoe
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,fastapi,lifecycle,maintenance,middleware,route
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: anyio>=4.0
|
|
24
|
+
Requires-Dist: pydantic>=2.0
|
|
25
|
+
Requires-Dist: starlette>=0.27
|
|
26
|
+
Provides-Extra: all
|
|
27
|
+
Requires-Dist: aiofiles>=23.0; extra == 'all'
|
|
28
|
+
Requires-Dist: fastapi>=0.100; extra == 'all'
|
|
29
|
+
Requires-Dist: jinja2>=3.1; extra == 'all'
|
|
30
|
+
Requires-Dist: redis[asyncio]>=5.0; extra == 'all'
|
|
31
|
+
Requires-Dist: typer>=0.12; extra == 'all'
|
|
32
|
+
Provides-Extra: cli
|
|
33
|
+
Requires-Dist: typer>=0.12; extra == 'cli'
|
|
34
|
+
Provides-Extra: dashboard
|
|
35
|
+
Requires-Dist: aiofiles>=23.0; extra == 'dashboard'
|
|
36
|
+
Requires-Dist: jinja2>=3.1; extra == 'dashboard'
|
|
37
|
+
Provides-Extra: dev
|
|
38
|
+
Requires-Dist: aiofiles>=23.0; extra == 'dev'
|
|
39
|
+
Requires-Dist: anyio[trio]; extra == 'dev'
|
|
40
|
+
Requires-Dist: fastapi>=0.100; extra == 'dev'
|
|
41
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
42
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
43
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
44
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
45
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
46
|
+
Provides-Extra: fastapi
|
|
47
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
48
|
+
Provides-Extra: redis
|
|
49
|
+
Requires-Dist: redis[asyncio]>=5.0; extra == 'redis'
|
|
50
|
+
Description-Content-Type: text/markdown
|
|
51
|
+
|
|
52
|
+
# api-shield
|
|
53
|
+
|
|
54
|
+
**Route lifecycle management for FastAPI — maintenance mode, environment gating, deprecation, canary rollouts, and more. No restarts required.**
|
|
55
|
+
|
|
56
|
+
Most "maintenance mode" tools are blunt instruments: shut everything down or nothing at all. `api-shield` treats each route as a first-class entity with its own lifecycle. State changes take effect immediately through an ASGI middleware — no redeployment, no server restart.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Contents
|
|
61
|
+
|
|
62
|
+
- [Quick Start](#quick-start)
|
|
63
|
+
- [How It Works](#how-it-works)
|
|
64
|
+
- [Decorators](#decorators)
|
|
65
|
+
- [Global Maintenance Mode](#global-maintenance-mode)
|
|
66
|
+
- [Backends](#backends)
|
|
67
|
+
- [OpenAPI & Docs Integration](#openapi--docs-integration)
|
|
68
|
+
- [CLI Reference](#cli-reference)
|
|
69
|
+
- [Audit Log](#audit-log)
|
|
70
|
+
- [Configuration File](#configuration-file)
|
|
71
|
+
- [Architecture](#architecture)
|
|
72
|
+
- [Testing](#testing)
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Quick Start
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
uv add api-shield
|
|
80
|
+
# or: pip install api-shield
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from fastapi import FastAPI
|
|
85
|
+
from shield.core.config import make_engine
|
|
86
|
+
from shield.fastapi import (
|
|
87
|
+
ShieldMiddleware,
|
|
88
|
+
apply_shield_to_openapi,
|
|
89
|
+
setup_shield_docs,
|
|
90
|
+
maintenance,
|
|
91
|
+
env_only,
|
|
92
|
+
disabled,
|
|
93
|
+
force_active,
|
|
94
|
+
deprecated,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
engine = make_engine() # reads SHIELD_BACKEND, SHIELD_ENV, etc.
|
|
98
|
+
|
|
99
|
+
app = FastAPI(title="My API")
|
|
100
|
+
app.add_middleware(ShieldMiddleware, engine=engine)
|
|
101
|
+
|
|
102
|
+
@app.get("/payments")
|
|
103
|
+
@maintenance(reason="Database migration — back at 04:00 UTC")
|
|
104
|
+
async def get_payments():
|
|
105
|
+
return {"payments": []}
|
|
106
|
+
|
|
107
|
+
@app.get("/health")
|
|
108
|
+
@force_active # always 200, immune to all shield checks
|
|
109
|
+
async def health():
|
|
110
|
+
return {"status": "ok"}
|
|
111
|
+
|
|
112
|
+
@app.get("/debug")
|
|
113
|
+
@env_only("dev", "staging") # silent 404 in production
|
|
114
|
+
async def debug():
|
|
115
|
+
return {"debug": True}
|
|
116
|
+
|
|
117
|
+
@app.get("/old-endpoint")
|
|
118
|
+
@disabled(reason="Use /v2/endpoint")
|
|
119
|
+
async def old_endpoint():
|
|
120
|
+
return {}
|
|
121
|
+
|
|
122
|
+
@app.get("/v1/users")
|
|
123
|
+
@deprecated(sunset="Sat, 01 Jan 2027 00:00:00 GMT", use_instead="/v2/users")
|
|
124
|
+
async def v1_users():
|
|
125
|
+
return {"users": []}
|
|
126
|
+
|
|
127
|
+
apply_shield_to_openapi(app, engine) # filter /docs and /redoc
|
|
128
|
+
setup_shield_docs(app, engine) # inject maintenance banners into UI
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
GET /payments → 503 {"error": {"code": "MAINTENANCE_MODE", "reason": "..."}}
|
|
133
|
+
GET /health → 200 always (force_active)
|
|
134
|
+
GET /debug → 404 in production (env_only)
|
|
135
|
+
GET /old-endpoint → 503 {"error": {"code": "ROUTE_DISABLED", "reason": "..."}}
|
|
136
|
+
GET /v1/users → 200 + Deprecation/Sunset/Link response headers
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## How It Works
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
Incoming HTTP request
|
|
145
|
+
│
|
|
146
|
+
▼
|
|
147
|
+
ShieldMiddleware.dispatch()
|
|
148
|
+
│
|
|
149
|
+
├─ /docs, /redoc, /openapi.json ──────────────────────→ pass through
|
|
150
|
+
│
|
|
151
|
+
├─ Lazy-scan app routes for __shield_meta__ (once only)
|
|
152
|
+
│
|
|
153
|
+
├─ @force_active route? ──────────────────────────────→ pass through
|
|
154
|
+
│ (unless global maintenance overrides — see below)
|
|
155
|
+
│
|
|
156
|
+
├─ engine.check(path, method)
|
|
157
|
+
│ │
|
|
158
|
+
│ ├─ Global maintenance ON + path not exempt? → 503
|
|
159
|
+
│ ├─ MAINTENANCE → 503 + Retry-After header
|
|
160
|
+
│ ├─ DISABLED → 503
|
|
161
|
+
│ ├─ ENV_GATED → 404 (silent — path existence not revealed)
|
|
162
|
+
│ ├─ DEPRECATED → pass through + inject response headers
|
|
163
|
+
│ └─ ACTIVE → pass through ✓
|
|
164
|
+
│
|
|
165
|
+
└─ call_next(request)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Route Registration
|
|
169
|
+
|
|
170
|
+
Shield decorators stamp `__shield_meta__` on the endpoint function. This metadata is registered with the engine at startup via two mechanisms:
|
|
171
|
+
|
|
172
|
+
1. **ASGI lifespan interception** — `ShieldMiddleware` hooks into `lifespan.startup.complete` to scan all app routes before the first request. This works with any `APIRouter` (plain or `ShieldRouter`).
|
|
173
|
+
2. **Lazy fallback** — on the first HTTP request if no lifespan was triggered (e.g. test environments).
|
|
174
|
+
|
|
175
|
+
State registration is **persistence-first**: if the backend already has a state for a route (written by a previous CLI command or earlier server run), the decorator default is ignored and the persisted state wins. This means runtime changes survive restarts.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Decorators
|
|
180
|
+
|
|
181
|
+
All decorators work on any router type — plain `APIRouter`, `ShieldRouter`, or routes added directly to the `FastAPI` app instance.
|
|
182
|
+
|
|
183
|
+
### `@maintenance(reason, start, end)`
|
|
184
|
+
|
|
185
|
+
Puts a route into maintenance mode. Returns 503 with a structured JSON body. If `start`/`end` are provided, the maintenance window is also stored for scheduling.
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from shield.fastapi import maintenance
|
|
189
|
+
from datetime import datetime, UTC
|
|
190
|
+
|
|
191
|
+
@router.get("/payments")
|
|
192
|
+
@maintenance(reason="DB migration in progress")
|
|
193
|
+
async def get_payments():
|
|
194
|
+
...
|
|
195
|
+
|
|
196
|
+
# With a scheduled window
|
|
197
|
+
@router.post("/orders")
|
|
198
|
+
@maintenance(
|
|
199
|
+
reason="Order system upgrade",
|
|
200
|
+
start=datetime(2025, 6, 1, 2, 0, tzinfo=UTC),
|
|
201
|
+
end=datetime(2025, 6, 1, 4, 0, tzinfo=UTC),
|
|
202
|
+
)
|
|
203
|
+
async def create_order():
|
|
204
|
+
...
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Response:
|
|
208
|
+
```json
|
|
209
|
+
{
|
|
210
|
+
"error": {
|
|
211
|
+
"code": "MAINTENANCE_MODE",
|
|
212
|
+
"message": "This endpoint is temporarily unavailable",
|
|
213
|
+
"reason": "DB migration in progress",
|
|
214
|
+
"path": "/payments",
|
|
215
|
+
"retry_after": "2025-06-01T04:00:00Z"
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
### `@disabled(reason)`
|
|
223
|
+
|
|
224
|
+
Permanently disables a route. Returns 503. Use for routes that should never be called again (migrations, removed features).
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from shield.fastapi import disabled
|
|
228
|
+
|
|
229
|
+
@router.get("/legacy/report")
|
|
230
|
+
@disabled(reason="Replaced by /v2/reports — update your clients")
|
|
231
|
+
async def legacy_report():
|
|
232
|
+
...
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
### `@env_only(*envs)`
|
|
238
|
+
|
|
239
|
+
Restricts a route to specific environment names. In any other environment the route returns a **silent 404** — it does not reveal that the path exists.
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
from shield.fastapi import env_only
|
|
243
|
+
|
|
244
|
+
@router.get("/internal/metrics")
|
|
245
|
+
@env_only("dev", "staging")
|
|
246
|
+
async def internal_metrics():
|
|
247
|
+
...
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The current environment is set via `SHIELD_ENV` or when constructing the engine:
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
engine = ShieldEngine(current_env="production")
|
|
254
|
+
# or
|
|
255
|
+
engine = make_engine(current_env="staging")
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
### `@force_active`
|
|
261
|
+
|
|
262
|
+
Bypasses all shield checks. Use for health checks, status endpoints, and any route that must always be reachable.
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
from shield.fastapi import force_active
|
|
266
|
+
|
|
267
|
+
@router.get("/health")
|
|
268
|
+
@force_active
|
|
269
|
+
async def health():
|
|
270
|
+
return {"status": "ok"}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
`@force_active` routes are also **immune to runtime changes** — you cannot disable or put them in maintenance via the CLI or engine. This is intentional: health check routes must be trustworthy.
|
|
274
|
+
|
|
275
|
+
The only exception is when global maintenance mode is enabled with `include_force_active=True` (see [Global Maintenance Mode](#global-maintenance-mode)).
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
### `@deprecated(sunset, use_instead)`
|
|
280
|
+
|
|
281
|
+
Marks a route as deprecated. Requests still succeed, but the middleware injects RFC-compliant response headers:
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
from shield.fastapi import deprecated
|
|
285
|
+
|
|
286
|
+
@router.get("/v1/users")
|
|
287
|
+
@deprecated(
|
|
288
|
+
sunset="Sat, 01 Jan 2027 00:00:00 GMT",
|
|
289
|
+
use_instead="/v2/users",
|
|
290
|
+
)
|
|
291
|
+
async def v1_users():
|
|
292
|
+
return {"users": []}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Response headers added automatically:
|
|
296
|
+
```
|
|
297
|
+
Deprecation: true
|
|
298
|
+
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
|
|
299
|
+
Link: </v2/users>; rel="successor-version"
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
The route is also marked `deprecated: true` in the OpenAPI schema and shown with a visual indicator in `/docs`.
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
## Global Maintenance Mode
|
|
307
|
+
|
|
308
|
+
Global maintenance blocks **every route** with a single call, without requiring per-route decorators. Use it for full deployments, infrastructure work, or emergency stops.
|
|
309
|
+
|
|
310
|
+
### Programmatic (lifespan or runtime)
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
from contextlib import asynccontextmanager
|
|
314
|
+
from fastapi import FastAPI
|
|
315
|
+
|
|
316
|
+
@asynccontextmanager
|
|
317
|
+
async def lifespan(app: FastAPI):
|
|
318
|
+
# Enable global maintenance at startup
|
|
319
|
+
await engine.enable_global_maintenance(
|
|
320
|
+
reason="Scheduled deployment — back in 15 minutes",
|
|
321
|
+
exempt_paths=["/health", "GET:/admin/status"],
|
|
322
|
+
include_force_active=False, # @force_active routes still bypass (default)
|
|
323
|
+
)
|
|
324
|
+
yield
|
|
325
|
+
await engine.disable_global_maintenance()
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Or toggle at runtime via any async context:
|
|
329
|
+
|
|
330
|
+
```python
|
|
331
|
+
# Enable — all non-exempt routes return 503 immediately
|
|
332
|
+
await engine.enable_global_maintenance(reason="Emergency patch")
|
|
333
|
+
|
|
334
|
+
# Disable — routes return to their per-route state
|
|
335
|
+
await engine.disable_global_maintenance()
|
|
336
|
+
|
|
337
|
+
# Check current state
|
|
338
|
+
cfg = await engine.get_global_maintenance()
|
|
339
|
+
print(cfg.enabled, cfg.reason, cfg.exempt_paths)
|
|
340
|
+
|
|
341
|
+
# Add/remove individual exemptions without toggling the mode
|
|
342
|
+
await engine.set_global_exempt_paths(["/health", "/status"])
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Via CLI
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
# Enable with exemptions
|
|
349
|
+
shield global enable \
|
|
350
|
+
--reason "Scheduled deployment" \
|
|
351
|
+
--exempt /health \
|
|
352
|
+
--exempt GET:/admin/status
|
|
353
|
+
|
|
354
|
+
# Block even force_active routes
|
|
355
|
+
shield global enable --reason "Hard lockdown" --include-force-active
|
|
356
|
+
|
|
357
|
+
# Add/remove exemptions while maintenance is already active
|
|
358
|
+
shield global exempt-add /monitoring/ping
|
|
359
|
+
shield global exempt-remove /monitoring/ping
|
|
360
|
+
|
|
361
|
+
# Check current state
|
|
362
|
+
shield global status
|
|
363
|
+
|
|
364
|
+
# Disable
|
|
365
|
+
shield global disable
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### Options
|
|
369
|
+
|
|
370
|
+
| Option | Default | Description |
|
|
371
|
+
|---|---|---|
|
|
372
|
+
| `reason` | `""` | Shown in every 503 response body |
|
|
373
|
+
| `exempt_paths` | `[]` | Bare paths (`/health`) or method-prefixed (`GET:/health`) |
|
|
374
|
+
| `include_force_active` | `False` | When `True`, `@force_active` routes are also blocked |
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## Backends
|
|
379
|
+
|
|
380
|
+
The backend determines where route state and the audit log are persisted.
|
|
381
|
+
|
|
382
|
+
### `MemoryBackend` (default)
|
|
383
|
+
|
|
384
|
+
In-process dict. No persistence across restarts. CLI cannot share state with the running server.
|
|
385
|
+
|
|
386
|
+
```python
|
|
387
|
+
from shield.core.backends.memory import MemoryBackend
|
|
388
|
+
engine = ShieldEngine(backend=MemoryBackend())
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Best for: development, single-process testing.
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
### `FileBackend`
|
|
396
|
+
|
|
397
|
+
JSON file on disk. Survives restarts. CLI shares state with the running server when both point to the same file.
|
|
398
|
+
|
|
399
|
+
```python
|
|
400
|
+
from shield.core.backends.file import FileBackend
|
|
401
|
+
engine = ShieldEngine(backend=FileBackend(path="shield-state.json"))
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Or via environment variable:
|
|
405
|
+
```bash
|
|
406
|
+
SHIELD_BACKEND=file SHIELD_FILE_PATH=./shield-state.json uvicorn app:app
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Best for: single-instance deployments, simple setups, CLI-driven workflows.
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
### `RedisBackend`
|
|
414
|
+
|
|
415
|
+
Redis via `redis-py` async. Supports multi-instance deployments. CLI changes reflect immediately on all running instances.
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
from shield.core.backends.redis import RedisBackend
|
|
419
|
+
engine = ShieldEngine(backend=RedisBackend(url="redis://localhost:6379/0"))
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Or via environment variable:
|
|
423
|
+
```bash
|
|
424
|
+
SHIELD_BACKEND=redis SHIELD_REDIS_URL=redis://localhost:6379/0 uvicorn app:app
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Key schema:
|
|
428
|
+
- `shield:state:{path}` — route state
|
|
429
|
+
- `shield:audit` — audit log (LPUSH, capped at 1000 entries)
|
|
430
|
+
- `shield:global` — global maintenance configuration
|
|
431
|
+
|
|
432
|
+
Best for: multi-instance / load-balanced deployments, production.
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
### Config file (`.shield`)
|
|
437
|
+
|
|
438
|
+
Both the app and CLI auto-discover a `.shield` file by walking up from the current directory:
|
|
439
|
+
|
|
440
|
+
```ini
|
|
441
|
+
# .shield
|
|
442
|
+
SHIELD_BACKEND=file
|
|
443
|
+
SHIELD_FILE_PATH=shield-state.json
|
|
444
|
+
SHIELD_ENV=production
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Priority order (highest wins):
|
|
448
|
+
1. Explicit constructor arguments
|
|
449
|
+
2. `os.environ`
|
|
450
|
+
3. `.shield` file
|
|
451
|
+
4. Built-in defaults
|
|
452
|
+
|
|
453
|
+
Pass a specific config file to the CLI:
|
|
454
|
+
```bash
|
|
455
|
+
shield --config /etc/myapp/.shield status
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
## OpenAPI & Docs Integration
|
|
461
|
+
|
|
462
|
+
### Schema filtering
|
|
463
|
+
|
|
464
|
+
```python
|
|
465
|
+
from shield.fastapi import apply_shield_to_openapi
|
|
466
|
+
|
|
467
|
+
apply_shield_to_openapi(app, engine)
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Effect on `/docs` and `/redoc`:
|
|
471
|
+
|
|
472
|
+
| Route status | Schema behaviour |
|
|
473
|
+
|---|---|
|
|
474
|
+
| `DISABLED` | Hidden from all schemas |
|
|
475
|
+
| `ENV_GATED` (wrong env) | Hidden from all schemas |
|
|
476
|
+
| `MAINTENANCE` | Visible; operation summary prefixed with `🔧`; description shows warning block; `x-shield-status` extension added |
|
|
477
|
+
| `DEPRECATED` | Marked `deprecated: true`; successor path shown |
|
|
478
|
+
| `ACTIVE` | No change |
|
|
479
|
+
|
|
480
|
+
Schema is computed fresh on every request — runtime state changes (CLI, engine calls) reflect immediately without restarting.
|
|
481
|
+
|
|
482
|
+
---
|
|
483
|
+
|
|
484
|
+
### Docs UI customisation
|
|
485
|
+
|
|
486
|
+
```python
|
|
487
|
+
from shield.fastapi import setup_shield_docs
|
|
488
|
+
|
|
489
|
+
apply_shield_to_openapi(app, engine) # must come first
|
|
490
|
+
setup_shield_docs(app, engine)
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Replaces both `/docs` and `/redoc` with enhanced versions:
|
|
494
|
+
|
|
495
|
+
**Global maintenance ON:**
|
|
496
|
+
- Full-width pulsing red sticky banner at the top of the page
|
|
497
|
+
- Reason text and exempt paths displayed
|
|
498
|
+
- Refreshes automatically every 15 seconds — no page reload needed
|
|
499
|
+
|
|
500
|
+
**Global maintenance OFF:**
|
|
501
|
+
- Small green "All systems operational" chip in the bottom-right corner
|
|
502
|
+
|
|
503
|
+
**Per-route maintenance:**
|
|
504
|
+
- Orange left-border on the operation block
|
|
505
|
+
- `🔧 MAINTENANCE` badge appended to the summary bar
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
## CLI Reference
|
|
510
|
+
|
|
511
|
+
The `shield` CLI operates on the same backend as the running server. Requires `SHIELD_BACKEND=file` or `SHIELD_BACKEND=redis` to share state (the default `memory` backend is process-local).
|
|
512
|
+
|
|
513
|
+
```bash
|
|
514
|
+
# Install entry point
|
|
515
|
+
uv pip install -e ".[cli]"
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### Route commands
|
|
519
|
+
|
|
520
|
+
```bash
|
|
521
|
+
# Show all registered routes
|
|
522
|
+
shield status
|
|
523
|
+
|
|
524
|
+
# Show one route
|
|
525
|
+
shield status GET:/payments
|
|
526
|
+
|
|
527
|
+
# Enable a route
|
|
528
|
+
shield enable GET:/payments
|
|
529
|
+
|
|
530
|
+
# Disable with a reason
|
|
531
|
+
shield disable GET:/payments --reason "Security patch"
|
|
532
|
+
|
|
533
|
+
# Put in maintenance (immediate)
|
|
534
|
+
shield maintenance GET:/payments --reason "DB swap"
|
|
535
|
+
|
|
536
|
+
# Put in maintenance with a time window
|
|
537
|
+
shield maintenance GET:/payments \
|
|
538
|
+
--reason "DB migration" \
|
|
539
|
+
--start 2025-06-01T02:00Z \
|
|
540
|
+
--end 2025-06-01T04:00Z
|
|
541
|
+
|
|
542
|
+
# Schedule a future maintenance window (auto-activates and deactivates)
|
|
543
|
+
shield schedule GET:/payments \
|
|
544
|
+
--start 2025-06-01T02:00Z \
|
|
545
|
+
--end 2025-06-01T04:00Z \
|
|
546
|
+
--reason "Planned migration"
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
### Global maintenance commands
|
|
550
|
+
|
|
551
|
+
```bash
|
|
552
|
+
shield global status
|
|
553
|
+
shield global enable --reason "Deploying v2" --exempt /health
|
|
554
|
+
shield global disable
|
|
555
|
+
shield global exempt-add /monitoring
|
|
556
|
+
shield global exempt-remove /monitoring
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
### Audit log
|
|
560
|
+
|
|
561
|
+
```bash
|
|
562
|
+
shield log # last 20 entries across all routes
|
|
563
|
+
shield log --route GET:/payments # filter by route
|
|
564
|
+
shield log --limit 100
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### Notes on route keys
|
|
568
|
+
|
|
569
|
+
Routes are stored with method-prefixed keys:
|
|
570
|
+
|
|
571
|
+
| What you type | What gets stored |
|
|
572
|
+
|---|---|
|
|
573
|
+
| `@router.get("/payments")` | `GET:/payments` |
|
|
574
|
+
| `@router.post("/payments")` | `POST:/payments` |
|
|
575
|
+
| `@router.get("/api/v1/users")` | `GET:/api/v1/users` |
|
|
576
|
+
|
|
577
|
+
Use the same format with the CLI:
|
|
578
|
+
```bash
|
|
579
|
+
shield disable "GET:/payments"
|
|
580
|
+
shield enable "/payments" # applies to all methods
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
585
|
+
## Audit Log
|
|
586
|
+
|
|
587
|
+
Every state change writes an immutable audit entry:
|
|
588
|
+
|
|
589
|
+
```python
|
|
590
|
+
# Via engine
|
|
591
|
+
entries = await engine.get_audit_log(limit=50)
|
|
592
|
+
entries = await engine.get_audit_log(path="GET:/payments", limit=20)
|
|
593
|
+
|
|
594
|
+
for e in entries:
|
|
595
|
+
print(e.timestamp, e.actor, e.action, e.path,
|
|
596
|
+
e.previous_status, "→", e.new_status, e.reason)
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
Fields: `id`, `timestamp`, `path`, `action`, `actor`, `reason`, `previous_status`, `new_status`.
|
|
600
|
+
|
|
601
|
+
The CLI uses `getpass.getuser()` (the logged-in OS username) as the default actor — no `--actor` flag needed for accountability:
|
|
602
|
+
|
|
603
|
+
```bash
|
|
604
|
+
shield disable GET:/payments --reason "Security patch"
|
|
605
|
+
# audit entry: actor="alice", action="disable", path="GET:/payments"
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
---
|
|
609
|
+
|
|
610
|
+
## Architecture
|
|
611
|
+
|
|
612
|
+
```
|
|
613
|
+
shield/
|
|
614
|
+
├── core/ # Framework-agnostic — zero FastAPI imports
|
|
615
|
+
│ ├── models.py # RouteState, AuditEntry, GlobalMaintenanceConfig
|
|
616
|
+
│ ├── engine.py # ShieldEngine — all business logic
|
|
617
|
+
│ ├── scheduler.py # MaintenanceScheduler (asyncio.Task based)
|
|
618
|
+
│ ├── config.py # Backend/engine factory + .shield file loading
|
|
619
|
+
│ ├── exceptions.py # MaintenanceException, EnvGatedException, ...
|
|
620
|
+
│ └── backends/
|
|
621
|
+
│ ├── base.py # ShieldBackend ABC
|
|
622
|
+
│ ├── memory.py # In-process dict
|
|
623
|
+
│ ├── file.py # JSON file via aiofiles
|
|
624
|
+
│ └── redis.py # Redis via redis-py async
|
|
625
|
+
│
|
|
626
|
+
├── fastapi/ # FastAPI adapter layer
|
|
627
|
+
│ ├── middleware.py # ShieldMiddleware (ASGI, BaseHTTPMiddleware)
|
|
628
|
+
│ ├── decorators.py # @maintenance, @disabled, @env_only, ...
|
|
629
|
+
│ ├── router.py # ShieldRouter + scan_routes()
|
|
630
|
+
│ └── openapi.py # Schema filter + docs UI customisation
|
|
631
|
+
│
|
|
632
|
+
└── cli/
|
|
633
|
+
└── main.py # Typer CLI app
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
### Key design rules
|
|
637
|
+
|
|
638
|
+
1. **`shield.core` never imports from `shield.fastapi`** — the core is framework-agnostic and can power future adapters (Flask, Litestar, Django).
|
|
639
|
+
2. **All business logic lives in `ShieldEngine`** — middleware and decorators are transport layers that call `engine.check()`, never make policy decisions themselves.
|
|
640
|
+
3. **`engine.check()` is the single chokepoint** — every request, regardless of router type, goes through this one method.
|
|
641
|
+
4. **Fail-open on backend errors** — if the backend is unreachable, requests pass through. Shield never takes down an API due to its own failures.
|
|
642
|
+
5. **Persistence-first registration** — if a route already has persisted state, the decorator default is ignored. Runtime changes survive restarts.
|
|
643
|
+
|
|
644
|
+
---
|
|
645
|
+
|
|
646
|
+
## Testing
|
|
647
|
+
|
|
648
|
+
```bash
|
|
649
|
+
# Run all tests
|
|
650
|
+
uv run pytest
|
|
651
|
+
|
|
652
|
+
# Run with verbose output
|
|
653
|
+
uv run pytest -v
|
|
654
|
+
|
|
655
|
+
# Run a specific test file
|
|
656
|
+
uv run pytest tests/fastapi/test_middleware.py
|
|
657
|
+
|
|
658
|
+
# Run only core tests (no FastAPI dependency)
|
|
659
|
+
uv run pytest tests/core/
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
### Writing tests with shield
|
|
663
|
+
|
|
664
|
+
```python
|
|
665
|
+
import pytest
|
|
666
|
+
from fastapi import FastAPI
|
|
667
|
+
from httpx import ASGITransport, AsyncClient
|
|
668
|
+
|
|
669
|
+
from shield.core.backends.memory import MemoryBackend
|
|
670
|
+
from shield.core.engine import ShieldEngine
|
|
671
|
+
from shield.fastapi.decorators import maintenance, force_active
|
|
672
|
+
from shield.fastapi.middleware import ShieldMiddleware
|
|
673
|
+
from shield.fastapi.router import ShieldRouter
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
async def test_maintenance_returns_503():
|
|
677
|
+
engine = ShieldEngine(backend=MemoryBackend())
|
|
678
|
+
app = FastAPI()
|
|
679
|
+
app.add_middleware(ShieldMiddleware, engine=engine)
|
|
680
|
+
router = ShieldRouter(engine=engine)
|
|
681
|
+
|
|
682
|
+
@router.get("/payments")
|
|
683
|
+
@maintenance(reason="DB migration")
|
|
684
|
+
async def get_payments():
|
|
685
|
+
return {"ok": True}
|
|
686
|
+
|
|
687
|
+
app.include_router(router)
|
|
688
|
+
await app.router.startup() # trigger shield route registration
|
|
689
|
+
|
|
690
|
+
async with AsyncClient(
|
|
691
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
692
|
+
) as client:
|
|
693
|
+
resp = await client.get("/payments")
|
|
694
|
+
|
|
695
|
+
assert resp.status_code == 503
|
|
696
|
+
assert resp.json()["error"]["code"] == "MAINTENANCE_MODE"
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
async def test_runtime_enable_via_engine():
|
|
700
|
+
engine = ShieldEngine(backend=MemoryBackend())
|
|
701
|
+
# ... set up app ...
|
|
702
|
+
|
|
703
|
+
# Put a route in maintenance at runtime (no decorator needed)
|
|
704
|
+
await engine.set_maintenance("GET:/orders", reason="Upgrade")
|
|
705
|
+
|
|
706
|
+
# Re-enable it
|
|
707
|
+
await engine.enable("GET:/orders")
|
|
708
|
+
|
|
709
|
+
state = await engine.get_state("GET:/orders")
|
|
710
|
+
assert state.status.value == "active"
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
### Test configuration
|
|
714
|
+
|
|
715
|
+
`pyproject.toml` includes:
|
|
716
|
+
```toml
|
|
717
|
+
[tool.pytest.ini_options]
|
|
718
|
+
asyncio_mode = "auto" # all async tests work without @pytest.mark.asyncio
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
---
|
|
722
|
+
|
|
723
|
+
## Error Response Format
|
|
724
|
+
|
|
725
|
+
All shield-generated error responses follow a consistent JSON structure:
|
|
726
|
+
|
|
727
|
+
```json
|
|
728
|
+
{
|
|
729
|
+
"error": {
|
|
730
|
+
"code": "MAINTENANCE_MODE",
|
|
731
|
+
"message": "This endpoint is temporarily unavailable",
|
|
732
|
+
"reason": "Database migration in progress",
|
|
733
|
+
"path": "/api/payments",
|
|
734
|
+
"retry_after": "2025-06-01T04:00:00Z"
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
| Scenario | HTTP status | `code` |
|
|
740
|
+
|---|---|---|
|
|
741
|
+
| Route in maintenance | 503 | `MAINTENANCE_MODE` |
|
|
742
|
+
| Route disabled | 503 | `ROUTE_DISABLED` |
|
|
743
|
+
| Route env-gated (wrong env) | 404 | *(no body — silent)* |
|
|
744
|
+
| Global maintenance active | 503 | `MAINTENANCE_MODE` |
|