Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
Header,
Query,
Body,
Response,
)
from fastapi.encoders import jsonable_encoder
from fastapi.responses import (
Expand All @@ -45,7 +46,7 @@
from pydantic import BaseModel
from jose import jwt
from jose.exceptions import JWTError
from kernelci.api.models import (

Check failure on line 49 in api/main.py

View workflow job for this annotation

GitHub Actions / Lint

Unable to import 'kernelci.api.models'
Node,
Hierarchy,
PublishEvent,
Expand All @@ -70,6 +71,7 @@
UserUpdate,
UserUpdateRequest,
UserGroup,
UserGroupCreateRequest,
InviteAcceptRequest,
InviteUrlResponse,
)
Expand Down Expand Up @@ -644,6 +646,77 @@
return updated_user


@app.get("/user-groups", response_model=PageModel, tags=["user"])
async def get_user_groups(request: Request,
current_user: User = Depends(get_current_superuser)):
"""List user groups (admin-only)."""
metrics.add('http_requests_total', 1)
query_params = dict(request.query_params)
for pg_key in ['limit', 'offset']:
query_params.pop(pg_key, None)
paginated_resp = await db.find_by_attributes(UserGroup, query_params)
paginated_resp.items = serialize_paginated_data(
UserGroup, paginated_resp.items)
return paginated_resp


@app.get("/user-groups/{group_id}", response_model=UserGroup, tags=["user"],
response_model_by_alias=False)
async def get_user_group(group_id: str,
current_user: User = Depends(get_current_superuser)):
"""Get a user group by id (admin-only)."""
metrics.add('http_requests_total', 1)
group = await db.find_by_id(UserGroup, group_id)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User group not found with id: {group_id}",
)
return group


@app.post("/user-groups", response_model=UserGroup, tags=["user"],
response_model_by_alias=False)
async def create_user_group(group: UserGroupCreateRequest,
current_user: User = Depends(
get_current_superuser)):
"""Create a user group (admin-only)."""
metrics.add('http_requests_total', 1)
existing = await db.find_one(UserGroup, name=group.name)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"User group already exists with name: {group.name}",
)
return await db.create(UserGroup(name=group.name))


@app.delete("/user-groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT,
tags=["user"])
async def delete_user_group(group_id: str,
current_user: User = Depends(
get_current_superuser)):
"""Delete a user group (admin-only)."""
metrics.add('http_requests_total', 1)
group = await db.find_by_id(UserGroup, group_id)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User group not found with id: {group_id}",
)
assigned_count = await db.count(User, {"groups.name": group.name})
if assigned_count:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"User group is assigned to users and cannot be deleted. "
"Remove it from users first."
),
)
await db.delete_by_id(UserGroup, group_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)


def _get_node_runtime(node: Node) -> Optional[str]:
"""Best-effort runtime lookup from node data."""
data = getattr(node, 'data', None)
Expand Down
5 changes: 5 additions & 0 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
Document,
PydanticObjectId,
)
from kernelci.api.models_base import DatabaseModel, ModelId

Check failure on line 32 in api/models.py

View workflow job for this annotation

GitHub Actions / Lint

Unable to import 'kernelci.api.models_base'


# PubSub model definitions
Expand Down Expand Up @@ -115,6 +115,11 @@
]


class UserGroupCreateRequest(BaseModel):
"""Create user group request schema for API router"""
name: str = Field(description="User group name")


class User(BeanieBaseUser, Document, # pylint: disable=too-many-ancestors
DatabaseModel):
"""API User model"""
Expand Down
133 changes: 131 additions & 2 deletions doc/api-details.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,14 +372,22 @@ User groups are plain name strings stored in the `usergroup` collection. Group
names must already exist before they can be assigned to users; otherwise the
API returns `400`.

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

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

Admin-only user group management endpoints are available:

- `GET /user-groups` (list; supports `name` filter)
- `GET /user-groups/<group-id>`
- `POST /user-groups` with `{"name": "runtime:lava-collabora:node-editor"}`
- `DELETE /user-groups/<group-id>` (fails with `409` if assigned to users)

Admin users can assign or remove groups via:

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

```
$ ./scripts/usermanager.py list-users
$ ./scripts/usermanager.py list-groups
$ ./scripts/usermanager.py create-group runtime:lava-collabora:node-editor
$ ./scripts/usermanager.py update-user 615f30020eb7c3c6616e5ac3 \
--data '{"groups": ["runtime:lava-collabora:node-editor"]}'
```

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

### Usermanager workflows (examples)

These examples use `scripts/usermanager.py`. It reads `./usermanager.toml` or
`~/.config/kernelci/usermanager.toml` by default, and you can override with
`--api-url`/`--token` or `KCI_API_URL`/`KCI_API_TOKEN`.

Common admin workflows:

- List users and capture IDs:

```
$ ./scripts/usermanager.py list-users
$ ./scripts/usermanager.py get-user <USER-ID>
```

