Skip to content

Commit f4c880a

Browse files
committed
groups: Add API and usermanager functions to manage usergroups
Signed-off-by: Denys Fedoryshchenko <denys.f@collabora.com>
1 parent eb824da commit f4c880a

6 files changed

Lines changed: 347 additions & 82 deletions

File tree

api/main.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
Header,
2929
Query,
3030
Body,
31+
Response,
3132
)
3233
from fastapi.encoders import jsonable_encoder
3334
from fastapi.responses import (
@@ -70,6 +71,7 @@
7071
UserUpdate,
7172
UserUpdateRequest,
7273
UserGroup,
74+
UserGroupCreateRequest,
7375
InviteAcceptRequest,
7476
InviteUrlResponse,
7577
)
@@ -644,6 +646,77 @@ async def update_user(user_id: str, request: Request, user: UserUpdateRequest,
644646
return updated_user
645647

646648

649+
@app.get("/user-groups", response_model=PageModel, tags=["user"])
650+
async def get_user_groups(request: Request,
651+
current_user: User = Depends(get_current_superuser)):
652+
"""List user groups (admin-only)."""
653+
metrics.add('http_requests_total', 1)
654+
query_params = dict(request.query_params)
655+
for pg_key in ['limit', 'offset']:
656+
query_params.pop(pg_key, None)
657+
paginated_resp = await db.find_by_attributes(UserGroup, query_params)
658+
paginated_resp.items = serialize_paginated_data(
659+
UserGroup, paginated_resp.items)
660+
return paginated_resp
661+
662+
663+
@app.get("/user-groups/{group_id}", response_model=UserGroup, tags=["user"],
664+
response_model_by_alias=False)
665+
async def get_user_group(group_id: str,
666+
current_user: User = Depends(get_current_superuser)):
667+
"""Get a user group by id (admin-only)."""
668+
metrics.add('http_requests_total', 1)
669+
group = await db.find_by_id(UserGroup, group_id)
670+
if not group:
671+
raise HTTPException(
672+
status_code=status.HTTP_404_NOT_FOUND,
673+
detail=f"User group not found with id: {group_id}",
674+
)
675+
return group
676+
677+
678+
@app.post("/user-groups", response_model=UserGroup, tags=["user"],
679+
response_model_by_alias=False)
680+
async def create_user_group(group: UserGroupCreateRequest,
681+
current_user: User = Depends(
682+
get_current_superuser)):
683+
"""Create a user group (admin-only)."""
684+
metrics.add('http_requests_total', 1)
685+
existing = await db.find_one(UserGroup, name=group.name)
686+
if existing:
687+
raise HTTPException(
688+
status_code=status.HTTP_400_BAD_REQUEST,
689+
detail=f"User group already exists with name: {group.name}",
690+
)
691+
return await db.create(UserGroup(name=group.name))
692+
693+
694+
@app.delete("/user-groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT,
695+
tags=["user"])
696+
async def delete_user_group(group_id: str,
697+
current_user: User = Depends(
698+
get_current_superuser)):
699+
"""Delete a user group (admin-only)."""
700+
metrics.add('http_requests_total', 1)
701+
group = await db.find_by_id(UserGroup, group_id)
702+
if not group:
703+
raise HTTPException(
704+
status_code=status.HTTP_404_NOT_FOUND,
705+
detail=f"User group not found with id: {group_id}",
706+
)
707+
assigned_count = await db.count(User, {"groups.name": group.name})
708+
if assigned_count:
709+
raise HTTPException(
710+
status_code=status.HTTP_409_CONFLICT,
711+
detail=(
712+
"User group is assigned to users and cannot be deleted. "
713+
"Remove it from users first."
714+
),
715+
)
716+
await db.delete_by_id(UserGroup, group_id)
717+
return Response(status_code=status.HTTP_204_NO_CONTENT)
718+
719+
647720
def _get_node_runtime(node: Node) -> Optional[str]:
648721
"""Best-effort runtime lookup from node data."""
649722
data = getattr(node, 'data', None)

api/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ def get_indexes(cls):
115115
]
116116

117117

118+
class UserGroupCreateRequest(BaseModel):
119+
"""Create user group request schema for API router"""
120+
name: str = Field(description="User group name")
121+
122+
118123
class User(BeanieBaseUser, Document, # pylint: disable=too-many-ancestors
119124
DatabaseModel):
120125
"""API User model"""

doc/api-details.md

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,14 +372,22 @@ User groups are plain name strings stored in the `usergroup` collection. Group
372372
names must already exist before they can be assigned to users; otherwise the
373373
API returns `400`.
374374

375-
There is currently no REST endpoint for creating or deleting user groups. Use
376-
MongoDB tooling to manage them. Example with `mongosh`:
375+
User groups are plain name strings stored in the `usergroup` collection. You
376+
can manage them via the API endpoints below or directly with MongoDB tooling.
377+
Example with `mongosh`:
377378

378379
```
379380
$ mongosh "mongodb://db:27017/kernelci"
380381
> db.usergroup.insertOne({name: "runtime:lava-collabora:node-editor"})
381382
```
382383

