From c22770e55c71bb8ae80fc5e2449a0dc0eb9c2b65 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Tue, 23 Aug 2022 15:16:14 -0700 Subject: [PATCH 1/3] WIP refactor Auth tests to allow enterprise Signed-off-by: Spike Curtis --- coderd/coderd_test.go | 329 +++----------------------------- coderd/coderdtest/authtest.go | 267 ++++++++++++++++++++++++++ coderd/coderdtest/coderdtest.go | 10 +- 3 files changed, 302 insertions(+), 304 deletions(-) create mode 100644 coderd/coderdtest/authtest.go diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index c042855687cd3..cfd762ec739b8 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -2,44 +2,15 @@ package coderd_test import ( "context" - "crypto/x509" - "database/sql" - "io" - "net" "net/http" - "net/http/httptest" - "net/url" - "os" - "strconv" - "strings" "testing" - "time" - "github.com/go-chi/chi/v5" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" - "golang.org/x/xerrors" - "google.golang.org/api/idtoken" - "google.golang.org/api/option" - - "cdr.dev/slog" - "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/buildinfo" - "github.com/coder/coder/coderd" - "github.com/coder/coder/coderd/autobuild/executor" "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/databasefake" - "github.com/coder/coder/coderd/database/postgres" - "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/telemetry" - "github.com/coder/coder/coderd/turnconn" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" "github.com/coder/coder/testutil" ) @@ -63,177 +34,18 @@ func TestBuildInfo(t *testing.T) { // TestAuthorizeAllEndpoints will check `authorize` is called on every endpoint registered. func TestAuthorizeAllEndpoints(t *testing.T) { t.Parallel() - authorizer := &recordingAuthorizer{} - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - - // This function was taken from coderdtest.newWithAPI. It is intentionally - // copied to avoid exposing the API to other tests in coderd. Tests should - // not need a reference to coderd.API...this test is an exception. - newClient := func(authorizer rbac.Authorizer) (*codersdk.Client, *coderd.API) { - // This can be hotswapped for a live database instance. - db := databasefake.New() - pubsub := database.NewPubsubInMemory() - if os.Getenv("DB") != "" { - connectionURL, closePg, err := postgres.Open() - require.NoError(t, err) - t.Cleanup(closePg) - sqlDB, err := sql.Open("postgres", connectionURL) - require.NoError(t, err) - t.Cleanup(func() { - _ = sqlDB.Close() - }) - err = database.MigrateUp(sqlDB) - require.NoError(t, err) - db = database.New(sqlDB) - - pubsub, err = database.NewPubsub(ctx, sqlDB, connectionURL) - require.NoError(t, err) - t.Cleanup(func() { - _ = pubsub.Close() - }) - } - - tickerCh := make(chan time.Time) - t.Cleanup(func() { close(tickerCh) }) - - ctx, cancel := context.WithCancel(ctx) // Shadowed to avoid mixing contexts. - defer t.Cleanup(cancel) // Defer to ensure cancelFunc is executed first. - - lifecycleExecutor := executor.New( - ctx, - db, - slogtest.Make(t, nil).Named("autobuild.executor").Leveled(slog.LevelDebug), - tickerCh, - ).WithStatsChannel(nil) - lifecycleExecutor.Run() - - srv := httptest.NewUnstartedServer(nil) - srv.Config.BaseContext = func(_ net.Listener) context.Context { - return ctx - } - srv.Start() - t.Cleanup(srv.Close) - serverURL, err := url.Parse(srv.URL) - require.NoError(t, err) - - turnServer, err := turnconn.New(nil) - require.NoError(t, err) - t.Cleanup(func() { - _ = turnServer.Close() - }) - - validator, err := idtoken.NewValidator(ctx, option.WithoutAuthentication()) - require.NoError(t, err) - - // We set the handler after server creation for the access URL. - coderAPI := coderd.New(&coderd.Options{ - AgentConnectionUpdateFrequency: 150 * time.Millisecond, - AccessURL: serverURL, - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), - Database: db, - Pubsub: pubsub, - - AWSCertificates: nil, - AzureCertificates: x509.VerifyOptions{}, - GithubOAuth2Config: nil, - GoogleTokenValidator: validator, - SSHKeygenAlgorithm: gitsshkey.AlgorithmEd25519, - TURNServer: turnServer, - APIRateLimit: 0, - Authorizer: authorizer, - Telemetry: telemetry.NewNoop(), - }) - srv.Config.Handler = coderAPI.Handler - - _ = coderdtest.NewProvisionerDaemon(t, coderAPI) - t.Cleanup(func() { - _ = coderAPI.Close() - }) - - return codersdk.New(serverURL), coderAPI - } - - client, api := newClient(authorizer) - admin := coderdtest.CreateFirstUser(t, client) - // The provisioner will call to coderd and register itself. This is async, - // so we wait for it to occur. - require.Eventually(t, func() bool { - provisionerds, err := client.ProvisionerDaemons(ctx) - return assert.NoError(t, err) && len(provisionerds) > 0 - }, testutil.WaitLong, testutil.IntervalSlow) - - provisionerds, err := client.ProvisionerDaemons(ctx) - require.NoError(t, err, "fetch provisioners") - require.Len(t, provisionerds, 1) - - organization, err := client.Organization(ctx, admin.OrganizationID) - require.NoError(t, err, "fetch org") - - // Setup some data in the database. - version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - Provision: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - // Return a workspace resource - Resources: []*proto.Resource{{ - Name: "some", - Type: "example", - Agents: []*proto.Agent{{ - Id: "something", - Auth: &proto.Agent_Token{}, - Apps: []*proto.App{{ - Name: "app", - Url: "http://localhost:3000", - }}, - }}, - }}, - }, - }, - }}, - }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, admin.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - file, err := client.Upload(ctx, codersdk.ContentTypeTar, make([]byte, 1024)) - require.NoError(t, err, "upload file") - workspaceResources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) - require.NoError(t, err, "workspace resources") - templateVersionDryRun, err := client.CreateTemplateVersionDryRun(ctx, version.ID, codersdk.CreateTemplateVersionDryRunRequest{ - ParameterValues: []codersdk.CreateParameterRequest{}, - }) - require.NoError(t, err, "template version dry-run") - - templateParam, err := client.CreateParameter(ctx, codersdk.ParameterTemplate, template.ID, codersdk.CreateParameterRequest{ - Name: "test-param", - SourceValue: "hello world", - SourceScheme: codersdk.ParameterSourceSchemeData, - DestinationScheme: codersdk.ParameterDestinationSchemeProvisionerVariable, - }) - require.NoError(t, err, "create template param") - - // Always fail auth from this point forward - authorizer.AlwaysReturn = rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), nil, nil) - + a := coderdtest.NewAuthTester(t, ctx) // Some quick reused objects - workspaceRBACObj := rbac.ResourceWorkspace.InOrg(organization.ID).WithOwner(workspace.OwnerID.String()) - workspaceExecObj := rbac.ResourceWorkspaceExecution.InOrg(organization.ID).WithOwner(workspace.OwnerID.String()) - + workspaceRBACObj := rbac.ResourceWorkspace.InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String()) + workspaceExecObj := rbac.ResourceWorkspaceExecution.InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String()) // skipRoutes allows skipping routes from being checked. skipRoutes := map[string]string{ "POST:/api/v2/users/logout": "Logging out deletes the API Key for other routes", } - type routeCheck struct { - NoAuthorize bool - AssertAction rbac.Action - AssertObject rbac.Object - StatusCode int - } - assertRoute := map[string]routeCheck{ + assertRoute := map[string]coderdtest.RouteCheck{ // These endpoints do not require auth "GET:/api/v2": {NoAuthorize: true}, "GET:/api/v2/buildinfo": {NoAuthorize: true}, @@ -273,7 +85,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { "GET:/api/v2/workspaceagents/{workspaceagent}/derp": {NoAuthorize: true}, // These endpoints have more assertions. This is good, add more endpoints to assert if you can! - "GET:/api/v2/organizations/{organization}": {AssertObject: rbac.ResourceOrganization.InOrg(admin.OrganizationID)}, + "GET:/api/v2/organizations/{organization}": {AssertObject: rbac.ResourceOrganization.InOrg(a.Admin.OrganizationID)}, "GET:/api/v2/users/{user}/organizations": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceOrganization}, "GET:/api/v2/users/{user}/workspace/{workspacename}": { AssertObject: rbac.ResourceWorkspace, @@ -351,81 +163,81 @@ func TestAuthorizeAllEndpoints(t *testing.T) { "GET:/api/v2/organizations/{organization}/templates": { StatusCode: http.StatusOK, AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "POST:/api/v2/organizations/{organization}/templates": { AssertAction: rbac.ActionCreate, - AssertObject: rbac.ResourceTemplate.InOrg(organization.ID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Organization.ID), }, "DELETE:/api/v2/templates/{template}": { AssertAction: rbac.ActionDelete, - AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "GET:/api/v2/templates/{template}": { AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "POST:/api/v2/files": {AssertAction: rbac.ActionCreate, AssertObject: rbac.ResourceFile}, "GET:/api/v2/files/{fileHash}": { AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceFile.WithOwner(admin.UserID.String()), + AssertObject: rbac.ResourceFile.WithOwner(a.Admin.UserID.String()), }, "GET:/api/v2/templates/{template}/versions": { AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "PATCH:/api/v2/templates/{template}/versions": { AssertAction: rbac.ActionUpdate, - AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "GET:/api/v2/templates/{template}/versions/{templateversionname}": { AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "GET:/api/v2/templateversions/{templateversion}": { AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "PATCH:/api/v2/templateversions/{templateversion}/cancel": { AssertAction: rbac.ActionUpdate, - AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "GET:/api/v2/templateversions/{templateversion}/logs": { AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "GET:/api/v2/templateversions/{templateversion}/parameters": { AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "GET:/api/v2/templateversions/{templateversion}/resources": { AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "GET:/api/v2/templateversions/{templateversion}/schema": { AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "POST:/api/v2/templateversions/{templateversion}/dry-run": { // The first check is to read the template AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), }, "GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}": { AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), }, "GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/resources": { AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), }, "GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/logs": { AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), }, "PATCH:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/cancel": { AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), }, "GET:/api/v2/provisionerdaemons": { StatusCode: http.StatusOK, @@ -446,7 +258,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { }, "GET:/api/v2/organizations/{organization}/templates/{templatename}": { AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID), + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "POST:/api/v2/organizations/{organization}/workspaces": { AssertAction: rbac.ActionCreate, @@ -469,96 +281,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { "POST:/api/v2/workspaces/{workspace}/builds": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, "POST:/api/v2/organizations/{organization}/templateversions": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, } - - for k, v := range assertRoute { - noTrailSlash := strings.TrimRight(k, "/") - if _, ok := assertRoute[noTrailSlash]; ok && noTrailSlash != k { - t.Errorf("route %q & %q is declared twice", noTrailSlash, k) - t.FailNow() - } - assertRoute[noTrailSlash] = v - } - - for k, v := range skipRoutes { - noTrailSlash := strings.TrimRight(k, "/") - if _, ok := skipRoutes[noTrailSlash]; ok && noTrailSlash != k { - t.Errorf("route %q & %q is declared twice", noTrailSlash, k) - t.FailNow() - } - skipRoutes[noTrailSlash] = v - } - - err = chi.Walk(api.Handler, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { - name := method + ":" + route - if _, ok := skipRoutes[strings.TrimRight(name, "/")]; ok { - return nil - } - t.Run(name, func(t *testing.T) { - authorizer.reset() - routeAssertions, ok := assertRoute[strings.TrimRight(name, "/")] - if !ok { - // By default, all omitted routes check for just "authorize" called - routeAssertions = routeCheck{} - } - - // Replace all url params with known values - route = strings.ReplaceAll(route, "{organization}", admin.OrganizationID.String()) - route = strings.ReplaceAll(route, "{user}", admin.UserID.String()) - route = strings.ReplaceAll(route, "{organizationname}", organization.Name) - route = strings.ReplaceAll(route, "{workspace}", workspace.ID.String()) - route = strings.ReplaceAll(route, "{workspacebuild}", workspace.LatestBuild.ID.String()) - route = strings.ReplaceAll(route, "{workspacename}", workspace.Name) - route = strings.ReplaceAll(route, "{workspacebuildname}", workspace.LatestBuild.Name) - route = strings.ReplaceAll(route, "{workspaceagent}", workspaceResources[0].Agents[0].ID.String()) - route = strings.ReplaceAll(route, "{buildnumber}", strconv.FormatInt(int64(workspace.LatestBuild.BuildNumber), 10)) - route = strings.ReplaceAll(route, "{template}", template.ID.String()) - route = strings.ReplaceAll(route, "{hash}", file.Hash) - route = strings.ReplaceAll(route, "{workspaceresource}", workspaceResources[0].ID.String()) - route = strings.ReplaceAll(route, "{workspaceapp}", workspaceResources[0].Agents[0].Apps[0].Name) - route = strings.ReplaceAll(route, "{templateversion}", version.ID.String()) - route = strings.ReplaceAll(route, "{templateversiondryrun}", templateVersionDryRun.ID.String()) - route = strings.ReplaceAll(route, "{templatename}", template.Name) - // Only checking template scoped params here - route = strings.ReplaceAll(route, "{scope}", string(templateParam.Scope)) - route = strings.ReplaceAll(route, "{id}", templateParam.ScopeID.String()) - - resp, err := client.Request(ctx, method, route, nil) - require.NoError(t, err, "do req") - body, _ := io.ReadAll(resp.Body) - t.Logf("Response Body: %q", string(body)) - _ = resp.Body.Close() - - if !routeAssertions.NoAuthorize { - assert.NotNil(t, authorizer.Called, "authorizer expected") - if routeAssertions.StatusCode != 0 { - assert.Equal(t, routeAssertions.StatusCode, resp.StatusCode, "expect unauthorized") - } else { - // It's either a 404 or 403. - if resp.StatusCode != http.StatusNotFound { - assert.Equal(t, http.StatusForbidden, resp.StatusCode, "expect unauthorized") - } - } - if authorizer.Called != nil { - if routeAssertions.AssertAction != "" { - assert.Equal(t, routeAssertions.AssertAction, authorizer.Called.Action, "resource action") - } - if routeAssertions.AssertObject.Type != "" { - assert.Equal(t, routeAssertions.AssertObject.Type, authorizer.Called.Object.Type, "resource type") - } - if routeAssertions.AssertObject.Owner != "" { - assert.Equal(t, routeAssertions.AssertObject.Owner, authorizer.Called.Object.Owner, "resource owner") - } - if routeAssertions.AssertObject.OrgID != "" { - assert.Equal(t, routeAssertions.AssertObject.OrgID, authorizer.Called.Object.OrgID, "resource org") - } - } - } else { - assert.Nil(t, authorizer.Called, "authorize not expected") - } - }) - return nil - }) - require.NoError(t, err) + a.Test(ctx, assertRoute, skipRoutes) } type authCall struct { diff --git a/coderd/coderdtest/authtest.go b/coderd/coderdtest/authtest.go new file mode 100644 index 0000000000000..0d74d8fd98704 --- /dev/null +++ b/coderd/coderdtest/authtest.go @@ -0,0 +1,267 @@ +package coderdtest + +import ( + "context" + "io" + "net/http" + "strconv" + "strings" + "testing" + + "github.com/coder/coder/coderd" + + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/testutil" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" +) + +type RouteCheck struct { + NoAuthorize bool + AssertAction rbac.Action + AssertObject rbac.Object + StatusCode int +} + +type AuthTester struct { + t *testing.T + api *coderd.API + authorizer *recordingAuthorizer + client *codersdk.Client + + Workspace codersdk.Workspace + Organization codersdk.Organization + Admin codersdk.CreateFirstUserResponse + Template codersdk.Template + Version codersdk.TemplateVersion + WorkspaceResource codersdk.WorkspaceResource + File codersdk.UploadResponse + TemplateVersionDryRun codersdk.ProvisionerJob + TemplateParam codersdk.Parameter +} + +func NewAuthTester(t *testing.T, ctx context.Context) *AuthTester { + authorizer := &recordingAuthorizer{} + + client, _, api := newWithAPI(t, &Options{Authorizer: authorizer, IncludeProvisionerD: true}) + admin := CreateFirstUser(t, client) + // The provisioner will call to coderd and register itself. This is async, + // so we wait for it to occur. + require.Eventually(t, func() bool { + provisionerds, err := client.ProvisionerDaemons(ctx) + return assert.NoError(t, err) && len(provisionerds) > 0 + }, testutil.WaitLong, testutil.IntervalSlow) + + provisionerds, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err, "fetch provisioners") + require.Len(t, provisionerds, 1) + + organization, err := client.Organization(ctx, admin.OrganizationID) + require.NoError(t, err, "fetch org") + + // Setup some data in the database. + version := CreateTemplateVersion(t, client, admin.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + // Return a workspace resource + Resources: []*proto.Resource{{ + Name: "some", + Type: "example", + Agents: []*proto.Agent{{ + Id: "something", + Auth: &proto.Agent_Token{}, + Apps: []*proto.App{{ + Name: "app", + Url: "http://localhost:3000", + }}, + }}, + }}, + }, + }, + }}, + }) + AwaitTemplateVersionJob(t, client, version.ID) + template := CreateTemplate(t, client, admin.OrganizationID, version.ID) + workspace := CreateWorkspace(t, client, admin.OrganizationID, template.ID) + AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + file, err := client.Upload(ctx, codersdk.ContentTypeTar, make([]byte, 1024)) + require.NoError(t, err, "upload file") + workspaceResources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) + require.NoError(t, err, "workspace resources") + templateVersionDryRun, err := client.CreateTemplateVersionDryRun(ctx, version.ID, codersdk.CreateTemplateVersionDryRunRequest{ + ParameterValues: []codersdk.CreateParameterRequest{}, + }) + require.NoError(t, err, "template version dry-run") + + templateParam, err := client.CreateParameter(ctx, codersdk.ParameterTemplate, template.ID, codersdk.CreateParameterRequest{ + Name: "test-param", + SourceValue: "hello world", + SourceScheme: codersdk.ParameterSourceSchemeData, + DestinationScheme: codersdk.ParameterDestinationSchemeProvisionerVariable, + }) + require.NoError(t, err, "create template param") + + // Always fail auth from this point forward + authorizer.AlwaysReturn = rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), nil, nil) + + return &AuthTester{ + t: t, + api: api, + authorizer: authorizer, + client: client, + Workspace: workspace, + Organization: organization, + Admin: admin, + Template: template, + Version: version, + WorkspaceResource: workspaceResources[0], + File: file, + TemplateVersionDryRun: templateVersionDryRun, + TemplateParam: templateParam, + } +} + +func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck, skipRoutes map[string]string) { + for k, v := range assertRoute { + noTrailSlash := strings.TrimRight(k, "/") + if _, ok := assertRoute[noTrailSlash]; ok && noTrailSlash != k { + a.t.Errorf("route %q & %q is declared twice", noTrailSlash, k) + a.t.FailNow() + } + assertRoute[noTrailSlash] = v + } + + for k, v := range skipRoutes { + noTrailSlash := strings.TrimRight(k, "/") + if _, ok := skipRoutes[noTrailSlash]; ok && noTrailSlash != k { + a.t.Errorf("route %q & %q is declared twice", noTrailSlash, k) + a.t.FailNow() + } + skipRoutes[noTrailSlash] = v + } + + err := chi.Walk(a.api.Handler, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { + name := method + ":" + route + if _, ok := skipRoutes[strings.TrimRight(name, "/")]; ok { + return nil + } + a.t.Run(name, func(t *testing.T) { + a.authorizer.reset() + routeAssertions, ok := assertRoute[strings.TrimRight(name, "/")] + if !ok { + // By default, all omitted routes check for just "authorize" called + routeAssertions = RouteCheck{} + } + + // Replace all url params with known values + route = strings.ReplaceAll(route, "{organization}", a.Admin.OrganizationID.String()) + route = strings.ReplaceAll(route, "{user}", a.Admin.UserID.String()) + route = strings.ReplaceAll(route, "{organizationname}", a.Organization.Name) + route = strings.ReplaceAll(route, "{workspace}", a.Workspace.ID.String()) + route = strings.ReplaceAll(route, "{workspacebuild}", a.Workspace.LatestBuild.ID.String()) + route = strings.ReplaceAll(route, "{workspacename}", a.Workspace.Name) + route = strings.ReplaceAll(route, "{workspacebuildname}", a.Workspace.LatestBuild.Name) + route = strings.ReplaceAll(route, "{workspaceagent}", a.WorkspaceResource.Agents[0].ID.String()) + route = strings.ReplaceAll(route, "{buildnumber}", strconv.FormatInt(int64(a.Workspace.LatestBuild.BuildNumber), 10)) + route = strings.ReplaceAll(route, "{template}", a.Template.ID.String()) + route = strings.ReplaceAll(route, "{hash}", a.File.Hash) + route = strings.ReplaceAll(route, "{workspaceresource}", a.WorkspaceResource.ID.String()) + route = strings.ReplaceAll(route, "{workspaceapp}", a.WorkspaceResource.Agents[0].Apps[0].Name) + route = strings.ReplaceAll(route, "{templateversion}", a.Version.ID.String()) + route = strings.ReplaceAll(route, "{templateversiondryrun}", a.TemplateVersionDryRun.ID.String()) + route = strings.ReplaceAll(route, "{templatename}", a.Template.Name) + // Only checking template scoped params here + route = strings.ReplaceAll(route, "{scope}", string(a.TemplateParam.Scope)) + route = strings.ReplaceAll(route, "{id}", a.TemplateParam.ScopeID.String()) + + resp, err := a.client.Request(ctx, method, route, nil) + require.NoError(t, err, "do req") + body, _ := io.ReadAll(resp.Body) + t.Logf("Response Body: %q", string(body)) + _ = resp.Body.Close() + + if !routeAssertions.NoAuthorize { + assert.NotNil(t, a.authorizer.Called, "authorizer expected") + if routeAssertions.StatusCode != 0 { + assert.Equal(t, routeAssertions.StatusCode, resp.StatusCode, "expect unauthorized") + } else { + // It's either a 404 or 403. + if resp.StatusCode != http.StatusNotFound { + assert.Equal(t, http.StatusForbidden, resp.StatusCode, "expect unauthorized") + } + } + if a.authorizer.Called != nil { + if routeAssertions.AssertAction != "" { + assert.Equal(t, routeAssertions.AssertAction, a.authorizer.Called.Action, "resource action") + } + if routeAssertions.AssertObject.Type != "" { + assert.Equal(t, routeAssertions.AssertObject.Type, a.authorizer.Called.Object.Type, "resource type") + } + if routeAssertions.AssertObject.Owner != "" { + assert.Equal(t, routeAssertions.AssertObject.Owner, a.authorizer.Called.Object.Owner, "resource owner") + } + if routeAssertions.AssertObject.OrgID != "" { + assert.Equal(t, routeAssertions.AssertObject.OrgID, a.authorizer.Called.Object.OrgID, "resource org") + } + } + } else { + assert.Nil(t, a.authorizer.Called, "authorize not expected") + } + }) + return nil + }) + require.NoError(a.t, err) +} + +type authCall struct { + SubjectID string + Roles []string + Action rbac.Action + Object rbac.Object +} + +type recordingAuthorizer struct { + Called *authCall + AlwaysReturn error +} + +func (r *recordingAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error { + r.Called = &authCall{ + SubjectID: subjectID, + Roles: roleNames, + Action: action, + Object: object, + } + return r.AlwaysReturn +} + +func (r *recordingAuthorizer) PrepareByRoleName(_ context.Context, subjectID string, roles []string, action rbac.Action, _ string) (rbac.PreparedAuthorized, error) { + return &fakePreparedAuthorizer{ + Original: r, + SubjectID: subjectID, + Roles: roles, + Action: action, + }, nil +} + +func (r *recordingAuthorizer) reset() { + r.Called = nil +} + +type fakePreparedAuthorizer struct { + Original *recordingAuthorizer + SubjectID string + Roles []string + Action rbac.Action +} + +func (f *fakePreparedAuthorizer) Authorize(ctx context.Context, object rbac.Object) error { + return f.Original.ByRoleName(ctx, f.SubjectID, f.Roles, f.Action, object) +} diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index d8d5cc97ea76a..d5503e21fe0aa 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -103,6 +103,14 @@ func NewWithProvisionerCloser(t *testing.T, options *Options) (*codersdk.Client, // and is a temporary measure while the API to register provisioners is ironed // out. func newWithCloser(t *testing.T, options *Options) (*codersdk.Client, io.Closer) { + client, closer, _ := newWithAPI(t, options) + return client, closer +} + +// newWithAPI constructs an in-memory API instance and returns a client to talk to it. +// Most tests never need a reference to the API, but AuthorizationTest in this module uses it. +// Do not expose the API or wrath shall descend upon thee. +func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *coderd.API) { if options == nil { options = &Options{} } @@ -216,7 +224,7 @@ func newWithCloser(t *testing.T, options *Options) (*codersdk.Client, io.Closer) _ = provisionerCloser.Close() }) - return codersdk.New(serverURL), provisionerCloser + return codersdk.New(serverURL), provisionerCloser, coderAPI } // NewProvisionerDaemon launches a provisionerd instance configured to work From df2497d1390c29528fd2e7880058940cdc0389d2 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Tue, 23 Aug 2022 15:50:01 -0700 Subject: [PATCH 2/3] enterprise RBAC testing Signed-off-by: Spike Curtis --- coderd/coderd_test.go | 295 +----------------------- coderd/coderdtest/authtest.go | 409 +++++++++++++++++++++++++++------ enterprise/coderd/auth_test.go | 26 +++ enterprise/coderd/licenses.go | 3 +- 4 files changed, 369 insertions(+), 364 deletions(-) create mode 100644 enterprise/coderd/auth_test.go diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index cfd762ec739b8..615e032f00eb2 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -2,7 +2,6 @@ package coderd_test import ( "context" - "net/http" "testing" "github.com/stretchr/testify/require" @@ -10,7 +9,6 @@ import ( "github.com/coder/coder/buildinfo" "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/testutil" ) @@ -36,296 +34,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - a := coderdtest.NewAuthTester(t, ctx) - // Some quick reused objects - workspaceRBACObj := rbac.ResourceWorkspace.InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String()) - workspaceExecObj := rbac.ResourceWorkspaceExecution.InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String()) - // skipRoutes allows skipping routes from being checked. - skipRoutes := map[string]string{ - "POST:/api/v2/users/logout": "Logging out deletes the API Key for other routes", - } - - assertRoute := map[string]coderdtest.RouteCheck{ - // These endpoints do not require auth - "GET:/api/v2": {NoAuthorize: true}, - "GET:/api/v2/buildinfo": {NoAuthorize: true}, - "GET:/api/v2/users/first": {NoAuthorize: true}, - "POST:/api/v2/users/first": {NoAuthorize: true}, - "POST:/api/v2/users/login": {NoAuthorize: true}, - "GET:/api/v2/users/authmethods": {NoAuthorize: true}, - "POST:/api/v2/csp/reports": {NoAuthorize: true}, - "GET:/api/v2/entitlements": {NoAuthorize: true}, - - "GET:/%40{user}/{workspacename}/apps/{application}/*": { - AssertAction: rbac.ActionRead, - AssertObject: workspaceRBACObj, - }, - "GET:/@{user}/{workspacename}/apps/{application}/*": { - AssertAction: rbac.ActionRead, - AssertObject: workspaceRBACObj, - }, - - // Has it's own auth - "GET:/api/v2/users/oauth2/github/callback": {NoAuthorize: true}, - "GET:/api/v2/users/oidc/callback": {NoAuthorize: true}, - - // All workspaceagents endpoints do not use rbac - "POST:/api/v2/workspaceagents/aws-instance-identity": {NoAuthorize: true}, - "POST:/api/v2/workspaceagents/azure-instance-identity": {NoAuthorize: true}, - "POST:/api/v2/workspaceagents/google-instance-identity": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/me/gitsshkey": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/me/iceservers": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/me/listen": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/me/turn": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/me/derp": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/me/wireguardlisten": {NoAuthorize: true}, - "POST:/api/v2/workspaceagents/me/keys": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/{workspaceagent}/derp": {NoAuthorize: true}, - - // These endpoints have more assertions. This is good, add more endpoints to assert if you can! - "GET:/api/v2/organizations/{organization}": {AssertObject: rbac.ResourceOrganization.InOrg(a.Admin.OrganizationID)}, - "GET:/api/v2/users/{user}/organizations": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceOrganization}, - "GET:/api/v2/users/{user}/workspace/{workspacename}": { - AssertObject: rbac.ResourceWorkspace, - AssertAction: rbac.ActionRead, - }, - "GET:/api/v2/users/me/workspace/{workspacename}/builds/{buildnumber}": { - AssertObject: rbac.ResourceWorkspace, - AssertAction: rbac.ActionRead, - }, - "GET:/api/v2/workspaces/{workspace}/builds/{workspacebuildname}": { - AssertAction: rbac.ActionRead, - AssertObject: workspaceRBACObj, - }, - "GET:/api/v2/workspacebuilds/{workspacebuild}": { - AssertAction: rbac.ActionRead, - AssertObject: workspaceRBACObj, - }, - "GET:/api/v2/workspacebuilds/{workspacebuild}/logs": { - AssertAction: rbac.ActionRead, - AssertObject: workspaceRBACObj, - }, - "GET:/api/v2/workspaces/{workspace}/builds": { - AssertAction: rbac.ActionRead, - AssertObject: workspaceRBACObj, - }, - "GET:/api/v2/workspaces/{workspace}": { - AssertAction: rbac.ActionRead, - AssertObject: workspaceRBACObj, - }, - "PUT:/api/v2/workspaces/{workspace}/autostart": { - AssertAction: rbac.ActionUpdate, - AssertObject: workspaceRBACObj, - }, - "PUT:/api/v2/workspaces/{workspace}/autostop": { - AssertAction: rbac.ActionUpdate, - AssertObject: workspaceRBACObj, - }, - "GET:/api/v2/workspaceresources/{workspaceresource}": { - AssertAction: rbac.ActionRead, - AssertObject: workspaceRBACObj, - }, - "PATCH:/api/v2/workspacebuilds/{workspacebuild}/cancel": { - AssertAction: rbac.ActionUpdate, - AssertObject: workspaceRBACObj, - }, - "GET:/api/v2/workspacebuilds/{workspacebuild}/resources": { - AssertAction: rbac.ActionRead, - AssertObject: workspaceRBACObj, - }, - "GET:/api/v2/workspacebuilds/{workspacebuild}/state": { - AssertAction: rbac.ActionRead, - AssertObject: workspaceRBACObj, - }, - "GET:/api/v2/workspaceagents/{workspaceagent}": { - AssertAction: rbac.ActionRead, - AssertObject: workspaceRBACObj, - }, - "GET:/api/v2/workspaceagents/{workspaceagent}/dial": { - AssertAction: rbac.ActionCreate, - AssertObject: workspaceExecObj, - }, - "GET:/api/v2/workspaceagents/{workspaceagent}/turn": { - AssertAction: rbac.ActionCreate, - AssertObject: workspaceExecObj, - }, - "GET:/api/v2/workspaceagents/{workspaceagent}/pty": { - AssertAction: rbac.ActionCreate, - AssertObject: workspaceExecObj, - }, - "GET:/api/v2/workspaces/": { - StatusCode: http.StatusOK, - AssertAction: rbac.ActionRead, - AssertObject: workspaceRBACObj, - }, - "GET:/api/v2/organizations/{organization}/templates": { - StatusCode: http.StatusOK, - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), - }, - "POST:/api/v2/organizations/{organization}/templates": { - AssertAction: rbac.ActionCreate, - AssertObject: rbac.ResourceTemplate.InOrg(a.Organization.ID), - }, - "DELETE:/api/v2/templates/{template}": { - AssertAction: rbac.ActionDelete, - AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), - }, - "GET:/api/v2/templates/{template}": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), - }, - "POST:/api/v2/files": {AssertAction: rbac.ActionCreate, AssertObject: rbac.ResourceFile}, - "GET:/api/v2/files/{fileHash}": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceFile.WithOwner(a.Admin.UserID.String()), - }, - "GET:/api/v2/templates/{template}/versions": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), - }, - "PATCH:/api/v2/templates/{template}/versions": { - AssertAction: rbac.ActionUpdate, - AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), - }, - "GET:/api/v2/templates/{template}/versions/{templateversionname}": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), - }, - "GET:/api/v2/templateversions/{templateversion}": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), - }, - "PATCH:/api/v2/templateversions/{templateversion}/cancel": { - AssertAction: rbac.ActionUpdate, - AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), - }, - "GET:/api/v2/templateversions/{templateversion}/logs": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), - }, - "GET:/api/v2/templateversions/{templateversion}/parameters": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), - }, - "GET:/api/v2/templateversions/{templateversion}/resources": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), - }, - "GET:/api/v2/templateversions/{templateversion}/schema": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), - }, - "POST:/api/v2/templateversions/{templateversion}/dry-run": { - // The first check is to read the template - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), - }, - "GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), - }, - "GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/resources": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), - }, - "GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/logs": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), - }, - "PATCH:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/cancel": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), - }, - "GET:/api/v2/provisionerdaemons": { - StatusCode: http.StatusOK, - AssertObject: rbac.ResourceProvisionerDaemon, - }, - - "POST:/api/v2/parameters/{scope}/{id}": { - AssertAction: rbac.ActionUpdate, - AssertObject: rbac.ResourceTemplate, - }, - "GET:/api/v2/parameters/{scope}/{id}": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate, - }, - "DELETE:/api/v2/parameters/{scope}/{id}/{name}": { - AssertAction: rbac.ActionUpdate, - AssertObject: rbac.ResourceTemplate, - }, - "GET:/api/v2/organizations/{organization}/templates/{templatename}": { - AssertAction: rbac.ActionRead, - AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), - }, - "POST:/api/v2/organizations/{organization}/workspaces": { - AssertAction: rbac.ActionCreate, - // No ID when creating - AssertObject: workspaceRBACObj, - }, - "GET:/api/v2/workspaces/{workspace}/watch": { - AssertAction: rbac.ActionRead, - AssertObject: workspaceRBACObj, - }, - "POST:/api/v2/users/{user}/organizations": { - AssertAction: rbac.ActionCreate, - AssertObject: rbac.ResourceOrganization, - }, - "GET:/api/v2/users": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceUser}, - - // These endpoints need payloads to get to the auth part. Payloads will be required - "PUT:/api/v2/users/{user}/roles": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, - "PUT:/api/v2/organizations/{organization}/members/{user}/roles": {NoAuthorize: true}, - "POST:/api/v2/workspaces/{workspace}/builds": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, - "POST:/api/v2/organizations/{organization}/templateversions": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, - } + a := coderdtest.NewAuthTester(ctx, t, nil) + skipRoutes, assertRoute := coderdtest.AGPLRoutes(a) a.Test(ctx, assertRoute, skipRoutes) } - -type authCall struct { - SubjectID string - Roles []string - Action rbac.Action - Object rbac.Object -} - -type recordingAuthorizer struct { - Called *authCall - AlwaysReturn error -} - -func (r *recordingAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error { - r.Called = &authCall{ - SubjectID: subjectID, - Roles: roleNames, - Action: action, - Object: object, - } - return r.AlwaysReturn -} - -func (r *recordingAuthorizer) PrepareByRoleName(_ context.Context, subjectID string, roles []string, action rbac.Action, _ string) (rbac.PreparedAuthorized, error) { - return &fakePreparedAuthorizer{ - Original: r, - SubjectID: subjectID, - Roles: roles, - Action: action, - }, nil -} - -func (r *recordingAuthorizer) reset() { - r.Called = nil -} - -type fakePreparedAuthorizer struct { - Original *recordingAuthorizer - SubjectID string - Roles []string - Action rbac.Action -} - -func (f *fakePreparedAuthorizer) Authorize(ctx context.Context, object rbac.Object) error { - return f.Original.ByRoleName(ctx, f.SubjectID, f.Roles, f.Action, object) -} diff --git a/coderd/coderdtest/authtest.go b/coderd/coderdtest/authtest.go index 0d74d8fd98704..10b96c660f1f0 100644 --- a/coderd/coderdtest/authtest.go +++ b/coderd/coderdtest/authtest.go @@ -10,15 +10,16 @@ import ( "github.com/coder/coder/coderd" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" "github.com/coder/coder/testutil" - "github.com/go-chi/chi/v5" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/xerrors" ) type RouteCheck struct { @@ -45,10 +46,18 @@ type AuthTester struct { TemplateParam codersdk.Parameter } -func NewAuthTester(t *testing.T, ctx context.Context) *AuthTester { +func NewAuthTester(ctx context.Context, t *testing.T, options *Options) *AuthTester { authorizer := &recordingAuthorizer{} + if options == nil { + options = &Options{} + } + if options.Authorizer != nil { + t.Error("NewAuthTester cannot be called with custom Authorizer") + } + options.Authorizer = authorizer + options.IncludeProvisionerD = true - client, _, api := newWithAPI(t, &Options{Authorizer: authorizer, IncludeProvisionerD: true}) + client, _, api := newWithAPI(t, options) admin := CreateFirstUser(t, client) // The provisioner will call to coderd and register itself. This is async, // so we wait for it to occur. @@ -128,6 +137,254 @@ func NewAuthTester(t *testing.T, ctx context.Context) *AuthTester { } } +func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { + // Some quick reused objects + workspaceRBACObj := rbac.ResourceWorkspace.InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String()) + workspaceExecObj := rbac.ResourceWorkspaceExecution.InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String()) + // skipRoutes allows skipping routes from being checked. + skipRoutes := map[string]string{ + "POST:/api/v2/users/logout": "Logging out deletes the API Key for other routes", + } + + assertRoute := map[string]RouteCheck{ + // These endpoints do not require auth + "GET:/api/v2": {NoAuthorize: true}, + "GET:/api/v2/buildinfo": {NoAuthorize: true}, + "GET:/api/v2/users/first": {NoAuthorize: true}, + "POST:/api/v2/users/first": {NoAuthorize: true}, + "POST:/api/v2/users/login": {NoAuthorize: true}, + "GET:/api/v2/users/authmethods": {NoAuthorize: true}, + "POST:/api/v2/csp/reports": {NoAuthorize: true}, + "GET:/api/v2/entitlements": {NoAuthorize: true}, + + "GET:/%40{user}/{workspacename}/apps/{application}/*": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "GET:/@{user}/{workspacename}/apps/{application}/*": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + + // Has it's own auth + "GET:/api/v2/users/oauth2/github/callback": {NoAuthorize: true}, + "GET:/api/v2/users/oidc/callback": {NoAuthorize: true}, + + // All workspaceagents endpoints do not use rbac + "POST:/api/v2/workspaceagents/aws-instance-identity": {NoAuthorize: true}, + "POST:/api/v2/workspaceagents/azure-instance-identity": {NoAuthorize: true}, + "POST:/api/v2/workspaceagents/google-instance-identity": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/gitsshkey": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/iceservers": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/listen": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/turn": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/derp": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/wireguardlisten": {NoAuthorize: true}, + "POST:/api/v2/workspaceagents/me/keys": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/{workspaceagent}/derp": {NoAuthorize: true}, + + // These endpoints have more assertions. This is good, add more endpoints to assert if you can! + "GET:/api/v2/organizations/{organization}": {AssertObject: rbac.ResourceOrganization.InOrg(a.Admin.OrganizationID)}, + "GET:/api/v2/users/{user}/organizations": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceOrganization}, + "GET:/api/v2/users/{user}/workspace/{workspacename}": { + AssertObject: rbac.ResourceWorkspace, + AssertAction: rbac.ActionRead, + }, + "GET:/api/v2/users/me/workspace/{workspacename}/builds/{buildnumber}": { + AssertObject: rbac.ResourceWorkspace, + AssertAction: rbac.ActionRead, + }, + "GET:/api/v2/workspaces/{workspace}/builds/{workspacebuildname}": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/workspacebuilds/{workspacebuild}": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/workspacebuilds/{workspacebuild}/logs": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/workspaces/{workspace}/builds": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/workspaces/{workspace}": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "PUT:/api/v2/workspaces/{workspace}/autostart": { + AssertAction: rbac.ActionUpdate, + AssertObject: workspaceRBACObj, + }, + "PUT:/api/v2/workspaces/{workspace}/autostop": { + AssertAction: rbac.ActionUpdate, + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/workspaceresources/{workspaceresource}": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "PATCH:/api/v2/workspacebuilds/{workspacebuild}/cancel": { + AssertAction: rbac.ActionUpdate, + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/workspacebuilds/{workspacebuild}/resources": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/workspacebuilds/{workspacebuild}/state": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/workspaceagents/{workspaceagent}": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/workspaceagents/{workspaceagent}/dial": { + AssertAction: rbac.ActionCreate, + AssertObject: workspaceExecObj, + }, + "GET:/api/v2/workspaceagents/{workspaceagent}/turn": { + AssertAction: rbac.ActionCreate, + AssertObject: workspaceExecObj, + }, + "GET:/api/v2/workspaceagents/{workspaceagent}/pty": { + AssertAction: rbac.ActionCreate, + AssertObject: workspaceExecObj, + }, + "GET:/api/v2/workspaces/": { + StatusCode: http.StatusOK, + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/organizations/{organization}/templates": { + StatusCode: http.StatusOK, + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), + }, + "POST:/api/v2/organizations/{organization}/templates": { + AssertAction: rbac.ActionCreate, + AssertObject: rbac.ResourceTemplate.InOrg(a.Organization.ID), + }, + "DELETE:/api/v2/templates/{template}": { + AssertAction: rbac.ActionDelete, + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), + }, + "GET:/api/v2/templates/{template}": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), + }, + "POST:/api/v2/files": {AssertAction: rbac.ActionCreate, AssertObject: rbac.ResourceFile}, + "GET:/api/v2/files/{fileHash}": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceFile.WithOwner(a.Admin.UserID.String()), + }, + "GET:/api/v2/templates/{template}/versions": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), + }, + "PATCH:/api/v2/templates/{template}/versions": { + AssertAction: rbac.ActionUpdate, + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), + }, + "GET:/api/v2/templates/{template}/versions/{templateversionname}": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), + }, + "GET:/api/v2/templateversions/{templateversion}": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), + }, + "PATCH:/api/v2/templateversions/{templateversion}/cancel": { + AssertAction: rbac.ActionUpdate, + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), + }, + "GET:/api/v2/templateversions/{templateversion}/logs": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), + }, + "GET:/api/v2/templateversions/{templateversion}/parameters": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), + }, + "GET:/api/v2/templateversions/{templateversion}/resources": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), + }, + "GET:/api/v2/templateversions/{templateversion}/schema": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), + }, + "POST:/api/v2/templateversions/{templateversion}/dry-run": { + // The first check is to read the template + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), + }, + "GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), + }, + "GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/resources": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), + }, + "GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/logs": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), + }, + "PATCH:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/cancel": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Version.OrganizationID), + }, + "GET:/api/v2/provisionerdaemons": { + StatusCode: http.StatusOK, + AssertObject: rbac.ResourceProvisionerDaemon, + }, + + "POST:/api/v2/parameters/{scope}/{id}": { + AssertAction: rbac.ActionUpdate, + AssertObject: rbac.ResourceTemplate, + }, + "GET:/api/v2/parameters/{scope}/{id}": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate, + }, + "DELETE:/api/v2/parameters/{scope}/{id}/{name}": { + AssertAction: rbac.ActionUpdate, + AssertObject: rbac.ResourceTemplate, + }, + "GET:/api/v2/organizations/{organization}/templates/{templatename}": { + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), + }, + "POST:/api/v2/organizations/{organization}/workspaces": { + AssertAction: rbac.ActionCreate, + // No ID when creating + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/workspaces/{workspace}/watch": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "POST:/api/v2/users/{user}/organizations": { + AssertAction: rbac.ActionCreate, + AssertObject: rbac.ResourceOrganization, + }, + "GET:/api/v2/users": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceUser}, + + // These endpoints need payloads to get to the auth part. Payloads will be required + "PUT:/api/v2/users/{user}/roles": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, + "PUT:/api/v2/organizations/{organization}/members/{user}/roles": {NoAuthorize: true}, + "POST:/api/v2/workspaces/{workspace}/builds": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, + "POST:/api/v2/organizations/{organization}/templateversions": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, + } + return skipRoutes, assertRoute +} + func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck, skipRoutes map[string]string) { for k, v := range assertRoute { noTrailSlash := strings.TrimRight(k, "/") @@ -147,76 +404,88 @@ func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck skipRoutes[noTrailSlash] = v } - err := chi.Walk(a.api.Handler, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { - name := method + ":" + route - if _, ok := skipRoutes[strings.TrimRight(name, "/")]; ok { - return nil - } - a.t.Run(name, func(t *testing.T) { - a.authorizer.reset() - routeAssertions, ok := assertRoute[strings.TrimRight(name, "/")] - if !ok { - // By default, all omitted routes check for just "authorize" called - routeAssertions = RouteCheck{} + err := chi.Walk( + a.api.Handler, + func( + method string, + route string, + handler http.Handler, + middlewares ...func(http.Handler) http.Handler, + ) error { + // work around chi's bugged handling of /*/*/ which can occur if we + // r.Mount("/", someHandler()) in our tree + for strings.Contains(route, "/*/") { + route = strings.Replace(route, "/*/", "/", -1) } - - // Replace all url params with known values - route = strings.ReplaceAll(route, "{organization}", a.Admin.OrganizationID.String()) - route = strings.ReplaceAll(route, "{user}", a.Admin.UserID.String()) - route = strings.ReplaceAll(route, "{organizationname}", a.Organization.Name) - route = strings.ReplaceAll(route, "{workspace}", a.Workspace.ID.String()) - route = strings.ReplaceAll(route, "{workspacebuild}", a.Workspace.LatestBuild.ID.String()) - route = strings.ReplaceAll(route, "{workspacename}", a.Workspace.Name) - route = strings.ReplaceAll(route, "{workspacebuildname}", a.Workspace.LatestBuild.Name) - route = strings.ReplaceAll(route, "{workspaceagent}", a.WorkspaceResource.Agents[0].ID.String()) - route = strings.ReplaceAll(route, "{buildnumber}", strconv.FormatInt(int64(a.Workspace.LatestBuild.BuildNumber), 10)) - route = strings.ReplaceAll(route, "{template}", a.Template.ID.String()) - route = strings.ReplaceAll(route, "{hash}", a.File.Hash) - route = strings.ReplaceAll(route, "{workspaceresource}", a.WorkspaceResource.ID.String()) - route = strings.ReplaceAll(route, "{workspaceapp}", a.WorkspaceResource.Agents[0].Apps[0].Name) - route = strings.ReplaceAll(route, "{templateversion}", a.Version.ID.String()) - route = strings.ReplaceAll(route, "{templateversiondryrun}", a.TemplateVersionDryRun.ID.String()) - route = strings.ReplaceAll(route, "{templatename}", a.Template.Name) - // Only checking template scoped params here - route = strings.ReplaceAll(route, "{scope}", string(a.TemplateParam.Scope)) - route = strings.ReplaceAll(route, "{id}", a.TemplateParam.ScopeID.String()) - - resp, err := a.client.Request(ctx, method, route, nil) - require.NoError(t, err, "do req") - body, _ := io.ReadAll(resp.Body) - t.Logf("Response Body: %q", string(body)) - _ = resp.Body.Close() - - if !routeAssertions.NoAuthorize { - assert.NotNil(t, a.authorizer.Called, "authorizer expected") - if routeAssertions.StatusCode != 0 { - assert.Equal(t, routeAssertions.StatusCode, resp.StatusCode, "expect unauthorized") - } else { - // It's either a 404 or 403. - if resp.StatusCode != http.StatusNotFound { - assert.Equal(t, http.StatusForbidden, resp.StatusCode, "expect unauthorized") - } + name := method + ":" + route + if _, ok := skipRoutes[strings.TrimRight(name, "/")]; ok { + return nil + } + a.t.Run(name, func(t *testing.T) { + a.authorizer.reset() + routeAssertions, ok := assertRoute[strings.TrimRight(name, "/")] + if !ok { + // By default, all omitted routes check for just "authorize" called + routeAssertions = RouteCheck{} } - if a.authorizer.Called != nil { - if routeAssertions.AssertAction != "" { - assert.Equal(t, routeAssertions.AssertAction, a.authorizer.Called.Action, "resource action") - } - if routeAssertions.AssertObject.Type != "" { - assert.Equal(t, routeAssertions.AssertObject.Type, a.authorizer.Called.Object.Type, "resource type") - } - if routeAssertions.AssertObject.Owner != "" { - assert.Equal(t, routeAssertions.AssertObject.Owner, a.authorizer.Called.Object.Owner, "resource owner") + + // Replace all url params with known values + route = strings.ReplaceAll(route, "{organization}", a.Admin.OrganizationID.String()) + route = strings.ReplaceAll(route, "{user}", a.Admin.UserID.String()) + route = strings.ReplaceAll(route, "{organizationname}", a.Organization.Name) + route = strings.ReplaceAll(route, "{workspace}", a.Workspace.ID.String()) + route = strings.ReplaceAll(route, "{workspacebuild}", a.Workspace.LatestBuild.ID.String()) + route = strings.ReplaceAll(route, "{workspacename}", a.Workspace.Name) + route = strings.ReplaceAll(route, "{workspacebuildname}", a.Workspace.LatestBuild.Name) + route = strings.ReplaceAll(route, "{workspaceagent}", a.WorkspaceResource.Agents[0].ID.String()) + route = strings.ReplaceAll(route, "{buildnumber}", strconv.FormatInt(int64(a.Workspace.LatestBuild.BuildNumber), 10)) + route = strings.ReplaceAll(route, "{template}", a.Template.ID.String()) + route = strings.ReplaceAll(route, "{hash}", a.File.Hash) + route = strings.ReplaceAll(route, "{workspaceresource}", a.WorkspaceResource.ID.String()) + route = strings.ReplaceAll(route, "{workspaceapp}", a.WorkspaceResource.Agents[0].Apps[0].Name) + route = strings.ReplaceAll(route, "{templateversion}", a.Version.ID.String()) + route = strings.ReplaceAll(route, "{templateversiondryrun}", a.TemplateVersionDryRun.ID.String()) + route = strings.ReplaceAll(route, "{templatename}", a.Template.Name) + // Only checking template scoped params here + route = strings.ReplaceAll(route, "{scope}", string(a.TemplateParam.Scope)) + route = strings.ReplaceAll(route, "{id}", a.TemplateParam.ScopeID.String()) + + resp, err := a.client.Request(ctx, method, route, nil) + require.NoError(t, err, "do req") + body, _ := io.ReadAll(resp.Body) + t.Logf("Response Body: %q", string(body)) + _ = resp.Body.Close() + + if !routeAssertions.NoAuthorize { + assert.NotNil(t, a.authorizer.Called, "authorizer expected") + if routeAssertions.StatusCode != 0 { + assert.Equal(t, routeAssertions.StatusCode, resp.StatusCode, "expect unauthorized") + } else { + // It's either a 404 or 403. + if resp.StatusCode != http.StatusNotFound { + assert.Equal(t, http.StatusForbidden, resp.StatusCode, "expect unauthorized") + } } - if routeAssertions.AssertObject.OrgID != "" { - assert.Equal(t, routeAssertions.AssertObject.OrgID, a.authorizer.Called.Object.OrgID, "resource org") + if a.authorizer.Called != nil { + if routeAssertions.AssertAction != "" { + assert.Equal(t, routeAssertions.AssertAction, a.authorizer.Called.Action, "resource action") + } + if routeAssertions.AssertObject.Type != "" { + assert.Equal(t, routeAssertions.AssertObject.Type, a.authorizer.Called.Object.Type, "resource type") + } + if routeAssertions.AssertObject.Owner != "" { + assert.Equal(t, routeAssertions.AssertObject.Owner, a.authorizer.Called.Object.Owner, "resource owner") + } + if routeAssertions.AssertObject.OrgID != "" { + assert.Equal(t, routeAssertions.AssertObject.OrgID, a.authorizer.Called.Object.OrgID, "resource org") + } } + } else { + assert.Nil(t, a.authorizer.Called, "authorize not expected") } - } else { - assert.Nil(t, a.authorizer.Called, "authorize not expected") - } + }) + return nil }) - return nil - }) require.NoError(a.t, err) } diff --git a/enterprise/coderd/auth_test.go b/enterprise/coderd/auth_test.go new file mode 100644 index 0000000000000..f42c47d8a1c42 --- /dev/null +++ b/enterprise/coderd/auth_test.go @@ -0,0 +1,26 @@ +package coderd_test + +import ( + "context" + "testing" + + "github.com/coder/coder/coderd/rbac" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/enterprise/coderd" + "github.com/coder/coder/testutil" +) + +// TestAuthorizeAllEndpoints will check `authorize` is called on every endpoint registered. +func TestAuthorizeAllEndpoints(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + a := coderdtest.NewAuthTester(ctx, t, &coderdtest.Options{APIBuilder: coderd.NewEnterprise}) + skipRoutes, assertRoute := coderdtest.AGPLRoutes(a) + assertRoute["POST:/api/v2/licenses"] = coderdtest.RouteCheck{ + AssertAction: rbac.ActionCreate, + AssertObject: rbac.ResourceLicense, + } + a.Test(ctx, assertRoute, skipRoutes) +} diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index 630c1ffe18619..aa80721274dca 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -7,6 +7,8 @@ import ( "net/http" "time" + "github.com/coder/coder/coderd/rbac" + "golang.org/x/xerrors" "github.com/go-chi/chi/v5" @@ -17,7 +19,6 @@ import ( "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" ) From 91e55bb3e676b0e99cc113300a154f1a2850b5fc Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Tue, 23 Aug 2022 16:48:30 -0700 Subject: [PATCH 3/3] Fix import ordering Signed-off-by: Spike Curtis --- enterprise/coderd/auth_test.go | 3 +-- enterprise/coderd/licenses.go | 9 +++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/enterprise/coderd/auth_test.go b/enterprise/coderd/auth_test.go index f42c47d8a1c42..0dec78d42fb8a 100644 --- a/enterprise/coderd/auth_test.go +++ b/enterprise/coderd/auth_test.go @@ -4,9 +4,8 @@ import ( "context" "testing" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/enterprise/coderd" "github.com/coder/coder/testutil" ) diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index aa80721274dca..a9512bb39a7af 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -7,18 +7,15 @@ import ( "net/http" "time" - "github.com/coder/coder/coderd/rbac" - - "golang.org/x/xerrors" - + "cdr.dev/slog" "github.com/go-chi/chi/v5" "github.com/golang-jwt/jwt/v4" - - "cdr.dev/slog" + "golang.org/x/xerrors" "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" )