Skip to content

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

Closed
gricha wants to merge 8 commits into
ref/request-principal-lintsfrom
feat/service-account-request-principal
Closed

gricha wants to merge 8 commits into
ref/request-principal-lintsfrom
feat/service-account-request-principal

Conversation

@gricha

@gricha gricha commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Behind auth.service-account-principal (default off), a service account's request.user is a ServiceAccountPrincipal instead of AnonymousUser. With the option off it stays anonymous, as #128779 has it. set_request_principal applies the option, so there is one place to turn it off.

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 existing request.user.id call site can reach the user with the same number. Those 403s show up in api.user_required, tagged by endpoint.

The Django-level request.user stays anonymous, so session auth and web views never see the account. SentryIsAuthenticated rejects callers with no user_id. Service accounts count as sudo, like org auth tokens, and get their own rate-limit bucket.

What's covered where

  • Shared defaults (ref(auth): Give callers with no human the org auth token defaults #129101) cover serializers.
  • Fixed here, per site: team and org lookups, update_groups, ScheduledDeletion, Activity and creator attribution, saved queries, dashboards, replay has_viewed and onboarding. Creating a project calls require_user.
  • Auth matrix results: 14 of the 36 known service-account gaps now match the oracle, and 5 human-only routes assert user-required. The other 17 remain as strict xfails: 12 because service-account auth is cell-only, and 5 because they check the caller's own membership.
  • Matrix setting: it runs with the option on.

Merges #128779 (still open) to build on its ViewerContextAuthentication and ServiceAccountAccess.

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

  1. ref(auth): Add a shared request.user interface #128982 Shared request.user interface and get_current_actor
  2. ref(auth): Set request.user and request.auth through one helper #129133 Set request.user / request.auth through one helper
  3. ref(auth): Give callers with no human the org auth token defaults #129101 Callers with no human view as anonymous by default
  4. ref(auth): Ask has_user() in access, org lookups and users/me #129136 Read request.user.user_id in access, permissions and org lookups
  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 callers with no human
  7. feat(auth): Put service accounts on request.user as a principal #128983 Service accounts on request.user, behind auth.service-account-principal

sentry-junior Bot and others added 6 commits October 26, 2026 16:31
…oint

The viewer-context endpoint now signs a SERVICE_ACCOUNT actor for the
account instead of Seer's org-only context. Requests carrying that
context authenticate as a userless credential bound to the account's
org, with scopes read from the cell replica on every request, and get a
new ServiceAccountAccess: every active project with open membership,
otherwise only the account's linked active projects.

A service-account context can't mint another account's context, and
service accounts skip the release permission cache so their IDs don't
share keys with API keys.

Refs ENG-8829

Co-Authored-By: Shaun Kaasten <shaun.kaasten@sentry.io>
…t-actor

Co-Authored-By: Shaun Kaasten <shaun.kaasten@sentry.io>
OrganizationReleasesBaseEndpoint.get_projects only admitted API keys, org
tokens, agents and users, so a service account got no projects and
has_release_permission never passed. Admit service accounts bound to the
organization.

Endpoints that default to "my projects" filter by membership, which a
service account never has. Treat its linked projects as its membership.

Refs ENG-8829
SeerRpcViewerContextAuthentication accepted a service-account ViewerContext
like Seer's own org-only one. seer-rpc methods trust their org_id argument
and skip access checks, so the account's scopes, project links and active
status were not enforced there. Reject service-account actors; HMAC callers
are unaffected.

This also covers the viewer-context endpoint, so its separate
service-account check is no longer needed.

Refs ENG-8829

Co-Authored-By: Shaun Kaasten <shaun.kaasten@sentry.io>
Show that the release endpoints' project lookup stays limited to a service
account's linked projects, including for $all, and clarify why service
accounts skip the release permission cache.

Refs ENG-8829
@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Oct 28, 2026
@gricha
gricha force-pushed the ref/request-principal-interface branch from 7090aa2 to df72683 Compare October 28, 2026 18:56
@gricha
gricha force-pushed the feat/service-account-request-principal branch from 2c9daae to e9c5638 Compare October 28, 2026 19:04
@gricha
gricha force-pushed the ref/request-principal-interface branch from df72683 to 2bea975 Compare October 29, 2026 00:37
@gricha
gricha force-pushed the feat/service-account-request-principal branch from e9c5638 to 73ba3bd Compare October 29, 2026 00:38
@gricha
gricha changed the base branch from ref/request-principal-interface to ref/request-actor-adoption October 29, 2026 00:38
Behind auth.service-account-principal (default off), a service account's
request.user is a ServiceAccountPrincipal instead of AnonymousUser. Off, it is
anonymous, as #128779 has it. set_request_principal applies the option, so
there is one place to turn it off.

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 existing request.user.id call site
can reach the user with the same number.

The Django-level request.user stays anonymous, so session auth and web views
never see the account. SentryIsAuthenticated rejects callers with no user_id;
service accounts count as sudo, like org auth tokens, and get their own
rate-limit bucket.

The stack's shared defaults cover serializers. What's left here is per-site:
team and org lookups, update_groups, ScheduledDeletion, Activity and creator
attribution, saved queries, dashboards, replay has_viewed and onboarding.
Creating a project calls require_user. 14 of the 36 known service-account gaps
in the auth matrix now match the oracle, and 5 human-only routes assert
user-required instead.
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Backend Test Failures

Failures on 861407c in this run:

tests/sentry/web/frontend/test_js_sdk_loader.py::JavaScriptSdkLoaderTest::test_headers — log
[gw0] linux -- Python 3.13.17 /home/runner/work/sentry/sentry/.venv/bin/python3
tests/sentry/web/frontend/test_js_sdk_loader.py:501: in test_headers
    assert "Vary" not in resp, f"Found Vary header: {resp['Vary']}"
E   AssertionError: Found Vary header: Cookie
E   assert 'Vary' not in <HttpResponse status_code=200, "text/javascript">
tests/sentry/web/frontend/test_team_avatar.py::TeamAvatarPhotoTest::test_headers — log
[gw1] linux -- Python 3.13.17 /home/runner/work/sentry/sentry/.venv/bin/python3
tests/sentry/web/frontend/test_team_avatar.py:26: in test_headers
    assert response.get("Vary") is None
E   assert 'Cookie' is None
E    +  where 'Cookie' = get('Vary')
E    +    where get = <HttpResponse status_code=200, "image/png">.get
tests/sentry/web/frontend/test_team_avatar.py::TeamAvatarPhotoTest::test_origin_header — log
[gw1] linux -- Python 3.13.17 /home/runner/work/sentry/sentry/.venv/bin/python3
tests/sentry/web/frontend/test_team_avatar.py:43: in test_origin_header
    assert response.get("Vary") == "Origin"
E   AssertionError: assert 'Origin, Cookie' == 'Origin'
E     
E     �[0m�[91m- Origin�[39;49;00m�[90m�[39;49;00m
E     �[92m+ Origin, Cookie�[39;49;00m�[90m�[39;49;00m
tests/sentry/users/web/test_user_avatar.py::UserAvatarTest::test_headers_control_file — log
[gw0] linux -- Python 3.13.17 /home/runner/work/sentry/sentry/.venv/bin/python3
tests/sentry/users/web/test_user_avatar.py:24: in test_headers_control_file
    assert response.get("Vary") is None
E   assert 'Cookie' is None
E    +  where 'Cookie' = get('Vary')
E    +    where get = <HttpResponse status_code=200, "image/png">.get
tests/sentry/web/frontend/test_organization_avatar.py::OrganizationAvatarTest::test_headers — log
[gw0] linux -- Python 3.13.17 /home/runner/work/sentry/sentry/.venv/bin/python3
tests/sentry/web/frontend/test_organization_avatar.py:34: in test_headers
    assert response.get("Vary") is None
E   assert 'Cookie' is None
E    +  where 'Cookie' = get('Vary')
E    +    where get = <HttpResponse status_code=200, "image/png">.get
tests/sentry/web/frontend/test_organization_avatar.py::OrganizationAvatarTest::test_origin_header — log
[gw0] linux -- Python 3.13.17 /home/runner/work/sentry/sentry/.venv/bin/python3
tests/sentry/web/frontend/test_organization_avatar.py:50: in test_origin_header
    assert response.get("Vary") == "Origin"
E   AssertionError: assert 'Origin, Cookie' == 'Origin'
E     
E     �[0m�[91m- Origin�[39;49;00m�[90m�[39;49;00m
E     �[92m+ Origin, Cookie�[39;49;00m�[90m�[39;49;00m

@gricha gricha closed this Oct 29, 2026
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 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 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
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
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).
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 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
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
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 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
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 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
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 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
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
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 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
gricha removed this pull request from stack #129145 October 29, 2026 18:54
gricha added a commit that referenced this pull request Oct 30, 2026
)

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. #128982 Shared `request.user` interface
2. #129133 Set `request.user` / `request.auth` through one helper
3. #129146 Service accounts on `request.user`
4. #129136 `has_user()` in access, org lookups and `users/me`
5. #129143 `requires_user` / `require_user()`
6. #129144 Lints for patterns that break for a service account
7. #129154 Orgs, teams, projects
8. #129155 Issues
9. #129149 Discover, Explore
10. #129156 Dashboards
11. #129150 Workflow engine
12. #129151 Replays
13. #129152 Preprod
14. #129153 Remaining endpoints
15. #129253 Audit entries
16. #129254 Per-user serializer lookups
17. #129255 `serialize_generic_user`
18. #129256 Flagpole user context
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.

1 participant