-
Notifications
You must be signed in to change notification settings - Fork 1.5k
fix(coderd): reject API keys of soft-deleted users during authentication #28634
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
86055ea
9e697d5
a83f7f0
9676ea9
84f5783
be7531c
6732127
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3439,6 +3439,37 @@ func TestGetAuthorizationUserRolesImpliedOrgRole(t *testing.T) { | |
| require.NotContains(t, saRoles.Roles, wantMember) | ||
| } | ||
|
|
||
| // TestGetAuthorizationUserRolesDeletedUser pins the query's contract for | ||
|
ThomasK33 marked this conversation as resolved.
ThomasK33 marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)
|
||
| // 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 | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3 [CRF-22] Three byte-identical twelve-line The three functions differ only in return type: identical comment,
|
||
| // 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) | ||
|
|
@@ -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) | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3 [CRF-15] The model-override deleted-member guard only fires for
|
||
| // 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(), | ||
|
|
@@ -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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3 [CRF-20] Structural: 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
|
||
|
|
||
| // ValidateAPIKeyError represents a validation failure with enough | ||
| // context for downstream middlewares to decide how to respond. | ||
| type ValidateAPIKeyError struct { | ||
|
|
@@ -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 | ||
|
|
@@ -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()) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note [CRF-25] Reject-on-read never removes the orphaned Nothing sweeps
|
||
| 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. | ||
|
|
@@ -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, | ||
|
|
@@ -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) { | ||
|
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) | ||
|
|
||
There was a problem hiding this comment.
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
postAPIKeyhas no guard at all. (Knov P2, Chopper P2, Bisky P2, Leorio P2; +11 at P3)The
errors.Is(err, httpmw.ErrUserDeleted)check sits insideif createToken.Lifetime != 0, andvalidateAPIKeyLifetimeis the only thing on this path that callsUserRBACSubject. Withlifetimeomitted (the default), and forPOST /users/{user}/keys(no guard at all), control falls through tocreateAPIKey->InsertAPIKey, whereinsert_apikey_fail_if_user_deleted(dump.sql:1170) raises. Verified live by several reviewers:Reachable without any orphan race:
ExtractUserContextresolves{user}by UUID viaGetUserByID, which has nodeleted = falsefilter. 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 thedatabase.UserwithDeleted. Checkif user.Deletedat the top of bothpostTokenandpostAPIKey, next to the existinguser.IsSystemguard, and return one consistent status. Preferhttpapi.ResourceNotFoundto matchtokenConfigand to avoid disclosing the user's deleted state (see CRF-15). Then delete the nestedErrUserDeletedbranch.