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.
Files changed (124) hide show
  1. {adapt_server-0.2.8 → adapt_server-0.3.0}/PKG-INFO +20 -8
  2. {adapt_server-0.2.8 → adapt_server-0.3.0}/README.md +17 -7
  3. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/__init__.py +1 -1
  4. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/models.py +11 -1
  5. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/users.py +65 -2
  6. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/api_keys.py +6 -1
  7. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/audit.py +5 -2
  8. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/auth/dependencies.py +4 -3
  9. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/auth/password.py +14 -1
  10. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/auth/routes.py +34 -4
  11. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/auth/session.py +4 -2
  12. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/cli.py +20 -1
  13. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/__init__.py +15 -0
  14. adapt_server-0.3.0/adapt/commands/admin/change_password.py +43 -0
  15. adapt_server-0.3.0/adapt/commands/admin/set_user_active.py +32 -0
  16. adapt_server-0.3.0/adapt/commands/list_endpoints.py +55 -0
  17. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/serve.py +53 -12
  18. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/discovery.py +8 -0
  19. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/locks.py +14 -3
  20. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/dataset_plugin.py +201 -4
  21. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/excel_plugin.py +123 -8
  22. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/static/admin/app.js +59 -1
  23. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/admin/index.html +26 -0
  24. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/datatable.html +19 -4
  25. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/profile.html +50 -0
  26. adapt_server-0.3.0/adapt/users.py +26 -0
  27. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt_server.egg-info/PKG-INFO +20 -8
  28. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt_server.egg-info/SOURCES.txt +5 -1
  29. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt_server.egg-info/requires.txt +2 -0
  30. {adapt_server-0.2.8 → adapt_server-0.3.0}/pyproject.toml +3 -1
  31. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_admin.py +102 -2
  32. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_auth.py +97 -1
  33. adapt_server-0.3.0/tests/test_cli_integration.py +168 -0
  34. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_datatable_template.py +23 -0
  35. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_discovery.py +27 -0
  36. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_excel_plugin.py +80 -0
  37. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_integration.py +113 -3
  38. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_locks.py +2 -2
  39. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_mcp.py +13 -3
  40. adapt_server-0.3.0/tests/test_serve.py +40 -0
  41. adapt_server-0.2.8/adapt/commands/list_endpoints.py +0 -42
  42. adapt_server-0.2.8/tests/test_cli_integration.py +0 -63
  43. {adapt_server-0.2.8 → adapt_server-0.3.0}/LICENSE +0 -0
  44. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/__main__.py +0 -0
  45. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/__init__.py +0 -0
  46. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/api_keys.py +0 -0
  47. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/audit_logs.py +0 -0
  48. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/cache.py +0 -0
  49. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/groups.py +0 -0
  50. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/locks.py +0 -0
  51. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/permissions.py +0 -0
  52. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/resources.py +0 -0
  53. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/admin/ui.py +0 -0
  54. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/app.py +0 -0
  55. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/auth/__init__.py +0 -0
  56. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/cache.py +0 -0
  57. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/__init__.py +0 -0
  58. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/addsuperuser.py +0 -0
  59. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/add_to_group.py +0 -0
  60. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/create_group.py +0 -0
  61. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/create_permissions.py +0 -0
  62. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/create_user.py +0 -0
  63. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/delete_group.py +0 -0
  64. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/delete_user.py +0 -0
  65. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/list_groups.py +0 -0
  66. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/list_resources.py +0 -0
  67. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/list_users.py +0 -0
  68. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/admin/remove_from_group.py +0 -0
  69. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/check.py +0 -0
  70. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/passwords.py +0 -0
  71. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/commands/reindex.py +0 -0
  72. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/config.py +0 -0
  73. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/mcp.py +0 -0
  74. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/models.py +0 -0
  75. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/permissions.py +0 -0
  76. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/__init__.py +0 -0
  77. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/base.py +0 -0
  78. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/csv_plugin.py +0 -0
  79. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/file_plugin.py +0 -0
  80. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/html_plugin.py +0 -0
  81. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/markdown_plugin.py +0 -0
  82. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/media_plugin.py +0 -0
  83. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/parquet_plugin.py +0 -0
  84. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/plugins/python_plugin.py +0 -0
  85. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/routes.py +0 -0
  86. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/routes_search.py +0 -0
  87. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/search.py +0 -0
  88. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/security.py +0 -0
  89. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/security_urls.py +0 -0
  90. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/static/admin/style.css +0 -0
  91. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/static/sortable-tables.css +0 -0
  92. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/static/sortable-tables.js +0 -0
  93. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/storage.py +0 -0
  94. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/admin_base.html +0 -0
  95. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/base.html +0 -0
  96. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/landing.html +0 -0
  97. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/login.html +0 -0
  98. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/media_gallery.html +0 -0
  99. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/media_player.html +0 -0
  100. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/templates/search_results.html +0 -0
  101. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/utils/__init__.py +0 -0
  102. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt/utils/query.py +0 -0
  103. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt_server.egg-info/dependency_links.txt +0 -0
  104. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt_server.egg-info/entry_points.txt +0 -0
  105. {adapt_server-0.2.8 → adapt_server-0.3.0}/adapt_server.egg-info/top_level.txt +0 -0
  106. {adapt_server-0.2.8 → adapt_server-0.3.0}/setup.cfg +0 -0
  107. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_cache.py +0 -0
  108. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_config.py +0 -0
  109. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_constraints.py +0 -0
  110. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_csv_plugin.py +0 -0
  111. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_dataset_plugin.py +0 -0
  112. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_dependencies.py +0 -0
  113. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_file_plugin.py +0 -0
  114. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_html_plugin.py +0 -0
  115. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_markdown_plugin.py +0 -0
  116. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_on_delete.py +0 -0
  117. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_parquet_plugin.py +0 -0
  118. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_phase3.py +0 -0
  119. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_plugin_interface.py +0 -0
  120. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_python_plugin.py +0 -0
  121. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_readonly.py +0 -0
  122. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_routes.py +0 -0
  123. {adapt_server-0.2.8 → adapt_server-0.3.0}/tests/test_search.py +0 -0
  124. {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.2.8
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 CRUD endpoints and DataTables UIs
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 and API keys (`X-API-Key`)
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:** admin/audit logging for security-relevant actions
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 requires an API key on every call — there's no session-cookie or
206
- anonymous path, since MCP has no concept of a browser session. Set
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. Full walkthrough, troubleshooting, and the tool
209
- reference table: [docs/manual/mcp_guide.md](docs/manual/mcp_guide.md).
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 CRUD endpoints and DataTables UIs
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 and API keys (`X-API-Key`)
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:** admin/audit logging for security-relevant actions
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 requires an API key on every call — there's no session-cookie or
160
- anonymous path, since MCP has no concept of a browser session. Set
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. Full walkthrough, troubleshooting, and the tool
163
- reference table: [docs/manual/mcp_guide.md](docs/manual/mcp_guide.md).
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
 
@@ -1,3 +1,3 @@
1
1
  """Adapt package namespace."""
2
2
 
3
- __version__ = "0.2.8"
3
+ __version__ = "0.3.0"
@@ -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 db.get(User, api_key.user_id)
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 = request.app.state.db_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
- logger.warning("Permission denied for user %s: %s on %s", user.username, action, resource)
100
- raise HTTPException(
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 the auto-generated REST/UI endpoints")
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)")