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
+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 {