Browse Source

feat: add `OrgAuthToken` model (#50409)

ref https://github.com/getsentry/sentry/issues/50144

based on RFC https://github.com/getsentry/rfcs/pull/91
Francesco Novy 1 year ago
parent
commit
fdc8f5aa91

+ 1 - 1
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

+ 77 - 0
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",
+            },
+        ),
+    ]

+ 1 - 0
src/sentry/models/__init__.py

@@ -71,6 +71,7 @@ from .organizationmember import *  # NOQA
 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

+ 77 - 0
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

+ 34 - 0
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

+ 34 - 0
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()

+ 70 - 0
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