Skip to content
Merged
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
87 changes: 83 additions & 4 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 @@ -585,8 +587,11 @@
if not group:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"User group does not exist with name: \
{group_name}")
detail=(
"User group does not exist with name: "
f"{group_name}"
),
)
groups.append(group)
user_update = UserUpdate(**(user.model_dump(
exclude={'groups', 'is_superuser'}, exclude_none=True)))
Expand Down Expand Up @@ -624,8 +629,11 @@
if not group:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"User group does not exist with name: \
{group_name}")
detail=(
"User group does not exist with name: "
f"{group_name}"
),
)
groups.append(group)
user_update = UserUpdate(**(user.model_dump(
exclude={'groups'}, exclude_none=True)))
Expand All @@ -644,6 +652,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 [email protected] \
--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
Loading