- Invite a user (optionally add groups):

```
$ ./scripts/usermanager.py invite \
--username alice \
--email alice@example.org \
--groups runtime:pull-labs-demo:node-editor \
--return-token
```

- Accept an invite manually (useful for service accounts or testing):

```
$ ./scripts/usermanager.py accept-invite --token "<INVITE-TOKEN>"
```

- Login to get a bearer token:

```
$ ./scripts/usermanager.py login --username alice
```

- Deactivate or reactivate a user:

```
$ ./scripts/usermanager.py update-user <USER-ID> --inactive
$ ./scripts/usermanager.py update-user <USER-ID> --active
```

- Grant or revoke superuser:

```
$ ./scripts/usermanager.py update-user <USER-ID> --superuser
$ ./scripts/usermanager.py update-user <USER-ID> --no-superuser
```

- Mark a user verified or unverified (admin only):

```
$ ./scripts/usermanager.py update-user <USER-ID> --verified
$ ./scripts/usermanager.py update-user <USER-ID> --unverified
```

- Assign or remove groups:

```
$ ./scripts/usermanager.py update-user <USER-ID> \
--add-group runtime:pull-labs-demo:node-editor
$ ./scripts/usermanager.py update-user <USER-ID> \
--remove-group runtime:pull-labs-demo:node-editor
$ ./scripts/usermanager.py update-user <USER-ID> \
--set-groups runtime:pull-labs-demo:node-editor,team-a
```

- Set a password (admin only, useful for service accounts):

```
$ ./scripts/usermanager.py update-user <USER-ID> --password "<new-password>"
```

- Manage user groups:

```
$ ./scripts/usermanager.py list-groups
$ ./scripts/usermanager.py create-group runtime:pull-labs-demo:node-editor
$ ./scripts/usermanager.py delete-group runtime:pull-labs-demo:node-editor
```

- Delete a user:

```
$ ./scripts/usermanager.py delete-user <USER-ID>
```

### Permissions and node update rules

Node update permissions are determined by the user and the node being edited:

- Superusers can update any node.
- The node owner can update their own nodes.
- Users with group `node:edit:any` can update any node.
- Users with a group listed in the node's `user_groups` can update that node.
- Users with `runtime:<runtime>:node-editor` or `runtime:<runtime>:node-admin`
can update nodes whose `data.runtime` matches `<runtime>`.

Example: allow updates only for runtime `pull-labs-demo`:

```
$ mongosh "mongodb://db:27017/kernelci"
> db.usergroup.insertOne({name: "runtime:pull-labs-demo:node-editor"})
```

```
$ ./scripts/usermanager.py update-user <USER-ID> \
--add-group runtime:pull-labs-demo:node-editor
```

To remove a user group definition entirely, delete it in MongoDB:

```
$ mongosh "mongodb://db:27017/kernelci"
> db.usergroup.deleteOne({name: "runtime:pull-labs-demo:node-editor"})
```


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

Expand Down
75 changes: 75 additions & 0 deletions scripts/usermanager.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#!/usr/bin/env python3

Check warning on line 1 in scripts/usermanager.py

View workflow job for this annotation

GitHub Actions / Lint

Missing module docstring
import argparse
import getpass
import json
import os
import re
import sys
import urllib.error
import urllib.parse
Expand Down Expand Up @@ -132,7 +133,7 @@
raise SystemExit(1)
try:
payload = json.loads(body) if body else []
except json.JSONDecodeError as exc:

Check warning on line 136 in scripts/usermanager.py

View workflow job for this annotation

GitHub Actions / Lint

Redefining name 'exc' from outer scope (line 14)
raise SystemExit("Failed to parse users response") from exc
if not isinstance(payload, list):
raise SystemExit("Unexpected users response")
Expand All @@ -151,6 +152,48 @@
return resolved_id


def _parse_paginated_items(payload):
if isinstance(payload, dict) and "items" in payload:
return payload.get("items") or []
if isinstance(payload, list):
return payload
return []


def _looks_like_object_id(value):
return bool(re.fullmatch(r"[0-9a-fA-F]{24}", value))


def _resolve_group_id(group_id, api_url, token):
if _looks_like_object_id(group_id):
return group_id
query = urllib.parse.urlencode({"name": group_id})
status, body = _request_json(
"GET", f"{api_url}/user-groups?{query}", token=token
)
if status >= 400:
_print_response(status, body)
raise SystemExit(1)
try:
payload = json.loads(body) if body else {}
except json.JSONDecodeError as exc:

Check warning on line 179 in scripts/usermanager.py

View workflow job for this annotation

GitHub Actions / Lint

