fleet/server/service/software_installers.go
Lucas Manuel Rodriguez 8d664bd456
Make software batch endpoint asynchronous (#22258)
#22069

API changes: https://github.com/fleetdm/fleet/pull/22259

QAd by applying 10 pieces of software on a team, which took 3+ minutes
in total (which, before these changes was timing out at 100s.)

With this approach, a GitOps CI run timing out might leave the
background process running (which will eventually be applied to the
database). The team discussed and agreed that we can fix this edge case
later.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files)
for more information.
- [X] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [X] Added/updated tests
- [X] Manual QA for all new/changed functionality
2024-09-20 11:55:47 -03:00

706 lines
24 KiB
Go

package service
import (
"context"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/url"
"strconv"
"github.com/docker/go-units"
authzctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
)
type uploadSoftwareInstallerRequest struct {
File *multipart.FileHeader
TeamID *uint
InstallScript string
PreInstallQuery string
PostInstallScript string
SelfService bool
UninstallScript string
}
type updateSoftwareInstallerRequest struct {
TitleID uint `url:"id"`
File *multipart.FileHeader
TeamID *uint
InstallScript *string
PreInstallQuery *string
PostInstallScript *string
UninstallScript *string
SelfService *bool
}
type uploadSoftwareInstallerResponse struct {
Err error `json:"error,omitempty"`
}
// MaxSoftwareInstallerSize is the maximum size allowed for software
// installers. This is enforced by the endpoints that upload installers.
const MaxSoftwareInstallerSize = 500 * units.MiB
// TODO: We parse the whole body before running svc.authz.Authorize.
// An authenticated but unauthorized user could abuse this.
func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
decoded := updateSoftwareInstallerRequest{}
// populate software title ID since we're overriding the decoder that would do it for us
titleID, err := uint32FromRequest(r, "id")
if err != nil {
return nil, badRequestErr("intFromRequest", err)
}
decoded.TitleID = uint(titleID)
err = r.ParseMultipartForm(512 * units.MiB)
if err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
return nil, &fleet.BadRequestError{
Message: "The maximum file size is 500 MB.",
InternalErr: err,
}
}
var nerr net.Error
if errors.As(err, &nerr) && nerr.Timeout() {
return nil, fleet.NewUserMessageError(
ctxerr.New(ctx, "Couldn't upload. Please ensure your internet connection speed is sufficient and stable."),
http.StatusRequestTimeout,
)
}
return nil, &fleet.BadRequestError{
Message: "failed to parse multipart form: " + err.Error(),
InternalErr: err,
}
}
// unlike for uploadSoftwareInstallerRequest, every field is optional, including the file upload
if r.MultipartForm.File["software"] != nil || len(r.MultipartForm.File["software"]) > 0 {
decoded.File = r.MultipartForm.File["software"][0]
if decoded.File.Size > MaxSoftwareInstallerSize {
// Should never happen here since the request's body is limited to the maximum size.
return nil, &fleet.BadRequestError{
Message: "The maximum file size is 500 MB.",
}
}
}
// default is no team
val, ok := r.MultipartForm.Value["team_id"]
if ok {
teamID, err := strconv.ParseUint(val[0], 10, 32)
if err != nil {
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Invalid team_id: %s", val[0])}
}
decoded.TeamID = ptr.Uint(uint(teamID))
}
installScriptMultipart, ok := r.MultipartForm.Value["install_script"]
if ok && len(installScriptMultipart) > 0 {
decoded.InstallScript = &installScriptMultipart[0]
}
preinstallQueryMultipart, ok := r.MultipartForm.Value["pre_install_query"]
if ok && len(preinstallQueryMultipart) > 0 {
decoded.PreInstallQuery = &preinstallQueryMultipart[0]
}
postInstallScriptMultipart, ok := r.MultipartForm.Value["post_install_script"]
if ok && len(postInstallScriptMultipart) > 0 {
decoded.PostInstallScript = &postInstallScriptMultipart[0]
}
uninstallScriptMultipart, ok := r.MultipartForm.Value["uninstall_script"]
if ok && len(uninstallScriptMultipart) > 0 {
decoded.UninstallScript = &uninstallScriptMultipart[0]
}
val, ok = r.MultipartForm.Value["self_service"]
if ok && len(val) > 0 && val[0] != "" {
parsed, err := strconv.ParseBool(val[0])
if err != nil {
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode self_service bool in multipart form: %s", err.Error())}
}
decoded.SelfService = &parsed
}
return &decoded, nil
}
func updateSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*updateSoftwareInstallerRequest)
payload := &fleet.UpdateSoftwareInstallerPayload{
TitleID: req.TitleID,
TeamID: req.TeamID,
InstallScript: req.InstallScript,
PreInstallQuery: req.PreInstallQuery,
PostInstallScript: req.PostInstallScript,
UninstallScript: req.UninstallScript,
SelfService: req.SelfService,
}
if req.File != nil {
ff, err := req.File.Open()
if err != nil {
return uploadSoftwareInstallerResponse{Err: err}, nil
}
defer ff.Close()
payload.InstallerFile = ff
payload.Filename = req.File.Filename
}
installer, err := svc.UpdateSoftwareInstaller(ctx, payload)
if err != nil {
return uploadSoftwareInstallerResponse{Err: err}, nil
}
return getSoftwareInstallerResponse{SoftwareInstaller: installer}, nil
}
func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
// TODO: We parse the whole body before running svc.authz.Authorize.
// An authenticated but unauthorized user could abuse this.
func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
decoded := uploadSoftwareInstallerRequest{}
err := r.ParseMultipartForm(512 * units.MiB)
if err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
return nil, &fleet.BadRequestError{
Message: "The maximum file size is 500 MB.",
InternalErr: err,
}
}
var nerr net.Error
if errors.As(err, &nerr) && nerr.Timeout() {
return nil, fleet.NewUserMessageError(
ctxerr.New(ctx, "Couldn't upload. Please ensure your internet connection speed is sufficient and stable."),
http.StatusRequestTimeout,
)
}
return nil, &fleet.BadRequestError{
Message: "failed to parse multipart form: " + err.Error(),
InternalErr: err,
}
}
if r.MultipartForm.File["software"] == nil || len(r.MultipartForm.File["software"]) == 0 {
return nil, &fleet.BadRequestError{
Message: "software multipart field is required",
InternalErr: err,
}
}
decoded.File = r.MultipartForm.File["software"][0]
if decoded.File.Size > MaxSoftwareInstallerSize {
// Should never happen here since the request's body is limited to the
// maximum size.
return nil, &fleet.BadRequestError{
Message: "The maximum file size is 500 MB.",
}
}
// default is no team
val, ok := r.MultipartForm.Value["team_id"]
if ok {
teamID, err := strconv.ParseUint(val[0], 10, 32)
if err != nil {
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Invalid team_id: %s", val[0])}
}
decoded.TeamID = ptr.Uint(uint(teamID))
}
val, ok = r.MultipartForm.Value["install_script"]
if ok && len(val) > 0 {
decoded.InstallScript = val[0]
}
val, ok = r.MultipartForm.Value["uninstall_script"]
if ok && len(val) > 0 {
decoded.UninstallScript = val[0]
}
val, ok = r.MultipartForm.Value["pre_install_query"]
if ok && len(val) > 0 {
decoded.PreInstallQuery = val[0]
}
val, ok = r.MultipartForm.Value["post_install_script"]
if ok && len(val) > 0 {
decoded.PostInstallScript = val[0]
}
val, ok = r.MultipartForm.Value["self_service"]
if ok && len(val) > 0 && val[0] != "" {
parsed, err := strconv.ParseBool(val[0])
if err != nil {
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode self_service bool in multipart form: %s", err.Error())}
}
decoded.SelfService = parsed
}
return &decoded, nil
}
func (r uploadSoftwareInstallerResponse) error() error { return r.Err }
func uploadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*uploadSoftwareInstallerRequest)
ff, err := req.File.Open()
if err != nil {
return uploadSoftwareInstallerResponse{Err: err}, nil
}
defer ff.Close()
payload := &fleet.UploadSoftwareInstallerPayload{
TeamID: req.TeamID,
InstallScript: req.InstallScript,
PreInstallQuery: req.PreInstallQuery,
PostInstallScript: req.PostInstallScript,
InstallerFile: ff,
Filename: req.File.Filename,
SelfService: req.SelfService,
UninstallScript: req.UninstallScript,
}
if err := svc.UploadSoftwareInstaller(ctx, payload); err != nil {
return uploadSoftwareInstallerResponse{Err: err}, nil
}
return &uploadSoftwareInstallerResponse{}, nil
}
func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
type deleteSoftwareInstallerRequest struct {
TeamID *uint `query:"team_id"`
TitleID uint `url:"title_id"`
}
type deleteSoftwareInstallerResponse struct {
Err error `json:"error,omitempty"`
}
func (r deleteSoftwareInstallerResponse) error() error { return r.Err }
func (r deleteSoftwareInstallerResponse) Status() int { return http.StatusNoContent }
func deleteSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*deleteSoftwareInstallerRequest)
err := svc.DeleteSoftwareInstaller(ctx, req.TitleID, req.TeamID)
if err != nil {
return deleteSoftwareInstallerResponse{Err: err}, nil
}
return deleteSoftwareInstallerResponse{}, nil
}
func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
type getSoftwareInstallerRequest struct {
Alt string `query:"alt,optional"`
TeamID *uint `query:"team_id"`
TitleID uint `url:"title_id"`
}
type downloadSoftwareInstallerRequest struct {
TitleID uint `url:"title_id"`
Token string `url:"token"`
}
func getSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*getSoftwareInstallerRequest)
payload, err := svc.DownloadSoftwareInstaller(ctx, false, req.Alt, req.TitleID, req.TeamID)
if err != nil {
return orbitDownloadSoftwareInstallerResponse{Err: err}, nil
}
return orbitDownloadSoftwareInstallerResponse{payload: payload}, nil
}
func getSoftwareInstallerTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*getSoftwareInstallerRequest)
token, err := svc.GenerateSoftwareInstallerToken(ctx, req.Alt, req.TitleID, req.TeamID)
if err != nil {
return getSoftwareInstallerTokenResponse{Err: err}, nil
}
return getSoftwareInstallerTokenResponse{Token: token}, nil
}
func downloadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*downloadSoftwareInstallerRequest)
meta, err := svc.GetSoftwareInstallerTokenMetadata(ctx, req.Token, req.TitleID)
if err != nil {
return orbitDownloadSoftwareInstallerResponse{Err: err}, nil
}
payload, err := svc.DownloadSoftwareInstaller(ctx, true, "media", meta.TitleID, &meta.TeamID)
if err != nil {
return orbitDownloadSoftwareInstallerResponse{Err: err}, nil
}
return orbitDownloadSoftwareInstallerResponse{payload: payload}, nil
}
func (svc *Service) GenerateSoftwareInstallerToken(ctx context.Context, _ string, _ uint, _ *uint) (string, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return "", fleet.ErrMissingLicense
}
func (svc *Service) GetSoftwareInstallerTokenMetadata(ctx context.Context, _ string, _ uint) (*fleet.SoftwareInstallerTokenMetadata,
error,
) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
func (svc *Service) GetSoftwareInstallerMetadata(ctx context.Context, _ bool, _ uint, _ *uint) (*fleet.SoftwareInstaller, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
type getSoftwareInstallerResponse struct {
SoftwareInstaller *fleet.SoftwareInstaller `json:"software_installer,omitempty"`
Err error `json:"error,omitempty"`
}
func (r getSoftwareInstallerResponse) error() error { return r.Err }
type getSoftwareInstallerTokenResponse struct {
Err error `json:"error,omitempty"`
Token string `json:"token"`
}
func (r getSoftwareInstallerTokenResponse) error() error { return r.Err }
type orbitDownloadSoftwareInstallerResponse struct {
Err error `json:"error,omitempty"`
// fields used by hijackRender for the response.
payload *fleet.DownloadSoftwareInstallerPayload
}
func (r orbitDownloadSoftwareInstallerResponse) error() error { return r.Err }
func (r orbitDownloadSoftwareInstallerResponse) hijackRender(ctx context.Context, w http.ResponseWriter) {
w.Header().Set("Content-Length", strconv.Itoa(int(r.payload.Size)))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, r.payload.Filename))
// OK to just log the error here as writing anything on
// `http.ResponseWriter` sets the status code to 200 (and it can't be
// changed.) Clients should rely on matching content-length with the
// header provided
if n, err := io.Copy(w, r.payload.Installer); err != nil {
logging.WithExtras(ctx, "err", err, "bytes_copied", n)
}
r.payload.Installer.Close()
}
func (svc *Service) DownloadSoftwareInstaller(ctx context.Context, _ bool, _ string, _ uint,
_ *uint) (*fleet.DownloadSoftwareInstallerPayload,
error,
) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
/////////////////////////////////////////////////////////////////////////////////
// Request to install software in a host
/////////////////////////////////////////////////////////////////////////////////
type installSoftwareRequest struct {
HostID uint `url:"host_id"`
SoftwareTitleID uint `url:"software_title_id"`
}
type installSoftwareResponse struct {
Err error `json:"error,omitempty"`
}
func (r installSoftwareResponse) error() error { return r.Err }
func (r installSoftwareResponse) Status() int { return http.StatusAccepted }
func installSoftwareTitleEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*installSoftwareRequest)
err := svc.InstallSoftwareTitle(ctx, req.HostID, req.SoftwareTitleID)
if err != nil {
return installSoftwareResponse{Err: err}, nil
}
return installSoftwareResponse{}, nil
}
func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
type uninstallSoftwareRequest struct {
HostID uint `url:"host_id"`
SoftwareTitleID uint `url:"software_title_id"`
}
func uninstallSoftwareTitleEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*uninstallSoftwareRequest)
err := svc.UninstallSoftwareTitle(ctx, req.HostID, req.SoftwareTitleID)
if err != nil {
return installSoftwareResponse{Err: err}, nil
}
return installSoftwareResponse{}, nil
}
func (svc *Service) UninstallSoftwareTitle(ctx context.Context, _ uint, _ uint) error {
// skipauth: No authorization check needed due to implementation returning only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
type getSoftwareInstallResultsRequest struct {
InstallUUID string `url:"install_uuid"`
}
type getSoftwareInstallResultsResponse struct {
Err error `json:"error,omitempty"`
Results *fleet.HostSoftwareInstallerResult `json:"results,omitempty"`
}
func (r getSoftwareInstallResultsResponse) error() error { return r.Err }
func getSoftwareInstallResultsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*getSoftwareInstallResultsRequest)
results, err := svc.GetSoftwareInstallResults(ctx, req.InstallUUID)
if err != nil {
return getSoftwareInstallResultsResponse{Err: err}, nil
}
return &getSoftwareInstallResultsResponse{Results: results}, nil
}
func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID string) (*fleet.HostSoftwareInstallerResult, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
////////////////////////////////////////////////////////////////////////////////
// Batch replace software installers
////////////////////////////////////////////////////////////////////////////////
type batchSetSoftwareInstallersRequest struct {
TeamName string `json:"-" query:"team_name,optional"`
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
Software []fleet.SoftwareInstallerPayload `json:"software"`
}
type batchSetSoftwareInstallersResponse struct {
RequestUUID string `json:"request_uuid"`
Err error `json:"error,omitempty"`
}
func (r batchSetSoftwareInstallersResponse) error() error { return r.Err }
func batchSetSoftwareInstallersEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*batchSetSoftwareInstallersRequest)
requestUUID, err := svc.BatchSetSoftwareInstallers(ctx, req.TeamName, req.Software, req.DryRun)
if err != nil {
return batchSetSoftwareInstallersResponse{Err: err}, nil
}
return batchSetSoftwareInstallersResponse{RequestUUID: requestUUID}, nil
}
func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool) (string, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return "", fleet.ErrMissingLicense
}
type batchSetSoftwareInstallersResultRequest struct {
RequestUUID string `url:"request_uuid"`
TeamName string `query:"team_name,optional"`
DryRun bool `query:"dry_run,optional"` // if true, apply validation but do not save changes
}
type batchSetSoftwareInstallersResultResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Packages []fleet.SoftwarePackageResponse `json:"packages"`
Err error `json:"error,omitempty"`
}
func (r batchSetSoftwareInstallersResultResponse) error() error { return r.Err }
func batchSetSoftwareInstallersResultEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*batchSetSoftwareInstallersResultRequest)
status, message, packages, err := svc.GetBatchSetSoftwareInstallersResult(ctx, req.TeamName, req.RequestUUID, req.DryRun)
if err != nil {
return batchSetSoftwareInstallersResultResponse{Err: err}, nil
}
return batchSetSoftwareInstallersResultResponse{
Status: status,
Message: message,
Packages: packages,
}, nil
}
func (svc *Service) GetBatchSetSoftwareInstallersResult(ctx context.Context, tmName string, requestUUID string, dryRun bool) (string, string, []fleet.SoftwarePackageResponse, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return "", "", nil, fleet.ErrMissingLicense
}
//////////////////////////////////////////////////////////////////////////////
// Self Service Install
//////////////////////////////////////////////////////////////////////////////
type fleetSelfServiceSoftwareInstallRequest struct {
Token string `url:"token"`
SoftwareTitleID uint `url:"software_title_id"`
}
func (r *fleetSelfServiceSoftwareInstallRequest) deviceAuthToken() string {
return r.Token
}
type submitSelfServiceSoftwareInstallResponse struct {
Err error `json:"error,omitempty"`
}
func (r submitSelfServiceSoftwareInstallResponse) error() error { return r.Err }
func (r submitSelfServiceSoftwareInstallResponse) Status() int { return http.StatusAccepted }
func submitSelfServiceSoftwareInstall(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
host, ok := hostctx.FromContext(ctx)
if !ok {
err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
return submitSelfServiceSoftwareInstallResponse{Err: err}, nil
}
req := request.(*fleetSelfServiceSoftwareInstallRequest)
if err := svc.SelfServiceInstallSoftwareTitle(ctx, host, req.SoftwareTitleID); err != nil {
return submitSelfServiceSoftwareInstallResponse{Err: err}, nil
}
return submitSelfServiceSoftwareInstallResponse{}, nil
}
func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *fleet.Host, softwareTitleID uint) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
func (svc *Service) HasSelfServiceSoftwareInstallers(ctx context.Context, host *fleet.Host) (bool, error) {
alreadyAuthenticated := svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken)
if !alreadyAuthenticated {
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
return false, err
}
}
return svc.ds.HasSelfServiceSoftwareInstallers(ctx, host.Platform, host.TeamID)
}
//////////////////////////////////////////////////////////////////////////////
// VPP App Store Apps Batch Install
//////////////////////////////////////////////////////////////////////////////
type batchAssociateAppStoreAppsRequest struct {
TeamName string `json:"-" query:"team_name"`
DryRun bool `json:"-" query:"dry_run,optional"`
Apps []fleet.VPPBatchPayload `json:"app_store_apps"`
}
func (b *batchAssociateAppStoreAppsRequest) DecodeBody(ctx context.Context, r io.Reader, u url.Values, c []*x509.Certificate) error {
if err := json.NewDecoder(r).Decode(b); err != nil {
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
return ctxerr.Wrap(ctx, fleet.NewUserMessageError(fmt.Errorf("Couldn't edit software. %q must be a %s, found %s", typeErr.Field, typeErr.Type.String(), typeErr.Value), http.StatusBadRequest))
}
}
return nil
}
type batchAssociateAppStoreAppsResponse struct {
Err error `json:"error,omitempty"`
}
func (r batchAssociateAppStoreAppsResponse) error() error { return r.Err }
func (r batchAssociateAppStoreAppsResponse) Status() int { return http.StatusNoContent }
func batchAssociateAppStoreAppsEndpoint(ctx context.Context, request any, svc fleet.Service) (errorer, error) {
req := request.(*batchAssociateAppStoreAppsRequest)
if err := svc.BatchAssociateVPPApps(ctx, req.TeamName, req.Apps, req.DryRun); err != nil {
return batchAssociateAppStoreAppsResponse{Err: err}, nil
}
return batchAssociateAppStoreAppsResponse{}, nil
}
func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, payloads []fleet.VPPBatchPayload, dryRun bool) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}