Cancel upcoming activities: DB schema and backend (#27710)

This commit is contained in:
Martin Angers 2025-04-01 14:08:56 -04:00 committed by GitHub
parent 6063939d9d
commit 69fcda9686
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 463 additions and 18 deletions

View file

@ -0,0 +1 @@
* Added the `DELETE /api/latest/fleet/hosts/:id/activities/upcoming/:activity_id` endpoint to cancel an upcoming activity for a host.

View file

@ -14,6 +14,7 @@ read := "read"
list := "list"
write := "write"
write_host_label := "write_host_label"
cancel_host_activity := "cancel_host_activity"
# User specific actions
write_role := "write_role"
@ -266,20 +267,27 @@ allow {
allowed_read_roles(action, base_roles, extra_roles)[_] == subject.global_role
}
# Global gitops, admin and mantainers can write hosts.
# Global gitops, admin and maintainers can write hosts.
allow {
object.type == "host"
subject.global_role == [admin, maintainer, gitops][_]
action == write
}
# Global admin, mantainers and gitops can write labels to hosts.
# Global admin, maintainers and gitops can write labels to hosts.
allow {
object.type == "host"
subject.global_role == [admin, maintainer, gitops][_]
action == write_host_label
}
# Global admin and maintainers can cancel activities on a host.
allow {
object.type == "host"
subject.global_role == [admin, maintainer][_]
action == cancel_host_activity
}
# Allow read for global observer and observer_plus, selective_read for gitops.
allow {
object.type == "host"
@ -310,6 +318,13 @@ allow {
action == write_host_label
}
# Team admins and maintainers can cancel activities on a host of their own team.
allow {
object.type == "host"
team_role(subject, object.team_id) == [admin, maintainer][_]
action == cancel_host_activity
}
# Allow read for host health for global admin/maintainer, team admins, observer.
allow {
object.type == "host_health"

View file

@ -14,15 +14,16 @@ import (
)
const (
read = fleet.ActionRead
list = fleet.ActionList
write = fleet.ActionWrite
writeRole = fleet.ActionWriteRole
run = fleet.ActionRun
runNew = fleet.ActionRunNew
changePwd = fleet.ActionChangePassword
selectiveRead = fleet.ActionSelectiveRead
selectiveList = fleet.ActionSelectiveList
read = fleet.ActionRead
list = fleet.ActionList
write = fleet.ActionWrite
writeRole = fleet.ActionWriteRole
run = fleet.ActionRun
runNew = fleet.ActionRunNew
changePwd = fleet.ActionChangePassword
selectiveRead = fleet.ActionSelectiveRead
selectiveList = fleet.ActionSelectiveList
cancelHostActivity = fleet.ActionCancelHostActivity
)
var auth *Authorizer
@ -744,12 +745,15 @@ func TestAuthorizeHost(t *testing.T) {
{user: nil, object: host, action: list, allow: false},
{user: nil, object: host, action: selectiveList, allow: false},
{user: nil, object: host, action: selectiveRead, allow: false},
{user: nil, object: host, action: cancelHostActivity, allow: false},
{user: nil, object: hostTeam1, action: read, allow: false},
{user: nil, object: hostTeam1, action: write, allow: false},
{user: nil, object: hostTeam1, action: selectiveRead, allow: false},
{user: nil, object: hostTeam1, action: cancelHostActivity, allow: false},
{user: nil, object: hostTeam2, action: read, allow: false},
{user: nil, object: hostTeam2, action: write, allow: false},
{user: nil, object: hostTeam2, action: selectiveRead, allow: false},
{user: nil, object: hostTeam2, action: cancelHostActivity, allow: false},
// No host access if the user has no roles.
{user: test.UserNoRoles, object: host, action: read, allow: false},
@ -757,12 +761,15 @@ func TestAuthorizeHost(t *testing.T) {
{user: test.UserNoRoles, object: host, action: list, allow: false},
{user: test.UserNoRoles, object: host, action: selectiveList, allow: false},
{user: test.UserNoRoles, object: host, action: selectiveRead, allow: false},
{user: test.UserNoRoles, object: host, action: cancelHostActivity, allow: false},
{user: test.UserNoRoles, object: hostTeam1, action: read, allow: false},
{user: test.UserNoRoles, object: hostTeam1, action: write, allow: false},
{user: test.UserNoRoles, object: hostTeam1, action: selectiveRead, allow: false},
{user: test.UserNoRoles, object: hostTeam1, action: cancelHostActivity, allow: false},
{user: test.UserNoRoles, object: hostTeam2, action: read, allow: false},
{user: test.UserNoRoles, object: hostTeam2, action: write, allow: false},
{user: test.UserNoRoles, object: hostTeam2, action: selectiveRead, allow: false},
{user: test.UserNoRoles, object: hostTeam2, action: cancelHostActivity, allow: false},
// Global observer can read all
{user: test.UserObserver, object: host, action: read, allow: true},
@ -770,12 +777,15 @@ func TestAuthorizeHost(t *testing.T) {
{user: test.UserObserver, object: host, action: list, allow: true},
{user: test.UserObserver, object: host, action: selectiveList, allow: true},
{user: test.UserObserver, object: host, action: selectiveRead, allow: true},
{user: test.UserObserver, object: host, action: cancelHostActivity, allow: false},
{user: test.UserObserver, object: hostTeam1, action: read, allow: true},
{user: test.UserObserver, object: hostTeam1, action: selectiveRead, allow: true},
{user: test.UserObserver, object: hostTeam1, action: write, allow: false},
{user: test.UserObserver, object: hostTeam1, action: cancelHostActivity, allow: false},
{user: test.UserObserver, object: hostTeam2, action: read, allow: true},
{user: test.UserObserver, object: hostTeam2, action: selectiveRead, allow: true},
{user: test.UserObserver, object: hostTeam2, action: write, allow: false},
{user: test.UserObserver, object: hostTeam2, action: cancelHostActivity, allow: false},
// Global observer+ can read all
{user: test.UserObserverPlus, object: host, action: read, allow: true},
@ -783,12 +793,15 @@ func TestAuthorizeHost(t *testing.T) {
{user: test.UserObserverPlus, object: host, action: list, allow: true},
{user: test.UserObserverPlus, object: host, action: selectiveList, allow: true},
{user: test.UserObserverPlus, object: host, action: selectiveRead, allow: true},
{user: test.UserObserverPlus, object: host, action: cancelHostActivity, allow: false},
{user: test.UserObserverPlus, object: hostTeam1, action: read, allow: true},
{user: test.UserObserverPlus, object: hostTeam1, action: selectiveRead, allow: true},
{user: test.UserObserverPlus, object: hostTeam1, action: write, allow: false},
{user: test.UserObserverPlus, object: hostTeam1, action: cancelHostActivity, allow: false},
{user: test.UserObserverPlus, object: hostTeam2, action: read, allow: true},
{user: test.UserObserverPlus, object: hostTeam2, action: selectiveRead, allow: true},
{user: test.UserObserverPlus, object: hostTeam2, action: write, allow: false},
{user: test.UserObserverPlus, object: hostTeam2, action: cancelHostActivity, allow: false},
// Global admin can read/write all
{user: test.UserAdmin, object: host, action: read, allow: true},
@ -796,12 +809,15 @@ func TestAuthorizeHost(t *testing.T) {
{user: test.UserAdmin, object: host, action: write, allow: true},
{user: test.UserAdmin, object: host, action: list, allow: true},
{user: test.UserAdmin, object: host, action: selectiveList, allow: true},
{user: test.UserAdmin, object: host, action: cancelHostActivity, allow: true},
{user: test.UserAdmin, object: hostTeam1, action: read, allow: true},
{user: test.UserAdmin, object: hostTeam1, action: selectiveRead, allow: true},
{user: test.UserAdmin, object: hostTeam1, action: write, allow: true},
{user: test.UserAdmin, object: hostTeam1, action: cancelHostActivity, allow: true},
{user: test.UserAdmin, object: hostTeam2, action: read, allow: true},
{user: test.UserAdmin, object: hostTeam2, action: selectiveRead, allow: true},
{user: test.UserAdmin, object: hostTeam2, action: write, allow: true},
{user: test.UserAdmin, object: hostTeam2, action: cancelHostActivity, allow: true},
// Global maintainer can read/write all
{user: test.UserMaintainer, object: host, action: read, allow: true},
@ -809,12 +825,15 @@ func TestAuthorizeHost(t *testing.T) {
{user: test.UserMaintainer, object: host, action: write, allow: true},
{user: test.UserMaintainer, object: host, action: list, allow: true},
{user: test.UserMaintainer, object: host, action: selectiveList, allow: true},
{user: test.UserMaintainer, object: host, action: cancelHostActivity, allow: true},
{user: test.UserMaintainer, object: hostTeam1, action: read, allow: true},
{user: test.UserMaintainer, object: hostTeam1, action: selectiveRead, allow: true},
{user: test.UserMaintainer, object: hostTeam1, action: write, allow: true},
{user: test.UserMaintainer, object: hostTeam1, action: cancelHostActivity, allow: true},
{user: test.UserMaintainer, object: hostTeam2, action: read, allow: true},
{user: test.UserMaintainer, object: hostTeam2, action: selectiveRead, allow: true},
{user: test.UserMaintainer, object: hostTeam2, action: write, allow: true},
{user: test.UserMaintainer, object: hostTeam2, action: cancelHostActivity, allow: true},
// Global GitOps can write and selectively read all.
{user: test.UserGitOps, object: host, action: read, allow: false},
@ -822,12 +841,15 @@ func TestAuthorizeHost(t *testing.T) {
{user: test.UserGitOps, object: host, action: selectiveRead, allow: true},
{user: test.UserGitOps, object: host, action: list, allow: false},
{user: test.UserGitOps, object: host, action: selectiveList, allow: true},
{user: test.UserGitOps, object: host, action: cancelHostActivity, allow: false},
{user: test.UserGitOps, object: hostTeam1, action: read, allow: false},
{user: test.UserGitOps, object: hostTeam1, action: write, allow: true},
{user: test.UserGitOps, object: hostTeam1, action: selectiveRead, allow: true},
{user: test.UserGitOps, object: hostTeam1, action: cancelHostActivity, allow: false},
{user: test.UserGitOps, object: hostTeam2, action: read, allow: false},
{user: test.UserGitOps, object: hostTeam2, action: write, allow: true},
{user: test.UserGitOps, object: hostTeam2, action: selectiveRead, allow: true},
{user: test.UserGitOps, object: hostTeam2, action: cancelHostActivity, allow: false},
// Team observer can read only on appropriate team
{user: teamObserver, object: host, action: read, allow: false},
@ -835,12 +857,15 @@ func TestAuthorizeHost(t *testing.T) {
{user: teamObserver, object: host, action: write, allow: false},
{user: teamObserver, object: host, action: list, allow: true},
{user: teamObserver, object: host, action: selectiveList, allow: true},
{user: teamObserver, object: host, action: cancelHostActivity, allow: false},
{user: teamObserver, object: hostTeam1, action: read, allow: true},
{user: teamObserver, object: hostTeam1, action: selectiveRead, allow: true},
{user: teamObserver, object: hostTeam1, action: write, allow: false},
{user: teamObserver, object: hostTeam1, action: cancelHostActivity, allow: false},
{user: teamObserver, object: hostTeam2, action: read, allow: false},
{user: teamObserver, object: hostTeam2, action: selectiveRead, allow: false},
{user: teamObserver, object: hostTeam2, action: write, allow: false},
{user: teamObserver, object: hostTeam2, action: cancelHostActivity, allow: false},
// Team observer+ can read only on appropriate team
{user: teamObserverPlus, object: host, action: read, allow: false},
@ -848,12 +873,15 @@ func TestAuthorizeHost(t *testing.T) {
{user: teamObserverPlus, object: host, action: write, allow: false},
{user: teamObserverPlus, object: host, action: list, allow: true},
{user: teamObserverPlus, object: host, action: selectiveList, allow: true},
{user: teamObserverPlus, object: host, action: cancelHostActivity, allow: false},
{user: teamObserverPlus, object: hostTeam1, action: read, allow: true},
{user: teamObserverPlus, object: hostTeam1, action: selectiveRead, allow: true},
{user: teamObserverPlus, object: hostTeam1, action: write, allow: false},
{user: teamObserverPlus, object: hostTeam1, action: cancelHostActivity, allow: false},
{user: teamObserverPlus, object: hostTeam2, action: read, allow: false},
{user: teamObserverPlus, object: hostTeam2, action: selectiveRead, allow: false},
{user: teamObserverPlus, object: hostTeam2, action: write, allow: false},
{user: teamObserverPlus, object: hostTeam2, action: cancelHostActivity, allow: false},
// Team maintainer can read/write only on appropriate team
{user: teamMaintainer, object: host, action: read, allow: false},
@ -861,11 +889,14 @@ func TestAuthorizeHost(t *testing.T) {
{user: teamMaintainer, object: host, action: write, allow: false},
{user: teamMaintainer, object: host, action: list, allow: true},
{user: teamMaintainer, object: host, action: selectiveList, allow: true},
{user: teamMaintainer, object: host, action: cancelHostActivity, allow: false},
{user: teamMaintainer, object: hostTeam1, action: read, allow: true},
{user: teamMaintainer, object: hostTeam1, action: selectiveRead, allow: true},
{user: teamMaintainer, object: hostTeam1, action: write, allow: true},
{user: teamMaintainer, object: hostTeam1, action: cancelHostActivity, allow: true},
{user: teamMaintainer, object: hostTeam2, action: read, allow: false},
{user: teamMaintainer, object: hostTeam2, action: write, allow: false},
{user: teamMaintainer, object: hostTeam2, action: cancelHostActivity, allow: false},
// Team admin can read/write only on appropriate team
{user: teamAdmin, object: host, action: read, allow: false},
@ -873,23 +904,29 @@ func TestAuthorizeHost(t *testing.T) {
{user: teamAdmin, object: host, action: write, allow: false},
{user: teamAdmin, object: host, action: list, allow: true},
{user: teamAdmin, object: host, action: selectiveList, allow: true},
{user: teamAdmin, object: host, action: cancelHostActivity, allow: false},
{user: teamAdmin, object: hostTeam1, action: read, allow: true},
{user: teamAdmin, object: hostTeam1, action: write, allow: true},
{user: teamAdmin, object: hostTeam1, action: cancelHostActivity, allow: true},
{user: teamAdmin, object: hostTeam2, action: read, allow: false},
{user: teamAdmin, object: hostTeam2, action: write, allow: false},
{user: teamAdmin, object: hostTeam2, action: cancelHostActivity, allow: false},
// Team GitOps can cannot read hosts, but it can write and selectively read them.
{user: teamGitOps, object: host, action: read, allow: false},
{user: teamGitOps, object: host, action: write, allow: false},
{user: teamGitOps, object: host, action: selectiveRead, allow: false},
{user: teamGitOps, object: host, action: cancelHostActivity, allow: false},
{user: teamGitOps, object: hostTeam1, action: read, allow: false},
{user: teamGitOps, object: hostTeam1, action: list, allow: false},
{user: teamGitOps, object: hostTeam1, action: selectiveList, allow: true},
{user: teamGitOps, object: hostTeam1, action: selectiveRead, allow: true},
{user: teamGitOps, object: hostTeam1, action: write, allow: false},
{user: teamGitOps, object: hostTeam1, action: cancelHostActivity, allow: false},
{user: teamGitOps, object: hostTeam2, action: read, allow: false},
{user: teamGitOps, object: hostTeam2, action: write, allow: false},
{user: teamGitOps, object: hostTeam2, action: selectiveRead, allow: false},
{user: teamGitOps, object: hostTeam2, action: cancelHostActivity, allow: false},
})
}

View file

@ -667,6 +667,10 @@ func (ds *Datastore) CleanupActivitiesAndAssociatedData(ctx context.Context, max
return nil
}
func (ds *Datastore) CancelHostUpcomingActivity(ctx context.Context, hostID uint, upcomingActivityID string) error {
panic("unimplemented")
}
// This function activates the next upcoming activity, if any, for the specified host.
// It does a few things to achieve this:
// - If there was an activity already marked as activated (activated_at is

View file

@ -0,0 +1,115 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20250331154206, Down_20250331154206)
}
func Up_20250331154206(tx *sql.Tx) error {
_, err := tx.Exec(`
ALTER TABLE host_script_results
ADD COLUMN canceled TINYINT(1) NOT NULL DEFAULT '0'
`)
if err != nil {
return fmt.Errorf("failed to alter host_script_results: %w", err)
}
_, err = tx.Exec(`
ALTER TABLE host_vpp_software_installs
ADD COLUMN canceled TINYINT(1) NOT NULL DEFAULT '0'
`)
if err != nil {
return fmt.Errorf("failed to alter host_vpp_software_installs: %w", err)
}
if _, err := tx.Exec(`
ALTER TABLE host_software_installs
ADD COLUMN canceled TINYINT(1) NOT NULL DEFAULT '0',
CHANGE COLUMN execution_status execution_status
ENUM('pending_install', 'failed_install', 'installed', 'pending_uninstall', 'failed_uninstall', 'canceled_install', 'canceled_uninstall')
GENERATED ALWAYS AS (
CASE
WHEN canceled = 1 AND uninstall = 0 THEN 'canceled_install'
WHEN canceled = 1 AND uninstall = 1 THEN 'canceled_uninstall'
WHEN post_install_script_exit_code IS NOT NULL AND
post_install_script_exit_code = 0 THEN 'installed'
WHEN post_install_script_exit_code IS NOT NULL AND
post_install_script_exit_code != 0 THEN 'failed_install'
WHEN install_script_exit_code IS NOT NULL AND
install_script_exit_code = 0 THEN 'installed'
WHEN install_script_exit_code IS NOT NULL AND
install_script_exit_code != 0 THEN 'failed_install'
WHEN pre_install_query_output IS NOT NULL AND
pre_install_query_output = '' THEN 'failed_install'
WHEN host_id IS NOT NULL AND uninstall = 0 THEN 'pending_install'
WHEN uninstall_script_exit_code IS NOT NULL AND
uninstall_script_exit_code != 0 THEN 'failed_uninstall'
WHEN uninstall_script_exit_code IS NOT NULL AND
uninstall_script_exit_code = 0 THEN NULL -- available for install again
WHEN host_id IS NOT NULL AND uninstall = 1 THEN 'pending_uninstall'
ELSE NULL -- not installed from Fleet installer or successfully uninstalled
END
) VIRTUAL NULL,
CHANGE COLUMN status status
ENUM('pending_install', 'failed_install', 'installed', 'pending_uninstall', 'failed_uninstall', 'canceled_install', 'canceled_uninstall')
GENERATED ALWAYS AS (
CASE
WHEN removed = 1 THEN NULL
WHEN canceled = 1 AND uninstall = 0 THEN 'canceled_install'
WHEN canceled = 1 AND uninstall = 1 THEN 'canceled_uninstall'
WHEN post_install_script_exit_code IS NOT NULL AND
post_install_script_exit_code = 0 THEN 'installed'
WHEN post_install_script_exit_code IS NOT NULL AND
post_install_script_exit_code != 0 THEN 'failed_install'
WHEN install_script_exit_code IS NOT NULL AND
install_script_exit_code = 0 THEN 'installed'
WHEN install_script_exit_code IS NOT NULL AND
install_script_exit_code != 0 THEN 'failed_install'
WHEN pre_install_query_output IS NOT NULL AND
pre_install_query_output = '' THEN 'failed_install'
WHEN host_id IS NOT NULL AND uninstall = 0 THEN 'pending_install'
WHEN uninstall_script_exit_code IS NOT NULL AND
uninstall_script_exit_code != 0 THEN 'failed_uninstall'
WHEN uninstall_script_exit_code IS NOT NULL AND
uninstall_script_exit_code = 0 THEN NULL -- available for install again
WHEN host_id IS NOT NULL AND uninstall = 1 THEN 'pending_uninstall'
ELSE NULL -- not installed from Fleet installer or successfully uninstalled
END
) STORED NULL
`); err != nil {
return fmt.Errorf("failed to add canceled column and update statuses generated columns on host_software_installs: %w", err)
}
return nil
}
func Down_20250331154206(tx *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,92 @@
package tables
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUp_20250331154206(t *testing.T) {
db := applyUpToPrev(t)
// this is basically the same test as 20241021224359_AddExecutionStatusToHostSoftwareInstalls_test.go
// to ensure that the same state still corresponds to the same resulting statuses, after migration
// and change of the status and execution_status columns.
hostID := insertHost(t, db, nil)
dataStmts := `
INSERT INTO script_contents (id, md5_checksum, contents) VALUES
(1, 'checksum', 'script content');
INSERT INTO software_titles (id, name, source, browser) VALUES (1, 'Foo.app', 'apps', '');
INSERT INTO software_installers
(id, title_id, filename, version, platform, install_script_content_id, storage_id, package_ids, uninstall_script_content_id)
VALUES
(1, 1, 'foo-installer.pkg', '1.1', 'darwin', 1, 'storage-id', '', 1);
`
_, err := db.Exec(dataStmts)
require.NoError(t, err)
hsiStmt := `
INSERT INTO host_software_installs (
host_id,
execution_id,
software_installer_id,
install_script_exit_code,
uninstall_script_exit_code,
updated_at,
uninstall,
removed
) VALUES (?, ?, ?, ?, ?, '2024-10-01 00:00:00', ?, 1)`
hsiInstall := execNoErrLastID(t, db, hsiStmt, hostID, "execution-id1", 1, 0, nil, 0)
hsiUninstall := execNoErrLastID(t, db, hsiStmt, hostID, "execution-id2", 1, nil, 0, 1)
// Apply current migration.
applyNext(t, db)
var statuses struct {
Status *string `db:"status"`
ExecutionStatus *string `db:"execution_status"`
}
err = db.Get(&statuses, "SELECT status, execution_status FROM host_software_installs WHERE id = ?", hsiInstall)
require.NoError(t, err)
require.NotNil(t, statuses.ExecutionStatus)
require.Equal(t, "installed", *statuses.ExecutionStatus)
require.Nil(t, statuses.Status)
err = db.Get(&statuses, "SELECT status, execution_status FROM host_software_installs WHERE id = ?", hsiUninstall)
require.NoError(t, err)
require.Nil(t, statuses.ExecutionStatus) // uninstalls have null status
require.Nil(t, statuses.Status)
execNoErr(t, db, `UPDATE host_software_installs SET removed = 0`)
err = db.Get(&statuses, "SELECT status, execution_status FROM host_software_installs WHERE id = ?", hsiInstall)
require.NoError(t, err)
require.NotNil(t, statuses.ExecutionStatus)
require.Equal(t, "installed", *statuses.ExecutionStatus)
require.NotNil(t, statuses.Status)
require.Equal(t, "installed", *statuses.Status)
err = db.Get(&statuses, "SELECT status, execution_status FROM host_software_installs WHERE id = ?", hsiUninstall)
require.NoError(t, err)
require.Nil(t, statuses.ExecutionStatus) // uninstalls have null status
require.Nil(t, statuses.Status)
execNoErr(t, db, `UPDATE host_software_installs SET canceled = 1`)
err = db.Get(&statuses, "SELECT status, execution_status FROM host_software_installs WHERE id = ?", hsiInstall)
require.NoError(t, err)
require.NotNil(t, statuses.ExecutionStatus)
require.Equal(t, "canceled_install", *statuses.ExecutionStatus)
require.NotNil(t, statuses.Status)
require.Equal(t, "canceled_install", *statuses.Status)
err = db.Get(&statuses, "SELECT status, execution_status FROM host_software_installs WHERE id = ?", hsiUninstall)
require.NoError(t, err)
require.NotNil(t, statuses.ExecutionStatus)
require.Equal(t, "canceled_uninstall", *statuses.ExecutionStatus)
require.NotNil(t, statuses.Status)
require.Equal(t, "canceled_uninstall", *statuses.Status)
}

File diff suppressed because one or more lines are too long

View file

@ -9,6 +9,8 @@ const (
ActionWrite = "write"
// ActionWriteHostLabel refers to writing labels on hosts.
ActionWriteHostLabel = "write_host_label"
// ActionCancelHostActivity refers to canceling an upcoming activity on a host.
ActionCancelHostActivity = "cancel_host_activity"
//
// User specific actions

View file

@ -679,6 +679,7 @@ type Datastore interface {
ListActivities(ctx context.Context, opt ListActivitiesOptions) ([]*Activity, *PaginationMetadata, error)
MarkActivitiesAsStreamed(ctx context.Context, activityIDs []uint) error
ListHostUpcomingActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*UpcomingActivity, *PaginationMetadata, error)
CancelHostUpcomingActivity(ctx context.Context, hostID uint, upcomingActivityID string) error
ListHostPastActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*Activity, *PaginationMetadata, error)
IsExecutionPendingForHost(ctx context.Context, hostID uint, scriptID uint) (bool, error)

View file

@ -602,6 +602,11 @@ type Service interface {
// ListHostPastActivities lists the activities that have already happened for the specified host.
ListHostPastActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*Activity, *PaginationMetadata, error)
// CancelHostUpcomingActivity cancels an upcoming activity for the specified
// host. If the activity does not exist in the queue of upcoming activities
// (e.g. it did complete), it returns a not found error.
CancelHostUpcomingActivity(ctx context.Context, hostID uint, upcomingActivityID string) error
// /////////////////////////////////////////////////////////////////////////////
// UserRolesService

View file

@ -498,6 +498,8 @@ type MarkActivitiesAsStreamedFunc func(ctx context.Context, activityIDs []uint)
type ListHostUpcomingActivitiesFunc func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.UpcomingActivity, *fleet.PaginationMetadata, error)
type CancelHostUpcomingActivityFunc func(ctx context.Context, hostID uint, upcomingActivityID string) error
type ListHostPastActivitiesFunc func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error)
type IsExecutionPendingForHostFunc func(ctx context.Context, hostID uint, scriptID uint) (bool, error)
@ -714,7 +716,7 @@ type ReplaceHostBatteriesFunc func(ctx context.Context, id uint, mappings []*fle
type VerifyEnrollSecretFunc func(ctx context.Context, secret string) (*fleet.EnrollSecret, error)
type IsEnrollSecretAvailableFunc func(ctx context.Context, secret string, new bool, teamID *uint) (bool, error)
type IsEnrollSecretAvailableFunc func(ctx context.Context, secret string, isNew bool, teamID *uint) (bool, error)
type EnrollHostFunc func(ctx context.Context, isMDMEnabled bool, osqueryHostId string, hardwareUUID string, hardwareSerial string, nodeKey string, teamID *uint, cooldown time.Duration) (*fleet.Host, error)
@ -2011,6 +2013,9 @@ type DataStore struct {
ListHostUpcomingActivitiesFunc ListHostUpcomingActivitiesFunc
ListHostUpcomingActivitiesFuncInvoked bool
CancelHostUpcomingActivityFunc CancelHostUpcomingActivityFunc
CancelHostUpcomingActivityFuncInvoked bool
ListHostPastActivitiesFunc ListHostPastActivitiesFunc
ListHostPastActivitiesFuncInvoked bool
@ -4877,6 +4882,13 @@ func (s *DataStore) ListHostUpcomingActivities(ctx context.Context, hostID uint,
return s.ListHostUpcomingActivitiesFunc(ctx, hostID, opt)
}
func (s *DataStore) CancelHostUpcomingActivity(ctx context.Context, hostID uint, upcomingActivityID string) error {
s.mu.Lock()
s.CancelHostUpcomingActivityFuncInvoked = true
s.mu.Unlock()
return s.CancelHostUpcomingActivityFunc(ctx, hostID, upcomingActivityID)
}
func (s *DataStore) ListHostPastActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) {
s.mu.Lock()
s.ListHostPastActivitiesFuncInvoked = true
@ -5633,11 +5645,11 @@ func (s *DataStore) VerifyEnrollSecret(ctx context.Context, secret string) (*fle
return s.VerifyEnrollSecretFunc(ctx, secret)
}
func (s *DataStore) IsEnrollSecretAvailable(ctx context.Context, secret string, new bool, teamID *uint) (bool, error) {
func (s *DataStore) IsEnrollSecretAvailable(ctx context.Context, secret string, isNew bool, teamID *uint) (bool, error) {
s.mu.Lock()
s.IsEnrollSecretAvailableFuncInvoked = true
s.mu.Unlock()
return s.IsEnrollSecretAvailableFunc(ctx, secret, new, teamID)
return s.IsEnrollSecretAvailableFunc(ctx, secret, isNew, teamID)
}
func (s *DataStore) EnrollHost(ctx context.Context, isMDMEnabled bool, osqueryHostId string, hardwareUUID string, hardwareSerial string, nodeKey string, teamID *uint, cooldown time.Duration) (*fleet.Host, error) {

View file

@ -244,3 +244,45 @@ func (svc *Service) ListHostPastActivities(ctx context.Context, hostID uint, opt
return svc.ds.ListHostPastActivities(ctx, hostID, opt)
}
////////////////////////////////////////////////////////////////////////////////
// Cancel host upcoming activity
////////////////////////////////////////////////////////////////////////////////
type cancelHostUpcomingActivityRequest struct {
HostID uint `url:"id"`
ActivityID string `url:"activity_id"`
}
type cancelHostUpcomingActivityResponse struct {
Err error `json:"error,omitempty"`
}
func (r cancelHostUpcomingActivityResponse) Error() error { return r.Err }
func (r cancelHostUpcomingActivityResponse) Status() int { return http.StatusNoContent }
func cancelHostUpcomingActivityEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*cancelHostUpcomingActivityRequest)
err := svc.CancelHostUpcomingActivity(ctx, req.HostID, req.ActivityID)
if err != nil {
return cancelHostUpcomingActivityResponse{Err: err}, nil
}
return cancelHostUpcomingActivityResponse{}, nil
}
func (svc *Service) CancelHostUpcomingActivity(ctx context.Context, hostID uint, upcomingActivityID string) error {
// First ensure the user has access to list hosts, then check the specific
// host once team_id is loaded.
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return err
}
host, err := svc.ds.HostLite(ctx, hostID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get host")
}
// Authorize again with team loaded now that we have team_id
if err := svc.authz.Authorize(ctx, host, fleet.ActionCancelHostActivity); err != nil {
return err
}
return svc.ds.CancelHostUpcomingActivity(ctx, hostID, upcomingActivityID)
}

View file

@ -9,6 +9,7 @@ import (
"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"
@ -364,3 +365,117 @@ func TestActivityWebhooksDisabled(t *testing.T) {
require.True(t, ds.NewActivityFuncInvoked)
assert.Equal(t, user, activityUser)
}
func TestCancelHostUpcomingActivityAuth(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}})
const (
teamHostID = 1
globalHostID = 2
)
teamHost := &fleet.Host{TeamID: ptr.Uint(1), Platform: "darwin"}
globalHost := &fleet.Host{Platform: "darwin"}
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
if hostID == teamHostID {
return teamHost, nil
}
return globalHost, nil
}
ds.CancelHostUpcomingActivityFunc = func(ctx context.Context, hostID uint, actID string) error {
return nil
}
cases := []struct {
name string
user *fleet.User
shouldFailGlobal bool
shouldFailTeam bool
}{
{
name: "global observer",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
shouldFailGlobal: true,
shouldFailTeam: true,
},
{
name: "team observer",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
shouldFailGlobal: true,
shouldFailTeam: true,
},
{
name: "global observer plus",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)},
shouldFailGlobal: true,
shouldFailTeam: true,
},
{
name: "team observer plus",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}},
shouldFailGlobal: true,
shouldFailTeam: true,
},
{
name: "global admin",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
shouldFailGlobal: false,
shouldFailTeam: false,
},
{
name: "team admin",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
shouldFailGlobal: true,
shouldFailTeam: false,
},
{
name: "global maintainer",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
shouldFailGlobal: false,
shouldFailTeam: false,
},
{
name: "team maintainer",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
shouldFailGlobal: true,
shouldFailTeam: false,
},
{
name: "team admin wrong team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 42}, Role: fleet.RoleAdmin}}},
shouldFailGlobal: true,
shouldFailTeam: true,
},
{
name: "team maintainer wrong team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 42}, Role: fleet.RoleMaintainer}}},
shouldFailGlobal: true,
shouldFailTeam: true,
},
{
name: "global gitops",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)},
shouldFailGlobal: true,
shouldFailTeam: true,
},
{
name: "team gitops",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}},
shouldFailGlobal: true,
shouldFailTeam: true,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
err := svc.CancelHostUpcomingActivity(ctx, globalHostID, "abc")
checkAuthErr(t, tt.shouldFailGlobal, err)
err = svc.CancelHostUpcomingActivity(ctx, teamHostID, "abc")
checkAuthErr(t, tt.shouldFailTeam, err)
})
}
}

View file

@ -480,6 +480,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/scripts", getHostScriptDetailsEndpoint, getHostScriptDetailsRequest{})
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/activities/upcoming", listHostUpcomingActivitiesEndpoint, listHostUpcomingActivitiesRequest{})
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/activities", listHostPastActivitiesEndpoint, listHostPastActivitiesRequest{})
ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/activities/upcoming/{activity_id}", cancelHostUpcomingActivityEndpoint, cancelHostUpcomingActivityRequest{})
ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/lock", lockHostEndpoint, lockHostRequest{})
ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/unlock", unlockHostEndpoint, unlockHostRequest{})
ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/wipe", wipeHostEndpoint, wipeHostRequest{})