Files
coder/coderd/workspaceapps/request.go
Mathias Fredriksson de41bd6b95 feat: add support for workspace app audit (#16801)
This change adds support for workspace app auditing.

To avoid audit log spam, we introduce the concept of app audit sessions.
An audit session is unique per workspace app, user, ip, user agent and
http status code. The sessions are stored in a separate table from audit
logs to allow use-case specific optimizations. Sessions are ephemeral
and the table does not function as a log.

The logic for auditing is placed in the DBTokenProvider for workspace
apps so that wsproxies are included.

This is the final change affecting the API fo #15139.

Updates #15139
2025-03-18 13:50:52 +02:00

474 lines
15 KiB
Go

package workspaceapps
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"golang.org/x/xerrors"
"github.com/google/uuid"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
)
var errWorkspaceStopped = xerrors.New("stopped workspace")
type AccessMethod string
const (
AccessMethodPath AccessMethod = "path"
AccessMethodSubdomain AccessMethod = "subdomain"
// AccessMethodTerminal is special since it's not a real app and only
// applies to the PTY endpoint on the API.
AccessMethodTerminal AccessMethod = "terminal"
)
type IssueTokenRequest struct {
AppRequest Request `json:"app_request"`
// PathAppBaseURL is required.
PathAppBaseURL string `json:"path_app_base_url"`
// AppHostname is the optional hostname for subdomain apps on the external
// proxy. It must start with an asterisk.
AppHostname string `json:"app_hostname"`
// AppPath is the path of the user underneath the app base path.
AppPath string `json:"app_path"`
// AppQuery is the query parameters the user provided in the app request.
AppQuery string `json:"app_query"`
// SessionToken is the session token provided by the user.
SessionToken string `json:"session_token"`
}
// AppBaseURL returns the base URL of this specific app request. An error is
// returned if a subdomain app hostname is not provided but the app is a
// subdomain app.
func (r IssueTokenRequest) AppBaseURL() (*url.URL, error) {
u, err := url.Parse(r.PathAppBaseURL)
if err != nil {
return nil, xerrors.Errorf("parse path app base URL: %w", err)
}
switch r.AppRequest.AccessMethod {
case AccessMethodPath, AccessMethodTerminal:
u.Path = r.AppRequest.BasePath
if !strings.HasSuffix(u.Path, "/") {
u.Path += "/"
}
return u, nil
case AccessMethodSubdomain:
if r.AppHostname == "" {
return nil, xerrors.New("subdomain app hostname is required to generate subdomain app URL")
}
appHost := appurl.ApplicationURL{
Prefix: r.AppRequest.Prefix,
AppSlugOrPort: r.AppRequest.AppSlugOrPort,
AgentName: r.AppRequest.AgentNameOrID,
WorkspaceName: r.AppRequest.WorkspaceNameOrID,
Username: r.AppRequest.UsernameOrID,
}
u.Host = strings.Replace(r.AppHostname, "*", appHost.String(), 1)
u.Path = r.AppRequest.BasePath
return u, nil
default:
return nil, xerrors.Errorf("invalid access method: %q", r.AppRequest.AccessMethod)
}
}
type Request struct {
AccessMethod AccessMethod `json:"access_method"`
// BasePath of the app. For path apps, this is the path prefix in the router
// for this particular app. For subdomain apps, this should be "/". This is
// used for setting the cookie path.
BasePath string `json:"base_path"`
// Prefix is the prefix of the subdomain app URL. Prefix should have a
// trailing "---" if set.
Prefix string `json:"app_prefix"`
// For the following fields, if the AccessMethod is AccessMethodTerminal,
// then only AgentNameOrID may be set and it must be a UUID. The other
// fields must be left blank.
UsernameOrID string `json:"username_or_id"`
// WorkspaceAndAgent xor WorkspaceNameOrID are required.
WorkspaceAndAgent string `json:"-"` // "workspace" or "workspace.agent"
WorkspaceNameOrID string `json:"workspace_name_or_id"`
// AgentNameOrID is not required if the workspace has only one agent.
AgentNameOrID string `json:"agent_name_or_id"`
AppSlugOrPort string `json:"app_slug_or_port"`
}
// Normalize replaces WorkspaceAndAgent with WorkspaceNameOrID and
// AgentNameOrID. This must be called before Validate.
func (r Request) Normalize() Request {
req := r
if req.WorkspaceAndAgent != "" {
// workspace.agent
workspaceAndAgent := strings.SplitN(req.WorkspaceAndAgent, ".", 2)
req.WorkspaceAndAgent = ""
req.WorkspaceNameOrID = workspaceAndAgent[0]
if len(workspaceAndAgent) > 1 {
req.AgentNameOrID = workspaceAndAgent[1]
}
}
if !strings.HasSuffix(req.BasePath, "/") {
req.BasePath += "/"
}
return req
}
// Check ensures the request is correct and contains the necessary
// parameters.
func (r Request) Check() error {
switch r.AccessMethod {
case AccessMethodPath, AccessMethodSubdomain, AccessMethodTerminal:
default:
return xerrors.Errorf("invalid access method: %q", r.AccessMethod)
}
if r.BasePath == "" {
return xerrors.New("base path is required")
}
if r.WorkspaceAndAgent != "" {
return xerrors.New("dev error: appReq.Validate() called before appReq.Normalize()")
}
if r.AccessMethod == AccessMethodTerminal {
if r.UsernameOrID != "" || r.WorkspaceNameOrID != "" || r.AppSlugOrPort != "" {
return xerrors.New("dev error: cannot specify any fields other than r.AccessMethod, r.BasePath and r.AgentNameOrID for terminal access method")
}
if r.AgentNameOrID == "" {
return xerrors.New("agent name or ID is required")
}
if _, err := uuid.Parse(r.AgentNameOrID); err != nil {
return xerrors.Errorf("invalid agent name or ID %q, must be a UUID: %w", r.AgentNameOrID, err)
}
return nil
}
if r.UsernameOrID == "" {
return xerrors.New("username or ID is required")
}
if r.UsernameOrID == codersdk.Me {
// We block "me" for workspace app auth to avoid any security issues
// caused by having an identical workspace name on yourself and a
// different user and potentially reusing a token.
//
// This is also mitigated by storing the workspace/agent ID in the
// token, but we block it here to be double safe.
//
// Subdomain apps have never been used with "me" from our code, and path
// apps now have a redirect to remove the "me" from the URL.
return xerrors.New(`username cannot be "me" in app requests`)
}
if r.WorkspaceNameOrID == "" {
return xerrors.New("workspace name or ID is required")
}
if r.AppSlugOrPort == "" {
return xerrors.New("app slug or port is required")
}
if r.Prefix != "" && r.AccessMethod != AccessMethodSubdomain {
return xerrors.New("prefix is only valid for subdomain apps")
}
if r.Prefix != "" && !strings.HasSuffix(r.Prefix, "---") {
return xerrors.New("prefix must have a trailing '---'")
}
return nil
}
type databaseRequest struct {
Request
// User is the user that owns the app.
User database.User
// Workspace is the workspace that the app is in.
Workspace database.Workspace
// Agent is the agent that the app is running on.
Agent database.WorkspaceAgent
// App is the app that the user is trying to access.
App database.WorkspaceApp
// AppURL is the resolved URL to the workspace app. This is only set for non
// terminal requests.
AppURL *url.URL
// AppSharingLevel is the sharing level of the app. This is forced to be set
// to AppSharingLevelOwner if the access method is terminal.
AppSharingLevel database.AppSharingLevel
}
// getDatabase does queries to get the owner user, workspace and agent
// associated with the app in the request. This will correctly perform the
// queries in the correct order based on the access method and what fields are
// available.
//
// If any of the queries don't return any rows, the error will wrap
// sql.ErrNoRows. All other errors should be considered internal server errors.
func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseRequest, error) {
// If the AccessMethod is AccessMethodTerminal, then we need to get the
// agent first since that's the only info we have.
if r.AccessMethod == AccessMethodTerminal {
return r.getDatabaseTerminal(ctx, db)
}
// For non-terminal requests, get the objects in order since we have all
// fields available.
// Get user.
var (
user database.User
userErr error
)
if userID, uuidErr := uuid.Parse(r.UsernameOrID); uuidErr == nil {
user, userErr = db.GetUserByID(ctx, userID)
} else {
user, userErr = db.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
Username: r.UsernameOrID,
})
}
if userErr != nil {
return nil, xerrors.Errorf("get user %q: %w", r.UsernameOrID, userErr)
}
// Get workspace.
var (
workspace database.Workspace
workspaceErr error
)
if workspaceID, uuidErr := uuid.Parse(r.WorkspaceNameOrID); uuidErr == nil {
workspace, workspaceErr = db.GetWorkspaceByID(ctx, workspaceID)
} else {
workspace, workspaceErr = db.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
OwnerID: user.ID,
Name: r.WorkspaceNameOrID,
Deleted: false,
})
}
if workspaceErr != nil {
return nil, xerrors.Errorf("get workspace %q: %w", r.WorkspaceNameOrID, workspaceErr)
}
// Get workspace agents.
agents, err := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
return nil, xerrors.Errorf("get workspace agents: %w", err)
}
build, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
return nil, xerrors.Errorf("get latest workspace build: %w", err)
}
if build.Transition == database.WorkspaceTransitionStop {
return nil, errWorkspaceStopped
}
if len(agents) == 0 {
// TODO(@deansheather): return a 404 if there are no agents in the
// workspace, requires a different error type.
return nil, xerrors.Errorf("no agents in workspace: %w", sql.ErrNoRows)
}
// Get workspace apps.
agentIDs := make([]uuid.UUID, len(agents))
for i, agent := range agents {
agentIDs[i] = agent.ID
}
apps, err := db.GetWorkspaceAppsByAgentIDs(ctx, agentIDs)
if err != nil {
return nil, xerrors.Errorf("get workspace apps: %w", err)
}
// Get the app first, because r.AgentNameOrID is optional depending on
// whether the app is a slug or a port and whether there are multiple agents
// in the workspace or not.
var (
agentNameOrID = r.AgentNameOrID
app database.WorkspaceApp
appURL string
appSharingLevel database.AppSharingLevel
// First check if it's a port-based URL with an optional "s" suffix for HTTPS.
potentialPortStr = strings.TrimSuffix(r.AppSlugOrPort, "s")
portUint, portUintErr = strconv.ParseUint(potentialPortStr, 10, 16)
)
//nolint:nestif
if portUintErr == nil {
protocol := "http"
if strings.HasSuffix(r.AppSlugOrPort, "s") {
protocol = "https"
}
if r.AccessMethod != AccessMethodSubdomain {
// TODO(@deansheather): this should return a 400 instead of a 500.
return nil, xerrors.New("port-based URLs are only supported for subdomain-based applications")
}
// If the user specified a port, then they must specify the agent if
// there are multiple agents in the workspace. App names are unique per
// workspace.
if agentNameOrID == "" {
if len(agents) != 1 {
return nil, xerrors.New("port specified with no agent, but multiple agents exist in the workspace")
}
agentNameOrID = agents[0].ID.String()
}
// If the app slug is a port number, then route to the port as an
// "anonymous app".
//
// This is only supported for subdomain-based applications.
appURL = fmt.Sprintf("%s://127.0.0.1:%d", protocol, portUint)
appSharingLevel = database.AppSharingLevelOwner
// Port sharing authorization
agentName := agentNameOrID
id, err := uuid.Parse(agentNameOrID)
for _, a := range agents {
// if err is nil then it's an UUID
if err == nil && a.ID == id {
agentName = a.Name
break
}
// otherwise it's a name
if a.Name == agentNameOrID {
break
}
}
// First check if there is a port share for the port
ps, err := db.GetWorkspaceAgentPortShare(ctx, database.GetWorkspaceAgentPortShareParams{
WorkspaceID: workspace.ID,
AgentName: agentName,
Port: int32(portUint),
})
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return nil, xerrors.Errorf("get workspace agent port share: %w", err)
}
// No port share found, so we keep default to owner.
} else {
appSharingLevel = ps.ShareLevel
}
} else {
for _, a := range apps {
if a.Slug == r.AppSlugOrPort {
app = a
if !app.Url.Valid {
return nil, xerrors.Errorf("app URL is not valid")
}
agentNameOrID = app.AgentID.String()
if app.SharingLevel != "" {
appSharingLevel = app.SharingLevel
} else {
appSharingLevel = database.AppSharingLevelOwner
}
appURL = app.Url.String
break
}
}
}
if appURL == "" {
return nil, xerrors.Errorf("no app found with slug %q: %w", r.AppSlugOrPort, sql.ErrNoRows)
}
// Finally, get agent.
var agent database.WorkspaceAgent
if agentID, uuidErr := uuid.Parse(agentNameOrID); uuidErr == nil {
for _, a := range agents {
if a.ID == agentID {
agent = a
break
}
}
} else {
if agentNameOrID == "" && len(agents) == 1 {
agent = agents[0]
} else {
for _, a := range agents {
if a.Name == agentNameOrID {
agent = a
break
}
}
}
if agent.ID == uuid.Nil {
return nil, xerrors.Errorf("no agent found with name %q: %w", r.AgentNameOrID, sql.ErrNoRows)
}
}
appURLParsed, err := url.Parse(appURL)
if err != nil {
return nil, xerrors.Errorf("parse app URL %q: %w", appURL, err)
}
return &databaseRequest{
Request: r,
User: user,
Workspace: workspace,
Agent: agent,
App: app,
AppURL: appURLParsed,
AppSharingLevel: appSharingLevel,
}, nil
}
// getDatabaseTerminal is called by getDatabase for AccessMethodTerminal
// requests.
func (r Request) getDatabaseTerminal(ctx context.Context, db database.Store) (*databaseRequest, error) {
if r.AccessMethod != AccessMethodTerminal {
return nil, xerrors.Errorf("invalid access method %q for terminal request", r.AccessMethod)
}
agentID, uuidErr := uuid.Parse(r.AgentNameOrID)
if uuidErr != nil {
return nil, xerrors.Errorf("invalid agent name or ID %q, must be a UUID for terminal requests: %w", r.AgentNameOrID, uuidErr)
}
var err error
agent, err := db.GetWorkspaceAgentByID(ctx, agentID)
if err != nil {
return nil, xerrors.Errorf("get workspace agent %q: %w", agentID, err)
}
// Get the corresponding resource.
res, err := db.GetWorkspaceResourceByID(ctx, agent.ResourceID)
if err != nil {
return nil, xerrors.Errorf("get workspace agent resource %q: %w", agent.ResourceID, err)
}
// Get the corresponding workspace build.
build, err := db.GetWorkspaceBuildByJobID(ctx, res.JobID)
if err != nil {
return nil, xerrors.Errorf("get workspace build by job ID %q: %w", res.JobID, err)
}
// Get the corresponding workspace.
workspace, err := db.GetWorkspaceByID(ctx, build.WorkspaceID)
if err != nil {
return nil, xerrors.Errorf("get workspace %q: %w", build.WorkspaceID, err)
}
// Get the workspace's owner.
user, err := db.GetUserByID(ctx, workspace.OwnerID)
if err != nil {
return nil, xerrors.Errorf("get user %q: %w", workspace.OwnerID, err)
}
return &databaseRequest{
Request: r,
User: user,
Workspace: workspace,
Agent: agent,
AppURL: nil,
AppSharingLevel: database.AppSharingLevelOwner,
}, nil
}