2023-04-04 00:59:41 +00:00
package workspaceapps
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/url"
2023-04-17 19:57:21 +00:00
"path"
"strings"
2023-04-04 00:59:41 +00:00
"time"
2023-09-19 21:54:51 +00:00
"golang.org/x/exp/slices"
2023-04-04 00:59:41 +00:00
"golang.org/x/xerrors"
"cdr.dev/slog"
2023-08-18 18:55:43 +00:00
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
2023-04-04 00:59:41 +00:00
)
// DBTokenProvider provides authentication and authorization for workspace apps
// by querying the database if the request is missing a valid token.
type DBTokenProvider struct {
Logger slog . Logger
2023-04-17 19:57:21 +00:00
// DashboardURL is the main dashboard access URL for error pages.
DashboardURL * url . URL
2023-04-04 00:59:41 +00:00
Authorizer rbac . Authorizer
Database database . Store
DeploymentValues * codersdk . DeploymentValues
OAuth2Configs * httpmw . OAuth2Configs
WorkspaceAgentInactiveTimeout time . Duration
2023-04-05 18:41:55 +00:00
SigningKey SecurityKey
2023-04-04 00:59:41 +00:00
}
var _ SignedTokenProvider = & DBTokenProvider { }
2023-04-05 18:41:55 +00:00
func NewDBTokenProvider ( log slog . Logger , accessURL * url . URL , authz rbac . Authorizer , db database . Store , cfg * codersdk . DeploymentValues , oauth2Cfgs * httpmw . OAuth2Configs , workspaceAgentInactiveTimeout time . Duration , signingKey SecurityKey ) SignedTokenProvider {
2023-04-04 00:59:41 +00:00
if workspaceAgentInactiveTimeout == 0 {
workspaceAgentInactiveTimeout = 1 * time . Minute
}
return & DBTokenProvider {
Logger : log ,
2023-04-17 19:57:21 +00:00
DashboardURL : accessURL ,
2023-04-04 00:59:41 +00:00
Authorizer : authz ,
Database : db ,
DeploymentValues : cfg ,
OAuth2Configs : oauth2Cfgs ,
WorkspaceAgentInactiveTimeout : workspaceAgentInactiveTimeout ,
2023-04-05 18:41:55 +00:00
SigningKey : signingKey ,
2023-04-04 00:59:41 +00:00
}
}
2023-04-17 19:57:21 +00:00
func ( p * DBTokenProvider ) FromRequest ( r * http . Request ) ( * SignedToken , bool ) {
return FromRequest ( r , p . SigningKey )
2023-04-04 00:59:41 +00:00
}
2023-04-17 19:57:21 +00:00
func ( p * DBTokenProvider ) Issue ( ctx context . Context , rw http . ResponseWriter , r * http . Request , issueReq IssueTokenRequest ) ( * SignedToken , string , bool ) {
2023-04-04 00:59:41 +00:00
// 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 ( ctx )
2023-04-17 19:57:21 +00:00
appReq := issueReq . AppRequest . Normalize ( )
2023-04-04 00:59:41 +00:00
err := appReq . Validate ( )
if err != nil {
2023-04-17 19:57:21 +00:00
WriteWorkspaceApp500 ( p . Logger , p . DashboardURL , rw , r , & appReq , err , "invalid app request" )
2023-04-04 00:59:41 +00:00
return nil , "" , false
}
token := SignedToken {
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 ,
2024-04-10 15:34:49 +00:00
DisableSessionExpiryRefresh : p . DeploymentValues . Sessions . DisableExpiryRefresh . Value ( ) ,
2023-04-17 19:57:21 +00:00
// Optional is true to allow for public apps. If the authorization check
// (later on) fails and the user is not authenticated, they will be
// redirected to the login page or app auth endpoint using code below.
2023-04-04 00:59:41 +00:00
Optional : true ,
2023-04-17 19:57:21 +00:00
SessionTokenFunc : func ( r * http . Request ) string {
return issueReq . SessionToken
} ,
2023-04-04 00:59:41 +00:00
} )
if ! ok {
return nil , "" , false
}
// Lookup workspace app details from DB.
dbReq , err := appReq . getDatabase ( dangerousSystemCtx , p . Database )
if xerrors . Is ( err , sql . ErrNoRows ) {
2023-09-19 21:54:51 +00:00
WriteWorkspaceApp404 ( p . Logger , p . DashboardURL , rw , r , & appReq , nil , err . Error ( ) )
2023-04-04 00:59:41 +00:00
return nil , "" , false
2024-01-17 18:06:59 +00:00
} else if xerrors . Is ( err , errWorkspaceStopped ) {
WriteWorkspaceOffline ( p . Logger , p . DashboardURL , rw , r , & appReq )
return nil , "" , false
2023-04-04 00:59:41 +00:00
} else if err != nil {
2023-04-17 19:57:21 +00:00
WriteWorkspaceApp500 ( p . Logger , p . DashboardURL , rw , r , & appReq , err , "get app details from database" )
2023-04-04 00:59:41 +00:00
return nil , "" , false
}
token . UserID = dbReq . User . ID
token . WorkspaceID = dbReq . Workspace . ID
token . AgentID = dbReq . Agent . ID
2023-04-17 19:57:21 +00:00
if dbReq . AppURL != nil {
token . AppURL = dbReq . AppURL . String ( )
}
2023-04-04 00:59:41 +00:00
// Verify the user has access to the app.
2023-09-19 21:54:51 +00:00
authed , warnings , err := p . authorizeRequest ( r . Context ( ) , authz , dbReq )
2023-04-04 00:59:41 +00:00
if err != nil {
2023-04-17 19:57:21 +00:00
WriteWorkspaceApp500 ( p . Logger , p . DashboardURL , rw , r , & appReq , err , "verify authz" )
2023-04-04 00:59:41 +00:00
return nil , "" , false
}
if ! authed {
if apiKey != nil {
// The request has a valid API key but insufficient permissions.
2023-09-19 21:54:51 +00:00
WriteWorkspaceApp404 ( p . Logger , p . DashboardURL , rw , r , & appReq , warnings , "insufficient permissions" )
2023-04-04 00:59:41 +00:00
return nil , "" , false
}
// Redirect to login as they don't have permission to access the app
// and they aren't signed in.
2023-04-17 19:57:21 +00:00
// We don't support login redirects for the terminal since it's a
// WebSocket endpoint and redirects won't work. The token must be
// specified as a query parameter.
if appReq . AccessMethod == AccessMethodTerminal {
2023-04-04 00:59:41 +00:00
httpapi . ResourceNotFound ( rw )
2023-04-17 19:57:21 +00:00
return nil , "" , false
}
appBaseURL , err := issueReq . AppBaseURL ( )
if err != nil {
WriteWorkspaceApp500 ( p . Logger , p . DashboardURL , rw , r , & appReq , err , "get app base URL" )
return nil , "" , false
}
// If the app is a path app and it's on the same host as the dashboard
// access URL, then we need to redirect to login using the standard
// login redirect function.
if appReq . AccessMethod == AccessMethodPath && appBaseURL . Host == p . DashboardURL . Host {
httpmw . RedirectToLogin ( rw , r , p . DashboardURL , httpmw . SignedOutErrorMessage )
return nil , "" , false
}
// Otherwise, we need to redirect to the app auth endpoint, which will
// redirect back to the app (with an encrypted API key) after the user
// has logged in.
//
// TODO: We should just make this a "BrowserURL" field on the issue struct. Then
// we can remove this logic and just defer to that. It can be set closer to the
// actual initial request that makes the IssueTokenRequest. Eg the external moon.
// This would replace RawQuery and AppPath fields.
redirectURI := * appBaseURL
if dbReq . AppURL != nil {
// Just use the user's current path and query if set.
if issueReq . AppPath != "" {
redirectURI . Path = path . Join ( redirectURI . Path , issueReq . AppPath )
} else if ! strings . HasSuffix ( redirectURI . Path , "/" ) {
redirectURI . Path += "/"
}
q := issueReq . AppQuery
if q != "" && dbReq . AppURL . RawQuery != "" {
q = dbReq . AppURL . RawQuery
}
redirectURI . RawQuery = q
2023-04-04 00:59:41 +00:00
}
2023-04-17 19:57:21 +00:00
// This endpoint accepts redirect URIs from the primary app wildcard
// host, proxy access URLs and proxy wildcard app hosts. It does not
// accept redirect URIs from the primary access URL or any other host.
u := * p . DashboardURL
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 . StatusSeeOther )
2023-04-04 00:59:41 +00:00
return nil , "" , false
}
// Check that the agent is online.
agentStatus := dbReq . Agent . Status ( p . WorkspaceAgentInactiveTimeout )
if agentStatus . Status != database . WorkspaceAgentStatusConnected {
2023-04-17 19:57:21 +00:00
WriteWorkspaceAppOffline ( p . Logger , p . DashboardURL , rw , r , & appReq , fmt . Sprintf ( "Agent state is %q, not %q" , agentStatus . Status , database . WorkspaceAgentStatusConnected ) )
2023-04-04 00:59:41 +00:00
return nil , "" , false
}
2024-02-13 06:30:49 +00:00
// This is where we used to check app health, but we don't do that anymore
// in case there are bugs with the healthcheck code that lock users out of
// their apps completely.
2023-04-04 00:59:41 +00:00
// As a sanity check, ensure the token we just made is valid for this
// request.
if ! token . MatchesRequest ( appReq ) {
2023-04-17 19:57:21 +00:00
WriteWorkspaceApp500 ( p . Logger , p . DashboardURL , rw , r , & appReq , nil , "fresh token does not match request" )
2023-04-04 00:59:41 +00:00
return nil , "" , false
}
// Sign the token.
token . Expiry = time . Now ( ) . Add ( DefaultTokenExpiry )
2023-04-05 18:41:55 +00:00
tokenStr , err := p . SigningKey . SignToken ( token )
2023-04-04 00:59:41 +00:00
if err != nil {
2023-04-17 19:57:21 +00:00
WriteWorkspaceApp500 ( p . Logger , p . DashboardURL , rw , r , & appReq , err , "generate token" )
2023-04-04 00:59:41 +00:00
return nil , "" , false
}
return & token , tokenStr , true
}
2023-09-19 21:54:51 +00:00
// authorizeRequest returns true/false if the request is authorized. The returned []string
// are warnings that aid in debugging. These messages do not prevent authorization,
// but may indicate that the request is not configured correctly.
// If an error is returned, the request should be aborted with a 500 error.
2024-03-29 15:14:27 +00:00
func ( p * DBTokenProvider ) authorizeRequest ( ctx context . Context , roles * rbac . Subject , dbReq * databaseRequest ) ( bool , [ ] string , error ) {
2023-09-19 21:54:51 +00:00
var warnings [ ] string
2023-04-04 00:59:41 +00:00
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 ( ) {
2023-09-19 21:54:51 +00:00
if dbReq . AppSharingLevel != database . AppSharingLevelOwner {
// This is helpful for debugging, and ok to leak to the user.
// This is because the app has the sharing level set to something that
// should be shared, but we are disabling it from a deployment wide
// flag. So the template should be fixed to set the sharing level to
// "owner" instead and this will not appear.
warnings = append ( warnings , fmt . Sprintf ( "unable to use configured sharing level %q because path-based app sharing is disabled (see --dangerous-allow-path-app-sharing), using sharing level \"owner\" instead" , sharingLevel ) )
}
2023-04-04 00:59:41 +00:00
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.
2023-09-19 21:54:51 +00:00
return sharingLevel == database . AppSharingLevelPublic , warnings , nil
2023-04-04 00:59:41 +00:00
}
// 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 &&
2024-03-29 15:14:27 +00:00
dbReq . Workspace . OwnerID . String ( ) != roles . ID &&
2023-04-04 00:59:41 +00:00
! p . DeploymentValues . Dangerous . AllowPathAppSiteOwnerAccess . Value ( ) {
2023-09-19 21:54:51 +00:00
// This is not ideal to check for the 'owner' role, but we are only checking
// to determine whether to show a warning for debugging reasons. This does
// not do any authz checks, so it is ok.
2024-03-29 15:14:27 +00:00
if roles != nil && slices . Contains ( roles . Roles . Names ( ) , rbac . RoleOwner ( ) ) {
2023-09-19 21:54:51 +00:00
warnings = append ( warnings , "path-based apps with \"owner\" share level are only accessible by the workspace owner (see --dangerous-allow-path-app-site-owner-access)" )
}
return false , warnings , nil
2023-04-04 00:59:41 +00:00
}
// 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.
2024-03-29 15:14:27 +00:00
rbacResourceOwned rbac . Object = rbac . ResourceWorkspaceApplicationConnect . WithOwner ( roles . ID )
2023-04-04 00:59:41 +00:00
)
if dbReq . AccessMethod == AccessMethodTerminal {
rbacResource = dbReq . Workspace . ExecutionRBAC ( )
2024-03-29 15:14:27 +00:00
rbacResourceOwned = rbac . ResourceWorkspaceExecution . WithOwner ( roles . ID )
2023-04-04 00:59:41 +00:00
}
// 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).
2024-03-29 15:14:27 +00:00
err := p . Authorizer . Authorize ( ctx , * roles , rbacAction , rbacResource )
2023-04-04 00:59:41 +00:00
if err == nil {
2023-09-19 21:54:51 +00:00
return true , [ ] string { } , nil
2023-04-04 00:59:41 +00:00
}
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.
2024-03-29 15:14:27 +00:00
err := p . Authorizer . Authorize ( ctx , * roles , rbacAction , rbacResourceOwned )
2023-04-04 00:59:41 +00:00
if err == nil {
2023-09-19 21:54:51 +00:00
return true , [ ] string { } , nil
2023-04-04 00:59:41 +00:00
}
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.
2023-09-19 21:54:51 +00:00
return true , [ ] string { } , nil
2023-04-04 00:59:41 +00:00
}
// No checks were successful.
2023-09-19 21:54:51 +00:00
return false , warnings , nil
2023-04-04 00:59:41 +00:00
}