Skip to content

Commit 8e71553

Browse files
authored
✨ feat: create initial DataAccessGrant Service (#95095)
1 parent 36ebaaf commit 8e71553

File tree

9 files changed

+240
-0
lines changed

9 files changed

+240
-0
lines changed

src/sentry/data_secrecy/data_access_grant_service/__init__.py

Whitespace-only changes.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from django.db import models
2+
from django.utils import timezone
3+
4+
from sentry.data_secrecy.data_access_grant_service.model import RpcEffectiveGrantStatus
5+
from sentry.data_secrecy.data_access_grant_service.serial import serialize_effective_grant_status
6+
from sentry.data_secrecy.data_access_grant_service.service import DataAccessGrantService
7+
from sentry.data_secrecy.models.data_access_grant import DataAccessGrant
8+
9+
10+
class DatabaseBackedDataAccessGrantService(DataAccessGrantService):
11+
def get_effective_grant_status(self, *, organization_id: int) -> RpcEffectiveGrantStatus | None:
12+
"""
13+
Get the effective grant status for an organization.
14+
"""
15+
now = timezone.now()
16+
active_grants = DataAccessGrant.objects.filter(
17+
organization_id=organization_id,
18+
grant_start__lte=now,
19+
grant_end__gt=now,
20+
revocation_date__isnull=True, # Not revoked
21+
)
22+
23+
if not active_grants.exists():
24+
return None
25+
26+
# Calculate aggregate grant status - only need the time window for access control
27+
min_start = active_grants.aggregate(min_start=models.Min("grant_start"))["min_start"]
28+
max_end = active_grants.aggregate(max_end=models.Max("grant_end"))["max_end"]
29+
30+
grant_status = {
31+
"access_start": min_start.isoformat(),
32+
"access_end": max_end.isoformat(),
33+
}
34+
35+
return serialize_effective_grant_status(grant_status, organization_id)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from datetime import datetime
2+
3+
from sentry.hybridcloud.rpc import RpcModel
4+
5+
6+
class RpcEffectiveGrantStatus(RpcModel):
7+
"""
8+
Simplified model for access control - only contains essential, aggregated grant information.
9+
"""
10+
11+
organization_id: int
12+
access_start: datetime
13+
access_end: datetime
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from datetime import datetime
2+
3+
from sentry.data_secrecy.data_access_grant_service.model import RpcEffectiveGrantStatus
4+
5+
6+
def serialize_effective_grant_status(
7+
grant_status: dict, organization_id: int
8+
) -> RpcEffectiveGrantStatus:
9+
"""
10+
Convert cached grant status to simplified RpcGrantStatus model for access control.
11+
"""
12+
13+
return RpcEffectiveGrantStatus(
14+
organization_id=organization_id,
15+
access_start=datetime.fromisoformat(grant_status["access_start"]),
16+
access_end=datetime.fromisoformat(grant_status["access_end"]),
17+
)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Please do not use
2+
# from __future__ import annotations
3+
# in modules such as this one where hybrid cloud data models or service classes are
4+
# defined, because we want to reflect on type annotations and avoid forward references.
5+
6+
import abc
7+
8+
from sentry.data_secrecy.data_access_grant_service.model import RpcEffectiveGrantStatus
9+
from sentry.hybridcloud.rpc.service import RpcService, rpc_method
10+
from sentry.silo.base import SiloMode
11+
12+
13+
class DataAccessGrantService(RpcService):
14+
key = "data_access_grant"
15+
local_mode = SiloMode.CONTROL
16+
17+
@classmethod
18+
def get_local_implementation(cls) -> RpcService:
19+
from sentry.data_secrecy.data_access_grant_service.impl import (
20+
DatabaseBackedDataAccessGrantService,
21+
)
22+
23+
return DatabaseBackedDataAccessGrantService()
24+
25+
@rpc_method
26+
@abc.abstractmethod
27+
def get_effective_grant_status(self, *, organization_id: int) -> RpcEffectiveGrantStatus | None:
28+
pass
29+
30+
31+
data_access_grant_service = DataAccessGrantService.create_delegation()

src/sentry/testutils/factories.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from sentry.auth.access import RpcBackedAccess
3131
from sentry.auth.services.auth.model import RpcAuthState, RpcMemberSsoState
3232
from sentry.constants import SentryAppInstallationStatus, SentryAppStatus
33+
from sentry.data_secrecy.models.data_access_grant import DataAccessGrant
3334
from sentry.event_manager import EventManager
3435
from sentry.eventstore.models import Event
3536
from sentry.hybridcloud.models.outbox import RegionOutbox, outbox_context
@@ -569,6 +570,11 @@ def create_project_template(project=None, organization=None, **kwargs) -> Projec
569570

570571
return project_template
571572

573+
@staticmethod
574+
@assume_test_silo_mode(SiloMode.CONTROL)
575+
def create_data_access_grant(**kwargs):
576+
return DataAccessGrant.objects.create(**kwargs)
577+
572578
@staticmethod
573579
@assume_test_silo_mode(SiloMode.REGION)
574580
def create_project_bookmark(project, user):

src/sentry/testutils/fixtures.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,9 @@ def create_external_team(self, team=None, integration=None, **kwargs):
495495
team=team, organization=team.organization, integration_id=integration.id, **kwargs
496496
)
497497

498+
def create_data_access_grant(self, **kwargs):
499+
return Factories.create_data_access_grant(**kwargs)
500+
498501
def create_codeowners(self, project=None, code_mapping=None, **kwargs):
499502
if not project:
500503
project = self.project

