From fdc8f5aa9183248a1e41e833058658623f884016 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 14 Jun 2023 14:17:21 +0200 Subject: [PATCH] feat: add `OrgAuthToken` model (#50409) ref https://github.com/getsentry/sentry/issues/50144 based on RFC https://github.com/getsentry/rfcs/pull/91 --- migrations_lockfile.txt | 2 +- .../migrations/0488_add_orgauthtoken.py | 77 +++++++++++++++++++ src/sentry/models/__init__.py | 1 + src/sentry/models/orgauthtoken.py | 77 +++++++++++++++++++ src/sentry/utils/security/orgauthtoken_jwt.py | 34 ++++++++ tests/sentry/models/test_orgauthtoken.py | 34 ++++++++ .../utils/security/test_orgauthtoken_jwt.py | 70 +++++++++++++++++ 7 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 src/sentry/migrations/0488_add_orgauthtoken.py create mode 100644 src/sentry/models/orgauthtoken.py create mode 100644 src/sentry/utils/security/orgauthtoken_jwt.py create mode 100644 tests/sentry/models/test_orgauthtoken.py create mode 100644 tests/sentry/utils/security/test_orgauthtoken_jwt.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 11116c806cc21e..0322a72cb68db6 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -6,5 +6,5 @@ To resolve this, rebase against latest master and regenerate your migration. Thi will then be regenerated, and you should be able to merge without conflicts. nodestore: 0002_nodestore_no_dictfield -sentry: 0487_add_indexes_to_bundles +sentry: 0488_add_orgauthtoken social_auth: 0001_initial diff --git a/src/sentry/migrations/0488_add_orgauthtoken.py b/src/sentry/migrations/0488_add_orgauthtoken.py new file mode 100644 index 00000000000000..efcdbcdbd00d17 --- /dev/null +++ b/src/sentry/migrations/0488_add_orgauthtoken.py @@ -0,0 +1,77 @@ +# Generated by Django 2.2.28 on 2023-06-14 10:24 + +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + +import sentry.db.models.fields.array +import sentry.db.models.fields.bounded +import sentry.db.models.fields.foreignkey +import sentry.db.models.fields.hybrid_cloud_foreign_key +import sentry.models.orgauthtoken +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. For + # the most part, this should only be used for operations where it's safe to run the migration + # after your code has deployed. So this should not be used for most operations that alter the + # schema of a table. + # Here are some things that make sense to mark as dangerous: + # - Large data migrations. Typically we want these to be run manually by ops so that they can + # be monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # have ops run this and not block the deploy. Note that while adding an index is a schema + # change, it's completely safe to run the operation after the code has deployed. + is_dangerous = False + + dependencies = [ + ("sentry", "0487_add_indexes_to_bundles"), + ] + + operations = [ + migrations.CreateModel( + name="OrgAuthToken", + fields=[ + ( + "id", + sentry.db.models.fields.bounded.BoundedBigAutoField( + primary_key=True, serialize=False + ), + ), + ( + "organization_id", + sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey( + "sentry.Organization", db_index=True, on_delete="CASCADE" + ), + ), + ("token_hashed", models.TextField(unique=True)), + ("token_last_characters", models.CharField(max_length=4, null=True)), + ("name", models.CharField(max_length=255)), + ( + "scope_list", + sentry.db.models.fields.array.ArrayField( + null=True, validators=[sentry.models.orgauthtoken.validate_scope_list] + ), + ), + ("date_added", models.DateTimeField(default=django.utils.timezone.now)), + ("date_last_used", models.DateTimeField(blank=True, null=True)), + ( + "project_last_used_id", + sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey( + "sentry.Project", blank=True, db_index=True, null=True, on_delete="SET_NULL" + ), + ), + ("date_deactivated", models.DateTimeField(blank=True, null=True)), + ( + "created_by", + sentry.db.models.fields.foreignkey.FlexibleForeignKey( + blank=True, null=True, on_delete="SET_NULL", to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "db_table": "sentry_orgauthtoken", + }, + ), + ] diff --git a/src/sentry/models/__init__.py b/src/sentry/models/__init__.py index 4ff7297184e00e..d27d77b49c4d56 100644 --- a/src/sentry/models/__init__.py +++ b/src/sentry/models/__init__.py @@ -71,6 +71,7 @@ from .organizationmembermapping import * # NOQA from .organizationmemberteam import * # NOQA from .organizationonboardingtask import * # NOQA +from .orgauthtoken import * # NOQA from .outbox import * # NOQA from .platformexternalissue import * # NOQA from .processingissue import * # NOQA diff --git a/src/sentry/models/orgauthtoken.py b/src/sentry/models/orgauthtoken.py new file mode 100644 index 00000000000000..ff34cb31f2729f --- /dev/null +++ b/src/sentry/models/orgauthtoken.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone +from django.utils.encoding import force_text + +from sentry.conf.server import SENTRY_SCOPES +from sentry.db.models import ( + ArrayField, + BaseManager, + FlexibleForeignKey, + Model, + control_silo_only_model, + sane_repr, +) +from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey +from sentry.models.project import Project + + +def validate_scope_list(value): + for choice in value: + if choice not in SENTRY_SCOPES: + raise ValidationError(f"{choice} is not a valid scope.") + + +@control_silo_only_model +class OrgAuthToken(Model): + __include_in_export__ = True + + organization_id = HybridCloudForeignKey("sentry.Organization", null=False, on_delete="CASCADE") + # The JWT token in hashed form + token_hashed = models.TextField(unique=True, null=False) + # An optional representation of the last characters of the original token, to be shown to the user + token_last_characters = models.CharField(max_length=4, null=True) + name = models.CharField(max_length=255, null=False) + scope_list = ArrayField( + models.TextField(), + validators=[validate_scope_list], + ) + + created_by = FlexibleForeignKey("sentry.User", null=True, blank=True, on_delete="SET_NULL") + date_added = models.DateTimeField(default=timezone.now, null=False) + date_last_used = models.DateTimeField(null=True, blank=True) + project_last_used_id = HybridCloudForeignKey( + "sentry.Project", null=True, blank=True, on_delete="SET_NULL" + ) + date_deactivated = models.DateTimeField(null=True, blank=True) + + objects = BaseManager(cache_fields=("token_hashed",)) + + class Meta: + app_label = "sentry" + db_table = "sentry_orgauthtoken" + + __repr__ = sane_repr("organization_id", "token_hashed") + + def __str__(self): + return force_text(self.token_hashed) + + def get_audit_log_data(self): + return {"scopes": self.get_scopes()} + + def get_scopes(self): + return self.scope_list + + def has_scope(self, scope): + return scope in self.get_scopes() + + def project_last_used(self) -> Project | None: + if self.project_last_used_id is None: + return None + + return Project.objects.get(id=self.project_last_used_id) + + def is_active(self) -> bool: + return self.date_deactivated is None diff --git a/src/sentry/utils/security/orgauthtoken_jwt.py b/src/sentry/utils/security/orgauthtoken_jwt.py new file mode 100644 index 00000000000000..e3e6666b860d97 --- /dev/null +++ b/src/sentry/utils/security/orgauthtoken_jwt.py @@ -0,0 +1,34 @@ +from datetime import datetime +from uuid import uuid4 + +from django.conf import settings + +from sentry.utils import jwt + +SENTRY_JWT_PREFIX = "sntrys_" + + +def generate_token(org_slug: str, region_url: str): + jwt_payload = { + "iss": "sentry.io", + "iat": datetime.utcnow(), + "nonce": uuid4().hex, + "sentry_url": settings.SENTRY_OPTIONS["system.url-prefix"], + "sentry_region_url": region_url, + "sentry_org": org_slug, + } + jwt_token = jwt.encode(jwt_payload, "", algorithm="none") + return f"{SENTRY_JWT_PREFIX}{jwt_token}" + + +def parse_token(token: str): + if not token.startswith(SENTRY_JWT_PREFIX): + return None + token = token[7:] + try: + jwt_payload = jwt.peek_claims(token) + if jwt_payload.get("iss") != "sentry.io": + return None + return jwt_payload + except jwt.DecodeError: + return None diff --git a/tests/sentry/models/test_orgauthtoken.py b/tests/sentry/models/test_orgauthtoken.py new file mode 100644 index 00000000000000..bbf73453f19aca --- /dev/null +++ b/tests/sentry/models/test_orgauthtoken.py @@ -0,0 +1,34 @@ +import pytest +from django.core.exceptions import ValidationError + +from sentry.models import Organization, OrgAuthToken +from sentry.testutils import TestCase +from sentry.testutils.silo import region_silo_test + + +@region_silo_test(stable=True) +class OrgAuthTokenTest(TestCase): + def test_get_scopes(self): + token = OrgAuthToken(scope_list=["project:read", "project:releases"]) + assert token.get_scopes() == ["project:read", "project:releases"] + + def test_has_scope(self): + token = OrgAuthToken(scope_list=["project:read", "project:releases"]) + assert token.has_scope("project:read") + assert token.has_scope("project:releases") + assert not token.has_scope("project:write") + + def test_validate_scope(self): + org = Organization(name="Test org", slug="test-org") + token = OrgAuthToken( + organization_id=org.id, + name="test token", + token_hashed="test-token", + scope_list=["project:xxxx"], + ) + + with pytest.raises( + ValidationError, + match="project:xxxx is not a valid scope.", + ): + token.full_clean() diff --git a/tests/sentry/utils/security/test_orgauthtoken_jwt.py b/tests/sentry/utils/security/test_orgauthtoken_jwt.py new file mode 100644 index 00000000000000..747f2311082135 --- /dev/null +++ b/tests/sentry/utils/security/test_orgauthtoken_jwt.py @@ -0,0 +1,70 @@ +from datetime import datetime + +from sentry.testutils import TestCase +from sentry.utils import jwt +from sentry.utils.security.orgauthtoken_jwt import SENTRY_JWT_PREFIX, generate_token, parse_token + + +class OrgAuthTokenJwtTest(TestCase): + def test_generate_token(self): + token = generate_token("test-org", "https://test-region.sentry.io") + + assert token + assert token.startswith(SENTRY_JWT_PREFIX) + + def test_parse_token(self): + token = generate_token("test-org", "https://test-region.sentry.io") + token_payload = parse_token(token) + + assert token_payload["sentry_org"] == "test-org" + assert token_payload["sentry_url"] == "http://testserver" + assert token_payload["sentry_region_url"] == "https://test-region.sentry.io" + assert token_payload["nonce"] + + def test_parse_invalid_token(self): + assert parse_token("invalid-token") is None + + def test_parse_invalid_token_iss(self): + jwt_payload = { + "iss": "invalid.io", + "iat": datetime.utcnow(), + "nonce": "test-nonce", + "sentry_url": "test-site", + "sentry_region_url": "test-site", + "sentry_org": "test-org", + } + + jwt_token = jwt.encode(jwt_payload, "ABC") + token = SENTRY_JWT_PREFIX + jwt_token + + assert parse_token(token) is None + + def test_parse_token_changed_secret(self): + jwt_payload = { + "iss": "sentry.io", + "iat": datetime.utcnow(), + "nonce": "test-nonce", + "sentry_url": "test-site", + "sentry_region_url": "test-site", + "sentry_org": "test-org", + } + + jwt_token = jwt.encode(jwt_payload, "other-secret-here") + token = SENTRY_JWT_PREFIX + jwt_token + + token_payload = parse_token(token) + + assert token_payload["sentry_org"] == "test-org" + assert token_payload["sentry_url"] == "test-site" + assert token_payload["nonce"] + + def test_generate_token_unique(self): + jwt1 = generate_token("test-org", "https://test-region.sentry.io") + jwt2 = generate_token("test-org", "https://test-region.sentry.io") + jwt3 = generate_token("test-org", "https://test-region.sentry.io") + + assert jwt1 + assert jwt2 + assert jwt3 + assert jwt1 != jwt2 + assert jwt2 != jwt3