adapt-server 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {adapt_server-0.2.0/adapt_server.egg-info → adapt_server-0.2.2}/PKG-INFO +1 -1
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/__init__.py +1 -1
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/resources.py +5 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/app.py +179 -8
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/check.py +2 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/list_endpoints.py +5 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/config.py +2 -1
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/dataset_plugin.py +14 -2
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/media_plugin.py +4 -6
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/base.html +8 -2
- adapt_server-0.2.2/adapt/templates/landing.html +93 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/utils/__init__.py +4 -10
- {adapt_server-0.2.0 → adapt_server-0.2.2/adapt_server.egg-info}/PKG-INFO +1 -1
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt_server.egg-info/SOURCES.txt +1 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/pyproject.toml +1 -1
- adapt_server-0.2.2/tests/test_cli_integration.py +43 -0
- adapt_server-0.2.2/tests/test_integration.py +527 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_phase3.py +8 -3
- adapt_server-0.2.2/tests/test_readonly.py +120 -0
- adapt_server-0.2.0/adapt/templates/landing.html +0 -56
- adapt_server-0.2.0/tests/test_integration.py +0 -273
- adapt_server-0.2.0/tests/test_readonly.py +0 -70
- {adapt_server-0.2.0 → adapt_server-0.2.2}/LICENSE +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/README.md +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/__main__.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/__init__.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/api_keys.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/audit_logs.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/cache.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/groups.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/locks.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/models.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/permissions.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/ui.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/admin/users.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/api_keys.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/audit.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/auth/__init__.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/auth/dependencies.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/auth/password.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/auth/routes.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/auth/session.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/cache.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/cli.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/__init__.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/addsuperuser.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/__init__.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/add_to_group.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/create_group.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/create_permissions.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/create_user.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/delete_group.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/delete_user.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/list_groups.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/list_resources.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/list_users.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/admin/remove_from_group.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/commands/serve.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/discovery.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/locks.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/models.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/permissions.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/__init__.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/base.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/csv_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/excel_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/html_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/markdown_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/parquet_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/plugins/python_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/routes.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/security.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/security_urls.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/static/admin/app.js +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/static/admin/style.css +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/storage.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/admin/index.html +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/admin_base.html +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/datatable.html +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/login.html +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/media_gallery.html +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/media_player.html +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/templates/profile.html +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt/utils/query.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt_server.egg-info/dependency_links.txt +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt_server.egg-info/entry_points.txt +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt_server.egg-info/requires.txt +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/adapt_server.egg-info/top_level.txt +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/setup.cfg +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_admin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_auth.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_config.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_constraints.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_csv_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_dataset_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_discovery.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_html_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_locks.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_markdown_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_on_delete.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_parquet_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_plugin_interface.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_python_plugin.py +0 -0
- {adapt_server-0.2.0 → adapt_server-0.2.2}/tests/test_security_urls.py +0 -0
|
@@ -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:
|
|
@@ -6,8 +6,11 @@ from contextlib import asynccontextmanager
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from fastapi import FastAPI, Request, Depends
|
|
9
|
+
from fastapi.openapi.docs import get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html
|
|
10
|
+
from fastapi.openapi.utils import get_openapi
|
|
9
11
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
10
12
|
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
|
13
|
+
from fastapi.routing import APIRoute
|
|
11
14
|
from datetime import datetime, timezone
|
|
12
15
|
import time
|
|
13
16
|
from .auth.dependencies import get_current_user
|
|
@@ -35,6 +38,10 @@ from .security_urls import login_redirect_url
|
|
|
35
38
|
|
|
36
39
|
logger = logging.getLogger(__name__)
|
|
37
40
|
|
|
41
|
+
_DOCS_INTERNAL_PATHS = frozenset({"/docs", "/docs/", "/docs/oauth2-redirect", "/openapi.json"})
|
|
42
|
+
_PUBLIC_OPENAPI_PATHS = frozenset({"/", "/auth/login", "/health"})
|
|
43
|
+
_AUTHENTICATED_OPENAPI_PATHS = frozenset({"/auth/logout", "/auth/me", "/profile", "/api/apikeys"})
|
|
44
|
+
|
|
38
45
|
|
|
39
46
|
async def cleanup_expired_sessions(engine, interval_hours=24):
|
|
40
47
|
"""Background task to clean up expired sessions."""
|
|
@@ -52,6 +59,132 @@ async def cleanup_expired_sessions(engine, interval_hours=24):
|
|
|
52
59
|
logger.info("Cleaned up %d expired sessions", result.rowcount)
|
|
53
60
|
|
|
54
61
|
|
|
62
|
+
def _normalize_path(path: str) -> str:
|
|
63
|
+
"""Normalize route paths so matching is consistent with and without trailing slashes."""
|
|
64
|
+
normalized = path.rstrip("/")
|
|
65
|
+
return normalized or "/"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resource_namespaces(resource) -> set[str]:
|
|
69
|
+
"""Return the supported permission namespaces for a discovered resource."""
|
|
70
|
+
namespace_no_ext = resource.relative_path.with_suffix("").as_posix()
|
|
71
|
+
namespace_with_ext = resource.relative_path.as_posix()
|
|
72
|
+
if "sub_namespace" in resource.metadata:
|
|
73
|
+
suffix = f"/{resource.metadata['sub_namespace']}"
|
|
74
|
+
namespace_no_ext += suffix
|
|
75
|
+
namespace_with_ext += suffix
|
|
76
|
+
return {namespace_no_ext, namespace_with_ext}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _all_resource_namespaces(resources) -> set[str]:
|
|
80
|
+
"""Collect all permission namespaces exposed by discovered resources."""
|
|
81
|
+
namespaces: set[str] = set()
|
|
82
|
+
for resource in resources:
|
|
83
|
+
namespaces.update(_resource_namespaces(resource))
|
|
84
|
+
return namespaces
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _visible_resource_namespaces(request: Request, user: User | None) -> set[str]:
|
|
88
|
+
"""Return the resource namespaces that should be visible to the current user."""
|
|
89
|
+
resources = request.app.state.resources
|
|
90
|
+
if not user:
|
|
91
|
+
return set()
|
|
92
|
+
|
|
93
|
+
if getattr(user, "is_superuser", False):
|
|
94
|
+
return _all_resource_namespaces(resources)
|
|
95
|
+
|
|
96
|
+
from .permissions import PermissionChecker
|
|
97
|
+
|
|
98
|
+
visible: set[str] = set()
|
|
99
|
+
with Session(request.app.state.db_engine) as db:
|
|
100
|
+
checker = PermissionChecker(db)
|
|
101
|
+
for resource in resources:
|
|
102
|
+
for namespace in _resource_namespaces(resource):
|
|
103
|
+
if checker.has_permission(user, namespace, "read"):
|
|
104
|
+
visible.add(namespace)
|
|
105
|
+
return visible
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _extract_resource_namespace(path: str, all_namespaces: set[str]) -> str | None:
|
|
109
|
+
"""Extract the resource namespace from a route path if it maps to a discovered resource."""
|
|
110
|
+
normalized_path = _normalize_path(path)
|
|
111
|
+
stripped_path = normalized_path.lstrip("/")
|
|
112
|
+
if stripped_path in all_namespaces:
|
|
113
|
+
return stripped_path
|
|
114
|
+
|
|
115
|
+
for prefix in ("/api/", "/schema/", "/ui/", "/media/"):
|
|
116
|
+
if normalized_path.startswith(prefix):
|
|
117
|
+
candidate = normalized_path[len(prefix):]
|
|
118
|
+
if candidate in all_namespaces:
|
|
119
|
+
return candidate
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _route_is_visible(route: APIRoute, request: Request, user: User | None, all_namespaces: set[str], visible_namespaces: set[str]) -> bool:
|
|
124
|
+
"""Decide whether a route should appear in the current request's OpenAPI schema."""
|
|
125
|
+
path = _normalize_path(route.path)
|
|
126
|
+
|
|
127
|
+
if path in _DOCS_INTERNAL_PATHS:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
if path.startswith("/admin") or "admin" in (route.tags or []):
|
|
131
|
+
return bool(user and getattr(user, "is_superuser", False))
|
|
132
|
+
|
|
133
|
+
if path in _PUBLIC_OPENAPI_PATHS:
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
if path in _AUTHENTICATED_OPENAPI_PATHS:
|
|
137
|
+
return user is not None
|
|
138
|
+
|
|
139
|
+
if path == "/ui/media":
|
|
140
|
+
media_namespaces = {
|
|
141
|
+
ns
|
|
142
|
+
for r in request.app.state.resources
|
|
143
|
+
if r.resource_type == "media"
|
|
144
|
+
for ns in _resource_namespaces(r)
|
|
145
|
+
}
|
|
146
|
+
return bool(media_namespaces & visible_namespaces)
|
|
147
|
+
|
|
148
|
+
resource_namespace = _extract_resource_namespace(path, all_namespaces)
|
|
149
|
+
if resource_namespace is not None:
|
|
150
|
+
return resource_namespace in visible_namespaces
|
|
151
|
+
|
|
152
|
+
if path.startswith("/auth/"):
|
|
153
|
+
return user is not None
|
|
154
|
+
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_openapi_schema(app: FastAPI, request: Request, user: User | None) -> dict:
|
|
159
|
+
"""Build a filtered OpenAPI schema for the current request context."""
|
|
160
|
+
all_namespaces = _all_resource_namespaces(request.app.state.resources)
|
|
161
|
+
visible_namespaces = _visible_resource_namespaces(request, user)
|
|
162
|
+
visible_routes = [
|
|
163
|
+
route
|
|
164
|
+
for route in app.routes
|
|
165
|
+
if isinstance(route, APIRoute)
|
|
166
|
+
and route.include_in_schema
|
|
167
|
+
and _route_is_visible(route, request, user, all_namespaces, visible_namespaces)
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
return get_openapi(
|
|
171
|
+
title=app.title,
|
|
172
|
+
version=app.version,
|
|
173
|
+
description=app.description,
|
|
174
|
+
routes=visible_routes,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _visible_resource_paths(request: Request, user: User | None) -> list[str]:
|
|
179
|
+
"""Return resource paths visible to the current user for JSON discovery responses."""
|
|
180
|
+
visible_namespaces = _visible_resource_namespaces(request, user)
|
|
181
|
+
visible_resources: list[str] = []
|
|
182
|
+
for resource in request.app.state.resources:
|
|
183
|
+
if _resource_namespaces(resource) & visible_namespaces:
|
|
184
|
+
visible_resources.append(resource.relative_path.as_posix())
|
|
185
|
+
return visible_resources
|
|
186
|
+
|
|
187
|
+
|
|
55
188
|
@asynccontextmanager
|
|
56
189
|
async def lifespan(app: FastAPI):
|
|
57
190
|
"""Handle application startup and shutdown events."""
|
|
@@ -68,6 +201,9 @@ async def lifespan(app: FastAPI):
|
|
|
68
201
|
await cleanup_task
|
|
69
202
|
except asyncio.CancelledError:
|
|
70
203
|
logger.debug("Application shutdown: cleanup task cancelled")
|
|
204
|
+
finally:
|
|
205
|
+
engine.dispose()
|
|
206
|
+
logger.debug("Application shutdown: database engine disposed")
|
|
71
207
|
|
|
72
208
|
|
|
73
209
|
def create_app(config: AdaptConfig) -> FastAPI:
|
|
@@ -82,7 +218,7 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
82
218
|
logger.debug("Creating FastAPI app with config: %s", config)
|
|
83
219
|
engine = init_database(config.db_path)
|
|
84
220
|
cache.configure(str(config.db_path))
|
|
85
|
-
app = FastAPI(title="Adapt", version=config.version, lifespan=lifespan)
|
|
221
|
+
app = FastAPI(title="Adapt", version=config.version, lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
|
|
86
222
|
app.state.config = config
|
|
87
223
|
app.state.db_engine = engine
|
|
88
224
|
app.state.use_tls = bool(config.tls_cert and config.tls_key)
|
|
@@ -168,6 +304,27 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
168
304
|
|
|
169
305
|
# Generate and mount routes
|
|
170
306
|
generate_routes(app, resources, config)
|
|
307
|
+
|
|
308
|
+
@app.get("/openapi.json", include_in_schema=False)
|
|
309
|
+
def openapi_schema(request: Request):
|
|
310
|
+
"""Return an OpenAPI document filtered to the current user's visible routes."""
|
|
311
|
+
user = get_current_user(request)
|
|
312
|
+
return JSONResponse(_build_openapi_schema(app, request, user))
|
|
313
|
+
|
|
314
|
+
@app.get("/docs", include_in_schema=False)
|
|
315
|
+
@app.get("/docs/", include_in_schema=False)
|
|
316
|
+
def swagger_ui():
|
|
317
|
+
"""Render Swagger UI against the request-filtered OpenAPI schema."""
|
|
318
|
+
return get_swagger_ui_html(
|
|
319
|
+
openapi_url="/openapi.json",
|
|
320
|
+
title=f"{app.title} - API Docs",
|
|
321
|
+
oauth2_redirect_url="/docs/oauth2-redirect",
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
@app.get("/docs/oauth2-redirect", include_in_schema=False)
|
|
325
|
+
def swagger_ui_redirect():
|
|
326
|
+
"""Serve the Swagger UI OAuth redirect helper."""
|
|
327
|
+
return get_swagger_ui_oauth2_redirect_html()
|
|
171
328
|
|
|
172
329
|
@app.get("/health", tags=["system"])
|
|
173
330
|
async def health(request: Request, user=Depends(get_current_user)):
|
|
@@ -215,11 +372,24 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
215
372
|
logger.debug("Unauthenticated access to media gallery, redirecting to login")
|
|
216
373
|
return RedirectResponse(url=login_redirect_url("/ui/media"), status_code=302)
|
|
217
374
|
|
|
218
|
-
|
|
375
|
+
all_media = [r for r in request.app.state.resources if r.resource_type == "media"]
|
|
376
|
+
if getattr(user, "is_superuser", False):
|
|
377
|
+
permitted_media = all_media
|
|
378
|
+
else:
|
|
379
|
+
from .permissions import PermissionChecker
|
|
380
|
+
with Session(engine) as db:
|
|
381
|
+
checker = PermissionChecker(db)
|
|
382
|
+
permitted_media = [
|
|
383
|
+
r for r in all_media
|
|
384
|
+
if checker.has_permission(user, r.relative_path.with_suffix("").as_posix(), "read")
|
|
385
|
+
]
|
|
386
|
+
if not permitted_media and not getattr(user, "is_superuser", False):
|
|
387
|
+
from fastapi import HTTPException
|
|
388
|
+
logger.warning("Permission denied for user %s: no accessible media resources", user.username)
|
|
389
|
+
raise HTTPException(status_code=403, detail="No accessible media resources")
|
|
390
|
+
|
|
219
391
|
media_items = []
|
|
220
|
-
for r in
|
|
221
|
-
# Check permission, but for simplicity, assume read permission
|
|
222
|
-
# In full impl, check permission
|
|
392
|
+
for r in permitted_media:
|
|
223
393
|
media_items.append({
|
|
224
394
|
"name": r.path.name,
|
|
225
395
|
"relative_path": r.relative_path.as_posix(),
|
|
@@ -256,8 +426,8 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
256
426
|
user = get_current_user(request)
|
|
257
427
|
accessible_resources = build_accessible_ui_links(request, user)
|
|
258
428
|
|
|
259
|
-
# Add media gallery if
|
|
260
|
-
if any(
|
|
429
|
+
# Add media gallery link only if the user can access at least one media file
|
|
430
|
+
if any(link["type"] == "media" for link in accessible_resources):
|
|
261
431
|
accessible_resources.append({"name": "Media Gallery", "url": "/ui/media", "type": "media"})
|
|
262
432
|
|
|
263
433
|
context = {
|
|
@@ -269,7 +439,8 @@ def create_app(config: AdaptConfig) -> FastAPI:
|
|
|
269
439
|
return request.app.state.templates.TemplateResponse(request, "landing.html", context)
|
|
270
440
|
else:
|
|
271
441
|
# JSON API response
|
|
272
|
-
|
|
442
|
+
user = get_current_user(request)
|
|
443
|
+
resources = _visible_resource_paths(request, user)
|
|
273
444
|
logger.debug("Returning JSON API response with %d resources", len(resources))
|
|
274
445
|
return {"resources": resources}
|
|
275
446
|
|
|
@@ -4,6 +4,7 @@ import logging
|
|
|
4
4
|
from ..config import AdaptConfig
|
|
5
5
|
from ..discovery import discover_resources
|
|
6
6
|
from ..storage import init_database
|
|
7
|
+
from .. import cache
|
|
7
8
|
|
|
8
9
|
logger = logging.getLogger(__name__)
|
|
9
10
|
|
|
@@ -23,6 +24,7 @@ def run_check(root: Path) -> None:
|
|
|
23
24
|
config = AdaptConfig(root=root)
|
|
24
25
|
config.load_from_file()
|
|
25
26
|
engine = init_database(config.db_path)
|
|
27
|
+
cache.configure(str(config.db_path))
|
|
26
28
|
resources = discover_resources(config.root, config)
|
|
27
29
|
count = len(resources)
|
|
28
30
|
logger.info("Discovered %d dataset(s) in root %s", count, config.root)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
import logging
|
|
3
3
|
|
|
4
|
+
from .. import cache
|
|
4
5
|
from ..config import AdaptConfig
|
|
5
6
|
from ..discovery import discover_resources
|
|
7
|
+
from ..storage import init_database
|
|
6
8
|
|
|
7
9
|
logger = logging.getLogger(__name__)
|
|
8
10
|
|
|
@@ -20,6 +22,9 @@ def run_list_endpoints(root: Path) -> None:
|
|
|
20
22
|
None
|
|
21
23
|
"""
|
|
22
24
|
config = AdaptConfig(root=root)
|
|
25
|
+
config.load_from_file()
|
|
26
|
+
init_database(config.db_path)
|
|
27
|
+
cache.configure(str(config.db_path))
|
|
23
28
|
resources = discover_resources(config.root, config)
|
|
24
29
|
if not resources:
|
|
25
30
|
logger.info("No resources discovered in root %s", config.root)
|
|
@@ -9,6 +9,7 @@ import json
|
|
|
9
9
|
import os
|
|
10
10
|
import sys
|
|
11
11
|
|
|
12
|
+
from adapt import __version__ as adapt_version
|
|
12
13
|
logger = logging.getLogger(__name__)
|
|
13
14
|
|
|
14
15
|
|
|
@@ -31,7 +32,7 @@ class AdaptConfig:
|
|
|
31
32
|
port: int = 8000
|
|
32
33
|
readonly: bool = False
|
|
33
34
|
debug: bool = False
|
|
34
|
-
version: str =
|
|
35
|
+
version: str = adapt_version
|
|
35
36
|
tls_cert: Path | None = None
|
|
36
37
|
tls_key: Path | None = None
|
|
37
38
|
secure_cookies: bool = False # Whether to set secure flag on cookies
|
|
@@ -7,8 +7,10 @@ import logging
|
|
|
7
7
|
|
|
8
8
|
from fastapi import Request
|
|
9
9
|
from fastapi.routing import APIRouter
|
|
10
|
+
from sqlmodel import Session
|
|
10
11
|
|
|
11
12
|
from ..utils import build_ui_links
|
|
13
|
+
from ..auth.dependencies import check_permission
|
|
12
14
|
from .base import Plugin, ResourceDescriptor, PluginContext, ensure_file
|
|
13
15
|
|
|
14
16
|
logger = logging.getLogger(__name__)
|
|
@@ -343,7 +345,18 @@ class DatasetPlugin(Plugin):
|
|
|
343
345
|
@ui_router.get("/", response_class=HTMLResponse)
|
|
344
346
|
def get_ui(request: Request):
|
|
345
347
|
"""Get the UI for the dataset."""
|
|
346
|
-
|
|
348
|
+
# UI should be read-only when either the server is read-only or the user lacks write permission.
|
|
349
|
+
user = getattr(request.state, "user", None)
|
|
350
|
+
can_write = not request.app.state.config.readonly
|
|
351
|
+
if can_write and user is not None:
|
|
352
|
+
resource_namespace = request.url.path
|
|
353
|
+
if resource_namespace.startswith("/ui/"):
|
|
354
|
+
resource_namespace = resource_namespace[len("/ui/"):]
|
|
355
|
+
resource_namespace = resource_namespace.strip("/")
|
|
356
|
+
with Session(request.app.state.db_engine) as db:
|
|
357
|
+
can_write = check_permission(user, db, "write", resource_namespace)
|
|
358
|
+
|
|
359
|
+
template_name, context = self.get_ui_template(descriptor, readonly=not can_write)
|
|
347
360
|
|
|
348
361
|
# Calculate API URL from UI URL
|
|
349
362
|
# Assumes /ui/... -> /api/... mapping
|
|
@@ -362,7 +375,6 @@ class DatasetPlugin(Plugin):
|
|
|
362
375
|
})
|
|
363
376
|
|
|
364
377
|
# Add common navbar context
|
|
365
|
-
user = getattr(request.state, 'user', None)
|
|
366
378
|
is_superuser = user and getattr(user, 'is_superuser', False)
|
|
367
379
|
ui_links = build_ui_links(request)
|
|
368
380
|
context.update({
|
|
@@ -132,13 +132,11 @@ class MediaPlugin(Plugin):
|
|
|
132
132
|
@router_ui.get("")
|
|
133
133
|
def media_player(request: Request):
|
|
134
134
|
"""Serve the media player UI."""
|
|
135
|
-
from ..auth.dependencies import get_current_user
|
|
136
135
|
from ..utils import build_accessible_ui_links
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
136
|
+
# Auth and permission are enforced by the route dependency injected in routes.py;
|
|
137
|
+
# request.state.user is populated by auth_middleware.
|
|
138
|
+
user = getattr(request.state, "user", None)
|
|
139
|
+
|
|
142
140
|
media_url = f"/media/{descriptor.path.relative_to(request.app.state.config.root).as_posix()}"
|
|
143
141
|
accessible_resources = build_accessible_ui_links(request, user)
|
|
144
142
|
context = {
|
|
@@ -26,10 +26,14 @@
|
|
|
26
26
|
{% endfor %}
|
|
27
27
|
</ul>
|
|
28
28
|
</li>
|
|
29
|
+
{% for link in ui_links %}
|
|
30
|
+
{% if link.name == "Media Gallery" %}
|
|
29
31
|
<li class="nav-item">
|
|
30
|
-
<a class="nav-link" href="
|
|
32
|
+
<a class="nav-link" href="{{ link.url }}">Media</a>
|
|
31
33
|
</li>
|
|
32
34
|
{% endif %}
|
|
35
|
+
{% endfor %}
|
|
36
|
+
{% endif %}
|
|
33
37
|
{% if is_superuser %}
|
|
34
38
|
<li class="nav-item">
|
|
35
39
|
<a class="nav-link" href="/admin/">Admin</a>
|
|
@@ -39,7 +43,7 @@
|
|
|
39
43
|
<div class="collapse navbar-collapse" id="navbarNav">
|
|
40
44
|
<ul class="navbar-nav me-auto">
|
|
41
45
|
<li class="nav-item">
|
|
42
|
-
<a class="nav-link" href="/docs">Docs</a>
|
|
46
|
+
<a class="nav-link" href="/docs">API Docs</a>
|
|
43
47
|
</li>
|
|
44
48
|
{% if user %}
|
|
45
49
|
<li class="nav-item">
|
|
@@ -48,10 +52,12 @@
|
|
|
48
52
|
{% endif %}
|
|
49
53
|
</ul>
|
|
50
54
|
</div>
|
|
55
|
+
{% if user %}
|
|
51
56
|
<form class="d-flex" method="post" action="/auth/logout">
|
|
52
57
|
<input type="hidden" name="csrf_token" value="{{ request.state.csrf_token if request and request.state else '' }}">
|
|
53
58
|
<button class="btn btn-outline-light" type="submit">Logout</button>
|
|
54
59
|
</form>
|
|
60
|
+
{% endif %}
|
|
55
61
|
</div>
|
|
56
62
|
</nav>
|
|
57
63
|
<div class="container-fluid mt-4">
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}Adapt - Adaptive File-Backed Web Server{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<div class="row g-4 align-items-stretch">
|
|
7
|
+
{% if user %}
|
|
8
|
+
<div class="col-lg-8">
|
|
9
|
+
<div class="p-4 p-lg-5 bg-light border rounded-3 h-100">
|
|
10
|
+
<span class="badge text-bg-dark mb-3">Signed in</span>
|
|
11
|
+
<h1 class="display-6">Welcome to Adapt</h1>
|
|
12
|
+
<p class="lead mb-4">Your workspace is ready. Adapt turns files in your document root into APIs, HTML UIs, and media views without additional app wiring.</p>
|
|
13
|
+
|
|
14
|
+
{% if ui_links %}
|
|
15
|
+
<h2 class="h4 mb-3">Your Accessible Resources</h2>
|
|
16
|
+
<div class="list-group shadow-sm">
|
|
17
|
+
{% for res in ui_links %}
|
|
18
|
+
<a href="{{ res.url }}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
|
19
|
+
<span>
|
|
20
|
+
<strong>{{ res.name }}</strong>
|
|
21
|
+
<span class="text-body-secondary">
|
|
22
|
+
{% if res.type == 'csv' %}CSV dataset{% elif res.type == 'excel' %}Excel workbook{% elif res.type == 'html' %}HTML page{% elif res.type == 'markdown' %}Markdown document{% elif res.type == 'media' %}Media collection{% else %}Dataset{% endif %}
|
|
23
|
+
</span>
|
|
24
|
+
</span>
|
|
25
|
+
<span aria-hidden="true">Open</span>
|
|
26
|
+
</a>
|
|
27
|
+
{% endfor %}
|
|
28
|
+
</div>
|
|
29
|
+
{% else %}
|
|
30
|
+
<div class="alert alert-info mb-0">
|
|
31
|
+
<h2 class="h5">No accessible resources found yet.</h2>
|
|
32
|
+
<p class="mb-0">If you expected to see data here, contact an administrator to assign the right permissions. {% if is_superuser %}You can also review access rules in the <a href="/admin/">Admin Dashboard</a>.{% endif %}</p>
|
|
33
|
+
</div>
|
|
34
|
+
{% endif %}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="col-lg-4">
|
|
38
|
+
<div class="card h-100 shadow-sm">
|
|
39
|
+
<div class="card-header">Next Steps</div>
|
|
40
|
+
<div class="card-body d-flex flex-column">
|
|
41
|
+
<h2 class="h5 card-title">Use your workspace</h2>
|
|
42
|
+
<p class="card-text">Browse the resources you can access, review your profile, and use the generated APIs or UI views for each provisioned file.</p>
|
|
43
|
+
<div class="mt-auto d-grid gap-2">
|
|
44
|
+
<a href="/profile" class="btn btn-primary">Open Profile</a>
|
|
45
|
+
<a href="https://github.com/McIndi/adapt" class="btn btn-outline-secondary">Documentation</a>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
{% if is_superuser %}
|
|
50
|
+
<div class="card mt-3 shadow-sm">
|
|
51
|
+
<div class="card-header">Admin Panel</div>
|
|
52
|
+
<div class="card-body">
|
|
53
|
+
<p class="card-text">Manage users, groups, permissions, and system settings.</p>
|
|
54
|
+
<a href="/admin/" class="btn btn-secondary">Go to Admin</a>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
{% endif %}
|
|
58
|
+
</div>
|
|
59
|
+
{% else %}
|
|
60
|
+
<div class="col-lg-7">
|
|
61
|
+
<div class="p-4 p-lg-5 bg-light border rounded-3 h-100">
|
|
62
|
+
<span class="badge text-bg-secondary mb-3">Authentication required</span>
|
|
63
|
+
<h1 class="display-6">Sign in to access this Adapt workspace</h1>
|
|
64
|
+
<p class="lead">This landing page is public, but the workspace contents are intended for authenticated users only.</p>
|
|
65
|
+
<p class="mb-4">Use your existing account to continue. If you do not have access yet, contact your Adapt administrator and ask them to provision an account and assign the right permissions.</p>
|
|
66
|
+
<div class="d-flex flex-wrap gap-2">
|
|
67
|
+
<a href="/auth/login" class="btn btn-primary btn-lg">Sign In</a>
|
|
68
|
+
<a href="https://github.com/McIndi/adapt" class="btn btn-outline-secondary btn-lg">Read the Docs</a>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="col-lg-5">
|
|
73
|
+
<div class="card h-100 shadow-sm">
|
|
74
|
+
<div class="card-header">What happens after login?</div>
|
|
75
|
+
<div class="card-body">
|
|
76
|
+
<div class="mb-3">
|
|
77
|
+
<h2 class="h6">Provisioned resources</h2>
|
|
78
|
+
<p class="mb-0">Datasets, documents, HTML pages, and media views appear automatically when your account has permission to see them.</p>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="mb-3">
|
|
81
|
+
<h2 class="h6">Least-privilege access</h2>
|
|
82
|
+
<p class="mb-0">Most routes are restricted, so you will only see the files and interfaces your account is allowed to use.</p>
|
|
83
|
+
</div>
|
|
84
|
+
<div>
|
|
85
|
+
<h2 class="h6">Need an account?</h2>
|
|
86
|
+
<p class="mb-0">Share the workspace URL with your admin and ask them to create a user and place you in the appropriate permission groups.</p>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
{% endif %}
|
|
92
|
+
</div>
|
|
93
|
+
{% endblock %}
|
|
@@ -63,20 +63,14 @@ def build_accessible_ui_links(request: Request, user: User | None) -> list[dict[
|
|
|
63
63
|
namespace = res.relative_path.with_suffix("").as_posix()
|
|
64
64
|
if "sub_namespace" in res.metadata:
|
|
65
65
|
namespace += f"/{res.metadata['sub_namespace']}"
|
|
66
|
+
if not checker.has_permission(user, namespace, "read"):
|
|
67
|
+
continue
|
|
66
68
|
if res.resource_type in ("html", "markdown"):
|
|
67
|
-
# Assume public
|
|
68
69
|
url = f"/{namespace}"
|
|
69
|
-
|
|
70
|
-
elif checker.has_permission(user, namespace, "read"):
|
|
70
|
+
else:
|
|
71
71
|
url = f"/ui/{namespace}"
|
|
72
|
-
accessible_resources.append({"name": namespace, "url": url, "type": res.resource_type})
|
|
73
|
-
else:
|
|
74
|
-
# For unauthenticated, show only public html/markdown
|
|
75
|
-
for res in resources:
|
|
76
|
-
if res.resource_type in ("html", "markdown"):
|
|
77
|
-
namespace = res.relative_path.with_suffix("").as_posix()
|
|
78
|
-
url = f"/{namespace}"
|
|
79
72
|
accessible_resources.append({"name": namespace, "url": url, "type": res.resource_type})
|
|
73
|
+
# Unauthenticated users see nothing — all resources require explicit permission.
|
|
80
74
|
|
|
81
75
|
logger.debug("Built %d accessible UI links for user %s", len(accessible_resources), user.username if user else None)
|
|
82
76
|
return accessible_resources
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "adapt-server"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Adaptive file-backed FastAPI server that turns datasets into CRUD APIs and UIs."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from adapt.commands.admin import run_create_permissions
|
|
2
|
+
from adapt.commands.check import run_check
|
|
3
|
+
from adapt.commands.list_endpoints import run_list_endpoints
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _seed_workspace(root):
|
|
7
|
+
# Include a media extension to exercise MediaPlugin cache reads during discovery.
|
|
8
|
+
(root / "data.csv").write_text("name,age\nAlice,30\nBob,25\n")
|
|
9
|
+
(root / "clip.mp3").write_bytes(b"not-a-real-mp3")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_run_check_discovers_media_without_cache_table_error(tmp_path, capsys):
|
|
13
|
+
_seed_workspace(tmp_path)
|
|
14
|
+
|
|
15
|
+
run_check(tmp_path)
|
|
16
|
+
|
|
17
|
+
output = capsys.readouterr().out
|
|
18
|
+
assert "Document root:" in output
|
|
19
|
+
assert "Discovered" in output
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_run_list_endpoints_discovers_media_without_cache_table_error(tmp_path, capsys):
|
|
23
|
+
_seed_workspace(tmp_path)
|
|
24
|
+
|
|
25
|
+
run_list_endpoints(tmp_path)
|
|
26
|
+
|
|
27
|
+
output = capsys.readouterr().out
|
|
28
|
+
assert "/api/data" in output
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_run_create_permissions_all_with_media_does_not_crash(tmp_path, capsys):
|
|
32
|
+
_seed_workspace(tmp_path)
|
|
33
|
+
|
|
34
|
+
run_create_permissions(
|
|
35
|
+
root=tmp_path,
|
|
36
|
+
resources=["__all__"],
|
|
37
|
+
all_group_name="all-rw",
|
|
38
|
+
read_group_name="all-ro",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
output = capsys.readouterr().out
|
|
42
|
+
assert "Using all" in output
|
|
43
|
+
assert "Permissions and groups created successfully." in output
|