adapt-server 0.2.0__tar.gz → 0.2.3__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.
- {adapt_server-0.2.0/adapt_server.egg-info → adapt_server-0.2.3}/PKG-INFO +1 -1
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/__init__.py +1 -1
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/api_keys.py +15 -33
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/locks.py +5 -5
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/resources.py +5 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/users.py +5 -7
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/api_keys.py +51 -1
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/app.py +209 -39
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/audit.py +1 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/auth/dependencies.py +3 -2
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/auth/routes.py +17 -47
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/check.py +2 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/list_endpoints.py +5 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/config.py +58 -47
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/discovery.py +1 -15
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/locks.py +1 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/base.py +1 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/dataset_plugin.py +21 -19
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/media_plugin.py +4 -6
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/routes.py +1 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/security.py +1 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/storage.py +1 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/base.html +8 -2
- adapt_server-0.2.3/adapt/templates/landing.html +93 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/utils/__init__.py +4 -10
- {adapt_server-0.2.0 → adapt_server-0.2.3/adapt_server.egg-info}/PKG-INFO +1 -1
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt_server.egg-info/SOURCES.txt +3 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/pyproject.toml +1 -1
- adapt_server-0.2.3/tests/test_cache.py +113 -0
- adapt_server-0.2.3/tests/test_cli_integration.py +43 -0
- adapt_server-0.2.3/tests/test_dependencies.py +214 -0
- adapt_server-0.2.3/tests/test_integration.py +527 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_phase3.py +8 -3
- adapt_server-0.2.3/tests/test_readonly.py +120 -0
- adapt_server-0.2.0/adapt/templates/landing.html +0 -56
- adapt_server-0.2.0/tests/test_integration.py +0 -273
- adapt_server-0.2.0/tests/test_readonly.py +0 -70
- {adapt_server-0.2.0 → adapt_server-0.2.3}/LICENSE +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/README.md +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/__main__.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/__init__.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/audit_logs.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/cache.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/groups.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/models.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/permissions.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/ui.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/auth/__init__.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/auth/password.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/auth/session.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/cache.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/cli.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/__init__.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/addsuperuser.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/__init__.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/add_to_group.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/create_group.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/create_permissions.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/create_user.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/delete_group.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/delete_user.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/list_groups.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/list_resources.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/list_users.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/remove_from_group.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/serve.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/models.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/permissions.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/__init__.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/csv_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/excel_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/html_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/markdown_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/parquet_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/python_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/security_urls.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/static/admin/app.js +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/static/admin/style.css +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/admin/index.html +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/admin_base.html +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/datatable.html +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/login.html +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/media_gallery.html +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/media_player.html +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/profile.html +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/utils/query.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt_server.egg-info/dependency_links.txt +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt_server.egg-info/entry_points.txt +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt_server.egg-info/requires.txt +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt_server.egg-info/top_level.txt +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/setup.cfg +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_admin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_auth.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_config.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_constraints.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_csv_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_dataset_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_discovery.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_html_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_locks.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_markdown_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_on_delete.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_parquet_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_plugin_interface.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_python_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_security_urls.py +0 -0
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
1
5
|
from fastapi import Depends, HTTPException, Request, Query
|
|
2
6
|
from sqlalchemy import asc, desc
|
|
3
7
|
from sqlmodel import Session, select
|
|
4
|
-
from typing import List
|
|
5
|
-
from datetime import datetime, timedelta, timezone
|
|
6
|
-
import logging
|
|
7
8
|
|
|
8
9
|
from ..auth import require_superuser
|
|
9
10
|
from ..storage import User, APIKey, get_db_session
|
|
10
|
-
from ..api_keys import
|
|
11
|
+
from ..api_keys import create_api_key_record, revoke_api_key_record
|
|
11
12
|
from ..audit import log_action
|
|
12
13
|
from . import router
|
|
13
14
|
from .models import APIKeyCreate
|
|
@@ -27,8 +28,6 @@ def list_api_keys(
|
|
|
27
28
|
user = Depends(require_superuser)
|
|
28
29
|
):
|
|
29
30
|
"""List all API keys in the system with optional query parameters."""
|
|
30
|
-
import json
|
|
31
|
-
|
|
32
31
|
stmt = select(APIKey)
|
|
33
32
|
|
|
34
33
|
if user_id is not None:
|
|
@@ -73,28 +72,15 @@ def create_api_key(key_data: APIKeyCreate, request: Request, db: Session = Depen
|
|
|
73
72
|
if not target_user:
|
|
74
73
|
logger.warning("Attempted to create API key for non-existent user %d", key_data.user_id)
|
|
75
74
|
raise HTTPException(status_code=404, detail="User not found")
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
raise HTTPException(status_code=400, detail="Expiration cannot exceed 1 year (365 days)")
|
|
83
|
-
expires_at = datetime.now(tz=timezone.utc) + timedelta(days=key_data.expires_in_days)
|
|
84
|
-
|
|
85
|
-
api_key = APIKey(
|
|
86
|
-
user_id=key_data.user_id,
|
|
87
|
-
key_hash=key_hash,
|
|
88
|
-
description=key_data.description,
|
|
89
|
-
expires_at=expires_at
|
|
90
|
-
)
|
|
91
|
-
db.add(api_key)
|
|
92
|
-
db.commit()
|
|
93
|
-
db.refresh(api_key)
|
|
94
|
-
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
raw_key, api_key = create_api_key_record(db, key_data.user_id, key_data.description, key_data.expires_in_days)
|
|
78
|
+
except ValueError as e:
|
|
79
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
80
|
+
|
|
95
81
|
log_action(request, "create_api_key", "apikey", f"Created API key for user {target_user.username}", user.id)
|
|
96
82
|
logger.info("Created API key %d for user %s", api_key.id, target_user.username)
|
|
97
|
-
|
|
83
|
+
|
|
98
84
|
# Return the raw key only once!
|
|
99
85
|
return {
|
|
100
86
|
"id": api_key.id,
|
|
@@ -108,16 +94,12 @@ def create_api_key(key_data: APIKeyCreate, request: Request, db: Session = Depen
|
|
|
108
94
|
@router.delete("/api-keys/{key_id}")
|
|
109
95
|
def revoke_api_key(key_id: int, request: Request, db: Session = Depends(get_db_session), user = Depends(require_superuser)):
|
|
110
96
|
"""Revoke an API key by ID."""
|
|
111
|
-
api_key = db
|
|
97
|
+
api_key = revoke_api_key_record(db, key_id)
|
|
112
98
|
if not api_key:
|
|
113
99
|
logger.warning("Attempted to revoke non-existent API key %d", key_id)
|
|
114
100
|
raise HTTPException(status_code=404, detail="API Key not found")
|
|
115
|
-
|
|
116
|
-
api_key.is_active = False
|
|
117
|
-
db.add(api_key)
|
|
118
|
-
db.commit()
|
|
119
|
-
|
|
101
|
+
|
|
120
102
|
log_action(request, "revoke_api_key", "apikey", f"Revoked API key {key_id}", user.id)
|
|
121
103
|
logger.info("Revoked API key %d", key_id)
|
|
122
|
-
|
|
104
|
+
|
|
123
105
|
return {"success": True}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
1
5
|
from fastapi import Depends, HTTPException, Request, Query
|
|
2
6
|
from sqlmodel import Session, select
|
|
3
|
-
from typing import List
|
|
4
|
-
import logging
|
|
5
7
|
|
|
6
8
|
from ..auth import require_superuser
|
|
7
9
|
from ..storage import LockRecord, get_db_session
|
|
8
10
|
from ..locks import LockManager
|
|
11
|
+
from ..utils.query import apply_filter, apply_sort, apply_pagination
|
|
9
12
|
from ..audit import log_action
|
|
10
13
|
from . import router
|
|
11
14
|
|
|
@@ -22,9 +25,6 @@ def list_locks(
|
|
|
22
25
|
user = Depends(require_superuser)
|
|
23
26
|
):
|
|
24
27
|
"""List all active locks with optional query parameters."""
|
|
25
|
-
from ..utils.query import apply_filter, apply_sort, apply_pagination
|
|
26
|
-
import json
|
|
27
|
-
|
|
28
28
|
locks = db.exec(select(LockRecord)).all()
|
|
29
29
|
|
|
30
30
|
# Convert to dicts for filtering/sorting
|
|
@@ -2,8 +2,10 @@ from pathlib import Path
|
|
|
2
2
|
from typing import List
|
|
3
3
|
import logging
|
|
4
4
|
|
|
5
|
+
from .. import cache
|
|
5
6
|
from ..config import AdaptConfig
|
|
6
7
|
from ..discovery import discover_resources
|
|
8
|
+
from ..storage import init_database
|
|
7
9
|
|
|
8
10
|
logger = logging.getLogger(__name__)
|
|
9
11
|
|
|
@@ -14,6 +16,9 @@ def list_resources(root: Path) -> List[str]:
|
|
|
14
16
|
Returns a list of resource identifiers including sub-namespaces for multi-resource files.
|
|
15
17
|
"""
|
|
16
18
|
config = AdaptConfig(root=root)
|
|
19
|
+
config.load_from_file()
|
|
20
|
+
init_database(config.db_path)
|
|
21
|
+
cache.configure(str(config.db_path))
|
|
17
22
|
resources = discover_resources(root, config)
|
|
18
23
|
resource_names = []
|
|
19
24
|
for r in resources:
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
1
5
|
from fastapi import Depends, HTTPException, Request, Query
|
|
2
6
|
from sqlmodel import Session, select
|
|
3
|
-
from typing import List
|
|
4
|
-
import logging
|
|
5
7
|
|
|
6
8
|
from ..auth import require_superuser, hash_password
|
|
7
9
|
from ..storage import User, get_db_session
|
|
10
|
+
from ..utils.query import apply_filter, apply_sort, apply_pagination
|
|
8
11
|
from ..audit import log_action
|
|
9
12
|
from . import router
|
|
10
13
|
from .models import UserCreate, UserPublic
|
|
@@ -22,9 +25,6 @@ def list_users(
|
|
|
22
25
|
user: User = Depends(require_superuser)
|
|
23
26
|
):
|
|
24
27
|
"""List all users with optional query parameters."""
|
|
25
|
-
from ..utils.query import apply_filter, apply_sort, apply_pagination
|
|
26
|
-
import json
|
|
27
|
-
|
|
28
28
|
query = select(User)
|
|
29
29
|
users = db.exec(query).all()
|
|
30
30
|
|
|
@@ -75,7 +75,6 @@ def create_user(user_data: UserCreate, request: Request, db: Session = Depends(g
|
|
|
75
75
|
"""Create a new user."""
|
|
76
76
|
# Check if server is in read-only mode
|
|
77
77
|
if request.app.state.config.readonly:
|
|
78
|
-
from fastapi import HTTPException
|
|
79
78
|
raise HTTPException(status_code=405, detail="Server is in read-only mode")
|
|
80
79
|
|
|
81
80
|
existing = db.exec(select(User).where(User.username == user_data.username)).first()
|
|
@@ -109,7 +108,6 @@ def delete_user(user_id: int, request: Request, db: Session = Depends(get_db_ses
|
|
|
109
108
|
"""Delete a user by ID."""
|
|
110
109
|
# Check if server is in read-only mode
|
|
111
110
|
if request.app.state.config.readonly:
|
|
112
|
-
from fastapi import HTTPException
|
|
113
111
|
raise HTTPException(status_code=405, detail="Server is in read-only mode")
|
|
114
112
|
|
|
115
113
|
target = db.get(User, user_id)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
"""adapt.api_keys — API key generation, verification, and lifecycle helpers."""
|
|
1
2
|
from __future__ import annotations
|
|
2
3
|
|
|
3
4
|
import logging
|
|
4
5
|
import secrets
|
|
5
6
|
import hashlib
|
|
6
|
-
from datetime import datetime, timezone
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
7
8
|
from sqlmodel import Session, select
|
|
8
9
|
from fastapi import Request, HTTPException, Security
|
|
9
10
|
from fastapi.security import APIKeyHeader
|
|
@@ -60,6 +61,55 @@ def verify_api_key(db: Session, raw_key: str) -> User | None:
|
|
|
60
61
|
logger.info(f"API key verified successfully for user {api_key.user_id}")
|
|
61
62
|
return db.get(User, api_key.user_id)
|
|
62
63
|
|
|
64
|
+
def create_api_key_record(
|
|
65
|
+
db: Session,
|
|
66
|
+
user_id: int,
|
|
67
|
+
description: str | None,
|
|
68
|
+
expires_in_days: int | None,
|
|
69
|
+
) -> tuple[str, APIKey]:
|
|
70
|
+
"""Create and persist an API key. Returns (raw_key, api_key).
|
|
71
|
+
|
|
72
|
+
Raises ValueError if expires_in_days exceeds 365.
|
|
73
|
+
"""
|
|
74
|
+
if expires_in_days is not None and expires_in_days > 365:
|
|
75
|
+
raise ValueError("Expiration cannot exceed 1 year (365 days)")
|
|
76
|
+
expires_at = None
|
|
77
|
+
if expires_in_days is not None:
|
|
78
|
+
expires_at = datetime.now(tz=timezone.utc) + timedelta(days=expires_in_days)
|
|
79
|
+
raw_key, key_hash = generate_api_key()
|
|
80
|
+
api_key = APIKey(
|
|
81
|
+
user_id=user_id,
|
|
82
|
+
key_hash=key_hash,
|
|
83
|
+
description=description,
|
|
84
|
+
expires_at=expires_at,
|
|
85
|
+
created_at=datetime.now(tz=timezone.utc),
|
|
86
|
+
is_active=True,
|
|
87
|
+
)
|
|
88
|
+
db.add(api_key)
|
|
89
|
+
db.commit()
|
|
90
|
+
db.refresh(api_key)
|
|
91
|
+
return raw_key, api_key
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def revoke_api_key_record(
|
|
95
|
+
db: Session,
|
|
96
|
+
key_id: int,
|
|
97
|
+
*,
|
|
98
|
+
owner_id: int | None = None,
|
|
99
|
+
) -> APIKey | None:
|
|
100
|
+
"""Set an API key inactive. If owner_id is given, enforces ownership.
|
|
101
|
+
|
|
102
|
+
Returns the key on success, None if not found or ownership mismatch.
|
|
103
|
+
"""
|
|
104
|
+
api_key = db.get(APIKey, key_id)
|
|
105
|
+
if not api_key or (owner_id is not None and api_key.user_id != owner_id):
|
|
106
|
+
return None
|
|
107
|
+
api_key.is_active = False
|
|
108
|
+
db.add(api_key)
|
|
109
|
+
db.commit()
|
|
110
|
+
return api_key
|
|
111
|
+
|
|
112
|
+
|
|
63
113
|
def get_user_from_api_key(
|
|
64
114
|
request: Request,
|
|
65
115
|
api_key: str = Security(API_KEY_HEADER)
|
|
@@ -1,24 +1,30 @@
|
|
|
1
|
+
"""adapt.app — FastAPI application factory and middleware configuration."""
|
|
1
2
|
from __future__ import annotations
|
|
2
3
|
|
|
3
4
|
import asyncio
|
|
4
5
|
import logging
|
|
6
|
+
import time
|
|
5
7
|
from contextlib import asynccontextmanager
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
|
|
8
|
-
from fastapi import FastAPI, Request, Depends
|
|
10
|
+
from fastapi import FastAPI, HTTPException, Request, Depends
|
|
11
|
+
from fastapi.openapi.docs import get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html
|
|
12
|
+
from fastapi.openapi.utils import get_openapi
|
|
9
13
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
10
14
|
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
|
11
|
-
from
|
|
12
|
-
import time
|
|
13
|
-
from .auth.dependencies import get_current_user
|
|
14
|
-
_START_TIME = time.time()
|
|
15
|
+
from fastapi.routing import APIRoute
|
|
15
16
|
from fastapi.staticfiles import StaticFiles
|
|
16
17
|
from fastapi.templating import Jinja2Templates
|
|
17
18
|
from sqlmodel import Session, select, delete
|
|
18
19
|
from datetime import datetime, timezone
|
|
19
20
|
|
|
21
|
+
from .auth import router as auth_router
|
|
22
|
+
from .auth.dependencies import get_current_user
|
|
23
|
+
from .auth.session import get_session
|
|
24
|
+
from .admin import router as admin_router
|
|
20
25
|
from .config import AdaptConfig
|
|
21
26
|
from .discovery import discover_resources
|
|
27
|
+
from .permissions import PermissionChecker
|
|
22
28
|
from .routes import generate_routes
|
|
23
29
|
from .storage import User, DBSession, init_database
|
|
24
30
|
from .locks import LockManager
|
|
@@ -33,8 +39,14 @@ from .security import (
|
|
|
33
39
|
)
|
|
34
40
|
from .security_urls import login_redirect_url
|
|
35
41
|
|
|
42
|
+
_START_TIME = time.time()
|
|
43
|
+
|
|
36
44
|
logger = logging.getLogger(__name__)
|
|
37
45
|
|
|
46
|
+
_DOCS_INTERNAL_PATHS = frozenset({"/docs", "/docs/", "/docs/oauth2-redirect", "/openapi.json"})
|
|
47
|
+
_PUBLIC_OPENAPI_PATHS = frozenset({"/", "/auth/login", "/health"})
|
|
48
|
+
_AUTHENTICATED_OPENAPI_PATHS = frozenset({"/auth/logout", "/auth/me", "/profile", "/api/apikeys"})
|
|
49
|
+
|
|
38
50
|
|
|
39
51
|
async def cleanup_expired_sessions(engine, interval_hours=24):
|
|
40
52
|
"""Background task to clean up expired sessions."""
|
|
@@ -52,6 +64,132 @@ async def cleanup_expired_sessions(engine, interval_hours=24):
|
|
|
52
64
|
logger.info("Cleaned up %d expired sessions", result.rowcount)
|
|
53
65
|
|
|
54
66
|
|
|
67
|
+
def _normalize_path(path: str) -> str:
|
|
68
|
+
"""Normalize route paths so matching is consistent with and without trailing slashes."""
|
|
69
|
+
normalized = path.rstrip("/")
|
|
70
|
+
return normalized or "/"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _resource_namespaces(resource) -> set[str]:
|
|
74
|
+
"""Return the supported permission namespaces for a discovered resource."""
|
|
75
|
+
namespace_no_ext = resource.relative_path.with_suffix("").as_posix()
|
|
76
|
+
namespace_with_ext = resource.relative_path.as_posix()
|
|
77
|
+
if "sub_namespace" in resource.metadata:
|
|
78
|
+
suffix = f"/{resource.metadata['sub_namespace']}"
|
|
79
|
+
namespace_no_ext += suffix
|
|
80
|
+
namespace_with_ext += suffix
|
|
81
|
+
return {namespace_no_ext, namespace_with_ext}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _all_resource_namespaces(resources) -> set[str]:
|
|
85
|
+
"""Collect all permission namespaces exposed by discovered resources."""
|
|
86
|
+
namespaces: set[str] = set()
|
|
87
|
+
for resource in resources:
|
|
88
|
+
namespaces.update(_resource_namespaces(resource))
|
|
89
|
+
return namespaces
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _visible_resource_namespaces(request: Request, user: User | None) -> set[str]:
|
|
93
|
+
"""Return the resource namespaces that should be visible to the current user."""
|
|
94
|
+
resources = request.app.state.resources
|
|
95
|
+
if not user:
|
|
96
|
+
return set()
|
|
97
|
+
|
|
98
|
+
if getattr(user, "is_superuser", False):
|
|
99
|
+
return _all_resource_namespaces(resources)
|
|
100
|
+
|
|
101
|
+
from .permissions import PermissionChecker
|
|
102
|
+
|
|
103
|
+
visible: set[str] = set()
|
|
104
|
+
with Session(request.app.state.db_engine) as db:
|
|
105
|
+
checker = PermissionChecker(db)
|
|
106
|
+
for resource in resources:
|
|
107
|
+
for namespace in _resource_namespaces(resource):
|
|
108
|
+
if checker.has_permission(user, namespace, "read"):
|
|
109
|
+
visible.add(namespace)
|
|
110
|
+
return visible
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _extract_resource_namespace(path: str, all_namespaces: set[str]) -> str | None:
|
|
114
|
+
"""Extract the resource namespace from a route path if it maps to a discovered resource."""
|
|
115
|
+
normalized_path = _normalize_path(path)
|
|
116
|
+
stripped_path = normalized_path.lstrip("/")
|
|
117
|
+
if stripped_path in all_namespaces:
|
|
118
|
+
return stripped_path
|
|
119
|
+
|
|
120
|
+
for prefix in ("/api/", "/schema/", "/ui/", "/media/"):
|
|
121
|
+
if normalized_path.startswith(prefix):
|
|
122
|
+
candidate = normalized_path[len(prefix):]
|
|
123
|
+
if candidate in all_namespaces:
|
|
124
|
+
return candidate
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _route_is_visible(route: APIRoute, request: Request, user: User | None, all_namespaces: set[str], visible_namespaces: set[str]) -> bool:
|
|
129
|
+
"""Decide whether a route should appear in the current request's OpenAPI schema."""
|
|
130
|
+
path = _normalize_path(route.path)
|
|
131
|
+
|
|
132
|
+
if path in _DOCS_INTERNAL_PATHS:
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
if path.startswith("/admin") or "admin" in (route.tags or []):
|
|
136
|
+
return bool(user and getattr(user, "is_superuser", False))
|
|
137
|
+
|
|
138
|
+
if path in _PUBLIC_OPENAPI_PATHS:
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
if path in _AUTHENTICATED_OPENAPI_PATHS:
|
|
142
|
+
return user is not None
|
|
143
|
+
|
|
144
|
+
if path == "/ui/media":
|
|
145
|
+
media_namespaces = {
|
|
146
|
+
ns
|
|
147
|
+
for r in request.app.state.resources
|
|
148
|
+
if r.resource_type == "media"
|
|
149
|
+
for ns in _resource_namespaces(r)
|
|
150
|
+
}
|
|
151
|
+
return bool(media_namespaces & visible_namespaces)
|
|
152
|
+
|
|
153
|
+
resource_namespace = _extract_resource_namespace(path, all_namespaces)
|
|
154
|
+
if resource_namespace is not None:
|
|
155
|
+
return resource_namespace in visible_namespaces
|
|
156
|
+
|
|
157
|
+
if path.startswith("/auth/"):
|
|
158
|
+
return user is not None
|
|
159
|
+
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _build_openapi_schema(app: FastAPI, request: Request, user: User | None) -> dict:
|
|
164
|
+
"""Build a filtered OpenAPI schema for the current request context."""
|
|
165
|
+
all_namespaces = _all_resource_namespaces(request.app.state.resources)
|
|
166
|
+
visible_namespaces = _visible_resource_namespaces(request, user)
|
|
167
|
+
visible_routes = [
|
|
168
|
+
route
|
|
169
|
+
for route in app.routes
|
|
170
|
+
if isinstance(route, APIRoute)
|
|
171
|
+
and route.include_in_schema
|
|
172
|
+
and _route_is_visible(route, request, user, all_namespaces, visible_namespaces)
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
return get_openapi(
|
|
176
|
+
title=app.title,
|
|
177
|
+
version=app.version,
|
|
178
|
+
description=app.description,
|
|
179
|
+
routes=visible_routes,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _visible_resource_paths(request: Request, user: User | None) -> list[str]:
|
|
184
|
+
"""Return resource paths visible to the current user for JSON discovery responses."""
|
|
185
|
+
visible_namespaces = _visible_resource_namespaces(request, user)
|
|
186
|
+
visible_resources: list[str] = []
|
|
187
|
+
for resource in request.app.state.resources:
|
|
188
|
+
if _resource_namespaces(resource) & visible_namespaces:
|
|
189
|
+
visible_resources.append(resource.relative_path.as_posix())
|
|
190
|
+
return visible_resources
|
|
191
|
+
|
|
192
|
+
|
|
55
193
|
@asynccontextmanager
|
|
56
194
|
async def lifespan(app: FastAPI):
|
|
57
195
|
"""Handle application startup and shutdown events."""
|
|
@@ -68,6 +206,26 @@ async def lifespan(app: FastAPI):
|
|
|
68
206
|
await cleanup_task
|
|
69
207
|
except asyncio.CancelledError:
|
|
70
208
|
logger.debug("Application shutdown: cleanup task cancelled")
|
|
209
|
+
finally:
|
|
210
|
+
engine.dispose()
|
|
211
|
+
logger.debug("Application shutdown: database engine disposed")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _init_infrastructure(config: AdaptConfig):
|
|
215
|
+
"""Initialize database, cache, lock manager, and resource discovery.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Tuple of (engine, lock_manager, resources).
|
|
219
|
+
"""
|
|
220
|
+
engine = init_database(config.db_path)
|
|
221
|
+
cache.configure(str(config.db_path))
|
|
222
|
+
lock_manager = LockManager(engine)
|
|
223
|
+
cleaned = lock_manager.release_stale_locks(max_age_seconds=300)
|
|
224
|
+
if cleaned > 0:
|
|
225
|
+
logging.warning("Cleaned %d stale locks on startup", cleaned)
|
|
226
|
+
resources = discover_resources(config.root, config)
|
|
227
|
+
logger.debug("Discovered %d resources", len(resources))
|
|
228
|
+
return engine, lock_manager, resources
|
|
71
229
|
|
|
72
230
|
|
|
73
231
|
def create_app(config: AdaptConfig) -> FastAPI:
|
|
@@ -80,28 +238,18 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
80
238
|
The configured FastAPI app instance.
|
|
81
239
|
"""
|
|
82
240
|
logger.debug("Creating FastAPI app with config: %s", config)
|
|
83
|
-
engine =
|
|
84
|
-
|
|
85
|
-
app = FastAPI(title="Adapt", version=config.version, lifespan=lifespan)
|
|
241
|
+
engine, lock_manager, resources = _init_infrastructure(config)
|
|
242
|
+
|
|
243
|
+
app = FastAPI(title="Adapt", version=config.version, lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
|
|
86
244
|
app.state.config = config
|
|
87
245
|
app.state.db_engine = engine
|
|
88
246
|
app.state.use_tls = bool(config.tls_cert and config.tls_key)
|
|
247
|
+
app.state.lock_manager = lock_manager
|
|
248
|
+
app.state.resources = resources
|
|
89
249
|
|
|
90
250
|
allowed_hosts = build_allowed_hosts(config.host)
|
|
91
251
|
if allowed_hosts != ["*"]:
|
|
92
252
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts)
|
|
93
|
-
|
|
94
|
-
# Initialize lock manager
|
|
95
|
-
lock_manager = LockManager(engine)
|
|
96
|
-
# Clean up stale locks from previous crash
|
|
97
|
-
cleaned = lock_manager.release_stale_locks(max_age_seconds=300) # 5 minutes
|
|
98
|
-
if cleaned > 0:
|
|
99
|
-
logging.warning(f"Cleaned {cleaned} stale locks on startup")
|
|
100
|
-
app.state.lock_manager = lock_manager
|
|
101
|
-
|
|
102
|
-
resources = discover_resources(config.root, config)
|
|
103
|
-
app.state.resources = resources
|
|
104
|
-
logger.debug("Discovered %d resources", len(resources))
|
|
105
253
|
|
|
106
254
|
# Set up Jinja2 templates
|
|
107
255
|
templates_dir = Path(__file__).parent / "templates"
|
|
@@ -140,8 +288,6 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
140
288
|
@app.middleware("http")
|
|
141
289
|
async def auth_middleware(request: Request, call_next):
|
|
142
290
|
"""Middleware to handle user authentication via session cookies."""
|
|
143
|
-
from .auth.session import get_session
|
|
144
|
-
from sqlmodel import Session
|
|
145
291
|
token = request.cookies.get("adapt_session")
|
|
146
292
|
request.state.user = None
|
|
147
293
|
if token:
|
|
@@ -159,15 +305,34 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
159
305
|
return response
|
|
160
306
|
|
|
161
307
|
# Mount authentication routes
|
|
162
|
-
from .auth import router as auth_router
|
|
163
308
|
app.include_router(auth_router, prefix="", tags=["auth"])
|
|
164
309
|
|
|
165
310
|
# Mount admin routes
|
|
166
|
-
from .admin import router as admin_router
|
|
167
311
|
app.include_router(admin_router)
|
|
168
312
|
|
|
169
313
|
# Generate and mount routes
|
|
170
314
|
generate_routes(app, resources, config)
|
|
315
|
+
|
|
316
|
+
@app.get("/openapi.json", include_in_schema=False)
|
|
317
|
+
def openapi_schema(request: Request):
|
|
318
|
+
"""Return an OpenAPI document filtered to the current user's visible routes."""
|
|
319
|
+
user = get_current_user(request)
|
|
320
|
+
return JSONResponse(_build_openapi_schema(app, request, user))
|
|
321
|
+
|
|
322
|
+
@app.get("/docs", include_in_schema=False)
|
|
323
|
+
@app.get("/docs/", include_in_schema=False)
|
|
324
|
+
def swagger_ui():
|
|
325
|
+
"""Render Swagger UI against the request-filtered OpenAPI schema."""
|
|
326
|
+
return get_swagger_ui_html(
|
|
327
|
+
openapi_url="/openapi.json",
|
|
328
|
+
title=f"{app.title} - API Docs",
|
|
329
|
+
oauth2_redirect_url="/docs/oauth2-redirect",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
@app.get("/docs/oauth2-redirect", include_in_schema=False)
|
|
333
|
+
def swagger_ui_redirect():
|
|
334
|
+
"""Serve the Swagger UI OAuth redirect helper."""
|
|
335
|
+
return get_swagger_ui_oauth2_redirect_html()
|
|
171
336
|
|
|
172
337
|
@app.get("/health", tags=["system"])
|
|
173
338
|
async def health(request: Request, user=Depends(get_current_user)):
|
|
@@ -208,18 +373,27 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
208
373
|
@app.get("/ui/media")
|
|
209
374
|
def media_gallery(request: Request):
|
|
210
375
|
"""Render the media gallery UI for authenticated users."""
|
|
211
|
-
from .auth.dependencies import get_current_user
|
|
212
376
|
user = get_current_user(request)
|
|
213
377
|
if not user:
|
|
214
|
-
# Redirect to login if not authenticated
|
|
215
378
|
logger.debug("Unauthenticated access to media gallery, redirecting to login")
|
|
216
379
|
return RedirectResponse(url=login_redirect_url("/ui/media"), status_code=302)
|
|
217
|
-
|
|
218
|
-
|
|
380
|
+
|
|
381
|
+
all_media = [r for r in request.app.state.resources if r.resource_type == "media"]
|
|
382
|
+
if getattr(user, "is_superuser", False):
|
|
383
|
+
permitted_media = all_media
|
|
384
|
+
else:
|
|
385
|
+
with Session(engine) as db:
|
|
386
|
+
checker = PermissionChecker(db)
|
|
387
|
+
permitted_media = [
|
|
388
|
+
r for r in all_media
|
|
389
|
+
if checker.has_permission(user, r.relative_path.with_suffix("").as_posix(), "read")
|
|
390
|
+
]
|
|
391
|
+
if not permitted_media and not getattr(user, "is_superuser", False):
|
|
392
|
+
logger.warning("Permission denied for user %s: no accessible media resources", user.username)
|
|
393
|
+
raise HTTPException(status_code=403, detail="No accessible media resources")
|
|
394
|
+
|
|
219
395
|
media_items = []
|
|
220
|
-
for r in
|
|
221
|
-
# Check permission, but for simplicity, assume read permission
|
|
222
|
-
# In full impl, check permission
|
|
396
|
+
for r in permitted_media:
|
|
223
397
|
media_items.append({
|
|
224
398
|
"name": r.path.name,
|
|
225
399
|
"relative_path": r.relative_path.as_posix(),
|
|
@@ -247,17 +421,14 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
247
421
|
@app.get("/")
|
|
248
422
|
def root(request: Request):
|
|
249
423
|
"""Handle root requests, rendering HTML landing page or JSON API response."""
|
|
250
|
-
from .auth.dependencies import get_current_user
|
|
251
|
-
from .utils import build_accessible_ui_links
|
|
252
|
-
|
|
253
424
|
accept = request.headers.get("accept", "")
|
|
254
425
|
if "text/html" in accept:
|
|
255
426
|
# Render landing page
|
|
256
427
|
user = get_current_user(request)
|
|
257
428
|
accessible_resources = build_accessible_ui_links(request, user)
|
|
258
429
|
|
|
259
|
-
# Add media gallery if
|
|
260
|
-
if any(
|
|
430
|
+
# Add media gallery link only if the user can access at least one media file
|
|
431
|
+
if any(link["type"] == "media" for link in accessible_resources):
|
|
261
432
|
accessible_resources.append({"name": "Media Gallery", "url": "/ui/media", "type": "media"})
|
|
262
433
|
|
|
263
434
|
context = {
|
|
@@ -269,13 +440,12 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
269
440
|
return request.app.state.templates.TemplateResponse(request, "landing.html", context)
|
|
270
441
|
else:
|
|
271
442
|
# JSON API response
|
|
272
|
-
|
|
443
|
+
user = get_current_user(request)
|
|
444
|
+
resources = _visible_resource_paths(request, user)
|
|
273
445
|
logger.debug("Returning JSON API response with %d resources", len(resources))
|
|
274
446
|
return {"resources": resources}
|
|
275
447
|
|
|
276
448
|
# Exception handler for auth redirects
|
|
277
|
-
from fastapi import HTTPException
|
|
278
|
-
|
|
279
449
|
@app.exception_handler(HTTPException)
|
|
280
450
|
async def auth_exception_handler(request: Request, exc: HTTPException):
|
|
281
451
|
"""Handle HTTP exceptions, redirecting to login for 401 errors in HTML requests."""
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
+
"""adapt.auth.dependencies — FastAPI dependency functions for authentication and authorization."""
|
|
1
2
|
from fastapi import Request, HTTPException, status, Depends
|
|
2
3
|
from sqlmodel import Session, select
|
|
3
4
|
import logging
|
|
4
5
|
|
|
5
6
|
from ..storage import User, UserGroup, GroupPermission, Permission
|
|
7
|
+
from .session import get_session
|
|
8
|
+
from ..api_keys import verify_api_key
|
|
6
9
|
|
|
7
10
|
logger = logging.getLogger(__name__)
|
|
8
11
|
|
|
9
12
|
def get_current_user(request: Request) -> User | None:
|
|
10
13
|
"""Get the current authenticated user from session or API key."""
|
|
11
14
|
# 1. Try Session Cookie
|
|
12
|
-
from .session import get_session
|
|
13
15
|
token = request.cookies.get("adapt_session")
|
|
14
16
|
if token:
|
|
15
17
|
with Session(request.app.state.db_engine) as db:
|
|
@@ -23,7 +25,6 @@ def get_current_user(request: Request) -> User | None:
|
|
|
23
25
|
# 2. Try API Key
|
|
24
26
|
api_key_header = request.headers.get("X-API-Key")
|
|
25
27
|
if api_key_header:
|
|
26
|
-
from ..api_keys import verify_api_key
|
|
27
28
|
with Session(request.app.state.db_engine) as db:
|
|
28
29
|
user = verify_api_key(db, api_key_header)
|
|
29
30
|
if user:
|