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:
parent
86ca43858d
commit
bb4875f8a5
9 changed files with 118 additions and 17 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue