mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 00:45:19 +00:00
Issue 1324 add activity feed (#1343)
* Add activities generation * Add activities endpoint * Fix merge error * Fix indentation issue * Add changes file * Address PR review comments * Add mock activity func * Address codacy warings * Set foreign key but on delete set null * Make user_id set to null if deleted
This commit is contained in:
parent
322ac3c8f6
commit
d5e40f329e
1
changes/issue-1324-activity-feed
Normal file
1
changes/issue-1324-activity-feed
Normal file
@ -0,0 +1 @@
|
||||
* Add activities API that shows changes across the platform as users take action. Resolves issue 1324
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/fleetdm/fleet/v4/server/authz"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
@ -51,6 +52,15 @@ func (svc *Service) NewTeam(ctx context.Context, p fleet.TeamPayload) (*fleet.Te
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := svc.ds.NewActivity(
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeCreatedTeam,
|
||||
&map[string]interface{}{"team_id": team.ID, "team_name": team.Name},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return team, nil
|
||||
}
|
||||
|
||||
@ -192,7 +202,15 @@ func (svc *Service) DeleteTeam(ctx context.Context, teamID uint) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.ds.DeleteTeam(teamID)
|
||||
if err := svc.ds.DeleteTeam(teamID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.ds.NewActivity(
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeDeletedTeam,
|
||||
&map[string]interface{}{"team_id": teamID},
|
||||
)
|
||||
}
|
||||
|
||||
func (svc *Service) TeamEnrollSecrets(ctx context.Context, teamID uint) ([]*fleet.EnrollSecret, error) {
|
||||
|
3
go.mod
3
go.mod
@ -32,6 +32,7 @@ require (
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/gosuri/uilive v0.0.4
|
||||
github.com/groob/mockimpl v0.0.0-20170306012045-dfa944a2a940 // indirect
|
||||
github.com/igm/sockjs-go/v3 v3.0.0
|
||||
github.com/jmoiron/sqlx v1.2.0
|
||||
github.com/jonboulle/clockwork v0.2.2 // indirect
|
||||
@ -61,6 +62,8 @@ require (
|
||||
github.com/throttled/throttled/v2 v2.8.0
|
||||
github.com/urfave/cli/v2 v2.3.0
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
|
||||
golang.org/x/tools v0.1.4 // indirect
|
||||
google.golang.org/grpc v1.38.0
|
||||
gopkg.in/guregu/null.v3 v3.4.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0-20170531160350-a96e63847dc3
|
||||
|
6
go.sum
6
go.sum
@ -285,6 +285,8 @@ github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0U
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gosuri/uilive v0.0.4 h1:hUEBpQDj8D8jXgtCdBu7sWsy5sbW/5GhuO8KBwJ2jyY=
|
||||
github.com/gosuri/uilive v0.0.4/go.mod h1:V/epo5LjjlDE5RJUcqx8dbw+zc93y5Ya3yg8tfZ74VI=
|
||||
github.com/groob/mockimpl v0.0.0-20170306012045-dfa944a2a940 h1:7qYt+uqKEGE5yHfRFOsgG6b8sW0qMSsNU50GbfskYAI=
|
||||
github.com/groob/mockimpl v0.0.0-20170306012045-dfa944a2a940/go.mod h1:KeaEsoeCyhGRrPvJFbO+SMJfKLRqNGzL22LObUSCY38=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/hashicorp/consul/api v1.1.0 h1:BNQPM9ytxj6jbjjdRPioQ94T6YXriSopn0i8COv6SRA=
|
||||
@ -780,6 +782,8 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@ -853,6 +857,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/tools v0.1.2 h1:kRBLX7v7Af8W7Gdbbc908OJcdgtK8bOz9Uaj8/F1ACA=
|
||||
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.4 h1:cVngSRcfgyZCzys3KYOpCFa+4dqX/Oub9tAq00ttGVs=
|
||||
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
@ -84,7 +84,7 @@ func (a *Authorizer) Authorize(ctx context.Context, object, action interface{})
|
||||
authctx.Checked = true
|
||||
}
|
||||
|
||||
subject := userFromContext(ctx)
|
||||
subject := UserFromContext(ctx)
|
||||
if subject == nil {
|
||||
return ForbiddenWithInternal("nil subject always forbidden", subject, object, action)
|
||||
}
|
||||
@ -162,9 +162,9 @@ func jsonToInterface(in interface{}) (interface{}, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// userFromContext retrieves a user from the viewer context, returning nil if
|
||||
// UserFromContext retrieves a user from the viewer context, returning nil if
|
||||
// there is no user.
|
||||
func userFromContext(ctx context.Context) *fleet.User {
|
||||
func UserFromContext(ctx context.Context) *fleet.User {
|
||||
vc, ok := viewer.FromContext(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
|
@ -110,6 +110,17 @@ allow {
|
||||
action == write
|
||||
}
|
||||
|
||||
##
|
||||
# Activities
|
||||
##
|
||||
|
||||
# All users can read activities
|
||||
allow {
|
||||
not is_null(subject)
|
||||
object.type == "activity"
|
||||
action == read
|
||||
}
|
||||
|
||||
##
|
||||
# Sessions
|
||||
##
|
||||
|
@ -100,4 +100,6 @@ var TestFunctions = []func(*testing.T, fleet.Datastore){
|
||||
testUserTeams,
|
||||
testUserCreateWithTeams,
|
||||
testSaveHostSoftware,
|
||||
testNewActivity,
|
||||
testActivityUsernameChange,
|
||||
}
|
||||
|
77
server/datastore/datastore_activities.go
Normal file
77
server/datastore/datastore_activities.go
Normal file
@ -0,0 +1,77 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testNewActivity(t *testing.T, ds fleet.Datastore) {
|
||||
u := &fleet.User{
|
||||
Password: []byte("asd"),
|
||||
Name: "fullname",
|
||||
Email: "email@asd.com",
|
||||
GlobalRole: ptr.String(fleet.RoleObserver),
|
||||
}
|
||||
_, err := ds.NewUser(u)
|
||||
require.Nil(t, err)
|
||||
require.NoError(t, ds.NewActivity(u, "test1", &map[string]interface{}{"detail": 1, "sometext": "aaa"}))
|
||||
require.NoError(t, ds.NewActivity(u, "test2", &map[string]interface{}{"detail": 2}))
|
||||
|
||||
opt := fleet.ListOptions{
|
||||
Page: 0,
|
||||
PerPage: 1,
|
||||
}
|
||||
activities, err := ds.ListActivities(opt)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, activities, 1)
|
||||
assert.Equal(t, "fullname", activities[0].ActorFullName)
|
||||
assert.Equal(t, "test1", activities[0].Type)
|
||||
|
||||
opt = fleet.ListOptions{
|
||||
Page: 1,
|
||||
PerPage: 1,
|
||||
}
|
||||
activities, err = ds.ListActivities(opt)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, activities, 1)
|
||||
assert.Equal(t, "fullname", activities[0].ActorFullName)
|
||||
assert.Equal(t, "test2", activities[0].Type)
|
||||
}
|
||||
|
||||
func testActivityUsernameChange(t *testing.T, ds fleet.Datastore) {
|
||||
u := &fleet.User{
|
||||
Password: []byte("asd"),
|
||||
Name: "fullname",
|
||||
Email: "email@asd.com",
|
||||
GlobalRole: ptr.String(fleet.RoleObserver),
|
||||
}
|
||||
_, err := ds.NewUser(u)
|
||||
require.Nil(t, err)
|
||||
require.NoError(t, ds.NewActivity(u, "test1", &map[string]interface{}{"detail": 1, "sometext": "aaa"}))
|
||||
require.NoError(t, ds.NewActivity(u, "test2", &map[string]interface{}{"detail": 2}))
|
||||
|
||||
activities, err := ds.ListActivities(fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, activities, 2)
|
||||
assert.Equal(t, "fullname", activities[0].ActorFullName)
|
||||
|
||||
u.Name = "newname"
|
||||
err = ds.SaveUser(u)
|
||||
require.NoError(t, err)
|
||||
|
||||
activities, err = ds.ListActivities(fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, activities, 2)
|
||||
assert.Equal(t, "newname", activities[0].ActorFullName)
|
||||
|
||||
err = ds.DeleteUser(u.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
activities, err = ds.ListActivities(fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, activities, 2)
|
||||
assert.Equal(t, "fullname", activities[0].ActorFullName)
|
||||
}
|
8
server/datastore/inmem/activities.go
Normal file
8
server/datastore/inmem/activities.go
Normal file
@ -0,0 +1,8 @@
|
||||
package inmem
|
||||
|
||||
import "github.com/fleetdm/fleet/v4/server/fleet"
|
||||
|
||||
// NewActivity stores an activity item that the user performed
|
||||
func (d *Datastore) NewActivity(user *fleet.User, activityType string, details *map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
45
server/datastore/mysql/activities.go
Normal file
45
server/datastore/mysql/activities.go
Normal file
@ -0,0 +1,45 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// NewActivity stores an activity item that the user performed
|
||||
func (d *Datastore) NewActivity(user *fleet.User, activityType string, details *map[string]interface{}) error {
|
||||
detailsBytes, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "marshaling activity details")
|
||||
}
|
||||
_, err = d.db.Exec(
|
||||
`INSERT INTO activities (user_id, user_name, activity_type, details) VALUES(?,?,?,?)`,
|
||||
user.ID,
|
||||
user.Name,
|
||||
activityType,
|
||||
detailsBytes,
|
||||
)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "new activity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListActivities returns a slice of activities performed across the organization
|
||||
func (d *Datastore) ListActivities(opt fleet.ListOptions) ([]*fleet.Activity, error) {
|
||||
activities := []*fleet.Activity{}
|
||||
query := `SELECT a.id, a.user_id, a.created_at, a.activity_type, a.details, coalesce(u.name, a.user_name) as name
|
||||
FROM activities a LEFT JOIN users u ON (a.user_id=u.id)
|
||||
WHERE true`
|
||||
query = appendListOptionsToSQL(query, opt)
|
||||
|
||||
err := d.db.Select(&activities, query)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, notFound("Activity")
|
||||
} else if err != nil {
|
||||
return nil, errors.Wrap(err, "select activities")
|
||||
}
|
||||
|
||||
return activities, nil
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20210709124443, Down_20210709124443)
|
||||
}
|
||||
|
||||
func Up_20210709124443(tx *sql.Tx) error {
|
||||
sql := `
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
created_at timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
user_id int(10) unsigned,
|
||||
user_name varchar(255),
|
||||
activity_type varchar(255) NOT NULL,
|
||||
details json DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY fk_activities_user_id (user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||
)
|
||||
`
|
||||
if _, err := tx.Exec(sql); err != nil {
|
||||
return errors.Wrap(err, "create activities")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20210709124443(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
56
server/fleet/activities.go
Normal file
56
server/fleet/activities.go
Normal file
@ -0,0 +1,56 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
const (
|
||||
// ActivityTypeCreatedPack is the activity type for created packs
|
||||
ActivityTypeCreatedPack = "created_pack"
|
||||
// ActivityTypeEditedPack is the activity type for edited packs
|
||||
ActivityTypeEditedPack = "edited_pack"
|
||||
// ActivityTypeDeletedPack is the activity type for deleted packs
|
||||
ActivityTypeDeletedPack = "deleted_pack"
|
||||
// ActivityTypeAppliedSpecPack is the activity type for pack specs applied
|
||||
ActivityTypeAppliedSpecPack = "applied_spec_pack"
|
||||
// ActivityTypeCreatedSavedQuery is the activity type for created saved queries
|
||||
ActivityTypeCreatedSavedQuery = "created_saved_query"
|
||||
// ActivityTypeEditedSavedQuery is the activity type for edited saved queries
|
||||
ActivityTypeEditedSavedQuery = "edited_saved_query"
|
||||
// ActivityTypeDeletedSavedQuery is the activity type for deleted saved queries
|
||||
ActivityTypeDeletedSavedQuery = "deleted_saved_query"
|
||||
// ActivityTypeDeletedMultipleSavedQuery is the activity type for multiple deleted saved queries
|
||||
ActivityTypeDeletedMultipleSavedQuery = "deleted_multiple_saved_query"
|
||||
// ActivityTypeAppliedSpecSavedQuery is the activity type for saved queries spec applied
|
||||
ActivityTypeAppliedSpecSavedQuery = "applied_spec_saved_query"
|
||||
// ActivityTypeCreatedTeam is the activity type for created team
|
||||
ActivityTypeCreatedTeam = "created_team"
|
||||
// ActivityTypeDeletedTeam is the activity type for deleted team
|
||||
ActivityTypeDeletedTeam = "deleted_team"
|
||||
// ActivityTypeLiveQuery is the activity type for live queries
|
||||
ActivityTypeLiveQuery = "live_query"
|
||||
)
|
||||
|
||||
type ActivitiesStore interface {
|
||||
NewActivity(user *User, activityType string, details *map[string]interface{}) error
|
||||
ListActivities(opt ListOptions) ([]*Activity, error)
|
||||
}
|
||||
|
||||
type ActivitiesService interface {
|
||||
ListActivities(ctx context.Context, opt ListOptions) ([]*Activity, error)
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
CreateTimestamp
|
||||
ID uint `json:"id" db:"id"`
|
||||
ActorFullName string `json:"actor_full_name" db:"name"`
|
||||
ActorID *uint `json:"actor_id" db:"user_id"`
|
||||
Type string `json:"type" db:"activity_type"`
|
||||
Details *json.RawMessage `json:"details" db:"details"`
|
||||
}
|
||||
|
||||
// AuthzType implement AuthzTyper to be able to verify access to activities
|
||||
func (*Activity) AuthzType() string {
|
||||
return "activity"
|
||||
}
|
@ -17,6 +17,7 @@ type Datastore interface {
|
||||
CarveStore
|
||||
TeamStore
|
||||
SoftwareStore
|
||||
ActivitiesStore
|
||||
|
||||
Name() string
|
||||
Drop() error
|
||||
|
@ -18,4 +18,5 @@ type Service interface {
|
||||
StatusService
|
||||
CarveService
|
||||
TeamService
|
||||
ActivitiesService
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ import "github.com/fleetdm/fleet/v4/server/fleet"
|
||||
//go:generate mockimpl -o datastore_query_results.go "s *QueryResultStore" "fleet.QueryResultStore"
|
||||
//go:generate mockimpl -o datastore_campaigns.go "s *CampaignStore" "fleet.CampaignStore"
|
||||
//go:generate mockimpl -o datastore_sessions.go "s *SessionStore" "fleet.SessionStore"
|
||||
//go:generate mockimpl -o datastore_activities.go "s *ActivitiesStore" "fleet.ActivitiesStore"
|
||||
|
||||
var _ fleet.Datastore = (*Store)(nil)
|
||||
|
||||
@ -36,6 +37,7 @@ type Store struct {
|
||||
QueryResultStore
|
||||
CarveStore
|
||||
SoftwareStore
|
||||
ActivitiesStore
|
||||
}
|
||||
|
||||
func (m *Store) Drop() error {
|
||||
|
28
server/mock/datastore_activities.go
Normal file
28
server/mock/datastore_activities.go
Normal file
@ -0,0 +1,28 @@
|
||||
// Automatically generated by mockimpl. DO NOT EDIT!
|
||||
|
||||
package mock
|
||||
|
||||
import "github.com/fleetdm/fleet/v4/server/fleet"
|
||||
|
||||
var _ fleet.ActivitiesStore = (*ActivitiesStore)(nil)
|
||||
|
||||
type NewActivityFunc func(user *fleet.User, activityType string, details *map[string]interface{}) error
|
||||
type ListActivitiesFunc func(opt fleet.ListOptions) ([]*fleet.Activity, error)
|
||||
|
||||
type ActivitiesStore struct {
|
||||
NewActivityFunc NewActivityFunc
|
||||
NewActivityFuncInvoked bool
|
||||
|
||||
ListActivitiesFunc ListActivitiesFunc
|
||||
ListActivitiesFuncInvoked bool
|
||||
}
|
||||
|
||||
func (s *ActivitiesStore) NewActivity(user *fleet.User, activityType string, details *map[string]interface{}) error {
|
||||
s.NewActivityFuncInvoked = true
|
||||
return s.NewActivityFunc(user, activityType, details)
|
||||
}
|
||||
|
||||
func (s *ActivitiesStore) ListActivities(opt fleet.ListOptions) ([]*fleet.Activity, error) {
|
||||
s.ListActivitiesFuncInvoked = true
|
||||
return s.ListActivitiesFunc(opt)
|
||||
}
|
34
server/service/endpoint_activities.go
Normal file
34
server/service/endpoint_activities.go
Normal file
@ -0,0 +1,34 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/go-kit/kit/endpoint"
|
||||
)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Get activities
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type listActivitiesRequest struct {
|
||||
ListOptions fleet.ListOptions
|
||||
}
|
||||
|
||||
type listActivitiesResponse struct {
|
||||
Activities []*fleet.Activity `json:"activities"`
|
||||
Err error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r listActivitiesResponse) error() error { return r.Err }
|
||||
|
||||
func makeListActivitiesEndpoint(svc fleet.Service) endpoint.Endpoint {
|
||||
return func(ctx context.Context, request interface{}) (interface{}, error) {
|
||||
req := request.(listActivitiesRequest)
|
||||
activities, err := svc.ListActivities(ctx, req.ListOptions)
|
||||
if err != nil {
|
||||
return listActivitiesResponse{Err: err}, nil
|
||||
}
|
||||
|
||||
return listActivitiesResponse{Activities: activities}, err
|
||||
}
|
||||
}
|
@ -118,6 +118,7 @@ type FleetEndpoints struct {
|
||||
AddTeamUsers endpoint.Endpoint
|
||||
DeleteTeamUsers endpoint.Endpoint
|
||||
TeamEnrollSecrets endpoint.Endpoint
|
||||
ListActivities endpoint.Endpoint
|
||||
}
|
||||
|
||||
// MakeFleetServerEndpoints creates the Fleet API endpoints.
|
||||
@ -225,6 +226,7 @@ func MakeFleetServerEndpoints(svc fleet.Service, urlPrefix string, limitStore th
|
||||
AddTeamUsers: authenticatedUser(svc, makeAddTeamUsersEndpoint(svc)),
|
||||
DeleteTeamUsers: authenticatedUser(svc, makeDeleteTeamUsersEndpoint(svc)),
|
||||
TeamEnrollSecrets: authenticatedUser(svc, makeTeamEnrollSecretsEndpoint(svc)),
|
||||
ListActivities: authenticatedUser(svc, makeListActivitiesEndpoint(svc)),
|
||||
|
||||
// Authenticated status endpoints
|
||||
StatusResultStore: authenticatedUser(svc, makeStatusResultStoreEndpoint(svc)),
|
||||
@ -344,6 +346,7 @@ type fleetHandlers struct {
|
||||
AddTeamUsers http.Handler
|
||||
DeleteTeamUsers http.Handler
|
||||
TeamEnrollSecrets http.Handler
|
||||
ListActivities http.Handler
|
||||
}
|
||||
|
||||
func makeKitHandlers(e FleetEndpoints, opts []kithttp.ServerOption) *fleetHandlers {
|
||||
@ -450,6 +453,7 @@ func makeKitHandlers(e FleetEndpoints, opts []kithttp.ServerOption) *fleetHandle
|
||||
AddTeamUsers: newServer(e.AddTeamUsers, decodeModifyTeamUsersRequest),
|
||||
DeleteTeamUsers: newServer(e.DeleteTeamUsers, decodeModifyTeamUsersRequest),
|
||||
TeamEnrollSecrets: newServer(e.TeamEnrollSecrets, decodeTeamEnrollSecretsRequest),
|
||||
ListActivities: newServer(e.ListActivities, decodeListActivitiesRequest),
|
||||
}
|
||||
}
|
||||
|
||||
@ -636,6 +640,8 @@ func attachFleetAPIRoutes(r *mux.Router, h *fleetHandlers) {
|
||||
r.Handle("/api/v1/osquery/log", h.SubmitLogs).Methods("POST").Name("submit_logs")
|
||||
r.Handle("/api/v1/osquery/carve/begin", h.CarveBegin).Methods("POST").Name("carve_begin")
|
||||
r.Handle("/api/v1/osquery/carve/block", h.CarveBlock).Methods("POST").Name("carve_block")
|
||||
|
||||
r.Handle("/api/v1/fleet/activities", h.ListActivities).Methods("GET").Name("list_activities")
|
||||
}
|
||||
|
||||
// WithSetup is an http middleware that checks if setup procedures have been completed.
|
||||
|
@ -136,17 +136,49 @@ func testUserCreationWrongTeamErrors(t *testing.T, ds fleet.Datastore) {
|
||||
GlobalRole: ptr.String(fleet.RoleObserver),
|
||||
Teams: &teams,
|
||||
}
|
||||
method := "POST"
|
||||
path := "/api/v1/fleet/users/admin"
|
||||
expectedStatusCode := http.StatusUnprocessableEntity
|
||||
|
||||
resp := doReq(t, params, method, server, path, token, expectedStatusCode)
|
||||
assertBodyContains(t, resp, `Error 1452: Cannot add or update a child row: a foreign key constraint fails`)
|
||||
}
|
||||
|
||||
func doReq(
|
||||
t *testing.T,
|
||||
params interface{},
|
||||
method string,
|
||||
server *httptest.Server,
|
||||
path string,
|
||||
token string,
|
||||
expectedStatusCode int,
|
||||
) *http.Response {
|
||||
j, err := json.Marshal(¶ms)
|
||||
assert.Nil(t, err)
|
||||
|
||||
requestBody := &nopCloser{bytes.NewBuffer(j)}
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/v1/fleet/users/admin", requestBody)
|
||||
req, _ := http.NewRequest(method, server.URL+path, requestBody)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
|
||||
assertBodyContains(t, resp, `Error 1452: Cannot add or update a child row: a foreign key constraint fails`)
|
||||
assert.Equal(t, expectedStatusCode, resp.StatusCode)
|
||||
return resp
|
||||
}
|
||||
|
||||
func doJSONReq(
|
||||
t *testing.T,
|
||||
params interface{},
|
||||
method string,
|
||||
server *httptest.Server,
|
||||
path string,
|
||||
token string,
|
||||
expectedStatusCode int,
|
||||
v interface{},
|
||||
) {
|
||||
resp := doReq(t, params, method, server, path, token, expectedStatusCode)
|
||||
err := json.NewDecoder(resp.Body).Decode(v)
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
func assertBodyContains(t *testing.T, resp *http.Response, expectedError string) {
|
||||
@ -156,6 +188,26 @@ func assertBodyContains(t *testing.T, resp *http.Response, expectedError string)
|
||||
assert.Contains(t, bodyString, expectedError)
|
||||
}
|
||||
|
||||
func testQueryCreationLogsActivity(t *testing.T, ds fleet.Datastore) {
|
||||
_, server := runServerForTestsWithDS(t, ds)
|
||||
token := getTestAdminToken(t, server)
|
||||
|
||||
params := fleet.QueryPayload{
|
||||
Name: ptr.String("user1"),
|
||||
Query: ptr.String("select * from time;"),
|
||||
}
|
||||
doReq(t, params, "POST", server, "/api/v1/fleet/queries", token, http.StatusOK)
|
||||
type activitiesRespose struct {
|
||||
Activities []map[string]interface{} `json:"activities"`
|
||||
}
|
||||
activities := activitiesRespose{}
|
||||
doJSONReq(t, nil, "GET", server, "/api/v1/fleet/activities", token, http.StatusOK, &activities)
|
||||
|
||||
assert.Len(t, activities.Activities, 1)
|
||||
assert.Equal(t, "Test Name admin1@example.com", activities.Activities[0]["actor_full_name"])
|
||||
assert.Equal(t, "created_saved_query", activities.Activities[0]["type"])
|
||||
}
|
||||
|
||||
func getJSON(r *http.Response, target interface{}) error {
|
||||
return json.NewDecoder(r.Body).Decode(target)
|
||||
}
|
||||
@ -197,29 +249,13 @@ func applyConfig(t *testing.T, spec []byte, server *httptest.Server, token strin
|
||||
var appConfigSpec fleet.AppConfigPayload
|
||||
err := yaml.Unmarshal(spec, &appConfigSpec)
|
||||
require.NoError(t, err)
|
||||
j, err := json.Marshal(&appConfigSpec)
|
||||
assert.Nil(t, err)
|
||||
|
||||
requestBody := &nopCloser{bytes.NewBuffer(j)}
|
||||
req, _ := http.NewRequest("PATCH", server.URL+"/api/v1/fleet/config", requestBody)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
doReq(t, appConfigSpec, "PATCH", server, "/api/v1/fleet/config", token, http.StatusOK)
|
||||
}
|
||||
|
||||
func getConfig(t *testing.T, server *httptest.Server, token string) *fleet.AppConfigPayload {
|
||||
req, _ := http.NewRequest("GET", server.URL+"/api/v1/fleet/config", nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var responseBody *fleet.AppConfigPayload
|
||||
err = json.NewDecoder(resp.Body).Decode(&responseBody)
|
||||
require.Nil(t, err)
|
||||
doJSONReq(t, nil, "GET", server, "/api/v1/fleet/config", token, http.StatusOK, &responseBody)
|
||||
return responseBody
|
||||
}
|
||||
|
||||
@ -227,6 +263,7 @@ func TestSQLErrorsAreProperlyHandled(t *testing.T) {
|
||||
mysql.RunTestsAgainstMySQL(t, []func(t *testing.T, ds fleet.Datastore){
|
||||
testDoubleUserCreationErrors,
|
||||
testUserCreationWrongTeamErrors,
|
||||
testQueryCreationLogsActivity,
|
||||
testUserWithoutRoleErrors,
|
||||
testUserWithWrongRoleErrors,
|
||||
testAppConfigAdditionalQueriesCanBeRemoved,
|
||||
|
14
server/service/service_activities.go
Normal file
14
server/service/service_activities.go
Normal file
@ -0,0 +1,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
)
|
||||
|
||||
// ListActivities returns a slice of activities for the whole organization
|
||||
func (svc *Service) ListActivities(ctx context.Context, opt fleet.ListOptions) ([]*fleet.Activity, error) {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Activity{}, fleet.ActionRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.ds.ListActivities(opt)
|
||||
}
|
@ -135,6 +135,14 @@ func (svc Service) NewDistributedQueryCampaign(ctx context.Context, queryString
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "counting hosts")
|
||||
}
|
||||
|
||||
if err := svc.ds.NewActivity(
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeLiveQuery,
|
||||
&map[string]interface{}{"target_counts": campaign.Metrics.TotalHosts},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return campaign, nil
|
||||
}
|
||||
|
||||
|
@ -1170,9 +1170,13 @@ func TestNewDistributedQueryCampaign(t *testing.T) {
|
||||
},
|
||||
})
|
||||
q := "select year, month, day, hour, minutes, seconds from time"
|
||||
ds.NewActivityFunc = func(user *fleet.User, activityType string, details *map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
campaign, err := svc.NewDistributedQueryCampaign(viewerCtx, q, nil, fleet.HostTargets{HostIDs: []uint{2}, LabelIDs: []uint{1}})
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, gotQuery.ID, gotCampaign.QueryID)
|
||||
assert.True(t, ds.NewActivityFuncInvoked)
|
||||
assert.Equal(t, []*fleet.DistributedQueryCampaignTarget{
|
||||
{
|
||||
Type: fleet.TargetHost,
|
||||
|
@ -2,7 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
)
|
||||
|
||||
@ -11,7 +11,15 @@ func (svc *Service) ApplyPackSpecs(ctx context.Context, specs []*fleet.PackSpec)
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.ds.ApplyPackSpecs(specs)
|
||||
if err := svc.ds.ApplyPackSpecs(specs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.ds.NewActivity(
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeAppliedSpecPack,
|
||||
&map[string]interface{}{},
|
||||
)
|
||||
}
|
||||
|
||||
func (svc *Service) GetPackSpecs(ctx context.Context) ([]*fleet.PackSpec, error) {
|
||||
@ -86,6 +94,14 @@ func (svc *Service) NewPack(ctx context.Context, p fleet.PackPayload) (*fleet.Pa
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := svc.ds.NewActivity(
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeCreatedPack,
|
||||
&map[string]interface{}{"pack_id": pack.ID, "pack_name": pack.Name},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &pack, nil
|
||||
}
|
||||
|
||||
@ -132,6 +148,14 @@ func (svc *Service) ModifyPack(ctx context.Context, id uint, p fleet.PackPayload
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := svc.ds.NewActivity(
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeEditedPack,
|
||||
&map[string]interface{}{"pack_id": pack.ID, "pack_name": pack.Name},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pack, err
|
||||
}
|
||||
|
||||
@ -140,7 +164,15 @@ func (svc *Service) DeletePack(ctx context.Context, name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.ds.DeletePack(name)
|
||||
if err := svc.ds.DeletePack(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.ds.NewActivity(
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeDeletedPack,
|
||||
&map[string]interface{}{"pack_name": name},
|
||||
)
|
||||
}
|
||||
|
||||
func (svc *Service) DeletePackByID(ctx context.Context, id uint) error {
|
||||
@ -152,7 +184,15 @@ func (svc *Service) DeletePackByID(ctx context.Context, id uint) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.ds.DeletePack(pack.Name)
|
||||
if err := svc.ds.DeletePack(pack.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.ds.NewActivity(
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeDeletedPack,
|
||||
&map[string]interface{}{"pack_name": pack.Name},
|
||||
)
|
||||
}
|
||||
|
||||
func (svc *Service) ListPacksForHost(ctx context.Context, hid uint) ([]*fleet.Pack, error) {
|
||||
|
@ -56,6 +56,9 @@ func TestNewSavesTargets(t *testing.T) {
|
||||
ds.NewPackFunc = func(pack *fleet.Pack, opts ...fleet.OptionalArg) (*fleet.Pack, error) {
|
||||
return pack, nil
|
||||
}
|
||||
ds.NewActivityFunc = func(user *fleet.User, activityType string, details *map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
packPayload := fleet.PackPayload{
|
||||
Name: ptr.String("foo"),
|
||||
@ -71,4 +74,5 @@ func TestNewSavesTargets(t *testing.T) {
|
||||
assert.Equal(t, uint(123), pack.HostIDs[0])
|
||||
assert.Equal(t, uint(456), pack.LabelIDs[0])
|
||||
assert.Equal(t, uint(789), pack.TeamIDs[0])
|
||||
assert.True(t, ds.NewActivityFuncInvoked)
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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/ptr"
|
||||
@ -47,7 +47,15 @@ func (svc Service) ApplyQuerySpecs(ctx context.Context, specs []*fleet.QuerySpec
|
||||
}
|
||||
|
||||
err := svc.ds.ApplyQueries(vc.UserID(), queries)
|
||||
return errors.Wrap(err, "applying queries")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "applying queries")
|
||||
}
|
||||
|
||||
return svc.ds.NewActivity(
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeAppliedSpecSavedQuery,
|
||||
&map[string]interface{}{"specs": specs},
|
||||
)
|
||||
}
|
||||
|
||||
func (svc Service) GetQuerySpecs(ctx context.Context) ([]*fleet.QuerySpec, error) {
|
||||
@ -133,6 +141,14 @@ func (svc *Service) NewQuery(ctx context.Context, p fleet.QueryPayload) (*fleet.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := svc.ds.NewActivity(
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeCreatedSavedQuery,
|
||||
&map[string]interface{}{"query_id": query.ID, "query_name": query.Name},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return query, nil
|
||||
}
|
||||
|
||||
@ -170,6 +186,14 @@ func (svc *Service) ModifyQuery(ctx context.Context, id uint, p fleet.QueryPaylo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := svc.ds.NewActivity(
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeEditedSavedQuery,
|
||||
&map[string]interface{}{"query_id": query.ID, "query_name": query.Name},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return query, nil
|
||||
}
|
||||
|
||||
@ -178,7 +202,15 @@ func (svc *Service) DeleteQuery(ctx context.Context, name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.ds.DeleteQuery(name)
|
||||
if err := svc.ds.DeleteQuery(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.ds.NewActivity(
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeDeletedSavedQuery,
|
||||
&map[string]interface{}{"query_name": name},
|
||||
)
|
||||
}
|
||||
|
||||
func (svc *Service) DeleteQueryByID(ctx context.Context, id uint) error {
|
||||
@ -191,7 +223,15 @@ func (svc *Service) DeleteQueryByID(ctx context.Context, id uint) error {
|
||||
return errors.Wrap(err, "lookup query by ID")
|
||||
}
|
||||
|
||||
return errors.Wrap(svc.ds.DeleteQuery(query.Name), "delete query")
|
||||
if err := svc.ds.DeleteQuery(query.Name); err != nil {
|
||||
return errors.Wrap(err, "delete query")
|
||||
}
|
||||
|
||||
return svc.ds.NewActivity(
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeDeletedSavedQuery,
|
||||
&map[string]interface{}{"query_name": query.Name},
|
||||
)
|
||||
}
|
||||
|
||||
func (svc *Service) DeleteQueries(ctx context.Context, ids []uint) (uint, error) {
|
||||
@ -199,5 +239,19 @@ func (svc *Service) DeleteQueries(ctx context.Context, ids []uint) (uint, error)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return svc.ds.DeleteQueries(ids)
|
||||
n, err := svc.ds.DeleteQueries(ids)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
|
||||
err = svc.ds.NewActivity(
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeDeletedMultipleSavedQuery,
|
||||
&map[string]interface{}{"query_ids": ids},
|
||||
)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
14
server/service/transport_activities.go
Normal file
14
server/service/transport_activities.go
Normal file
@ -0,0 +1,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func decodeListActivitiesRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
||||
opt, err := listOptionsFromRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return listActivitiesRequest{ListOptions: opt}, nil
|
||||
}
|
Loading…
Reference in New Issue
Block a user