mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
feat: Dbauthz is now default, remove out of experimental (#6650)
* feat: dbauthz always on, out of experimental * Add ability to do rbac checks in unit tests * Remove AuthorizeAllEndpoints * Remove duplicate rbac checks
This commit is contained in:
@ -1,559 +1,145 @@
|
||||
package coderdtest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/moby/moby/pkg/namesgenerator"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cryptorand"
|
||||
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/rbac/regosql"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/cryptorand"
|
||||
)
|
||||
|
||||
func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
|
||||
// Some quick reused objects
|
||||
workspaceRBACObj := rbac.ResourceWorkspace.WithID(a.Workspace.ID).InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String())
|
||||
workspaceExecObj := rbac.ResourceWorkspaceExecution.WithID(a.Workspace.ID).InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String())
|
||||
applicationConnectObj := rbac.ResourceWorkspaceApplicationConnect.WithID(a.Workspace.ID).InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String())
|
||||
templateObj := rbac.ResourceTemplate.WithID(a.Template.ID).InOrg(a.Template.OrganizationID)
|
||||
// RBACAsserter is a helper for asserting that the correct RBAC checks are
|
||||
// performed. This struct is tied to a given user, and only authorizes calls
|
||||
// for this user are checked.
|
||||
type RBACAsserter struct {
|
||||
Subject rbac.Subject
|
||||
|
||||
// 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",
|
||||
"GET:/derp": "This requires a WebSocket upgrade!",
|
||||
"GET:/derp/latency-check": "This always returns a 200!",
|
||||
}
|
||||
|
||||
assertRoute := map[string]RouteCheck{
|
||||
// These endpoints do not require auth
|
||||
"GET:/healthz": {NoAuthorize: true},
|
||||
"GET:/api/v2": {NoAuthorize: true},
|
||||
"GET:/api/v2/buildinfo": {NoAuthorize: true},
|
||||
"GET:/api/v2/experiments": {NoAuthorize: true}, // This route requires AuthN, but not AuthZ.
|
||||
"GET:/api/v2/updatecheck": {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},
|
||||
"POST:/api/v2/authcheck": {NoAuthorize: true},
|
||||
"GET:/api/v2/applications/host": {NoAuthorize: true},
|
||||
"GET:/api/v2/deployment/ssh": {NoAuthorize: true, StatusCode: http.StatusOK},
|
||||
|
||||
// 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/gitauth": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/gitsshkey": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/coordinate": {NoAuthorize: true},
|
||||
"POST:/api/v2/workspaceagents/me/startup": {NoAuthorize: true},
|
||||
"POST:/api/v2/workspaceagents/me/app-health": {NoAuthorize: true},
|
||||
"POST:/api/v2/workspaceagents/me/report-stats": {NoAuthorize: true},
|
||||
"POST:/api/v2/workspaceagents/me/report-lifecycle": {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.WithID(a.Admin.OrganizationID).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/{user}/workspace/{workspacename}/builds/{buildnumber}": {
|
||||
AssertObject: rbac.ResourceWorkspace,
|
||||
AssertAction: rbac.ActionRead,
|
||||
},
|
||||
"GET:/api/v2/users/{user}/keys/tokens": {
|
||||
AssertObject: rbac.ResourceAPIKey,
|
||||
AssertAction: rbac.ActionRead,
|
||||
StatusCode: http.StatusOK,
|
||||
},
|
||||
"GET:/api/v2/users/{user}/keys/{keyid}": {
|
||||
AssertObject: rbac.ResourceAPIKey,
|
||||
AssertAction: rbac.ActionRead,
|
||||
},
|
||||
"GET:/api/v2/users/{user}/keys/tokens/{keyname}": {
|
||||
AssertObject: rbac.ResourceAPIKey,
|
||||
AssertAction: rbac.ActionRead,
|
||||
},
|
||||
"GET:/api/v2/users/{user}/keys/tokens/tokenconfig": {NoAuthorize: true},
|
||||
"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}/ttl": {
|
||||
AssertAction: rbac.ActionUpdate,
|
||||
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.ActionUpdate,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"GET:/api/v2/workspaceagents/{workspaceagent}": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: workspaceRBACObj,
|
||||
},
|
||||
"GET:/api/v2/workspaceagents/{workspaceagent}/pty": {
|
||||
AssertAction: rbac.ActionCreate,
|
||||
AssertObject: workspaceExecObj,
|
||||
},
|
||||
"GET:/api/v2/workspaceagents/{workspaceagent}/coordinate": {
|
||||
AssertAction: rbac.ActionCreate,
|
||||
AssertObject: workspaceExecObj,
|
||||
},
|
||||
"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: templateObj,
|
||||
},
|
||||
"GET:/api/v2/templates/{template}": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"POST:/api/v2/files": {AssertAction: rbac.ActionCreate, AssertObject: rbac.ResourceFile},
|
||||
"GET:/api/v2/files/{fileID}": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: rbac.ResourceFile.WithOwner(a.Admin.UserID.String()),
|
||||
},
|
||||
"GET:/api/v2/templates/{template}/versions": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"PATCH:/api/v2/templates/{template}/versions": {
|
||||
AssertAction: rbac.ActionUpdate,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"GET:/api/v2/templates/{template}/versions/{templateversionname}": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"GET:/api/v2/templateversions/{templateversion}": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"PATCH:/api/v2/templateversions/{templateversion}/cancel": {
|
||||
AssertAction: rbac.ActionUpdate,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"GET:/api/v2/templateversions/{templateversion}/logs": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"GET:/api/v2/templateversions/{templateversion}/parameters": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"GET:/api/v2/templateversions/{templateversion}/rich-parameters": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"GET:/api/v2/templateversions/{templateversion}/resources": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"GET:/api/v2/templateversions/{templateversion}/schema": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"POST:/api/v2/templateversions/{templateversion}/dry-run": {
|
||||
// The first check is to read the template
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"GET:/api/v2/templateversions/{templateversion}/dry-run/{jobID}": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"GET:/api/v2/templateversions/{templateversion}/dry-run/{jobID}/resources": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"GET:/api/v2/templateversions/{templateversion}/dry-run/{jobID}/logs": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"PATCH:/api/v2/templateversions/{templateversion}/dry-run/{jobID}/cancel": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"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: templateObj,
|
||||
},
|
||||
"GET:/api/v2/organizations/{organization}/templates/{templatename}/versions/{templateversionname}": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"GET:/api/v2/organizations/{organization}/templates/{templatename}/versions/{templateversionname}/previous": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: templateObj,
|
||||
},
|
||||
"POST:/api/v2/organizations/{organization}/members/{user}/workspaces": {
|
||||
AssertAction: rbac.ActionCreate,
|
||||
// No ID when creating
|
||||
AssertObject: workspaceRBACObj,
|
||||
},
|
||||
"GET:/api/v2/workspaces/{workspace}/watch": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: workspaceRBACObj,
|
||||
},
|
||||
"GET:/api/v2/users": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceUser},
|
||||
"GET:/api/v2/applications/auth-redirect": {AssertAction: rbac.ActionCreate, AssertObject: rbac.ResourceAPIKey},
|
||||
|
||||
// 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},
|
||||
|
||||
// For any route using SQL filters, we do not check authorization.
|
||||
// This is because the in memory fake does not use SQL.
|
||||
"GET:/api/v2/workspaces/": {
|
||||
StatusCode: http.StatusOK,
|
||||
NoAuthorize: true,
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: rbac.ResourceWorkspace,
|
||||
},
|
||||
"GET:/api/v2/organizations/{organization}/templates": {
|
||||
StatusCode: http.StatusOK,
|
||||
NoAuthorize: true,
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: rbac.ResourceTemplate,
|
||||
},
|
||||
|
||||
"GET:/api/v2/debug/coordinator": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: rbac.ResourceDebugInfo,
|
||||
},
|
||||
}
|
||||
|
||||
// Routes like proxy routes support all HTTP methods. A helper func to expand
|
||||
// 1 url to all http methods.
|
||||
assertAllHTTPMethods := func(url string, check RouteCheck) {
|
||||
methods := []string{
|
||||
http.MethodGet, http.MethodHead, http.MethodPost,
|
||||
http.MethodPut, http.MethodPatch, http.MethodDelete,
|
||||
http.MethodConnect, http.MethodOptions, http.MethodTrace,
|
||||
}
|
||||
|
||||
for _, method := range methods {
|
||||
route := method + ":" + url
|
||||
assertRoute[route] = check
|
||||
}
|
||||
}
|
||||
|
||||
assertAllHTTPMethods("/%40{user}/{workspace_and_agent}/apps/{workspaceapp}/*", RouteCheck{
|
||||
AssertAction: rbac.ActionCreate,
|
||||
AssertObject: applicationConnectObj,
|
||||
})
|
||||
assertAllHTTPMethods("/@{user}/{workspace_and_agent}/apps/{workspaceapp}/*", RouteCheck{
|
||||
AssertAction: rbac.ActionCreate,
|
||||
AssertObject: applicationConnectObj,
|
||||
})
|
||||
|
||||
return skipRoutes, assertRoute
|
||||
Recorder *RecordingAuthorizer
|
||||
}
|
||||
|
||||
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
|
||||
URLParams map[string]string
|
||||
}
|
||||
|
||||
func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, api *coderd.API, admin codersdk.CreateFirstUserResponse) *AuthTester {
|
||||
authorizer, ok := api.Authorizer.(*RecordingAuthorizer)
|
||||
// AssertRBAC returns an RBACAsserter for the given user. This asserter will
|
||||
// allow asserting that the correct RBAC checks are performed for the given user.
|
||||
// All checks that are not run against this user will be ignored.
|
||||
func AssertRBAC(t *testing.T, api *coderd.API, client *codersdk.Client) RBACAsserter {
|
||||
if client.SessionToken() == "" {
|
||||
t.Fatal("client must be logged in")
|
||||
}
|
||||
recorder, ok := api.Authorizer.(*RecordingAuthorizer)
|
||||
if !ok {
|
||||
t.Fail()
|
||||
}
|
||||
_, err := client.CreateToken(ctx, admin.UserID.String(), codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour,
|
||||
Scope: codersdk.APIKeyScopeAll,
|
||||
TokenName: namesgenerator.GetRandomName(1),
|
||||
})
|
||||
require.NoError(t, err, "create token")
|
||||
|
||||
apiKeys, err := client.Tokens(ctx, admin.UserID.String(), codersdk.TokensFilter{
|
||||
IncludeAll: true,
|
||||
})
|
||||
require.NoError(t, err, "get tokens")
|
||||
apiKey := apiKeys[0]
|
||||
|
||||
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,
|
||||
ProvisionApply: []*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{{
|
||||
Name: "agent",
|
||||
Id: "something",
|
||||
Auth: &proto.Agent_Token{},
|
||||
Apps: []*proto.App{{
|
||||
Slug: "testapp",
|
||||
DisplayName: "testapp",
|
||||
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, bytes.NewReader(make([]byte, 1024)))
|
||||
require.NoError(t, err, "upload file")
|
||||
workspace, err = client.Workspace(ctx, workspace.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")
|
||||
urlParameters := map[string]string{
|
||||
"{organization}": admin.OrganizationID.String(),
|
||||
"{user}": admin.UserID.String(),
|
||||
"{organizationname}": organization.Name,
|
||||
"{workspace}": workspace.ID.String(),
|
||||
"{workspacebuild}": workspace.LatestBuild.ID.String(),
|
||||
"{workspacename}": workspace.Name,
|
||||
"{workspaceagent}": workspace.LatestBuild.Resources[0].Agents[0].ID.String(),
|
||||
"{buildnumber}": strconv.FormatInt(int64(workspace.LatestBuild.BuildNumber), 10),
|
||||
"{template}": template.ID.String(),
|
||||
"{fileID}": file.ID.String(),
|
||||
"{workspaceresource}": workspace.LatestBuild.Resources[0].ID.String(),
|
||||
"{workspaceapp}": workspace.LatestBuild.Resources[0].Agents[0].Apps[0].Slug,
|
||||
"{templateversion}": version.ID.String(),
|
||||
"{jobID}": templateVersionDryRun.ID.String(),
|
||||
"{templatename}": template.Name,
|
||||
"{workspace_and_agent}": workspace.Name + "." + workspace.LatestBuild.Resources[0].Agents[0].Name,
|
||||
"{keyid}": apiKey.ID,
|
||||
"{keyname}": apiKey.TokenName,
|
||||
// Only checking template scoped params here
|
||||
"parameters/{scope}/{id}": fmt.Sprintf("parameters/%s/%s",
|
||||
string(templateParam.Scope), templateParam.ScopeID.String()),
|
||||
t.Fatal("expected RecordingAuthorizer")
|
||||
}
|
||||
|
||||
return &AuthTester{
|
||||
t: t,
|
||||
api: api,
|
||||
authorizer: authorizer,
|
||||
Client: client,
|
||||
Workspace: workspace,
|
||||
Organization: organization,
|
||||
Admin: admin,
|
||||
Template: template,
|
||||
Version: version,
|
||||
WorkspaceResource: workspace.LatestBuild.Resources[0],
|
||||
File: file,
|
||||
TemplateVersionDryRun: templateVersionDryRun,
|
||||
TemplateParam: templateParam,
|
||||
URLParams: urlParameters,
|
||||
// We use the database directly to not cause additional auth checks on behalf
|
||||
// of the user. This does add authz checks on behalf of the system user, but
|
||||
// it is hard to avoid that.
|
||||
// nolint:gocritic
|
||||
ctx := dbauthz.AsSystemRestricted(context.Background())
|
||||
token := client.SessionToken()
|
||||
parts := strings.Split(token, "-")
|
||||
key, err := api.Database.GetAPIKeyByID(ctx, parts[0])
|
||||
require.NoError(t, err, "fetch client api key")
|
||||
|
||||
roles, err := api.Database.GetAuthorizationUserRoles(ctx, key.UserID)
|
||||
require.NoError(t, err, "fetch user roles")
|
||||
|
||||
return RBACAsserter{
|
||||
Subject: rbac.Subject{
|
||||
ID: key.UserID.String(),
|
||||
Roles: rbac.RoleNames(roles.Roles),
|
||||
Groups: roles.Groups,
|
||||
Scope: rbac.ScopeName(key.Scope),
|
||||
},
|
||||
Recorder: recorder,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck, skipRoutes map[string]string) {
|
||||
// Always fail auth from this point forward
|
||||
a.authorizer.Wrapped = &FakeAuthorizer{
|
||||
AlwaysReturn: rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), rbac.Subject{}, "", rbac.Object{}, nil),
|
||||
}
|
||||
|
||||
routeMissing := make(map[string]bool)
|
||||
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
|
||||
routeMissing[noTrailSlash] = true
|
||||
}
|
||||
|
||||
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.RootHandler,
|
||||
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)
|
||||
}
|
||||
name := method + ":" + route
|
||||
if _, ok := skipRoutes[strings.TrimRight(name, "/")]; ok {
|
||||
return nil
|
||||
}
|
||||
a.t.Run(name, func(t *testing.T) {
|
||||
a.authorizer.Reset()
|
||||
routeKey := strings.TrimRight(name, "/")
|
||||
|
||||
routeAssertions, ok := assertRoute[routeKey]
|
||||
if !ok {
|
||||
// By default, all omitted routes check for just "authorize" called
|
||||
routeAssertions = RouteCheck{}
|
||||
}
|
||||
delete(routeMissing, routeKey)
|
||||
|
||||
// Replace all url params with known values
|
||||
for k, v := range a.URLParams {
|
||||
route = strings.ReplaceAll(route, k, v)
|
||||
}
|
||||
|
||||
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.lastCall() != nil {
|
||||
last := a.authorizer.lastCall()
|
||||
if routeAssertions.AssertAction != "" {
|
||||
assert.Equal(t, routeAssertions.AssertAction, last.Action, "resource action")
|
||||
}
|
||||
if routeAssertions.AssertObject.Type != "" {
|
||||
assert.Equal(t, routeAssertions.AssertObject.Type, last.Object.Type, "resource type")
|
||||
}
|
||||
if routeAssertions.AssertObject.Owner != "" {
|
||||
assert.Equal(t, routeAssertions.AssertObject.Owner, last.Object.Owner, "resource owner")
|
||||
}
|
||||
if routeAssertions.AssertObject.OrgID != "" {
|
||||
assert.Equal(t, routeAssertions.AssertObject.OrgID, last.Object.OrgID, "resource org")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert.Nil(t, a.authorizer.Called, "authorize not expected")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
})
|
||||
require.NoError(a.t, err)
|
||||
require.Len(a.t, routeMissing, 0, "didn't walk some asserted routes: %v", routeMissing)
|
||||
// AllCalls is for debugging. If you are not sure where calls are coming from,
|
||||
// call this and use a debugger or print them. They have small callstacks
|
||||
// on them to help locate the 'Authorize' call.
|
||||
// Only calls to Authorize by the given subject will be returned.
|
||||
// Note that duplicate rbac calls are handled by the rbac.Cacher(), but
|
||||
// will be recorded twice. So AllCalls() returns calls regardless if they
|
||||
// were returned from the cached or not.
|
||||
func (a RBACAsserter) AllCalls() []AuthCall {
|
||||
return a.Recorder.AllCalls(&a.Subject)
|
||||
}
|
||||
|
||||
type authCall struct {
|
||||
// AssertChecked will assert a given rbac check was performed. It does not care
|
||||
// about order of checks, or any other checks. This is useful when you do not
|
||||
// care about asserting every check that was performed.
|
||||
func (a RBACAsserter) AssertChecked(t *testing.T, action rbac.Action, objects ...interface{}) {
|
||||
converted := a.convertObjects(t, objects...)
|
||||
pairs := make([]ActionObjectPair, 0, len(converted))
|
||||
for _, obj := range converted {
|
||||
pairs = append(pairs, a.Recorder.Pair(action, obj))
|
||||
}
|
||||
a.Recorder.AssertOutOfOrder(t, a.Subject, pairs...)
|
||||
}
|
||||
|
||||
// AssertInOrder must be called in the correct order of authz checks. If the objects
|
||||
// or actions are not in the correct order, the test will fail.
|
||||
func (a RBACAsserter) AssertInOrder(t *testing.T, action rbac.Action, objects ...interface{}) {
|
||||
converted := a.convertObjects(t, objects...)
|
||||
pairs := make([]ActionObjectPair, 0, len(converted))
|
||||
for _, obj := range converted {
|
||||
pairs = append(pairs, a.Recorder.Pair(action, obj))
|
||||
}
|
||||
a.Recorder.AssertActor(t, a.Subject, pairs...)
|
||||
}
|
||||
|
||||
// convertObjects converts the codersdk types to rbac.Object. Unfortunately
|
||||
// does not have type safety, and instead uses a t.Fatal to enforce types.
|
||||
func (RBACAsserter) convertObjects(t *testing.T, objs ...interface{}) []rbac.Object {
|
||||
converted := make([]rbac.Object, 0, len(objs))
|
||||
for _, obj := range objs {
|
||||
var robj rbac.Object
|
||||
switch obj := obj.(type) {
|
||||
case rbac.Object:
|
||||
robj = obj
|
||||
case rbac.Objecter:
|
||||
robj = obj.RBACObject()
|
||||
case codersdk.TemplateVersion:
|
||||
robj = rbac.ResourceTemplate.InOrg(obj.OrganizationID)
|
||||
case codersdk.User:
|
||||
robj = rbac.ResourceUser.WithID(obj.ID)
|
||||
case codersdk.Workspace:
|
||||
robj = rbac.ResourceWorkspace.WithID(obj.ID).InOrg(obj.OrganizationID).WithOwner(obj.OwnerID.String())
|
||||
default:
|
||||
t.Fatalf("unsupported type %T to convert to rbac.Object, add the implementation", obj)
|
||||
}
|
||||
converted = append(converted, robj)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
// Reset will clear all previously recorded authz calls.
|
||||
// This is helpful when wanting to ignore checks run in test setup.
|
||||
func (a RBACAsserter) Reset() RBACAsserter {
|
||||
a.Recorder.Reset()
|
||||
return a
|
||||
}
|
||||
|
||||
type AuthCall struct {
|
||||
rbac.AuthCall
|
||||
|
||||
asserted bool
|
||||
// callers is a small stack trace for debugging.
|
||||
callers []string
|
||||
}
|
||||
|
||||
var _ rbac.Authorizer = (*RecordingAuthorizer)(nil)
|
||||
@ -562,7 +148,7 @@ var _ rbac.Authorizer = (*RecordingAuthorizer)(nil)
|
||||
// calls made. This is useful for testing as these calls can later be asserted.
|
||||
type RecordingAuthorizer struct {
|
||||
sync.RWMutex
|
||||
Called []authCall
|
||||
Called []AuthCall
|
||||
Wrapped rbac.Authorizer
|
||||
}
|
||||
|
||||
@ -586,7 +172,7 @@ func (*RecordingAuthorizer) Pair(action rbac.Action, object rbac.Objecter) Actio
|
||||
func (r *RecordingAuthorizer) AllAsserted() error {
|
||||
r.RLock()
|
||||
defer r.RUnlock()
|
||||
missed := []authCall{}
|
||||
missed := []AuthCall{}
|
||||
for _, c := range r.Called {
|
||||
if !c.asserted {
|
||||
missed = append(missed, c)
|
||||
@ -599,11 +185,48 @@ func (r *RecordingAuthorizer) AllAsserted() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllCalls is useful for debugging.
|
||||
func (r *RecordingAuthorizer) AllCalls(actor *rbac.Subject) []AuthCall {
|
||||
r.RLock()
|
||||
defer r.RUnlock()
|
||||
|
||||
called := make([]AuthCall, 0, len(r.Called))
|
||||
for _, c := range r.Called {
|
||||
if actor != nil && !c.Actor.Equal(*actor) {
|
||||
continue
|
||||
}
|
||||
called = append(called, c)
|
||||
}
|
||||
return called
|
||||
}
|
||||
|
||||
// AssertOutOfOrder asserts that the given actor performed the given action
|
||||
// on the given objects. It does not care about the order of the calls.
|
||||
// When marking authz calls as asserted, it will mark the first matching
|
||||
// calls first.
|
||||
func (r *RecordingAuthorizer) AssertOutOfOrder(t *testing.T, actor rbac.Subject, did ...ActionObjectPair) {
|
||||
r.Lock()
|
||||
defer r.Unlock()
|
||||
|
||||
for _, do := range did {
|
||||
found := false
|
||||
// Find the first non-asserted call that matches the actor, action, and object.
|
||||
for i, call := range r.Called {
|
||||
if !call.asserted && call.Actor.Equal(actor) && call.Action == do.Action && call.Object.Equal(do.Object) {
|
||||
r.Called[i].asserted = true
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "assertion missing: %s %s %s", actor, do.Action, do.Object)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertActor asserts in order. If the order of authz calls does not match,
|
||||
// this will fail.
|
||||
func (r *RecordingAuthorizer) AssertActor(t *testing.T, actor rbac.Subject, did ...ActionObjectPair) {
|
||||
r.RLock()
|
||||
defer r.RUnlock()
|
||||
r.Lock()
|
||||
defer r.Unlock()
|
||||
ptr := 0
|
||||
for i, call := range r.Called {
|
||||
if ptr == len(did) {
|
||||
@ -626,15 +249,38 @@ func (r *RecordingAuthorizer) AssertActor(t *testing.T, actor rbac.Subject, did
|
||||
func (r *RecordingAuthorizer) recordAuthorize(subject rbac.Subject, action rbac.Action, object rbac.Object) {
|
||||
r.Lock()
|
||||
defer r.Unlock()
|
||||
r.Called = append(r.Called, authCall{
|
||||
|
||||
r.Called = append(r.Called, AuthCall{
|
||||
AuthCall: rbac.AuthCall{
|
||||
Actor: subject,
|
||||
Action: action,
|
||||
Object: object,
|
||||
},
|
||||
callers: []string{
|
||||
// This is a decent stack trace for debugging.
|
||||
// Some dbauthz calls are a bit nested, so we skip a few.
|
||||
caller(2),
|
||||
caller(3),
|
||||
caller(4),
|
||||
caller(5),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func caller(skip int) string {
|
||||
pc, file, line, ok := runtime.Caller(skip + 1)
|
||||
i := strings.Index(file, "coder")
|
||||
if i >= 0 {
|
||||
file = file[i:]
|
||||
}
|
||||
str := fmt.Sprintf("%s:%d", file, line)
|
||||
if ok {
|
||||
f := runtime.FuncForPC(pc)
|
||||
str += " | " + filepath.Base(f.Name())
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
func (r *RecordingAuthorizer) Authorize(ctx context.Context, subject rbac.Subject, action rbac.Action, object rbac.Object) error {
|
||||
r.recordAuthorize(subject, action, object)
|
||||
if r.Wrapped == nil {
|
||||
@ -669,17 +315,6 @@ func (r *RecordingAuthorizer) Reset() {
|
||||
r.Called = nil
|
||||
}
|
||||
|
||||
// lastCall is implemented to support legacy tests.
|
||||
// Deprecated
|
||||
func (r *RecordingAuthorizer) lastCall() *authCall {
|
||||
r.RLock()
|
||||
defer r.RUnlock()
|
||||
if len(r.Called) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &r.Called[len(r.Called)-1]
|
||||
}
|
||||
|
||||
// PreparedRecorder is the prepared version of the RecordingAuthorizer.
|
||||
// It records the Authorize() calls to the original recorder. If the caller
|
||||
// uses CompileToSQL, all recording stops. This is to support parity between
|
||||
|
Reference in New Issue
Block a user