tests/sentry/data_secrecy/data_access_grant_service/__init__.py

Whitespace-only changes.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
from datetime import datetime, timedelta, timezone
2+
3+
from sentry.data_secrecy.data_access_grant_service.service import data_access_grant_service
4+
from sentry.data_secrecy.models.data_access_grant import DataAccessGrant
5+
from sentry.testutils.cases import TestCase
6+
from sentry.testutils.helpers.datetime import freeze_time
7+
from sentry.testutils.silo import all_silo_test, create_test_regions
8+
9+
10+
@all_silo_test(regions=create_test_regions("us"))
11+
@freeze_time("2025-07-08 00:00:00")
12+
class TestDataAccessGrantService(TestCase):
13+
def setUp(self):
14+
self.organization = self.create_organization()
15+
self.organization_2 = self.create_organization()
16+
17+
def test_get_effective_waiver_status_with_active_grant(self):
18+
now = datetime.now(tz=timezone.utc)
19+
grant_start = now - timedelta(hours=1)
20+
grant_end = now + timedelta(hours=1)
21+
22+
self.create_data_access_grant(
23+
organization_id=self.organization.id,
24+
grant_type=DataAccessGrant.GrantType.ZENDESK,
25+
ticket_id="TICKET-123",
26+
grant_start=grant_start,
27+
grant_end=grant_end,
28+
)
29+
30+
result = data_access_grant_service.get_effective_grant_status(
31+
organization_id=self.organization.id
32+
)
33+
34+
assert result is not None
35+
assert result.organization_id == self.organization.id
36+
assert result.access_start == grant_start
37+
assert result.access_end == grant_end
38+
39+
def test_get_effective_waiver_status_with_no_grants(self):
40+
result = data_access_grant_service.get_effective_grant_status(
41+
organization_id=self.organization.id
42+
)
43+
assert result is None
44+
45+
def test_get_effective_waiver_status_with_expired_grant(self):
46+
now = datetime.now(tz=timezone.utc)
47+
grant_start = now - timedelta(hours=2)
48+
grant_end = now - timedelta(hours=1) # Expired
49+
50+
self.create_data_access_grant(
51+
organization_id=self.organization.id,
52+
grant_type=DataAccessGrant.GrantType.ZENDESK,
53+
ticket_id="TICKET-123",
54+
grant_start=grant_start,
55+
grant_end=grant_end,
56+
)
57+
58+
result = data_access_grant_service.get_effective_grant_status(
59+
organization_id=self.organization.id
60+
)
61+
assert result is None
62+
63+
def test_get_effective_waiver_status_with_future_grant(self):
64+
now = datetime.now(tz=timezone.utc)
65+
grant_start = now + timedelta(hours=1) # Future
66+
grant_end = now + timedelta(hours=2)
67+
68+
self.create_data_access_grant(
69+
organization_id=self.organization.id,
70+
grant_type=DataAccessGrant.GrantType.ZENDESK,
71+
ticket_id="TICKET-123",
72+
grant_start=grant_start,
73+
grant_end=grant_end,
74+
)
75+
76+
result = data_access_grant_service.get_effective_grant_status(
77+
organization_id=self.organization.id
78+
)
79+
assert result is None
80+
81+
def test_get_effective_waiver_status_with_revoked_grant(self):
82+
now = datetime.now(tz=timezone.utc)
83+
grant_start = now - timedelta(hours=1)
84+
grant_end = now + timedelta(hours=1)
85+
86+
self.create_data_access_grant(
87+
organization_id=self.organization.id,
88+
grant_type=DataAccessGrant.GrantType.ZENDESK,
89+
ticket_id="TICKET-123",
90+
grant_start=grant_start,
91+
grant_end=grant_end,
92+
revocation_date=now,
93+
revocation_reason=DataAccessGrant.RevocationReason.MANUAL_REVOCATION,
94+
)
95+
96+
result = data_access_grant_service.get_effective_grant_status(
97+
organization_id=self.organization.id
98+
)
99+
assert result is None
100+
101+
def test_get_effective_waiver_status_with_multiple_grants(self):
102+
now = datetime.now(tz=timezone.utc)
103+
104+
# Grant 1: Earlier start, earlier end
105+
grant1_start = now - timedelta(hours=2)
106+
grant1_end = now + timedelta(hours=1)
107+
108+
# Grant 2: Later start, later end
109+
grant2_start = now - timedelta(hours=1)
110+
grant2_end = now + timedelta(hours=2)
111+
112+
self.create_data_access_grant(
113+
organization_id=self.organization.id,
114+
grant_type=DataAccessGrant.GrantType.ZENDESK,
115+
ticket_id="TICKET-123",
116+
grant_start=grant1_start,
117+
grant_end=grant1_end,
118+
)
119+
self.create_data_access_grant(
120+
organization_id=self.organization.id,
121+
grant_type=DataAccessGrant.GrantType.MANUAL,
122+
granted_by_user=self.user,
123+
grant_start=grant2_start,
124+
grant_end=grant2_end,
125+
)
126+
127+
result = data_access_grant_service.get_effective_grant_status(
128+
organization_id=self.organization.id
129+
)
130+
131+
assert result is not None
132+
assert result.organization_id == self.organization.id
133+
# Should use earliest start and latest end
134+
assert result.access_start == grant1_start
135+
assert result.access_end == grant2_end

0 commit comments

Comments
 (0)