-
Notifications
You must be signed in to change notification settings - Fork 299
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Enable service account token auth for public API
- Loading branch information
Showing
36 changed files
with
830 additions
and
73 deletions.
There are no files selected for viewing
20 changes: 20 additions & 0 deletions
20
engine/apps/alerts/migrations/0065_alertreceivechannel_service_account.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
# Generated by Django 4.2.15 on 2024-11-12 13:13 | ||
|
||
from django.db import migrations, models | ||
import django.db.models.deletion | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
('user_management', '0027_serviceaccount'), | ||
('alerts', '0064_migrate_resolutionnoteslackmessage_slack_channel_id'), | ||
] | ||
|
||
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'), | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
29 changes: 29 additions & 0 deletions
29
engine/apps/auth_token/migrations/0007_serviceaccounttoken.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
# Generated by Django 4.2.15 on 2024-11-12 13:13 | ||
|
||
from django.db import migrations, models | ||
import django.db.models.deletion | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
('user_management', '0027_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')}, | ||
}, | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
import binascii | ||
from hmac import compare_digest | ||
|
||
from django.db import models | ||
|
||
from apps.api.permissions import ( | ||
DASHBOARDS_READ, | ||
DASHBOARDS_WRITE, | ||
PLUGINS_WRITE, | ||
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 ServiceAccountTokenManager(models.Manager): | ||
def get_queryset(self): | ||
return super().get_queryset().select_related("service_account__organization") | ||
|
||
|
||
class ServiceAccountToken(BaseAuthToken): | ||
GRAFANA_SA_PREFIX = "glsa_" | ||
|
||
objects = ServiceAccountTokenManager() | ||
|
||
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): | ||
# Grafana API request: get permissions and confirm token is valid | ||
permissions = get_service_account_token_permissions(organization, token) | ||
if not permissions: | ||
# NOTE: a token can be disabled/re-enabled (not setting as revoked in oncall DB for now) | ||
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: | ||
# if it didn't match an existing token, create a new one | ||
# make request to Grafana API api/user using token | ||
service_account_data = get_service_account_details(organization, token) | ||
if not service_account_data: | ||
# Grafana versions < 11.3 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 = 0 # default to zero for old Grafana versions (to keep service account unique) | ||
if service_account_data["uid"] is not None: | ||
# extract service account Grafana ID | ||
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import json | ||
|
||
import httpretty | ||
|
||
|
||
def setup_service_account_api_mocks(organization, 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" | ||
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" | ||
httpretty.register_uri(httpretty.GET, user_url, responses=[mock_response]) |
Oops, something went wrong.