adapt-server 0.2.8__tar.gz → 0.3.0__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.8 → adapt_server-0.3.0}/PKG-INFO +20 -8
- {adapt_server-0.2.8 → adapt_server-0.3.0}/README.md +17 -7
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/__init__.py +1 -1
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/models.py +11 -1
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/users.py +65 -2
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/api_keys.py +6 -1
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/audit.py +5 -2
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/auth/dependencies.py +4 -3
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/auth/password.py +14 -1
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/auth/routes.py +34 -4
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/auth/session.py +4 -2
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/cli.py +20 -1
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/__init__.py +15 -0
- adapt_server-0.3.0/adapt/commands/admin/change_password.py +43 -0
- adapt_server-0.3.0/adapt/commands/admin/set_user_active.py +32 -0
- adapt_server-0.3.0/adapt/commands/list_endpoints.py +55 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/serve.py +53 -12
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/discovery.py +8 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/locks.py +14 -3
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/dataset_plugin.py +201 -4
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/excel_plugin.py +123 -8
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/static/admin/app.js +59 -1
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/admin/index.html +26 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/datatable.html +19 -4
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/profile.html +50 -0
- adapt_server-0.3.0/adapt/users.py +26 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt_server.egg-info/PKG-INFO +20 -8
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt_server.egg-info/SOURCES.txt +5 -1
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt_server.egg-info/requires.txt +2 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/pyproject.toml +3 -1
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_admin.py +102 -2
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_auth.py +97 -1
- adapt_server-0.3.0/tests/test_cli_integration.py +168 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_datatable_template.py +23 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_discovery.py +27 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_excel_plugin.py +80 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_integration.py +113 -3
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_locks.py +2 -2
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_mcp.py +13 -3
- adapt_server-0.3.0/tests/test_serve.py +40 -0
- adapt_server-0.2.8/adapt/commands/list_endpoints.py +0 -42
- adapt_server-0.2.8/tests/test_cli_integration.py +0 -63
- {adapt_server-0.2.8 → adapt_server-0.3.0}/LICENSE +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/__main__.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/__init__.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/api_keys.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/audit_logs.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/cache.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/groups.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/locks.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/permissions.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/resources.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/ui.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/app.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/auth/__init__.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/cache.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/__init__.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/addsuperuser.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/add_to_group.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/create_group.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/create_permissions.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/create_user.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/delete_group.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/delete_user.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/list_groups.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/list_resources.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/list_users.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/remove_from_group.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/check.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/passwords.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/reindex.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/config.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/mcp.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/models.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/permissions.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/__init__.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/base.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/csv_plugin.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/file_plugin.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/html_plugin.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/markdown_plugin.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/media_plugin.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/parquet_plugin.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/python_plugin.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/routes.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/routes_search.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/search.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/security.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/security_urls.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/static/admin/style.css +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/static/sortable-tables.css +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/static/sortable-tables.js +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/storage.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/admin_base.html +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/base.html +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/landing.html +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/login.html +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/media_gallery.html +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/media_player.html +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/search_results.html +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/utils/__init__.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/utils/query.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt_server.egg-info/dependency_links.txt +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt_server.egg-info/entry_points.txt +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt_server.egg-info/top_level.txt +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/setup.cfg +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_cache.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_config.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_constraints.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_csv_plugin.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_dataset_plugin.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_dependencies.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_file_plugin.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_html_plugin.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_markdown_plugin.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_on_delete.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_parquet_plugin.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_phase3.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_plugin_interface.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_python_plugin.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_readonly.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_routes.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_search.py +0 -0
- {adapt_server-0.2.8 → adapt_server-0.3.0}/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.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -28,6 +28,7 @@ Requires-Dist: pendulum
|
|
|
28
28
|
Requires-Dist: watchfiles
|
|
29
29
|
Requires-Dist: jinja2
|
|
30
30
|
Requires-Dist: openpyxl
|
|
31
|
+
Requires-Dist: xlrd>=2.0.2
|
|
31
32
|
Requires-Dist: markdown
|
|
32
33
|
Requires-Dist: python-multipart
|
|
33
34
|
Requires-Dist: mutagen
|
|
@@ -40,6 +41,7 @@ Requires-Dist: mcp<2,>=1.28
|
|
|
40
41
|
Provides-Extra: dev
|
|
41
42
|
Requires-Dist: pytest; extra == "dev"
|
|
42
43
|
Requires-Dist: httpx; extra == "dev"
|
|
44
|
+
Requires-Dist: httpx2; extra == "dev"
|
|
43
45
|
Requires-Dist: build; extra == "dev"
|
|
44
46
|
Requires-Dist: twine; extra == "dev"
|
|
45
47
|
Dynamic: license-file
|
|
@@ -48,7 +50,8 @@ Dynamic: license-file
|
|
|
48
50
|
|
|
49
51
|
Adapt is a FastAPI server that turns files in a directory into APIs and UIs.
|
|
50
52
|
|
|
51
|
-
- Datasets (`.csv`, `.xlsx`, `.parquet`) become
|
|
53
|
+
- Datasets (`.csv`, `.xlsx`, `.xls`, `.parquet`) become API endpoints and DataTables UIs
|
|
54
|
+
- Legacy `.xls` workbooks are read-only. Modern `.xlsx` workbooks support CRUD operations.
|
|
52
55
|
- Markdown/HTML become browsable pages
|
|
53
56
|
- Media files become streaming endpoints and player/gallery UIs
|
|
54
57
|
- Python files can register custom routers
|
|
@@ -71,6 +74,9 @@ adapt admin create-permissions /path/to/docroot __all__
|
|
|
71
74
|
# Create a regular user
|
|
72
75
|
adapt admin create-user --username editor --password secret /path/to/docroot
|
|
73
76
|
|
|
77
|
+
# Reset an existing password and revoke that user's browser sessions
|
|
78
|
+
adapt admin change-password --username editor /path/to/docroot
|
|
79
|
+
|
|
74
80
|
# By default, the editor user has no permissions.
|
|
75
81
|
# See available groups (created by `adapt admin create-permissions`) and assign user to desired group
|
|
76
82
|
adapt admin list-groups /path/to/docroot
|
|
@@ -116,16 +122,17 @@ This reflects the current implementation in the codebase.
|
|
|
116
122
|
|
|
117
123
|
### In Place
|
|
118
124
|
|
|
119
|
-
- **Authentication:** session cookies
|
|
125
|
+
- **Authentication:** session cookies, API keys (`X-API-Key`), and inactive-user enforcement
|
|
120
126
|
- **Authorization:** RBAC (users, groups, permissions), plus superuser bypass
|
|
121
127
|
- **Password security:** PBKDF2 hashing with per-user salts
|
|
128
|
+
- **Password changes:** self-service and administrator resets revoke all browser sessions for the user
|
|
122
129
|
- **Session security:** expiration enforcement, sliding renewal, cleanup task
|
|
123
130
|
- **CSRF protection:** enforced for cookie-authenticated unsafe methods (`POST/PUT/PATCH/DELETE`), including mixed session + API-key requests
|
|
124
131
|
- **Redirect hardening:** login `next` paths are validated as local relative paths
|
|
125
132
|
- **Response hardening:** CSP, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, HSTS (when TLS is enabled)
|
|
126
133
|
- **Host header hardening:** Trusted Host middleware
|
|
127
134
|
- **Data integrity:** lock-based, atomic writes for mutable dataset plugins
|
|
128
|
-
- **Auditability:**
|
|
135
|
+
- **Auditability:** audit records for authentication, administration, and successful dataset mutations
|
|
129
136
|
- **Sensitive response cleanup:** admin user APIs no longer expose `password_hash`
|
|
130
137
|
|
|
131
138
|
### Important Deployment Notes
|
|
@@ -202,11 +209,12 @@ claude mcp add --transport http adapt http://localhost:8000/mcp \
|
|
|
202
209
|
}
|
|
203
210
|
```
|
|
204
211
|
|
|
205
|
-
MCP
|
|
206
|
-
|
|
212
|
+
MCP checks authentication when a tool runs. Tool calls use the shared
|
|
213
|
+
authentication resolver, which accepts a session cookie or an API key. API
|
|
214
|
+
keys are the supported and recommended mechanism for MCP clients. Set
|
|
207
215
|
`mcp_enabled: false` in `.adapt/conf.json` (or `ADAPT_MCP_ENABLED=false`) to
|
|
208
|
-
remove `/mcp` entirely.
|
|
209
|
-
|
|
216
|
+
remove `/mcp` entirely. For setup and troubleshooting, read the
|
|
217
|
+
[MCP guide](docs/manual/mcp_guide.md).
|
|
210
218
|
For dataset reads, `sort` is the column name and `order` must be `asc` or
|
|
211
219
|
`desc`.
|
|
212
220
|
|
|
@@ -235,6 +243,9 @@ adapt admin list-resources <root>
|
|
|
235
243
|
adapt admin create-permissions <root> __all__
|
|
236
244
|
```
|
|
237
245
|
|
|
246
|
+
Use `--reload` during development. Uvicorn watches Python files in the document
|
|
247
|
+
root and restarts Adapt after a change.
|
|
248
|
+
|
|
238
249
|
## Documentation
|
|
239
250
|
|
|
240
251
|
Detailed docs live under `docs/manual/`.
|
|
@@ -246,6 +257,7 @@ Detailed docs live under `docs/manual/`.
|
|
|
246
257
|
- API reference: [docs/manual/api_reference.md](docs/manual/api_reference.md)
|
|
247
258
|
- MCP guide: [docs/manual/mcp_guide.md](docs/manual/mcp_guide.md)
|
|
248
259
|
- Plugin development: [docs/manual/plugin_development.md](docs/manual/plugin_development.md)
|
|
260
|
+
- Known limitations: [docs/manual/known_limitations.md](docs/manual/known_limitations.md)
|
|
249
261
|
|
|
250
262
|
## License
|
|
251
263
|
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Adapt is a FastAPI server that turns files in a directory into APIs and UIs.
|
|
4
4
|
|
|
5
|
-
- Datasets (`.csv`, `.xlsx`, `.parquet`) become
|
|
5
|
+
- Datasets (`.csv`, `.xlsx`, `.xls`, `.parquet`) become API endpoints and DataTables UIs
|
|
6
|
+
- Legacy `.xls` workbooks are read-only. Modern `.xlsx` workbooks support CRUD operations.
|
|
6
7
|
- Markdown/HTML become browsable pages
|
|
7
8
|
- Media files become streaming endpoints and player/gallery UIs
|
|
8
9
|
- Python files can register custom routers
|
|
@@ -25,6 +26,9 @@ adapt admin create-permissions /path/to/docroot __all__
|
|
|
25
26
|
# Create a regular user
|
|
26
27
|
adapt admin create-user --username editor --password secret /path/to/docroot
|
|
27
28
|
|
|
29
|
+
# Reset an existing password and revoke that user's browser sessions
|
|
30
|
+
adapt admin change-password --username editor /path/to/docroot
|
|
31
|
+
|
|
28
32
|
# By default, the editor user has no permissions.
|
|
29
33
|
# See available groups (created by `adapt admin create-permissions`) and assign user to desired group
|
|
30
34
|
adapt admin list-groups /path/to/docroot
|
|
@@ -70,16 +74,17 @@ This reflects the current implementation in the codebase.
|
|
|
70
74
|
|
|
71
75
|
### In Place
|
|
72
76
|
|
|
73
|
-
- **Authentication:** session cookies
|
|
77
|
+
- **Authentication:** session cookies, API keys (`X-API-Key`), and inactive-user enforcement
|
|
74
78
|
- **Authorization:** RBAC (users, groups, permissions), plus superuser bypass
|
|
75
79
|
- **Password security:** PBKDF2 hashing with per-user salts
|
|
80
|
+
- **Password changes:** self-service and administrator resets revoke all browser sessions for the user
|
|
76
81
|
- **Session security:** expiration enforcement, sliding renewal, cleanup task
|
|
77
82
|
- **CSRF protection:** enforced for cookie-authenticated unsafe methods (`POST/PUT/PATCH/DELETE`), including mixed session + API-key requests
|
|
78
83
|
- **Redirect hardening:** login `next` paths are validated as local relative paths
|
|
79
84
|
- **Response hardening:** CSP, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, HSTS (when TLS is enabled)
|
|
80
85
|
- **Host header hardening:** Trusted Host middleware
|
|
81
86
|
- **Data integrity:** lock-based, atomic writes for mutable dataset plugins
|
|
82
|
-
- **Auditability:**
|
|
87
|
+
- **Auditability:** audit records for authentication, administration, and successful dataset mutations
|
|
83
88
|
- **Sensitive response cleanup:** admin user APIs no longer expose `password_hash`
|
|
84
89
|
|
|
85
90
|
### Important Deployment Notes
|
|
@@ -156,11 +161,12 @@ claude mcp add --transport http adapt http://localhost:8000/mcp \
|
|
|
156
161
|
}
|
|
157
162
|
```
|
|
158
163
|
|
|
159
|
-
MCP
|
|
160
|
-
|
|
164
|
+
MCP checks authentication when a tool runs. Tool calls use the shared
|
|
165
|
+
authentication resolver, which accepts a session cookie or an API key. API
|
|
166
|
+
keys are the supported and recommended mechanism for MCP clients. Set
|
|
161
167
|
`mcp_enabled: false` in `.adapt/conf.json` (or `ADAPT_MCP_ENABLED=false`) to
|
|
162
|
-
remove `/mcp` entirely.
|
|
163
|
-
|
|
168
|
+
remove `/mcp` entirely. For setup and troubleshooting, read the
|
|
169
|
+
[MCP guide](docs/manual/mcp_guide.md).
|
|
164
170
|
For dataset reads, `sort` is the column name and `order` must be `asc` or
|
|
165
171
|
`desc`.
|
|
166
172
|
|
|
@@ -189,6 +195,9 @@ adapt admin list-resources <root>
|
|
|
189
195
|
adapt admin create-permissions <root> __all__
|
|
190
196
|
```
|
|
191
197
|
|
|
198
|
+
Use `--reload` during development. Uvicorn watches Python files in the document
|
|
199
|
+
root and restarts Adapt after a change.
|
|
200
|
+
|
|
192
201
|
## Documentation
|
|
193
202
|
|
|
194
203
|
Detailed docs live under `docs/manual/`.
|
|
@@ -200,6 +209,7 @@ Detailed docs live under `docs/manual/`.
|
|
|
200
209
|
- API reference: [docs/manual/api_reference.md](docs/manual/api_reference.md)
|
|
201
210
|
- MCP guide: [docs/manual/mcp_guide.md](docs/manual/mcp_guide.md)
|
|
202
211
|
- Plugin development: [docs/manual/plugin_development.md](docs/manual/plugin_development.md)
|
|
212
|
+
- Known limitations: [docs/manual/known_limitations.md](docs/manual/known_limitations.md)
|
|
203
213
|
|
|
204
214
|
## License
|
|
205
215
|
|
|
@@ -11,6 +11,16 @@ class UserCreate(BaseModel):
|
|
|
11
11
|
password: str
|
|
12
12
|
is_superuser: bool = False
|
|
13
13
|
|
|
14
|
+
|
|
15
|
+
class PasswordReset(BaseModel):
|
|
16
|
+
"""Model for replacing a user's password."""
|
|
17
|
+
new_password: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UserStatusUpdate(BaseModel):
|
|
21
|
+
"""Model for activating or deactivating a user."""
|
|
22
|
+
is_active: bool
|
|
23
|
+
|
|
14
24
|
class GroupCreate(BaseModel):
|
|
15
25
|
"""Model for creating a new group."""
|
|
16
26
|
name: str
|
|
@@ -52,4 +62,4 @@ class APIKeyCreate(BaseModel):
|
|
|
52
62
|
"""Model for creating a new API key."""
|
|
53
63
|
user_id: int
|
|
54
64
|
description: Optional[str] = None
|
|
55
|
-
expires_in_days: Optional[int] = None
|
|
65
|
+
expires_in_days: Optional[int] = None
|
|
@@ -6,11 +6,14 @@ from fastapi import Depends, HTTPException, Request, Query
|
|
|
6
6
|
from sqlmodel import Session, select
|
|
7
7
|
|
|
8
8
|
from ..auth import require_superuser, hash_password
|
|
9
|
+
from ..auth.password import update_password
|
|
10
|
+
from ..commands.passwords import is_weak_password
|
|
9
11
|
from ..storage import User, get_db_session
|
|
12
|
+
from ..users import set_user_active
|
|
10
13
|
from ..utils.query import apply_filter, apply_sort, apply_pagination
|
|
11
14
|
from ..audit import log_action
|
|
12
15
|
from . import router
|
|
13
|
-
from .models import UserCreate, UserPublic
|
|
16
|
+
from .models import PasswordReset, UserCreate, UserPublic, UserStatusUpdate
|
|
14
17
|
|
|
15
18
|
logger = logging.getLogger(__name__)
|
|
16
19
|
|
|
@@ -103,6 +106,66 @@ def create_user(user_data: UserCreate, request: Request, db: Session = Depends(g
|
|
|
103
106
|
created_at=new_user.created_at,
|
|
104
107
|
)
|
|
105
108
|
|
|
109
|
+
|
|
110
|
+
@router.put("/users/{user_id}/password")
|
|
111
|
+
def reset_user_password(
|
|
112
|
+
user_id: int,
|
|
113
|
+
password_data: PasswordReset,
|
|
114
|
+
request: Request,
|
|
115
|
+
db: Session = Depends(get_db_session),
|
|
116
|
+
user: User = Depends(require_superuser),
|
|
117
|
+
):
|
|
118
|
+
"""Replace a user's password and revoke their browser sessions."""
|
|
119
|
+
if request.app.state.config.readonly:
|
|
120
|
+
raise HTTPException(status_code=405, detail="Server is in read-only mode")
|
|
121
|
+
|
|
122
|
+
target = db.get(User, user_id)
|
|
123
|
+
if not target:
|
|
124
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
125
|
+
if is_weak_password(password_data.new_password, username=target.username):
|
|
126
|
+
raise HTTPException(status_code=400, detail="New password is too weak")
|
|
127
|
+
|
|
128
|
+
update_password(db, target, password_data.new_password)
|
|
129
|
+
log_action(
|
|
130
|
+
request,
|
|
131
|
+
"reset_password",
|
|
132
|
+
"user",
|
|
133
|
+
f"Reset password for user {target.username}",
|
|
134
|
+
user.id,
|
|
135
|
+
)
|
|
136
|
+
logger.info("User %s reset the password for user %s", user.username, target.username)
|
|
137
|
+
return {"success": True, "sessions_revoked": True}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@router.put("/users/{user_id}/status")
|
|
141
|
+
def update_user_status(
|
|
142
|
+
user_id: int,
|
|
143
|
+
status_data: UserStatusUpdate,
|
|
144
|
+
request: Request,
|
|
145
|
+
db: Session = Depends(get_db_session),
|
|
146
|
+
user: User = Depends(require_superuser),
|
|
147
|
+
):
|
|
148
|
+
"""Activate or deactivate a user account."""
|
|
149
|
+
if request.app.state.config.readonly:
|
|
150
|
+
raise HTTPException(status_code=405, detail="Server is in read-only mode")
|
|
151
|
+
|
|
152
|
+
target = db.get(User, user_id)
|
|
153
|
+
if not target:
|
|
154
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
155
|
+
if target.id == user.id and not status_data.is_active:
|
|
156
|
+
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
|
|
157
|
+
|
|
158
|
+
revoked_sessions = set_user_active(db, target, status_data.is_active)
|
|
159
|
+
action = "activate_user" if status_data.is_active else "deactivate_user"
|
|
160
|
+
state = "Activated" if status_data.is_active else "Deactivated"
|
|
161
|
+
log_action(request, action, "user", f"{state} user {target.username}", user.id)
|
|
162
|
+
logger.info("%s user %s by %s", state, target.username, user.username)
|
|
163
|
+
return {
|
|
164
|
+
"success": True,
|
|
165
|
+
"is_active": target.is_active,
|
|
166
|
+
"sessions_revoked": revoked_sessions,
|
|
167
|
+
}
|
|
168
|
+
|
|
106
169
|
@router.delete("/users/{user_id}")
|
|
107
170
|
def delete_user(user_id: int, request: Request, db: Session = Depends(get_db_session), user: User = Depends(require_superuser)):
|
|
108
171
|
"""Delete a user by ID."""
|
|
@@ -124,4 +187,4 @@ def delete_user(user_id: int, request: Request, db: Session = Depends(get_db_ses
|
|
|
124
187
|
log_action(request, "delete_user", "user", f"Deleted user {target.username}", user.id)
|
|
125
188
|
logger.info("Deleted user %s", target.username)
|
|
126
189
|
|
|
127
|
-
return {"success": True}
|
|
190
|
+
return {"success": True}
|
|
@@ -41,6 +41,11 @@ def verify_api_key(db: Session, raw_key: str) -> User | None:
|
|
|
41
41
|
if not api_key.is_active:
|
|
42
42
|
logger.warning("API key verification failed: key is inactive")
|
|
43
43
|
return None
|
|
44
|
+
|
|
45
|
+
user = db.get(User, api_key.user_id)
|
|
46
|
+
if not user or not user.is_active:
|
|
47
|
+
logger.warning("API key verification failed: user is inactive or missing")
|
|
48
|
+
return None
|
|
44
49
|
|
|
45
50
|
# Check expiration
|
|
46
51
|
if api_key.expires_at:
|
|
@@ -59,7 +64,7 @@ def verify_api_key(db: Session, raw_key: str) -> User | None:
|
|
|
59
64
|
db.commit()
|
|
60
65
|
|
|
61
66
|
logger.info(f"API key verified successfully for user {api_key.user_id}")
|
|
62
|
-
return
|
|
67
|
+
return user
|
|
63
68
|
|
|
64
69
|
def create_api_key_record(
|
|
65
70
|
db: Session,
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import logging
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
|
+
from sqlalchemy.engine import Engine
|
|
6
7
|
from sqlmodel import Session
|
|
7
8
|
from fastapi import Request
|
|
8
9
|
|
|
@@ -16,12 +17,14 @@ def log_action(
|
|
|
16
17
|
action: str,
|
|
17
18
|
resource: str | None = None,
|
|
18
19
|
details: str | None = None,
|
|
19
|
-
user_id: int | None = None
|
|
20
|
+
user_id: int | None = None,
|
|
21
|
+
engine: Engine | None = None,
|
|
20
22
|
):
|
|
21
23
|
"""Log an action to the audit log."""
|
|
22
24
|
logger.debug(f"Logging action: {action} on {resource}")
|
|
23
25
|
try:
|
|
24
|
-
engine
|
|
26
|
+
if engine is None:
|
|
27
|
+
engine = request.app.state.db_engine
|
|
25
28
|
|
|
26
29
|
# Try to get user_id from request state if not provided
|
|
27
30
|
if user_id is None:
|
|
@@ -96,15 +96,16 @@ def permission_dependency(action_param: str, resource: str):
|
|
|
96
96
|
with Session(request.app.state.db_engine) as db:
|
|
97
97
|
try:
|
|
98
98
|
if not check_permission(user, db, action, resource):
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
logger.warning("Permission denied for user %s: %s on %s", user.username, action, resource)
|
|
100
|
+
raise HTTPException(
|
|
101
101
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
102
102
|
detail=f"Permission denied: {action} on {resource}"
|
|
103
103
|
)
|
|
104
|
+
request.state.user = user
|
|
104
105
|
db.commit() # Commit if successful
|
|
105
106
|
logger.debug("Permission granted for user %s: %s on %s", user.username, action, resource)
|
|
106
107
|
return user
|
|
107
108
|
except Exception:
|
|
108
109
|
db.rollback() # Rollback on error
|
|
109
110
|
raise
|
|
110
|
-
return check
|
|
111
|
+
return check
|
|
@@ -2,6 +2,10 @@ import secrets
|
|
|
2
2
|
import hashlib
|
|
3
3
|
import logging
|
|
4
4
|
|
|
5
|
+
from sqlmodel import Session, delete
|
|
6
|
+
|
|
7
|
+
from ..storage import DBSession, User
|
|
8
|
+
|
|
5
9
|
logger = logging.getLogger(__name__)
|
|
6
10
|
|
|
7
11
|
def hash_password(password: str) -> str:
|
|
@@ -21,4 +25,13 @@ def verify_password(password: str, hashed: str) -> bool:
|
|
|
21
25
|
new_digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 100_000).hex()
|
|
22
26
|
result = secrets.compare_digest(new_digest, digest)
|
|
23
27
|
logger.debug("Password verification %s", "successful" if result else "failed")
|
|
24
|
-
return result
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def update_password(db: Session, user: User, password: str) -> None:
|
|
32
|
+
"""Replace a user's password and revoke all of their browser sessions."""
|
|
33
|
+
user.password_hash = hash_password(password)
|
|
34
|
+
db.add(user)
|
|
35
|
+
db.exec(delete(DBSession).where(DBSession.user_id == user.id))
|
|
36
|
+
db.commit()
|
|
37
|
+
logger.info("Updated password and revoked sessions for user %s", user.username)
|
|
@@ -9,7 +9,8 @@ from ..storage import User, DBSession, APIKey, get_db_session
|
|
|
9
9
|
from ..audit import log_action
|
|
10
10
|
from ..api_keys import create_api_key_record, revoke_api_key_record
|
|
11
11
|
from ..utils import build_accessible_ui_links
|
|
12
|
-
from .password import verify_password
|
|
12
|
+
from .password import update_password, verify_password
|
|
13
|
+
from ..commands.passwords import is_weak_password
|
|
13
14
|
from .session import create_session, SESSION_COOKIE
|
|
14
15
|
from .dependencies import require_auth
|
|
15
16
|
from . import router
|
|
@@ -20,6 +21,11 @@ class APIKeyCreateRequest(BaseModel):
|
|
|
20
21
|
description: str | None = None
|
|
21
22
|
expires_in_days: int | None = None
|
|
22
23
|
|
|
24
|
+
|
|
25
|
+
class PasswordChangeRequest(BaseModel):
|
|
26
|
+
current_password: str
|
|
27
|
+
new_password: str
|
|
28
|
+
|
|
23
29
|
@router.get("/auth/login")
|
|
24
30
|
def login_page(request: Request):
|
|
25
31
|
"""Render the login page."""
|
|
@@ -35,7 +41,7 @@ def login(form: OAuth2PasswordRequestForm = Depends(), request: Request = None,
|
|
|
35
41
|
with Session(db_engine) as db:
|
|
36
42
|
stmt = select(User).where(User.username == form.username)
|
|
37
43
|
user = db.exec(stmt).first()
|
|
38
|
-
if not user or not verify_password(form.password, user.password_hash):
|
|
44
|
+
if not user or not user.is_active or not verify_password(form.password, user.password_hash):
|
|
39
45
|
logger.warning("Failed login attempt for username %s", form.username)
|
|
40
46
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
41
47
|
token = create_session(db, user.id)
|
|
@@ -89,7 +95,31 @@ def profile_page(request: Request, user: User = Depends(require_auth)):
|
|
|
89
95
|
def me(user: User = Depends(require_auth)):
|
|
90
96
|
"""Get current user information."""
|
|
91
97
|
logger.debug("User %s requested their info", user.username)
|
|
92
|
-
return {"username": user.username, "is_superuser": getattr(user, "is_superuser", False)}
|
|
98
|
+
return {"id": user.id, "username": user.username, "is_superuser": getattr(user, "is_superuser", False)}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.put("/auth/password")
|
|
102
|
+
def change_password(
|
|
103
|
+
password_data: PasswordChangeRequest,
|
|
104
|
+
request: Request,
|
|
105
|
+
response: Response,
|
|
106
|
+
user: User = Depends(require_auth),
|
|
107
|
+
db: Session = Depends(get_db_session),
|
|
108
|
+
):
|
|
109
|
+
"""Change the current user's password and revoke their browser sessions."""
|
|
110
|
+
if request.app.state.config.readonly:
|
|
111
|
+
raise HTTPException(status_code=405, detail="Server is in read-only mode")
|
|
112
|
+
if not verify_password(password_data.current_password, user.password_hash):
|
|
113
|
+
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
|
114
|
+
if verify_password(password_data.new_password, user.password_hash):
|
|
115
|
+
raise HTTPException(status_code=400, detail="New password must be different")
|
|
116
|
+
if is_weak_password(password_data.new_password, username=user.username):
|
|
117
|
+
raise HTTPException(status_code=400, detail="New password is too weak")
|
|
118
|
+
|
|
119
|
+
update_password(db, user, password_data.new_password)
|
|
120
|
+
log_action(request, "change_password", "auth", "User changed password", user.id)
|
|
121
|
+
response.delete_cookie(key=SESSION_COOKIE)
|
|
122
|
+
return {"message": "Password changed. Sign in again."}
|
|
93
123
|
|
|
94
124
|
|
|
95
125
|
@router.post("/api/apikeys", status_code=201)
|
|
@@ -165,4 +195,4 @@ def revoke_api_key(
|
|
|
165
195
|
raise HTTPException(status_code=404, detail="API key not found")
|
|
166
196
|
|
|
167
197
|
log_action(req, "revoke_api_key", "apikey", f"Revoked API key {key_id} for user {user.username}", user.id)
|
|
168
|
-
logger.info("User %s revoked API key %d", user.username, key_id)
|
|
198
|
+
logger.info("User %s revoked API key %d", user.username, key_id)
|
|
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta, timezone
|
|
|
4
4
|
from sqlmodel import Session, select
|
|
5
5
|
import logging
|
|
6
6
|
|
|
7
|
-
from ..storage import DBSession
|
|
7
|
+
from ..storage import DBSession, User
|
|
8
8
|
|
|
9
9
|
logger = logging.getLogger(__name__)
|
|
10
10
|
|
|
@@ -33,8 +33,10 @@ def get_session(db: Session, token: str) -> DBSession | None:
|
|
|
33
33
|
now = datetime.now(tz=timezone.utc)
|
|
34
34
|
stmt = (
|
|
35
35
|
select(DBSession)
|
|
36
|
+
.join(User, User.id == DBSession.user_id)
|
|
36
37
|
.where(DBSession.token == token)
|
|
37
38
|
.where(DBSession.expires_at > now) # Enforce expiration
|
|
39
|
+
.where(User.is_active == True) # noqa: E712 - SQL expression
|
|
38
40
|
)
|
|
39
41
|
session = db.exec(stmt).first()
|
|
40
42
|
|
|
@@ -50,4 +52,4 @@ def get_session(db: Session, token: str) -> DBSession | None:
|
|
|
50
52
|
hmac.new(b"dummy_key", token.encode(), "sha256").digest()
|
|
51
53
|
logger.debug("Session not found or expired for token")
|
|
52
54
|
|
|
53
|
-
return session
|
|
55
|
+
return session
|
|
@@ -43,7 +43,7 @@ def main() -> None:
|
|
|
43
43
|
help="Allow a weak password without the safety confirmation prompt",
|
|
44
44
|
)
|
|
45
45
|
|
|
46
|
-
list_parser = subparsers.add_parser("list-endpoints", help="List
|
|
46
|
+
list_parser = subparsers.add_parser("list-endpoints", help="List generated resource endpoints")
|
|
47
47
|
list_parser.add_argument("root", nargs="?", default=".", help="Document root to inspect")
|
|
48
48
|
|
|
49
49
|
reindex_parser = subparsers.add_parser("reindex", help="Rebuild the full-text search index")
|
|
@@ -83,6 +83,25 @@ def main() -> None:
|
|
|
83
83
|
admin_delete_user_parser.add_argument("root", nargs="?", default=".", help="Document root")
|
|
84
84
|
admin_delete_user_parser.add_argument("--username", required=True, help="Username")
|
|
85
85
|
|
|
86
|
+
admin_change_password_parser = admin_subparsers.add_parser("change-password", help="Change a user password")
|
|
87
|
+
admin_change_password_parser.add_argument("root", nargs="?", default=".", help="Document root")
|
|
88
|
+
admin_change_password_parser.add_argument("--username", required=True, help="Username")
|
|
89
|
+
admin_change_password_parser.add_argument("--password", help="New password (will prompt if missing)")
|
|
90
|
+
admin_change_password_parser.add_argument("--password-confirm", help="Password confirmation for non-interactive use")
|
|
91
|
+
admin_change_password_parser.add_argument(
|
|
92
|
+
"--allow-weak-password",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="Allow a weak password without the safety confirmation prompt",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
admin_activate_user_parser = admin_subparsers.add_parser("activate-user", help="Activate a user")
|
|
98
|
+
admin_activate_user_parser.add_argument("root", nargs="?", default=".", help="Document root")
|
|
99
|
+
admin_activate_user_parser.add_argument("--username", required=True, help="Username")
|
|
100
|
+
|
|
101
|
+
admin_deactivate_user_parser = admin_subparsers.add_parser("deactivate-user", help="Deactivate a user")
|
|
102
|
+
admin_deactivate_user_parser.add_argument("root", nargs="?", default=".", help="Document root")
|
|
103
|
+
admin_deactivate_user_parser.add_argument("--username", required=True, help="Username")
|
|
104
|
+
|
|
86
105
|
admin_create_group_parser = admin_subparsers.add_parser("create-group", help="Create group")
|
|
87
106
|
admin_create_group_parser.add_argument("root", nargs="?", default=".", help="Document root")
|
|
88
107
|
admin_create_group_parser.add_argument("--name", required=True, help="Group name")
|
|
@@ -8,6 +8,8 @@ from .list_groups import run_list_groups
|
|
|
8
8
|
from .list_users import run_list_users
|
|
9
9
|
from .create_user import run_create_user
|
|
10
10
|
from .delete_user import run_delete_user
|
|
11
|
+
from .change_password import run_change_password
|
|
12
|
+
from .set_user_active import run_set_user_active
|
|
11
13
|
from .create_group import run_create_group
|
|
12
14
|
from .delete_group import run_delete_group
|
|
13
15
|
from .add_to_group import run_add_to_group
|
|
@@ -58,6 +60,19 @@ def run_admin(args):
|
|
|
58
60
|
elif args.admin_command == "delete-user":
|
|
59
61
|
logger.debug("Running admin command: delete-user for username=%s", args.username)
|
|
60
62
|
run_delete_user(Path(args.root).resolve(), args.username)
|
|
63
|
+
elif args.admin_command == "change-password":
|
|
64
|
+
logger.debug("Running admin command: change-password for username=%s", args.username)
|
|
65
|
+
run_change_password(
|
|
66
|
+
root=Path(args.root).resolve(),
|
|
67
|
+
username=args.username,
|
|
68
|
+
password=args.password,
|
|
69
|
+
password_confirm=args.password_confirm,
|
|
70
|
+
allow_weak_password=args.allow_weak_password,
|
|
71
|
+
)
|
|
72
|
+
elif args.admin_command in ("activate-user", "deactivate-user"):
|
|
73
|
+
is_active = args.admin_command == "activate-user"
|
|
74
|
+
logger.debug("Running admin command: %s for username=%s", args.admin_command, args.username)
|
|
75
|
+
run_set_user_active(Path(args.root).resolve(), args.username, is_active)
|
|
61
76
|
elif args.admin_command == "create-group":
|
|
62
77
|
logger.debug("Running admin command: create-group for name=%s", args.name)
|
|
63
78
|
run_create_group(Path(args.root).resolve(), args.name, args.description)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from sqlmodel import Session, select
|
|
5
|
+
|
|
6
|
+
from ...auth.password import update_password
|
|
7
|
+
from ...config import AdaptConfig
|
|
8
|
+
from ...storage import User, init_database
|
|
9
|
+
from ..passwords import resolve_password
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_change_password(
|
|
15
|
+
root: Path,
|
|
16
|
+
username: str,
|
|
17
|
+
password: str | None,
|
|
18
|
+
password_confirm: str | None = None,
|
|
19
|
+
allow_weak_password: bool = False,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Change a user password and revoke all browser sessions."""
|
|
22
|
+
config = AdaptConfig(root=root)
|
|
23
|
+
engine = init_database(config.db_path)
|
|
24
|
+
|
|
25
|
+
password = resolve_password(
|
|
26
|
+
username=username,
|
|
27
|
+
password=password,
|
|
28
|
+
password_confirm=password_confirm,
|
|
29
|
+
allow_weak_password=allow_weak_password,
|
|
30
|
+
)
|
|
31
|
+
if password is None:
|
|
32
|
+
logger.warning("Aborted password change for %s due to password validation", username)
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
with Session(engine) as db:
|
|
36
|
+
user = db.exec(select(User).where(User.username == username)).first()
|
|
37
|
+
if not user:
|
|
38
|
+
logger.warning("User '%s' not found", username)
|
|
39
|
+
print(f"User '{username}' not found")
|
|
40
|
+
return
|
|
41
|
+
update_password(db, user, password)
|
|
42
|
+
|
|
43
|
+
print(f"Changed password for user '{username}' and revoked all browser sessions")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from sqlmodel import Session, select
|
|
5
|
+
|
|
6
|
+
from ...config import AdaptConfig
|
|
7
|
+
from ...storage import User, init_database
|
|
8
|
+
from ...users import set_user_active
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_set_user_active(root: Path, username: str, is_active: bool) -> None:
|
|
14
|
+
"""Activate or deactivate a user account by username."""
|
|
15
|
+
config = AdaptConfig(root=root)
|
|
16
|
+
engine = init_database(config.db_path)
|
|
17
|
+
|
|
18
|
+
with Session(engine) as db:
|
|
19
|
+
user = db.exec(select(User).where(User.username == username)).first()
|
|
20
|
+
if not user:
|
|
21
|
+
logger.warning("User '%s' not found", username)
|
|
22
|
+
print(f"User '{username}' not found")
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
revoked_sessions = set_user_active(db, user, is_active)
|
|
26
|
+
|
|
27
|
+
status = "Activated" if is_active else "Deactivated"
|
|
28
|
+
logger.info("%s user '%s'", status, username)
|
|
29
|
+
if is_active:
|
|
30
|
+
print(f"Activated user '{username}'")
|
|
31
|
+
else:
|
|
32
|
+
print(f"Deactivated user '{username}' and revoked {revoked_sessions} browser session(s)")
|