mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: Add browser-only connections to Enterprise (#4135)
* feat: Add browser-only connections to Enterprise Fixes #4131. * Fix formatting
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user