feat: Add browser-only connections to Enterprise (#4135)

* feat: Add browser-only connections to Enterprise

Fixes #4131.

* Fix formatting
This commit is contained in:
Kyle Carberry
2022-09-22 15:14:22 +00:00
committed by GitHub
parent 656dcc0050
commit 7ad4276224
19 changed files with 263 additions and 41 deletions
+28 -5
View File
@@ -52,7 +52,6 @@ func New(ctx context.Context, options *Options) (*API, error) {
OIDC: options.OIDCConfig,
}
apiKeyMiddleware := httpmw.ExtractAPIKey(options.Database, oauthConfigs, false)
api.AGPL.APIHandler.Group(func(r chi.Router) {
r.Get("/entitlements", api.serveEntitlements)
r.Route("/licenses", func(r chi.Router) {
@@ -88,7 +87,9 @@ func New(ctx context.Context, options *Options) (*API, error) {
type Options struct {
*coderd.Options
AuditLogging bool
AuditLogging bool
// Whether to block non-browser connections.
BrowserOnly bool
SCIMAPIKey []byte
EntitlementsUpdateInterval time.Duration
Keys map[string]ed25519.PublicKey
@@ -107,6 +108,7 @@ type entitlements struct {
hasLicense bool
activeUsers codersdk.Feature
auditLogs codersdk.Entitlement
browserOnly codersdk.Entitlement
scim codersdk.Entitlement
}
@@ -131,8 +133,9 @@ func (api *API) updateEntitlements(ctx context.Context) error {
Enabled: false,
Entitlement: codersdk.EntitlementNotEntitled,
},
auditLogs: codersdk.EntitlementNotEntitled,
scim: codersdk.EntitlementNotEntitled,
auditLogs: codersdk.EntitlementNotEntitled,
scim: codersdk.EntitlementNotEntitled,
browserOnly: codersdk.EntitlementNotEntitled,
}
// Here we loop through licenses to detect enabled features.
@@ -165,6 +168,9 @@ func (api *API) updateEntitlements(ctx context.Context) error {
if claims.Features.AuditLog > 0 {
entitlements.auditLogs = entitlement
}
if claims.Features.BrowserOnly > 0 {
entitlements.browserOnly = entitlement
}
if claims.Features.SCIM > 0 {
entitlements.scim = entitlement
}
@@ -174,7 +180,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
auditor := agplaudit.NewNop()
// A flag could be added to the options that would allow disabling
// enhanced audit logging here!
if entitlements.auditLogs == codersdk.EntitlementEntitled && api.AuditLogging {
if entitlements.auditLogs != codersdk.EntitlementNotEntitled && api.AuditLogging {
auditor = audit.NewAuditor(
audit.DefaultFilter,
backends.NewPostgres(api.Database, true),
@@ -184,6 +190,14 @@ func (api *API) updateEntitlements(ctx context.Context) error {
api.AGPL.Auditor.Store(&auditor)
}
if entitlements.browserOnly != api.entitlements.browserOnly {
var handler func(rw http.ResponseWriter) bool
if entitlements.browserOnly != codersdk.EntitlementNotEntitled && api.BrowserOnly {
handler = api.shouldBlockNonBrowserConnections
}
api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler)
}
api.entitlements = entitlements
return nil
@@ -230,6 +244,15 @@ func (api *API) serveEntitlements(rw http.ResponseWriter, r *http.Request) {
"Audit logging is enabled but your license for this feature is expired.")
}
resp.Features[codersdk.FeatureBrowserOnly] = codersdk.Feature{
Entitlement: entitlements.browserOnly,
Enabled: api.BrowserOnly,
}
if entitlements.browserOnly == codersdk.EntitlementGracePeriod && api.BrowserOnly {
resp.Warnings = append(resp.Warnings,
"Browser only connections are enabled but your license for this feature is expired.")
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
+15 -5
View File
@@ -84,15 +84,18 @@ func TestEntitlements(t *testing.T) {
})
t.Run("Warnings", func(t *testing.T) {
t.Parallel()
client := coderdenttest.New(t, nil)
client := coderdenttest.New(t, &coderdenttest.Options{
BrowserOnly: true,
})
first := coderdtest.CreateFirstUser(t, client)
for i := 0; i < 4; i++ {
coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
}
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
UserLimit: 4,
AuditLog: true,
GraceAt: time.Now().Add(-time.Second),
UserLimit: 4,
AuditLog: true,
BrowserOnly: true,
GraceAt: time.Now().Add(-time.Second),
})
res, err := client.Entitlements(context.Background())
require.NoError(t, err)
@@ -107,11 +110,18 @@ func TestEntitlements(t *testing.T) {
assert.True(t, al.Enabled)
assert.Nil(t, al.Limit)
assert.Nil(t, al.Actual)
assert.Len(t, res.Warnings, 2)
bo := res.Features[codersdk.FeatureBrowserOnly]
assert.Equal(t, codersdk.EntitlementGracePeriod, bo.Entitlement)
assert.True(t, bo.Enabled)
assert.Nil(t, bo.Limit)
assert.Nil(t, bo.Actual)
assert.Len(t, res.Warnings, 3)
assert.Contains(t, res.Warnings,
"Your deployment has 5 active users but is only licensed for 4.")
assert.Contains(t, res.Warnings,
"Audit logging is enabled but your license for this feature is expired.")
assert.Contains(t, res.Warnings,
"Browser only connections are enabled but your license for this feature is expired.")
})
t.Run("Pubsub", func(t *testing.T) {
t.Parallel()
@@ -36,6 +36,7 @@ func init() {
type Options struct {
*coderdtest.Options
BrowserOnly bool
EntitlementsUpdateInterval time.Duration
SCIMAPIKey []byte
}
@@ -56,6 +57,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
srv, cancelFunc, oop := coderdtest.NewOptions(t, options.Options)
coderAPI, err := coderd.New(context.Background(), &coderd.Options{
AuditLogging: true,
BrowserOnly: options.BrowserOnly,
SCIMAPIKey: options.SCIMAPIKey,
Options: oop,
EntitlementsUpdateInterval: options.EntitlementsUpdateInterval,
@@ -84,6 +86,7 @@ type LicenseOptions struct {
ExpiresAt time.Time
UserLimit int64
AuditLog bool
BrowserOnly bool
SCIM bool
}
@@ -108,6 +111,10 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
if options.AuditLog {
auditLog = 1
}
browserOnly := int64(0)
if options.BrowserOnly {
browserOnly = 1
}
scim := int64(0)
if options.SCIM {
scim = 1
@@ -125,9 +132,10 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
AccountID: options.AccountID,
Version: coderd.CurrentVersion,
Features: coderd.Features{
UserLimit: options.UserLimit,
AuditLog: auditLog,
SCIM: scim,
UserLimit: options.UserLimit,
AuditLog: auditLog,
BrowserOnly: browserOnly,
SCIM: scim,
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)
+4 -3
View File
@@ -45,9 +45,10 @@ var key20220812 []byte
var Keys = map[string]ed25519.PublicKey{"2022-08-12": ed25519.PublicKey(key20220812)}
type Features struct {
UserLimit int64 `json:"user_limit"`
AuditLog int64 `json:"audit_log"`
SCIM int64 `json:"scim"`
UserLimit int64 `json:"user_limit"`
AuditLog int64 `json:"audit_log"`
BrowserOnly int64 `json:"browser_only"`
SCIM int64 `json:"scim"`
}
type Claims struct {
+17 -13
View File
@@ -78,16 +78,18 @@ func TestGetLicense(t *testing.T) {
defer cancel()
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
AccountID: "testing",
AuditLog: true,
SCIM: true,
AccountID: "testing",
AuditLog: true,
SCIM: true,
BrowserOnly: true,
})
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
AccountID: "testing2",
AuditLog: true,
SCIM: true,
UserLimit: 200,
AccountID: "testing2",
AuditLog: true,
SCIM: true,
BrowserOnly: true,
UserLimit: 200,
})
licenses, err := client.Licenses(ctx)
@@ -96,16 +98,18 @@ func TestGetLicense(t *testing.T) {
assert.Equal(t, int32(1), licenses[0].ID)
assert.Equal(t, "testing", licenses[0].Claims["account_id"])
assert.Equal(t, map[string]interface{}{
codersdk.FeatureUserLimit: json.Number("0"),
codersdk.FeatureAuditLog: json.Number("1"),
codersdk.FeatureSCIM: json.Number("1"),
codersdk.FeatureUserLimit: json.Number("0"),
codersdk.FeatureAuditLog: json.Number("1"),
codersdk.FeatureSCIM: json.Number("1"),
codersdk.FeatureBrowserOnly: json.Number("1"),
}, licenses[0].Claims["features"])
assert.Equal(t, int32(2), licenses[1].ID)
assert.Equal(t, "testing2", licenses[1].Claims["account_id"])
assert.Equal(t, map[string]interface{}{
codersdk.FeatureUserLimit: json.Number("200"),
codersdk.FeatureAuditLog: json.Number("1"),
codersdk.FeatureSCIM: json.Number("1"),
codersdk.FeatureUserLimit: json.Number("200"),
codersdk.FeatureAuditLog: json.Number("1"),
codersdk.FeatureSCIM: json.Number("1"),
codersdk.FeatureBrowserOnly: json.Number("1"),
}, licenses[1].Claims["features"])
})
}
+22
View File
@@ -0,0 +1,22 @@
package coderd
import (
"context"
"net/http"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
)
func (api *API) shouldBlockNonBrowserConnections(rw http.ResponseWriter) bool {
api.entitlementsMu.Lock()
browserOnly := api.entitlements.browserOnly
api.entitlementsMu.Unlock()
if api.BrowserOnly && browserOnly != codersdk.EntitlementNotEntitled {
httpapi.Write(context.Background(), rw, http.StatusConflict, codersdk.Response{
Message: "Non-browser connections are disabled for your deployment.",
})
return true
}
return false
}
+97
View File
@@ -0,0 +1,97 @@
package coderd_test
import (
"context"
"net/http"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/agent"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/coderd/coderdenttest"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
)
func TestBlockNonBrowser(t *testing.T) {
t.Parallel()
t.Run("Enabled", func(t *testing.T) {
t.Parallel()
client := coderdenttest.New(t, &coderdenttest.Options{
BrowserOnly: true,
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
})
user := coderdtest.CreateFirstUser(t, client)
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
BrowserOnly: true,
})
id := setupWorkspaceAgent(t, client, user)
_, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, id)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
})
t.Run("Disabled", func(t *testing.T) {
t.Parallel()
client := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
})
user := coderdtest.CreateFirstUser(t, client)
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
BrowserOnly: false,
})
id := setupWorkspaceAgent(t, client, user)
conn, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, id)
require.NoError(t, err)
_ = conn.Close()
})
}
func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse) uuid.UUID {
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Name: "example",
Auth: &proto.Agent_Token{
Token: authToken,
},
}},
}},
},
},
}},
})
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = authToken
agentCloser := agent.New(agent.Options{
FetchMetadata: agentClient.WorkspaceAgentMetadata,
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
Logger: slogtest.Make(t, nil).Named("agent"),
})
defer func() {
_ = agentCloser.Close()
}()
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
return resources[0].Agents[0].ID
}