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:
Tomas Touceda 2021-07-13 16:54:22 -03:00 committed by GitHub
parent 322ac3c8f6
commit d5e40f329e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 542 additions and 34 deletions

View File

@ -0,0 +1 @@
* Add activities API that shows changes across the platform as users take action. Resolves issue 1324

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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

View File

@ -110,6 +110,17 @@ allow {
action == write
}
##
# Activities
##
# All users can read activities
allow {
not is_null(subject)
object.type == "activity"
action == read
}
##
# Sessions
##

View File

@ -100,4 +100,6 @@ var TestFunctions = []func(*testing.T, fleet.Datastore){
testUserTeams,
testUserCreateWithTeams,
testSaveHostSoftware,
testNewActivity,
testActivityUsernameChange,
}

View 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)
}

View 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
}

View 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
}

View File

@ -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
}

View 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"
}

View File

@ -17,6 +17,7 @@ type Datastore interface {
CarveStore
TeamStore
SoftwareStore
ActivitiesStore
Name() string
Drop() error

View File

@ -18,4 +18,5 @@ type Service interface {
StatusService
CarveService
TeamService
ActivitiesService
}

View File

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

View 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)
}

View 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
}
}

View File

@ -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.

View File

@ -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(&params)
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,

View 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)
}

View File

@ -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
}

View File

@ -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,

View File

@ -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) {

View File

@ -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)
}

View File

@ -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
}

View 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
}