diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index 3a7e25d6b..af1fc2c6b 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -9,6 +9,7 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.request import Request +from apps.auth_token.grafana.grafana_auth_token import setup_organization from apps.grafana_plugin.helpers.gcom import check_token from apps.grafana_plugin.sync_data import SyncPermission, SyncUser from apps.user_management.exceptions import OrganizationDeletedException, OrganizationMovedException @@ -133,6 +134,14 @@ def _get_user(request: Request, organization: Organization) -> User: except KeyError: user_id = context["UserID"] + if context.get("IsServiceAccount", False): + # no user involved in service account requests + logger.info(f"serviceaccount request - id={user_id}") + service_account_role = context.get("Role", "None") + if service_account_role.lower() != "admin": + raise exceptions.AuthenticationFailed("Service account requests must have Admin or Editor role.") + return None + try: return organization.users.get(user_id=user_id) except User.DoesNotExist: @@ -148,6 +157,9 @@ def _get_user(request: Request, organization: Organization) -> User: except (ValueError, TypeError): raise exceptions.AuthenticationFailed("Grafana context must be JSON dict.") + if context.get("IsServiceAccount", False): + raise exceptions.AuthenticationFailed("Service accounts requests are not allowed.") + try: user_id = context.get("UserId", context.get("UserID")) if user_id is not None: @@ -347,7 +359,7 @@ def authenticate(self, request): if not auth.startswith(ServiceAccountToken.GRAFANA_SA_PREFIX): return None - organization = self.get_organization(request) + organization = self.get_organization(request, auth) if not organization: raise exceptions.AuthenticationFailed("Invalid organization.") if organization.is_moved: @@ -357,12 +369,15 @@ def authenticate(self, request): return self.authenticate_credentials(organization, auth) - def get_organization(self, request): + def get_organization(self, request, auth): grafana_url = request.headers.get(X_GRAFANA_URL) if grafana_url: organization = Organization.objects.filter(grafana_url=grafana_url).first() if not organization: - raise exceptions.AuthenticationFailed("Invalid Grafana URL.") + success = setup_organization(grafana_url, auth) + if not success: + raise exceptions.AuthenticationFailed("Invalid Grafana URL.") + organization = Organization.objects.filter(grafana_url=grafana_url).first() return organization if settings.LICENSE == settings.CLOUD_LICENSE_NAME: diff --git a/engine/apps/auth_token/grafana/grafana_auth_token.py b/engine/apps/auth_token/grafana/grafana_auth_token.py index 6576e4179..6183a9da5 100644 --- a/engine/apps/auth_token/grafana/grafana_auth_token.py +++ b/engine/apps/auth_token/grafana/grafana_auth_token.py @@ -52,3 +52,11 @@ def get_service_account_details(organization: Organization, token: str) -> typin grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=token) user_data, _ = grafana_api_client.get_current_user() return user_data + + +def setup_organization(grafana_url: str, token: str): + grafana_api_client = GrafanaAPIClient(api_url=grafana_url, api_token=token) + _, call_status = grafana_api_client.setup_organization() + if call_status["status_code"] != 200: + return False + return True diff --git a/engine/apps/auth_token/tests/helpers.py b/engine/apps/auth_token/tests/helpers.py index bcecce6f2..ebbff6af5 100644 --- a/engine/apps/auth_token/tests/helpers.py +++ b/engine/apps/auth_token/tests/helpers.py @@ -3,16 +3,16 @@ import httpretty -def setup_service_account_api_mocks(organization, perms=None, user_data=None, perms_status=200, user_status=200): +def setup_service_account_api_mocks(grafana_url, perms=None, user_data=None, perms_status=200, user_status=200): # requires enabling httpretty if perms is None: perms = {} mock_response = httpretty.Response(status=perms_status, body=json.dumps(perms)) - perms_url = f"{organization.grafana_url}/api/access-control/user/permissions" + perms_url = f"{grafana_url}/api/access-control/user/permissions" httpretty.register_uri(httpretty.GET, perms_url, responses=[mock_response]) if user_data is None: user_data = {"login": "some-login", "uid": "service-account:42"} mock_response = httpretty.Response(status=user_status, body=json.dumps(user_data)) - user_url = f"{organization.grafana_url}/api/user" + user_url = f"{grafana_url}/api/user" httpretty.register_uri(httpretty.GET, user_url, responses=[mock_response]) diff --git a/engine/apps/auth_token/tests/test_grafana_auth.py b/engine/apps/auth_token/tests/test_grafana_auth.py index 3a8ec56c0..3cb01727f 100644 --- a/engine/apps/auth_token/tests/test_grafana_auth.py +++ b/engine/apps/auth_token/tests/test_grafana_auth.py @@ -10,7 +10,8 @@ from apps.auth_token.auth import X_GRAFANA_INSTANCE_ID, GrafanaServiceAccountAuthentication from apps.auth_token.models import ServiceAccountToken from apps.auth_token.tests.helpers import setup_service_account_api_mocks -from apps.user_management.models import ServiceAccountUser +from apps.user_management.models import Organization, ServiceAccountUser +from common.constants.plugin_ids import PluginID from settings.base import CLOUD_LICENSE_NAME, OPEN_SOURCE_LICENSE_NAME, SELF_HOSTED_SETTINGS @@ -98,13 +99,17 @@ def test_grafana_authentication_missing_org(): @pytest.mark.django_db @httpretty.activate(verbose=True, allow_net_connect=False) def test_grafana_authentication_invalid_grafana_url(): + grafana_url = "http://grafana.test" token = f"{ServiceAccountToken.GRAFANA_SA_PREFIX}xyz" headers = { "HTTP_AUTHORIZATION": token, - "HTTP_X_GRAFANA_URL": "http://grafana.test", # no org for this URL + "HTTP_X_GRAFANA_URL": grafana_url, # no org for this URL } request = APIRequestFactory().get("/", **headers) + request_sync_url = f"{grafana_url}/api/plugins/{PluginID.ONCALL}/resources/plugin/sync?wait=true&force=true" + httpretty.register_uri(httpretty.POST, request_sync_url, status=404) + with pytest.raises(exceptions.AuthenticationFailed) as exc: GrafanaServiceAccountAuthentication().authenticate(request) assert exc.value.detail == "Invalid Grafana URL." @@ -145,7 +150,7 @@ def test_grafana_authentication_permissions_call_fails(make_organization): # setup Grafana API responses # permissions endpoint returns a 401 - setup_service_account_api_mocks(organization, perms_status=401) + setup_service_account_api_mocks(organization.grafana_url, perms_status=401) with pytest.raises(exceptions.AuthenticationFailed) as exc: GrafanaServiceAccountAuthentication().authenticate(request) @@ -178,7 +183,7 @@ def test_grafana_authentication_existing_token( request = APIRequestFactory().get("/", **headers) # setup Grafana API responses - setup_service_account_api_mocks(organization, {"some-perm": "value"}) + setup_service_account_api_mocks(organization.grafana_url, {"some-perm": "value"}) user, auth_token = GrafanaServiceAccountAuthentication().authenticate(request) @@ -214,7 +219,7 @@ def test_grafana_authentication_token_created(make_organization): # setup Grafana API responses permissions = {"some-perm": "value"} user_data = {"login": "some-login", "uid": "service-account:42"} - setup_service_account_api_mocks(organization, permissions, user_data) + setup_service_account_api_mocks(organization.grafana_url, permissions, user_data) user, auth_token = GrafanaServiceAccountAuthentication().authenticate(request) @@ -256,7 +261,7 @@ def test_grafana_authentication_token_created_older_grafana(make_organization): # setup Grafana API responses permissions = {"some-perm": "value"} # User API fails for older Grafana versions - setup_service_account_api_mocks(organization, permissions, user_status=400) + setup_service_account_api_mocks(organization.grafana_url, permissions, user_status=400) user, auth_token = GrafanaServiceAccountAuthentication().authenticate(request) @@ -290,10 +295,50 @@ def test_grafana_authentication_token_reuse_service_account(make_organization, m "login": service_account.login, "uid": f"service-account:{service_account.grafana_id}", } - setup_service_account_api_mocks(organization, permissions, user_data) + setup_service_account_api_mocks(organization.grafana_url, permissions, user_data) user, auth_token = GrafanaServiceAccountAuthentication().authenticate(request) assert isinstance(user, ServiceAccountUser) assert user.service_account == service_account assert auth_token.service_account == service_account + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_grafana_authentication_token_setup_org_if_missing(make_organization): + grafana_url = "http://grafana.test" + token_string = "glsa_the-token" + + headers = { + "HTTP_AUTHORIZATION": token_string, + "HTTP_X_GRAFANA_URL": grafana_url, + } + request = APIRequestFactory().get("/", **headers) + + # setup Grafana API responses + permissions = {"some-perm": "value"} + setup_service_account_api_mocks(grafana_url, permissions) + + request_sync_url = f"{grafana_url}/api/plugins/{PluginID.ONCALL}/resources/plugin/sync?wait=true&force=true" + httpretty.register_uri(httpretty.POST, request_sync_url) + + assert Organization.objects.filter(grafana_url=grafana_url).count() == 0 + + def sync_org(): + make_organization(grafana_url=grafana_url, is_rbac_permissions_enabled=True) + return (True, {"status_code": 200}) + + with patch("apps.grafana_plugin.helpers.client.GrafanaAPIClient.setup_organization") as mock_setup_org: + mock_setup_org.side_effect = sync_org + user, auth_token = GrafanaServiceAccountAuthentication().authenticate(request) + + mock_setup_org.assert_called_once() + + assert isinstance(user, ServiceAccountUser) + service_account = user.service_account + # organization is created + organization = Organization.objects.filter(grafana_url=grafana_url).get() + assert organization.grafana_url == grafana_url + assert service_account.organization == organization + assert auth_token.service_account == user.service_account diff --git a/engine/apps/auth_token/tests/test_plugin_auth.py b/engine/apps/auth_token/tests/test_plugin_auth.py index d03c40b38..a44bd3777 100644 --- a/engine/apps/auth_token/tests/test_plugin_auth.py +++ b/engine/apps/auth_token/tests/test_plugin_auth.py @@ -5,7 +5,7 @@ from rest_framework.exceptions import AuthenticationFailed from rest_framework.test import APIRequestFactory -from apps.auth_token.auth import PluginAuthentication +from apps.auth_token.auth import BasePluginAuthentication, PluginAuthentication INSTANCE_CONTEXT = '{"stack_id": 42, "org_id": 24, "grafana_token": "abc"}' @@ -171,3 +171,33 @@ def test_plugin_authentication_self_hosted_setup_new_user(make_organization, mak assert ret_user.user_id == 12 assert ret_token.organization == organization assert organization.users.count() == 1 + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_raises", [("Admin", False), ("Editor", True), ("Viewer", True), ("Other", True)] +) +def test_plugin_authentication_service_account(make_organization, role, expected_raises): + # Setting gcom_token_org_last_time_synced to now, so it doesn't try to sync with gcom + organization = make_organization( + stack_id=42, org_id=24, gcom_token="123", api_token="abc", gcom_token_org_last_time_synced=timezone.now() + ) + + headers = { + "HTTP_AUTHORIZATION": "gcom:123", + "HTTP_X-Instance-Context": INSTANCE_CONTEXT, + "HTTP_X-Grafana-Context": json.dumps({"UserId": 12, "Role": role, "IsServiceAccount": True}), + } + request = APIRequestFactory().get("/", **headers) + + if expected_raises: + with pytest.raises(AuthenticationFailed): + BasePluginAuthentication().authenticate(request) + else: + ret_user, ret_token = BasePluginAuthentication().authenticate(request) + assert ret_user is None + assert ret_token.organization == organization + + # PluginAuthentication should always raise an exception if the request comes from a service account + with pytest.raises(AuthenticationFailed): + PluginAuthentication().authenticate(request) diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index 17d1cabd2..0037cb741 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -337,6 +337,9 @@ def create_service_account_token( def get_service_account_token_permissions(self) -> APIClientResponse[typing.Dict[str, typing.List[str]]]: return self.api_get("api/access-control/user/permissions") + def setup_organization(self) -> APIClientResponse: + return self.api_post(f"api/plugins/{PluginID.ONCALL}/resources/plugin/sync?wait=true&force=true") + def sync(self, organization: "Organization") -> APIClientResponse: return self.api_post(f"api/plugins/{organization.active_ui_plugin_id}/resources/plugin/sync") diff --git a/engine/apps/public_api/tests/test_alert_groups.py b/engine/apps/public_api/tests/test_alert_groups.py index acc6b823e..2b79f478e 100644 --- a/engine/apps/public_api/tests/test_alert_groups.py +++ b/engine/apps/public_api/tests/test_alert_groups.py @@ -756,7 +756,7 @@ def test_actions_disabled_for_service_accounts( perms = { permissions.RBACPermission.Permissions.ALERT_GROUPS_WRITE.value: ["*"], } - setup_service_account_api_mocks(organization, perms=perms) + setup_service_account_api_mocks(organization.grafana_url, perms=perms) client = APIClient() disabled_actions = ["acknowledge", "unacknowledge", "resolve", "unresolve", "silence", "unsilence"] diff --git a/engine/apps/public_api/tests/test_integrations.py b/engine/apps/public_api/tests/test_integrations.py index 9a4e29c64..9ac249fd1 100644 --- a/engine/apps/public_api/tests/test_integrations.py +++ b/engine/apps/public_api/tests/test_integrations.py @@ -124,7 +124,7 @@ def test_create_integration_via_service_account( perms = { permissions.RBACPermission.Permissions.INTEGRATIONS_WRITE.value: ["*"], } - setup_service_account_api_mocks(organization, perms) + setup_service_account_api_mocks(organization.grafana_url, perms) client = APIClient() data_for_create = { diff --git a/engine/apps/public_api/tests/test_resolution_notes.py b/engine/apps/public_api/tests/test_resolution_notes.py index a98abac53..63eaa6450 100644 --- a/engine/apps/public_api/tests/test_resolution_notes.py +++ b/engine/apps/public_api/tests/test_resolution_notes.py @@ -168,7 +168,7 @@ def test_create_resolution_note_via_service_account( perms = { permissions.RBACPermission.Permissions.ALERT_GROUPS_WRITE.value: ["*"], } - setup_service_account_api_mocks(organization, perms) + setup_service_account_api_mocks(organization.grafana_url, perms) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel)