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..9e7010a8e8a69 --- /dev/null +++ b/coderd/agentapi/connectionlog.go @@ -0,0 +1,99 @@ +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" +) + +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) { + // 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) + } + + if connectionID == uuid.Nil { + return nil, xerrors.New("connection ID cannot be nil") + } + action, err := db2sdk.ConnectionLogStatusFromAgentProtoConnectionAction(req.GetConnection().GetAction()) + if err != nil { + return nil, err + } + connectionType, err := db2sdk.ConnectionLogConnectionTypeFromAgentProtoConnectionType(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.Upsert(ctx, database.UpsertConnectionLogParams{ + ID: uuid.New(), + Time: req.GetConnection().GetTimestamp().AsTime(), + OrganizationID: workspace.OrganizationID, + WorkspaceOwnerID: workspace.OwnerID, + WorkspaceID: workspace.ID, + WorkspaceName: workspace.Name, + AgentName: workspaceAgent.Name, + Type: connectionType, + 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, + }, + CloseReason: sql.NullString{ + String: reason, + Valid: reason != "", + }, + // Used to populate whether the connection was established or closed + // outside of the DB (slog). + ConnectionStatus: action, + + // It's not possible to tell which user connected. Once we have + // the capability, this may be reported by the agent. + 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) + } + + return &emptypb.Empty{}, nil +} diff --git a/coderd/agentapi/audit_test.go b/coderd/agentapi/connectionlog_test.go similarity index 62% rename from coderd/agentapi/audit_test.go rename to coderd/agentapi/connectionlog_test.go index b881fde5d22bc..0b5ffba34aa5b 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,15 +16,14 @@ 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" "github.com/coder/coder/v2/coderd/database/dbtime" - "github.com/coder/coder/v2/codersdk/agentsdk" ) -func TestAuditReport(t *testing.T) { +func TestConnectionLog(t *testing.T) { t.Parallel() var ( @@ -38,10 +37,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 +57,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 +66,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 +74,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 +105,14 @@ func TestAuditReport(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - mAudit := audit.NewMock() + connLogger := connectionlog.NewFake() 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,41 +129,48 @@ 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.UpsertConnectionLogParams{ + 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.NullUUID{ + UUID: uuid.Nil, + Valid: false, + }, + ConnectionStatus: 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: 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: agentProtoConnectionTypeToConnectionLog(t, *tt.typ), + CloseReason: sql.NullString{ + String: tt.reason, + Valid: tt.reason != "", + }, + ConnectionID: uuid.NullUUID{ + UUID: tt.id, + Valid: tt.id != uuid.Nil, + }, + })) }) } } -func agentProtoConnectionActionToAudit(t *testing.T, action agentproto.Connection_Action) database.AuditAction { - a, err := db2sdk.AuditActionFromAgentProtoConnectionAction(action) +func agentProtoConnectionTypeToConnectionLog(t *testing.T, typ agentproto.Connection_Type) database.ConnectionType { + a, err := db2sdk.ConnectionLogConnectionTypeFromAgentProtoConnectionType(typ) require.NoError(t, err) return a } -func agentProtoConnectionTypeToSDK(t *testing.T, typ agentproto.Connection_Type) agentsdk.ConnectionType { - action, err := agentsdk.ConnectionTypeFromProto(typ) +func agentProtoConnectionActionToConnectionLog(t *testing.T, action agentproto.Connection_Action) database.ConnectionStatus { + a, err := db2sdk.ConnectionLogStatusFromAgentProtoConnectionAction(action) require.NoError(t, err) - return action + return a } func asAtomicPointer[T any](v T) *atomic.Pointer[T] { 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..1b56ffc288fd3 --- /dev/null +++ b/coderd/connectionlog/connectionlog.go @@ -0,0 +1,121 @@ +package connectionlog + +import ( + "context" + "sync" + "testing" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/database" +) + +type ConnectionLogger interface { + Upsert(ctx context.Context, clog database.UpsertConnectionLogParams) error +} + +type nop struct{} + +func NewNop() ConnectionLogger { + return nop{} +} + +func (nop) Upsert(context.Context, database.UpsertConnectionLogParams) error { + return nil +} + +func NewFake() *FakeConnectionLogger { + return &FakeConnectionLogger{} +} + +type FakeConnectionLogger struct { + mu sync.Mutex + upsertions []database.UpsertConnectionLogParams +} + +func (m *FakeConnectionLogger) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.upsertions = make([]database.UpsertConnectionLogParams, 0) +} + +func (m *FakeConnectionLogger) ConnectionLogs() []database.UpsertConnectionLogParams { + m.mu.Lock() + defer m.mu.Unlock() + return m.upsertions +} + +func (m *FakeConnectionLogger) Upsert(_ context.Context, clog database.UpsertConnectionLogParams) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.upsertions = append(m.upsertions, clog) + + return nil +} + +func (m *FakeConnectionLogger) Contains(t testing.TB, expected database.UpsertConnectionLogParams) bool { + m.mu.Lock() + defer m.mu.Unlock() + 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 + } + 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.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.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() { + t.Logf("connection log %d: expected IP %s, got %s", idx+1, expected.Ip.IPNet, cl.Ip.IPNet) + continue + } + 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.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.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.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 + } + + return false +} diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 4a7871f21d15d..af93327739cfa 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -727,26 +727,31 @@ func TemplateRoleActions(role codersdk.TemplateRole) []policy.Action { return []policy.Action{} } -func AuditActionFromAgentProtoConnectionAction(action agentproto.Connection_Action) (database.AuditAction, error) { - switch action { - case agentproto.Connection_CONNECT: - return database.AuditActionConnect, nil - case agentproto.Connection_DISCONNECT: - return database.AuditActionDisconnect, nil +func ConnectionLogConnectionTypeFromAgentProtoConnectionType(typ agentproto.Connection_Type) (database.ConnectionType, error) { + switch typ { + case agentproto.Connection_SSH: + return database.ConnectionTypeSsh, nil + case agentproto.Connection_JETBRAINS: + return database.ConnectionTypeJetbrains, nil + case agentproto.Connection_VSCODE: + return database.ConnectionTypeVscode, nil + case agentproto.Connection_RECONNECTING_PTY: + return database.ConnectionTypeReconnectingPty, nil default: - // Also Connection_ACTION_UNSPECIFIED, no mapping. - return "", xerrors.Errorf("unknown agent connection action %q", action) + // Also Connection_TYPE_UNSPECIFIED, no mapping. + return "", xerrors.Errorf("unknown agent connection type %q", typ) } } -func AgentProtoConnectionActionToAuditAction(action database.AuditAction) (agentproto.Connection_Action, error) { +func ConnectionLogStatusFromAgentProtoConnectionAction(action agentproto.Connection_Action) (database.ConnectionStatus, 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.ConnectionStatusConnected, nil + case agentproto.Connection_DISCONNECT: + return database.ConnectionStatusDisconnected, 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 5bfa015af3d78..540e446c9431f 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,21 @@ 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) { + // 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) + } + + 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 @@ -4949,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 @@ -5162,3 +5203,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..d8faa90883b3f 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -329,6 +329,70 @@ func (s *MethodTestSuite) TestAuditLogs() { })) } +func (s *MethodTestSuite) TestConnectionLogs() { + 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, + }) + 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("UpsertConnectionLog", s.Subtest(func(db database.Store, check *expects) { + ws := createWorkspace(s.T(), db) + check.Args(database.UpsertConnectionLogParams{ + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + ConnectionStatus: database.ConnectionStatusConnected, + 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.UpsertConnectionLogParams{ + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + }) + _ = dbgen.ConnectionLog(s.T(), db, database.UpsertConnectionLogParams{ + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + }) + check.Args(database.GetConnectionLogsOffsetParams{ + LimitOpt: 10, + }).Asserts(rbac.ResourceConnectionLog, policy.ActionRead).WithNotAuthorized("nil") + })) + s.Run("GetAuthorizedConnectionLogsOffset", s.Subtest(func(db database.Store, check *expects) { + ws := createWorkspace(s.T(), db) + _ = 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.UpsertConnectionLogParams{ + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + }) + 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..1a4ad35b25826 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -73,6 +73,50 @@ func AuditLog(t testing.TB, db database.Store, seed database.AuditLog) database. return log } +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()), + 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)), + Type: takeFirst(seed.Type, database.ConnectionTypeSsh), + 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), + }, + UserAgent: sql.NullString{ + String: takeFirst(seed.UserAgent.String, ""), + Valid: takeFirst(seed.UserAgent.Valid, false), + }, + 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), + }, + 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), + }, + ConnectionStatus: takeFirst(seed.ConnectionStatus, database.ConnectionStatusConnected), + }) + 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..3b9e6b4904379 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() @@ -12263,6 +12268,66 @@ 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() + + for i, existing := range q.connectionLogs { + if existing.ConnectionID == arg.ConnectionID && + existing.WorkspaceID == arg.WorkspaceID && + existing.AgentName == arg.AgentName { + if arg.ConnectionStatus != database.ConnectionStatusDisconnected { + 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 + } + } + + 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, + 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, + CloseTime: closeTime, + } + + 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() @@ -13856,3 +13921,96 @@ 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 + } + + 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, + 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 + } + } + + 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..dc6b72f27f59d 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) @@ -3119,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) @@ -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..179180e0c2a05 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() @@ -6578,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 22a0b3d5a8adc..055d417b727a9 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' @@ -51,6 +53,20 @@ CREATE TYPE build_reason AS ENUM ( 'autodelete' ); +CREATE TYPE connection_status AS ENUM ( + 'connected', + 'disconnected' +); + +CREATE TYPE connection_type AS ENUM ( + 'ssh', + 'vscode', + 'jetbrains', + 'reconnecting_pty', + 'workspace_app', + 'port_forwarding' +); + CREATE TYPE crypto_key_feature AS ENUM ( 'workspace_apps_token', 'workspace_apps_api_key', @@ -845,6 +861,39 @@ 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, + type connection_type NOT NULL, + code integer, + ip inet, + user_agent text, + user_id uuid, + slug_or_port text, + connection_id uuid, + close_time timestamp with time zone, + 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 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 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 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 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.'; + +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 '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, sequence integer NOT NULL, @@ -2349,6 +2398,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 +2687,16 @@ 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); + +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 +2906,15 @@ 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_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 d6b87ddff5376..71a4cab86225a 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -9,6 +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; + 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/000341_connection_logs.down.sql b/coderd/database/migrations/000341_connection_logs.down.sql new file mode 100644 index 0000000000000..398d9534b8f06 --- /dev/null +++ b/coderd/database/migrations/000341_connection_logs.down.sql @@ -0,0 +1,10 @@ +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_type; + +DROP TYPE IF EXISTS connection_status; diff --git a/coderd/database/migrations/000341_connection_logs.up.sql b/coderd/database/migrations/000341_connection_logs.up.sql new file mode 100644 index 0000000000000..cb8cf0c023c30 --- /dev/null +++ b/coderd/database/migrations/000341_connection_logs.up.sql @@ -0,0 +1,66 @@ +CREATE TYPE connection_status AS ENUM ( + 'connected', + 'disconnected' +); + +CREATE TYPE connection_type AS ENUM ( + -- SSH events + 'ssh', + 'vscode', + 'jetbrains', + 'reconnecting_pty', + -- Web events + 'workspace_app', + 'port_forwarding' +); + +CREATE TABLE connection_logs ( + id uuid NOT NULL, + "time" timestamp with time zone NOT 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, + type connection_type NOT NULL, + code integer, + ip inet, + + -- Only set for web events + user_agent text, + user_id uuid, + slug_or_port text, + + -- 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, + + 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 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 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 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 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.'; + +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 '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.'; + +-- 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); +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..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.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") } }) diff --git a/coderd/database/migrations/testdata/fixtures/000341_connection_logs.up.sql b/coderd/database/migrations/testdata/fixtures/000341_connection_logs.up.sql new file mode 100644 index 0000000000000..fa57b128d3a78 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000341_connection_logs.up.sql @@ -0,0 +1,53 @@ +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_time, + close_reason +) VALUES ( + '00000000-0000-0000-0000-000000000001', -- log 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 + 'ssh', -- type + 0, -- code + '127.0.0.1', -- ip + NULL, -- user agent + NULL, -- user id + NULL, -- slug or port + '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', -- 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 + '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', + 'a0061a8e-7db7-4585-838c-3116a003dd21', -- user id + 'code-server', -- slug or port + NULL, -- connection id (request ID) + NULL, -- close time + 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..84619941f9756 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,83 @@ 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.Type, + &i.ConnectionLog.Code, + &i.ConnectionLog.Ip, + &i.ConnectionLog.UserAgent, + &i.ConnectionLog.UserID, + &i.ConnectionLog.SlugOrPort, + &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 + } + 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..74943d3b07e09 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 ( @@ -412,6 +413,134 @@ func AllBuildReasonValues() []BuildReason { } } +type ConnectionStatus string + +const ( + ConnectionStatusConnected ConnectionStatus = "connected" + ConnectionStatusDisconnected ConnectionStatus = "disconnected" +) + +func (e *ConnectionStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ConnectionStatus(s) + case string: + *e = ConnectionStatus(s) + default: + return fmt.Errorf("unsupported scan type for ConnectionStatus: %T", src) + } + return nil +} + +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 *NullConnectionStatus) Scan(value interface{}) error { + if value == nil { + ns.ConnectionStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ConnectionStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullConnectionStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ConnectionStatus), nil +} + +func (e ConnectionStatus) Valid() bool { + switch e { + case ConnectionStatusConnected, + ConnectionStatusDisconnected: + return true + } + return false +} + +func AllConnectionStatusValues() []ConnectionStatus { + return []ConnectionStatus{ + ConnectionStatusConnected, + ConnectionStatusDisconnected, + } +} + +type ConnectionType string + +const ( + ConnectionTypeSsh ConnectionType = "ssh" + ConnectionTypeVscode ConnectionType = "vscode" + ConnectionTypeJetbrains ConnectionType = "jetbrains" + ConnectionTypeReconnectingPty ConnectionType = "reconnecting_pty" + ConnectionTypeWorkspaceApp ConnectionType = "workspace_app" + ConnectionTypePortForwarding ConnectionType = "port_forwarding" +) + +func (e *ConnectionType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ConnectionType(s) + case string: + *e = ConnectionType(s) + default: + return fmt.Errorf("unsupported scan type for ConnectionType: %T", src) + } + return nil +} + +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 *NullConnectionType) Scan(value interface{}) error { + if value == nil { + ns.ConnectionType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ConnectionType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullConnectionType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ConnectionType), nil +} + +func (e ConnectionType) Valid() bool { + switch e { + case ConnectionTypeSsh, + ConnectionTypeVscode, + ConnectionTypeJetbrains, + ConnectionTypeReconnectingPty, + ConnectionTypeWorkspaceApp, + ConnectionTypePortForwarding: + return true + } + return false +} + +func AllConnectionTypeValues() []ConnectionType { + return []ConnectionType{ + ConnectionTypeSsh, + ConnectionTypeVscode, + ConnectionTypeJetbrains, + ConnectionTypeReconnectingPty, + ConnectionTypeWorkspaceApp, + ConnectionTypePortForwarding, + } +} + type CryptoKeyFeature string const ( @@ -2792,6 +2921,32 @@ 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"` + Type ConnectionType `db:"type" json:"type"` + // 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 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 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 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"` + // 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"` + // 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"` +} + 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..cc382f4972d5a 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) @@ -638,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 74ac5b0a20caf..126c42f02eaaa 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -2049,6 +2049,368 @@ 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.UpsertConnectionLogParams{ + WorkspaceID: wsID, + WorkspaceOwnerID: user.ID, + 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 +} + +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, + ConnectionID: uuid.NullUUID{UUID: connectionID, Valid: true}, + ConnectionStatus: database.ConnectionStatusConnected, + } + + 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 '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, + 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(), + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + WorkspaceName: ws.Name, + Type: database.ConnectionTypeSsh, + } + + 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, + ConnectionID: uuid.NullUUID{UUID: connectionID, Valid: true}, + ConnectionStatus: database.ConnectionStatusConnected, + } + + 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, + ConnectionStatus: database.ConnectionStatusConnected, + + // Ignored + ID: uuid.New(), + Time: connectTime2, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + WorkspaceName: ws.Name, + Type: database.ConnectionTypeSsh, + Code: sql.NullInt32{Int32: 0, Valid: false}, + } + + 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) + }) + + 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 { 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..8b2a44db27ae4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -967,6 +967,248 @@ 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.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 + -- 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 +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 + -- @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"` + 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) { + 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.Type, + &i.ConnectionLog.Code, + &i.ConnectionLog.Ip, + &i.ConnectionLog.UserAgent, + &i.ConnectionLog.UserID, + &i.ConnectionLog.SlugOrPort, + &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 + } + 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 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, + close_time +) VALUES + ($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. + 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 +` + +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 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"` + ConnectionStatus ConnectionStatus `db:"connection_status" json:"connection_status"` +} + +func (q *sqlQuerier) UpsertConnectionLog(ctx context.Context, arg UpsertConnectionLogParams) (ConnectionLog, error) { + row := q.db.QueryRowContext(ctx, upsertConnectionLog, + arg.ID, + arg.Time, + arg.OrganizationID, + arg.WorkspaceOwnerID, + arg.WorkspaceID, + arg.WorkspaceName, + arg.AgentName, + arg.Type, + arg.Code, + arg.Ip, + arg.UserAgent, + arg.UserID, + arg.SlugOrPort, + arg.ConnectionID, + arg.CloseReason, + arg.ConnectionStatus, + ) + var i ConnectionLog + err := row.Scan( + &i.ID, + &i.Time, + &i.OrganizationID, + &i.WorkspaceOwnerID, + &i.WorkspaceID, + &i.WorkspaceName, + &i.AgentName, + &i.Type, + &i.Code, + &i.Ip, + &i.UserAgent, + &i.UserID, + &i.SlugOrPort, + &i.ConnectionID, + &i.CloseTime, + &i.CloseReason, + ) + 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..024fbfafd1896 --- /dev/null +++ b/coderd/database/queries/connectionlogs.sql @@ -0,0 +1,95 @@ +-- name: GetConnectionLogsOffset :many +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 + -- 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 +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 + -- @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: 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, + close_time +) VALUES + ($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. + 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/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..4335be3af0d66 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); @@ -102,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/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..00e46c4f6daa4 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,25 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW ip := r.RemoteAddr // Approximation of the status code. - statusCode := sw.Status + // #nosec G115 - Safe conversion as HTTP status code is expected to be within int32 range (typically 100-599) + var statusCode int32 = int32(sw.Status) if statusCode == 0 { statusCode = http.StatusOK } - type additionalFields struct { - audit.AdditionalFields - SlugOrPort string `json:"slug_or_port,omitempty"` - } - appInfo := additionalFields{ - AdditionalFields: audit.AdditionalFields{ - WorkspaceOwner: aReq.dbReq.Workspace.OwnerUsername, - WorkspaceName: aReq.dbReq.Workspace.Name, - WorkspaceID: aReq.dbReq.Workspace.ID, - }, - } + var ( + connType database.ConnectionType + slugOrPort = aReq.dbReq.AppSlugOrPort + ) + switch { case aReq.dbReq.AccessMethod == AccessMethodTerminal: - appInfo.SlugOrPort = "terminal" + connType = database.ConnectionTypeWorkspaceApp + slugOrPort = "terminal" case aReq.dbReq.App.ID == uuid.Nil: - // If this isn't an app or a terminal, it's a port. - appInfo.SlugOrPort = aReq.dbReq.AppSlugOrPort + connType = database.ConnectionTypePortForwarding + default: + connType = database.ConnectionTypeWorkspaceApp } // If we end up logging, ensure relevant fields are set. @@ -444,7 +440,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 +460,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 +474,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 } @@ -490,51 +485,37 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW return } - // Marshal additional fields only if we're writing an audit log entry. - appInfoBytes, err := json.Marshal(appInfo) - if err != nil { - logger.Error(ctx, "marshal additional fields failed", slog.Error(err)) - } + connLogger := *p.ConnectionLogger.Load() + + err = connLogger.Upsert(ctx, database.UpsertConnectionLogParams{ + ID: uuid.New(), + Time: aReq.time, + OrganizationID: aReq.dbReq.Workspace.OrganizationID, + WorkspaceOwnerID: aReq.dbReq.Workspace.OwnerID, + WorkspaceID: aReq.dbReq.Workspace.ID, + WorkspaceName: aReq.dbReq.Workspace.Name, + AgentName: aReq.dbReq.Agent.Name, + Type: connType, + Code: sql.NullInt32{ + Int32: statusCode, + Valid: true, + }, + Ip: database.ParseIP(ip), + UserAgent: sql.NullString{Valid: userAgent != "", String: userAgent}, + UserID: uuid.NullUUID{ + UUID: userID, + Valid: userID != uuid.Nil, + }, + SlugOrPort: sql.NullString{Valid: slugOrPort != "", String: slugOrPort}, + ConnectionStatus: database.ConnectionStatusConnected, - // 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, - }) + // N/A + ConnectionID: uuid.NullUUID{}, + CloseReason: sql.NullString{}, + }) + if err != nil { + 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 597d1daadfa54..50ef0dec1519a 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.NewFake() t.Cleanup(func() { if t.Failed() { return } - assert.Len(t, auditor.AuditLogs(), 0, "one or more test cases produced unexpected audit logs, did you replace the auditor or forget to call ResetLogs?") + assert.Len(t, connLogger.ConnectionLogs(), 0, "one or more test cases produced unexpected connection logs, did you replace the auditor or forget to call ResetLogs?") }) client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ AppHostname: "*.test.coder.com", @@ -105,7 +103,7 @@ func Test_ResolveRequest(t *testing.T) { "CF-Connecting-IP", }, }, - Auditor: auditor, + ConnectionLogger: connLogger, }) t.Cleanup(func() { _ = closer.Close() @@ -231,23 +229,8 @@ func Test_ResolveRequest(t *testing.T) { } require.NotEqual(t, uuid.Nil, agentID) - //nolint:gocritic // This is a test, allow dbauthz.AsSystemRestricted. - agent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) - require.NoError(t, err) - - //nolint:gocritic // This is a test, allow dbauthz.AsSystemRestricted. - apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) - require.NoError(t, err) - appsBySlug := make(map[string]database.WorkspaceApp, len(apps)) - for _, app := range apps { - appsBySlug[app.Slug] = app - } - // Reset audit logs so cleanup check can pass. - auditor.ResetLogs() - - assertAuditAgent := auditAsserter[database.WorkspaceAgent](workspace) - assertAuditApp := auditAsserter[database.WorkspaceApp](workspace) + connLogger.Reset() t.Run("OK", func(t *testing.T) { t.Parallel() @@ -287,9 +270,9 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: app, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) - auditableUA := "Tidua" + auditableUA := "Noitcennoc" t.Log("app", app) rw := httptest.NewRecorder() @@ -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, database.ConnectionTypeWorkspaceApp, 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.NewFake() 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, database.ConnectionTypeWorkspaceApp, 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.NewFake() auditableIP := testutil.RandomIPv6(t) t.Log("app", app) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -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, database.ConnectionTypeWorkspaceApp, 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, database.ConnectionTypeWorkspaceApp, 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.NewFake() auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -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.NewFake() 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, database.ConnectionTypeWorkspaceApp, me.ID) + require.Len(t, connLogger.ConnectionLogs(), 1) } else { require.Nil(t, token) - require.Len(t, auditor.AuditLogs(), 0, "no audit logs") + require.Len(t, connLogger.ConnectionLogs(), 0) } _ = w.Body.Close() }) @@ -639,7 +623,7 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameOwner, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewFake() 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, database.ConnectionTypeWorkspaceApp, 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.NewFake() 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.NewFake() 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", database.ConnectionTypePortForwarding, 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.NewFake() 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.NewFake() 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, database.ConnectionTypeWorkspaceApp, 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.NewFake() 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", database.ConnectionTypeWorkspaceApp, 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.NewFake() 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, database.ConnectionTypeWorkspaceApp, 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.NewFake() 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.NewFake() 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, database.ConnectionTypeWorkspaceApp, 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.NewFake() 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, database.ConnectionTypeWorkspaceApp, 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.NewFake() 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, database.ConnectionTypeWorkspaceApp, 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.NewFake() 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, database.ConnectionTypeWorkspaceApp, me.ID) + require.Len(t, connLogger.ConnectionLogs(), 1) }) - t.Run("AuditLogging", func(t *testing.T) { + t.Run("ConnectionLogging", func(t *testing.T) { t.Parallel() for _, app := range allApps { @@ -1170,18 +1149,18 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: app, }).Normalize() - auditor := audit.NewMock() + connLogger := connectionlog.NewFake() auditableIP := testutil.RandomIPv6(t) t.Log("app", app) - // First request, new audit log. + // First request, new connection log. rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) r.RemoteAddr = auditableIP - _, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ + _, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -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, database.ConnectionTypeWorkspaceApp, 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, database.ConnectionTypeWorkspaceApp, me.ID) + require.Len(t, connLogger.ConnectionLogs(), 2, "two connection logs, session timed out") // Fourth request, new IP produces new audit log. auditableIP = testutil.RandomIPv6(t) @@ -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, database.ConnectionTypeWorkspaceApp, me.ID) + require.Len(t, connLogger.ConnectionLogs(), 3, "three connection logs, new IP") } }) } -func workspaceappsResolveRequest(t testing.TB, auditor audit.Auditor, w http.ResponseWriter, r *http.Request, opts workspaceapps.ResolveRequestOptions) (token *workspaceapps.SignedToken, ok bool) { +func workspaceappsResolveRequest(t testing.TB, connLogger connectionlog.ConnectionLogger, w http.ResponseWriter, r *http.Request, opts workspaceapps.ResolveRequestOptions) (token *workspaceapps.SignedToken, ok bool) { t.Helper() - if opts.SignedTokenProvider != nil && auditor != nil { - opts.SignedTokenProvider = signedTokenProviderWithAuditor(t, opts.SignedTokenProvider, auditor, time.Hour) + if opts.SignedTokenProvider != nil && connLogger != nil { + opts.SignedTokenProvider = signedTokenProviderWithConnLogger(t, opts.SignedTokenProvider, connLogger, time.Hour) } tracing.StatusWriterMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1266,52 +1245,41 @@ func workspaceappsResolveRequest(t testing.TB, auditor audit.Auditor, w http.Res return token, ok } -func signedTokenProviderWithAuditor(t testing.TB, provider workspaceapps.SignedTokenProvider, auditor audit.Auditor, sessionTimeout time.Duration) workspaceapps.SignedTokenProvider { +func signedTokenProviderWithConnLogger(t testing.TB, provider workspaceapps.SignedTokenProvider, connLogger connectionlog.ConnectionLogger, sessionTimeout time.Duration) workspaceapps.SignedTokenProvider { t.Helper() p, ok := provider.(*workspaceapps.DBTokenProvider) require.True(t, ok, "provider is not a DBTokenProvider") shallowCopy := *p - shallowCopy.Auditor = &atomic.Pointer[audit.Auditor]{} - shallowCopy.Auditor.Store(&auditor) + shallowCopy.ConnectionLogger = &atomic.Pointer[connectionlog.ConnectionLogger]{} + shallowCopy.ConnectionLogger.Store(&connLogger) shallowCopy.WorkspaceAppAuditSessionTimeout = sessionTimeout return &shallowCopy } -func auditAsserter[T audit.Auditable](workspace codersdk.Workspace) func(t testing.TB, rr *httptest.ResponseRecorder, r *http.Request, auditor *audit.MockAuditor, auditable T, userID uuid.UUID, additionalFields map[string]any) { - return func(t testing.TB, rr *httptest.ResponseRecorder, r *http.Request, auditor *audit.MockAuditor, auditable T, userID uuid.UUID, additionalFields map[string]any) { - t.Helper() - - 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.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() - 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.UpsertConnectionLogParams{ + OrganizationID: workspace.OrganizationID, + WorkspaceOwnerID: workspace.OwnerID, + WorkspaceID: workspace.ID, + WorkspaceName: workspace.Name, + AgentName: agentName, + Type: typ, + Ip: database.ParseIP(r.RemoteAddr), + UserAgent: sql.NullString{Valid: r.UserAgent() != "", String: r.UserAgent()}, + Code: sql.NullInt32{ + Int32: int32(resp.StatusCode), // nolint:gosec + Valid: true, + }, + UserID: uuid.NullUUID{ + UUID: userID, + Valid: true, + }, + SlugOrPort: sql.NullString{Valid: slugOrPort != "", String: slugOrPort}, + })) } 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 { 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..e428a13baf183 --- /dev/null +++ b/enterprise/coderd/connectionlog/connectionlog.go @@ -0,0 +1,66 @@ +package connectionlog + +import ( + "context" + + "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" + auditbackends "github.com/coder/coder/v2/enterprise/audit/backends" +) + +type Backend interface { + Upsert(ctx context.Context, clog database.UpsertConnectionLogParams) error +} + +func NewConnectionLogger(backends ...Backend) agpl.ConnectionLogger { + return &connectionLogger{ + backends: backends, + } +} + +type connectionLogger struct { + backends []Backend +} + +func (c *connectionLogger) Upsert(ctx context.Context, clog database.UpsertConnectionLogParams) error { + var errs error + for _, backend := range c.backends { + err := backend.Upsert(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) Upsert(ctx context.Context, clog database.UpsertConnectionLogParams) error { + //nolint:gocritic // This is the Connection Logger + _, err := b.db.UpsertConnectionLog(dbauthz.AsConnectionLogger(ctx), clog) + return err +} + +type connectionSlogBackend struct { + exporter *auditbackends.SlogExporter +} + +func NewSlogBackend(logger slog.Logger) Backend { + return &connectionSlogBackend{ + exporter: auditbackends.NewSlogExporter(logger), + } +} + +func (b *connectionSlogBackend) Upsert(ctx context.Context, clog database.UpsertConnectionLogParams) 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..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", @@ -2174,6 +2176,7 @@ export type RBACResource = | "assign_role" | "audit_log" | "chat" + | "connection_log" | "crypto_key" | "debug_info" | "deployment_config" @@ -2213,6 +2216,7 @@ export const RBACResources: RBACResource[] = [ "assign_role", "audit_log", "chat", + "connection_log", "crypto_key", "debug_info", "deployment_config",