fleet/server/mdm/android/service/androidmgmt/google_client.go

379 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package androidmgmt
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"reflect"
"strings"
"time"
"cloud.google.com/go/pubsub"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android"
"github.com/go-json-experiment/json"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
"github.com/google/uuid"
"google.golang.org/api/androidmanagement/v1"
"google.golang.org/api/googleapi"
"google.golang.org/api/option"
)
// GoogleClient connects directly to Google's Android Management API. It is intended to be used for development/debugging.
// To enable, set the following env vars: FLEET_DEV_ANDROID_GOOGLE_CLIENT=1 and FLEET_DEV_ANDROID_GOOGLE_SERVICE_CREDENTIALS=$(cat credentials.json)
type GoogleClient struct {
logger kitlog.Logger
mgmt *androidmanagement.Service
androidServiceCredentials string
androidProjectID string
}
// Compile-time check to ensure that ProxyClient implements Client.
var _ Client = &GoogleClient{}
func NewGoogleClient(ctx context.Context, logger kitlog.Logger, getenv dev_mode.GetEnv) Client {
androidServiceCredentials := getenv("FLEET_DEV_ANDROID_GOOGLE_SERVICE_CREDENTIALS")
if androidServiceCredentials == "" {
return nil
}
type credentials struct {
ProjectID string `json:"project_id"`
}
var creds credentials
err := json.Unmarshal([]byte(androidServiceCredentials), &creds)
if err != nil {
level.Error(logger).Log("msg", "unmarshaling android service credentials", "err", err)
return nil
}
slogLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
mgmt, err := androidmanagement.NewService(ctx,
option.WithCredentialsJSON([]byte(androidServiceCredentials)),
option.WithLogger(slogLogger),
)
if err != nil {
level.Error(logger).Log("msg", "creating android management service", "err", err)
return nil
}
return &GoogleClient{
logger: logger,
mgmt: mgmt,
androidServiceCredentials: androidServiceCredentials,
androidProjectID: creds.ProjectID,
}
}
func (g *GoogleClient) SignupURLsCreate(ctx context.Context, _, callbackURL string) (*android.SignupDetails, error) {
signupURL, err := g.mgmt.SignupUrls.Create().ProjectId(g.androidProjectID).CallbackUrl(callbackURL).Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("creating signup url: %w", err)
}
return &android.SignupDetails{
Url: signupURL.Url,
Name: signupURL.Name,
}, nil
}
func (g *GoogleClient) EnterprisesCreate(ctx context.Context, req EnterprisesCreateRequest) (EnterprisesCreateResponse, error) {
res := EnterprisesCreateResponse{}
topicName, err := g.createPubSub(ctx, req.PubSubPushURL)
if err != nil {
return res, fmt.Errorf("creating PubSub topic: %w", err)
}
enterprise, err := g.mgmt.Enterprises.Create(&androidmanagement.Enterprise{
EnabledNotificationTypes: req.EnabledNotificationTypes,
PubsubTopic: topicName,
}).
ProjectId(g.androidProjectID).
EnterpriseToken(req.EnterpriseToken).
SignupUrlName(req.SignupURLName).
Context(ctx).
Do()
switch {
case googleapi.IsNotModified(err):
return res, fmt.Errorf("android enterprise %s was already created", req.SignupURLName)
case err != nil:
return res, fmt.Errorf("creating enterprise: %w", err)
}
res.EnterpriseName = enterprise.Name
res.TopicName = topicName
return res, nil
}
// createPubSub creates the PubSub topic and subscription
func (g *GoogleClient) createPubSub(ctx context.Context, pushURL string) (string, error) {
pubSubClient, err := pubsub.NewClient(ctx, g.androidProjectID, option.WithCredentialsJSON([]byte(g.androidServiceCredentials)))
if err != nil {
return "", fmt.Errorf("creating PubSub client: %w", err)
}
defer pubSubClient.Close()
pubSubTopicAndSubscriptionID := "a" + uuid.NewString() // PubSub topic names must start with a letter
topicConfig := pubsub.TopicConfig{
// Message retention is free for 1 day, so we default to that.
// Both the topic and subscription retention durations should be 1 day since Google uses whatever is longer.
// https://cloud.google.com/pubsub/pricing
RetentionDuration: 24 * time.Hour,
}
topic, err := pubSubClient.CreateTopicWithConfig(ctx, pubSubTopicAndSubscriptionID, &topicConfig)
if err != nil {
return "", fmt.Errorf("creating PubSub topic: %w", err)
}
// Grant Android device policy the right to publish
// See: https://developers.google.com/android/management/notifications
policy, err := topic.IAM().Policy(ctx) // Ensure the topic exists before creating the subscription
if err != nil {
return "", fmt.Errorf("getting PubSub topic policy: %w", err)
}
policy.Add("serviceAccount:android-cloud-policy@system.gserviceaccount.com", "roles/pubsub.publisher")
if err := topic.IAM().SetPolicy(ctx, policy); err != nil {
return "", fmt.Errorf("setting PubSub subscription policy: %w", err)
}
// Note: We could add a second level of authentication for the subscription, where, upon receiving a message,
// Fleet server does an API call to Google to verify the message validity.
_, err = pubSubClient.CreateSubscription(ctx, pubSubTopicAndSubscriptionID, pubsub.SubscriptionConfig{
Topic: topic,
AckDeadline: 60 * time.Second,
RetentionDuration: 24 * time.Hour,
ExpirationPolicy: time.Duration(0), // Should never expire
PushConfig: pubsub.PushConfig{
Endpoint: pushURL,
},
})
if err != nil {
return "", fmt.Errorf("creating PubSub subscription: %w", err)
}
// Note: Currently, we do not clean up the PubSub topic/subscription if the enterprise creation fails. This would be a nice enhancement.
return topic.String(), nil
}
// generatePolicyFieldMask creates an "update mask": a list of an androidmanagement.Policy's fields that will be updated in
// a given call to EnterprisesPoliciesPatch. We omit `applications` from this list of fields to ensure that apps are only
// updated through calls to EnterprisesPoliciesModifyPolicyApplications.
// See https://developers.google.com/android/management/reference/rest/v1/enterprises.policies/patch#query-parameters
// for more details.
func generatePolicyFieldMask() string {
getJSONFieldName := func(t string) string {
fieldName, _, _ := strings.Cut(t, ",")
return fieldName
}
var p androidmanagement.Policy
t := reflect.TypeOf(p)
var mask []string
for i := range t.NumField() {
f := t.Field(i)
jsonTag, ok := f.Tag.Lookup("json")
// ignore applications because we manage that directly
if n := getJSONFieldName(jsonTag); ok &&
n != "applications" &&
n != "-" && n != "string" && n != "omitempty" {
mask = append(mask, n)
}
}
return strings.Join(mask, ",")
}
var policyFieldMask = generatePolicyFieldMask()
func (g *GoogleClient) EnterprisesPoliciesPatch(ctx context.Context, policyName string, policy *androidmanagement.Policy, opts PoliciesPatchOpts) (*androidmanagement.Policy, error) {
call := g.mgmt.Enterprises.Policies.Patch(policyName, policy).Context(ctx)
switch {
case opts.ExcludeApps:
call = call.UpdateMask(policyFieldMask)
case opts.OnlyUpdateApps:
call = call.UpdateMask("applications")
}
ret, err := call.Do()
switch {
case googleapi.IsNotModified(err):
g.logger.Log("msg", "Android policy not modified", "policy_name", policyName)
return nil, err
case err != nil:
return nil, fmt.Errorf("patching policy %s: %w", policyName, err)
}
return ret, nil
}
func (g *GoogleClient) EnterprisesDevicesPatch(ctx context.Context, deviceName string, device *androidmanagement.Device) (*androidmanagement.Device, error) {
ret, err := g.mgmt.Enterprises.Devices.Patch(deviceName, device).Context(ctx).Do()
switch {
case googleapi.IsNotModified(err):
g.logger.Log("msg", "Android device not modified", "device_name", deviceName)
return nil, err
case err != nil:
return nil, fmt.Errorf("patching device %s: %w", deviceName, err)
}
return ret, nil
}
func (g *GoogleClient) EnterprisesDevicesGet(ctx context.Context, deviceName string) (*androidmanagement.Device, error) {
ret, err := g.mgmt.Enterprises.Devices.Get(deviceName).Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("getting device %s: %w", deviceName, err)
}
return ret, nil
}
func (g *GoogleClient) EnterprisesDevicesDelete(ctx context.Context, deviceName string) error {
_, err := g.mgmt.Enterprises.Devices.Delete(deviceName).Context(ctx).Do()
switch {
case googleapi.IsNotModified(err):
g.logger.Log("msg", "Android device already deleted", "device_name", deviceName)
return nil
case err != nil:
return fmt.Errorf("deleting device %s: %w", deviceName, err)
}
return nil
}
func (g *GoogleClient) EnterprisesDevicesListPartial(ctx context.Context, enterpriseName string, pageToken string) (*androidmanagement.ListDevicesResponse, error) {
ret, err := g.mgmt.Enterprises.Devices.List(enterpriseName).Context(ctx).PageToken(pageToken).PageSize(100).Fields("nextPageToken", "devices/name").Do()
if err != nil {
return nil, fmt.Errorf("listing devices: %w", err)
}
return ret, nil
}
func (g *GoogleClient) EnterprisesEnrollmentTokensCreate(ctx context.Context, enterpriseName string, token *androidmanagement.EnrollmentToken,
) (*androidmanagement.EnrollmentToken, error) {
token, err := g.mgmt.Enterprises.EnrollmentTokens.Create(enterpriseName, token).Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("creating enrollment token: %w", err)
}
return token, nil
}
func (g *GoogleClient) EnterpriseDelete(ctx context.Context, enterpriseName string) error {
// To find out the enterprise's PubSub topic, we need to get the enterprise first.
// We can also pull the topic from the DB, but this way is more reliable.
enterprise, err := g.mgmt.Enterprises.Get(enterpriseName).Context(ctx).Do()
if err != nil {
level.Error(g.logger).Log("msg", "getting enterprise; perhaps it was already deleted?", "err", err, "enterprise_name", enterpriseName)
return nil
}
_, err = g.mgmt.Enterprises.Delete(enterpriseName).Do()
switch {
case googleapi.IsNotModified(err):
level.Info(g.logger).Log("msg", "enterprise was already deleted", "enterprise_name", enterpriseName)
return nil
case err != nil:
return fmt.Errorf("deleting enterprise %s: %w", enterpriseName, err)
}
// Delete the PubSub topic if it exists
if enterprise == nil || len(enterprise.PubsubTopic) == 0 {
return nil
}
// PubSub topic and subscription have the same ID (but not name) by convention.
topicAndSubscriptionID, err := getLastPart(ctx, enterprise.PubsubTopic)
if err != nil || len(topicAndSubscriptionID) == 0 {
level.Error(g.logger).Log("msg", "getting last part of PubSub topic", "err", err, "topic", enterprise.PubsubTopic)
return nil
}
pubSubClient, err := pubsub.NewClient(ctx, g.androidProjectID, option.WithCredentialsJSON([]byte(g.androidServiceCredentials)))
if err != nil {
return fmt.Errorf("creating PubSub client: %w", err)
}
defer pubSubClient.Close()
// Try to delete both the topic and subscription before checking for errors in case one fails but the other succeeds.
errTopic := pubSubClient.Topic(topicAndSubscriptionID).Delete(ctx)
errSub := pubSubClient.Subscription(topicAndSubscriptionID).Delete(ctx)
if errTopic != nil {
return fmt.Errorf("deleting PubSub topic %s: %w", enterprise.PubsubTopic, errTopic)
}
if errSub != nil {
return fmt.Errorf("deleting PubSub subscription %s: %w", topicAndSubscriptionID, errSub)
}
return nil
}
func (g *GoogleClient) EnterprisesList(ctx context.Context, serverURL string) ([]*androidmanagement.Enterprise, error) {
var enterprises []*androidmanagement.Enterprise
err := g.mgmt.Enterprises.List().ProjectId(g.androidProjectID).Context(ctx).Pages(ctx, func(page *androidmanagement.ListEnterprisesResponse) error {
enterprises = append(enterprises, page.Enterprises...)
return nil
})
if err != nil {
return nil, fmt.Errorf("listing enterprises: %w", err)
}
return enterprises, nil
}
// SetAuthenticationSecret is not used by GoogleClient because this client gets its secret from env var.
func (g *GoogleClient) SetAuthenticationSecret(_ string) error {
return nil
}
func getLastPart(ctx context.Context, name string) (string, error) {
nameParts := strings.Split(name, "/")
if len(nameParts) == 0 {
return "", ctxerr.Errorf(ctx, "invalid Google resource name: %s", name)
}
return nameParts[len(nameParts)-1], nil
}
type appNotFoundError struct{}
var _ fleet.NotFoundError = (*appNotFoundError)(nil)
func (p appNotFoundError) Error() string {
return "Couldnt add software. The application ID isnt available in Play Store. Please find ID on the Play Store and try again."
}
func (p appNotFoundError) IsNotFound() bool {
return true
}
func (g *GoogleClient) EnterprisesApplications(ctx context.Context, enterpriseName, packageName string) (*androidmanagement.Application, error) {
path := fmt.Sprintf("%s/applications/%s", enterpriseName, packageName)
app, err := g.mgmt.Enterprises.Applications.Get(path).Context(ctx).Do()
if err != nil {
if isErrorCode(err, http.StatusNotFound) || (isErrorCode(err, http.StatusInternalServerError) && strings.Contains(err.Error(), "Requested entity was not found")) {
// For some reason, the AMAPI can return a 500 when an app is not found.
return nil, ctxerr.Wrap(ctx, appNotFoundError{})
}
return nil, fmt.Errorf("getting application %s: %w", packageName, err)
}
return app, nil
}
func (g *GoogleClient) EnterprisesPoliciesModifyPolicyApplications(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) {
var changes []*androidmanagement.ApplicationPolicyChange
for _, p := range appPolicies {
changes = append(changes, &androidmanagement.ApplicationPolicyChange{
Application: p,
})
}
req := androidmanagement.ModifyPolicyApplicationsRequest{
Changes: changes,
}
ret, err := g.mgmt.Enterprises.Policies.ModifyPolicyApplications(policyName, &req).Context(ctx).Do()
switch {
case googleapi.IsNotModified(err):
g.logger.Log("msg", "Android application policy not modified", "policy_name", policyName)
return nil, err
case err != nil:
return nil, ctxerr.Wrapf(ctx, err, "modifying application policy %s", policyName)
}
return ret.Policy, nil
}