From 54accd68739b5bbee34024c231ffb83322bf5941 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 12 Jun 2025 07:15:17 +0000 Subject: [PATCH 01/16] chore: route connection logs to new table --- coderd/agentapi/api.go | 16 +- coderd/agentapi/audit.go | 105 ------- coderd/agentapi/connectionlog.go | 79 ++++++ .../{audit_test.go => connectionlog_test.go} | 75 +++-- coderd/apidoc/docs.go | 2 + coderd/apidoc/swagger.json | 2 + coderd/audit/request.go | 22 +- coderd/coderd.go | 10 +- coderd/coderdtest/authorize.go | 1 + coderd/coderdtest/coderdtest.go | 9 + coderd/connectionlog/connectionlog.go | 125 ++++++++ coderd/database/db2sdk/db2sdk.go | 6 +- coderd/database/dbauthz/dbauthz.go | 46 +++ coderd/database/dbauthz/dbauthz_test.go | 74 +++++ coderd/database/dbgen/dbgen.go | 37 +++ coderd/database/dbmem/dbmem.go | 80 ++++++ coderd/database/dbmetrics/querymetrics.go | 21 ++ coderd/database/dbmock/dbmock.go | 45 +++ coderd/database/dump.sql | 49 ++++ coderd/database/foreign_key_constraint.go | 1 + .../000334_connection_logs.down.sql | 8 + .../migrations/000334_connection_logs.up.sql | 47 +++ coderd/database/migrations/migrate_test.go | 2 +- .../fixtures/000334_connection_logs.up.sql | 50 ++++ coderd/database/modelmethods.go | 13 + coderd/database/modelqueries.go | 62 ++++ coderd/database/models.go | 87 ++++++ coderd/database/querier.go | 2 + coderd/database/querier_test.go | 172 +++++++++++ coderd/database/queries.sql.go | 160 +++++++++++ coderd/database/queries/connectionlogs.sql | 47 +++ coderd/database/types.go | 18 ++ coderd/database/unique_constraint.go | 1 + coderd/rbac/authz.go | 1 + coderd/rbac/object_gen.go | 9 + coderd/rbac/policy/policy.go | 6 + coderd/rbac/regosql/configs.go | 14 + coderd/rbac/roles.go | 4 +- coderd/rbac/roles_test.go | 9 + coderd/workspaceagentsrpc.go | 2 +- coderd/workspaceapps/db.go | 127 +++------ coderd/workspaceapps/db_test.go | 268 ++++++++---------- codersdk/deployment.go | 2 + codersdk/rbacresources_gen.go | 2 + docs/reference/api/members.md | 5 + docs/reference/api/schemas.md | 1 + enterprise/audit/backends/slog.go | 47 ++- enterprise/audit/backends/slog_test.go | 21 +- enterprise/cli/server.go | 1 + enterprise/coderd/coderd.go | 23 +- .../coderd/connectionlog/connectionlog.go | 70 +++++ enterprise/coderd/license/license_test.go | 1 + site/src/api/rbacresourcesGenerated.ts | 4 + site/src/api/typesGenerated.ts | 2 + 54 files changed, 1650 insertions(+), 443 deletions(-) delete mode 100644 coderd/agentapi/audit.go create mode 100644 coderd/agentapi/connectionlog.go rename coderd/agentapi/{audit_test.go => connectionlog_test.go} (67%) create mode 100644 coderd/connectionlog/connectionlog.go create mode 100644 coderd/database/migrations/000334_connection_logs.down.sql create mode 100644 coderd/database/migrations/000334_connection_logs.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000334_connection_logs.up.sql create mode 100644 coderd/database/queries/connectionlogs.sql create mode 100644 enterprise/coderd/connectionlog/connectionlog.go diff --git a/coderd/agentapi/api.go b/coderd/agentapi/api.go index c409f8ea89e9b..dbcb8ea024914 100644 --- a/coderd/agentapi/api.go +++ b/coderd/agentapi/api.go @@ -19,7 +19,7 @@ import ( agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/agentapi/resourcesmonitor" "github.com/coder/coder/v2/coderd/appearance" - "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/connectionlog" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/externalauth" @@ -50,7 +50,7 @@ type API struct { *ResourcesMonitoringAPI *LogsAPI *ScriptsAPI - *AuditAPI + *ConnLogAPI *SubAgentAPI *tailnet.DRPCService @@ -71,7 +71,7 @@ type Options struct { Database database.Store NotificationsEnqueuer notifications.Enqueuer Pubsub pubsub.Pubsub - Auditor *atomic.Pointer[audit.Auditor] + ConnectionLogger *atomic.Pointer[connectionlog.ConnectionLogger] DerpMapFn func() *tailcfg.DERPMap TailnetCoordinator *atomic.Pointer[tailnet.Coordinator] StatsReporter *workspacestats.Reporter @@ -180,11 +180,11 @@ func New(opts Options) *API { Database: opts.Database, } - api.AuditAPI = &AuditAPI{ - AgentFn: api.agent, - Auditor: opts.Auditor, - Database: opts.Database, - Log: opts.Log, + api.ConnLogAPI = &ConnLogAPI{ + AgentFn: api.agent, + ConnectionLogger: opts.ConnectionLogger, + Database: opts.Database, + Log: opts.Log, } api.DRPCService = &tailnet.DRPCService{ diff --git a/coderd/agentapi/audit.go b/coderd/agentapi/audit.go deleted file mode 100644 index 2025b2d6cd92b..0000000000000 --- a/coderd/agentapi/audit.go +++ /dev/null @@ -1,105 +0,0 @@ -package agentapi - -import ( - "context" - "encoding/json" - "strconv" - "sync/atomic" - - "github.com/google/uuid" - "golang.org/x/xerrors" - "google.golang.org/protobuf/types/known/emptypb" - - "cdr.dev/slog" - - agentproto "github.com/coder/coder/v2/agent/proto" - "github.com/coder/coder/v2/coderd/audit" - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/db2sdk" - "github.com/coder/coder/v2/codersdk/agentsdk" -) - -type AuditAPI struct { - AgentFn func(context.Context) (database.WorkspaceAgent, error) - Auditor *atomic.Pointer[audit.Auditor] - Database database.Store - Log slog.Logger -} - -func (a *AuditAPI) ReportConnection(ctx context.Context, req *agentproto.ReportConnectionRequest) (*emptypb.Empty, error) { - // We will use connection ID as request ID, typically this is the - // SSH session ID as reported by the agent. - connectionID, err := uuid.FromBytes(req.GetConnection().GetId()) - if err != nil { - return nil, xerrors.Errorf("connection id from bytes: %w", err) - } - - action, err := db2sdk.AuditActionFromAgentProtoConnectionAction(req.GetConnection().GetAction()) - if err != nil { - return nil, err - } - connectionType, err := agentsdk.ConnectionTypeFromProto(req.GetConnection().GetType()) - if err != nil { - return nil, err - } - - // Fetch contextual data for this audit event. - workspaceAgent, err := a.AgentFn(ctx) - if err != nil { - return nil, xerrors.Errorf("get agent: %w", err) - } - workspace, err := a.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID) - if err != nil { - return nil, xerrors.Errorf("get workspace by agent id: %w", err) - } - build, err := a.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) - if err != nil { - return nil, xerrors.Errorf("get latest workspace build by workspace id: %w", err) - } - - // We pass the below information to the Auditor so that it - // can form a friendly string for the user to view in the UI. - type additionalFields struct { - audit.AdditionalFields - - ConnectionType agentsdk.ConnectionType `json:"connection_type"` - Reason string `json:"reason,omitempty"` - } - resourceInfo := additionalFields{ - AdditionalFields: audit.AdditionalFields{ - WorkspaceID: workspace.ID, - WorkspaceName: workspace.Name, - WorkspaceOwner: workspace.OwnerUsername, - BuildNumber: strconv.FormatInt(int64(build.BuildNumber), 10), - BuildReason: database.BuildReason(string(build.Reason)), - }, - ConnectionType: connectionType, - Reason: req.GetConnection().GetReason(), - } - - riBytes, err := json.Marshal(resourceInfo) - if err != nil { - a.Log.Error(ctx, "marshal resource info for agent connection failed", slog.Error(err)) - riBytes = []byte("{}") - } - - audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceAgent]{ - Audit: *a.Auditor.Load(), - Log: a.Log, - Time: req.GetConnection().GetTimestamp().AsTime(), - OrganizationID: workspace.OrganizationID, - RequestID: connectionID, - Action: action, - New: workspaceAgent, - Old: workspaceAgent, - IP: req.GetConnection().GetIp(), - Status: int(req.GetConnection().GetStatusCode()), - AdditionalFields: riBytes, - - // It's not possible to tell which user connected. Once we have - // the capability, this may be reported by the agent. - UserID: uuid.Nil, - }) - - return &emptypb.Empty{}, nil -} diff --git a/coderd/agentapi/connectionlog.go b/coderd/agentapi/connectionlog.go new file mode 100644 index 0000000000000..d3d780d97f8da --- /dev/null +++ b/coderd/agentapi/connectionlog.go @@ -0,0 +1,79 @@ +package agentapi + +import ( + "context" + "database/sql" + "sync/atomic" + + "github.com/google/uuid" + "golang.org/x/xerrors" + "google.golang.org/protobuf/types/known/emptypb" + + "cdr.dev/slog" + + agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/coderd/connectionlog" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/codersdk/agentsdk" +) + +type ConnLogAPI struct { + AgentFn func(context.Context) (database.WorkspaceAgent, error) + ConnectionLogger *atomic.Pointer[connectionlog.ConnectionLogger] + Database database.Store + Log slog.Logger +} + +func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.ReportConnectionRequest) (*emptypb.Empty, error) { + action, err := db2sdk.ConnectionLogActionFromAgentProtoConnectionAction(req.GetConnection().GetAction()) + if err != nil { + return nil, err + } + connectionType, err := agentsdk.ConnectionTypeFromProto(req.GetConnection().GetType()) + if err != nil { + return nil, err + } + + // Fetch contextual data for this connection log event. + workspaceAgent, err := a.AgentFn(ctx) + if err != nil { + return nil, xerrors.Errorf("get agent: %w", err) + } + workspace, err := a.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID) + if err != nil { + return nil, xerrors.Errorf("get workspace by agent id: %w", err) + } + + reason := req.GetConnection().GetReason() + connLogger := *a.ConnectionLogger.Load() + err = connLogger.Export(ctx, database.ConnectionLog{ + ID: uuid.New(), + Time: req.GetConnection().GetTimestamp().AsTime(), + OrganizationID: workspace.OrganizationID, + WorkspaceOwnerID: workspace.OwnerID, + WorkspaceID: workspace.ID, + WorkspaceName: workspace.Name, + AgentName: workspaceAgent.Name, + Action: action, + Code: req.GetConnection().GetStatusCode(), + Ip: database.ParseIP(req.GetConnection().GetIp()), + ConnectionType: sql.NullString{ + String: string(connectionType), + Valid: true, + }, + Reason: sql.NullString{ + String: reason, + Valid: reason != "", + }, + + // It's not possible to tell which user connected. Once we have + // the capability, this may be reported by the agent. + UserID: uuid.Nil, // We don't have the user ID in the connection request. + }) + if err != nil { + return nil, xerrors.Errorf("export connection log: %w", err) + } + + return &emptypb.Empty{}, nil +} diff --git a/coderd/agentapi/audit_test.go b/coderd/agentapi/connectionlog_test.go similarity index 67% rename from coderd/agentapi/audit_test.go rename to coderd/agentapi/connectionlog_test.go index b881fde5d22bc..6e984fc75218b 100644 --- a/coderd/agentapi/audit_test.go +++ b/coderd/agentapi/connectionlog_test.go @@ -2,7 +2,7 @@ package agentapi_test import ( "context" - "encoding/json" + "database/sql" "net" "sync/atomic" "testing" @@ -16,7 +16,7 @@ import ( agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/agentapi" - "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/connectionlog" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbmock" @@ -24,7 +24,7 @@ import ( "github.com/coder/coder/v2/codersdk/agentsdk" ) -func TestAuditReport(t *testing.T) { +func TestConnectionLog(t *testing.T) { t.Parallel() var ( @@ -38,10 +38,6 @@ func TestAuditReport(t *testing.T) { OwnerID: owner.ID, Name: "cool-workspace", } - build = database.WorkspaceBuild{ - ID: uuid.New(), - WorkspaceID: workspace.ID, - } agent = database.WorkspaceAgent{ ID: uuid.New(), } @@ -62,7 +58,7 @@ func TestAuditReport(t *testing.T) { id: uuid.New(), action: agentproto.Connection_CONNECT.Enum(), typ: agentproto.Connection_SSH.Enum(), - time: time.Now(), + time: dbtime.Now(), ip: "127.0.0.1", status: 200, }, @@ -71,7 +67,7 @@ func TestAuditReport(t *testing.T) { id: uuid.New(), action: agentproto.Connection_CONNECT.Enum(), typ: agentproto.Connection_VSCODE.Enum(), - time: time.Now(), + time: dbtime.Now(), ip: "8.8.8.8", }, { @@ -79,28 +75,28 @@ func TestAuditReport(t *testing.T) { id: uuid.New(), action: agentproto.Connection_CONNECT.Enum(), typ: agentproto.Connection_JETBRAINS.Enum(), - time: time.Now(), + time: dbtime.Now(), }, { name: "Reconnecting PTY Connect", id: uuid.New(), action: agentproto.Connection_CONNECT.Enum(), typ: agentproto.Connection_RECONNECTING_PTY.Enum(), - time: time.Now(), + time: dbtime.Now(), }, { name: "SSH Disconnect", id: uuid.New(), action: agentproto.Connection_DISCONNECT.Enum(), typ: agentproto.Connection_SSH.Enum(), - time: time.Now(), + time: dbtime.Now(), }, { name: "SSH Disconnect", id: uuid.New(), action: agentproto.Connection_DISCONNECT.Enum(), typ: agentproto.Connection_SSH.Enum(), - time: time.Now(), + time: dbtime.Now(), status: 500, reason: "because error says so", }, @@ -110,15 +106,14 @@ func TestAuditReport(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - mAudit := audit.NewMock() + connLogger := connectionlog.NewMock() mDB := dbmock.NewMockStore(gomock.NewController(t)) mDB.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(workspace, nil) - mDB.EXPECT().GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), workspace.ID).Return(build, nil) - api := &agentapi.AuditAPI{ - Auditor: asAtomicPointer[audit.Auditor](mAudit), - Database: mDB, + api := &agentapi.ConnLogAPI{ + ConnectionLogger: asAtomicPointer[connectionlog.ConnectionLogger](connLogger), + Database: mDB, AgentFn: func(context.Context) (database.WorkspaceAgent, error) { return agent, nil }, @@ -135,33 +130,33 @@ func TestAuditReport(t *testing.T) { }, }) - require.True(t, mAudit.Contains(t, database.AuditLog{ - Time: dbtime.Time(tt.time).In(time.UTC), - Action: agentProtoConnectionActionToAudit(t, *tt.action), - OrganizationID: workspace.OrganizationID, - UserID: uuid.Nil, - RequestID: tt.id, - ResourceType: database.ResourceTypeWorkspaceAgent, - ResourceID: agent.ID, - ResourceTarget: agent.Name, - Ip: pqtype.Inet{Valid: true, IPNet: net.IPNet{IP: net.ParseIP(tt.ip), Mask: net.CIDRMask(32, 32)}}, - StatusCode: tt.status, - })) + require.True(t, connLogger.Contains(t, database.ConnectionLog{ + Time: dbtime.Time(tt.time).In(time.UTC), + OrganizationID: workspace.OrganizationID, + WorkspaceOwnerID: workspace.OwnerID, + WorkspaceID: workspace.ID, + WorkspaceName: workspace.Name, + AgentName: agent.Name, + UserID: uuid.Nil, + Action: agentProtoConnectionActionToConnectionLog(t, *tt.action), - // Check some additional fields. - var m map[string]any - err := json.Unmarshal(mAudit.AuditLogs()[0].AdditionalFields, &m) - require.NoError(t, err) - require.Equal(t, string(agentProtoConnectionTypeToSDK(t, *tt.typ)), m["connection_type"].(string)) - if tt.reason != "" { - require.Equal(t, tt.reason, m["reason"]) - } + Code: tt.status, + Ip: pqtype.Inet{Valid: true, IPNet: net.IPNet{IP: net.ParseIP(tt.ip), Mask: net.CIDRMask(32, 32)}}, + ConnectionType: sql.NullString{ + String: string(agentProtoConnectionTypeToSDK(t, *tt.typ)), + Valid: true, + }, + Reason: sql.NullString{ + String: tt.reason, + Valid: tt.reason != "", + }, + })) }) } } -func agentProtoConnectionActionToAudit(t *testing.T, action agentproto.Connection_Action) database.AuditAction { - a, err := db2sdk.AuditActionFromAgentProtoConnectionAction(action) +func agentProtoConnectionActionToConnectionLog(t *testing.T, action agentproto.Connection_Action) database.ConnectionAction { + a, err := db2sdk.ConnectionLogActionFromAgentProtoConnectionAction(action) require.NoError(t, err) return a } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d11a0635d6f52..f1a997a056942 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15241,6 +15241,7 @@ const docTemplate = `{ "assign_role", "audit_log", "chat", + "connection_log", "crypto_key", "debug_info", "deployment_config", @@ -15280,6 +15281,7 @@ const docTemplate = `{ "ResourceAssignRole", "ResourceAuditLog", "ResourceChat", + "ResourceConnectionLog", "ResourceCryptoKey", "ResourceDebugInfo", "ResourceDeploymentConfig", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index aabe0b9b12672..5d4fdeec4ccc6 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13833,6 +13833,7 @@ "assign_role", "audit_log", "chat", + "connection_log", "crypto_key", "debug_info", "deployment_config", @@ -13872,6 +13873,7 @@ "ResourceAssignRole", "ResourceAuditLog", "ResourceChat", + "ResourceConnectionLog", "ResourceCryptoKey", "ResourceDebugInfo", "ResourceDeploymentConfig", diff --git a/coderd/audit/request.go b/coderd/audit/request.go index fd755e39c5216..c8b41e47a4f5a 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -6,13 +6,11 @@ import ( "encoding/json" "flag" "fmt" - "net" "net/http" "strconv" "time" "github.com/google/uuid" - "github.com/sqlc-dev/pqtype" "go.opentelemetry.io/otel/baggage" "golang.org/x/xerrors" @@ -424,7 +422,7 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request action = req.Action } - ip := ParseIP(p.Request.RemoteAddr) + ip := database.ParseIP(p.Request.RemoteAddr) auditLog := database.AuditLog{ ID: uuid.New(), Time: dbtime.Now(), @@ -456,7 +454,7 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request // BackgroundAudit creates an audit log for a background event. // The audit log is committed upon invocation. func BackgroundAudit[T Auditable](ctx context.Context, p *BackgroundAuditParams[T]) { - ip := ParseIP(p.IP) + ip := database.ParseIP(p.IP) diff := Diff(p.Audit, p.Old, p.New) var err error @@ -571,19 +569,3 @@ func either[T Auditable, R any](old, newVal T, fn func(T) R, auditAction databas panic("both old and new are nil") } } - -func ParseIP(ipStr string) pqtype.Inet { - ip := net.ParseIP(ipStr) - ipNet := net.IPNet{} - if ip != nil { - ipNet = net.IPNet{ - IP: ip, - Mask: net.CIDRMask(len(ip)*8, len(ip)*8), - } - } - - return pqtype.Inet{ - IPNet: ipNet, - Valid: ip != nil, - } -} diff --git a/coderd/coderd.go b/coderd/coderd.go index 0b8a13befde56..82dc5bd1375b7 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -59,6 +59,7 @@ import ( "github.com/coder/coder/v2/coderd/appearance" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/awsidentity" + "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/database/dbrollup" @@ -152,6 +153,7 @@ type Options struct { CacheDir string Auditor audit.Auditor + ConnectionLogger connectionlog.ConnectionLogger AgentConnectionUpdateFrequency time.Duration AgentInactiveDisconnectTimeout time.Duration AWSCertificates awsidentity.Certificates @@ -399,6 +401,9 @@ func New(options *Options) *API { if options.Auditor == nil { options.Auditor = audit.NewNop() } + if options.ConnectionLogger == nil { + options.ConnectionLogger = connectionlog.NewNop() + } if options.SSHConfig.HostnamePrefix == "" { options.SSHConfig.HostnamePrefix = "coder." } @@ -567,6 +572,7 @@ func New(options *Options) *API { }, metricsCache: metricsCache, Auditor: atomic.Pointer[audit.Auditor]{}, + ConnectionLogger: atomic.Pointer[connectionlog.ConnectionLogger]{}, TailnetCoordinator: atomic.Pointer[tailnet.Coordinator]{}, UpdatesProvider: updatesProvider, TemplateScheduleStore: options.TemplateScheduleStore, @@ -588,7 +594,7 @@ func New(options *Options) *API { options.Logger.Named("workspaceapps"), options.AccessURL, options.Authorizer, - &api.Auditor, + &api.ConnectionLogger, options.Database, options.DeploymentValues, oauthConfigs, @@ -689,6 +695,7 @@ func New(options *Options) *API { } api.Auditor.Store(&options.Auditor) + api.ConnectionLogger.Store(&options.ConnectionLogger) api.TailnetCoordinator.Store(&options.TailnetCoordinator) dialer := &InmemTailnetDialer{ CoordPtr: &api.TailnetCoordinator, @@ -1575,6 +1582,7 @@ type API struct { // specific replica. ID uuid.UUID Auditor atomic.Pointer[audit.Auditor] + ConnectionLogger atomic.Pointer[connectionlog.ConnectionLogger] WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool] TailnetCoordinator atomic.Pointer[tailnet.Coordinator] NetworkTelemetryBatcher *tailnet.NetworkTelemetryBatcher diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 279405c4e6a21..bcf2575028297 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -447,6 +447,7 @@ func randomRBACType() string { all := []string{ rbac.ResourceWorkspace.Type, rbac.ResourceAuditLog.Type, + rbac.ResourceConnectionLog.Type, rbac.ResourceTemplate.Type, rbac.ResourceGroup.Type, rbac.ResourceFile.Type, diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index a8f444c8f632e..08100a975e3c0 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -58,6 +58,7 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/autobuild" "github.com/coder/coder/v2/coderd/awsidentity" + "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/db2sdk" @@ -122,6 +123,7 @@ type Options struct { TemplateScheduleStore schedule.TemplateScheduleStore Coordinator tailnet.Coordinator CoordinatorResumeTokenProvider tailnet.ResumeTokenProvider + ConnectionLogger connectionlog.ConnectionLogger HealthcheckFunc func(ctx context.Context, apiKey string) *healthsdk.HealthcheckReport HealthcheckTimeout time.Duration @@ -353,6 +355,12 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can } auditor.Store(&options.Auditor) + var connectionLogger atomic.Pointer[connectionlog.ConnectionLogger] + if options.ConnectionLogger == nil { + options.ConnectionLogger = connectionlog.NewNop() + } + connectionLogger.Store(&options.ConnectionLogger) + ctx, cancelFunc := context.WithCancel(context.Background()) experiments := coderd.ReadExperiments(*options.Logger, options.DeploymentValues.Experiments) lifecycleExecutor := autobuild.NewExecutor( @@ -539,6 +547,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can ExternalAuthConfigs: options.ExternalAuthConfigs, Auditor: options.Auditor, + ConnectionLogger: options.ConnectionLogger, AWSCertificates: options.AWSCertificates, AzureCertificates: options.AzureCertificates, GithubOAuth2Config: options.GithubOAuth2Config, diff --git a/coderd/connectionlog/connectionlog.go b/coderd/connectionlog/connectionlog.go new file mode 100644 index 0000000000000..00845b07d8a4b --- /dev/null +++ b/coderd/connectionlog/connectionlog.go @@ -0,0 +1,125 @@ +package connectionlog + +import ( + "context" + "sync" + "testing" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/database" +) + +type ConnectionLogger interface { + Export(ctx context.Context, clog database.ConnectionLog) error +} + +type nop struct{} + +func NewNop() ConnectionLogger { + return nop{} +} + +func (nop) Export(context.Context, database.ConnectionLog) error { + return nil +} + +func NewMock() *MockConnectionLogger { + return &MockConnectionLogger{} +} + +type MockConnectionLogger struct { + mu sync.Mutex + connectionLogs []database.ConnectionLog +} + +func (m *MockConnectionLogger) ResetLogs() { + m.mu.Lock() + defer m.mu.Unlock() + m.connectionLogs = make([]database.ConnectionLog, 0) +} + +func (m *MockConnectionLogger) ConnectionLogs() []database.ConnectionLog { + m.mu.Lock() + defer m.mu.Unlock() + return m.connectionLogs +} + +func (m *MockConnectionLogger) Export(_ context.Context, clog database.ConnectionLog) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.connectionLogs = append(m.connectionLogs, clog) + + return nil +} + +func (m *MockConnectionLogger) Contains(t testing.TB, expected database.ConnectionLog) bool { + m.mu.Lock() + defer m.mu.Unlock() + for idx, cl := range m.connectionLogs { + if expected.ID != uuid.Nil && cl.ID != expected.ID { + t.Logf("connection log %d: expected ID %s, got %s", idx+1, expected.ID, cl.ID) + continue + } + if !expected.Time.IsZero() && expected.Time != cl.Time { + t.Logf("connection log %d: expected Time %s, got %s", idx+1, expected.Time, cl.Time) + continue + } + if expected.OrganizationID != uuid.Nil && cl.OrganizationID != expected.OrganizationID { + t.Logf("connection log %d: expected OrganizationID %s, got %s", idx+1, expected.OrganizationID, cl.OrganizationID) + continue + } + if expected.WorkspaceOwnerID != uuid.Nil && cl.WorkspaceOwnerID != expected.WorkspaceOwnerID { + t.Logf("connection log %d: expected WorkspaceOwnerID %s, got %s", idx+1, expected.WorkspaceOwnerID, cl.WorkspaceOwnerID) + continue + } + if expected.WorkspaceID != uuid.Nil && cl.WorkspaceID != expected.WorkspaceID { + t.Logf("connection log %d: expected WorkspaceID %s, got %s", idx+1, expected.WorkspaceID, cl.WorkspaceID) + continue + } + if expected.WorkspaceName != "" && cl.WorkspaceName != expected.WorkspaceName { + t.Logf("connection log %d: expected WorkspaceName %s, got %s", idx+1, expected.WorkspaceName, cl.WorkspaceName) + continue + } + if expected.AgentName != "" && cl.AgentName != expected.AgentName { + t.Logf("connection log %d: expected AgentName %s, got %s", idx+1, expected.AgentName, cl.AgentName) + continue + } + if expected.Action != "" && cl.Action != expected.Action { + t.Logf("connection log %d: expected Action %s, got %s", idx+1, expected.Action, cl.Action) + continue + } + if expected.Code != 0 && cl.Code != expected.Code { + t.Logf("connection log %d: expected Code %d, got %d", idx+1, expected.Code, cl.Code) + continue + } + if expected.Ip.Valid && cl.Ip.IPNet.String() != expected.Ip.IPNet.String() { + t.Logf("connection log %d: expected IP %s, got %s", idx+1, expected.Ip.IPNet, cl.Ip.IPNet) + continue + } + if expected.SlugOrPort.Valid && cl.SlugOrPort != expected.SlugOrPort { + t.Logf("connection log %d: expected SlugOrPort %s, got %s", idx+1, expected.SlugOrPort.String, cl.SlugOrPort.String) + continue + } + if expected.UserAgent.Valid && cl.UserAgent != expected.UserAgent { + t.Logf("connection log %d: expected UserAgent %s, got %s", idx+1, expected.UserAgent.String, cl.UserAgent.String) + continue + } + if expected.UserID != uuid.Nil && cl.UserID != expected.UserID { + t.Logf("connection log %d: expected UserID %s, got %s", idx+1, expected.UserID, cl.UserID) + continue + } + if expected.ConnectionType.Valid && cl.ConnectionType != expected.ConnectionType { + t.Logf("connection log %d: expected ConnectionType %s, got %s", idx+1, expected.ConnectionType.String, cl.ConnectionType.String) + continue + } + if expected.Reason.Valid && cl.Reason != expected.Reason { + t.Logf("connection log %d: expected Reason %s, got %s", idx+1, expected.Reason.String, cl.Reason.String) + continue + } + return true + } + + return false +} diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 4a7871f21d15d..995089fdd6133 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -727,12 +727,12 @@ func TemplateRoleActions(role codersdk.TemplateRole) []policy.Action { return []policy.Action{} } -func AuditActionFromAgentProtoConnectionAction(action agentproto.Connection_Action) (database.AuditAction, error) { +func ConnectionLogActionFromAgentProtoConnectionAction(action agentproto.Connection_Action) (database.ConnectionAction, error) { switch action { case agentproto.Connection_CONNECT: - return database.AuditActionConnect, nil + return database.ConnectionActionConnect, nil case agentproto.Connection_DISCONNECT: - return database.AuditActionDisconnect, nil + return database.ConnectionActionDisconnect, nil default: // Also Connection_ACTION_UNSPECIFIED, no mapping. return "", xerrors.Errorf("unknown agent connection action %q", action) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5bfa015af3d78..f7f49aa9141dc 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -279,6 +279,24 @@ var ( Scope: rbac.ScopeAll, }.WithCachedASTValue() + subjectConnectionLogger = rbac.Subject{ + Type: rbac.SubjectTypeConnectionLogger, + FriendlyName: "Connection Logger", + ID: uuid.Nil.String(), + Roles: rbac.Roles([]rbac.Role{ + { + Identifier: rbac.RoleIdentifier{Name: "connectionlogger"}, + DisplayName: "Connection Logger", + Site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceConnectionLog.Type: {policy.ActionCreate, policy.ActionRead}, + }), + Org: map[string][]rbac.Permission{}, + User: []rbac.Permission{}, + }, + }), + Scope: rbac.ScopeAll, + }.WithCachedASTValue() + subjectNotifier = rbac.Subject{ Type: rbac.SubjectTypeNotifier, FriendlyName: "Notifier", @@ -462,6 +480,10 @@ func AsKeyReader(ctx context.Context) context.Context { return As(ctx, subjectCryptoKeyReader) } +func AsConnectionLogger(ctx context.Context) context.Context { + return As(ctx, subjectConnectionLogger) +} + // AsNotifier returns a context with an actor that has permissions required for // creating/reading/updating/deleting notifications. func AsNotifier(ctx context.Context) context.Context { @@ -1762,6 +1784,22 @@ func (q *querier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]d return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChatsByOwnerID)(ctx, ownerID) } +func (q *querier) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) { + // Shortcut if the user is an owner. The SQL filter is noticeable, + // and this is an easy win for owners. Which is the common case. + err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog) + if err == nil { + return q.db.GetConnectionLogsOffset(ctx, arg) + } + + prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceConnectionLog.Type) + if err != nil { + return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) + } + + return q.db.GetAuthorizedConnectionLogsOffset(ctx, arg, prep) +} + func (q *querier) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return "", err @@ -3458,6 +3496,10 @@ func (q *querier) InsertChatMessages(ctx context.Context, arg database.InsertCha return q.db.InsertChatMessages(ctx, arg) } +func (q *querier) InsertConnectionLog(ctx context.Context, arg database.InsertConnectionLogParams) (database.ConnectionLog, error) { + return insert(q.log, q.auth, rbac.ResourceConnectionLog, q.db.InsertConnectionLog)(ctx, arg) +} + func (q *querier) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceCryptoKey); err != nil { return database.CryptoKey{}, err @@ -5162,3 +5204,7 @@ func (q *querier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersP func (q *querier) GetAuthorizedAuditLogsOffset(ctx context.Context, arg database.GetAuditLogsOffsetParams, _ rbac.PreparedAuthorized) ([]database.GetAuditLogsOffsetRow, error) { return q.GetAuditLogsOffset(ctx, arg) } + +func (q *querier) GetAuthorizedConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams, _ rbac.PreparedAuthorized) ([]database.GetConnectionLogsOffsetRow, error) { + return q.GetConnectionLogsOffset(ctx, arg) +} diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 50373fbeb72e6..07c8b594c1e0a 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -329,6 +329,80 @@ func (s *MethodTestSuite) TestAuditLogs() { })) } +func (s *MethodTestSuite) TestConnectionLogs() { + s.Run("InsertConnectionLog", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + o := dbgen.Organization(s.T(), db, database.Organization{}) + tpl := dbgen.Template(s.T(), db, database.Template{ + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ + ID: uuid.New(), + OwnerID: u.ID, + OrganizationID: o.ID, + AutomaticUpdates: database.AutomaticUpdatesNever, + TemplateID: tpl.ID, + }) + check.Args(database.InsertConnectionLogParams{ + Action: database.ConnectionActionConnect, + WorkspaceID: ws.ID, + }).Asserts(rbac.ResourceConnectionLog, policy.ActionCreate) + })) + s.Run("GetConnectionLogsOffset", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + o := dbgen.Organization(s.T(), db, database.Organization{}) + tpl := dbgen.Template(s.T(), db, database.Template{ + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ + ID: uuid.New(), + OwnerID: u.ID, + OrganizationID: o.ID, + AutomaticUpdates: database.AutomaticUpdatesNever, + TemplateID: tpl.ID, + }) + _ = dbgen.ConnectionLog(s.T(), db, database.ConnectionLog{ + Action: database.ConnectionActionConnect, + WorkspaceID: ws.ID, + }) + _ = dbgen.ConnectionLog(s.T(), db, database.ConnectionLog{ + Action: database.ConnectionActionConnect, + WorkspaceID: ws.ID, + }) + check.Args(database.GetConnectionLogsOffsetParams{ + LimitOpt: 10, + }).Asserts(rbac.ResourceConnectionLog, policy.ActionRead).WithNotAuthorized("nil") + })) + s.Run("GetAuthorizedConnectionLogsOffset", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + o := dbgen.Organization(s.T(), db, database.Organization{}) + tpl := dbgen.Template(s.T(), db, database.Template{ + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ + ID: uuid.New(), + OwnerID: u.ID, + OrganizationID: o.ID, + AutomaticUpdates: database.AutomaticUpdatesNever, + TemplateID: tpl.ID, + }) + _ = dbgen.ConnectionLog(s.T(), db, database.ConnectionLog{ + Action: database.ConnectionActionConnect, + WorkspaceID: ws.ID, + }) + _ = dbgen.ConnectionLog(s.T(), db, database.ConnectionLog{ + Action: database.ConnectionActionConnect, + WorkspaceID: ws.ID, + }) + check.Args(database.GetConnectionLogsOffsetParams{ + LimitOpt: 10, + }, emptyPreparedAuthorized{}).Asserts(rbac.ResourceConnectionLog, policy.ActionRead) + })) +} + func (s *MethodTestSuite) TestFile() { s.Run("GetFileByHashAndCreator", s.Subtest(func(db database.Store, check *expects) { f := dbgen.File(s.T(), db, database.File{}) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index c85db83a2adc9..154e2336a9d51 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -73,6 +73,43 @@ func AuditLog(t testing.TB, db database.Store, seed database.AuditLog) database. return log } +func ConnectionLog(t testing.TB, db database.Store, seed database.ConnectionLog) database.ConnectionLog { + log, err := db.InsertConnectionLog(genCtx, database.InsertConnectionLogParams{ + ID: takeFirst(seed.ID, uuid.New()), + Time: takeFirst(seed.Time, dbtime.Now()), + OrganizationID: takeFirst(seed.OrganizationID, uuid.New()), + WorkspaceOwnerID: takeFirst(seed.WorkspaceOwnerID, uuid.New()), + WorkspaceID: takeFirst(seed.WorkspaceID, uuid.New()), + WorkspaceName: takeFirst(seed.WorkspaceName, testutil.GetRandomName(t)), + AgentName: takeFirst(seed.AgentName, testutil.GetRandomName(t)), + Action: takeFirst(seed.Action, database.ConnectionActionOpen), + Code: takeFirst(seed.Code, 0), + Ip: pqtype.Inet{ + IPNet: takeFirstIP(seed.Ip.IPNet, net.IPNet{}), + Valid: takeFirst(seed.Ip.Valid, false), + }, + UserAgent: sql.NullString{ + String: takeFirst(seed.UserAgent.String, ""), + Valid: takeFirst(seed.UserAgent.Valid, false), + }, + UserID: takeFirst(seed.UserID, uuid.New()), + SlugOrPort: sql.NullString{ + String: takeFirst(seed.SlugOrPort.String, ""), + Valid: takeFirst(seed.SlugOrPort.Valid, false), + }, + ConnectionType: sql.NullString{ + String: takeFirst(seed.ConnectionType.String, ""), + Valid: takeFirst(seed.ConnectionType.Valid, false), + }, + Reason: sql.NullString{ + String: takeFirst(seed.Reason.String, ""), + Valid: takeFirst(seed.Reason.Valid, false), + }, + }) + require.NoError(t, err, "insert connection log") + return log +} + func Template(t testing.TB, db database.Store, seed database.Template) database.Template { id := takeFirst(seed.ID, uuid.New()) if seed.GroupACL == nil { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index f838a93d24c78..bfbb3061574c4 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -218,6 +218,7 @@ type data struct { auditLogs []database.AuditLog chats []database.Chat chatMessages []database.ChatMessage + connectionLogs []database.ConnectionLog cryptoKeys []database.CryptoKey dbcryptKeys []database.DBCryptKey files []database.File @@ -2945,6 +2946,10 @@ func (q *FakeQuerier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) return chats, nil } +func (q *FakeQuerier) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) { + return q.GetAuthorizedConnectionLogsOffset(ctx, arg, nil) +} + func (q *FakeQuerier) GetCoordinatorResumeTokenSigningKey(_ context.Context) (string, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -8622,6 +8627,30 @@ func (q *FakeQuerier) InsertChatMessages(ctx context.Context, arg database.Inser return messages, nil } +func (q *FakeQuerier) InsertConnectionLog(_ context.Context, arg database.InsertConnectionLogParams) (database.ConnectionLog, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.ConnectionLog{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + log := database.ConnectionLog(arg) + + q.connectionLogs = append(q.connectionLogs, log) + slices.SortFunc(q.connectionLogs, func(a, b database.ConnectionLog) int { + if a.Time.Before(b.Time) { + return -1 + } else if a.Time.Equal(b.Time) { + return 0 + } + return 1 + }) + + return log, nil +} + func (q *FakeQuerier) InsertCryptoKey(_ context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { err := validateDatabaseType(arg) if err != nil { @@ -13856,3 +13885,54 @@ func (q *FakeQuerier) GetAuthorizedAuditLogsOffset(ctx context.Context, arg data return logs, nil } + +func (q *FakeQuerier) GetAuthorizedConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams, prepared rbac.PreparedAuthorized) ([]database.GetConnectionLogsOffsetRow, error) { + if err := validateDatabaseType(arg); err != nil { + return nil, err + } + + // Call this to match the same function calls as the SQL implementation. + // It functionally does nothing for filtering. + if prepared != nil { + _, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{ + VariableConverter: regosql.ConnectionLogConverter(), + }) + if err != nil { + return nil, err + } + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + if arg.LimitOpt == 0 { + // Default to 100 is set in the SQL query. + arg.LimitOpt = 100 + } + + logs := make([]database.GetConnectionLogsOffsetRow, 0, arg.LimitOpt) + + for _, clog := range q.connectionLogs { + if arg.OffsetOpt > 0 { + arg.OffsetOpt-- + continue + } + if prepared != nil && prepared.Authorize(ctx, clog.RBACObject()) != nil { + continue + } + + logs = append(logs, database.GetConnectionLogsOffsetRow{ + ConnectionLog: clog, + }) + if len(logs) >= int(arg.LimitOpt) { + break + } + } + + count := int64(len(logs)) + for i := range logs { + logs[i].Count = count + } + + return logs, nil +} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index e208f9898cb1e..12a12afb56ac1 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -662,6 +662,13 @@ func (m queryMetricsStore) GetChatsByOwnerID(ctx context.Context, ownerID uuid.U return r0, r1 } +func (m queryMetricsStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) { + start := time.Now() + r0, r1 := m.s.GetConnectionLogsOffset(ctx, arg) + m.queryLatencies.WithLabelValues("GetConnectionLogsOffset").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetCoordinatorResumeTokenSigningKey(ctx) @@ -2076,6 +2083,13 @@ func (m queryMetricsStore) InsertChatMessages(ctx context.Context, arg database. return r0, r1 } +func (m queryMetricsStore) InsertConnectionLog(ctx context.Context, arg database.InsertConnectionLogParams) (database.ConnectionLog, error) { + start := time.Now() + r0, r1 := m.s.InsertConnectionLog(ctx, arg) + m.queryLatencies.WithLabelValues("InsertConnectionLog").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { start := time.Now() key, err := m.s.InsertCryptoKey(ctx, arg) @@ -3321,3 +3335,10 @@ func (m queryMetricsStore) GetAuthorizedAuditLogsOffset(ctx context.Context, arg m.queryLatencies.WithLabelValues("GetAuthorizedAuditLogsOffset").Observe(time.Since(start).Seconds()) return r0, r1 } + +func (m queryMetricsStore) GetAuthorizedConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams, prepared rbac.PreparedAuthorized) ([]database.GetConnectionLogsOffsetRow, error) { + start := time.Now() + r0, r1 := m.s.GetAuthorizedConnectionLogsOffset(ctx, arg, prepared) + m.queryLatencies.WithLabelValues("GetAuthorizedConnectionLogsOffset").Observe(time.Since(start).Seconds()) + return r0, r1 +} diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index b6a04754f17b0..4a9fadf2436fa 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1202,6 +1202,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedAuditLogsOffset(ctx, arg, prepared return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedAuditLogsOffset", reflect.TypeOf((*MockStore)(nil).GetAuthorizedAuditLogsOffset), ctx, arg, prepared) } +// GetAuthorizedConnectionLogsOffset mocks base method. +func (m *MockStore) GetAuthorizedConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams, prepared rbac.PreparedAuthorized) ([]database.GetConnectionLogsOffsetRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAuthorizedConnectionLogsOffset", ctx, arg, prepared) + ret0, _ := ret[0].([]database.GetConnectionLogsOffsetRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAuthorizedConnectionLogsOffset indicates an expected call of GetAuthorizedConnectionLogsOffset. +func (mr *MockStoreMockRecorder) GetAuthorizedConnectionLogsOffset(ctx, arg, prepared any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedConnectionLogsOffset", reflect.TypeOf((*MockStore)(nil).GetAuthorizedConnectionLogsOffset), ctx, arg, prepared) +} + // GetAuthorizedTemplates mocks base method. func (m *MockStore) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, prepared rbac.PreparedAuthorized) ([]database.Template, error) { m.ctrl.T.Helper() @@ -1307,6 +1322,21 @@ func (mr *MockStoreMockRecorder) GetChatsByOwnerID(ctx, ownerID any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetChatsByOwnerID), ctx, ownerID) } +// GetConnectionLogsOffset mocks base method. +func (m *MockStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetConnectionLogsOffset", ctx, arg) + ret0, _ := ret[0].([]database.GetConnectionLogsOffsetRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetConnectionLogsOffset indicates an expected call of GetConnectionLogsOffset. +func (mr *MockStoreMockRecorder) GetConnectionLogsOffset(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConnectionLogsOffset", reflect.TypeOf((*MockStore)(nil).GetConnectionLogsOffset), ctx, arg) +} + // GetCoordinatorResumeTokenSigningKey mocks base method. func (m *MockStore) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) { m.ctrl.T.Helper() @@ -4381,6 +4411,21 @@ func (mr *MockStoreMockRecorder) InsertChatMessages(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatMessages", reflect.TypeOf((*MockStore)(nil).InsertChatMessages), ctx, arg) } +// InsertConnectionLog mocks base method. +func (m *MockStore) InsertConnectionLog(ctx context.Context, arg database.InsertConnectionLogParams) (database.ConnectionLog, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertConnectionLog", ctx, arg) + ret0, _ := ret[0].(database.ConnectionLog) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertConnectionLog indicates an expected call of InsertConnectionLog. +func (mr *MockStoreMockRecorder) InsertConnectionLog(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertConnectionLog", reflect.TypeOf((*MockStore)(nil).InsertConnectionLog), ctx, arg) +} + // InsertCryptoKey mocks base method. func (m *MockStore) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 22a0b3d5a8adc..74130890b3c27 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -51,6 +51,13 @@ CREATE TYPE build_reason AS ENUM ( 'autodelete' ); +CREATE TYPE connection_action AS ENUM ( + 'connect', + 'disconnect', + 'open', + 'close' +); + CREATE TYPE crypto_key_feature AS ENUM ( 'workspace_apps_token', 'workspace_apps_api_key', @@ -845,6 +852,34 @@ CREATE TABLE chats ( title text NOT NULL ); +CREATE TABLE connection_logs ( + id uuid NOT NULL, + "time" timestamp with time zone NOT NULL, + organization_id uuid NOT NULL, + workspace_owner_id uuid NOT NULL, + workspace_id uuid NOT NULL, + workspace_name text NOT NULL, + agent_name text NOT NULL, + action connection_action NOT NULL, + code integer NOT NULL, + ip inet, + user_agent text, + user_id uuid NOT NULL, + slug_or_port text, + connection_type text, + reason text +); + +COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code for the workspace app request, or the exit code of an SSH connection.'; + +COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For workspace apps, this is the User-Agent header from the request.'; + +COMMENT ON COLUMN connection_logs.user_id IS 'uuid.Nil for SSH actions. For workspace apps, this is the ID of the user that made the request.'; + +COMMENT ON COLUMN connection_logs.connection_type IS 'Null for Workspace App actions. For SSH actions, this is the type of connection (e.g., "ssh", "websocket").'; + +COMMENT ON COLUMN connection_logs.reason IS 'Null for Workspace App actions. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI.'; + CREATE TABLE crypto_keys ( feature crypto_key_feature NOT NULL, sequence integer NOT NULL, @@ -2349,6 +2384,9 @@ ALTER TABLE ONLY chat_messages ALTER TABLE ONLY chats ADD CONSTRAINT chats_pkey PRIMARY KEY (id); +ALTER TABLE ONLY connection_logs + ADD CONSTRAINT connection_logs_pkey PRIMARY KEY (id); + ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); @@ -2635,6 +2673,14 @@ CREATE INDEX idx_audit_log_user_id ON audit_logs USING btree (user_id); CREATE INDEX idx_audit_logs_time_desc ON audit_logs USING btree ("time" DESC); +CREATE INDEX idx_connection_logs_organization_id ON connection_logs USING btree (organization_id); + +CREATE INDEX idx_connection_logs_time_desc ON connection_logs USING btree ("time" DESC); + +CREATE INDEX idx_connection_logs_workspace_id ON connection_logs USING btree (workspace_id); + +CREATE INDEX idx_connection_logs_workspace_owner_id ON connection_logs USING btree (workspace_owner_id); + CREATE INDEX idx_custom_roles_id ON custom_roles USING btree (id); CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); @@ -2844,6 +2890,9 @@ ALTER TABLE ONLY chat_messages ALTER TABLE ONLY chats ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY connection_logs + ADD CONSTRAINT connection_logs_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL; + ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index d6b87ddff5376..e303375830a4b 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -9,6 +9,7 @@ const ( ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyChatMessagesChatID ForeignKeyConstraint = "chat_messages_chat_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatsOwnerID ForeignKeyConstraint = "chats_owner_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyConnectionLogsWorkspaceID ForeignKeyConstraint = "connection_logs_workspace_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL; ForeignKeyCryptoKeysSecretKeyID ForeignKeyConstraint = "crypto_keys_secret_key_id_fkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthAccessTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); diff --git a/coderd/database/migrations/000334_connection_logs.down.sql b/coderd/database/migrations/000334_connection_logs.down.sql new file mode 100644 index 0000000000000..2a39d8fbd1061 --- /dev/null +++ b/coderd/database/migrations/000334_connection_logs.down.sql @@ -0,0 +1,8 @@ +DROP INDEX IF EXISTS idx_connection_logs_workspace_id; +DROP INDEX IF EXISTS idx_connection_logs_workspace_owner_id; +DROP INDEX IF EXISTS idx_connection_logs_organization_id; +DROP INDEX IF EXISTS idx_connection_logs_time_desc; + +DROP TABLE IF EXISTS connection_logs; + +DROP TYPE IF EXISTS connection_action; diff --git a/coderd/database/migrations/000334_connection_logs.up.sql b/coderd/database/migrations/000334_connection_logs.up.sql new file mode 100644 index 0000000000000..a87466d3a3428 --- /dev/null +++ b/coderd/database/migrations/000334_connection_logs.up.sql @@ -0,0 +1,47 @@ +CREATE TYPE connection_action AS ENUM ( + -- SSH actions + 'connect', + 'disconnect', + -- Workspace App actions + 'open', + 'close' +); + +CREATE TABLE connection_logs ( + id uuid NOT NULL, + "time" timestamp with time zone NOT NULL, + organization_id uuid NOT NULL, + workspace_owner_id uuid NOT NULL, + workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE SET NULL, + workspace_name text NOT NULL, + agent_name text NOT NULL, + action connection_action NOT NULL, + code integer NOT NULL, + ip inet, + + -- Null for SSH actions. + user_agent text, + user_id uuid NOT NULL, -- Can be NULL, but must be uuid.Nil. + slug_or_port text, + + -- Null for Workspace App actions. + connection_type text, + reason text, + + PRIMARY KEY (id) +); + +COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code for the workspace app request, or the exit code of an SSH connection.'; + +COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For workspace apps, this is the User-Agent header from the request.'; + +COMMENT ON COLUMN connection_logs.user_id IS 'uuid.Nil for SSH actions. For workspace apps, this is the ID of the user that made the request.'; + +COMMENT ON COLUMN connection_logs.connection_type IS 'Null for Workspace App actions. For SSH actions, this is the type of connection (e.g., "ssh", "websocket").'; + +COMMENT ON COLUMN connection_logs.reason IS 'Null for Workspace App actions. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI.'; + +CREATE INDEX idx_connection_logs_time_desc ON connection_logs USING btree ("time" DESC); +CREATE INDEX idx_connection_logs_organization_id ON connection_logs USING btree (organization_id); +CREATE INDEX idx_connection_logs_workspace_owner_id ON connection_logs USING btree (workspace_owner_id); +CREATE INDEX idx_connection_logs_workspace_id ON connection_logs USING btree (workspace_id); diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 65dc9e6267310..ef2fc8745561d 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -283,7 +283,7 @@ func TestMigrateUpWithFixtures(t *testing.T) { if len(emptyTables) > 0 { t.Log("The following tables have zero rows, consider adding fixtures for them or create a full database dump:") t.Errorf("tables have zero rows: %v", emptyTables) - t.Log("See https://github.com/coder/coder/blob/main/docs/CONTRIBUTING.md#database-fixtures-for-testing-migrations for more information") + t.Log("See https://github.com/coder/coder/blob/main/docs/contributing/backend.md#database-fixtures-for-testing-migrations for more information") } }) diff --git a/coderd/database/migrations/testdata/fixtures/000334_connection_logs.up.sql b/coderd/database/migrations/testdata/fixtures/000334_connection_logs.up.sql new file mode 100644 index 0000000000000..16ec968f911b8 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000334_connection_logs.up.sql @@ -0,0 +1,50 @@ +INSERT INTO connection_logs ( + id, + "time", + organization_id, + workspace_owner_id, + workspace_id, + workspace_name, + agent_name, + action, + code, + ip, + user_agent, + user_id, + slug_or_port, + connection_type, + reason +) VALUES ( + '00000000-0000-0000-0000-000000000001', -- log id + '2023-10-01 12:00:00+00', + '00000000-0000-0000-0000-000000000020', -- organization id + '00000000-0000-0000-0000-000000000030', -- workspace owner id + '3a9a1feb-e89d-457c-9d53-ac751b198ebe', -- workspace id + 'Test Workspace', -- workspace name + 'test-agent', -- agent name + 'connect', + 0, -- code + '127.0.0.1', + NULL, -- user agent + '00000000-0000-0000-0000-000000000000', -- user id (uuid.Nil) + NULL, -- slug or port + 'ssh', -- connection type + 'connected via CLI' -- reason +), +( + '00000000-0000-0000-0000-000000000002', -- log id + '2023-10-01 12:05:00+00', + '00000000-0000-0000-0000-000000000020', -- organization id + '00000000-0000-0000-0000-000000000030', -- workspace owner id + '3a9a1feb-e89d-457c-9d53-ac751b198ebe', -- workspace id + 'Test Workspace', -- workspace name + 'test-agent', -- agent name + 'open', + 200, -- code + '127.0.0.1', + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36', + '00000000-0000-0000-0000-000000000030', -- user id + 'code-server', -- slug or port + NULL, -- connection type + NULL -- reason +); diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index b3f6deed9eff0..7135bc275b87e 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -117,6 +117,19 @@ func (w AuditLog) RBACObject() rbac.Object { return obj } +func (w GetConnectionLogsOffsetRow) RBACObject() rbac.Object { + return w.ConnectionLog.RBACObject() +} + +func (w ConnectionLog) RBACObject() rbac.Object { + obj := rbac.ResourceConnectionLog.WithID(w.ID) + if w.OrganizationID != uuid.Nil { + obj = obj.InOrg(w.OrganizationID) + } + + return obj +} + func (s APIKeyScope) ToRBAC() rbac.ScopeName { switch s { case APIKeyScopeAll: diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 1e4d249d8a034..47b24c5e38bb8 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -50,6 +50,7 @@ type customQuerier interface { workspaceQuerier userQuerier auditLogQuerier + connectionLogQuerier } type templateQuerier interface { @@ -530,6 +531,67 @@ func (q *sqlQuerier) GetAuthorizedAuditLogsOffset(ctx context.Context, arg GetAu return items, nil } +type connectionLogQuerier interface { + GetAuthorizedConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams, prepared rbac.PreparedAuthorized) ([]GetConnectionLogsOffsetRow, error) +} + +func (q *sqlQuerier) GetAuthorizedConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams, prepared rbac.PreparedAuthorized) ([]GetConnectionLogsOffsetRow, error) { + authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{ + VariableConverter: regosql.ConnectionLogConverter(), + }) + if err != nil { + return nil, xerrors.Errorf("compile authorized filter: %w", err) + } + filtered, err := insertAuthorizedFilter(getConnectionLogsOffset, fmt.Sprintf(" AND %s", authorizedFilter)) + if err != nil { + return nil, xerrors.Errorf("insert authorized filter: %w", err) + } + + query := fmt.Sprintf("-- name: GetAuthorizedConnectionLogsOffset :many\n%s", filtered) + rows, err := q.db.QueryContext(ctx, query, + arg.OffsetOpt, + arg.LimitOpt, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetConnectionLogsOffsetRow + for rows.Next() { + var i GetConnectionLogsOffsetRow + if err := rows.Scan( + &i.ConnectionLog.ID, + &i.ConnectionLog.Time, + &i.ConnectionLog.OrganizationID, + &i.ConnectionLog.WorkspaceOwnerID, + &i.ConnectionLog.WorkspaceID, + &i.ConnectionLog.WorkspaceName, + &i.ConnectionLog.AgentName, + &i.ConnectionLog.Action, + &i.ConnectionLog.Code, + &i.ConnectionLog.Ip, + &i.ConnectionLog.UserAgent, + &i.ConnectionLog.UserID, + &i.ConnectionLog.SlugOrPort, + &i.ConnectionLog.ConnectionType, + &i.ConnectionLog.Reason, + &i.UserUsername, + &i.WorkspaceOwnerUsername, + &i.Count, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + func insertAuthorizedFilter(query string, replaceWith string) (string, error) { if !strings.Contains(query, authorizedQueryPlaceholder) { return "", xerrors.Errorf("query does not contain authorized replace string, this is not an authorized query") diff --git a/coderd/database/models.go b/coderd/database/models.go index 69ae70b6c3bd3..8cd9e02bc5503 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -412,6 +412,70 @@ func AllBuildReasonValues() []BuildReason { } } +type ConnectionAction string + +const ( + ConnectionActionConnect ConnectionAction = "connect" + ConnectionActionDisconnect ConnectionAction = "disconnect" + ConnectionActionOpen ConnectionAction = "open" + ConnectionActionClose ConnectionAction = "close" +) + +func (e *ConnectionAction) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ConnectionAction(s) + case string: + *e = ConnectionAction(s) + default: + return fmt.Errorf("unsupported scan type for ConnectionAction: %T", src) + } + return nil +} + +type NullConnectionAction struct { + ConnectionAction ConnectionAction `json:"connection_action"` + Valid bool `json:"valid"` // Valid is true if ConnectionAction is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullConnectionAction) Scan(value interface{}) error { + if value == nil { + ns.ConnectionAction, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ConnectionAction.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullConnectionAction) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ConnectionAction), nil +} + +func (e ConnectionAction) Valid() bool { + switch e { + case ConnectionActionConnect, + ConnectionActionDisconnect, + ConnectionActionOpen, + ConnectionActionClose: + return true + } + return false +} + +func AllConnectionActionValues() []ConnectionAction { + return []ConnectionAction{ + ConnectionActionConnect, + ConnectionActionDisconnect, + ConnectionActionOpen, + ConnectionActionClose, + } +} + type CryptoKeyFeature string const ( @@ -2792,6 +2856,29 @@ type ChatMessage struct { Content json.RawMessage `db:"content" json:"content"` } +type ConnectionLog struct { + ID uuid.UUID `db:"id" json:"id"` + Time time.Time `db:"time" json:"time"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + WorkspaceName string `db:"workspace_name" json:"workspace_name"` + AgentName string `db:"agent_name" json:"agent_name"` + Action ConnectionAction `db:"action" json:"action"` + // Either the HTTP status code for the workspace app request, or the exit code of an SSH connection. + Code int32 `db:"code" json:"code"` + Ip pqtype.Inet `db:"ip" json:"ip"` + // Null for SSH actions. For workspace apps, this is the User-Agent header from the request. + UserAgent sql.NullString `db:"user_agent" json:"user_agent"` + // uuid.Nil for SSH actions. For workspace apps, this is the ID of the user that made the request. + UserID uuid.UUID `db:"user_id" json:"user_id"` + SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"` + // Null for Workspace App actions. For SSH actions, this is the type of connection (e.g., "ssh", "websocket"). + ConnectionType sql.NullString `db:"connection_type" json:"connection_type"` + // Null for Workspace App actions. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI. + Reason sql.NullString `db:"reason" json:"reason"` +} + type CryptoKey struct { Feature CryptoKeyFeature `db:"feature" json:"feature"` Sequence int32 `db:"sequence" json:"sequence"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b612143b63776..ffc85cddd76e8 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -156,6 +156,7 @@ type sqlcQuerier interface { GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]Chat, error) + GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error) GetCryptoKeys(ctx context.Context) ([]CryptoKey, error) @@ -470,6 +471,7 @@ type sqlcQuerier interface { InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) InsertChatMessages(ctx context.Context, arg InsertChatMessagesParams) ([]ChatMessage, error) + InsertConnectionLog(ctx context.Context, arg InsertConnectionLogParams) (ConnectionLog, error) InsertCryptoKey(ctx context.Context, arg InsertCryptoKeyParams) (CryptoKey, error) InsertCustomRole(ctx context.Context, arg InsertCustomRoleParams) (CustomRole, error) InsertDBCryptKey(ctx context.Context, arg InsertDBCryptKeyParams) error diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 74ac5b0a20caf..bd5ba07a9fe2b 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -2049,6 +2049,178 @@ func auditOnlyIDs[T database.AuditLog | database.GetAuditLogsOffsetRow](logs []T return ids } +func TestGetAuthorizedConnectionLogsOffset(t *testing.T) { + t.Parallel() + + var allLogs []database.ConnectionLog + db, _ := dbtestutil.NewDB(t) + authz := rbac.NewAuthorizer(prometheus.NewRegistry()) + authDb := dbauthz.New(db, authz, slogtest.Make(t, &slogtest.Options{}), coderdtest.AccessControlStorePointer()) + + orgA := dbfake.Organization(t, db).Do() + orgB := dbfake.Organization(t, db).Do() + + user := dbgen.User(t, db, database.User{}) + + tpl := dbgen.Template(t, db, database.Template{ + OrganizationID: orgA.Org.ID, + CreatedBy: user.ID, + }) + + wsID := uuid.New() + createTemplateVersion(t, db, tpl, tvArgs{ + WorkspaceTransition: database.WorkspaceTransitionStart, + Status: database.ProvisionerJobStatusSucceeded, + CreateWorkspace: true, + WorkspaceID: wsID, + }) + + // This map is a simple way to insert a given number of organizations + // and audit logs for each organization. + // map[orgID][]ConnectionLogID + orgConnectionLogs := map[uuid.UUID][]uuid.UUID{ + orgA.Org.ID: {uuid.New(), uuid.New()}, + orgB.Org.ID: {uuid.New(), uuid.New()}, + } + orgIDs := make([]uuid.UUID, 0, len(orgConnectionLogs)) + for orgID := range orgConnectionLogs { + orgIDs = append(orgIDs, orgID) + } + for orgID, ids := range orgConnectionLogs { + for _, id := range ids { + allLogs = append(allLogs, dbgen.ConnectionLog(t, authDb, database.ConnectionLog{ + WorkspaceID: wsID, + ID: id, + OrganizationID: orgID, + })) + } + } + + // Now fetch all the logs + ctx := testutil.Context(t, testutil.WaitLong) + auditorRole, err := rbac.RoleByName(rbac.RoleAuditor()) + require.NoError(t, err) + + memberRole, err := rbac.RoleByName(rbac.RoleMember()) + require.NoError(t, err) + + orgAuditorRoles := func(t *testing.T, orgID uuid.UUID) rbac.Role { + t.Helper() + + role, err := rbac.RoleByName(rbac.ScopedRoleOrgAuditor(orgID)) + require.NoError(t, err) + return role + } + + t.Run("NoAccess", func(t *testing.T) { + t.Parallel() + + // Given: A user who is a member of 0 organizations + memberCtx := dbauthz.As(ctx, rbac.Subject{ + FriendlyName: "member", + ID: uuid.NewString(), + Roles: rbac.Roles{memberRole}, + Scope: rbac.ScopeAll, + }) + + // When: The user queries for connection logs + logs, err := authDb.GetConnectionLogsOffset(memberCtx, database.GetConnectionLogsOffsetParams{}) + require.NoError(t, err) + // Then: No logs returned + require.Len(t, logs, 0, "no logs should be returned") + }) + + t.Run("SiteWideAuditor", func(t *testing.T) { + t.Parallel() + + // Given: A site wide auditor + siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ + FriendlyName: "owner", + ID: uuid.NewString(), + Roles: rbac.Roles{auditorRole}, + Scope: rbac.ScopeAll, + }) + + // When: the auditor queries for connection logs + logs, err := authDb.GetConnectionLogsOffset(siteAuditorCtx, database.GetConnectionLogsOffsetParams{}) + require.NoError(t, err) + // Then: All logs are returned + require.ElementsMatch(t, connectionOnlyIDs(allLogs), connectionOnlyIDs(logs)) + }) + + t.Run("SingleOrgAuditor", func(t *testing.T) { + t.Parallel() + + orgID := orgIDs[0] + // Given: An organization scoped auditor + orgAuditCtx := dbauthz.As(ctx, rbac.Subject{ + FriendlyName: "org-auditor", + ID: uuid.NewString(), + Roles: rbac.Roles{orgAuditorRoles(t, orgID)}, + Scope: rbac.ScopeAll, + }) + + // When: The auditor queries for connection logs + logs, err := authDb.GetConnectionLogsOffset(orgAuditCtx, database.GetConnectionLogsOffsetParams{}) + require.NoError(t, err) + // Then: Only the logs for the organization are returned + require.ElementsMatch(t, orgConnectionLogs[orgID], connectionOnlyIDs(logs)) + }) + + t.Run("TwoOrgAuditors", func(t *testing.T) { + t.Parallel() + + first := orgIDs[0] + second := orgIDs[1] + // Given: A user who is an auditor for two organizations + multiOrgAuditCtx := dbauthz.As(ctx, rbac.Subject{ + FriendlyName: "org-auditor", + ID: uuid.NewString(), + Roles: rbac.Roles{orgAuditorRoles(t, first), orgAuditorRoles(t, second)}, + Scope: rbac.ScopeAll, + }) + + // When: The user queries for connection logs + logs, err := authDb.GetConnectionLogsOffset(multiOrgAuditCtx, database.GetConnectionLogsOffsetParams{}) + require.NoError(t, err) + // Then: All logs for both organizations are returned + require.ElementsMatch(t, append(orgConnectionLogs[first], orgConnectionLogs[second]...), connectionOnlyIDs(logs)) + }) + + t.Run("ErroneousOrg", func(t *testing.T) { + t.Parallel() + + // Given: A user who is an auditor for an organization that has 0 logs + userCtx := dbauthz.As(ctx, rbac.Subject{ + FriendlyName: "org-auditor", + ID: uuid.NewString(), + Roles: rbac.Roles{orgAuditorRoles(t, uuid.New())}, + Scope: rbac.ScopeAll, + }) + + // When: The user queries for audit logs + logs, err := authDb.GetConnectionLogsOffset(userCtx, database.GetConnectionLogsOffsetParams{}) + require.NoError(t, err) + // Then: No logs are returned + require.Len(t, logs, 0, "no logs should be returned") + }) +} + +func connectionOnlyIDs[T database.ConnectionLog | database.GetConnectionLogsOffsetRow](logs []T) []uuid.UUID { + ids := make([]uuid.UUID, 0, len(logs)) + for _, log := range logs { + switch log := any(log).(type) { + case database.ConnectionLog: + ids = append(ids, log.ID) + case database.GetConnectionLogsOffsetRow: + ids = append(ids, log.ConnectionLog.ID) + default: + panic("unreachable") + } + } + return ids +} + type tvArgs struct { Status database.ProvisionerJobStatus // CreateWorkspace is true if we should create a workspace for the template version diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index eec91c7586d61..e38cfa1268b64 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -967,6 +967,166 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam return err } +const getConnectionLogsOffset = `-- name: GetConnectionLogsOffset :many +SELECT + connection_logs.id, connection_logs.time, connection_logs.organization_id, connection_logs.workspace_owner_id, connection_logs.workspace_id, connection_logs.workspace_name, connection_logs.agent_name, connection_logs.action, connection_logs.code, connection_logs.ip, connection_logs.user_agent, connection_logs.user_id, connection_logs.slug_or_port, connection_logs.connection_type, connection_logs.reason, + users.username AS user_username, + workspace_owner.username AS workspace_owner_username, + COUNT(connection_logs.*) OVER () AS count +FROM + connection_logs +LEFT JOIN users ON connection_logs.user_id = users.id +LEFT JOIN users as workspace_owner ON + connection_logs.workspace_owner_id = workspace_owner.id +WHERE TRUE + -- Authorize Filter clause will be injected below in + -- GetAuthorizedConnectionLogsOffset + -- @authorize_filter +ORDER BY + "time" DESC +LIMIT + -- a limit of 0 means "no limit". The connection log table is unbounded + -- in size, and is expected to be quite large. Implement a default + -- limit of 100 to prevent accidental excessively large queries. + COALESCE(NULLIF($2 :: int, 0), 100) +OFFSET + $1 +` + +type GetConnectionLogsOffsetParams struct { + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` +} + +type GetConnectionLogsOffsetRow struct { + ConnectionLog ConnectionLog `db:"connection_log" json:"connection_log"` + UserUsername sql.NullString `db:"user_username" json:"user_username"` + WorkspaceOwnerUsername sql.NullString `db:"workspace_owner_username" json:"workspace_owner_username"` + Count int64 `db:"count" json:"count"` +} + +func (q *sqlQuerier) GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error) { + rows, err := q.db.QueryContext(ctx, getConnectionLogsOffset, arg.OffsetOpt, arg.LimitOpt) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetConnectionLogsOffsetRow + for rows.Next() { + var i GetConnectionLogsOffsetRow + if err := rows.Scan( + &i.ConnectionLog.ID, + &i.ConnectionLog.Time, + &i.ConnectionLog.OrganizationID, + &i.ConnectionLog.WorkspaceOwnerID, + &i.ConnectionLog.WorkspaceID, + &i.ConnectionLog.WorkspaceName, + &i.ConnectionLog.AgentName, + &i.ConnectionLog.Action, + &i.ConnectionLog.Code, + &i.ConnectionLog.Ip, + &i.ConnectionLog.UserAgent, + &i.ConnectionLog.UserID, + &i.ConnectionLog.SlugOrPort, + &i.ConnectionLog.ConnectionType, + &i.ConnectionLog.Reason, + &i.UserUsername, + &i.WorkspaceOwnerUsername, + &i.Count, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertConnectionLog = `-- name: InsertConnectionLog :one +INSERT INTO + connection_logs ( + id, + "time", + organization_id, + workspace_owner_id, + workspace_id, + workspace_name, + agent_name, + action, + code, + ip, + user_agent, + user_id, + slug_or_port, + connection_type, + reason + ) +VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, time, organization_id, workspace_owner_id, workspace_id, workspace_name, agent_name, action, code, ip, user_agent, user_id, slug_or_port, connection_type, reason +` + +type InsertConnectionLogParams struct { + ID uuid.UUID `db:"id" json:"id"` + Time time.Time `db:"time" json:"time"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + WorkspaceName string `db:"workspace_name" json:"workspace_name"` + AgentName string `db:"agent_name" json:"agent_name"` + Action ConnectionAction `db:"action" json:"action"` + Code int32 `db:"code" json:"code"` + Ip pqtype.Inet `db:"ip" json:"ip"` + UserAgent sql.NullString `db:"user_agent" json:"user_agent"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"` + ConnectionType sql.NullString `db:"connection_type" json:"connection_type"` + Reason sql.NullString `db:"reason" json:"reason"` +} + +func (q *sqlQuerier) InsertConnectionLog(ctx context.Context, arg InsertConnectionLogParams) (ConnectionLog, error) { + row := q.db.QueryRowContext(ctx, insertConnectionLog, + arg.ID, + arg.Time, + arg.OrganizationID, + arg.WorkspaceOwnerID, + arg.WorkspaceID, + arg.WorkspaceName, + arg.AgentName, + arg.Action, + arg.Code, + arg.Ip, + arg.UserAgent, + arg.UserID, + arg.SlugOrPort, + arg.ConnectionType, + arg.Reason, + ) + var i ConnectionLog + err := row.Scan( + &i.ID, + &i.Time, + &i.OrganizationID, + &i.WorkspaceOwnerID, + &i.WorkspaceID, + &i.WorkspaceName, + &i.AgentName, + &i.Action, + &i.Code, + &i.Ip, + &i.UserAgent, + &i.UserID, + &i.SlugOrPort, + &i.ConnectionType, + &i.Reason, + ) + return i, err +} + const deleteCryptoKey = `-- name: DeleteCryptoKey :one UPDATE crypto_keys SET secret = NULL, secret_key_id = NULL diff --git a/coderd/database/queries/connectionlogs.sql b/coderd/database/queries/connectionlogs.sql new file mode 100644 index 0000000000000..26818506c4eb5 --- /dev/null +++ b/coderd/database/queries/connectionlogs.sql @@ -0,0 +1,47 @@ +-- name: GetConnectionLogsOffset :many +SELECT + sqlc.embed(connection_logs), + users.username AS user_username, + workspace_owner.username AS workspace_owner_username, + COUNT(connection_logs.*) OVER () AS count +FROM + connection_logs +LEFT JOIN users ON connection_logs.user_id = users.id +LEFT JOIN users as workspace_owner ON + connection_logs.workspace_owner_id = workspace_owner.id +WHERE TRUE + -- Authorize Filter clause will be injected below in + -- GetAuthorizedConnectionLogsOffset + -- @authorize_filter +ORDER BY + "time" DESC +LIMIT + -- a limit of 0 means "no limit". The connection log table is unbounded + -- in size, and is expected to be quite large. Implement a default + -- limit of 100 to prevent accidental excessively large queries. + COALESCE(NULLIF(@limit_opt :: int, 0), 100) +OFFSET + @offset_opt; + + +-- name: InsertConnectionLog :one +INSERT INTO + connection_logs ( + id, + "time", + organization_id, + workspace_owner_id, + workspace_id, + workspace_name, + agent_name, + action, + code, + ip, + user_agent, + user_id, + slug_or_port, + connection_type, + reason + ) +VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *; diff --git a/coderd/database/types.go b/coderd/database/types.go index 2528a30aa3fe8..32696607d5434 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -4,10 +4,12 @@ import ( "database/sql/driver" "encoding/json" "fmt" + "net" "strings" "time" "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/rbac/policy" @@ -232,3 +234,19 @@ func (a *UserLinkClaims) Scan(src interface{}) error { func (a UserLinkClaims) Value() (driver.Value, error) { return json.Marshal(a) } + +func ParseIP(ipStr string) pqtype.Inet { + ip := net.ParseIP(ipStr) + ipNet := net.IPNet{} + if ip != nil { + ipNet = net.IPNet{ + IP: ip, + Mask: net.CIDRMask(len(ip)*8, len(ip)*8), + } + } + + return pqtype.Inet{ + IPNet: ipNet, + Valid: ip != nil, + } +} diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 4c9c8cedcba23..aa7b9f385e432 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -11,6 +11,7 @@ const ( UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); UniqueChatMessagesPkey UniqueConstraint = "chat_messages_pkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); UniqueChatsPkey UniqueConstraint = "chats_pkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_pkey PRIMARY KEY (id); + UniqueConnectionLogsPkey UniqueConstraint = "connection_logs_pkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_pkey PRIMARY KEY (id); UniqueCryptoKeysPkey UniqueConstraint = "crypto_keys_pkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); UniqueCustomRolesUniqueKey UniqueConstraint = "custom_roles_unique_key" // ALTER TABLE ONLY custom_roles ADD CONSTRAINT custom_roles_unique_key UNIQUE (name, organization_id); UniqueDbcryptKeysActiveKeyDigestKey UniqueConstraint = "dbcrypt_keys_active_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 9e3a0536279ae..d198f14f15e5c 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -65,6 +65,7 @@ const ( SubjectTypeUser SubjectType = "user" SubjectTypeProvisionerd SubjectType = "provisionerd" SubjectTypeAutostart SubjectType = "autostart" + SubjectTypeConnectionLogger SubjectType = "connection_logger" SubjectTypeJobReaper SubjectType = "job_reaper" SubjectTypeResourceMonitor SubjectType = "resource_monitor" SubjectTypeCryptoKeyRotator SubjectType = "crypto_key_rotator" diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index f19d90894dd55..826fd667ba3ca 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -64,6 +64,14 @@ var ( Type: "chat", } + // ResourceConnectionLog + // Valid Actions + // - "ActionCreate" :: create new connection log entries + // - "ActionRead" :: read connection logs + ResourceConnectionLog = Object{ + Type: "connection_log", + } + // ResourceCryptoKey // Valid Actions // - "ActionCreate" :: create crypto keys @@ -371,6 +379,7 @@ func AllResources() []Objecter { ResourceAssignRole, ResourceAuditLog, ResourceChat, + ResourceConnectionLog, ResourceCryptoKey, ResourceDebugInfo, ResourceDeploymentConfig, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 160062283f857..b77f66d6dbfaf 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -132,6 +132,12 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionCreate: actDef("create new audit log entries"), }, }, + "connection_log": { + Actions: map[Action]ActionDefinition{ + ActionRead: actDef("read connection logs"), + ActionCreate: actDef("create new connection log entries"), + }, + }, "deployment_config": { Actions: map[Action]ActionDefinition{ ActionRead: actDef("read deployment config"), diff --git a/coderd/rbac/regosql/configs.go b/coderd/rbac/regosql/configs.go index 4ccd1cb3bbaef..749d0db112ee4 100644 --- a/coderd/rbac/regosql/configs.go +++ b/coderd/rbac/regosql/configs.go @@ -50,6 +50,20 @@ func AuditLogConverter() *sqltypes.VariableConverter { return matcher } +func ConnectionLogConverter() *sqltypes.VariableConverter { + matcher := sqltypes.NewVariableConverter().RegisterMatcher( + resourceIDMatcher(), + sqltypes.StringVarMatcher("COALESCE(connection_logs.organization_id :: text, '')", []string{"input", "object", "org_owner"}), + // Connection logs have no user owner, only owner by an organization. + sqltypes.AlwaysFalse(userOwnerMatcher()), + ) + matcher.RegisterMatcher( + sqltypes.AlwaysFalse(groupACLMatcher(matcher)), + sqltypes.AlwaysFalse(userACLMatcher(matcher)), + ) + return matcher +} + func UserConverter() *sqltypes.VariableConverter { matcher := sqltypes.NewVariableConverter().RegisterMatcher( resourceIDMatcher(), diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 28ddc38462ce9..0b5dcabe94320 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -313,6 +313,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Site: Permissions(map[string][]policy.Action{ ResourceAssignOrgRole.Type: {policy.ActionRead}, ResourceAuditLog.Type: {policy.ActionRead}, + ResourceConnectionLog.Type: {policy.ActionRead}, // Allow auditors to see the resources that audit logs reflect. ResourceTemplate.Type: {policy.ActionRead, policy.ActionViewInsights}, ResourceUser.Type: {policy.ActionRead}, @@ -449,7 +450,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Site: []Permission{}, Org: map[string][]Permission{ organizationID.String(): Permissions(map[string][]policy.Action{ - ResourceAuditLog.Type: {policy.ActionRead}, + ResourceAuditLog.Type: {policy.ActionRead}, + ResourceConnectionLog.Type: {policy.ActionRead}, // Allow auditors to see the resources that audit logs reflect. ResourceTemplate.Type: {policy.ActionRead, policy.ActionViewInsights}, ResourceGroup.Type: {policy.ActionRead}, diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 5738edfe8caa2..1992da110ba3d 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -871,6 +871,15 @@ func TestRolePermissions(t *testing.T) { }, }, }, + { + Name: "ConnectionLogs", + Actions: []policy.Action{policy.ActionRead, policy.ActionCreate}, + Resource: rbac.ResourceConnectionLog, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner}, + false: {setOtherOrg, setOrgNotMe, memberMe, orgMemberMe, templateAdmin, userAdmin}, + }, + }, } // We expect every permission to be tested above. diff --git a/coderd/workspaceagentsrpc.go b/coderd/workspaceagentsrpc.go index 1cbabad8ea622..0806118f2a832 100644 --- a/coderd/workspaceagentsrpc.go +++ b/coderd/workspaceagentsrpc.go @@ -139,7 +139,7 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) { Database: api.Database, NotificationsEnqueuer: api.NotificationsEnqueuer, Pubsub: api.Pubsub, - Auditor: &api.Auditor, + ConnectionLogger: &api.ConnectionLogger, DerpMapFn: api.DERPMap, TailnetCoordinator: &api.TailnetCoordinator, AppearanceFetcher: &api.AppearanceFetcher, diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 90c6f107daa5e..d0c0a2e2f2361 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -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() @@ -365,20 +364,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) @@ -386,12 +385,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 { @@ -401,7 +400,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 } @@ -413,28 +412,18 @@ 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 slugOrPort string 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 + slugOrPort = "terminal" + default: + slugOrPort = aReq.dbReq.AppSlugOrPort } // If we end up logging, ensure relevant fields are set. @@ -444,7 +433,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), ) @@ -464,9 +453,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, }) @@ -479,7 +467,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 } @@ -489,52 +477,25 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW // didn't timeout due to inactivity. return } - - // Marshal additional fields only if we're writing an audit log entry. - appInfoBytes, err := json.Marshal(appInfo) + connLogger := *p.ConnectionLogger.Load() + err = connLogger.Export(ctx, database.ConnectionLog{ + 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, + Action: database.ConnectionActionOpen, + Code: statusCode, + Ip: database.ParseIP(ip), + UserAgent: sql.NullString{Valid: userAgent != "", String: userAgent}, + UserID: userID, + SlugOrPort: sql.NullString{Valid: slugOrPort != "", String: slugOrPort}, + }) 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, "insert connection log failed", slog.Error(err)) + return } } } diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index 597d1daadfa54..a49e6d66fdb7e 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -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.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?") + 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.ResetLogs() t.Run("OK", func(t *testing.T) { t.Parallel() @@ -287,9 +270,9 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: app, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) - auditableUA := "Tidua" + auditableUA := "Noitcennoc" t.Log("app", app) rw := httptest.NewRecorder() @@ -299,7 +282,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, @@ -335,11 +318,11 @@ 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, me.ID) + require.Len(t, connLogger.ConnectionLogs(), 1) var parsedToken workspaceapps.SignedToken - err := jwtutils.Verify(ctx, api.AppSigningKeyCache, cookie.Value, &parsedToken) + err = jwtutils.Verify(ctx, api.AppSigningKeyCache, cookie.Value, &parsedToken) require.NoError(t, err) // normalize expiry require.WithinDuration(t, token.Expiry.Time(), parsedToken.Expiry.Time(), 2*time.Second) @@ -352,7 +335,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, @@ -365,7 +348,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") } }) } @@ -384,7 +367,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: app, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) t.Log("app", app) @@ -393,7 +376,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, @@ -408,14 +391,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, secondUser.ID) + require.Len(t, connLogger.ConnectionLogs(), 1) } }) @@ -432,14 +416,14 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: app, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) t.Log("app", app) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -454,8 +438,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, uuid.Nil) + require.Len(t, connLogger.ConnectionLogs(), 1) } else { if !assert.True(t, ok) { dump, err := httputil.DumpResponse(w, true) @@ -468,8 +452,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, uuid.Nil) + require.Len(t, connLogger.ConnectionLogs(), 1) } _ = w.Body.Close() } @@ -481,12 +465,12 @@ func Test_ResolveRequest(t *testing.T) { req := (workspaceapps.Request{ AccessMethod: "invalid", }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -496,7 +480,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) { @@ -564,7 +548,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNamePublic, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -572,7 +556,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, @@ -593,11 +577,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, 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() }) @@ -639,7 +623,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameOwner, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -653,7 +637,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, @@ -678,8 +662,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, me.ID) + require.Len(t, connLogger.ConnectionLogs(), 1) }) t.Run("PortPathBlocked", func(t *testing.T) { @@ -694,7 +678,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: "8080", }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -702,7 +686,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, @@ -717,7 +701,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) { @@ -732,7 +716,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: "9090", }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -740,7 +724,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, @@ -751,11 +735,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", me.ID) + require.Len(t, connLogger.ConnectionLogs(), 1) }) t.Run("PortSubdomainHTTPSS", func(t *testing.T) { @@ -770,7 +751,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: "9090ss", }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -778,7 +759,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, @@ -794,7 +775,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) { @@ -809,7 +790,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameEndsInS, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -817,7 +798,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, @@ -827,8 +808,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, me.ID) + require.Len(t, connLogger.ConnectionLogs(), 1) }) t.Run("Terminal", func(t *testing.T) { @@ -840,7 +821,7 @@ func Test_ResolveRequest(t *testing.T) { AgentNameOrID: agentID.String(), }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -848,7 +829,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, @@ -864,10 +845,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", me.ID) + require.Len(t, connLogger.ConnectionLogs(), 1) }) t.Run("InsufficientPermissions", func(t *testing.T) { @@ -882,7 +861,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameOwner, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -890,7 +869,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, @@ -900,8 +879,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, secondUser.ID) + require.Len(t, connLogger.ConnectionLogs(), 1) }) t.Run("UserNotFound", func(t *testing.T) { @@ -915,7 +894,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameOwner, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -923,7 +902,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, @@ -933,7 +912,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) { @@ -948,7 +927,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameOwner, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -957,7 +936,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, @@ -974,8 +953,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, uuid.Nil) + require.Len(t, connLogger.ConnectionLogs(), 1) loc, err := w.Location() require.NoError(t, err) @@ -1014,7 +993,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameAgentUnhealthy, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -1022,7 +1001,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, @@ -1036,8 +1015,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, me.ID) + require.Len(t, connLogger.ConnectionLogs(), 1) body, err := io.ReadAll(w.Body) require.NoError(t, err) @@ -1077,7 +1056,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameInitializing, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -1085,7 +1064,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, @@ -1095,8 +1074,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, me.ID) + require.Len(t, connLogger.ConnectionLogs(), 1) }) // Unhealthy apps are now permitted to connect anyways. This wasn't always @@ -1135,7 +1114,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameUnhealthy, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -1143,7 +1122,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, @@ -1153,11 +1132,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, 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 { @@ -1170,18 +1149,18 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: app, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewMock() 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, @@ -1190,8 +1169,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, me.ID) + require.Len(t, connLogger.ConnectionLogs(), 1) // Second request, no audit log because the session is active. rw = httptest.NewRecorder() @@ -1199,7 +1178,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, @@ -1208,7 +1187,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() @@ -1216,7 +1195,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, @@ -1226,8 +1205,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, 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) @@ -1236,7 +1215,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, @@ -1245,16 +1224,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, 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) { @@ -1266,52 +1245,35 @@ 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() - - 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") - } +func assertConnLogContains(t testing.TB, rr *httptest.ResponseRecorder, r *http.Request, connLogger *connectionlog.MockConnectionLogger, workspace codersdk.Workspace, agentName string, slugOrPort string, userID uuid.UUID) { + t.Helper() - 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) - } - } + resp := rr.Result() + defer resp.Body.Close() + + require.True(t, connLogger.Contains(t, database.ConnectionLog{ + OrganizationID: workspace.OrganizationID, + WorkspaceOwnerID: workspace.OwnerID, + WorkspaceID: workspace.ID, + WorkspaceName: workspace.Name, + AgentName: agentName, + Action: database.ConnectionActionOpen, + Ip: database.ParseIP(r.RemoteAddr), + UserAgent: sql.NullString{Valid: r.UserAgent() != "", String: r.UserAgent()}, + Code: int32(resp.StatusCode), // nolint:gosec + UserID: userID, + SlugOrPort: sql.NullString{Valid: slugOrPort != "", String: slugOrPort}, + })) } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index ac72ed2fc1ec1..e64450fadd9ed 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -65,6 +65,7 @@ type FeatureName string const ( FeatureUserLimit FeatureName = "user_limit" FeatureAuditLog FeatureName = "audit_log" + FeatureConnectionLog FeatureName = "connection_log" FeatureBrowserOnly FeatureName = "browser_only" FeatureSCIM FeatureName = "scim" FeatureTemplateRBAC FeatureName = "template_rbac" @@ -88,6 +89,7 @@ const ( var FeatureNames = []FeatureName{ FeatureUserLimit, FeatureAuditLog, + FeatureConnectionLog, FeatureBrowserOnly, FeatureSCIM, FeatureTemplateRBAC, diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 95792bb8e2a7b..3545e5d133957 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -10,6 +10,7 @@ const ( ResourceAssignRole RBACResource = "assign_role" ResourceAuditLog RBACResource = "audit_log" ResourceChat RBACResource = "chat" + ResourceConnectionLog RBACResource = "connection_log" ResourceCryptoKey RBACResource = "crypto_key" ResourceDebugInfo RBACResource = "debug_info" ResourceDeploymentConfig RBACResource = "deployment_config" @@ -73,6 +74,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceAssignRole: {ActionAssign, ActionRead, ActionUnassign}, ResourceAuditLog: {ActionCreate, ActionRead}, ResourceChat: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceConnectionLog: {ActionCreate, ActionRead}, ResourceCryptoKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceDebugInfo: {ActionRead}, ResourceDeploymentConfig: {ActionRead, ActionUpdate}, diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 6b5d124753bc0..8cb87765f73c6 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -188,6 +188,7 @@ Status Code **200** | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | | `resource_type` | `chat` | +| `resource_type` | `connection_log` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -357,6 +358,7 @@ Status Code **200** | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | | `resource_type` | `chat` | +| `resource_type` | `connection_log` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -526,6 +528,7 @@ Status Code **200** | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | | `resource_type` | `chat` | +| `resource_type` | `connection_log` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -664,6 +667,7 @@ Status Code **200** | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | | `resource_type` | `chat` | +| `resource_type` | `connection_log` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -1024,6 +1028,7 @@ Status Code **200** | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | | `resource_type` | `chat` | +| `resource_type` | `connection_log` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 4191ab8970e92..39d00c3899b72 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -6309,6 +6309,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `assign_role` | | `audit_log` | | `chat` | +| `connection_log` | | `crypto_key` | | `debug_info` | | `deployment_config` | diff --git a/enterprise/audit/backends/slog.go b/enterprise/audit/backends/slog.go index c49ebae296ff0..e675ac19f2d0d 100644 --- a/enterprise/audit/backends/slog.go +++ b/enterprise/audit/backends/slog.go @@ -12,38 +12,34 @@ import ( "github.com/coder/coder/v2/enterprise/audit" ) -type slogBackend struct { +type SlogExporter struct { log slog.Logger } -func NewSlog(logger slog.Logger) audit.Backend { - return &slogBackend{log: logger} +func NewSlogExporter(logger slog.Logger) *SlogExporter { + return &SlogExporter{log: logger} } -func (*slogBackend) Decision() audit.FilterDecision { - return audit.FilterDecisionExport -} - -func (b *slogBackend) Export(ctx context.Context, alog database.AuditLog, details audit.BackendDetails) error { +func (e *SlogExporter) ExportStruct(ctx context.Context, data interface{}, message string, extraFields ...slog.Field) error { // We don't use structs.Map because we don't want to recursively convert // fields into maps. When we keep the type information, slog can more // pleasantly format the output. For example, the clean result of // (*NullString).Value() may be printed instead of {String: "foo", Valid: true}. - sfs := structs.Fields(alog) + sfs := structs.Fields(data) var fields []any for _, sf := range sfs { - fields = append(fields, b.fieldToSlog(sf)) + fields = append(fields, e.fieldToSlog(sf)) } - if details.Actor != nil { - fields = append(fields, slog.F("actor", details.Actor)) + for _, field := range extraFields { + fields = append(fields, field) } - b.log.Info(ctx, "audit_log", fields...) + e.log.Info(ctx, message, fields...) return nil } -func (*slogBackend) fieldToSlog(field *structs.Field) slog.Field { +func (*SlogExporter) fieldToSlog(field *structs.Field) slog.Field { val := field.Value() switch ty := field.Value().(type) { @@ -55,3 +51,26 @@ func (*slogBackend) fieldToSlog(field *structs.Field) slog.Field { return slog.F(field.Name(), val) } + +type auditSlogBackend struct { + exporter *SlogExporter +} + +func NewSlog(logger slog.Logger) audit.Backend { + return &auditSlogBackend{ + exporter: NewSlogExporter(logger), + } +} + +func (*auditSlogBackend) Decision() audit.FilterDecision { + return audit.FilterDecisionExport +} + +func (b *auditSlogBackend) Export(ctx context.Context, alog database.AuditLog, details audit.BackendDetails) error { + var extraFields []slog.Field + if details.Actor != nil { + extraFields = append(extraFields, slog.F("actor", details.Actor)) + } + + return b.exporter.ExportStruct(ctx, alog, "audit_log", extraFields...) +} diff --git a/enterprise/audit/backends/slog_test.go b/enterprise/audit/backends/slog_test.go index 5fe3cf70c519a..99be36b3f9d15 100644 --- a/enterprise/audit/backends/slog_test.go +++ b/enterprise/audit/backends/slog_test.go @@ -24,7 +24,7 @@ import ( "github.com/coder/coder/v2/enterprise/audit/backends" ) -func TestSlogBackend(t *testing.T) { +func TestSlogExporter(t *testing.T) { t.Parallel() t.Run("OK", func(t *testing.T) { t.Parallel() @@ -32,30 +32,29 @@ func TestSlogBackend(t *testing.T) { var ( ctx, cancel = context.WithCancel(context.Background()) - sink = &fakeSink{} - logger = slog.Make(sink) - backend = backends.NewSlog(logger) + sink = &fakeSink{} + logger = slog.Make(sink) + exporter = backends.NewSlogExporter(logger) alog = audittest.RandomLog() ) defer cancel() - err := backend.Export(ctx, alog, audit.BackendDetails{}) + err := exporter.ExportStruct(ctx, alog, "audit_log") require.NoError(t, err) require.Len(t, sink.entries, 1) require.Equal(t, sink.entries[0].Message, "audit_log") require.Len(t, sink.entries[0].Fields, len(structs.Fields(alog))) }) - t.Run("FormatsCorrectly", func(t *testing.T) { t.Parallel() var ( ctx, cancel = context.WithCancel(context.Background()) - buf = bytes.NewBuffer(nil) - logger = slog.Make(slogjson.Sink(buf)) - backend = backends.NewSlog(logger) + buf = bytes.NewBuffer(nil) + logger = slog.Make(slogjson.Sink(buf)) + exporter = backends.NewSlogExporter(logger) _, inet, _ = net.ParseCIDR("127.0.0.1/32") alog = database.AuditLog{ @@ -81,11 +80,11 @@ func TestSlogBackend(t *testing.T) { ) defer cancel() - err := backend.Export(ctx, alog, audit.BackendDetails{Actor: &audit.Actor{ + err := exporter.ExportStruct(ctx, alog, "audit_log", slog.F("actor", &audit.Actor{ ID: uuid.UUID{2}, Username: "coadler", Email: "doug@coder.com", - }}) + })) require.NoError(t, err) logger.Sync() diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 1bf4f31a8506b..3b1fd63ab1c4c 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -87,6 +87,7 @@ func (r *RootCmd) Server(_ func()) *serpent.Command { o := &coderd.Options{ Options: options, AuditLogging: true, + ConnectionLogging: true, BrowserOnly: options.DeploymentValues.BrowserOnly.Value(), SCIMAPIKey: []byte(options.DeploymentValues.SCIMAPIKey.Value()), RBAC: true, diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index f46848812a69e..ae44e38b09758 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -22,6 +22,7 @@ import ( agplportsharing "github.com/coder/coder/v2/coderd/portsharing" agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/enterprise/coderd/connectionlog" "github.com/coder/coder/v2/enterprise/coderd/enidpsync" "github.com/coder/coder/v2/enterprise/coderd/portsharing" @@ -36,6 +37,7 @@ import ( "github.com/coder/coder/v2/coderd" agplaudit "github.com/coder/coder/v2/coderd/audit" + agplconnectionlog "github.com/coder/coder/v2/coderd/connectionlog" agpldbauthz "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/healthcheck" @@ -123,6 +125,13 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { options.IDPSync = enidpsync.NewSync(options.Logger, options.RuntimeConfig, options.Entitlements, idpsync.FromDeploymentValues(options.DeploymentValues)) } + if options.ConnectionLogger == nil { + options.ConnectionLogger = connectionlog.NewConnectionLogger( + connectionlog.NewDBBackend(options.Database), + connectionlog.NewSlogBackend(options.Logger), + ) + } + api := &API{ ctx: ctx, cancel: cancelFunc, @@ -585,8 +594,9 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { type Options struct { *coderd.Options - RBAC bool - AuditLogging bool + RBAC bool + AuditLogging bool + ConnectionLogging bool // Whether to block non-browser connections. BrowserOnly bool SCIMAPIKey []byte @@ -687,6 +697,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { ctx, api.Database, len(agedReplicas), len(api.ExternalAuthConfigs), api.LicenseKeys, map[codersdk.FeatureName]bool{ codersdk.FeatureAuditLog: api.AuditLogging, + codersdk.FeatureConnectionLog: api.ConnectionLogging, codersdk.FeatureBrowserOnly: api.BrowserOnly, codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0, codersdk.FeatureMultipleExternalAuth: len(api.ExternalAuthConfigs) > 1, @@ -725,6 +736,14 @@ func (api *API) updateEntitlements(ctx context.Context) error { api.AGPL.Auditor.Store(&auditor) } + if initial, changed, enabled := featureChanged(codersdk.FeatureConnectionLog); shouldUpdate(initial, changed, enabled) { + connectionLogger := agplconnectionlog.NewNop() + if enabled { + connectionLogger = api.AGPL.Options.ConnectionLogger + } + api.AGPL.ConnectionLogger.Store(&connectionLogger) + } + if initial, changed, enabled := featureChanged(codersdk.FeatureBrowserOnly); shouldUpdate(initial, changed, enabled) { var handler func(rw http.ResponseWriter) bool if enabled { diff --git a/enterprise/coderd/connectionlog/connectionlog.go b/enterprise/coderd/connectionlog/connectionlog.go new file mode 100644 index 0000000000000..ce83dd0217f1c --- /dev/null +++ b/enterprise/coderd/connectionlog/connectionlog.go @@ -0,0 +1,70 @@ +package connectionlog + +import ( + "context" + + "cdr.dev/slog" + + "github.com/hashicorp/go-multierror" + + agpl "github.com/coder/coder/v2/coderd/connectionlog" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + auditbackends "github.com/coder/coder/v2/enterprise/audit/backends" +) + +type Backend interface { + Export(ctx context.Context, clog database.ConnectionLog) error +} + +func NewConnectionLogger(backends ...Backend) agpl.ConnectionLogger { + return &connectionLogger{ + backends: backends, + } +} + +type connectionLogger struct { + backends []Backend +} + +func (c *connectionLogger) Export(ctx context.Context, clog database.ConnectionLog) error { + var errs error + for _, backend := range c.backends { + err := backend.Export(ctx, clog) + if err != nil { + errs = multierror.Append(errs, err) + } + } + return errs +} + +type dbBackend struct { + db database.Store +} + +func NewDBBackend(db database.Store) Backend { + return &dbBackend{db: db} +} + +func (b *dbBackend) Export(ctx context.Context, clog database.ConnectionLog) error { + //nolint:gocritic // This is the Connection Logger + _, err := b.db.InsertConnectionLog(dbauthz.AsConnectionLogger(ctx), database.InsertConnectionLogParams(clog)) + if err != nil { + return err + } + return nil +} + +type connectionSlogBackend struct { + exporter *auditbackends.SlogExporter +} + +func NewSlogBackend(logger slog.Logger) Backend { + return &connectionSlogBackend{ + exporter: auditbackends.NewSlogExporter(logger), + } +} + +func (b *connectionSlogBackend) Export(ctx context.Context, clog database.ConnectionLog) error { + return b.exporter.ExportStruct(ctx, clog, "connection_log") +} diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 184a611c40949..b78ee12400324 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -649,6 +649,7 @@ func TestLicenseEntitlements(t *testing.T) { // maybe some should be moved to "AlwaysEnabled" instead. defaultEnablements := map[codersdk.FeatureName]bool{ codersdk.FeatureAuditLog: true, + codersdk.FeatureConnectionLog: true, codersdk.FeatureBrowserOnly: true, codersdk.FeatureSCIM: true, codersdk.FeatureMultipleExternalAuth: true, diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 885f603c1eb82..14e01b6609217 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -37,6 +37,10 @@ export const RBACResourceActions: Partial< read: "read a chat", update: "update a chat", }, + connection_log: { + create: "create new connection log entries", + read: "read connection logs", + }, crypto_key: { create: "create crypto keys", delete: "delete crypto keys", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c662b27386401..9b93bb20083be 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2174,6 +2174,7 @@ export type RBACResource = | "assign_role" | "audit_log" | "chat" + | "connection_log" | "crypto_key" | "debug_info" | "deployment_config" @@ -2213,6 +2214,7 @@ export const RBACResources: RBACResource[] = [ "assign_role", "audit_log", "chat", + "connection_log", "crypto_key", "debug_info", "deployment_config", From 9d7efc192b09dfaa4f1454ce32252c6c268cdc49 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 12 Jun 2025 07:37:09 +0000 Subject: [PATCH 02/16] gen, identation --- coderd/database/dbauthz/dbauthz_test.go | 35 ++++--------------- .../migrations/000334_connection_logs.up.sql | 34 +++++++++--------- coderd/database/queries/connectionlogs.sql | 14 ++++---- site/src/api/typesGenerated.ts | 2 ++ 4 files changed, 33 insertions(+), 52 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 07c8b594c1e0a..5c4d0c3c41b6d 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -330,39 +330,30 @@ func (s *MethodTestSuite) TestAuditLogs() { } func (s *MethodTestSuite) TestConnectionLogs() { - s.Run("InsertConnectionLog", s.Subtest(func(db database.Store, check *expects) { + createWorkspace := func(t *testing.T, db database.Store) database.WorkspaceTable { u := dbgen.User(s.T(), db, database.User{}) o := dbgen.Organization(s.T(), db, database.Organization{}) tpl := dbgen.Template(s.T(), db, database.Template{ OrganizationID: o.ID, CreatedBy: u.ID, }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ + return dbgen.Workspace(s.T(), db, database.WorkspaceTable{ ID: uuid.New(), OwnerID: u.ID, OrganizationID: o.ID, AutomaticUpdates: database.AutomaticUpdatesNever, TemplateID: tpl.ID, }) + } + s.Run("InsertConnectionLog", s.Subtest(func(db database.Store, check *expects) { + ws := createWorkspace(s.T(), db) check.Args(database.InsertConnectionLogParams{ Action: database.ConnectionActionConnect, WorkspaceID: ws.ID, }).Asserts(rbac.ResourceConnectionLog, policy.ActionCreate) })) s.Run("GetConnectionLogsOffset", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - tpl := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - ID: uuid.New(), - OwnerID: u.ID, - OrganizationID: o.ID, - AutomaticUpdates: database.AutomaticUpdatesNever, - TemplateID: tpl.ID, - }) + ws := createWorkspace(s.T(), db) _ = dbgen.ConnectionLog(s.T(), db, database.ConnectionLog{ Action: database.ConnectionActionConnect, WorkspaceID: ws.ID, @@ -376,19 +367,7 @@ func (s *MethodTestSuite) TestConnectionLogs() { }).Asserts(rbac.ResourceConnectionLog, policy.ActionRead).WithNotAuthorized("nil") })) s.Run("GetAuthorizedConnectionLogsOffset", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - tpl := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - ID: uuid.New(), - OwnerID: u.ID, - OrganizationID: o.ID, - AutomaticUpdates: database.AutomaticUpdatesNever, - TemplateID: tpl.ID, - }) + ws := createWorkspace(s.T(), db) _ = dbgen.ConnectionLog(s.T(), db, database.ConnectionLog{ Action: database.ConnectionActionConnect, WorkspaceID: ws.ID, diff --git a/coderd/database/migrations/000334_connection_logs.up.sql b/coderd/database/migrations/000334_connection_logs.up.sql index a87466d3a3428..f51222c3cfcfa 100644 --- a/coderd/database/migrations/000334_connection_logs.up.sql +++ b/coderd/database/migrations/000334_connection_logs.up.sql @@ -1,26 +1,26 @@ CREATE TYPE connection_action AS ENUM ( - -- SSH actions - 'connect', - 'disconnect', - -- Workspace App actions - 'open', - 'close' + -- SSH actions + 'connect', + 'disconnect', + -- Workspace App actions + 'open', + 'close' ); CREATE TABLE connection_logs ( - id uuid NOT NULL, - "time" timestamp with time zone NOT NULL, - organization_id uuid NOT NULL, - workspace_owner_id uuid NOT NULL, - workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE SET NULL, - workspace_name text NOT NULL, - agent_name text NOT NULL, - action connection_action NOT NULL, - code integer NOT NULL, + id uuid NOT NULL, + "time" timestamp with time zone NOT NULL, + organization_id uuid NOT NULL, + workspace_owner_id uuid NOT NULL, + workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE SET NULL, + workspace_name text NOT NULL, + agent_name text NOT NULL, + action connection_action NOT NULL, + code integer NOT NULL, ip inet, -- Null for SSH actions. - user_agent text, + user_agent text, user_id uuid NOT NULL, -- Can be NULL, but must be uuid.Nil. slug_or_port text, @@ -28,7 +28,7 @@ CREATE TABLE connection_logs ( connection_type text, reason text, - PRIMARY KEY (id) + PRIMARY KEY (id) ); COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code for the workspace app request, or the exit code of an SSH connection.'; diff --git a/coderd/database/queries/connectionlogs.sql b/coderd/database/queries/connectionlogs.sql index 26818506c4eb5..65aacecd175d0 100644 --- a/coderd/database/queries/connectionlogs.sql +++ b/coderd/database/queries/connectionlogs.sql @@ -16,9 +16,9 @@ WHERE TRUE ORDER BY "time" DESC LIMIT - -- a limit of 0 means "no limit". The connection log table is unbounded - -- in size, and is expected to be quite large. Implement a default - -- limit of 100 to prevent accidental excessively large queries. + -- a limit of 0 means "no limit". The connection log table is unbounded + -- in size, and is expected to be quite large. Implement a default + -- limit of 100 to prevent accidental excessively large queries. COALESCE(NULLIF(@limit_opt :: int, 0), 100) OFFSET @offset_opt; @@ -27,9 +27,9 @@ OFFSET -- name: InsertConnectionLog :one INSERT INTO connection_logs ( - id, - "time", - organization_id, + id, + "time", + organization_id, workspace_owner_id, workspace_id, workspace_name, @@ -42,6 +42,6 @@ INSERT INTO slug_or_port, connection_type, reason - ) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9b93bb20083be..a536d20bba4a0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -937,6 +937,7 @@ export type FeatureName = | "appearance" | "audit_log" | "browser_only" + | "connection_log" | "control_shared_ports" | "custom_roles" | "external_provisioner_daemons" @@ -958,6 +959,7 @@ export const FeatureNames: FeatureName[] = [ "appearance", "audit_log", "browser_only", + "connection_log", "control_shared_ports", "custom_roles", "external_provisioner_daemons", From 4475ee493d2c76d9080f94b519fa3dd694cbc40b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 12 Jun 2025 07:38:40 +0000 Subject: [PATCH 03/16] comment --- coderd/agentapi/connectionlog.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/agentapi/connectionlog.go b/coderd/agentapi/connectionlog.go index d3d780d97f8da..0098111c4336e 100644 --- a/coderd/agentapi/connectionlog.go +++ b/coderd/agentapi/connectionlog.go @@ -69,7 +69,7 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor // It's not possible to tell which user connected. Once we have // the capability, this may be reported by the agent. - UserID: uuid.Nil, // We don't have the user ID in the connection request. + UserID: uuid.Nil, }) if err != nil { return nil, xerrors.Errorf("export connection log: %w", err) From f7c656a241a2bcb98dc5b883a181b627569e10e4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 12 Jun 2025 07:56:09 +0000 Subject: [PATCH 04/16] comment --- coderd/database/dump.sql | 2 ++ .../migrations/000334_connection_logs.up.sql | 2 ++ coderd/database/models.go | 1 + coderd/database/queries.sql.go | 14 +++++++------- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 74130890b3c27..429fc01a5e6d1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -37,6 +37,8 @@ CREATE TYPE audit_action AS ENUM ( 'close' ); +COMMENT ON TYPE audit_action IS 'NOTE: `connect`, `disconnect`, `open`, and `close` are deprecated and no longer used - these events are now tracked in the connection_logs table.'; + CREATE TYPE automatic_updates AS ENUM ( 'always', 'never' diff --git a/coderd/database/migrations/000334_connection_logs.up.sql b/coderd/database/migrations/000334_connection_logs.up.sql index f51222c3cfcfa..f4f338fc48ab6 100644 --- a/coderd/database/migrations/000334_connection_logs.up.sql +++ b/coderd/database/migrations/000334_connection_logs.up.sql @@ -41,6 +41,8 @@ COMMENT ON COLUMN connection_logs.connection_type IS 'Null for Workspace App act COMMENT ON COLUMN connection_logs.reason IS 'Null for Workspace App actions. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI.'; +COMMENT ON TYPE audit_action IS 'NOTE: `connect`, `disconnect`, `open`, and `close` are deprecated and no longer used - these events are now tracked in the connection_logs table.'; + CREATE INDEX idx_connection_logs_time_desc ON connection_logs USING btree ("time" DESC); CREATE INDEX idx_connection_logs_organization_id ON connection_logs USING btree (organization_id); CREATE INDEX idx_connection_logs_workspace_owner_id ON connection_logs USING btree (workspace_owner_id); diff --git a/coderd/database/models.go b/coderd/database/models.go index 8cd9e02bc5503..89c38a070d359 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -193,6 +193,7 @@ func AllAppSharingLevelValues() []AppSharingLevel { } } +// NOTE: `connect`, `disconnect`, `open`, and `close` are deprecated and no longer used - these events are now tracked in the connection_logs table. type AuditAction string const ( diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e38cfa1268b64..24801dbe17b92 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -985,9 +985,9 @@ WHERE TRUE ORDER BY "time" DESC LIMIT - -- a limit of 0 means "no limit". The connection log table is unbounded - -- in size, and is expected to be quite large. Implement a default - -- limit of 100 to prevent accidental excessively large queries. + -- a limit of 0 means "no limit". The connection log table is unbounded + -- in size, and is expected to be quite large. Implement a default + -- limit of 100 to prevent accidental excessively large queries. COALESCE(NULLIF($2 :: int, 0), 100) OFFSET $1 @@ -1050,9 +1050,9 @@ func (q *sqlQuerier) GetConnectionLogsOffset(ctx context.Context, arg GetConnect const insertConnectionLog = `-- name: InsertConnectionLog :one INSERT INTO connection_logs ( - id, - "time", - organization_id, + id, + "time", + organization_id, workspace_owner_id, workspace_id, workspace_name, @@ -1065,7 +1065,7 @@ INSERT INTO slug_or_port, connection_type, reason - ) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, time, organization_id, workspace_owner_id, workspace_id, workspace_name, agent_name, action, code, ip, user_agent, user_id, slug_or_port, connection_type, reason ` From 43bd52f6bab1b7396ff41e203345a9d7089a08a5 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 12 Jun 2025 09:23:56 +0000 Subject: [PATCH 05/16] add back request id as connection id --- coderd/agentapi/connectionlog.go | 8 ++++++++ coderd/agentapi/connectionlog_test.go | 1 + coderd/connectionlog/connectionlog.go | 4 ++++ coderd/database/dump.sql | 3 +++ coderd/database/migrations/000334_connection_logs.up.sql | 3 +++ .../testdata/fixtures/000334_connection_logs.up.sql | 3 +++ coderd/database/modelqueries.go | 1 + coderd/database/models.go | 6 ++++-- coderd/database/queries.sql.go | 9 +++++++-- coderd/database/queries/connectionlogs.sql | 3 ++- coderd/workspaceapps/db.go | 3 +++ 11 files changed, 39 insertions(+), 5 deletions(-) diff --git a/coderd/agentapi/connectionlog.go b/coderd/agentapi/connectionlog.go index 0098111c4336e..8a914dfc28857 100644 --- a/coderd/agentapi/connectionlog.go +++ b/coderd/agentapi/connectionlog.go @@ -26,6 +26,13 @@ type ConnLogAPI struct { } func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.ReportConnectionRequest) (*emptypb.Empty, error) { + // We will use connection ID as request ID, typically this is the + // SSH session ID as reported by the agent. + connectionID, err := uuid.FromBytes(req.GetConnection().GetId()) + if err != nil { + return nil, xerrors.Errorf("connection id from bytes: %w", err) + } + action, err := db2sdk.ConnectionLogActionFromAgentProtoConnectionAction(req.GetConnection().GetAction()) if err != nil { return nil, err @@ -50,6 +57,7 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor err = connLogger.Export(ctx, database.ConnectionLog{ ID: uuid.New(), Time: req.GetConnection().GetTimestamp().AsTime(), + ConnectionID: connectionID, OrganizationID: workspace.OrganizationID, WorkspaceOwnerID: workspace.OwnerID, WorkspaceID: workspace.ID, diff --git a/coderd/agentapi/connectionlog_test.go b/coderd/agentapi/connectionlog_test.go index 6e984fc75218b..44df3305675c9 100644 --- a/coderd/agentapi/connectionlog_test.go +++ b/coderd/agentapi/connectionlog_test.go @@ -132,6 +132,7 @@ func TestConnectionLog(t *testing.T) { require.True(t, connLogger.Contains(t, database.ConnectionLog{ Time: dbtime.Time(tt.time).In(time.UTC), + ConnectionID: tt.id, OrganizationID: workspace.OrganizationID, WorkspaceOwnerID: workspace.OwnerID, WorkspaceID: workspace.ID, diff --git a/coderd/connectionlog/connectionlog.go b/coderd/connectionlog/connectionlog.go index 00845b07d8a4b..b70d44866d98c 100644 --- a/coderd/connectionlog/connectionlog.go +++ b/coderd/connectionlog/connectionlog.go @@ -66,6 +66,10 @@ func (m *MockConnectionLogger) Contains(t testing.TB, expected database.Connecti t.Logf("connection log %d: expected Time %s, got %s", idx+1, expected.Time, cl.Time) continue } + if expected.ConnectionID != uuid.Nil && cl.ConnectionID != expected.ConnectionID { + t.Logf("connection log %d: expected ConnectionID %s, got %s", idx+1, expected.ConnectionID, cl.ConnectionID) + continue + } if expected.OrganizationID != uuid.Nil && cl.OrganizationID != expected.OrganizationID { t.Logf("connection log %d: expected OrganizationID %s, got %s", idx+1, expected.OrganizationID, cl.OrganizationID) continue diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 429fc01a5e6d1..869f8a85ecb28 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -857,6 +857,7 @@ CREATE TABLE chats ( CREATE TABLE connection_logs ( id uuid NOT NULL, "time" timestamp with time zone NOT NULL, + connection_id uuid NOT NULL, organization_id uuid NOT NULL, workspace_owner_id uuid NOT NULL, workspace_id uuid NOT NULL, @@ -872,6 +873,8 @@ CREATE TABLE connection_logs ( reason text ); +COMMENT ON COLUMN connection_logs.connection_id IS 'Either the workspace app request ID or the SSH connection ID. Used to correlate connections and disconnections.'; + COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code for the workspace app request, or the exit code of an SSH connection.'; COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For workspace apps, this is the User-Agent header from the request.'; diff --git a/coderd/database/migrations/000334_connection_logs.up.sql b/coderd/database/migrations/000334_connection_logs.up.sql index f4f338fc48ab6..a29c99d05cf7c 100644 --- a/coderd/database/migrations/000334_connection_logs.up.sql +++ b/coderd/database/migrations/000334_connection_logs.up.sql @@ -10,6 +10,7 @@ CREATE TYPE connection_action AS ENUM ( CREATE TABLE connection_logs ( id uuid NOT NULL, "time" timestamp with time zone NOT NULL, + connection_id uuid NOT NULL, organization_id uuid NOT NULL, workspace_owner_id uuid NOT NULL, workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE SET NULL, @@ -31,6 +32,8 @@ CREATE TABLE connection_logs ( PRIMARY KEY (id) ); +COMMENT ON COLUMN connection_logs.connection_id IS 'Either the workspace app request ID or the SSH connection ID. Used to correlate connections and disconnections.'; + COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code for the workspace app request, or the exit code of an SSH connection.'; COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For workspace apps, this is the User-Agent header from the request.'; diff --git a/coderd/database/migrations/testdata/fixtures/000334_connection_logs.up.sql b/coderd/database/migrations/testdata/fixtures/000334_connection_logs.up.sql index 16ec968f911b8..54c5dfdd42cd8 100644 --- a/coderd/database/migrations/testdata/fixtures/000334_connection_logs.up.sql +++ b/coderd/database/migrations/testdata/fixtures/000334_connection_logs.up.sql @@ -1,6 +1,7 @@ INSERT INTO connection_logs ( id, "time", + connection_id, organization_id, workspace_owner_id, workspace_id, @@ -17,6 +18,7 @@ INSERT INTO connection_logs ( ) VALUES ( '00000000-0000-0000-0000-000000000001', -- log id '2023-10-01 12:00:00+00', + '00000000-0000-0000-0000-000000000003', -- connection id '00000000-0000-0000-0000-000000000020', -- organization id '00000000-0000-0000-0000-000000000030', -- workspace owner id '3a9a1feb-e89d-457c-9d53-ac751b198ebe', -- workspace id @@ -34,6 +36,7 @@ INSERT INTO connection_logs ( ( '00000000-0000-0000-0000-000000000002', -- log id '2023-10-01 12:05:00+00', + '00000000-0000-0000-0000-000000000004', -- connection id (request ID) '00000000-0000-0000-0000-000000000020', -- organization id '00000000-0000-0000-0000-000000000030', -- workspace owner id '3a9a1feb-e89d-457c-9d53-ac751b198ebe', -- workspace id diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 47b24c5e38bb8..c9b10b9d22b08 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -562,6 +562,7 @@ func (q *sqlQuerier) GetAuthorizedConnectionLogsOffset(ctx context.Context, arg if err := rows.Scan( &i.ConnectionLog.ID, &i.ConnectionLog.Time, + &i.ConnectionLog.ConnectionID, &i.ConnectionLog.OrganizationID, &i.ConnectionLog.WorkspaceOwnerID, &i.ConnectionLog.WorkspaceID, diff --git a/coderd/database/models.go b/coderd/database/models.go index 89c38a070d359..d07a37be05215 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2858,8 +2858,10 @@ type ChatMessage struct { } type ConnectionLog struct { - ID uuid.UUID `db:"id" json:"id"` - Time time.Time `db:"time" json:"time"` + ID uuid.UUID `db:"id" json:"id"` + Time time.Time `db:"time" json:"time"` + // Either the workspace app request ID or the SSH connection ID. Used to correlate connections and disconnections. + ConnectionID uuid.UUID `db:"connection_id" json:"connection_id"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"` WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 24801dbe17b92..e0f4fa953934a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -969,7 +969,7 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam const getConnectionLogsOffset = `-- name: GetConnectionLogsOffset :many SELECT - connection_logs.id, connection_logs.time, connection_logs.organization_id, connection_logs.workspace_owner_id, connection_logs.workspace_id, connection_logs.workspace_name, connection_logs.agent_name, connection_logs.action, connection_logs.code, connection_logs.ip, connection_logs.user_agent, connection_logs.user_id, connection_logs.slug_or_port, connection_logs.connection_type, connection_logs.reason, + connection_logs.id, connection_logs.time, connection_logs.connection_id, connection_logs.organization_id, connection_logs.workspace_owner_id, connection_logs.workspace_id, connection_logs.workspace_name, connection_logs.agent_name, connection_logs.action, connection_logs.code, connection_logs.ip, connection_logs.user_agent, connection_logs.user_id, connection_logs.slug_or_port, connection_logs.connection_type, connection_logs.reason, users.username AS user_username, workspace_owner.username AS workspace_owner_username, COUNT(connection_logs.*) OVER () AS count @@ -1017,6 +1017,7 @@ func (q *sqlQuerier) GetConnectionLogsOffset(ctx context.Context, arg GetConnect if err := rows.Scan( &i.ConnectionLog.ID, &i.ConnectionLog.Time, + &i.ConnectionLog.ConnectionID, &i.ConnectionLog.OrganizationID, &i.ConnectionLog.WorkspaceOwnerID, &i.ConnectionLog.WorkspaceID, @@ -1052,6 +1053,7 @@ INSERT INTO connection_logs ( id, "time", + connection_id, organization_id, workspace_owner_id, workspace_id, @@ -1067,12 +1069,13 @@ INSERT INTO reason ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, time, organization_id, workspace_owner_id, workspace_id, workspace_name, agent_name, action, code, ip, user_agent, user_id, slug_or_port, connection_type, reason + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id, time, connection_id, organization_id, workspace_owner_id, workspace_id, workspace_name, agent_name, action, code, ip, user_agent, user_id, slug_or_port, connection_type, reason ` type InsertConnectionLogParams struct { ID uuid.UUID `db:"id" json:"id"` Time time.Time `db:"time" json:"time"` + ConnectionID uuid.UUID `db:"connection_id" json:"connection_id"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"` WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` @@ -1092,6 +1095,7 @@ func (q *sqlQuerier) InsertConnectionLog(ctx context.Context, arg InsertConnecti row := q.db.QueryRowContext(ctx, insertConnectionLog, arg.ID, arg.Time, + arg.ConnectionID, arg.OrganizationID, arg.WorkspaceOwnerID, arg.WorkspaceID, @@ -1110,6 +1114,7 @@ func (q *sqlQuerier) InsertConnectionLog(ctx context.Context, arg InsertConnecti err := row.Scan( &i.ID, &i.Time, + &i.ConnectionID, &i.OrganizationID, &i.WorkspaceOwnerID, &i.WorkspaceID, diff --git a/coderd/database/queries/connectionlogs.sql b/coderd/database/queries/connectionlogs.sql index 65aacecd175d0..36f92499481cf 100644 --- a/coderd/database/queries/connectionlogs.sql +++ b/coderd/database/queries/connectionlogs.sql @@ -29,6 +29,7 @@ INSERT INTO connection_logs ( id, "time", + connection_id, organization_id, workspace_owner_id, workspace_id, @@ -44,4 +45,4 @@ INSERT INTO reason ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING *; diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index d0c0a2e2f2361..39db141427a16 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -477,10 +477,13 @@ func (p *DBTokenProvider) connLogInitRequest(ctx context.Context, w http.Respons // didn't timeout due to inactivity. return } + + requestID := httpmw.RequestID(r) connLogger := *p.ConnectionLogger.Load() err = connLogger.Export(ctx, database.ConnectionLog{ ID: uuid.New(), Time: aReq.time, + ConnectionID: requestID, OrganizationID: aReq.dbReq.Workspace.OrganizationID, WorkspaceOwnerID: aReq.dbReq.Workspace.OwnerID, WorkspaceID: aReq.dbReq.Workspace.ID, From c2ae96eac5d578f9aab082a55b317be7c7b13dce Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 12 Jun 2025 09:27:59 +0000 Subject: [PATCH 06/16] lint --- coderd/database/dbgen/dbgen.go | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 154e2336a9d51..c927c188ccd24 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -77,6 +77,7 @@ func ConnectionLog(t testing.TB, db database.Store, seed database.ConnectionLog) log, err := db.InsertConnectionLog(genCtx, database.InsertConnectionLogParams{ ID: takeFirst(seed.ID, uuid.New()), Time: takeFirst(seed.Time, dbtime.Now()), + ConnectionID: takeFirst(seed.ConnectionID, uuid.New()), OrganizationID: takeFirst(seed.OrganizationID, uuid.New()), WorkspaceOwnerID: takeFirst(seed.WorkspaceOwnerID, uuid.New()), WorkspaceID: takeFirst(seed.WorkspaceID, uuid.New()), From 8bc1a6fa87235a794ed458d684b0865cf72d7d17 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 13 Jun 2025 01:49:29 +0000 Subject: [PATCH 07/16] review --- coderd/agentapi/connectionlog.go | 10 ++- coderd/agentapi/connectionlog_test.go | 11 ++- coderd/connectionlog/connectionlog.go | 4 +- coderd/database/db2sdk/db2sdk.go | 17 +++++ coderd/database/dbauthz/dbauthz.go | 3 +- coderd/database/dbgen/dbgen.go | 8 +-- coderd/database/dump.sql | 12 +++- .../000334_connection_logs.down.sql | 2 + .../migrations/000334_connection_logs.up.sql | 13 +++- coderd/database/models.go | 71 ++++++++++++++++++- coderd/database/queries.sql.go | 35 ++++----- coderd/database/queries/connectionlogs.sql | 3 +- .../coderd/connectionlog/connectionlog.go | 3 +- 13 files changed, 146 insertions(+), 46 deletions(-) diff --git a/coderd/agentapi/connectionlog.go b/coderd/agentapi/connectionlog.go index 8a914dfc28857..853aefdfacdec 100644 --- a/coderd/agentapi/connectionlog.go +++ b/coderd/agentapi/connectionlog.go @@ -10,12 +10,10 @@ import ( "google.golang.org/protobuf/types/known/emptypb" "cdr.dev/slog" - agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/connectionlog" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" - "github.com/coder/coder/v2/codersdk/agentsdk" ) type ConnLogAPI struct { @@ -37,7 +35,7 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor if err != nil { return nil, err } - connectionType, err := agentsdk.ConnectionTypeFromProto(req.GetConnection().GetType()) + connectionType, err := db2sdk.ConnectionLogConnectionTypeEnumFromAgentProtoConnectionType(req.GetConnection().GetType()) if err != nil { return nil, err } @@ -66,9 +64,9 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor Action: action, Code: req.GetConnection().GetStatusCode(), Ip: database.ParseIP(req.GetConnection().GetIp()), - ConnectionType: sql.NullString{ - String: string(connectionType), - Valid: true, + ConnectionType: database.NullConnectionTypeEnum{ + ConnectionTypeEnum: connectionType, + Valid: true, }, Reason: sql.NullString{ String: reason, diff --git a/coderd/agentapi/connectionlog_test.go b/coderd/agentapi/connectionlog_test.go index 44df3305675c9..a6663512e0b75 100644 --- a/coderd/agentapi/connectionlog_test.go +++ b/coderd/agentapi/connectionlog_test.go @@ -21,7 +21,6 @@ import ( "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtime" - "github.com/coder/coder/v2/codersdk/agentsdk" ) func TestConnectionLog(t *testing.T) { @@ -143,9 +142,9 @@ func TestConnectionLog(t *testing.T) { Code: tt.status, Ip: pqtype.Inet{Valid: true, IPNet: net.IPNet{IP: net.ParseIP(tt.ip), Mask: net.CIDRMask(32, 32)}}, - ConnectionType: sql.NullString{ - String: string(agentProtoConnectionTypeToSDK(t, *tt.typ)), - Valid: true, + ConnectionType: database.NullConnectionTypeEnum{ + ConnectionTypeEnum: agentProtoConnectionTypeToConnectionLog(t, *tt.typ), + Valid: true, }, Reason: sql.NullString{ String: tt.reason, @@ -162,8 +161,8 @@ func agentProtoConnectionActionToConnectionLog(t *testing.T, action agentproto.C return a } -func agentProtoConnectionTypeToSDK(t *testing.T, typ agentproto.Connection_Type) agentsdk.ConnectionType { - action, err := agentsdk.ConnectionTypeFromProto(typ) +func agentProtoConnectionTypeToConnectionLog(t *testing.T, typ agentproto.Connection_Type) database.ConnectionTypeEnum { + action, err := db2sdk.ConnectionLogConnectionTypeEnumFromAgentProtoConnectionType(typ) require.NoError(t, err) return action } diff --git a/coderd/connectionlog/connectionlog.go b/coderd/connectionlog/connectionlog.go index b70d44866d98c..faca6dd3b6754 100644 --- a/coderd/connectionlog/connectionlog.go +++ b/coderd/connectionlog/connectionlog.go @@ -114,8 +114,8 @@ func (m *MockConnectionLogger) Contains(t testing.TB, expected database.Connecti t.Logf("connection log %d: expected UserID %s, got %s", idx+1, expected.UserID, cl.UserID) continue } - if expected.ConnectionType.Valid && cl.ConnectionType != expected.ConnectionType { - t.Logf("connection log %d: expected ConnectionType %s, got %s", idx+1, expected.ConnectionType.String, cl.ConnectionType.String) + if expected.ConnectionType.Valid && cl.ConnectionType.ConnectionTypeEnum != expected.ConnectionType.ConnectionTypeEnum { + t.Logf("connection log %d: expected ConnectionType %s, got %s", idx+1, expected.ConnectionType.ConnectionTypeEnum, cl.ConnectionType.ConnectionTypeEnum) continue } if expected.Reason.Valid && cl.Reason != expected.Reason { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 995089fdd6133..ba876d6d64d64 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -739,6 +739,23 @@ func ConnectionLogActionFromAgentProtoConnectionAction(action agentproto.Connect } } +func ConnectionLogConnectionTypeEnumFromAgentProtoConnectionType(typ agentproto.Connection_Type) (database.ConnectionTypeEnum, error) { + switch typ { + case agentproto.Connection_SSH: + return database.ConnectionTypeEnumSsh, nil + case agentproto.Connection_JETBRAINS: + return database.ConnectionTypeEnumJetbrains, nil + case agentproto.Connection_VSCODE: + return database.ConnectionTypeEnumVscode, nil + case agentproto.Connection_RECONNECTING_PTY: + return database.ConnectionTypeEnumReconnectingPty, nil + case agentproto.Connection_TYPE_UNSPECIFIED: + return database.ConnectionTypeEnumUnspecified, nil + default: + return "", xerrors.Errorf("unknown agent connection type %q", typ) + } +} + func AgentProtoConnectionActionToAuditAction(action database.AuditAction) (agentproto.Connection_Action, error) { switch action { case database.AuditActionConnect: diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f7f49aa9141dc..0fd2783cba554 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1785,8 +1785,7 @@ func (q *querier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]d } func (q *querier) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) { - // Shortcut if the user is an owner. The SQL filter is noticeable, - // and this is an easy win for owners. Which is the common case. + // Just like with the audit logs query, shortcut if the user is an owner. err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog) if err == nil { return q.db.GetConnectionLogsOffset(ctx, arg) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index c927c188ccd24..c7625ea88384c 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -98,10 +98,10 @@ func ConnectionLog(t testing.TB, db database.Store, seed database.ConnectionLog) String: takeFirst(seed.SlugOrPort.String, ""), Valid: takeFirst(seed.SlugOrPort.Valid, false), }, - ConnectionType: sql.NullString{ - String: takeFirst(seed.ConnectionType.String, ""), - Valid: takeFirst(seed.ConnectionType.Valid, false), - }, + ConnectionType: takeFirst(seed.ConnectionType, database.NullConnectionTypeEnum{ + ConnectionTypeEnum: database.ConnectionTypeEnumSsh, + Valid: true, + }), Reason: sql.NullString{ String: takeFirst(seed.Reason.String, ""), Valid: takeFirst(seed.Reason.Valid, false), diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 869f8a85ecb28..cecbf87571caa 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -60,6 +60,14 @@ CREATE TYPE connection_action AS ENUM ( 'close' ); +CREATE TYPE connection_type_enum AS ENUM ( + 'ssh', + 'vscode', + 'jetbrains', + 'reconnecting_pty', + 'unspecified' +); + CREATE TYPE crypto_key_feature AS ENUM ( 'workspace_apps_token', 'workspace_apps_api_key', @@ -869,7 +877,7 @@ CREATE TABLE connection_logs ( user_agent text, user_id uuid NOT NULL, slug_or_port text, - connection_type text, + connection_type connection_type_enum, reason text ); @@ -881,7 +889,7 @@ COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For works COMMENT ON COLUMN connection_logs.user_id IS 'uuid.Nil for SSH actions. For workspace apps, this is the ID of the user that made the request.'; -COMMENT ON COLUMN connection_logs.connection_type IS 'Null for Workspace App actions. For SSH actions, this is the type of connection (e.g., "ssh", "websocket").'; +COMMENT ON COLUMN connection_logs.connection_type IS 'Null for Workspace App actions. For SSH actions, this is the type of connection (e.g., "SSH", "VS Code").'; COMMENT ON COLUMN connection_logs.reason IS 'Null for Workspace App actions. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI.'; diff --git a/coderd/database/migrations/000334_connection_logs.down.sql b/coderd/database/migrations/000334_connection_logs.down.sql index 2a39d8fbd1061..9f9cdc590e8aa 100644 --- a/coderd/database/migrations/000334_connection_logs.down.sql +++ b/coderd/database/migrations/000334_connection_logs.down.sql @@ -6,3 +6,5 @@ DROP INDEX IF EXISTS idx_connection_logs_time_desc; DROP TABLE IF EXISTS connection_logs; DROP TYPE IF EXISTS connection_action; + +DROP TYPE IF EXISTS connection_type_enum; diff --git a/coderd/database/migrations/000334_connection_logs.up.sql b/coderd/database/migrations/000334_connection_logs.up.sql index a29c99d05cf7c..62bd2f5a0cbe1 100644 --- a/coderd/database/migrations/000334_connection_logs.up.sql +++ b/coderd/database/migrations/000334_connection_logs.up.sql @@ -7,6 +7,15 @@ CREATE TYPE connection_action AS ENUM ( 'close' ); +-- Mirrors `Connection.Type` in `agent.Proto` / agentSDK.ConnectionType` +CREATE TYPE connection_type_enum AS ENUM ( + 'ssh', + 'vscode', + 'jetbrains', + 'reconnecting_pty', + 'unspecified' +); + CREATE TABLE connection_logs ( id uuid NOT NULL, "time" timestamp with time zone NOT NULL, @@ -26,7 +35,7 @@ CREATE TABLE connection_logs ( slug_or_port text, -- Null for Workspace App actions. - connection_type text, + connection_type connection_type_enum, reason text, PRIMARY KEY (id) @@ -40,7 +49,7 @@ COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For works COMMENT ON COLUMN connection_logs.user_id IS 'uuid.Nil for SSH actions. For workspace apps, this is the ID of the user that made the request.'; -COMMENT ON COLUMN connection_logs.connection_type IS 'Null for Workspace App actions. For SSH actions, this is the type of connection (e.g., "ssh", "websocket").'; +COMMENT ON COLUMN connection_logs.connection_type IS 'Null for Workspace App actions. For SSH actions, this is the type of connection (e.g., "SSH", "VS Code").'; COMMENT ON COLUMN connection_logs.reason IS 'Null for Workspace App actions. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI.'; diff --git a/coderd/database/models.go b/coderd/database/models.go index d07a37be05215..00fd2dee1e68b 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -477,6 +477,73 @@ func AllConnectionActionValues() []ConnectionAction { } } +type ConnectionTypeEnum string + +const ( + ConnectionTypeEnumSsh ConnectionTypeEnum = "ssh" + ConnectionTypeEnumVscode ConnectionTypeEnum = "vscode" + ConnectionTypeEnumJetbrains ConnectionTypeEnum = "jetbrains" + ConnectionTypeEnumReconnectingPty ConnectionTypeEnum = "reconnecting_pty" + ConnectionTypeEnumUnspecified ConnectionTypeEnum = "unspecified" +) + +func (e *ConnectionTypeEnum) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ConnectionTypeEnum(s) + case string: + *e = ConnectionTypeEnum(s) + default: + return fmt.Errorf("unsupported scan type for ConnectionTypeEnum: %T", src) + } + return nil +} + +type NullConnectionTypeEnum struct { + ConnectionTypeEnum ConnectionTypeEnum `json:"connection_type_enum"` + Valid bool `json:"valid"` // Valid is true if ConnectionTypeEnum is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullConnectionTypeEnum) Scan(value interface{}) error { + if value == nil { + ns.ConnectionTypeEnum, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ConnectionTypeEnum.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullConnectionTypeEnum) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ConnectionTypeEnum), nil +} + +func (e ConnectionTypeEnum) Valid() bool { + switch e { + case ConnectionTypeEnumSsh, + ConnectionTypeEnumVscode, + ConnectionTypeEnumJetbrains, + ConnectionTypeEnumReconnectingPty, + ConnectionTypeEnumUnspecified: + return true + } + return false +} + +func AllConnectionTypeEnumValues() []ConnectionTypeEnum { + return []ConnectionTypeEnum{ + ConnectionTypeEnumSsh, + ConnectionTypeEnumVscode, + ConnectionTypeEnumJetbrains, + ConnectionTypeEnumReconnectingPty, + ConnectionTypeEnumUnspecified, + } +} + type CryptoKeyFeature string const ( @@ -2876,8 +2943,8 @@ type ConnectionLog struct { // uuid.Nil for SSH actions. For workspace apps, this is the ID of the user that made the request. UserID uuid.UUID `db:"user_id" json:"user_id"` SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"` - // Null for Workspace App actions. For SSH actions, this is the type of connection (e.g., "ssh", "websocket"). - ConnectionType sql.NullString `db:"connection_type" json:"connection_type"` + // Null for Workspace App actions. For SSH actions, this is the type of connection (e.g., "SSH", "VS Code"). + ConnectionType NullConnectionTypeEnum `db:"connection_type" json:"connection_type"` // Null for Workspace App actions. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI. Reason sql.NullString `db:"reason" json:"reason"` } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e0f4fa953934a..ee213fcd84139 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -975,7 +975,8 @@ SELECT COUNT(connection_logs.*) OVER () AS count FROM connection_logs -LEFT JOIN users ON connection_logs.user_id = users.id +LEFT JOIN users ON + connection_logs.user_id = users.id LEFT JOIN users as workspace_owner ON connection_logs.workspace_owner_id = workspace_owner.id WHERE TRUE @@ -1073,22 +1074,22 @@ VALUES ` type InsertConnectionLogParams struct { - ID uuid.UUID `db:"id" json:"id"` - Time time.Time `db:"time" json:"time"` - ConnectionID uuid.UUID `db:"connection_id" json:"connection_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"` - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` - WorkspaceName string `db:"workspace_name" json:"workspace_name"` - AgentName string `db:"agent_name" json:"agent_name"` - Action ConnectionAction `db:"action" json:"action"` - Code int32 `db:"code" json:"code"` - Ip pqtype.Inet `db:"ip" json:"ip"` - UserAgent sql.NullString `db:"user_agent" json:"user_agent"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"` - ConnectionType sql.NullString `db:"connection_type" json:"connection_type"` - Reason sql.NullString `db:"reason" json:"reason"` + ID uuid.UUID `db:"id" json:"id"` + Time time.Time `db:"time" json:"time"` + ConnectionID uuid.UUID `db:"connection_id" json:"connection_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + WorkspaceName string `db:"workspace_name" json:"workspace_name"` + AgentName string `db:"agent_name" json:"agent_name"` + Action ConnectionAction `db:"action" json:"action"` + Code int32 `db:"code" json:"code"` + Ip pqtype.Inet `db:"ip" json:"ip"` + UserAgent sql.NullString `db:"user_agent" json:"user_agent"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"` + ConnectionType NullConnectionTypeEnum `db:"connection_type" json:"connection_type"` + Reason sql.NullString `db:"reason" json:"reason"` } func (q *sqlQuerier) InsertConnectionLog(ctx context.Context, arg InsertConnectionLogParams) (ConnectionLog, error) { diff --git a/coderd/database/queries/connectionlogs.sql b/coderd/database/queries/connectionlogs.sql index 36f92499481cf..49dc37ef12034 100644 --- a/coderd/database/queries/connectionlogs.sql +++ b/coderd/database/queries/connectionlogs.sql @@ -6,7 +6,8 @@ SELECT COUNT(connection_logs.*) OVER () AS count FROM connection_logs -LEFT JOIN users ON connection_logs.user_id = users.id +LEFT JOIN users ON + connection_logs.user_id = users.id LEFT JOIN users as workspace_owner ON connection_logs.workspace_owner_id = workspace_owner.id WHERE TRUE diff --git a/enterprise/coderd/connectionlog/connectionlog.go b/enterprise/coderd/connectionlog/connectionlog.go index ce83dd0217f1c..87a99a3f4619c 100644 --- a/enterprise/coderd/connectionlog/connectionlog.go +++ b/enterprise/coderd/connectionlog/connectionlog.go @@ -3,10 +3,9 @@ package connectionlog import ( "context" - "cdr.dev/slog" - "github.com/hashicorp/go-multierror" + "cdr.dev/slog" agpl "github.com/coder/coder/v2/coderd/connectionlog" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" From af70a4a19c96c6ebac65ca4e3c7897388202df54 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 13 Jun 2025 01:53:54 +0000 Subject: [PATCH 08/16] docs link changed again --- coderd/database/migrations/migrate_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index ef2fc8745561d..cd843bd97aa7a 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -283,7 +283,7 @@ func TestMigrateUpWithFixtures(t *testing.T) { if len(emptyTables) > 0 { t.Log("The following tables have zero rows, consider adding fixtures for them or create a full database dump:") t.Errorf("tables have zero rows: %v", emptyTables) - t.Log("See https://github.com/coder/coder/blob/main/docs/contributing/backend.md#database-fixtures-for-testing-migrations for more information") + t.Log("See https://github.com/coder/coder/blob/main/docs/about/contributing/backend.md#database-fixtures-for-testing-migrations for more information") } }) From 02f36dd671b1c5c2426451bdd3fec70d4555e7ae Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 13 Jun 2025 02:22:15 +0000 Subject: [PATCH 09/16] migration numbers --- ...4_connection_logs.down.sql => 000335_connection_logs.down.sql} | 0 ...00334_connection_logs.up.sql => 000335_connection_logs.up.sql} | 0 ...00334_connection_logs.up.sql => 000335_connection_logs.up.sql} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000334_connection_logs.down.sql => 000335_connection_logs.down.sql} (100%) rename coderd/database/migrations/{000334_connection_logs.up.sql => 000335_connection_logs.up.sql} (100%) rename coderd/database/migrations/testdata/fixtures/{000334_connection_logs.up.sql => 000335_connection_logs.up.sql} (100%) diff --git a/coderd/database/migrations/000334_connection_logs.down.sql b/coderd/database/migrations/000335_connection_logs.down.sql similarity index 100% rename from coderd/database/migrations/000334_connection_logs.down.sql rename to coderd/database/migrations/000335_connection_logs.down.sql diff --git a/coderd/database/migrations/000334_connection_logs.up.sql b/coderd/database/migrations/000335_connection_logs.up.sql similarity index 100% rename from coderd/database/migrations/000334_connection_logs.up.sql rename to coderd/database/migrations/000335_connection_logs.up.sql diff --git a/coderd/database/migrations/testdata/fixtures/000334_connection_logs.up.sql b/coderd/database/migrations/testdata/fixtures/000335_connection_logs.up.sql similarity index 100% rename from coderd/database/migrations/testdata/fixtures/000334_connection_logs.up.sql rename to coderd/database/migrations/testdata/fixtures/000335_connection_logs.up.sql From cf39fd7f5e6c4a4589a83ad00a4495fb868dcd8c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 13 Jun 2025 13:45:47 +0000 Subject: [PATCH 10/16] use a single row for connections and disconnections --- coderd/agentapi/connectionlog.go | 35 ++-- coderd/agentapi/connectionlog_test.go | 31 ++-- coderd/connectionlog/connectionlog.go | 50 +++--- coderd/database/db2sdk/db2sdk.go | 38 ++--- coderd/database/dbauthz/dbauthz.go | 8 +- coderd/database/dbauthz/dbauthz_test.go | 35 ++-- coderd/database/dbgen/dbgen.go | 25 +-- coderd/database/dbmem/dbmem.go | 77 ++++++--- coderd/database/dbmetrics/querymetrics.go | 14 +- coderd/database/dbmock/dbmock.go | 30 ++-- coderd/database/dump.sql | 42 +++-- coderd/database/foreign_key_constraint.go | 4 +- .../000335_connection_logs.down.sql | 4 +- .../migrations/000335_connection_logs.up.sql | 52 +++--- .../fixtures/000335_connection_logs.up.sql | 40 ++--- coderd/database/modelqueries.go | 8 +- coderd/database/models.go | 107 ++++++------ coderd/database/querier.go | 2 +- coderd/database/querier_test.go | 158 +++++++++++++++++- coderd/database/queries.sql.go | 127 +++++++------- coderd/database/queries/connectionlogs.sql | 55 +++--- coderd/database/unique_constraint.go | 1 + coderd/workspaceapps/db.go | 18 +- coderd/workspaceapps/db_test.go | 15 +- .../coderd/connectionlog/connectionlog.go | 17 +- 25 files changed, 610 insertions(+), 383 deletions(-) diff --git a/coderd/agentapi/connectionlog.go b/coderd/agentapi/connectionlog.go index 853aefdfacdec..0ca783adda2a5 100644 --- a/coderd/agentapi/connectionlog.go +++ b/coderd/agentapi/connectionlog.go @@ -24,18 +24,21 @@ type ConnLogAPI struct { } func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.ReportConnectionRequest) (*emptypb.Empty, error) { - // We will use connection ID as request ID, typically this is the - // SSH session ID as reported by the agent. + // We use the connection ID to identify which connection log event to mark + // as closed, when we receive a close action for that ID. connectionID, err := uuid.FromBytes(req.GetConnection().GetId()) if err != nil { return nil, xerrors.Errorf("connection id from bytes: %w", err) } - action, err := db2sdk.ConnectionLogActionFromAgentProtoConnectionAction(req.GetConnection().GetAction()) + if connectionID == uuid.Nil { + return nil, xerrors.New("connection ID cannot be nil") + } + action, err := db2sdk.ConnectionActionFromAgentProtoConnectionAction(req.GetConnection().GetAction()) if err != nil { return nil, err } - connectionType, err := db2sdk.ConnectionLogConnectionTypeEnumFromAgentProtoConnectionType(req.GetConnection().GetType()) + connectionType, err := db2sdk.ConnectionLogConnectionTypeFromAgentProtoConnectionType(req.GetConnection().GetType()) if err != nil { return nil, err } @@ -52,30 +55,38 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor reason := req.GetConnection().GetReason() connLogger := *a.ConnectionLogger.Load() - err = connLogger.Export(ctx, database.ConnectionLog{ + err = connLogger.Upsert(ctx, database.UpsertConnectionLogParams{ ID: uuid.New(), Time: req.GetConnection().GetTimestamp().AsTime(), - ConnectionID: connectionID, OrganizationID: workspace.OrganizationID, WorkspaceOwnerID: workspace.OwnerID, WorkspaceID: workspace.ID, WorkspaceName: workspace.Name, AgentName: workspaceAgent.Name, - Action: action, + Type: connectionType, Code: req.GetConnection().GetStatusCode(), Ip: database.ParseIP(req.GetConnection().GetIp()), - ConnectionType: database.NullConnectionTypeEnum{ - ConnectionTypeEnum: connectionType, - Valid: true, + ConnectionID: uuid.NullUUID{ + UUID: connectionID, + Valid: true, }, - Reason: sql.NullString{ + CloseReason: sql.NullString{ String: reason, Valid: reason != "", }, + // Used to populate whether the connection was established or closed + // outside of the DB (slog). + ConnectionAction: action, // It's not possible to tell which user connected. Once we have // the capability, this may be reported by the agent. - UserID: uuid.Nil, + UserID: uuid.NullUUID{ + Valid: false, + }, + // N/A + UserAgent: sql.NullString{}, + // N/A + SlugOrPort: sql.NullString{}, }) if err != nil { return nil, xerrors.Errorf("export connection log: %w", err) diff --git a/coderd/agentapi/connectionlog_test.go b/coderd/agentapi/connectionlog_test.go index a6663512e0b75..1263699922497 100644 --- a/coderd/agentapi/connectionlog_test.go +++ b/coderd/agentapi/connectionlog_test.go @@ -129,42 +129,45 @@ func TestConnectionLog(t *testing.T) { }, }) - require.True(t, connLogger.Contains(t, database.ConnectionLog{ + require.True(t, connLogger.Contains(t, database.UpsertConnectionLogParams{ Time: dbtime.Time(tt.time).In(time.UTC), - ConnectionID: tt.id, OrganizationID: workspace.OrganizationID, WorkspaceOwnerID: workspace.OwnerID, WorkspaceID: workspace.ID, WorkspaceName: workspace.Name, AgentName: agent.Name, - UserID: uuid.Nil, - Action: agentProtoConnectionActionToConnectionLog(t, *tt.action), + UserID: uuid.NullUUID{ + UUID: uuid.Nil, + Valid: false, + }, + ConnectionAction: connectionLogActionFromAgentProtoConnectionAction(t, *tt.action), Code: tt.status, Ip: pqtype.Inet{Valid: true, IPNet: net.IPNet{IP: net.ParseIP(tt.ip), Mask: net.CIDRMask(32, 32)}}, - ConnectionType: database.NullConnectionTypeEnum{ - ConnectionTypeEnum: agentProtoConnectionTypeToConnectionLog(t, *tt.typ), - Valid: true, - }, - Reason: sql.NullString{ + Type: connectionLogConnectionTypeFromAgentProtoConnectionType(t, *tt.typ), + CloseReason: sql.NullString{ String: tt.reason, Valid: tt.reason != "", }, + ConnectionID: uuid.NullUUID{ + UUID: tt.id, + Valid: tt.id != uuid.Nil, + }, })) }) } } -func agentProtoConnectionActionToConnectionLog(t *testing.T, action agentproto.Connection_Action) database.ConnectionAction { - a, err := db2sdk.ConnectionLogActionFromAgentProtoConnectionAction(action) +func connectionLogConnectionTypeFromAgentProtoConnectionType(t *testing.T, typ agentproto.Connection_Type) database.ConnectionType { + a, err := db2sdk.ConnectionLogConnectionTypeFromAgentProtoConnectionType(typ) require.NoError(t, err) return a } -func agentProtoConnectionTypeToConnectionLog(t *testing.T, typ agentproto.Connection_Type) database.ConnectionTypeEnum { - action, err := db2sdk.ConnectionLogConnectionTypeEnumFromAgentProtoConnectionType(typ) +func connectionLogActionFromAgentProtoConnectionAction(t *testing.T, action agentproto.Connection_Action) database.ConnectionAction { + a, err := db2sdk.ConnectionActionFromAgentProtoConnectionAction(action) require.NoError(t, err) - return action + return a } func asAtomicPointer[T any](v T) *atomic.Pointer[T] { diff --git a/coderd/connectionlog/connectionlog.go b/coderd/connectionlog/connectionlog.go index faca6dd3b6754..17ccfe25f73e3 100644 --- a/coderd/connectionlog/connectionlog.go +++ b/coderd/connectionlog/connectionlog.go @@ -11,7 +11,7 @@ import ( ) type ConnectionLogger interface { - Export(ctx context.Context, clog database.ConnectionLog) error + Upsert(ctx context.Context, clog database.UpsertConnectionLogParams) error } type nop struct{} @@ -20,7 +20,7 @@ func NewNop() ConnectionLogger { return nop{} } -func (nop) Export(context.Context, database.ConnectionLog) error { +func (nop) Upsert(context.Context, database.UpsertConnectionLogParams) error { return nil } @@ -29,35 +29,35 @@ func NewMock() *MockConnectionLogger { } type MockConnectionLogger struct { - mu sync.Mutex - connectionLogs []database.ConnectionLog + mu sync.Mutex + upsertions []database.UpsertConnectionLogParams } -func (m *MockConnectionLogger) ResetLogs() { +func (m *MockConnectionLogger) Reset() { m.mu.Lock() defer m.mu.Unlock() - m.connectionLogs = make([]database.ConnectionLog, 0) + m.upsertions = make([]database.UpsertConnectionLogParams, 0) } -func (m *MockConnectionLogger) ConnectionLogs() []database.ConnectionLog { +func (m *MockConnectionLogger) ConnectionLogs() []database.UpsertConnectionLogParams { m.mu.Lock() defer m.mu.Unlock() - return m.connectionLogs + return m.upsertions } -func (m *MockConnectionLogger) Export(_ context.Context, clog database.ConnectionLog) error { +func (m *MockConnectionLogger) Upsert(_ context.Context, clog database.UpsertConnectionLogParams) error { m.mu.Lock() defer m.mu.Unlock() - m.connectionLogs = append(m.connectionLogs, clog) + m.upsertions = append(m.upsertions, clog) return nil } -func (m *MockConnectionLogger) Contains(t testing.TB, expected database.ConnectionLog) bool { +func (m *MockConnectionLogger) Contains(t testing.TB, expected database.UpsertConnectionLogParams) bool { m.mu.Lock() defer m.mu.Unlock() - for idx, cl := range m.connectionLogs { + for idx, cl := range m.upsertions { if expected.ID != uuid.Nil && cl.ID != expected.ID { t.Logf("connection log %d: expected ID %s, got %s", idx+1, expected.ID, cl.ID) continue @@ -66,10 +66,6 @@ func (m *MockConnectionLogger) Contains(t testing.TB, expected database.Connecti t.Logf("connection log %d: expected Time %s, got %s", idx+1, expected.Time, cl.Time) continue } - if expected.ConnectionID != uuid.Nil && cl.ConnectionID != expected.ConnectionID { - t.Logf("connection log %d: expected ConnectionID %s, got %s", idx+1, expected.ConnectionID, cl.ConnectionID) - continue - } if expected.OrganizationID != uuid.Nil && cl.OrganizationID != expected.OrganizationID { t.Logf("connection log %d: expected OrganizationID %s, got %s", idx+1, expected.OrganizationID, cl.OrganizationID) continue @@ -90,8 +86,8 @@ func (m *MockConnectionLogger) Contains(t testing.TB, expected database.Connecti t.Logf("connection log %d: expected AgentName %s, got %s", idx+1, expected.AgentName, cl.AgentName) continue } - if expected.Action != "" && cl.Action != expected.Action { - t.Logf("connection log %d: expected Action %s, got %s", idx+1, expected.Action, cl.Action) + if expected.Type != "" && cl.Type != expected.Type { + t.Logf("connection log %d: expected Type %s, got %s", idx+1, expected.Type, cl.Type) continue } if expected.Code != 0 && cl.Code != expected.Code { @@ -102,24 +98,20 @@ func (m *MockConnectionLogger) Contains(t testing.TB, expected database.Connecti t.Logf("connection log %d: expected IP %s, got %s", idx+1, expected.Ip.IPNet, cl.Ip.IPNet) continue } - if expected.SlugOrPort.Valid && cl.SlugOrPort != expected.SlugOrPort { - t.Logf("connection log %d: expected SlugOrPort %s, got %s", idx+1, expected.SlugOrPort.String, cl.SlugOrPort.String) - continue - } - if expected.UserAgent.Valid && cl.UserAgent != expected.UserAgent { + if expected.UserAgent.Valid && cl.UserAgent.String != expected.UserAgent.String { t.Logf("connection log %d: expected UserAgent %s, got %s", idx+1, expected.UserAgent.String, cl.UserAgent.String) continue } - if expected.UserID != uuid.Nil && cl.UserID != expected.UserID { - t.Logf("connection log %d: expected UserID %s, got %s", idx+1, expected.UserID, cl.UserID) + if expected.UserID.Valid && cl.UserID.UUID != expected.UserID.UUID { + t.Logf("connection log %d: expected UserID %s, got %s", idx+1, expected.UserID.UUID, cl.UserID.UUID) continue } - if expected.ConnectionType.Valid && cl.ConnectionType.ConnectionTypeEnum != expected.ConnectionType.ConnectionTypeEnum { - t.Logf("connection log %d: expected ConnectionType %s, got %s", idx+1, expected.ConnectionType.ConnectionTypeEnum, cl.ConnectionType.ConnectionTypeEnum) + if expected.SlugOrPort.Valid && cl.SlugOrPort.String != expected.SlugOrPort.String { + t.Logf("connection log %d: expected SlugOrPort %s, got %s", idx+1, expected.SlugOrPort.String, cl.SlugOrPort.String) continue } - if expected.Reason.Valid && cl.Reason != expected.Reason { - t.Logf("connection log %d: expected Reason %s, got %s", idx+1, expected.Reason.String, cl.Reason.String) + if expected.ConnectionID.Valid && cl.ConnectionID.UUID != expected.ConnectionID.UUID { + t.Logf("connection log %d: expected ConnectionID %s, got %s", idx+1, expected.ConnectionID.UUID, cl.ConnectionID.UUID) continue } return true diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index ba876d6d64d64..eaf585cb8793d 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -727,43 +727,31 @@ func TemplateRoleActions(role codersdk.TemplateRole) []policy.Action { return []policy.Action{} } -func ConnectionLogActionFromAgentProtoConnectionAction(action agentproto.Connection_Action) (database.ConnectionAction, error) { - switch action { - case agentproto.Connection_CONNECT: - return database.ConnectionActionConnect, nil - case agentproto.Connection_DISCONNECT: - return database.ConnectionActionDisconnect, nil - default: - // Also Connection_ACTION_UNSPECIFIED, no mapping. - return "", xerrors.Errorf("unknown agent connection action %q", action) - } -} - -func ConnectionLogConnectionTypeEnumFromAgentProtoConnectionType(typ agentproto.Connection_Type) (database.ConnectionTypeEnum, error) { +func ConnectionLogConnectionTypeFromAgentProtoConnectionType(typ agentproto.Connection_Type) (database.ConnectionType, error) { switch typ { case agentproto.Connection_SSH: - return database.ConnectionTypeEnumSsh, nil + return database.ConnectionTypeSsh, nil case agentproto.Connection_JETBRAINS: - return database.ConnectionTypeEnumJetbrains, nil + return database.ConnectionTypeJetbrains, nil case agentproto.Connection_VSCODE: - return database.ConnectionTypeEnumVscode, nil + return database.ConnectionTypeVscode, nil case agentproto.Connection_RECONNECTING_PTY: - return database.ConnectionTypeEnumReconnectingPty, nil - case agentproto.Connection_TYPE_UNSPECIFIED: - return database.ConnectionTypeEnumUnspecified, nil + return database.ConnectionTypeReconnectingPty, nil default: + // Also Connection_ACTION_UNSPECIFIED, no mapping. return "", xerrors.Errorf("unknown agent connection type %q", typ) } } -func AgentProtoConnectionActionToAuditAction(action database.AuditAction) (agentproto.Connection_Action, error) { +func ConnectionActionFromAgentProtoConnectionAction(action agentproto.Connection_Action) (database.ConnectionAction, error) { switch action { - case database.AuditActionConnect: - return agentproto.Connection_CONNECT, nil - case database.AuditActionDisconnect: - return agentproto.Connection_DISCONNECT, nil + case agentproto.Connection_CONNECT: + return database.ConnectionActionConnect, nil + case agentproto.Connection_DISCONNECT: + return database.ConnectionActionDisconnect, nil default: - return agentproto.Connection_ACTION_UNSPECIFIED, xerrors.Errorf("unknown agent connection action %q", action) + // Also Connection_ACTION_UNSPECIFIED, no mapping. + return "", xerrors.Errorf("unknown agent connection action %q", action) } } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 0fd2783cba554..540e446c9431f 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3495,10 +3495,6 @@ func (q *querier) InsertChatMessages(ctx context.Context, arg database.InsertCha return q.db.InsertChatMessages(ctx, arg) } -func (q *querier) InsertConnectionLog(ctx context.Context, arg database.InsertConnectionLogParams) (database.ConnectionLog, error) { - return insert(q.log, q.auth, rbac.ResourceConnectionLog, q.db.InsertConnectionLog)(ctx, arg) -} - func (q *querier) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceCryptoKey); err != nil { return database.CryptoKey{}, err @@ -4990,6 +4986,10 @@ func (q *querier) UpsertApplicationName(ctx context.Context, value string) error return q.db.UpsertApplicationName(ctx, value) } +func (q *querier) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) { + return insert(q.log, q.auth, rbac.ResourceConnectionLog, q.db.UpsertConnectionLog)(ctx, arg) +} + func (q *querier) UpsertCoordinatorResumeTokenSigningKey(ctx context.Context, value string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 5c4d0c3c41b6d..e03f7196f3b06 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -345,22 +345,29 @@ func (s *MethodTestSuite) TestConnectionLogs() { TemplateID: tpl.ID, }) } - s.Run("InsertConnectionLog", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpsertConnectionLog", s.Subtest(func(db database.Store, check *expects) { ws := createWorkspace(s.T(), db) - check.Args(database.InsertConnectionLogParams{ - Action: database.ConnectionActionConnect, - WorkspaceID: ws.ID, + check.Args(database.UpsertConnectionLogParams{ + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + ConnectionAction: database.ConnectionActionConnect, + WorkspaceOwnerID: ws.OwnerID, }).Asserts(rbac.ResourceConnectionLog, policy.ActionCreate) })) s.Run("GetConnectionLogsOffset", s.Subtest(func(db database.Store, check *expects) { ws := createWorkspace(s.T(), db) _ = dbgen.ConnectionLog(s.T(), db, database.ConnectionLog{ - Action: database.ConnectionActionConnect, - WorkspaceID: ws.ID, + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, }) _ = dbgen.ConnectionLog(s.T(), db, database.ConnectionLog{ - Action: database.ConnectionActionConnect, - WorkspaceID: ws.ID, + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, }) check.Args(database.GetConnectionLogsOffsetParams{ LimitOpt: 10, @@ -369,12 +376,16 @@ func (s *MethodTestSuite) TestConnectionLogs() { s.Run("GetAuthorizedConnectionLogsOffset", s.Subtest(func(db database.Store, check *expects) { ws := createWorkspace(s.T(), db) _ = dbgen.ConnectionLog(s.T(), db, database.ConnectionLog{ - Action: database.ConnectionActionConnect, - WorkspaceID: ws.ID, + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, }) _ = dbgen.ConnectionLog(s.T(), db, database.ConnectionLog{ - Action: database.ConnectionActionConnect, - WorkspaceID: ws.ID, + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, }) check.Args(database.GetConnectionLogsOffsetParams{ LimitOpt: 10, diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index c7625ea88384c..c7da30007b2a4 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -74,16 +74,15 @@ func AuditLog(t testing.TB, db database.Store, seed database.AuditLog) database. } func ConnectionLog(t testing.TB, db database.Store, seed database.ConnectionLog) database.ConnectionLog { - log, err := db.InsertConnectionLog(genCtx, database.InsertConnectionLogParams{ + log, err := db.UpsertConnectionLog(genCtx, database.UpsertConnectionLogParams{ ID: takeFirst(seed.ID, uuid.New()), Time: takeFirst(seed.Time, dbtime.Now()), - ConnectionID: takeFirst(seed.ConnectionID, uuid.New()), OrganizationID: takeFirst(seed.OrganizationID, uuid.New()), WorkspaceOwnerID: takeFirst(seed.WorkspaceOwnerID, uuid.New()), WorkspaceID: takeFirst(seed.WorkspaceID, uuid.New()), WorkspaceName: takeFirst(seed.WorkspaceName, testutil.GetRandomName(t)), AgentName: takeFirst(seed.AgentName, testutil.GetRandomName(t)), - Action: takeFirst(seed.Action, database.ConnectionActionOpen), + Type: takeFirst(seed.Type, database.ConnectionTypeSsh), Code: takeFirst(seed.Code, 0), Ip: pqtype.Inet{ IPNet: takeFirstIP(seed.Ip.IPNet, net.IPNet{}), @@ -93,19 +92,23 @@ func ConnectionLog(t testing.TB, db database.Store, seed database.ConnectionLog) String: takeFirst(seed.UserAgent.String, ""), Valid: takeFirst(seed.UserAgent.Valid, false), }, - UserID: takeFirst(seed.UserID, uuid.New()), + UserID: uuid.NullUUID{ + UUID: takeFirst(seed.UserID.UUID, uuid.Nil), + Valid: takeFirst(seed.UserID.Valid, false), + }, SlugOrPort: sql.NullString{ String: takeFirst(seed.SlugOrPort.String, ""), Valid: takeFirst(seed.SlugOrPort.Valid, false), }, - ConnectionType: takeFirst(seed.ConnectionType, database.NullConnectionTypeEnum{ - ConnectionTypeEnum: database.ConnectionTypeEnumSsh, - Valid: true, - }), - Reason: sql.NullString{ - String: takeFirst(seed.Reason.String, ""), - Valid: takeFirst(seed.Reason.Valid, false), + ConnectionID: uuid.NullUUID{ + UUID: takeFirst(seed.ConnectionID.UUID, uuid.Nil), + Valid: takeFirst(seed.ConnectionID.Valid, false), + }, + CloseReason: sql.NullString{ + String: takeFirst(seed.CloseReason.String, ""), + Valid: takeFirst(seed.CloseReason.Valid, false), }, + ConnectionAction: database.ConnectionActionConnect, }) require.NoError(t, err, "insert connection log") return log diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index bfbb3061574c4..bd299653dfafe 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -8627,30 +8627,6 @@ func (q *FakeQuerier) InsertChatMessages(ctx context.Context, arg database.Inser return messages, nil } -func (q *FakeQuerier) InsertConnectionLog(_ context.Context, arg database.InsertConnectionLogParams) (database.ConnectionLog, error) { - err := validateDatabaseType(arg) - if err != nil { - return database.ConnectionLog{}, err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - log := database.ConnectionLog(arg) - - q.connectionLogs = append(q.connectionLogs, log) - slices.SortFunc(q.connectionLogs, func(a, b database.ConnectionLog) int { - if a.Time.Before(b.Time) { - return -1 - } else if a.Time.Equal(b.Time) { - return 0 - } - return 1 - }) - - return log, nil -} - func (q *FakeQuerier) InsertCryptoKey(_ context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { err := validateDatabaseType(arg) if err != nil { @@ -12292,6 +12268,59 @@ func (q *FakeQuerier) UpsertApplicationName(_ context.Context, data string) erro return nil } +func (q *FakeQuerier) UpsertConnectionLog(_ context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.ConnectionLog{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + if arg.ConnectionAction == "disconnect" { + for i, existing := range q.connectionLogs { + if existing.ConnectionID == arg.ConnectionID && + existing.WorkspaceID == arg.WorkspaceID && + existing.AgentName == arg.AgentName { + // Update existing connection with close time and reason + q.connectionLogs[i].CloseTime = sql.NullTime{Valid: true, Time: arg.Time} + q.connectionLogs[i].CloseReason = arg.CloseReason + return q.connectionLogs[i], nil + } + } + } + + log := database.ConnectionLog{ + ID: arg.ID, + Time: arg.Time, + OrganizationID: arg.OrganizationID, + WorkspaceOwnerID: arg.WorkspaceOwnerID, + WorkspaceID: arg.WorkspaceID, + WorkspaceName: arg.WorkspaceName, + AgentName: arg.AgentName, + Type: arg.Type, + Code: arg.Code, + Ip: arg.Ip, + UserAgent: arg.UserAgent, + UserID: arg.UserID, + SlugOrPort: arg.SlugOrPort, + ConnectionID: arg.ConnectionID, + CloseReason: arg.CloseReason, + } + + q.connectionLogs = append(q.connectionLogs, log) + slices.SortFunc(q.connectionLogs, func(a, b database.ConnectionLog) int { + if a.Time.Before(b.Time) { + return -1 + } else if a.Time.Equal(b.Time) { + return 0 + } + return 1 + }) + + return log, nil +} + func (q *FakeQuerier) UpsertCoordinatorResumeTokenSigningKey(_ context.Context, value string) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 12a12afb56ac1..dc6b72f27f59d 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2083,13 +2083,6 @@ func (m queryMetricsStore) InsertChatMessages(ctx context.Context, arg database. return r0, r1 } -func (m queryMetricsStore) InsertConnectionLog(ctx context.Context, arg database.InsertConnectionLogParams) (database.ConnectionLog, error) { - start := time.Now() - r0, r1 := m.s.InsertConnectionLog(ctx, arg) - m.queryLatencies.WithLabelValues("InsertConnectionLog").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m queryMetricsStore) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { start := time.Now() key, err := m.s.InsertCryptoKey(ctx, arg) @@ -3133,6 +3126,13 @@ func (m queryMetricsStore) UpsertApplicationName(ctx context.Context, value stri return r0 } +func (m queryMetricsStore) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) { + start := time.Now() + r0, r1 := m.s.UpsertConnectionLog(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertConnectionLog").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) UpsertCoordinatorResumeTokenSigningKey(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertCoordinatorResumeTokenSigningKey(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 4a9fadf2436fa..179180e0c2a05 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4411,21 +4411,6 @@ func (mr *MockStoreMockRecorder) InsertChatMessages(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatMessages", reflect.TypeOf((*MockStore)(nil).InsertChatMessages), ctx, arg) } -// InsertConnectionLog mocks base method. -func (m *MockStore) InsertConnectionLog(ctx context.Context, arg database.InsertConnectionLogParams) (database.ConnectionLog, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InsertConnectionLog", ctx, arg) - ret0, _ := ret[0].(database.ConnectionLog) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// InsertConnectionLog indicates an expected call of InsertConnectionLog. -func (mr *MockStoreMockRecorder) InsertConnectionLog(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertConnectionLog", reflect.TypeOf((*MockStore)(nil).InsertConnectionLog), ctx, arg) -} - // InsertCryptoKey mocks base method. func (m *MockStore) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { m.ctrl.T.Helper() @@ -6623,6 +6608,21 @@ func (mr *MockStoreMockRecorder) UpsertApplicationName(ctx, value any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertApplicationName", reflect.TypeOf((*MockStore)(nil).UpsertApplicationName), ctx, value) } +// UpsertConnectionLog mocks base method. +func (m *MockStore) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertConnectionLog", ctx, arg) + ret0, _ := ret[0].(database.ConnectionLog) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertConnectionLog indicates an expected call of UpsertConnectionLog. +func (mr *MockStoreMockRecorder) UpsertConnectionLog(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertConnectionLog", reflect.TypeOf((*MockStore)(nil).UpsertConnectionLog), ctx, arg) +} + // UpsertCoordinatorResumeTokenSigningKey mocks base method. func (m *MockStore) UpsertCoordinatorResumeTokenSigningKey(ctx context.Context, value string) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index cecbf87571caa..7629069046832 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -55,17 +55,15 @@ CREATE TYPE build_reason AS ENUM ( CREATE TYPE connection_action AS ENUM ( 'connect', - 'disconnect', - 'open', - 'close' + 'disconnect' ); -CREATE TYPE connection_type_enum AS ENUM ( +CREATE TYPE connection_type AS ENUM ( 'ssh', 'vscode', 'jetbrains', 'reconnecting_pty', - 'unspecified' + 'web' ); CREATE TYPE crypto_key_feature AS ENUM ( @@ -865,33 +863,35 @@ CREATE TABLE chats ( CREATE TABLE connection_logs ( id uuid NOT NULL, "time" timestamp with time zone NOT NULL, - connection_id uuid NOT NULL, organization_id uuid NOT NULL, workspace_owner_id uuid NOT NULL, workspace_id uuid NOT NULL, workspace_name text NOT NULL, agent_name text NOT NULL, - action connection_action NOT NULL, + type connection_type NOT NULL, code integer NOT NULL, ip inet, user_agent text, - user_id uuid NOT NULL, + user_id uuid, slug_or_port text, - connection_type connection_type_enum, - reason text + connection_id uuid, + close_time timestamp with time zone, + close_reason text ); -COMMENT ON COLUMN connection_logs.connection_id IS 'Either the workspace app request ID or the SSH connection ID. Used to correlate connections and disconnections.'; +COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection.'; + +COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For web connections, this is the User-Agent header from the request.'; -COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code for the workspace app request, or the exit code of an SSH connection.'; +COMMENT ON COLUMN connection_logs.user_id IS 'uuid.Nil for SSH actions. For web connections, this is the ID of the user that made the request.'; -COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For workspace apps, this is the User-Agent header from the request.'; +COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for SSH actions. For web connections, this is the slug of the app or the port number being forwarded.'; -COMMENT ON COLUMN connection_logs.user_id IS 'uuid.Nil for SSH actions. For workspace apps, this is the ID of the user that made the request.'; +COMMENT ON COLUMN connection_logs.connection_id IS 'The SSH connection ID. Used to correlate connections and disconnections. As it originates from the agent, it is not guaranteed to be unique.'; -COMMENT ON COLUMN connection_logs.connection_type IS 'Null for Workspace App actions. For SSH actions, this is the type of connection (e.g., "SSH", "VS Code").'; +COMMENT ON COLUMN connection_logs.close_time IS 'Null for web connections. For SSH actions, Null until we receive a second event for the same connection_id. This is the time when the connection was closed.'; -COMMENT ON COLUMN connection_logs.reason IS 'Null for Workspace App actions. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI.'; +COMMENT ON COLUMN connection_logs.close_reason IS 'Null for web connections. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI.'; CREATE TABLE crypto_keys ( feature crypto_key_feature NOT NULL, @@ -2686,6 +2686,8 @@ CREATE INDEX idx_audit_log_user_id ON audit_logs USING btree (user_id); CREATE INDEX idx_audit_logs_time_desc ON audit_logs USING btree ("time" DESC); +CREATE UNIQUE INDEX idx_connection_logs_connection_id_workspace_id_agent_name ON connection_logs USING btree (connection_id, workspace_id, agent_name); + CREATE INDEX idx_connection_logs_organization_id ON connection_logs USING btree (organization_id); CREATE INDEX idx_connection_logs_time_desc ON connection_logs USING btree ("time" DESC); @@ -2904,7 +2906,13 @@ ALTER TABLE ONLY chats ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; ALTER TABLE ONLY connection_logs - ADD CONSTRAINT connection_logs_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL; + ADD CONSTRAINT connection_logs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + +ALTER TABLE ONLY connection_logs + ADD CONSTRAINT connection_logs_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; + +ALTER TABLE ONLY connection_logs + ADD CONSTRAINT connection_logs_workspace_owner_id_fkey FOREIGN KEY (workspace_owner_id) REFERENCES users(id) ON DELETE CASCADE; ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index e303375830a4b..71a4cab86225a 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -9,7 +9,9 @@ const ( ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyChatMessagesChatID ForeignKeyConstraint = "chat_messages_chat_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatsOwnerID ForeignKeyConstraint = "chats_owner_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyConnectionLogsWorkspaceID ForeignKeyConstraint = "connection_logs_workspace_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL; + ForeignKeyConnectionLogsOrganizationID ForeignKeyConstraint = "connection_logs_organization_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyConnectionLogsWorkspaceID ForeignKeyConstraint = "connection_logs_workspace_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; + ForeignKeyConnectionLogsWorkspaceOwnerID ForeignKeyConstraint = "connection_logs_workspace_owner_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_owner_id_fkey FOREIGN KEY (workspace_owner_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyCryptoKeysSecretKeyID ForeignKeyConstraint = "crypto_keys_secret_key_id_fkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthAccessTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); diff --git a/coderd/database/migrations/000335_connection_logs.down.sql b/coderd/database/migrations/000335_connection_logs.down.sql index 9f9cdc590e8aa..1b688cec5fb25 100644 --- a/coderd/database/migrations/000335_connection_logs.down.sql +++ b/coderd/database/migrations/000335_connection_logs.down.sql @@ -5,6 +5,6 @@ DROP INDEX IF EXISTS idx_connection_logs_time_desc; DROP TABLE IF EXISTS connection_logs; -DROP TYPE IF EXISTS connection_action; +DROP TYPE IF EXISTS connection_type; -DROP TYPE IF EXISTS connection_type_enum; +DROP TYPE IF EXISTS connection_action; diff --git a/coderd/database/migrations/000335_connection_logs.up.sql b/coderd/database/migrations/000335_connection_logs.up.sql index 62bd2f5a0cbe1..31efd0bc5cfca 100644 --- a/coderd/database/migrations/000335_connection_logs.up.sql +++ b/coderd/database/migrations/000335_connection_logs.up.sql @@ -1,60 +1,64 @@ CREATE TYPE connection_action AS ENUM ( - -- SSH actions 'connect', - 'disconnect', - -- Workspace App actions - 'open', - 'close' + 'disconnect' ); --- Mirrors `Connection.Type` in `agent.Proto` / agentSDK.ConnectionType` -CREATE TYPE connection_type_enum AS ENUM ( +CREATE TYPE connection_type AS ENUM ( + -- SSH actions 'ssh', 'vscode', 'jetbrains', 'reconnecting_pty', - 'unspecified' + -- Workspace Apps or Web Port Forwarding + 'web' ); CREATE TABLE connection_logs ( id uuid NOT NULL, "time" timestamp with time zone NOT NULL, - connection_id uuid NOT NULL, - organization_id uuid NOT NULL, - workspace_owner_id uuid NOT NULL, - workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE SET NULL, + organization_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + workspace_owner_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE, + workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, workspace_name text NOT NULL, agent_name text NOT NULL, - action connection_action NOT NULL, + type connection_type NOT NULL, code integer NOT NULL, ip inet, - -- Null for SSH actions. + -- Only set for 'web' logs. user_agent text, - user_id uuid NOT NULL, -- Can be NULL, but must be uuid.Nil. + user_id uuid, slug_or_port text, - -- Null for Workspace App actions. - connection_type connection_type_enum, - reason text, + -- Null for 'web' logs. + connection_id uuid, + close_time timestamp with time zone, -- Null until we receive a second event for the same connection_id. + close_reason text, PRIMARY KEY (id) ); -COMMENT ON COLUMN connection_logs.connection_id IS 'Either the workspace app request ID or the SSH connection ID. Used to correlate connections and disconnections.'; -COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code for the workspace app request, or the exit code of an SSH connection.'; +COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection.'; + +COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For web connections, this is the User-Agent header from the request.'; -COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For workspace apps, this is the User-Agent header from the request.'; +COMMENT ON COLUMN connection_logs.user_id IS 'uuid.Nil for SSH actions. For web connections, this is the ID of the user that made the request.'; -COMMENT ON COLUMN connection_logs.user_id IS 'uuid.Nil for SSH actions. For workspace apps, this is the ID of the user that made the request.'; +COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for SSH actions. For web connections, this is the slug of the app or the port number being forwarded.'; -COMMENT ON COLUMN connection_logs.connection_type IS 'Null for Workspace App actions. For SSH actions, this is the type of connection (e.g., "SSH", "VS Code").'; +COMMENT ON COLUMN connection_logs.connection_id IS 'The SSH connection ID. Used to correlate connections and disconnections. As it originates from the agent, it is not guaranteed to be unique.'; -COMMENT ON COLUMN connection_logs.reason IS 'Null for Workspace App actions. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI.'; +COMMENT ON COLUMN connection_logs.close_time IS 'Null for web connections. For SSH actions, Null until we receive a second event for the same connection_id. This is the time when the connection was closed.'; + +COMMENT ON COLUMN connection_logs.close_reason IS 'Null for web connections. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI.'; COMMENT ON TYPE audit_action IS 'NOTE: `connect`, `disconnect`, `open`, and `close` are deprecated and no longer used - these events are now tracked in the connection_logs table.'; +-- To associate connection closure events with the connection start events. +CREATE UNIQUE INDEX idx_connection_logs_connection_id_workspace_id_agent_name +ON connection_logs (connection_id, workspace_id, agent_name); + CREATE INDEX idx_connection_logs_time_desc ON connection_logs USING btree ("time" DESC); CREATE INDEX idx_connection_logs_organization_id ON connection_logs USING btree (organization_id); CREATE INDEX idx_connection_logs_workspace_owner_id ON connection_logs USING btree (workspace_owner_id); diff --git a/coderd/database/migrations/testdata/fixtures/000335_connection_logs.up.sql b/coderd/database/migrations/testdata/fixtures/000335_connection_logs.up.sql index 54c5dfdd42cd8..cae9aaa35059c 100644 --- a/coderd/database/migrations/testdata/fixtures/000335_connection_logs.up.sql +++ b/coderd/database/migrations/testdata/fixtures/000335_connection_logs.up.sql @@ -1,53 +1,53 @@ INSERT INTO connection_logs ( id, "time", - connection_id, organization_id, workspace_owner_id, workspace_id, workspace_name, agent_name, - action, + type, code, ip, user_agent, user_id, slug_or_port, - connection_type, - reason + connection_id, + close_time, + close_reason ) VALUES ( '00000000-0000-0000-0000-000000000001', -- log id - '2023-10-01 12:00:00+00', - '00000000-0000-0000-0000-000000000003', -- connection id - '00000000-0000-0000-0000-000000000020', -- organization id - '00000000-0000-0000-0000-000000000030', -- workspace owner id + '2023-10-01 12:00:00+00', -- start time + 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', -- organization id + 'a0061a8e-7db7-4585-838c-3116a003dd21', -- workspace owner id '3a9a1feb-e89d-457c-9d53-ac751b198ebe', -- workspace id 'Test Workspace', -- workspace name 'test-agent', -- agent name - 'connect', + 'ssh', -- type 0, -- code - '127.0.0.1', + '127.0.0.1', -- ip NULL, -- user agent - '00000000-0000-0000-0000-000000000000', -- user id (uuid.Nil) + NULL, -- user id NULL, -- slug or port - 'ssh', -- connection type - 'connected via CLI' -- reason + '00000000-0000-0000-0000-000000000003', -- connection id + '2023-10-01 12:00:10+00', -- close time + 'server shut down' -- reason ), ( '00000000-0000-0000-0000-000000000002', -- log id - '2023-10-01 12:05:00+00', - '00000000-0000-0000-0000-000000000004', -- connection id (request ID) - '00000000-0000-0000-0000-000000000020', -- organization id - '00000000-0000-0000-0000-000000000030', -- workspace owner id + '2023-10-01 12:05:00+00', -- start time + 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', -- organization id + 'a0061a8e-7db7-4585-838c-3116a003dd21', -- workspace owner id '3a9a1feb-e89d-457c-9d53-ac751b198ebe', -- workspace id 'Test Workspace', -- workspace name 'test-agent', -- agent name - 'open', + 'web', -- type 200, -- code '127.0.0.1', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36', - '00000000-0000-0000-0000-000000000030', -- user id + 'a0061a8e-7db7-4585-838c-3116a003dd21', -- user id 'code-server', -- slug or port - NULL, -- connection type + NULL, -- connection id (request ID) + NULL, -- close time NULL -- reason ); diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index c9b10b9d22b08..3241bf8798352 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -562,20 +562,20 @@ func (q *sqlQuerier) GetAuthorizedConnectionLogsOffset(ctx context.Context, arg if err := rows.Scan( &i.ConnectionLog.ID, &i.ConnectionLog.Time, - &i.ConnectionLog.ConnectionID, &i.ConnectionLog.OrganizationID, &i.ConnectionLog.WorkspaceOwnerID, &i.ConnectionLog.WorkspaceID, &i.ConnectionLog.WorkspaceName, &i.ConnectionLog.AgentName, - &i.ConnectionLog.Action, + &i.ConnectionLog.Type, &i.ConnectionLog.Code, &i.ConnectionLog.Ip, &i.ConnectionLog.UserAgent, &i.ConnectionLog.UserID, &i.ConnectionLog.SlugOrPort, - &i.ConnectionLog.ConnectionType, - &i.ConnectionLog.Reason, + &i.ConnectionLog.ConnectionID, + &i.ConnectionLog.CloseTime, + &i.ConnectionLog.CloseReason, &i.UserUsername, &i.WorkspaceOwnerUsername, &i.Count, diff --git a/coderd/database/models.go b/coderd/database/models.go index 00fd2dee1e68b..afa4bcf88a3dc 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -418,8 +418,6 @@ type ConnectionAction string const ( ConnectionActionConnect ConnectionAction = "connect" ConnectionActionDisconnect ConnectionAction = "disconnect" - ConnectionActionOpen ConnectionAction = "open" - ConnectionActionClose ConnectionAction = "close" ) func (e *ConnectionAction) Scan(src interface{}) error { @@ -460,9 +458,7 @@ func (ns NullConnectionAction) Value() (driver.Value, error) { func (e ConnectionAction) Valid() bool { switch e { case ConnectionActionConnect, - ConnectionActionDisconnect, - ConnectionActionOpen, - ConnectionActionClose: + ConnectionActionDisconnect: return true } return false @@ -472,75 +468,73 @@ func AllConnectionActionValues() []ConnectionAction { return []ConnectionAction{ ConnectionActionConnect, ConnectionActionDisconnect, - ConnectionActionOpen, - ConnectionActionClose, } } -type ConnectionTypeEnum string +type ConnectionType string const ( - ConnectionTypeEnumSsh ConnectionTypeEnum = "ssh" - ConnectionTypeEnumVscode ConnectionTypeEnum = "vscode" - ConnectionTypeEnumJetbrains ConnectionTypeEnum = "jetbrains" - ConnectionTypeEnumReconnectingPty ConnectionTypeEnum = "reconnecting_pty" - ConnectionTypeEnumUnspecified ConnectionTypeEnum = "unspecified" + ConnectionTypeSsh ConnectionType = "ssh" + ConnectionTypeVscode ConnectionType = "vscode" + ConnectionTypeJetbrains ConnectionType = "jetbrains" + ConnectionTypeReconnectingPty ConnectionType = "reconnecting_pty" + ConnectionTypeWeb ConnectionType = "web" ) -func (e *ConnectionTypeEnum) Scan(src interface{}) error { +func (e *ConnectionType) Scan(src interface{}) error { switch s := src.(type) { case []byte: - *e = ConnectionTypeEnum(s) + *e = ConnectionType(s) case string: - *e = ConnectionTypeEnum(s) + *e = ConnectionType(s) default: - return fmt.Errorf("unsupported scan type for ConnectionTypeEnum: %T", src) + return fmt.Errorf("unsupported scan type for ConnectionType: %T", src) } return nil } -type NullConnectionTypeEnum struct { - ConnectionTypeEnum ConnectionTypeEnum `json:"connection_type_enum"` - Valid bool `json:"valid"` // Valid is true if ConnectionTypeEnum is not NULL +type NullConnectionType struct { + ConnectionType ConnectionType `json:"connection_type"` + Valid bool `json:"valid"` // Valid is true if ConnectionType is not NULL } // Scan implements the Scanner interface. -func (ns *NullConnectionTypeEnum) Scan(value interface{}) error { +func (ns *NullConnectionType) Scan(value interface{}) error { if value == nil { - ns.ConnectionTypeEnum, ns.Valid = "", false + ns.ConnectionType, ns.Valid = "", false return nil } ns.Valid = true - return ns.ConnectionTypeEnum.Scan(value) + return ns.ConnectionType.Scan(value) } // Value implements the driver Valuer interface. -func (ns NullConnectionTypeEnum) Value() (driver.Value, error) { +func (ns NullConnectionType) Value() (driver.Value, error) { if !ns.Valid { return nil, nil } - return string(ns.ConnectionTypeEnum), nil + return string(ns.ConnectionType), nil } -func (e ConnectionTypeEnum) Valid() bool { +func (e ConnectionType) Valid() bool { switch e { - case ConnectionTypeEnumSsh, - ConnectionTypeEnumVscode, - ConnectionTypeEnumJetbrains, - ConnectionTypeEnumReconnectingPty, - ConnectionTypeEnumUnspecified: + case ConnectionTypeSsh, + ConnectionTypeVscode, + ConnectionTypeJetbrains, + ConnectionTypeReconnectingPty, + ConnectionTypeWeb: return true } return false } -func AllConnectionTypeEnumValues() []ConnectionTypeEnum { - return []ConnectionTypeEnum{ - ConnectionTypeEnumSsh, - ConnectionTypeEnumVscode, - ConnectionTypeEnumJetbrains, - ConnectionTypeEnumReconnectingPty, - ConnectionTypeEnumUnspecified, +func AllConnectionTypeValues() []ConnectionType { + return []ConnectionType{ + ConnectionTypeSsh, + ConnectionTypeVscode, + ConnectionTypeJetbrains, + ConnectionTypeReconnectingPty, + ConnectionTypeWeb, } } @@ -2925,28 +2919,29 @@ type ChatMessage struct { } type ConnectionLog struct { - ID uuid.UUID `db:"id" json:"id"` - Time time.Time `db:"time" json:"time"` - // Either the workspace app request ID or the SSH connection ID. Used to correlate connections and disconnections. - ConnectionID uuid.UUID `db:"connection_id" json:"connection_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"` - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` - WorkspaceName string `db:"workspace_name" json:"workspace_name"` - AgentName string `db:"agent_name" json:"agent_name"` - Action ConnectionAction `db:"action" json:"action"` - // Either the HTTP status code for the workspace app request, or the exit code of an SSH connection. + ID uuid.UUID `db:"id" json:"id"` + Time time.Time `db:"time" json:"time"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + WorkspaceName string `db:"workspace_name" json:"workspace_name"` + AgentName string `db:"agent_name" json:"agent_name"` + Type ConnectionType `db:"type" json:"type"` + // Either the HTTP status code of the web request, or the exit code of an SSH connection. Code int32 `db:"code" json:"code"` Ip pqtype.Inet `db:"ip" json:"ip"` - // Null for SSH actions. For workspace apps, this is the User-Agent header from the request. + // Null for SSH actions. For web connections, this is the User-Agent header from the request. UserAgent sql.NullString `db:"user_agent" json:"user_agent"` - // uuid.Nil for SSH actions. For workspace apps, this is the ID of the user that made the request. - UserID uuid.UUID `db:"user_id" json:"user_id"` + // uuid.Nil for SSH actions. For web connections, this is the ID of the user that made the request. + UserID uuid.NullUUID `db:"user_id" json:"user_id"` + // Null for SSH actions. For web connections, this is the slug of the app or the port number being forwarded. SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"` - // Null for Workspace App actions. For SSH actions, this is the type of connection (e.g., "SSH", "VS Code"). - ConnectionType NullConnectionTypeEnum `db:"connection_type" json:"connection_type"` - // Null for Workspace App actions. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI. - Reason sql.NullString `db:"reason" json:"reason"` + // The SSH connection ID. Used to correlate connections and disconnections. As it originates from the agent, it is not guaranteed to be unique. + ConnectionID uuid.NullUUID `db:"connection_id" json:"connection_id"` + // Null for web connections. For SSH actions, Null until we receive a second event for the same connection_id. This is the time when the connection was closed. + CloseTime sql.NullTime `db:"close_time" json:"close_time"` + // Null for web connections. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI. + CloseReason sql.NullString `db:"close_reason" json:"close_reason"` } type CryptoKey struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index ffc85cddd76e8..cc382f4972d5a 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -471,7 +471,6 @@ type sqlcQuerier interface { InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) InsertChatMessages(ctx context.Context, arg InsertChatMessagesParams) ([]ChatMessage, error) - InsertConnectionLog(ctx context.Context, arg InsertConnectionLogParams) (ConnectionLog, error) InsertCryptoKey(ctx context.Context, arg InsertCryptoKeyParams) (CryptoKey, error) InsertCustomRole(ctx context.Context, arg InsertCustomRoleParams) (CustomRole, error) InsertDBCryptKey(ctx context.Context, arg InsertDBCryptKeyParams) error @@ -640,6 +639,7 @@ type sqlcQuerier interface { UpsertAnnouncementBanners(ctx context.Context, value string) error UpsertAppSecurityKey(ctx context.Context, value string) error UpsertApplicationName(ctx context.Context, value string) error + UpsertConnectionLog(ctx context.Context, arg UpsertConnectionLogParams) (ConnectionLog, error) UpsertCoordinatorResumeTokenSigningKey(ctx context.Context, value string) error // The default proxy is implied and not actually stored in the database. // So we need to store it's configuration here for display purposes. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index bd5ba07a9fe2b..0d3da76b9c0df 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -2089,9 +2089,10 @@ func TestGetAuthorizedConnectionLogsOffset(t *testing.T) { for orgID, ids := range orgConnectionLogs { for _, id := range ids { allLogs = append(allLogs, dbgen.ConnectionLog(t, authDb, database.ConnectionLog{ - WorkspaceID: wsID, - ID: id, - OrganizationID: orgID, + WorkspaceID: wsID, + WorkspaceOwnerID: user.ID, + ID: id, + OrganizationID: orgID, })) } } @@ -2221,6 +2222,157 @@ func connectionOnlyIDs[T database.ConnectionLog | database.GetConnectionLogsOffs return ids } +func TestUpsertConnectionLog(t *testing.T) { + t.Parallel() + createWorkspace := func(t *testing.T, db database.Store) database.WorkspaceTable { + u := dbgen.User(t, db, database.User{}) + o := dbgen.Organization(t, db, database.Organization{}) + tpl := dbgen.Template(t, db, database.Template{ + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + return dbgen.Workspace(t, db, database.WorkspaceTable{ + ID: uuid.New(), + OwnerID: u.ID, + OrganizationID: o.ID, + AutomaticUpdates: database.AutomaticUpdatesNever, + TemplateID: tpl.ID, + }) + } + + t.Run("ConnectThenDisconnect", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := context.Background() + + ws := createWorkspace(t, db) + + connectionID := uuid.New() + agentName := "test-agent" + + // 1. Insert a 'connect' event. + connectTime := dbtime.Now() + connectParams := database.UpsertConnectionLogParams{ + ID: uuid.New(), + Time: connectTime, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + WorkspaceID: ws.ID, + WorkspaceName: ws.Name, + AgentName: agentName, + Type: database.ConnectionTypeSsh, + Code: 0, + ConnectionID: uuid.NullUUID{UUID: connectionID, Valid: true}, + ConnectionAction: database.ConnectionActionConnect, + } + + log1, err := db.UpsertConnectionLog(ctx, connectParams) + require.NoError(t, err) + require.Equal(t, connectParams.ID, log1.ID) + require.False(t, log1.CloseTime.Valid, "CloseTime should not be set on connect") + + // Check that one row exists. + rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{LimitOpt: 10}) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, int64(1), rows[0].Count) + + // 2. Insert a 'disconnect' event for the same connection. + disconnectTime := connectTime.Add(time.Second) + disconnectParams := database.UpsertConnectionLogParams{ + ConnectionID: uuid.NullUUID{UUID: connectionID, Valid: true}, + WorkspaceID: ws.ID, + AgentName: agentName, + ConnectionAction: database.ConnectionActionDisconnect, + + // Updated to: + Time: disconnectTime, + CloseReason: sql.NullString{String: "test disconnect", Valid: true}, + + // Ignored + ID: uuid.New(), + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + WorkspaceName: ws.Name, + Type: database.ConnectionTypeSsh, + Code: 0, + } + + log2, err := db.UpsertConnectionLog(ctx, disconnectParams) + require.NoError(t, err) + + // Updated + require.Equal(t, log1.ID, log2.ID) + require.True(t, log2.CloseTime.Valid) + require.True(t, disconnectTime.Equal(log2.CloseTime.Time)) + require.Equal(t, disconnectParams.CloseReason.String, log2.CloseReason.String) + + rows, err = db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{}) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, int64(1), rows[0].Count) + }) + + t.Run("ConnectDoesNotUpdate", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := context.Background() + + ws := createWorkspace(t, db) + + connectionID := uuid.New() + agentName := "test-agent" + + // 1. Insert a 'connect' event. + connectTime := dbtime.Now() + connectParams := database.UpsertConnectionLogParams{ + ID: uuid.New(), + Time: connectTime, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + WorkspaceID: ws.ID, + WorkspaceName: ws.Name, + AgentName: agentName, + Type: database.ConnectionTypeSsh, + Code: 0, + ConnectionID: uuid.NullUUID{UUID: connectionID, Valid: true}, + ConnectionAction: database.ConnectionActionConnect, + } + + log, err := db.UpsertConnectionLog(ctx, connectParams) + require.NoError(t, err) + + // 2. Insert another 'connect' event for the same connection. + connectTime2 := connectTime.Add(time.Second) + connectParams2 := database.UpsertConnectionLogParams{ + ConnectionID: uuid.NullUUID{UUID: connectionID, Valid: true}, + WorkspaceID: ws.ID, + AgentName: agentName, + ConnectionAction: database.ConnectionActionConnect, + + // Ignored + ID: uuid.New(), + Time: connectTime2, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + WorkspaceName: ws.Name, + Type: database.ConnectionTypeSsh, + Code: 0, + } + + origLog, err := db.UpsertConnectionLog(ctx, connectParams2) + require.NoError(t, err) + require.Equal(t, log, origLog, "connect update should be a no-op") + + // Check that still only one row exists. + rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{}) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, int64(1), rows[0].Count) + require.Equal(t, log, rows[0].ConnectionLog) + }) +} + type tvArgs struct { Status database.ProvisionerJobStatus // CreateWorkspace is true if we should create a workspace for the template version diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ee213fcd84139..7351e432d7d0b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -969,7 +969,7 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam const getConnectionLogsOffset = `-- name: GetConnectionLogsOffset :many SELECT - connection_logs.id, connection_logs.time, connection_logs.connection_id, connection_logs.organization_id, connection_logs.workspace_owner_id, connection_logs.workspace_id, connection_logs.workspace_name, connection_logs.agent_name, connection_logs.action, connection_logs.code, connection_logs.ip, connection_logs.user_agent, connection_logs.user_id, connection_logs.slug_or_port, connection_logs.connection_type, connection_logs.reason, + connection_logs.id, connection_logs.time, connection_logs.organization_id, connection_logs.workspace_owner_id, connection_logs.workspace_id, connection_logs.workspace_name, connection_logs.agent_name, connection_logs.type, connection_logs.code, connection_logs.ip, connection_logs.user_agent, connection_logs.user_id, connection_logs.slug_or_port, connection_logs.connection_id, connection_logs.close_time, connection_logs.close_reason, users.username AS user_username, workspace_owner.username AS workspace_owner_username, COUNT(connection_logs.*) OVER () AS count @@ -1018,20 +1018,20 @@ func (q *sqlQuerier) GetConnectionLogsOffset(ctx context.Context, arg GetConnect if err := rows.Scan( &i.ConnectionLog.ID, &i.ConnectionLog.Time, - &i.ConnectionLog.ConnectionID, &i.ConnectionLog.OrganizationID, &i.ConnectionLog.WorkspaceOwnerID, &i.ConnectionLog.WorkspaceID, &i.ConnectionLog.WorkspaceName, &i.ConnectionLog.AgentName, - &i.ConnectionLog.Action, + &i.ConnectionLog.Type, &i.ConnectionLog.Code, &i.ConnectionLog.Ip, &i.ConnectionLog.UserAgent, &i.ConnectionLog.UserID, &i.ConnectionLog.SlugOrPort, - &i.ConnectionLog.ConnectionType, - &i.ConnectionLog.Reason, + &i.ConnectionLog.ConnectionID, + &i.ConnectionLog.CloseTime, + &i.ConnectionLog.CloseReason, &i.UserUsername, &i.WorkspaceOwnerUsername, &i.Count, @@ -1049,86 +1049,97 @@ func (q *sqlQuerier) GetConnectionLogsOffset(ctx context.Context, arg GetConnect return items, nil } -const insertConnectionLog = `-- name: InsertConnectionLog :one -INSERT INTO - connection_logs ( - id, - "time", - connection_id, - organization_id, - workspace_owner_id, - workspace_id, - workspace_name, - agent_name, - action, - code, - ip, - user_agent, - user_id, - slug_or_port, - connection_type, - reason - ) -VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id, time, connection_id, organization_id, workspace_owner_id, workspace_id, workspace_name, agent_name, action, code, ip, user_agent, user_id, slug_or_port, connection_type, reason -` - -type InsertConnectionLogParams struct { - ID uuid.UUID `db:"id" json:"id"` - Time time.Time `db:"time" json:"time"` - ConnectionID uuid.UUID `db:"connection_id" json:"connection_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"` - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` - WorkspaceName string `db:"workspace_name" json:"workspace_name"` - AgentName string `db:"agent_name" json:"agent_name"` - Action ConnectionAction `db:"action" json:"action"` - Code int32 `db:"code" json:"code"` - Ip pqtype.Inet `db:"ip" json:"ip"` - UserAgent sql.NullString `db:"user_agent" json:"user_agent"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"` - ConnectionType NullConnectionTypeEnum `db:"connection_type" json:"connection_type"` - Reason sql.NullString `db:"reason" json:"reason"` -} - -func (q *sqlQuerier) InsertConnectionLog(ctx context.Context, arg InsertConnectionLogParams) (ConnectionLog, error) { - row := q.db.QueryRowContext(ctx, insertConnectionLog, +const upsertConnectionLog = `-- name: UpsertConnectionLog :one +INSERT INTO connection_logs ( + id, + "time", + organization_id, + workspace_owner_id, + workspace_id, + workspace_name, + agent_name, + type, + code, + ip, + user_agent, + user_id, + slug_or_port, + connection_id, + close_reason +) VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) +ON CONFLICT (connection_id, workspace_id, agent_name) +DO UPDATE SET + -- No-op if the connection is still open. + close_time = CASE + WHEN $16::connection_action = 'disconnect' + THEN EXCLUDED."time" + ELSE connection_logs.close_time + END, + close_reason = CASE + WHEN $16::connection_action = 'disconnect' + THEN EXCLUDED.close_reason + ELSE connection_logs.close_reason + END +RETURNING id, time, organization_id, workspace_owner_id, workspace_id, workspace_name, agent_name, type, code, ip, user_agent, user_id, slug_or_port, connection_id, close_time, close_reason +` + +type UpsertConnectionLogParams struct { + ID uuid.UUID `db:"id" json:"id"` + Time time.Time `db:"time" json:"time"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + WorkspaceName string `db:"workspace_name" json:"workspace_name"` + AgentName string `db:"agent_name" json:"agent_name"` + Type ConnectionType `db:"type" json:"type"` + Code int32 `db:"code" json:"code"` + Ip pqtype.Inet `db:"ip" json:"ip"` + UserAgent sql.NullString `db:"user_agent" json:"user_agent"` + UserID uuid.NullUUID `db:"user_id" json:"user_id"` + SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"` + ConnectionID uuid.NullUUID `db:"connection_id" json:"connection_id"` + CloseReason sql.NullString `db:"close_reason" json:"close_reason"` + ConnectionAction ConnectionAction `db:"connection_action" json:"connection_action"` +} + +func (q *sqlQuerier) UpsertConnectionLog(ctx context.Context, arg UpsertConnectionLogParams) (ConnectionLog, error) { + row := q.db.QueryRowContext(ctx, upsertConnectionLog, arg.ID, arg.Time, - arg.ConnectionID, arg.OrganizationID, arg.WorkspaceOwnerID, arg.WorkspaceID, arg.WorkspaceName, arg.AgentName, - arg.Action, + arg.Type, arg.Code, arg.Ip, arg.UserAgent, arg.UserID, arg.SlugOrPort, - arg.ConnectionType, - arg.Reason, + arg.ConnectionID, + arg.CloseReason, + arg.ConnectionAction, ) var i ConnectionLog err := row.Scan( &i.ID, &i.Time, - &i.ConnectionID, &i.OrganizationID, &i.WorkspaceOwnerID, &i.WorkspaceID, &i.WorkspaceName, &i.AgentName, - &i.Action, + &i.Type, &i.Code, &i.Ip, &i.UserAgent, &i.UserID, &i.SlugOrPort, - &i.ConnectionType, - &i.Reason, + &i.ConnectionID, + &i.CloseTime, + &i.CloseReason, ) return i, err } diff --git a/coderd/database/queries/connectionlogs.sql b/coderd/database/queries/connectionlogs.sql index 49dc37ef12034..b28c86f8b7c6c 100644 --- a/coderd/database/queries/connectionlogs.sql +++ b/coderd/database/queries/connectionlogs.sql @@ -25,25 +25,36 @@ OFFSET @offset_opt; --- name: InsertConnectionLog :one -INSERT INTO - connection_logs ( - id, - "time", - connection_id, - organization_id, - workspace_owner_id, - workspace_id, - workspace_name, - agent_name, - action, - code, - ip, - user_agent, - user_id, - slug_or_port, - connection_type, - reason - ) -VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING *; +-- name: UpsertConnectionLog :one +INSERT INTO connection_logs ( + id, + "time", + organization_id, + workspace_owner_id, + workspace_id, + workspace_name, + agent_name, + type, + code, + ip, + user_agent, + user_id, + slug_or_port, + connection_id, + close_reason +) VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) +ON CONFLICT (connection_id, workspace_id, agent_name) +DO UPDATE SET + -- No-op if the connection is still open. + close_time = CASE + WHEN @connection_action::connection_action = 'disconnect' + THEN EXCLUDED."time" + ELSE connection_logs.close_time + END, + close_reason = CASE + WHEN @connection_action::connection_action = 'disconnect' + THEN EXCLUDED.close_reason + ELSE connection_logs.close_reason + END +RETURNING *; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index aa7b9f385e432..4335be3af0d66 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -103,6 +103,7 @@ const ( UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id); UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); + UniqueIndexConnectionLogsConnectionIDWorkspaceIDAgentName UniqueConstraint = "idx_connection_logs_connection_id_workspace_id_agent_name" // CREATE UNIQUE INDEX idx_connection_logs_connection_id_workspace_id_agent_name ON connection_logs USING btree (connection_id, workspace_id, agent_name); UniqueIndexCustomRolesNameLower UniqueConstraint = "idx_custom_roles_name_lower" // CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)) WHERE (deleted = false); UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text))); diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 39db141427a16..8ffbc5de53adb 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -478,26 +478,32 @@ func (p *DBTokenProvider) connLogInitRequest(ctx context.Context, w http.Respons return } - requestID := httpmw.RequestID(r) connLogger := *p.ConnectionLogger.Load() - err = connLogger.Export(ctx, database.ConnectionLog{ + err = connLogger.Upsert(ctx, database.UpsertConnectionLogParams{ ID: uuid.New(), Time: aReq.time, - ConnectionID: requestID, 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, - Action: database.ConnectionActionOpen, + Type: database.ConnectionTypeWeb, Code: statusCode, Ip: database.ParseIP(ip), UserAgent: sql.NullString{Valid: userAgent != "", String: userAgent}, - UserID: userID, + UserID: uuid.NullUUID{ + UUID: userID, + Valid: userID != uuid.Nil, + }, SlugOrPort: sql.NullString{Valid: slugOrPort != "", String: slugOrPort}, + ConnectionAction: database.ConnectionActionConnect, + + // N/A + ConnectionID: uuid.NullUUID{}, + CloseReason: sql.NullString{}, }) if err != nil { - logger.Error(ctx, "insert connection log failed", slog.Error(err)) + logger.Error(ctx, "upsert connection log failed", slog.Error(err)) return } } diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index a49e6d66fdb7e..0f78a47ebe0d8 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -230,7 +230,7 @@ func Test_ResolveRequest(t *testing.T) { require.NotEqual(t, uuid.Nil, agentID) // Reset audit logs so cleanup check can pass. - connLogger.ResetLogs() + connLogger.Reset() t.Run("OK", func(t *testing.T) { t.Parallel() @@ -1257,23 +1257,26 @@ func signedTokenProviderWithConnLogger(t testing.TB, provider workspaceapps.Sign return &shallowCopy } -func assertConnLogContains(t testing.TB, rr *httptest.ResponseRecorder, r *http.Request, connLogger *connectionlog.MockConnectionLogger, workspace codersdk.Workspace, agentName string, slugOrPort string, userID uuid.UUID) { +func assertConnLogContains(t *testing.T, rr *httptest.ResponseRecorder, r *http.Request, connLogger *connectionlog.MockConnectionLogger, workspace codersdk.Workspace, agentName string, slugOrPort string, userID uuid.UUID) { t.Helper() resp := rr.Result() defer resp.Body.Close() - require.True(t, connLogger.Contains(t, database.ConnectionLog{ + require.True(t, connLogger.Contains(t, database.UpsertConnectionLogParams{ OrganizationID: workspace.OrganizationID, WorkspaceOwnerID: workspace.OwnerID, WorkspaceID: workspace.ID, WorkspaceName: workspace.Name, AgentName: agentName, - Action: database.ConnectionActionOpen, + Type: database.ConnectionTypeWeb, Ip: database.ParseIP(r.RemoteAddr), UserAgent: sql.NullString{Valid: r.UserAgent() != "", String: r.UserAgent()}, Code: int32(resp.StatusCode), // nolint:gosec - UserID: userID, - SlugOrPort: sql.NullString{Valid: slugOrPort != "", String: slugOrPort}, + UserID: uuid.NullUUID{ + UUID: userID, + Valid: true, + }, + SlugOrPort: sql.NullString{Valid: slugOrPort != "", String: slugOrPort}, })) } diff --git a/enterprise/coderd/connectionlog/connectionlog.go b/enterprise/coderd/connectionlog/connectionlog.go index 87a99a3f4619c..e428a13baf183 100644 --- a/enterprise/coderd/connectionlog/connectionlog.go +++ b/enterprise/coderd/connectionlog/connectionlog.go @@ -13,7 +13,7 @@ import ( ) type Backend interface { - Export(ctx context.Context, clog database.ConnectionLog) error + Upsert(ctx context.Context, clog database.UpsertConnectionLogParams) error } func NewConnectionLogger(backends ...Backend) agpl.ConnectionLogger { @@ -26,10 +26,10 @@ type connectionLogger struct { backends []Backend } -func (c *connectionLogger) Export(ctx context.Context, clog database.ConnectionLog) error { +func (c *connectionLogger) Upsert(ctx context.Context, clog database.UpsertConnectionLogParams) error { var errs error for _, backend := range c.backends { - err := backend.Export(ctx, clog) + err := backend.Upsert(ctx, clog) if err != nil { errs = multierror.Append(errs, err) } @@ -45,13 +45,10 @@ func NewDBBackend(db database.Store) Backend { return &dbBackend{db: db} } -func (b *dbBackend) Export(ctx context.Context, clog database.ConnectionLog) error { +func (b *dbBackend) Upsert(ctx context.Context, clog database.UpsertConnectionLogParams) error { //nolint:gocritic // This is the Connection Logger - _, err := b.db.InsertConnectionLog(dbauthz.AsConnectionLogger(ctx), database.InsertConnectionLogParams(clog)) - if err != nil { - return err - } - return nil + _, err := b.db.UpsertConnectionLog(dbauthz.AsConnectionLogger(ctx), clog) + return err } type connectionSlogBackend struct { @@ -64,6 +61,6 @@ func NewSlogBackend(logger slog.Logger) Backend { } } -func (b *connectionSlogBackend) Export(ctx context.Context, clog database.ConnectionLog) error { +func (b *connectionSlogBackend) Upsert(ctx context.Context, clog database.UpsertConnectionLogParams) error { return b.exporter.ExportStruct(ctx, clog, "connection_log") } From 6bdf3be3fc9a8d6a477c743973ad70d3990f5f87 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 13 Jun 2025 13:54:30 +0000 Subject: [PATCH 11/16] dbmem --- coderd/database/dbmem/dbmem.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index bd299653dfafe..450da78338865 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -12277,16 +12277,17 @@ func (q *FakeQuerier) UpsertConnectionLog(_ context.Context, arg database.Upsert q.mutex.Lock() defer q.mutex.Unlock() - if arg.ConnectionAction == "disconnect" { - for i, existing := range q.connectionLogs { - if existing.ConnectionID == arg.ConnectionID && - existing.WorkspaceID == arg.WorkspaceID && - existing.AgentName == arg.AgentName { - // Update existing connection with close time and reason - q.connectionLogs[i].CloseTime = sql.NullTime{Valid: true, Time: arg.Time} - q.connectionLogs[i].CloseReason = arg.CloseReason + for i, existing := range q.connectionLogs { + if existing.ConnectionID == arg.ConnectionID && + existing.WorkspaceID == arg.WorkspaceID && + existing.AgentName == arg.AgentName { + if arg.ConnectionAction != "disconnect" { return q.connectionLogs[i], nil } + // Update existing connection with close time and reason + q.connectionLogs[i].CloseTime = sql.NullTime{Valid: true, Time: arg.Time} + q.connectionLogs[i].CloseReason = arg.CloseReason + return q.connectionLogs[i], nil } } From d42bdffac734628f6f7e0ef90bb5547016fedb80 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 13 Jun 2025 13:57:00 +0000 Subject: [PATCH 12/16] fix migrations --- ...5_connection_logs.down.sql => 000336_connection_logs.down.sql} | 0 ...00335_connection_logs.up.sql => 000336_connection_logs.up.sql} | 0 ...00335_connection_logs.up.sql => 000336_connection_logs.up.sql} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000335_connection_logs.down.sql => 000336_connection_logs.down.sql} (100%) rename coderd/database/migrations/{000335_connection_logs.up.sql => 000336_connection_logs.up.sql} (100%) rename coderd/database/migrations/testdata/fixtures/{000335_connection_logs.up.sql => 000336_connection_logs.up.sql} (100%) diff --git a/coderd/database/migrations/000335_connection_logs.down.sql b/coderd/database/migrations/000336_connection_logs.down.sql similarity index 100% rename from coderd/database/migrations/000335_connection_logs.down.sql rename to coderd/database/migrations/000336_connection_logs.down.sql diff --git a/coderd/database/migrations/000335_connection_logs.up.sql b/coderd/database/migrations/000336_connection_logs.up.sql similarity index 100% rename from coderd/database/migrations/000335_connection_logs.up.sql rename to coderd/database/migrations/000336_connection_logs.up.sql diff --git a/coderd/database/migrations/testdata/fixtures/000335_connection_logs.up.sql b/coderd/database/migrations/testdata/fixtures/000336_connection_logs.up.sql similarity index 100% rename from coderd/database/migrations/testdata/fixtures/000335_connection_logs.up.sql rename to coderd/database/migrations/testdata/fixtures/000336_connection_logs.up.sql From a1302bdce90017a11b7198da4f8c5bbdade7ee3e Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 17 Jun 2025 05:38:36 +0000 Subject: [PATCH 13/16] null exit code until disconnect --- coderd/agentapi/connectionlog.go | 11 ++-- coderd/agentapi/connectionlog_test.go | 15 +++--- coderd/connectionlog/connectionlog.go | 4 +- coderd/database/db2sdk/db2sdk.go | 8 +-- coderd/database/dbauthz/dbauthz_test.go | 2 +- coderd/database/dbgen/dbgen.go | 7 ++- coderd/database/dbmem/dbmem.go | 2 +- coderd/database/dump.sql | 10 ++-- .../000336_connection_logs.down.sql | 2 +- .../migrations/000336_connection_logs.up.sql | 12 ++--- coderd/database/models.go | 50 +++++++++---------- coderd/database/querier_test.go | 16 +++--- coderd/database/queries.sql.go | 31 +++++++----- coderd/database/queries/connectionlogs.sql | 25 ++++++---- coderd/workspaceapps/db.go | 11 ++-- coderd/workspaceapps/db_test.go | 5 +- 16 files changed, 117 insertions(+), 94 deletions(-) diff --git a/coderd/agentapi/connectionlog.go b/coderd/agentapi/connectionlog.go index 0ca783adda2a5..9e7010a8e8a69 100644 --- a/coderd/agentapi/connectionlog.go +++ b/coderd/agentapi/connectionlog.go @@ -34,7 +34,7 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor if connectionID == uuid.Nil { return nil, xerrors.New("connection ID cannot be nil") } - action, err := db2sdk.ConnectionActionFromAgentProtoConnectionAction(req.GetConnection().GetAction()) + action, err := db2sdk.ConnectionLogStatusFromAgentProtoConnectionAction(req.GetConnection().GetAction()) if err != nil { return nil, err } @@ -64,8 +64,11 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor WorkspaceName: workspace.Name, AgentName: workspaceAgent.Name, Type: connectionType, - Code: req.GetConnection().GetStatusCode(), - Ip: database.ParseIP(req.GetConnection().GetIp()), + Code: sql.NullInt32{ + Int32: req.GetConnection().GetStatusCode(), + Valid: req.GetConnection().GetStatusCode() != 0, + }, + Ip: database.ParseIP(req.GetConnection().GetIp()), ConnectionID: uuid.NullUUID{ UUID: connectionID, Valid: true, @@ -76,7 +79,7 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor }, // Used to populate whether the connection was established or closed // outside of the DB (slog). - ConnectionAction: action, + ConnectionStatus: action, // It's not possible to tell which user connected. Once we have // the capability, this may be reported by the agent. diff --git a/coderd/agentapi/connectionlog_test.go b/coderd/agentapi/connectionlog_test.go index 1263699922497..91c950e7dc1c7 100644 --- a/coderd/agentapi/connectionlog_test.go +++ b/coderd/agentapi/connectionlog_test.go @@ -140,11 +140,14 @@ func TestConnectionLog(t *testing.T) { UUID: uuid.Nil, Valid: false, }, - ConnectionAction: connectionLogActionFromAgentProtoConnectionAction(t, *tt.action), + ConnectionStatus: agentProtoConnectionActionToConnectionLog(t, *tt.action), - Code: tt.status, + Code: sql.NullInt32{ + Int32: tt.status, + Valid: tt.status != 0, + }, Ip: pqtype.Inet{Valid: true, IPNet: net.IPNet{IP: net.ParseIP(tt.ip), Mask: net.CIDRMask(32, 32)}}, - Type: connectionLogConnectionTypeFromAgentProtoConnectionType(t, *tt.typ), + Type: agentProtoConnectionTypeToConnectionLog(t, *tt.typ), CloseReason: sql.NullString{ String: tt.reason, Valid: tt.reason != "", @@ -158,14 +161,14 @@ func TestConnectionLog(t *testing.T) { } } -func connectionLogConnectionTypeFromAgentProtoConnectionType(t *testing.T, typ agentproto.Connection_Type) database.ConnectionType { +func agentProtoConnectionTypeToConnectionLog(t *testing.T, typ agentproto.Connection_Type) database.ConnectionType { a, err := db2sdk.ConnectionLogConnectionTypeFromAgentProtoConnectionType(typ) require.NoError(t, err) return a } -func connectionLogActionFromAgentProtoConnectionAction(t *testing.T, action agentproto.Connection_Action) database.ConnectionAction { - a, err := db2sdk.ConnectionActionFromAgentProtoConnectionAction(action) +func agentProtoConnectionActionToConnectionLog(t *testing.T, action agentproto.Connection_Action) database.ConnectionStatus { + a, err := db2sdk.ConnectionLogStatusFromAgentProtoConnectionAction(action) require.NoError(t, err) return a } diff --git a/coderd/connectionlog/connectionlog.go b/coderd/connectionlog/connectionlog.go index 17ccfe25f73e3..4bd70e8bf6b19 100644 --- a/coderd/connectionlog/connectionlog.go +++ b/coderd/connectionlog/connectionlog.go @@ -90,8 +90,8 @@ func (m *MockConnectionLogger) Contains(t testing.TB, expected database.UpsertCo t.Logf("connection log %d: expected Type %s, got %s", idx+1, expected.Type, cl.Type) continue } - if expected.Code != 0 && cl.Code != expected.Code { - t.Logf("connection log %d: expected Code %d, got %d", idx+1, expected.Code, cl.Code) + if expected.Code.Valid && cl.Code.Int32 != expected.Code.Int32 { + t.Logf("connection log %d: expected Code %d, got %d", idx+1, expected.Code.Int32, cl.Code.Int32) continue } if expected.Ip.Valid && cl.Ip.IPNet.String() != expected.Ip.IPNet.String() { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index eaf585cb8793d..af93327739cfa 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -738,17 +738,17 @@ func ConnectionLogConnectionTypeFromAgentProtoConnectionType(typ agentproto.Conn case agentproto.Connection_RECONNECTING_PTY: return database.ConnectionTypeReconnectingPty, nil default: - // Also Connection_ACTION_UNSPECIFIED, no mapping. + // Also Connection_TYPE_UNSPECIFIED, no mapping. return "", xerrors.Errorf("unknown agent connection type %q", typ) } } -func ConnectionActionFromAgentProtoConnectionAction(action agentproto.Connection_Action) (database.ConnectionAction, error) { +func ConnectionLogStatusFromAgentProtoConnectionAction(action agentproto.Connection_Action) (database.ConnectionStatus, error) { switch action { case agentproto.Connection_CONNECT: - return database.ConnectionActionConnect, nil + return database.ConnectionStatusConnected, nil case agentproto.Connection_DISCONNECT: - return database.ConnectionActionDisconnect, nil + return database.ConnectionStatusDisconnected, nil default: // Also Connection_ACTION_UNSPECIFIED, no mapping. return "", xerrors.Errorf("unknown agent connection action %q", action) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e03f7196f3b06..c9814d5e0dee6 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -351,7 +351,7 @@ func (s *MethodTestSuite) TestConnectionLogs() { Type: database.ConnectionTypeSsh, WorkspaceID: ws.ID, OrganizationID: ws.OrganizationID, - ConnectionAction: database.ConnectionActionConnect, + ConnectionStatus: database.ConnectionStatusConnected, WorkspaceOwnerID: ws.OwnerID, }).Asserts(rbac.ResourceConnectionLog, policy.ActionCreate) })) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index c7da30007b2a4..5d8d621ca3a99 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -83,7 +83,10 @@ func ConnectionLog(t testing.TB, db database.Store, seed database.ConnectionLog) WorkspaceName: takeFirst(seed.WorkspaceName, testutil.GetRandomName(t)), AgentName: takeFirst(seed.AgentName, testutil.GetRandomName(t)), Type: takeFirst(seed.Type, database.ConnectionTypeSsh), - Code: takeFirst(seed.Code, 0), + Code: sql.NullInt32{ + Int32: takeFirst(seed.Code.Int32, 0), + Valid: takeFirst(seed.Code.Valid, false), + }, Ip: pqtype.Inet{ IPNet: takeFirstIP(seed.Ip.IPNet, net.IPNet{}), Valid: takeFirst(seed.Ip.Valid, false), @@ -108,7 +111,7 @@ func ConnectionLog(t testing.TB, db database.Store, seed database.ConnectionLog) String: takeFirst(seed.CloseReason.String, ""), Valid: takeFirst(seed.CloseReason.Valid, false), }, - ConnectionAction: database.ConnectionActionConnect, + ConnectionStatus: database.ConnectionStatusConnected, }) require.NoError(t, err, "insert connection log") return log diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 450da78338865..ccc6e778e9b77 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -12281,7 +12281,7 @@ func (q *FakeQuerier) UpsertConnectionLog(_ context.Context, arg database.Upsert if existing.ConnectionID == arg.ConnectionID && existing.WorkspaceID == arg.WorkspaceID && existing.AgentName == arg.AgentName { - if arg.ConnectionAction != "disconnect" { + if arg.ConnectionStatus != database.ConnectionStatusDisconnected { return q.connectionLogs[i], nil } // Update existing connection with close time and reason diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 7629069046832..5d8d3ff84452e 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -53,9 +53,9 @@ CREATE TYPE build_reason AS ENUM ( 'autodelete' ); -CREATE TYPE connection_action AS ENUM ( - 'connect', - 'disconnect' +CREATE TYPE connection_status AS ENUM ( + 'connected', + 'disconnected' ); CREATE TYPE connection_type AS ENUM ( @@ -869,7 +869,7 @@ CREATE TABLE connection_logs ( workspace_name text NOT NULL, agent_name text NOT NULL, type connection_type NOT NULL, - code integer NOT NULL, + code integer, ip inet, user_agent text, user_id uuid, @@ -879,7 +879,7 @@ CREATE TABLE connection_logs ( close_reason text ); -COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection.'; +COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection. For SSH actions, this is Null if the connection is still open.'; COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For web connections, this is the User-Agent header from the request.'; diff --git a/coderd/database/migrations/000336_connection_logs.down.sql b/coderd/database/migrations/000336_connection_logs.down.sql index 1b688cec5fb25..398d9534b8f06 100644 --- a/coderd/database/migrations/000336_connection_logs.down.sql +++ b/coderd/database/migrations/000336_connection_logs.down.sql @@ -7,4 +7,4 @@ DROP TABLE IF EXISTS connection_logs; DROP TYPE IF EXISTS connection_type; -DROP TYPE IF EXISTS connection_action; +DROP TYPE IF EXISTS connection_status; diff --git a/coderd/database/migrations/000336_connection_logs.up.sql b/coderd/database/migrations/000336_connection_logs.up.sql index 31efd0bc5cfca..32d80bea0cca3 100644 --- a/coderd/database/migrations/000336_connection_logs.up.sql +++ b/coderd/database/migrations/000336_connection_logs.up.sql @@ -1,6 +1,6 @@ -CREATE TYPE connection_action AS ENUM ( - 'connect', - 'disconnect' +CREATE TYPE connection_status AS ENUM ( + 'connected', + 'disconnected' ); CREATE TYPE connection_type AS ENUM ( @@ -22,7 +22,7 @@ CREATE TABLE connection_logs ( workspace_name text NOT NULL, agent_name text NOT NULL, type connection_type NOT NULL, - code integer NOT NULL, + code integer, -- Null until we upsert a disconnected log for the same connection_id. ip inet, -- Only set for 'web' logs. @@ -32,14 +32,14 @@ CREATE TABLE connection_logs ( -- Null for 'web' logs. connection_id uuid, - close_time timestamp with time zone, -- Null until we receive a second event for the same connection_id. + close_time timestamp with time zone, -- Null until we upsert a disconnected log for the same connection_id. close_reason text, PRIMARY KEY (id) ); -COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection.'; +COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection. For SSH actions, this is Null if the connection is still open, or if we never received a disconnect log..'; COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For web connections, this is the User-Agent header from the request.'; diff --git a/coderd/database/models.go b/coderd/database/models.go index afa4bcf88a3dc..5e9f34da2b899 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -413,61 +413,61 @@ func AllBuildReasonValues() []BuildReason { } } -type ConnectionAction string +type ConnectionStatus string const ( - ConnectionActionConnect ConnectionAction = "connect" - ConnectionActionDisconnect ConnectionAction = "disconnect" + ConnectionStatusConnected ConnectionStatus = "connected" + ConnectionStatusDisconnected ConnectionStatus = "disconnected" ) -func (e *ConnectionAction) Scan(src interface{}) error { +func (e *ConnectionStatus) Scan(src interface{}) error { switch s := src.(type) { case []byte: - *e = ConnectionAction(s) + *e = ConnectionStatus(s) case string: - *e = ConnectionAction(s) + *e = ConnectionStatus(s) default: - return fmt.Errorf("unsupported scan type for ConnectionAction: %T", src) + return fmt.Errorf("unsupported scan type for ConnectionStatus: %T", src) } return nil } -type NullConnectionAction struct { - ConnectionAction ConnectionAction `json:"connection_action"` - Valid bool `json:"valid"` // Valid is true if ConnectionAction is not NULL +type NullConnectionStatus struct { + ConnectionStatus ConnectionStatus `json:"connection_status"` + Valid bool `json:"valid"` // Valid is true if ConnectionStatus is not NULL } // Scan implements the Scanner interface. -func (ns *NullConnectionAction) Scan(value interface{}) error { +func (ns *NullConnectionStatus) Scan(value interface{}) error { if value == nil { - ns.ConnectionAction, ns.Valid = "", false + ns.ConnectionStatus, ns.Valid = "", false return nil } ns.Valid = true - return ns.ConnectionAction.Scan(value) + return ns.ConnectionStatus.Scan(value) } // Value implements the driver Valuer interface. -func (ns NullConnectionAction) Value() (driver.Value, error) { +func (ns NullConnectionStatus) Value() (driver.Value, error) { if !ns.Valid { return nil, nil } - return string(ns.ConnectionAction), nil + return string(ns.ConnectionStatus), nil } -func (e ConnectionAction) Valid() bool { +func (e ConnectionStatus) Valid() bool { switch e { - case ConnectionActionConnect, - ConnectionActionDisconnect: + case ConnectionStatusConnected, + ConnectionStatusDisconnected: return true } return false } -func AllConnectionActionValues() []ConnectionAction { - return []ConnectionAction{ - ConnectionActionConnect, - ConnectionActionDisconnect, +func AllConnectionStatusValues() []ConnectionStatus { + return []ConnectionStatus{ + ConnectionStatusConnected, + ConnectionStatusDisconnected, } } @@ -2927,9 +2927,9 @@ type ConnectionLog struct { WorkspaceName string `db:"workspace_name" json:"workspace_name"` AgentName string `db:"agent_name" json:"agent_name"` Type ConnectionType `db:"type" json:"type"` - // Either the HTTP status code of the web request, or the exit code of an SSH connection. - Code int32 `db:"code" json:"code"` - Ip pqtype.Inet `db:"ip" json:"ip"` + // Either the HTTP status code of the web request, or the exit code of an SSH connection. For SSH actions, this is Null if the connection is still open. + Code sql.NullInt32 `db:"code" json:"code"` + Ip pqtype.Inet `db:"ip" json:"ip"` // Null for SSH actions. For web connections, this is the User-Agent header from the request. UserAgent sql.NullString `db:"user_agent" json:"user_agent"` // uuid.Nil for SSH actions. For web connections, this is the ID of the user that made the request. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 0d3da76b9c0df..322d18223a03d 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -2261,9 +2261,8 @@ func TestUpsertConnectionLog(t *testing.T) { WorkspaceName: ws.Name, AgentName: agentName, Type: database.ConnectionTypeSsh, - Code: 0, ConnectionID: uuid.NullUUID{UUID: connectionID, Valid: true}, - ConnectionAction: database.ConnectionActionConnect, + ConnectionStatus: database.ConnectionStatusConnected, } log1, err := db.UpsertConnectionLog(ctx, connectParams) @@ -2277,17 +2276,18 @@ func TestUpsertConnectionLog(t *testing.T) { require.Len(t, rows, 1) require.Equal(t, int64(1), rows[0].Count) - // 2. Insert a 'disconnect' event for the same connection. + // 2. Insert a 'disconnected' event for the same connection. disconnectTime := connectTime.Add(time.Second) disconnectParams := database.UpsertConnectionLogParams{ ConnectionID: uuid.NullUUID{UUID: connectionID, Valid: true}, WorkspaceID: ws.ID, AgentName: agentName, - ConnectionAction: database.ConnectionActionDisconnect, + ConnectionStatus: database.ConnectionStatusDisconnected, // Updated to: Time: disconnectTime, CloseReason: sql.NullString{String: "test disconnect", Valid: true}, + Code: sql.NullInt32{Int32: 1, Valid: true}, // Ignored ID: uuid.New(), @@ -2295,7 +2295,6 @@ func TestUpsertConnectionLog(t *testing.T) { WorkspaceOwnerID: ws.OwnerID, WorkspaceName: ws.Name, Type: database.ConnectionTypeSsh, - Code: 0, } log2, err := db.UpsertConnectionLog(ctx, disconnectParams) @@ -2334,9 +2333,8 @@ func TestUpsertConnectionLog(t *testing.T) { WorkspaceName: ws.Name, AgentName: agentName, Type: database.ConnectionTypeSsh, - Code: 0, ConnectionID: uuid.NullUUID{UUID: connectionID, Valid: true}, - ConnectionAction: database.ConnectionActionConnect, + ConnectionStatus: database.ConnectionStatusConnected, } log, err := db.UpsertConnectionLog(ctx, connectParams) @@ -2348,7 +2346,7 @@ func TestUpsertConnectionLog(t *testing.T) { ConnectionID: uuid.NullUUID{UUID: connectionID, Valid: true}, WorkspaceID: ws.ID, AgentName: agentName, - ConnectionAction: database.ConnectionActionConnect, + ConnectionStatus: database.ConnectionStatusConnected, // Ignored ID: uuid.New(), @@ -2357,7 +2355,7 @@ func TestUpsertConnectionLog(t *testing.T) { WorkspaceOwnerID: ws.OwnerID, WorkspaceName: ws.Name, Type: database.ConnectionTypeSsh, - Code: 0, + Code: sql.NullInt32{Int32: 0, Valid: false}, } origLog, err := db.UpsertConnectionLog(ctx, connectParams2) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7351e432d7d0b..32056fe72e60c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1071,16 +1071,21 @@ INSERT INTO connection_logs ( ON CONFLICT (connection_id, workspace_id, agent_name) DO UPDATE SET -- No-op if the connection is still open. - close_time = CASE - WHEN $16::connection_action = 'disconnect' - THEN EXCLUDED."time" - ELSE connection_logs.close_time - END, - close_reason = CASE - WHEN $16::connection_action = 'disconnect' - THEN EXCLUDED.close_reason - ELSE connection_logs.close_reason - END + close_time = CASE + WHEN $16::connection_status = 'disconnected' + THEN EXCLUDED."time" + ELSE connection_logs.close_time + END, + close_reason = CASE + WHEN $16::connection_status = 'disconnected' + THEN EXCLUDED.close_reason + ELSE connection_logs.close_reason + END, + code = CASE + WHEN $16::connection_status = 'disconnected' + THEN EXCLUDED.code + ELSE connection_logs.code + END RETURNING id, time, organization_id, workspace_owner_id, workspace_id, workspace_name, agent_name, type, code, ip, user_agent, user_id, slug_or_port, connection_id, close_time, close_reason ` @@ -1093,14 +1098,14 @@ type UpsertConnectionLogParams struct { WorkspaceName string `db:"workspace_name" json:"workspace_name"` AgentName string `db:"agent_name" json:"agent_name"` Type ConnectionType `db:"type" json:"type"` - Code int32 `db:"code" json:"code"` + Code sql.NullInt32 `db:"code" json:"code"` Ip pqtype.Inet `db:"ip" json:"ip"` UserAgent sql.NullString `db:"user_agent" json:"user_agent"` UserID uuid.NullUUID `db:"user_id" json:"user_id"` SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"` ConnectionID uuid.NullUUID `db:"connection_id" json:"connection_id"` CloseReason sql.NullString `db:"close_reason" json:"close_reason"` - ConnectionAction ConnectionAction `db:"connection_action" json:"connection_action"` + ConnectionStatus ConnectionStatus `db:"connection_status" json:"connection_status"` } func (q *sqlQuerier) UpsertConnectionLog(ctx context.Context, arg UpsertConnectionLogParams) (ConnectionLog, error) { @@ -1120,7 +1125,7 @@ func (q *sqlQuerier) UpsertConnectionLog(ctx context.Context, arg UpsertConnecti arg.SlugOrPort, arg.ConnectionID, arg.CloseReason, - arg.ConnectionAction, + arg.ConnectionStatus, ) var i ConnectionLog err := row.Scan( diff --git a/coderd/database/queries/connectionlogs.sql b/coderd/database/queries/connectionlogs.sql index b28c86f8b7c6c..9fd6c83c9038d 100644 --- a/coderd/database/queries/connectionlogs.sql +++ b/coderd/database/queries/connectionlogs.sql @@ -47,14 +47,19 @@ INSERT INTO connection_logs ( ON CONFLICT (connection_id, workspace_id, agent_name) DO UPDATE SET -- No-op if the connection is still open. - close_time = CASE - WHEN @connection_action::connection_action = 'disconnect' - THEN EXCLUDED."time" - ELSE connection_logs.close_time - END, - close_reason = CASE - WHEN @connection_action::connection_action = 'disconnect' - THEN EXCLUDED.close_reason - ELSE connection_logs.close_reason - END + close_time = CASE + WHEN @connection_status::connection_status = 'disconnected' + THEN EXCLUDED."time" + ELSE connection_logs.close_time + END, + close_reason = CASE + WHEN @connection_status::connection_status = 'disconnected' + THEN EXCLUDED.close_reason + ELSE connection_logs.close_reason + END, + code = CASE + WHEN @connection_status::connection_status = 'disconnected' + THEN EXCLUDED.code + ELSE connection_logs.code + END RETURNING *; diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 8ffbc5de53adb..f0803789d149a 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -488,15 +488,18 @@ func (p *DBTokenProvider) connLogInitRequest(ctx context.Context, w http.Respons WorkspaceName: aReq.dbReq.Workspace.Name, AgentName: aReq.dbReq.Agent.Name, Type: database.ConnectionTypeWeb, - Code: statusCode, - Ip: database.ParseIP(ip), - UserAgent: sql.NullString{Valid: userAgent != "", String: userAgent}, + 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}, - ConnectionAction: database.ConnectionActionConnect, + ConnectionStatus: database.ConnectionStatusConnected, // N/A ConnectionID: uuid.NullUUID{}, diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index 0f78a47ebe0d8..34a4b8a1a3ad0 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -1272,7 +1272,10 @@ func assertConnLogContains(t *testing.T, rr *httptest.ResponseRecorder, r *http. Type: database.ConnectionTypeWeb, Ip: database.ParseIP(r.RemoteAddr), UserAgent: sql.NullString{Valid: r.UserAgent() != "", String: r.UserAgent()}, - Code: int32(resp.StatusCode), // nolint:gosec + Code: sql.NullInt32{ + Int32: int32(resp.StatusCode), // nolint:gosec + Valid: true, + }, UserID: uuid.NullUUID{ UUID: userID, Valid: true, From 154bec560a12062bb68155815ae61a9dc126dfad Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 24 Jun 2025 04:25:06 +0000 Subject: [PATCH 14/16] join on extra columns --- coderd/database/dbauthz/dbauthz_test.go | 8 +-- coderd/database/dbgen/dbgen.go | 4 +- coderd/database/dbmem/dbmem.go | 44 ++++++++++++- coderd/database/dump.sql | 6 +- .../migrations/000336_connection_logs.up.sql | 10 +-- coderd/database/modelqueries.go | 15 +++++ coderd/database/models.go | 6 +- coderd/database/querier_test.go | 2 +- coderd/database/queries.sql.go | 66 +++++++++++++++++-- coderd/database/queries/connectionlogs.sql | 28 +++++++- codersdk/agentsdk/convert.go | 34 ---------- 11 files changed, 160 insertions(+), 63 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index c9814d5e0dee6..d8faa90883b3f 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -357,13 +357,13 @@ func (s *MethodTestSuite) TestConnectionLogs() { })) s.Run("GetConnectionLogsOffset", s.Subtest(func(db database.Store, check *expects) { ws := createWorkspace(s.T(), db) - _ = dbgen.ConnectionLog(s.T(), db, database.ConnectionLog{ + _ = dbgen.ConnectionLog(s.T(), db, database.UpsertConnectionLogParams{ Type: database.ConnectionTypeSsh, WorkspaceID: ws.ID, OrganizationID: ws.OrganizationID, WorkspaceOwnerID: ws.OwnerID, }) - _ = dbgen.ConnectionLog(s.T(), db, database.ConnectionLog{ + _ = dbgen.ConnectionLog(s.T(), db, database.UpsertConnectionLogParams{ Type: database.ConnectionTypeSsh, WorkspaceID: ws.ID, OrganizationID: ws.OrganizationID, @@ -375,13 +375,13 @@ func (s *MethodTestSuite) TestConnectionLogs() { })) s.Run("GetAuthorizedConnectionLogsOffset", s.Subtest(func(db database.Store, check *expects) { ws := createWorkspace(s.T(), db) - _ = dbgen.ConnectionLog(s.T(), db, database.ConnectionLog{ + _ = dbgen.ConnectionLog(s.T(), db, database.UpsertConnectionLogParams{ Type: database.ConnectionTypeSsh, WorkspaceID: ws.ID, OrganizationID: ws.OrganizationID, WorkspaceOwnerID: ws.OwnerID, }) - _ = dbgen.ConnectionLog(s.T(), db, database.ConnectionLog{ + _ = dbgen.ConnectionLog(s.T(), db, database.UpsertConnectionLogParams{ Type: database.ConnectionTypeSsh, WorkspaceID: ws.ID, OrganizationID: ws.OrganizationID, diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 5d8d621ca3a99..1a4ad35b25826 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -73,7 +73,7 @@ func AuditLog(t testing.TB, db database.Store, seed database.AuditLog) database. return log } -func ConnectionLog(t testing.TB, db database.Store, seed database.ConnectionLog) database.ConnectionLog { +func ConnectionLog(t testing.TB, db database.Store, seed database.UpsertConnectionLogParams) database.ConnectionLog { log, err := db.UpsertConnectionLog(genCtx, database.UpsertConnectionLogParams{ ID: takeFirst(seed.ID, uuid.New()), Time: takeFirst(seed.Time, dbtime.Now()), @@ -111,7 +111,7 @@ func ConnectionLog(t testing.TB, db database.Store, seed database.ConnectionLog) String: takeFirst(seed.CloseReason.String, ""), Valid: takeFirst(seed.CloseReason.Valid, false), }, - ConnectionStatus: database.ConnectionStatusConnected, + ConnectionStatus: takeFirst(seed.ConnectionStatus, database.ConnectionStatusConnected), }) require.NoError(t, err, "insert connection log") return log diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index ccc6e778e9b77..f60001eea6774 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -13951,9 +13951,51 @@ func (q *FakeQuerier) GetAuthorizedConnectionLogsOffset(ctx context.Context, arg continue } + workspaceOwner, err := q.getUserByIDNoLock(clog.WorkspaceOwnerID) + if err != nil { + continue // JOIN on workspace_owner failed + } + org, err := q.getOrganizationByIDNoLock(clog.OrganizationID) + if err != nil { + continue // JOIN on organizations failed + } + workspace, err := q.getWorkspaceByIDNoLock(ctx, clog.WorkspaceID) + if err != nil { + continue // JOIN on workspaces failed + } + + // LEFT JOIN on users + var user database.User + var userErr error + if clog.UserID.Valid { + user, userErr = q.getUserByIDNoLock(clog.UserID.UUID) + } + userValid := clog.UserID.Valid && userErr == nil + + // Append the fully hydrated row logs = append(logs, database.GetConnectionLogsOffsetRow{ - ConnectionLog: clog, + ConnectionLog: clog, + WorkspaceDeleted: workspace.Deleted, + WorkspaceOwnerUsername: workspaceOwner.Username, + OrganizationName: org.Name, + OrganizationDisplayName: org.DisplayName, + OrganizationIcon: org.Icon, + UserUsername: sql.NullString{String: user.Username, Valid: userValid}, + UserName: sql.NullString{String: user.Name, Valid: userValid}, + UserEmail: sql.NullString{String: user.Email, Valid: userValid}, + UserCreatedAt: sql.NullTime{Time: user.CreatedAt, Valid: userValid}, + UserUpdatedAt: sql.NullTime{Time: user.UpdatedAt, Valid: userValid}, + UserLastSeenAt: sql.NullTime{Time: user.LastSeenAt, Valid: userValid}, + UserStatus: database.NullUserStatus{UserStatus: user.Status, Valid: userValid}, + UserLoginType: database.NullLoginType{LoginType: user.LoginType, Valid: userValid}, + UserRoles: user.RBACRoles, + UserAvatarUrl: sql.NullString{String: user.AvatarURL, Valid: userValid}, + UserDeleted: sql.NullBool{Bool: user.Deleted, Valid: userValid}, + UserQuietHoursSchedule: sql.NullString{String: user.QuietHoursSchedule, Valid: userValid}, + Count: 0, // Will be set after the loop. }) + + // Apply LIMIT, same as the audit log implementation. if len(logs) >= int(arg.LimitOpt) { break } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 5d8d3ff84452e..7ed4a452bdbdc 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -879,7 +879,7 @@ CREATE TABLE connection_logs ( close_reason text ); -COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection. For SSH actions, this is Null if the connection is still open.'; +COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection. For non-web connections, this is Null until we receive a disconnect event for the same connection_id.'; COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For web connections, this is the User-Agent header from the request.'; @@ -889,9 +889,9 @@ COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for SSH actions. For web COMMENT ON COLUMN connection_logs.connection_id IS 'The SSH connection ID. Used to correlate connections and disconnections. As it originates from the agent, it is not guaranteed to be unique.'; -COMMENT ON COLUMN connection_logs.close_time IS 'Null for web connections. For SSH actions, Null until we receive a second event for the same connection_id. This is the time when the connection was closed.'; +COMMENT ON COLUMN connection_logs.close_time IS 'The time the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.'; -COMMENT ON COLUMN connection_logs.close_reason IS 'Null for web connections. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI.'; +COMMENT ON COLUMN connection_logs.close_reason IS 'The reason the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.'; CREATE TABLE crypto_keys ( feature crypto_key_feature NOT NULL, diff --git a/coderd/database/migrations/000336_connection_logs.up.sql b/coderd/database/migrations/000336_connection_logs.up.sql index 32d80bea0cca3..d84dceee79ee0 100644 --- a/coderd/database/migrations/000336_connection_logs.up.sql +++ b/coderd/database/migrations/000336_connection_logs.up.sql @@ -22,7 +22,7 @@ CREATE TABLE connection_logs ( workspace_name text NOT NULL, agent_name text NOT NULL, type connection_type NOT NULL, - code integer, -- Null until we upsert a disconnected log for the same connection_id. + code integer, ip inet, -- Only set for 'web' logs. @@ -32,14 +32,14 @@ CREATE TABLE connection_logs ( -- Null for 'web' logs. connection_id uuid, - close_time timestamp with time zone, -- Null until we upsert a disconnected log for the same connection_id. + close_time timestamp with time zone, -- Null until we upsert a disconnect log for the same connection_id. close_reason text, PRIMARY KEY (id) ); -COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection. For SSH actions, this is Null if the connection is still open, or if we never received a disconnect log..'; +COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection. For non-web connections, this is Null until we receive a disconnect event for the same connection_id.'; COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For web connections, this is the User-Agent header from the request.'; @@ -49,9 +49,9 @@ COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for SSH actions. For web COMMENT ON COLUMN connection_logs.connection_id IS 'The SSH connection ID. Used to correlate connections and disconnections. As it originates from the agent, it is not guaranteed to be unique.'; -COMMENT ON COLUMN connection_logs.close_time IS 'Null for web connections. For SSH actions, Null until we receive a second event for the same connection_id. This is the time when the connection was closed.'; +COMMENT ON COLUMN connection_logs.close_time IS 'The time the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.'; -COMMENT ON COLUMN connection_logs.close_reason IS 'Null for web connections. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI.'; +COMMENT ON COLUMN connection_logs.close_reason IS 'The reason the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.'; COMMENT ON TYPE audit_action IS 'NOTE: `connect`, `disconnect`, `open`, and `close` are deprecated and no longer used - these events are now tracked in the connection_logs table.'; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 3241bf8798352..84619941f9756 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -576,8 +576,23 @@ func (q *sqlQuerier) GetAuthorizedConnectionLogsOffset(ctx context.Context, arg &i.ConnectionLog.ConnectionID, &i.ConnectionLog.CloseTime, &i.ConnectionLog.CloseReason, + &i.WorkspaceDeleted, &i.UserUsername, + &i.UserName, + &i.UserEmail, + &i.UserCreatedAt, + &i.UserUpdatedAt, + &i.UserLastSeenAt, + &i.UserStatus, + &i.UserLoginType, + &i.UserRoles, + &i.UserAvatarUrl, + &i.UserDeleted, + &i.UserQuietHoursSchedule, &i.WorkspaceOwnerUsername, + &i.OrganizationName, + &i.OrganizationDisplayName, + &i.OrganizationIcon, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/models.go b/coderd/database/models.go index 5e9f34da2b899..3c473fd5c83dd 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2927,7 +2927,7 @@ type ConnectionLog struct { WorkspaceName string `db:"workspace_name" json:"workspace_name"` AgentName string `db:"agent_name" json:"agent_name"` Type ConnectionType `db:"type" json:"type"` - // Either the HTTP status code of the web request, or the exit code of an SSH connection. For SSH actions, this is Null if the connection is still open. + // Either the HTTP status code of the web request, or the exit code of an SSH connection. For non-web connections, this is Null until we receive a disconnect event for the same connection_id. Code sql.NullInt32 `db:"code" json:"code"` Ip pqtype.Inet `db:"ip" json:"ip"` // Null for SSH actions. For web connections, this is the User-Agent header from the request. @@ -2938,9 +2938,9 @@ type ConnectionLog struct { SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"` // The SSH connection ID. Used to correlate connections and disconnections. As it originates from the agent, it is not guaranteed to be unique. ConnectionID uuid.NullUUID `db:"connection_id" json:"connection_id"` - // Null for web connections. For SSH actions, Null until we receive a second event for the same connection_id. This is the time when the connection was closed. + // The time the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id. CloseTime sql.NullTime `db:"close_time" json:"close_time"` - // Null for web connections. For SSH actions, this is the reason for the connection or disconnection, to be displayed in the UI. + // The reason the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id. CloseReason sql.NullString `db:"close_reason" json:"close_reason"` } diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 322d18223a03d..1f27629f01e28 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -2088,7 +2088,7 @@ func TestGetAuthorizedConnectionLogsOffset(t *testing.T) { } for orgID, ids := range orgConnectionLogs { for _, id := range ids { - allLogs = append(allLogs, dbgen.ConnectionLog(t, authDb, database.ConnectionLog{ + allLogs = append(allLogs, dbgen.ConnectionLog(t, authDb, database.UpsertConnectionLogParams{ WorkspaceID: wsID, WorkspaceOwnerID: user.ID, ID: id, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 32056fe72e60c..91d51083954fe 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -970,15 +970,37 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam const getConnectionLogsOffset = `-- name: GetConnectionLogsOffset :many SELECT connection_logs.id, connection_logs.time, connection_logs.organization_id, connection_logs.workspace_owner_id, connection_logs.workspace_id, connection_logs.workspace_name, connection_logs.agent_name, connection_logs.type, connection_logs.code, connection_logs.ip, connection_logs.user_agent, connection_logs.user_id, connection_logs.slug_or_port, connection_logs.connection_id, connection_logs.close_time, connection_logs.close_reason, - users.username AS user_username, + workspaces.deleted AS workspace_deleted, + -- sqlc.embed(users) would be nice but it does not seem to play well with + -- left joins. This user metadata is necessary for parity with the audit logs + -- API. + users.username AS user_username, + users.name AS user_name, + users.email AS user_email, + users.created_at AS user_created_at, + users.updated_at AS user_updated_at, + users.last_seen_at AS user_last_seen_at, + users.status AS user_status, + users.login_type AS user_login_type, + users.rbac_roles AS user_roles, + users.avatar_url AS user_avatar_url, + users.deleted AS user_deleted, + users.quiet_hours_schedule AS user_quiet_hours_schedule, workspace_owner.username AS workspace_owner_username, + organizations.name as organization_name, + organizations.display_name as organization_display_name, + organizations.icon as organization_icon, COUNT(connection_logs.*) OVER () AS count FROM connection_logs +JOIN users AS workspace_owner ON + connection_logs.workspace_owner_id = workspace_owner.id LEFT JOIN users ON connection_logs.user_id = users.id -LEFT JOIN users as workspace_owner ON - connection_logs.workspace_owner_id = workspace_owner.id +JOIN organizations ON + connection_logs.organization_id = organizations.id +JOIN workspaces ON + connection_logs.workspace_id = workspaces.id WHERE TRUE -- Authorize Filter clause will be injected below in -- GetAuthorizedConnectionLogsOffset @@ -1000,10 +1022,25 @@ type GetConnectionLogsOffsetParams struct { } type GetConnectionLogsOffsetRow struct { - ConnectionLog ConnectionLog `db:"connection_log" json:"connection_log"` - UserUsername sql.NullString `db:"user_username" json:"user_username"` - WorkspaceOwnerUsername sql.NullString `db:"workspace_owner_username" json:"workspace_owner_username"` - Count int64 `db:"count" json:"count"` + ConnectionLog ConnectionLog `db:"connection_log" json:"connection_log"` + WorkspaceDeleted bool `db:"workspace_deleted" json:"workspace_deleted"` + UserUsername sql.NullString `db:"user_username" json:"user_username"` + UserName sql.NullString `db:"user_name" json:"user_name"` + UserEmail sql.NullString `db:"user_email" json:"user_email"` + UserCreatedAt sql.NullTime `db:"user_created_at" json:"user_created_at"` + UserUpdatedAt sql.NullTime `db:"user_updated_at" json:"user_updated_at"` + UserLastSeenAt sql.NullTime `db:"user_last_seen_at" json:"user_last_seen_at"` + UserStatus NullUserStatus `db:"user_status" json:"user_status"` + UserLoginType NullLoginType `db:"user_login_type" json:"user_login_type"` + UserRoles pq.StringArray `db:"user_roles" json:"user_roles"` + UserAvatarUrl sql.NullString `db:"user_avatar_url" json:"user_avatar_url"` + UserDeleted sql.NullBool `db:"user_deleted" json:"user_deleted"` + UserQuietHoursSchedule sql.NullString `db:"user_quiet_hours_schedule" json:"user_quiet_hours_schedule"` + WorkspaceOwnerUsername string `db:"workspace_owner_username" json:"workspace_owner_username"` + OrganizationName string `db:"organization_name" json:"organization_name"` + OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` + OrganizationIcon string `db:"organization_icon" json:"organization_icon"` + Count int64 `db:"count" json:"count"` } func (q *sqlQuerier) GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error) { @@ -1032,8 +1069,23 @@ func (q *sqlQuerier) GetConnectionLogsOffset(ctx context.Context, arg GetConnect &i.ConnectionLog.ConnectionID, &i.ConnectionLog.CloseTime, &i.ConnectionLog.CloseReason, + &i.WorkspaceDeleted, &i.UserUsername, + &i.UserName, + &i.UserEmail, + &i.UserCreatedAt, + &i.UserUpdatedAt, + &i.UserLastSeenAt, + &i.UserStatus, + &i.UserLoginType, + &i.UserRoles, + &i.UserAvatarUrl, + &i.UserDeleted, + &i.UserQuietHoursSchedule, &i.WorkspaceOwnerUsername, + &i.OrganizationName, + &i.OrganizationDisplayName, + &i.OrganizationIcon, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/queries/connectionlogs.sql b/coderd/database/queries/connectionlogs.sql index 9fd6c83c9038d..66d24f21dbac5 100644 --- a/coderd/database/queries/connectionlogs.sql +++ b/coderd/database/queries/connectionlogs.sql @@ -1,15 +1,37 @@ -- name: GetConnectionLogsOffset :many SELECT sqlc.embed(connection_logs), - users.username AS user_username, + workspaces.deleted AS workspace_deleted, + -- sqlc.embed(users) would be nice but it does not seem to play well with + -- left joins. This user metadata is necessary for parity with the audit logs + -- API. + users.username AS user_username, + users.name AS user_name, + users.email AS user_email, + users.created_at AS user_created_at, + users.updated_at AS user_updated_at, + users.last_seen_at AS user_last_seen_at, + users.status AS user_status, + users.login_type AS user_login_type, + users.rbac_roles AS user_roles, + users.avatar_url AS user_avatar_url, + users.deleted AS user_deleted, + users.quiet_hours_schedule AS user_quiet_hours_schedule, workspace_owner.username AS workspace_owner_username, + organizations.name as organization_name, + organizations.display_name as organization_display_name, + organizations.icon as organization_icon, COUNT(connection_logs.*) OVER () AS count FROM connection_logs +JOIN users AS workspace_owner ON + connection_logs.workspace_owner_id = workspace_owner.id LEFT JOIN users ON connection_logs.user_id = users.id -LEFT JOIN users as workspace_owner ON - connection_logs.workspace_owner_id = workspace_owner.id +JOIN organizations ON + connection_logs.organization_id = organizations.id +JOIN workspaces ON + connection_logs.workspace_id = workspaces.id WHERE TRUE -- Authorize Filter clause will be injected below in -- GetAuthorizedConnectionLogsOffset diff --git a/codersdk/agentsdk/convert.go b/codersdk/agentsdk/convert.go index d01c9e527fce9..775ce06c73c69 100644 --- a/codersdk/agentsdk/convert.go +++ b/codersdk/agentsdk/convert.go @@ -408,40 +408,6 @@ func ProtoFromLifecycleState(s codersdk.WorkspaceAgentLifecycle) (proto.Lifecycl return proto.Lifecycle_State(caps), nil } -func ConnectionTypeFromProto(typ proto.Connection_Type) (ConnectionType, error) { - switch typ { - case proto.Connection_TYPE_UNSPECIFIED: - return ConnectionTypeUnspecified, nil - case proto.Connection_SSH: - return ConnectionTypeSSH, nil - case proto.Connection_VSCODE: - return ConnectionTypeVSCode, nil - case proto.Connection_JETBRAINS: - return ConnectionTypeJetBrains, nil - case proto.Connection_RECONNECTING_PTY: - return ConnectionTypeReconnectingPTY, nil - default: - return "", xerrors.Errorf("unknown connection type %q", typ) - } -} - -func ProtoFromConnectionType(typ ConnectionType) (proto.Connection_Type, error) { - switch typ { - case ConnectionTypeUnspecified: - return proto.Connection_TYPE_UNSPECIFIED, nil - case ConnectionTypeSSH: - return proto.Connection_SSH, nil - case ConnectionTypeVSCode: - return proto.Connection_VSCODE, nil - case ConnectionTypeJetBrains: - return proto.Connection_JETBRAINS, nil - case ConnectionTypeReconnectingPTY: - return proto.Connection_RECONNECTING_PTY, nil - default: - return 0, xerrors.Errorf("unknown connection type %q", typ) - } -} - func DevcontainersFromProto(pdcs []*proto.WorkspaceAgentDevcontainer) ([]codersdk.WorkspaceAgentDevcontainer, error) { ret := make([]codersdk.WorkspaceAgentDevcontainer, len(pdcs)) for i, pdc := range pdcs { From 6b9b1beeb0479ed2e42b85932854d7a91f8950dc Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 24 Jun 2025 05:32:01 +0000 Subject: [PATCH 15/16] seperate type for web port forwarding --- coderd/database/dump.sql | 9 +++-- ...wn.sql => 000341_connection_logs.down.sql} | 0 ...s.up.sql => 000341_connection_logs.up.sql} | 17 +++++---- ...s.up.sql => 000341_connection_logs.up.sql} | 2 +- coderd/database/models.go | 15 +++++--- coderd/workspaceapps/db.go | 14 +++++-- coderd/workspaceapps/db_test.go | 38 +++++++++---------- 7 files changed, 54 insertions(+), 41 deletions(-) rename coderd/database/migrations/{000336_connection_logs.down.sql => 000341_connection_logs.down.sql} (100%) rename coderd/database/migrations/{000336_connection_logs.up.sql => 000341_connection_logs.up.sql} (83%) rename coderd/database/migrations/testdata/fixtures/{000336_connection_logs.up.sql => 000341_connection_logs.up.sql} (98%) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 7ed4a452bdbdc..055d417b727a9 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -63,7 +63,8 @@ CREATE TYPE connection_type AS ENUM ( 'vscode', 'jetbrains', 'reconnecting_pty', - 'web' + 'workspace_app', + 'port_forwarding' ); CREATE TYPE crypto_key_feature AS ENUM ( @@ -881,11 +882,11 @@ CREATE TABLE connection_logs ( COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection. For non-web connections, this is Null until we receive a disconnect event for the same connection_id.'; -COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For web connections, this is the User-Agent header from the request.'; +COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH events. For web connections, this is the User-Agent header from the request.'; -COMMENT ON COLUMN connection_logs.user_id IS 'uuid.Nil for SSH actions. For web connections, this is the ID of the user that made the request.'; +COMMENT ON COLUMN connection_logs.user_id IS 'uuid.Nil for SSH events. For web connections, this is the ID of the user that made the request.'; -COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for SSH actions. For web connections, this is the slug of the app or the port number being forwarded.'; +COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for SSH events. For web connections, this is the slug of the app or the port number being forwarded.'; COMMENT ON COLUMN connection_logs.connection_id IS 'The SSH connection ID. Used to correlate connections and disconnections. As it originates from the agent, it is not guaranteed to be unique.'; diff --git a/coderd/database/migrations/000336_connection_logs.down.sql b/coderd/database/migrations/000341_connection_logs.down.sql similarity index 100% rename from coderd/database/migrations/000336_connection_logs.down.sql rename to coderd/database/migrations/000341_connection_logs.down.sql diff --git a/coderd/database/migrations/000336_connection_logs.up.sql b/coderd/database/migrations/000341_connection_logs.up.sql similarity index 83% rename from coderd/database/migrations/000336_connection_logs.up.sql rename to coderd/database/migrations/000341_connection_logs.up.sql index d84dceee79ee0..cb8cf0c023c30 100644 --- a/coderd/database/migrations/000336_connection_logs.up.sql +++ b/coderd/database/migrations/000341_connection_logs.up.sql @@ -4,13 +4,14 @@ CREATE TYPE connection_status AS ENUM ( ); CREATE TYPE connection_type AS ENUM ( - -- SSH actions + -- SSH events 'ssh', 'vscode', 'jetbrains', 'reconnecting_pty', - -- Workspace Apps or Web Port Forwarding - 'web' + -- Web events + 'workspace_app', + 'port_forwarding' ); CREATE TABLE connection_logs ( @@ -25,12 +26,12 @@ CREATE TABLE connection_logs ( code integer, ip inet, - -- Only set for 'web' logs. + -- Only set for web events user_agent text, user_id uuid, slug_or_port text, - -- Null for 'web' logs. + -- Null for web events connection_id uuid, close_time timestamp with time zone, -- Null until we upsert a disconnect log for the same connection_id. close_reason text, @@ -41,11 +42,11 @@ CREATE TABLE connection_logs ( COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection. For non-web connections, this is Null until we receive a disconnect event for the same connection_id.'; -COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH actions. For web connections, this is the User-Agent header from the request.'; +COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH events. For web connections, this is the User-Agent header from the request.'; -COMMENT ON COLUMN connection_logs.user_id IS 'uuid.Nil for SSH actions. For web connections, this is the ID of the user that made the request.'; +COMMENT ON COLUMN connection_logs.user_id IS 'uuid.Nil for SSH events. For web connections, this is the ID of the user that made the request.'; -COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for SSH actions. For web connections, this is the slug of the app or the port number being forwarded.'; +COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for SSH events. For web connections, this is the slug of the app or the port number being forwarded.'; COMMENT ON COLUMN connection_logs.connection_id IS 'The SSH connection ID. Used to correlate connections and disconnections. As it originates from the agent, it is not guaranteed to be unique.'; diff --git a/coderd/database/migrations/testdata/fixtures/000336_connection_logs.up.sql b/coderd/database/migrations/testdata/fixtures/000341_connection_logs.up.sql similarity index 98% rename from coderd/database/migrations/testdata/fixtures/000336_connection_logs.up.sql rename to coderd/database/migrations/testdata/fixtures/000341_connection_logs.up.sql index cae9aaa35059c..fa57b128d3a78 100644 --- a/coderd/database/migrations/testdata/fixtures/000336_connection_logs.up.sql +++ b/coderd/database/migrations/testdata/fixtures/000341_connection_logs.up.sql @@ -41,7 +41,7 @@ INSERT INTO connection_logs ( '3a9a1feb-e89d-457c-9d53-ac751b198ebe', -- workspace id 'Test Workspace', -- workspace name 'test-agent', -- agent name - 'web', -- type + 'workspace_app', -- type 200, -- code '127.0.0.1', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36', diff --git a/coderd/database/models.go b/coderd/database/models.go index 3c473fd5c83dd..74943d3b07e09 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -478,7 +478,8 @@ const ( ConnectionTypeVscode ConnectionType = "vscode" ConnectionTypeJetbrains ConnectionType = "jetbrains" ConnectionTypeReconnectingPty ConnectionType = "reconnecting_pty" - ConnectionTypeWeb ConnectionType = "web" + ConnectionTypeWorkspaceApp ConnectionType = "workspace_app" + ConnectionTypePortForwarding ConnectionType = "port_forwarding" ) func (e *ConnectionType) Scan(src interface{}) error { @@ -522,7 +523,8 @@ func (e ConnectionType) Valid() bool { ConnectionTypeVscode, ConnectionTypeJetbrains, ConnectionTypeReconnectingPty, - ConnectionTypeWeb: + ConnectionTypeWorkspaceApp, + ConnectionTypePortForwarding: return true } return false @@ -534,7 +536,8 @@ func AllConnectionTypeValues() []ConnectionType { ConnectionTypeVscode, ConnectionTypeJetbrains, ConnectionTypeReconnectingPty, - ConnectionTypeWeb, + ConnectionTypeWorkspaceApp, + ConnectionTypePortForwarding, } } @@ -2930,11 +2933,11 @@ type ConnectionLog struct { // Either the HTTP status code of the web request, or the exit code of an SSH connection. For non-web connections, this is Null until we receive a disconnect event for the same connection_id. Code sql.NullInt32 `db:"code" json:"code"` Ip pqtype.Inet `db:"ip" json:"ip"` - // Null for SSH actions. For web connections, this is the User-Agent header from the request. + // Null for SSH events. For web connections, this is the User-Agent header from the request. UserAgent sql.NullString `db:"user_agent" json:"user_agent"` - // uuid.Nil for SSH actions. For web connections, this is the ID of the user that made the request. + // uuid.Nil for SSH events. For web connections, this is the ID of the user that made the request. UserID uuid.NullUUID `db:"user_id" json:"user_id"` - // Null for SSH actions. For web connections, this is the slug of the app or the port number being forwarded. + // Null for SSH events. For web connections, this is the slug of the app or the port number being forwarded. SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"` // The SSH connection ID. Used to correlate connections and disconnections. As it originates from the agent, it is not guaranteed to be unique. ConnectionID uuid.NullUUID `db:"connection_id" json:"connection_id"` diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index f0803789d149a..00e46c4f6daa4 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -418,12 +418,19 @@ func (p *DBTokenProvider) connLogInitRequest(ctx context.Context, w http.Respons statusCode = http.StatusOK } - var slugOrPort string + var ( + connType database.ConnectionType + slugOrPort = aReq.dbReq.AppSlugOrPort + ) + switch { case aReq.dbReq.AccessMethod == AccessMethodTerminal: + connType = database.ConnectionTypeWorkspaceApp slugOrPort = "terminal" + case aReq.dbReq.App.ID == uuid.Nil: + connType = database.ConnectionTypePortForwarding default: - slugOrPort = aReq.dbReq.AppSlugOrPort + connType = database.ConnectionTypeWorkspaceApp } // If we end up logging, ensure relevant fields are set. @@ -479,6 +486,7 @@ func (p *DBTokenProvider) connLogInitRequest(ctx context.Context, w http.Respons } connLogger := *p.ConnectionLogger.Load() + err = connLogger.Upsert(ctx, database.UpsertConnectionLogParams{ ID: uuid.New(), Time: aReq.time, @@ -487,7 +495,7 @@ func (p *DBTokenProvider) connLogInitRequest(ctx context.Context, w http.Respons WorkspaceID: aReq.dbReq.Workspace.ID, WorkspaceName: aReq.dbReq.Workspace.Name, AgentName: aReq.dbReq.Agent.Name, - Type: database.ConnectionTypeWeb, + Type: connType, Code: sql.NullInt32{ Int32: statusCode, Valid: true, diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index 34a4b8a1a3ad0..5efc2c1571a0e 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -318,7 +318,7 @@ func Test_ResolveRequest(t *testing.T) { require.Equal(t, codersdk.SignedAppTokenCookie, cookie.Name) require.Equal(t, req.BasePath, cookie.Path) - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, me.ID) + assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID) require.Len(t, connLogger.ConnectionLogs(), 1) var parsedToken workspaceapps.SignedToken @@ -398,7 +398,7 @@ func Test_ResolveRequest(t *testing.T) { require.NotNil(t, token) require.Zero(t, w.StatusCode) - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, secondUser.ID) + assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, secondUser.ID) require.Len(t, connLogger.ConnectionLogs(), 1) } }) @@ -438,7 +438,7 @@ func Test_ResolveRequest(t *testing.T) { require.NotZero(t, rw.Code) require.NotEqual(t, http.StatusOK, rw.Code) - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, uuid.Nil) + assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, uuid.Nil) require.Len(t, connLogger.ConnectionLogs(), 1) } else { if !assert.True(t, ok) { @@ -452,7 +452,7 @@ func Test_ResolveRequest(t *testing.T) { t.Fatalf("expected 200 (or unset) response code, got %d", rw.Code) } - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, uuid.Nil) + assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, uuid.Nil) require.Len(t, connLogger.ConnectionLogs(), 1) } _ = w.Body.Close() @@ -577,7 +577,7 @@ 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) - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, me.ID) + assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID) require.Len(t, connLogger.ConnectionLogs(), 1) } else { require.Nil(t, token) @@ -662,7 +662,7 @@ func Test_ResolveRequest(t *testing.T) { require.NoError(t, err) require.Equal(t, appNameOwner, parsedToken.AppSlugOrPort) - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, me.ID) + assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, me.ID) require.Len(t, connLogger.ConnectionLogs(), 1) }) @@ -735,7 +735,7 @@ 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) - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, "9090", me.ID) + assertConnLogContains(t, rw, r, connLogger, workspace, agentName, "9090", database.ConnectionTypePortForwarding, me.ID) require.Len(t, connLogger.ConnectionLogs(), 1) }) @@ -808,7 +808,7 @@ func Test_ResolveRequest(t *testing.T) { }) require.True(t, ok) require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort) - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameEndsInS, me.ID) + assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameEndsInS, database.ConnectionTypeWorkspaceApp, me.ID) require.Len(t, connLogger.ConnectionLogs(), 1) }) @@ -845,7 +845,7 @@ func Test_ResolveRequest(t *testing.T) { require.Equal(t, req.AgentNameOrID, token.Request.AgentNameOrID) require.Empty(t, token.AppSlugOrPort) require.Empty(t, token.AppURL) - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, "terminal", me.ID) + assertConnLogContains(t, rw, r, connLogger, workspace, agentName, "terminal", database.ConnectionTypeWorkspaceApp, me.ID) require.Len(t, connLogger.ConnectionLogs(), 1) }) @@ -879,7 +879,7 @@ func Test_ResolveRequest(t *testing.T) { }) require.False(t, ok) require.Nil(t, token) - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, secondUser.ID) + assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, secondUser.ID) require.Len(t, connLogger.ConnectionLogs(), 1) }) @@ -953,7 +953,7 @@ 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. - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, uuid.Nil) + assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, uuid.Nil) require.Len(t, connLogger.ConnectionLogs(), 1) loc, err := w.Location() @@ -1015,7 +1015,7 @@ func Test_ResolveRequest(t *testing.T) { w := rw.Result() defer w.Body.Close() require.Equal(t, http.StatusBadGateway, w.StatusCode) - assertConnLogContains(t, rw, r, connLogger, workspace, agentNameUnhealthy, appNameAgentUnhealthy, me.ID) + assertConnLogContains(t, rw, r, connLogger, workspace, agentNameUnhealthy, appNameAgentUnhealthy, database.ConnectionTypeWorkspaceApp, me.ID) require.Len(t, connLogger.ConnectionLogs(), 1) body, err := io.ReadAll(w.Body) @@ -1074,7 +1074,7 @@ func Test_ResolveRequest(t *testing.T) { }) require.True(t, ok, "ResolveRequest failed, should pass even though app is initializing") require.NotNil(t, token) - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, me.ID) + assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID) require.Len(t, connLogger.ConnectionLogs(), 1) }) @@ -1132,7 +1132,7 @@ func Test_ResolveRequest(t *testing.T) { }) require.True(t, ok, "ResolveRequest failed, should pass even though app is unhealthy") require.NotNil(t, token) - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, me.ID) + assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID) require.Len(t, connLogger.ConnectionLogs(), 1) }) @@ -1169,7 +1169,7 @@ func Test_ResolveRequest(t *testing.T) { AppRequest: req, }) require.True(t, ok) - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, me.ID) + 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. @@ -1205,7 +1205,7 @@ func Test_ResolveRequest(t *testing.T) { AppRequest: req, }) require.True(t, ok) - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, me.ID) + 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. @@ -1224,7 +1224,7 @@ func Test_ResolveRequest(t *testing.T) { AppRequest: req, }) require.True(t, ok) - assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, me.ID) + assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID) require.Len(t, connLogger.ConnectionLogs(), 3, "three connection logs, new IP") } }) @@ -1257,7 +1257,7 @@ func signedTokenProviderWithConnLogger(t testing.TB, provider workspaceapps.Sign return &shallowCopy } -func assertConnLogContains(t *testing.T, rr *httptest.ResponseRecorder, r *http.Request, connLogger *connectionlog.MockConnectionLogger, workspace codersdk.Workspace, agentName string, slugOrPort string, userID uuid.UUID) { +func assertConnLogContains(t *testing.T, rr *httptest.ResponseRecorder, r *http.Request, connLogger *connectionlog.MockConnectionLogger, workspace codersdk.Workspace, agentName string, slugOrPort string, typ database.ConnectionType, userID uuid.UUID) { t.Helper() resp := rr.Result() @@ -1269,7 +1269,7 @@ func assertConnLogContains(t *testing.T, rr *httptest.ResponseRecorder, r *http. WorkspaceID: workspace.ID, WorkspaceName: workspace.Name, AgentName: agentName, - Type: database.ConnectionTypeWeb, + Type: typ, Ip: database.ParseIP(r.RemoteAddr), UserAgent: sql.NullString{Valid: r.UserAgent() != "", String: r.UserAgent()}, Code: sql.NullInt32{ From 011b2767e8d0ade7332c6b8bd283185804aa7a5b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 25 Jun 2025 10:41:53 +0000 Subject: [PATCH 16/16] review --- coderd/agentapi/connectionlog_test.go | 2 +- coderd/connectionlog/connectionlog.go | 14 +++---- coderd/database/dbmem/dbmem.go | 6 +++ coderd/database/querier_test.go | 40 ++++++++++++++++++ coderd/database/queries.sql.go | 48 +++++++++++++--------- coderd/database/queries/connectionlogs.sql | 48 +++++++++++++--------- coderd/workspaceapps/db_test.go | 40 +++++++++--------- 7 files changed, 130 insertions(+), 68 deletions(-) diff --git a/coderd/agentapi/connectionlog_test.go b/coderd/agentapi/connectionlog_test.go index 91c950e7dc1c7..0b5ffba34aa5b 100644 --- a/coderd/agentapi/connectionlog_test.go +++ b/coderd/agentapi/connectionlog_test.go @@ -105,7 +105,7 @@ func TestConnectionLog(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() mDB := dbmock.NewMockStore(gomock.NewController(t)) mDB.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(workspace, nil) diff --git a/coderd/connectionlog/connectionlog.go b/coderd/connectionlog/connectionlog.go index 4bd70e8bf6b19..1b56ffc288fd3 100644 --- a/coderd/connectionlog/connectionlog.go +++ b/coderd/connectionlog/connectionlog.go @@ -24,28 +24,28 @@ func (nop) Upsert(context.Context, database.UpsertConnectionLogParams) error { return nil } -func NewMock() *MockConnectionLogger { - return &MockConnectionLogger{} +func NewFake() *FakeConnectionLogger { + return &FakeConnectionLogger{} } -type MockConnectionLogger struct { +type FakeConnectionLogger struct { mu sync.Mutex upsertions []database.UpsertConnectionLogParams } -func (m *MockConnectionLogger) Reset() { +func (m *FakeConnectionLogger) Reset() { m.mu.Lock() defer m.mu.Unlock() m.upsertions = make([]database.UpsertConnectionLogParams, 0) } -func (m *MockConnectionLogger) ConnectionLogs() []database.UpsertConnectionLogParams { +func (m *FakeConnectionLogger) ConnectionLogs() []database.UpsertConnectionLogParams { m.mu.Lock() defer m.mu.Unlock() return m.upsertions } -func (m *MockConnectionLogger) Upsert(_ context.Context, clog database.UpsertConnectionLogParams) error { +func (m *FakeConnectionLogger) Upsert(_ context.Context, clog database.UpsertConnectionLogParams) error { m.mu.Lock() defer m.mu.Unlock() @@ -54,7 +54,7 @@ func (m *MockConnectionLogger) Upsert(_ context.Context, clog database.UpsertCon return nil } -func (m *MockConnectionLogger) Contains(t testing.TB, expected database.UpsertConnectionLogParams) bool { +func (m *FakeConnectionLogger) Contains(t testing.TB, expected database.UpsertConnectionLogParams) bool { m.mu.Lock() defer m.mu.Unlock() for idx, cl := range m.upsertions { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index f60001eea6774..3b9e6b4904379 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -12291,6 +12291,11 @@ func (q *FakeQuerier) UpsertConnectionLog(_ context.Context, arg database.Upsert } } + var closeTime sql.NullTime + if arg.ConnectionStatus == database.ConnectionStatusDisconnected { + closeTime = sql.NullTime{Valid: true, Time: arg.Time} + } + log := database.ConnectionLog{ ID: arg.ID, Time: arg.Time, @@ -12307,6 +12312,7 @@ func (q *FakeQuerier) UpsertConnectionLog(_ context.Context, arg database.Upsert SlugOrPort: arg.SlugOrPort, ConnectionID: arg.ConnectionID, CloseReason: arg.CloseReason, + CloseTime: closeTime, } q.connectionLogs = append(q.connectionLogs, log) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 1f27629f01e28..126c42f02eaaa 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -2369,6 +2369,46 @@ func TestUpsertConnectionLog(t *testing.T) { require.Equal(t, int64(1), rows[0].Count) require.Equal(t, log, rows[0].ConnectionLog) }) + + t.Run("NoConnect", func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := context.Background() + + ws := createWorkspace(t, db) + + connectionID := uuid.New() + agentName := "test-agent" + + // Insert just a 'disconect' event + disconnectTime := dbtime.Now() + connectParams := database.UpsertConnectionLogParams{ + ID: uuid.New(), + Time: disconnectTime, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + WorkspaceID: ws.ID, + WorkspaceName: ws.Name, + AgentName: agentName, + Type: database.ConnectionTypeSsh, + ConnectionID: uuid.NullUUID{UUID: connectionID, Valid: true}, + ConnectionStatus: database.ConnectionStatusDisconnected, + } + + _, err := db.UpsertConnectionLog(ctx, connectParams) + require.NoError(t, err) + + rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{}) + require.NoError(t, err) + require.Len(t, rows, 1) + + // We expect the connection event to be marked as closed with the start + // and close time being the same. + require.True(t, rows[0].ConnectionLog.CloseTime.Valid) + require.Equal(t, disconnectTime, rows[0].ConnectionLog.CloseTime.Time.UTC()) + require.Equal(t, rows[0].ConnectionLog.Time.UTC(), rows[0].ConnectionLog.CloseTime.Time.UTC()) + }) } type tvArgs struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 91d51083954fe..8b2a44db27ae4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -971,25 +971,25 @@ const getConnectionLogsOffset = `-- name: GetConnectionLogsOffset :many SELECT connection_logs.id, connection_logs.time, connection_logs.organization_id, connection_logs.workspace_owner_id, connection_logs.workspace_id, connection_logs.workspace_name, connection_logs.agent_name, connection_logs.type, connection_logs.code, connection_logs.ip, connection_logs.user_agent, connection_logs.user_id, connection_logs.slug_or_port, connection_logs.connection_id, connection_logs.close_time, connection_logs.close_reason, workspaces.deleted AS workspace_deleted, - -- sqlc.embed(users) would be nice but it does not seem to play well with - -- left joins. This user metadata is necessary for parity with the audit logs + -- sqlc.embed(users) would be nice but it does not seem to play well with + -- left joins. This user metadata is necessary for parity with the audit logs -- API. - users.username AS user_username, - users.name AS user_name, - users.email AS user_email, - users.created_at AS user_created_at, - users.updated_at AS user_updated_at, - users.last_seen_at AS user_last_seen_at, - users.status AS user_status, - users.login_type AS user_login_type, - users.rbac_roles AS user_roles, - users.avatar_url AS user_avatar_url, - users.deleted AS user_deleted, - users.quiet_hours_schedule AS user_quiet_hours_schedule, + users.username AS user_username, + users.name AS user_name, + users.email AS user_email, + users.created_at AS user_created_at, + users.updated_at AS user_updated_at, + users.last_seen_at AS user_last_seen_at, + users.status AS user_status, + users.login_type AS user_login_type, + users.rbac_roles AS user_roles, + users.avatar_url AS user_avatar_url, + users.deleted AS user_deleted, + users.quiet_hours_schedule AS user_quiet_hours_schedule, workspace_owner.username AS workspace_owner_username, - organizations.name as organization_name, - organizations.display_name as organization_display_name, - organizations.icon as organization_icon, + organizations.name AS organization_name, + organizations.display_name AS organization_display_name, + organizations.icon AS organization_icon, COUNT(connection_logs.*) OVER () AS count FROM connection_logs @@ -1117,9 +1117,17 @@ INSERT INTO connection_logs ( user_id, slug_or_port, connection_id, - close_reason + close_reason, + close_time ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, + -- If we've only received a disconnect event, mark the event as immediately + -- closed. + CASE + WHEN $16::connection_status = 'disconnected' + THEN $2 :: timestamp with time zone + ELSE NULL + END) ON CONFLICT (connection_id, workspace_id, agent_name) DO UPDATE SET -- No-op if the connection is still open. @@ -1137,7 +1145,7 @@ DO UPDATE SET WHEN $16::connection_status = 'disconnected' THEN EXCLUDED.code ELSE connection_logs.code - END + END RETURNING id, time, organization_id, workspace_owner_id, workspace_id, workspace_name, agent_name, type, code, ip, user_agent, user_id, slug_or_port, connection_id, close_time, close_reason ` diff --git a/coderd/database/queries/connectionlogs.sql b/coderd/database/queries/connectionlogs.sql index 66d24f21dbac5..024fbfafd1896 100644 --- a/coderd/database/queries/connectionlogs.sql +++ b/coderd/database/queries/connectionlogs.sql @@ -2,25 +2,25 @@ SELECT sqlc.embed(connection_logs), workspaces.deleted AS workspace_deleted, - -- sqlc.embed(users) would be nice but it does not seem to play well with - -- left joins. This user metadata is necessary for parity with the audit logs + -- sqlc.embed(users) would be nice but it does not seem to play well with + -- left joins. This user metadata is necessary for parity with the audit logs -- API. - users.username AS user_username, - users.name AS user_name, - users.email AS user_email, - users.created_at AS user_created_at, - users.updated_at AS user_updated_at, - users.last_seen_at AS user_last_seen_at, - users.status AS user_status, - users.login_type AS user_login_type, - users.rbac_roles AS user_roles, - users.avatar_url AS user_avatar_url, - users.deleted AS user_deleted, - users.quiet_hours_schedule AS user_quiet_hours_schedule, + users.username AS user_username, + users.name AS user_name, + users.email AS user_email, + users.created_at AS user_created_at, + users.updated_at AS user_updated_at, + users.last_seen_at AS user_last_seen_at, + users.status AS user_status, + users.login_type AS user_login_type, + users.rbac_roles AS user_roles, + users.avatar_url AS user_avatar_url, + users.deleted AS user_deleted, + users.quiet_hours_schedule AS user_quiet_hours_schedule, workspace_owner.username AS workspace_owner_username, - organizations.name as organization_name, - organizations.display_name as organization_display_name, - organizations.icon as organization_icon, + organizations.name AS organization_name, + organizations.display_name AS organization_display_name, + organizations.icon AS organization_icon, COUNT(connection_logs.*) OVER () AS count FROM connection_logs @@ -63,9 +63,17 @@ INSERT INTO connection_logs ( user_id, slug_or_port, connection_id, - close_reason + close_reason, + close_time ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, + -- If we've only received a disconnect event, mark the event as immediately + -- closed. + CASE + WHEN @connection_status::connection_status = 'disconnected' + THEN $2 :: timestamp with time zone + ELSE NULL + END) ON CONFLICT (connection_id, workspace_id, agent_name) DO UPDATE SET -- No-op if the connection is still open. @@ -83,5 +91,5 @@ DO UPDATE SET WHEN @connection_status::connection_status = 'disconnected' THEN EXCLUDED.code ELSE connection_logs.code - END + END RETURNING *; diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index 5efc2c1571a0e..50ef0dec1519a 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -81,7 +81,7 @@ func Test_ResolveRequest(t *testing.T) { deploymentValues.Dangerous.AllowPathAppSharing = true deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = true - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() t.Cleanup(func() { if t.Failed() { return @@ -270,7 +270,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: app, }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) auditableUA := "Noitcennoc" @@ -367,7 +367,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: app, }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) t.Log("app", app) @@ -416,7 +416,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: app, }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) t.Log("app", app) @@ -465,7 +465,7 @@ func Test_ResolveRequest(t *testing.T) { req := (workspaceapps.Request{ AccessMethod: "invalid", }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) @@ -548,7 +548,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNamePublic, }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -623,7 +623,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameOwner, }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -678,7 +678,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: "8080", }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -716,7 +716,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: "9090", }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -751,7 +751,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: "9090ss", }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -790,7 +790,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameEndsInS, }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -821,7 +821,7 @@ func Test_ResolveRequest(t *testing.T) { AgentNameOrID: agentID.String(), }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -861,7 +861,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameOwner, }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -894,7 +894,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameOwner, }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -927,7 +927,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameOwner, }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -993,7 +993,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameAgentUnhealthy, }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -1056,7 +1056,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameInitializing, }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -1114,7 +1114,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameUnhealthy, }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() @@ -1149,7 +1149,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: app, }).Normalize() - connLogger := connectionlog.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) t.Log("app", app) @@ -1257,7 +1257,7 @@ func signedTokenProviderWithConnLogger(t testing.TB, provider workspaceapps.Sign return &shallowCopy } -func assertConnLogContains(t *testing.T, rr *httptest.ResponseRecorder, r *http.Request, connLogger *connectionlog.MockConnectionLogger, workspace codersdk.Workspace, agentName string, slugOrPort string, typ database.ConnectionType, userID uuid.UUID) { +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()