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
This commit is contained in:
Mathias Fredriksson
2025-03-18 13:50:52 +02:00
committed by GitHub
parent 3ae55bbbf4
commit de41bd6b95
25 changed files with 1042 additions and 159 deletions
+211 -17
View File
@@ -3,27 +3,32 @@ package workspaceapps
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"slices"
"strings"
"sync/atomic"
"time"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/go-jose/go-jose/v4/jwt"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/codersdk"
)
@@ -33,13 +38,15 @@ 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
Keycache cryptokeys.SigningKeycache
DashboardURL *url.URL
Authorizer rbac.Authorizer
Auditor *atomic.Pointer[audit.Auditor]
Database database.Store
DeploymentValues *codersdk.DeploymentValues
OAuth2Configs *httpmw.OAuth2Configs
WorkspaceAgentInactiveTimeout time.Duration
WorkspaceAppAuditSessionTimeout time.Duration
Keycache cryptokeys.SigningKeycache
}
var _ SignedTokenProvider = &DBTokenProvider{}
@@ -47,25 +54,32 @@ var _ SignedTokenProvider = &DBTokenProvider{}
func NewDBTokenProvider(log slog.Logger,
accessURL *url.URL,
authz rbac.Authorizer,
auditor *atomic.Pointer[audit.Auditor],
db database.Store,
cfg *codersdk.DeploymentValues,
oauth2Cfgs *httpmw.OAuth2Configs,
workspaceAgentInactiveTimeout time.Duration,
workspaceAppAuditSessionTimeout time.Duration,
signer cryptokeys.SigningKeycache,
) SignedTokenProvider {
if workspaceAgentInactiveTimeout == 0 {
workspaceAgentInactiveTimeout = 1 * time.Minute
}
if workspaceAppAuditSessionTimeout == 0 {
workspaceAppAuditSessionTimeout = time.Hour
}
return &DBTokenProvider{
Logger: log,
DashboardURL: accessURL,
Authorizer: authz,
Database: db,
DeploymentValues: cfg,
OAuth2Configs: oauth2Cfgs,
WorkspaceAgentInactiveTimeout: workspaceAgentInactiveTimeout,
Keycache: signer,
Logger: log,
DashboardURL: accessURL,
Authorizer: authz,
Auditor: auditor,
Database: db,
DeploymentValues: cfg,
OAuth2Configs: oauth2Cfgs,
WorkspaceAgentInactiveTimeout: workspaceAgentInactiveTimeout,
WorkspaceAppAuditSessionTimeout: workspaceAppAuditSessionTimeout,
Keycache: signer,
}
}
@@ -81,6 +95,9 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
// // permissions.
dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx)
aReq, commitAudit := p.auditInitRequest(ctx, rw, r)
defer commitAudit()
appReq := issueReq.AppRequest.Normalize()
err := appReq.Check()
if err != nil {
@@ -111,6 +128,8 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
return nil, "", false
}
aReq.apiKey = apiKey // Update audit request.
// Lookup workspace app details from DB.
dbReq, err := appReq.getDatabase(dangerousSystemCtx, p.Database)
if xerrors.Is(err, sql.ErrNoRows) {
@@ -123,6 +142,9 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "get app details from database")
return nil, "", false
}
aReq.dbReq = dbReq // Update audit request.
token.UserID = dbReq.User.ID
token.WorkspaceID = dbReq.Workspace.ID
token.AgentID = dbReq.Agent.ID
@@ -341,3 +363,175 @@ func (p *DBTokenProvider) authorizeRequest(ctx context.Context, roles *rbac.Subj
// No checks were successful.
return false, warnings, nil
}
type auditRequest struct {
time time.Time
apiKey *database.APIKey
dbReq *databaseRequest
}
// auditInitRequest creates a new audit session and audit log for the given
// request, if one does not already exist. If an audit session already exists,
// it will be updated with the current timestamp. A session is used to reduce
// the number of audit logs created.
//
// A session is unique to the agent, app, user and users IP. If any of these
// values change, a new session and audit log is created.
func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (aReq *auditRequest, commit func()) {
// Get the status writer from the request context so we can figure
// out the HTTP status and autocommit the audit log.
sw, ok := w.(*tracing.StatusWriter)
if !ok {
panic("dev error: http.ResponseWriter is not *tracing.StatusWriter")
}
aReq = &auditRequest{
time: dbtime.Now(),
}
// Set the commit function on the status writer to create an audit
// log, this ensures that the status and response body are available.
var committed bool
return aReq, func() {
if committed {
return
}
committed = true
if aReq.dbReq == nil {
// App doesn't exist, there's information in the Request
// struct but we need UUIDs for audit logging.
return
}
userID := uuid.Nil
if aReq.apiKey != nil {
userID = aReq.apiKey.UserID
}
userAgent := r.UserAgent()
ip := r.RemoteAddr
// Approximation of the status code.
statusCode := sw.Status
if statusCode == 0 {
statusCode = http.StatusOK
}
type additionalFields struct {
audit.AdditionalFields
SlugOrPort string `json:"slug_or_port,omitempty"`
}
appInfo := additionalFields{
AdditionalFields: audit.AdditionalFields{
WorkspaceOwner: aReq.dbReq.Workspace.OwnerUsername,
WorkspaceName: aReq.dbReq.Workspace.Name,
WorkspaceID: aReq.dbReq.Workspace.ID,
},
}
switch {
case aReq.dbReq.AccessMethod == AccessMethodTerminal:
appInfo.SlugOrPort = "terminal"
case aReq.dbReq.App.ID == uuid.Nil:
// If this isn't an app or a terminal, it's a port.
appInfo.SlugOrPort = aReq.dbReq.AppSlugOrPort
}
// If we end up logging, ensure relevant fields are set.
logger := p.Logger.With(
slog.F("workspace_id", aReq.dbReq.Workspace.ID),
slog.F("agent_id", aReq.dbReq.Agent.ID),
slog.F("app_id", aReq.dbReq.App.ID),
slog.F("user_id", userID),
slog.F("user_agent", userAgent),
slog.F("app_slug_or_port", appInfo.SlugOrPort),
slog.F("status_code", statusCode),
)
var startedAt time.Time
err := p.Database.InTx(func(tx database.Store) (err error) {
// nolint:gocritic // System context is needed to write audit sessions.
dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx)
startedAt, err = tx.UpsertWorkspaceAppAuditSession(dangerousSystemCtx, database.UpsertWorkspaceAppAuditSessionParams{
// Config.
StaleIntervalMS: p.WorkspaceAppAuditSessionTimeout.Milliseconds(),
// Data.
AgentID: aReq.dbReq.Agent.ID,
AppID: aReq.dbReq.App.ID, // Can be unset, in which case uuid.Nil is fine.
UserID: userID, // Can be unset, in which case uuid.Nil is fine.
Ip: ip,
UserAgent: userAgent,
SlugOrPort: appInfo.SlugOrPort,
StatusCode: int32(statusCode),
StartedAt: aReq.time,
UpdatedAt: aReq.time,
})
if err != nil {
return xerrors.Errorf("insert workspace app audit session: %w", err)
}
return nil
}, nil)
if err != nil {
logger.Error(ctx, "update workspace app audit session failed", slog.Error(err))
// Avoid spamming the audit log if deduplication failed, this should
// only happen if there are problems communicating with the database.
return
}
if !startedAt.Equal(aReq.time) {
// If the unique session wasn't renewed, we don't want to log a new
// audit event for it.
return
}
// Marshal additional fields only if we're writing an audit log entry.
appInfoBytes, err := json.Marshal(appInfo)
if err != nil {
logger.Error(ctx, "marshal additional fields failed", slog.Error(err))
}
// We use the background audit function instead of init request
// here because we don't know the resource type ahead of time.
// This also allows us to log unauthenticated access.
auditor := *p.Auditor.Load()
requestID := httpmw.RequestID(r)
switch {
case aReq.dbReq.App.ID != uuid.Nil:
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceApp]{
Audit: auditor,
Log: logger,
Action: database.AuditActionOpen,
OrganizationID: aReq.dbReq.Workspace.OrganizationID,
UserID: userID,
RequestID: requestID,
Time: aReq.time,
Status: statusCode,
IP: ip,
UserAgent: userAgent,
New: aReq.dbReq.App,
AdditionalFields: appInfoBytes,
})
default:
// Web terminal, port app, etc.
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceAgent]{
Audit: auditor,
Log: logger,
Action: database.AuditActionOpen,
OrganizationID: aReq.dbReq.Workspace.OrganizationID,
UserID: userID,
RequestID: requestID,
Time: aReq.time,
Status: statusCode,
IP: ip,
UserAgent: userAgent,
New: aReq.dbReq.Agent,
AdditionalFields: appInfoBytes,
})
}
}
}
+333 -19
View File
@@ -2,6 +2,8 @@ package workspaceapps_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net"
@@ -10,6 +12,7 @@ import (
"net/http/httputil"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
@@ -19,9 +22,13 @@ import (
"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"
@@ -76,6 +83,13 @@ func Test_ResolveRequest(t *testing.T) {
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,
@@ -91,6 +105,7 @@ func Test_ResolveRequest(t *testing.T) {
"CF-Connecting-IP",
},
},
Auditor: auditor,
})
t.Cleanup(func() {
_ = closer.Close()
@@ -102,7 +117,7 @@ func Test_ResolveRequest(t *testing.T) {
me, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
secondUserClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
secondUserClient, secondUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
agentAuthToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{
@@ -210,11 +225,30 @@ func Test_ResolveRequest(t *testing.T) {
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()
@@ -253,13 +287,19 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -295,6 +335,9 @@ func Test_ResolveRequest(t *testing.T) {
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)
@@ -307,8 +350,9 @@ func Test_ResolveRequest(t *testing.T) {
rw = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/app", nil)
r.AddCookie(cookie)
r.RemoteAddr = auditableIP
secondToken, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
secondToken, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -321,6 +365,7 @@ func Test_ResolveRequest(t *testing.T) {
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")
}
})
}
@@ -339,12 +384,16 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -364,6 +413,9 @@ func Test_ResolveRequest(t *testing.T) {
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")
}
})
@@ -380,10 +432,14 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: app,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
t.Log("app", app)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -397,6 +453,9 @@ func Test_ResolveRequest(t *testing.T) {
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)
@@ -408,6 +467,9 @@ func Test_ResolveRequest(t *testing.T) {
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()
}
@@ -419,9 +481,12 @@ func Test_ResolveRequest(t *testing.T) {
req := (workspaceapps.Request{
AccessMethod: "invalid",
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -431,6 +496,7 @@ func Test_ResolveRequest(t *testing.T) {
})
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) {
@@ -498,11 +564,15 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -523,8 +593,11 @@ func Test_ResolveRequest(t *testing.T) {
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()
})
@@ -566,6 +639,9 @@ func Test_ResolveRequest(t *testing.T) {
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())
@@ -573,10 +649,11 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -600,6 +677,9 @@ func Test_ResolveRequest(t *testing.T) {
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) {
@@ -614,11 +694,15 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -628,6 +712,12 @@ func Test_ResolveRequest(t *testing.T) {
})
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) {
@@ -642,11 +732,15 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -657,6 +751,11 @@ func Test_ResolveRequest(t *testing.T) {
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) {
@@ -671,11 +770,15 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
_, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -690,6 +793,8 @@ func Test_ResolveRequest(t *testing.T) {
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) {
@@ -704,11 +809,15 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -718,6 +827,8 @@ func Test_ResolveRequest(t *testing.T) {
})
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) {
@@ -729,11 +840,15 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -749,6 +864,10 @@ func Test_ResolveRequest(t *testing.T) {
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) {
@@ -763,11 +882,15 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -777,6 +900,8 @@ func Test_ResolveRequest(t *testing.T) {
})
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) {
@@ -790,11 +915,15 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -804,6 +933,7 @@ func Test_ResolveRequest(t *testing.T) {
})
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) {
@@ -818,12 +948,16 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -838,6 +972,10 @@ func Test_ResolveRequest(t *testing.T) {
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)
@@ -876,11 +1014,15 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -894,6 +1036,8 @@ func Test_ResolveRequest(t *testing.T) {
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)
@@ -933,11 +1077,15 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -947,6 +1095,8 @@ func Test_ResolveRequest(t *testing.T) {
})
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
@@ -985,11 +1135,15 @@ func Test_ResolveRequest(t *testing.T) {
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 := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -999,5 +1153,165 @@ func Test_ResolveRequest(t *testing.T) {
})
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)
}
}
}
+7 -2
View File
@@ -195,6 +195,8 @@ type databaseRequest struct {
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.
@@ -288,6 +290,7 @@ func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseR
// 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.
@@ -353,8 +356,9 @@ func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseR
appSharingLevel = ps.ShareLevel
}
} else {
for _, app := range apps {
if app.Slug == r.AppSlugOrPort {
for _, a := range apps {
if a.Slug == r.AppSlugOrPort {
app = a
if !app.Url.Valid {
return nil, xerrors.Errorf("app URL is not valid")
}
@@ -410,6 +414,7 @@ func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseR
User: user,
Workspace: workspace,
Agent: agent,
App: app,
AppURL: appURLParsed,
AppSharingLevel: appSharingLevel,
}, nil