Skip to content

feat(auth): Put service accounts on request.user as a principal - #129146

Merged
gricha merged 1 commit into
ref/request-principal-chokepointfrom
feat/service-account-principal-core
Oct 30, 2026
Merged

gricha merged 1 commit into
ref/request-principal-chokepointfrom
feat/service-account-principal-core

Conversation

@gricha

@gricha gricha commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

On a DRF request, a service account's request.user is a ServiceAccountPrincipal instead of AnonymousUser. Service accounts are gated by organizations:service-accounts, which is now checked at authentication too (authenticate_service_account), so that flag is the one switch. Before, only service-account creation and the token endpoint checked it, and anything holding the shared secret could sign a service-account viewer context.

The principal has no user, so has_user() is False and acting_user_id() is None, and its actor is SERVICE_ACCOUNT. Its id is a ServiceAccountId that never equals an int and raises UserRequired (403 user-required) when converted to one, so no request.user.id call site can reach the user with the same number.

Temporary: ServiceAccountId is a runtime guard, because about 490 request.user.id reads still assume a user. It's deleted, along with the RPC pass-through for UserRequired, once mypy types request.user to include ServiceAccountPrincipal (plugin, shrinking per-module allowlist) and those reads are migrated.

This PR sits right above the chokepoint on purpose: every PR after it is written and tested against the real principal. Endpoints aren't blocked up front. What breaks for a service account shows up in the auth matrix, and the PRs above fix it or mark the endpoint requires_user.

