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:
parent
50eb1fed5d
commit
0d4db59137
11 changed files with 140 additions and 23 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue