Activity bounded context: /api/latest/fleet/activities (1 of 2) (#38115)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #37806 

This PR creates an activity bounded context and moves the following HTTP
endpoint (including the full vertical slice) there:
`/api/latest/fleet/activities`

NONE of the other activity functionality is moved! This is an
incremental approach starting with just 1 API/service endpoint.

A significant part of this PR is tests. This feature is now receiving
significantly more unit/integration test coverage than before.

Also, this PR does not remove the `ListActivities` datastore method in
the legacy code. That will be done in the follow up PR (part 2 of 2).

This refactoring effort also uncovered an activity/user authorization
issue: https://fleetdm.slack.com/archives/C02A8BRABB5/p1768582236611479

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Activity listing API now available with query filtering, date-range
filtering, and type-based filtering
* Pagination support for activity results with cursor-based and
offset-based options
* Configurable sorting by creation date or activity ID in ascending or
descending order
* Automatic enrichment of activity records with actor user details
(name, email, avatar)
* Role-based access controls applied to activity visibility based on
user permissions

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky 2026-01-19 09:07:14 -05:00 committed by GitHub
parent b32681937c
commit 6019fa6d5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 2604 additions and 130 deletions

View file

@ -0,0 +1 @@
Internal refactoring: introduced activity bounded context as part of modular monolith architecture. Moved /api/latest/fleet/activities endpoint to new server/activity/ packages.

View file

@ -32,6 +32,9 @@ import (
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/pkg/scripts"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/acl/activityacl"
activity_bootstrap "github.com/fleetdm/fleet/v4/server/activity/bootstrap"
"github.com/fleetdm/fleet/v4/server/authz"
configpkg "github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
licensectx "github.com/fleetdm/fleet/v4/server/contexts/license"
@ -59,10 +62,12 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/buford"
nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/fleetdm/fleet/v4/server/pubsub"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/service/async"
"github.com/fleetdm/fleet/v4/server/service/conditional_access_microsoft_proxy"
"github.com/fleetdm/fleet/v4/server/service/middleware/auth"
otelmw "github.com/fleetdm/fleet/v4/server/service/middleware/otel"
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
"github.com/fleetdm/fleet/v4/server/service/redis_key_value"
@ -71,6 +76,7 @@ import (
"github.com/fleetdm/fleet/v4/server/sso"
"github.com/fleetdm/fleet/v4/server/version"
"github.com/getsentry/sentry-go"
"github.com/go-kit/kit/endpoint"
kitprometheus "github.com/go-kit/kit/metrics/prometheus"
"github.com/go-kit/log"
kitlog "github.com/go-kit/log"
@ -1259,6 +1265,9 @@ the way that the Fleet server works.
}
}
// Bootstrap activity bounded context
activityRoutes := createActivityBoundedContext(svc, dbConns, logger)
var apiHandler, frontendHandler, endUserEnrollOTAHandler http.Handler
{
frontendHandler = service.PrometheusMetricsHandler(
@ -1275,7 +1284,7 @@ the way that the Fleet server works.
extra = append(extra, service.WithHTTPSigVerifier(httpSigVerifier))
apiHandler = service.MakeHandler(svc, config, httpLogger, limiterStore, redisPool,
[]endpointer.HandlerRoutesFunc{android_service.GetRoutes(svc, androidSvc)}, extra...)
[]endpointer.HandlerRoutesFunc{android_service.GetRoutes(svc, androidSvc), activityRoutes}, extra...)
setupRequired, err := svc.SetupRequired(baseCtx)
if err != nil {
@ -1649,6 +1658,28 @@ the way that the Fleet server works.
return serveCmd
}
func createActivityBoundedContext(svc fleet.Service, dbConns *common_mysql.DBConnections, logger kitlog.Logger) endpointer.HandlerRoutesFunc {
legacyAuthorizer, err := authz.NewAuthorizer()
if err != nil {
initFatal(err, "initializing activity authorizer")
}
activityAuthorizer := authz.NewAuthorizerAdapter(legacyAuthorizer)
activityUserProvider := activityacl.NewFleetServiceAdapter(svc)
// Note: the first return value below (_) will be used in legacy Service layer in next PR
_, activityRoutesFn := activity_bootstrap.New(
dbConns,
activityAuthorizer,
activityUserProvider,
logger,
)
// Create auth middleware for activity bounded context
activityAuthMiddleware := func(next endpoint.Endpoint) endpoint.Endpoint {
return auth.AuthenticatedUser(svc, next)
}
activityRoutes := activityRoutesFn(activityAuthMiddleware)
return activityRoutes
}
func printDatabaseNotInitializedError() {
fmt.Printf("################################################################################\n"+
"# ERROR:\n"+

View file

@ -6,6 +6,18 @@ import (
"testing"
)
// TestLogWriter adapts testing.TB to io.Writer for use with go-kit/log.
// Logs are associated with the test and only shown on failure (or with -v).
type TestLogWriter struct {
T testing.TB
}
func (w *TestLogWriter) Write(p []byte) (n int, err error) {
// Trim trailing newline because go-kit/log adds one, and t.Log() adds another.
w.T.Log(strings.TrimSuffix(string(p), "\n"))
return len(p), nil
}
// SaveEnv snapshots the current environment and restores it when the test
// ends.
//

View file

@ -0,0 +1,81 @@
// Package activityacl provides the anti-corruption layer between the activity
// bounded context and legacy Fleet code.
//
// This package is the ONLY place that imports both activity types and fleet types.
// It translates between them, allowing the activity context to remain decoupled
// from legacy code.
package activityacl
import (
"context"
"github.com/fleetdm/fleet/v4/server/activity"
"github.com/fleetdm/fleet/v4/server/fleet"
)
// FleetServiceAdapter provides access to Fleet service methods
// for data that the activity bounded context doesn't own.
type FleetServiceAdapter struct {
svc fleet.UserLookupService
}
// NewFleetServiceAdapter creates a new adapter for the Fleet service.
func NewFleetServiceAdapter(svc fleet.UserLookupService) *FleetServiceAdapter {
return &FleetServiceAdapter{svc: svc}
}
// Ensure FleetServiceAdapter implements activity.UserProvider
var _ activity.UserProvider = (*FleetServiceAdapter)(nil)
// UsersByIDs fetches users by their IDs from the Fleet service.
func (a *FleetServiceAdapter) UsersByIDs(ctx context.Context, ids []uint) ([]*activity.User, error) {
if len(ids) == 0 {
return nil, nil
}
// Fetch only the requested users by their IDs
users, err := a.svc.UsersByIDs(ctx, ids)
if err != nil {
return nil, err
}
// Convert to activity.User
result := make([]*activity.User, 0, len(users))
for _, u := range users {
result = append(result, convertUser(u))
}
return result, nil
}
// FindUserIDs searches for users by name/email prefix and returns their IDs.
func (a *FleetServiceAdapter) FindUserIDs(ctx context.Context, query string) ([]uint, error) {
if query == "" {
return nil, nil
}
// Search users via Fleet service with the query
users, err := a.svc.ListUsers(ctx, fleet.UserListOptions{
ListOptions: fleet.ListOptions{
MatchQuery: query,
},
})
if err != nil {
return nil, err
}
ids := make([]uint, 0, len(users))
for _, u := range users {
ids = append(ids, u.ID)
}
return ids, nil
}
func convertUser(u *fleet.User) *activity.User {
return &activity.User{
ID: u.ID,
Name: u.Name,
Email: u.Email,
Gravatar: u.GravatarURL,
APIOnly: u.APIOnly,
}
}

View file

@ -0,0 +1,22 @@
// Package http provides HTTP request and response types for the activity bounded context.
// These types are used exclusively by the activities endpoint handler.
package http
import (
"github.com/fleetdm/fleet/v4/server/activity/api"
)
// ListActivitiesRequest is the HTTP request type for listing activities.
type ListActivitiesRequest struct {
ListOptions api.ListOptions `url:"list_options"`
}
// ListActivitiesResponse is the HTTP response type for listing activities.
type ListActivitiesResponse struct {
Meta *api.PaginationMetadata `json:"meta"`
Activities []*api.Activity `json:"activities"`
Err error `json:"error,omitempty"`
}
// Error implements the platform_http.Errorer interface.
func (r ListActivitiesResponse) Error() error { return r.Err }

View file

@ -0,0 +1,72 @@
// Package api provides the public API for the activity bounded context.
// External code should use this package to interact with activities.
package api
import (
"context"
"encoding/json"
"time"
)
// OrderDirection represents the sort direction for list queries.
type OrderDirection string
const (
OrderAsc OrderDirection = "asc"
OrderDesc OrderDirection = "desc"
)
type ListActivitiesService interface {
ListActivities(ctx context.Context, opt ListOptions) ([]*Activity, *PaginationMetadata, error)
}
// Activity represents a recorded activity in the audit log.
type Activity struct {
ID uint `json:"id,omitempty" db:"id"`
UUID string `json:"uuid,omitempty" db:"uuid"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
Type string `json:"type" db:"activity_type"`
ActorID *uint `json:"actor_id,omitempty" db:"user_id"`
ActorFullName *string `json:"actor_full_name,omitempty" db:"name"`
ActorEmail *string `json:"actor_email,omitempty" db:"user_email"`
ActorGravatar *string `json:"actor_gravatar,omitempty" db:"gravatar_url"`
ActorAPIOnly *bool `json:"actor_api_only,omitempty" db:"api_only"`
Streamed *bool `json:"-" db:"streamed"`
FleetInitiated bool `json:"fleet_initiated" db:"fleet_initiated"`
Details *json.RawMessage `json:"details" db:"details"`
}
// AuthzType implements the authorization type for activities.
func (a *Activity) AuthzType() string {
return "activity"
}
// ListOptions defines options for listing activities.
// Note: Query parameter decoding is handled by listOptionsFromRequest in the service layer,
// not via struct tags. This keeps the API package free of HTTP-specific concerns.
type ListOptions struct {
// Pagination
Page uint
PerPage uint
After string // Cursor-based pagination: start after this value (used with OrderKey)
// Sorting
OrderKey string // Field to order by (e.g., "created_at", "id")
OrderDirection OrderDirection // OrderAsc or OrderDesc
// Filters
ActivityType string // Filter by activity type
StartCreatedAt string // ISO date string, filter activities created after this time
EndCreatedAt string // ISO date string, filter activities created before this time
MatchQuery string // Search query for actor name and email
Streamed *bool // Filter by streamed status (nil = all, true = streamed only, false = not streamed only)
}
// PaginationMetadata contains pagination information for list responses.
type PaginationMetadata struct {
HasNextResults bool `json:"has_next_results"`
HasPreviousResults bool `json:"has_previous_results"`
// TotalResults is excluded from JSON responses to match legacy behavior.
// It can be used by server-side code but won't be sent to API clients.
TotalResults uint `json:"-"`
}

View file

@ -0,0 +1,7 @@
package api
// Service is the composite interface for the activity bounded context.
// It embeds all method-specific interfaces. Bootstrap returns this type.
type Service interface {
ListActivitiesService
}

View file

@ -0,0 +1,125 @@
package activity_test
import (
"regexp"
"testing"
"github.com/fleetdm/fleet/v4/server/archtest"
)
const m = archtest.ModuleName
var (
fleetDeps = regexp.MustCompile(`^github\.com/fleetdm/`)
// Common allowed dependencies across activity packages
activityPkgs = []string{
m + "/server/activity",
m + "/server/activity/api",
m + "/server/activity/api/http",
m + "/server/activity/internal/types",
}
platformPkgs = []string{
m + "/server/platform/...",
m + "/server/contexts/ctxerr",
m + "/server/contexts/viewer",
m + "/server/contexts/license",
m + "/server/contexts/logging",
m + "/server/contexts/authz",
m + "/server/contexts/publicip",
}
)
// TestActivityPackageDependencies runs architecture tests for all activity packages.
// Each package has specific rules about what dependencies are allowed.
func TestActivityPackageDependencies(t *testing.T) {
t.Parallel()
cases := []struct {
name string
pkg string
shouldNotDepend []string // defaults to m + "/..." if empty
ignoreDeps []string
}{
{
name: "root package has no Fleet dependencies",
pkg: m + "/server/activity",
},
{
name: "api package has no Fleet dependencies",
pkg: m + "/server/activity/api",
},
{
name: "api/http only depends on api",
pkg: m + "/server/activity/api/http",
ignoreDeps: []string{m + "/server/activity/api"},
},
{
name: "internal/types only depends on api",
pkg: m + "/server/activity/internal/types",
ignoreDeps: []string{m + "/server/activity/api"},
},
{
name: "internal/mysql depends on api, types, and platform",
pkg: m + "/server/activity/internal/mysql",
ignoreDeps: []string{
m + "/server/activity/api",
m + "/server/activity/internal/types",
m + "/server/activity/internal/testutils",
m + "/server/platform/http",
m + "/server/platform/mysql",
m + "/server/platform/mysql/testing_utils",
m + "/server/contexts/ctxerr",
m + "/server/ptr",
},
},
{
name: "internal/service depends on activity and platform packages",
pkg: m + "/server/activity/internal/service",
ignoreDeps: append(append([]string{
m + "/server/ptr",
}, activityPkgs...), platformPkgs...),
},
{
name: "bootstrap depends on activity and platform packages",
pkg: m + "/server/activity/bootstrap",
ignoreDeps: append(append([]string{
m + "/server/activity/internal/mysql",
m + "/server/activity/internal/service",
}, activityPkgs...), platformPkgs...),
},
{
name: "all packages only depend on activity and platform",
pkg: m + "/server/activity/...",
ignoreDeps: append(append([]string{
m + "/server/ptr",
m + "/server/activity/internal/mysql",
m + "/server/activity/internal/service",
m + "/server/activity/internal/testutils",
}, activityPkgs...), platformPkgs...),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
shouldNotDepend := tc.shouldNotDepend
if len(shouldNotDepend) == 0 {
shouldNotDepend = []string{m + "/..."}
}
test := archtest.NewPackageTest(t, tc.pkg).
OnlyInclude(fleetDeps).
ShouldNotDependOn(shouldNotDepend...).
WithTests()
if len(tc.ignoreDeps) > 0 {
test.IgnoreDeps(tc.ignoreDeps...)
}
test.Check()
})
}
}

View file

@ -0,0 +1,32 @@
// Package bootstrap provides the public entry point for the activity bounded context.
// It wires together internal components and exposes them for use in serve.go.
package bootstrap
import (
"github.com/fleetdm/fleet/v4/server/activity"
"github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/activity/internal/mysql"
"github.com/fleetdm/fleet/v4/server/activity/internal/service"
platform_authz "github.com/fleetdm/fleet/v4/server/platform/authz"
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/go-kit/kit/endpoint"
kitlog "github.com/go-kit/log"
)
// New creates a new activity bounded context and returns its service and route handler.
func New(
dbConns *platform_mysql.DBConnections,
authorizer platform_authz.Authorizer,
userProvider activity.UserProvider,
logger kitlog.Logger,
) (api.Service, func(authMiddleware endpoint.Middleware) eu.HandlerRoutesFunc) {
ds := mysql.NewDatastore(dbConns, logger)
svc := service.NewService(authorizer, ds, userProvider, logger)
routesFn := func(authMiddleware endpoint.Middleware) eu.HandlerRoutesFunc {
return service.GetRoutes(svc, authMiddleware)
}
return svc, routesFn
}

View file

@ -0,0 +1,177 @@
// Package mysql provides the MySQL datastore implementation for the activity bounded context.
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"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"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
"github.com/jmoiron/sqlx"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
)
// 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 kitlog.Logger
}
// NewDatastore creates a new MySQL datastore for activities.
func NewDatastore(conns *platform_mysql.DBConnections, logger kitlog.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",
trace.WithSpanKind(trace.SpanKindInternal))
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 activities 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)
}
if opt.EndCreatedAt != "" {
activitiesQ += " AND a.created_at <= ?"
args = append(args, opt.EndCreatedAt)
} else if opt.StartCreatedAt != "" {
// When filtering by start date, cap at now to ensure consistent results
activitiesQ += " AND a.created_at <= ?"
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
}
// 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 activities 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 {
level.Warn(ds.logger).Log("msg", "Activity details not found", "activity_id", a.ID)
continue
}
a.Details = det
}
return nil
}

View file

@ -0,0 +1,381 @@
package mysql
import (
"fmt"
"testing"
"time"
activityapi "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/activity/internal/testutils"
"github.com/fleetdm/fleet/v4/server/activity/internal/types"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testEnv holds test dependencies.
type testEnv struct {
*testutils.TestDB
ds *Datastore
}
func TestListActivities(t *testing.T) {
tdb := testutils.SetupTestDB(t, "activity_mysql")
ds := NewDatastore(tdb.Conns(), tdb.Logger)
env := &testEnv{TestDB: tdb, ds: ds}
cases := []struct {
name string
fn func(t *testing.T, env *testEnv)
}{
{"Basic", testListActivitiesBasic},
{"Streamed", testListActivitiesStreamed},
{"PaginationMetadata", testListActivitiesPaginationMetadata},
{"ActivityTypeFilter", testListActivitiesActivityTypeFilter},
{"DateRangeFilter", testListActivitiesDateRangeFilter},
{"MatchQuery", testListActivitiesMatchQuery},
{"Ordering", testListActivitiesOrdering},
{"CursorPagination", testListActivitiesCursorPagination},
{"HostOnlyExcluded", testListActivitiesHostOnlyExcluded},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer env.TruncateTables(t)
c.fn(t, env)
})
}
}
func testListActivitiesBasic(t *testing.T, env *testEnv) {
ctx := t.Context()
userID := env.InsertUser(t, "testuser", "test@example.com")
// Create user activities and a system activity (nil user)
for i := range 3 {
env.InsertActivity(t, ptr.Uint(userID), fmt.Sprintf("test_activity_%d", i), map[string]any{"detail": i})
}
env.InsertActivity(t, nil, "system_activity", map[string]any{})
activities, meta, err := env.ds.ListActivities(ctx, listOpts(withMetadata()))
require.NoError(t, err)
assert.Len(t, activities, 4)
assert.NotNil(t, meta)
// Verify user activities have actor info
for _, a := range activities {
assert.NotZero(t, a.ID)
assert.NotEmpty(t, a.Type)
assert.NotNil(t, a.Details)
if a.Type == "system_activity" {
// System activity has no actor
assert.Nil(t, a.ActorID)
assert.Nil(t, a.ActorFullName)
} else {
require.NotNil(t, a.ActorID)
assert.Equal(t, userID, *a.ActorID)
}
}
}
func testListActivitiesStreamed(t *testing.T, env *testEnv) {
ctx := t.Context()
userID := env.InsertUser(t, "testuser", "test@example.com")
var activityIDs []uint
for i := range 3 {
id := env.InsertActivity(t, ptr.Uint(userID), "test_activity", map[string]any{"detail": i})
activityIDs = append(activityIDs, id)
}
// Mark first activity as streamed
_, err := env.DB.ExecContext(ctx, "UPDATE activities SET streamed = true WHERE id = ?", activityIDs[0])
require.NoError(t, err)
cases := []struct {
name string
streamed *bool
expectedIDs []uint
}{
{"all", nil, activityIDs},
{"non-streamed only", ptr.Bool(false), activityIDs[1:]},
{"streamed only", ptr.Bool(true), activityIDs[:1]},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
activities, _, err := env.ds.ListActivities(ctx, listOpts(withStreamed(tc.streamed)))
require.NoError(t, err)
gotIDs := make([]uint, len(activities))
for i, a := range activities {
gotIDs[i] = a.ID
}
assert.ElementsMatch(t, tc.expectedIDs, gotIDs)
})
}
}
func testListActivitiesPaginationMetadata(t *testing.T, env *testEnv) {
ctx := t.Context()
userID := env.InsertUser(t, "testuser", "test@example.com")
for i := range 3 {
env.InsertActivity(t, ptr.Uint(userID), fmt.Sprintf("test_%d", i), map[string]any{})
}
cases := []struct {
name string
perPage uint
page uint
wantCount int
wantNext bool
wantPrev bool
}{
{"first page with more", 2, 0, 2, true, false},
{"second page partial", 2, 1, 1, false, true},
{"all results", 100, 0, 3, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
activities, meta, err := env.ds.ListActivities(ctx, listOpts(withPerPage(tc.perPage), withPage(tc.page), withMetadata()))
require.NoError(t, err)
assert.Len(t, activities, tc.wantCount)
require.NotNil(t, meta)
assert.Equal(t, tc.wantNext, meta.HasNextResults)
assert.Equal(t, tc.wantPrev, meta.HasPreviousResults)
})
}
}
func testListActivitiesActivityTypeFilter(t *testing.T, env *testEnv) {
ctx := t.Context()
userID := env.InsertUser(t, "testuser", "test@example.com")
env.InsertActivity(t, ptr.Uint(userID), "edited_script", map[string]any{})
env.InsertActivity(t, ptr.Uint(userID), "edited_script", map[string]any{})
env.InsertActivity(t, ptr.Uint(userID), "mdm_enrolled", map[string]any{})
cases := []struct {
activityType string
wantCount int
}{
{"edited_script", 2},
{"mdm_enrolled", 1},
{"non_existent", 0},
}
for _, tc := range cases {
t.Run(tc.activityType, func(t *testing.T) {
activities, _, err := env.ds.ListActivities(ctx, listOpts(withActivityType(tc.activityType)))
require.NoError(t, err)
assert.Len(t, activities, tc.wantCount)
for _, a := range activities {
assert.Equal(t, tc.activityType, a.Type)
}
})
}
}
func testListActivitiesDateRangeFilter(t *testing.T, env *testEnv) {
ctx := t.Context()
userID := env.InsertUser(t, "testuser", "test@example.com")
now := time.Now().UTC().Truncate(time.Second)
// Only create activities in the past/present (activities can't have future creation dates)
dates := []time.Time{
now.Add(-48 * time.Hour),
now.Add(-24 * time.Hour),
now,
}
for _, dt := range dates {
env.InsertActivityWithTime(t, ptr.Uint(userID), "test_activity", map[string]any{}, dt)
}
cases := []struct {
name string
start string
end string
wantCount int
}{
{"no filter", "", "", 3},
{"start only", now.Add(-72 * time.Hour).Format(time.RFC3339), "", 3},
{"start and end", now.Add(-72 * time.Hour).Format(time.RFC3339), now.Add(-12 * time.Hour).Format(time.RFC3339), 2},
{"end only", "", now.Add(-30 * time.Hour).Format(time.RFC3339), 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
activities, _, err := env.ds.ListActivities(ctx, listOpts(withDateRange(tc.start, tc.end)))
require.NoError(t, err)
assert.Len(t, activities, tc.wantCount)
})
}
}
func testListActivitiesMatchQuery(t *testing.T, env *testEnv) {
ctx := t.Context()
johnUserID := env.InsertUser(t, "john_doe", "john@example.com")
janeUserID := env.InsertUser(t, "jane_smith", "jane@example.com")
env.InsertActivity(t, ptr.Uint(johnUserID), "test_activity", map[string]any{})
env.InsertActivity(t, ptr.Uint(janeUserID), "test_activity", map[string]any{})
cases := []struct {
name string
query string
matchingUserIDs []uint
wantCount int
}{
{"by username prefix", "john", nil, 1},
{"by email prefix", "jane@", nil, 1},
{"no match", "nomatch", nil, 0},
{"via matching user IDs", "nomatch", []uint{johnUserID}, 1},
{"via multiple matching user IDs", "nomatch", []uint{johnUserID, janeUserID}, 2},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
opts := listOpts(withMatchQuery(tc.query))
opts.MatchingUserIDs = tc.matchingUserIDs
activities, _, err := env.ds.ListActivities(ctx, opts)
require.NoError(t, err)
assert.Len(t, activities, tc.wantCount)
})
}
}
func testListActivitiesOrdering(t *testing.T, env *testEnv) {
ctx := t.Context()
userID := env.InsertUser(t, "testuser", "test@example.com")
now := time.Now().UTC().Truncate(time.Second)
env.InsertActivityWithTime(t, ptr.Uint(userID), "activity_oldest", map[string]any{}, now.Add(-2*time.Hour))
env.InsertActivityWithTime(t, ptr.Uint(userID), "activity_middle", map[string]any{}, now.Add(-1*time.Hour))
env.InsertActivityWithTime(t, ptr.Uint(userID), "activity_newest", map[string]any{}, now)
cases := []struct {
name string
orderKey string
orderDir activityapi.OrderDirection
wantFirst string
wantLast string
}{
{"created_at desc", "created_at", activityapi.OrderDesc, "activity_newest", "activity_oldest"},
{"created_at asc", "created_at", activityapi.OrderAsc, "activity_oldest", "activity_newest"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
activities, _, err := env.ds.ListActivities(ctx, listOpts(withOrder(tc.orderKey, tc.orderDir)))
require.NoError(t, err)
require.Len(t, activities, 3)
assert.Equal(t, tc.wantFirst, activities[0].Type)
assert.Equal(t, tc.wantLast, activities[2].Type)
})
}
// Verify ID ordering works
activities, _, err := env.ds.ListActivities(ctx, listOpts(withOrder("id", activityapi.OrderAsc)))
require.NoError(t, err)
require.Len(t, activities, 3)
assert.Less(t, activities[0].ID, activities[1].ID)
assert.Less(t, activities[1].ID, activities[2].ID)
}
func testListActivitiesCursorPagination(t *testing.T, env *testEnv) {
ctx := t.Context()
userID := env.InsertUser(t, "testuser", "test@example.com")
for i := range 5 {
env.InsertActivity(t, ptr.Uint(userID), fmt.Sprintf("activity_%d", i), map[string]any{})
}
// Get first page
activities, _, err := env.ds.ListActivities(ctx, listOpts(withPerPage(2), withOrder("id", activityapi.OrderAsc)))
require.NoError(t, err)
require.Len(t, activities, 2)
lastID := activities[1].ID
// Get next page using cursor
activities, _, err = env.ds.ListActivities(ctx, listOpts(withPerPage(2), withOrder("id", activityapi.OrderAsc), withAfter(fmt.Sprintf("%d", lastID))))
require.NoError(t, err)
require.Len(t, activities, 2)
for _, a := range activities {
assert.Greater(t, a.ID, lastID)
}
}
func testListActivitiesHostOnlyExcluded(t *testing.T, env *testEnv) {
ctx := t.Context()
userID := env.InsertUser(t, "testuser", "test@example.com")
env.InsertActivity(t, ptr.Uint(userID), "regular_activity", map[string]any{})
// Create host-only activity directly (should be excluded)
_, err := env.DB.ExecContext(ctx, `
INSERT INTO activities (user_id, user_name, user_email, activity_type, details, created_at, host_only, streamed)
VALUES (?, 'testuser', 'test@example.com', 'host_only_activity', '{}', NOW(), true, false)
`, userID)
require.NoError(t, err)
activities, _, err := env.ds.ListActivities(ctx, listOpts())
require.NoError(t, err)
assert.Len(t, activities, 1)
assert.Equal(t, "regular_activity", activities[0].Type)
}
// Test helpers for building ListOptions
type listOptsFunc func(*types.ListOptions)
func listOpts(opts ...listOptsFunc) types.ListOptions {
o := types.ListOptions{ListOptions: activityapi.ListOptions{PerPage: 100}}
for _, fn := range opts {
fn(&o)
}
return o
}
func withPerPage(n uint) listOptsFunc {
return func(o *types.ListOptions) { o.PerPage = n }
}
func withPage(n uint) listOptsFunc {
return func(o *types.ListOptions) { o.Page = n }
}
func withMetadata() listOptsFunc {
return func(o *types.ListOptions) { o.IncludeMetadata = true }
}
func withStreamed(s *bool) listOptsFunc {
return func(o *types.ListOptions) { o.Streamed = s }
}
func withActivityType(t string) listOptsFunc {
return func(o *types.ListOptions) { o.ActivityType = t }
}
func withMatchQuery(q string) listOptsFunc {
return func(o *types.ListOptions) { o.MatchQuery = q }
}
func withDateRange(start, end string) listOptsFunc {
return func(o *types.ListOptions) {
o.StartCreatedAt = start
o.EndCreatedAt = end
}
}
func withOrder(key string, dir activityapi.OrderDirection) listOptsFunc {
return func(o *types.ListOptions) {
o.OrderKey = key
o.OrderDirection = dir
}
}
func withAfter(cursor string) listOptsFunc {
return func(o *types.ListOptions) { o.After = cursor }
}

View file

@ -0,0 +1,150 @@
package service
import (
"context"
"encoding/json"
"io"
"net/http"
"reflect"
"strconv"
"github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
"github.com/go-kit/kit/endpoint"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/gorilla/mux"
)
const (
// defaultPerPage is used when per_page is not specified but page is specified.
defaultPerPage = 20
// unlimitedPerPage is used when neither page nor per_page is specified,
// effectively returning all results (legacy behavior for backwards compatibility).
unlimitedPerPage = 1000000
)
// encodeResponse encodes the response as JSON.
func encodeResponse(ctx context.Context, w http.ResponseWriter, response any) error {
return eu.EncodeCommonResponse(ctx, w, response,
func(w http.ResponseWriter, response any) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(response)
},
nil, // no domain-specific error encoder
)
}
// makeDecoder creates a decoder for the given request type.
func makeDecoder(iface any) kithttp.DecodeRequestFunc {
return eu.MakeDecoder(iface, func(body io.Reader, req any) error {
return json.NewDecoder(body).Decode(req)
}, parseCustomTags, nil, nil, nil)
}
// parseCustomTags handles custom URL tag values for activity requests.
func parseCustomTags(urlTagValue string, r *http.Request, field reflect.Value) (bool, error) {
if urlTagValue == "list_options" {
opts, err := listOptionsFromRequest(r)
if err != nil {
return false, err
}
field.Set(reflect.ValueOf(opts))
return true, nil
}
return false, nil
}
// listOptionsFromRequest parses list options from query parameters.
func listOptionsFromRequest(r *http.Request) (api.ListOptions, error) {
q := r.URL.Query()
var page int
if val := q.Get("page"); val != "" {
var err error
page, err = strconv.Atoi(val)
if err != nil {
return api.ListOptions{}, ctxerr.Wrap(r.Context(), &platform_http.BadRequestError{Message: "non-int page value"})
}
if page < 0 {
return api.ListOptions{}, ctxerr.Wrap(r.Context(), &platform_http.BadRequestError{Message: "negative page value"})
}
}
var perPage int
if val := q.Get("per_page"); val != "" {
var err error
perPage, err = strconv.Atoi(val)
if err != nil {
return api.ListOptions{}, ctxerr.Wrap(r.Context(), &platform_http.BadRequestError{Message: "non-int per_page value"})
}
if perPage <= 0 {
return api.ListOptions{}, ctxerr.Wrap(r.Context(), &platform_http.BadRequestError{Message: "invalid per_page value"})
}
}
orderKey := q.Get("order_key")
orderDirectionString := q.Get("order_direction")
if orderKey == "" && orderDirectionString != "" {
return api.ListOptions{}, ctxerr.Wrap(r.Context(), &platform_http.BadRequestError{Message: "order_key must be specified with order_direction"})
}
var orderDirection api.OrderDirection
switch orderDirectionString {
case "desc":
orderDirection = api.OrderDesc
case "asc", "":
orderDirection = api.OrderAsc
default:
return api.ListOptions{}, ctxerr.Wrap(r.Context(), &platform_http.BadRequestError{Message: "unknown order_direction: " + orderDirectionString})
}
return api.ListOptions{
Page: uint(page), //nolint:gosec // dismiss G115
PerPage: uint(perPage), //nolint:gosec // dismiss G115
OrderKey: orderKey,
OrderDirection: orderDirection,
After: q.Get("after"),
ActivityType: q.Get("activity_type"),
StartCreatedAt: q.Get("start_created_at"),
EndCreatedAt: q.Get("end_created_at"),
MatchQuery: q.Get("query"),
}, nil
}
// handlerFunc is the handler function type for Activity service endpoints.
type handlerFunc func(ctx context.Context, request any, svc api.Service) platform_http.Errorer
// Compile-time check to ensure endpointer implements Endpointer.
var _ eu.Endpointer[handlerFunc] = &endpointer{}
type endpointer struct {
svc api.Service
}
func (e *endpointer) CallHandlerFunc(f handlerFunc, ctx context.Context, request any,
svc any) (platform_http.Errorer, error) {
return f(ctx, request, svc.(api.Service)), nil
}
func (e *endpointer) Service() any {
return e.svc
}
func newUserAuthenticatedEndpointer(svc api.Service, authMiddleware endpoint.Middleware, opts []kithttp.ServerOption, r *mux.Router,
versions ...string) *eu.CommonEndpointer[handlerFunc] {
return &eu.CommonEndpointer[handlerFunc]{
EP: &endpointer{
svc: svc,
},
MakeDecoderFn: makeDecoder,
EncodeFn: encodeResponse,
Opts: opts,
AuthMiddleware: authMiddleware,
Router: r,
Versions: versions,
}
}

View file

@ -0,0 +1,69 @@
package service
import (
"context"
"github.com/fleetdm/fleet/v4/server/activity/api"
api_http "github.com/fleetdm/fleet/v4/server/activity/api/http"
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
"github.com/go-kit/kit/endpoint"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/gorilla/mux"
)
// GetRoutes returns a function that registers activity routes on the router.
func GetRoutes(svc api.Service, authMiddleware endpoint.Middleware) eu.HandlerRoutesFunc {
return func(r *mux.Router, opts []kithttp.ServerOption) {
attachFleetAPIRoutes(r, svc, authMiddleware, opts)
}
}
func attachFleetAPIRoutes(r *mux.Router, svc api.Service, authMiddleware endpoint.Middleware, opts []kithttp.ServerOption) {
// User-authenticated endpoints
ue := newUserAuthenticatedEndpointer(svc, authMiddleware, opts, r, apiVersions()...)
ue.GET("/api/_version_/fleet/activities", listActivitiesEndpoint, api_http.ListActivitiesRequest{})
}
func apiVersions() []string {
return []string{"v1", "latest"}
}
// listActivitiesEndpoint handles GET /api/_version_/fleet/activities
func listActivitiesEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
req := request.(*api_http.ListActivitiesRequest)
opt := req.ListOptions // Access the embedded api.ListOptions
fillListOptions(&opt)
activities, meta, err := svc.ListActivities(ctx, opt)
if err != nil {
return api_http.ListActivitiesResponse{Err: err}
}
return api_http.ListActivitiesResponse{
Meta: meta,
Activities: activities,
}
}
// fillListOptions sets default values for list options.
// Note: IncludeMetadata is set internally by the service layer.
func fillListOptions(opt *api.ListOptions) {
// Default ordering by created_at descending (newest first) if not specified
if opt.OrderKey == "" {
opt.OrderKey = "created_at"
opt.OrderDirection = api.OrderDesc
}
// Default PerPage based on whether pagination was requested
if opt.PerPage == 0 {
if opt.Page == 0 {
// No pagination requested - return all results (legacy behavior)
opt.PerPage = unlimitedPerPage
} else {
// Page specified without per_page - use sensible default
opt.PerPage = defaultPerPage
}
}
}

View file

@ -0,0 +1,126 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/fleetdm/fleet/v4/server/activity/api"
platform_endpointer "github.com/fleetdm/fleet/v4/server/platform/endpointer"
"github.com/go-kit/kit/endpoint"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestListActivitiesValidation(t *testing.T) {
t.Parallel()
// These tests verify decoder validation logic that returns 400 Bad Request.
// Happy path and business logic are covered by integration tests.
cases := []struct {
name string
query string
wantErr string
}{
{
name: "non-integer page",
query: "page=abc",
wantErr: "non-int page value",
},
{
name: "negative page",
query: "page=-1",
wantErr: "negative page value",
},
{
name: "non-integer per_page",
query: "per_page=abc",
wantErr: "non-int per_page value",
},
{
name: "zero per_page",
query: "per_page=0",
wantErr: "invalid per_page value",
},
{
name: "negative per_page",
query: "per_page=-5",
wantErr: "invalid per_page value",
},
{
name: "order_direction without order_key",
query: "order_direction=desc",
wantErr: "order_key must be specified with order_direction",
},
{
name: "invalid order_direction",
query: "order_key=id&order_direction=invalid",
wantErr: "unknown order_direction: invalid",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := setupTestRouter()
req := httptest.NewRequest("GET", "/api/v1/fleet/activities?"+tc.query, nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
var response struct {
Message string `json:"message"`
Errors []struct {
Name string `json:"name"`
Reason string `json:"reason"`
} `json:"errors"`
}
err := json.NewDecoder(rr.Body).Decode(&response)
require.NoError(t, err)
// Check that the error message is in the errors array
require.Len(t, response.Errors, 1)
assert.Equal(t, "base", response.Errors[0].Name)
assert.Equal(t, tc.wantErr, response.Errors[0].Reason)
})
}
}
// errorEncoder wraps platform_endpointer.EncodeError for use in tests.
func errorEncoder(ctx context.Context, err error, w http.ResponseWriter) {
platform_endpointer.EncodeError(ctx, err, w, nil)
}
func setupTestRouter() *mux.Router {
r := mux.NewRouter()
// Mock service that should never be called (validation fails before reaching service)
mockSvc := &mockService{}
// Pass-through auth middleware
authMiddleware := func(e endpoint.Endpoint) endpoint.Endpoint { return e }
// Server options with proper error encoding
opts := []kithttp.ServerOption{
kithttp.ServerErrorEncoder(errorEncoder),
}
routesFn := GetRoutes(mockSvc, authMiddleware)
routesFn(r, opts)
return r
}
// mockService implements api.Service for handler tests.
// For validation tests, this should never be called.
type mockService struct{}
func (m *mockService) ListActivities(_ context.Context, _ api.ListOptions) ([]*api.Activity, *api.PaginationMetadata, error) {
panic("mockService.ListActivities should not be called in validation tests")
}

View file

@ -0,0 +1,121 @@
// Package service provides the service implementation for the activity bounded context.
package service
import (
"context"
"maps"
"slices"
"github.com/fleetdm/fleet/v4/server/activity"
"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_authz "github.com/fleetdm/fleet/v4/server/platform/authz"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
)
// Service is the activity bounded context service implementation.
type Service struct {
authz platform_authz.Authorizer
store types.Datastore
users activity.UserProvider
logger kitlog.Logger
}
// NewService creates a new activity service.
func NewService(authz platform_authz.Authorizer, store types.Datastore, users activity.UserProvider, logger kitlog.Logger) *Service {
return &Service{
authz: authz,
store: store,
users: users,
logger: logger,
}
}
// Ensure Service implements api.Service
var _ api.Service = (*Service)(nil)
// ListActivities returns a slice of activities for the whole organization.
func (s *Service) ListActivities(ctx context.Context, opt api.ListOptions) ([]*api.Activity, *api.PaginationMetadata, error) {
// Convert public options to internal options (which include internal fields)
// Don't include metadata for cursor-based pagination (when After is set)
internalOpt := types.ListOptions{
ListOptions: opt,
IncludeMetadata: opt.After == "",
}
// Authorization check
if err := s.authz.Authorize(ctx, &api.Activity{}, platform_authz.ActionRead); err != nil {
return nil, nil, err
}
// If searching, also search users table to get matching user IDs.
// Use graceful degradation for authorization errors only: if user search fails
// due to authorization, proceed without user-based filtering rather than failing.
if opt.MatchQuery != "" {
userIDs, err := s.users.FindUserIDs(ctx, opt.MatchQuery)
switch {
case err == nil:
internalOpt.MatchingUserIDs = userIDs
case platform_authz.IsForbidden(err):
level.Debug(s.logger).Log("msg", "user search forbidden, proceeding without user filter", "err", err)
default:
return nil, nil, ctxerr.Wrap(ctx, err, "failed to search users for activity query")
}
}
activities, meta, err := s.store.ListActivities(ctx, internalOpt)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "list activities")
}
// Enrich activities with user data via ACL.
// Use graceful degradation for authorization errors only: if user enrichment fails
// due to authorization, return activities without user data rather than failing.
if err := s.enrichWithUserData(ctx, activities); err != nil {
if platform_authz.IsForbidden(err) {
level.Debug(s.logger).Log("msg", "user enrichment forbidden, proceeding without enrichment", "err", err)
} else {
return nil, nil, ctxerr.Wrap(ctx, err, "failed to enrich activities with user data")
}
}
return activities, meta, nil
}
// enrichWithUserData adds user data (gravatar, email, name, api_only) to activities by fetching via ACL.
func (s *Service) enrichWithUserData(ctx context.Context, activities []*api.Activity) error {
// Collect unique user IDs and build lookup of activity indices per user
lookup := make(map[uint][]int)
for idx, a := range activities {
if a.ActorID != nil {
lookup[*a.ActorID] = append(lookup[*a.ActorID], idx)
}
}
if len(lookup) == 0 {
return nil
}
users, err := s.users.UsersByIDs(ctx, slices.Collect(maps.Keys(lookup)))
if err != nil {
return ctxerr.Wrap(ctx, err, "list users for activity enrichment")
}
// Enrich activities with user data
for _, user := range users {
entries, ok := lookup[user.ID]
if !ok {
continue
}
for _, idx := range entries {
activities[idx].ActorEmail = &user.Email
activities[idx].ActorGravatar = &user.Gravatar
activities[idx].ActorFullName = &user.Name
activities[idx].ActorAPIOnly = &user.APIOnly
}
}
return nil
}

View file

@ -0,0 +1,439 @@
package service
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/fleetdm/fleet/v4/server/activity"
"github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/activity/internal/types"
platform_authz "github.com/fleetdm/fleet/v4/server/platform/authz"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-kit/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Test fixtures
var (
johnUser = &activity.User{ID: 100, Name: "John Doe", Email: "john@example.com", Gravatar: "gravatar1", APIOnly: false}
janeUser = &activity.User{ID: 200, Name: "Jane Smith", Email: "jane@example.com", Gravatar: "gravatar2", APIOnly: true}
)
// Mock implementations
type mockAuthorizer struct {
authErr error
}
func (m *mockAuthorizer) Authorize(ctx context.Context, subject platform_authz.AuthzTyper, action platform_authz.Action) error {
return m.authErr
}
type mockDatastore struct {
activities []*api.Activity
meta *api.PaginationMetadata
err error
lastOpt types.ListOptions
}
func (m *mockDatastore) ListActivities(ctx context.Context, opt types.ListOptions) ([]*api.Activity, *api.PaginationMetadata, error) {
m.lastOpt = opt
return m.activities, m.meta, m.err
}
type mockUserProvider struct {
users []*activity.User
listUsersErr error
searchUserIDs []uint
searchErr error
lastIDs []uint
lastQuery string
}
func (m *mockUserProvider) UsersByIDs(ctx context.Context, ids []uint) ([]*activity.User, error) {
m.lastIDs = ids
return m.users, m.listUsersErr
}
func (m *mockUserProvider) FindUserIDs(ctx context.Context, query string) ([]uint, error) {
m.lastQuery = query
return m.searchUserIDs, m.searchErr
}
// testSetup holds test dependencies with pre-configured mocks
type testSetup struct {
svc *Service
authz *mockAuthorizer
ds *mockDatastore
users *mockUserProvider
}
// setupTest creates a service with default working mocks.
// Use functional options to customize mock behavior.
func setupTest(opts ...func(*testSetup)) *testSetup {
ts := &testSetup{
authz: &mockAuthorizer{},
ds: &mockDatastore{},
users: &mockUserProvider{},
}
for _, opt := range opts {
opt(ts)
}
ts.svc = NewService(ts.authz, ts.ds, ts.users, log.NewNopLogger())
return ts
}
// Setup options
func withAuthError(err error) func(*testSetup) {
return func(ts *testSetup) { ts.authz.authErr = err }
}
func withActivities(activities []*api.Activity) func(*testSetup) {
return func(ts *testSetup) { ts.ds.activities = activities }
}
func withMeta(meta *api.PaginationMetadata) func(*testSetup) {
return func(ts *testSetup) { ts.ds.meta = meta }
}
func withDatastoreError(err error) func(*testSetup) {
return func(ts *testSetup) { ts.ds.err = err }
}
func withUsers(users []*activity.User) func(*testSetup) {
return func(ts *testSetup) { ts.users.users = users }
}
func withUsersByIDsError(err error) func(*testSetup) {
return func(ts *testSetup) { ts.users.listUsersErr = err }
}
func withSearchUserIDs(ids []uint) func(*testSetup) {
return func(ts *testSetup) { ts.users.searchUserIDs = ids }
}
func withSearchError(err error) func(*testSetup) {
return func(ts *testSetup) { ts.users.searchErr = err }
}
func TestListActivitiesBasic(t *testing.T) {
t.Parallel()
ctx := t.Context()
details := json.RawMessage(`{"key": "value"}`)
ts := setupTest(
withActivities([]*api.Activity{
{ID: 1, Type: "test_activity", Details: &details},
{ID: 2, Type: "another_activity"},
}),
withMeta(&api.PaginationMetadata{HasNextResults: true}),
)
activities, meta, err := ts.svc.ListActivities(ctx, api.ListOptions{
PerPage: 10,
Page: 0,
})
require.NoError(t, err)
assert.Len(t, activities, 2)
assert.NotNil(t, meta)
assert.True(t, meta.HasNextResults)
// Verify options were passed correctly
assert.Equal(t, uint(10), ts.ds.lastOpt.PerPage)
assert.Equal(t, uint(0), ts.ds.lastOpt.Page)
}
func TestListActivitiesWithUserEnrichment(t *testing.T) {
t.Parallel()
ctx := t.Context()
ts := setupTest(
withActivities([]*api.Activity{
{ID: 1, Type: "test_activity", ActorID: ptr.Uint(johnUser.ID)},
{ID: 2, Type: "another_activity", ActorID: ptr.Uint(janeUser.ID)},
{ID: 3, Type: "system_activity"}, // No actor
}),
withUsers([]*activity.User{johnUser, janeUser}),
)
activities, meta, err := ts.svc.ListActivities(ctx, api.ListOptions{})
require.NoError(t, err)
assert.Len(t, activities, 3)
assert.Nil(t, meta)
// Verify user IDs were passed to UsersByIDs
assert.ElementsMatch(t, []uint{johnUser.ID, janeUser.ID}, ts.users.lastIDs)
// Verify activity 1 was enriched with John's data
assert.Equal(t, johnUser.Email, *activities[0].ActorEmail)
assert.Equal(t, johnUser.Gravatar, *activities[0].ActorGravatar)
assert.Equal(t, johnUser.Name, *activities[0].ActorFullName)
assert.Equal(t, johnUser.APIOnly, *activities[0].ActorAPIOnly)
// Verify activity 2 was enriched with Jane's data
assert.Equal(t, janeUser.Email, *activities[1].ActorEmail)
assert.Equal(t, janeUser.Gravatar, *activities[1].ActorGravatar)
assert.Equal(t, janeUser.Name, *activities[1].ActorFullName)
assert.Equal(t, janeUser.APIOnly, *activities[1].ActorAPIOnly)
// Verify activity 3 has no user enrichment (no actor)
assert.Nil(t, activities[2].ActorEmail)
assert.Nil(t, activities[2].ActorGravatar)
assert.Nil(t, activities[2].ActorFullName)
assert.Nil(t, activities[2].ActorAPIOnly)
}
// TestListActivitiesWithMatchQuery verifies that user search queries are properly
// translated into user ID filters for the datastore.
//
// When user searches for "john", the service:
// 1. Calls FindUserIDs("john") which returns matching user IDs (100, 200, 300)
// 2. Passes ALL matching user IDs to the datastore query
// 3. Datastore filters activities to those by any of the matching users
//
// Note: The search may return users who have no activities;
// the datastore simply won't find any activities for those user IDs.
func TestListActivitiesWithMatchQuery(t *testing.T) {
t.Parallel()
ctx := t.Context()
ts := setupTest(
withActivities([]*api.Activity{
{ID: 1, Type: "test_activity", ActorID: ptr.Uint(johnUser.ID)},
}),
withSearchUserIDs([]uint{100, 200, 300}), // 3 users match "john", but only user 100 has activities
withUsers([]*activity.User{johnUser}),
)
activities, meta, err := ts.svc.ListActivities(ctx, api.ListOptions{
MatchQuery: "john",
})
require.NoError(t, err)
// Verify the activity was returned and enriched
require.Len(t, activities, 1)
assert.Equal(t, uint(1), activities[0].ID)
assert.Equal(t, johnUser.Email, *activities[0].ActorEmail)
assert.Nil(t, meta, "metadata not configured in test setup")
// Verify FindUserIDs was called with the query
assert.Equal(t, "john", ts.users.lastQuery)
// Verify all matching user IDs were passed to datastore (even those without activities)
assert.ElementsMatch(t, []uint{100, 200, 300}, ts.ds.lastOpt.MatchingUserIDs)
}
// TestListActivitiesWithMatchQueryNoMatchingUsers verifies behavior when the user
// search returns no matching users. The empty user ID list should still be passed
// to the datastore, which will then return no activities (since no users matched).
func TestListActivitiesWithMatchQueryNoMatchingUsers(t *testing.T) {
t.Parallel()
ctx := t.Context()
ts := setupTest(
withActivities([]*api.Activity{}), // Datastore returns nothing when filtering by empty user list
withSearchUserIDs([]uint{}), // No users match "nonexistent"
)
activities, meta, err := ts.svc.ListActivities(ctx, api.ListOptions{
MatchQuery: "nonexistent",
})
require.NoError(t, err)
assert.Empty(t, activities)
assert.Nil(t, meta)
// Verify FindUserIDs was called
assert.Equal(t, "nonexistent", ts.users.lastQuery)
// Empty slice should be passed to datastore (not nil)
assert.NotNil(t, ts.ds.lastOpt.MatchingUserIDs)
assert.Empty(t, ts.ds.lastOpt.MatchingUserIDs)
}
func TestListActivitiesWithDuplicateUserIDs(t *testing.T) {
t.Parallel()
ctx := t.Context()
// Multiple activities by the same user
ts := setupTest(
withActivities([]*api.Activity{
{ID: 1, Type: "created_policy", ActorID: ptr.Uint(johnUser.ID)},
{ID: 2, Type: "deleted_policy", ActorID: ptr.Uint(johnUser.ID)},
{ID: 3, Type: "edited_policy", ActorID: ptr.Uint(johnUser.ID)},
}),
withUsers([]*activity.User{johnUser}),
)
activities, meta, err := ts.svc.ListActivities(ctx, api.ListOptions{})
require.NoError(t, err)
assert.Len(t, activities, 3)
assert.Nil(t, meta)
// UsersByIDs should only be called with unique IDs (deduplication)
assert.Equal(t, []uint{johnUser.ID}, ts.users.lastIDs)
// All activities should be enriched with John's data
for i, a := range activities {
require.NotNil(t, a.ActorEmail, "activity %d should have ActorEmail", i)
assert.Equal(t, johnUser.Email, *a.ActorEmail)
assert.Equal(t, johnUser.Name, *a.ActorFullName)
}
}
func TestListActivitiesCursorPaginationMetadata(t *testing.T) {
t.Parallel()
ctx := t.Context()
ts := setupTest(
withActivities([]*api.Activity{{ID: 1}}),
withMeta(&api.PaginationMetadata{HasNextResults: true}),
)
// Without cursor (After="") - should include metadata
_, meta, err := ts.svc.ListActivities(ctx, api.ListOptions{
PerPage: 10,
})
require.NoError(t, err)
assert.True(t, ts.ds.lastOpt.IncludeMetadata, "should include metadata when After is empty")
assert.NotNil(t, meta)
// With cursor (After="123") - should not include metadata
_, _, err = ts.svc.ListActivities(ctx, api.ListOptions{
PerPage: 10,
After: "123",
})
require.NoError(t, err)
assert.False(t, ts.ds.lastOpt.IncludeMetadata, "should not include metadata when After is set")
}
// TestListActivitiesErrors tests hard-fail error scenarios (authorization denied, datastore errors).
func TestListActivitiesErrors(t *testing.T) {
t.Parallel()
cases := []struct {
name string
opts []func(*testSetup)
listOpts api.ListOptions
errContains string
}{
{
name: "authorization denied",
opts: []func(*testSetup){withAuthError(errors.New("forbidden"))},
errContains: "forbidden",
},
{
name: "datastore error",
opts: []func(*testSetup){withDatastoreError(errors.New("database error"))},
errContains: "database error",
},
{
name: "user enrichment error",
opts: []func(*testSetup){
withActivities([]*api.Activity{
{ID: 1, Type: "test_activity", ActorID: ptr.Uint(100)},
}),
withUsersByIDsError(errors.New("user service error")),
},
errContains: "user service error",
},
{
name: "search users error",
opts: []func(*testSetup){
withActivities([]*api.Activity{
{ID: 1, Type: "test_activity"},
}),
withSearchError(errors.New("search error")),
},
listOpts: api.ListOptions{MatchQuery: "john"},
errContains: "search error",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := t.Context()
ts := setupTest(tc.opts...)
activities, meta, err := ts.svc.ListActivities(ctx, tc.listOpts)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.errContains)
assert.Nil(t, activities)
assert.Nil(t, meta)
})
}
}
// forbiddenError is a mock error that implements platform_authz.Forbidden for testing.
type forbiddenError struct {
msg string
}
func (e forbiddenError) Error() string { return e.msg }
func (e forbiddenError) Forbidden() {}
// TestListActivitiesGracefulDegradation verifies that forbidden errors during
// user enrichment and search are handled gracefully (logged but not returned to caller).
// Non-forbidden errors are tested in TestListActivitiesErrors.
func TestListActivitiesGracefulDegradation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
opts []func(*testSetup)
listOpts api.ListOptions
expectedCount int
expectUserEnrichment bool
expectMatchingUserIDs bool
}{
{
name: "forbidden error on user enrichment returns activities without enrichment",
opts: []func(*testSetup){
withActivities([]*api.Activity{
{ID: 1, Type: "test_activity", ActorID: ptr.Uint(100)},
}),
withUsersByIDsError(forbiddenError{msg: "forbidden"}),
},
expectedCount: 1,
expectUserEnrichment: false,
},
{
name: "forbidden error on user search returns activities without user filter",
opts: []func(*testSetup){
withActivities([]*api.Activity{
{ID: 1, Type: "test_activity"},
{ID: 2, Type: "another_activity"},
}),
withSearchError(forbiddenError{msg: "forbidden"}),
},
listOpts: api.ListOptions{MatchQuery: "john"},
expectedCount: 2,
expectMatchingUserIDs: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := t.Context()
ts := setupTest(tc.opts...)
activities, meta, err := ts.svc.ListActivities(ctx, tc.listOpts)
require.NoError(t, err)
require.Len(t, activities, tc.expectedCount)
assert.Nil(t, meta)
if !tc.expectUserEnrichment && len(activities) > 0 && activities[0].ActorID != nil {
assert.Nil(t, activities[0].ActorEmail)
assert.Nil(t, activities[0].ActorFullName)
}
if tc.listOpts.MatchQuery != "" && !tc.expectMatchingUserIDs {
assert.Nil(t, ts.ds.lastOpt.MatchingUserIDs)
}
})
}
}

View file

@ -0,0 +1,167 @@
package tests
import (
"net/http"
"strconv"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIntegration(t *testing.T) {
s := setupIntegrationTest(t)
cases := []struct {
name string
fn func(t *testing.T, s *integrationTestSuite)
}{
{"ListActivities", testListActivities},
{"ListActivitiesPagination", testListActivitiesPagination},
{"ListActivitiesCursorPagination", testListActivitiesCursorPagination},
{"ListActivitiesFilters", testListActivitiesFilters},
{"ListActivitiesUserEnrichment", testListActivitiesUserEnrichment},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer s.truncateTables(t)
c.fn(t, s)
})
}
}
func testListActivities(t *testing.T, s *integrationTestSuite) {
userID := s.insertUser(t, "admin", "admin@example.com")
// Insert activities
s.InsertActivity(t, ptr.Uint(userID), "applied_spec_pack", map[string]any{})
s.InsertActivity(t, ptr.Uint(userID), "deleted_pack", map[string]any{})
s.InsertActivity(t, ptr.Uint(userID), "edited_pack", map[string]any{})
result, statusCode := s.getActivities(t, "per_page=100")
assert.Equal(t, http.StatusOK, statusCode)
assert.Len(t, result.Activities, 3)
assert.NotNil(t, result.Meta)
// Verify order (newest first by default)
assert.Equal(t, "edited_pack", result.Activities[0].Type)
assert.Equal(t, "deleted_pack", result.Activities[1].Type)
assert.Equal(t, "applied_spec_pack", result.Activities[2].Type)
}
func testListActivitiesPagination(t *testing.T, s *integrationTestSuite) {
userID := s.insertUser(t, "admin", "admin@example.com")
// Insert 5 activities
for i := range 5 {
s.InsertActivity(t, ptr.Uint(userID), "test_activity", map[string]any{"index": i})
}
// First page
result, _ := s.getActivities(t, "per_page=2&order_key=id&order_direction=asc")
assert.Len(t, result.Activities, 2)
assert.True(t, result.Meta.HasNextResults)
assert.False(t, result.Meta.HasPreviousResults)
// Second page
result, _ = s.getActivities(t, "per_page=2&page=1&order_key=id&order_direction=asc")
assert.Len(t, result.Activities, 2)
assert.True(t, result.Meta.HasNextResults)
assert.True(t, result.Meta.HasPreviousResults)
// Last page
result, _ = s.getActivities(t, "per_page=2&page=2&order_key=id&order_direction=asc")
assert.Len(t, result.Activities, 1)
assert.False(t, result.Meta.HasNextResults)
assert.True(t, result.Meta.HasPreviousResults)
}
func testListActivitiesCursorPagination(t *testing.T, s *integrationTestSuite) {
userID := s.insertUser(t, "admin", "admin@example.com")
// Insert 3 activities
s.InsertActivity(t, ptr.Uint(userID), "applied_spec_pack", map[string]any{})
s.InsertActivity(t, ptr.Uint(userID), "deleted_pack", map[string]any{})
s.InsertActivity(t, ptr.Uint(userID), "edited_pack", map[string]any{})
// Test cursor-based pagination with after=0
// Meta should be nil for cursor-based pagination (doesn't return metadata)
result, statusCode := s.getActivities(t, "per_page=1&order_key=id&after=0")
assert.Equal(t, http.StatusOK, statusCode)
assert.Len(t, result.Activities, 1)
assert.Nil(t, result.Meta)
assert.Equal(t, "applied_spec_pack", result.Activities[0].Type)
// Test cursor pagination to get the next activity
firstID := result.Activities[0].ID
result, _ = s.getActivities(t, "per_page=1&order_key=id&after="+strconv.FormatUint(uint64(firstID), 10))
assert.Len(t, result.Activities, 1)
assert.Nil(t, result.Meta)
assert.Equal(t, "deleted_pack", result.Activities[0].Type)
// Test descending order with cursor
result, _ = s.getActivities(t, "per_page=1&order_key=id&order_direction=desc&after=999999")
assert.Len(t, result.Activities, 1)
assert.Nil(t, result.Meta)
// Descending order, so the newest (edited_pack) should be first
assert.Equal(t, "edited_pack", result.Activities[0].Type)
}
func testListActivitiesFilters(t *testing.T, s *integrationTestSuite) {
johnUserID := s.insertUser(t, "john_doe", "john@example.com")
janeUserID := s.insertUser(t, "jane_smith", "jane@example.com")
now := time.Now().UTC().Truncate(time.Second)
// Insert activities with different types, times, and users
s.InsertActivityWithTime(t, ptr.Uint(johnUserID), "type_a", map[string]any{}, now.Add(-48*time.Hour))
s.InsertActivityWithTime(t, ptr.Uint(johnUserID), "type_a", map[string]any{}, now.Add(-24*time.Hour))
s.InsertActivityWithTime(t, ptr.Uint(johnUserID), "type_b", map[string]any{}, now)
s.InsertActivityWithTime(t, ptr.Uint(janeUserID), "type_a", map[string]any{}, now) // Jane's activity
// Filter by type
result, _ := s.getActivities(t, "per_page=100&activity_type=type_a")
assert.Len(t, result.Activities, 3) // 2 from john + 1 from jane
for _, a := range result.Activities {
assert.Equal(t, "type_a", a.Type)
}
// Filter by date range
startDate := now.Add(-36 * time.Hour).Format(time.RFC3339)
result, _ = s.getActivities(t, "per_page=100&start_created_at="+startDate)
assert.Len(t, result.Activities, 3) // -24h, now (john), now (jane)
// Filter by user search query - should only return john's activities
result, _ = s.getActivities(t, "per_page=100&query=john")
assert.Len(t, result.Activities, 3) // Only john's 3 activities, not jane's
for _, a := range result.Activities {
require.NotNil(t, a.ActorID)
assert.Equal(t, johnUserID, *a.ActorID)
}
// Filter by user search query - should only return jane's activities
result, _ = s.getActivities(t, "per_page=100&query=jane")
assert.Len(t, result.Activities, 1) // Only jane's 1 activity
require.NotNil(t, result.Activities[0].ActorID)
assert.Equal(t, janeUserID, *result.Activities[0].ActorID)
}
func testListActivitiesUserEnrichment(t *testing.T, s *integrationTestSuite) {
userID := s.insertUser(t, "John Doe", "john@example.com")
s.InsertActivity(t, ptr.Uint(userID), "test_activity", map[string]any{})
result, _ := s.getActivities(t, "per_page=100")
require.Len(t, result.Activities, 1)
// Verify user enrichment from mock user provider
a := result.Activities[0]
assert.NotNil(t, a.ActorID)
assert.Equal(t, userID, *a.ActorID)
assert.NotNil(t, a.ActorFullName)
assert.Equal(t, "John Doe", *a.ActorFullName)
assert.NotNil(t, a.ActorEmail)
assert.Equal(t, "john@example.com", *a.ActorEmail)
}

View file

@ -0,0 +1,56 @@
package tests
import (
"context"
"strings"
"github.com/fleetdm/fleet/v4/server/activity"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
platform_authz "github.com/fleetdm/fleet/v4/server/platform/authz"
)
// Mock implementations for dependencies outside the bounded context
type mockAuthorizer struct{}
func (m *mockAuthorizer) Authorize(ctx context.Context, subject platform_authz.AuthzTyper, action platform_authz.Action) error {
// Mark authorization as checked (like the real authorizer does)
if authzCtx, ok := authz_ctx.FromContext(ctx); ok {
authzCtx.SetChecked()
}
return nil // Allow all for integration tests
}
type mockUserProvider struct {
users map[uint]*activity.User
}
func newMockUserProvider() *mockUserProvider {
return &mockUserProvider{users: make(map[uint]*activity.User)}
}
func (m *mockUserProvider) AddUser(u *activity.User) {
m.users[u.ID] = u
}
func (m *mockUserProvider) UsersByIDs(ctx context.Context, ids []uint) ([]*activity.User, error) {
var result []*activity.User
for _, id := range ids {
if u, ok := m.users[id]; ok {
result = append(result, u)
}
}
return result, nil
}
func (m *mockUserProvider) FindUserIDs(ctx context.Context, query string) ([]uint, error) {
query = strings.ToLower(query)
var ids []uint
for _, u := range m.users {
if strings.Contains(strings.ToLower(u.Name), query) ||
strings.Contains(strings.ToLower(u.Email), query) {
ids = append(ids, u.ID)
}
}
return ids, nil
}

View file

@ -0,0 +1,99 @@
package tests
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/fleetdm/fleet/v4/server/activity"
api_http "github.com/fleetdm/fleet/v4/server/activity/api/http"
"github.com/fleetdm/fleet/v4/server/activity/internal/mysql"
"github.com/fleetdm/fleet/v4/server/activity/internal/service"
"github.com/fleetdm/fleet/v4/server/activity/internal/testutils"
"github.com/go-kit/kit/endpoint"
"github.com/gorilla/mux"
"github.com/stretchr/testify/require"
)
// integrationTestSuite holds all dependencies for integration tests.
type integrationTestSuite struct {
*testutils.TestDB
ds *mysql.Datastore
server *httptest.Server
userProvider *mockUserProvider
}
// setupIntegrationTest creates a new test suite with a real database and HTTP server.
func setupIntegrationTest(t *testing.T) *integrationTestSuite {
t.Helper()
tdb := testutils.SetupTestDB(t, "activity_integration")
ds := mysql.NewDatastore(tdb.Conns(), tdb.Logger)
// Create mocks
authorizer := &mockAuthorizer{}
userProvider := newMockUserProvider()
// Create service
svc := service.NewService(authorizer, ds, userProvider, tdb.Logger)
// Create router with routes
router := mux.NewRouter()
// Pass-through auth middleware (authzcheck middleware handles creating the authz context)
authMiddleware := func(e endpoint.Endpoint) endpoint.Endpoint { return e }
routesFn := service.GetRoutes(svc, authMiddleware)
routesFn(router, nil)
// Create test server
server := httptest.NewServer(router)
t.Cleanup(func() {
server.Close()
})
return &integrationTestSuite{
TestDB: tdb,
ds: ds,
server: server,
userProvider: userProvider,
}
}
// truncateTables clears all test data between tests.
func (s *integrationTestSuite) truncateTables(t *testing.T) {
t.Helper()
s.TruncateTables(t)
}
// insertUser creates a user in the database and mock user provider.
func (s *integrationTestSuite) insertUser(t *testing.T, name, email string) uint {
t.Helper()
userID := s.TestDB.InsertUser(t, name, email)
// Also add to mock user provider for enrichment
s.userProvider.AddUser(&activity.User{
ID: userID,
Name: name,
Email: email,
})
return userID
}
// getActivities makes an HTTP request to list activities and returns the parsed response.
func (s *integrationTestSuite) getActivities(t *testing.T, queryParams string) (*api_http.ListActivitiesResponse, int) {
t.Helper()
url := s.server.URL + "/api/v1/fleet/activities"
if queryParams != "" {
url += "?" + queryParams
}
resp, err := http.Get(url) //nolint:gosec // test server URL is safe
require.NoError(t, err)
var result api_http.ListActivitiesResponse
err = json.NewDecoder(resp.Body).Decode(&result)
resp.Body.Close()
require.NoError(t, err)
return &result, resp.StatusCode
}

View file

@ -0,0 +1,113 @@
// Package testutils provides shared test utilities for the activity bounded context.
package testutils
import (
"encoding/json"
"path/filepath"
"runtime"
"testing"
"time"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
mysql_testing_utils "github.com/fleetdm/fleet/v4/server/platform/mysql/testing_utils"
"github.com/go-kit/log"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)
// TestDB holds the database connection for tests.
type TestDB struct {
DB *sqlx.DB
Logger log.Logger
}
// SetupTestDB creates a test database with the Fleet schema loaded.
func SetupTestDB(t *testing.T, testNamePrefix string) *TestDB {
t.Helper()
testName, opts := mysql_testing_utils.ProcessOptions(t, &mysql_testing_utils.DatastoreTestOptions{
UniqueTestName: testNamePrefix + "_" + t.Name(),
})
_, thisFile, _, _ := runtime.Caller(0)
schemaPath := filepath.Join(filepath.Dir(thisFile), "../../../../server/datastore/mysql/schema.sql")
mysql_testing_utils.LoadSchema(t, testName, opts, schemaPath)
config := mysql_testing_utils.MysqlTestConfig(testName)
db, err := common_mysql.NewDB(config, &common_mysql.DBOptions{}, "")
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
return &TestDB{
DB: db,
Logger: log.NewNopLogger(),
}
}
// Conns returns DBConnections for creating a datastore.
func (tdb *TestDB) Conns() *common_mysql.DBConnections {
return &common_mysql.DBConnections{Primary: tdb.DB, Replica: tdb.DB}
}
// TruncateTables clears the activities and users tables.
func (tdb *TestDB) TruncateTables(t *testing.T) {
t.Helper()
mysql_testing_utils.TruncateTables(t, tdb.DB, tdb.Logger, nil, "activities", "users")
}
// InsertUser creates a user in the database and returns the user ID.
func (tdb *TestDB) InsertUser(t *testing.T, name, email string) uint {
t.Helper()
ctx := t.Context()
result, err := tdb.DB.ExecContext(ctx, `
INSERT INTO users (name, email, password, salt, created_at, updated_at)
VALUES (?, ?, 'password', 'salt', NOW(), NOW())
`, name, email)
require.NoError(t, err)
id, err := result.LastInsertId()
require.NoError(t, err)
return uint(id)
}
// InsertActivity creates an activity in the database and returns the activity ID.
// Pass nil for userID to create a system activity (no associated user).
func (tdb *TestDB) InsertActivity(t *testing.T, userID *uint, activityType string, details map[string]any) uint {
t.Helper()
return tdb.InsertActivityWithTime(t, userID, activityType, details, time.Now().UTC())
}
// InsertActivityWithTime creates an activity with a specific timestamp.
// Pass nil for userID to create a system activity (no associated user).
func (tdb *TestDB) InsertActivityWithTime(t *testing.T, userID *uint, activityType string, details map[string]any, createdAt time.Time) uint {
t.Helper()
ctx := t.Context()
detailsJSON, err := json.Marshal(details)
require.NoError(t, err)
var userName *string
var userEmail string // NOT NULL DEFAULT '' in schema
if userID != nil {
var user struct {
Name string `db:"name"`
Email string `db:"email"`
}
err = sqlx.GetContext(ctx, tdb.DB, &user, "SELECT name, email FROM users WHERE id = ?", *userID)
require.NoError(t, err)
userName = &user.Name
userEmail = user.Email
}
result, err := tdb.DB.ExecContext(ctx, `
INSERT INTO activities (user_id, user_name, user_email, activity_type, details, created_at, host_only, streamed)
VALUES (?, ?, ?, ?, ?, ?, false, false)
`, userID, userName, userEmail, activityType, detailsJSON, createdAt)
require.NoError(t, err)
id, err := result.LastInsertId()
require.NoError(t, err)
return uint(id)
}

View file

@ -0,0 +1,46 @@
// Package types defines internal interfaces for the activity bounded context.
package types
import (
"context"
"github.com/fleetdm/fleet/v4/server/activity/api"
)
// ListOptions extends api.ListOptions with internal fields used by the datastore.
type ListOptions struct {
api.ListOptions
// Internal fields: set programmatically by service, not from query params
IncludeMetadata bool
MatchingUserIDs []uint // User IDs matching MatchQuery (populated by service via ACL)
}
// GetPage returns the page number for pagination.
func (o *ListOptions) GetPage() uint { return o.Page }
// GetPerPage returns the number of items per page.
func (o *ListOptions) GetPerPage() uint { return o.PerPage }
// GetOrderKey returns the field to order by.
func (o *ListOptions) GetOrderKey() string { return o.OrderKey }
// IsDescending returns true if the order direction is descending.
func (o *ListOptions) IsDescending() bool { return o.OrderDirection == api.OrderDesc }
// GetCursorValue returns the cursor value for cursor-based pagination.
func (o *ListOptions) GetCursorValue() string { return o.After }
// WantsPaginationInfo returns true if pagination metadata should be included.
func (o *ListOptions) WantsPaginationInfo() bool { return o.IncludeMetadata }
// GetSecondaryOrderKey returns the secondary order key (not used for activities).
func (o *ListOptions) GetSecondaryOrderKey() string { return "" }
// IsSecondaryDescending returns true if the secondary order is descending (not used for activities).
func (o *ListOptions) IsSecondaryDescending() bool { return false }
// Datastore is the datastore interface for the activity bounded context.
type Datastore interface {
ListActivities(ctx context.Context, opt ListOptions) ([]*api.Activity, *api.PaginationMetadata, error)
}

25
server/activity/user.go Normal file
View file

@ -0,0 +1,25 @@
// Package activity is the root package for the activity bounded context.
// It contains public types that need to be shared with the ACL layer.
package activity
import "context"
// User represents minimal user info needed by the activity context.
// Populated via ACL from the legacy user service.
type User struct {
ID uint
Name string
Email string
Gravatar string
APIOnly bool
}
// UserProvider is the interface for fetching user data.
// Implemented by the ACL adapter that calls the Fleet service.
type UserProvider interface {
// UsersByIDs returns users for the given IDs (for enriching activities).
UsersByIDs(ctx context.Context, ids []uint) ([]*User, error)
// FindUserIDs searches for users by name/email prefix and returns their IDs.
// Used for the `query` parameter search functionality.
FindUserIDs(ctx context.Context, query string) ([]uint, error)
}

View file

@ -17,6 +17,7 @@ import (
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
platform_authz "github.com/fleetdm/fleet/v4/server/platform/authz"
"github.com/open-policy-agent/opa/rego"
)
@ -131,14 +132,6 @@ func (a *Authorizer) Authorize(ctx context.Context, object, action interface{})
return nil
}
// AuthzTyper is the interface that may be implemented to get a `type`
// property added during marshaling for authorization. Any struct that will be
// used as a subject or object in authorization should implement this interface.
type AuthzTyper interface {
// AuthzType returns the type as a snake_case string.
AuthzType() string
}
// ExtraAuthzer is the interface to implement extra fields for the policy.
type ExtraAuthzer interface {
// ExtraAuthz returns the extra key/value pairs for the type.
@ -173,7 +166,7 @@ func jsonToInterface(in interface{}) (interface{}, error) {
}
// Add the `type` property if the AuthzTyper interface is implemented.
if typer, ok := in.(AuthzTyper); ok {
if typer, ok := in.(platform_authz.AuthzTyper); ok {
out["type"] = typer.AuthzType()
}
// Add any extra key/values defined by the type.
@ -202,3 +195,19 @@ func UserFromContext(ctx context.Context) *fleet.User {
}
return vc.User
}
// AuthorizerAdapter adapts the legacy Authorizer to the platform_authz.Authorizer interface.
// This provides stronger typing via AuthzTyper (instead of `any`) while reusing the existing OPA-based authorization.
type AuthorizerAdapter struct {
authorizer *Authorizer
}
// NewAuthorizerAdapter creates an adapter that wraps the legacy Authorizer.
func NewAuthorizerAdapter(authorizer *Authorizer) *AuthorizerAdapter {
return &AuthorizerAdapter{authorizer: authorizer}
}
// Authorize implements platform_authz.Authorizer.
func (a *AuthorizerAdapter) Authorize(ctx context.Context, subject platform_authz.AuthzTyper, action platform_authz.Action) error {
return a.authorizer.Authorize(ctx, subject, string(action))
}

View file

@ -46,6 +46,9 @@ func (e *Forbidden) StatusCode() int {
return http.StatusForbidden
}
// Forbidden implements platform_authz.Forbidden interface.
func (e *Forbidden) Forbidden() {}
// Internal allows the internal error message to be logged.
func (e *Forbidden) Internal() string {
return e.internal

View file

@ -7,6 +7,7 @@ import (
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
platform_authz "github.com/fleetdm/fleet/v4/server/platform/authz"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/stretchr/testify/assert"
@ -2045,7 +2046,7 @@ func runTestCasesGroups(t *testing.T, testCaseGroups []tcGroup) {
}
obj := fmt.Sprintf("%T", tt.object)
if at, ok := tt.object.(AuthzTyper); ok {
if at, ok := tt.object.(platform_authz.AuthzTyper); ok {
obj = at.AuthzType()
}

View file

@ -409,13 +409,29 @@ func CreateMySQLDS(t testing.TB) *Datastore {
}
func CreateNamedMySQLDS(t *testing.T, name string) *Datastore {
ds, _ := CreateNamedMySQLDSWithConns(t, name)
return ds
}
// CreateNamedMySQLDSWithConns creates a MySQL datastore and returns both the datastore
// and the underlying database connections. This matches the production flow where
// DBConnections are created first and shared across datastores.
func CreateNamedMySQLDSWithConns(t *testing.T, name string) (*Datastore, *common_mysql.DBConnections) {
if _, ok := os.LookupEnv("MYSQL_TEST"); !ok {
t.Skip("MySQL tests are disabled")
}
ds := initializeDatabase(t, name, new(testing_utils.DatastoreTestOptions))
t.Cleanup(func() { ds.Close() })
return ds
replica, ok := ds.replica.(*sqlx.DB)
require.True(t, ok, "ds.replica should be *sqlx.DB in tests")
dbConns := &common_mysql.DBConnections{
Primary: ds.primary,
Replica: replica,
}
return ds, dbConns
}
func ExecAdhocSQL(tb testing.TB, ds *Datastore, fn func(q sqlx.ExtContext) error) {

View file

@ -16,6 +16,16 @@ import (
var userSearchColumns = []string{"name", "email"}
// userSelectColumns contains everything except `settings`. Since we only want to include
// user settings on an opt-in basis from the API perspective (see `include_ui_settings`
// query param on `GET` `/me` and `GET` `/users/:id`), excluding it here ensures it's only
// included in API responses when explicitly coded to be, via calling the dedicated
// UserSettings method. Otherwise, `settings` would be included in `user` objects in
// various places, which we do not want.
const userSelectColumns = `id, created_at, updated_at, password, salt, name, email,
admin_forced_password_reset, gravatar_url, position, sso_enabled, global_role,
api_only, mfa_enabled, invite_id`
// NewUser creates a new user
func (ds *Datastore) NewUser(ctx context.Context, user *fleet.User) (*fleet.User, error) {
if err := fleet.ValidateRole(user.GlobalRole, user.Teams); err != nil {
@ -79,13 +89,8 @@ func (ds *Datastore) NewUser(ctx context.Context, user *fleet.User) (*fleet.User
func (ds *Datastore) findUser(ctx context.Context, searchCol string, searchVal interface{}) (*fleet.User, error) {
sqlStatement := fmt.Sprintf(
// everything except `settings`. Since we only want to include user settings on an opt-in basis
// from the API perspective (see `include_ui_settings` query param on `GET` `/me` and `GET` `/users/:id`), excluding it here ensures it's only included in API responses
// when explicitly coded to be, via calling the dedicated UserSettings method. Otherwise,
// `settings` would be included in `user` objects in various places, which we do not want.
"SELECT id, created_at, updated_at, password, salt, name, email, admin_forced_password_reset, gravatar_url, position, sso_enabled, global_role, api_only, mfa_enabled, invite_id FROM users "+
"WHERE %s = ? LIMIT 1",
searchCol,
"SELECT %s FROM users WHERE %s = ? LIMIT 1",
userSelectColumns, searchCol,
)
user := &fleet.User{}
@ -153,6 +158,28 @@ func (ds *Datastore) ListUsers(ctx context.Context, opt fleet.UserListOptions) (
return users, nil
}
// UsersByIDs returns users matching the provided IDs.
// Note: This method does NOT load team memberships. It's currently only used
// by the activity bounded context for user enrichment, which doesn't need teams.
func (ds *Datastore) UsersByIDs(ctx context.Context, ids []uint) ([]*fleet.User, error) {
if len(ids) == 0 {
return nil, nil
}
query, args, err := sqlx.In(
fmt.Sprintf("SELECT %s FROM users WHERE id IN (?)", userSelectColumns), ids)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build users by IDs query")
}
var users []*fleet.User
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &users, query, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "select users by IDs")
}
return users, nil
}
func (ds *Datastore) UserByEmail(ctx context.Context, email string) (*fleet.User, error) {
return ds.findUser(ctx, "email", email)
}

View file

@ -61,6 +61,9 @@ type Datastore interface {
// HasUsers returns whether Fleet has any users registered
HasUsers(ctx context.Context) (bool, error)
ListUsers(ctx context.Context, opt UserListOptions) ([]*User, error)
// UsersByIDs returns users matching the provided IDs. This is more efficient
// than ListUsers when you only need specific users by their IDs.
UsersByIDs(ctx context.Context, ids []uint) ([]*User, error)
UserByEmail(ctx context.Context, email string) (*User, error)
UserByID(ctx context.Context, id uint) (*User, error)
UserOrDeletedUserByID(ctx context.Context, id uint) (*User, error)

View file

@ -73,8 +73,18 @@ type OsqueryService interface {
YaraRuleByName(ctx context.Context, name string) (*YaraRule, error)
}
// UserLookupService provides methods for looking up users.
// This interface is extracted for use by components that only need user lookup capabilities.
type UserLookupService interface {
// ListUsers returns all users.
ListUsers(ctx context.Context, opt UserListOptions) (users []*User, err error)
// UsersByIDs returns users matching the provided IDs.
UsersByIDs(ctx context.Context, ids []uint) ([]*User, error)
}
type Service interface {
OsqueryService
UserLookupService
// GetTransparencyURL gets the URL to redirect to when an end user clicks About Fleet
GetTransparencyURL(ctx context.Context) (string, error)
@ -136,9 +146,6 @@ type Service interface {
// AuthenticatedUser returns the current user from the viewer context.
AuthenticatedUser(ctx context.Context) (user *User, err error)
// ListUsers returns all users.
ListUsers(ctx context.Context, opt UserListOptions) (users []*User, err error)
// ChangePassword validates the existing password, and sets the new password. User is retrieved from the viewer
// context.
ChangePassword(ctx context.Context, oldPass, newPass string) error
@ -614,12 +621,6 @@ type Service interface {
// logins, running a live query, etc.
NewActivity(ctx context.Context, user *User, activity ActivityDetails) error
// ListActivities lists the activities stored in the datastore.
//
// What we call "Activities" are administrative operations,
// logins, running a live query, etc.
ListActivities(ctx context.Context, opt ListActivitiesOptions) ([]*Activity, *PaginationMetadata, error)
// ListHostUpcomingActivities lists the upcoming activities for the specified
// host. Those are activities that are queued or scheduled to run on the host
// but haven't run yet. It also returns the total (unpaginated) count of upcoming

View file

@ -67,6 +67,8 @@ type HasUsersFunc func(ctx context.Context) (bool, error)
type ListUsersFunc func(ctx context.Context, opt fleet.UserListOptions) ([]*fleet.User, error)
type UsersByIDsFunc func(ctx context.Context, ids []uint) ([]*fleet.User, error)
type UserByEmailFunc func(ctx context.Context, email string) (*fleet.User, error)
type UserByIDFunc func(ctx context.Context, id uint) (*fleet.User, error)
@ -1812,6 +1814,9 @@ type DataStore struct {
ListUsersFunc ListUsersFunc
ListUsersFuncInvoked bool
UsersByIDsFunc UsersByIDsFunc
UsersByIDsFuncInvoked bool
UserByEmailFunc UserByEmailFunc
UserByEmailFuncInvoked bool
@ -4486,6 +4491,13 @@ func (s *DataStore) ListUsers(ctx context.Context, opt fleet.UserListOptions) ([
return s.ListUsersFunc(ctx, opt)
}
func (s *DataStore) UsersByIDs(ctx context.Context, ids []uint) ([]*fleet.User, error) {
s.mu.Lock()
s.UsersByIDsFuncInvoked = true
s.mu.Unlock()
return s.UsersByIDsFunc(ctx, ids)
}
func (s *DataStore) UserByEmail(ctx context.Context, email string) (*fleet.User, error) {
s.mu.Lock()
s.UserByEmailFuncInvoked = true

View file

@ -35,6 +35,10 @@ type SubmitResultLogsFunc func(ctx context.Context, logs []json.RawMessage) (err
type YaraRuleByNameFunc func(ctx context.Context, name string) (*fleet.YaraRule, error)
type ListUsersFunc func(ctx context.Context, opt fleet.UserListOptions) (users []*fleet.User, err error)
type UsersByIDsFunc func(ctx context.Context, ids []uint) ([]*fleet.User, error)
type GetTransparencyURLFunc func(ctx context.Context) (string, error)
type AuthenticateOrbitHostFunc func(ctx context.Context, nodeKey string) (host *fleet.Host, debug bool, err error)
@ -65,8 +69,6 @@ type UserUnauthorizedFunc func(ctx context.Context, id uint) (user *fleet.User,
type AuthenticatedUserFunc func(ctx context.Context) (user *fleet.User, err error)
type ListUsersFunc func(ctx context.Context, opt fleet.UserListOptions) (users []*fleet.User, err error)
type ChangePasswordFunc func(ctx context.Context, oldPass string, newPass string) error
type RequestPasswordResetFunc func(ctx context.Context, email string) (err error)
@ -383,8 +385,6 @@ type ApplyTeamSpecsFunc func(ctx context.Context, specs []*fleet.TeamSpec, apply
type NewActivityFunc func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error
type ListActivitiesFunc func(ctx context.Context, opt fleet.ListActivitiesOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error)
type ListHostUpcomingActivitiesFunc func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.UpcomingActivity, *fleet.PaginationMetadata, error)
type ListHostPastActivitiesFunc func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error)
@ -900,6 +900,12 @@ type Service struct {
YaraRuleByNameFunc YaraRuleByNameFunc
YaraRuleByNameFuncInvoked bool
ListUsersFunc ListUsersFunc
ListUsersFuncInvoked bool
UsersByIDsFunc UsersByIDsFunc
UsersByIDsFuncInvoked bool
GetTransparencyURLFunc GetTransparencyURLFunc
GetTransparencyURLFuncInvoked bool
@ -945,9 +951,6 @@ type Service struct {
AuthenticatedUserFunc AuthenticatedUserFunc
AuthenticatedUserFuncInvoked bool
ListUsersFunc ListUsersFunc
ListUsersFuncInvoked bool
ChangePasswordFunc ChangePasswordFunc
ChangePasswordFuncInvoked bool
@ -1422,9 +1425,6 @@ type Service struct {
NewActivityFunc NewActivityFunc
NewActivityFuncInvoked bool
ListActivitiesFunc ListActivitiesFunc
ListActivitiesFuncInvoked bool
ListHostUpcomingActivitiesFunc ListHostUpcomingActivitiesFunc
ListHostUpcomingActivitiesFuncInvoked bool
@ -2219,6 +2219,20 @@ func (s *Service) YaraRuleByName(ctx context.Context, name string) (*fleet.YaraR
return s.YaraRuleByNameFunc(ctx, name)
}
func (s *Service) ListUsers(ctx context.Context, opt fleet.UserListOptions) (users []*fleet.User, err error) {
s.mu.Lock()
s.ListUsersFuncInvoked = true
s.mu.Unlock()
return s.ListUsersFunc(ctx, opt)
}
func (s *Service) UsersByIDs(ctx context.Context, ids []uint) ([]*fleet.User, error) {
s.mu.Lock()
s.UsersByIDsFuncInvoked = true
s.mu.Unlock()
return s.UsersByIDsFunc(ctx, ids)
}
func (s *Service) GetTransparencyURL(ctx context.Context) (string, error) {
s.mu.Lock()
s.GetTransparencyURLFuncInvoked = true
@ -2324,13 +2338,6 @@ func (s *Service) AuthenticatedUser(ctx context.Context) (user *fleet.User, err
return s.AuthenticatedUserFunc(ctx)
}
func (s *Service) ListUsers(ctx context.Context, opt fleet.UserListOptions) (users []*fleet.User, err error) {
s.mu.Lock()
s.ListUsersFuncInvoked = true
s.mu.Unlock()
return s.ListUsersFunc(ctx, opt)
}
func (s *Service) ChangePassword(ctx context.Context, oldPass string, newPass string) error {
s.mu.Lock()
s.ChangePasswordFuncInvoked = true
@ -3437,13 +3444,6 @@ func (s *Service) NewActivity(ctx context.Context, user *fleet.User, activity fl
return s.NewActivityFunc(ctx, user, activity)
}
func (s *Service) ListActivities(ctx context.Context, opt fleet.ListActivitiesOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) {
s.mu.Lock()
s.ListActivitiesFuncInvoked = true
s.mu.Unlock()
return s.ListActivitiesFunc(ctx, opt)
}
func (s *Service) ListHostUpcomingActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.UpcomingActivity, *fleet.PaginationMetadata, error) {
s.mu.Lock()
s.ListHostUpcomingActivitiesFuncInvoked = true

View file

@ -0,0 +1,42 @@
// Package authz provides authorization interfaces for bounded contexts.
// This package contains only interfaces with no dependencies on fleet packages,
// allowing bounded contexts to use authorization without coupling to legacy code.
package authz
import (
"context"
"errors"
)
// Action represents an authorization action.
type Action string
const (
ActionRead Action = "read"
)
// Authorizer is the interface for authorization checks.
type Authorizer interface {
// Authorize checks if the current user (from context) can perform the action on the subject.
// subject must implement AuthzTyper interface.
Authorize(ctx context.Context, subject AuthzTyper, action Action) error
}
// AuthzTyper is implemented by types that can be authorized.
// Each bounded context defines its own authorization subjects that implement this interface.
type AuthzTyper interface {
AuthzType() string
}
// Forbidden is an interface for authorization errors.
// Errors implementing this interface indicate that the requested action was forbidden.
type Forbidden interface {
error
Forbidden()
}
// IsForbidden returns true if the error (or any wrapped error) is a forbidden/authorization error.
func IsForbidden(err error) bool {
var f Forbidden
return errors.As(err, &f)
}

View file

@ -20,16 +20,9 @@ import (
)
////////////////////////////////////////////////////////////////////////////////
// Get activities
// Activities response (used by host past activities endpoint)
////////////////////////////////////////////////////////////////////////////////
type listActivitiesRequest struct {
ListOptions fleet.ListOptions `url:"list_options"`
ActivityType string `query:"activity_type,optional"`
StartCreatedAt string `query:"start_created_at,optional"`
EndCreatedAt string `query:"end_created_at,optional"`
}
type listActivitiesResponse struct {
Meta *fleet.PaginationMetadata `json:"meta"`
Activities []*fleet.Activity `json:"activities"`
@ -38,29 +31,6 @@ type listActivitiesResponse struct {
func (r listActivitiesResponse) Error() error { return r.Err }
func listActivitiesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*listActivitiesRequest)
activities, metadata, err := svc.ListActivities(ctx, fleet.ListActivitiesOptions{
ListOptions: req.ListOptions,
ActivityType: req.ActivityType,
StartCreatedAt: req.StartCreatedAt,
EndCreatedAt: req.EndCreatedAt,
})
if err != nil {
return listActivitiesResponse{Err: err}, nil
}
return listActivitiesResponse{Meta: metadata, Activities: activities}, nil
}
// ListActivities returns a slice of activities for the whole organization
func (svc *Service) ListActivities(ctx context.Context, opt fleet.ListActivitiesOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) {
if err := svc.authz.Authorize(ctx, &fleet.Activity{}, fleet.ActionRead); err != nil {
return nil, nil, err
}
return svc.ds.ListActivities(ctx, opt)
}
func (svc *Service) NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
return newActivity(ctx, user, activity, svc.ds, svc.logger)
}

View file

@ -8,12 +8,10 @@ import (
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -30,45 +28,6 @@ func (a ActivityTypeTest) Documentation() (activity string, details string, deta
return "test_activity", "test_activity", "test_activity"
}
func TestListActivities(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
globalUsers := []*fleet.User{test.UserAdmin, test.UserMaintainer, test.UserObserver, test.UserObserverPlus}
teamUsers := []*fleet.User{test.UserTeamAdminTeam1, test.UserTeamMaintainerTeam1, test.UserTeamObserverTeam1}
ds.ListActivitiesFunc = func(ctx context.Context, opts fleet.ListActivitiesOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) {
return []*fleet.Activity{
{ID: 1},
{ID: 2},
}, nil, nil
}
// any global user can read activities
for _, u := range globalUsers {
activities, _, err := svc.ListActivities(test.UserContext(ctx, u), fleet.ListActivitiesOptions{})
require.NoError(t, err)
require.Len(t, activities, 2)
}
// team users cannot read activities
for _, u := range teamUsers {
_, _, err := svc.ListActivities(test.UserContext(ctx, u), fleet.ListActivitiesOptions{})
require.Error(t, err)
require.Contains(t, err.Error(), authz.ForbiddenErrorMessage)
}
// user with no roles cannot read activities
_, _, err := svc.ListActivities(test.UserContext(ctx, test.UserNoRoles), fleet.ListActivitiesOptions{})
require.Error(t, err)
require.Contains(t, err.Error(), authz.ForbiddenErrorMessage)
// no user in context
_, _, err = svc.ListActivities(ctx, fleet.ListActivitiesOptions{})
require.Error(t, err)
require.Contains(t, err.Error(), authz.ForbiddenErrorMessage)
}
func Test_logRoleChangeActivities(t *testing.T) {
tests := []struct {
name string

View file

@ -483,8 +483,6 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
// This endpoint is deprecated and maintained for backwards compatibility. This and above endpoint are functionally equivalent
ue.POST("/api/_version_/fleet/queries/run_by_names", createDistributedQueryCampaignByIdentifierEndpoint, createDistributedQueryCampaignByIdentifierRequest{})
ue.GET("/api/_version_/fleet/activities", listActivitiesEndpoint, listActivitiesRequest{})
ue.GET("/api/_version_/fleet/packs/{id:[0-9]+}/scheduled", getScheduledQueriesInPackEndpoint, getScheduledQueriesInPackRequest{})
ue.EndingAtVersion("v1").POST("/api/_version_/fleet/schedule", scheduleQueryEndpoint, scheduleQueryRequest{})
ue.StartingAtVersion("2022-04").POST("/api/_version_/fleet/packs/schedule", scheduleQueryEndpoint, scheduleQueryRequest{})

View file

@ -135,6 +135,7 @@ func (s *integrationEnterpriseTestSuite) SetupSuite() {
SoftwareInstallStore: softwareInstallStore,
SoftwareTitleIconStore: softwareTitleIconStore,
ConditionalAccessMicrosoftProxy: mockedConditionalAccessMicrosoftProxyInstance,
DBConns: s.dbConns,
}
if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
config.Logger = kitlog.NewNopLogger()
@ -24145,3 +24146,45 @@ func (s *integrationEnterpriseTestSuite) TestUpdateSoftwareAutoUpdateConfig() {
s.lastActivityMatches(fleet.ActivityEditedAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"app_store_id":"adam_vpp_app_1", "auto_update_enabled":false, "platform":"ipados", "self_service":false, "software_display_name":"Updated Display Name", "software_icon_url":null, "software_title":"vpp1", "software_title_id":%d, "team_id":%d, "team_name":"%s"}`, vppApp.TitleID, team.ID, team.Name), 0)
}
func (s *integrationEnterpriseTestSuite) TestListActivitiesAuth() {
t := s.T()
ctx := t.Context()
// Create a team and team user for testing.
// The standard test users (s.users) only include global roles (admin, maintainer, observer).
// Team users must be created per-test since they're not in the standard set.
team, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: t.Name() + "_team",
Description: "Team for activities auth test",
})
require.NoError(t, err)
teamObserver := &fleet.User{
Name: "Activities Team Observer",
Email: "activities-team-observer@example.com",
Teams: []fleet.UserTeam{{Team: *team, Role: fleet.RoleObserver}},
}
require.NoError(t, teamObserver.SetPassword(test.GoodPassword, 10, 10))
teamObserver, err = s.ds.NewUser(ctx, teamObserver)
require.NoError(t, err)
// Global users can access activities
for _, email := range []string{
"admin1@example.com", // global admin
"user1@example.com", // global maintainer
"user2@example.com", // global observer
} {
s.setTokenForTest(t, email, test.GoodPassword)
var resp listActivitiesResponse
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &resp)
}
// Team-only users cannot access activities
s.setTokenForTest(t, teamObserver.Email, test.GoodPassword)
var resp listActivitiesResponse
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusForbidden, &resp)
// Reset to admin token
s.token = s.getTestAdminToken()
}

