Skip to content

Commit

Permalink
feat: add OrgAuthToken model (#50409)
Browse files Browse the repository at this point in the history
ref #50144

based on RFC getsentry/rfcs#91
  • Loading branch information
mydea committed Jun 14, 2023
1 parent 589c975 commit fdc8f5a
Show file tree
Hide file tree
Showing 7 changed files with 294 additions and 1 deletion.
2 changes: 1 addition & 1 deletion migrations_lockfile.txt
Expand Up @@ -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 changes: 77 additions & 0 deletions 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 change: 1 addition & 0 deletions src/sentry/models/__init__.py
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions 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 changes: 34 additions & 0 deletions 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 changes: 34 additions & 0 deletions 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 changes: 70 additions & 0 deletions 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

0 comments on commit fdc8f5a

Please sign in to comment.