Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PoC service account token auth for public API #5212

Draft
wants to merge 1 commit into
base: dev
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions docker-compose-developer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ services:
container_name: grafana
labels: *oncall-labels
image: "grafana/${GRAFANA_IMAGE:-grafana:latest}"
# image: "grafana/grafana:11.0.6"
restart: always
environment:
GF_SECURITY_ADMIN_USER: oncall
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 4.2.15 on 2024-10-21 19:19

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('user_management', '0024_serviceaccount'),
('alerts', '0061_alter_alertgroup_resolved_by_alert'),
]

operations = [
migrations.AddField(
model_name='alertreceivechannel',
name='service_account',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='alert_receive_channels', to='user_management.serviceaccount'),
),
]
14 changes: 11 additions & 3 deletions engine/apps/alerts/models/alert_receive_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,13 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
author = models.ForeignKey(
"user_management.User", on_delete=models.SET_NULL, related_name="alert_receive_channels", blank=True, null=True
)
service_account = models.ForeignKey(
"user_management.ServiceAccount",
on_delete=models.SET_NULL,
related_name="alert_receive_channels",
blank=True,
null=True,
)
team = models.ForeignKey(
"user_management.Team",
on_delete=models.SET_NULL,
Expand Down Expand Up @@ -764,15 +771,16 @@ def listen_for_alertreceivechannel_model_save(
from apps.heartbeat.models import IntegrationHeartBeat

if created:
write_resource_insight_log(instance=instance, author=instance.author, event=EntityEvent.CREATED)
author = instance.author or instance.service_account
write_resource_insight_log(instance=instance, author=author, event=EntityEvent.CREATED)
default_filter = ChannelFilter(alert_receive_channel=instance, filtering_term=None, is_default=True)
default_filter.save()
write_resource_insight_log(instance=default_filter, author=instance.author, event=EntityEvent.CREATED)
write_resource_insight_log(instance=default_filter, author=author, event=EntityEvent.CREATED)

TEN_MINUTES = 600 # this is timeout for cloud heartbeats
if instance.is_available_for_integration_heartbeat:
heartbeat = IntegrationHeartBeat.objects.create(alert_receive_channel=instance, timeout_seconds=TEN_MINUTES)
write_resource_insight_log(instance=heartbeat, author=instance.author, event=EntityEvent.CREATED)
write_resource_insight_log(instance=heartbeat, author=author, event=EntityEvent.CREATED)

metrics_add_integrations_to_cache([instance], instance.organization)

Expand Down
43 changes: 13 additions & 30 deletions engine/apps/auth_token/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.request import Request

from apps.api.permissions import GrafanaAPIPermissions, LegacyAccessControlRole, RBACPermission, user_is_authorized
from apps.api.permissions import RBACPermission, user_is_authorized
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 All @@ -20,13 +20,13 @@

from .constants import GOOGLE_OAUTH2_AUTH_TOKEN_NAME, SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME
from .exceptions import InvalidToken
from .grafana.grafana_auth_token import get_service_account_token_permissions
from .models import (
ApiAuthToken,
GoogleOAuth2Token,
IntegrationBacksyncAuthToken,
PluginAuthToken,
ScheduleExportAuthToken,
ServiceAccountToken,
SlackAuthToken,
UserScheduleExportAuthToken,
)
Expand Down Expand Up @@ -338,16 +338,16 @@ def authenticate_credentials(
return auth_token.user, auth_token


X_GRAFANA_URL = "X-Grafana-URL"
X_GRAFANA_INSTANCE_ID = "X-Grafana-Instance-ID"
GRAFANA_SA_PREFIX = "glsa_"


class GrafanaServiceAccountAuthentication(BaseAuthentication):
def authenticate(self, request):
auth = get_authorization_header(request).decode("utf-8")
if not auth:
raise exceptions.AuthenticationFailed("Invalid token.")
if not auth.startswith(GRAFANA_SA_PREFIX):
if not auth.startswith(ServiceAccountToken.GRAFANA_SA_PREFIX):
return None

organization = self.get_organization(request)
Expand All @@ -361,6 +361,12 @@ def authenticate(self, request):
return self.authenticate_credentials(organization, auth)

def get_organization(self, request):
grafana_url = request.headers.get(X_GRAFANA_URL)
if grafana_url:
organization = Organization.objects.filter(grafana_url=grafana_url).first()
if organization:
return organization

if settings.LICENSE == settings.CLOUD_LICENSE_NAME:
instance_id = request.headers.get(X_GRAFANA_INSTANCE_ID)
if not instance_id:
Expand All @@ -372,36 +378,13 @@ def get_organization(self, request):
return Organization.objects.filter(org_slug=org_slug, stack_slug=instance_slug).first()

def authenticate_credentials(self, organization, token):
permissions = get_service_account_token_permissions(organization, token)
if not permissions:
try:
user, auth_token = ServiceAccountToken.validate_token(organization, token)
except InvalidToken:
raise exceptions.AuthenticationFailed("Invalid token.")

role = LegacyAccessControlRole.NONE
if not organization.is_rbac_permissions_enabled:
role = self.determine_role_from_permissions(permissions)

user = User(
organization_id=organization.pk,
name="Grafana Service Account",
username="grafana_service_account",
role=role,
permissions=GrafanaAPIPermissions.construct_permissions(permissions.keys()),
)

auth_token = ApiAuthToken(organization=organization, user=user, name="Grafana Service Account")

return user, auth_token

# Using default permissions as proxies for roles since we cannot explicitly get role from the service account token
def determine_role_from_permissions(self, permissions):
if "plugins:write" in permissions:
return LegacyAccessControlRole.ADMIN
if "dashboards:write" in permissions:
return LegacyAccessControlRole.EDITOR
if "dashboards:read" in permissions:
return LegacyAccessControlRole.VIEWER
return LegacyAccessControlRole.NONE


class IntegrationBacksyncAuthentication(BaseAuthentication):
model = IntegrationBacksyncAuthToken
Expand Down
6 changes: 6 additions & 0 deletions engine/apps/auth_token/grafana/grafana_auth_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,9 @@ def get_service_account_token_permissions(organization: Organization, token: str
grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=token)
permissions, _ = grafana_api_client.get_service_account_token_permissions()
return permissions


def get_service_account_details(organization: Organization, token: str) -> typing.Dict[str, typing.List[str]]:
grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=token)
user_data, _ = grafana_api_client.get_current_user()
return user_data
29 changes: 29 additions & 0 deletions engine/apps/auth_token/migrations/0007_serviceaccounttoken.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 4.2.15 on 2024-10-21 19:35

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('user_management', '0024_serviceaccount'),
('auth_token', '0006_googleoauth2token'),
]

operations = [
migrations.CreateModel(
name='ServiceAccountToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_key', models.CharField(db_index=True, max_length=8)),
('digest', models.CharField(max_length=128)),
('created_at', models.DateTimeField(auto_now_add=True)),
('revoked_at', models.DateTimeField(null=True)),
('service_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to='user_management.serviceaccount')),
],
options={
'unique_together': {('token_key', 'service_account', 'digest')},
},
),
]
1 change: 1 addition & 0 deletions engine/apps/auth_token/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
from .integration_backsync_auth_token import IntegrationBacksyncAuthToken # noqa: F401
from .plugin_auth_token import PluginAuthToken # noqa: F401
from .schedule_export_auth_token import ScheduleExportAuthToken # noqa: F401
from .service_account_token import ServiceAccountToken # noqa: F401
from .slack_auth_token import SlackAuthToken # noqa: F401
from .user_schedule_export_auth_token import UserScheduleExportAuthToken # noqa: F401
115 changes: 115 additions & 0 deletions engine/apps/auth_token/models/service_account_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import binascii
from hmac import compare_digest

