Add user permissions management and utility functions#65
Conversation
Reviewer's GuideIntroduces reusable helpers for computing and updating permissions, adds first-class per-user permission management (including a new SET_USER_PERMISSIONS permission and request handler), wires it into the router and bootstrap, and extends tests and client helpers for the new functionality. Sequence diagram for changing a user's permissionssequenceDiagram
actor Admin
participant ConnectionHandler
participant RequestChangeUserPermissionsHandler as ChangeUserPerms
participant Session
participant User
Admin->>ConnectionHandler: send change_user_permissions
ConnectionHandler->>ChangeUserPerms: handle(handler)
ChangeUserPerms->>Session: Session()
ChangeUserPerms->>User: get_existing(session, handler.username)
alt [Permissions.SET_USER_PERMISSIONS not in this_user.all_permissions]
ChangeUserPerms-->>ConnectionHandler: conclude_request(code=403)
else [caller authorized]
ChangeUserPerms->>Session: session.get(User, target_username)
alt [user_to_change is None]
ChangeUserPerms-->>ConnectionHandler: conclude_request(code=404)
else [user found]
ChangeUserPerms->>ChangeUserPerms: validate handler.data["permissions"]
alt [set(new_permissions) != user_to_change.own_permissions]
ChangeUserPerms->>User: set own_permissions(new_permissions)
ChangeUserPerms->>Session: session.commit()
end
ChangeUserPerms-->>ConnectionHandler: conclude_request(code=200)
end
end
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
|
||
| require_auth = True | ||
|
|
||
| def handle(self, handler: ConnectionHandler): |
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- Updating
User.own_permissionsdoes not invalidate the cachedall_permissionsproperty, soall_permissionscan become stale after permission changes; consider removing@cached_propertyor explicitly clearing the cache when own/group permissions are modified. - In
RequestChangeUserPermissionsHandler.handle, some branches return a(code, target_username, actor_username)tuple while others justreturnwith no value; it would be clearer and less error-prone to standardize the return type across all exit paths.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- Updating `User.own_permissions` does not invalidate the cached `all_permissions` property, so `all_permissions` can become stale after permission changes; consider removing `@cached_property` or explicitly clearing the cache when own/group permissions are modified.
- In `RequestChangeUserPermissionsHandler.handle`, some branches return a `(code, target_username, actor_username)` tuple while others just `return` with no value; it would be clearer and less error-prone to standardize the return type across all exit paths.
## Individual Comments
### Comment 1
<location path="src/include/handlers/management/user.py" line_range="891-878" />
<code_context>
+ )
+ return 404, target_username, handler.username
+
+ new_permissions = handler.data.get("permissions", [])
+
+ if not all(isinstance(permission, str) for permission in new_permissions):
+ handler.conclude_request(
+ **{
+ "code": 400,
+ "message": "All permissions must be of type str",
+ "data": {},
+ }
+ )
+ return
+
+ if set(new_permissions) != user_to_change.own_permissions:
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Validate incoming permission names against the Permissions enum instead of accepting arbitrary strings.
Currently we only check that `new_permissions` items are strings, so arbitrary or misspelled permissions can be stored and then never enforced. Please validate each entry against the `Permissions` enum (e.g., casting with try/except or checking against a whitelist) and return a 4xx for unknown values to prevent inconsistent or ineffective permission assignments.
Suggested implementation:
```python
new_permissions = handler.data.get("permissions", [])
# Validate that all permissions are strings
if not all(isinstance(permission, str) for permission in new_permissions):
handler.conclude_request(
**{
"code": 400,
"message": "All permissions must be of type str",
"data": {},
}
)
return
# Validate that all permissions map to known Permissions enum values
valid_permissions = {permission.value for permission in Permissions}
invalid_permissions = [
permission
for permission in new_permissions
if permission not in valid_permissions
]
if invalid_permissions:
handler.conclude_request(
**{
"code": 400,
"message": "Unknown permissions: " + ", ".join(invalid_permissions),
"data": {},
}
)
return
```
1. Ensure the `Permissions` enum is imported in `src/include/handlers/management/user.py`. For example (adjust path/naming to your project structure):
`from src.include.models.permissions import Permissions`
2. If your `Permissions` enum uses names instead of values for external representation, replace `permission.value` with `permission.name` in `valid_permissions = {permission.value for permission in Permissions}`.
3. If you prefer a different error format (e.g., structured `data` detailing invalid permissions), adjust the `"message"` and `"data"` fields accordingly but keep the 4xx status code.
</issue_to_address>
### Comment 2
<location path="tests/test_users.py" line_range="81-90" />
<code_context>
data = assert_success(response)
assert data["username"] == "admin"
+ @pytest.mark.asyncio
+ async def test_change_user_permissions(
+ self, authenticated_client: CFMSTestClient, test_user: dict
+ ):
+ response = await authenticated_client.change_user_permissions(
+ test_user["username"], ["list_users"]
+ )
+ assert_success(response)
+
+ info_response = await authenticated_client.get_user_info(test_user["username"])
+ data = assert_success(info_response)
+ assert "list_users" in data["permissions"]
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests for permission checks when caller lacks SET_USER_PERMISSIONS
Please also add a test where the authenticated user lacks `Permissions.SET_USER_PERMISSIONS` and calling `change_user_permissions` returns a 403 (and, ideally, the expected error message). This will verify the new authorization gate, not just the happy path.
Suggested implementation:
```python
data = assert_success(response)
assert data["username"] == "admin"
@pytest.mark.asyncio
async def test_change_user_permissions(
self, authenticated_client: CFMSTestClient, test_user: dict
):
response = await authenticated_client.change_user_permissions(
test_user["username"], ["list_users"]
)
assert_success(response)
info_response = await authenticated_client.get_user_info(test_user["username"])
data = assert_success(info_response)
assert "list_users" in data["permissions"]
@pytest.mark.asyncio
async def test_change_user_permissions_forbidden_without_permission(
self,
low_privilege_client: CFMSTestClient,
test_user: dict,
):
# Attempt to change permissions using a client that lacks SET_USER_PERMISSIONS
response = await low_privilege_client.change_user_permissions(
test_user["username"], ["list_users"]
)
# Expect a 403 Forbidden response and an appropriate error payload
assert response.status_code == 403
body = response.json()
# These keys/fields may need to be aligned with your existing error schema
assert body.get("success") is False
assert body.get("error") is not None
```
To make this test pass and align with your existing helpers and fixtures, you may need to:
1. Add or adapt a `low_privilege_client` (or similarly named) fixture that returns a `CFMSTestClient` authenticated as a user who does **not** have `Permissions.SET_USER_PERMISSIONS`.
2. Adjust the assertions on the error response (`success`, `error` keys, and their structure) to match your existing error response schema and any helper like `assert_error`/`assert_failure` if present.
3. If `change_user_permissions` currently returns an already-parsed payload instead of a raw response object, update the test accordingly (e.g., asserting on `response["status"]` or using the appropriate helper).
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| "data": {}, | ||
| } | ||
| ) | ||
| return |
There was a problem hiding this comment.
suggestion (bug_risk): Validate incoming permission names against the Permissions enum instead of accepting arbitrary strings.
Currently we only check that new_permissions items are strings, so arbitrary or misspelled permissions can be stored and then never enforced. Please validate each entry against the Permissions enum (e.g., casting with try/except or checking against a whitelist) and return a 4xx for unknown values to prevent inconsistent or ineffective permission assignments.
Suggested implementation:
new_permissions = handler.data.get("permissions", [])
# Validate that all permissions are strings
if not all(isinstance(permission, str) for permission in new_permissions):
handler.conclude_request(
**{
"code": 400,
"message": "All permissions must be of type str",
"data": {},
}
)
return
# Validate that all permissions map to known Permissions enum values
valid_permissions = {permission.value for permission in Permissions}
invalid_permissions = [
permission
for permission in new_permissions
if permission not in valid_permissions
]
if invalid_permissions:
handler.conclude_request(
**{
"code": 400,
"message": "Unknown permissions: " + ", ".join(invalid_permissions),
"data": {},
}
)
return-
Ensure the
Permissionsenum is imported insrc/include/handlers/management/user.py. For example (adjust path/naming to your project structure):from src.include.models.permissions import Permissions -
If your
Permissionsenum uses names instead of values for external representation, replacepermission.valuewithpermission.nameinvalid_permissions = {permission.value for permission in Permissions}. -
If you prefer a different error format (e.g., structured
datadetailing invalid permissions), adjust the"message"and"data"fields accordingly but keep the 4xx status code.
| @pytest.mark.asyncio | ||
| async def test_change_user_permissions( | ||
| self, authenticated_client: CFMSTestClient, test_user: dict | ||
| ): | ||
| response = await authenticated_client.change_user_permissions( | ||
| test_user["username"], ["list_users"] | ||
| ) | ||
| assert_success(response) | ||
|
|
||
| info_response = await authenticated_client.get_user_info(test_user["username"]) |
There was a problem hiding this comment.
suggestion (testing): Add tests for permission checks when caller lacks SET_USER_PERMISSIONS
Please also add a test where the authenticated user lacks Permissions.SET_USER_PERMISSIONS and calling change_user_permissions returns a 403 (and, ideally, the expected error message). This will verify the new authorization gate, not just the happy path.
Suggested implementation:
data = assert_success(response)
assert data["username"] == "admin"
@pytest.mark.asyncio
async def test_change_user_permissions(
self, authenticated_client: CFMSTestClient, test_user: dict
):
response = await authenticated_client.change_user_permissions(
test_user["username"], ["list_users"]
)
assert_success(response)
info_response = await authenticated_client.get_user_info(test_user["username"])
data = assert_success(info_response)
assert "list_users" in data["permissions"]
@pytest.mark.asyncio
async def test_change_user_permissions_forbidden_without_permission(
self,
low_privilege_client: CFMSTestClient,
test_user: dict,
):
# Attempt to change permissions using a client that lacks SET_USER_PERMISSIONS
response = await low_privilege_client.change_user_permissions(
test_user["username"], ["list_users"]
)
# Expect a 403 Forbidden response and an appropriate error payload
assert response.status_code == 403
body = response.json()
# These keys/fields may need to be aligned with your existing error schema
assert body.get("success") is False
assert body.get("error") is not NoneTo make this test pass and align with your existing helpers and fixtures, you may need to:
- Add or adapt a
low_privilege_client(or similarly named) fixture that returns aCFMSTestClientauthenticated as a user who does not havePermissions.SET_USER_PERMISSIONS. - Adjust the assertions on the error response (
success,errorkeys, and their structure) to match your existing error response schema and any helper likeassert_error/assert_failureif present. - If
change_user_permissionscurrently returns an already-parsed payload instead of a raw response object, update the test accordingly (e.g., asserting onresponse["status"]or using the appropriate helper).
Summary by Sourcery
Introduce direct user permissions management and centralize permission calculation logic for users and groups.
New Features:
Enhancements:
Tests: