From f189a241afad5de9efd8aed57ec966cca6dad36e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Wed, 24 Jun 2026 09:07:08 +0000 Subject: [PATCH 1/8] fix: report workspace owner's external auth state on create form When an admin created a workspace for another user, the external authentication section reflected the admin's own auth state instead of the selected owner's, because the endpoint and form keyed external auth by template version only. The external-auth endpoint now accepts an optional user_id. When it differs from the requester, it authorizes create-workspace-for-owner, reads the owner's link status under an elevated read-only context, and omits the authenticate URL. The create form threads the selected owner through the query and shows a read-only status for other users instead of a login button that would authenticate the admin. --- coderd/apidoc/docs.go | 7 ++ coderd/apidoc/swagger.json | 7 ++ coderd/templateversions.go | 88 +++++++++++++---- coderd/templateversions_test.go | 97 +++++++++++++++++++ codersdk/templateversions.go | 4 +- docs/reference/api/templates.md | 7 +- site/src/api/api.ts | 8 +- site/src/api/queries/templates.ts | 12 ++- site/src/hooks/useExternalAuth.ts | 7 +- .../CreateWorkspacePage.tsx | 2 +- .../CreateWorkspacePageView.stories.tsx | 46 ++++++++- .../CreateWorkspacePageView.tsx | 13 +++ .../ExternalAuthButton.stories.tsx | 32 ++++++ .../ExternalAuthButton.tsx | 75 ++++++++------ 14 files changed, 344 insertions(+), 61 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b9ac6429025b0..83fd146438a1e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8672,6 +8672,13 @@ const docTemplate = `{ "name": "templateversion", "in": "path", "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Owner to report external auth state for. Defaults to the requesting user.", + "name": "user_id", + "in": "query" } ], "responses": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 73ed128a325e1..47b078c8fffe1 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7694,6 +7694,13 @@ "name": "templateversion", "in": "path", "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Owner to report external auth state for. Defaults to the requesting user.", + "name": "user_id", + "in": "query" } ], "responses": { diff --git a/coderd/templateversions.go b/coderd/templateversions.go index ef7f6e0899693..00ea022f08f59 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -328,6 +328,7 @@ func (api *API) templateVersionRichParameters(rw http.ResponseWriter, r *http.Re // @Produce json // @Tags Templates // @Param templateversion path string true "Template version ID" format(uuid) +// @Param user_id query string false "Owner to report external auth state for. Defaults to the requesting user." format(uuid) // @Success 200 {array} codersdk.TemplateVersionExternalAuth // @Router /api/v2/templateversions/{templateversion}/external-auth [get] func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Request) { @@ -337,6 +338,43 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ templateVersion = httpmw.TemplateVersionParam(r) ) + // The external auth state is reported for the workspace owner. By default + // this is the requesting user, but an admin creating a workspace for someone + // else passes that user's ID so the form reflects the owner's auth state + // instead of the admin's. + ownerID := apiKey.UserID + if q := r.URL.Query().Get("user_id"); q != "" && q != codersdk.Me { + uid, err := uuid.Parse(q) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid user_id query parameter.", + Detail: err.Error(), + }) + return + } + ownerID = uid + } + self := ownerID == apiKey.UserID + + // readCtx is used to look up the owner's external auth links. For the + // requesting user this is the request context. When reporting another + // user's state the requester must be allowed to create a workspace on that + // owner's behalf, mirroring the workspace creation authorization. After that + // check the links are read with an elevated context so the requester does + // not also need personal read access to the owner. + readCtx := ctx + if !self { + if !api.Authorize(r, policy.ActionCreate, + rbac.ResourceWorkspace.InOrg(templateVersion.OrganizationID).WithOwner(ownerID.String())) { + httpapi.Forbidden(rw) + return + } + // The requester was authorized to create a workspace for this owner + // above. Read the owner's external auth link status (not tokens) with an + // elevated context so admins without personal read access still work. + readCtx = dbauthz.AsSystemRestricted(ctx) //nolint:gocritic // Authorized as create-workspace-for-owner above; reads only link status. + } + var rawProviders []database.ExternalAuthProvider err := json.Unmarshal(templateVersion.ExternalAuthProviders, &rawProviders) if err != nil { @@ -364,28 +402,34 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ return } - // This is the URL that will redirect the user with a state token. - redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/external-auth/%s", config.ID)) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to parse access URL.", - Detail: err.Error(), - }) - return + provider := codersdk.TemplateVersionExternalAuth{ + ID: config.ID, + Type: config.Type, + DisplayName: config.DisplayName, + DisplayIcon: config.DisplayIcon, + Optional: rawProvider.Optional, } - provider := codersdk.TemplateVersionExternalAuth{ - ID: config.ID, - Type: config.Type, - AuthenticateURL: redirectURL.String(), - DisplayName: config.DisplayName, - DisplayIcon: config.DisplayIcon, - Optional: rawProvider.Optional, + // Only the requesting user can complete an authentication flow from the + // form. Logging in always authenticates the current session, so the URL + // is omitted when reporting another user's state to avoid offering an + // action that would authenticate the admin rather than the owner. + if self { + // This is the URL that will redirect the user with a state token. + redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/external-auth/%s", config.ID)) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to parse access URL.", + Detail: err.Error(), + }) + return + } + provider.AuthenticateURL = redirectURL.String() } - authLink, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{ + authLink, err := api.Database.GetExternalAuthLink(readCtx, database.GetExternalAuthLinkParams{ ProviderID: config.ID, - UserID: apiKey.UserID, + UserID: ownerID, }) // If there isn't an auth link, then the user just isn't authenticated. if errors.Is(err, sql.ErrNoRows) { @@ -400,6 +444,16 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ return } + if !self { + // Refreshing mutates the owner's token, so we do not do it from this + // read-only status check. The presence of a link means the owner has + // connected the provider. The workspace build refreshes the token at + // provision time and skips it if it is no longer valid. + provider.Authenticated = true + providers = append(providers, provider) + continue + } + _, err = config.RefreshToken(ctx, api.Database, authLink) if err != nil && !externalauth.IsInvalidTokenError(err) { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index c3d2153f3421e..18692bd905b1e 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -1045,6 +1045,103 @@ func TestTemplateVersionsExternalAuth(t *testing.T) { require.True(t, providers[0].Authenticated) require.True(t, providers[0].Optional) }) + t.Run("ForAnotherUser", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + }}, + }) + owner := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionGraph: []*proto.Response{{ + Type: &proto.Response_Graph{ + Graph: &proto.GraphComplete{ + ExternalAuthProviders: []*proto.ExternalAuthProviderResource{{Id: "github"}}, + }, + }, + }}, + }) + version = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + require.Empty(t, version.Job.Error) + // Publish a template so the org admin can read the version. + _ = coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // The requester is an org admin who can create workspaces for other users + // but does not have personal read access to them. The target user + // authenticates with the provider. + adminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, + rbac.ScopedRoleOrgAdmin(owner.OrganizationID)) + memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + resp := coderdtest.RequestExternalAuthCallback(t, "github", memberClient) + _ = resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + // The requesting admin has not authenticated, so their own state is + // unauthenticated. + self, err := adminClient.TemplateVersionExternalAuth(ctx, version.ID) + require.NoError(t, err) + require.Len(t, self, 1) + require.False(t, self[0].Authenticated) + + // Reporting the target user's state shows them as authenticated, and the + // authenticate URL is omitted because the admin cannot authenticate on + // their behalf. + forOwner, err := adminClient.TemplateVersionExternalAuth(ctx, version.ID, + codersdk.WithQueryParam("user_id", member.ID.String())) + require.NoError(t, err) + require.Len(t, forOwner, 1) + require.True(t, forOwner[0].Authenticated) + require.Empty(t, forOwner[0].AuthenticateURL) + }) + t.Run("ForAnotherUserUnauthorized", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + }}, + }) + owner := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionGraph: []*proto.Response{{ + Type: &proto.Response_Graph{ + Graph: &proto.GraphComplete{ + ExternalAuthProviders: []*proto.ExternalAuthProviderResource{{Id: "github"}}, + }, + }, + }}, + }) + version = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + require.Empty(t, version.Job.Error) + // Publish a template so org members can read the version. + _ = coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + requesterClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + _, target := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // A plain member cannot view another user's external auth state, because + // they cannot create a workspace on that user's behalf. + _, err := requesterClient.TemplateVersionExternalAuth(ctx, version.ID, + codersdk.WithQueryParam("user_id", target.ID.String())) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + }) } func TestTemplateVersionResources(t *testing.T) { diff --git a/codersdk/templateversions.go b/codersdk/templateversions.go index 01cd23370746f..88cf544ec3c75 100644 --- a/codersdk/templateversions.go +++ b/codersdk/templateversions.go @@ -144,8 +144,8 @@ func (c *Client) TemplateVersionRichParameters(ctx context.Context, version uuid } // TemplateVersionExternalAuth returns authentication providers for the requested template version. -func (c *Client) TemplateVersionExternalAuth(ctx context.Context, version uuid.UUID) ([]TemplateVersionExternalAuth, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/external-auth", version), nil) +func (c *Client) TemplateVersionExternalAuth(ctx context.Context, version uuid.UUID, opts ...RequestOption) ([]TemplateVersionExternalAuth, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/external-auth", version), nil, opts...) if err != nil { return nil, err } diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index e5c8b6e665f45..80a0cc8b4f7ed 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -2839,9 +2839,10 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/e ### Parameters -| Name | In | Type | Required | Description | -|-------------------|------|--------------|----------|---------------------| -| `templateversion` | path | string(uuid) | true | Template version ID | +| Name | In | Type | Required | Description | +|-------------------|-------|--------------|----------|---------------------------------------------------------------------------| +| `templateversion` | path | string(uuid) | true | Template version ID | +| `user_id` | query | string(uuid) | false | Owner to report external auth state for. Defaults to the requesting user. | ### Example responses diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ed8e78e1dde7d..295a470a60857 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1141,9 +1141,15 @@ class ApiMethods { getTemplateVersionExternalAuth = async ( versionId: string, + userId?: string, ): Promise => { + const params = new URLSearchParams(); + if (userId) { + params.set("user_id", userId); + } + const query = params.toString(); const response = await this.axios.get( - `/api/v2/templateversions/${versionId}/external-auth`, + `/api/v2/templateversions/${versionId}/external-auth${query ? `?${query}` : ""}`, ); return response.data; diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 9d1f6740f80de..9478b647bbec0 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -206,16 +206,20 @@ export const templaceACLAvailable = ( }; }; -const templateVersionExternalAuthKey = (versionId: string) => [ +const templateVersionExternalAuthKey = (versionId: string, userId?: string) => [ templateVersionRoot, versionId, + userId ?? "me", "externalAuth", ]; -export const templateVersionExternalAuth = (versionId: string) => { +export const templateVersionExternalAuth = ( + versionId: string, + userId?: string, +) => { return { - queryKey: templateVersionExternalAuthKey(versionId), - queryFn: () => API.getTemplateVersionExternalAuth(versionId), + queryKey: templateVersionExternalAuthKey(versionId, userId), + queryFn: () => API.getTemplateVersionExternalAuth(versionId, userId), }; }; diff --git a/site/src/hooks/useExternalAuth.ts b/site/src/hooks/useExternalAuth.ts index 81dcae5de063a..1db14fcbd038c 100644 --- a/site/src/hooks/useExternalAuth.ts +++ b/site/src/hooks/useExternalAuth.ts @@ -4,7 +4,10 @@ import { templateVersionExternalAuth } from "#/api/queries/templates"; export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; -export const useExternalAuth = (versionId: string | undefined) => { +export const useExternalAuth = ( + versionId: string | undefined, + userId?: string, +) => { const [pollingState, setPollingState] = useState< Record >({}); @@ -20,7 +23,7 @@ export const useExternalAuth = (versionId: string | undefined) => { isPending: isLoadingExternalAuth, error, } = useQuery({ - ...templateVersionExternalAuth(versionId ?? ""), + ...templateVersionExternalAuth(versionId ?? "", userId), enabled: Boolean(versionId), refetchInterval: isAnyPolling ? 1000 : false, }); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 9873339cd030d..0a94c714a8922 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -253,7 +253,7 @@ const CreateWorkspacePage: FC = () => { externalAuthPollingState, startPollingExternalAuth, isLoadingExternalAuth, - } = useExternalAuth(realizedVersionId); + } = useExternalAuth(realizedVersionId, owner.id); const isLoadingFormData = ws.current?.readyState === WebSocket.CONNECTING || diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index 4a2a68a1d49f6..fb4b889ce7d8a 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -3,7 +3,11 @@ import { expect, screen, within } from "storybook/test"; import { DetailedError } from "#/api/errors"; import type { Preset, PreviewParameter } from "#/api/typesGenerated"; import { chromatic } from "#/testHelpers/chromatic"; -import { MockTemplate, MockUserOwner } from "#/testHelpers/entities"; +import { + MockTemplate, + MockUserMember, + MockUserOwner, +} from "#/testHelpers/entities"; import { CreateWorkspacePageView } from "./CreateWorkspacePageView"; const meta: Meta = { @@ -15,6 +19,8 @@ const meta: Meta = { diagnostics: [], defaultName: "", defaultOwner: MockUserOwner, + owner: MockUserOwner, + setOwner: () => {}, externalAuth: [], externalAuthPollingState: {}, hasAllRequiredExternalAuth: true, @@ -456,3 +462,41 @@ export const WithUrlPresetOverridesDefault: Story = { ).toBeInTheDocument(); }, }; + +// When an admin creates a workspace for another user, the external auth section +// reflects that owner's state. The requester cannot authenticate on their +// behalf, so the login buttons are replaced with a read-only status. +export const ExternalAuthForAnotherUser: Story = { + args: { + owner: MockUserMember, + hasAllRequiredExternalAuth: false, + externalAuth: [ + { + id: "github", + type: "github", + display_name: "GitHub", + display_icon: "/icon/github.svg", + authenticate_url: "", + authenticated: true, + }, + { + id: "gitlab", + type: "gitlab", + display_name: "GitLab", + display_icon: "/icon/gitlab.svg", + authenticate_url: "", + authenticated: false, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByText(/must connect any required providers themselves/i), + ).toBeInTheDocument(); + expect(canvas.getByText("Not connected")).toBeInTheDocument(); + expect( + canvas.queryByRole("button", { name: /login with/i }), + ).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index db58d22d68e6e..d0463ae386526 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -392,6 +392,11 @@ export const CreateWorkspacePageView: FC = ({ ), ); + // External auth is connected to the workspace owner. When creating a + // workspace for another user, the form reflects that owner's auth state and + // the requester cannot authenticate on their behalf. + const isCreatingForSelf = owner.id === defaultOwner.id; + return ( <>
@@ -583,11 +588,19 @@ export const CreateWorkspacePageView: FC = ({ all required external authentication providers listed below. )} + {!isCreatingForSelf && ( + + This shows the external authentication state for{" "} + {owner.username}. They must connect any required providers + themselves; you can't authenticate on their behalf. + + )} {externalAuth.map((auth) => ( startPollingExternalAuth(auth.id)} displayRetry={ diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.stories.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.stories.tsx index acce4aa74305e..61d103b33394c 100644 --- a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; import type { TemplateVersionExternalAuth } from "#/api/typesGenerated"; import { ExternalAuthButton } from "./ExternalAuthButton"; @@ -118,3 +119,34 @@ export const BitbucketAuthenticated: Story = { }, }, }; + +// When an admin creates a workspace for another user, the requester cannot +// authenticate on the owner's behalf, so the login action is hidden and the +// unconnected state is read-only. +export const ForAnotherUserNotConnected: Story = { + args: { + auth: MockExternalAuth, + canAuthenticate: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Not connected")).toBeInTheDocument(); + expect( + canvas.queryByRole("button", { name: /login with github/i }), + ).not.toBeInTheDocument(); + }, +}; + +export const ForAnotherUserAuthenticated: Story = { + args: { + auth: { + ...MockExternalAuth, + authenticated: true, + }, + canAuthenticate: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Authenticated")).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx index f9eb2cf131115..f98648b30d956 100644 --- a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx +++ b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx @@ -17,6 +17,10 @@ interface ExternalAuthButtonProps { isLoading: boolean; onStartPolling: () => void; error?: unknown; + // canAuthenticate is false when an admin is creating a workspace for another + // user. The login flow authenticates the current session, so it cannot + // connect a provider on the owner's behalf and is hidden in that case. + canAuthenticate?: boolean; } export const ExternalAuthButton: FC = ({ @@ -25,6 +29,7 @@ export const ExternalAuthButton: FC = ({ isLoading, onStartPolling, error, + canAuthenticate = true, }) => { return (
@@ -52,37 +57,47 @@ export const ExternalAuthButton: FC = ({ Authenticated

- ) : ( - - )} + ) : canAuthenticate ? ( + <> + - {displayRetry && !auth.authenticated && ( - - - - - - Retry login with {auth.display_name} - - + {displayRetry && ( + + + + + + Retry login with {auth.display_name} + + + )} + + ) : ( +

+ Not connected +

)}
From a0ed7323a189f70fddbcb5909ea8f0bb5d12e47f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Sat, 27 Jun 2026 00:28:45 +0000 Subject: [PATCH 2/8] refactor(coderd): scope external auth checks to a dedicated rbac subject Replace AsSystemRestricted with a new AsExternalAuthChecker actor when reporting or validating a workspace owner's external auth state. The new subject can only read and refresh a user's external auth links (ResourceUser personal read/update) instead of granting read on every resource plus broad writes. Applied to both call sites of templateVersionExternalAuthForUser: the create-workspace form endpoint and the create-time owner check. --- coderd/database/dbauthz/dbauthz.go | 33 ++++++++++++++++++ coderd/database/dbauthz/dbauthz_test.go | 45 +++++++++++++++++++++++++ coderd/rbac/authz.go | 1 + coderd/templateversions.go | 4 +-- coderd/workspaces.go | 4 +-- 5 files changed, 83 insertions(+), 4 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d6e5a27e77f7b..6f54f4fbaf7c8 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -793,6 +793,31 @@ var ( }), Scope: rbac.ScopeAll, }.WithCachedASTValue() + + // subjectExternalAuthChecker reports whether a user has a usable external + // auth link for a template's required providers. It can read and refresh a + // user's external auth links (personal user data) and nothing else, so it + // can validate a workspace owner's external auth without granting broad + // system access. + subjectExternalAuthChecker = rbac.Subject{ + Type: rbac.SubjectTypeExternalAuthChecker, + FriendlyName: "External Auth Checker", + ID: uuid.Nil.String(), + Roles: rbac.Roles([]rbac.Role{ + { + Identifier: rbac.RoleIdentifier{Name: "external-auth-checker"}, + DisplayName: "External Auth Checker", + Site: rbac.Permissions(map[string][]policy.Action{ + // ReadPersonal fetches the link; UpdatePersonal lets + // RefreshToken persist a refreshed token. + rbac.ResourceUser.Type: {policy.ActionReadPersonal, policy.ActionUpdatePersonal}, + }), + User: []rbac.Permission{}, + ByOrgID: map[string]rbac.OrgPermissions{}, + }, + }), + Scope: rbac.ScopeAll, + }.WithCachedASTValue() ) // AsProvisionerd returns a context with an actor that has permissions required @@ -929,6 +954,14 @@ func AsSCIMProvisioner(ctx context.Context) context.Context { return As(ctx, subjectSCIM) } +// AsExternalAuthChecker returns a context with an actor that can read and +// refresh a user's external auth links, and nothing else. It is used to report +// a workspace owner's external auth state to an authorized requester without +// granting broad system access. +func AsExternalAuthChecker(ctx context.Context) context.Context { + return As(ctx, subjectExternalAuthChecker) +} + var AsRemoveActor = rbac.Subject{ ID: "remove-actor", } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 865d075f0be16..1c6992e1c6ed6 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -7323,3 +7323,48 @@ func TestAsChatd(t *testing.T) { require.Error(t, err, "provisioner daemon read should be denied") }) } + +func TestAsExternalAuthChecker(t *testing.T) { + t.Parallel() + + ctx := dbauthz.AsExternalAuthChecker(context.Background()) + actor, ok := dbauthz.ActorFromContext(ctx) + require.True(t, ok, "actor must be present") + + auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()) + + t.Run("AllowedActions", func(t *testing.T) { + t.Parallel() + + // Reading and refreshing a user's external auth link requires personal + // read and update on the user resource. + for _, action := range []policy.Action{ + policy.ActionReadPersonal, policy.ActionUpdatePersonal, + } { + err := auth.Authorize(ctx, actor, action, rbac.ResourceUser) + require.NoError(t, err, "user %s should be allowed", action) + } + }) + + t.Run("DeniedActions", func(t *testing.T) { + t.Parallel() + + // No general user read/write, only personal external auth access. + for _, action := range []policy.Action{ + policy.ActionRead, policy.ActionCreate, + policy.ActionUpdate, policy.ActionDelete, + } { + err := auth.Authorize(ctx, actor, action, rbac.ResourceUser) + require.Error(t, err, "user %s should be denied", action) + } + + // Unlike AsSystemRestricted, this actor cannot read other resources. + for _, res := range []rbac.Object{ + rbac.ResourceWorkspace, rbac.ResourceTemplate, + rbac.ResourceApiKey, rbac.ResourceOrganization, + } { + err := auth.Authorize(ctx, actor, policy.ActionRead, res) + require.Error(t, err, "%s read should be denied", res.Type) + } + }) +} diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 4b253bc10d262..344b008ba0b46 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -86,6 +86,7 @@ const ( SubjectTypeChatd SubjectType = "chatd" SubjectTypeAIProviderMetadataReader SubjectType = "ai_provider_metadata_reader" SubjectTypeSCIMProvisioner SubjectType = "scim_provisioner" + SubjectTypeExternalAuthChecker SubjectType = "external_auth_checker" ) const ( diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 60519a18476af..51c4954fa0a3b 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -368,8 +368,8 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ httpapi.Forbidden(rw) return } - //nolint:gocritic // Authorized as create-workspace-for-owner above; reads the owner's external auth link status. - readCtx = dbauthz.AsSystemRestricted(ctx) + //nolint:gocritic // Authorized as create-workspace-for-owner above; the checker only reads/refreshes the owner's external auth links. + readCtx = dbauthz.AsExternalAuthChecker(ctx) } providers, err := api.templateVersionExternalAuthForUser(readCtx, templateVersion, ownerID) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 9d429ccbf5e4c..782dd6ba45b28 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -920,8 +920,8 @@ func createWorkspace( // at build time uses the owner's external auth links, so the owner is the // subject of the check even when another user initiates the build. func (api *API) requireWorkspaceOwnerExternalAuth(ctx context.Context, templateVersion database.TemplateVersion, ownerID uuid.UUID) error { - //nolint:gocritic // System access is required to validate the workspace owner's external auth links because admins and API clients may create workspaces for other users. - providers, err := api.templateVersionExternalAuthForUser(dbauthz.AsSystemRestricted(ctx), templateVersion, ownerID) + //nolint:gocritic // The checker only reads/refreshes the workspace owner's external auth links, because admins and API clients may create workspaces for other users. + providers, err := api.templateVersionExternalAuthForUser(dbauthz.AsExternalAuthChecker(ctx), templateVersion, ownerID) if err != nil { return err } From 3bba52ab9b425409e860d21d399ffa95989ec27e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Sat, 27 Jun 2026 00:53:01 +0000 Subject: [PATCH 3/8] refactor(site): default external auth user to "me" and require the hook arg Give getTemplateVersionExternalAuth and the templateVersionExternalAuth query/key a userId = "me" default instead of an optional param, and make useExternalAuth's userId required so callers must say whose external auth they want. Update TaskPrompt to pass "me" explicitly. --- site/src/api/api.ts | 9 ++------- site/src/api/queries/templates.ts | 6 +++--- site/src/hooks/useExternalAuth.ts | 2 +- site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx | 4 ++-- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 9ade56dd8abc4..6f95b624d69c2 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1157,15 +1157,10 @@ class ApiMethods { getTemplateVersionExternalAuth = async ( versionId: string, - userId?: string, + userId = "me", ): Promise => { - const params = new URLSearchParams(); - if (userId) { - params.set("user_id", userId); - } - const query = params.toString(); const response = await this.axios.get( - `/api/v2/templateversions/${versionId}/external-auth${query ? `?${query}` : ""}`, + `/api/v2/templateversions/${versionId}/external-auth?user_id=${userId}`, ); return response.data; diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 9478b647bbec0..2a01eff1a4589 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -206,16 +206,16 @@ export const templaceACLAvailable = ( }; }; -const templateVersionExternalAuthKey = (versionId: string, userId?: string) => [ +const templateVersionExternalAuthKey = (versionId: string, userId = "me") => [ templateVersionRoot, versionId, - userId ?? "me", + userId, "externalAuth", ]; export const templateVersionExternalAuth = ( versionId: string, - userId?: string, + userId = "me", ) => { return { queryKey: templateVersionExternalAuthKey(versionId, userId), diff --git a/site/src/hooks/useExternalAuth.ts b/site/src/hooks/useExternalAuth.ts index 1db14fcbd038c..3604db9a902d3 100644 --- a/site/src/hooks/useExternalAuth.ts +++ b/site/src/hooks/useExternalAuth.ts @@ -6,7 +6,7 @@ export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; export const useExternalAuth = ( versionId: string | undefined, - userId?: string, + userId: string, ) => { const [pollingState, setPollingState] = useState< Record diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx index 21481e6c2d48d..e6ead5046be62 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx @@ -179,7 +179,7 @@ const CreateTaskForm: FC = ({ templates, onSuccess }) => { externalAuthError, isPollingExternalAuth, isLoadingExternalAuth, - } = useExternalAuth(selectedVersionId); + } = useExternalAuth(selectedVersionId, "me"); const missedExternalAuth = externalAuth?.filter( (auth) => !auth.optional && !auth.authenticated, ); @@ -437,7 +437,7 @@ const ExternalAuthButtons: FC = ({ missedExternalAuth, }) => { const { startPollingExternalAuth, externalAuthPollingState } = - useExternalAuth(versionId); + useExternalAuth(versionId, "me"); return missedExternalAuth.map((auth) => { const isPollingExternalAuth = From 772747f9a2ae808fb6a7c936ee27e750dfbe5ca3 Mon Sep 17 00:00:00 2001 From: McKayla Washburn-Love Date: Thu, 23 Jul 2026 21:25:04 +0000 Subject: [PATCH 4/8] Update dbauthz.go --- coderd/database/dbauthz/dbauthz.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index e750d659c7e70..19732537ca356 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -838,11 +838,9 @@ var ( Scope: rbac.ScopeAll, }.WithCachedASTValue() - // subjectExternalAuthChecker reports whether a user has a usable external - // auth link for a template's required providers. It can read and refresh a - // user's external auth links (personal user data) and nothing else, so it - // can validate a workspace owner's external auth without granting broad - // system access. + // subjectExternalAuthChecker is used to check whether a user has configured + // external auth providers or not when an admin is creating a workspace for + // another user. subjectExternalAuthChecker = rbac.Subject{ Type: rbac.SubjectTypeExternalAuthChecker, FriendlyName: "External Auth Checker", @@ -852,8 +850,7 @@ var ( Identifier: rbac.RoleIdentifier{Name: "external-auth-checker"}, DisplayName: "External Auth Checker", Site: rbac.Permissions(map[string][]policy.Action{ - // ReadPersonal fetches the link; UpdatePersonal lets - // RefreshToken persist a refreshed token. + // policy.ActionUpdatePersonal allows us to refresh tokens. rbac.ResourceUser.Type: {policy.ActionReadPersonal, policy.ActionUpdatePersonal}, }), User: []rbac.Permission{}, From 56c1b31b1a561afe15092807d86bb5a1b3d7dffa Mon Sep 17 00:00:00 2001 From: McKayla Washburn-Love Date: Thu, 23 Jul 2026 21:28:14 +0000 Subject: [PATCH 5/8] Update dbauthz.go --- coderd/database/dbauthz/dbauthz.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 19732537ca356..c4bf11bfe9712 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1007,10 +1007,8 @@ func AsSCIMProvisioner(ctx context.Context) context.Context { return As(ctx, subjectSCIM) } -// AsExternalAuthChecker returns a context with an actor that can read and -// refresh a user's external auth links, and nothing else. It is used to report -// a workspace owner's external auth state to an authorized requester without -// granting broad system access. +// AsExternalAuthChecker returns a context with an actor that has permission to +// read and refresh any user's external auth links. func AsExternalAuthChecker(ctx context.Context) context.Context { return As(ctx, subjectExternalAuthChecker) } From 40b87282cad8526375975231871e9874b29e0347 Mon Sep 17 00:00:00 2001 From: McKayla Washburn-Love Date: Thu, 23 Jul 2026 21:45:13 +0000 Subject: [PATCH 6/8] yet another comment tweak --- coderd/database/dbauthz/dbauthz.go | 14 +++++++------- coderd/database/dbauthz/dbauthz_test.go | 2 +- coderd/rbac/authz.go | 2 +- coderd/templateversions.go | 25 +++++++++---------------- coderd/workspaces.go | 4 ++-- 5 files changed, 20 insertions(+), 27 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index c4bf11bfe9712..ba49283512391 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -838,12 +838,12 @@ var ( Scope: rbac.ScopeAll, }.WithCachedASTValue() - // subjectExternalAuthChecker is used to check whether a user has configured + // subjectExternalAuthCoordinator is used to check whether a user has configured // external auth providers or not when an admin is creating a workspace for // another user. - subjectExternalAuthChecker = rbac.Subject{ - Type: rbac.SubjectTypeExternalAuthChecker, - FriendlyName: "External Auth Checker", + subjectExternalAuthCoordinator = rbac.Subject{ + Type: rbac.SubjectTypeExternalAuthCoordinator, + FriendlyName: "External Auth Coordinator", ID: uuid.Nil.String(), Roles: rbac.Roles([]rbac.Role{ { @@ -1007,10 +1007,10 @@ func AsSCIMProvisioner(ctx context.Context) context.Context { return As(ctx, subjectSCIM) } -// AsExternalAuthChecker returns a context with an actor that has permission to +// AsExternalAuthCoordinator returns a context with an actor that has permission to // read and refresh any user's external auth links. -func AsExternalAuthChecker(ctx context.Context) context.Context { - return As(ctx, subjectExternalAuthChecker) +func AsExternalAuthCoordinator(ctx context.Context) context.Context { + return As(ctx, subjectExternalAuthCoordinator) } var AsRemoveActor = rbac.Subject{ diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 044c73f79f4f8..d06e4e5c5ee87 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -7637,7 +7637,7 @@ func TestAsChatd(t *testing.T) { func TestAsExternalAuthChecker(t *testing.T) { t.Parallel() - ctx := dbauthz.AsExternalAuthChecker(context.Background()) + ctx := dbauthz.AsExternalAuthCoordinator(context.Background()) actor, ok := dbauthz.ActorFromContext(ctx) require.True(t, ok, "actor must be present") diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 05f1e495c7580..c1b94300a9097 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -88,7 +88,7 @@ const ( SubjectTypeChatd SubjectType = "chatd" SubjectTypeAIProviderMetadataReader SubjectType = "ai_provider_metadata_reader" SubjectTypeSCIMProvisioner SubjectType = "scim_provisioner" - SubjectTypeExternalAuthChecker SubjectType = "external_auth_checker" + SubjectTypeExternalAuthCoordinator SubjectType = "external_auth_coordinator" ) const ( diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 51c4954fa0a3b..d680260e5992e 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -339,13 +339,10 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ templateVersion = httpmw.TemplateVersionParam(r) ) - // The external auth state is reported for the workspace owner. By default - // this is the requesting user, but an admin creating a workspace for someone - // else passes that user's ID so the form reflects the owner's auth state - // instead of the admin's. ownerID := apiKey.UserID + externalAuthCtx := ctx if q := r.URL.Query().Get("user_id"); q != "" && q != codersdk.Me { - uid, err := uuid.Parse(q) + id, err := uuid.Parse(q) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid user_id query parameter.", @@ -353,26 +350,22 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ }) return } - ownerID = uid - } + ownerID = id - // readCtx looks up the owner's external auth links. For the requesting user - // this is the request context. When reporting another user's state the - // requester must be allowed to create a workspace on that owner's behalf, - // mirroring workspace creation. The links are then read with an elevated - // context so the requester does not also need personal read access. - readCtx := ctx - if ownerID != apiKey.UserID { + // Verify that the user has permission to create a workspace on behalf of + // the proposed workspace owner. If so, use a system actor to perform later + // checks that the user is unlikely to have the other required permissions + // for. if !api.Authorize(r, policy.ActionCreate, rbac.ResourceWorkspace.InOrg(templateVersion.OrganizationID).WithOwner(ownerID.String())) { httpapi.Forbidden(rw) return } //nolint:gocritic // Authorized as create-workspace-for-owner above; the checker only reads/refreshes the owner's external auth links. - readCtx = dbauthz.AsExternalAuthChecker(ctx) + externalAuthCtx = dbauthz.AsExternalAuthCoordinator(ctx) } - providers, err := api.templateVersionExternalAuthForUser(readCtx, templateVersion, ownerID) + providers, err := api.templateVersionExternalAuthForUser(externalAuthCtx, templateVersion, ownerID) if err != nil { httperror.WriteResponseError(ctx, rw, err) return diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 3f409d122d54c..eb89dc723cf29 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -873,8 +873,8 @@ func createWorkspace( // at build time uses the owner's external auth links, so the owner is the // subject of the check even when another user initiates the build. func (api *API) requireWorkspaceOwnerExternalAuth(ctx context.Context, templateVersion database.TemplateVersion, ownerID uuid.UUID) error { - //nolint:gocritic // The checker only reads/refreshes the workspace owner's external auth links, because admins and API clients may create workspaces for other users. - providers, err := api.templateVersionExternalAuthForUser(dbauthz.AsExternalAuthChecker(ctx), templateVersion, ownerID) + //nolint:gocritic // Reads/refreshes the external auth links. Necessary when admins create workspaces for other users. + providers, err := api.templateVersionExternalAuthForUser(dbauthz.AsExternalAuthCoordinator(ctx), templateVersion, ownerID) if err != nil { return err } From de1a4d2f5ebf6fa449846ccc66108d831e55feb9 Mon Sep 17 00:00:00 2001 From: McKayla Washburn-Love Date: Thu, 23 Jul 2026 21:56:16 +0000 Subject: [PATCH 7/8] last one I hope --- coderd/database/dbauthz/dbauthz.go | 4 ++-- site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index ba49283512391..b35529b0286d8 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -847,8 +847,8 @@ var ( ID: uuid.Nil.String(), Roles: rbac.Roles([]rbac.Role{ { - Identifier: rbac.RoleIdentifier{Name: "external-auth-checker"}, - DisplayName: "External Auth Checker", + Identifier: rbac.RoleIdentifier{Name: "external-auth-coordinator"}, + DisplayName: "External Auth Coordinator", Site: rbac.Permissions(map[string][]policy.Action{ // policy.ActionUpdatePersonal allows us to refresh tokens. rbac.ResourceUser.Type: {policy.ActionReadPersonal, policy.ActionUpdatePersonal}, diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx index 0def56071ba03..6057108106ee8 100644 --- a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx +++ b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx @@ -17,9 +17,10 @@ interface ExternalAuthButtonProps { isLoading: boolean; onStartPolling: () => void; error?: unknown; - // canAuthenticate is false when an admin is creating a workspace for another - // user. The login flow authenticates the current session, so it cannot - // connect a provider on the owner's behalf and is hidden in that case. + /** + * Users can only connect external auth for themselves. An admin creating a + * workspace for someone else should just be shown the status. + */ canAuthenticate?: boolean; } From cec96d751d776e114b0f50088e9c08a182d25ba4 Mon Sep 17 00:00:00 2001 From: McKayla Washburn-Love Date: Thu, 23 Jul 2026 22:30:20 +0000 Subject: [PATCH 8/8] is this all? --- coderd/templateversions_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index 08dbdaa4b5b46..ec631d36d1a23 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -1056,6 +1056,8 @@ func TestTemplateVersionsExternalAuth(t *testing.T) { ID: "github", Regex: regexp.MustCompile(`github\.com`), Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + RefreshGroup: new(singleflight.Group), }}, }) owner := coderdtest.CreateFirstUser(t, client) @@ -1110,6 +1112,8 @@ func TestTemplateVersionsExternalAuth(t *testing.T) { ID: "github", Regex: regexp.MustCompile(`github\.com`), Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + RefreshGroup: new(singleflight.Group), }}, }) owner := coderdtest.CreateFirstUser(t, client)