Resolves #38437 The list activities endpoint applied an implicit `created_at <= now` cap only when `start_created_at` was set, leaving the upper bound unbounded in every other case, this was changed so that we now apply that cap unconditionally and override only when the caller passes an explicit `end_created_at` (as peer the REST docs).
288 lines
8.8 KiB
Go
288 lines
8.8 KiB
Go
// Package mysql provides the MySQL datastore implementation for the activity bounded context.
|
|
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/activity/api"
|
|
"github.com/fleetdm/fleet/v4/server/activity/internal/types"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
|
|
"github.com/jmoiron/sqlx"
|
|
"go.opentelemetry.io/otel"
|
|
)
|
|
|
|
// tracer is an OTEL tracer. It has no-op behavior when OTEL is not enabled.
|
|
var tracer = otel.Tracer("github.com/fleetdm/fleet/v4/server/activity/internal/mysql")
|
|
|
|
// Datastore is the MySQL implementation of the activity datastore.
|
|
type Datastore struct {
|
|
primary *sqlx.DB
|
|
replica *sqlx.DB
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewDatastore creates a new MySQL datastore for activities.
|
|
func NewDatastore(conns *platform_mysql.DBConnections, logger *slog.Logger) *Datastore {
|
|
return &Datastore{primary: conns.Primary, replica: conns.Replica, logger: logger}
|
|
}
|
|
|
|
func (ds *Datastore) reader(ctx context.Context) *sqlx.DB {
|
|
return ds.replica
|
|
}
|
|
|
|
// Ensure Datastore implements types.Datastore
|
|
var _ types.Datastore = (*Datastore)(nil)
|
|
|
|
// ListActivities returns a slice of activities performed across the organization.
|
|
func (ds *Datastore) ListActivities(ctx context.Context, opt types.ListOptions) ([]*api.Activity, *api.PaginationMetadata, error) {
|
|
ctx, span := tracer.Start(ctx, "activity.mysql.ListActivities")
|
|
defer span.End()
|
|
|
|
activitiesQ := `
|
|
SELECT
|
|
a.id,
|
|
a.user_id,
|
|
a.created_at,
|
|
a.activity_type,
|
|
a.user_name as name,
|
|
a.streamed,
|
|
a.user_email,
|
|
a.fleet_initiated
|
|
FROM activity_past a
|
|
WHERE a.host_only = false`
|
|
|
|
var args []any
|
|
|
|
if opt.Streamed != nil {
|
|
activitiesQ += " AND a.streamed = ?"
|
|
args = append(args, *opt.Streamed)
|
|
}
|
|
|
|
if opt.ActivityType != "" {
|
|
activitiesQ += " AND a.activity_type = ?"
|
|
args = append(args, opt.ActivityType)
|
|
}
|
|
|
|
// MatchQuery: search by user_name/user_email in activity table, plus user IDs from users table search
|
|
if opt.MatchQuery != "" {
|
|
activitiesQ += " AND (a.user_name LIKE ? OR a.user_email LIKE ?"
|
|
args = append(args, opt.MatchQuery+"%", opt.MatchQuery+"%")
|
|
|
|
// Add user IDs from users table search (populated by service via ACL)
|
|
if len(opt.MatchingUserIDs) > 0 {
|
|
inQ, inArgs, err := sqlx.In(" OR a.user_id IN (?)", opt.MatchingUserIDs)
|
|
if err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "bind user IDs for IN clause")
|
|
}
|
|
activitiesQ += inQ
|
|
args = append(args, inArgs...)
|
|
}
|
|
|
|
activitiesQ += ")"
|
|
}
|
|
|
|
if opt.StartCreatedAt != "" {
|
|
activitiesQ += " AND a.created_at >= ?"
|
|
args = append(args, opt.StartCreatedAt)
|
|
}
|
|
|
|
// Always cap the upper bound on created_at so that results are consistent
|
|
// regardless of whether StartCreatedAt is provided. Defaults to now.
|
|
activitiesQ += " AND a.created_at <= ?"
|
|
if opt.EndCreatedAt != "" {
|
|
args = append(args, opt.EndCreatedAt)
|
|
} else {
|
|
args = append(args, time.Now().UTC())
|
|
}
|
|
|
|
// Apply pagination using platform_mysql
|
|
activitiesQ, args = platform_mysql.AppendListOptionsWithParams(activitiesQ, args, &opt)
|
|
|
|
var activities []*api.Activity
|
|
err := sqlx.SelectContext(ctx, ds.reader(ctx), &activities, activitiesQ, args...)
|
|
if err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "select activities")
|
|
}
|
|
|
|
// Fetch details as a separate query (due to MySQL sort buffer issues with large JSON)
|
|
if len(activities) > 0 {
|
|
if err := ds.fetchActivityDetails(ctx, activities); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
|
|
// Build pagination metadata
|
|
var meta *api.PaginationMetadata
|
|
if opt.IncludeMetadata {
|
|
meta = &api.PaginationMetadata{
|
|
HasPreviousResults: opt.Page > 0,
|
|
}
|
|
if uint(len(activities)) > opt.PerPage && opt.PerPage > 0 {
|
|
meta.HasNextResults = true
|
|
activities = activities[:len(activities)-1]
|
|
}
|
|
}
|
|
|
|
return activities, meta, nil
|
|
}
|
|
|
|
// MarkActivitiesAsStreamed marks the given activities as streamed.
|
|
func (ds *Datastore) MarkActivitiesAsStreamed(ctx context.Context, activityIDs []uint) error {
|
|
if len(activityIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
ctx, span := tracer.Start(ctx, "activity.mysql.MarkActivitiesAsStreamed")
|
|
defer span.End()
|
|
|
|
stmt := `UPDATE activity_past SET streamed = true WHERE id IN (?);`
|
|
query, args, err := sqlx.In(stmt, activityIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sqlx.In mark activities as streamed")
|
|
}
|
|
if _, err := ds.primary.ExecContext(ctx, query, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "exec mark activities as streamed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListHostPastActivities returns past activities for a specific host.
|
|
func (ds *Datastore) ListHostPastActivities(ctx context.Context, hostID uint, opt types.ListOptions) ([]*api.Activity, *api.PaginationMetadata, error) {
|
|
ctx, span := tracer.Start(ctx, "activity.mysql.ListHostPastActivities")
|
|
defer span.End()
|
|
|
|
const listStmt = `
|
|
SELECT
|
|
ha.activity_id as id,
|
|
a.user_email as user_email,
|
|
a.user_name as name,
|
|
a.activity_type as activity_type,
|
|
a.details as details,
|
|
a.created_at as created_at,
|
|
a.user_id as user_id,
|
|
a.fleet_initiated as fleet_initiated
|
|
FROM
|
|
activity_host_past ha
|
|
JOIN activity_past a
|
|
ON ha.activity_id = a.id
|
|
WHERE
|
|
ha.host_id = ?`
|
|
|
|
args := []any{hostID}
|
|
|
|
stmt, args := platform_mysql.AppendListOptionsWithParams(listStmt, args, &opt)
|
|
|
|
var activities []*api.Activity
|
|
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &activities, stmt, args...); err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "select host past activities")
|
|
}
|
|
|
|
var metaData *api.PaginationMetadata
|
|
if opt.IncludeMetadata {
|
|
metaData = &api.PaginationMetadata{HasPreviousResults: opt.Page > 0}
|
|
if uint(len(activities)) > opt.PerPage { //nolint:gosec // dismiss G115
|
|
metaData.HasNextResults = true
|
|
activities = activities[:len(activities)-1]
|
|
}
|
|
}
|
|
|
|
return activities, metaData, nil
|
|
}
|
|
|
|
// CleanupExpiredActivities deletes up to maxCount activities older than expiryWindowDays
|
|
// that are not linked to any host. Host-linked activities are preserved.
|
|
func (ds *Datastore) CleanupExpiredActivities(ctx context.Context, maxCount int, expiryWindowDays int) error {
|
|
ctx, span := tracer.Start(ctx, "activity.mysql.CleanupExpiredActivities")
|
|
defer span.End()
|
|
|
|
const selectQuery = `
|
|
SELECT a.id FROM activity_past a
|
|
LEFT JOIN activity_host_past ha ON (a.id=ha.activity_id)
|
|
WHERE ha.activity_id IS NULL AND a.created_at < DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
ORDER BY a.id ASC
|
|
LIMIT ?`
|
|
|
|
var activityIDs []uint
|
|
if err := sqlx.SelectContext(ctx, ds.primary, &activityIDs, selectQuery, expiryWindowDays, maxCount); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "select expired activities for deletion")
|
|
}
|
|
if len(activityIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
deleteQuery, args, err := sqlx.In(`DELETE FROM activity_past WHERE id IN (?)`, activityIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "build expired activities IN query")
|
|
}
|
|
if _, err := ds.primary.ExecContext(ctx, deleteQuery, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete expired activities")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CleanupHostActivities removes activity_host_past rows for the given host IDs.
|
|
func (ds *Datastore) CleanupHostActivities(ctx context.Context, hostIDs []uint) error {
|
|
ctx, span := tracer.Start(ctx, "activity.mysql.CleanupHostActivities")
|
|
defer span.End()
|
|
|
|
if len(hostIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
stmt, args, err := sqlx.In(`DELETE FROM activity_host_past WHERE host_id IN (?)`, hostIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "build activity_host_past IN query")
|
|
}
|
|
if _, err := ds.primary.ExecContext(ctx, stmt, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete activity_host_past for deleted hosts")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fetchActivityDetails fetches details for activities in a separate query
|
|
// to avoid MySQL sort buffer issues with large JSON entries.
|
|
func (ds *Datastore) fetchActivityDetails(ctx context.Context, activities []*api.Activity) error {
|
|
ids := make([]uint, 0, len(activities))
|
|
for _, a := range activities {
|
|
ids = append(ids, a.ID)
|
|
}
|
|
|
|
detailsStmt, detailsArgs, err := sqlx.In("SELECT id, details FROM activity_past WHERE id IN (?)", ids)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "bind activity IDs for details")
|
|
}
|
|
|
|
type activityDetails struct {
|
|
ID uint `db:"id"`
|
|
Details *json.RawMessage `db:"details"`
|
|
}
|
|
var details []activityDetails
|
|
|
|
err = sqlx.SelectContext(ctx, ds.reader(ctx), &details, detailsStmt, detailsArgs...)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return ctxerr.Wrap(ctx, err, "select activity details")
|
|
}
|
|
|
|
detailsLookup := make(map[uint]*json.RawMessage, len(details))
|
|
for _, d := range details {
|
|
detailsLookup[d.ID] = d.Details
|
|
}
|
|
|
|
for _, a := range activities {
|
|
det, ok := detailsLookup[a.ID]
|
|
if !ok {
|
|
ds.logger.WarnContext(ctx, "Activity details not found", "activity_id", a.ID)
|
|
continue
|
|
}
|
|
a.Details = det
|
|
}
|
|
|
|
return nil
|
|
}
|