flask-fenrir 0.1.0__tar.gz
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.
- flask_fenrir-0.1.0/.envrc +1 -0
- flask_fenrir-0.1.0/.gitignore +7 -0
- flask_fenrir-0.1.0/LICENSE +21 -0
- flask_fenrir-0.1.0/PKG-INFO +158 -0
- flask_fenrir-0.1.0/README.md +139 -0
- flask_fenrir-0.1.0/flask_fenrir.py +257 -0
- flask_fenrir-0.1.0/pyproject.toml +39 -0
- flask_fenrir-0.1.0/tests/test_fenrir_api.py +415 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export PYPI_TOKEN=pypi-AgEIcHlwaS5vcmcCJDg5MWY3OTBmLWYwNWEtNDU3OS05NDE4LTk1MDc1OWM4NDU2MAACKlszLCIzY2JiOTk5Ny0wMDg0LTQ3M2MtODE3Zi02YWZkZTRlMjllMmIiXQAABiBF2S2fNYfpL_9SrG9_YxQJ80z5yFYP161OOn1MS-IkSw
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fenrir
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flask-fenrir
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop-in Flask API for LLM agent access to SQLModel web apps
|
|
5
|
+
Project-URL: Repository, https://github.com/fenrir/fenrir-api
|
|
6
|
+
Author: Fenrir
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: api,flask,llm,sqlalchemy,sqlmodel
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Flask
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: flask>=3.0
|
|
17
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# flask-fenrir
|
|
21
|
+
|
|
22
|
+
Drop-in Flask API for LLM agent access to SQLModel web apps. One install, two lines of code, one env var.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv add flask-fenrir
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from flask_fenrir import create_fenrir_bp
|
|
34
|
+
|
|
35
|
+
app.register_blueprint(create_fenrir_bp(engine))
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from flask_fenrir import secure_app
|
|
40
|
+
|
|
41
|
+
secure_app(app) # optional — adds basic auth to the whole app
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
dokku config:set myapp FENRIR_API_KEY=$(openssl rand -hex 32)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That's it. One env var for everything — bearer token for LLM agents, basic auth password for browser access.
|
|
49
|
+
|
|
50
|
+
## What it does
|
|
51
|
+
|
|
52
|
+
Registers a `/fenrir/` blueprint with four endpoints, all behind bearer token auth:
|
|
53
|
+
|
|
54
|
+
| Endpoint | Method | Description |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `/fenrir/` | GET | App info, FENRIR.md contents, table list with row counts |
|
|
57
|
+
| `/fenrir/schema` | GET | Full schema introspection (columns, types, PKs, FKs, indexes) |
|
|
58
|
+
| `/fenrir/query` | POST | Read-only SQL (`SELECT` / `WITH ... SELECT`), returns rows as JSON |
|
|
59
|
+
|
|
60
|
+
### Authentication
|
|
61
|
+
|
|
62
|
+
`FENRIR_API_KEY` is the single secret for your app.
|
|
63
|
+
|
|
64
|
+
**Fenrir endpoints** (`/fenrir/*`) require `Authorization: Bearer <key>`.
|
|
65
|
+
|
|
66
|
+
**`secure_app(app)`** (optional) adds basic auth to all other routes — any username, password must match `FENRIR_API_KEY`. This replaces Dokku's `http-auth` so everything is managed in one place.
|
|
67
|
+
|
|
68
|
+
Both are skipped when `FLASK_DEBUG` is on. If the env var isn't set, everything returns 401/503 (fail closed).
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
# Options:
|
|
72
|
+
secure_app(app) # basic auth on all routes
|
|
73
|
+
secure_app(app, skip_paths=["/health", "/webhook"]) # skip specific paths
|
|
74
|
+
secure_app(app, api_key_auth={ # also accept a separate API key
|
|
75
|
+
"header": "X-API-Key",
|
|
76
|
+
"secret": os.getenv("API_KEY"),
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### FENRIR.md
|
|
81
|
+
|
|
82
|
+
Write a `FENRIR.md` at your project root with domain context for the LLM:
|
|
83
|
+
|
|
84
|
+
```markdown
|
|
85
|
+
# My App
|
|
86
|
+
|
|
87
|
+
A brief description of what this app does.
|
|
88
|
+
|
|
89
|
+
## Domain concepts
|
|
90
|
+
|
|
91
|
+
- **Widget** — the core thing users create. Has a `status` field: draft, active, archived.
|
|
92
|
+
- **Team** — groups of users. The `owner_id` is always a user, not another team.
|
|
93
|
+
|
|
94
|
+
## Useful queries
|
|
95
|
+
|
|
96
|
+
- Active widgets by team: `SELECT t.name, COUNT(*) FROM widget w JOIN team t ON ...`
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The `/fenrir/` endpoint serves this content so the LLM can understand your app before querying it.
|
|
100
|
+
|
|
101
|
+
#### Generating FENRIR.md with an LLM
|
|
102
|
+
|
|
103
|
+
If the app is running locally with flask-fenrir enabled, you can have an LLM generate the file. Run this prompt from the app's project root:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
This app uses flask-fenrir — a small Flask blueprint that exposes the
|
|
107
|
+
database to LLM agents via REST (schema introspection, read/write SQL).
|
|
108
|
+
It serves a FENRIR.md file at GET /fenrir/ to give the LLM domain
|
|
109
|
+
context it can't infer from the schema alone. That's the file you're
|
|
110
|
+
writing now.
|
|
111
|
+
|
|
112
|
+
The API is at /fenrir/.
|
|
113
|
+
|
|
114
|
+
Read the codebase to understand the app's models, business logic, and
|
|
115
|
+
domain. Then hit GET /fenrir/schema and POST /fenrir/query to see what
|
|
116
|
+
the database actually looks like from Fenrir POV.
|
|
117
|
+
|
|
118
|
+
Compare what you learned from the code with what the schema and data
|
|
119
|
+
show. Write a FENRIR.md that bridges the gap — focus on things an LLM
|
|
120
|
+
querying the database blind would NOT be able to figure out from the
|
|
121
|
+
schema alone:
|
|
122
|
+
- What the app actually does (one-liner)
|
|
123
|
+
- What each table/field means in business terms
|
|
124
|
+
- Non-obvious values: enums, flags, status codes, soft deletes, fields
|
|
125
|
+
whose names are misleading
|
|
126
|
+
- How tables relate to each other (especially implicit relationships
|
|
127
|
+
not captured by foreign keys)
|
|
128
|
+
- Common useful queries for an operator
|
|
129
|
+
- Gotchas and edge cases
|
|
130
|
+
|
|
131
|
+
Don't repeat what's already obvious from column names and types.
|
|
132
|
+
An LLM can read a schema — FENRIR.md should add the context it can't
|
|
133
|
+
infer.
|
|
134
|
+
|
|
135
|
+
Ask me questions if anything in the code or data is unclear. Don't
|
|
136
|
+
guess at business logic — it's better to ask than to document something
|
|
137
|
+
wrong.
|
|
138
|
+
|
|
139
|
+
This file will need to be maintained alongside the codebase. Keep it concise
|
|
140
|
+
and structured so it's easy to update when models change. If a section
|
|
141
|
+
would go stale quickly, leave it out or note what to watch for.
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Row limit
|
|
145
|
+
|
|
146
|
+
By default, `/fenrir/query` caps results at 1000 rows. Override it:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
app.register_blueprint(create_fenrir_bp(engine, row_limit=500))
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Dependencies
|
|
153
|
+
|
|
154
|
+
Just Flask (≥3.0) and SQLAlchemy (≥2.0). Works with any Flask + SQLAlchemy app — SQLModel not required at runtime.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# flask-fenrir
|
|
2
|
+
|
|
3
|
+
Drop-in Flask API for LLM agent access to SQLModel web apps. One install, two lines of code, one env var.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv add flask-fenrir
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from flask_fenrir import create_fenrir_bp
|
|
15
|
+
|
|
16
|
+
app.register_blueprint(create_fenrir_bp(engine))
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from flask_fenrir import secure_app
|
|
21
|
+
|
|
22
|
+
secure_app(app) # optional — adds basic auth to the whole app
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
dokku config:set myapp FENRIR_API_KEY=$(openssl rand -hex 32)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
That's it. One env var for everything — bearer token for LLM agents, basic auth password for browser access.
|
|
30
|
+
|
|
31
|
+
## What it does
|
|
32
|
+
|
|
33
|
+
Registers a `/fenrir/` blueprint with four endpoints, all behind bearer token auth:
|
|
34
|
+
|
|
35
|
+
| Endpoint | Method | Description |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `/fenrir/` | GET | App info, FENRIR.md contents, table list with row counts |
|
|
38
|
+
| `/fenrir/schema` | GET | Full schema introspection (columns, types, PKs, FKs, indexes) |
|
|
39
|
+
| `/fenrir/query` | POST | Read-only SQL (`SELECT` / `WITH ... SELECT`), returns rows as JSON |
|
|
40
|
+
|
|
41
|
+
### Authentication
|
|
42
|
+
|
|
43
|
+
`FENRIR_API_KEY` is the single secret for your app.
|
|
44
|
+
|
|
45
|
+
**Fenrir endpoints** (`/fenrir/*`) require `Authorization: Bearer <key>`.
|
|
46
|
+
|
|
47
|
+
**`secure_app(app)`** (optional) adds basic auth to all other routes — any username, password must match `FENRIR_API_KEY`. This replaces Dokku's `http-auth` so everything is managed in one place.
|
|
48
|
+
|
|
49
|
+
Both are skipped when `FLASK_DEBUG` is on. If the env var isn't set, everything returns 401/503 (fail closed).
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
# Options:
|
|
53
|
+
secure_app(app) # basic auth on all routes
|
|
54
|
+
secure_app(app, skip_paths=["/health", "/webhook"]) # skip specific paths
|
|
55
|
+
secure_app(app, api_key_auth={ # also accept a separate API key
|
|
56
|
+
"header": "X-API-Key",
|
|
57
|
+
"secret": os.getenv("API_KEY"),
|
|
58
|
+
})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### FENRIR.md
|
|
62
|
+
|
|
63
|
+
Write a `FENRIR.md` at your project root with domain context for the LLM:
|
|
64
|
+
|
|
65
|
+
```markdown
|
|
66
|
+
# My App
|
|
67
|
+
|
|
68
|
+
A brief description of what this app does.
|
|
69
|
+
|
|
70
|
+
## Domain concepts
|
|
71
|
+
|
|
72
|
+
- **Widget** — the core thing users create. Has a `status` field: draft, active, archived.
|
|
73
|
+
- **Team** — groups of users. The `owner_id` is always a user, not another team.
|
|
74
|
+
|
|
75
|
+
## Useful queries
|
|
76
|
+
|
|
77
|
+
- Active widgets by team: `SELECT t.name, COUNT(*) FROM widget w JOIN team t ON ...`
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The `/fenrir/` endpoint serves this content so the LLM can understand your app before querying it.
|
|
81
|
+
|
|
82
|
+
#### Generating FENRIR.md with an LLM
|
|
83
|
+
|
|
84
|
+
If the app is running locally with flask-fenrir enabled, you can have an LLM generate the file. Run this prompt from the app's project root:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
This app uses flask-fenrir — a small Flask blueprint that exposes the
|
|
88
|
+
database to LLM agents via REST (schema introspection, read/write SQL).
|
|
89
|
+
It serves a FENRIR.md file at GET /fenrir/ to give the LLM domain
|
|
90
|
+
context it can't infer from the schema alone. That's the file you're
|
|
91
|
+
writing now.
|
|
92
|
+
|
|
93
|
+
The API is at /fenrir/.
|
|
94
|
+
|
|
95
|
+
Read the codebase to understand the app's models, business logic, and
|
|
96
|
+
domain. Then hit GET /fenrir/schema and POST /fenrir/query to see what
|
|
97
|
+
the database actually looks like from Fenrir POV.
|
|
98
|
+
|
|
99
|
+
Compare what you learned from the code with what the schema and data
|
|
100
|
+
show. Write a FENRIR.md that bridges the gap — focus on things an LLM
|
|
101
|
+
querying the database blind would NOT be able to figure out from the
|
|
102
|
+
schema alone:
|
|
103
|
+
- What the app actually does (one-liner)
|
|
104
|
+
- What each table/field means in business terms
|
|
105
|
+
- Non-obvious values: enums, flags, status codes, soft deletes, fields
|
|
106
|
+
whose names are misleading
|
|
107
|
+
- How tables relate to each other (especially implicit relationships
|
|
108
|
+
not captured by foreign keys)
|
|
109
|
+
- Common useful queries for an operator
|
|
110
|
+
- Gotchas and edge cases
|
|
111
|
+
|
|
112
|
+
Don't repeat what's already obvious from column names and types.
|
|
113
|
+
An LLM can read a schema — FENRIR.md should add the context it can't
|
|
114
|
+
infer.
|
|
115
|
+
|
|
116
|
+
Ask me questions if anything in the code or data is unclear. Don't
|
|
117
|
+
guess at business logic — it's better to ask than to document something
|
|
118
|
+
wrong.
|
|
119
|
+
|
|
120
|
+
This file will need to be maintained alongside the codebase. Keep it concise
|
|
121
|
+
and structured so it's easy to update when models change. If a section
|
|
122
|
+
would go stale quickly, leave it out or note what to watch for.
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Row limit
|
|
126
|
+
|
|
127
|
+
By default, `/fenrir/query` caps results at 1000 rows. Override it:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
app.register_blueprint(create_fenrir_bp(engine, row_limit=500))
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Dependencies
|
|
134
|
+
|
|
135
|
+
Just Flask (≥3.0) and SQLAlchemy (≥2.0). Works with any Flask + SQLAlchemy app — SQLModel not required at runtime.
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fenrir-api — Drop-in Flask API for LLM agent access to SQLModel web apps.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from flask_fenrir import create_fenrir_bp, secure_app
|
|
6
|
+
|
|
7
|
+
app.register_blueprint(create_fenrir_bp(engine))
|
|
8
|
+
secure_app(app)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from functools import wraps
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from flask import Blueprint, Flask, Response, current_app, jsonify, request
|
|
19
|
+
from sqlalchemy import inspect, text
|
|
20
|
+
from sqlalchemy.engine import Engine
|
|
21
|
+
|
|
22
|
+
__all__ = ["create_fenrir_bp", "secure_app"]
|
|
23
|
+
|
|
24
|
+
DEFAULT_ROW_LIMIT = 1000
|
|
25
|
+
|
|
26
|
+
# Matches SELECT or WITH ... SELECT (the only read-only shapes we allow)
|
|
27
|
+
_READ_ONLY_RE = re.compile(
|
|
28
|
+
r"^\s*(SELECT|WITH\s)", re.IGNORECASE | re.DOTALL
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _require_auth(f):
|
|
33
|
+
"""Reject requests unless Authorization header matches FENRIR_API_KEY.
|
|
34
|
+
|
|
35
|
+
Auth is skipped entirely when the Flask app has DEBUG enabled.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
@wraps(f)
|
|
39
|
+
def wrapper(*args, **kwargs):
|
|
40
|
+
if current_app.debug:
|
|
41
|
+
return f(*args, **kwargs)
|
|
42
|
+
|
|
43
|
+
api_key = os.environ.get("FENRIR_API_KEY")
|
|
44
|
+
if not api_key:
|
|
45
|
+
return jsonify({"error": "FENRIR_API_KEY not configured"}), 401
|
|
46
|
+
|
|
47
|
+
auth = request.headers.get("Authorization", "")
|
|
48
|
+
if not auth.startswith("Bearer ") or auth[7:] != api_key:
|
|
49
|
+
return jsonify({"error": "unauthorized"}), 401
|
|
50
|
+
|
|
51
|
+
return f(*args, **kwargs)
|
|
52
|
+
|
|
53
|
+
return wrapper
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def secure_app(
|
|
57
|
+
app: Flask,
|
|
58
|
+
*,
|
|
59
|
+
skip_paths: list[str] | None = None,
|
|
60
|
+
api_key_auth: dict[str, str | None] | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Add basic auth to all routes except /fenrir/ and static files.
|
|
63
|
+
|
|
64
|
+
Uses FENRIR_API_KEY as the password (any username accepted).
|
|
65
|
+
Skipped entirely when app.debug is True.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
app: Flask application.
|
|
69
|
+
skip_paths: Additional path prefixes to skip auth for (e.g. ["/health"]).
|
|
70
|
+
api_key_auth: Optional dict with "header" and "secret" keys for API key
|
|
71
|
+
auth (e.g. {"header": "X-API-Key", "secret": os.getenv("API_KEY")}).
|
|
72
|
+
Allows programmatic access (MCP, etc.) with a separate secret.
|
|
73
|
+
"""
|
|
74
|
+
_skip = ["/fenrir/", "/static/"]
|
|
75
|
+
if skip_paths:
|
|
76
|
+
_skip.extend(skip_paths)
|
|
77
|
+
|
|
78
|
+
@app.before_request
|
|
79
|
+
def _basic_auth_check():
|
|
80
|
+
if app.debug:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# Skip excluded paths
|
|
84
|
+
path = request.path
|
|
85
|
+
if path == "/" or any(path.startswith(p) for p in _skip):
|
|
86
|
+
return
|
|
87
|
+
if request.endpoint == "static":
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
api_key = os.environ.get("FENRIR_API_KEY")
|
|
91
|
+
if not api_key:
|
|
92
|
+
return Response("Not configured", 503)
|
|
93
|
+
|
|
94
|
+
# Check API key header if configured (for MCP / programmatic access)
|
|
95
|
+
if api_key_auth and api_key_auth.get("secret"):
|
|
96
|
+
header_val = request.headers.get(api_key_auth.get("header", ""))
|
|
97
|
+
if header_val and header_val == api_key_auth["secret"]:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# Check basic auth — any username, password must match FENRIR_API_KEY
|
|
101
|
+
auth = request.authorization
|
|
102
|
+
if auth and auth.password == api_key:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
return Response(
|
|
106
|
+
"Authentication required",
|
|
107
|
+
401,
|
|
108
|
+
{"WWW-Authenticate": 'Basic realm="Login"'},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _read_fenrir_md() -> str | None:
|
|
113
|
+
"""Read FENRIR.md from the app's root directory."""
|
|
114
|
+
root = Path(current_app.root_path)
|
|
115
|
+
# Walk up at most two levels — root_path is often the package dir,
|
|
116
|
+
# FENRIR.md lives at the project root (next to pyproject.toml).
|
|
117
|
+
for base in [root, root.parent, root.parent.parent]:
|
|
118
|
+
candidate = base / "FENRIR.md"
|
|
119
|
+
if candidate.is_file():
|
|
120
|
+
return candidate.read_text(encoding="utf-8")
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _extract_app_name(md: str | None, app_name: str) -> str:
|
|
125
|
+
"""Pull the first heading from FENRIR.md, or fall back to Flask app name."""
|
|
126
|
+
if md:
|
|
127
|
+
for line in md.splitlines():
|
|
128
|
+
stripped = line.strip()
|
|
129
|
+
if stripped.startswith("#"):
|
|
130
|
+
return stripped.lstrip("#").strip()
|
|
131
|
+
return app_name
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def create_fenrir_bp(engine: Engine, *, row_limit: int = DEFAULT_ROW_LIMIT) -> Blueprint:
|
|
135
|
+
"""Create and return the Fenrir API blueprint.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
engine: SQLAlchemy engine to introspect and query.
|
|
139
|
+
row_limit: Max rows returned by /fenrir/query (default 1000).
|
|
140
|
+
"""
|
|
141
|
+
bp = Blueprint("fenrir", __name__, url_prefix="/fenrir")
|
|
142
|
+
|
|
143
|
+
# -- GET /fenrir/ ----------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
@bp.route("/")
|
|
146
|
+
@_require_auth
|
|
147
|
+
def index():
|
|
148
|
+
md = _read_fenrir_md()
|
|
149
|
+
app_name = _extract_app_name(md, current_app.name)
|
|
150
|
+
|
|
151
|
+
# Table list with row counts
|
|
152
|
+
insp = inspect(engine)
|
|
153
|
+
tables = []
|
|
154
|
+
with engine.connect() as conn:
|
|
155
|
+
for table_name in sorted(insp.get_table_names()):
|
|
156
|
+
count = conn.execute(
|
|
157
|
+
text(f'SELECT COUNT(*) FROM "{table_name}"')
|
|
158
|
+
).scalar()
|
|
159
|
+
tables.append({"name": table_name, "row_count": count})
|
|
160
|
+
|
|
161
|
+
return jsonify({
|
|
162
|
+
"app": app_name,
|
|
163
|
+
"fenrir_md": md,
|
|
164
|
+
"tables": tables,
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
# -- GET /fenrir/schema ----------------------------------------------------
|
|
168
|
+
|
|
169
|
+
@bp.route("/schema")
|
|
170
|
+
@_require_auth
|
|
171
|
+
def schema():
|
|
172
|
+
insp = inspect(engine)
|
|
173
|
+
tables = {}
|
|
174
|
+
|
|
175
|
+
for table_name in sorted(insp.get_table_names()):
|
|
176
|
+
columns = []
|
|
177
|
+
for col in insp.get_columns(table_name):
|
|
178
|
+
columns.append({
|
|
179
|
+
"name": col["name"],
|
|
180
|
+
"type": str(col["type"]),
|
|
181
|
+
"nullable": col.get("nullable", True),
|
|
182
|
+
"default": str(col["default"]) if col.get("default") is not None else None,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
pk = insp.get_pk_constraint(table_name)
|
|
186
|
+
fks = [
|
|
187
|
+
{
|
|
188
|
+
"columns": fk["constrained_columns"],
|
|
189
|
+
"referred_table": fk["referred_table"],
|
|
190
|
+
"referred_columns": fk["referred_columns"],
|
|
191
|
+
}
|
|
192
|
+
for fk in insp.get_foreign_keys(table_name)
|
|
193
|
+
]
|
|
194
|
+
indexes = [
|
|
195
|
+
{
|
|
196
|
+
"name": idx["name"],
|
|
197
|
+
"columns": idx["column_names"],
|
|
198
|
+
"unique": idx["unique"],
|
|
199
|
+
}
|
|
200
|
+
for idx in insp.get_indexes(table_name)
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
tables[table_name] = {
|
|
204
|
+
"columns": columns,
|
|
205
|
+
"primary_key": pk.get("constrained_columns", []) if pk else [],
|
|
206
|
+
"foreign_keys": fks,
|
|
207
|
+
"indexes": indexes,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return jsonify({"tables": tables})
|
|
211
|
+
|
|
212
|
+
# -- POST /fenrir/query ----------------------------------------------------
|
|
213
|
+
|
|
214
|
+
@bp.route("/query", methods=["POST"])
|
|
215
|
+
@_require_auth
|
|
216
|
+
def query():
|
|
217
|
+
body = request.get_json(silent=True) or {}
|
|
218
|
+
sql = body.get("sql", "").strip()
|
|
219
|
+
|
|
220
|
+
if not sql:
|
|
221
|
+
return jsonify({"error": "missing 'sql' field"}), 400
|
|
222
|
+
|
|
223
|
+
if not _READ_ONLY_RE.match(sql):
|
|
224
|
+
return jsonify({"error": "only SELECT (or WITH ... SELECT) allowed"}), 400
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
with engine.connect().execution_options(
|
|
228
|
+
postgresql_readonly=True, # PostgreSQL
|
|
229
|
+
sqlite_raw_colnames=True, # harmless on SQLite
|
|
230
|
+
) as conn:
|
|
231
|
+
conn.begin()
|
|
232
|
+
# SET TRANSACTION READ ONLY works on PostgreSQL.
|
|
233
|
+
# SQLite doesn't support it, so we skip errors silently.
|
|
234
|
+
try:
|
|
235
|
+
conn.execute(text("SET TRANSACTION READ ONLY"))
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
result = conn.execute(text(sql))
|
|
239
|
+
columns = list(result.keys())
|
|
240
|
+
rows = [list(r) for r in result.fetchmany(row_limit)]
|
|
241
|
+
total = len(rows)
|
|
242
|
+
# Check if there were more rows we didn't fetch
|
|
243
|
+
extra = result.fetchone()
|
|
244
|
+
truncated = extra is not None
|
|
245
|
+
conn.rollback()
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
return jsonify({"error": str(exc)}), 422
|
|
248
|
+
|
|
249
|
+
return jsonify({
|
|
250
|
+
"columns": columns,
|
|
251
|
+
"rows": rows,
|
|
252
|
+
"row_count": total,
|
|
253
|
+
"truncated": truncated,
|
|
254
|
+
"row_limit": row_limit,
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
return bp
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "flask-fenrir"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Drop-in Flask API for LLM agent access to SQLModel web apps"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
authors = [{name = "Fenrir"}]
|
|
9
|
+
keywords = ["flask", "llm", "api", "sqlmodel", "sqlalchemy"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Framework :: Flask",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"flask>=3.0",
|
|
19
|
+
"sqlalchemy>=2.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Repository = "https://github.com/fenrir/fenrir-api"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
only-include = ["flask_fenrir.py"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8.0",
|
|
38
|
+
"sqlmodel>=0.0.22",
|
|
39
|
+
]
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""Tests for fenrir-api using a minimal Flask + SQLModel app with SQLite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from flask import Flask
|
|
12
|
+
from sqlalchemy import create_engine
|
|
13
|
+
from sqlmodel import Field, Session, SQLModel
|
|
14
|
+
|
|
15
|
+
from flask_fenrir import create_fenrir_bp, secure_app
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Models
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Author(SQLModel, table=True):
|
|
23
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
24
|
+
name: str
|
|
25
|
+
email: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Book(SQLModel, table=True):
|
|
29
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
30
|
+
title: str
|
|
31
|
+
author_id: int = Field(foreign_key="author.id")
|
|
32
|
+
pages: int | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Fixtures
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
API_KEY = "test-secret-key"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture()
|
|
43
|
+
def app(tmp_path: Path):
|
|
44
|
+
"""Create a minimal Flask app with an in-memory SQLite DB."""
|
|
45
|
+
# Write a FENRIR.md next to the app
|
|
46
|
+
fenrir_md = tmp_path / "FENRIR.md"
|
|
47
|
+
fenrir_md.write_text("# Bookstore\n\nA tiny bookstore app.\n")
|
|
48
|
+
|
|
49
|
+
engine = create_engine("sqlite://", echo=False)
|
|
50
|
+
SQLModel.metadata.create_all(engine)
|
|
51
|
+
|
|
52
|
+
# Seed data
|
|
53
|
+
with Session(engine) as session:
|
|
54
|
+
a = Author(name="Tolkien", email="jrr@shire.nz")
|
|
55
|
+
session.add(a)
|
|
56
|
+
session.commit()
|
|
57
|
+
session.refresh(a)
|
|
58
|
+
session.add(Book(title="The Hobbit", author_id=a.id, pages=310))
|
|
59
|
+
session.commit()
|
|
60
|
+
|
|
61
|
+
app = Flask(__name__, root_path=str(tmp_path))
|
|
62
|
+
app.config["TESTING"] = True
|
|
63
|
+
app.register_blueprint(create_fenrir_bp(engine))
|
|
64
|
+
|
|
65
|
+
os.environ["FENRIR_API_KEY"] = API_KEY
|
|
66
|
+
yield app
|
|
67
|
+
os.environ.pop("FENRIR_API_KEY", None)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.fixture()
|
|
71
|
+
def client(app):
|
|
72
|
+
return app.test_client()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def auth_headers():
|
|
76
|
+
return {"Authorization": f"Bearer {API_KEY}"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Auth tests
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TestAuth:
|
|
85
|
+
def test_missing_header(self, client):
|
|
86
|
+
r = client.get("/fenrir/")
|
|
87
|
+
assert r.status_code == 401
|
|
88
|
+
|
|
89
|
+
def test_wrong_key(self, client):
|
|
90
|
+
r = client.get("/fenrir/", headers={"Authorization": "Bearer wrong"})
|
|
91
|
+
assert r.status_code == 401
|
|
92
|
+
|
|
93
|
+
def test_correct_key(self, client):
|
|
94
|
+
r = client.get("/fenrir/", headers=auth_headers())
|
|
95
|
+
assert r.status_code == 200
|
|
96
|
+
|
|
97
|
+
def test_no_env_var(self, client):
|
|
98
|
+
os.environ.pop("FENRIR_API_KEY", None)
|
|
99
|
+
r = client.get("/fenrir/", headers={"Authorization": "Bearer anything"})
|
|
100
|
+
assert r.status_code == 401
|
|
101
|
+
|
|
102
|
+
def test_debug_mode_skips_auth(self, app):
|
|
103
|
+
app.debug = True
|
|
104
|
+
with app.test_client() as c:
|
|
105
|
+
r = c.get("/fenrir/")
|
|
106
|
+
assert r.status_code == 200
|
|
107
|
+
|
|
108
|
+
def test_debug_mode_skips_auth_even_without_env_var(self, app):
|
|
109
|
+
os.environ.pop("FENRIR_API_KEY", None)
|
|
110
|
+
app.debug = True
|
|
111
|
+
with app.test_client() as c:
|
|
112
|
+
r = c.get("/fenrir/")
|
|
113
|
+
assert r.status_code == 200
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# GET /fenrir/
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TestIndex:
|
|
122
|
+
def test_returns_app_info(self, client):
|
|
123
|
+
r = client.get("/fenrir/", headers=auth_headers())
|
|
124
|
+
data = r.get_json()
|
|
125
|
+
assert data["app"] == "Bookstore"
|
|
126
|
+
assert "Bookstore" in data["fenrir_md"]
|
|
127
|
+
assert any(t["name"] == "author" for t in data["tables"])
|
|
128
|
+
assert any(t["name"] == "book" for t in data["tables"])
|
|
129
|
+
|
|
130
|
+
def test_row_counts(self, client):
|
|
131
|
+
r = client.get("/fenrir/", headers=auth_headers())
|
|
132
|
+
data = r.get_json()
|
|
133
|
+
authors = next(t for t in data["tables"] if t["name"] == "author")
|
|
134
|
+
books = next(t for t in data["tables"] if t["name"] == "book")
|
|
135
|
+
assert authors["row_count"] == 1
|
|
136
|
+
assert books["row_count"] == 1
|
|
137
|
+
|
|
138
|
+
def test_no_fenrir_md(self, tmp_path):
|
|
139
|
+
"""When FENRIR.md doesn't exist, fenrir_md should be null."""
|
|
140
|
+
empty_dir = tmp_path / "empty"
|
|
141
|
+
empty_dir.mkdir()
|
|
142
|
+
engine = create_engine("sqlite://")
|
|
143
|
+
SQLModel.metadata.create_all(engine)
|
|
144
|
+
|
|
145
|
+
app = Flask(__name__, root_path=str(empty_dir))
|
|
146
|
+
app.config["TESTING"] = True
|
|
147
|
+
app.register_blueprint(create_fenrir_bp(engine))
|
|
148
|
+
|
|
149
|
+
os.environ["FENRIR_API_KEY"] = API_KEY
|
|
150
|
+
with app.test_client() as c:
|
|
151
|
+
r = c.get("/fenrir/", headers=auth_headers())
|
|
152
|
+
data = r.get_json()
|
|
153
|
+
assert data["fenrir_md"] is None
|
|
154
|
+
# Falls back to Flask app name
|
|
155
|
+
assert data["app"] is not None
|
|
156
|
+
os.environ.pop("FENRIR_API_KEY", None)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# GET /fenrir/schema
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TestSchema:
|
|
165
|
+
def test_returns_tables(self, client):
|
|
166
|
+
r = client.get("/fenrir/schema", headers=auth_headers())
|
|
167
|
+
data = r.get_json()
|
|
168
|
+
assert "author" in data["tables"]
|
|
169
|
+
assert "book" in data["tables"]
|
|
170
|
+
|
|
171
|
+
def test_column_info(self, client):
|
|
172
|
+
r = client.get("/fenrir/schema", headers=auth_headers())
|
|
173
|
+
author = r.get_json()["tables"]["author"]
|
|
174
|
+
col_names = [c["name"] for c in author["columns"]]
|
|
175
|
+
assert "id" in col_names
|
|
176
|
+
assert "name" in col_names
|
|
177
|
+
assert "email" in col_names
|
|
178
|
+
|
|
179
|
+
def test_primary_key(self, client):
|
|
180
|
+
r = client.get("/fenrir/schema", headers=auth_headers())
|
|
181
|
+
author = r.get_json()["tables"]["author"]
|
|
182
|
+
assert "id" in author["primary_key"]
|
|
183
|
+
|
|
184
|
+
def test_foreign_keys(self, client):
|
|
185
|
+
r = client.get("/fenrir/schema", headers=auth_headers())
|
|
186
|
+
book = r.get_json()["tables"]["book"]
|
|
187
|
+
assert len(book["foreign_keys"]) == 1
|
|
188
|
+
fk = book["foreign_keys"][0]
|
|
189
|
+
assert fk["referred_table"] == "author"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# POST /fenrir/query
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class TestQuery:
|
|
198
|
+
def test_select(self, client):
|
|
199
|
+
r = client.post(
|
|
200
|
+
"/fenrir/query",
|
|
201
|
+
json={"sql": "SELECT name FROM author"},
|
|
202
|
+
headers=auth_headers(),
|
|
203
|
+
)
|
|
204
|
+
data = r.get_json()
|
|
205
|
+
assert data["columns"] == ["name"]
|
|
206
|
+
assert data["rows"] == [["Tolkien"]]
|
|
207
|
+
assert data["row_count"] == 1
|
|
208
|
+
assert data["truncated"] is False
|
|
209
|
+
|
|
210
|
+
def test_with_select(self, client):
|
|
211
|
+
r = client.post(
|
|
212
|
+
"/fenrir/query",
|
|
213
|
+
json={"sql": "WITH a AS (SELECT * FROM author) SELECT name FROM a"},
|
|
214
|
+
headers=auth_headers(),
|
|
215
|
+
)
|
|
216
|
+
assert r.status_code == 200
|
|
217
|
+
assert r.get_json()["row_count"] == 1
|
|
218
|
+
|
|
219
|
+
def test_rejects_insert(self, client):
|
|
220
|
+
r = client.post(
|
|
221
|
+
"/fenrir/query",
|
|
222
|
+
json={"sql": "INSERT INTO author (name) VALUES ('nope')"},
|
|
223
|
+
headers=auth_headers(),
|
|
224
|
+
)
|
|
225
|
+
assert r.status_code == 400
|
|
226
|
+
|
|
227
|
+
def test_rejects_delete(self, client):
|
|
228
|
+
r = client.post(
|
|
229
|
+
"/fenrir/query",
|
|
230
|
+
json={"sql": "DELETE FROM author"},
|
|
231
|
+
headers=auth_headers(),
|
|
232
|
+
)
|
|
233
|
+
assert r.status_code == 400
|
|
234
|
+
|
|
235
|
+
def test_missing_sql(self, client):
|
|
236
|
+
r = client.post("/fenrir/query", json={}, headers=auth_headers())
|
|
237
|
+
assert r.status_code == 400
|
|
238
|
+
|
|
239
|
+
def test_row_limit(self, tmp_path):
|
|
240
|
+
"""Row limit caps the number of returned rows."""
|
|
241
|
+
engine = create_engine("sqlite://")
|
|
242
|
+
SQLModel.metadata.create_all(engine)
|
|
243
|
+
|
|
244
|
+
with Session(engine) as session:
|
|
245
|
+
for i in range(10):
|
|
246
|
+
session.add(Author(name=f"Author {i}"))
|
|
247
|
+
session.commit()
|
|
248
|
+
|
|
249
|
+
app = Flask(__name__, root_path=str(tmp_path))
|
|
250
|
+
app.config["TESTING"] = True
|
|
251
|
+
app.register_blueprint(create_fenrir_bp(engine, row_limit=3))
|
|
252
|
+
|
|
253
|
+
os.environ["FENRIR_API_KEY"] = API_KEY
|
|
254
|
+
with app.test_client() as c:
|
|
255
|
+
r = c.post(
|
|
256
|
+
"/fenrir/query",
|
|
257
|
+
json={"sql": "SELECT * FROM author"},
|
|
258
|
+
headers=auth_headers(),
|
|
259
|
+
)
|
|
260
|
+
data = r.get_json()
|
|
261
|
+
assert data["row_count"] == 3
|
|
262
|
+
assert data["truncated"] is True
|
|
263
|
+
assert data["row_limit"] == 3
|
|
264
|
+
os.environ.pop("FENRIR_API_KEY", None)
|
|
265
|
+
|
|
266
|
+
def test_bad_sql(self, client):
|
|
267
|
+
r = client.post(
|
|
268
|
+
"/fenrir/query",
|
|
269
|
+
json={"sql": "SELECT * FROM nonexistent"},
|
|
270
|
+
headers=auth_headers(),
|
|
271
|
+
)
|
|
272
|
+
assert r.status_code == 422
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# secure_app
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class TestSecureApp:
|
|
281
|
+
"""Tests for the secure_app() basic auth middleware."""
|
|
282
|
+
|
|
283
|
+
@pytest.fixture()
|
|
284
|
+
def secured_app(self, tmp_path):
|
|
285
|
+
engine = create_engine("sqlite://")
|
|
286
|
+
SQLModel.metadata.create_all(engine)
|
|
287
|
+
|
|
288
|
+
app = Flask(__name__, root_path=str(tmp_path))
|
|
289
|
+
app.config["TESTING"] = True
|
|
290
|
+
app.register_blueprint(create_fenrir_bp(engine))
|
|
291
|
+
secure_app(app)
|
|
292
|
+
|
|
293
|
+
@app.route("/dashboard")
|
|
294
|
+
def dashboard():
|
|
295
|
+
return "ok"
|
|
296
|
+
|
|
297
|
+
@app.route("/api/data")
|
|
298
|
+
def api_data():
|
|
299
|
+
return "ok"
|
|
300
|
+
|
|
301
|
+
os.environ["FENRIR_API_KEY"] = API_KEY
|
|
302
|
+
yield app
|
|
303
|
+
os.environ.pop("FENRIR_API_KEY", None)
|
|
304
|
+
|
|
305
|
+
@pytest.fixture()
|
|
306
|
+
def secured_client(self, secured_app):
|
|
307
|
+
return secured_app.test_client()
|
|
308
|
+
|
|
309
|
+
def _basic_auth_headers(self, password=API_KEY, username="anything"):
|
|
310
|
+
import base64
|
|
311
|
+
creds = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
312
|
+
return {"Authorization": f"Basic {creds}"}
|
|
313
|
+
|
|
314
|
+
# -- Basic auth on app routes --
|
|
315
|
+
|
|
316
|
+
def test_app_route_blocked_without_auth(self, secured_client):
|
|
317
|
+
r = secured_client.get("/dashboard")
|
|
318
|
+
assert r.status_code == 401
|
|
319
|
+
assert "WWW-Authenticate" in r.headers
|
|
320
|
+
|
|
321
|
+
def test_app_route_allowed_with_basic_auth(self, secured_client):
|
|
322
|
+
r = secured_client.get("/dashboard", headers=self._basic_auth_headers())
|
|
323
|
+
assert r.status_code == 200
|
|
324
|
+
|
|
325
|
+
def test_app_route_wrong_password(self, secured_client):
|
|
326
|
+
r = secured_client.get("/dashboard", headers=self._basic_auth_headers(password="wrong"))
|
|
327
|
+
assert r.status_code == 401
|
|
328
|
+
|
|
329
|
+
def test_any_username_accepted(self, secured_client):
|
|
330
|
+
r = secured_client.get("/dashboard", headers=self._basic_auth_headers(username="bob"))
|
|
331
|
+
assert r.status_code == 200
|
|
332
|
+
|
|
333
|
+
# -- Fenrir paths skip basic auth (use their own bearer auth) --
|
|
334
|
+
|
|
335
|
+
def test_fenrir_not_blocked_by_basic_auth(self, secured_client):
|
|
336
|
+
"""Fenrir endpoints should not get a basic auth challenge — they use bearer."""
|
|
337
|
+
r = secured_client.get("/fenrir/", headers=auth_headers())
|
|
338
|
+
assert r.status_code == 200
|
|
339
|
+
|
|
340
|
+
def test_fenrir_without_any_auth_gets_bearer_401(self, secured_client):
|
|
341
|
+
"""Fenrir without bearer token should return JSON 401, not basic auth challenge."""
|
|
342
|
+
r = secured_client.get("/fenrir/")
|
|
343
|
+
assert r.status_code == 401
|
|
344
|
+
data = r.get_json()
|
|
345
|
+
assert data is not None # JSON response, not basic auth HTML
|
|
346
|
+
|
|
347
|
+
# -- Debug mode --
|
|
348
|
+
|
|
349
|
+
def test_debug_skips_basic_auth(self, secured_app):
|
|
350
|
+
secured_app.debug = True
|
|
351
|
+
with secured_app.test_client() as c:
|
|
352
|
+
r = c.get("/dashboard")
|
|
353
|
+
assert r.status_code == 200
|
|
354
|
+
|
|
355
|
+
# -- No env var --
|
|
356
|
+
|
|
357
|
+
def test_no_env_var_returns_503(self, secured_client):
|
|
358
|
+
os.environ.pop("FENRIR_API_KEY", None)
|
|
359
|
+
r = secured_client.get("/dashboard")
|
|
360
|
+
assert r.status_code == 503
|
|
361
|
+
|
|
362
|
+
# -- skip_paths --
|
|
363
|
+
|
|
364
|
+
def test_skip_paths(self, tmp_path):
|
|
365
|
+
engine = create_engine("sqlite://")
|
|
366
|
+
SQLModel.metadata.create_all(engine)
|
|
367
|
+
|
|
368
|
+
app = Flask(__name__, root_path=str(tmp_path))
|
|
369
|
+
app.config["TESTING"] = True
|
|
370
|
+
app.register_blueprint(create_fenrir_bp(engine))
|
|
371
|
+
secure_app(app, skip_paths=["/health"])
|
|
372
|
+
|
|
373
|
+
@app.route("/health")
|
|
374
|
+
def health():
|
|
375
|
+
return "ok"
|
|
376
|
+
|
|
377
|
+
os.environ["FENRIR_API_KEY"] = API_KEY
|
|
378
|
+
with app.test_client() as c:
|
|
379
|
+
r = c.get("/health")
|
|
380
|
+
assert r.status_code == 200
|
|
381
|
+
os.environ.pop("FENRIR_API_KEY", None)
|
|
382
|
+
|
|
383
|
+
# -- api_key_auth --
|
|
384
|
+
|
|
385
|
+
def test_api_key_auth(self, tmp_path):
|
|
386
|
+
engine = create_engine("sqlite://")
|
|
387
|
+
SQLModel.metadata.create_all(engine)
|
|
388
|
+
|
|
389
|
+
MCP_SECRET = "mcp-secret-123"
|
|
390
|
+
|
|
391
|
+
app = Flask(__name__, root_path=str(tmp_path))
|
|
392
|
+
app.config["TESTING"] = True
|
|
393
|
+
app.register_blueprint(create_fenrir_bp(engine))
|
|
394
|
+
secure_app(app, api_key_auth={"header": "X-API-Key", "secret": MCP_SECRET})
|
|
395
|
+
|
|
396
|
+
@app.route("/api/stuff")
|
|
397
|
+
def stuff():
|
|
398
|
+
return "ok"
|
|
399
|
+
|
|
400
|
+
os.environ["FENRIR_API_KEY"] = API_KEY
|
|
401
|
+
with app.test_client() as c:
|
|
402
|
+
# Basic auth still works
|
|
403
|
+
r = c.get("/api/stuff", headers=self._basic_auth_headers())
|
|
404
|
+
assert r.status_code == 200
|
|
405
|
+
# API key header works with its own secret
|
|
406
|
+
r = c.get("/api/stuff", headers={"X-API-Key": MCP_SECRET})
|
|
407
|
+
assert r.status_code == 200
|
|
408
|
+
# FENRIR_API_KEY doesn't work as API key header
|
|
409
|
+
r = c.get("/api/stuff", headers={"X-API-Key": API_KEY})
|
|
410
|
+
assert r.status_code == 401
|
|
411
|
+
# Wrong secret rejected
|
|
412
|
+
r = c.get("/api/stuff", headers={"X-API-Key": "wrong"})
|
|
413
|
+
assert r.status_code == 401
|
|
414
|
+
os.environ.pop("FENRIR_API_KEY", None)
|
|
415
|
+
|