mirror of https://github.com/coder/coder.git
495 lines
17 KiB
Go
495 lines
17 KiB
Go
package workspaceapps
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog"
|
|
"github.com/coder/coder/coderd/database"
|
|
"github.com/coder/coder/coderd/httpapi"
|
|
"github.com/coder/coder/coderd/httpmw"
|
|
"github.com/coder/coder/coderd/rbac"
|
|
"github.com/coder/coder/codersdk"
|
|
"github.com/coder/coder/site"
|
|
)
|
|
|
|
const (
|
|
// TODO(@deansheather): configurable expiry
|
|
TicketExpiry = time.Minute
|
|
|
|
// RedirectURIQueryParam is the query param for the app URL to be passed
|
|
// back to the API auth endpoint on the main access URL.
|
|
RedirectURIQueryParam = "redirect_uri"
|
|
)
|
|
|
|
// ResolveRequest takes an app request, checks if it's valid and authenticated,
|
|
// and returns a ticket with details about the app.
|
|
//
|
|
// The ticket is written as a signed JWT into a cookie and will be automatically
|
|
// used in the next request to the same app to avoid database calls.
|
|
//
|
|
// Upstream code should avoid any database calls ever.
|
|
func (p *Provider) ResolveRequest(rw http.ResponseWriter, r *http.Request, appReq Request) (*Ticket, bool) {
|
|
err := appReq.Validate()
|
|
if err != nil {
|
|
p.writeWorkspaceApp500(rw, r, &appReq, err, "invalid app request")
|
|
return nil, false
|
|
}
|
|
|
|
if appReq.WorkspaceAndAgent != "" {
|
|
// workspace.agent
|
|
workspaceAndAgent := strings.SplitN(appReq.WorkspaceAndAgent, ".", 2)
|
|
appReq.WorkspaceAndAgent = ""
|
|
appReq.WorkspaceNameOrID = workspaceAndAgent[0]
|
|
if len(workspaceAndAgent) > 1 {
|
|
appReq.AgentNameOrID = workspaceAndAgent[1]
|
|
}
|
|
|
|
// Sanity check.
|
|
err := appReq.Validate()
|
|
if err != nil {
|
|
p.writeWorkspaceApp500(rw, r, &appReq, err, "invalid app request")
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
// Get the existing ticket from the request.
|
|
ticketCookie, err := r.Cookie(codersdk.DevURLSessionTicketCookie)
|
|
if err == nil {
|
|
ticket, err := p.ParseTicket(ticketCookie.Value)
|
|
if err == nil {
|
|
if ticket.MatchesRequest(appReq) {
|
|
// The request has a ticket, which is a valid ticket signed by
|
|
// us, and matches the app that the user was trying to access.
|
|
return &ticket, true
|
|
}
|
|
}
|
|
}
|
|
|
|
// There's no ticket or it's invalid, so we need to check auth using the
|
|
// session token, validate auth and access to the app, then generate a new
|
|
// ticket.
|
|
ticket := Ticket{
|
|
AccessMethod: appReq.AccessMethod,
|
|
UsernameOrID: appReq.UsernameOrID,
|
|
WorkspaceNameOrID: appReq.WorkspaceNameOrID,
|
|
AgentNameOrID: appReq.AgentNameOrID,
|
|
AppSlugOrPort: appReq.AppSlugOrPort,
|
|
}
|
|
|
|
// We use the regular API apiKey extraction middleware fn here to avoid any
|
|
// differences in behavior between the two.
|
|
apiKey, authz, ok := httpmw.ExtractAPIKey(rw, r, httpmw.ExtractAPIKeyConfig{
|
|
DB: p.Database,
|
|
OAuth2Configs: p.OAuth2Configs,
|
|
RedirectToLogin: false,
|
|
DisableSessionExpiryRefresh: p.DeploymentValues.DisableSessionExpiryRefresh.Value(),
|
|
// 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 using code below (not the redirect from the
|
|
// middleware itself).
|
|
Optional: true,
|
|
})
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
|
|
// Get user.
|
|
var (
|
|
user database.User
|
|
userErr error
|
|
)
|
|
if userID, uuidErr := uuid.Parse(appReq.UsernameOrID); uuidErr == nil {
|
|
user, userErr = p.Database.GetUserByID(r.Context(), userID)
|
|
} else {
|
|
user, userErr = p.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{
|
|
Username: appReq.UsernameOrID,
|
|
})
|
|
}
|
|
if xerrors.Is(userErr, sql.ErrNoRows) {
|
|
p.writeWorkspaceApp404(rw, r, &appReq, fmt.Sprintf("user %q not found", appReq.UsernameOrID))
|
|
return nil, false
|
|
} else if userErr != nil {
|
|
p.writeWorkspaceApp500(rw, r, &appReq, userErr, "get user")
|
|
return nil, false
|
|
}
|
|
ticket.UserID = user.ID
|
|
|
|
// Get workspace.
|
|
var (
|
|
workspace database.Workspace
|
|
workspaceErr error
|
|
)
|
|
if workspaceID, uuidErr := uuid.Parse(appReq.WorkspaceNameOrID); uuidErr == nil {
|
|
workspace, workspaceErr = p.Database.GetWorkspaceByID(r.Context(), workspaceID)
|
|
} else {
|
|
workspace, workspaceErr = p.Database.GetWorkspaceByOwnerIDAndName(r.Context(), database.GetWorkspaceByOwnerIDAndNameParams{
|
|
OwnerID: user.ID,
|
|
Name: appReq.WorkspaceNameOrID,
|
|
Deleted: false,
|
|
})
|
|
}
|
|
if xerrors.Is(workspaceErr, sql.ErrNoRows) {
|
|
p.writeWorkspaceApp404(rw, r, &appReq, fmt.Sprintf("workspace %q not found", appReq.WorkspaceNameOrID))
|
|
return nil, false
|
|
} else if workspaceErr != nil {
|
|
p.writeWorkspaceApp500(rw, r, &appReq, workspaceErr, "get workspace")
|
|
return nil, false
|
|
}
|
|
ticket.WorkspaceID = workspace.ID
|
|
|
|
// Get agent.
|
|
var (
|
|
agent database.WorkspaceAgent
|
|
agentErr error
|
|
trustAgent = false
|
|
)
|
|
if agentID, uuidErr := uuid.Parse(appReq.AgentNameOrID); uuidErr == nil {
|
|
agent, agentErr = p.Database.GetWorkspaceAgentByID(r.Context(), agentID)
|
|
} else {
|
|
build, err := p.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID)
|
|
if err != nil {
|
|
p.writeWorkspaceApp500(rw, r, &appReq, err, "get latest workspace build")
|
|
return nil, false
|
|
}
|
|
|
|
resources, err := p.Database.GetWorkspaceResourcesByJobID(r.Context(), build.JobID)
|
|
if err != nil {
|
|
p.writeWorkspaceApp500(rw, r, &appReq, err, "get workspace resources")
|
|
return nil, false
|
|
}
|
|
resourcesIDs := []uuid.UUID{}
|
|
for _, resource := range resources {
|
|
resourcesIDs = append(resourcesIDs, resource.ID)
|
|
}
|
|
|
|
agents, err := p.Database.GetWorkspaceAgentsByResourceIDs(r.Context(), resourcesIDs)
|
|
if err != nil {
|
|
p.writeWorkspaceApp500(rw, r, &appReq, err, "get workspace agents")
|
|
return nil, false
|
|
}
|
|
|
|
if appReq.AgentNameOrID == "" {
|
|
if len(agents) != 1 {
|
|
p.writeWorkspaceApp404(rw, r, &appReq, "no agent specified, but multiple exist in workspace")
|
|
return nil, false
|
|
}
|
|
|
|
agent = agents[0]
|
|
trustAgent = true
|
|
} else {
|
|
for _, a := range agents {
|
|
if a.Name == appReq.AgentNameOrID {
|
|
agent = a
|
|
trustAgent = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if agent.ID == uuid.Nil {
|
|
agentErr = sql.ErrNoRows
|
|
}
|
|
}
|
|
if xerrors.Is(agentErr, sql.ErrNoRows) {
|
|
p.writeWorkspaceApp404(rw, r, &appReq, fmt.Sprintf("agent %q not found", appReq.AgentNameOrID))
|
|
return nil, false
|
|
} else if agentErr != nil {
|
|
p.writeWorkspaceApp500(rw, r, &appReq, agentErr, "get agent")
|
|
return nil, false
|
|
}
|
|
|
|
// Verify the agent belongs to the workspace.
|
|
if !trustAgent {
|
|
agentResource, err := p.Database.GetWorkspaceResourceByID(r.Context(), agent.ResourceID)
|
|
if err != nil {
|
|
p.writeWorkspaceApp500(rw, r, &appReq, err, "get agent resource")
|
|
return nil, false
|
|
}
|
|
build, err := p.Database.GetWorkspaceBuildByJobID(r.Context(), agentResource.JobID)
|
|
if err != nil {
|
|
p.writeWorkspaceApp500(rw, r, &appReq, err, "get agent workspace build")
|
|
return nil, false
|
|
}
|
|
if build.WorkspaceID != workspace.ID {
|
|
p.writeWorkspaceApp404(rw, r, &appReq, "agent does not belong to workspace")
|
|
return nil, false
|
|
}
|
|
}
|
|
ticket.AgentID = agent.ID
|
|
|
|
// Get app.
|
|
appSharingLevel := database.AppSharingLevelOwner
|
|
portUint, portUintErr := strconv.ParseUint(appReq.AppSlugOrPort, 10, 16)
|
|
if appReq.AccessMethod == AccessMethodSubdomain && portUintErr == nil {
|
|
// If the app slug 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.
|
|
ticket.AppURL = fmt.Sprintf("http://127.0.0.1:%d", portUint)
|
|
} else {
|
|
app, ok := p.lookupWorkspaceApp(rw, r, agent.ID, appReq.AppSlugOrPort)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
|
|
if !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.Slug),
|
|
RetryEnabled: true,
|
|
DashboardURL: p.AccessURL.String(),
|
|
})
|
|
return nil, false
|
|
}
|
|
|
|
if app.SharingLevel != "" {
|
|
appSharingLevel = app.SharingLevel
|
|
}
|
|
ticket.AppURL = app.Url.String
|
|
}
|
|
|
|
// Verify the user has access to the app.
|
|
authed, ok := p.fetchWorkspaceApplicationAuth(rw, r, authz, appReq.AccessMethod, workspace, appSharingLevel)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
if !authed {
|
|
if apiKey != nil {
|
|
// The request has a valid API key but insufficient permissions.
|
|
p.writeWorkspaceApp404(rw, r, &appReq, "insufficient permissions")
|
|
return nil, false
|
|
}
|
|
|
|
// Redirect to login as they don't have permission to access the app
|
|
// and they aren't signed in.
|
|
if appReq.AccessMethod == AccessMethodSubdomain {
|
|
redirectURI := *r.URL
|
|
redirectURI.Scheme = p.AccessURL.Scheme
|
|
redirectURI.Host = httpapi.RequestHost(r)
|
|
|
|
u := *p.AccessURL
|
|
u.Path = "/api/v2/applications/auth-redirect"
|
|
q := u.Query()
|
|
q.Add(RedirectURIQueryParam, redirectURI.String())
|
|
u.RawQuery = q.Encode()
|
|
|
|
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
|
|
} else {
|
|
httpmw.RedirectToLogin(rw, r, httpmw.SignedOutErrorMessage)
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// As a sanity check, ensure the ticket we just made is valid for this
|
|
// request.
|
|
if !ticket.MatchesRequest(appReq) {
|
|
p.writeWorkspaceApp500(rw, r, &appReq, nil, "fresh ticket does not match request")
|
|
return nil, false
|
|
}
|
|
|
|
// Sign the ticket.
|
|
ticketExpiry := time.Now().Add(TicketExpiry)
|
|
ticket.Expiry = ticketExpiry.Unix()
|
|
ticketStr, err := p.GenerateTicket(ticket)
|
|
if err != nil {
|
|
p.writeWorkspaceApp500(rw, r, &appReq, err, "generate ticket")
|
|
return nil, false
|
|
}
|
|
|
|
// Write the ticket cookie. We always want this to apply to the current
|
|
// hostname (even for subdomain apps, without any wildcard shenanigans,
|
|
// because the ticket is only valid for a single app).
|
|
http.SetCookie(rw, &http.Cookie{
|
|
Name: codersdk.DevURLSessionTicketCookie,
|
|
Value: ticketStr,
|
|
Path: appReq.BasePath,
|
|
Expires: ticketExpiry,
|
|
})
|
|
|
|
return &ticket, true
|
|
}
|
|
|
|
// lookupWorkspaceApp looks up the workspace application by slug 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 (p *Provider) lookupWorkspaceApp(rw http.ResponseWriter, r *http.Request, agentID uuid.UUID, appSlug string) (database.WorkspaceApp, bool) {
|
|
app, err := p.Database.GetWorkspaceAppByAgentIDAndSlug(r.Context(), database.GetWorkspaceAppByAgentIDAndSlugParams{
|
|
AgentID: agentID,
|
|
Slug: appSlug,
|
|
})
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
p.writeWorkspaceApp404(rw, r, nil, "application not found in agent by slug")
|
|
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: p.AccessURL.String(),
|
|
})
|
|
return database.WorkspaceApp{}, false
|
|
}
|
|
|
|
return app, true
|
|
}
|
|
|
|
func (p *Provider) authorizeWorkspaceApp(ctx context.Context, roles *httpmw.Authorization, accessMethod AccessMethod, sharingLevel database.AppSharingLevel, workspace database.Workspace) (bool, error) {
|
|
if accessMethod == "" {
|
|
accessMethod = AccessMethodPath
|
|
}
|
|
isPathApp := accessMethod == AccessMethodPath
|
|
|
|
// If path-based app sharing is disabled (which is the default), we can
|
|
// force the sharing level to be "owner" so that the user can only access
|
|
// their own apps.
|
|
//
|
|
// Site owners are blocked from accessing path-based apps unless the
|
|
// Dangerous.AllowPathAppSiteOwnerAccess flag is enabled in the check below.
|
|
if isPathApp && !p.DeploymentValues.Dangerous.AllowPathAppSharing.Value() {
|
|
sharingLevel = database.AppSharingLevelOwner
|
|
}
|
|
|
|
// Short circuit if not authenticated.
|
|
if roles == nil {
|
|
// The user is not authenticated, so they can only access the app if it
|
|
// is public.
|
|
return sharingLevel == database.AppSharingLevelPublic, nil
|
|
}
|
|
|
|
// Block anyone from accessing workspaces they don't own in path-based apps
|
|
// unless the admin disables this security feature. This blocks site-owners
|
|
// from accessing any apps from any user's workspaces.
|
|
//
|
|
// When the Dangerous.AllowPathAppSharing flag is not enabled, the sharing
|
|
// level will be forced to "owner", so this check will always be true for
|
|
// workspaces owned by different users.
|
|
if isPathApp &&
|
|
sharingLevel == database.AppSharingLevelOwner &&
|
|
workspace.OwnerID.String() != roles.Actor.ID &&
|
|
!p.DeploymentValues.Dangerous.AllowPathAppSiteOwnerAccess.Value() {
|
|
return false, 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 := p.Authorizer.Authorize(ctx, roles.Actor, 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.Actor.ID)
|
|
err := p.Authorizer.Authorize(ctx, roles.Actor, 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.Authorizer 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 (p *Provider) fetchWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, authz *httpmw.Authorization, accessMethod AccessMethod, workspace database.Workspace, appSharingLevel database.AppSharingLevel) (authed bool, ok bool) {
|
|
ok, err := p.authorizeWorkspaceApp(r.Context(), authz, accessMethod, appSharingLevel, workspace)
|
|
if err != nil {
|
|
p.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: p.AccessURL.String(),
|
|
})
|
|
return false, false
|
|
}
|
|
|
|
return ok, true
|
|
}
|
|
|
|
// writeWorkspaceApp404 writes a HTML 404 error page for a workspace app. If
|
|
// appReq is not nil, it will be used to log the request details at debug level.
|
|
func (p *Provider) writeWorkspaceApp404(rw http.ResponseWriter, r *http.Request, appReq *Request, msg string) {
|
|
if appReq != nil {
|
|
slog.Helper()
|
|
p.Logger.Debug(r.Context(),
|
|
"workspace app 404: "+msg,
|
|
slog.F("username_or_id", appReq.UsernameOrID),
|
|
slog.F("workspace_and_agent", appReq.WorkspaceAndAgent),
|
|
slog.F("workspace_name_or_id", appReq.WorkspaceNameOrID),
|
|
slog.F("agent_name_or_id", appReq.AgentNameOrID),
|
|
slog.F("app_slug_or_port", appReq.AppSlugOrPort),
|
|
)
|
|
}
|
|
|
|
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 or you do not have permission to access it.",
|
|
RetryEnabled: false,
|
|
DashboardURL: p.AccessURL.String(),
|
|
})
|
|
}
|
|
|
|
// writeWorkspaceApp500 writes a HTML 500 error page for a workspace app. If
|
|
// appReq is not nil, it's fields will be added to the logged error message.
|
|
func (p *Provider) writeWorkspaceApp500(rw http.ResponseWriter, r *http.Request, appReq *Request, err error, msg string) {
|
|
slog.Helper()
|
|
ctx := r.Context()
|
|
if appReq != nil {
|
|
slog.With(ctx,
|
|
slog.F("username_or_id", appReq.UsernameOrID),
|
|
slog.F("workspace_and_agent", appReq.WorkspaceAndAgent),
|
|
slog.F("workspace_name_or_id", appReq.WorkspaceNameOrID),
|
|
slog.F("agent_name_or_id", appReq.AgentNameOrID),
|
|
slog.F("app_name_or_port", appReq.AppSlugOrPort),
|
|
)
|
|
}
|
|
p.Logger.Warn(ctx,
|
|
"workspace app auth server error: "+msg,
|
|
slog.Error(err),
|
|
)
|
|
|
|
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
|
Status: http.StatusInternalServerError,
|
|
Title: "Internal Server Error",
|
|
Description: "An internal server error occurred.",
|
|
RetryEnabled: false,
|
|
DashboardURL: p.AccessURL.String(),
|
|
})
|
|
}
|