diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f5060c..671fa857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dev/README.md b/dev/README.md index 4df11327..38c7f73c 100644 --- a/dev/README.md +++ b/dev/README.md @@ -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://.grafana.net/ + +[enterprise] +license_text = +``` + +(_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 diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index ae12d704..464e2cc0 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -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: diff --git a/docker-compose-mysql-rabbitmq.yml b/docker-compose-mysql-rabbitmq.yml index 27015cf7..18f20b69 100644 --- a/docker-compose-mysql-rabbitmq.yml +++ b/docker-compose-mysql-rabbitmq.yml @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 1dcdfc61..6bb3e6cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/engine/apps/api/permissions/__init__.py b/engine/apps/api/permissions/__init__.py index 60caf3dc..a1bebd91 100644 --- a/engine/apps/api/permissions/__init__.py +++ b/engine/apps/api/permissions/__init__.py @@ -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 diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index cf20686f..098f323c 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -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, diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index 835eb76a..2b0a997c 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -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( diff --git a/engine/conftest.py b/engine/conftest.py index de2ad0a3..09046897 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -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 diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.tsx b/grafana-plugin/src/components/UserGroups/UserGroups.tsx index c91ef3a8..70c46aa3 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.tsx +++ b/grafana-plugin/src/components/UserGroups/UserGroups.tsx @@ -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} diff --git a/grafana-plugin/src/plugin.json b/grafana-plugin/src/plugin.json index 2ab9172e..cdbfef28 100644 --- a/grafana-plugin/src/plugin.json +++ b/grafana-plugin/src/plugin.json @@ -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",