chore!: route connection logs to new table (#18340)

### Breaking Change (changelog note):
> User connections to workspaces, and the opening of workspace apps or ports will no longer create entries in the audit log. Those events will now be included in the 'Connection Log'.
Please see the 'Connection Log' page in the dashboard, and the Connection Log [documentation](https://coder.com/docs/admin/monitoring/connection-logs) for details. Those with permission to view the Audit Log will also be able to view the Connection Log. The new Connection Log has the same licensing restrictions as the Audit Log, and requires a Premium Coder deployment.

### Context

This is the first PR of a few for moving connection events out of the audit log, and into a new database table and web UI page called the 'Connection Log'.

This PR:
- Creates the new table
- Adds and tests queries for inserting and reading, including reading with an RBAC filter.
- Implements the corresponding RBAC changes, such that anyone who can view the audit log can read from the table
- Implements, under the enterprise package, a `ConnectionLogger` abstraction to replace the `Auditor` abstraction for these logs. (No-op'd in AGPL, like the `Auditor`)
- Routes SSH connection and Workspace App events into the new `ConnectionLogger`
- Updates all existing tests to check the values of the `ConnectionLogger` instead of the `Auditor`.

Future PRs:
- Add filtering to the query
- Add an enterprise endpoint to query the new table
- Write a query to delete old events from the audit log, call it from dbpurge.
- Implement a table in the Web UI for viewing connection logs.


> [!NOTE]
> The PRs in this stack obviously won't be (completely) atomic. Whilst they'll each pass CI, the stack is designed to be merged all at once. I'm splitting them up for the sake of those reviewing, and so changes can be reviewed as early as possible.  Despite this, it's really hard to make this PR any smaller than it already is. I'll be keeping it in draft until it's actually ready to merge.
This commit is contained in:
Ethan
2025-07-15 14:36:06 +10:00
committed by GitHub
parent 43b0bb7f61
commit 08e17a07fc
54 changed files with 2199 additions and 493 deletions
+62 -81
View File
@@ -3,7 +3,6 @@ package workspaceapps
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/url"
@@ -18,7 +17,7 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/connectionlog"
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
@@ -40,7 +39,7 @@ type DBTokenProvider struct {
// DashboardURL is the main dashboard access URL for error pages.
DashboardURL *url.URL
Authorizer rbac.Authorizer
Auditor *atomic.Pointer[audit.Auditor]
ConnectionLogger *atomic.Pointer[connectionlog.ConnectionLogger]
Database database.Store
DeploymentValues *codersdk.DeploymentValues
OAuth2Configs *httpmw.OAuth2Configs
@@ -54,7 +53,7 @@ var _ SignedTokenProvider = &DBTokenProvider{}
func NewDBTokenProvider(log slog.Logger,
accessURL *url.URL,
authz rbac.Authorizer,
auditor *atomic.Pointer[audit.Auditor],
connectionLogger *atomic.Pointer[connectionlog.ConnectionLogger],
db database.Store,
cfg *codersdk.DeploymentValues,
oauth2Cfgs *httpmw.OAuth2Configs,
@@ -73,7 +72,7 @@ func NewDBTokenProvider(log slog.Logger,
Logger: log,
DashboardURL: accessURL,
Authorizer: authz,
Auditor: auditor,
ConnectionLogger: connectionLogger,
Database: db,
DeploymentValues: cfg,
OAuth2Configs: oauth2Cfgs,
@@ -95,7 +94,7 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
// // permissions.
dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx)
aReq, commitAudit := p.auditInitRequest(ctx, rw, r)
aReq, commitAudit := p.connLogInitRequest(ctx, rw, r)
defer commitAudit()
appReq := issueReq.AppRequest.Normalize()
@@ -386,20 +385,20 @@ func (p *DBTokenProvider) authorizeRequest(ctx context.Context, roles *rbac.Subj
return false, warnings, nil
}
type auditRequest struct {
type connLogRequest 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.
// connLogInitRequest creates a new connection log session and connect log for the
// given request, if one does not already exist. If a connection log session
// already exists, it will be updated with the current timestamp. A session is used to
// reduce the number of connection 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()) {
// values change, a new session and connect log is created.
func (p *DBTokenProvider) connLogInitRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (aReq *connLogRequest, 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)
@@ -407,12 +406,12 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW
panic("dev error: http.ResponseWriter is not *tracing.StatusWriter")
}
aReq = &auditRequest{
aReq = &connLogRequest{
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.
// Set the commit function on the status writer to create a connection log
// this ensures that the status and response body are available.
var committed bool
return aReq, func() {
if committed {
@@ -422,7 +421,7 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW
if aReq.dbReq == nil {
// App doesn't exist, there's information in the Request
// struct but we need UUIDs for audit logging.
// struct but we need UUIDs for connection logging.
return
}
@@ -434,28 +433,25 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW
ip := r.RemoteAddr
// Approximation of the status code.
statusCode := sw.Status
// #nosec G115 - Safe conversion as HTTP status code is expected to be within int32 range (typically 100-599)
var statusCode int32 = int32(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,
},
}
var (
connType database.ConnectionType
slugOrPort = aReq.dbReq.AppSlugOrPort
)
switch {
case aReq.dbReq.AccessMethod == AccessMethodTerminal:
appInfo.SlugOrPort = "terminal"
connType = database.ConnectionTypeWorkspaceApp
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
connType = database.ConnectionTypePortForwarding
default:
connType = database.ConnectionTypeWorkspaceApp
}
// If we end up logging, ensure relevant fields are set.
@@ -465,7 +461,7 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW
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("app_slug_or_port", slugOrPort),
slog.F("status_code", statusCode),
)
@@ -485,9 +481,8 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW
UserID: userID, // Can be unset, in which case uuid.Nil is fine.
Ip: ip,
UserAgent: userAgent,
SlugOrPort: appInfo.SlugOrPort,
// #nosec G115 - Safe conversion as HTTP status code is expected to be within int32 range (typically 100-599)
StatusCode: int32(statusCode),
SlugOrPort: slugOrPort,
StatusCode: statusCode,
StartedAt: aReq.time,
UpdatedAt: aReq.time,
})
@@ -500,7 +495,7 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW
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
// Avoid spamming the connection log if deduplication failed, this should
// only happen if there are problems communicating with the database.
return
}
@@ -511,51 +506,37 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW
return
}
// Marshal additional fields only if we're writing an audit log entry.
appInfoBytes, err := json.Marshal(appInfo)
connLogger := *p.ConnectionLogger.Load()
err = connLogger.Upsert(ctx, database.UpsertConnectionLogParams{
ID: uuid.New(),
Time: aReq.time,
OrganizationID: aReq.dbReq.Workspace.OrganizationID,
WorkspaceOwnerID: aReq.dbReq.Workspace.OwnerID,
WorkspaceID: aReq.dbReq.Workspace.ID,
WorkspaceName: aReq.dbReq.Workspace.Name,
AgentName: aReq.dbReq.Agent.Name,
Type: connType,
Code: sql.NullInt32{
Int32: statusCode,
Valid: true,
},
Ip: database.ParseIP(ip),
UserAgent: sql.NullString{Valid: userAgent != "", String: userAgent},
UserID: uuid.NullUUID{
UUID: userID,
Valid: userID != uuid.Nil,
},
SlugOrPort: sql.NullString{Valid: slugOrPort != "", String: slugOrPort},
ConnectionStatus: database.ConnectionStatusConnected,
// N/A
ConnectionID: uuid.NullUUID{},
DisconnectReason: sql.NullString{},
})
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,
})
logger.Error(ctx, "upsert connection log failed", slog.Error(err))
return
}
}
}
+119 -151
View File
@@ -3,7 +3,6 @@ package workspaceapps_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net"
@@ -22,10 +21,9 @@ 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/connectionlog"
"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"
@@ -83,12 +81,12 @@ func Test_ResolveRequest(t *testing.T) {
deploymentValues.Dangerous.AllowPathAppSharing = true
deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = true
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
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?")
assert.Len(t, connLogger.ConnectionLogs(), 0, "one or more test cases produced unexpected connection logs, did you replace the auditor or forget to call ResetLogs?")
})
client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
AppHostname: "*.test.coder.com",
@@ -105,7 +103,7 @@ func Test_ResolveRequest(t *testing.T) {
"CF-Connecting-IP",
},
},
Auditor: auditor,
ConnectionLogger: connLogger,
})
t.Cleanup(func() {
_ = closer.Close()
@@ -231,23 +229,8 @@ func Test_ResolveRequest(t *testing.T) {
}
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)
connLogger.Reset()
t.Run("OK", func(t *testing.T) {
t.Parallel()
@@ -285,9 +268,9 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: app,
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
auditableUA := "Tidua"
auditableUA := "Noitcennoc"
t.Log("app", app)
rw := httptest.NewRecorder()
@@ -297,7 +280,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set("User-Agent", auditableUA)
// Try resolving the request without a token.
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -333,8 +316,8 @@ 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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
require.Len(t, connLogger.ConnectionLogs(), 1)
var parsedToken workspaceapps.SignedToken
err := jwtutils.Verify(ctx, api.AppSigningKeyCache, cookie.Value, &parsedToken)
@@ -350,7 +333,7 @@ func Test_ResolveRequest(t *testing.T) {
r.AddCookie(cookie)
r.RemoteAddr = auditableIP
secondToken, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
secondToken, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -363,7 +346,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")
require.Len(t, connLogger.ConnectionLogs(), 1, "no new connection log, FromRequest returned the same token and is not logged")
}
})
}
@@ -382,7 +365,7 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: app,
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
t.Log("app", app)
@@ -391,7 +374,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -406,14 +389,15 @@ func Test_ResolveRequest(t *testing.T) {
require.Nil(t, token)
require.NotZero(t, w.StatusCode)
require.Equal(t, http.StatusNotFound, w.StatusCode)
require.Len(t, connLogger.ConnectionLogs(), 1)
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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, secondUser.ID)
require.Len(t, connLogger.ConnectionLogs(), 1)
}
})
@@ -430,14 +414,14 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: app,
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
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{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -452,8 +436,8 @@ func Test_ResolveRequest(t *testing.T) {
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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, uuid.Nil)
require.Len(t, connLogger.ConnectionLogs(), 1)
} else {
if !assert.True(t, ok) {
dump, err := httputil.DumpResponse(w, true)
@@ -466,8 +450,8 @@ func Test_ResolveRequest(t *testing.T) {
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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, uuid.Nil)
require.Len(t, connLogger.ConnectionLogs(), 1)
}
_ = w.Body.Close()
}
@@ -479,12 +463,12 @@ func Test_ResolveRequest(t *testing.T) {
req := (workspaceapps.Request{
AccessMethod: "invalid",
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
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{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -494,7 +478,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")
require.Len(t, connLogger.ConnectionLogs(), 0)
})
t.Run("SplitWorkspaceAndAgent", func(t *testing.T) {
@@ -562,7 +546,7 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: appNamePublic,
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
@@ -570,7 +554,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -591,11 +575,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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID)
require.Len(t, connLogger.ConnectionLogs(), 1)
} else {
require.Nil(t, token)
require.Len(t, auditor.AuditLogs(), 0, "no audit logs")
require.Len(t, connLogger.ConnectionLogs(), 0)
}
_ = w.Body.Close()
})
@@ -637,7 +621,7 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: appNameOwner,
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
@@ -651,7 +635,7 @@ func Test_ResolveRequest(t *testing.T) {
// 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{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -676,8 +660,8 @@ func Test_ResolveRequest(t *testing.T) {
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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, me.ID)
require.Len(t, connLogger.ConnectionLogs(), 1)
})
t.Run("PortPathBlocked", func(t *testing.T) {
@@ -692,7 +676,7 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: "8080",
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
@@ -700,7 +684,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -715,7 +699,7 @@ func Test_ResolveRequest(t *testing.T) {
_ = 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")
require.Len(t, connLogger.ConnectionLogs(), 0, "no connection logs for port path blocked requests")
})
t.Run("PortSubdomain", func(t *testing.T) {
@@ -730,7 +714,7 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: "9090",
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
@@ -738,7 +722,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -749,11 +733,8 @@ 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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, "9090", database.ConnectionTypePortForwarding, me.ID)
require.Len(t, connLogger.ConnectionLogs(), 1)
})
t.Run("PortSubdomainHTTPSS", func(t *testing.T) {
@@ -768,7 +749,7 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: "9090ss",
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
@@ -776,7 +757,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
_, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
_, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -792,7 +773,7 @@ func Test_ResolveRequest(t *testing.T) {
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")
require.Len(t, connLogger.ConnectionLogs(), 0)
})
t.Run("SubdomainEndsInS", func(t *testing.T) {
@@ -807,7 +788,7 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: appNameEndsInS,
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
@@ -815,7 +796,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -825,8 +806,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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameEndsInS, database.ConnectionTypeWorkspaceApp, me.ID)
require.Len(t, connLogger.ConnectionLogs(), 1)
})
t.Run("Terminal", func(t *testing.T) {
@@ -838,7 +819,7 @@ func Test_ResolveRequest(t *testing.T) {
AgentNameOrID: agentID.String(),
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
@@ -846,7 +827,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -862,10 +843,8 @@ 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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, "terminal", database.ConnectionTypeWorkspaceApp, me.ID)
require.Len(t, connLogger.ConnectionLogs(), 1)
})
t.Run("InsufficientPermissions", func(t *testing.T) {
@@ -880,7 +859,7 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: appNameOwner,
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
@@ -888,7 +867,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -898,8 +877,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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, secondUser.ID)
require.Len(t, connLogger.ConnectionLogs(), 1)
})
t.Run("UserNotFound", func(t *testing.T) {
@@ -913,7 +892,7 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: appNameOwner,
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
@@ -921,7 +900,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -931,7 +910,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")
require.Len(t, connLogger.ConnectionLogs(), 0)
})
t.Run("RedirectSubdomainAuth", func(t *testing.T) {
@@ -946,7 +925,7 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: appNameOwner,
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
@@ -955,7 +934,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Host = "app.com"
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -972,8 +951,8 @@ func Test_ResolveRequest(t *testing.T) {
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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, uuid.Nil)
require.Len(t, connLogger.ConnectionLogs(), 1)
loc, err := w.Location()
require.NoError(t, err)
@@ -1012,7 +991,7 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: appNameAgentUnhealthy,
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
@@ -1020,7 +999,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -1034,8 +1013,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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentNameUnhealthy, appNameAgentUnhealthy, database.ConnectionTypeWorkspaceApp, me.ID)
require.Len(t, connLogger.ConnectionLogs(), 1)
body, err := io.ReadAll(w.Body)
require.NoError(t, err)
@@ -1075,7 +1054,7 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: appNameInitializing,
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
@@ -1083,7 +1062,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -1093,8 +1072,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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID)
require.Len(t, connLogger.ConnectionLogs(), 1)
})
// Unhealthy apps are now permitted to connect anyways. This wasn't always
@@ -1133,7 +1112,7 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: appNameUnhealthy,
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
@@ -1141,7 +1120,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -1151,11 +1130,11 @@ 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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID)
require.Len(t, connLogger.ConnectionLogs(), 1)
})
t.Run("AuditLogging", func(t *testing.T) {
t.Run("ConnectionLogging", func(t *testing.T) {
t.Parallel()
for _, app := range allApps {
@@ -1168,18 +1147,18 @@ func Test_ResolveRequest(t *testing.T) {
AppSlugOrPort: app,
}).Normalize()
auditor := audit.NewMock()
connLogger := connectionlog.NewFake()
auditableIP := testutil.RandomIPv6(t)
t.Log("app", app)
// First request, new audit log.
// First request, new connection 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{
_, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -1188,8 +1167,8 @@ func Test_ResolveRequest(t *testing.T) {
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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
require.Len(t, connLogger.ConnectionLogs(), 1)
// Second request, no audit log because the session is active.
rw = httptest.NewRecorder()
@@ -1197,7 +1176,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
_, ok = workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
_, ok = workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -1206,7 +1185,7 @@ func Test_ResolveRequest(t *testing.T) {
AppRequest: req,
})
require.True(t, ok)
require.Len(t, auditor.AuditLogs(), 1, "single audit log, previous session active")
require.Len(t, connLogger.ConnectionLogs(), 1, "single connection log, previous session active")
// Third request, session timed out, new audit log.
rw = httptest.NewRecorder()
@@ -1214,7 +1193,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
sessionTimeoutTokenProvider := signedTokenProviderWithAuditor(t, api.WorkspaceAppsProvider, auditor, 0)
sessionTimeoutTokenProvider := signedTokenProviderWithConnLogger(t, api.WorkspaceAppsProvider, connLogger, 0)
_, ok = workspaceappsResolveRequest(t, nil, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: sessionTimeoutTokenProvider,
@@ -1224,8 +1203,8 @@ func Test_ResolveRequest(t *testing.T) {
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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
require.Len(t, connLogger.ConnectionLogs(), 2, "two connection logs, session timed out")
// Fourth request, new IP produces new audit log.
auditableIP = testutil.RandomIPv6(t)
@@ -1234,7 +1213,7 @@ func Test_ResolveRequest(t *testing.T) {
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
_, ok = workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
_, ok = workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
@@ -1243,16 +1222,16 @@ func Test_ResolveRequest(t *testing.T) {
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")
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
require.Len(t, connLogger.ConnectionLogs(), 3, "three connection 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) {
func workspaceappsResolveRequest(t testing.TB, connLogger connectionlog.ConnectionLogger, 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)
if opts.SignedTokenProvider != nil && connLogger != nil {
opts.SignedTokenProvider = signedTokenProviderWithConnLogger(t, opts.SignedTokenProvider, connLogger, time.Hour)
}
tracing.StatusWriterMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -1264,52 +1243,41 @@ func workspaceappsResolveRequest(t testing.TB, auditor audit.Auditor, w http.Res
return token, ok
}
func signedTokenProviderWithAuditor(t testing.TB, provider workspaceapps.SignedTokenProvider, auditor audit.Auditor, sessionTimeout time.Duration) workspaceapps.SignedTokenProvider {
func signedTokenProviderWithConnLogger(t testing.TB, provider workspaceapps.SignedTokenProvider, connLogger connectionlog.ConnectionLogger, 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.ConnectionLogger = &atomic.Pointer[connectionlog.ConnectionLogger]{}
shallowCopy.ConnectionLogger.Store(&connLogger)
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()
func assertConnLogContains(t *testing.T, rr *httptest.ResponseRecorder, r *http.Request, connLogger *connectionlog.FakeConnectionLogger, workspace codersdk.Workspace, agentName string, slugOrPort string, typ database.ConnectionType, userID uuid.UUID) {
t.Helper()
resp := rr.Result()
defer resp.Body.Close()
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)
}
}
require.True(t, connLogger.Contains(t, database.UpsertConnectionLogParams{
OrganizationID: workspace.OrganizationID,
WorkspaceOwnerID: workspace.OwnerID,
WorkspaceID: workspace.ID,
WorkspaceName: workspace.Name,
AgentName: agentName,
Type: typ,
Ip: database.ParseIP(r.RemoteAddr),
UserAgent: sql.NullString{Valid: r.UserAgent() != "", String: r.UserAgent()},
Code: sql.NullInt32{
Int32: int32(resp.StatusCode), // nolint:gosec
Valid: true,
},
UserID: uuid.NullUUID{
UUID: userID,
Valid: true,
},
SlugOrPort: sql.NullString{Valid: slugOrPort != "", String: slugOrPort},
}))
}