from django.db import models

from apps.api.permissions import GrafanaAPIPermissions, LegacyAccessControlRole
from apps.auth_token import constants
from apps.auth_token.crypto import hash_token_string
from apps.auth_token.exceptions import InvalidToken
from apps.auth_token.grafana.grafana_auth_token import (
get_service_account_details,
get_service_account_token_permissions,
)
from apps.auth_token.models import BaseAuthToken
from apps.user_management.models import ServiceAccount, ServiceAccountUser


class ServiceAccountToken(BaseAuthToken):
GRAFANA_SA_PREFIX = "glsa_"

service_account: "ServiceAccount"
service_account = models.ForeignKey(ServiceAccount, on_delete=models.CASCADE, related_name="tokens")

class Meta:
unique_together = ("token_key", "service_account", "digest")

@property
def organization(self):
return self.service_account.organization

@classmethod
def validate_token(cls, organization, token):
# get permissions and confirm token is valid
permissions = get_service_account_token_permissions(organization, token)
if not permissions:
# TODO: if token in DB, mark as revoked? (NOTE: a token can be disabled/re-enabled)
# consider revoking at the oncall side too?
raise InvalidToken

# check if we have already seen this token
validated_token = None
service_account = None
prefix_length = len(cls.GRAFANA_SA_PREFIX)
token_key = token[prefix_length : prefix_length + constants.TOKEN_KEY_LENGTH]
try:
hashable_token = binascii.hexlify(token.encode()).decode()
digest = hash_token_string(hashable_token)
except (TypeError, binascii.Error):
raise InvalidToken
for existing_token in cls.objects.filter(service_account__organization=organization, token_key=token_key):
if compare_digest(digest, existing_token.digest):
validated_token = existing_token
service_account = existing_token.service_account
break

if not validated_token:
# create a token
# make request to api/user using token
service_account_data = get_service_account_details(organization, token)
if not service_account_data:
# older grafana versions return 403 trying to get user details with service account token
# use some default values
service_account_data = {
"login": "grafana_service_account",
"uid": None, # "service-account:7"
}

grafana_id = None
if service_account_data["uid"] is not None:
try:
grafana_id = int(service_account_data["uid"].split(":")[-1])
except ValueError:
pass

