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.
Files changed (103) hide show
  1. {adapt_server-0.2.2/adapt_server.egg-info → adapt_server-0.2.3}/PKG-INFO +1 -1
  2. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/__init__.py +1 -1
  3. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/api_keys.py +15 -33
  4. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/locks.py +5 -5
  5. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/users.py +5 -7
  6. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/api_keys.py +51 -1
  7. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/app.py +32 -33
  8. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/audit.py +1 -0
  9. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/auth/dependencies.py +3 -2
  10. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/auth/routes.py +17 -47
  11. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/config.py +56 -46
  12. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/discovery.py +1 -15
  13. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/locks.py +1 -0
  14. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/base.py +1 -0
  15. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/dataset_plugin.py +7 -17
  16. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/routes.py +1 -0
  17. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/security.py +1 -0
  18. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/storage.py +1 -0
  19. {adapt_server-0.2.2 → adapt_server-0.2.3/adapt_server.egg-info}/PKG-INFO +1 -1
  20. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt_server.egg-info/SOURCES.txt +2 -0
  21. {adapt_server-0.2.2 → adapt_server-0.2.3}/pyproject.toml +1 -1
  22. adapt_server-0.2.3/tests/test_cache.py +113 -0
  23. adapt_server-0.2.3/tests/test_dependencies.py +214 -0
  24. {adapt_server-0.2.2 → adapt_server-0.2.3}/LICENSE +0 -0
  25. {adapt_server-0.2.2 → adapt_server-0.2.3}/README.md +0 -0
  26. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/__main__.py +0 -0
  27. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/__init__.py +0 -0
  28. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/audit_logs.py +0 -0
  29. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/cache.py +0 -0
  30. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/groups.py +0 -0
  31. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/models.py +0 -0
  32. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/permissions.py +0 -0
  33. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/resources.py +0 -0
  34. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/admin/ui.py +0 -0
  35. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/auth/__init__.py +0 -0
  36. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/auth/password.py +0 -0
  37. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/auth/session.py +0 -0
  38. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/cache.py +0 -0
  39. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/cli.py +0 -0
  40. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/__init__.py +0 -0
  41. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/addsuperuser.py +0 -0
  42. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/__init__.py +0 -0
  43. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/add_to_group.py +0 -0
  44. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/create_group.py +0 -0
  45. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/create_permissions.py +0 -0
  46. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/create_user.py +0 -0
  47. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/delete_group.py +0 -0
  48. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/delete_user.py +0 -0
  49. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/list_groups.py +0 -0
  50. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/list_resources.py +0 -0
  51. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/list_users.py +0 -0
  52. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/admin/remove_from_group.py +0 -0
  53. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/check.py +0 -0
  54. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/list_endpoints.py +0 -0
  55. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/commands/serve.py +0 -0
  56. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/models.py +0 -0
  57. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/permissions.py +0 -0
  58. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/__init__.py +0 -0
  59. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/csv_plugin.py +0 -0
  60. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/excel_plugin.py +0 -0
  61. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/html_plugin.py +0 -0
  62. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/markdown_plugin.py +0 -0
  63. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/media_plugin.py +0 -0
  64. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/parquet_plugin.py +0 -0
  65. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/plugins/python_plugin.py +0 -0
  66. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/security_urls.py +0 -0
  67. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/static/admin/app.js +0 -0
  68. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/static/admin/style.css +0 -0
  69. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/admin/index.html +0 -0
  70. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/admin_base.html +0 -0
  71. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/base.html +0 -0
  72. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/datatable.html +0 -0
  73. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/landing.html +0 -0
  74. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/login.html +0 -0
  75. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/media_gallery.html +0 -0
  76. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/media_player.html +0 -0
  77. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/templates/profile.html +0 -0
  78. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/utils/__init__.py +0 -0
  79. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt/utils/query.py +0 -0
  80. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt_server.egg-info/dependency_links.txt +0 -0
  81. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt_server.egg-info/entry_points.txt +0 -0
  82. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt_server.egg-info/requires.txt +0 -0
  83. {adapt_server-0.2.2 → adapt_server-0.2.3}/adapt_server.egg-info/top_level.txt +0 -0
  84. {adapt_server-0.2.2 → adapt_server-0.2.3}/setup.cfg +0 -0
  85. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_admin.py +0 -0
  86. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_auth.py +0 -0
  87. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_cli_integration.py +0 -0
  88. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_config.py +0 -0
  89. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_constraints.py +0 -0
  90. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_csv_plugin.py +0 -0
  91. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_dataset_plugin.py +0 -0
  92. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_discovery.py +0 -0
  93. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_html_plugin.py +0 -0
  94. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_integration.py +0 -0
  95. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_locks.py +0 -0
  96. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_markdown_plugin.py +0 -0
  97. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_on_delete.py +0 -0
  98. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_parquet_plugin.py +0 -0
  99. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_phase3.py +0 -0
  100. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_plugin_interface.py +0 -0
  101. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_python_plugin.py +0 -0
  102. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_readonly.py +0 -0
  103. {adapt_server-0.2.2 → adapt_server-0.2.3}/tests/test_security_urls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adapt-server
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Adaptive file-backed FastAPI server that turns datasets into CRUD APIs and UIs.
5
5
  Author-email: notesofcliff <notesofcliff@gmail.com>
6
6
  License-Expression: MIT
@@ -1,3 +1,3 @@
1
1
  """Adapt package namespace."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
@@ -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 generate_api_key
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
- raw_key, key_hash = generate_api_key()
78
-
79
- expires_at = None
80
- if key_data.expires_in_days is not None:
81
- if key_data.expires_in_days > 365:
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.get(APIKey, key_id)
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 = init_database(config.db_path)
220
- cache.configure(str(config.db_path))
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,3 +1,4 @@
1
+ """adapt.audit — Structured audit logging for user actions."""
1
2
  from __future__ import annotations
2
3
 
3
4
  import logging
@@ -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 generate_api_key
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
- # Validate expiration
113
- expires_at = None
114
- if request.expires_in_days is not None:
115
- if request.expires_in_days > 365:
116
- raise HTTPException(status_code=400, detail="Expiration cannot exceed 1 year (365 days)")
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.get(APIKey, key_id)
188
- if not api_key or api_key.user_id != user.id:
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)