Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This just reminded me we need to clean-up the GrafanaHeadersMixin in mixins.py at some point whenever we remove the old status endpoint. This way of doing it is better in that it is more tolerant of missing/different fields when dealing with plugin rollout difference between objects.

# 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just out of curiosity, what's the use-case for the new setup_organization call here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a use case in which you can bootstrap a Grafana stack from scratch (via Terraform), setting up a service account token in the process with which you could hit our API and OnCall may not know about the organization yet, so this should sync the org if the service account token auth passes and we don't have a record for that org yet.

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
Loading