Skip to content

feat: app sharing (now open source!) #4378

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Oct 14, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: app sharing pt.2
  • Loading branch information
deansheather committed Oct 5, 2022
commit 67a7057da520c36d60c9657b5585c9f9628eb11e
12 changes: 7 additions & 5 deletions codersdk/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ const (
)

const (
FeatureUserLimit = "user_limit"
FeatureAuditLog = "audit_log"
FeatureBrowserOnly = "browser_only"
FeatureSCIM = "scim"
FeatureWorkspaceQuota = "workspace_quota"
FeatureUserLimit = "user_limit"
FeatureAuditLog = "audit_log"
FeatureBrowserOnly = "browser_only"
FeatureSCIM = "scim"
FeatureWorkspaceQuota = "workspace_quota"
FeatureApplicationSharing = "application_sharing"
)

var FeatureNames = []string{
Expand All @@ -28,6 +29,7 @@ var FeatureNames = []string{
FeatureBrowserOnly,
FeatureSCIM,
FeatureWorkspaceQuota,
FeatureApplicationSharing,
}

type Feature struct {
Expand Down
31 changes: 30 additions & 1 deletion enterprise/coderd/appsharing.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import (
// EnterpriseAppAuthorizer provides an enterprise implementation of
// agplcoderd.AppAuthorizer that allows apps to be shared at certain levels.
type EnterpriseAppAuthorizer struct {
RBAC rbac.Authorizer
RBAC rbac.Authorizer
LevelOwnerAllowed bool
LevelTemplateAllowed bool
LevelAuthenticatedAllowed bool
LevelPublicAllowed bool
}

var _ agplcoderd.AppAuthorizer = &EnterpriseAppAuthorizer{}
Expand All @@ -23,6 +27,28 @@ var _ agplcoderd.AppAuthorizer = &EnterpriseAppAuthorizer{}
func (a *EnterpriseAppAuthorizer) Authorize(r *http.Request, db database.Store, shareLevel database.AppShareLevel, workspace database.Workspace) (bool, error) {
ctx := r.Context()

// TODO: better errors displayed to the user in this case
switch shareLevel {
case database.AppShareLevelOwner:
if !a.LevelOwnerAllowed {
return false, nil
}
case database.AppShareLevelTemplate:
if !a.LevelTemplateAllowed {
return false, nil
}
case database.AppShareLevelAuthenticated:
if !a.LevelAuthenticatedAllowed {
return false, nil
}
case database.AppShareLevelPublic:
if !a.LevelPublicAllowed {
return false, nil
}
default:
return false, xerrors.Errorf("unknown workspace app sharing level %q", shareLevel)
}

// Short circuit if not authenticated.
roles, ok := httpmw.UserAuthorizationOptional(r)
if !ok {
Expand All @@ -33,6 +59,9 @@ func (a *EnterpriseAppAuthorizer) Authorize(r *http.Request, db database.Store,

// Do a standard RBAC check. This accounts for share level "owner" and any
// other RBAC rules that may be in place.
//
// Regardless of share level, the owner of the workspace can always access
// applications.
err := a.RBAC.ByRoleName(ctx, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), rbac.ActionCreate, workspace.ApplicationConnectRBAC())
if err == nil {
return true, nil
Expand Down
42 changes: 29 additions & 13 deletions enterprise/coderd/appsharing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,13 @@ import (
"github.com/stretchr/testify/require"

"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/coderd/coderdenttest"
"github.com/coder/coder/testutil"
)

func TestEnterpriseAppAuthorizer(t *testing.T) {
t.Parallel()

func setupAppAuthorizerTest(t *testing.T, allowedSharingLevels []database.AppShareLevel) (workspace codersdk.Workspace, agent codersdk.WorkspaceAgent, user codersdk.User, client *codersdk.Client, clientWithTemplateAccess *codersdk.Client, clientWithNoTemplateAccess *codersdk.Client, clientWithNoAuth *codersdk.Client) {
//nolint:gosec
const password = "password"

Expand All @@ -42,23 +41,23 @@ func TestEnterpriseAppAuthorizer(t *testing.T) {
require.True(t, ok)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
t.Cleanup(cancel)

// Setup a user, template with apps, workspace on a coderdtest using the
// EnterpriseAppAuthorizer.
client := coderdenttest.New(t, &coderdenttest.Options{
client = coderdenttest.New(t, &coderdenttest.Options{
AllowedApplicationSharingLevels: allowedSharingLevels,
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
})
firstUser := coderdtest.CreateFirstUser(t, client)
user, err := client.User(ctx, firstUser.UserID.String())
user, err = client.User(ctx, firstUser.UserID.String())
require.NoError(t, err)
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
// TODO: license stuff
BrowserOnly: true,
ApplicationSharing: true,
})
workspace, agent := setupWorkspaceAgent(t, client, firstUser, uint16(tcpAddr.Port))
workspace, agent = setupWorkspaceAgent(t, client, firstUser, uint16(tcpAddr.Port))

// Create a user in the same org (should be able to read the template).
userWithTemplateAccess, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
Expand All @@ -69,7 +68,7 @@ func TestEnterpriseAppAuthorizer(t *testing.T) {
})
require.NoError(t, err)

clientWithTemplateAccess := codersdk.New(client.URL)
clientWithTemplateAccess = codersdk.New(client.URL)
loginRes, err := clientWithTemplateAccess.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: userWithTemplateAccess.Email,
Password: password,
Expand All @@ -80,7 +79,7 @@ func TestEnterpriseAppAuthorizer(t *testing.T) {
// Create a user in a different org (should not be able to read the
// template).
differentOrg, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
Name: "a different org",
Name: "a-different-org",
})
require.NoError(t, err)
userWithNoTemplateAccess, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
Expand All @@ -91,7 +90,7 @@ func TestEnterpriseAppAuthorizer(t *testing.T) {
})
require.NoError(t, err)

clientWithNoTemplateAccess := codersdk.New(client.URL)
clientWithNoTemplateAccess = codersdk.New(client.URL)
loginRes, err = clientWithNoTemplateAccess.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: userWithNoTemplateAccess.Email,
Password: password,
Expand All @@ -100,11 +99,28 @@ func TestEnterpriseAppAuthorizer(t *testing.T) {
clientWithNoTemplateAccess.SessionToken = loginRes.SessionToken

// Create an unauthenticated codersdk client.
clientWithNoAuth := codersdk.New(client.URL)
clientWithNoAuth = codersdk.New(client.URL)

return workspace, agent, user, client, clientWithTemplateAccess, clientWithNoTemplateAccess, clientWithNoAuth
}

func TestEnterpriseAppAuthorizer(t *testing.T) {
t.Parallel()

// For the purposes of these tests we allow all levels.
workspace, agent, user, client, clientWithTemplateAccess, clientWithNoTemplateAccess, clientWithNoAuth := setupAppAuthorizerTest(t, []database.AppShareLevel{
database.AppShareLevelOwner,
database.AppShareLevelTemplate,
database.AppShareLevelAuthenticated,
database.AppShareLevelPublic,
})

verifyAccess := func(t *testing.T, appName string, client *codersdk.Client, shouldHaveAccess bool) {
t.Helper()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

appPath := fmt.Sprintf("/@%s/%s.%s/apps/%s", user.Username, workspace.Name, agent.Name, appName)
res, err := client.Request(ctx, http.MethodGet, appPath, nil)
require.NoError(t, err)
Expand Down
96 changes: 82 additions & 14 deletions enterprise/coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import (

"cdr.dev/slog"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/audit"
"github.com/coder/coder/enterprise/audit/backends"
Expand All @@ -32,6 +34,42 @@ func New(ctx context.Context, options *Options) (*API, error) {
if options.Keys == nil {
options.Keys = Keys
}
if options.Options == nil {
options.Options = &coderd.Options{}
}
if options.Options.Authorizer == nil {
options.Options.Authorizer = rbac.NewAuthorizer()
}
if options.Options.AppAuthorizer == nil {
var (
// The default is that only level "owner" should be allowed.
levelOwnerAllowed = len(options.AllowedApplicationSharingLevels) == 0
levelTemplateAllowed = false
levelAuthenticatedAllowed = false
levelPublicAllowed = false
)
for _, v := range options.AllowedApplicationSharingLevels {
switch v {
case database.AppShareLevelOwner:
levelOwnerAllowed = true
case database.AppShareLevelTemplate:
levelTemplateAllowed = true
case database.AppShareLevelAuthenticated:
levelAuthenticatedAllowed = true
case database.AppShareLevelPublic:
levelPublicAllowed = true
default:
return nil, xerrors.Errorf("unknown workspace app sharing level %q", v)
}
}
options.Options.AppAuthorizer = &EnterpriseAppAuthorizer{
RBAC: options.Options.Authorizer,
LevelOwnerAllowed: levelOwnerAllowed,
LevelTemplateAllowed: levelTemplateAllowed,
LevelAuthenticatedAllowed: levelAuthenticatedAllowed,
LevelPublicAllowed: levelPublicAllowed,
}
}
ctx, cancelFunc := context.WithCancel(ctx)
api := &API{
AGPL: coderd.New(options.Options),
Expand All @@ -42,10 +80,11 @@ func New(ctx context.Context, options *Options) (*API, error) {
Entitlement: codersdk.EntitlementNotEntitled,
Enabled: false,
},
auditLogs: codersdk.EntitlementNotEntitled,
browserOnly: codersdk.EntitlementNotEntitled,
scim: codersdk.EntitlementNotEntitled,
workspaceQuota: codersdk.EntitlementNotEntitled,
auditLogs: codersdk.EntitlementNotEntitled,
browserOnly: codersdk.EntitlementNotEntitled,
scim: codersdk.EntitlementNotEntitled,
workspaceQuota: codersdk.EntitlementNotEntitled,
applicationSharing: codersdk.EntitlementNotEntitled,
},
cancelEntitlementsLoop: cancelFunc,
}
Expand Down Expand Up @@ -106,6 +145,9 @@ type Options struct {
BrowserOnly bool
SCIMAPIKey []byte
UserWorkspaceQuota int
// Defaults to []database.AppShareLevel{database.AppShareLevelOwner} which
// essentially means "function identically to AGPL Coder".
AllowedApplicationSharingLevels []database.AppShareLevel

EntitlementsUpdateInterval time.Duration
Keys map[string]ed25519.PublicKey
Expand All @@ -121,12 +163,13 @@ type API struct {
}

type entitlements struct {
hasLicense bool
activeUsers codersdk.Feature
auditLogs codersdk.Entitlement
browserOnly codersdk.Entitlement
scim codersdk.Entitlement
workspaceQuota codersdk.Entitlement
hasLicense bool
activeUsers codersdk.Feature
auditLogs codersdk.Entitlement
browserOnly codersdk.Entitlement
scim codersdk.Entitlement
workspaceQuota codersdk.Entitlement
applicationSharing codersdk.Entitlement
}

func (api *API) Close() error {
Expand All @@ -150,10 +193,11 @@ func (api *API) updateEntitlements(ctx context.Context) error {
Enabled: false,
Entitlement: codersdk.EntitlementNotEntitled,
},
auditLogs: codersdk.EntitlementNotEntitled,
scim: codersdk.EntitlementNotEntitled,
browserOnly: codersdk.EntitlementNotEntitled,
workspaceQuota: codersdk.EntitlementNotEntitled,
auditLogs: codersdk.EntitlementNotEntitled,
scim: codersdk.EntitlementNotEntitled,
browserOnly: codersdk.EntitlementNotEntitled,
workspaceQuota: codersdk.EntitlementNotEntitled,
applicationSharing: codersdk.EntitlementNotEntitled,
}

// Here we loop through licenses to detect enabled features.
Expand Down Expand Up @@ -195,6 +239,9 @@ func (api *API) updateEntitlements(ctx context.Context) error {
if claims.Features.WorkspaceQuota > 0 {
entitlements.workspaceQuota = entitlement
}
if claims.Features.ApplicationSharing > 0 {
entitlements.applicationSharing = entitlement
}
}

if entitlements.auditLogs != api.entitlements.auditLogs {
Expand Down Expand Up @@ -308,6 +355,27 @@ func (api *API) serveEntitlements(rw http.ResponseWriter, r *http.Request) {
}
}

// App sharing is disabled if no levels are allowed or the only allowed
// level is "owner".
appSharingEnabled := true
if len(api.AllowedApplicationSharingLevels) == 0 || (len(api.AllowedApplicationSharingLevels) == 1 && api.AllowedApplicationSharingLevels[0] == database.AppShareLevelOwner) {
appSharingEnabled = false
}
resp.Features[codersdk.FeatureApplicationSharing] = codersdk.Feature{
Entitlement: entitlements.applicationSharing,
Enabled: appSharingEnabled,
}
if appSharingEnabled {
if entitlements.applicationSharing == codersdk.EntitlementNotEntitled {
resp.Warnings = append(resp.Warnings,
"Application sharing is enabled but your license is not entitled to this feature.")
}
if entitlements.applicationSharing == codersdk.EntitlementGracePeriod {
resp.Warnings = append(resp.Warnings,
"Application sharing is enabled but your license for this feature is expired.")
}
}

httpapi.Write(ctx, rw, http.StatusOK, resp)
}

Expand Down
Loading