What else this changes:

  • Web views: set_request_principal sets a service account only on a DRF Request (its setter also copies it to Django's request.user). Until DRF authenticates it, Django's request.user is anonymous, and session auth never sees it, since ViewerContextAuthentication runs before SessionAuthentication. The check reads the credential (is_service_account_auth(auth)), not isinstance(user, ...): the middleware passes Django's lazy user, and loading it reads the session, which adds Vary: Cookie to every response. test_leaves_lazy_user_unloaded guards it.
  • Access: SentryPermission.determine_access asks has_user() and acting_user_id(), so a service account keeps the credential-based access (from_rpc_auth) it had as AnonymousUser, instead of its ServiceAccountId reaching the org lookup.
  • Sudo: neither the decorator nor the sudo middleware treats a service account as sudo; both ask has_user() before has_usable_password(). Sudo re-authenticates a person, so require_sudo() answers a caller with no user with a 403 user-required instead of a sudo prompt, from @sudo_required and from project delete. Org, team and project delete and project transfer stay user-only.
  • Rate limits: service accounts get their own bucket.
  • RPC: UserRequired passes through as an API error.

The auth matrix enables the flag for its service-account rows and records what still fails at this point in the stack. Rows that don't match the oracle are strict xfails, so the PR that fixes a row has to remove it. At this point 89 service-account rows are known gaps: 28 audit actors, 21 per-user serializer lookups, 20 request.user.id reads, 2 serialize_generic_user, 1 project creation that needs require_user, 12 control-silo endpoints and 5 that check the caller's own membership or org role. The PRs above remove them. At the top of the stack, 17 remain: the control-silo and membership ones.

This replaces #128983, split per review. It builds on #128779, now landed.

Stack (each PR is based on the one above it):

  1. ref(auth): Add a shared request.user interface #128982 Shared request.user interface
  2. ref(auth): Set request.user and request.auth through one helper #129133 Set request.user / request.auth through one helper
  3. feat(auth): Put service accounts on request.user as a principal #129146 Service accounts on request.user
  4. ref(auth): Ask has_user() in access, org lookups and users/me #129136 has_user() in access, org lookups and users/me
  5. feat(api): Let endpoints and code require a user #129143 requires_user / require_user()
  6. ref(auth): Lint request.user patterns that break for callers with no user #129144 Lints for patterns that break for a service account
  7. ref(orgs): Ask has_user() for the acting user in org and team endpoints #129154 Orgs, teams, projects
  8. ref(issues): Ask has_user() for the acting user on issue actions #129155 Issues
  9. ref(explore): Ask has_user() for the acting user on saved queries and events #129149 Discover, Explore
  10. ref(dashboards): Use acting_user_id() for the dashboard creator #129156 Dashboards
  11. ref(workflow_engine): Ask has_user() for the detector and workflow creator #129150 Workflow engine
  12. ref(replays): Use acting_user_id() for the viewer #129151 Replays
  13. ref(preprod): Use acting_user_id() for the uploader #129152 Preprod
  14. ref(auth): Ask has_user() for the acting user in the remaining endpoints #129153 Remaining endpoints
  15. ref(audit): Attribute audit entries only to a user #129253 Audit entries
  16. ref(api): Skip per-user serializer lookups when no user is calling #129254 Per-user serializer lookups
  17. ref(users): Serialize no user when no user is behind the caller #129255 serialize_generic_user
  18. ref(features): Give flagpole no user context without a user #129256 Flagpole user context

@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Oct 29, 2026
@sentry

sentry Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Sentry Snapshot Testing

Name Added Removed Changed Renamed Unchanged Skipped Status
sentry-frontend
sentry-frontend
0 0 0 0 780 0 ✅ Unchanged

⚙️ sentry-frontend Snapshot Settings

gricha added a commit that referenced this pull request Oct 29, 2026
The dashboard creator on the model and the serializer read the caller's
human from request.user.user_id instead of request.user.id if
request.user.is_authenticated else None, or is_authenticated used to mean
"is a person".

No behavior change: today only AnonymousUser has no user_id, and every
authenticated caller's user_id is its id. Once service accounts can sit on
request.user (behind auth.service-account-principal), their user_id is None,
so these places record no user instead of reading an id that raises
UserRequired.

Split out of #128983 so owning teams can review their slice; the core change
is #129146. Needs #128982 (user_id on request.user).
@gricha
gricha force-pushed the feat/service-account-principal-core branch from 54744db to c77b783 Compare October 29, 2026 16:25
gricha added a commit that referenced this pull request Oct 29, 2026
Scheduled deletion actor, onboarding, Tempest credentials, Seer agentic
triage and integration direct-enable read the caller's human from
request.user.user_id instead of request.user.id if
request.user.is_authenticated else None, or is_authenticated used to mean
"is a person".

No behavior change: today only AnonymousUser has no user_id, and every
authenticated caller's user_id is its id. Once service accounts can sit on
request.user (behind auth.service-account-principal), their user_id is None,
so these places record no user instead of reading an id that raises
UserRequired.

Split out of #128983 so owning teams can review their slice; the core change
is #129146. Needs #128982 (user_id on request.user).
gricha added a commit that referenced this pull request Oct 29, 2026
update_groups' acting user, group integration and external-issue activity,
and per-user issue defaults read the caller's human from
request.user.user_id instead of request.user.id if
request.user.is_authenticated else None, or is_authenticated used to mean
"is a person".

No behavior change: today only AnonymousUser has no user_id, and every
authenticated caller's user_id is its id. Once service accounts can sit on
request.user (behind auth.service-account-principal), their user_id is None,
so these places record no user instead of reading an id that raises
UserRequired.

Split out of #128983 so owning teams can review their slice; the core change
is #129146. Needs #128982 (user_id on request.user).
gricha added a commit that referenced this pull request Oct 29, 2026
Discover and Explore saved queries, saved formulas, and the events base read
the caller's human from request.user.user_id instead of request.user.id if
request.user.is_authenticated else None, or is_authenticated used to mean
"is a person".

No behavior change: today only AnonymousUser has no user_id, and every
authenticated caller's user_id is its id. Once service accounts can sit on
request.user (behind auth.service-account-principal), their user_id is None,
so these places record no user instead of reading an id that raises
UserRequired.

Split out of #128983 so owning teams can review their slice; the core change
is #129146. Needs #128982 (user_id on request.user).
gricha added a commit that referenced this pull request Oct 29, 2026
Org details, org index, teams, project index, Team lookups, org deletion
actor and auth_organization_config read the caller's human from
request.user.user_id instead of request.user.id if
request.user.is_authenticated else None, or is_authenticated used to mean
"is a person".

No behavior change: today only AnonymousUser has no user_id, and every
authenticated caller's user_id is its id. Once service accounts can sit on
request.user (behind auth.service-account-principal), their user_id is None,
so these places record no user instead of reading an id that raises
UserRequired.

Split out of #128983 so owning teams can review their slice; the core change
is #129146. Needs #128982 (user_id on request.user).
gricha added a commit that referenced this pull request Oct 29, 2026
The detector and workflow creator in the validators read the caller's human
from request.user.user_id instead of request.user.id if
request.user.is_authenticated else None, or is_authenticated used to mean
"is a person".

No behavior change: today only AnonymousUser has no user_id, and every
authenticated caller's user_id is its id. Once service accounts can sit on
request.user (behind auth.service-account-principal), their user_id is None,
so these places record no user instead of reading an id that raises
UserRequired.

Split out of #128983 so owning teams can review their slice; the core change
is #129146. Needs #128982 (user_id on request.user).
gricha added a commit that referenced this pull request Oct 29, 2026
The snapshot uploader on preprod snapshot endpoints read the caller's human
from request.user.user_id instead of request.user.id if
request.user.is_authenticated else None, or is_authenticated used to mean
"is a person".

No behavior change: today only AnonymousUser has no user_id, and every
authenticated caller's user_id is its id. Once service accounts can sit on
request.user (behind auth.service-account-principal), their user_id is None,
so these places record no user instead of reading an id that raises
UserRequired.

Split out of #128983 so owning teams can review their slice; the core change
is #129146. Needs #128982 (user_id on request.user).
gricha added a commit that referenced this pull request Oct 29, 2026
has_viewed and viewed-by on replay details read the caller's human from
request.user.user_id instead of request.user.id if
request.user.is_authenticated else None, or is_authenticated used to mean
"is a person".

No behavior change: today only AnonymousUser has no user_id, and every
authenticated caller's user_id is its id. Once service accounts can sit on
request.user (behind auth.service-account-principal), their user_id is None,
so these places record no user instead of reading an id that raises
UserRequired.

Split out of #128983 so owning teams can review their slice; the core change
is #129146. Needs #128982 (user_id on request.user).
@gricha
gricha changed the base branch from ref/request-principal-on-128779 to ref/request-principal-lints October 29, 2026 16:26
gricha added a commit that referenced this pull request Oct 29, 2026
Org details, org index, teams, project index, Team lookups, org deletion
actor and auth_organization_config read the caller's human from
request.user.user_id instead of request.user.id if
request.user.is_authenticated else None, or is_authenticated used to mean
"is a person".

No behavior change: today only AnonymousUser has no user_id, and every
authenticated caller's user_id is its id. Once service accounts can sit on
request.user (behind auth.service-account-principal), their user_id is None,
so these places record no user instead of reading an id that raises
UserRequired.

Split out of #128983 so owning teams can review their slice; the core change
is #129146. Needs #128982 (user_id on request.user).
@gricha
gricha force-pushed the feat/service-account-principal-core branch 2 times, most recently from 2284703 to 01cdaf9 Compare October 29, 2026 18:23
gricha added a commit that referenced this pull request Oct 29, 2026
update_groups' acting user, group integration and external-issue activity,
and per-user issue defaults read the caller's human from
request.user.user_id instead of request.user.id if
request.user.is_authenticated else None, or is_authenticated used to mean
"is a person".

No behavior change: today only AnonymousUser has no user_id, and every
authenticated caller's user_id is its id. Once service accounts can sit on
request.user (behind auth.service-account-principal), their user_id is None,
so these places record no user instead of reading an id that raises
UserRequired.

Split out of #128983 so owning teams can review their slice; the core change
is #129146. Needs #128982 (user_id on request.user).
@gricha
gricha force-pushed the feat/service-account-principal-core branch from 01cdaf9 to e13701c Compare October 29, 2026 18:30
gricha added a commit that referenced this pull request Oct 29, 2026
The detector and workflow creator in the validators read the caller's human
from request.user.user_id instead of request.user.id if
request.user.is_authenticated else None, or is_authenticated used to mean
"is a person".

No behavior change: today only AnonymousUser has no user_id, and every
authenticated caller's user_id is its id. Once service accounts can sit on
request.user (behind auth.service-account-principal), their user_id is None,
so these places record no user instead of reading an id that raises
UserRequired.

Split out of #128983 so owning teams can review their slice; the core change
is #129146. Needs #128982 (user_id on request.user).
gricha added a commit that referenced this pull request Oct 29, 2026
Discover and Explore saved queries, saved formulas, and the events base read
the caller's human from request.user.user_id instead of request.user.id if
request.user.is_authenticated else None, or is_authenticated used to mean
"is a person".

No behavior change: today only AnonymousUser has no user_id, and every
authenticated caller's user_id is its id. Once service accounts can sit on
request.user (behind auth.service-account-principal), their user_id is None,
so these places record no user instead of reading an id that raises
UserRequired.

Split out of #128983 so owning teams can review their slice; the core change
is #129146. Needs #128982 (user_id on request.user).
Comment on lines +9 to +31
@dataclasses.dataclass(frozen=True)
class ServiceAccountId:
"""
A service account's ``request.user.id``.

It never equals a user id, and turning it into an int raises ``UserRequired``, so a
lookup or write keyed on ``request.user.id`` fails instead of reaching the user with
the same number.

Temporary runtime guard: about 490 ``request.user.id`` reads still assume a user. It
goes once mypy types ``request.user`` to include ``ServiceAccountPrincipal`` (plugin,
shrinking per-module allowlist) and those reads are migrated.
"""

service_account_id: int

def __int__(self) -> NoReturn:
raise UserRequired()

__index__ = __int__

def __str__(self) -> str:
return f"service_account:{self.service_account_id}"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is kind of stupid but bear with me, it's an explicit decision i kinda run with here.

this is a temporary workaround which lets me to gracefully fail the endpoints that rely on user with "UserRequired" exception.

this will be fast followed with actual change in implementation on endpoints side + mypy change that would make sure request.user CAN be service account principal that will force callers into explicit decision.

@gricha
gricha marked this pull request as ready for review October 29, 2026 22:51
@gricha
gricha requested review from a team as code owners October 29, 2026 22:51
@gricha
gricha requested a review from azulus October 29, 2026 22:51
@gricha
gricha force-pushed the feat/service-account-principal-core branch from 56e5749 to a8d7e08 Compare October 29, 2026 23:04
@gricha
gricha requested a review from a team as a code owner October 29, 2026 23:04

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@gricha
gricha force-pushed the feat/service-account-principal-core branch from a8d7e08 to 40ec8fd Compare October 29, 2026 23:26

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/sentry/api/decorators.py Resolved
@gricha
gricha force-pushed the feat/service-account-principal-core branch from 40ec8fd to b338a2d Compare October 29, 2026 23:53
@gricha
gricha force-pushed the feat/service-account-principal-core branch 2 times, most recently from 418311f to c82a0cd Compare October 30, 2026 05:06
@gricha
gricha force-pushed the feat/service-account-principal-core branch from c82a0cd to 4f531ba Compare October 30, 2026 05:21
@gricha
gricha force-pushed the feat/service-account-principal-core branch from 4f531ba to ac8d3fc Compare October 30, 2026 06:38

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/sentry/api/decorators.py Resolved
Comment thread tests/sentry/middleware/test_auth.py Resolved

@skaasten skaasten left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(from an high level understanding, not the ins and outs of django auth processing. )

@michelletran-sentry michelletran-sentry left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems good to me!

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 632f066. Configure here.

Comment thread src/sentry/api/permissions.py Resolved
On a DRF request, a service account's request.user is a
ServiceAccountPrincipal instead of AnonymousUser. set_request_principal sets
it only on a DRF Request; Django's request.user stays anonymous until DRF
authenticates, and session auth never sees it.

Service accounts only authenticate for organizations with
organizations:service-accounts, now checked at authentication too, so the
flag is the one switch.

The principal's user_id is None and its actor is SERVICE_ACCOUNT. Its id is a
ServiceAccountId that never equals an int and raises UserRequired (403,
user-required) when converted to one, so no request.user.id call site can
reach the user with the same number.

SentryPermission.determine_access asks has_user() and reads user_id, so a
service account keeps the credential-based access it had as AnonymousUser.
Sudo checks, in the decorator and the middleware, don't treat service
accounts as sudo. They get their own rate-limit bucket, and UserRequired
passes through RPC as an API error.

The auth matrix enables the flag for its service-account rows. Rows that
don't match the oracle yet are strict xfails, so the PRs that fix them have
to remove them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants