Files
coder/coderd/workspaceapps/auth.go
2023-03-30 23:24:51 +10:00

388 lines
14 KiB
Go

package workspaceapps
import (
"context"
"database/sql"
"fmt"
"net/http"
"strings"
"time"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbauthz"
"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) {
// nolint:gocritic // We need to make a number of database calls. Setting a system context here
// // is simpler than calling dbauthz.AsSystemRestricted on every call.
// // dangerousSystemCtx is only used for database calls. The actual authentication
// // logic is handled in Provider.authorizeWorkspaceApp which directly checks the actor's
// // permissions.
dangerousSystemCtx := dbauthz.AsSystemRestricted(r.Context())
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 {
err := ticket.Request.Validate()
if err == nil && 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{
Request: appReq,
}
// 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
}
// Lookup workspace app details from DB.
dbReq, err := appReq.getDatabase(dangerousSystemCtx, p.Database)
if xerrors.Is(err, sql.ErrNoRows) {
p.writeWorkspaceApp404(rw, r, &appReq, err.Error())
return nil, false
} else if err != nil {
p.writeWorkspaceApp500(rw, r, &appReq, err, "get app details from database")
return nil, false
}
ticket.UserID = dbReq.User.ID
ticket.WorkspaceID = dbReq.Workspace.ID
ticket.AgentID = dbReq.Agent.ID
ticket.AppURL = dbReq.AppURL
// TODO(@deansheather): return an error if the agent is offline or the app
// is not running.
// Verify the user has access to the app.
authed, ok := p.verifyAuthz(rw, r, authz, dbReq)
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.
switch appReq.AccessMethod {
case AccessMethodPath:
httpmw.RedirectToLogin(rw, r, httpmw.SignedOutErrorMessage)
case AccessMethodSubdomain:
// Redirect to the app auth redirect endpoint with a valid redirect
// URI.
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)
case AccessMethodTerminal:
// Return an error.
httpapi.ResourceNotFound(rw)
}
return nil, false
}
// Check that the agent is online.
agentStatus := dbReq.Agent.Status(p.WorkspaceAgentInactiveTimeout)
if agentStatus.Status != database.WorkspaceAgentStatusConnected {
p.writeWorkspaceAppOffline(rw, r, &appReq, fmt.Sprintf("Agent state is %q, not %q", agentStatus.Status, database.WorkspaceAgentStatusConnected))
return nil, false
}
// Check that the app is healthy.
if dbReq.AppHealth != "" && dbReq.AppHealth != database.WorkspaceAppHealthDisabled && dbReq.AppHealth != database.WorkspaceAppHealthHealthy {
p.writeWorkspaceAppOffline(rw, r, &appReq, fmt.Sprintf("App health is %q, not %q", dbReq.AppHealth, database.WorkspaceAppHealthHealthy))
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
}
func (p *Provider) authorizeRequest(ctx context.Context, roles *httpmw.Authorization, dbReq *databaseRequest) (bool, error) {
accessMethod := dbReq.AccessMethod
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.
sharingLevel := dbReq.AppSharingLevel
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 &&
dbReq.Workspace.OwnerID.String() != roles.Actor.ID &&
!p.DeploymentValues.Dangerous.AllowPathAppSiteOwnerAccess.Value() {
return false, nil
}
// Figure out which RBAC resource to check. For terminals we use execution
// instead of application connect.
var (
rbacAction rbac.Action = rbac.ActionCreate
rbacResource rbac.Object = dbReq.Workspace.ApplicationConnectRBAC()
// rbacResourceOwned is for the level "authenticated". We still need to
// make sure the API key has permissions to connect to the actor's own
// workspace. Scopes would prevent this.
rbacResourceOwned rbac.Object = rbac.ResourceWorkspaceApplicationConnect.WithOwner(roles.Actor.ID)
)
if dbReq.AccessMethod == AccessMethodTerminal {
rbacResource = dbReq.Workspace.ExecutionRBAC()
rbacResourceOwned = rbac.ResourceWorkspaceExecution.WithOwner(roles.Actor.ID)
}
// 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, rbacAction, rbacResource)
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:
// Check with the owned resource to ensure the API key has permissions
// to connect to the actor's own workspace. This enforces scopes.
err := p.Authorizer.Authorize(ctx, roles.Actor, rbacAction, rbacResourceOwned)
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
}
// verifyAuthz 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) verifyAuthz(rw http.ResponseWriter, r *http.Request, authz *httpmw.Authorization, dbReq *databaseRequest) (authed bool, ok bool) {
ok, err := p.authorizeRequest(r.Context(), authz, dbReq)
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(),
})
}
// writeWorkspaceAppOffline writes a HTML 502 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) writeWorkspaceAppOffline(rw http.ResponseWriter, r *http.Request, appReq *Request, msg string) {
if appReq != nil {
slog.Helper()
p.Logger.Debug(r.Context(),
"workspace app unavailable: "+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.StatusBadGateway,
Title: "Application Unavailable",
Description: msg,
RetryEnabled: true,
DashboardURL: p.AccessURL.String(),
})
}