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()
+4 -6
View File
@@ -46,10 +46,8 @@ const (
ConnectionTypeReconnectingPTY ConnectionType = "reconnecting_pty"
ConnectionTypeWorkspaceApp ConnectionType = "workspace_app"
ConnectionTypePortForwarding ConnectionType = "port_forwarding"
// ConnectionTypeTunnel is recorded by coderd when a client
// establishes a tailnet tunnel to a workspace agent, and carries
// the authenticated user's identity. Tunnels via the user-scoped
// tailnet API (e.g. Coder Desktop) are not currently recorded.
// ConnectionTypeTunnel records accepted and denied tailnet tunnel
// requests made by authenticated users.
ConnectionTypeTunnel ConnectionType = "tunnel"
)
@@ -73,10 +71,10 @@ func (s ConnectionLogStatus) Valid() bool {
type ConnectionLogWebInfo struct {
UserAgent string `json:"user_agent"`
// User is omitted if the connection event was from an unauthenticated user.
// User is omitted if the connection event was unauthenticated.
User *User `json:"user"`
SlugOrPort string `json:"slug_or_port"`
// StatusCode is the HTTP status code of the request.
// StatusCode is the HTTP status code or tunnel authorization outcome.
StatusCode int32 `json:"status_code"`
}
+13 -23
View File
@@ -30,30 +30,20 @@ events for the same workspace and agent.
## Tunnel Connections
The connection log records a tunnel event each time a client
establishes a tunnel to a workspace agent, carrying the identity, IP
address, and user agent of the authenticated user who opened it. Tunnels
carry SSH and IDE traffic, so these events provide the user attribution
that agent-reported events lack.
The connection log records the authorization decision for each request to add a tunnel to a workspace agent.
Accepted requests have status code `101`, and denied requests have status code `403`.
Tunnel events include the authenticated user's identity, IP address, and user agent.
Keep the following in mind when interpreting tunnel events:
- A tunnel event records that a tunnel was established, not what it was
used for. Any client that dials a workspace agent produces one,
including `coder ssh`, `coder port-forward`, `coder ping`,
`coder speedtest`, and IDE extensions. One tunnel may carry many
sessions, or none.
- Tunnel events are deduplicated per user, workspace agent, IP address,
and client. Clients automatically re-establish tunnels after network
interruptions or server restarts; reconnections do not produce new
events while a session is active. A new event is recorded when a
session has been idle for one hour, or when the user connects from a
new IP address or client.
- Connections made through Coder Desktop (Coder Connect) do not
currently produce tunnel events.
- Like workspace app connections, tunnel events are point-in-time
records: they have no close time and are excluded from `status:`
filter results.
- A tunnel event records an authorization decision, not how the tunnel was used.
Clients such as `coder ssh`, `coder port-forward`, `coder ping`, `coder speedtest`, Coder Desktop, and IDE extensions can request tunnels.
- Tunnel events are deduplicated per workspace agent, actor, IP address, client, and authorization result.
Clients automatically re-request tunnels after network interruptions or server restarts.
These requests do not produce new events while a session is active.
A new event is recorded after one hour of inactivity, or when the actor, IP address, client, or result changes.
- Like workspace app connections, tunnel events are point-in-time records.
They have no close time and are excluded from `status:` filter results.
## How to Filter Connection Logs
@@ -67,9 +57,9 @@ You can filter connection logs by the following parameters:
For more connection types, refer to the
[CoderSDK documentation](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ConnectionType).
- `username`: The name of the user who initiated the connection.
Results will not include agent-reported SSH or IDE sessions.
Results do not include agent-reported SSH or IDE sessions.
- `user_email`: The email of the user who initiated the connection.
Results will not include agent-reported SSH or IDE sessions.
Results do not include agent-reported SSH or IDE sessions.
- `connected_after`: The time after which the connection started.
Uses the RFC3339Nano format.
- `connected_before`: The time before which the connection started.
+6 -6
View File
@@ -4514,12 +4514,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
### Properties
| Name | Type | Required | Restrictions | Description |
|----------------|--------------------------------|----------|--------------|---------------------------------------------------------------------------|
| `slug_or_port` | string | false | | |
| `status_code` | integer | false | | Status code is the HTTP status code of the request. |
| `user` | [codersdk.User](#codersdkuser) | false | | User is omitted if the connection event was from an unauthenticated user. |
| `user_agent` | string | false | | |
| Name | Type | Required | Restrictions | Description |
|----------------|--------------------------------|----------|--------------|----------------------------------------------------------------------|
| `slug_or_port` | string | false | | |
| `status_code` | integer | false | | Status code is the HTTP status code or tunnel authorization outcome. |
| `user` | [codersdk.User](#codersdkuser) | false | | User is omitted if the connection event was unauthenticated. |
| `user_agent` | string | false | | |
## codersdk.ConnectionType
+3
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"net"
"net/http"
"testing"
"time"
@@ -203,6 +204,7 @@ func TestConnectionLogs(t *testing.T) {
OrganizationID: ws.OrganizationID,
WorkspaceOwnerID: ws.OwnerID,
UserAgent: sql.NullString{String: "coder-cli/2.0.0", Valid: true},
Code: sql.NullInt32{Int32: http.StatusSwitchingProtocols, Valid: true},
UserID: uuid.NullUUID{UUID: ws.OwnerID, Valid: true},
})
@@ -216,6 +218,7 @@ func TestConnectionLogs(t *testing.T) {
require.Equal(t, clog.UserAgent.String, logs.ConnectionLogs[0].WebInfo.UserAgent)
require.NotNil(t, logs.ConnectionLogs[0].WebInfo.User)
require.Equal(t, ws.OwnerID, logs.ConnectionLogs[0].WebInfo.User.ID)
require.EqualValues(t, http.StatusSwitchingProtocols, logs.ConnectionLogs[0].WebInfo.StatusCode)
})
t.Run("SSHInfo", func(t *testing.T) {
+1 -2
View File
@@ -33,11 +33,10 @@ func (s *ClientService) ServeMultiAgentClient(ctx context.Context, version strin
}
switch major {
case 2:
auth := agpl.SingleTailnetCoordinateeAuth{}
streamID := agpl.StreamID{
Name: id.String(),
ID: id,
Auth: auth,
Auth: agpl.SingleTailnetCoordinateeAuth{},
}
return s.ServeConnV2(ctx, conn, streamID)
default:
+2 -2
View File
@@ -3727,12 +3727,12 @@ export const ConnectionLogStatuses: ConnectionLogStatus[] = [
export interface ConnectionLogWebInfo {
readonly user_agent: string;
/**
* User is omitted if the connection event was from an unauthenticated user.
* User is omitted if the connection event was unauthenticated.
*/
readonly user: User | null;
readonly slug_or_port: string;
/**
* StatusCode is the HTTP status code of the request.
* StatusCode is the HTTP status code or tunnel authorization outcome.
*/
readonly status_code: number;
}
@@ -1,6 +1,9 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import {
MockConnectedSSHConnectionLog,
MockDeniedTunnelConnectionLog,
MockTunnelConnectionLog,
MockWebConnectionLog,
} from "#/testHelpers/entities";
import { ConnectionLogDescription } from "./ConnectionLogDescription";
@@ -97,10 +100,11 @@ export const JetBrains: Story = {
export const Tunnel: Story = {
args: {
connectionLog: {
...MockWebConnectionLog,
type: "tunnel",
},
connectionLog: MockTunnelConnectionLog,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/established a tunnel to/)).toBeVisible();
},
};
@@ -109,13 +113,22 @@ export const Tunnel: Story = {
export const TunnelOtherUser: Story = {
args: {
connectionLog: {
...MockWebConnectionLog,
type: "tunnel",
...MockTunnelConnectionLog,
workspace_owner_username: "some-other-user",
},
},
};
export const TunnelDenied: Story = {
args: {
connectionLog: MockDeniedTunnelConnectionLog,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/was denied a tunnel to/)).toBeVisible();
},
};
export const WebTerminal: Story = {
args: {
connectionLog: {
@@ -92,13 +92,16 @@ export const ConnectionLogDescription: FC<ConnectionLogDescriptionProps> = ({
case "tunnel": {
if (!web_info) return null;
const { user } = web_info;
const { user, status_code } = web_info;
const actor = user?.username ?? "Unknown user";
const action =
status_code >= 400
? "was denied a tunnel to"
: "established a tunnel to";
const isOwnWorkspace = workspace_owner_username === user?.username;
return (
<span>
{/* Tunnel rows are only written for authenticated requests,
so user should always be present. */}
{user?.username ?? "Unknown user"} established a tunnel to{" "}
{actor} {action}{" "}
{isOwnWorkspace ? "their" : `${workspace_owner_username}'s`}{" "}
<Link asChild showExternalIcon={false} className="text-base">
<RouterLink to={`/@${workspace_owner_username}/${workspace_name}`}>
+22
View File
@@ -3068,6 +3068,28 @@ export const MockWebConnectionLog: TypesGen.ConnectionLog = {
},
};
const MockTunnelWebInfo: TypesGen.ConnectionLogWebInfo = {
user_agent: "coder-cli/2.0.0",
user: MockUserMember,
slug_or_port: "",
status_code: 101,
};
export const MockTunnelConnectionLog: TypesGen.ConnectionLog = {
...MockWebConnectionLog,
type: "tunnel",
web_info: MockTunnelWebInfo,
};
export const MockDeniedTunnelConnectionLog: TypesGen.ConnectionLog = {
...MockTunnelConnectionLog,
id: "09747acf-207f-4f53-a875-fde339924f60",
web_info: {
...MockTunnelWebInfo,
status_code: 403,
},
};
export const MockConnectedSSHConnectionLog: TypesGen.ConnectionLog = {
id: "7884a866-4ae1-4945-9fba-b2b8d2b7c5a9",
connect_time: "2022-05-19T16:45:57.122Z",
+2 -2
View File
@@ -76,7 +76,7 @@ func TestTunnelSrcCoordController_Mainline(t *testing.T) {
reqs := make(chan *proto.CoordinateRequest, 100)
resps := make(chan *proto.CoordinateResponse, 100)
mCoord.EXPECT().Coordinate(gomock.Any(), clientID, gomock.Any(), tailnet.ClientCoordinateeAuth{agentID}).
mCoord.EXPECT().Coordinate(gomock.Any(), clientID, gomock.Any(), tailnet.ClientCoordinateeAuth{AgentID: agentID}).
Times(1).Return(reqs, resps)
var coord tailnet.Coordinator = mCoord
@@ -415,7 +415,7 @@ func TestAgentCoordinationController_SendsReadyForHandshake(t *testing.T) {
reqs := make(chan *proto.CoordinateRequest, 100)
resps := make(chan *proto.CoordinateResponse, 100)
mCoord.EXPECT().Coordinate(gomock.Any(), clientID, gomock.Any(), tailnet.ClientCoordinateeAuth{agentID}).
mCoord.EXPECT().Coordinate(gomock.Any(), clientID, gomock.Any(), tailnet.ClientCoordinateeAuth{AgentID: agentID}).
Times(1).Return(reqs, resps)
var coord tailnet.Coordinator = mCoord
+1 -1
View File
@@ -204,7 +204,7 @@ func (s *DRPCService) Coordinate(stream proto.DRPCTailnet_CoordinateStream) erro
_ = stream.Close()
return xerrors.New("no Stream ID")
}
logger := s.Logger.With(slog.F("peer_id", streamID), slog.F("name", streamID.Name))
logger := s.Logger.With(slog.F("peer_id", streamID.ID.String()), slog.F("name", streamID.Name))
logger.Debug(ctx, "starting tailnet Coordinate")
coord := *(s.CoordPtr.Load())
reqs, resps := coord.Coordinate(ctx, streamID.ID, streamID.Name, streamID.Auth)
+79 -6
View File
@@ -238,7 +238,8 @@ func TestClientUserCoordinateeAuth(t *testing.T) {
ctrl := gomock.NewController(t)
updatesProvider := tailnettest.NewMockWorkspaceUpdatesProvider(ctrl)
fCoord, client := createUpdateService(t, ctx, clientID, updatesProvider)
auditor := &recordingTunnelAuditor{}
fCoord, client := createUpdateService(t, ctx, clientID, updatesProvider, auditor)
// Coordinate
stream, err := client.Coordinate(ctx)
@@ -261,9 +262,62 @@ func TestClientUserCoordinateeAuth(t *testing.T) {
require.NoError(t, call.Auth.Authorize(ctx, &proto.CoordinateRequest{
AddTunnel: &proto.CoordinateRequest_Tunnel{Id: tailnet.UUIDToByteSlice(agentID)},
}))
require.Error(t, call.Auth.Authorize(ctx, &proto.CoordinateRequest{
require.Len(t, auditor.decisions, 1)
require.Equal(t, agentID, auditor.decisions[0].agentID)
require.NoError(t, auditor.decisions[0].authorizationErr)
err = call.Auth.Authorize(ctx, &proto.CoordinateRequest{
AddTunnel: &proto.CoordinateRequest_Tunnel{Id: tailnet.UUIDToByteSlice(agentID2)},
}))
})
require.EqualError(t, err, "workspace agent not found or you do not have permission")
require.Len(t, auditor.decisions, 2)
require.Equal(t, agentID2, auditor.decisions[1].agentID)
require.Error(t, auditor.decisions[1].authorizationErr)
err = call.Auth.Authorize(ctx, &proto.CoordinateRequest{
AddTunnel: &proto.CoordinateRequest_Tunnel{Id: tailnet.UUIDToByteSlice(agentID)},
UpdateSelf: &proto.CoordinateRequest_UpdateSelf{
Node: &proto.Node{Addresses: []string{"not-an-address"}},
},
})
require.ErrorContains(t, err, "parse node address")
require.Len(t, auditor.decisions, 3)
require.Equal(t, agentID, auditor.decisions[2].agentID)
require.NoError(t, auditor.decisions[2].authorizationErr)
err = call.Auth.Authorize(ctx, &proto.CoordinateRequest{
AddTunnel: &proto.CoordinateRequest_Tunnel{Id: []byte("invalid")},
})
require.ErrorContains(t, err, "parse add tunnel id")
require.Len(t, auditor.decisions, 3)
}
func TestClientCoordinateeAuthTunnelAuditor(t *testing.T) {
t.Parallel()
agentID := uuid.New()
otherAgentID := uuid.New()
auditor := &recordingTunnelAuditor{}
auth := tailnet.ClientCoordinateeAuth{
AgentID: agentID,
Auditor: auditor,
}
err := auth.Authorize(t.Context(), &proto.CoordinateRequest{
AddTunnel: &proto.CoordinateRequest_Tunnel{Id: tailnet.UUIDToByteSlice(agentID)},
ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{{}},
})
require.ErrorContains(t, err, "clients may not send ready_for_handshake")
require.Len(t, auditor.decisions, 1)
require.Equal(t, agentID, auditor.decisions[0].agentID)
require.NoError(t, auditor.decisions[0].authorizationErr)
err = auth.Authorize(t.Context(), &proto.CoordinateRequest{
AddTunnel: &proto.CoordinateRequest_Tunnel{Id: tailnet.UUIDToByteSlice(otherAgentID)},
})
require.ErrorContains(t, err, "invalid agent id")
require.Len(t, auditor.decisions, 2)
require.Equal(t, otherAgentID, auditor.decisions[1].agentID)
require.Error(t, auditor.decisions[1].authorizationErr)
}
func TestWorkspaceUpdates(t *testing.T) {
@@ -278,7 +332,7 @@ func TestWorkspaceUpdates(t *testing.T) {
clientID := uuid.UUID{0x03}
wsID := uuid.UUID{0x04}
_, client := createUpdateService(t, ctx, clientID, updatesProvider)
_, client := createUpdateService(t, ctx, clientID, updatesProvider, nil)
// Workspace updates
expected := &proto.WorkspaceUpdate{
@@ -315,7 +369,7 @@ func TestWorkspaceUpdates(t *testing.T) {
}
//nolint:revive // t takes precedence
func createUpdateService(t *testing.T, ctx context.Context, clientID uuid.UUID, updates tailnet.WorkspaceUpdatesProvider) (*tailnettest.FakeCoordinator, proto.DRPCTailnetClient) {
func createUpdateService(t *testing.T, ctx context.Context, clientID uuid.UUID, updates tailnet.WorkspaceUpdatesProvider, auditor tailnet.TunnelAuditor) (*tailnettest.FakeCoordinator, proto.DRPCTailnetClient) {
fCoord := tailnettest.NewFakeCoordinator()
var coord tailnet.Coordinator = fCoord
coordPtr := atomic.Pointer[tailnet.Coordinator]{}
@@ -341,7 +395,8 @@ func createUpdateService(t *testing.T, ctx context.Context, clientID uuid.UUID,
Name: "client",
ID: clientID,
Auth: tailnet.ClientUserCoordinateeAuth{
Auth: &fakeTunnelAuth{},
Auth: &fakeTunnelAuth{},
Auditor: auditor,
},
})
t.Logf("ServeClient returned; err=%v", err)
@@ -360,6 +415,24 @@ func createUpdateService(t *testing.T, ctx context.Context, clientID uuid.UUID,
return fCoord, client
}
type tunnelAuditDecision struct {
agentID uuid.UUID
authorizationErr error
}
type recordingTunnelAuditor struct {
decisions []tunnelAuditDecision
}
func (a *recordingTunnelAuditor) Audit(agentID uuid.UUID, authorizationErr error) {
a.decisions = append(a.decisions, tunnelAuditDecision{
agentID: agentID,
authorizationErr: authorizationErr,
})
}
var _ tailnet.TunnelAuditor = (*recordingTunnelAuditor)(nil)
type fakeTunnelAuth struct{}
// AuthorizeTunnel implements tailnet.TunnelAuthorizer.
+50 -19
View File
@@ -33,40 +33,59 @@ type CoordinateeAuth interface {
Authorize(ctx context.Context, req *proto.CoordinateRequest) error
}
// SingleTailnetCoordinateeAuth allows all tunnels, since Coderd and wsproxy are allowed to initiate a tunnel to any agent
// TunnelAuditor records AddTunnel authorization decisions. Audit must not
// block because authorization may run while the coordinator mutex is held.
type TunnelAuditor interface {
Audit(agentID uuid.UUID, authorizationErr error)
}
// SingleTailnetCoordinateeAuth allows all tunnels because coderd and workspace
// proxies may initiate a tunnel to any agent.
type SingleTailnetCoordinateeAuth struct{}
func (SingleTailnetCoordinateeAuth) Authorize(context.Context, *proto.CoordinateRequest) error {
return nil
}
// ClientCoordinateeAuth allows connecting to a single, given agent
// ClientCoordinateeAuth allows connecting to a single agent.
type ClientCoordinateeAuth struct {
AgentID uuid.UUID
Auditor TunnelAuditor
}
func (c ClientCoordinateeAuth) Authorize(_ context.Context, req *proto.CoordinateRequest) error {
if tun := req.GetAddTunnel(); tun != nil {
uid, err := uuid.FromBytes(tun.Id)
var agentID uuid.UUID
authErr, report := func() (error, bool) {
tun := req.GetAddTunnel()
if tun == nil {
return nil, false
}
var err error
agentID, err = uuid.FromBytes(tun.Id)
if err != nil {
return xerrors.Errorf("parse add tunnel id: %w", err)
return xerrors.Errorf("parse add tunnel id: %w", err), false
}
if c.AgentID != uid {
return xerrors.Errorf("invalid agent id, expected %s, got %s", c.AgentID.String(), uid.String())
if c.AgentID != agentID {
return xerrors.Errorf("invalid agent id, expected %s, got %s", c.AgentID.String(), agentID.String()), true
}
return nil, true
}()
if report && c.Auditor != nil {
c.Auditor.Audit(agentID, authErr)
}
if authErr != nil {
return authErr
}
return handleClientNodeRequests(req)
}
// AgentCoordinateeAuth disallows all tunnels, since agents are not allowed to initiate their own tunnels
// AgentCoordinateeAuth disallows tunnels because agents may not initiate them.
type AgentCoordinateeAuth struct {
ID uuid.UUID
}
func (a AgentCoordinateeAuth) Authorize(_ context.Context, req *proto.CoordinateRequest) error {
if tun := req.GetAddTunnel(); tun != nil {
if req.GetAddTunnel() != nil {
return xerrors.New("agents cannot open tunnels")
}
@@ -110,21 +129,33 @@ func (a AgentCoordinateeAuth) authorizeNodePrefixes(prefixes []string) error {
}
type ClientUserCoordinateeAuth struct {
Auth TunnelAuthorizer
Auth TunnelAuthorizer
Auditor TunnelAuditor
}
func (a ClientUserCoordinateeAuth) Authorize(ctx context.Context, req *proto.CoordinateRequest) error {
if tun := req.GetAddTunnel(); tun != nil {
uid, err := uuid.FromBytes(tun.Id)
if err != nil {
return xerrors.Errorf("parse add tunnel id: %w", err)
var agentID uuid.UUID
authErr, report := func() (error, bool) {
tun := req.GetAddTunnel()
if tun == nil {
return nil, false
}
err = a.Auth.AuthorizeTunnel(ctx, uid)
var err error
agentID, err = uuid.FromBytes(tun.Id)
if err != nil {
return xerrors.Errorf("workspace agent not found or you do not have permission")
return xerrors.Errorf("parse add tunnel id: %w", err), false
}
if err := a.Auth.AuthorizeTunnel(ctx, agentID); err != nil {
return xerrors.New("workspace agent not found or you do not have permission"), true
}
return nil, true
}()
if report && a.Auditor != nil {
a.Auditor.Audit(agentID, authErr)
}
if authErr != nil {
return authErr
}
return handleClientNodeRequests(req)
}