mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: log tailnet tunnel authorization decisions (#27819)
This commit is contained in:
Generated
+2
-2
@@ -18995,11 +18995,11 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"status_code": {
|
||||
"description": "StatusCode is the HTTP status code of the request.",
|
||||
"description": "StatusCode is the HTTP status code or tunnel authorization outcome.",
|
||||
"type": "integer"
|
||||
},
|
||||
"user": {
|
||||
"description": "User is omitted if the connection event was from an unauthenticated user.",
|
||||
"description": "User is omitted if the connection event was unauthenticated.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.User"
|
||||
|
||||
Generated
+2
-2
@@ -17173,11 +17173,11 @@
|
||||
"type": "string"
|
||||
},
|
||||
"status_code": {
|
||||
"description": "StatusCode is the HTTP status code of the request.",
|
||||
"description": "StatusCode is the HTTP status code or tunnel authorization outcome.",
|
||||
"type": "integer"
|
||||
},
|
||||
"user": {
|
||||
"description": "User is omitted if the connection event was from an unauthenticated user.",
|
||||
"description": "User is omitted if the connection event was unauthenticated.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.User"
|
||||
|
||||
+105
-61
@@ -1369,20 +1369,20 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
api.logTunnelConnection(ctx, r, waws)
|
||||
|
||||
ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageBinary)
|
||||
defer wsNetConn.Close()
|
||||
|
||||
ctx = api.wsWatcher.Watch(ctx, api.Logger, conn)
|
||||
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
auth := tailnet.ClientCoordinateeAuth{
|
||||
AgentID: waws.WorkspaceAgent.ID,
|
||||
Auditor: api.tunnelAuditor(r),
|
||||
}
|
||||
err = api.TailnetClientService.ServeClient(ctx, version, wsNetConn, tailnet.StreamID{
|
||||
Name: "client",
|
||||
ID: peerID,
|
||||
Auth: tailnet.ClientCoordinateeAuth{
|
||||
AgentID: waws.WorkspaceAgent.ID,
|
||||
},
|
||||
Auth: auth,
|
||||
})
|
||||
if err != nil && !xerrors.Is(err, io.EOF) && !xerrors.Is(err, context.Canceled) {
|
||||
_ = conn.Close(websocket.StatusInternalError, err.Error())
|
||||
@@ -1390,64 +1390,110 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
|
||||
}
|
||||
}
|
||||
|
||||
// logTunnelConnection records a connection log entry attributing a
|
||||
// tunnel to the authenticated user who opened it. Agent-reported rows
|
||||
// cannot identify the user (see coderd/agentapi/connectionlog.go), and
|
||||
// workspace-proxy-authenticated requests carry no API key and are
|
||||
// skipped.
|
||||
func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws database.GetWorkspaceAgentAndWorkspaceByIDRow) {
|
||||
apiKey, ok := httpmw.APIKeyOptional(r)
|
||||
type connectionLogTunnelAuditor struct {
|
||||
api *API
|
||||
userID uuid.UUID
|
||||
ip string
|
||||
userAgent string
|
||||
}
|
||||
|
||||
var _ tailnet.TunnelAuditor = connectionLogTunnelAuditor{}
|
||||
|
||||
func (a connectionLogTunnelAuditor) Audit(agentID uuid.UUID, authorizationErr error) {
|
||||
statusCode := int32(http.StatusSwitchingProtocols)
|
||||
if authorizationErr != nil {
|
||||
statusCode = http.StatusForbidden
|
||||
}
|
||||
// Authorization runs under the in-memory coordinator mutex, so the
|
||||
// database write cannot happen inline.
|
||||
go a.api.logTunnelConnection(agentID, statusCode, a.userID, a.ip, a.userAgent)
|
||||
}
|
||||
|
||||
func (api *API) tunnelAuditor(r *http.Request) tailnet.TunnelAuditor {
|
||||
subject, ok := dbauthz.ActorFromContext(r.Context())
|
||||
if !ok {
|
||||
api.Logger.Error(r.Context(), "tunnel auditor missing authorization subject")
|
||||
return nil
|
||||
}
|
||||
if subject.Type != rbac.SubjectTypeUser {
|
||||
api.Logger.Debug(r.Context(), "skip tunnel audit for non-user subject",
|
||||
slog.F("subject_type", subject.Type),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
userID, err := uuid.Parse(subject.ID)
|
||||
if err != nil {
|
||||
api.Logger.Error(r.Context(), "parse tunnel auditor user ID", slog.Error(err))
|
||||
return nil
|
||||
}
|
||||
return connectionLogTunnelAuditor{
|
||||
api: api,
|
||||
userID: userID,
|
||||
ip: r.RemoteAddr,
|
||||
userAgent: r.UserAgent(),
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) logTunnelConnection(agentID uuid.UUID, statusCode int32, userID uuid.UUID, ip, userAgent string) {
|
||||
ctx, cancel := context.WithTimeout(api.ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// nolint:gocritic // System context is needed to attribute denied tunnel decisions.
|
||||
waws, err := api.Database.GetWorkspaceAgentAndWorkspaceByID(dbauthz.AsSystemRestricted(ctx), agentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
api.Logger.Warn(ctx, "skip tunnel connection log for unknown agent",
|
||||
slog.F("agent_id", agentID),
|
||||
slog.F("user_id", userID),
|
||||
)
|
||||
return
|
||||
}
|
||||
api.Logger.Error(ctx, "resolve tunnel connection log agent",
|
||||
slog.F("agent_id", agentID),
|
||||
slog.F("user_id", userID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
// Bounded so log backpressure cannot stall tunnel establishment.
|
||||
writeCtx, writeCancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer writeCancel()
|
||||
userAgent := r.UserAgent()
|
||||
now := dbtime.Now()
|
||||
|
||||
// Clients re-dial automatically, so dedupe reconnects through the
|
||||
// same audit session mechanism as workspace apps, keyed on
|
||||
// (agent, user, IP, user agent). Status 101 and the empty slug
|
||||
// keep tunnel sessions from ever colliding with app or
|
||||
// port-forwarding sessions.
|
||||
staleInterval := api.Options.WorkspaceAppAuditSessionTimeout
|
||||
if staleInterval == 0 {
|
||||
staleInterval = time.Hour
|
||||
}
|
||||
// nolint:gocritic // System context is needed to write audit sessions.
|
||||
newSession, err := api.Database.UpsertWorkspaceAppAuditSession(dbauthz.AsSystemRestricted(writeCtx), database.UpsertWorkspaceAppAuditSessionParams{
|
||||
// Config.
|
||||
now := dbtime.Now()
|
||||
// nolint:gocritic // System context is needed to write connection-log dedupe sessions.
|
||||
newSession, err := api.Database.UpsertWorkspaceAppAuditSession(dbauthz.AsSystemRestricted(ctx), database.UpsertWorkspaceAppAuditSessionParams{
|
||||
StaleIntervalMS: staleInterval.Milliseconds(),
|
||||
|
||||
// Data.
|
||||
ID: uuid.New(),
|
||||
AgentID: waws.WorkspaceAgent.ID,
|
||||
AppID: uuid.Nil, // Tunnels are not associated with an app.
|
||||
UserID: apiKey.UserID,
|
||||
Ip: r.RemoteAddr,
|
||||
UserAgent: userAgent,
|
||||
SlugOrPort: "",
|
||||
StatusCode: http.StatusSwitchingProtocols,
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: uuid.New(),
|
||||
AgentID: agentID,
|
||||
AppID: uuid.Nil,
|
||||
UserID: userID,
|
||||
Ip: ip,
|
||||
UserAgent: userAgent,
|
||||
SlugOrPort: "",
|
||||
StatusCode: statusCode,
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
// Skip logging rather than risk spamming the connection log.
|
||||
api.Logger.Error(ctx, "upsert tunnel audit session",
|
||||
slog.F("workspace_id", waws.WorkspaceTable.ID),
|
||||
slog.F("user_id", apiKey.UserID),
|
||||
slog.F("agent_id", waws.WorkspaceAgent.ID),
|
||||
slog.F("user_id", userID),
|
||||
slog.F("user_agent", userAgent),
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if !newSession {
|
||||
// Reconnection of an already-logged session.
|
||||
return
|
||||
}
|
||||
|
||||
connLogger := *api.ConnectionLogger.Load()
|
||||
err = connLogger.Upsert(writeCtx, database.UpsertConnectionLogParams{
|
||||
logger := api.ConnectionLogger.Load()
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
err = (*logger).Upsert(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now,
|
||||
OrganizationID: waws.WorkspaceTable.OrganizationID,
|
||||
@@ -1456,25 +1502,21 @@ func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws d
|
||||
WorkspaceName: waws.WorkspaceTable.Name,
|
||||
AgentName: waws.WorkspaceAgent.Name,
|
||||
Type: database.ConnectionTypeTunnel,
|
||||
IP: database.ParseIP(r.RemoteAddr),
|
||||
Code: sql.NullInt32{
|
||||
Int32: http.StatusSwitchingProtocols,
|
||||
Valid: true,
|
||||
},
|
||||
UserAgent: sql.NullString{String: userAgent, Valid: userAgent != ""},
|
||||
UserID: uuid.NullUUID{UUID: apiKey.UserID, Valid: true},
|
||||
// Left unset so each session gets its own row; reusing peerID
|
||||
// would make resume_token reconnects upsert into a stale row.
|
||||
ConnectionID: uuid.NullUUID{},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
// N/A
|
||||
IP: database.ParseIP(ip),
|
||||
Code: sql.NullInt32{Int32: statusCode, Valid: true},
|
||||
UserAgent: sql.NullString{String: userAgent, Valid: userAgent != ""},
|
||||
UserID: uuid.NullUUID{UUID: userID, Valid: userID != uuid.Nil},
|
||||
SlugOrPort: sql.NullString{},
|
||||
ConnectionID: uuid.NullUUID{},
|
||||
DisconnectReason: sql.NullString{},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "upsert tunnel connection log",
|
||||
slog.F("workspace_id", waws.WorkspaceTable.ID),
|
||||
slog.F("user_id", apiKey.UserID),
|
||||
slog.F("agent_id", waws.WorkspaceAgent.ID),
|
||||
slog.F("user_id", userID),
|
||||
slog.F("user_agent", userAgent),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
@@ -2542,15 +2584,17 @@ func (api *API) tailnetRPCConn(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
ctx = api.wsWatcher.Watch(ctx, api.Logger, conn)
|
||||
auth := tailnet.ClientUserCoordinateeAuth{
|
||||
Auth: &rbacAuthorizer{
|
||||
sshPrep: sshPrep,
|
||||
db: api.Database,
|
||||
},
|
||||
Auditor: api.tunnelAuditor(r),
|
||||
}
|
||||
err = api.TailnetClientService.ServeClient(ctx, version, wsNetConn, tailnet.StreamID{
|
||||
Name: "client",
|
||||
ID: peerID,
|
||||
Auth: tailnet.ClientUserCoordinateeAuth{
|
||||
Auth: &rbacAuthorizer{
|
||||
sshPrep: sshPrep,
|
||||
db: api.Database,
|
||||
},
|
||||
},
|
||||
Auth: auth,
|
||||
})
|
||||
if err != nil && !xerrors.Is(err, io.EOF) && !xerrors.Is(err, context.Canceled) {
|
||||
_ = conn.Close(websocket.StatusInternalError, err.Error())
|
||||
|
||||
@@ -967,28 +967,6 @@ func TestWorkspaceAgentClientCoordinate_ConnectionLog(t *testing.T) {
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
err = conn.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// A second handshake within the audit session stale interval is a
|
||||
// reconnection and must be deduplicated rather than producing a
|
||||
// second row.
|
||||
conn2, err := workspacesdk.New(client).
|
||||
DialAgent(ctx, resources[0].Agents[0].ID, &workspacesdk.DialAgentOptions{
|
||||
Logger: testutil.Logger(t).Named("client2"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer conn2.Close()
|
||||
// The connection log write happens in the coordinate handler
|
||||
// before any coordination traffic is served, so once the tunnel is
|
||||
// reachable the second handshake has already been processed.
|
||||
require.True(t, conn2.AwaitReachable(ctx))
|
||||
|
||||
tunnelRows := 0
|
||||
for _, cl := range connLogger.ConnectionLogs() {
|
||||
if cl.Type == database.ConnectionTypeTunnel {
|
||||
tunnelRows++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, tunnelRows)
|
||||
}
|
||||
|
||||
func TestWorkspaceAgentClientCoordinate_BadVersion(t *testing.T) {
|
||||
@@ -3104,6 +3082,87 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserTailnetConnectionLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
connLogger := connectionlog.NewFake()
|
||||
firstClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
ConnectionLogger: connLogger,
|
||||
Coordinator: tailnet.NewCoordinator(logger),
|
||||
Logger: &logger,
|
||||
})
|
||||
firstUser := coderdtest.CreateFirstUser(t, firstClient)
|
||||
member, memberUser := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID)
|
||||
|
||||
allowedWorkspace := buildWorkspaceWithAgent(t, member, firstUser.OrganizationID, memberUser.ID, api.Database, api.Pubsub)
|
||||
allowedSDKWorkspace, err := member.Workspace(ctx, allowedWorkspace.ID)
|
||||
require.NoError(t, err)
|
||||
allowedAgentID := allowedSDKWorkspace.LatestBuild.Resources[0].Agents[0].ID
|
||||
|
||||
deniedWorkspace := buildWorkspaceWithAgent(t, firstClient, firstUser.OrganizationID, firstUser.UserID, api.Database, api.Pubsub)
|
||||
deniedSDKWorkspace, err := firstClient.Workspace(ctx, deniedWorkspace.ID)
|
||||
require.NoError(t, err)
|
||||
deniedAgentID := deniedSDKWorkspace.LatestBuild.Resources[0].Agents[0].ID
|
||||
|
||||
dial := func() (*websocket.Conn, tailnetproto.DRPCTailnet_CoordinateClient) {
|
||||
u, err := member.URL.Parse("/api/v2/tailnet?version=2.0")
|
||||
require.NoError(t, err)
|
||||
//nolint:bodyclose // websocket.Dial owns the HTTP response body on success.
|
||||
wsConn, response, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{
|
||||
"Coder-Session-Token": []string{member.SessionToken()},
|
||||
},
|
||||
})
|
||||
if err != nil && response != nil {
|
||||
err = codersdk.ReadBodyAsError(response)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
rpcClient, err := tailnet.NewDRPCClient(
|
||||
websocket.NetConn(ctx, wsConn, websocket.MessageBinary),
|
||||
logger,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
stream, err := rpcClient.Coordinate(ctx)
|
||||
require.NoError(t, err)
|
||||
return wsConn, stream
|
||||
}
|
||||
|
||||
acceptedConn, acceptedStream := dial()
|
||||
require.NoError(t, acceptedStream.Send(&tailnetproto.CoordinateRequest{
|
||||
AddTunnel: &tailnetproto.CoordinateRequest_Tunnel{Id: tailnet.UUIDToByteSlice(allowedAgentID)},
|
||||
}))
|
||||
require.Eventually(t, func() bool {
|
||||
return connLogger.Contains(t, database.UpsertConnectionLogParams{
|
||||
WorkspaceID: allowedWorkspace.ID,
|
||||
AgentName: allowedSDKWorkspace.LatestBuild.Resources[0].Agents[0].Name,
|
||||
Type: database.ConnectionTypeTunnel,
|
||||
Code: sql.NullInt32{Int32: http.StatusSwitchingProtocols, Valid: true},
|
||||
UserID: uuid.NullUUID{UUID: memberUser.ID, Valid: true},
|
||||
UserAgent: sql.NullString{},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
require.NoError(t, acceptedConn.Close(websocket.StatusNormalClosure, "done"))
|
||||
|
||||
deniedConn, deniedStream := dial()
|
||||
require.NoError(t, deniedStream.Send(&tailnetproto.CoordinateRequest{
|
||||
AddTunnel: &tailnetproto.CoordinateRequest_Tunnel{Id: tailnet.UUIDToByteSlice(deniedAgentID)},
|
||||
}))
|
||||
require.Eventually(t, func() bool {
|
||||
return connLogger.Contains(t, database.UpsertConnectionLogParams{
|
||||
WorkspaceID: deniedWorkspace.ID,
|
||||
AgentName: deniedSDKWorkspace.LatestBuild.Resources[0].Agents[0].Name,
|
||||
Type: database.ConnectionTypeTunnel,
|
||||
Code: sql.NullInt32{Int32: http.StatusForbidden, Valid: true},
|
||||
UserID: uuid.NullUUID{UUID: memberUser.ID, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
require.NoError(t, deniedConn.Close(websocket.StatusNormalClosure, "done"))
|
||||
}
|
||||
|
||||
func TestUserTailnetTelemetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user