Thanks to visit codestin.com
Credit goes to github.com

Skip to content
15 changes: 15 additions & 0 deletions coderd/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package coderd

import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
Expand Down Expand Up @@ -154,6 +155,14 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
if createToken.Lifetime != 0 {
err := api.validateAPIKeyLifetime(ctx, user.ID, createToken.Lifetime)
if err != nil {
// The {user} param can resolve a soft-deleted user; creating
// a token for one is a bad request, not a server error.
if errors.Is(err, httpmw.ErrUserDeleted) {

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.

P2 [CRF-11] Creating a token or API key for a soft-deleted user returns a 500 with a raw Postgres string, because the deleted-user guard is nested inside the optional lifetime branch and postAPIKey has no guard at all. (Knov P2, Chopper P2, Bisky P2, Leorio P2; +11 at P3)

The errors.Is(err, httpmw.ErrUserDeleted) check sits inside if createToken.Lifetime != 0, and validateAPIKeyLifetime is the only thing on this path that calls UserRBACSubject. With lifetime omitted (the default), and for POST /users/{user}/keys (no guard at all), control falls through to createAPIKey -> InsertAPIKey, where insert_apikey_fail_if_user_deleted (dump.sql:1170) raises. Verified live by several reviewers:

POST /users/{uuid}/keys/tokens (no lifetime) -> 500 "Failed to create API key." detail="insert API key: pq: Cannot create API key for deleted user"
POST /users/{uuid}/keys/tokens (lifetime set) -> 400 "Cannot create a token for a deleted user."
POST /users/{uuid}/keys                       -> 500, same pq detail

Reachable without any orphan race: ExtractUserContext resolves {user} by UUID via GetUserByID, which has no deleted = false filter. The PR description's "token create/config endpoints return 400/404 for a deleted target user" is false for two of three shapes, and the 500 leaks a driver string and burns the server-error budget.

Fix (also resolves CRF-15's status split): httpmw.UserParam(r) already returns the database.User with Deleted. Check if user.Deleted at the top of both postToken and postAPIKey, next to the existing user.IsSystem guard, and return one consistent status. Prefer httpapi.ResourceNotFound to match tokenConfig and to avoid disclosing the user's deleted state (see CRF-15). Then delete the nested ErrUserDeleted branch.

🤖

httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot create a token for a deleted user.",
})
return
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to validate create API key request.",
Detail: err.Error(),
Expand Down Expand Up @@ -507,6 +516,12 @@ func (api *API) tokenConfig(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)
maxLifetime, err := api.getMaxTokenLifetime(r.Context(), user.ID)
if err != nil {
// The {user} param can resolve a soft-deleted user; their token
// configuration is gone with them, not a server error.
if errors.Is(err, httpmw.ErrUserDeleted) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get token configuration.",
Detail: err.Error(),
Expand Down
31 changes: 31 additions & 0 deletions coderd/database/querier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3439,6 +3439,37 @@ func TestGetAuthorizationUserRolesImpliedOrgRole(t *testing.T) {
require.NotContains(t, saRoles.Roles, wantMember)
}

// TestGetAuthorizationUserRolesDeletedUser pins the query's contract for
Comment thread
ThomasK33 marked this conversation as resolved.
Comment thread
ThomasK33 marked this conversation as resolved.

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.

P3 [CRF-21] The test comment names a dependent that does not exist and drifts from the query comment it duplicates, and the test does not pin the org-role removal it describes. (Gon P2; Zoro Nit; Melody, Mafuuu, Meruem Note)

queries/users.sql:588 names two dependents (provisionerdserver.go, dynamicparameters/render.go, both verified as the only non-test GetAuthorizationUserRoles callers). The test comment adds "prebuilds," which no prebuilds code reaches: prebuild builds are owned by the non-deleted prebuilds system user, so the dependency it asserts does not exist. One PR, two copies of the contract, already drifted. Separately, the comment says org-scoped roles are gone by soft-delete time, but the test user has no org membership, so nothing would notice if that changed. Cut the comment to what the test pins and point at the query, and add the user to an org and assert the org role disappears if you want to pin that half of the contract. (Melody adds that the query comment's own "delete build" example is unreachable, since users.go:696 refuses to delete a user who still owns workspaces; worth tightening the example.)

🤖

// soft-deleted users: the row is still returned, with Deleted set and the
// site-level roles (including the implied member role) still resolving.
// Org-scoped roles are gone by then: the cleanup trigger deletes the user's
// organization_members rows during the soft-delete. Non-authentication
// callers (provisioner builds resolving a soft-deleted owner's roles to run
// the delete build, prebuilds, dynamic parameter rendering) depend on the
// row being returned; the authentication path rejects the subject in
// httpmw.UserRBACSubject based on the Deleted column instead of a WHERE
// filter here.
func TestGetAuthorizationUserRolesDeletedUser(t *testing.T) {
t.Parallel()

db, _ := dbtestutil.NewDB(t)
user := dbgen.User(t, db, database.User{})

ctx := testutil.Context(t, testutil.WaitShort)

roles, err := db.GetAuthorizationUserRoles(ctx, user.ID)
require.NoError(t, err)
require.False(t, roles.Deleted)

err = db.UpdateUserDeletedByID(ctx, user.ID)
require.NoError(t, err)

roles, err = db.GetAuthorizationUserRoles(ctx, user.ID)
require.NoError(t, err, "deleted users must still resolve roles")
require.True(t, roles.Deleted)
require.Contains(t, roles.Roles, "member")
}

// TestGetAuthorizationUserRolesUnionsDefaultOrgMemberRoles verifies the
// resolve-at-read semantics for organizations.default_org_member_roles:
// every member's effective roles include the org's defaults, and changes
Expand Down
13 changes: 12 additions & 1 deletion coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion coderd/database/queries/users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,16 @@ SELECT
-- username and email are returned just to help for logging purposes
-- status is used to enforce 'suspended' users, as all roles are ignored
-- when suspended.
id, username, status, email,
-- deleted is returned so the authentication path can reject credentials
-- of soft-deleted users (see httpmw.UserRBACSubject). Deleted users are
-- intentionally NOT filtered out here: non-authentication consumers must
-- keep resolving roles for a soft-deleted owner. Known dependents:
-- coderd/provisionerdserver/provisionerdserver.go (role resolution for
-- builds, e.g. the delete build for a deleted owner's workspaces) and
-- coderd/dynamicparameters/render.go (owner context for rendering).
-- Do not add a WHERE deleted = false filter without migrating those
-- consumers first.
id, username, status, email, deleted,
-- All user roles, including their org roles.
array_cat(
-- All users are members
Expand Down
35 changes: 35 additions & 0 deletions coderd/exp_chats.go
Original file line number Diff line number Diff line change
Expand Up @@ -3553,6 +3553,14 @@ func (api *API) chatCreateWorkspace(
) (codersdk.Workspace, error) {
actor, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll)
if err != nil {
// Chats are not purged when their owner is soft-deleted, so a

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.

P3 [CRF-22] Three byte-identical twelve-line ErrUserDeleted prologues in chatCreateWorkspace, chatStartWorkspace, and chatStopWorkspace; extract one helper. (Bisky, Zoro P3)

The three functions differ only in return type: identical comment, UserRBACSubject call, errors.Is branch, 403 body, and ctx = dbauthz.As(ctx, actor). Triplicated fail-closed branches drift independently, and the next status or message change will touch three places and miss one. Extract a chatOwnerContext(ctx, ownerID) (context.Context, error) (the shape chattool/listtemplates.go asOwner already demonstrates), then each call site is four lines, and there is one place to test. TestChatStopWorkspace_BypassesRequireActiveVersion already drives one of them and can be copied for the helper test. The fourth model-override site (5214) is a different unit; keep it separate (see CRF-15).

🤖

// chat tool call can still act for a deleted owner. Surface a
// structured response instead of an opaque wrapped error.
if errors.Is(err, httpmw.ErrUserDeleted) {
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
Message: "Chat owner has been deleted.",
})
}
return codersdk.Workspace{}, xerrors.Errorf("load user authorization: %w", err)
}
ctx = dbauthz.As(ctx, actor)
Expand Down Expand Up @@ -3630,6 +3638,14 @@ func (api *API) chatStartWorkspace(
) (codersdk.WorkspaceBuild, error) {
actor, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll)
if err != nil {
// Chats are not purged when their owner is soft-deleted, so a
// chat tool call can still act for a deleted owner. Surface a
// structured response instead of an opaque wrapped error.
if errors.Is(err, httpmw.ErrUserDeleted) {
return codersdk.WorkspaceBuild{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
Message: "Chat owner has been deleted.",
})
}
return codersdk.WorkspaceBuild{}, xerrors.Errorf("load user authorization: %w", err)
}
ctx = dbauthz.As(ctx, actor)
Expand Down Expand Up @@ -3706,6 +3722,14 @@ func (api *API) chatStopWorkspace(
) (codersdk.WorkspaceBuild, error) {
actor, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll)
if err != nil {
// Chats are not purged when their owner is soft-deleted, so a
// chat tool call can still act for a deleted owner. Surface a
// structured response instead of an opaque wrapped error.
if errors.Is(err, httpmw.ErrUserDeleted) {
return codersdk.WorkspaceBuild{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
Message: "Chat owner has been deleted.",
})
}
return codersdk.WorkspaceBuild{}, xerrors.Errorf("load user authorization: %w", err)
}
ctx = dbauthz.As(ctx, actor)
Expand Down Expand Up @@ -5187,6 +5211,14 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http
if apiKey.UserID != member.UserID {
memberSubject, _, err := httpmw.UserRBACSubject(ctx, api.Database, member.UserID, rbac.ScopeAll)
if err != nil {
// A deleted member is a bad request target, not a server

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.

P3 [CRF-15] The model-override deleted-member guard only fires for mode=model and non-self callers, and the normal soft-delete path 500s in middleware before it; the response also uses 400 and discloses the deleted state, unlike the PR's sibling sites. (Knov, Mafuuu P3; Razor Note)

putUserChatPersonalModelOverride resolves member := httpmw.OrganizationMemberParam(r), then reaches UserRBACSubject only inside case ChatPersonalModelOverrideModeModel under if apiKey.UserID != member.UserID. Requests with mode=chat_default/deployment_default skip it. And in the normal path, delete_deleted_user_resources removes the user's organization_members rows, so ExtractOrganizationMember returns 0 rows and organizationparam.go 500s ("Expected exactly one organization member, but got 0", labeled "should never happen") before this branch runs. So the site is only reachable in the orphan state, and the guard is placed where a UserRBACSubject call happened to exist rather than where member is resolved. Check member.UserID's deleted flag once after it is resolved, and pick a status consistent with CRF-11 (404). The 400 message here also discloses the target's deleted state; userparam.go deliberately uses a constant message so no state about the queried user leaks (see CRF-11).

🤖

// error.
if errors.Is(err, httpmw.ErrUserDeleted) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot set a model override for a deleted user.",
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error validating model config override.",
Detail: err.Error(),
Expand Down Expand Up @@ -6403,6 +6435,9 @@ func (api *API) downloadChatFile(rw http.ResponseWriter, r *http.Request) {
return
}

// Fails closed for every error, including ErrUserDeleted: a signed
// download URL minted for a since-deleted user must stop working, and
// this unauthenticated endpoint intentionally leaks nothing beyond 404.
subject, status, err := httpmw.UserRBACSubject(ctx, api.Database, claims.UserID, rbac.ScopeAll)
if err != nil || status != database.UserStatusActive {
httpapi.ResourceNotFound(rw)
Expand Down
58 changes: 44 additions & 14 deletions coderd/httpmw/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ type ValidateAPIKeyResult struct {
UserStatus database.UserStatus
}

// ErrUserDeleted is returned by UserRBACSubject when the user exists but is
// soft-deleted. A soft-deleted user must never authorize as a subject, even
// when a credential row (for example an orphaned api_keys row) still
// references them.
var ErrUserDeleted = xerrors.New("user is deleted")

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.

P3 [CRF-20] Structural: ErrUserDeleted carries no HTTP disposition, so correctness depends on all fourteen callers hand-mapping it, with the pre-existing 500 as the default for anyone who forgets; and the policy lives in httpmw while service packages reach sideways into it. (Ryosuke, Meruem P3)

The fifteenth caller gets a 500 for a correctly-rejected credential, which is exactly what this PR set out to remove, and three of this round's findings (CRF-11, CRF-13, CRF-15) are instances of that default being wrong. The codebase already has the mechanism: declare the sentinel as an httperror.NewResponseError (identity is preserved, so errors.Is and all existing routing keep working; go list -deps confirms no import cycle from httpmw). Callers that already unwrap responders then get the right status for free, the three identical exp_chats blocks and the chatd/listtemplates blocks collapse, and only the two genuine per-route overrides (401 signed-out in apikey, the OAuth2 RFC mapping) stay explicit. Ryosuke's larger point: UserRBACSubject takes a store and a UUID and returns an rbac.Subject, nothing HTTP-shaped, so it belongs beside rbac/dbauthz with httpmw as a consumer. A structural alternative worth weighing before the routing calcifies across five packages.

🤖


// ValidateAPIKeyError represents a validation failure with enough
// context for downstream middlewares to decide how to respond.
type ValidateAPIKeyError struct {
Expand Down Expand Up @@ -234,9 +240,10 @@ func PrecheckAPIKey(cfg ValidateAPIKeyConfig) func(http.Handler) http.Handler {
// - Token extraction and parsing
// - Database lookup + secret hash validation
// - Expiry check
// - User role lookup (UserRBACSubject): rejects soft-deleted users
// before any write below
// - OIDC/OAuth token refresh (if applicable)
// - API key LastUsed / ExpiresAt DB updates
// - User role lookup (UserRBACSubject)
//
// It does NOT:
// - Write HTTP error responses
Expand Down Expand Up @@ -271,6 +278,36 @@ func ValidateAPIKey(ctx context.Context, cfg ValidateAPIKeyConfig, r *http.Reque
}
}

// Fetch user roles before any of the writes below: a soft-deleted user
// is a correctly rejected credential, not a server error. An api_keys
// row can outlive its user (a row that survived or was resurrected past
// delete_deleted_user_resources, a restored backup, an insert that
// bypassed trigger_insert_apikeys) and must be inert: rejecting before
// the OIDC/GitHub token refresh and the LastUsed/expiry writes keeps
// the stale token from calling the IdP with the deleted user's refresh
// token, rewriting user_links, bumping the deleted user's last_seen_at,
// or re-firing the cleanup trigger.
actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, key.ScopeSet())

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.

Note [CRF-25] Reject-on-read never removes the orphaned api_keys row, so the bad state is tolerated on every request instead of eliminated. (Meruem Note)

Nothing sweeps api_keys rows whose user is deleted; dbpurge deletes only expired keys, so an orphaned row with a live expires_at is presented and rejected on every request until it expires, each rejection costing a GetAuthorizationUserRoles round trip. DeleteAPIKeysByUserID already exists. A purge pass over api_keys joined to users.deleted would make the bad state stop existing rather than being permanently tolerated, and it does not depend on #28546. Worth considering as the other half of the same fix; not blocking this PR.

🤖

if err != nil {
if errors.Is(err, ErrUserDeleted) {
return nil, &ValidateAPIKeyError{
Code: http.StatusUnauthorized,
Response: codersdk.Response{
Message: SignedOutErrorMessage,
Detail: "API key belongs to a deleted user.",
},
}
}
return nil, &ValidateAPIKeyError{
Code: http.StatusInternalServerError,
Response: codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("Internal error fetching user's roles. %s", err.Error()),
},
Hard: true,
}
}

// Refresh OIDC/GitHub tokens if applicable.
if key.LoginType == database.LoginTypeGithub || key.LoginType == database.LoginTypeOIDC {
//nolint:gocritic // System needs to fetch UserLink to check if it's valid.
Expand Down Expand Up @@ -472,19 +509,6 @@ func ValidateAPIKey(ctx context.Context, cfg ValidateAPIKeyConfig, r *http.Reque
}
}

// Fetch user roles.
actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, key.ScopeSet())
if err != nil {
return nil, &ValidateAPIKeyError{
Code: http.StatusInternalServerError,
Response: codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("Internal error fetching user's roles. %s", err.Error()),
},
Hard: true,
}
}

return &ValidateAPIKeyResult{
Key: *key,
Subject: actor,
Expand Down Expand Up @@ -896,13 +920,19 @@ func extractExpectedAudience(accessURL *url.URL, r *http.Request) string {

// UserRBACSubject fetches a user's rbac.Subject from the database. It pulls all roles from both
// site and organization scopes. It also pulls the groups, and the user's status.
// It returns ErrUserDeleted for soft-deleted users: subjects are only built to
// act, and a deleted user may not act.
func UserRBACSubject(ctx context.Context, db database.Store, userID uuid.UUID, scope rbac.ExpandableScope) (rbac.Subject, database.UserStatus, error) {
Comment thread
ThomasK33 marked this conversation as resolved.
//nolint:gocritic // system needs to update user roles
roles, err := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), userID)
if err != nil {
return rbac.Subject{}, "", xerrors.Errorf("get authorization user roles: %w", err)
}

if roles.Deleted {
return rbac.Subject{}, "", ErrUserDeleted
}

roleNames, err := roles.RoleNames()
if err != nil {
return rbac.Subject{}, "", xerrors.Errorf("expand role names: %w", err)
Expand Down
Loading
Loading