Skip to content

Commit

Permalink
feat: add service account checks in plugin auth (#5305)
Browse files Browse the repository at this point in the history
Related to grafana/oncall-private#2826
Related to grafana/irm#459

Allow org sync requests from service account users. Also trigger a sync
during public API requests if the org wasn't yet setup.
  • Loading branch information
matiasb authored Nov 28, 2024
1 parent 86ca438 commit bb4875f
Show file tree
Hide file tree
Showing 9 changed files with 118 additions and 17 deletions.
21 changes: 18 additions & 3 deletions engine/apps/auth_token/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions engine/apps/auth_token/grafana/grafana_auth_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions engine/apps/auth_token/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
59 changes: 52 additions & 7 deletions engine/apps/auth_token/tests/test_grafana_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
32 changes: 31 additions & 1 deletion engine/apps/auth_token/tests/test_plugin_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}'

Expand Down Expand Up @@ -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)
3 changes: 3 additions & 0 deletions engine/apps/grafana_plugin/helpers/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion engine/apps/public_api/tests/test_alert_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
2 changes: 1 addition & 1 deletion engine/apps/public_api/tests/test_integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion engine/apps/public_api/tests/test_resolution_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit bb4875f

Please sign in to comment.