# get or create service account
service_account, _ = ServiceAccount.objects.get_or_create(
organization=organization,
grafana_id=grafana_id,
defaults={
"login": service_account_data["login"],
},
)
# create token
validated_token = cls.objects.get_or_create(
service_account=service_account,
token_key=token_key,
digest=digest,
)

def _determine_role_from_permissions(permissions):
# Using default permissions as proxies for roles since
# we cannot explicitly get role from the service account token
if "plugins:write" in permissions:
return LegacyAccessControlRole.ADMIN
if "dashboards:write" in permissions:
return LegacyAccessControlRole.EDITOR
if "dashboards:read" in permissions:
return LegacyAccessControlRole.VIEWER
return LegacyAccessControlRole.NONE

# setup an in-mem ServiceAccountUser
role = LegacyAccessControlRole.NONE
if not organization.is_rbac_permissions_enabled:
role = _determine_role_from_permissions(permissions)

user = ServiceAccountUser(
organization=organization,
service_account=service_account,
username=service_account.username,
public_primary_key=service_account.public_primary_key,
role=role,
permissions=GrafanaAPIPermissions.construct_permissions(permissions.keys()),
)

return user, validated_token
5 changes: 3 additions & 2 deletions engine/apps/auth_token/tests/test_grafana_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from rest_framework import exceptions
from rest_framework.test import APIRequestFactory

from apps.auth_token.auth import GRAFANA_SA_PREFIX, X_GRAFANA_INSTANCE_ID, GrafanaServiceAccountAuthentication
from apps.auth_token.auth import X_GRAFANA_INSTANCE_ID, GrafanaServiceAccountAuthentication
from apps.auth_token.models import ServiceAccountToken
from settings.base import CLOUD_LICENSE_NAME, OPEN_SOURCE_LICENSE_NAME, SELF_HOSTED_SETTINGS


Expand Down Expand Up @@ -65,7 +66,7 @@ def check_common_inputs() -> (dict[str, typing.Any], str):
result = GrafanaServiceAccountAuthentication().authenticate(request)
assert result is None

token = f"{GRAFANA_SA_PREFIX}xyz"
token = f"{ServiceAccountToken.GRAFANA_SA_PREFIX}xyz"
headers = {
"HTTP_AUTHORIZATION": token,
}
Expand Down
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 @@ -315,6 +315,9 @@ def get_grafana_labels_plugin_settings(self) -> APIClientResponse["GrafanaAPICli
def get_grafana_irm_plugin_settings(self) -> APIClientResponse["GrafanaAPIClient.Types.PluginSettings"]:
return self.get_grafana_plugin_settings(PluginID.IRM)

def get_current_user(self) -> APIClientResponse[typing.Dict[str, typing.List[str]]]:
return self.api_get("api/user")

def get_service_account(self, login: str) -> APIClientResponse["GrafanaAPIClient.Types.ServiceAccountResponse"]:
return self.api_get(f"api/serviceaccounts/search?query={login}")

Expand Down
5 changes: 4 additions & 1 deletion engine/apps/public_api/serializers/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from apps.alerts.models import AlertReceiveChannel
from apps.base.messaging import get_messaging_backends
from apps.integrations.legacy_prefix import has_legacy_prefix, remove_legacy_prefix
from apps.user_management.models import ServiceAccountUser
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.mixins import PHONE_CALL, SLACK, SMS, TELEGRAM, WEB, EagerLoadingMixin
Expand Down Expand Up @@ -123,11 +124,13 @@ def create(self, validated_data):
connection_error = GrafanaAlertingSyncManager.check_for_connection_errors(organization)
if connection_error:
raise serializers.ValidationError(connection_error)
user = self.context["request"].user
with transaction.atomic():
try:
instance = AlertReceiveChannel.create(
**validated_data,
author=self.context["request"].user,
author=user if not isinstance(user, ServiceAccountUser) else None,
service_account=user.service_account if isinstance(user, ServiceAccountUser) else None,
organization=organization,
)
except AlertReceiveChannel.DuplicateDirectPagingError:
Expand Down
6 changes: 3 additions & 3 deletions engine/apps/public_api/tests/test_resolution_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
from rest_framework.test import APIClient

from apps.alerts.models import ResolutionNote
from apps.auth_token.auth import GRAFANA_SA_PREFIX, ApiTokenAuthentication, GrafanaServiceAccountAuthentication
from apps.auth_token.models import ApiAuthToken
from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication
from apps.auth_token.models import ApiAuthToken, ServiceAccountToken


@pytest.mark.django_db
Expand Down Expand Up @@ -366,7 +366,7 @@ def test_create_resolution_note_grafana_auth(make_organization_and_user, make_al
mock_api_key_auth.assert_called_once()
assert response.status_code == status.HTTP_403_FORBIDDEN

token = f"{GRAFANA_SA_PREFIX}123"
token = f"{ServiceAccountToken.GRAFANA_SA_PREFIX}123"
# GrafanaServiceAccountAuthentication handle invalid token
with patch(
"apps.auth_token.auth.ApiTokenAuthentication.authenticate", wraps=api_token_auth.authenticate
Expand Down
Loading
Loading