384+
Admin-only user group management endpoints are available:
385+
386+
- `GET /user-groups` (list; supports `name` filter)
387+
- `GET /user-groups/<group-id>`
388+
- `POST /user-groups` with `{"name": "runtime:lava-collabora:node-editor"}`
389+
- `DELETE /user-groups/<group-id>` (fails with `409` if assigned to users)
390+
383391
Admin users can assign or remove groups via:
384392

385393
- `POST /user/invite` with a `groups` list
@@ -393,12 +401,133 @@ Example using the helper script:
393401

394402
```
395403
$ ./scripts/usermanager.py list-users
404+
$ ./scripts/usermanager.py list-groups
405+
$ ./scripts/usermanager.py create-group runtime:lava-collabora:node-editor
396406
$ ./scripts/usermanager.py update-user 615f30020eb7c3c6616e5ac3 \
397407
--data '{"groups": ["runtime:lava-collabora:node-editor"]}'
398408
```
399409

400410
Users cannot update their own groups; admin access is required.
401411

412+
### Usermanager workflows (examples)
413+
414+
These examples use `scripts/usermanager.py`. It reads `./usermanager.toml` or
415+
`~/.config/kernelci/usermanager.toml` by default, and you can override with
416+
`--api-url`/`--token` or `KCI_API_URL`/`KCI_API_TOKEN`.
417+
418+
Common admin workflows:
419+
420+
- List users and capture IDs:
421+
422+
```
423+
$ ./scripts/usermanager.py list-users
424+
$ ./scripts/usermanager.py get-user <USER-ID>
425+
```
426+
427+
- Invite a user (optionally add groups):
428+
429+
```
430+
$ ./scripts/usermanager.py invite \
431+
--username alice \
432+
--email alice@example.org \
433+
--groups runtime:pull-labs-demo:node-editor \
434+
--return-token
435+
```
436+
437+
- Accept an invite manually (useful for service accounts or testing):
438+
439+
```
440+
$ ./scripts/usermanager.py accept-invite --token "<INVITE-TOKEN>"
441+
```
442+
443+
- Login to get a bearer token:
444+
445+
```
446+
$ ./scripts/usermanager.py login --username alice
447+
```
448+
449+
- Deactivate or reactivate a user:
450+
451+
```
452+
$ ./scripts/usermanager.py update-user <USER-ID> --inactive
453+
$ ./scripts/usermanager.py update-user <USER-ID> --active
454+
```
455+
456+
- Grant or revoke superuser:
457+
458+
```
459+
$ ./scripts/usermanager.py update-user <USER-ID> --superuser
460+
$ ./scripts/usermanager.py update-user <USER-ID> --no-superuser
461+
```
462+
463+
- Mark a user verified or unverified (admin only):
464+
465+
```
466+
$ ./scripts/usermanager.py update-user <USER-ID> --verified
467+
$ ./scripts/usermanager.py update-user <USER-ID> --unverified
468+
```
469+
470+
- Assign or remove groups:
471+
472+
```
473+
$ ./scripts/usermanager.py update-user <USER-ID> \
474+
--add-group runtime:pull-labs-demo:node-editor
475+
$ ./scripts/usermanager.py update-user <USER-ID> \
476+
--remove-group runtime:pull-labs-demo:node-editor
477+
$ ./scripts/usermanager.py update-user <USER-ID> \
478+
--set-groups runtime:pull-labs-demo:node-editor,team-a
479+
```
480+
481+
- Set a password (admin only, useful for service accounts):
482+
483+
```
484+
$ ./scripts/usermanager.py update-user <USER-ID> --password "<new-password>"
485+
```
486+
487+
- Manage user groups:
488+
489+
```
490+
$ ./scripts/usermanager.py list-groups
491+
$ ./scripts/usermanager.py create-group runtime:pull-labs-demo:node-editor
492+
$ ./scripts/usermanager.py delete-group runtime:pull-labs-demo:node-editor
493+
```
494+
495+
- Delete a user:
496+
497+
```
498+
$ ./scripts/usermanager.py delete-user <USER-ID>
499+
```
500+
501+
### Permissions and node update rules
502+
503+
Node update permissions are determined by the user and the node being edited:
504+
505+
- Superusers can update any node.
506+
- The node owner can update their own nodes.
507+
- Users with group `node:edit:any` can update any node.
508+
- Users with a group listed in the node's `user_groups` can update that node.
509+
- Users with `runtime:<runtime>:node-editor` or `runtime:<runtime>:node-admin`
510+
can update nodes whose `data.runtime` matches `<runtime>`.
511+
512+
Example: allow updates only for runtime `pull-labs-demo`:
513+
514+
```
515+
$ mongosh "mongodb://db:27017/kernelci"
516+
> db.usergroup.insertOne({name: "runtime:pull-labs-demo:node-editor"})
517+
```
518+
519+
```
520+
$ ./scripts/usermanager.py update-user <USER-ID> \
521+
--add-group runtime:pull-labs-demo:node-editor
522+
```
523+
524+
To remove a user group definition entirely, delete it in MongoDB:
525+
526+
```
527+
$ mongosh "mongodb://db:27017/kernelci"
528+
> db.usergroup.deleteOne({name: "runtime:pull-labs-demo:node-editor"})
529+
```
530+
402531

