Skip to content

Add user permissions management and utility functions#65

Merged
Creeper19472 merged 3 commits into
masterfrom
feat-user-permissions
Jun 16, 2026
Merged

Add user permissions management and utility functions#65
Creeper19472 merged 3 commits into
masterfrom
feat-user-permissions

Conversation

@Creeper19472

@Creeper19472 Creeper19472 commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Summary by Sourcery

Introduce direct user permissions management and centralize permission calculation logic for users and groups.

New Features:

  • Add API and handler to change a user's direct permissions with appropriate authorization checks.
  • Expose a client helper method for changing user permissions from tests and external callers.
  • Introduce a new SET_USER_PERMISSIONS permission to gate permission management operations.

Enhancements:

  • Refactor permission grant/revocation and effective-permission computation into shared utility functions used by both users and groups.
  • Simplify updating of permission entries for users and groups via a reusable helper that replaces existing permissions atomically.

Tests:

  • Add tests covering successful and unauthorized user permission changes, including client helper coverage.

@sourcery-ai

sourcery-ai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Introduces 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 permissions

sequenceDiagram
    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
Loading

File-Level Changes

Change Details Files
Refactor permission computation and updating into shared utility functions and apply them to User and UserGroup models.
  • Add _permission_grants_and_revocations to compute active granted and revoked permissions from permission entries with time filtering.
  • Add _effective_permissions to derive the net effective permission set from a collection of permission entries.
  • Add _replace_permission_entries to atomically replace a collection of permission ORM entries in a session based on a list of permission names.
  • Change User.all_permissions to reuse the new helpers for combining user and group permissions and revocations consistently.
  • Add User.own_permissions property with getter and setter using the new helpers to manage direct user permissions.
  • Simplify UserGroup.all_permissions to use _effective_permissions and its setter to use _replace_permission_entries.
src/include/database/models/classic.py
Add an authenticated management endpoint and routing for changing a user’s own_permissions, gated by a new SET_USER_PERMISSIONS permission.
  • Implement RequestChangeUserPermissionsHandler with JSON schema validation, permission check against Permissions.SET_USER_PERMISSIONS, basic input validation, and use of User.own_permissions to update direct permissions with commit.
  • Register the new handler name in the management user handler all and in the main router mapping under "change_user_permissions".
  • Extend the Permissions enum with SET_USER_PERMISSIONS and grant it to the admin/default group during server initialization.
src/include/handlers/management/user.py
src/include/router.py
src/include/classes/enum/permissions.py
src/main.py
Extend client and tests to cover the new change_user_permissions flow and authentication behavior.
  • Add CFMSTestClient.change_user_permissions helper to send the new request.
  • Add tests verifying an authenticated caller can change another user’s permissions and that the updated permissions are reflected in get_user_info.
  • Add a negative test ensuring change_user_permissions without authentication returns 401.
tests/test_users.py
tests/test_client.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help


require_auth = True

def handle(self, handler: ConnectionHandler):

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • 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.
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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

"data": {},
}
)
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
  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.

Comment thread tests/test_users.py
Comment on lines +81 to +90
@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"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 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).

@Creeper19472 Creeper19472 merged commit a7ccf7f into master Jun 16, 2026
6 checks passed
@Creeper19472 Creeper19472 deleted the feat-user-permissions branch June 16, 2026 03:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant