Add "Notifications Receiver" RBAC role (#1853)

# What this PR does

Closes #1651 

Plus, add developer instructions on how to run `grafana-enterprise` with
RBAC for OnCall, enabled locally.

## Todo

- [x] add API integration test for new `permission` query param filter

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Joey Orlando 2023-05-02 08:19:34 -04:00 committed by GitHub
parent 50eb1fed5d
commit 0d4db59137
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 140 additions and 23 deletions

View file

@ -7,13 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Changed
- Remove template editor from Slack by @iskhakov ([1847](https://github.com/grafana/oncall/pull/1847))
### Added
- Add filter descriptions to web ui by @iskhakov ([1845](https://github.com/grafana/oncall/pull/1845))
- Add "Notifications Receiver" RBAC role by @joeyorlando ([#1853](https://github.com/grafana/oncall/pull/1853))
### Changed
- Remove template editor from Slack by @iskhakov ([1847](https://github.com/grafana/oncall/pull/1847))
### Fixed

View file

@ -2,8 +2,9 @@
- [Running the project](#running-the-project)
- [`COMPOSE_PROFILES`](#compose_profiles)
- [`GRAFANA_VERSION`](#grafana_version)
- [`GRAFANA_IMAGE`](#grafana_image)
- [Configuring Grafana](#configuring-grafana)
- [Enabling RBAC for OnCall for local development](#enabling-rbac-for-oncall-for-local-development)
- [Django Silk Profiling](#django-silk-profiling)
- [Running backend services outside Docker](#running-backend-services-outside-docker)
- [UI Integration Tests](#ui-integration-tests)
@ -80,11 +81,11 @@ The default is `engine,oncall_ui,redis,grafana`. This runs:
- Redis as the Celery message broker/cache
- a Grafana container
### `GRAFANA_VERSION`
### `GRAFANA_IMAGE`
If you would like to change the version of Grafana being run, simply pass in a `GRAFANA_VERSION` environment variable
to `make start` (or alternatively set it in your `.env.dev` file). The value of this environment variable should be a
valid `grafana/grafana` published Docker [image tag](https://hub.docker.com/r/grafana/grafana/tags).
If you would like to change the image or version of Grafana being run, simply pass in a `GRAFANA_IMAGE` environment variable
to `make start` (or alternatively set it in your root `.env` file). The value of this environment variable should be a
valid `grafana` image/tag combination (ex. `grafana:main` or `grafana-enterprise:latest`).
### Configuring Grafana
@ -99,9 +100,44 @@ touch ./dev/grafana.dev.ini
touch .env && ./dev/add_env_var.sh GRAFANA_DEV_PROVISIONING ./dev/grafana.dev.ini .env
```
For example, if you would like to enable the `topnav` feature toggle, you can modify your `./dev/grafana.dev.ini` as
such:
```ini
[feature_toggles]
enable = top_nav
```
The next time you start the project via `docker-compose`, the `grafana` container will have `./dev/grafana.dev.ini`
volume mounted inside the container.
### Enabling RBAC for OnCall for local development
To run the project locally w/ RBAC for OnCall enabled, you will first need to run a `grafana-enterprise` container,
instead of a `grafana` container. See the instructions [here](#grafana_image) on how to do so.
Next, you will need to follow the steps [here](https://grafana.com/docs/grafana/latest/administration/enterprise-licensing/)
on setting up/downloading a Grafana Enterprise license.
Lastly, you will need to modify the instance's configuration. Follow the instructions [here](#configuring-grafana) on
how to do so. You can modify your configuration file (`./dev/grafana.dev.ini`) as such:
```ini
[rbac]
enabled = true
[feature_toggles]
enable = accessControlOnCall
[server]
root_url = https://<your-stack-slug>.grafana.net/
[enterprise]
license_text = <content-of-the-license-jwt-that-you-downloaded>
```
(_Note_: you may need to restart your `grafana` container after modifying its configuration)
### Django Silk Profiling
In order to setup [`django-silk`](https://github.com/jazzband/django-silk) for local profiling, perform the following
@ -157,6 +193,7 @@ yarn test:integration
## Useful `make` commands
See [`COMPOSE_PROFILES`](#compose_profiles) for more information on what this option is and how to configure it.
> 🚶This part was moved to `make help` command. Run it to see all the available commands and their descriptions
## Setting environment variables

View file

@ -275,13 +275,12 @@ services:
grafana:
container_name: grafana
labels: *oncall-labels
image: "grafana/grafana:${GRAFANA_VERSION:-latest}"
image: "grafana/${GRAFANA_IMAGE:-grafana:latest}"
restart: always
environment:
GF_SECURITY_ADMIN_USER: oncall
GF_SECURITY_ADMIN_PASSWORD: oncall
GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app
GF_FEATURE_TOGGLES_ENABLE: topnav
env_file:
- ./dev/.env.${DB}.dev
ports:

View file

@ -131,7 +131,7 @@ services:
- with_grafana
grafana:
image: "grafana/grafana:${GRAFANA_VERSION:-latest}"
image: "grafana/${GRAFANA_IMAGE:-grafana:latest}"
restart: always
ports:
- "3000:3000"

View file

@ -73,7 +73,7 @@ services:
retries: 10
grafana:
image: "grafana/grafana:${GRAFANA_VERSION:-latest}"
image: "grafana/${GRAFANA_IMAGE:-grafana:latest}"
restart: always
ports:
- "3000:3000"

View file

@ -236,6 +236,22 @@ class RBACPermission(permissions.BasePermission):
return True
ALL_PERMISSION_NAMES = [perm for perm in dir(RBACPermission.Permissions) if not perm.startswith("_")]
ALL_PERMISSION_CLASSES = [
getattr(RBACPermission.Permissions, permission_name) for permission_name in ALL_PERMISSION_NAMES
]
ALL_PERMISSION_CHOICES = [
(permission_class.value, permission_name)
for permission_class, permission_name in zip(ALL_PERMISSION_CLASSES, ALL_PERMISSION_NAMES)
]
def get_permission_from_permission_string(perm: str) -> typing.Optional[LegacyAccessControlCompatiblePermission]:
for permission_class in ALL_PERMISSION_CLASSES:
if permission_class.value == perm:
return permission_class
class IsOwner(permissions.BasePermission):
def __init__(self, ownership_field: typing.Optional[str] = None) -> None:
self.ownership_field = ownership_field

View file

@ -10,7 +10,12 @@ from rest_framework import status
from rest_framework.response import Response
from rest_framework.test import APIClient
from apps.api.permissions import DONT_USE_LEGACY_PERMISSION_MAPPING, LegacyAccessControlRole
from apps.api.permissions import (
DONT_USE_LEGACY_PERMISSION_MAPPING,
GrafanaAPIPermission,
LegacyAccessControlRole,
RBACPermission,
)
from apps.base.models import UserNotificationPolicy
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
from apps.user_management.models.user import default_working_hours
@ -181,6 +186,39 @@ def test_list_users(
assert response.json() == expected_payload
@pytest.mark.django_db
def test_list_users_filtered_by_granted_permission(
make_organization,
make_user_for_organization,
make_token_for_organization,
make_user_auth_headers,
):
perm_to_filter_on = RBACPermission.Permissions.NOTIFICATIONS_READ.value
perms_to_grant = [GrafanaAPIPermission(action=perm_to_filter_on)]
organization = make_organization()
admin_user = make_user_for_organization(organization)
user1 = make_user_for_organization(organization, permissions=perms_to_grant)
user2 = make_user_for_organization(organization, permissions=perms_to_grant)
user3 = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER)
_, token = make_token_for_organization(organization)
client = APIClient()
url = reverse("api-internal:user-list")
response = client.get(
f"{url}?permission={perm_to_filter_on}", format="json", **make_user_auth_headers(admin_user, token)
)
assert response.status_code == status.HTTP_200_OK
returned_user_pks = [u["pk"] for u in response.json()["results"]]
assert admin_user.public_primary_key in returned_user_pks
assert user1.public_primary_key in returned_user_pks
assert user2.public_primary_key in returned_user_pks
assert user3.public_primary_key not in returned_user_pks
@pytest.mark.django_db
def test_notification_chain_verbal(
make_organization,

View file

@ -17,9 +17,11 @@ from rest_framework.views import APIView
from apps.alerts.paging import check_user_availability
from apps.api.permissions import (
ALL_PERMISSION_CHOICES,
IsOwnerOrHasRBACPermissions,
LegacyAccessControlRole,
RBACPermission,
get_permission_from_permission_string,
user_is_authorized,
)
from apps.api.serializers.team import TeamSerializer
@ -98,13 +100,24 @@ class UserFilter(filters.FilterSet):
"""
email = filters.CharFilter(field_name="email", lookup_expr="icontains")
roles = filters.MultipleChoiceFilter(
field_name="role", choices=LegacyAccessControlRole.choices()
) # LEGACY.. this should get removed eventually
# TODO: remove "roles" in next version
roles = filters.MultipleChoiceFilter(field_name="role", choices=LegacyAccessControlRole.choices())
permission = filters.ChoiceFilter(method="filter_by_permission", choices=ALL_PERMISSION_CHOICES)
class Meta:
model = User
fields = ["email", "roles"]
# TODO: remove "roles" in next version
fields = ["email", "roles", "permission"]
def filter_by_permission(self, queryset, name, value):
rbac_permission = get_permission_from_permission_string(value)
if not rbac_permission:
# TODO: maybe raise a 400 here?
return queryset
return queryset.filter(
**User.build_permissions_query(rbac_permission, self.request.user.organization),
)
class UserView(

View file

@ -292,10 +292,11 @@ ROLE_PERMISSION_MAPPING = get_user_permission_role_mapping_from_frontend_plugin_
def make_user():
def _make_user(role: typing.Optional[LegacyAccessControlRole] = None, **kwargs):
role = LegacyAccessControlRole.ADMIN if role is None else role
permissions = ROLE_PERMISSION_MAPPING[role] if IS_RBAC_ENABLED else []
return UserFactory(
role=role, permissions=[GrafanaAPIPermission(action=perm.value) for perm in permissions], **kwargs
)
permissions = kwargs.pop("permissions", None)
if permissions is None:
permissions_to_grant = ROLE_PERMISSION_MAPPING[role] if IS_RBAC_ENABLED else []
permissions = [GrafanaAPIPermission(action=perm.value) for perm in permissions_to_grant]
return UserFactory(role=role, permissions=permissions, **kwargs)
return _make_user

View file

@ -110,7 +110,7 @@ const UserGroups = (props: UserGroupsProps) => {
key={items.length}
showSearch
placeholder="Add user"
href="/users/?roles=0&roles=1&filters=true"
href={`/users/?permission=${UserActions.NotificationsRead.permission}&filters=true`}
value={null}
onChange={handleUserAdd}
showError={showError}

View file

@ -311,6 +311,18 @@
},
"grants": ["Viewer"]
},
{
"role": {
"name": "Notifications Receiver",
"description": "Grants the ability to receive OnCall alert notifications. By virtue, also grants the user the ability to edit their own OnCall settings.",
"permissions": [
{ "action": "plugins.app:access", "scope": "plugins:id:grafana-oncall-app" },
{ "action": "grafana-oncall-app.notifications:read" },
{ "action": "grafana-oncall-app.user-settings:write" }
]
},
"grants": []
},
{
"role": {
"name": "OnCaller",