Redefining name 'exc' from outer scope (line 14)
raise SystemExit("Failed to parse user-groups response") from exc
items = _parse_paginated_items(payload)
matches = [
group
for group in items
if isinstance(group, dict) and group.get("name") == group_id
]
if not matches:
raise SystemExit(f"No group found with name: {group_id}")
if len(matches) > 1:
raise SystemExit(f"Multiple groups found with name: {group_id}")
resolved_id = matches[0].get("id")
if not resolved_id:
raise SystemExit(f"Group {group_id} has no id")
return resolved_id


def _request_json(method, url, data=None, token=None, form=False):
headers = {"accept": "application/json"}
body = None
Expand All @@ -168,7 +211,7 @@
with urllib.request.urlopen(req) as response:
payload = response.read().decode("utf-8")
return response.status, payload
except urllib.error.HTTPError as exc:

Check warning on line 214 in scripts/usermanager.py

View workflow job for this annotation

GitHub Actions / Lint

Redefining name 'exc' from outer scope (line 14)
payload = exc.read().decode("utf-8")
return exc.code, payload

Expand All @@ -194,7 +237,7 @@
)


def main():

Check warning on line 240 in scripts/usermanager.py

View workflow job for this annotation

GitHub Actions / Lint

Too many statements (158/50)

Check warning on line 240 in scripts/usermanager.py

View workflow job for this annotation

GitHub Actions / Lint

Too many branches (36/12)

Check warning on line 240 in scripts/usermanager.py

View workflow job for this annotation

GitHub Actions / Lint

Too many local variables (36/15)

Check warning on line 240 in scripts/usermanager.py

View workflow job for this annotation

GitHub Actions / Lint

Missing function or method docstring
default_paths = "\n".join(f" - {path}" for path in DEFAULT_CONFIG_PATHS)
parser = argparse.ArgumentParser(
description="KernelCI API user management helper",
Expand Down Expand Up @@ -238,7 +281,7 @@
invite.add_argument("--return-token", action="store_true")
invite.add_argument("--resend-if-exists", action="store_true")

invite_url = subparsers.add_parser("invite-url", help="Preview invite URL base")

Check warning on line 284 in scripts/usermanager.py

View workflow job for this annotation

GitHub Actions / Lint

Unused variable 'invite_url'

accept = subparsers.add_parser("accept-invite", help="Accept an invite")
accept.add_argument("--token")
Expand Down Expand Up @@ -309,6 +352,17 @@
delete_user = subparsers.add_parser("delete-user", help="Delete user by id")
delete_user.add_argument("user_id")

list_groups = subparsers.add_parser("list-groups", help="List user groups")

get_group = subparsers.add_parser("get-group", help="Get user group by id or name")
get_group.add_argument("group_id")

create_group = subparsers.add_parser("create-group", help="Create user group")
create_group.add_argument("name")

delete_group = subparsers.add_parser("delete-group", help="Delete user group")
delete_group.add_argument("group_id")

subparsers.add_parser(
"print-config-example", help="Print a sample usermanager.toml"
)
Expand Down Expand Up @@ -362,6 +416,10 @@
"get-user",
"update-user",
"delete-user",
"list-groups",
"get-group",
"create-group",
"delete-group",
}:
token = _require_token(token, args)

Expand Down Expand Up @@ -422,7 +480,7 @@
if args.data:
try:
data = json.loads(args.data)
except json.JSONDecodeError as exc:

Check warning on line 483 in scripts/usermanager.py

View workflow job for this annotation

GitHub Actions / Lint

Redefining name 'exc' from outer scope (line 14)
raise SystemExit("Invalid JSON for --data") from exc
if not isinstance(data, dict):
raise SystemExit("--data must be a JSON object")
Expand Down Expand Up @@ -471,6 +529,23 @@
status, body = _request_json(
"DELETE", f"{api_url}/user/{resolved_id}", token=token
)
elif args.command == "list-groups":
status, body = _request_json("GET", f"{api_url}/user-groups", token=token)
elif args.command == "get-group":
resolved_id = _resolve_group_id(args.group_id, api_url, token)
status, body = _request_json(
"GET", f"{api_url}/user-groups/{resolved_id}", token=token
)
elif args.command == "create-group":
payload = {"name": args.name}
status, body = _request_json(
"POST", f"{api_url}/user-groups", payload, token=token
)
elif args.command == "delete-group":
resolved_id = _resolve_group_id(args.group_id, api_url, token)
status, body = _request_json(
"DELETE", f"{api_url}/user-groups/{resolved_id}", token=token
)
else:
raise SystemExit("Unknown command")

Expand Down
9 changes: 9 additions & 0 deletions tests/unit_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,15 @@ def mock_db_find_by_id(mocker):
return async_mock


@pytest.fixture
def mock_db_delete_by_id(mocker):
"""Mocks async call to Database class method used to delete an object"""
async_mock = AsyncMock()
mocker.patch('api.db.Database.delete_by_id',
side_effect=async_mock)
return async_mock


@pytest.fixture
def mock_db_find_one(mocker):
"""Mocks async call to database method used to find one object"""
Expand Down
Loading