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

This commit is contained in:
Dean Sheather
2022-10-15 02:46:38 +10:00
committed by GitHub
parent 19d7281daf
commit d898737d6d
55 changed files with 1069 additions and 412 deletions

View File

@ -34,12 +34,23 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
return
}
var createToken codersdk.CreateTokenRequest
if !httpapi.Read(ctx, rw, r, &createToken) {
return
}
scope := database.APIKeyScopeAll
if scope != "" {
scope = database.APIKeyScope(createToken.Scope)
}
// tokens last 100 years
lifeTime := time.Hour * 876000
cookie, err := api.createAPIKey(ctx, createAPIKeyParams{
UserID: user.ID,
LoginType: database.LoginTypeToken,
ExpiresAt: database.Now().Add(lifeTime),
Scope: scope,
LifetimeSeconds: int64(lifeTime.Seconds()),
})
if err != nil {
@ -54,6 +65,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
}
// Creates a new session key, used for logging in via the CLI.
// DEPRECATED: use postToken instead.
func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := httpmw.UserParam(r)
@ -229,6 +241,11 @@ func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*h
if params.Scope != "" {
scope = params.Scope
}
switch scope {
case database.APIKeyScopeAll, database.APIKeyScopeApplicationConnect:
default:
return nil, xerrors.Errorf("invalid API key scope: %q", scope)
}
key, err := api.Database.InsertAPIKey(ctx, database.InsertAPIKeyParams{
ID: keyID,

View File

@ -14,30 +14,61 @@ import (
func TestTokens(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
_ = coderdtest.CreateFirstUser(t, client)
keys, err := client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.Empty(t, keys)
res, err := client.CreateToken(ctx, codersdk.Me)
require.NoError(t, err)
require.Greater(t, len(res.Key), 2)
t.Run("CRUD", func(t *testing.T) {
t.Parallel()
keys, err = client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.EqualValues(t, len(keys), 1)
require.Contains(t, res.Key, keys[0].ID)
// expires_at must be greater than 50 years
require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300))
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
keys, err := client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.Empty(t, keys)
err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID)
require.NoError(t, err)
keys, err = client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.Empty(t, keys)
res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{})
require.NoError(t, err)
require.Greater(t, len(res.Key), 2)
keys, err = client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.EqualValues(t, len(keys), 1)
require.Contains(t, res.Key, keys[0].ID)
// expires_at must be greater than 50 years
require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300))
require.Equal(t, codersdk.APIKeyScopeAll, keys[0].Scope)
// no update
err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID)
require.NoError(t, err)
keys, err = client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.Empty(t, keys)
})
t.Run("Scoped", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Scope: codersdk.APIKeyScopeApplicationConnect,
})
require.NoError(t, err)
require.Greater(t, len(res.Key), 2)
keys, err := client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.EqualValues(t, len(keys), 1)
require.Contains(t, res.Key, keys[0].ID)
// expires_at must be greater than 50 years
require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300))
require.Equal(t, keys[0].Scope, codersdk.APIKeyScopeApplicationConnect)
})
}
func TestAPIKey(t *testing.T) {

View File

@ -197,7 +197,7 @@ func New(options *Options) *API {
RedirectToLogin: false,
Optional: true,
}),
httpmw.ExtractUserParam(api.Database),
httpmw.ExtractUserParam(api.Database, false),
httpmw.ExtractWorkspaceAndAgentParam(api.Database),
),
// Build-Version is helpful for debugging.
@ -214,8 +214,18 @@ func New(options *Options) *API {
r.Use(
tracing.Middleware(api.TracerProvider),
httpmw.RateLimitPerMinute(options.APIRateLimit),
apiKeyMiddlewareRedirect,
httpmw.ExtractUserParam(api.Database),
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
DB: options.Database,
OAuth2Configs: oauthConfigs,
// Optional is true to allow for public apps. If an
// authorization check fails and the user is not authenticated,
// they will be redirected to the login page by the app handler.
RedirectToLogin: false,
Optional: true,
}),
// Redirect to the login page if the user tries to open an app with
// "me" as the username and they are not logged in.
httpmw.ExtractUserParam(api.Database, true),
// Extracts the <workspace.agent> from the url
httpmw.ExtractWorkspaceAndAgentParam(api.Database),
)
@ -310,7 +320,7 @@ func New(options *Options) *API {
r.Get("/roles", api.assignableOrgRoles)
r.Route("/{user}", func(r chi.Router) {
r.Use(
httpmw.ExtractUserParam(options.Database),
httpmw.ExtractUserParam(options.Database, false),
httpmw.ExtractOrganizationMemberParam(options.Database),
)
r.Put("/roles", api.putMemberRoles)
@ -389,7 +399,7 @@ func New(options *Options) *API {
r.Get("/", api.assignableSiteRoles)
})
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Use(httpmw.ExtractUserParam(options.Database, false))
r.Delete("/", api.deleteUser)
r.Get("/", api.userByName)
r.Put("/profile", api.putUserProfile)

View File

@ -2324,6 +2324,10 @@ func (q *fakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertW
q.mutex.Lock()
defer q.mutex.Unlock()
if arg.SharingLevel == "" {
arg.SharingLevel = database.AppSharingLevelOwner
}
// nolint:gosimple
workspaceApp := database.WorkspaceApp{
ID: arg.ID,
@ -2334,6 +2338,7 @@ func (q *fakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertW
Command: arg.Command,
Url: arg.Url,
Subdomain: arg.Subdomain,
SharingLevel: arg.SharingLevel,
HealthcheckUrl: arg.HealthcheckUrl,
HealthcheckInterval: arg.HealthcheckInterval,
HealthcheckThreshold: arg.HealthcheckThreshold,

View File

@ -5,6 +5,12 @@ CREATE TYPE api_key_scope AS ENUM (
'application_connect'
);
CREATE TYPE app_sharing_level AS ENUM (
'owner',
'authenticated',
'public'
);
CREATE TYPE audit_action AS ENUM (
'create',
'write',
@ -371,7 +377,8 @@ CREATE TABLE workspace_apps (
healthcheck_interval integer DEFAULT 0 NOT NULL,
healthcheck_threshold integer DEFAULT 0 NOT NULL,
health workspace_app_health DEFAULT 'disabled'::public.workspace_app_health NOT NULL,
subdomain boolean DEFAULT false NOT NULL
subdomain boolean DEFAULT false NOT NULL,
sharing_level app_sharing_level DEFAULT 'owner'::public.app_sharing_level NOT NULL
);
CREATE TABLE workspace_builds (

View File

@ -0,0 +1,5 @@
-- Drop column sharing_level from workspace_apps
ALTER TABLE workspace_apps DROP COLUMN sharing_level;
-- Drop type app_sharing_level
DROP TYPE app_sharing_level;

View File

@ -0,0 +1,12 @@
-- Add enum app_sharing_level
CREATE TYPE app_sharing_level AS ENUM (
-- only the workspace owner can access the app
'owner',
-- any authenticated user on the site can access the app
'authenticated',
-- any user can access the app even if they are not authenticated
'public'
);
-- Add sharing_level column to workspace_apps table
ALTER TABLE workspace_apps ADD COLUMN sharing_level app_sharing_level NOT NULL DEFAULT 'owner'::app_sharing_level;

View File

@ -34,6 +34,26 @@ func (e *APIKeyScope) Scan(src interface{}) error {
return nil
}
type AppSharingLevel string
const (
AppSharingLevelOwner AppSharingLevel = "owner"
AppSharingLevelAuthenticated AppSharingLevel = "authenticated"
AppSharingLevelPublic AppSharingLevel = "public"
)
func (e *AppSharingLevel) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = AppSharingLevel(s)
case string:
*e = AppSharingLevel(s)
default:
return fmt.Errorf("unsupported scan type for AppSharingLevel: %T", src)
}
return nil
}
type AuditAction string
const (
@ -626,6 +646,7 @@ type WorkspaceApp struct {
HealthcheckThreshold int32 `db:"healthcheck_threshold" json:"healthcheck_threshold"`
Health WorkspaceAppHealth `db:"health" json:"health"`
Subdomain bool `db:"subdomain" json:"subdomain"`
SharingLevel AppSharingLevel `db:"sharing_level" json:"sharing_level"`
}
type WorkspaceBuild struct {

View File

@ -4324,7 +4324,7 @@ func (q *sqlQuerier) UpdateWorkspaceAgentVersionByID(ctx context.Context, arg Up
}
const getWorkspaceAppByAgentIDAndName = `-- name: GetWorkspaceAppByAgentIDAndName :one
SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain FROM workspace_apps WHERE agent_id = $1 AND name = $2
SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE agent_id = $1 AND name = $2
`
type GetWorkspaceAppByAgentIDAndNameParams struct {
@ -4348,12 +4348,13 @@ func (q *sqlQuerier) GetWorkspaceAppByAgentIDAndName(ctx context.Context, arg Ge
&i.HealthcheckThreshold,
&i.Health,
&i.Subdomain,
&i.SharingLevel,
)
return i, err
}
const getWorkspaceAppsByAgentID = `-- name: GetWorkspaceAppsByAgentID :many
SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain FROM workspace_apps WHERE agent_id = $1 ORDER BY name ASC
SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE agent_id = $1 ORDER BY name ASC
`
func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceApp, error) {
@ -4378,6 +4379,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid
&i.HealthcheckThreshold,
&i.Health,
&i.Subdomain,
&i.SharingLevel,
); err != nil {
return nil, err
}
@ -4393,7 +4395,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid
}
const getWorkspaceAppsByAgentIDs = `-- name: GetWorkspaceAppsByAgentIDs :many
SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain FROM workspace_apps WHERE agent_id = ANY($1 :: uuid [ ]) ORDER BY name ASC
SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE agent_id = ANY($1 :: uuid [ ]) ORDER BY name ASC
`
func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceApp, error) {
@ -4418,6 +4420,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.
&i.HealthcheckThreshold,
&i.Health,
&i.Subdomain,
&i.SharingLevel,
); err != nil {
return nil, err
}
@ -4433,7 +4436,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.
}
const getWorkspaceAppsCreatedAfter = `-- name: GetWorkspaceAppsCreatedAfter :many
SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain FROM workspace_apps WHERE created_at > $1 ORDER BY name ASC
SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE created_at > $1 ORDER BY name ASC
`
func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceApp, error) {
@ -4458,6 +4461,7 @@ func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt
&i.HealthcheckThreshold,
&i.Health,
&i.Subdomain,
&i.SharingLevel,
); err != nil {
return nil, err
}
@ -4483,13 +4487,14 @@ INSERT INTO
command,
url,
subdomain,
sharing_level,
healthcheck_url,
healthcheck_interval,
healthcheck_threshold,
health
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level
`
type InsertWorkspaceAppParams struct {
@ -4501,6 +4506,7 @@ type InsertWorkspaceAppParams struct {
Command sql.NullString `db:"command" json:"command"`
Url sql.NullString `db:"url" json:"url"`
Subdomain bool `db:"subdomain" json:"subdomain"`
SharingLevel AppSharingLevel `db:"sharing_level" json:"sharing_level"`
HealthcheckUrl string `db:"healthcheck_url" json:"healthcheck_url"`
HealthcheckInterval int32 `db:"healthcheck_interval" json:"healthcheck_interval"`
HealthcheckThreshold int32 `db:"healthcheck_threshold" json:"healthcheck_threshold"`
@ -4517,6 +4523,7 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace
arg.Command,
arg.Url,
arg.Subdomain,
arg.SharingLevel,
arg.HealthcheckUrl,
arg.HealthcheckInterval,
arg.HealthcheckThreshold,
@ -4536,6 +4543,7 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace
&i.HealthcheckThreshold,
&i.Health,
&i.Subdomain,
&i.SharingLevel,
)
return i, err
}

View File

@ -21,13 +21,14 @@ INSERT INTO
command,
url,
subdomain,
sharing_level,
healthcheck_url,
healthcheck_interval,
healthcheck_threshold,
health
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *;
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *;
-- name: UpdateWorkspaceAppHealthByID :exec
UPDATE

View File

@ -83,8 +83,8 @@ type OAuth2Configs struct {
}
const (
signedOutErrorMessage string = "You are signed out or your session has expired. Please sign in again to continue."
internalErrorMessage string = "An internal error occurred. Please try again or contact the system administrator."
SignedOutErrorMessage = "You are signed out or your session has expired. Please sign in again to continue."
internalErrorMessage = "An internal error occurred. Please try again or contact the system administrator."
)
type ExtractAPIKeyConfig struct {
@ -119,21 +119,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler {
// like workspace applications.
write := func(code int, response codersdk.Response) {
if cfg.RedirectToLogin {
path := r.URL.Path
if r.URL.RawQuery != "" {
path += "?" + r.URL.RawQuery
}
q := url.Values{}
q.Add("message", response.Message)
q.Add("redirect", path)
u := &url.URL{
Path: "/login",
RawQuery: q.Encode(),
}
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
RedirectToLogin(rw, r, response.Message)
return
}
@ -157,7 +143,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler {
token := apiTokenFromRequest(r)
if token == "" {
optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: signedOutErrorMessage,
Message: SignedOutErrorMessage,
Detail: fmt.Sprintf("Cookie %q or query parameter must be provided.", codersdk.SessionTokenKey),
})
return
@ -166,7 +152,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler {
keyID, keySecret, err := SplitAPIToken(token)
if err != nil {
optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: signedOutErrorMessage,
Message: SignedOutErrorMessage,
Detail: "Invalid API key format: " + err.Error(),
})
return
@ -176,7 +162,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler {
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: signedOutErrorMessage,
Message: SignedOutErrorMessage,
Detail: "API key is invalid.",
})
return
@ -192,7 +178,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler {
hashedSecret := sha256.Sum256([]byte(keySecret))
if subtle.ConstantTimeCompare(key.HashedSecret, hashedSecret[:]) != 1 {
optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: signedOutErrorMessage,
Message: SignedOutErrorMessage,
Detail: "API key secret is invalid.",
})
return
@ -255,7 +241,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler {
// Checking if the key is expired.
if key.ExpiresAt.Before(now) {
optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: signedOutErrorMessage,
Message: SignedOutErrorMessage,
Detail: fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()),
})
return
@ -422,3 +408,23 @@ func SplitAPIToken(token string) (id string, secret string, err error) {
return keyID, keySecret, nil
}
// RedirectToLogin redirects the user to the login page with the `message` and
// `redirect` query parameters set.
func RedirectToLogin(rw http.ResponseWriter, r *http.Request, message string) {
path := r.URL.Path
if r.URL.RawQuery != "" {
path += "?" + r.URL.RawQuery
}
q := url.Values{}
q.Add("message", message)
q.Add("redirect", path)
u := &url.URL{
Path: "/login",
RawQuery: q.Encode(),
}
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
}

View File

@ -148,7 +148,7 @@ func TestOrganizationParam(t *testing.T) {
DB: db,
RedirectToLogin: false,
}),
httpmw.ExtractUserParam(db),
httpmw.ExtractUserParam(db, false),
httpmw.ExtractOrganizationParam(db),
httpmw.ExtractOrganizationMemberParam(db),
)
@ -189,7 +189,7 @@ func TestOrganizationParam(t *testing.T) {
RedirectToLogin: false,
}),
httpmw.ExtractOrganizationParam(db),
httpmw.ExtractUserParam(db),
httpmw.ExtractUserParam(db, false),
httpmw.ExtractOrganizationMemberParam(db),
)
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {

View File

@ -33,8 +33,11 @@ func UserParam(r *http.Request) database.User {
return user
}
// ExtractUserParam extracts a user from an ID/username in the {user} URL parameter.
func ExtractUserParam(db database.Store) func(http.Handler) http.Handler {
// ExtractUserParam extracts a user from an ID/username in the {user} URL
// parameter.
//
//nolint:revive
func ExtractUserParam(db database.Store, redirectToLoginOnMe bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
var (
@ -53,7 +56,19 @@ func ExtractUserParam(db database.Store) func(http.Handler) http.Handler {
}
if userQuery == "me" {
user, err = db.GetUserByID(ctx, APIKey(r).UserID)
apiKey, ok := APIKeyOptional(r)
if !ok {
if redirectToLoginOnMe {
RedirectToLogin(rw, r, SignedOutErrorMessage)
return
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot use \"me\" without a valid session.",
})
return
}
user, err = db.GetUserByID(ctx, apiKey.UserID)
if xerrors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return

View File

@ -63,7 +63,7 @@ func TestUserParam(t *testing.T) {
r = returnedRequest
})).ServeHTTP(rw, r)
httpmw.ExtractUserParam(db)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
httpmw.ExtractUserParam(db, false)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
})).ServeHTTP(rw, r)
res := rw.Result()
@ -85,7 +85,7 @@ func TestUserParam(t *testing.T) {
routeContext := chi.NewRouteContext()
routeContext.URLParams.Add("user", "ben")
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeContext))
httpmw.ExtractUserParam(db)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
httpmw.ExtractUserParam(db, false)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
})).ServeHTTP(rw, r)
res := rw.Result()
@ -107,7 +107,7 @@ func TestUserParam(t *testing.T) {
routeContext := chi.NewRouteContext()
routeContext.URLParams.Add("user", "me")
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeContext))
httpmw.ExtractUserParam(db)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
httpmw.ExtractUserParam(db, false)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
_ = httpmw.UserParam(r)
rw.WriteHeader(http.StatusOK)
})).ServeHTTP(rw, r)

View File

@ -305,7 +305,7 @@ func TestWorkspaceAgentByNameParam(t *testing.T) {
DB: db,
RedirectToLogin: true,
}),
httpmw.ExtractUserParam(db),
httpmw.ExtractUserParam(db, false),
httpmw.ExtractWorkspaceAndAgentParam(db),
)
rtr.Get("/", func(w http.ResponseWriter, r *http.Request) {

View File

@ -814,6 +814,14 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
health = database.WorkspaceAppHealthInitializing
}
sharingLevel := database.AppSharingLevelOwner
switch app.SharingLevel {
case sdkproto.AppSharingLevel_AUTHENTICATED:
sharingLevel = database.AppSharingLevelAuthenticated
case sdkproto.AppSharingLevel_PUBLIC:
sharingLevel = database.AppSharingLevelPublic
}
dbApp, err := db.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{
ID: uuid.New(),
CreatedAt: database.Now(),
@ -829,6 +837,7 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
Valid: app.Url != "",
},
Subdomain: app.Subdomain,
SharingLevel: sharingLevel,
HealthcheckUrl: app.Healthcheck.Url,
HealthcheckInterval: app.Healthcheck.Interval,
HealthcheckThreshold: app.Healthcheck.Threshold,

View File

@ -1207,6 +1207,7 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey {
CreatedAt: k.CreatedAt,
UpdatedAt: k.UpdatedAt,
LoginType: codersdk.LoginType(k.LoginType),
Scope: codersdk.APIKeyScope(k.Scope),
LifetimeSeconds: k.LifetimeSeconds,
}
}

View File

@ -286,7 +286,7 @@ func TestPostLogin(t *testing.T) {
require.Equal(t, int64(86400), key.LifetimeSeconds, "default should be 86400")
// tokens have a longer life
token, err := client.CreateToken(ctx, codersdk.Me)
token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{})
require.NoError(t, err, "make new token api key")
split = strings.Split(token.Key, "-")
apiKey, err := client.GetAPIKey(ctx, admin.UserID.String(), split[0])
@ -1202,7 +1202,7 @@ func TestPostTokens(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
apiKey, err := client.CreateToken(ctx, codersdk.Me)
apiKey, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{})
require.NotNil(t, apiKey)
require.GreaterOrEqual(t, len(apiKey.Key), 2)
require.NoError(t, err)

View File

@ -594,11 +594,12 @@ func convertApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp {
apps := make([]codersdk.WorkspaceApp, 0)
for _, dbApp := range dbApps {
apps = append(apps, codersdk.WorkspaceApp{
ID: dbApp.ID,
Name: dbApp.Name,
Command: dbApp.Command.String,
Icon: dbApp.Icon,
Subdomain: dbApp.Subdomain,
ID: dbApp.ID,
Name: dbApp.Name,
Command: dbApp.Command.String,
Icon: dbApp.Icon,
Subdomain: dbApp.Subdomain,
SharingLevel: codersdk.WorkspaceAppSharingLevel(dbApp.SharingLevel),
Healthcheck: codersdk.Healthcheck{
URL: dbApp.HealthcheckUrl,
Interval: dbApp.HealthcheckInterval,

View File

@ -16,10 +16,12 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"go.opentelemetry.io/otel/trace"
"golang.org/x/xerrors"
jose "gopkg.in/square/go-jose.v2"
"cdr.dev/slog"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
@ -32,8 +34,6 @@ import (
const (
// This needs to be a super unique query parameter because we don't want to
// conflict with query parameters that users may use.
// TODO: this will make dogfooding harder so come up with a more unique
// solution
//nolint:gosec
subdomainProxyAPIKeyParam = "coder_application_connect_api_key_35e783"
redirectURIQueryParam = "redirect_uri"
@ -51,8 +51,32 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
workspace := httpmw.WorkspaceParam(r)
agent := httpmw.WorkspaceAgentParam(r)
if !api.Authorize(r, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) {
httpapi.ResourceNotFound(rw)
// We do not support port proxying on paths, so lookup the app by name.
appName := chi.URLParam(r, "workspaceapp")
app, ok := api.lookupWorkspaceApp(rw, r, agent.ID, appName)
if !ok {
return
}
appSharingLevel := database.AppSharingLevelOwner
if app.SharingLevel != "" {
appSharingLevel = app.SharingLevel
}
authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspace, appSharingLevel)
if !ok {
return
}
if !authed {
_, hasAPIKey := httpmw.APIKeyOptional(r)
if hasAPIKey {
// The request has a valid API key but insufficient permissions.
renderApplicationNotFound(rw, r, api.AccessURL)
return
}
// Redirect to login as they don't have permission to access the app and
// they aren't signed in.
httpmw.RedirectToLogin(rw, r, httpmw.SignedOutErrorMessage)
return
}
@ -67,10 +91,9 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
api.proxyWorkspaceApplication(proxyApplication{
Workspace: workspace,
Agent: agent,
// We do not support port proxying for paths.
AppName: chi.URLParam(r, "workspaceapp"),
Port: 0,
Path: chiPath,
App: &app,
Port: 0,
Path: chiPath,
}, rw, r)
}
@ -156,16 +179,30 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
workspace := httpmw.WorkspaceParam(r)
agent := httpmw.WorkspaceAgentParam(r)
var workspaceAppPtr *database.WorkspaceApp
if app.AppName != "" {
workspaceApp, ok := api.lookupWorkspaceApp(rw, r, agent.ID, app.AppName)
if !ok {
return
}
workspaceAppPtr = &workspaceApp
}
// Verify application auth. This function will redirect or
// return an error page if the user doesn't have permission.
if !api.verifyWorkspaceApplicationAuth(rw, r, workspace, host) {
sharingLevel := database.AppSharingLevelOwner
if workspaceAppPtr != nil && workspaceAppPtr.SharingLevel != "" {
sharingLevel = workspaceAppPtr.SharingLevel
}
if !api.verifyWorkspaceApplicationSubdomainAuth(rw, r, host, workspace, sharingLevel) {
return
}
api.proxyWorkspaceApplication(proxyApplication{
Workspace: workspace,
Agent: agent,
AppName: app.AppName,
App: workspaceAppPtr,
Port: app.Port,
Path: r.URL.Path,
}, rw, r)
@ -231,22 +268,139 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
return app, true
}
// verifyWorkspaceApplicationAuth checks that the request is authorized to
// access the given application. If the user does not have a app session key,
// lookupWorkspaceApp looks up the workspace application by name in the given
// agent and returns it. If the application is not found or there was a server
// error while looking it up, an HTML error page is returned and false is
// returned so the caller can return early.
func (api *API) lookupWorkspaceApp(rw http.ResponseWriter, r *http.Request, agentID uuid.UUID, appName string) (database.WorkspaceApp, bool) {
app, err := api.Database.GetWorkspaceAppByAgentIDAndName(r.Context(), database.GetWorkspaceAppByAgentIDAndNameParams{
AgentID: agentID,
Name: appName,
})
if xerrors.Is(err, sql.ErrNoRows) {
renderApplicationNotFound(rw, r, api.AccessURL)
return database.WorkspaceApp{}, false
}
if err != nil {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusInternalServerError,
Title: "Internal Server Error",
Description: "Could not fetch workspace application: " + err.Error(),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
return database.WorkspaceApp{}, false
}
return app, true
}
func (api *API) authorizeWorkspaceApp(r *http.Request, sharingLevel database.AppSharingLevel, workspace database.Workspace) (bool, error) {
ctx := r.Context()
// Short circuit if not authenticated.
roles, ok := httpmw.UserAuthorizationOptional(r)
if !ok {
// The user is not authenticated, so they can only access the app if it
// is public.
return sharingLevel == database.AppSharingLevelPublic, nil
}
// 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 or whether it's enabled or not, the owner of
// the workspace can always access applications (as long as their API key's
// scope allows it).
err := api.Authorizer.ByRoleName(ctx, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), []string{}, rbac.ActionCreate, workspace.ApplicationConnectRBAC())
if err == nil {
return true, nil
}
switch sharingLevel {
case database.AppSharingLevelOwner:
// We essentially already did this above with the regular RBAC check.
// Owners can always access their own apps according to RBAC rules, so
// they have already been returned from this function.
case database.AppSharingLevelAuthenticated:
// The user is authenticated at this point, but we need to make sure
// that they have ApplicationConnect permissions to their own
// workspaces. This ensures that the key's scope has permission to
// connect to workspace apps.
object := rbac.ResourceWorkspaceApplicationConnect.WithOwner(roles.ID.String())
err := api.Authorizer.ByRoleName(ctx, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), []string{}, rbac.ActionCreate, object)
if err == nil {
return true, nil
}
case database.AppSharingLevelPublic:
// We don't really care about scopes and stuff if it's public anyways.
// Someone with a restricted-scope API key could just not submit the
// API key cookie in the request and access the page.
return true, nil
}
// No checks were successful.
return false, nil
}
// fetchWorkspaceApplicationAuth authorizes the user using api.AppAuthorizer
// for a given app share level in the given workspace. The user's authorization
// status is returned. If a server error occurs, a HTML error page is rendered
// and false is returned so the caller can return early.
func (api *API) fetchWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, appSharingLevel database.AppSharingLevel) (authed bool, ok bool) {
ok, err := api.authorizeWorkspaceApp(r, appSharingLevel, workspace)
if err != nil {
api.Logger.Error(r.Context(), "authorize workspace app", slog.Error(err))
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusInternalServerError,
Title: "Internal Server Error",
Description: "Could not verify authorization. Please try again or contact an administrator.",
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
return false, false
}
return ok, true
}
// checkWorkspaceApplicationAuth authorizes the user using api.AppAuthorizer
// for a given app share level in the given workspace. If the user is not
// authorized or a server error occurs, a discrete HTML error page is rendered
// and false is returned so the caller can return early.
func (api *API) checkWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool {
authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspace, appSharingLevel)
if !ok {
return false
}
if !authed {
renderApplicationNotFound(rw, r, api.AccessURL)
return false
}
return true
}
// verifyWorkspaceApplicationSubdomainAuth checks that the request is authorized
// to access the given application. If the user does not have a app session key,
// they will be redirected to the route below. If the user does have a session
// key but insufficient permissions a static error page will be rendered.
func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, host string) bool {
_, ok := httpmw.APIKeyOptional(r)
if ok {
if !api.Authorize(r, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) {
renderApplicationNotFound(rw, r, api.AccessURL)
return false
}
// Request should be all good to go!
func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter, r *http.Request, host string, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool {
authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspace, appSharingLevel)
if !ok {
return false
}
if authed {
return true
}
_, hasAPIKey := httpmw.APIKeyOptional(r)
if hasAPIKey {
// The request has a valid API key but insufficient permissions.
renderApplicationNotFound(rw, r, api.AccessURL)
return false
}
// If the request has the special query param then we need to set a cookie
// and strip that query parameter.
if encryptedAPIKey := r.URL.Query().Get(subdomainProxyAPIKeyParam); encryptedAPIKey != "" {
@ -421,58 +575,49 @@ type proxyApplication struct {
Workspace database.Workspace
Agent database.WorkspaceAgent
// Either AppName or Port must be set, but not both.
AppName string
Port uint16
// Either App or Port must be set, but not both.
App *database.WorkspaceApp
Port uint16
// SharingLevel MUST be set to database.AppSharingLevelOwner by default for
// ports.
SharingLevel database.AppSharingLevel
// Path must either be empty or have a leading slash.
Path string
}
func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, rbac.ActionCreate, proxyApp.Workspace.ApplicationConnectRBAC()) {
httpapi.ResourceNotFound(rw)
sharingLevel := database.AppSharingLevelOwner
if proxyApp.App != nil && proxyApp.App.SharingLevel != "" {
sharingLevel = proxyApp.App.SharingLevel
}
if !api.checkWorkspaceApplicationAuth(rw, r, proxyApp.Workspace, sharingLevel) {
return
}
// If the app does not exist, but the app name is a port number, then
// route to the port as an "anonymous app". We only support HTTP for
// port-based URLs.
//
// This is only supported for subdomain-based applications.
internalURL := fmt.Sprintf("http://127.0.0.1:%d", proxyApp.Port)
// If the app name was used instead, fetch the app from the database so we
// can get the internal URL.
if proxyApp.AppName != "" {
app, err := api.Database.GetWorkspaceAppByAgentIDAndName(ctx, database.GetWorkspaceAppByAgentIDAndNameParams{
AgentID: proxyApp.Agent.ID,
Name: proxyApp.AppName,
})
if xerrors.Is(err, sql.ErrNoRows) {
renderApplicationNotFound(rw, r, api.AccessURL)
return
}
if err != nil {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusInternalServerError,
Title: "Internal Server Error",
Description: "Could not fetch workspace application: " + err.Error(),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
return
}
if !app.Url.Valid {
if proxyApp.App != nil {
if !proxyApp.App.Url.Valid {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Bad Request",
Description: fmt.Sprintf("Application %q does not have a URL set.", app.Name),
Description: fmt.Sprintf("Application %q does not have a URL set.", proxyApp.App.Name),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
return
}
internalURL = app.Url.String
internalURL = proxyApp.App.Url.String
}
appURL, err := url.Parse(internalURL)
@ -692,8 +837,8 @@ func decryptAPIKey(ctx context.Context, db database.Store, encryptedAPIKey strin
func renderApplicationNotFound(rw http.ResponseWriter, r *http.Request, accessURL *url.URL) {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusNotFound,
Title: "Application not found",
Description: "The application or workspace you are trying to access does not exist.",
Title: "Application Not Found",
Description: "The application or workspace you are trying to access does not exist or you do not have permission to access it.",
RetryEnabled: false,
DashboardURL: accessURL.String(),
})

View File

@ -7,6 +7,7 @@ import (
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"testing"
@ -28,11 +29,13 @@ import (
)
const (
proxyTestAgentName = "agent-name"
proxyTestAppName = "example"
proxyTestAppQuery = "query=true"
proxyTestAppBody = "hello world"
proxyTestFakeAppName = "fake"
proxyTestAgentName = "agent-name"
proxyTestAppNameFake = "test-app-fake"
proxyTestAppNameOwner = "test-app-owner"
proxyTestAppNameAuthenticated = "test-app-authenticated"
proxyTestAppNamePublic = "test-app-public"
proxyTestAppQuery = "query=true"
proxyTestAppBody = "hello world"
proxyTestSubdomain = "test.coder.com"
)
@ -101,6 +104,8 @@ func setupProxyTest(t *testing.T, workspaceMutators ...func(*codersdk.CreateWork
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
appURL := fmt.Sprintf("http://127.0.0.1:%d?%s", tcpAddr.Port, proxyTestAppQuery)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionDryRun: echo.ProvisionComplete,
@ -118,13 +123,26 @@ func setupProxyTest(t *testing.T, workspaceMutators ...func(*codersdk.CreateWork
},
Apps: []*proto.App{
{
Name: proxyTestAppName,
Url: fmt.Sprintf("http://127.0.0.1:%d?%s", tcpAddr.Port, proxyTestAppQuery),
}, {
Name: proxyTestFakeAppName,
Name: proxyTestAppNameFake,
SharingLevel: proto.AppSharingLevel_OWNER,
// Hopefully this IP and port doesn't exist.
Url: "http://127.1.0.1:65535",
},
{
Name: proxyTestAppNameOwner,
SharingLevel: proto.AppSharingLevel_OWNER,
Url: appURL,
},
{
Name: proxyTestAppNameAuthenticated,
SharingLevel: proto.AppSharingLevel_AUTHENTICATED,
Url: appURL,
},
{
Name: proxyTestAppNamePublic,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
},
},
}},
}},
@ -180,7 +198,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example", nil)
resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil)
require.NoError(t, err)
defer resp.Body.Close()
@ -201,7 +219,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := userClient.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example", nil)
resp, err := userClient.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusNotFound, resp.StatusCode)
@ -213,7 +231,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example", nil)
resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
@ -225,7 +243,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example/", nil)
resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/", workspace.Name, proxyTestAppNameOwner), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
@ -240,7 +258,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example/?"+proxyTestAppQuery, nil)
resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery), nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
@ -255,7 +273,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/fake/", nil)
resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/", workspace.Name, proxyTestAppNameFake), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
@ -281,7 +299,7 @@ func TestWorkspaceApplicationAuth(t *testing.T) {
require.NoError(t, err)
// Try to load the application without authentication.
subdomain := fmt.Sprintf("%s--%s--%s--%s", proxyTestAppName, proxyTestAgentName, workspace.Name, user.Username)
subdomain := fmt.Sprintf("%s--%s--%s--%s", proxyTestAppNameOwner, proxyTestAgentName, workspace.Name, user.Username)
u, err := url.Parse(fmt.Sprintf("http://%s.%s/test", subdomain, proxyTestSubdomain))
require.NoError(t, err)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
@ -607,7 +625,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := userClient.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppName), nil)
resp, err := userClient.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppNameOwner), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusNotFound, resp.StatusCode)
@ -619,7 +637,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
slashlessURL := proxyURL(t, proxyTestAppName, "")
slashlessURL := proxyURL(t, proxyTestAppNameOwner, "")
resp, err := client.Request(ctx, http.MethodGet, slashlessURL, nil)
require.NoError(t, err)
defer resp.Body.Close()
@ -636,7 +654,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
querylessURL := proxyURL(t, proxyTestAppName, "/", "")
querylessURL := proxyURL(t, proxyTestAppNameOwner, "/", "")
resp, err := client.Request(ctx, http.MethodGet, querylessURL, nil)
require.NoError(t, err)
defer resp.Body.Close()
@ -653,7 +671,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppName, "/", proxyTestAppQuery), nil)
resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppNameOwner, "/", proxyTestAppQuery), nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
@ -683,7 +701,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, proxyTestFakeAppName, "/", ""), nil)
resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppNameFake, "/", ""), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
@ -708,3 +726,168 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
require.Contains(t, resBody.Message, "Coder reserves ports less than")
})
}
func TestAppSharing(t *testing.T) {
t.Parallel()
setup := func(t *testing.T) (workspace codersdk.Workspace, agnt codersdk.WorkspaceAgent, user codersdk.User, client *codersdk.Client, clientInOtherOrg *codersdk.Client, clientWithNoAuth *codersdk.Client) {
//nolint:gosec
const password = "password"
client, _, workspace, _ = setupProxyTest(t)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
t.Cleanup(cancel)
user, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
// Verify that the apps have the correct sharing levels set.
workspaceBuild, err := client.WorkspaceBuild(ctx, workspace.LatestBuild.ID)
require.NoError(t, err)
agnt = workspaceBuild.Resources[0].Agents[0]
found := map[string]codersdk.WorkspaceAppSharingLevel{}
expected := map[string]codersdk.WorkspaceAppSharingLevel{
proxyTestAppNameFake: codersdk.WorkspaceAppSharingLevelOwner,
proxyTestAppNameOwner: codersdk.WorkspaceAppSharingLevelOwner,
proxyTestAppNameAuthenticated: codersdk.WorkspaceAppSharingLevelAuthenticated,
proxyTestAppNamePublic: codersdk.WorkspaceAppSharingLevelPublic,
}
for _, app := range agnt.Apps {
found[app.Name] = app.SharingLevel
}
require.Equal(t, expected, found, "apps have incorrect sharing levels")
// Create a user in a different org.
otherOrg, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
Name: "a-different-org",
})
require.NoError(t, err)
userInOtherOrg, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
Email: "no-template-access@coder.com",
Username: "no-template-access",
Password: password,
OrganizationID: otherOrg.ID,
})
require.NoError(t, err)
clientInOtherOrg = codersdk.New(client.URL)
loginRes, err := clientInOtherOrg.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: userInOtherOrg.Email,
Password: password,
})
require.NoError(t, err)
clientInOtherOrg.SessionToken = loginRes.SessionToken
clientInOtherOrg.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
// Create an unauthenticated codersdk client.
clientWithNoAuth = codersdk.New(client.URL)
clientWithNoAuth.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
return workspace, agnt, user, client, clientInOtherOrg, clientWithNoAuth
}
verifyAccess := func(t *testing.T, username, workspaceName, agentName, appName string, client *codersdk.Client, shouldHaveAccess, shouldRedirectToLogin bool) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// If the client has a session token, we also want to check that a
// scoped key works.
clients := []*codersdk.Client{client}
if client.SessionToken != "" {
token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Scope: codersdk.APIKeyScopeApplicationConnect,
})
require.NoError(t, err)
scopedClient := codersdk.New(client.URL)
scopedClient.SessionToken = token.Key
scopedClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect
clients = append(clients, scopedClient)
}
for i, client := range clients {
msg := fmt.Sprintf("client %d", i)
appPath := fmt.Sprintf("/@%s/%s.%s/apps/%s/?%s", username, workspaceName, agentName, appName, proxyTestAppQuery)
res, err := client.Request(ctx, http.MethodGet, appPath, nil)
require.NoError(t, err, msg)
dump, err := httputil.DumpResponse(res, true)
res.Body.Close()
require.NoError(t, err, msg)
t.Logf("response dump: %s", dump)
if !shouldHaveAccess {
if shouldRedirectToLogin {
assert.Equal(t, http.StatusTemporaryRedirect, res.StatusCode, "should not have access, expected temporary redirect. "+msg)
location, err := res.Location()
require.NoError(t, err, msg)
assert.Equal(t, "/login", location.Path, "should not have access, expected redirect to /login. "+msg)
} else {
// If the user doesn't have access we return 404 to avoid
// leaking information about the existence of the app.
assert.Equal(t, http.StatusNotFound, res.StatusCode, "should not have access, expected not found. "+msg)
}
}
if shouldHaveAccess {
assert.Equal(t, http.StatusOK, res.StatusCode, "should have access, expected ok. "+msg)
assert.Contains(t, string(dump), "hello world", "should have access, expected hello world. "+msg)
}
}
}
t.Run("Level", func(t *testing.T) {
t.Parallel()
workspace, agent, user, client, clientInOtherOrg, clientWithNoAuth := setup(t)
t.Run("Owner", func(t *testing.T) {
t.Parallel()
// Owner should be able to access their own workspace.
verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, client, true, false)
// Authenticated users should not have access to a workspace that
// they do not own.
verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientInOtherOrg, false, false)
// Unauthenticated user should not have any access.
verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientWithNoAuth, false, true)
})
t.Run("Authenticated", func(t *testing.T) {
t.Parallel()
// Owner should be able to access their own workspace.
verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, client, true, false)
// Authenticated users should be able to access the workspace.
verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, true, false)
// Unauthenticated user should not have any access.
verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientWithNoAuth, false, true)
})
t.Run("Public", func(t *testing.T) {
t.Parallel()
// Owner should be able to access their own workspace.
verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, client, true, false)
// Authenticated users should be able to access the workspace.
verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientInOtherOrg, true, false)
// Unauthenticated user should be able to access the workspace.
verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientWithNoAuth, true, false)
})
})
}