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

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions coderd/apidoc/docs.go

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

7 changes: 7 additions & 0 deletions coderd/apidoc/swagger.json

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

28 changes: 28 additions & 0 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,28 @@ var (
}),
Scope: rbac.ScopeAll,
}.WithCachedASTValue()

// 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.
subjectExternalAuthCoordinator = rbac.Subject{
Type: rbac.SubjectTypeExternalAuthCoordinator,
FriendlyName: "External Auth Coordinator",
ID: uuid.Nil.String(),
Roles: rbac.Roles([]rbac.Role{
{
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},
}),
User: []rbac.Permission{},
ByOrgID: map[string]rbac.OrgPermissions{},
},
}),
Scope: rbac.ScopeAll,
}.WithCachedASTValue()
)

// AsProvisionerd returns a context with an actor that has permissions required
Expand Down Expand Up @@ -985,6 +1007,12 @@ func AsSCIMProvisioner(ctx context.Context) context.Context {
return As(ctx, subjectSCIM)
}

// AsExternalAuthCoordinator returns a context with an actor that has permission to
// read and refresh any user's external auth links.
func AsExternalAuthCoordinator(ctx context.Context) context.Context {
return As(ctx, subjectExternalAuthCoordinator)
}

var AsRemoveActor = rbac.Subject{
ID: "remove-actor",
}
Expand Down
45 changes: 45 additions & 0 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7633,3 +7633,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.AsExternalAuthCoordinator(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)
}
})
}
1 change: 1 addition & 0 deletions coderd/rbac/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const (
SubjectTypeChatd SubjectType = "chatd"
SubjectTypeAIProviderMetadataReader SubjectType = "ai_provider_metadata_reader"
SubjectTypeSCIMProvisioner SubjectType = "scim_provisioner"
SubjectTypeExternalAuthCoordinator SubjectType = "external_auth_coordinator"
)

const (
Expand Down
29 changes: 28 additions & 1 deletion coderd/templateversions.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,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) {
Expand All @@ -338,7 +339,33 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ
templateVersion = httpmw.TemplateVersionParam(r)
)

providers, err := api.templateVersionExternalAuthForUser(ctx, templateVersion, apiKey.UserID)
ownerID := apiKey.UserID
externalAuthCtx := ctx
if q := r.URL.Query().Get("user_id"); q != "" && q != codersdk.Me {
id, 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 = id

// 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.
externalAuthCtx = dbauthz.AsExternalAuthCoordinator(ctx)
}

providers, err := api.templateVersionExternalAuthForUser(externalAuthCtx, templateVersion, ownerID)
if err != nil {
httperror.WriteResponseError(ctx, rw, err)
return
Expand Down
99 changes: 99 additions & 0 deletions coderd/templateversions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,105 @@ 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(),
DisplayName: "GitHub",
RefreshGroup: new(singleflight.Group),
}},
})
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)

// The reported state is the target user's, not the requesting admin's:
// the admin is unauthenticated but the target shows authenticated.
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)
})
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(),
DisplayName: "GitHub",
RefreshGroup: new(singleflight.Group),
}},
})
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) {
Expand Down
4 changes: 2 additions & 2 deletions coderd/workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 // 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 // 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
}
Expand Down
4 changes: 2 additions & 2 deletions codersdk/templateversions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
7 changes: 4 additions & 3 deletions docs/reference/api/templates.md

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

3 changes: 2 additions & 1 deletion site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1160,9 +1160,10 @@ class ApiMethods {

getTemplateVersionExternalAuth = async (
versionId: string,
userId = "me",
): Promise<TypesGen.TemplateVersionExternalAuth[]> => {
const response = await this.axios.get(
`/api/v2/templateversions/${versionId}/external-auth`,
`/api/v2/templateversions/${versionId}/external-auth?user_id=${userId}`,
);

return response.data;
Expand Down
12 changes: 8 additions & 4 deletions site/src/api/queries/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,16 +206,20 @@ export const templaceACLAvailable = (
};
};

const templateVersionExternalAuthKey = (versionId: string) => [
const templateVersionExternalAuthKey = (versionId: string, userId = "me") => [
templateVersionRoot,
versionId,
userId,
"externalAuth",
];

export const templateVersionExternalAuth = (versionId: string) => {
export const templateVersionExternalAuth = (
versionId: string,
userId = "me",
) => {
return {
queryKey: templateVersionExternalAuthKey(versionId),
queryFn: () => API.getTemplateVersionExternalAuth(versionId),
queryKey: templateVersionExternalAuthKey(versionId, userId),
queryFn: () => API.getTemplateVersionExternalAuth(versionId, userId),
};
};

Expand Down
7 changes: 5 additions & 2 deletions site/src/hooks/useExternalAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ExternalAuthPollingState>
>({});
Expand All @@ -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,
});
Expand Down
4 changes: 2 additions & 2 deletions site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
externalAuthError,
isPollingExternalAuth,
isLoadingExternalAuth,
} = useExternalAuth(selectedVersionId);
} = useExternalAuth(selectedVersionId, "me");
const missedExternalAuth = externalAuth?.filter(
(auth) => !auth.optional && !auth.authenticated,
);
Expand Down Expand Up @@ -437,7 +437,7 @@ const ExternalAuthButtons: FC<ExternalAuthButtonProps> = ({
missedExternalAuth,
}) => {
const { startPollingExternalAuth, externalAuthPollingState } =
useExternalAuth(versionId);
useExternalAuth(versionId, "me");

return missedExternalAuth.map((auth) => {
const isPollingExternalAuth =
Expand Down
2 changes: 1 addition & 1 deletion site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ const CreateWorkspacePage: FC = () => {
externalAuthPollingState,
startPollingExternalAuth,
isLoadingExternalAuth,
} = useExternalAuth(realizedVersionId);
} = useExternalAuth(realizedVersionId, owner.id);

const isLoadingFormData =
ws.current?.readyState === WebSocket.CONNECTING ||
Expand Down
Loading
Loading