mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+211
-17
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user