webduck 1.0.0__py3-none-any.whl

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.
webduck/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """WebDuck - A DuckDB server with REST API and Web UI."""
@@ -0,0 +1 @@
1
+ """WebDuck REST API endpoints."""
webduck/api/admin.py ADDED
@@ -0,0 +1,371 @@
1
+ # ------------------------------------------------------------------------------
2
+ # Copyright (c) 2026 autumo GmbH. All rights reserved.
3
+ #
4
+ # Licensed under the MIT License. See LICENSE file in the project root for
5
+ # full license information.
6
+ #
7
+ # NOTICE: This file is part of WebDuck. The above copyright notice and this
8
+ # permission notice shall be included in all copies or substantial portions
9
+ # of this software.
10
+ # ------------------------------------------------------------------------------
11
+
12
+ # =============================================================================
13
+ # WebDuck — Admin REST API
14
+ # ---------------------------------------------------------------------------
15
+ # JWT-protected admin endpoints for user, project, and database management.
16
+ #
17
+ # Endpoints:
18
+ # POST /admin/login — Authenticate, get JWT
19
+ # GET /admin/projects — List projects
20
+ # POST /admin/projects — Create project
21
+ # DELETE /admin/projects/{project} — Delete project
22
+ # GET /admin/projects/{project}/databases — List databases
23
+ # POST /admin/projects/{project}/databases — Create database
24
+ # DELETE /admin/projects/{project}/databases/{db} — Delete database
25
+ # PUT /admin/projects/{p}/databases/{db}/password — Set DB password
26
+ # GET /admin/users — List admin users
27
+ # POST /admin/users — Create admin user
28
+ # DELETE /admin/users/{username} — Delete admin user
29
+ #
30
+ # Project: WebDuck
31
+ # Author: autumo GmbH
32
+ # Version: 1.0.0
33
+ # Date: 2026-07-20
34
+ # =============================================================================
35
+
36
+ """WebDuck REST API - Admin endpoints (JWT protected)."""
37
+
38
+ from fastapi import APIRouter, Depends, HTTPException, status
39
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
40
+ from pydantic import BaseModel
41
+
42
+ from webduck.auth.manager import AuthManager
43
+ from webduck.logging import log_error, log_warning
44
+ from webduck.storage.engine import StorageEngine
45
+
46
+ router = APIRouter(prefix="/admin", tags=["admin"])
47
+
48
+ # HTTPBearer extracts the "Authorization: Bearer <token>" header on each request.
49
+ # FastAPI's Depends(security) injects the parsed credentials into endpoints.
50
+ security = HTTPBearer()
51
+
52
+ # These module-level globals are injected once at startup via set_dependencies().
53
+ # They act as singletons for the auth and storage layers used by every endpoint.
54
+ auth_manager: AuthManager | None = None
55
+ storage_engine: StorageEngine | None = None
56
+
57
+
58
+ def set_dependencies(auth: AuthManager, storage: StorageEngine) -> None:
59
+ """Set dependencies for admin endpoints.
60
+
61
+ Called once during app startup from main.py to wire in the real
62
+ AuthManager and StorageEngine instances. All admin endpoints depend
63
+ on these being set before serving requests.
64
+ """
65
+ global auth_manager, storage_engine
66
+ auth_manager = auth
67
+ storage_engine = storage
68
+
69
+
70
+ async def verify_admin(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
71
+ """Verify admin JWT token.
72
+
73
+ JWT Authentication Flow:
74
+ 1. Client sends request with "Authorization: Bearer <jwt>" header.
75
+ 2. HTTPBearer (Depends(security)) extracts the raw token string.
76
+ 3. verify_jwt_token() decodes and validates the JWT (expiry, signature).
77
+ 4. On success the embedded username is returned and injected into the
78
+ endpoint via the 'username' parameter — proving the caller is authenticated.
79
+ 5. On failure (None returned) a 401 is raised before any endpoint logic runs.
80
+
81
+ This dependency is attached to every protected endpoint:
82
+ username: str = Depends(verify_admin)
83
+ """
84
+ if not auth_manager:
85
+ log_error("verify_admin: Auth manager not initialized")
86
+ raise HTTPException(status_code=500, detail="Auth manager not initialized")
87
+
88
+ # Decode JWT and extract username; returns None if expired/invalid.
89
+ username = auth_manager.verify_jwt_token(credentials.credentials)
90
+ if not username:
91
+ log_warning("verify_admin: Invalid or expired token")
92
+ raise HTTPException(
93
+ status_code=status.HTTP_401_UNAUTHORIZED,
94
+ detail="Invalid or expired token",
95
+ )
96
+ # Return value is injected as 'username' into the calling endpoint.
97
+ return username
98
+
99
+
100
+ # --- Request/Response Models ---
101
+
102
+ class LoginRequest(BaseModel):
103
+ username: str
104
+ password: str
105
+
106
+
107
+ class LoginResponse(BaseModel):
108
+ token: str
109
+ token_type: str = "bearer"
110
+
111
+
112
+ class ProjectRequest(BaseModel):
113
+ name: str
114
+
115
+
116
+ class DatabaseRequest(BaseModel):
117
+ name: str
118
+
119
+
120
+ class PasswordRequest(BaseModel):
121
+ """Request to set a database password.
122
+
123
+ access_level controls what operations the password holder can perform
124
+ against the specific database: 'read' (SELECT only), 'write' (INSERT,
125
+ UPDATE, DELETE), or 'admin' (full DDL + DML).
126
+ """
127
+ password: str
128
+ access_level: str = "read"
129
+
130
+
131
+ # --- Endpoints ---
132
+
133
+ @router.post("/login", response_model=LoginResponse)
134
+ async def login(req: LoginRequest) -> LoginResponse:
135
+ """Login and get JWT token.
136
+
137
+ This is the only unauthenticated admin endpoint. It accepts username +
138
+ password, verifies credentials against the stored bcrypt hash, and
139
+ returns a signed JWT that must be included in all subsequent requests.
140
+ """
141
+ if not auth_manager:
142
+ log_error("login: Auth manager not initialized")
143
+ raise HTTPException(status_code=500, detail="Auth manager not initialized")
144
+
145
+ if not auth_manager.verify_user(req.username, req.password):
146
+ log_warning(f"Failed login attempt for user '{req.username}'")
147
+ raise HTTPException(
148
+ status_code=status.HTTP_401_UNAUTHORIZED,
149
+ detail="Invalid credentials",
150
+ )
151
+
152
+ token = auth_manager.create_jwt_token(req.username)
153
+ return LoginResponse(token=token)
154
+
155
+
156
+ @router.get("/projects")
157
+ async def list_projects(username: str = Depends(verify_admin)) -> list[str]:
158
+ """List all projects."""
159
+ if not storage_engine:
160
+ log_error("list_projects: Storage engine not initialized")
161
+ raise HTTPException(status_code=500, detail="Storage engine not initialized")
162
+ return storage_engine.list_projects()
163
+
164
+
165
+ @router.post("/projects")
166
+ async def create_project(
167
+ req: ProjectRequest, username: str = Depends(verify_admin)
168
+ ) -> dict:
169
+ """Create a new project."""
170
+ if not storage_engine:
171
+ log_error("create_project: Storage engine not initialized")
172
+ raise HTTPException(
173
+ status_code=500,
174
+ detail="Storage engine not initialized",
175
+ )
176
+
177
+ if not storage_engine.create_project(req.name):
178
+ log_warning(f"create_project: Project '{req.name}' already exists")
179
+ raise HTTPException(
180
+ status_code=400,
181
+ detail="Project already exists",
182
+ )
183
+
184
+ return {
185
+ "success": True,
186
+ "message": f"Project '{req.name}' created",
187
+ }
188
+
189
+
190
+ @router.delete("/projects/{project}")
191
+ async def delete_project(project: str, username: str = Depends(verify_admin)) -> dict:
192
+ """Delete a project and all its databases."""
193
+ if not storage_engine:
194
+ log_error("delete_project: Storage engine not initialized")
195
+ raise HTTPException(status_code=500, detail="Storage engine not initialized")
196
+
197
+ if not storage_engine.delete_project(project):
198
+ raise HTTPException(status_code=404, detail="Project not found")
199
+
200
+ return {"success": True, "message": f"Project '{project}' deleted"}
201
+
202
+
203
+ class ReorderRequest(BaseModel):
204
+ """Expects the full desired order as a list of project names.
205
+
206
+ The list must contain every existing project exactly once. The storage
207
+ engine persists this ordering so that subsequent list_projects() calls
208
+ return projects in the user-defined sequence (e.g. for a sidebar or
209
+ dashboard layout).
210
+ """
211
+ projects: list[str]
212
+
213
+
214
+ @router.post("/reorder-projects")
215
+ async def reorder_projects(
216
+ req: ReorderRequest,
217
+ username: str = Depends(verify_admin),
218
+ ) -> dict:
219
+ """Reorder projects.
220
+
221
+ Accepts the complete ordered list of project names and persists the
222
+ new display order. This is used by the UI to let admins drag-and-drop
223
+ projects into a custom sequence.
224
+ """
225
+ if not storage_engine:
226
+ log_error("reorder_projects: Storage engine not initialized")
227
+ raise HTTPException(
228
+ status_code=500,
229
+ detail="Storage engine not initialized",
230
+ )
231
+ storage_engine.reorder_projects(req.projects)
232
+ return {
233
+ "success": True,
234
+ "message": "Projects reordered",
235
+ }
236
+
237
+
238
+ # --- Project-Scoped Database Endpoints ---
239
+ # All database endpoints are nested under a project: /admin/projects/{project}/databases.
240
+ # This enforces a two-level hierarchy: a project groups related databases, and every
241
+ # database operation requires specifying its parent project. This prevents name
242
+ # collisions across projects and keeps the storage layout organized on disk.
243
+
244
+
245
+ @router.get(
246
+ "/projects/{project}/databases"
247
+ )
248
+ async def list_databases(project: str, username: str = Depends(verify_admin)) -> list[str]:
249
+ """List all databases in a project."""
250
+ if not storage_engine:
251
+ log_error("list_databases: Storage engine not initialized")
252
+ raise HTTPException(status_code=500, detail="Storage engine not initialized")
253
+ return storage_engine.list_databases(project)
254
+
255
+
256
+ @router.post("/projects/{project}/databases")
257
+ async def create_database(
258
+ project: str, req: DatabaseRequest, username: str = Depends(verify_admin)
259
+ ) -> dict:
260
+ """Create a new database in a project."""
261
+ if not storage_engine:
262
+ log_error("create_database: Storage engine not initialized")
263
+ raise HTTPException(status_code=500, detail="Storage engine not initialized")
264
+
265
+ if storage_engine.database_exists(project, req.name):
266
+ log_warning(f"create_database: Database '{req.name}' already exists in '{project}'")
267
+ raise HTTPException(status_code=400, detail="Database already exists")
268
+
269
+ if not storage_engine.create_database(project, req.name):
270
+ log_error(f"create_database: Failed to create database '{req.name}' in '{project}'")
271
+ raise HTTPException(status_code=500, detail="Failed to create database")
272
+
273
+ return {"success": True, "message": f"Database '{req.name}' created in project '{project}'"}
274
+
275
+
276
+ @router.delete("/projects/{project}/databases/{database}")
277
+ async def delete_database(
278
+ project: str, database: str, username: str = Depends(verify_admin)
279
+ ) -> dict:
280
+ """Delete a database."""
281
+ if not storage_engine:
282
+ log_error("delete_database: Storage engine not initialized")
283
+ raise HTTPException(status_code=500, detail="Storage engine not initialized")
284
+
285
+ if not storage_engine.delete_database(project, database):
286
+ raise HTTPException(status_code=404, detail="Database not found")
287
+
288
+ return {"success": True, "message": f"Database '{database}' deleted"}
289
+
290
+
291
+ @router.put("/projects/{project}/databases/{database}/password")
292
+ async def set_database_password(
293
+ project: str,
294
+ database: str,
295
+ req: PasswordRequest,
296
+ username: str = Depends(verify_admin),
297
+ ) -> dict:
298
+ """Set password for database access.
299
+
300
+ This endpoint uses ProjectAuth (a separate auth layer from the admin JWT
301
+ system) to manage per-database credentials. The local import avoids a
302
+ circular dependency since ProjectAuth lives in the same auth.manager module
303
+ that admin.py already imports at module level for AuthManager.
304
+
305
+ The access_level ('read', 'write', or 'admin') is stored alongside the
306
+ password and enforced when non-admin clients connect to the database.
307
+ """
308
+ from webduck.auth.manager import ProjectAuth
309
+
310
+ project_auth = ProjectAuth(storage_engine.data_dir)
311
+
312
+ if not project_auth.set_database_password(project, database, req.password, req.access_level):
313
+ log_error(f"set_database_password: Failed to set password for '{database}' in '{project}'")
314
+ raise HTTPException(status_code=500, detail="Failed to set password")
315
+
316
+ return {"success": True, "message": f"Password set for '{database}' ({req.access_level})"}
317
+
318
+
319
+ @router.get("/users")
320
+ async def list_users(username: str = Depends(verify_admin)) -> list[str]:
321
+ """List all admin users."""
322
+ if not auth_manager:
323
+ log_error("list_users: Auth manager not initialized")
324
+ raise HTTPException(status_code=500, detail="Auth manager not initialized")
325
+ return auth_manager.list_users()
326
+
327
+
328
+ class CreateUserRequest(BaseModel):
329
+ username: str
330
+ password: str
331
+
332
+
333
+ @router.post("/users")
334
+ async def create_user(
335
+ req: CreateUserRequest, username: str = Depends(verify_admin)
336
+ ) -> dict:
337
+ """Create a new admin user."""
338
+ if not auth_manager:
339
+ log_error("create_user: Auth manager not initialized")
340
+ raise HTTPException(status_code=500, detail="Auth manager not initialized")
341
+
342
+ if auth_manager.user_exists(req.username):
343
+ log_warning(f"create_user: User '{req.username}' already exists")
344
+ raise HTTPException(status_code=400, detail="User already exists")
345
+
346
+ if not auth_manager.create_user(req.username, req.password):
347
+ log_error(f"create_user: Failed to create user '{req.username}'")
348
+ raise HTTPException(status_code=500, detail="Failed to create user")
349
+
350
+ return {"success": True, "message": f"User '{req.username}' created"}
351
+
352
+
353
+ @router.delete("/users/{target_username}")
354
+ async def delete_user(
355
+ target_username: str, username: str = Depends(verify_admin)
356
+ ) -> dict:
357
+ """Delete an admin user."""
358
+ if not auth_manager:
359
+ log_error("delete_user: Auth manager not initialized")
360
+ raise HTTPException(status_code=500, detail="Auth manager not initialized")
361
+
362
+ if target_username == username:
363
+ # Prevent an admin from deleting their own account. Without this guard
364
+ # an admin could lock themselves out, leaving the system with zero admins.
365
+ log_warning(f"delete_user: User '{username}' attempted to delete themselves")
366
+ raise HTTPException(status_code=400, detail="Cannot delete yourself")
367
+
368
+ if not auth_manager.delete_user(target_username):
369
+ raise HTTPException(status_code=404, detail="User not found")
370
+
371
+ return {"success": True, "message": f"User '{target_username}' deleted"}