View file

@ -297,6 +297,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
License: &fleet.LicenseInfo{
Tier: fleet.TierPremium,
},
DBConns: s.dbConns,
Logger: serverLogger,
FleetConfig: &fleetCfg,
MDMStorage: mdmStorage,

View file

@ -41,7 +41,7 @@ func (s *integrationSSOTestSuite) SetupSuite() {
s.withDS.SetupSuite("integrationSSOTestSuite")
pool := redistest.SetupRedis(s.T(), "zz", false, false, false)
opts := &TestServerOpts{Pool: pool}
opts := &TestServerOpts{Pool: pool, DBConns: s.dbConns}
if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
opts.Logger = kitlog.NewNopLogger()
}

View file

@ -25,6 +25,7 @@ import (
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/live_query/live_query_mock"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/fleetdm/fleet/v4/server/pubsub"
"github.com/fleetdm/fleet/v4/server/service/contract"
"github.com/fleetdm/fleet/v4/server/test"
@ -38,13 +39,14 @@ import (
)
type withDS struct {
s *suite.Suite
ds *mysql.Datastore
s *suite.Suite
ds *mysql.Datastore
dbConns *common_mysql.DBConnections
}
func (ts *withDS) SetupSuite(dbName string) {
t := ts.s.T()
ts.ds = mysql.CreateNamedMySQLDS(t, dbName)
ts.ds, ts.dbConns = mysql.CreateNamedMySQLDSWithConns(t, dbName)
// remove any migration-created labels
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(context.Background(), `DELETE FROM labels`)
@ -93,6 +95,7 @@ func (ts *withServer) SetupSuite(dbName string) {
Lq: ts.lq,
FleetConfig: &cfg,
Pool: redisPool,
DBConns: ts.dbConns,
}
if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
opts.Logger = kitlog.NewNopLogger()

View file

@ -23,6 +23,9 @@ import (
"github.com/fleetdm/fleet/v4/ee/server/service/est"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig"
"github.com/fleetdm/fleet/v4/server/acl/activityacl"
activity_bootstrap "github.com/fleetdm/fleet/v4/server/activity/bootstrap"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/license"
@ -46,13 +49,16 @@ import (
scep_depot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/async"
"github.com/fleetdm/fleet/v4/server/service/middleware/auth"
"github.com/fleetdm/fleet/v4/server/service/mock"
"github.com/fleetdm/fleet/v4/server/service/redis_key_value"
"github.com/fleetdm/fleet/v4/server/service/redis_lock"
"github.com/fleetdm/fleet/v4/server/sso"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/go-kit/kit/endpoint"
kitlog "github.com/go-kit/log"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
@ -414,6 +420,7 @@ type TestServerOpts struct {
androidMockClient *android_mock.Client
androidModule android.Service
ConditionalAccess *ConditionalAccess
DBConns *common_mysql.DBConnections
}
func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServerOpts) (map[string]fleet.User, *httptest.Server) {
@ -450,6 +457,24 @@ func RunServerForTestsWithServiceWithDS(t *testing.T, ctx context.Context, ds fl
opts[0].FeatureRoutes = append(opts[0].FeatureRoutes, android_service.GetRoutes(svc, opts[0].androidModule))
}
// Add activity routes if DBConns is provided
if len(opts) > 0 && opts[0].DBConns != nil {
legacyAuthorizer, err := authz.NewAuthorizer()
require.NoError(t, err)
activityAuthorizer := authz.NewAuthorizerAdapter(legacyAuthorizer)
activityUserProvider := activityacl.NewFleetServiceAdapter(svc)
_, activityRoutesFn := activity_bootstrap.New(
opts[0].DBConns,
activityAuthorizer,
activityUserProvider,
logger,
)
activityAuthMiddleware := func(next endpoint.Endpoint) endpoint.Endpoint {
return auth.AuthenticatedUser(svc, next)
}
opts[0].FeatureRoutes = append(opts[0].FeatureRoutes, activityRoutesFn(activityAuthMiddleware))
}
var mdmPusher nanomdm_push.Pusher
if len(opts) > 0 && opts[0].MDMPusher != nil {
mdmPusher = opts[0].MDMPusher

View file

@ -225,6 +225,15 @@ func (svc *Service) ListUsers(ctx context.Context, opt fleet.UserListOptions) ([
return svc.ds.ListUsers(ctx, opt)
}
func (svc *Service) UsersByIDs(ctx context.Context, ids []uint) ([]*fleet.User, error) {
// Authorize read access to users (no specific team context)
if err := svc.authz.Authorize(ctx, &fleet.User{}, fleet.ActionRead); err != nil {
return nil, err
}
return svc.ds.UsersByIDs(ctx, ids)
}
// //////////////////////////////////////////////////////////////////////////////
// Me (get own current user)
// //////////////////////////////////////////////////////////////////////////////