mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
* chore: merge apikey/token session config values There is a confusing difference between an apikey and a token. This difference leaks into our configs. This change does not resolve the difference. It only groups the config values to try and manage any bloat that occurs from adding more similar config values
329 lines
13 KiB
Go
329 lines
13 KiB
Go
package workspaceapps
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/exp/slices"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog"
|
|
"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"
|
|
)
|
|
|
|
// 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
|
|
|
|
// DashboardURL is the main dashboard access URL for error pages.
|
|
DashboardURL *url.URL
|
|
Authorizer rbac.Authorizer
|
|
Database database.Store
|
|
DeploymentValues *codersdk.DeploymentValues
|
|
OAuth2Configs *httpmw.OAuth2Configs
|
|
WorkspaceAgentInactiveTimeout time.Duration
|
|
SigningKey SecurityKey
|
|
}
|
|
|
|
var _ SignedTokenProvider = &DBTokenProvider{}
|
|
|
|
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 {
|
|
if workspaceAgentInactiveTimeout == 0 {
|
|
workspaceAgentInactiveTimeout = 1 * time.Minute
|
|
}
|
|
|
|
return &DBTokenProvider{
|
|
Logger: log,
|
|
DashboardURL: accessURL,
|
|
Authorizer: authz,
|
|
Database: db,
|
|
DeploymentValues: cfg,
|
|
OAuth2Configs: oauth2Cfgs,
|
|
WorkspaceAgentInactiveTimeout: workspaceAgentInactiveTimeout,
|
|
SigningKey: signingKey,
|
|
}
|
|
}
|
|
|
|
func (p *DBTokenProvider) FromRequest(r *http.Request) (*SignedToken, bool) {
|
|
return FromRequest(r, p.SigningKey)
|
|
}
|
|
|
|
func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *http.Request, issueReq IssueTokenRequest) (*SignedToken, string, 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(ctx)
|
|
|
|
appReq := issueReq.AppRequest.Normalize()
|
|
err := appReq.Validate()
|
|
if err != nil {
|
|
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "invalid app request")
|
|
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,
|
|
DisableSessionExpiryRefresh: p.DeploymentValues.Sessions.DisableExpiryRefresh.Value(),
|
|
// 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.
|
|
Optional: true,
|
|
SessionTokenFunc: func(r *http.Request) string {
|
|
return issueReq.SessionToken
|
|
},
|
|
})
|
|
if !ok {
|
|
return nil, "", false
|
|
}
|
|
|
|
// Lookup workspace app details from DB.
|
|
dbReq, err := appReq.getDatabase(dangerousSystemCtx, p.Database)
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
WriteWorkspaceApp404(p.Logger, p.DashboardURL, rw, r, &appReq, nil, err.Error())
|
|
return nil, "", false
|
|
} else if xerrors.Is(err, errWorkspaceStopped) {
|
|
WriteWorkspaceOffline(p.Logger, p.DashboardURL, rw, r, &appReq)
|
|
return nil, "", false
|
|
} else if err != nil {
|
|
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "get app details from database")
|
|
return nil, "", false
|
|
}
|
|
token.UserID = dbReq.User.ID
|
|
token.WorkspaceID = dbReq.Workspace.ID
|
|
token.AgentID = dbReq.Agent.ID
|
|
if dbReq.AppURL != nil {
|
|
token.AppURL = dbReq.AppURL.String()
|
|
}
|
|
|
|
// Verify the user has access to the app.
|
|
authed, warnings, err := p.authorizeRequest(r.Context(), authz, dbReq)
|
|
if err != nil {
|
|
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "verify authz")
|
|
return nil, "", false
|
|
}
|
|
if !authed {
|
|
if apiKey != nil {
|
|
// The request has a valid API key but insufficient permissions.
|
|
WriteWorkspaceApp404(p.Logger, p.DashboardURL, rw, r, &appReq, warnings, "insufficient permissions")
|
|
return nil, "", false
|
|
}
|
|
|
|
// Redirect to login as they don't have permission to access the app
|
|
// and they aren't signed in.
|
|
|
|
// 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 {
|
|
httpapi.ResourceNotFound(rw)
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
return nil, "", false
|
|
}
|
|
|
|
// Check that the agent is online.
|
|
agentStatus := dbReq.Agent.Status(p.WorkspaceAgentInactiveTimeout)
|
|
if agentStatus.Status != database.WorkspaceAgentStatusConnected {
|
|
WriteWorkspaceAppOffline(p.Logger, p.DashboardURL, rw, r, &appReq, fmt.Sprintf("Agent state is %q, not %q", agentStatus.Status, database.WorkspaceAgentStatusConnected))
|
|
return nil, "", false
|
|
}
|
|
|
|
// 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.
|
|
|
|
// As a sanity check, ensure the token we just made is valid for this
|
|
// request.
|
|
if !token.MatchesRequest(appReq) {
|
|
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, nil, "fresh token does not match request")
|
|
return nil, "", false
|
|
}
|
|
|
|
// Sign the token.
|
|
token.Expiry = time.Now().Add(DefaultTokenExpiry)
|
|
tokenStr, err := p.SigningKey.SignToken(token)
|
|
if err != nil {
|
|
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "generate token")
|
|
return nil, "", false
|
|
}
|
|
|
|
return &token, tokenStr, true
|
|
}
|
|
|
|
// 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.
|
|
func (p *DBTokenProvider) authorizeRequest(ctx context.Context, roles *rbac.Subject, dbReq *databaseRequest) (bool, []string, error) {
|
|
var warnings []string
|
|
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() {
|
|
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))
|
|
}
|
|
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, warnings, 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.ID &&
|
|
!p.DeploymentValues.Dangerous.AllowPathAppSiteOwnerAccess.Value() {
|
|
// 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.
|
|
if roles != nil && slices.Contains(roles.Roles.Names(), rbac.RoleOwner()) {
|
|
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
|
|
}
|
|
|
|
// 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.ID)
|
|
)
|
|
if dbReq.AccessMethod == AccessMethodTerminal {
|
|
rbacResource = dbReq.Workspace.ExecutionRBAC()
|
|
rbacResourceOwned = rbac.ResourceWorkspaceExecution.WithOwner(roles.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, rbacAction, rbacResource)
|
|
if err == nil {
|
|
return true, []string{}, 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, rbacAction, rbacResourceOwned)
|
|
if err == nil {
|
|
return true, []string{}, 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, []string{}, nil
|
|
}
|
|
|
|
// No checks were successful.
|
|
return false, warnings, nil
|
|
}
|