mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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:
parent
b32681937c
commit
6019fa6d5a
41 changed files with 2604 additions and 130 deletions
1
changes/37806-fleet-activities
Normal file
1
changes/37806-fleet-activities
Normal 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.
|
||||
|
|
@ -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"+
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
81
server/acl/activityacl/fleet_adapter.go
Normal file
81
server/acl/activityacl/fleet_adapter.go
Normal 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,
|
||||
}
|
||||
}
|
||||
22
server/activity/api/http/types.go
Normal file
22
server/activity/api/http/types.go
Normal 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 }
|
||||
72
server/activity/api/list_activities.go
Normal file
72
server/activity/api/list_activities.go
Normal 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:"-"`
|
||||
}
|
||||
7
server/activity/api/service.go
Normal file
7
server/activity/api/service.go
Normal 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
|
||||
}
|
||||
125
server/activity/arch_test.go
Normal file
125
server/activity/arch_test.go
Normal 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
32
server/activity/bootstrap/bootstrap.go
Normal file
32
server/activity/bootstrap/bootstrap.go
Normal 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
|
||||
}
|
||||
177
server/activity/internal/mysql/activity.go
Normal file
177
server/activity/internal/mysql/activity.go
Normal 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
|
||||
}
|
||||
381
server/activity/internal/mysql/activity_test.go
Normal file
381
server/activity/internal/mysql/activity_test.go
Normal 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 }
|
||||
}
|
||||
150
server/activity/internal/service/endpoint_utils.go
Normal file
150
server/activity/internal/service/endpoint_utils.go
Normal 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,
|
||||
}
|
||||
}
|
||||
69
server/activity/internal/service/handler.go
Normal file
69
server/activity/internal/service/handler.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
126
server/activity/internal/service/handler_test.go
Normal file
126
server/activity/internal/service/handler_test.go
Normal 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")
|
||||
}
|
||||
121
server/activity/internal/service/service.go
Normal file
121
server/activity/internal/service/service.go
Normal 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
|
||||
}
|
||||
439
server/activity/internal/service/service_test.go
Normal file
439
server/activity/internal/service/service_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
167
server/activity/internal/tests/integration_test.go
Normal file
167
server/activity/internal/tests/integration_test.go
Normal 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)
|
||||
}
|
||||
56
server/activity/internal/tests/mocks_test.go
Normal file
56
server/activity/internal/tests/mocks_test.go
Normal 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
|
||||
}
|
||||
99
server/activity/internal/tests/suite_test.go
Normal file
99
server/activity/internal/tests/suite_test.go
Normal 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
|
||||
}
|
||||
113
server/activity/internal/testutils/testutils.go
Normal file
113
server/activity/internal/testutils/testutils.go
Normal 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)
|
||||
}
|
||||
46
server/activity/internal/types/activity.go
Normal file
46
server/activity/internal/types/activity.go
Normal 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
25
server/activity/user.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
42
server/platform/authz/authz.go
Normal file
42
server/platform/authz/authz.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,6 +297,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
|
|||
License: &fleet.LicenseInfo{
|
||||
Tier: fleet.TierPremium,
|
||||
},
|
||||
DBConns: s.dbConns,
|
||||
Logger: serverLogger,
|
||||
FleetConfig: &fleetCfg,
|
||||
MDMStorage: mdmStorage,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// //////////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
|||
Loading…
Reference in a new issue