adapt-server 0.2.2__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.2/adapt_server.egg-info → adapt_server-0.2.3}/PKG-INFO +1 -1
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/__init__.py +1 -1
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/api_keys.py +15 -33
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/locks.py +5 -5
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/users.py +5 -7
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/api_keys.py +51 -1
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/app.py +32 -33
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/audit.py +1 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/auth/dependencies.py +3 -2
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/auth/routes.py +17 -47
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/config.py +56 -46
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/discovery.py +1 -15
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/locks.py +1 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/base.py +1 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/dataset_plugin.py +7 -17
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/routes.py +1 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/security.py +1 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/storage.py +1 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3/adapt_server.egg-info}/PKG-INFO +1 -1
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt_server.egg-info/SOURCES.txt +2 -0
- {adapt_server-0.2.2 → 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_dependencies.py +214 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/LICENSE +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/README.md +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/__main__.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/__init__.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/audit_logs.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/cache.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/groups.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/models.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/permissions.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/resources.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/ui.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/auth/__init__.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/auth/password.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/auth/session.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/cache.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/cli.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/__init__.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/addsuperuser.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/__init__.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/add_to_group.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/create_group.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/create_permissions.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/create_user.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/delete_group.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/delete_user.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/list_groups.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/list_resources.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/list_users.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/remove_from_group.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/check.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/list_endpoints.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/serve.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/models.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/permissions.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/__init__.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/csv_plugin.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/excel_plugin.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/html_plugin.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/markdown_plugin.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/media_plugin.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/parquet_plugin.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/python_plugin.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/security_urls.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/static/admin/app.js +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/static/admin/style.css +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/admin/index.html +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/admin_base.html +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/base.html +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/datatable.html +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/landing.html +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/login.html +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/media_gallery.html +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/media_player.html +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/profile.html +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/utils/__init__.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/utils/query.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt_server.egg-info/dependency_links.txt +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt_server.egg-info/entry_points.txt +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt_server.egg-info/requires.txt +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt_server.egg-info/top_level.txt +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/setup.cfg +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_admin.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_auth.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_cli_integration.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_config.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_constraints.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_csv_plugin.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_dataset_plugin.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_discovery.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_html_plugin.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_integration.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_locks.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_markdown_plugin.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_on_delete.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_parquet_plugin.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_phase3.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_plugin_interface.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_python_plugin.py +0 -0
- {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_readonly.py +0 -0
- {adapt_server-0.2.2 → 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
|
|
@@ -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,27 +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
|
|
9
11
|
from fastapi.openapi.docs import get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html
|
|
10
12
|
from fastapi.openapi.utils import get_openapi
|
|
11
13
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
12
14
|
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
|
13
15
|
from fastapi.routing import APIRoute
|
|
14
|
-
from datetime import datetime, timezone
|
|
15
|
-
import time
|
|
16
|
-
from .auth.dependencies import get_current_user
|
|
17
|
-
_START_TIME = time.time()
|
|
18
16
|
from fastapi.staticfiles import StaticFiles
|
|
19
17
|
from fastapi.templating import Jinja2Templates
|
|
20
18
|
from sqlmodel import Session, select, delete
|
|
21
19
|
from datetime import datetime, timezone
|
|
22
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
|
|
23
25
|
from .config import AdaptConfig
|
|
24
26
|
from .discovery import discover_resources
|
|
27
|
+
from .permissions import PermissionChecker
|
|
25
28
|
from .routes import generate_routes
|
|
26
29
|
from .storage import User, DBSession, init_database
|
|
27
30
|
from .locks import LockManager
|
|
@@ -36,6 +39,8 @@ from .security import (
|
|
|
36
39
|
)
|
|
37
40
|
from .security_urls import login_redirect_url
|
|
38
41
|
|
|
42
|
+
_START_TIME = time.time()
|
|
43
|
+
|
|
39
44
|
logger = logging.getLogger(__name__)
|
|
40
45
|
|
|
41
46
|
_DOCS_INTERNAL_PATHS = frozenset({"/docs", "/docs/", "/docs/oauth2-redirect", "/openapi.json"})
|
|
@@ -206,6 +211,23 @@ async def lifespan(app: FastAPI):
|
|
|
206
211
|
logger.debug("Application shutdown: database engine disposed")
|
|
207
212
|
|
|
208
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
|
|
229
|
+
|
|
230
|
+
|
|
209
231
|
def create_app(config: AdaptConfig) -> FastAPI:
|
|
210
232
|
"""Create and configure the FastAPI application.
|
|
211
233
|
|
|
@@ -216,28 +238,18 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
216
238
|
The configured FastAPI app instance.
|
|
217
239
|
"""
|
|
218
240
|
logger.debug("Creating FastAPI app with config: %s", config)
|
|
219
|
-
engine =
|
|
220
|
-
|
|
241
|
+
engine, lock_manager, resources = _init_infrastructure(config)
|
|
242
|
+
|
|
221
243
|
app = FastAPI(title="Adapt", version=config.version, lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
|
|
222
244
|
app.state.config = config
|
|
223
245
|
app.state.db_engine = engine
|
|
224
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
|
|
225
249
|
|
|
226
250
|
allowed_hosts = build_allowed_hosts(config.host)
|
|
227
251
|
if allowed_hosts != ["*"]:
|
|
228
252
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts)
|
|
229
|
-
|
|
230
|
-
# Initialize lock manager
|
|
231
|
-
lock_manager = LockManager(engine)
|
|
232
|
-
# Clean up stale locks from previous crash
|
|
233
|
-
cleaned = lock_manager.release_stale_locks(max_age_seconds=300) # 5 minutes
|
|
234
|
-
if cleaned > 0:
|
|
235
|
-
logging.warning(f"Cleaned {cleaned} stale locks on startup")
|
|
236
|
-
app.state.lock_manager = lock_manager
|
|
237
|
-
|
|
238
|
-
resources = discover_resources(config.root, config)
|
|
239
|
-
app.state.resources = resources
|
|
240
|
-
logger.debug("Discovered %d resources", len(resources))
|
|
241
253
|
|
|
242
254
|
# Set up Jinja2 templates
|
|
243
255
|
templates_dir = Path(__file__).parent / "templates"
|
|
@@ -276,8 +288,6 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
276
288
|
@app.middleware("http")
|
|
277
289
|
async def auth_middleware(request: Request, call_next):
|
|
278
290
|
"""Middleware to handle user authentication via session cookies."""
|
|
279
|
-
from .auth.session import get_session
|
|
280
|
-
from sqlmodel import Session
|
|
281
291
|
token = request.cookies.get("adapt_session")
|
|
282
292
|
request.state.user = None
|
|
283
293
|
if token:
|
|
@@ -295,11 +305,9 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
295
305
|
return response
|
|
296
306
|
|
|
297
307
|
# Mount authentication routes
|
|
298
|
-
from .auth import router as auth_router
|
|
299
308
|
app.include_router(auth_router, prefix="", tags=["auth"])
|
|
300
309
|
|
|
301
310
|
# Mount admin routes
|
|
302
|
-
from .admin import router as admin_router
|
|
303
311
|
app.include_router(admin_router)
|
|
304
312
|
|
|
305
313
|
# Generate and mount routes
|
|
@@ -365,18 +373,15 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
365
373
|
@app.get("/ui/media")
|
|
366
374
|
def media_gallery(request: Request):
|
|
367
375
|
"""Render the media gallery UI for authenticated users."""
|
|
368
|
-
from .auth.dependencies import get_current_user
|
|
369
376
|
user = get_current_user(request)
|
|
370
377
|
if not user:
|
|
371
|
-
# Redirect to login if not authenticated
|
|
372
378
|
logger.debug("Unauthenticated access to media gallery, redirecting to login")
|
|
373
379
|
return RedirectResponse(url=login_redirect_url("/ui/media"), status_code=302)
|
|
374
|
-
|
|
380
|
+
|
|
375
381
|
all_media = [r for r in request.app.state.resources if r.resource_type == "media"]
|
|
376
382
|
if getattr(user, "is_superuser", False):
|
|
377
383
|
permitted_media = all_media
|
|
378
384
|
else:
|
|
379
|
-
from .permissions import PermissionChecker
|
|
380
385
|
with Session(engine) as db:
|
|
381
386
|
checker = PermissionChecker(db)
|
|
382
387
|
permitted_media = [
|
|
@@ -384,7 +389,6 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
384
389
|
if checker.has_permission(user, r.relative_path.with_suffix("").as_posix(), "read")
|
|
385
390
|
]
|
|
386
391
|
if not permitted_media and not getattr(user, "is_superuser", False):
|
|
387
|
-
from fastapi import HTTPException
|
|
388
392
|
logger.warning("Permission denied for user %s: no accessible media resources", user.username)
|
|
389
393
|
raise HTTPException(status_code=403, detail="No accessible media resources")
|
|
390
394
|
|
|
@@ -417,9 +421,6 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
417
421
|
@app.get("/")
|
|
418
422
|
def root(request: Request):
|
|
419
423
|
"""Handle root requests, rendering HTML landing page or JSON API response."""
|
|
420
|
-
from .auth.dependencies import get_current_user
|
|
421
|
-
from .utils import build_accessible_ui_links
|
|
422
|
-
|
|
423
424
|
accept = request.headers.get("accept", "")
|
|
424
425
|
if "text/html" in accept:
|
|
425
426
|
# Render landing page
|
|
@@ -445,8 +446,6 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
445
446
|
return {"resources": resources}
|
|
446
447
|
|
|
447
448
|
# Exception handler for auth redirects
|
|
448
|
-
from fastapi import HTTPException
|
|
449
|
-
|
|
450
449
|
@app.exception_handler(HTTPException)
|
|
451
450
|
async def auth_exception_handler(request: Request, exc: HTTPException):
|
|
452
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:
|
|
@@ -3,15 +3,14 @@ from fastapi.responses import RedirectResponse
|
|
|
3
3
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
4
4
|
from sqlmodel import Session, select
|
|
5
5
|
from pydantic import BaseModel
|
|
6
|
-
from datetime import datetime, timedelta, timezone
|
|
7
6
|
import logging
|
|
8
7
|
|
|
9
8
|
from ..storage import User, DBSession, APIKey, get_db_session
|
|
10
|
-
from ..config import AdaptConfig
|
|
11
9
|
from ..audit import log_action
|
|
12
|
-
from ..api_keys import
|
|
10
|
+
from ..api_keys import create_api_key_record, revoke_api_key_record
|
|
11
|
+
from ..utils import build_accessible_ui_links
|
|
13
12
|
from .password import verify_password
|
|
14
|
-
from .session import create_session
|
|
13
|
+
from .session import create_session, SESSION_COOKIE
|
|
15
14
|
from .dependencies import require_auth
|
|
16
15
|
from . import router
|
|
17
16
|
|
|
@@ -58,7 +57,6 @@ def login(form: OAuth2PasswordRequestForm = Depends(), request: Request = None,
|
|
|
58
57
|
@router.post("/auth/logout")
|
|
59
58
|
def logout(request: Request, response: Response):
|
|
60
59
|
"""Handle user logout."""
|
|
61
|
-
from .session import SESSION_COOKIE
|
|
62
60
|
token = request.cookies.get(SESSION_COOKIE)
|
|
63
61
|
if token:
|
|
64
62
|
db_engine = request.app.state.db_engine
|
|
@@ -79,7 +77,6 @@ def logout(request: Request, response: Response):
|
|
|
79
77
|
@router.get("/profile")
|
|
80
78
|
def profile_page(request: Request, user: User = Depends(require_auth)):
|
|
81
79
|
"""Render the user profile page."""
|
|
82
|
-
from ..utils import build_accessible_ui_links
|
|
83
80
|
ui_links = build_accessible_ui_links(request, user)
|
|
84
81
|
logger.debug("Rendering profile page for user %s", user.username)
|
|
85
82
|
return request.app.state.templates.TemplateResponse(request, "profile.html", {
|
|
@@ -103,45 +100,25 @@ def create_api_key(
|
|
|
103
100
|
db: Session = Depends(get_db_session)
|
|
104
101
|
):
|
|
105
102
|
"""Create a new API key for the authenticated user."""
|
|
106
|
-
# Check if server is in read-only mode
|
|
107
103
|
if req.app.state.config.readonly:
|
|
108
104
|
raise HTTPException(status_code=405, detail="Server is in read-only mode")
|
|
109
|
-
|
|
105
|
+
|
|
110
106
|
logger.debug("User %s creating API key", user.username)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
expires_at = datetime.now(tz=timezone.utc) + timedelta(days=request.expires_in_days)
|
|
118
|
-
|
|
119
|
-
# Generate key
|
|
120
|
-
raw_key, key_hash = generate_api_key()
|
|
121
|
-
|
|
122
|
-
# Create API key record
|
|
123
|
-
api_key = APIKey(
|
|
124
|
-
key_hash=key_hash,
|
|
125
|
-
user_id=user.id,
|
|
126
|
-
description=request.description,
|
|
127
|
-
expires_at=expires_at,
|
|
128
|
-
created_at=datetime.now(tz=timezone.utc),
|
|
129
|
-
is_active=True
|
|
130
|
-
)
|
|
131
|
-
db.add(api_key)
|
|
132
|
-
db.commit()
|
|
133
|
-
db.refresh(api_key)
|
|
134
|
-
|
|
135
|
-
# Log audit
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
raw_key, api_key = create_api_key_record(db, user.id, request.description, request.expires_in_days)
|
|
110
|
+
except ValueError as e:
|
|
111
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
112
|
+
|
|
136
113
|
log_action(req, "create_api_key", "apikey", f"Created API key for user {user.username}", user.id)
|
|
137
|
-
|
|
138
114
|
logger.info("User %s created API key %d", user.username, api_key.id)
|
|
115
|
+
|
|
139
116
|
return {
|
|
140
117
|
"id": api_key.id,
|
|
141
118
|
"key": raw_key, # Only returned once
|
|
142
119
|
"description": api_key.description,
|
|
143
120
|
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
|
144
|
-
"created_at": api_key.created_at.isoformat()
|
|
121
|
+
"created_at": api_key.created_at.isoformat(),
|
|
145
122
|
}
|
|
146
123
|
|
|
147
124
|
|
|
@@ -178,21 +155,14 @@ def revoke_api_key(
|
|
|
178
155
|
db: Session = Depends(get_db_session)
|
|
179
156
|
):
|
|
180
157
|
"""Revoke an API key owned by the authenticated user."""
|
|
181
|
-
# Check if server is in read-only mode
|
|
182
158
|
if req.app.state.config.readonly:
|
|
183
159
|
raise HTTPException(status_code=405, detail="Server is in read-only mode")
|
|
184
|
-
|
|
160
|
+
|
|
185
161
|
logger.debug("User %s revoking API key %d", user.username, key_id)
|
|
186
|
-
|
|
187
|
-
api_key = db
|
|
188
|
-
if not api_key
|
|
162
|
+
|
|
163
|
+
api_key = revoke_api_key_record(db, key_id, owner_id=user.id)
|
|
164
|
+
if not api_key:
|
|
189
165
|
raise HTTPException(status_code=404, detail="API key not found")
|
|
190
|
-
|
|
191
|
-
api_key.is_active = False
|
|
192
|
-
db.add(api_key)
|
|
193
|
-
db.commit()
|
|
194
|
-
|
|
195
|
-
# Log audit
|
|
166
|
+
|
|
196
167
|
log_action(req, "revoke_api_key", "apikey", f"Revoked API key {key_id} for user {user.username}", user.id)
|
|
197
|
-
|
|
198
168
|
logger.info("User %s revoked API key %d", user.username, key_id)
|