fix: log tailnet tunnel authorization decisions (#27819)

This commit is contained in:
Jon Ayers
2026-08-18 18:21:50 -05:00
committed by GitHub
parent b3f05a23fc
commit 821d91fabd
17 changed files with 399 additions and 164 deletions
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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
View File
@@ -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())
+81 -22
View File
@@ -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()