Files
coder/coderd/workspaceapps/db_test.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

1318 lines
43 KiB
Go

package workspaceapps_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
)
func Test_ResolveRequest(t *testing.T) {
t.Parallel()
const (
agentName = "agent"
appNameOwner = "app-owner"
appNameAuthed = "app-authed"
appNamePublic = "app-public"
appNameInvalidURL = "app-invalid-url"
// Users can access unhealthy and initializing apps (as of 2024-02).
appNameUnhealthy = "app-unhealthy"
appNameInitializing = "app-initializing"
appNameEndsInS = "app-ends-in-s"
// This agent will never connect, so it will never become "connected".
// Users cannot access unhealthy agents.
agentNameUnhealthy = "agent-unhealthy"
appNameAgentUnhealthy = "app-agent-unhealthy"
// This is not a valid URL we listen on in the test, but it needs to be
// set to a value.
appURL = "http://localhost:8080"
)
allApps := []string{appNameOwner, appNameAuthed, appNamePublic}
// Start a listener for a server that always responds with 500 for the
// unhealthy app.
unhealthySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("unhealthy"))
}))
t.Cleanup(unhealthySrv.Close)
// Start a listener for a server that never responds.
initializingServer, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
t.Cleanup(func() {
_ = initializingServer.Close()
})
initializingURL := fmt.Sprintf("http://%s", initializingServer.Addr().String())
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.DisablePathApps = false
deploymentValues.Dangerous.AllowPathAppSharing = true
deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = true
auditor := audit.NewMock()
t.Cleanup(func() {
if t.Failed() {
return
}
assert.Len(t, auditor.AuditLogs(), 0, "one or more test cases produced unexpected audit logs, did you replace the auditor or forget to call ResetLogs?")
})
client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
AppHostname: "*.test.coder.com",
DeploymentValues: deploymentValues,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Millisecond * 100,
MetricsCacheRefreshInterval: time.Millisecond * 100,
RealIPConfig: &httpmw.RealIPConfig{
TrustedOrigins: []*net.IPNet{{
IP: net.ParseIP("127.0.0.1"),
Mask: net.CIDRMask(8, 32),
}},
TrustedHeaders: []string{
"CF-Connecting-IP",
},
},
Auditor: auditor,
})
t.Cleanup(func() {
_ = closer.Close()
})
ctx := testutil.Context(t, testutil.WaitMedium)
firstUser := coderdtest.CreateFirstUser(t, client)
me, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
secondUserClient, secondUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
agentAuthToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: []*proto.Response{{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{
{
Id: uuid.NewString(),
Name: agentName,
Auth: &proto.Agent_Token{
Token: agentAuthToken,
},
Apps: []*proto.App{
{
Slug: appNameOwner,
DisplayName: appNameOwner,
SharingLevel: proto.AppSharingLevel_OWNER,
Url: appURL,
},
{
Slug: appNameAuthed,
DisplayName: appNameAuthed,
SharingLevel: proto.AppSharingLevel_AUTHENTICATED,
Url: appURL,
},
{
Slug: appNamePublic,
DisplayName: appNamePublic,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
},
{
Slug: appNameInvalidURL,
DisplayName: appNameInvalidURL,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: "test:path/to/app",
},
{
Slug: appNameUnhealthy,
DisplayName: appNameUnhealthy,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
Healthcheck: &proto.Healthcheck{
Url: unhealthySrv.URL,
Interval: 1,
Threshold: 1,
},
},
{
Slug: appNameInitializing,
DisplayName: appNameInitializing,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
Healthcheck: &proto.Healthcheck{
Url: initializingURL,
Interval: 30,
Threshold: 1000,
},
},
{
Slug: appNameEndsInS,
DisplayName: appNameEndsInS,
SharingLevel: proto.AppSharingLevel_OWNER,
Url: appURL,
},
},
},
{
Id: uuid.NewString(),
Name: agentNameUnhealthy,
Auth: &proto.Agent_Token{
Token: uuid.NewString(),
},
Apps: []*proto.App{
{
Slug: appNameAgentUnhealthy,
DisplayName: appNameAgentUnhealthy,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
},
},
},
},
}},
},
},
}},
})
template := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
_ = agenttest.New(t, client.URL, agentAuthToken)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID, agentName)
agentID := uuid.Nil
for _, resource := range resources {
for _, agnt := range resource.Agents {
if agnt.Name == agentName {
agentID = agnt.ID
break
}
}
}
require.NotEqual(t, uuid.Nil, agentID)
//nolint:gocritic // This is a test, allow dbauthz.AsSystemRestricted.
agent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID)
require.NoError(t, err)
//nolint:gocritic // This is a test, allow dbauthz.AsSystemRestricted.
apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID)
require.NoError(t, err)
appsBySlug := make(map[string]database.WorkspaceApp, len(apps))
for _, app := range apps {
appsBySlug[app.Slug] = app
}
// Reset audit logs so cleanup check can pass.
auditor.ResetLogs()
assertAuditAgent := auditAsserter[database.WorkspaceAgent](workspace)
assertAuditApp := auditAsserter[database.WorkspaceApp](workspace)
t.Run("OK", func(t *testing.T) {
t.Parallel()
cases := []struct {
name string
workspaceNameOrID string
agentNameOrID string
}{
{
name: "Names",
workspaceNameOrID: workspace.Name,
agentNameOrID: agentName,
},
{
name: "IDs",
workspaceNameOrID: workspace.ID.String(),
agentNameOrID: agentID.String(),
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
// Try resolving a request for each app as the owner, without a
// token, then use the token to resolve each app.
for _, app := range allApps {
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: c.workspaceNameOrID,
AgentNameOrID: c.agentNameOrID,
AppSlugOrPort: app,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
auditableUA := "Tidua"
t.Log("app", app)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
r.Header.Set("User-Agent", auditableUA)
// Try resolving the request without a token.
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
w := rw.Result()
if !assert.True(t, ok) {
dump, err := httputil.DumpResponse(w, true)
require.NoError(t, err, "error dumping failed response")
t.Log(string(dump))
return
}
_ = w.Body.Close()
require.Equal(t, &workspaceapps.SignedToken{
RegisteredClaims: jwtutils.RegisteredClaims{
Expiry: jwt.NewNumericDate(token.Expiry.Time()),
},
Request: req,
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
}, token)
require.NotZero(t, token.Expiry)
require.WithinDuration(t, time.Now().Add(workspaceapps.DefaultTokenExpiry), token.Expiry.Time(), time.Minute)
// Check that the token was set in the response and is valid.
require.Len(t, w.Cookies(), 1)
cookie := w.Cookies()[0]
require.Equal(t, codersdk.SignedAppTokenCookie, cookie.Name)
require.Equal(t, req.BasePath, cookie.Path)
assertAuditApp(t, rw, r, auditor, appsBySlug[app], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "audit log count")
var parsedToken workspaceapps.SignedToken
err := jwtutils.Verify(ctx, api.AppSigningKeyCache, cookie.Value, &parsedToken)
require.NoError(t, err)
// normalize expiry
require.WithinDuration(t, token.Expiry.Time(), parsedToken.Expiry.Time(), 2*time.Second)
parsedToken.Expiry = token.Expiry
require.Equal(t, token, &parsedToken)
// Try resolving the request with the token only.
rw = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/app", nil)
r.AddCookie(cookie)
r.RemoteAddr = auditableIP
secondToken, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
// normalize expiry
require.WithinDuration(t, token.Expiry.Time(), secondToken.Expiry.Time(), 2*time.Second)
secondToken.Expiry = token.Expiry
require.Equal(t, token, secondToken)
require.Len(t, auditor.AuditLogs(), 1, "no new audit log, FromRequest returned the same token and is not audited")
}
})
}
})
t.Run("AuthenticatedOtherUser", func(t *testing.T) {
t.Parallel()
for _, app := range allApps {
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: app,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
t.Log("app", app)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
w := rw.Result()
_ = w.Body.Close()
if app == appNameOwner {
require.False(t, ok)
require.Nil(t, token)
require.NotZero(t, w.StatusCode)
require.Equal(t, http.StatusNotFound, w.StatusCode)
return
}
require.True(t, ok)
require.NotNil(t, token)
require.Zero(t, w.StatusCode)
assertAuditApp(t, rw, r, auditor, appsBySlug[app], secondUser.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
}
})
t.Run("Unauthenticated", func(t *testing.T) {
t.Parallel()
for _, app := range allApps {
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: app,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
t.Log("app", app)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
w := rw.Result()
if app != appNamePublic {
require.False(t, ok)
require.Nil(t, token)
require.NotZero(t, rw.Code)
require.NotEqual(t, http.StatusOK, rw.Code)
assertAuditApp(t, rw, r, auditor, appsBySlug[app], uuid.Nil, nil)
require.Len(t, auditor.AuditLogs(), 1, "audit log for unauthenticated requests")
} else {
if !assert.True(t, ok) {
dump, err := httputil.DumpResponse(w, true)
require.NoError(t, err, "error dumping failed response")
t.Log(string(dump))
return
}
require.NotNil(t, token)
if rw.Code != 0 && rw.Code != http.StatusOK {
t.Fatalf("expected 200 (or unset) response code, got %d", rw.Code)
}
assertAuditApp(t, rw, r, auditor, appsBySlug[app], uuid.Nil, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
}
_ = w.Body.Close()
}
})
t.Run("Invalid", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: "invalid",
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.False(t, ok)
require.Nil(t, token)
require.Len(t, auditor.AuditLogs(), 0, "no audit logs for invalid requests")
})
t.Run("SplitWorkspaceAndAgent", func(t *testing.T) {
t.Parallel()
cases := []struct {
name string
workspaceAndAgent string
workspace string
agent string
ok bool
}{
{
name: "WorkspaceOnly",
workspaceAndAgent: workspace.Name,
workspace: workspace.Name,
agent: "",
ok: true,
},
{
name: "WorkspaceAndAgent",
workspaceAndAgent: fmt.Sprintf("%s.%s", workspace.Name, agentName),
workspace: workspace.Name,
agent: agentName,
ok: true,
},
{
name: "WorkspaceID",
workspaceAndAgent: workspace.ID.String(),
workspace: workspace.ID.String(),
agent: "",
ok: true,
},
{
name: "WorkspaceIDAndAgentID",
workspaceAndAgent: fmt.Sprintf("%s.%s", workspace.ID, agentID),
workspace: workspace.ID.String(),
agent: agentID.String(),
ok: true,
},
{
name: "Invalid1",
workspaceAndAgent: "invalid",
ok: false,
},
{
name: "Invalid2",
workspaceAndAgent: ".",
ok: false,
},
{
name: "Slash",
workspaceAndAgent: fmt.Sprintf("%s/%s", workspace.Name, agentName),
ok: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceAndAgent: c.workspaceAndAgent,
AppSlugOrPort: appNamePublic,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
w := rw.Result()
if !assert.Equal(t, c.ok, ok) {
dump, err := httputil.DumpResponse(w, true)
require.NoError(t, err, "error dumping failed response")
t.Log(string(dump))
return
}
if c.ok {
require.NotNil(t, token)
require.Equal(t, token.WorkspaceNameOrID, c.workspace)
require.Equal(t, token.AgentNameOrID, c.agent)
require.Equal(t, token.WorkspaceID, workspace.ID)
require.Equal(t, token.AgentID, agentID)
assertAuditApp(t, rw, r, auditor, appsBySlug[token.AppSlugOrPort], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
} else {
require.Nil(t, token)
require.Len(t, auditor.AuditLogs(), 0, "no audit logs")
}
_ = w.Body.Close()
})
}
})
t.Run("TokenDoesNotMatchRequest", func(t *testing.T) {
t.Parallel()
badToken := workspaceapps.SignedToken{
Request: (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
// App name differs
AppSlugOrPort: appNamePublic,
}).Normalize(),
RegisteredClaims: jwtutils.RegisteredClaims{
Expiry: jwt.NewNumericDate(time.Now().Add(time.Minute)),
},
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
}
badTokenStr, err := jwtutils.Sign(ctx, api.AppSigningKeyCache, badToken)
require.NoError(t, err)
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
// App name differs
AppSlugOrPort: appNameOwner,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.AddCookie(&http.Cookie{
Name: codersdk.SignedAppTokenCookie,
Value: badTokenStr,
})
r.RemoteAddr = auditableIP
// Even though the token is invalid, we should still perform request
// resolution without failure since we'll just ignore the bad token.
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
require.NotNil(t, token)
require.Equal(t, appNameOwner, token.AppSlugOrPort)
// Cookie should be set in response, and it should be a different
// token.
w := rw.Result()
_ = w.Body.Close()
cookies := w.Cookies()
require.Len(t, cookies, 1)
require.Equal(t, cookies[0].Name, codersdk.SignedAppTokenCookie)
require.NotEqual(t, cookies[0].Value, badTokenStr)
var parsedToken workspaceapps.SignedToken
err = jwtutils.Verify(ctx, api.AppSigningKeyCache, cookies[0].Value, &parsedToken)
require.NoError(t, err)
require.Equal(t, appNameOwner, parsedToken.AppSlugOrPort)
assertAuditApp(t, rw, r, auditor, appsBySlug[appNameOwner], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
})
t.Run("PortPathBlocked", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: "8080",
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.False(t, ok)
require.Nil(t, token)
w := rw.Result()
_ = w.Body.Close()
// TODO(mafredri): Verify this is the correct status code.
require.Equal(t, http.StatusInternalServerError, w.StatusCode)
require.Len(t, auditor.AuditLogs(), 0, "no audit logs for port path blocked requests")
})
t.Run("PortSubdomain", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: "9090",
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort)
require.Equal(t, "http://127.0.0.1:9090", token.AppURL)
assertAuditAgent(t, rw, r, auditor, agent, me.ID, map[string]any{
"slug_or_port": "9090",
})
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
})
t.Run("PortSubdomainHTTPSS", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: "9090ss",
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
_, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
// should parse as app and fail to find app "9090ss"
require.False(t, ok)
w := rw.Result()
_ = w.Body.Close()
b, err := io.ReadAll(w.Body)
require.NoError(t, err)
require.Contains(t, string(b), "404 - Application Not Found")
require.Equal(t, http.StatusNotFound, w.StatusCode)
require.Len(t, auditor.AuditLogs(), 0, "no audit logs for invalid requests")
})
t.Run("SubdomainEndsInS", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: appNameEndsInS,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort)
assertAuditApp(t, rw, r, auditor, appsBySlug[appNameEndsInS], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
})
t.Run("Terminal", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodTerminal,
BasePath: "/app",
AgentNameOrID: agentID.String(),
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
require.Equal(t, req.AccessMethod, token.AccessMethod)
require.Equal(t, req.BasePath, token.BasePath)
require.Empty(t, token.UsernameOrID)
require.Empty(t, token.WorkspaceNameOrID)
require.Equal(t, req.AgentNameOrID, token.Request.AgentNameOrID)
require.Empty(t, token.AppSlugOrPort)
require.Empty(t, token.AppURL)
assertAuditAgent(t, rw, r, auditor, agent, me.ID, map[string]any{
"slug_or_port": "terminal",
})
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
})
t.Run("InsufficientPermissions", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: appNameOwner,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.False(t, ok)
require.Nil(t, token)
assertAuditApp(t, rw, r, auditor, appsBySlug[appNameOwner], secondUser.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
})
t.Run("UserNotFound", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "thisuserdoesnotexist",
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: appNameOwner,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.False(t, ok)
require.Nil(t, token)
require.Len(t, auditor.AuditLogs(), 0, "no audit logs for user not found")
})
t.Run("RedirectSubdomainAuth", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: appNameOwner,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/some-path", nil)
// Should not be used as the hostname in the redirect URI.
r.Host = "app.com"
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
AppPath: "/some-path",
})
require.False(t, ok)
require.Nil(t, token)
w := rw.Result()
defer w.Body.Close()
require.Equal(t, http.StatusSeeOther, w.StatusCode)
// Note that we don't capture the owner UUID here because the apiKey
// check/authorization exits early.
assertAuditApp(t, rw, r, auditor, appsBySlug[appNameOwner], uuid.Nil, nil)
require.Len(t, auditor.AuditLogs(), 1, "autit log entry for redirect")
loc, err := w.Location()
require.NoError(t, err)
require.Equal(t, api.AccessURL.Scheme, loc.Scheme)
require.Equal(t, api.AccessURL.Host, loc.Host)
require.Equal(t, "/api/v2/applications/auth-redirect", loc.Path)
redirectURIStr := loc.Query().Get(workspaceapps.RedirectURIQueryParam)
redirectURI, err := url.Parse(redirectURIStr)
require.NoError(t, err)
appHost := appurl.ApplicationURL{
Prefix: "",
AppSlugOrPort: req.AppSlugOrPort,
AgentName: req.AgentNameOrID,
WorkspaceName: req.WorkspaceNameOrID,
Username: req.UsernameOrID,
}
host := strings.Replace(api.AppHostname, "*", appHost.String(), 1)
require.Equal(t, "http", redirectURI.Scheme)
require.Equal(t, host, redirectURI.Host)
require.Equal(t, "/some-path", redirectURI.Path)
})
t.Run("UnhealthyAgent", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentNameUnhealthy,
AppSlugOrPort: appNameAgentUnhealthy,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.False(t, ok, "request succeeded even though agent is not connected")
require.Nil(t, token)
w := rw.Result()
defer w.Body.Close()
require.Equal(t, http.StatusBadGateway, w.StatusCode)
assertAuditApp(t, rw, r, auditor, appsBySlug[appNameAgentUnhealthy], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
body, err := io.ReadAll(w.Body)
require.NoError(t, err)
bodyStr := string(body)
bodyStr = strings.ReplaceAll(bodyStr, """, `"`)
// It'll either be "connecting" or "disconnected". Both are OK for this
// test.
require.Contains(t, bodyStr, `Agent state is "`)
})
// Initializing apps are now permitted to connect anyways. This wasn't
// always the case, but we're testing the behavior to ensure it doesn't
// change back accidentally.
t.Run("InitializingAppPermitted", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
agent, err := client.WorkspaceAgent(ctx, agentID)
require.NoError(t, err)
for _, app := range agent.Apps {
if app.Slug == appNameInitializing {
t.Log("app is", app.Health)
require.Equal(t, codersdk.WorkspaceAppHealthInitializing, app.Health)
break
}
}
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: appNameInitializing,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok, "ResolveRequest failed, should pass even though app is initializing")
require.NotNil(t, token)
assertAuditApp(t, rw, r, auditor, appsBySlug[token.AppSlugOrPort], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
})
// Unhealthy apps are now permitted to connect anyways. This wasn't always
// the case, but we're testing the behavior to ensure it doesn't change back
// accidentally.
t.Run("UnhealthyAppPermitted", func(t *testing.T) {
t.Parallel()
require.Eventually(t, func() bool {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
agent, err := client.WorkspaceAgent(ctx, agentID)
if err != nil {
t.Log("could not get agent", err)
return false
}
for _, app := range agent.Apps {
if app.Slug == appNameUnhealthy {
t.Log("app is", app.Health)
return app.Health == codersdk.WorkspaceAppHealthUnhealthy
}
}
t.Log("could not find app")
return false
}, testutil.WaitLong, testutil.IntervalFast, "wait for app to become unhealthy")
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: appNameUnhealthy,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok, "ResolveRequest failed, should pass even though app is unhealthy")
require.NotNil(t, token)
assertAuditApp(t, rw, r, auditor, appsBySlug[token.AppSlugOrPort], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
})
t.Run("AuditLogging", func(t *testing.T) {
t.Parallel()
for _, app := range allApps {
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: app,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
t.Log("app", app)
// First request, new audit log.
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
_, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
assertAuditApp(t, rw, r, auditor, appsBySlug[app], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
// Second request, no audit log because the session is active.
rw = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
_, ok = workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
require.Len(t, auditor.AuditLogs(), 1, "single audit log, previous session active")
// Third request, session timed out, new audit log.
rw = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
sessionTimeoutTokenProvider := signedTokenProviderWithAuditor(t, api.WorkspaceAppsProvider, auditor, 0)
_, ok = workspaceappsResolveRequest(t, nil, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: sessionTimeoutTokenProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
assertAuditApp(t, rw, r, auditor, appsBySlug[app], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 2, "two audit logs, session timed out")
// Fourth request, new IP produces new audit log.
auditableIP = testutil.RandomIPv6(t)
rw = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
_, ok = workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
assertAuditApp(t, rw, r, auditor, appsBySlug[app], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 3, "three audit logs, new IP")
}
})
}
func workspaceappsResolveRequest(t testing.TB, auditor audit.Auditor, w http.ResponseWriter, r *http.Request, opts workspaceapps.ResolveRequestOptions) (token *workspaceapps.SignedToken, ok bool) {
t.Helper()
if opts.SignedTokenProvider != nil && auditor != nil {
opts.SignedTokenProvider = signedTokenProviderWithAuditor(t, opts.SignedTokenProvider, auditor, time.Hour)
}
tracing.StatusWriterMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpmw.AttachRequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok = workspaceapps.ResolveRequest(w, r, opts)
})).ServeHTTP(w, r)
})).ServeHTTP(w, r)
return token, ok
}
func signedTokenProviderWithAuditor(t testing.TB, provider workspaceapps.SignedTokenProvider, auditor audit.Auditor, sessionTimeout time.Duration) workspaceapps.SignedTokenProvider {
t.Helper()
p, ok := provider.(*workspaceapps.DBTokenProvider)
require.True(t, ok, "provider is not a DBTokenProvider")
shallowCopy := *p
shallowCopy.Auditor = &atomic.Pointer[audit.Auditor]{}
shallowCopy.Auditor.Store(&auditor)
shallowCopy.WorkspaceAppAuditSessionTimeout = sessionTimeout
return &shallowCopy
}
func auditAsserter[T audit.Auditable](workspace codersdk.Workspace) func(t testing.TB, rr *httptest.ResponseRecorder, r *http.Request, auditor *audit.MockAuditor, auditable T, userID uuid.UUID, additionalFields map[string]any) {
return func(t testing.TB, rr *httptest.ResponseRecorder, r *http.Request, auditor *audit.MockAuditor, auditable T, userID uuid.UUID, additionalFields map[string]any) {
t.Helper()
resp := rr.Result()
defer resp.Body.Close()
require.True(t, auditor.Contains(t, database.AuditLog{
OrganizationID: workspace.OrganizationID,
Action: database.AuditActionOpen,
ResourceType: audit.ResourceType(auditable),
ResourceID: audit.ResourceID(auditable),
ResourceTarget: audit.ResourceTarget(auditable),
UserID: userID,
Ip: audit.ParseIP(r.RemoteAddr),
UserAgent: sql.NullString{Valid: r.UserAgent() != "", String: r.UserAgent()},
StatusCode: int32(resp.StatusCode), //nolint:gosec
}), "audit log")
// Verify additional fields, assume the last log entry.
alog := auditor.AuditLogs()[len(auditor.AuditLogs())-1]
// Contains does not verify uuid.Nil.
if userID == uuid.Nil {
require.Equal(t, uuid.Nil, alog.UserID, "unauthenticated user")
}
add := make(map[string]any)
if len(alog.AdditionalFields) > 0 {
err := json.Unmarshal([]byte(alog.AdditionalFields), &add)
require.NoError(t, err, "audit log unmarhsal additional fields")
}
for k, v := range additionalFields {
require.Equal(t, v, add[k], "audit log additional field %s: additional fields: %v", k, add)
}
}
}