Basic Android MDM on/off functionality (#26309)

For #26218 

Basic Android MDM on/off backend functionality. Manually tested.

The following env vars must be set:
```
FLEET_DEV_ANDROID_ENABLED=1
FLEET_DEV_ANDROID_SERVICE_CREDENTIALS=$(cat credentials.json)
FLEET_DEV_ANDROID_PUBSUB_TOPIC=projects/your-project/topics/your-topic
```

I picked https://github.com/go-json-experiment/json as the JSON library,
which seems like the safest option.
- will become json/v2 at some point
- currently used in production by other companies, like Tailscale
- well-maintained
- Some context here: https://github.com/fleetdm/fleet/issues/25512

Plan for next work:
- refactoring from 1st PR
- add pubsub with device enroll -> spec proxy for fleetdm.com
- come back to this sub-task to add tests and finish TODOs

# Checklist for submitter

- [x] Added/updated automated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Victor Lyuboslavsky
2025-02-18 09:43:11 -06:00
committed by GitHub
parent 176f476942
commit fc33df1981
30 changed files with 451 additions and 50 deletions
@@ -94,6 +94,7 @@
"ndes_scep_proxy": null
},
"mdm": {
"android_enabled_and_configured": false,
"apple_bm_terms_expired": false,
"apple_server_url": "",
"apple_bm_enabled_and_configured": false,
@@ -67,6 +67,7 @@
"ndes_scep_proxy": null
},
"mdm": {
"android_enabled_and_configured": false,
"apple_bm_terms_expired": false,
"apple_server_url": "",
"apple_bm_enabled_and_configured": false,
@@ -19,6 +19,7 @@ spec:
ndes_scep_proxy: null
zendesk: null
mdm:
android_enabled_and_configured: false
apple_bm_terms_expired: false
apple_server_url: ""
apple_bm_enabled_and_configured: false
@@ -19,6 +19,7 @@ spec:
ndes_scep_proxy: null
zendesk: null
mdm:
android_enabled_and_configured: false
apple_bm_terms_expired: false
apple_server_url: ""
apple_bm_enabled_and_configured: false
@@ -46,6 +46,7 @@
"enable_software_inventory": false
},
"mdm": {
"android_enabled_and_configured": false,
"apple_business_manager": null,
"apple_server_url": "",
"volume_purchasing_program": null,
@@ -19,6 +19,7 @@ spec:
ndes_scep_proxy: null
zendesk: null
mdm:
android_enabled_and_configured: false
apple_business_manager: null
apple_server_url: ""
volume_purchasing_program: null
@@ -19,6 +19,7 @@ spec:
ndes_scep_proxy: null
zendesk: null
mdm:
android_enabled_and_configured: false
apple_business_manager:
apple_server_url: ""
volume_purchasing_program:
@@ -19,6 +19,7 @@ spec:
ndes_scep_proxy: null
zendesk: null
mdm:
android_enabled_and_configured: false
apple_business_manager:
apple_server_url: ""
volume_purchasing_program:
+1
View File
@@ -44,6 +44,7 @@ require (
github.com/github/smimesign v0.2.0
github.com/go-git/go-git/v5 v5.13.0
github.com/go-ini/ini v1.67.0
github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535
github.com/go-kit/kit v0.12.0
github.com/go-kit/log v0.2.1
github.com/go-ole/go-ole v1.2.6
+2
View File
@@ -302,6 +302,8 @@ github.com/go-git/go-git/v5 v5.13.0/go.mod h1:Wjo7/JyVKtQgUNdXYXIepzWfJQkUEIGvkv
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535 h1:yE7argOs92u+sSCRgqqe6eF+cDaVhSPlioy1UkA0p/w=
github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s=
github.com/go-kit/kit v0.4.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.7.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+18
View File
@@ -18,6 +18,7 @@ type PackageTest struct {
pkgs []string
includeRegex *regexp.Regexp
ignorePkgs map[string]struct{}
ignoreXTests map[string]struct{}
withTests bool
}
@@ -49,6 +50,20 @@ func (pt *PackageTest) IgnorePackages(pkgs ...string) *PackageTest {
return pt
}
func (pt *PackageTest) IgnoreXTests(pkgs ...string) *PackageTest {
if pt.ignoreXTests == nil {
pt.ignoreXTests = make(map[string]struct{}, len(pkgs))
}
cleanPkgs := make([]string, 0, len(pkgs))
for _, p := range pkgs {
cleanPkgs = append(cleanPkgs, strings.TrimSuffix(p, "_test"))
}
for _, p := range pt.expandPackages(cleanPkgs) {
pt.ignoreXTests[p] = struct{}{}
}
return pt
}
func (pt *PackageTest) WithTests() *PackageTest {
pt.withTests = true
return pt
@@ -147,6 +162,9 @@ func (pt *PackageTest) read(pChan chan<- *packageDependency, topDependency *pack
}
// XTestImports are packages with _test suffix that are in the same directory as the package.
if _, ignore := pt.ignoreXTests[dep.name]; ignore {
continue
}
for _, i := range pkg.XTestImports {
queue.PushBack(&packageDependency{name: i, parent: dep.asXTest()})
}
+10
View File
@@ -1019,3 +1019,13 @@ allow {
subject.global_role == [admin, maintainer, gitops][_]
action == write
}
##
# Android
##
# Global admins can connect enteprise.
allow {
object.type == "android_enterprise"
subject.global_role == admin
action == write
}
+11
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/jmoiron/sqlx"
@@ -108,6 +109,16 @@ func (ds *Datastore) insertOrReplaceConfigAsset(ctx context.Context, tx sqlx.Ext
return nil
}
func (ds *Datastore) SetAndroidEnabledAndConfigured(ctx context.Context, configured bool) error {
ctx = ctxdb.RequirePrimary(ctx, true)
appConfig, err := ds.AppConfig(ctx)
if err != nil {
return err
}
appConfig.MDM.AndroidEnabledAndConfigured = configured
return ds.SaveAppConfig(ctx, appConfig)
}
func (ds *Datastore) VerifyEnrollSecret(ctx context.Context, secret string) (*fleet.EnrollSecret, error) {
var s fleet.EnrollSecret
err := sqlx.GetContext(ctx, ds.reader(ctx), &s, "SELECT team_id FROM enroll_secrets WHERE secret = ?", secret)
+1 -1
View File
@@ -75,7 +75,7 @@ CREATE TABLE `app_config_json` (
PRIMARY KEY (`id`)
) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_setup\": {\"script\": null, \"software\": null, \"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"apple_server_url\": \"\", \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"volume_purchasing_program\": null, \"windows_migration_enabled\": false, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null, \"ndes_scep_proxy\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01');
INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_setup\": {\"script\": null, \"software\": null, \"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"apple_server_url\": \"\", \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"volume_purchasing_program\": null, \"windows_migration_enabled\": false, \"android_enabled_and_configured\": false, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null, \"ndes_scep_proxy\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01');
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `calendar_events` (
+3
View File
@@ -207,6 +207,9 @@ type MDM struct {
VolumePurchasingProgram optjson.Slice[MDMAppleVolumePurchasingProgramInfo] `json:"volume_purchasing_program"`
// AndroidEnabledAndConfigured is set to true if Fleet successfully bound to an Android Management Enterprise
AndroidEnabledAndConfigured bool `json:"android_enabled_and_configured"`
/////////////////////////////////////////////////////////////////
// WARNING: If you add to this struct make sure it's taken into
// account in the AppConfig Clone implementation!
+5
View File
@@ -1968,6 +1968,11 @@ type Datastore interface {
// ExpandEmbeddedSecretsAndUpdatedAt is like ExpandEmbeddedSecrets but also
// returns the latest updated_at time of the secrets used in the expansion.
ExpandEmbeddedSecretsAndUpdatedAt(ctx context.Context, document string) (string, *time.Time, error)
// /////////////////////////////////////////////////////////////////////////////
// Android
SetAndroidEnabledAndConfigured(ctx context.Context, configured bool) error
}
// MDMAppleStore wraps nanomdm's storage and adds methods to deal with
+6 -2
View File
@@ -1,8 +1,8 @@
package android
type SignupDetails struct {
Url string `json:"url,omitempty"`
Name string `json:"name,omitempty"`
Url string
Name string
}
type Enterprise struct {
@@ -19,6 +19,10 @@ func (e Enterprise) IsValid() bool {
return e.EnterpriseID != ""
}
func (e Enterprise) AuthzType() string {
return "android_enterprise"
}
type EnrollmentToken struct {
Value string `json:"value"`
}
+2
View File
@@ -13,6 +13,8 @@ func TestAllAndroidPackageDependencies(t *testing.T) {
t.Parallel()
archtest.NewPackageTest(t, "github.com/fleetdm/fleet/v4/server/mdm/android...").
OnlyInclude(regexp.MustCompile(`^github\.com/fleetdm/`)).
WithTests().
IgnoreXTests("github.com/fleetdm/fleet/v4/server/fleet"). // ignore fleet_test package
IgnorePackages(
"github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql",
"github.com/fleetdm/fleet/v4/server/service/externalsvc", // TODO(#26218): remove this dependency on Jira and Zendesk
+3 -1
View File
@@ -7,6 +7,8 @@ import (
type Datastore interface {
CreateEnterprise(ctx context.Context) (uint, error)
GetEnterpriseByID(ctx context.Context, ID uint) (*Enterprise, error)
GetEnterprise(ctx context.Context) (*Enterprise, error)
UpdateEnterprise(ctx context.Context, enterprise *Enterprise) error
ListEnterprises(ctx context.Context) ([]*Enterprise, error)
DeleteEnterprises(ctx context.Context) error
DeleteOtherEnterprises(ctx context.Context, ID uint) error
}
+28 -7
View File
@@ -28,7 +28,20 @@ func (ds *Datastore) GetEnterpriseByID(ctx context.Context, id uint) (*android.E
case errors.Is(err, sql.ErrNoRows):
return nil, notFound("Android enterprise").WithID(id)
case err != nil:
return nil, ctxerr.Wrap(ctx, err, "selecting enterprise")
return nil, ctxerr.Wrap(ctx, err, "getting enterprise by id")
}
return &enterprise, nil
}
func (ds *Datastore) GetEnterprise(ctx context.Context) (*android.Enterprise, error) {
stmt := `SELECT id, enterprise_id FROM android_enterprises WHERE enterprise_id != '' LIMIT 1`
var enterprise android.Enterprise
err := sqlx.GetContext(ctx, ds.reader(ctx), &enterprise, stmt)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, notFound("Android enterprise")
case err != nil:
return nil, ctxerr.Wrap(ctx, err, "getting active enterprise")
}
return &enterprise, nil
}
@@ -51,12 +64,20 @@ func (ds *Datastore) UpdateEnterprise(ctx context.Context, enterprise *android.E
return nil
}
func (ds *Datastore) ListEnterprises(ctx context.Context) ([]*android.Enterprise, error) {
stmt := `SELECT id, signup_name, enterprise_id FROM android_enterprises`
var enterprises []*android.Enterprise
err := sqlx.SelectContext(ctx, ds.reader(ctx), &enterprises, stmt)
func (ds *Datastore) DeleteOtherEnterprises(ctx context.Context, id uint) error {
stmt := `DELETE FROM android_enterprises WHERE id != ?`
_, err := ds.Writer(ctx).ExecContext(ctx, stmt, id)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "selecting enterprises")
return ctxerr.Wrap(ctx, err, "deleting other enterprises")
}
return enterprises, nil
return nil
}
func (ds *Datastore) DeleteEnterprises(ctx context.Context) error {
stmt := `DELETE FROM android_enterprises`
_, err := ds.Writer(ctx).ExecContext(ctx, stmt)
if err != nil {
return ctxerr.Wrap(ctx, err, "deleting all enterprises")
}
return nil
}
+59 -3
View File
@@ -21,6 +21,7 @@ func TestEnterprise(t *testing.T) {
}{
{"CreateGetEnterprise", testCreateGetEnterprise},
{"UpdateEnterprise", testUpdateEnterprise},
{"DeleteEnterprises", testDeleteEnterprises},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -65,10 +66,65 @@ func testUpdateEnterprise(t *testing.T, ds *mysql.Datastore) {
require.NoError(t, err)
assert.Equal(t, enterprise, result)
enterprises, err := ds.ListEnterprises(testCtx())
result, err = ds.GetEnterprise(testCtx())
require.NoError(t, err)
assert.Len(t, enterprises, 1)
assert.Equal(t, enterprise, enterprises[0])
assert.Equal(t, enterprise.ID, result.ID)
assert.Equal(t, enterprise.EnterpriseID, result.EnterpriseID)
}
func testDeleteEnterprises(t *testing.T, ds *mysql.Datastore) {
err := ds.DeleteEnterprises(testCtx())
require.NoError(t, err)
err = ds.DeleteOtherEnterprises(testCtx(), 9999)
require.NoError(t, err)
enterprise := createEnterprise(t, ds)
result, err := ds.GetEnterpriseByID(testCtx(), enterprise.ID)
require.NoError(t, err)
assert.Equal(t, enterprise, result)
// Create enteprise without enterprise_id
id, err := ds.CreateEnterprise(testCtx())
require.NoError(t, err)
assert.NotZero(t, id)
tempEnterprise := &android.Enterprise{
ID: id, // start with an invalid ID
SignupName: "signupUrls/C97372c91c6a85139",
EnterpriseID: "",
}
err = ds.UpdateEnterprise(testCtx(), tempEnterprise)
require.NoError(t, err)
err = ds.DeleteOtherEnterprises(testCtx(), enterprise.ID)
require.NoError(t, err)
result, err = ds.GetEnterpriseByID(testCtx(), enterprise.ID)
require.NoError(t, err)
assert.Equal(t, enterprise, result)
_, err = ds.GetEnterpriseByID(testCtx(), tempEnterprise.ID)
assert.True(t, fleet.IsNotFound(err))
err = ds.DeleteEnterprises(testCtx())
require.NoError(t, err)
_, err = ds.GetEnterpriseByID(testCtx(), enterprise.ID)
assert.True(t, fleet.IsNotFound(err))
}
func createEnterprise(t *testing.T, ds *mysql.Datastore) *android.Enterprise {
enterprise := &android.Enterprise{
ID: 9999, // start with an invalid ID
SignupName: "signupUrls/C97372c91c6a85139",
EnterpriseID: "LC04bp524j",
}
id, err := ds.CreateEnterprise(testCtx())
require.NoError(t, err)
assert.NotZero(t, id)
enterprise.ID = id
err = ds.UpdateEnterprise(testCtx(), enterprise)
require.NoError(t, err)
return enterprise
}
func testCtx() context.Context {
+1
View File
@@ -5,6 +5,7 @@ import "context"
type Service interface {
EnterpriseSignup(ctx context.Context) (*SignupDetails, error)
EnterpriseSignupCallback(ctx context.Context, enterpriseID uint, enterpriseToken string) error
DeleteEnterprise(ctx context.Context) error
CreateEnrollmentToken(ctx context.Context) (*EnrollmentToken, error)
}
+5 -5
View File
@@ -6,7 +6,6 @@ import (
"bufio"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -17,6 +16,8 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/android"
"github.com/fleetdm/fleet/v4/server/service/middleware/auth"
"github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
"github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"github.com/go-kit/kit/endpoint"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/gorilla/mux"
@@ -37,9 +38,7 @@ func encodeResponse(ctx context.Context, w http.ResponseWriter, response interfa
}
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(response)
return json.MarshalWrite(w, response, jsontext.WithIndent(" "))
}
// statuser allows response types to implement a custom
@@ -98,7 +97,8 @@ func makeDecoder(iface interface{}) kithttp.DecodeRequestFunc {
}
req := v.Interface()
if err := json.NewDecoder(body).Decode(req); err != nil {
err = json.UnmarshalRead(body, req)
if err != nil {
return nil, endpoint_utils.BadRequestErr("json decoder error", err)
}
v = reflect.ValueOf(req)
+2
View File
@@ -24,6 +24,8 @@ func attachFleetAPIRoutes(r *mux.Router, fleetSvc fleet.Service, svc android.Ser
ue := newUserAuthenticatedEndpointer(fleetSvc, svc, opts, r, apiVersions()...)
ue.GET("/api/_version_/fleet/android_enterprise/signup_url", androidEnterpriseSignupEndpoint, nil)
ue.DELETE("/api/_version_/fleet/android_enterprise", androidDeleteEnterpriseEndpoint, nil)
ue.GET("/api/_version_/fleet/android_enterprise/{id:[0-9]+}/enrollment_token", androidEnrollmentTokenEndpoint,
androidEnrollmentTokenRequest{})
+110
View File
@@ -0,0 +1,110 @@
package proxy
import (
"context"
"errors"
"fmt"
"os"
"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"
"google.golang.org/api/androidmanagement/v1"
"google.golang.org/api/googleapi"
"google.golang.org/api/option"
)
// Proxy is a temporary placeholder as an interface to the Google API.
// Once the real proxy is implemented on fleetdm.com, this package will be removed.
var (
// Required env vars to use the proxy
androidServiceCredentials = os.Getenv("FLEET_DEV_ANDROID_SERVICE_CREDENTIALS")
androidPubSubTopic = os.Getenv("FLEET_DEV_ANDROID_PUBSUB_TOPIC")
androidProjectID string
)
type Proxy struct {
logger kitlog.Logger
mgmt *androidmanagement.Service
}
func NewProxy(ctx context.Context, logger kitlog.Logger) *Proxy {
if androidServiceCredentials == "" || androidPubSubTopic == "" {
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
}
androidProjectID = creds.ProjectID
mgmt, err := androidmanagement.NewService(ctx, option.WithCredentialsJSON([]byte(androidServiceCredentials)))
if err != nil {
level.Error(logger).Log("msg", "creating android management service", "err", err)
return nil
}
return &Proxy{
logger: logger,
mgmt: mgmt,
}
}
func (p *Proxy) SignupURLsCreate(callbackURL string) (*android.SignupDetails, error) {
if p == nil || p.mgmt == nil {
return nil, errors.New("android management service not initialized")
}
signupURL, err := p.mgmt.SignupUrls.Create().ProjectId(androidProjectID).CallbackUrl(callbackURL).Do()
if err != nil {
return nil, fmt.Errorf("creating signup url: %w", err)
}
return &android.SignupDetails{
Url: signupURL.Url,
Name: signupURL.Name,
}, nil
}
func (p *Proxy) EnterprisesCreate(enabledNotificationTypes []string, enterpriseToken string, signupUrlName string) (string, error) {
if p == nil || p.mgmt == nil {
return "", errors.New("android management service not initialized")
}
enterprise, err := p.mgmt.Enterprises.Create(&androidmanagement.Enterprise{
EnabledNotificationTypes: enabledNotificationTypes,
PubsubTopic: androidPubSubTopic,
}).
ProjectId(androidProjectID).
EnterpriseToken(enterpriseToken).
SignupUrlName(signupUrlName).
Do()
switch {
case googleapi.IsNotModified(err):
return "", fmt.Errorf("android enterprise %s was already created", signupUrlName)
case err != nil:
return "", fmt.Errorf("creating enterprise: %w", err)
}
return enterprise.Name, nil
}
func (p *Proxy) EnterpriseDelete(enterpriseID string) error {
if p == nil || p.mgmt == nil {
return errors.New("android management service not initialized")
}
_, err := p.mgmt.Enterprises.Delete("enterprises/" + enterpriseID).Do()
switch {
case googleapi.IsNotModified(err):
level.Info(p.logger).Log("msg", "enterprise was already deleted", "enterprise_id", enterpriseID)
return nil
case err != nil:
return fmt.Errorf("deleting enterprise %s: %w", enterpriseID, err)
}
return nil
}
+137 -24
View File
@@ -4,21 +4,24 @@ import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android"
"github.com/fleetdm/fleet/v4/server/mdm/android/service/proxy"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
"google.golang.org/api/androidmanagement/v1"
)
type Service struct {
logger kitlog.Logger
authz *authz.Authorizer
mgmt *androidmanagement.Service
ds android.Datastore
fleetDS fleet.Datastore
proxy *proxy.Proxy
}
func NewService(
@@ -32,16 +35,14 @@ func NewService(
return nil, fmt.Errorf("new authorizer: %w", err)
}
// mgmt, err := androidmanagement.NewService(ctx)
// if err != nil {
// return nil, ctxerr.Wrap(ctx, err, "creating android management service")
// }
return Service{
prx := proxy.NewProxy(ctx, logger)
return &Service{
logger: logger,
authz: authorizer,
mgmt: nil,
ds: ds,
fleetDS: fleetDS,
proxy: prx,
}, nil
}
@@ -51,26 +52,57 @@ type androidResponse struct {
func (r androidResponse) Error() error { return r.Err }
func newErrResponse(err error) androidResponse {
return androidResponse{Err: err}
}
type androidEnterpriseSignupResponse struct {
*android.SignupDetails
Url string `json:"android_enterprise_signup_url"`
androidResponse
}
func androidEnterpriseSignupEndpoint(ctx context.Context, _ interface{}, svc android.Service) fleet.Errorer {
result, err := svc.EnterpriseSignup(ctx)
if err != nil {
return androidResponse{Err: err}
return newErrResponse(err)
}
return androidEnterpriseSignupResponse{SignupDetails: result}
return androidEnterpriseSignupResponse{Url: result.Url}
}
func (s Service) EnterpriseSignup(ctx context.Context) (*android.SignupDetails, error) {
s.authz.SkipAuthorization(ctx)
func (svc *Service) EnterpriseSignup(ctx context.Context) (*android.SignupDetails, error) {
if err := svc.authz.Authorize(ctx, &android.Enterprise{}, fleet.ActionWrite); err != nil {
return nil, err
}
// TODO: remove me
level.Warn(s.logger).Log("msg", "EnterpriseSignup called")
return nil, errors.New("not implemented")
appConfig, err := svc.fleetDS.AppConfig(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting app config")
}
if appConfig.MDM.AndroidEnabledAndConfigured {
return nil, fleet.NewInvalidArgumentError("android",
"Android is already enabled and configured").WithStatus(http.StatusConflict)
}
id, err := svc.ds.CreateEnterprise(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating enterprise")
}
callbackURL := fmt.Sprintf("%s/api/v1/fleet/android_enterprise/%d/connect", appConfig.ServerSettings.ServerURL, id)
signupDetails, err := svc.proxy.SignupURLsCreate(callbackURL)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating signup url")
}
err = svc.ds.UpdateEnterprise(ctx, &android.Enterprise{
ID: id,
SignupName: signupDetails.Name,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "updating enterprise")
}
return signupDetails, nil
}
type androidEnterpriseSignupCallbackRequest struct {
@@ -84,12 +116,93 @@ func androidEnterpriseSignupCallbackEndpoint(ctx context.Context, request interf
return androidResponse{Err: err}
}
func (s Service) EnterpriseSignupCallback(ctx context.Context, id uint, enterpriseToken string) error {
s.authz.SkipAuthorization(ctx)
func (svc *Service) EnterpriseSignupCallback(ctx context.Context, id uint, enterpriseToken string) error {
// Skip authorization because the callback is called by Google.
// TODO: Add some authorization here so random people can't bind random Android enterprises just for fun.
svc.authz.SkipAuthorization(ctx)
// TODO: remove me
level.Warn(s.logger).Log("msg", "EnterpriseSignupCallback called", "id", id, "enterpriseToken", enterpriseToken)
return errors.New("not implemented")
appConfig, err := svc.fleetDS.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting app config")
}
if appConfig.MDM.AndroidEnabledAndConfigured {
return fleet.NewInvalidArgumentError("android",
"Android is already enabled and configured").WithStatus(http.StatusConflict)
}
enterprise, err := svc.ds.GetEnterpriseByID(ctx, id)
switch {
case fleet.IsNotFound(err):
return fleet.NewInvalidArgumentError("id",
fmt.Sprintf("Enterprise with ID %d not found", id)).WithStatus(http.StatusNotFound)
case err != nil:
return ctxerr.Wrap(ctx, err, "getting enterprise")
}
name, err := svc.proxy.EnterprisesCreate(
[]string{"ENROLLMENT", "STATUS_REPORT", "COMMAND", "USAGE_LOGS"},
enterpriseToken,
enterprise.SignupName,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "creating enterprise")
}
enterpriseID := strings.TrimPrefix(name, "enterprises/")
enterprise.EnterpriseID = enterpriseID
err = svc.ds.UpdateEnterprise(ctx, enterprise)
if err != nil {
return ctxerr.Wrap(ctx, err, "updating enterprise")
}
err = svc.ds.DeleteOtherEnterprises(ctx, id)
if err != nil {
return ctxerr.Wrap(ctx, err, "deleting temp enterprises")
}
err = svc.fleetDS.SetAndroidEnabledAndConfigured(ctx, true)
if err != nil {
return ctxerr.Wrap(ctx, err, "setting android enabled and configured")
}
return nil
}
func androidDeleteEnterpriseEndpoint(ctx context.Context, _ interface{}, svc android.Service) fleet.Errorer {
err := svc.DeleteEnterprise(ctx)
return androidResponse{Err: err}
}
func (svc *Service) DeleteEnterprise(ctx context.Context) error {
if err := svc.authz.Authorize(ctx, &android.Enterprise{}, fleet.ActionWrite); err != nil {
return err
}
// Get enterprise
enterprise, err := svc.ds.GetEnterprise(ctx)
switch {
case fleet.IsNotFound(err):
// No enterprise to delete
case err != nil:
return ctxerr.Wrap(ctx, err, "getting enterprise")
default:
err = svc.proxy.EnterpriseDelete(enterprise.EnterpriseID)
if err != nil {
return ctxerr.Wrap(ctx, err, "deleting enterprise via Google API")
}
}
err = svc.ds.DeleteEnterprises(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "deleting enterprises")
}
err = svc.fleetDS.SetAndroidEnabledAndConfigured(ctx, false)
if err != nil {
return ctxerr.Wrap(ctx, err, "clearing android enabled and configured")
}
return nil
}
type androidEnrollmentTokenRequest struct {
@@ -109,10 +222,10 @@ func androidEnrollmentTokenEndpoint(ctx context.Context, request interface{}, sv
return androidEnrollmentTokenResponse{EnrollmentToken: token}
}
func (s Service) CreateEnrollmentToken(ctx context.Context) (*android.EnrollmentToken, error) {
s.authz.SkipAuthorization(ctx)
func (svc *Service) CreateEnrollmentToken(ctx context.Context) (*android.EnrollmentToken, error) {
svc.authz.SkipAuthorization(ctx)
// TODO: remove me
level.Warn(s.logger).Log("msg", "CreateEnrollmentToken called")
level.Warn(svc.logger).Log("msg", "CreateEnrollmentToken called")
return nil, errors.New("not implemented")
}
+12
View File
@@ -1227,6 +1227,8 @@ type ExpandEmbeddedSecretsFunc func(ctx context.Context, document string) (strin
type ExpandEmbeddedSecretsAndUpdatedAtFunc func(ctx context.Context, document string) (string, *time.Time, error)
type SetAndroidEnabledAndConfiguredFunc func(ctx context.Context, configured bool) error
type DataStore struct {
HealthCheckFunc HealthCheckFunc
HealthCheckFuncInvoked bool
@@ -3037,6 +3039,9 @@ type DataStore struct {
ExpandEmbeddedSecretsAndUpdatedAtFunc ExpandEmbeddedSecretsAndUpdatedAtFunc
ExpandEmbeddedSecretsAndUpdatedAtFuncInvoked bool
SetAndroidEnabledAndConfiguredFunc SetAndroidEnabledAndConfiguredFunc
SetAndroidEnabledAndConfiguredFuncInvoked bool
mu sync.Mutex
}
@@ -7260,3 +7265,10 @@ func (s *DataStore) ExpandEmbeddedSecretsAndUpdatedAt(ctx context.Context, docum
s.mu.Unlock()
return s.ExpandEmbeddedSecretsAndUpdatedAtFunc(ctx, document)
}
func (s *DataStore) SetAndroidEnabledAndConfigured(ctx context.Context, configured bool) error {
s.mu.Lock()
s.SetAndroidEnabledAndConfiguredFuncInvoked = true
s.mu.Unlock()
return s.SetAndroidEnabledAndConfiguredFunc(ctx, configured)
}
+5
View File
@@ -13,6 +13,7 @@ import (
"net"
"net/http"
"net/url"
"os"
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
"github.com/fleetdm/fleet/v4/pkg/optjson"
@@ -58,6 +59,7 @@ type appConfigResponseFields struct {
// SandboxEnabled is true if fleet serve was ran with server.sandbox_enabled=true
SandboxEnabled bool `json:"sandbox_enabled,omitempty"`
Err error `json:"error,omitempty"`
AndroidEnabled bool `json:"android_enabled,omitempty"`
}
// UnmarshalJSON implements the json.Unmarshaler interface to make sure we serialize
@@ -199,6 +201,7 @@ func getAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Se
Logging: loggingConfig,
Email: emailConfig,
SandboxEnabled: svc.SandboxEnabled(),
AndroidEnabled: os.Getenv("FLEET_DEV_ANDROID_ENABLED") == "1", // Temporary feature flag that will be removed.
},
}
return response, nil
@@ -542,6 +545,8 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle
appConfig.MDM.AppleBMTermsExpired = oldAppConfig.MDM.AppleBMTermsExpired
appConfig.MDM.AppleBMEnabledAndConfigured = oldAppConfig.MDM.AppleBMEnabledAndConfigured
appConfig.MDM.EnabledAndConfigured = oldAppConfig.MDM.EnabledAndConfigured
// ignore MDM.AndroidEnabledAndConfigured because it is set by the server only
appConfig.MDM.AndroidEnabledAndConfigured = oldAppConfig.MDM.AndroidEnabledAndConfigured
// do not send a test email in dry-run mode, so this is a good place to stop
// (we also delete the removed integrations after that, which we don't want
+21 -7
View File
@@ -2,24 +2,38 @@ package main
import (
"context"
"encoding/json"
"flag"
"log"
"os"
"github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"google.golang.org/api/androidmanagement/v1"
"google.golang.org/api/option"
)
// Required env vars:
var (
androidServiceCredentials = os.Getenv("FLEET_ANDROID_SERVICE_CREDENTIALS")
androidProjectID = os.Getenv("FLEET_ANDROID_PROJECT_ID")
androidServiceCredentials = os.Getenv("FLEET_DEV_ANDROID_SERVICE_CREDENTIALS")
androidProjectID string
)
func main() {
if androidServiceCredentials == "" || androidProjectID == "" {
log.Fatal("FLEET_ANDROID_SERVICE_CREDENTIALS and FLEET_ANDROID_PROJECT_ID must be set")
if androidServiceCredentials == "" {
log.Fatal("FLEET_DEV_ANDROID_SERVICE_CREDENTIALS must be set")
}
type credentials struct {
ProjectID string `json:"project_id"`
}
var creds credentials
err := json.Unmarshal([]byte(androidServiceCredentials), &creds)
if err != nil {
log.Fatalf("unmarshaling android service credentials: %s", err)
}
androidProjectID = creds.ProjectID
if androidProjectID == "" {
log.Fatal("project_id not found in android service credentials")
}
command := flag.String("command", "", "")
@@ -106,7 +120,7 @@ func devicesList(mgmt *androidmanagement.Service, enterpriseID string) {
return
}
for _, device := range result.Devices {
data, err := json.MarshalIndent(device, "", " ")
data, err := json.Marshal(device, jsontext.WithIndent(" "))
if err != nil {
log.Fatalf("Error marshalling device: %v", err)
}
@@ -136,7 +150,7 @@ func devicesRelinquishOwnership(mgmt *androidmanagement.Service, enterpriseID, d
if err != nil {
log.Fatalf("Error issuing command: %v", err)
}
data, err := json.MarshalIndent(operation, "", " ")
data, err := json.Marshal(operation, jsontext.WithIndent(" "))
if err != nil {
log.Fatalf("Error marshalling operation: %v", err)
}
@@ -174,6 +174,7 @@ github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleVolumePurchasingProgramInfo] Value []fleet.MDMAppleVolumePurchasingProgramInfo
github.com/fleetdm/fleet/v4/server/fleet/MDMAppleVolumePurchasingProgramInfo Location string
github.com/fleetdm/fleet/v4/server/fleet/MDMAppleVolumePurchasingProgramInfo Teams []string
github.com/fleetdm/fleet/v4/server/fleet/MDM AndroidEnabledAndConfigured bool
github.com/fleetdm/fleet/v4/server/fleet/AppConfig Scripts optjson.Slice[string]
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Set bool
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Valid bool