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
85 changes: 65 additions & 20 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
from pydantic import BaseModel
from jose import jwt
from jose.exceptions import JWTError
from kernelci.api.models import (

Check failure on line 48 in api/main.py

View workflow job for this annotation

GitHub Actions / Lint

Unable to import 'kernelci.api.models'
Node,
Hierarchy,
PublishEvent,
Expand Down Expand Up @@ -88,6 +88,7 @@
await pubsub_startup()
await create_indexes()
await initialize_beanie()
await ensure_legacy_node_editors()
yield

# List of all the supported API versions. This is a placeholder until the API
Expand Down Expand Up @@ -145,6 +146,23 @@
await db.initialize_beanie()


async def ensure_legacy_node_editors():
"""Grant legacy node edit privileges to specific users."""
legacy_usernames = {'staging.kernelci.org', 'production'}
group_name = 'node:edit:any'
group = await db.find_one(UserGroup, name=group_name)
if not group:
group = await db.create(UserGroup(name=group_name))
for username in legacy_usernames:
user = await db.find_one(User, username=username)
if not user:
continue
if any(existing.name == group_name for existing in user.groups):
continue
user.groups.append(group)
await db.update(user)


@app.exception_handler(ValueError)
async def value_error_exception_handler(request: Request, exc: ValueError):
"""Global exception handler for 'ValueError'"""
Expand Down Expand Up @@ -547,6 +565,11 @@
its own profile.
"""
metrics.add('http_requests_total', 1)
if user.groups:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User groups can only be updated by an admin user",
)
if user.username and user.username != current_user.username:
existing_user = await db.find_one(User, username=user.username)
if existing_user:
Expand All @@ -565,7 +588,7 @@
{group_name}")
groups.append(group)
user_update = UserUpdate(**(user.model_dump(
exclude={'groups'}, exclude_none=True)))
exclude={'groups', 'is_superuser'}, exclude_none=True)))
if groups:
user_update.groups = groups
return await users_router.routes[1].endpoint(
Expand Down Expand Up @@ -612,15 +635,44 @@
updated_user = await users_router.routes[3].endpoint(
user_update, request, user_from_id, user_manager
)
# Update user to be an admin user explicitly if requested as
# `fastapi-users` user update route does not allow it
if user.is_superuser:
# Update superuser explicitly since fastapi-users update route ignores it.
if user.is_superuser is not None:
user_from_id = await db.find_by_id(User, updated_user.id)
user_from_id.is_superuser = True
user_from_id.is_superuser = user.is_superuser
updated_user = await db.update(user_from_id)
return updated_user


def _get_node_runtime(node: Node) -> Optional[str]:
"""Best-effort runtime lookup from node data."""
data = getattr(node, 'data', None)
if isinstance(data, dict):
return data.get('runtime')
return getattr(data, 'runtime', None)


def _user_can_edit_node(user: User, node: Node) -> bool:
"""Return True when user can update the given node."""
if user.is_superuser:
return True
if user.username == node.owner:
return True
user_group_names = {group.name for group in user.groups}
if 'node:edit:any' in user_group_names:
return True
if any(group_name in user_group_names
for group_name in getattr(node, 'user_groups', [])):
return True
runtime = _get_node_runtime(node)
if runtime:
runtime_editor = f'runtime:{runtime}:node-editor'
runtime_admin = f'runtime:{runtime}:node-admin'
if (runtime_editor in user_group_names
or runtime_admin in user_group_names):
return True
return False


async def authorize_user(node_id: str,
user: User = Depends(get_current_user)):
"""Return the user if active, authenticated, and authorized"""
Expand All @@ -633,17 +685,11 @@
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Node not found with id: {node_id}"
)
# users staging.kernelci.org and production are superusers
# TBD: This is HACK until qualcomm can migrate to direct KCIDB
if user.username in ['staging.kernelci.org', 'production']:
return user
if not user.username == node_from_id.owner:
if not any(group.name in node_from_id.user_groups
for group in user.groups):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unauthorized to complete the operation"
)
if not _user_can_edit_node(user, node_from_id):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unauthorized to complete the operation"
)
return user


Expand Down Expand Up @@ -1108,10 +1154,8 @@
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Node not found with id: {node_id}"
)
# verify ownership, and ignore if not owner
if not user.username == node_from_id.owner\
and user.username != 'production' and\
user.username != 'staging.kernelci.org':
# Verify authorization, and ignore if not permitted.
if not _user_can_edit_node(user, node_from_id):
continue
# right now we support only field:
# processed_by_kcidb_bridge, also value should be boolean
Expand Down Expand Up @@ -1448,6 +1492,7 @@
pubsub_startup,
create_indexes,
initialize_beanie,
ensure_legacy_node_editors,
start_background_tasks,
])

Expand Down
36 changes: 35 additions & 1 deletion doc/api-details.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ $ curl -X 'POST' \
### Update own user account

A user can update certain information for its own account, such as
`email`, `username`, `password`, and `groups` with a `PATCH /user/me` request.
`email`, `username`, and `password` with a `PATCH /user/me` request.
For example,
```
$ curl -X 'PATCH' \
Expand All @@ -348,6 +348,7 @@ $ curl -X 'PATCH' \
```

Please note that user management fields such as `is_useruser`, `is_verified`, and `is_active` can not be updated by this request for security purposes.
User group membership can only be updated by admin users.


### Update an existing user account (Admin only)
Expand All @@ -365,6 +366,39 @@ $ curl -X 'PATCH' \
-d '{"email": "[email protected]", "groups": ["kernelci"]}'
```

### User groups and permissions

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`:

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

Admin users can assign or remove groups via:

- `POST /user/invite` with a `groups` list
- `PATCH /user/<user-id>` with `groups`
- `scripts/usermanager.py update-user --data '{"groups": [...]}'`

To remove a group, send a `groups` list that omits it; the list replaces the
existing groups.

Example using the helper script:

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

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


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

Expand Down
Loading