Browse Source

feat(org-token): Keep track of last used date/project (#52111)

This updates the key places the org auth token can be used to update the
token last used date/project.
Francesco Novy 1 year ago
parent
commit
911198aa2f

+ 6 - 2
src/sentry/api/endpoints/organization_artifactbundle_assemble.py

@@ -6,9 +6,10 @@ from sentry import analytics, options
 from sentry.api.base import region_silo_endpoint
 from sentry.api.bases.organization import OrganizationReleasesBaseEndpoint
 from sentry.api.exceptions import ResourceDoesNotExist
+from sentry.api.utils import get_auth_api_token_type
 from sentry.constants import ObjectStatus
 from sentry.models import FileBlobOwner, Project
-from sentry.models.apitoken import is_api_token_auth
+from sentry.models.orgauthtoken import is_org_auth_token_auth, update_org_auth_token_last_used
 from sentry.tasks.assemble import (
     AssembleTask,
     ChunkFileState,
@@ -147,7 +148,10 @@ class OrganizationArtifactBundleAssembleEndpoint(
             organization_id=organization.id,
             project_ids=project_ids,
             user_agent=request.META.get("HTTP_USER_AGENT", ""),
-            auth_type="api_token" if is_api_token_auth(request.auth) else None,
+            auth_type=get_auth_api_token_type(request.auth),
         )
 
+        if is_org_auth_token_auth(request.auth):
+            update_org_auth_token_last_used(request.auth, project_ids)
+
         return Response({"state": ChunkFileState.CREATED, "missingChunks": []}, status=200)

+ 8 - 2
src/sentry/api/endpoints/organization_releases.py

@@ -23,6 +23,7 @@ from sentry.api.serializers.rest_framework import (
     ReleaseHeadCommitSerializerDeprecated,
     ReleaseWithVersionSerializer,
 )
+from sentry.api.utils import get_auth_api_token_type
 from sentry.exceptions import InvalidSearchQuery
 from sentry.models import (
     Activity,
@@ -33,7 +34,7 @@ from sentry.models import (
     ReleaseStatus,
     SemverFilter,
 )
-from sentry.models.apitoken import is_api_token_auth
+from sentry.models.orgauthtoken import is_org_auth_token_auth, update_org_auth_token_last_used
 from sentry.search.events.constants import (
     OPERATOR_TO_DJANGO,
     RELEASE_ALIAS,
@@ -564,9 +565,14 @@ class OrganizationReleasesEndpoint(
                     project_ids=[project.id for project in projects],
                     user_agent=request.META.get("HTTP_USER_AGENT", ""),
                     created_status=status,
-                    auth_type="api_token" if is_api_token_auth(request.auth) else None,
+                    auth_type=get_auth_api_token_type(request.auth),
                 )
 
+                if is_org_auth_token_auth(request.auth):
+                    update_org_auth_token_last_used(
+                        request.auth, [project.id for project in projects]
+                    )
+
                 scope.set_tag("success_status", status)
                 return Response(serialize(release, request.user), status=status)
             scope.set_tag("failure_reason", "serializer_error")

+ 7 - 2
src/sentry/api/endpoints/project_releases.py

@@ -11,8 +11,9 @@ from sentry.api.bases.project import ProjectEndpoint, ProjectReleasePermission
 from sentry.api.paginator import OffsetPaginator
 from sentry.api.serializers import serialize
 from sentry.api.serializers.rest_framework import ReleaseWithVersionSerializer
+from sentry.api.utils import get_auth_api_token_type
 from sentry.models import Activity, Environment, Release, ReleaseStatus
-from sentry.models.apitoken import is_api_token_auth
+from sentry.models.orgauthtoken import is_org_auth_token_auth, update_org_auth_token_last_used
 from sentry.plugins.interfaces.releasehook import ReleaseHook
 from sentry.ratelimits.config import SENTRY_RATELIMITER_GROUP_DEFAULTS, RateLimitConfig
 from sentry.signals import release_created
@@ -187,8 +188,12 @@ class ProjectReleasesEndpoint(ProjectEndpoint, EnvironmentMixin):
                     project_ids=[project.id],
                     user_agent=request.META.get("HTTP_USER_AGENT", "")[:256],
                     created_status=status,
-                    auth_type="api_token" if is_api_token_auth(request.auth) else None,
+                    auth_type=get_auth_api_token_type(request.auth),
                 )
+
+                if is_org_auth_token_auth(request.auth):
+                    update_org_auth_token_last_used(request.auth, [project.id])
+
                 scope.set_tag("success_status", status)
 
                 # Disable snuba here as it often causes 429s when overloaded and

+ 13 - 0
src/sentry/api/utils.py

@@ -20,7 +20,10 @@ from sentry import options
 # Unfortunately, this function is imported as an export of this module in several places, keep it.
 from sentry.auth.access import get_cached_organization_member  # noqa
 from sentry.auth.superuser import is_active_superuser
+from sentry.models.apikey import is_api_key_auth
+from sentry.models.apitoken import is_api_token_auth
 from sentry.models.organization import Organization
+from sentry.models.orgauthtoken import is_org_auth_token_auth
 from sentry.search.utils import InvalidQuery, parse_datetime_string
 from sentry.services.hybrid_cloud import extract_id_from
 from sentry.services.hybrid_cloud.organization import (
@@ -335,3 +338,13 @@ def print_and_capture_handler_exception(
     event_id: str | None = capture_exception(exception, scope=scope)
 
     return event_id
+
+
+def get_auth_api_token_type(auth: object) -> str | None:
+    if is_api_token_auth(auth):
+        return "api_token"
+    if is_org_auth_token_auth(auth):
+        return "org_auth_token"
+    if is_api_key_auth(auth):
+        return "api_key"
+    return None

+ 23 - 0
src/sentry/models/orgauthtoken.py

@@ -15,6 +15,7 @@ from sentry.db.models import (
     sane_repr,
 )
 from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey
+from sentry.services.hybrid_cloud.orgauthtoken import orgauthtoken_service
 
 
 def validate_scope_list(value):
@@ -80,3 +81,25 @@ def is_org_auth_token_auth(auth: object) -> bool:
     if isinstance(auth, AuthenticatedToken):
         return auth.kind == "org_auth_token"
     return isinstance(auth, OrgAuthToken)
+
+
+def get_org_auth_token_id_from_auth(auth: object) -> int | None:
+    from sentry.services.hybrid_cloud.auth import AuthenticatedToken
+
+    if isinstance(auth, OrgAuthToken):
+        return auth.id
+    if isinstance(auth, AuthenticatedToken):
+        return auth.entity_id
+    return None
+
+
+def update_org_auth_token_last_used(auth: object, project_ids: list[int]):
+    org_auth_token_id = get_org_auth_token_id_from_auth(auth)
+    organization_id = getattr(auth, "organization_id", None)
+    if org_auth_token_id is not None and organization_id is not None:
+        orgauthtoken_service.update_orgauthtoken(
+            organization_id=organization_id,
+            org_auth_token_id=org_auth_token_id,
+            date_last_used=timezone.now(),
+            project_last_used_id=project_ids[0] if len(project_ids) > 0 else None,
+        )

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

@@ -79,6 +79,7 @@ class OutboxCategory(IntEnum):
     ORGANIZATION_MEMBER_CREATE = 13  # Unused
     SEND_SIGNAL = 14
     ORGANIZATION_MAPPING_CUSTOMER_ID_UPDATE = 15
+    ORGAUTHTOKEN_UPDATE = 16
 
     @classmethod
     def as_choices(cls):

+ 7 - 0
src/sentry/receivers/outbox/region.py

@@ -30,6 +30,7 @@ from sentry.services.hybrid_cloud.organizationmember_mapping import (
     RpcOrganizationMemberMappingUpdate,
     organizationmember_mapping_service,
 )
+from sentry.services.hybrid_cloud.orgauthtoken import orgauthtoken_rpc_service
 from sentry.types.region import get_local_region
 
 
@@ -39,6 +40,12 @@ def process_audit_log_event(payload: Any, **kwds: Any):
         log_rpc_service.record_audit_log(event=AuditLogEvent(**payload))
 
 
+@receiver(process_region_outbox, sender=OutboxCategory.ORGAUTHTOKEN_UPDATE)
+def process_orgauthtoken_update(payload: Any, **kwds: Any):
+    if payload is not None:
+        orgauthtoken_rpc_service.update_orgauthtoken(**payload)
+
+
 @receiver(process_region_outbox, sender=OutboxCategory.USER_IP_EVENT)
 def process_user_ip_event(payload: Any, **kwds: Any):
     if payload is not None:

+ 1 - 0
src/sentry/services/hybrid_cloud/orgauthtoken/__init__.py

@@ -0,0 +1 @@
+from .service import *  # noqa

+ 50 - 0
src/sentry/services/hybrid_cloud/orgauthtoken/impl.py

@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Optional
+
+from sentry.models.orgauthtoken import OrgAuthToken
+from sentry.models.outbox import OutboxCategory, OutboxScope, RegionOutbox
+from sentry.services.hybrid_cloud.orgauthtoken.service import OrgAuthTokenService
+
+if TYPE_CHECKING:
+    from datetime import datetime
+
+
+class DatabaseBackedOrgAuthTokenService(OrgAuthTokenService):
+    def update_orgauthtoken(
+        self,
+        *,
+        organization_id: int,
+        org_auth_token_id: int,
+        date_last_used: Optional[datetime] = None,
+        project_last_used_id: Optional[int] = None,
+    ) -> None:
+        token = OrgAuthToken.objects.filter(id=org_auth_token_id).first()
+
+        if token is None:
+            return
+
+        token.update(date_last_used=date_last_used, project_last_used_id=project_last_used_id)
+
+
+class OutboxBackedOrgAuthTokenService(OrgAuthTokenService):
+    def update_orgauthtoken(
+        self,
+        *,
+        organization_id: int,
+        org_auth_token_id: int,
+        date_last_used: Optional[datetime] = None,
+        project_last_used_id: Optional[int] = None,
+    ) -> None:
+        RegionOutbox(
+            shard_scope=OutboxScope.ORGANIZATION_SCOPE,
+            shard_identifier=organization_id,
+            category=OutboxCategory.ORGAUTHTOKEN_UPDATE,
+            object_identifier=org_auth_token_id,
+            payload={
+                "organization_id": organization_id,
+                "org_auth_token_id": org_auth_token_id,
+                "date_last_used": date_last_used,
+                "project_last_used_id": project_last_used_id,
+            },  # type:ignore
+        ).save()

+ 61 - 0
src/sentry/services/hybrid_cloud/orgauthtoken/service.py

@@ -0,0 +1,61 @@
+# Please do not use
+#     from __future__ import annotations
+# in modules such as this one where hybrid cloud data models or service classes are
+# defined, because we want to reflect on type annotations and avoid forward references.
+
+from abc import abstractmethod
+from datetime import datetime
+from typing import Optional, cast
+
+from sentry.services.hybrid_cloud import silo_mode_delegation
+from sentry.services.hybrid_cloud.rpc import RpcService, rpc_method
+from sentry.silo import SiloMode
+
+
+class OrgAuthTokenService(RpcService):
+    key = "orgauthtoken"
+    local_mode = SiloMode.CONTROL
+
+    @classmethod
+    def get_local_implementation(cls) -> RpcService:
+        return impl_by_db()
+
+    @rpc_method
+    @abstractmethod
+    def update_orgauthtoken(
+        self,
+        *,
+        organization_id: int,
+        org_auth_token_id: int,
+        date_last_used: Optional[datetime] = None,
+        project_last_used_id: Optional[int] = None,
+    ) -> None:
+        pass
+
+
+def impl_by_db() -> OrgAuthTokenService:
+    from .impl import DatabaseBackedOrgAuthTokenService
+
+    return DatabaseBackedOrgAuthTokenService()
+
+
+def impl_by_outbox() -> OrgAuthTokenService:
+    from .impl import OutboxBackedOrgAuthTokenService
+
+    return OutboxBackedOrgAuthTokenService()
+
+
+# An asynchronous service which can delegate to an outbox implementation, essentially enqueueing
+# updates of tokens for future processing.
+orgauthtoken_service: OrgAuthTokenService = silo_mode_delegation(
+    {
+        SiloMode.REGION: impl_by_outbox,
+        SiloMode.CONTROL: impl_by_db,
+        SiloMode.MONOLITH: impl_by_db,
+    }
+)
+
+
+orgauthtoken_rpc_service: OrgAuthTokenService = cast(
+    OrgAuthTokenService, OrgAuthTokenService.create_delegation()
+)

Some files were not shown because too many files changed in this diff