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.
Files changed (106) hide show
  1. {adapt_server-0.2.0/adapt_server.egg-info → adapt_server-0.2.3}/PKG-INFO +1 -1
  2. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/__init__.py +1 -1
  3. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/api_keys.py +15 -33
  4. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/locks.py +5 -5
  5. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/resources.py +5 -0
  6. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/users.py +5 -7
  7. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/api_keys.py +51 -1
  8. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/app.py +209 -39
  9. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/audit.py +1 -0
  10. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/auth/dependencies.py +3 -2
  11. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/auth/routes.py +17 -47
  12. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/check.py +2 -0
  13. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/list_endpoints.py +5 -0
  14. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/config.py +58 -47
  15. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/discovery.py +1 -15
  16. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/locks.py +1 -0
  17. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/base.py +1 -0
  18. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/dataset_plugin.py +21 -19
  19. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/media_plugin.py +4 -6
  20. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/routes.py +1 -0
  21. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/security.py +1 -0
  22. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/storage.py +1 -0
  23. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/base.html +8 -2
  24. adapt_server-0.2.3/adapt/templates/landing.html +93 -0
  25. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/utils/__init__.py +4 -10
  26. {adapt_server-0.2.0 → adapt_server-0.2.3/adapt_server.egg-info}/PKG-INFO +1 -1
  27. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt_server.egg-info/SOURCES.txt +3 -0
  28. {adapt_server-0.2.0 → adapt_server-0.2.3}/pyproject.toml +1 -1
  29. adapt_server-0.2.3/tests/test_cache.py +113 -0
  30. adapt_server-0.2.3/tests/test_cli_integration.py +43 -0
  31. adapt_server-0.2.3/tests/test_dependencies.py +214 -0
  32. adapt_server-0.2.3/tests/test_integration.py +527 -0
  33. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_phase3.py +8 -3
  34. adapt_server-0.2.3/tests/test_readonly.py +120 -0
  35. adapt_server-0.2.0/adapt/templates/landing.html +0 -56
  36. adapt_server-0.2.0/tests/test_integration.py +0 -273
  37. adapt_server-0.2.0/tests/test_readonly.py +0 -70
  38. {adapt_server-0.2.0 → adapt_server-0.2.3}/LICENSE +0 -0
  39. {adapt_server-0.2.0 → adapt_server-0.2.3}/README.md +0 -0
  40. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/__main__.py +0 -0
  41. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/__init__.py +0 -0
  42. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/audit_logs.py +0 -0
  43. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/cache.py +0 -0
  44. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/groups.py +0 -0
  45. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/models.py +0 -0
  46. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/permissions.py +0 -0
  47. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/admin/ui.py +0 -0
  48. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/auth/__init__.py +0 -0
  49. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/auth/password.py +0 -0
  50. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/auth/session.py +0 -0
  51. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/cache.py +0 -0
  52. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/cli.py +0 -0
  53. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/__init__.py +0 -0
  54. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/addsuperuser.py +0 -0
  55. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/__init__.py +0 -0
  56. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/add_to_group.py +0 -0
  57. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/create_group.py +0 -0
  58. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/create_permissions.py +0 -0
  59. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/create_user.py +0 -0
  60. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/delete_group.py +0 -0
  61. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/delete_user.py +0 -0
  62. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/list_groups.py +0 -0
  63. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/list_resources.py +0 -0
  64. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/list_users.py +0 -0
  65. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/admin/remove_from_group.py +0 -0
  66. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/commands/serve.py +0 -0
  67. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/models.py +0 -0
  68. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/permissions.py +0 -0
  69. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/__init__.py +0 -0
  70. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/csv_plugin.py +0 -0
  71. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/excel_plugin.py +0 -0
  72. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/html_plugin.py +0 -0
  73. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/markdown_plugin.py +0 -0
  74. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/parquet_plugin.py +0 -0
  75. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/plugins/python_plugin.py +0 -0
  76. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/security_urls.py +0 -0
  77. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/static/admin/app.js +0 -0
  78. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/static/admin/style.css +0 -0
  79. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/admin/index.html +0 -0
  80. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/admin_base.html +0 -0
  81. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/datatable.html +0 -0
  82. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/login.html +0 -0
  83. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/media_gallery.html +0 -0
  84. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/media_player.html +0 -0
  85. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/templates/profile.html +0 -0
  86. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt/utils/query.py +0 -0
  87. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt_server.egg-info/dependency_links.txt +0 -0
  88. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt_server.egg-info/entry_points.txt +0 -0
  89. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt_server.egg-info/requires.txt +0 -0
  90. {adapt_server-0.2.0 → adapt_server-0.2.3}/adapt_server.egg-info/top_level.txt +0 -0
  91. {adapt_server-0.2.0 → adapt_server-0.2.3}/setup.cfg +0 -0
  92. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_admin.py +0 -0
  93. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_auth.py +0 -0
  94. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_config.py +0 -0
  95. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_constraints.py +0 -0
  96. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_csv_plugin.py +0 -0
  97. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_dataset_plugin.py +0 -0
  98. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_discovery.py +0 -0
  99. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_html_plugin.py +0 -0
  100. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_locks.py +0 -0
  101. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_markdown_plugin.py +0 -0
  102. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_on_delete.py +0 -0
  103. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_parquet_plugin.py +0 -0
  104. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_plugin_interface.py +0 -0
  105. {adapt_server-0.2.0 → adapt_server-0.2.3}/tests/test_python_plugin.py +0 -0
  106. {adapt_server-0.2.0 → 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.0
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.1.1"
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
@@ -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 datetime import datetime, timezone
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 = init_database(config.db_path)
84
- cache.configure(str(config.db_path))
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
- media_resources = [r for r in request.app.state.resources if r.resource_type == "media"]
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 media_resources:
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 there are media files
260
- if any(r.resource_type == "media" for r in request.app.state.resources):
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
- resources = [r.relative_path.as_posix() for r in request.app.state.resources]
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,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: