feat: add service account checks in plugin auth (#5305)

Related to https://github.com/grafana/oncall-private/issues/2826
Related to https://github.com/grafana/irm/pull/459

Allow org sync requests from service account users. Also trigger a sync
during public API requests if the org wasn't yet setup.
This commit is contained in:
Matias Bordese 2024-11-28 16:03:07 -03:00 committed by GitHub
parent 86ca43858d
commit bb4875f8a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 118 additions and 17 deletions

View file

@ -9,6 +9,7 @@ from rest_framework import exceptions
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 @@ class BasePluginAuthentication(BaseAuthentication):
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 @@ class PluginAuthentication(BasePluginAuthentication):
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 @@ class GrafanaServiceAccountAuthentication(BaseAuthentication):
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 @@ class GrafanaServiceAccountAuthentication(BaseAuthentication):
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:

View file

@ -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

View file

@ -3,16 +3,16 @@ import json
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])

View file

@ -10,7 +10,8 @@ from apps.api.permissions import LegacyAccessControlRole
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

View file

@ -5,7 +5,7 @@ from django.utils import timezone
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)

View file

@ -337,6 +337,9 @@ class GrafanaAPIClient(APIClient):
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")

View file

@ -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"]

View file

@ -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 = {

View file

@ -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)