mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
Fixes an issue where sub-agent apps created via CreateSubAgent would
bypass the check for the template's max port sharing level:
- Clamps dynamically inserted `workspace_apps` to the template max
sharing level in `coderd.agentapi.SubAgentAPI`.
- Emits a warning when clamping occurs.
- Adds unit test coverage for the max sharing level matrix.
- Adds an integration-ish test through the devcontainer sub-agent client
path.
> 🤖 Generated by Coder Agents with guidance from a human.
516 lines
16 KiB
Go
516 lines
16 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"slices"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/mock/gomock"
|
|
|
|
"github.com/coder/coder/v2/agent/agentcontainers"
|
|
"github.com/coder/coder/v2/agent/proto"
|
|
"github.com/coder/coder/v2/coderd/agentapi"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
|
agpldbauthz "github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
|
"github.com/coder/coder/v2/coderd/database/dbmock"
|
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
|
agplportsharing "github.com/coder/coder/v2/coderd/portsharing"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
entdbauthz "github.com/coder/coder/v2/enterprise/coderd/dbauthz"
|
|
entportsharing "github.com/coder/coder/v2/enterprise/coderd/portsharing"
|
|
"github.com/coder/coder/v2/testutil"
|
|
"github.com/coder/quartz"
|
|
)
|
|
|
|
func TestSubAgentAPICreateSubAgentAppShareRespectsEnterpriseMaxPortShareLevel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type expectedApp struct {
|
|
slugSuffix string
|
|
sharingLevel database.AppSharingLevel
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
maxPortShareLevel database.AppSharingLevel
|
|
apps []*proto.CreateSubAgentRequest_App
|
|
expectedStoredApps []expectedApp
|
|
}{
|
|
{
|
|
name: "AuthenticatedClampsPublicOnly",
|
|
maxPortShareLevel: database.AppSharingLevelAuthenticated,
|
|
apps: []*proto.CreateSubAgentRequest_App{
|
|
{
|
|
Slug: "public-app",
|
|
Share: proto.CreateSubAgentRequest_App_PUBLIC.Enum(),
|
|
Url: ptr.Ref("http://localhost:8080"),
|
|
},
|
|
{
|
|
Slug: "authenticated-app",
|
|
Share: proto.CreateSubAgentRequest_App_AUTHENTICATED.Enum(),
|
|
Url: ptr.Ref("http://localhost:8081"),
|
|
},
|
|
{
|
|
Slug: "owner-app",
|
|
Share: proto.CreateSubAgentRequest_App_OWNER.Enum(),
|
|
Url: ptr.Ref("http://localhost:8082"),
|
|
},
|
|
{
|
|
Slug: "organization-app",
|
|
Share: proto.CreateSubAgentRequest_App_ORGANIZATION.Enum(),
|
|
Url: ptr.Ref("http://localhost:8083"),
|
|
},
|
|
},
|
|
expectedStoredApps: []expectedApp{
|
|
{
|
|
slugSuffix: "-authenticated-app",
|
|
sharingLevel: database.AppSharingLevelAuthenticated,
|
|
},
|
|
{
|
|
slugSuffix: "-organization-app",
|
|
sharingLevel: database.AppSharingLevelOrganization,
|
|
},
|
|
{
|
|
slugSuffix: "-owner-app",
|
|
sharingLevel: database.AppSharingLevelOwner,
|
|
},
|
|
{
|
|
slugSuffix: "-public-app",
|
|
sharingLevel: database.AppSharingLevelAuthenticated,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "PublicAllowsPublicAuthenticatedOrganizationAndOwner",
|
|
maxPortShareLevel: database.AppSharingLevelPublic,
|
|
apps: []*proto.CreateSubAgentRequest_App{
|
|
{
|
|
Slug: "public-app",
|
|
Share: proto.CreateSubAgentRequest_App_PUBLIC.Enum(),
|
|
Url: ptr.Ref("http://localhost:8080"),
|
|
},
|
|
{
|
|
Slug: "authenticated-app",
|
|
Share: proto.CreateSubAgentRequest_App_AUTHENTICATED.Enum(),
|
|
Url: ptr.Ref("http://localhost:8081"),
|
|
},
|
|
{
|
|
Slug: "owner-app",
|
|
Share: proto.CreateSubAgentRequest_App_OWNER.Enum(),
|
|
Url: ptr.Ref("http://localhost:8082"),
|
|
},
|
|
{
|
|
Slug: "organization-app",
|
|
Share: proto.CreateSubAgentRequest_App_ORGANIZATION.Enum(),
|
|
Url: ptr.Ref("http://localhost:8083"),
|
|
},
|
|
},
|
|
expectedStoredApps: []expectedApp{
|
|
{
|
|
slugSuffix: "-authenticated-app",
|
|
sharingLevel: database.AppSharingLevelAuthenticated,
|
|
},
|
|
{
|
|
slugSuffix: "-organization-app",
|
|
sharingLevel: database.AppSharingLevelOrganization,
|
|
},
|
|
{
|
|
slugSuffix: "-owner-app",
|
|
sharingLevel: database.AppSharingLevelOwner,
|
|
},
|
|
{
|
|
slugSuffix: "-public-app",
|
|
sharingLevel: database.AppSharingLevelPublic,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "OrganizationClampsAuthenticatedAndPublic",
|
|
maxPortShareLevel: database.AppSharingLevelOrganization,
|
|
apps: []*proto.CreateSubAgentRequest_App{
|
|
{
|
|
Slug: "authenticated-app",
|
|
Share: proto.CreateSubAgentRequest_App_AUTHENTICATED.Enum(),
|
|
Url: ptr.Ref("http://localhost:8080"),
|
|
},
|
|
{
|
|
Slug: "public-app",
|
|
Share: proto.CreateSubAgentRequest_App_PUBLIC.Enum(),
|
|
Url: ptr.Ref("http://localhost:8081"),
|
|
},
|
|
{
|
|
Slug: "owner-app",
|
|
Share: proto.CreateSubAgentRequest_App_OWNER.Enum(),
|
|
Url: ptr.Ref("http://localhost:8082"),
|
|
},
|
|
{
|
|
Slug: "organization-app",
|
|
Share: proto.CreateSubAgentRequest_App_ORGANIZATION.Enum(),
|
|
Url: ptr.Ref("http://localhost:8083"),
|
|
},
|
|
},
|
|
expectedStoredApps: []expectedApp{
|
|
{
|
|
slugSuffix: "-authenticated-app",
|
|
sharingLevel: database.AppSharingLevelOrganization,
|
|
},
|
|
{
|
|
slugSuffix: "-organization-app",
|
|
sharingLevel: database.AppSharingLevelOrganization,
|
|
},
|
|
{
|
|
slugSuffix: "-owner-app",
|
|
sharingLevel: database.AppSharingLevelOwner,
|
|
},
|
|
{
|
|
slugSuffix: "-public-app",
|
|
sharingLevel: database.AppSharingLevelOrganization,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "OwnerClampsOrganizationAuthenticatedAndPublic",
|
|
maxPortShareLevel: database.AppSharingLevelOwner,
|
|
apps: []*proto.CreateSubAgentRequest_App{
|
|
{
|
|
Slug: "authenticated-app",
|
|
Share: proto.CreateSubAgentRequest_App_AUTHENTICATED.Enum(),
|
|
Url: ptr.Ref("http://localhost:8080"),
|
|
},
|
|
{
|
|
Slug: "public-app",
|
|
Share: proto.CreateSubAgentRequest_App_PUBLIC.Enum(),
|
|
Url: ptr.Ref("http://localhost:8081"),
|
|
},
|
|
{
|
|
Slug: "owner-app",
|
|
Share: proto.CreateSubAgentRequest_App_OWNER.Enum(),
|
|
Url: ptr.Ref("http://localhost:8082"),
|
|
},
|
|
{
|
|
Slug: "organization-app",
|
|
Share: proto.CreateSubAgentRequest_App_ORGANIZATION.Enum(),
|
|
Url: ptr.Ref("http://localhost:8083"),
|
|
},
|
|
},
|
|
expectedStoredApps: []expectedApp{
|
|
{
|
|
slugSuffix: "-authenticated-app",
|
|
sharingLevel: database.AppSharingLevelOwner,
|
|
},
|
|
{
|
|
slugSuffix: "-organization-app",
|
|
sharingLevel: database.AppSharingLevelOwner,
|
|
},
|
|
{
|
|
slugSuffix: "-owner-app",
|
|
sharingLevel: database.AppSharingLevelOwner,
|
|
},
|
|
{
|
|
slugSuffix: "-public-app",
|
|
sharingLevel: database.AppSharingLevelOwner,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, api, upsertedApps := newMockSubAgentAPIWithMaxPortShareLevel(t, tt.maxPortShareLevel, len(tt.apps))
|
|
resp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
|
|
Name: "child-agent",
|
|
Directory: "/workspaces/coder",
|
|
Architecture: "amd64",
|
|
OperatingSystem: "linux",
|
|
Apps: tt.apps,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.Agent)
|
|
require.Empty(t, resp.AppCreationErrors)
|
|
require.Len(t, *upsertedApps, len(tt.expectedStoredApps))
|
|
|
|
slices.SortFunc(*upsertedApps, func(a, b database.UpsertWorkspaceAppParams) int {
|
|
return cmp.Compare(appSlugSuffix(a.Slug), appSlugSuffix(b.Slug))
|
|
})
|
|
slices.SortFunc(tt.expectedStoredApps, func(a, b expectedApp) int {
|
|
return cmp.Compare(a.slugSuffix, b.slugSuffix)
|
|
})
|
|
|
|
for i, expectedApp := range tt.expectedStoredApps {
|
|
require.Equal(t, expectedApp.slugSuffix, appSlugSuffix((*upsertedApps)[i].Slug))
|
|
require.Equal(t, expectedApp.sharingLevel, (*upsertedApps)[i].SharingLevel)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func appSlugSuffix(slug string) string {
|
|
_, suffix, ok := strings.Cut(slug, "-")
|
|
if !ok {
|
|
return slug
|
|
}
|
|
return "-" + suffix
|
|
}
|
|
|
|
func newMockSubAgentAPIWithMaxPortShareLevel(
|
|
t *testing.T,
|
|
maxPortShareLevel database.AppSharingLevel,
|
|
appCount int,
|
|
) (context.Context, *agentapi.SubAgentAPI, *[]database.UpsertWorkspaceAppParams) {
|
|
t.Helper()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
log := testutil.Logger(t)
|
|
clock := quartz.NewMock(t)
|
|
ownerID := uuid.New()
|
|
organizationID := uuid.New()
|
|
templateID := uuid.New()
|
|
parentAgent := database.WorkspaceAgent{
|
|
ID: uuid.New(),
|
|
ResourceID: uuid.New(),
|
|
}
|
|
workspace := database.Workspace{
|
|
ID: uuid.New(),
|
|
OwnerID: ownerID,
|
|
OrganizationID: organizationID,
|
|
TemplateID: templateID,
|
|
}
|
|
template := database.Template{
|
|
ID: templateID,
|
|
MaxPortSharingLevel: maxPortShareLevel,
|
|
}
|
|
upsertedApps := []database.UpsertWorkspaceAppParams{}
|
|
|
|
db := dbmock.NewMockStore(gomock.NewController(t))
|
|
db.EXPECT().GetWorkspaceByAgentID(gomock.Any(), parentAgent.ID).Return(workspace, nil)
|
|
db.EXPECT().GetTemplateByID(gomock.Any(), templateID).Return(template, nil)
|
|
db.EXPECT().InsertWorkspaceAgent(gomock.Any(), gomock.Any()).DoAndReturn(
|
|
func(_ context.Context, params database.InsertWorkspaceAgentParams) (database.WorkspaceAgent, error) {
|
|
require.True(t, params.ParentID.Valid)
|
|
require.Equal(t, parentAgent.ID, params.ParentID.UUID)
|
|
|
|
return database.WorkspaceAgent{
|
|
ID: params.ID,
|
|
Name: params.Name,
|
|
AuthToken: params.AuthToken,
|
|
}, nil
|
|
},
|
|
)
|
|
db.EXPECT().UpsertWorkspaceApp(gomock.Any(), gomock.Any()).DoAndReturn(
|
|
func(_ context.Context, params database.UpsertWorkspaceAppParams) (database.WorkspaceApp, error) {
|
|
upsertedApps = append(upsertedApps, params)
|
|
return database.WorkspaceApp{
|
|
ID: params.ID,
|
|
AgentID: params.AgentID,
|
|
Slug: params.Slug,
|
|
SharingLevel: params.SharingLevel,
|
|
}, nil
|
|
},
|
|
).Times(appCount)
|
|
|
|
portSharer := &atomic.Pointer[agplportsharing.PortSharer]{}
|
|
var ps agplportsharing.PortSharer = entportsharing.NewEnterprisePortSharer()
|
|
portSharer.Store(&ps)
|
|
api := &agentapi.SubAgentAPI{
|
|
OwnerID: ownerID,
|
|
OrganizationID: organizationID,
|
|
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
|
return parentAgent, nil
|
|
},
|
|
Log: log,
|
|
Clock: clock,
|
|
Database: db,
|
|
PortSharer: portSharer,
|
|
}
|
|
|
|
return ctx, api, &upsertedApps
|
|
}
|
|
|
|
func TestDevcontainerSubAgentAppShareClampedByEnterpriseTemplateMaxPortShareLevel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, db, client := newDevcontainerSubAgentClientWithMaxPortShareLevel(t, database.AppSharingLevelAuthenticated)
|
|
subAgent, err := client.Create(ctx, agentcontainers.SubAgent{
|
|
Name: "devcontainer",
|
|
Directory: "/workspaces/coder",
|
|
Architecture: "amd64",
|
|
OperatingSystem: "linux",
|
|
Apps: []agentcontainers.SubAgentApp{
|
|
{
|
|
Slug: "public-app",
|
|
URL: "http://localhost:8080",
|
|
Share: codersdk.WorkspaceAppSharingLevelPublic,
|
|
},
|
|
{
|
|
Slug: "owner-app",
|
|
URL: "http://localhost:8081",
|
|
Share: codersdk.WorkspaceAppSharingLevelOwner,
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, uuid.Nil, subAgent.ID)
|
|
|
|
apps, err := db.GetWorkspaceAppsByAgentID(ctx, subAgent.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, apps, 2)
|
|
slices.SortFunc(apps, func(a, b database.WorkspaceApp) int {
|
|
return cmp.Compare(appSlugSuffix(a.Slug), appSlugSuffix(b.Slug))
|
|
})
|
|
require.Equal(t, "-owner-app", appSlugSuffix(apps[0].Slug))
|
|
require.Equal(t, database.AppSharingLevelOwner, apps[0].SharingLevel)
|
|
require.Equal(t, "-public-app", appSlugSuffix(apps[1].Slug))
|
|
require.Equal(t, database.AppSharingLevelAuthenticated, apps[1].SharingLevel)
|
|
}
|
|
|
|
func TestDevcontainerCoderAppShareClampedWithGroupRestrictedEnterpriseTemplateACL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, db, client := newDevcontainerSubAgentClientWithMaxPortShareLevel(t,
|
|
database.AppSharingLevelAuthenticated,
|
|
withGroupRestrictedTemplateACL,
|
|
)
|
|
subAgent, err := client.Create(ctx, agentcontainers.SubAgent{
|
|
Name: "devcontainer",
|
|
Directory: "/workspaces/coder",
|
|
Architecture: "amd64",
|
|
OperatingSystem: "linux",
|
|
Apps: []agentcontainers.SubAgentApp{
|
|
{
|
|
Slug: "public-app",
|
|
URL: "http://localhost:8080",
|
|
Share: codersdk.WorkspaceAppSharingLevelPublic,
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
apps, err := db.GetWorkspaceAppsByAgentID(ctx, subAgent.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, apps, 1)
|
|
require.Equal(t, "-public-app", appSlugSuffix(apps[0].Slug))
|
|
require.Equal(t, database.AppSharingLevelAuthenticated, apps[0].SharingLevel)
|
|
}
|
|
|
|
type devcontainerSubAgentClientOption func(testing.TB, database.Store, database.Organization, database.User, *database.Template)
|
|
|
|
func newDevcontainerSubAgentClientWithMaxPortShareLevel(
|
|
t *testing.T,
|
|
maxPortShareLevel database.AppSharingLevel,
|
|
options ...devcontainerSubAgentClientOption,
|
|
) (context.Context, database.Store, agentcontainers.SubAgentClient) {
|
|
t.Helper()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
log := testutil.Logger(t)
|
|
clock := quartz.NewMock(t)
|
|
|
|
rawDB, _ := dbtestutil.NewDB(t)
|
|
org := dbgen.Organization(t, rawDB, database.Organization{})
|
|
user := dbgen.User(t, rawDB, database.User{})
|
|
template := dbgen.Template(t, rawDB, database.Template{
|
|
OrganizationID: org.ID,
|
|
CreatedBy: user.ID,
|
|
MaxPortSharingLevel: maxPortShareLevel,
|
|
})
|
|
for _, option := range options {
|
|
option(t, rawDB, org, user, &template)
|
|
}
|
|
templateVersion := dbgen.TemplateVersion(t, rawDB, database.TemplateVersion{
|
|
TemplateID: uuid.NullUUID{Valid: true, UUID: template.ID},
|
|
OrganizationID: org.ID,
|
|
CreatedBy: user.ID,
|
|
})
|
|
workspace := dbgen.Workspace(t, rawDB, database.WorkspaceTable{
|
|
OrganizationID: org.ID,
|
|
TemplateID: template.ID,
|
|
OwnerID: user.ID,
|
|
})
|
|
job := dbgen.ProvisionerJob(t, rawDB, nil, database.ProvisionerJob{
|
|
Type: database.ProvisionerJobTypeWorkspaceBuild,
|
|
OrganizationID: org.ID,
|
|
})
|
|
build := dbgen.WorkspaceBuild(t, rawDB, database.WorkspaceBuild{
|
|
JobID: job.ID,
|
|
WorkspaceID: workspace.ID,
|
|
TemplateVersionID: templateVersion.ID,
|
|
})
|
|
resource := dbgen.WorkspaceResource(t, rawDB, database.WorkspaceResource{
|
|
JobID: build.JobID,
|
|
})
|
|
parentAgent := dbgen.WorkspaceAgent(t, rawDB, database.WorkspaceAgent{
|
|
ResourceID: resource.ID,
|
|
})
|
|
|
|
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
|
|
accessControlStore := &atomic.Pointer[agpldbauthz.AccessControlStore]{}
|
|
var acs agpldbauthz.AccessControlStore = entdbauthz.EnterpriseTemplateAccessControlStore{}
|
|
accessControlStore.Store(&acs)
|
|
db := agpldbauthz.New(rawDB, auth, log, accessControlStore)
|
|
portSharer := &atomic.Pointer[agplportsharing.PortSharer]{}
|
|
var ps agplportsharing.PortSharer = entportsharing.NewEnterprisePortSharer()
|
|
portSharer.Store(&ps)
|
|
api := &agentapi.SubAgentAPI{
|
|
OwnerID: user.ID,
|
|
OrganizationID: org.ID,
|
|
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
|
return parentAgent, nil
|
|
},
|
|
Log: log,
|
|
Clock: clock,
|
|
Database: db,
|
|
PortSharer: portSharer,
|
|
}
|
|
|
|
client := agentcontainers.NewSubAgentClientFromAPI(log, devcontainerSubAgentDRPCClient{api: api})
|
|
return ctx, rawDB, client
|
|
}
|
|
|
|
func withGroupRestrictedTemplateACL(t testing.TB, db database.Store, org database.Organization, user database.User, template *database.Template) {
|
|
t.Helper()
|
|
|
|
group := dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
|
|
dbgen.GroupMember(t, db, database.GroupMemberTable{
|
|
GroupID: group.ID,
|
|
UserID: user.ID,
|
|
})
|
|
template.GroupACL = database.TemplateACL{
|
|
group.ID.String(): db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse),
|
|
}
|
|
template.UserACL = database.TemplateACL{}
|
|
require.NoError(t, db.UpdateTemplateACLByID(context.Background(), database.UpdateTemplateACLByIDParams{
|
|
ID: template.ID,
|
|
GroupACL: template.GroupACL,
|
|
UserACL: template.UserACL,
|
|
}))
|
|
}
|
|
|
|
type devcontainerSubAgentDRPCClient struct {
|
|
proto.DRPCAgentClient28
|
|
api *agentapi.SubAgentAPI
|
|
}
|
|
|
|
func (c devcontainerSubAgentDRPCClient) CreateSubAgent(ctx context.Context, req *proto.CreateSubAgentRequest) (*proto.CreateSubAgentResponse, error) {
|
|
return c.api.CreateSubAgent(ctx, req)
|
|
}
|
|
|
|
func (c devcontainerSubAgentDRPCClient) DeleteSubAgent(ctx context.Context, req *proto.DeleteSubAgentRequest) (*proto.DeleteSubAgentResponse, error) {
|
|
return c.api.DeleteSubAgent(ctx, req)
|
|
}
|
|
|
|
func (c devcontainerSubAgentDRPCClient) ListSubAgents(ctx context.Context, req *proto.ListSubAgentsRequest) (*proto.ListSubAgentsResponse, error) {
|
|
return c.api.ListSubAgents(ctx, req)
|
|
}
|