feat: add connectionlogs API (#18628)

This is the second PR for moving connection events out of the audit log.

This PR:
- Adds the `/api/v2/connectionlog` endpoint
- Adds filtering for `GetAuthorizedConnectionLogsOffset` and thus the endpoint. 
There's quite a few, but I was aiming for feature parity with the audit log.
  1. `organization:<id|name>`
  2. `workspace_owner:<username>`
  3. `workspace_owner_email:<email>`
  4. `type:<ssh|vscode|jetbrains|reconnecting_pty|workspace_app|port_forwarding>`
  5. `username:<username>` 
     - Only includes web-based connection events (workspace apps, web port forwarding) as only those include user metadata.
  6. `user_email:<email>`
  7. `connected_after:<time>`
  8. `connected_before:<time>`
  9. `workspace_id:<id>`
  10. `connection_id:<id>`
      - If you have one snapshot of the connection log, and some sessions are ongoing in that snapshot, you could use this filter to check if they've been closed since.
  11. `status:<connected|disconnected>`
       - If `connected` only sessions with a null `close_time` are returned, if `disconnected`, only those with a non-null `close_time`. If filter is omitted, both are returned.
       
Future PRs:
- Populate `count` on `ConnectionLogResponse` using a seperate query (to preemptively mitigate the issue described in #17689)
- Implement a table in the Web UI for viewing connection logs.
- Write a query to delete old events from the audit log, call it from dbpurge.
- Write documentation for the endpoint / feature (including these filters)
This commit is contained in:
Ethan
2025-07-15 14:55:34 +10:00
committed by GitHub
parent 08e17a07fc
commit 7a339a1ffe
25 changed files with 1863 additions and 30 deletions
+179
View File
@@ -383,6 +383,52 @@ const docTemplate = `{
}
}
},
"/connectionlog": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get connection logs",
"operationId": "get-connection-logs",
"parameters": [
{
"type": "string",
"description": "Search query",
"name": "q",
"in": "query"
},
{
"type": "integer",
"description": "Page limit",
"name": "limit",
"in": "query",
"required": true
},
{
"type": "integer",
"description": "Page offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ConnectionLogResponse"
}
}
}
}
},
"/csp/reports": {
"post": {
"security": [
@@ -11444,6 +11490,139 @@ const docTemplate = `{
}
}
},
"codersdk.ConnectionLog": {
"type": "object",
"properties": {
"agent_name": {
"type": "string"
},
"connect_time": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"ip": {
"type": "string"
},
"organization": {
"$ref": "#/definitions/codersdk.MinimalOrganization"
},
"ssh_info": {
"description": "SSHInfo is only set when ` + "`" + `type` + "`" + ` is one of:\n- ` + "`" + `ConnectionTypeSSH` + "`" + `\n- ` + "`" + `ConnectionTypeReconnectingPTY` + "`" + `\n- ` + "`" + `ConnectionTypeVSCode` + "`" + `\n- ` + "`" + `ConnectionTypeJetBrains` + "`" + `",
"allOf": [
{
"$ref": "#/definitions/codersdk.ConnectionLogSSHInfo"
}
]
},
"type": {
"$ref": "#/definitions/codersdk.ConnectionType"
},
"web_info": {
"description": "WebInfo is only set when ` + "`" + `type` + "`" + ` is one of:\n- ` + "`" + `ConnectionTypePortForwarding` + "`" + `\n- ` + "`" + `ConnectionTypeWorkspaceApp` + "`" + `",
"allOf": [
{
"$ref": "#/definitions/codersdk.ConnectionLogWebInfo"
}
]
},
"workspace_id": {
"type": "string",
"format": "uuid"
},
"workspace_name": {
"type": "string"
},
"workspace_owner_id": {
"type": "string",
"format": "uuid"
},
"workspace_owner_username": {
"type": "string"
}
}
},
"codersdk.ConnectionLogResponse": {
"type": "object",
"properties": {
"connection_logs": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ConnectionLog"
}
},
"count": {
"type": "integer"
}
}
},
"codersdk.ConnectionLogSSHInfo": {
"type": "object",
"properties": {
"connection_id": {
"type": "string",
"format": "uuid"
},
"disconnect_reason": {
"description": "DisconnectReason is omitted if a disconnect event with the same connection ID\nhas not yet been seen.",
"type": "string"
},
"disconnect_time": {
"description": "DisconnectTime is omitted if a disconnect event with the same connection ID\nhas not yet been seen.",
"type": "string",
"format": "date-time"
},
"exit_code": {
"description": "ExitCode is the exit code of the SSH session. It is omitted if a\ndisconnect event with the same connection ID has not yet been seen.",
"type": "integer"
}
}
},
"codersdk.ConnectionLogWebInfo": {
"type": "object",
"properties": {
"slug_or_port": {
"type": "string"
},
"status_code": {
"description": "StatusCode is the HTTP status code of the request.",
"type": "integer"
},
"user": {
"description": "User is omitted if the connection event was from an unauthenticated user.",
"allOf": [
{
"$ref": "#/definitions/codersdk.User"
}
]
},
"user_agent": {
"type": "string"
}
}
},
"codersdk.ConnectionType": {
"type": "string",
"enum": [
"ssh",
"vscode",
"jetbrains",
"reconnecting_pty",
"workspace_app",
"port_forwarding"
],
"x-enum-varnames": [
"ConnectionTypeSSH",
"ConnectionTypeVSCode",
"ConnectionTypeJetBrains",
"ConnectionTypeReconnectingPTY",
"ConnectionTypeWorkspaceApp",
"ConnectionTypePortForwarding"
]
},
"codersdk.ConvertLoginRequest": {
"type": "object",
"required": [
+175
View File
@@ -323,6 +323,48 @@
}
}
},
"/connectionlog": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get connection logs",
"operationId": "get-connection-logs",
"parameters": [
{
"type": "string",
"description": "Search query",
"name": "q",
"in": "query"
},
{
"type": "integer",
"description": "Page limit",
"name": "limit",
"in": "query",
"required": true
},
{
"type": "integer",
"description": "Page offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ConnectionLogResponse"
}
}
}
}
},
"/csp/reports": {
"post": {
"security": [
@@ -10174,6 +10216,139 @@
}
}
},
"codersdk.ConnectionLog": {
"type": "object",
"properties": {
"agent_name": {
"type": "string"
},
"connect_time": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"ip": {
"type": "string"
},
"organization": {
"$ref": "#/definitions/codersdk.MinimalOrganization"
},
"ssh_info": {
"description": "SSHInfo is only set when `type` is one of:\n- `ConnectionTypeSSH`\n- `ConnectionTypeReconnectingPTY`\n- `ConnectionTypeVSCode`\n- `ConnectionTypeJetBrains`",
"allOf": [
{
"$ref": "#/definitions/codersdk.ConnectionLogSSHInfo"
}
]
},
"type": {
"$ref": "#/definitions/codersdk.ConnectionType"
},
"web_info": {
"description": "WebInfo is only set when `type` is one of:\n- `ConnectionTypePortForwarding`\n- `ConnectionTypeWorkspaceApp`",
"allOf": [
{
"$ref": "#/definitions/codersdk.ConnectionLogWebInfo"
}
]
},
"workspace_id": {
"type": "string",
"format": "uuid"
},
"workspace_name": {
"type": "string"
},
"workspace_owner_id": {
"type": "string",
"format": "uuid"
},
"workspace_owner_username": {
"type": "string"
}
}
},
"codersdk.ConnectionLogResponse": {
"type": "object",
"properties": {
"connection_logs": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ConnectionLog"
}
},
"count": {
"type": "integer"
}
}
},
"codersdk.ConnectionLogSSHInfo": {
"type": "object",
"properties": {
"connection_id": {
"type": "string",
"format": "uuid"
},
"disconnect_reason": {
"description": "DisconnectReason is omitted if a disconnect event with the same connection ID\nhas not yet been seen.",
"type": "string"
},
"disconnect_time": {
"description": "DisconnectTime is omitted if a disconnect event with the same connection ID\nhas not yet been seen.",
"type": "string",
"format": "date-time"
},
"exit_code": {
"description": "ExitCode is the exit code of the SSH session. It is omitted if a\ndisconnect event with the same connection ID has not yet been seen.",
"type": "integer"
}
}
},
"codersdk.ConnectionLogWebInfo": {
"type": "object",
"properties": {
"slug_or_port": {
"type": "string"
},
"status_code": {
"description": "StatusCode is the HTTP status code of the request.",
"type": "integer"
},
"user": {
"description": "User is omitted if the connection event was from an unauthenticated user.",
"allOf": [
{
"$ref": "#/definitions/codersdk.User"
}
]
},
"user_agent": {
"type": "string"
}
}
},
"codersdk.ConnectionType": {
"type": "string",
"enum": [
"ssh",
"vscode",
"jetbrains",
"reconnecting_pty",
"workspace_app",
"port_forwarding"
],
"x-enum-varnames": [
"ConnectionTypeSSH",
"ConnectionTypeVSCode",
"ConnectionTypeJetBrains",
"ConnectionTypeReconnectingPTY",
"ConnectionTypeWorkspaceApp",
"ConnectionTypePortForwarding"
]
},
"codersdk.ConvertLoginRequest": {
"type": "object",
"required": ["password", "to_type"],
+1 -1
View File
@@ -40,7 +40,7 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
page, ok := parsePagination(rw, r)
page, ok := ParsePagination(rw, r)
if !ok {
return
}
+13
View File
@@ -630,6 +630,19 @@ func (q *sqlQuerier) GetAuthorizedConnectionLogsOffset(ctx context.Context, arg
query := fmt.Sprintf("-- name: GetAuthorizedConnectionLogsOffset :many\n%s", filtered)
rows, err := q.db.QueryContext(ctx, query,
arg.OrganizationID,
arg.WorkspaceOwner,
arg.WorkspaceOwnerID,
arg.WorkspaceOwnerEmail,
arg.Type,
arg.UserID,
arg.Username,
arg.UserEmail,
arg.ConnectedAfter,
arg.ConnectedBefore,
arg.WorkspaceID,
arg.ConnectionID,
arg.Status,
arg.OffsetOpt,
arg.LimitOpt,
)
+245 -1
View File
@@ -32,6 +32,7 @@ import (
"github.com/coder/coder/v2/coderd/provisionerdserver"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisionersdk"
"github.com/coder/coder/v2/testutil"
)
@@ -2245,6 +2246,249 @@ func TestGetAuthorizedConnectionLogsOffset(t *testing.T) {
})
}
func TestConnectionLogsOffsetFilters(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
db, _ := dbtestutil.NewDB(t)
orgA := dbfake.Organization(t, db).Do()
orgB := dbfake.Organization(t, db).Do()
user1 := dbgen.User(t, db, database.User{
Username: "user1",
Email: "user1@test.com",
})
user2 := dbgen.User(t, db, database.User{
Username: "user2",
Email: "user2@test.com",
})
user3 := dbgen.User(t, db, database.User{
Username: "user3",
Email: "user3@test.com",
})
ws1Tpl := dbgen.Template(t, db, database.Template{OrganizationID: orgA.Org.ID, CreatedBy: user1.ID})
ws1 := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user1.ID,
OrganizationID: orgA.Org.ID,
TemplateID: ws1Tpl.ID,
})
ws2Tpl := dbgen.Template(t, db, database.Template{OrganizationID: orgB.Org.ID, CreatedBy: user2.ID})
ws2 := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user2.ID,
OrganizationID: orgB.Org.ID,
TemplateID: ws2Tpl.ID,
})
now := dbtime.Now()
log1ConnID := uuid.New()
log1 := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
Time: now.Add(-4 * time.Hour),
OrganizationID: ws1.OrganizationID,
WorkspaceOwnerID: ws1.OwnerID,
WorkspaceID: ws1.ID,
WorkspaceName: ws1.Name,
Type: database.ConnectionTypeWorkspaceApp,
ConnectionStatus: database.ConnectionStatusConnected,
UserID: uuid.NullUUID{UUID: user1.ID, Valid: true},
UserAgent: sql.NullString{String: "Mozilla/5.0", Valid: true},
SlugOrPort: sql.NullString{String: "code-server", Valid: true},
ConnectionID: uuid.NullUUID{UUID: log1ConnID, Valid: true},
})
log2ConnID := uuid.New()
log2 := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
Time: now.Add(-3 * time.Hour),
OrganizationID: ws1.OrganizationID,
WorkspaceOwnerID: ws1.OwnerID,
WorkspaceID: ws1.ID,
WorkspaceName: ws1.Name,
Type: database.ConnectionTypeVscode,
ConnectionStatus: database.ConnectionStatusConnected,
ConnectionID: uuid.NullUUID{UUID: log2ConnID, Valid: true},
})
// Mark log2 as disconnected
log2 = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
Time: now.Add(-2 * time.Hour),
ConnectionID: log2.ConnectionID,
WorkspaceID: ws1.ID,
WorkspaceOwnerID: ws1.OwnerID,
AgentName: log2.AgentName,
ConnectionStatus: database.ConnectionStatusDisconnected,
OrganizationID: log2.OrganizationID,
})
log3ConnID := uuid.New()
log3 := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
Time: now.Add(-2 * time.Hour),
OrganizationID: ws2.OrganizationID,
WorkspaceOwnerID: ws2.OwnerID,
WorkspaceID: ws2.ID,
WorkspaceName: ws2.Name,
Type: database.ConnectionTypeSsh,
ConnectionStatus: database.ConnectionStatusConnected,
UserID: uuid.NullUUID{UUID: user2.ID, Valid: true},
ConnectionID: uuid.NullUUID{UUID: log3ConnID, Valid: true},
})
// Mark log3 as disconnected
log3 = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
Time: now.Add(-1 * time.Hour),
ConnectionID: log3.ConnectionID,
WorkspaceOwnerID: log3.WorkspaceOwnerID,
WorkspaceID: ws2.ID,
AgentName: log3.AgentName,
ConnectionStatus: database.ConnectionStatusDisconnected,
OrganizationID: log3.OrganizationID,
})
log4 := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
Time: now.Add(-1 * time.Hour),
OrganizationID: ws2.OrganizationID,
WorkspaceOwnerID: ws2.OwnerID,
WorkspaceID: ws2.ID,
WorkspaceName: ws2.Name,
Type: database.ConnectionTypeVscode,
ConnectionStatus: database.ConnectionStatusConnected,
UserID: uuid.NullUUID{UUID: user3.ID, Valid: true},
})
testCases := []struct {
name string
params database.GetConnectionLogsOffsetParams
expectedLogIDs []uuid.UUID
}{
{
name: "NoFilter",
params: database.GetConnectionLogsOffsetParams{},
expectedLogIDs: []uuid.UUID{
log1.ID, log2.ID, log3.ID, log4.ID,
},
},
{
name: "OrganizationID",
params: database.GetConnectionLogsOffsetParams{
OrganizationID: orgB.Org.ID,
},
expectedLogIDs: []uuid.UUID{log3.ID, log4.ID},
},
{
name: "WorkspaceOwner",
params: database.GetConnectionLogsOffsetParams{
WorkspaceOwner: user1.Username,
},
expectedLogIDs: []uuid.UUID{log1.ID, log2.ID},
},
{
name: "WorkspaceOwnerID",
params: database.GetConnectionLogsOffsetParams{
WorkspaceOwnerID: user1.ID,
},
expectedLogIDs: []uuid.UUID{log1.ID, log2.ID},
},
{
name: "WorkspaceOwnerEmail",
params: database.GetConnectionLogsOffsetParams{
WorkspaceOwnerEmail: user2.Email,
},
expectedLogIDs: []uuid.UUID{log3.ID, log4.ID},
},
{
name: "Type",
params: database.GetConnectionLogsOffsetParams{
Type: string(database.ConnectionTypeVscode),
},
expectedLogIDs: []uuid.UUID{log2.ID, log4.ID},
},
{
name: "UserID",
params: database.GetConnectionLogsOffsetParams{
UserID: user1.ID,
},
expectedLogIDs: []uuid.UUID{log1.ID},
},
{
name: "Username",
params: database.GetConnectionLogsOffsetParams{
Username: user1.Username,
},
expectedLogIDs: []uuid.UUID{log1.ID},
},
{
name: "UserEmail",
params: database.GetConnectionLogsOffsetParams{
UserEmail: user3.Email,
},
expectedLogIDs: []uuid.UUID{log4.ID},
},
{
name: "ConnectedAfter",
params: database.GetConnectionLogsOffsetParams{
ConnectedAfter: now.Add(-90 * time.Minute), // 1.5 hours ago
},
expectedLogIDs: []uuid.UUID{log4.ID},
},
{
name: "ConnectedBefore",
params: database.GetConnectionLogsOffsetParams{
ConnectedBefore: now.Add(-150 * time.Minute),
},
expectedLogIDs: []uuid.UUID{log1.ID, log2.ID},
},
{
name: "WorkspaceID",
params: database.GetConnectionLogsOffsetParams{
WorkspaceID: ws2.ID,
},
expectedLogIDs: []uuid.UUID{log3.ID, log4.ID},
},
{
name: "ConnectionID",
params: database.GetConnectionLogsOffsetParams{
ConnectionID: log1.ConnectionID.UUID,
},
expectedLogIDs: []uuid.UUID{log1.ID},
},
{
name: "StatusOngoing",
params: database.GetConnectionLogsOffsetParams{
Status: string(codersdk.ConnectionLogStatusOngoing),
},
expectedLogIDs: []uuid.UUID{log4.ID},
},
{
name: "StatusCompleted",
params: database.GetConnectionLogsOffsetParams{
Status: string(codersdk.ConnectionLogStatusCompleted),
},
expectedLogIDs: []uuid.UUID{log2.ID, log3.ID},
},
{
name: "OrganizationAndTypeAndStatus",
params: database.GetConnectionLogsOffsetParams{
OrganizationID: orgA.Org.ID,
Type: string(database.ConnectionTypeVscode),
Status: string(codersdk.ConnectionLogStatusCompleted),
},
expectedLogIDs: []uuid.UUID{log2.ID},
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
logs, err := db.GetConnectionLogsOffset(ctx, tc.params)
require.NoError(t, err)
require.ElementsMatch(t, tc.expectedLogIDs, connectionOnlyIDs(logs))
})
}
}
func connectionOnlyIDs[T database.ConnectionLog | database.GetConnectionLogsOffsetRow](logs []T) []uuid.UUID {
ids := make([]uuid.UUID, 0, len(logs))
for _, log := range logs {
@@ -2313,7 +2557,7 @@ func TestUpsertConnectionLog(t *testing.T) {
log1, err := db.UpsertConnectionLog(ctx, connectParams)
require.NoError(t, err)
require.Equal(t, connectParams.ID, log1.ID)
require.False(t, log1.DisconnectTime.Valid, "CloseTime should not be set on connect")
require.False(t, log1.DisconnectTime.Valid, "DisconnectTime should not be set on connect")
// Check that one row exists.
rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{LimitOpt: 10})
+125 -6
View File
@@ -910,7 +910,97 @@ LEFT JOIN users ON
connection_logs.user_id = users.id
JOIN organizations ON
connection_logs.organization_id = organizations.id
WHERE TRUE
WHERE
-- Filter organization_id
CASE
WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.organization_id = $1
ELSE true
END
-- Filter by workspace owner username
AND CASE
WHEN $2 :: text != '' THEN
workspace_owner_id = (
SELECT id FROM users
WHERE lower(username) = lower($2) AND deleted = false
)
ELSE true
END
-- Filter by workspace_owner_id
AND CASE
WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspace_owner_id = $3
ELSE true
END
-- Filter by workspace_owner_email
AND CASE
WHEN $4 :: text != '' THEN
workspace_owner_id = (
SELECT id FROM users
WHERE email = $4 AND deleted = false
)
ELSE true
END
-- Filter by type
AND CASE
WHEN $5 :: text != '' THEN
type = $5 :: connection_type
ELSE true
END
-- Filter by user_id
AND CASE
WHEN $6 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
user_id = $6
ELSE true
END
-- Filter by username
AND CASE
WHEN $7 :: text != '' THEN
user_id = (
SELECT id FROM users
WHERE lower(username) = lower($7) AND deleted = false
)
ELSE true
END
-- Filter by user_email
AND CASE
WHEN $8 :: text != '' THEN
users.email = $8
ELSE true
END
-- Filter by connected_after
AND CASE
WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
connect_time >= $9
ELSE true
END
-- Filter by connected_before
AND CASE
WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
connect_time <= $10
ELSE true
END
-- Filter by workspace_id
AND CASE
WHEN $11 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.workspace_id = $11
ELSE true
END
-- Filter by connection_id
AND CASE
WHEN $12 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.connection_id = $12
ELSE true
END
-- Filter by whether the session has a disconnect_time
AND CASE
WHEN $13 :: text != '' THEN
(($13 = 'ongoing' AND disconnect_time IS NULL) OR
($13 = 'completed' AND disconnect_time IS NOT NULL)) AND
-- Exclude web events, since we don't know their close time.
"type" NOT IN ('workspace_app', 'port_forwarding')
ELSE true
END
-- Authorize Filter clause will be injected below in
-- GetAuthorizedConnectionLogsOffset
-- @authorize_filter
@@ -920,14 +1010,27 @@ LIMIT
-- a limit of 0 means "no limit". The connection log table is unbounded
-- in size, and is expected to be quite large. Implement a default
-- limit of 100 to prevent accidental excessively large queries.
COALESCE(NULLIF($2 :: int, 0), 100)
COALESCE(NULLIF($15 :: int, 0), 100)
OFFSET
$1
$14
`
type GetConnectionLogsOffsetParams struct {
OffsetOpt int32 `db:"offset_opt" json:"offset_opt"`
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
WorkspaceOwner string `db:"workspace_owner" json:"workspace_owner"`
WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"`
WorkspaceOwnerEmail string `db:"workspace_owner_email" json:"workspace_owner_email"`
Type string `db:"type" json:"type"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Username string `db:"username" json:"username"`
UserEmail string `db:"user_email" json:"user_email"`
ConnectedAfter time.Time `db:"connected_after" json:"connected_after"`
ConnectedBefore time.Time `db:"connected_before" json:"connected_before"`
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
ConnectionID uuid.UUID `db:"connection_id" json:"connection_id"`
Status string `db:"status" json:"status"`
OffsetOpt int32 `db:"offset_opt" json:"offset_opt"`
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
}
type GetConnectionLogsOffsetRow struct {
@@ -951,7 +1054,23 @@ type GetConnectionLogsOffsetRow struct {
}
func (q *sqlQuerier) GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error) {
rows, err := q.db.QueryContext(ctx, getConnectionLogsOffset, arg.OffsetOpt, arg.LimitOpt)
rows, err := q.db.QueryContext(ctx, getConnectionLogsOffset,
arg.OrganizationID,
arg.WorkspaceOwner,
arg.WorkspaceOwnerID,
arg.WorkspaceOwnerEmail,
arg.Type,
arg.UserID,
arg.Username,
arg.UserEmail,
arg.ConnectedAfter,
arg.ConnectedBefore,
arg.WorkspaceID,
arg.ConnectionID,
arg.Status,
arg.OffsetOpt,
arg.LimitOpt,
)
if err != nil {
return nil, err
}
+91 -1
View File
@@ -28,7 +28,97 @@ LEFT JOIN users ON
connection_logs.user_id = users.id
JOIN organizations ON
connection_logs.organization_id = organizations.id
WHERE TRUE
WHERE
-- Filter organization_id
CASE
WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.organization_id = @organization_id
ELSE true
END
-- Filter by workspace owner username
AND CASE
WHEN @workspace_owner :: text != '' THEN
workspace_owner_id = (
SELECT id FROM users
WHERE lower(username) = lower(@workspace_owner) AND deleted = false
)
ELSE true
END
-- Filter by workspace_owner_id
AND CASE
WHEN @workspace_owner_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspace_owner_id = @workspace_owner_id
ELSE true
END
-- Filter by workspace_owner_email
AND CASE
WHEN @workspace_owner_email :: text != '' THEN
workspace_owner_id = (
SELECT id FROM users
WHERE email = @workspace_owner_email AND deleted = false
)
ELSE true
END
-- Filter by type
AND CASE
WHEN @type :: text != '' THEN
type = @type :: connection_type
ELSE true
END
-- Filter by user_id
AND CASE
WHEN @user_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
user_id = @user_id
ELSE true
END
-- Filter by username
AND CASE
WHEN @username :: text != '' THEN
user_id = (
SELECT id FROM users
WHERE lower(username) = lower(@username) AND deleted = false
)
ELSE true
END
-- Filter by user_email
AND CASE
WHEN @user_email :: text != '' THEN
users.email = @user_email
ELSE true
END
-- Filter by connected_after
AND CASE
WHEN @connected_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
connect_time >= @connected_after
ELSE true
END
-- Filter by connected_before
AND CASE
WHEN @connected_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
connect_time <= @connected_before
ELSE true
END
-- Filter by workspace_id
AND CASE
WHEN @workspace_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.workspace_id = @workspace_id
ELSE true
END
-- Filter by connection_id
AND CASE
WHEN @connection_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.connection_id = @connection_id
ELSE true
END
-- Filter by whether the session has a disconnect_time
AND CASE
WHEN @status :: text != '' THEN
((@status = 'ongoing' AND disconnect_time IS NULL) OR
(@status = 'completed' AND disconnect_time IS NOT NULL)) AND
-- Exclude web events, since we don't know their close time.
"type" NOT IN ('workspace_app', 'port_forwarding')
ELSE true
END
-- Authorize Filter clause will be injected below in
-- GetAuthorizedConnectionLogsOffset
-- @authorize_filter
+1 -1
View File
@@ -195,7 +195,7 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
organization = httpmw.OrganizationParam(r)
paginationParams, ok = parsePagination(rw, r)
paginationParams, ok = ParsePagination(rw, r)
)
if !ok {
return
+2 -2
View File
@@ -9,9 +9,9 @@ import (
"github.com/coder/coder/v2/codersdk"
)
// parsePagination extracts pagination query params from the http request.
// ParsePagination extracts pagination query params from the http request.
// If an error is encountered, the error is written to w and ok is set to false.
func parsePagination(w http.ResponseWriter, r *http.Request) (p codersdk.Pagination, ok bool) {
func ParsePagination(w http.ResponseWriter, r *http.Request) (p codersdk.Pagination, ok bool) {
ctx := r.Context()
queryParams := r.URL.Query()
parser := httpapi.NewQueryParamParser()
@@ -1,4 +1,4 @@
package coderd
package coderd_test
import (
"context"
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/codersdk"
)
@@ -123,7 +124,7 @@ func TestPagination(t *testing.T) {
query.Set("offset", c.Offset)
r.URL.RawQuery = query.Encode()
params, ok := parsePagination(rw, r)
params, ok := coderd.ParsePagination(rw, r)
if c.ExpectedError == "" {
require.True(t, ok, "expect ok")
require.Equal(t, c.ExpectedParams, params, "expected params")
+40
View File
@@ -86,6 +86,46 @@ func AuditLogs(ctx context.Context, db database.Store, query string) (database.G
return filter, countFilter, parser.Errors
}
func ConnectionLogs(ctx context.Context, db database.Store, query string, apiKey database.APIKey) (database.GetConnectionLogsOffsetParams, []codersdk.ValidationError) {
// Always lowercase for all searches.
query = strings.ToLower(query)
values, errors := searchTerms(query, func(term string, values url.Values) error {
values.Add("search", term)
return nil
})
if len(errors) > 0 {
return database.GetConnectionLogsOffsetParams{}, errors
}
parser := httpapi.NewQueryParamParser()
filter := database.GetConnectionLogsOffsetParams{
OrganizationID: parseOrganization(ctx, db, parser, values, "organization"),
WorkspaceOwner: parser.String(values, "", "workspace_owner"),
WorkspaceOwnerEmail: parser.String(values, "", "workspace_owner_email"),
Type: string(httpapi.ParseCustom(parser, values, "", "type", httpapi.ParseEnum[database.ConnectionType])),
Username: parser.String(values, "", "username"),
UserEmail: parser.String(values, "", "user_email"),
ConnectedAfter: parser.Time3339Nano(values, time.Time{}, "connected_after"),
ConnectedBefore: parser.Time3339Nano(values, time.Time{}, "connected_before"),
WorkspaceID: parser.UUID(values, uuid.Nil, "workspace_id"),
ConnectionID: parser.UUID(values, uuid.Nil, "connection_id"),
Status: string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[codersdk.ConnectionLogStatus])),
}
if filter.Username == "me" {
filter.UserID = apiKey.UserID
filter.Username = ""
}
if filter.WorkspaceOwner == "me" {
filter.WorkspaceOwnerID = apiKey.UserID
filter.WorkspaceOwner = ""
}
parser.ErrorExcessParams(values)
return filter, parser.Errors
}
func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) {
// Always lowercase for all searches.
query = strings.ToLower(query)
+66
View File
@@ -408,6 +408,72 @@ func TestSearchAudit(t *testing.T) {
}
}
func TestSearchConnectionLogs(t *testing.T) {
t.Parallel()
t.Run("All", func(t *testing.T) {
t.Parallel()
orgID := uuid.New()
workspaceOwnerID := uuid.New()
workspaceID := uuid.New()
connectionID := uuid.New()
db, _ := dbtestutil.NewDB(t)
dbgen.Organization(t, db, database.Organization{
ID: orgID,
Name: "testorg",
})
dbgen.User(t, db, database.User{
ID: workspaceOwnerID,
Username: "testowner",
Email: "owner@example.com",
})
query := fmt.Sprintf(`organization:testorg workspace_owner:testowner `+
`workspace_owner_email:owner@example.com type:port_forwarding username:testuser `+
`user_email:test@example.com connected_after:"2023-01-01T00:00:00Z" `+
`connected_before:"2023-01-16T12:00:00+12:00" workspace_id:%s connection_id:%s status:ongoing`,
workspaceID.String(), connectionID.String())
values, errs := searchquery.ConnectionLogs(context.Background(), db, query, database.APIKey{})
require.Len(t, errs, 0)
expected := database.GetConnectionLogsOffsetParams{
OrganizationID: orgID,
WorkspaceOwner: "testowner",
WorkspaceOwnerEmail: "owner@example.com",
Type: string(database.ConnectionTypePortForwarding),
Username: "testuser",
UserEmail: "test@example.com",
ConnectedAfter: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
ConnectedBefore: time.Date(2023, 1, 16, 0, 0, 0, 0, time.UTC),
WorkspaceID: workspaceID,
ConnectionID: connectionID,
Status: string(codersdk.ConnectionLogStatusOngoing),
}
require.Equal(t, expected, values)
})
t.Run("Me", func(t *testing.T) {
t.Parallel()
userID := uuid.New()
db, _ := dbtestutil.NewDB(t)
query := `username:me workspace_owner:me`
values, errs := searchquery.ConnectionLogs(context.Background(), db, query, database.APIKey{UserID: userID})
require.Len(t, errs, 0)
expected := database.GetConnectionLogsOffsetParams{
UserID: userID,
WorkspaceOwnerID: userID,
}
require.Equal(t, expected, values)
})
}
func TestSearchUsers(t *testing.T) {
t.Parallel()
testCases := []struct {
+1 -1
View File
@@ -807,7 +807,7 @@ func (api *API) templateVersionsByTemplate(rw http.ResponseWriter, r *http.Reque
ctx := r.Context()
template := httpmw.TemplateParam(r)
paginationParams, ok := parsePagination(rw, r)
paginationParams, ok := ParsePagination(rw, r)
if !ok {
return
}
+1 -1
View File
@@ -290,7 +290,7 @@ func (api *API) GetUsers(rw http.ResponseWriter, r *http.Request) ([]database.Us
return nil, -1, false
}
paginationParams, ok := parsePagination(rw, r)
paginationParams, ok := ParsePagination(rw, r)
if !ok {
return nil, -1, false
}
+1 -1
View File
@@ -119,7 +119,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspace := httpmw.WorkspaceParam(r)
paginationParams, ok := parsePagination(rw, r)
paginationParams, ok := ParsePagination(rw, r)
if !ok {
return
}
+1 -1
View File
@@ -146,7 +146,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
page, ok := parsePagination(rw, r)
page, ok := ParsePagination(rw, r)
if !ok {
return
}
-12
View File
@@ -37,18 +37,6 @@ import (
// log-source. This should be removed in the future.
var ExternalLogSourceID = uuid.MustParse("3b579bf4-1ed8-4b99-87a8-e9a1e3410410")
// ConnectionType is the type of connection that the agent is receiving.
type ConnectionType string
// Connection type enums.
const (
ConnectionTypeUnspecified ConnectionType = "Unspecified"
ConnectionTypeSSH ConnectionType = "SSH"
ConnectionTypeVSCode ConnectionType = "VS Code"
ConnectionTypeJetBrains ConnectionType = "JetBrains"
ConnectionTypeReconnectingPTY ConnectionType = "Web Terminal"
)
// New returns a client that is used to interact with the
// Coder API from a workspace agent.
func New(serverURL *url.URL) *Client {
+126
View File
@@ -0,0 +1,126 @@
package codersdk
import (
"context"
"encoding/json"
"net/http"
"net/netip"
"strings"
"time"
"github.com/google/uuid"
)
type ConnectionLog struct {
ID uuid.UUID `json:"id" format:"uuid"`
ConnectTime time.Time `json:"connect_time" format:"date-time"`
Organization MinimalOrganization `json:"organization"`
WorkspaceOwnerID uuid.UUID `json:"workspace_owner_id" format:"uuid"`
WorkspaceOwnerUsername string `json:"workspace_owner_username"`
WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"`
WorkspaceName string `json:"workspace_name"`
AgentName string `json:"agent_name"`
IP netip.Addr `json:"ip"`
Type ConnectionType `json:"type"`
// WebInfo is only set when `type` is one of:
// - `ConnectionTypePortForwarding`
// - `ConnectionTypeWorkspaceApp`
WebInfo *ConnectionLogWebInfo `json:"web_info,omitempty"`
// SSHInfo is only set when `type` is one of:
// - `ConnectionTypeSSH`
// - `ConnectionTypeReconnectingPTY`
// - `ConnectionTypeVSCode`
// - `ConnectionTypeJetBrains`
SSHInfo *ConnectionLogSSHInfo `json:"ssh_info,omitempty"`
}
// ConnectionType is the type of connection that the agent is receiving.
type ConnectionType string
const (
ConnectionTypeSSH ConnectionType = "ssh"
ConnectionTypeVSCode ConnectionType = "vscode"
ConnectionTypeJetBrains ConnectionType = "jetbrains"
ConnectionTypeReconnectingPTY ConnectionType = "reconnecting_pty"
ConnectionTypeWorkspaceApp ConnectionType = "workspace_app"
ConnectionTypePortForwarding ConnectionType = "port_forwarding"
)
// ConnectionLogStatus is the status of a connection log entry.
// It's the argument to the `status` filter when fetching connection logs.
type ConnectionLogStatus string
const (
ConnectionLogStatusOngoing ConnectionLogStatus = "ongoing"
ConnectionLogStatusCompleted ConnectionLogStatus = "completed"
)
func (s ConnectionLogStatus) Valid() bool {
switch s {
case ConnectionLogStatusOngoing, ConnectionLogStatusCompleted:
return true
default:
return false
}
}
type ConnectionLogWebInfo struct {
UserAgent string `json:"user_agent"`
// User is omitted if the connection event was from an unauthenticated user.
User *User `json:"user"`
SlugOrPort string `json:"slug_or_port"`
// StatusCode is the HTTP status code of the request.
StatusCode int32 `json:"status_code"`
}
type ConnectionLogSSHInfo struct {
ConnectionID uuid.UUID `json:"connection_id" format:"uuid"`
// DisconnectTime is omitted if a disconnect event with the same connection ID
// has not yet been seen.
DisconnectTime *time.Time `json:"disconnect_time,omitempty" format:"date-time"`
// DisconnectReason is omitted if a disconnect event with the same connection ID
// has not yet been seen.
DisconnectReason string `json:"disconnect_reason,omitempty"`
// ExitCode is the exit code of the SSH session. It is omitted if a
// disconnect event with the same connection ID has not yet been seen.
ExitCode *int32 `json:"exit_code,omitempty"`
}
type ConnectionLogsRequest struct {
SearchQuery string `json:"q,omitempty"`
Pagination
}
type ConnectionLogResponse struct {
ConnectionLogs []ConnectionLog `json:"connection_logs"`
Count int64 `json:"count"`
}
func (c *Client) ConnectionLogs(ctx context.Context, req ConnectionLogsRequest) (ConnectionLogResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/connectionlog", nil, req.Pagination.asRequestOption(), func(r *http.Request) {
q := r.URL.Query()
var params []string
if req.SearchQuery != "" {
params = append(params, req.SearchQuery)
}
q.Set("q", strings.Join(params, " "))
r.URL.RawQuery = q.Encode()
})
if err != nil {
return ConnectionLogResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ConnectionLogResponse{}, ReadBodyAsError(res)
}
var logRes ConnectionLogResponse
err = json.NewDecoder(res.Body).Decode(&logRes)
if err != nil {
return ConnectionLogResponse{}, err
}
return logRes, nil
}
+92
View File
@@ -207,6 +207,98 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get connection logs
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/connectionlog?limit=0 \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /connectionlog`
### Parameters
| Name | In | Type | Required | Description |
|----------|-------|---------|----------|--------------|
| `q` | query | string | false | Search query |
| `limit` | query | integer | true | Page limit |
| `offset` | query | integer | false | Page offset |
### Example responses
> 200 Response
```json
{
"connection_logs": [
{
"agent_name": "string",
"connect_time": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"ip": "string",
"organization": {
"display_name": "string",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string"
},
"ssh_info": {
"connection_id": "d3547de1-d1f2-4344-b4c2-17169b7526f9",
"disconnect_reason": "string",
"disconnect_time": "2019-08-24T14:15:22Z",
"exit_code": 0
},
"type": "ssh",
"web_info": {
"slug_or_port": "string",
"status_code": 0,
"user": {
"avatar_url": "http://example.com",
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
"organization_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
"roles": [
{
"display_name": "string",
"name": "string",
"organization_id": "string"
}
],
"status": "active",
"theme_preference": "string",
"updated_at": "2019-08-24T14:15:22Z",
"username": "string"
},
"user_agent": "string"
},
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
"workspace_name": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_username": "string"
}
],
"count": 0
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ConnectionLogResponse](schemas.md#codersdkconnectionlogresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get entitlements
### Code samples
+222
View File
@@ -1085,6 +1085,228 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `p50` | number | false | | |
| `p95` | number | false | | |
## codersdk.ConnectionLog
```json
{
"agent_name": "string",
"connect_time": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"ip": "string",
"organization": {
"display_name": "string",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string"
},
"ssh_info": {
"connection_id": "d3547de1-d1f2-4344-b4c2-17169b7526f9",
"disconnect_reason": "string",
"disconnect_time": "2019-08-24T14:15:22Z",
"exit_code": 0
},
"type": "ssh",
"web_info": {
"slug_or_port": "string",
"status_code": 0,
"user": {
"avatar_url": "http://example.com",
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
"organization_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
"roles": [
{
"display_name": "string",
"name": "string",
"organization_id": "string"
}
],
"status": "active",
"theme_preference": "string",
"updated_at": "2019-08-24T14:15:22Z",
"username": "string"
},
"user_agent": "string"
},
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
"workspace_name": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_username": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|----------------------------|----------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
| `agent_name` | string | false | | |
| `connect_time` | string | false | | |
| `id` | string | false | | |
| `ip` | string | false | | |
| `organization` | [codersdk.MinimalOrganization](#codersdkminimalorganization) | false | | |
| `ssh_info` | [codersdk.ConnectionLogSSHInfo](#codersdkconnectionlogsshinfo) | false | | Ssh info is only set when `type` is one of: - `ConnectionTypeSSH` - `ConnectionTypeReconnectingPTY` - `ConnectionTypeVSCode` - `ConnectionTypeJetBrains` |
| `type` | [codersdk.ConnectionType](#codersdkconnectiontype) | false | | |
| `web_info` | [codersdk.ConnectionLogWebInfo](#codersdkconnectionlogwebinfo) | false | | Web info is only set when `type` is one of: - `ConnectionTypePortForwarding` - `ConnectionTypeWorkspaceApp` |
| `workspace_id` | string | false | | |
| `workspace_name` | string | false | | |
| `workspace_owner_id` | string | false | | |
| `workspace_owner_username` | string | false | | |
## codersdk.ConnectionLogResponse
```json
{
"connection_logs": [
{
"agent_name": "string",
"connect_time": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"ip": "string",
"organization": {
"display_name": "string",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string"
},
"ssh_info": {
"connection_id": "d3547de1-d1f2-4344-b4c2-17169b7526f9",
"disconnect_reason": "string",
"disconnect_time": "2019-08-24T14:15:22Z",
"exit_code": 0
},
"type": "ssh",
"web_info": {
"slug_or_port": "string",
"status_code": 0,
"user": {
"avatar_url": "http://example.com",
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
"organization_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
"roles": [
{
"display_name": "string",
"name": "string",
"organization_id": "string"
}
],
"status": "active",
"theme_preference": "string",
"updated_at": "2019-08-24T14:15:22Z",
"username": "string"
},
"user_agent": "string"
},
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
"workspace_name": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_username": "string"
}
],
"count": 0
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|-------------------|-----------------------------------------------------------|----------|--------------|-------------|
| `connection_logs` | array of [codersdk.ConnectionLog](#codersdkconnectionlog) | false | | |
| `count` | integer | false | | |
## codersdk.ConnectionLogSSHInfo
```json
{
"connection_id": "d3547de1-d1f2-4344-b4c2-17169b7526f9",
"disconnect_reason": "string",
"disconnect_time": "2019-08-24T14:15:22Z",
"exit_code": 0
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|---------------------|---------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------|
| `connection_id` | string | false | | |
| `disconnect_reason` | string | false | | Disconnect reason is omitted if a disconnect event with the same connection ID has not yet been seen. |
| `disconnect_time` | string | false | | Disconnect time is omitted if a disconnect event with the same connection ID has not yet been seen. |
| `exit_code` | integer | false | | Exit code is the exit code of the SSH session. It is omitted if a disconnect event with the same connection ID has not yet been seen. |
## codersdk.ConnectionLogWebInfo
```json
{
"slug_or_port": "string",
"status_code": 0,
"user": {
"avatar_url": "http://example.com",
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
"organization_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
"roles": [
{
"display_name": "string",
"name": "string",
"organization_id": "string"
}
],
"status": "active",
"theme_preference": "string",
"updated_at": "2019-08-24T14:15:22Z",
"username": "string"
},
"user_agent": "string"
}
```
### 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 | | |
## codersdk.ConnectionType
```json
"ssh"
```
### Properties
#### Enumerated Values
| Value |
|--------------------|
| `ssh` |
| `vscode` |
| `jetbrains` |
| `reconnecting_pty` |
| `workspace_app` |
| `port_forwarding` |
## codersdk.ConvertLoginRequest
```json
+7
View File
@@ -226,6 +226,13 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Use(apiKeyMiddleware)
r.Get("/", api.replicas)
})
r.Route("/connectionlog", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
api.RequireFeatureMW(codersdk.FeatureConnectionLog),
)
r.Get("/", api.connectionLogs)
})
r.Route("/licenses", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Post("/refresh-entitlements", api.postRefreshEntitlements)
@@ -59,6 +59,7 @@ func init() {
type Options struct {
*coderdtest.Options
ConnectionLogging bool
AuditLogging bool
BrowserOnly bool
EntitlementsUpdateInterval time.Duration
@@ -100,6 +101,7 @@ func NewWithAPI(t *testing.T, options *Options) (
setHandler, cancelFunc, serverURL, oop := coderdtest.NewOptions(t, options.Options)
coderAPI, err := coderd.New(context.Background(), &coderd.Options{
RBAC: true,
ConnectionLogging: options.ConnectionLogging,
AuditLogging: options.AuditLogging,
BrowserOnly: options.BrowserOnly,
SCIMAPIKey: options.SCIMAPIKey,
+149
View File
@@ -0,0 +1,149 @@
package coderd
import (
"net/http"
"net/netip"
"github.com/google/uuid"
agpl "github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/codersdk"
)
// @Summary Get connection logs
// @ID get-connection-logs
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param q query string false "Search query"
// @Param limit query int true "Page limit"
// @Param offset query int false "Page offset"
// @Success 200 {object} codersdk.ConnectionLogResponse
// @Router /connectionlog [get]
func (api *API) connectionLogs(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
page, ok := agpl.ParsePagination(rw, r)
if !ok {
return
}
queryStr := r.URL.Query().Get("q")
filter, errs := searchquery.ConnectionLogs(ctx, api.Database, queryStr, apiKey)
if len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid connection search query.",
Validations: errs,
})
return
}
// #nosec G115 - Safe conversion as pagination offset is expected to be within int32 range
filter.OffsetOpt = int32(page.Offset)
// #nosec G115 - Safe conversion as pagination limit is expected to be within int32 range
filter.LimitOpt = int32(page.Limit)
dblogs, err := api.Database.GetConnectionLogsOffset(ctx, filter)
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ConnectionLogResponse{
ConnectionLogs: convertConnectionLogs(dblogs),
Count: 0, // TODO(ethanndickson): Set count
})
}
func convertConnectionLogs(dblogs []database.GetConnectionLogsOffsetRow) []codersdk.ConnectionLog {
clogs := make([]codersdk.ConnectionLog, 0, len(dblogs))
for _, dblog := range dblogs {
clogs = append(clogs, convertConnectionLog(dblog))
}
return clogs
}
func convertConnectionLog(dblog database.GetConnectionLogsOffsetRow) codersdk.ConnectionLog {
ip, _ := netip.AddrFromSlice(dblog.ConnectionLog.Ip.IPNet.IP)
var user *codersdk.User
if dblog.ConnectionLog.UserID.Valid {
sdkUser := db2sdk.User(database.User{
ID: dblog.ConnectionLog.UserID.UUID,
Email: dblog.UserEmail.String,
Username: dblog.UserUsername.String,
CreatedAt: dblog.UserCreatedAt.Time,
UpdatedAt: dblog.UserUpdatedAt.Time,
Status: dblog.UserStatus.UserStatus,
RBACRoles: dblog.UserRoles,
LoginType: dblog.UserLoginType.LoginType,
AvatarURL: dblog.UserAvatarUrl.String,
Deleted: dblog.UserDeleted.Bool,
LastSeenAt: dblog.UserLastSeenAt.Time,
QuietHoursSchedule: dblog.UserQuietHoursSchedule.String,
Name: dblog.UserName.String,
}, []uuid.UUID{})
user = &sdkUser
}
var (
webInfo *codersdk.ConnectionLogWebInfo
sshInfo *codersdk.ConnectionLogSSHInfo
)
switch dblog.ConnectionLog.Type {
case database.ConnectionTypeWorkspaceApp,
database.ConnectionTypePortForwarding:
webInfo = &codersdk.ConnectionLogWebInfo{
UserAgent: dblog.ConnectionLog.UserAgent.String,
User: user,
SlugOrPort: dblog.ConnectionLog.SlugOrPort.String,
StatusCode: dblog.ConnectionLog.Code.Int32,
}
case database.ConnectionTypeSsh,
database.ConnectionTypeReconnectingPty,
database.ConnectionTypeJetbrains,
database.ConnectionTypeVscode:
sshInfo = &codersdk.ConnectionLogSSHInfo{
ConnectionID: dblog.ConnectionLog.ConnectionID.UUID,
DisconnectReason: dblog.ConnectionLog.DisconnectReason.String,
}
if dblog.ConnectionLog.DisconnectTime.Valid {
sshInfo.DisconnectTime = &dblog.ConnectionLog.DisconnectTime.Time
}
if dblog.ConnectionLog.Code.Valid {
sshInfo.ExitCode = &dblog.ConnectionLog.Code.Int32
}
}
return codersdk.ConnectionLog{
ID: dblog.ConnectionLog.ID,
ConnectTime: dblog.ConnectionLog.ConnectTime,
Organization: codersdk.MinimalOrganization{
ID: dblog.ConnectionLog.OrganizationID,
Name: dblog.OrganizationName,
DisplayName: dblog.OrganizationDisplayName,
Icon: dblog.OrganizationIcon,
},
WorkspaceOwnerID: dblog.ConnectionLog.WorkspaceOwnerID,
WorkspaceOwnerUsername: dblog.WorkspaceOwnerUsername,
WorkspaceID: dblog.ConnectionLog.WorkspaceID,
WorkspaceName: dblog.ConnectionLog.WorkspaceName,
AgentName: dblog.ConnectionLog.AgentName,
Type: codersdk.ConnectionType(dblog.ConnectionLog.Type),
IP: ip,
WebInfo: webInfo,
SSHInfo: sshInfo,
}
}
+251
View File
@@ -0,0 +1,251 @@
package coderd_test
import (
"context"
"database/sql"
"fmt"
"net"
"testing"
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
)
func TestConnectionLogs(t *testing.T) {
t.Parallel()
createWorkspace := func(t *testing.T, db database.Store) database.WorkspaceTable {
u := dbgen.User(t, db, database.User{})
o := dbgen.Organization(t, db, database.Organization{})
tpl := dbgen.Template(t, db, database.Template{
OrganizationID: o.ID,
CreatedBy: u.ID,
})
return dbgen.Workspace(t, db, database.WorkspaceTable{
ID: uuid.New(),
OwnerID: u.ID,
OrganizationID: o.ID,
AutomaticUpdates: database.AutomaticUpdatesNever,
TemplateID: tpl.ID,
})
}
t.Run("OK", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
client, db, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
ConnectionLogging: true,
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAuditLog: 1,
codersdk.FeatureConnectionLog: 1,
},
},
})
ws := createWorkspace(t, db)
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
Type: database.ConnectionTypeSsh,
WorkspaceID: ws.ID,
OrganizationID: ws.OrganizationID,
WorkspaceOwnerID: ws.OwnerID,
})
logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{})
require.NoError(t, err)
require.Len(t, logs.ConnectionLogs, 1)
require.Equal(t, codersdk.ConnectionTypeSSH, logs.ConnectionLogs[0].Type)
})
t.Run("Empty", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
client, _, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
ConnectionLogging: true,
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAuditLog: 1,
codersdk.FeatureConnectionLog: 1,
},
},
})
logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{})
require.NoError(t, err)
require.Len(t, logs.ConnectionLogs, 0)
})
t.Run("ByOrganizationIDAndName", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
client, db, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
ConnectionLogging: true,
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAuditLog: 1,
codersdk.FeatureConnectionLog: 1,
},
},
})
org := dbgen.Organization(t, db, database.Organization{})
ws := createWorkspace(t, db)
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
Type: database.ConnectionTypeSsh,
WorkspaceID: ws.ID,
OrganizationID: org.ID,
WorkspaceOwnerID: ws.OwnerID,
})
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
Type: database.ConnectionTypeSsh,
WorkspaceID: ws.ID,
OrganizationID: ws.OrganizationID,
WorkspaceOwnerID: ws.OwnerID,
})
// By name
logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{
SearchQuery: fmt.Sprintf("organization:%s", org.Name),
})
require.NoError(t, err)
require.Len(t, logs.ConnectionLogs, 1)
require.Equal(t, org.ID, logs.ConnectionLogs[0].Organization.ID)
// By ID
logs, err = client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{
SearchQuery: fmt.Sprintf("organization:%s", ws.OrganizationID),
})
require.NoError(t, err)
require.Len(t, logs.ConnectionLogs, 1)
require.Equal(t, ws.OrganizationID, logs.ConnectionLogs[0].Organization.ID)
})
t.Run("WebInfo", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
client, db, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
ConnectionLogging: true,
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAuditLog: 1,
codersdk.FeatureConnectionLog: 1,
},
},
})
now := dbtime.Now()
connID := uuid.New()
ws := createWorkspace(t, db)
clog := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
Time: now.Add(-time.Hour),
Type: database.ConnectionTypeWorkspaceApp,
WorkspaceID: ws.ID,
OrganizationID: ws.OrganizationID,
WorkspaceOwnerID: ws.OwnerID,
ConnectionID: uuid.NullUUID{UUID: connID, Valid: true},
UserAgent: sql.NullString{String: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36", Valid: true},
UserID: uuid.NullUUID{UUID: ws.OwnerID, Valid: true},
SlugOrPort: sql.NullString{String: "code-server", Valid: true},
})
logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{})
require.NoError(t, err)
require.Len(t, logs.ConnectionLogs, 1)
require.NotNil(t, logs.ConnectionLogs[0].WebInfo)
require.Equal(t, clog.SlugOrPort.String, logs.ConnectionLogs[0].WebInfo.SlugOrPort)
require.Equal(t, clog.UserAgent.String, logs.ConnectionLogs[0].WebInfo.UserAgent)
require.Equal(t, ws.OwnerID, logs.ConnectionLogs[0].WebInfo.User.ID)
})
t.Run("SSHInfo", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
client, db, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
ConnectionLogging: true,
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAuditLog: 1,
codersdk.FeatureConnectionLog: 1,
},
},
})
now := dbtime.Now()
connID := uuid.New()
ws := createWorkspace(t, db)
clog := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
Time: now.Add(-time.Hour),
Type: database.ConnectionTypeSsh,
WorkspaceID: ws.ID,
OrganizationID: ws.OrganizationID,
WorkspaceOwnerID: ws.OwnerID,
ConnectionID: uuid.NullUUID{UUID: connID, Valid: true},
})
logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{})
require.NoError(t, err)
require.Len(t, logs.ConnectionLogs, 1)
require.NotNil(t, logs.ConnectionLogs[0].SSHInfo)
require.Empty(t, logs.ConnectionLogs[0].WebInfo)
require.Empty(t, logs.ConnectionLogs[0].SSHInfo.ExitCode)
require.Empty(t, logs.ConnectionLogs[0].SSHInfo.DisconnectTime)
require.Empty(t, logs.ConnectionLogs[0].SSHInfo.DisconnectReason)
// Mark log as closed
updatedClog := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
Time: now,
OrganizationID: clog.OrganizationID,
Type: clog.Type,
WorkspaceID: clog.WorkspaceID,
WorkspaceOwnerID: clog.WorkspaceOwnerID,
WorkspaceName: clog.WorkspaceName,
AgentName: clog.AgentName,
Code: sql.NullInt32{
Int32: 0,
Valid: false,
},
Ip: pqtype.Inet{IPNet: net.IPNet{
IP: net.ParseIP("192.168.0.1"),
Mask: net.CIDRMask(8, 32),
}, Valid: true},
ConnectionID: clog.ConnectionID,
ConnectionStatus: database.ConnectionStatusDisconnected,
DisconnectReason: sql.NullString{
String: "example close reason",
Valid: true,
},
})
logs, err = client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{})
require.NoError(t, err)
require.Len(t, logs.ConnectionLogs, 1)
require.NotNil(t, logs.ConnectionLogs[0].SSHInfo)
require.Nil(t, logs.ConnectionLogs[0].WebInfo)
require.Equal(t, codersdk.ConnectionTypeSSH, logs.ConnectionLogs[0].Type)
require.Equal(t, clog.ConnectionID.UUID, logs.ConnectionLogs[0].SSHInfo.ConnectionID)
require.True(t, logs.ConnectionLogs[0].SSHInfo.DisconnectTime.Equal(now))
require.Equal(t, updatedClog.DisconnectReason.String, logs.ConnectionLogs[0].SSHInfo.DisconnectReason)
})
}
+69
View File
@@ -322,6 +322,75 @@ export interface ConnectionLatency {
readonly p95: number;
}
// From codersdk/connectionlog.go
export interface ConnectionLog {
readonly id: string;
readonly connect_time: string;
readonly organization: MinimalOrganization;
readonly workspace_owner_id: string;
readonly workspace_owner_username: string;
readonly workspace_id: string;
readonly workspace_name: string;
readonly agent_name: string;
readonly ip: string;
readonly type: ConnectionType;
readonly web_info?: ConnectionLogWebInfo;
readonly ssh_info?: ConnectionLogSSHInfo;
}
// From codersdk/connectionlog.go
export interface ConnectionLogResponse {
readonly connection_logs: readonly ConnectionLog[];
readonly count: number;
}
// From codersdk/connectionlog.go
export interface ConnectionLogSSHInfo {
readonly connection_id: string;
readonly disconnect_time?: string;
readonly disconnect_reason?: string;
readonly exit_code?: number;
}
// From codersdk/connectionlog.go
export type ConnectionLogStatus = "completed" | "ongoing";
export const ConnectionLogStatuses: ConnectionLogStatus[] = [
"completed",
"ongoing",
];
// From codersdk/connectionlog.go
export interface ConnectionLogWebInfo {
readonly user_agent: string;
readonly user: User | null;
readonly slug_or_port: string;
readonly status_code: number;
}
// From codersdk/connectionlog.go
export interface ConnectionLogsRequest extends Pagination {
readonly q?: string;
}
// From codersdk/connectionlog.go
export type ConnectionType =
| "jetbrains"
| "port_forwarding"
| "reconnecting_pty"
| "ssh"
| "vscode"
| "workspace_app";
export const ConnectionTypes: ConnectionType[] = [
"jetbrains",
"port_forwarding",
"reconnecting_pty",
"ssh",
"vscode",
"workspace_app",
];
// From codersdk/files.go
export const ContentTypeTar = "application/x-tar";