From 4a434390ec7d558ed72c9194ab5f0df389cd9cb7 Mon Sep 17 00:00:00 2001 From: Om Date: Wed, 12 Aug 2026 20:57:42 +0530 Subject: [PATCH] fix(backend): store token expiry as UTC instead of server local time datetime.fromtimestamp(x / 1000) with no tz argument returns a naive datetime in the server's local time. With USE_TZ = True, Django stamps that naive value as UTC, so on a host whose local timezone is not UTC the stored expiry is shifted by the UTC offset. Where the offset is positive (Asia/Kolkata, +5:30) a token outlives the expiry the user asked for. Where it is negative (America/New_York, -5:00) it expires early. Pass tz=dt_timezone.utc at each site, matching what api/views/audit.py and backend/schema.py already do. Affected: graphene/mutations/service_accounts.py service account token expiry (x2) graphene/mutations/environment.py token expiry (x2) graphene/mutations/lockbox.py lockbox expiry ee/authentication/scim/graphene/queries.py SCIM log filtering (read path) The shipped docker-compose does not set TZ and Docker defaults to UTC, so a default deployment is unaffected. It needs a non-UTC host: a bare-metal install, a container run with TZ set, or a dev machine. This also silences "RuntimeWarning: DateTimeField received a naive datetime while time zone support is active" on these writes. Reported privately to security@phase.dev first; opening publicly at the maintainers' request. --- backend/backend/graphene/mutations/environment.py | 6 +++--- backend/backend/graphene/mutations/lockbox.py | 4 ++-- backend/backend/graphene/mutations/service_accounts.py | 6 +++--- backend/ee/authentication/scim/graphene/queries.py | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/backend/backend/graphene/mutations/environment.py b/backend/backend/graphene/mutations/environment.py index b08306c56..ceadca75d 100644 --- a/backend/backend/graphene/mutations/environment.py +++ b/backend/backend/graphene/mutations/environment.py @@ -49,7 +49,7 @@ ServiceTokenType, UserTokenType, ) -from datetime import datetime +from datetime import datetime, timezone as dt_timezone class EnvironmentInput(graphene.InputObjectType): @@ -694,7 +694,7 @@ def mutate( ) if expiry is not None: - expires_at = datetime.fromtimestamp(expiry / 1000) + expires_at = datetime.fromtimestamp(expiry / 1000, tz=dt_timezone.utc) else: expires_at = None @@ -821,7 +821,7 @@ def mutate( ) if expiry is not None: - expires_at = datetime.fromtimestamp(expiry / 1000) + expires_at = datetime.fromtimestamp(expiry / 1000, tz=dt_timezone.utc) else: expires_at = None diff --git a/backend/backend/graphene/mutations/lockbox.py b/backend/backend/graphene/mutations/lockbox.py index 25f1f3af3..8622a629d 100644 --- a/backend/backend/graphene/mutations/lockbox.py +++ b/backend/backend/graphene/mutations/lockbox.py @@ -1,7 +1,7 @@ from api.models import Lockbox from backend.graphene.types import LockboxType import graphene -from datetime import datetime +from datetime import datetime, timezone as dt_timezone class LockboxInput(graphene.InputObjectType): @@ -19,7 +19,7 @@ class Arguments: @classmethod def mutate(cls, root, info, input): if input.expiry is not None: - expires_at = datetime.fromtimestamp(input.expiry / 1000) + expires_at = datetime.fromtimestamp(input.expiry / 1000, tz=dt_timezone.utc) else: expires_at = None diff --git a/backend/backend/graphene/mutations/service_accounts.py b/backend/backend/graphene/mutations/service_accounts.py index 8c38da51a..3d385b1fc 100644 --- a/backend/backend/graphene/mutations/service_accounts.py +++ b/backend/backend/graphene/mutations/service_accounts.py @@ -25,7 +25,7 @@ from api.utils.audit_logging import log_audit_event, get_actor_info_from_graphql from api.utils.rest import get_resolver_request_meta from backend.graphene.types import ServiceAccountTokenType, ServiceAccountType -from datetime import datetime +from datetime import datetime, timezone as dt_timezone from django.conf import settings @@ -450,7 +450,7 @@ def mutate( _check_sa_permission(user, service_account, "create", "ServiceAccountTokens") if expiry is not None: - expires_at = datetime.fromtimestamp(expiry / 1000) + expires_at = datetime.fromtimestamp(expiry / 1000, tz=dt_timezone.utc) else: expires_at = None @@ -588,7 +588,7 @@ def mutate(cls, root, info, service_account_id, name, expiry=None): wrapped_share_b = wrap_share_hex(share_b, wrap_key) if expiry is not None: - expires_at = datetime.fromtimestamp(expiry / 1000) + expires_at = datetime.fromtimestamp(expiry / 1000, tz=dt_timezone.utc) else: expires_at = None diff --git a/backend/ee/authentication/scim/graphene/queries.py b/backend/ee/authentication/scim/graphene/queries.py index 4ca543f3a..9052125fa 100644 --- a/backend/ee/authentication/scim/graphene/queries.py +++ b/backend/ee/authentication/scim/graphene/queries.py @@ -1,7 +1,7 @@ from graphql import GraphQLError from api.models import OrganisationMember, SCIMEvent, SCIMToken from api.utils.access.permissions import user_has_permission, user_is_org_member -from datetime import datetime +from datetime import datetime, timezone as dt_timezone def resolve_scim_tokens(root, info, organisation_id): @@ -52,10 +52,10 @@ def resolve_scim_events( qs = SCIMEvent.objects.filter(organisation_id=organisation_id) if start is not None: - qs = qs.filter(timestamp__gte=datetime.fromtimestamp(start / 1000)) + qs = qs.filter(timestamp__gte=datetime.fromtimestamp(start / 1000, tz=dt_timezone.utc)) if end is not None: - qs = qs.filter(timestamp__lte=datetime.fromtimestamp(end / 1000)) + qs = qs.filter(timestamp__lte=datetime.fromtimestamp(end / 1000, tz=dt_timezone.utc)) if event_types: # Frontend sends uppercase enum values (e.g. USER_CREATED),