Launchpad MP (501314) - r00ta/openfga-users-to-groups-endpoints#437
Launchpad MP (501314) - r00ta/openfga-users-to-groups-endpoints#437r00tabot wants to merge 1 commit intoSpaghettiHub:masterfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds user-group membership management endpoints (v2 + v3) backed by OpenFGA tuples, introducing a DB view + repository/model to list group members and preventing system users from being auto-added to groups. Also adjusts migration ordering so OpenFGA built-in migrations run before Alembic.
Changes:
- Add
maasserver_usergroup_members_view(based onopenfga.tuplejoined toauth_user) plus service-layer model/repository to query group members. - Add membership operations (list/add/remove) to the service layer and expose them via v2 + v3 APIs.
- Update migrations/test DB bootstrap and add/extend tests; prevent system users from being assigned to groups.
Reviewed changes
Copilot reviewed 24 out of 25 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/maasservicelayer/services/usergroups.py | Adds membership methods and duplicate-membership guard. |
| src/maasservicelayer/services/openfga_tuples.py | Adds tuple deletion helper for removing a user from a group. |
| src/maasservicelayer/db/repositories/usergroups_members.py | New read-only repository + clause factory for the members view. |
| src/maasservicelayer/models/usergroup_members.py | New model representing a user’s membership in a group. |
| src/maasservicelayer/db/tables.py | Registers the maasserver_usergroup_members_view table/view metadata. |
| src/maasservicelayer/db/alembic/versions/0020_create_maasserver_user_group_membership.py | Creates the DB view used for membership listing. |
| src/maasservicelayer/services/init.py | Wires the new repository into the service collection. |
| src/maasapiserver/v3/api/public/handlers/usergroups.py | Adds v3 membership endpoints (GET/POST/DELETE). |
| src/maasapiserver/v3/api/public/models/requests/usergroup_members.py | Adds v3 request model for adding a member by user_id. |
| src/maasapiserver/v3/api/public/models/responses/usergroup_members.py | Adds v3 response models for membership listing. |
| src/maasserver/api/usergroups.py | Adds v2 membership operations (list_members, add_member, remove_member). |
| src/maasserver/exceptions.py | Adds a v2 API conflict exception for “already a member”. |
| src/maasserver/models/signals/users.py | Skips auto-adding system users to default groups. |
| src/maasserver/models/signals/tests/test_users.py | Tests that system users are ignored by the post-save hook. |
| src/maasserver/management/commands/dbupgrade.py | Runs OpenFGA migrations before Alembic (to support the view). |
| src/maasserver/testing/initial.maas_test.sql | Updates test DB bootstrap with the new view + Alembic version bump. |
| src/maasserver/testing/factory.py | Adds make_Usergroup() factory using the service layer. |
| src/maasopenfga/internal/migrations/00002_migrate_environments.go | Updates OpenFGA migration logic to ignore internal users. |
| src/tests/fixtures/factories/user.py | Adjusts test user factory defaults. |
| src/tests/maasservicelayer/services/test_usergroups.py | Adds unit/integration tests for membership operations. |
| src/tests/maasservicelayer/services/test_openfga_tuples.py | Adds test coverage for removing user-group membership tuples. |
| src/tests/maasservicelayer/db/repositories/test_usergroups_members.py | Adds repository tests for members view querying. |
| src/tests/maasapiserver/v3/api/public/handlers/test_usergroups.py | Adds handler tests for v3 membership endpoints. |
| src/maasserver/api/tests/test_usergroups.py | Adds v2 API tests for membership operations + permissions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| OpenFGATuplesClauseFactory.with_user(f"user:{user_id}"), | ||
| OpenFGATuplesClauseFactory.with_relation("member"), | ||
| OpenFGATuplesClauseFactory.with_object_type("group"), | ||
| OpenFGATuplesClauseFactory.with_object_type("group"), |
There was a problem hiding this comment.
remove_user_from_group builds a filter list that includes with_object_type("group") twice. This is redundant at best, and if one of these was intended to be a different predicate it will currently delete the wrong set of tuples. Remove the duplicate filter (or replace it with the intended condition) so the query precisely targets the membership tuple.
| OpenFGATuplesClauseFactory.with_object_type("group"), |
| "maasserver_usergroup_members_view", | ||
| METADATA, | ||
| Column("id", Integer, Identity(), primary_key=True), | ||
| Column("group_id", String, primary_key=True), |
There was a problem hiding this comment.
UserGroupMembersView.c.group_id is declared as String, but the view definition casts t.object_id to integer and the clause factory passes an int. In Postgres this can cause operator resolution errors (varchar = integer). Define group_id as an Integer/BigInteger to match the view output and how it’s queried.
| Column("group_id", String, primary_key=True), | |
| Column("group_id", Integer, primary_key=True), |
| selectStmt, selectArgs, err := builder. | ||
| Select("id", "is_superuser"). | ||
| From("auth_user"). | ||
| Where(sq.Or{sq.NotEq{"username": "MAAS"}, sq.NotEq{"username": "maas-init-node"}}). // Ignore the internal users |
There was a problem hiding this comment.
The Where(sq.Or{sq.NotEq{"username": "MAAS"}, sq.NotEq{"username": "maas-init-node"}}) condition is logically always true (every row is != at least one of those values), so internal users will not be filtered out. Use an AND of the two NotEq conditions or sq.NotEq{"username": []string{"MAAS","maas-init-node"}}/sq.NotIn to exclude both usernames correctly.
| Where(sq.Or{sq.NotEq{"username": "MAAS"}, sq.NotEq{"username": "maas-init-node"}}). // Ignore the internal users | |
| Where(sq.NotEq{"username": []string{"MAAS", "maas-init-node"}}). // Ignore the internal users |
| class UserGroupMember(MaasBaseModel): | ||
| group_id: int | ||
| username: str | ||
| email: str |
There was a problem hiding this comment.
UserGroupMember.email is declared as str, but auth_user.email (and the view column) is nullable in this codebase. If a user has NULL email, mapping the row into this model will fail validation. Make email optional (str | None) to match the database schema.
| email: str | |
| email: str | None |
| kind = "UserGroupMember" | ||
| user_id: int | ||
| username: str | ||
| email: str |
There was a problem hiding this comment.
UserGroupMemberResponse.email is typed as str, but member emails can be null (auth_user.email is nullable). This will cause response validation errors when serializing members without an email. Change the field to str | None (and keep from_model passing through the value).
| email: str | |
| email: str | None |
| """@description Adds a user to a user group. | ||
| @param (url-string) "{id}" [required=true] A group ID. | ||
| @param (string) "username" [required=true] The username to add. | ||
|
|
||
| @success (http-status-code) "server_success" 200 | ||
| @success (json) "content_success" The updated list of group members. | ||
|
|
||
| @error (http-status-code) "400" 400 | ||
| @error (content) "badrequest" username is required. | ||
| @error (http-status-code) "404" 404 | ||
| @error (content) "notfound" The group or user is not found. | ||
| @error (http-status-code) "409" 409 | ||
| @error (content) "conflict" The user is already a member of the group. | ||
|
|
||
| """ | ||
| username = request.data.get("username") | ||
| if not username: | ||
| raise MAASAPIBadRequest("username is required.") | ||
|
|
||
| user = service_layer.services.users.get_by_username(username) | ||
| if user is None: | ||
| raise MAASAPINotFound(f"User '{username}' not found.") | ||
|
|
||
| try: | ||
| service_layer.services.usergroups.add_user_to_group_by_id( | ||
| user.id, int(id) | ||
| ) | ||
| except UserGroupNotFound as err: | ||
| raise MAASAPINotFound( | ||
| f"UserGroup with id {id} not found." | ||
| ) from err | ||
| except UserAlreadyInGroup as err: | ||
| raise UserAlreadyGroupMemberConflict( | ||
| f"User `{user.username}` is already a member of the group with ID `{id}`." | ||
| ) from err | ||
|
|
||
| return rc.ALL_OK |
There was a problem hiding this comment.
The add_member docstring advertises returning “The updated list of group members” (200 JSON), but the implementation returns rc.ALL_OK without any member list. Either update the handler to return the updated members (e.g. call list_usergroup_members and serialize) or adjust the API docs to match the actual response body.
| def upgrade() -> None: | ||
| sql = dedent("""\ | ||
| SELECT | ||
| (t.object_id)::integer AS group_id, |
There was a problem hiding this comment.
The view casts t.object_id to integer for group_id, but maasserver_usergroup.id is a BIGINT (see migration 0019). To avoid overflow/mismatch as IDs grow, cast to bigint and model the column as BigInteger in SQLAlchemy.
| (t.object_id)::integer AS group_id, | |
| (t.object_id)::bigint AS group_id, |
| Column("username", String(150), nullable=False, unique=True), | ||
| Column("email", String(254), nullable=True, unique=True), |
There was a problem hiding this comment.
UserGroupMembersView marks username and email as unique=True, but this is a membership view (one user can appear in multiple rows if they’re in multiple groups), so these columns are not unique at the view level. Keeping these as unique is misleading and can break any code that relies on SQLAlchemy’s uniqueness metadata. Drop the unique=True flags for these view columns.
| Column("username", String(150), nullable=False, unique=True), | |
| Column("email", String(254), nullable=True, unique=True), | |
| Column("username", String(150), nullable=False), | |
| Column("email", String(254), nullable=True), |
| user = { | ||
| "username": "myusername", | ||
| "password": "pbkdf2_sha256$260000$f1nMJPH4Z5Wc8QxkTsZ1p6$ylZBpgGE3FNlP2zOU21cYiLtvxwtkglsPKUETtXhzDw=", # hash('test') | ||
| "is_superuser": False, | ||
| "first_name": "first", | ||
| "last_name": "last", | ||
| "email": "mail@example.com", | ||
| "is_staff": False, |
There was a problem hiding this comment.
create_test_user now always sets email to a constant value. In this codebase auth_user.email is unique, so any test creating more than one user without overriding email will start failing with unique-constraint violations. Generate a unique email by default (e.g., derived from the username or a random suffix) instead of a constant.
This is autogenerated by maas.r00ta.com. Enjoy!
Commit message: feat: add v2 and v3 API usergroup membership endpoints
Details: