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
+3 -2
View File
@@ -489,8 +489,9 @@ func New(options *Options) *API {
type API struct {
*Options
Auditor atomic.Pointer[audit.Auditor]
HTTPAuth *HTTPAuthorizer
Auditor atomic.Pointer[audit.Auditor]
WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool]
HTTPAuth *HTTPAuthorizer
// APIHandler serves "/api/v2"
APIHandler chi.Router
+5
View File
@@ -403,6 +403,11 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
httpapi.ResourceNotFound(rw)
return
}
// This is used by Enterprise code to control the functionality of this route.
override := api.WorkspaceClientCoordinateOverride.Load()
if override != nil && (*override)(rw) {
return
}
api.websocketWaitMutex.Lock()
api.websocketWaitGroup.Add(1)
+5 -4
View File
@@ -15,12 +15,13 @@ const (
)
const (
FeatureUserLimit = "user_limit"
FeatureAuditLog = "audit_log"
FeatureSCIM = "scim"
FeatureUserLimit = "user_limit"
FeatureAuditLog = "audit_log"
FeatureBrowserOnly = "browser_only"
FeatureSCIM = "scim"
)
var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog, FeatureSCIM}
var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog, FeatureBrowserOnly, FeatureSCIM}
type Feature struct {
Entitlement Entitlement `json:"entitlement"`
+22 -2
View File
@@ -270,12 +270,14 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
}
ctx, cancelFunc := context.WithCancel(ctx)
closed := make(chan struct{})
first := make(chan error)
go func() {
defer close(closed)
isFirst := true
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
logger.Debug(ctx, "connecting")
// nolint:bodyclose
ws, _, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{
ws, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{
HTTPClient: httpClient,
// Need to disable compression to avoid a data-race.
CompressionMode: websocket.CompressionDisabled,
@@ -283,10 +285,22 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
if errors.Is(err, context.Canceled) {
return
}
if isFirst {
if res.StatusCode == http.StatusConflict {
first <- readBodyAsError(res)
return
}
isFirst = false
close(first)
}
if err != nil {
logger.Debug(ctx, "failed to dial", slog.Error(err))
continue
}
if isFirst {
isFirst = false
close(first)
}
sendNode, errChan := tailnet.ServeCoordinator(websocket.NetConn(ctx, ws, websocket.MessageBinary), func(node []*tailnet.Node) error {
return conn.UpdateNodes(node)
})
@@ -305,13 +319,19 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
_ = ws.Close(websocket.StatusAbnormalClosure, "")
}
}()
err = <-first
if err != nil {
cancelFunc()
_ = conn.Close()
return nil, err
}
return &agent.Conn{
Conn: conn,
CloseFunc: func() {
cancelFunc()
<-closed
},
}, nil
}, err
}
// WorkspaceAgent returns an agent by ID.
+1
View File
@@ -6,6 +6,7 @@ Contact sales@coder.com to obtain a license.
These features are:
* Audit Logging
* Browser Only Connections
## Adding your license key
+3 -1
View File
@@ -57,12 +57,14 @@ func TestFeaturesList(t *testing.T) {
var entitlements codersdk.Entitlements
err := json.Unmarshal(buf.Bytes(), &entitlements)
require.NoError(t, err, "unmarshal JSON output")
assert.Len(t, entitlements.Features, 2)
assert.Len(t, entitlements.Features, 3)
assert.Empty(t, entitlements.Warnings)
assert.Equal(t, codersdk.EntitlementNotEntitled,
entitlements.Features[codersdk.FeatureUserLimit].Entitlement)
assert.Equal(t, codersdk.EntitlementNotEntitled,
entitlements.Features[codersdk.FeatureAuditLog].Entitlement)
assert.Equal(t, codersdk.EntitlementNotEntitled,
entitlements.Features[codersdk.FeatureBrowserOnly].Entitlement)
assert.False(t, entitlements.HasLicense)
})
}
+10 -2
View File
@@ -6,6 +6,7 @@ import (
"github.com/spf13/cobra"
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/enterprise/coderd"
agpl "github.com/coder/coder/cli"
@@ -15,11 +16,13 @@ import (
func server() *cobra.Command {
var (
auditLogging bool
browserOnly bool
scimAuthHeader string
)
cmd := agpl.Server(func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, error) {
api, err := coderd.New(ctx, &coderd.Options{
AuditLogging: auditLogging,
BrowserOnly: browserOnly,
SCIMAPIKey: []byte(scimAuthHeader),
Options: options,
})
@@ -28,9 +31,14 @@ func server() *cobra.Command {
}
return api.AGPL, nil
})
enterpriseOnly := cliui.Styles.Keyword.Render("This is an Enterprise feature. Contact sales@coder.com for licensing")
cliflag.BoolVarP(cmd.Flags(), &auditLogging, "audit-logging", "", "CODER_AUDIT_LOGGING", true,
"Specifies whether audit logging is enabled.")
cliflag.StringVarP(cmd.Flags(), &scimAuthHeader, "scim-auth-header", "", "CODER_SCIM_API_KEY", "", "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication.")
"Specifies whether audit logging is enabled. "+enterpriseOnly)
cliflag.BoolVarP(cmd.Flags(), &browserOnly, "browser-only", "", "CODER_BROWSER_ONLY", false,
"Whether Coder only allows connections to workspaces via the browser. "+enterpriseOnly)
cliflag.StringVarP(cmd.Flags(), &scimAuthHeader, "scim-auth-header", "", "CODER_SCIM_API_KEY", "",
"Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication. "+enterpriseOnly)
return cmd
}
+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
}
+1
View File
@@ -19,4 +19,5 @@ export type Message = { message: string }
export enum FeatureNames {
AuditLog = "audit_log",
UserLimit = "user_limit",
BrowserOnly = "browser_only",
}
+8 -1
View File
@@ -41,6 +41,7 @@ interface ResourcesProps {
workspace: Workspace
canUpdateWorkspace: boolean
buildInfo?: BuildInfoResponse | undefined
hideSSHButton?: boolean
}
export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
@@ -49,6 +50,7 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
workspace,
canUpdateWorkspace,
buildInfo,
hideSSHButton,
}) => {
const styles = useStyles()
const theme: Theme = useTheme()
@@ -149,7 +151,12 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
<div className={styles.accessLinks}>
{canUpdateWorkspace && agent.status === "connected" && (
<>
<SSHButton workspaceName={workspace.name} agentName={agent.name} />
{!hideSSHButton && (
<SSHButton
workspaceName={workspace.name}
agentName={agent.name}
/>
)}
<TerminalLink
workspaceName={workspace.name}
agentName={agent.name}
@@ -43,6 +43,7 @@ export interface WorkspaceProps {
resources?: TypesGen.WorkspaceResource[]
builds?: TypesGen.WorkspaceBuild[]
canUpdateWorkspace: boolean
hideSSHButton?: boolean
workspaceErrors: Partial<Record<WorkspaceErrors, Error | unknown>>
buildInfo?: TypesGen.BuildInfoResponse
}
@@ -63,6 +64,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
builds,
canUpdateWorkspace,
workspaceErrors,
hideSSHButton,
buildInfo,
}) => {
const styles = useStyles()
@@ -137,6 +139,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
workspace={workspace}
canUpdateWorkspace={canUpdateWorkspace}
buildInfo={buildInfo}
hideSSHButton={hideSSHButton}
/>
)}
@@ -1,11 +1,13 @@
import { makeStyles } from "@material-ui/core/styles"
import { useActor, useMachine, useSelector } from "@xstate/react"
import { FeatureNames } from "api/types"
import dayjs from "dayjs"
import minMax from "dayjs/plugin/minMax"
import { FC, useContext, useEffect } from "react"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { DeleteDialog } from "../../components/Dialogs/DeleteDialog/DeleteDialog"
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
@@ -30,6 +32,7 @@ export const WorkspacePage: FC = () => {
const xServices = useContext(XServiceContext)
const me = useSelector(xServices.authXService, selectUser)
const featureVisibility = useSelector(xServices.entitlementsXService, selectFeatureVisibility)
const [workspaceState, workspaceSend] = useMachine(workspaceMachine, {
context: {
@@ -130,6 +133,7 @@ export const WorkspacePage: FC = () => {
resources={workspace.latest_build.resources}
builds={builds}
canUpdateWorkspace={canUpdateWorkspace}
hideSSHButton={featureVisibility[FeatureNames.BrowserOnly]}
workspaceErrors={{
[WorkspaceErrors.GET_RESOURCES_ERROR]: refreshWorkspaceWarning,
[WorkspaceErrors.GET_BUILDS_ERROR]: getBuildsError,
+4
View File
@@ -768,6 +768,10 @@ export const MockEntitlementsWithWarnings: TypesGen.Entitlements = {
enabled: true,
entitlement: "entitled",
},
browser_only: {
enabled: true,
entitlement: "entitled",
},
},
}