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

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

### Context

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

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

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


> [!NOTE]
> The PRs in this stack obviously won't be (completely) atomic. Whilst they'll each pass CI, the stack is designed to be merged all at once. I'm splitting them up for the sake of those reviewing, and so changes can be reviewed as early as possible.  Despite this, it's really hard to make this PR any smaller than it already is. I'll be keeping it in draft until it's actually ready to merge.
This commit is contained in:
Ethan
2025-07-15 14:36:06 +10:00
committed by GitHub
parent 43b0bb7f61
commit 08e17a07fc
54 changed files with 2199 additions and 493 deletions
+155
View File
@@ -196,6 +196,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 (
@@ -415,6 +416,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 (
@@ -2784,6 +2913,32 @@ type AuditLog struct {
ResourceIcon string `db:"resource_icon" json:"resource_icon"`
}
type ConnectionLog struct {
ID uuid.UUID `db:"id" json:"id"`
ConnectTime time.Time `db:"connect_time" json:"connect_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"`
Ip pqtype.Inet `db:"ip" json:"ip"`
// Either the HTTP status code of the web request, or the exit code of an SSH connection. For non-web connections, this is Null until we receive a disconnect event for the same connection_id.
Code sql.NullInt32 `db:"code" json:"code"`
// 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"`
// Null 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.
DisconnectTime sql.NullTime `db:"disconnect_time" json:"disconnect_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.
DisconnectReason sql.NullString `db:"disconnect_reason" json:"disconnect_reason"`
}
type CryptoKey struct {
Feature CryptoKeyFeature `db:"feature" json:"feature"`
Sequence int32 `db:"sequence" json:"sequence"`