feat: add agentapi endpoint to report connections for audit (#16507)

This change adds a new `ReportConnection` endpoint to the `agentapi`.

The protocol version was bumped previously, so it has been omitted here.

This allows the agent to report connection events, for example when the
user connects to the workspace via SSH or VS Code.

Updates #15139
This commit is contained in:
Mathias Fredriksson
2025-02-20 14:52:01 +02:00
committed by GitHub
parent dedc32fb1a
commit b07b33ec9d
16 changed files with 1488 additions and 709 deletions
+10
View File
@@ -19,6 +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/database"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/externalauth"
@@ -48,6 +49,7 @@ type API struct {
*ResourcesMonitoringAPI
*LogsAPI
*ScriptsAPI
*AuditAPI
*tailnet.DRPCService
mu sync.Mutex
@@ -66,6 +68,7 @@ type Options struct {
Database database.Store
NotificationsEnqueuer notifications.Enqueuer
Pubsub pubsub.Pubsub
Auditor *atomic.Pointer[audit.Auditor]
DerpMapFn func() *tailcfg.DERPMap
TailnetCoordinator *atomic.Pointer[tailnet.Coordinator]
StatsReporter *workspacestats.Reporter
@@ -174,6 +177,13 @@ func New(opts Options) *API {
Database: opts.Database,
}
api.AuditAPI = &AuditAPI{
AgentFn: api.agent,
Auditor: opts.Auditor,
Database: opts.Database,
Log: opts.Log,
}
api.DRPCService = &tailnet.DRPCService{
CoordPtr: opts.TailnetCoordinator,
Logger: opts.Log,
+105
View File
@@ -0,0 +1,105 @@
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
}
+179
View File
@@ -0,0 +1,179 @@
package agentapi_test
import (
"context"
"encoding/json"
"net"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"google.golang.org/protobuf/types/known/timestamppb"
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/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) {
t.Parallel()
var (
owner = database.User{
ID: uuid.New(),
Username: "cool-user",
}
workspace = database.Workspace{
ID: uuid.New(),
OrganizationID: uuid.New(),
OwnerID: owner.ID,
Name: "cool-workspace",
}
build = database.WorkspaceBuild{
ID: uuid.New(),
WorkspaceID: workspace.ID,
}
agent = database.WorkspaceAgent{
ID: uuid.New(),
}
)
tests := []struct {
name string
id uuid.UUID
action *agentproto.Connection_Action
typ *agentproto.Connection_Type
time time.Time
ip string
status int32
reason string
}{
{
name: "SSH Connect",
id: uuid.New(),
action: agentproto.Connection_CONNECT.Enum(),
typ: agentproto.Connection_SSH.Enum(),
time: time.Now(),
ip: "127.0.0.1",
status: 200,
},
{
name: "VS Code Connect",
id: uuid.New(),
action: agentproto.Connection_CONNECT.Enum(),
typ: agentproto.Connection_VSCODE.Enum(),
time: time.Now(),
ip: "8.8.8.8",
},
{
name: "JetBrains Connect",
id: uuid.New(),
action: agentproto.Connection_CONNECT.Enum(),
typ: agentproto.Connection_JETBRAINS.Enum(),
time: time.Now(),
},
{
name: "Reconnecting PTY Connect",
id: uuid.New(),
action: agentproto.Connection_CONNECT.Enum(),
typ: agentproto.Connection_RECONNECTING_PTY.Enum(),
time: time.Now(),
},
{
name: "SSH Disconnect",
id: uuid.New(),
action: agentproto.Connection_DISCONNECT.Enum(),
typ: agentproto.Connection_SSH.Enum(),
time: time.Now(),
},
{
name: "SSH Disconnect",
id: uuid.New(),
action: agentproto.Connection_DISCONNECT.Enum(),
typ: agentproto.Connection_SSH.Enum(),
time: time.Now(),
status: 500,
reason: "because error says so",
},
}
//nolint:paralleltest // No longer necessary to reinitialise the variable tt.
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mAudit := audit.NewMock()
mDB := dbmock.NewMockStore(gomock.NewController(t))
mDB.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(workspace, nil)
mDB.EXPECT().GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), workspace.ID).Return(build, nil)
api := &agentapi.AuditAPI{
Auditor: asAtomicPointer[audit.Auditor](mAudit),
Database: mDB,
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
return agent, nil
},
}
api.ReportConnection(context.Background(), &agentproto.ReportConnectionRequest{
Connection: &agentproto.Connection{
Id: tt.id[:],
Action: *tt.action,
Type: *tt.typ,
Timestamp: timestamppb.New(tt.time),
Ip: tt.ip,
StatusCode: tt.status,
Reason: &tt.reason,
},
})
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,
})
// 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"])
}
})
}
}
func agentProtoConnectionActionToAudit(t *testing.T, action agentproto.Connection_Action) database.AuditAction {
a, err := db2sdk.AuditActionFromAgentProtoConnectionAction(action)
require.NoError(t, err)
return a
}
func agentProtoConnectionTypeToSDK(t *testing.T, typ agentproto.Connection_Type) agentsdk.ConnectionType {
action, err := agentsdk.ConnectionTypeFromProto(typ)
require.NoError(t, err)
return action
}
func asAtomicPointer[T any](v T) *atomic.Pointer[T] {
var p atomic.Pointer[T]
p.Store(&v)
return &p
}