403532
### Delete user matching user ID (Admin only)
404533

scripts/usermanager.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import getpass
44
import json
55
import os
6+
import re
67
import sys
78
import urllib.error
89
import urllib.parse
@@ -151,6 +152,48 @@ def _resolve_user_id(user_id, api_url, token):
151152
return resolved_id
152153

153154

155+
def _parse_paginated_items(payload):
156+
if isinstance(payload, dict) and "items" in payload:
157+
return payload.get("items") or []
158+
if isinstance(payload, list):
159+
return payload
160+
return []
161+
162+
163+
def _looks_like_object_id(value):
164+
return bool(re.fullmatch(r"[0-9a-fA-F]{24}", value))
165+
166+
167+
def _resolve_group_id(group_id, api_url, token):
168+
if _looks_like_object_id(group_id):
169+
return group_id
170+
query = urllib.parse.urlencode({"name": group_id})
171+
status, body = _request_json(
172+
"GET", f"{api_url}/user-groups?{query}", token=token
173+
)
174+
if status >= 400:
175+
_print_response(status, body)
176+
raise SystemExit(1)
177+
try:
178+
payload = json.loads(body) if body else {}
179+
except json.JSONDecodeError as exc:
180+
raise SystemExit("Failed to parse user-groups response") from exc
181+
items = _parse_paginated_items(payload)
182+
matches = [
183+
group
184+
for group in items
185+
if isinstance(group, dict) and group.get("name") == group_id
186+
]
187+
if not matches:
188+
raise SystemExit(f"No group found with name: {group_id}")
189+
if len(matches) > 1:
190+
raise SystemExit(f"Multiple groups found with name: {group_id}")
191+
resolved_id = matches[0].get("id")
192+
if not resolved_id:
193+
raise SystemExit(f"Group {group_id} has no id")
194+
return resolved_id
195+
196+
154197
def _request_json(method, url, data=None, token=None, form=False):
155198
headers = {"accept": "application/json"}
156199
body = None
@@ -309,6 +352,17 @@ def main():
309352
delete_user = subparsers.add_parser("delete-user", help="Delete user by id")
310353
delete_user.add_argument("user_id")
311354

355+
list_groups = subparsers.add_parser("list-groups", help="List user groups")
356+
357+
get_group = subparsers.add_parser("get-group", help="Get user group by id or name")
358+
get_group.add_argument("group_id")
359+
360+
create_group = subparsers.add_parser("create-group", help="Create user group")
361+
create_group.add_argument("name")
362+
363+
delete_group = subparsers.add_parser("delete-group", help="Delete user group")
364+
delete_group.add_argument("group_id")
365+
312366
subparsers.add_parser(
313367
"print-config-example", help="Print a sample usermanager.toml"
314368
)
@@ -362,6 +416,10 @@ def main():
362416
"get-user",
363417
"update-user",
364418
"delete-user",
419+
"list-groups",
420+
"get-group",
421+
"create-group",
422+
"delete-group",
365423
}:
366424
token = _require_token(token, args)
367425

@@ -471,6 +529,23 @@ def main():
471529
status, body = _request_json(
472530
"DELETE", f"{api_url}/user/{resolved_id}", token=token
473531
)
532+
elif args.command == "list-groups":
533+
status, body = _request_json("GET", f"{api_url}/user-groups", token=token)
534+
elif args.command == "get-group":
535+
resolved_id = _resolve_group_id(args.group_id, api_url, token)
536+
status, body = _request_json(
537+
"GET", f"{api_url}/user-groups/{resolved_id}", token=token
538+
)
539+
elif args.command == "create-group":
540+
payload = {"name": args.name}
541+
status, body = _request_json(
542+
"POST", f"{api_url}/user-groups", payload, token=token
543+
)
544+
elif args.command == "delete-group":
545+
resolved_id = _resolve_group_id(args.group_id, api_url, token)
546+
status, body = _request_json(
547+
"DELETE", f"{api_url}/user-groups/{resolved_id}", token=token
548+
)
474549
else:
475550
raise SystemExit("Unknown command")
476551

tests/unit_tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,15 @@ def mock_db_find_by_id(mocker):
153153
return async_mock
154154

155155

156+
@pytest.fixture
157+
def mock_db_delete_by_id(mocker):
158+
"""Mocks async call to Database class method used to delete an object"""
159+
async_mock = AsyncMock()
160+
mocker.patch('api.db.Database.delete_by_id',
161+
side_effect=async_mock)
162+
return async_mock
163+
164+
156165
@pytest.fixture
157166
def mock_db_find_one(mocker):
158167
"""Mocks async call to database method used to find one object"""

0 commit comments

Comments
 (0)