mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
Merge branch 'main' of github.com:/coder/coder into dk/prebuilds
This commit is contained in:
@ -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
|
||||
@ -176,6 +179,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
coderd/agentapi/audit.go
Normal file
105
coderd/agentapi/audit.go
Normal 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
coderd/agentapi/audit_test.go
Normal file
179
coderd/agentapi/audit_test.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user