249 lines
9.4 KiB
Go
249 lines
9.4 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
|
|
)
|
|
|
|
func (svc *Service) GetActivitiesWebhookSettings(ctx context.Context) (fleet.ActivitiesWebhookSettings, error) {
|
|
appConfig, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return fleet.ActivitiesWebhookSettings{}, ctxerr.Wrap(ctx, err, "get app config for activities webhook")
|
|
}
|
|
return appConfig.WebhookSettings.ActivitiesWebhook, nil
|
|
}
|
|
|
|
func (svc *Service) ActivateNextUpcomingActivityForHost(ctx context.Context, hostID uint, fromCompletedExecID string) error {
|
|
return svc.ds.ActivateNextUpcomingActivityForHost(ctx, hostID, fromCompletedExecID)
|
|
}
|
|
|
|
func (svc *Service) NewActivity(ctx context.Context, user *fleet.User, activity activity_api.ActivityDetails) error {
|
|
var apiUser *activity_api.User
|
|
if user != nil {
|
|
apiUser = &activity_api.User{
|
|
ID: user.ID,
|
|
Name: user.Name,
|
|
Email: user.Email,
|
|
Deleted: user.Deleted,
|
|
}
|
|
}
|
|
return svc.activitySvc.NewActivity(ctx, apiUser, activity)
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// List host upcoming activities
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type listHostUpcomingActivitiesRequest struct {
|
|
HostID uint `url:"id"`
|
|
ListOptions fleet.ListOptions `url:"list_options"`
|
|
}
|
|
|
|
type listHostUpcomingActivitiesResponse struct {
|
|
Meta *fleet.PaginationMetadata `json:"meta"`
|
|
Activities []*fleet.UpcomingActivity `json:"activities"`
|
|
Count uint `json:"count"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listHostUpcomingActivitiesResponse) Error() error { return r.Err }
|
|
|
|
func listHostUpcomingActivitiesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*listHostUpcomingActivitiesRequest)
|
|
acts, meta, err := svc.ListHostUpcomingActivities(ctx, req.HostID, req.ListOptions)
|
|
if err != nil {
|
|
return listHostUpcomingActivitiesResponse{Err: err}, nil
|
|
}
|
|
|
|
return listHostUpcomingActivitiesResponse{Meta: meta, Activities: acts, Count: meta.TotalResults}, nil
|
|
}
|
|
|
|
// ListHostUpcomingActivities returns a slice of upcoming activities for the
|
|
// specified host.
|
|
func (svc *Service) ListHostUpcomingActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.UpcomingActivity, *fleet.PaginationMetadata, 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 nil, nil, err
|
|
}
|
|
host, err := svc.ds.HostLite(ctx, hostID)
|
|
if err != nil {
|
|
return nil, nil, 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.ActionRead); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// cursor-based pagination is not supported for upcoming activities
|
|
opt.After = ""
|
|
// custom ordering is not supported, always by upcoming queue order
|
|
// (acual order is in the query, not set via ListOptions)
|
|
opt.OrderKey = ""
|
|
opt.OrderDirection = fleet.OrderAscending
|
|
// no matching query support
|
|
opt.MatchQuery = ""
|
|
// always include metadata
|
|
opt.IncludeMetadata = true
|
|
|
|
return svc.ds.ListHostUpcomingActivities(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, executionID 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
|
|
}
|
|
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return fleet.ErrNoContext
|
|
}
|
|
|
|
// prevent cancellation of lock/wipe that are already activated
|
|
actMeta, err := svc.ds.GetHostUpcomingActivityMeta(ctx, hostID, executionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if actMeta.ActivatedAt != nil &&
|
|
(actMeta.WellKnownAction == fleet.WellKnownActionLock || actMeta.WellKnownAction == fleet.WellKnownActionWipe) {
|
|
return &fleet.BadRequestError{
|
|
Message: "Couldn't cancel activity. Lock and wipe can't be canceled if they're about to run to prevent you from losing access to the host.",
|
|
}
|
|
}
|
|
|
|
// Capture VPP release info BEFORE ds.CancelHostUpcomingActivity runs. For
|
|
// VPP installs that haven't yet been activated, the reservation info lives
|
|
// in upcoming_activities.payload + vpp_app_upcoming_activities, and the
|
|
// cancel transaction unconditionally deletes the upcoming row — so a
|
|
// post-cancel lookup would return notFound and we'd silently leak the seat.
|
|
// Best-effort: any lookup failure here is logged when the release path
|
|
// runs and doesn't block the cancel response.
|
|
releaseInfo, releaseLookupErr := svc.ds.GetVPPInstallReleaseInfoForCancel(ctx, host.ID, executionID)
|
|
|
|
pastAct, err := svc.ds.CancelHostUpcomingActivity(ctx, hostID, executionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if pastAct != nil {
|
|
// If a VPP install was canceled, release the reserved license seat
|
|
// (if any). Best-effort: failures shouldn't block the cancel response.
|
|
if _, isVPPCancel := pastAct.(fleet.ActivityTypeCanceledInstallAppStoreApp); isVPPCancel {
|
|
if rErr := svc.releaseVPPSeat(ctx, host, releaseInfo, releaseLookupErr); rErr != nil {
|
|
svc.logger.ErrorContext(ctx, "failed to release reserved VPP license on cancel",
|
|
"err", rErr, "host_id", host.ID, "execution_id", executionID)
|
|
}
|
|
}
|
|
if err := svc.NewActivity(ctx, vc.User, pastAct); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "create activity for cancelation")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// releaseVPPSeat disassociates the VPP license seat reserved by the canceled
|
|
// install, when applicable. The release info is captured by the caller before
|
|
// ds.CancelHostUpcomingActivity runs (the pre-activation case relies on data
|
|
// the cancel transaction deletes). The seat is released only when the canceled
|
|
// install was the one that originally reserved it (associated_event_id is
|
|
// non-empty) AND no other still-active install for the same (host, adam_id)
|
|
// needs the seat. Personal-enrollment hosts are disassociated by clientUserId
|
|
// (matching how they were assigned); device-enrolled hosts by serial number.
|
|
func (svc *Service) releaseVPPSeat(ctx context.Context, host *fleet.Host, info *fleet.VPPInstallReleaseInfo, lookupErr error) error {
|
|
if lookupErr != nil {
|
|
if fleet.IsNotFound(lookupErr) {
|
|
return nil
|
|
}
|
|
return ctxerr.Wrap(ctx, lookupErr, "get vpp install release info")
|
|
}
|
|
if info == nil || info.AssociatedEventID == "" || info.HasOtherActiveInstall {
|
|
return nil
|
|
}
|
|
|
|
tokenDB, err := svc.ds.GetVPPTokenByTeamID(ctx, host.TeamID)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return nil
|
|
}
|
|
return ctxerr.Wrap(ctx, err, "get vpp token for seat release")
|
|
}
|
|
|
|
hostMDM, err := svc.ds.GetHostMDM(ctx, host.ID)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return ctxerr.Wrap(ctx, err, "get host mdm for seat release")
|
|
}
|
|
isPersonal := hostMDM != nil && hostMDM.IsPersonalEnrollment
|
|
|
|
// PricingParam: STDQ is the standard tier used by Fleet's install path.
|
|
// If a customer uses PLUS we'd need to look up the asset here — defer until
|
|
// observed in the wild.
|
|
req := &vpp.DisassociateAssetsRequest{
|
|
Assets: []vpp.Asset{{AdamID: info.AdamID, PricingParam: "STDQ"}},
|
|
}
|
|
if isPersonal {
|
|
managedAppleID, err := svc.ds.GetHostManagedAppleID(ctx, host.ID)
|
|
if err != nil || managedAppleID == "" {
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return ctxerr.Wrap(ctx, err, "get managed apple id for seat release")
|
|
}
|
|
return nil
|
|
}
|
|
clientUser, err := svc.ds.GetVPPClientUser(ctx, tokenDB.ID, managedAppleID)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return nil
|
|
}
|
|
return ctxerr.Wrap(ctx, err, "get vpp client user for seat release")
|
|
}
|
|
req.ClientUserIds = []string{clientUser.ClientUserID}
|
|
} else {
|
|
req.SerialNumbers = []string{host.HardwareSerial}
|
|
}
|
|
|
|
if _, err := vpp.DisassociateAssets(tokenDB.Token, req); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "disassociate vpp assets on cancel")
|
|
}
|
|
return nil
|
|
}
|