From 7cd581866afbf3e2e80604a272cfebf013440dc8 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Wed, 8 Feb 2023 20:20:23 -0300 Subject: [PATCH] add API endpoint to see disk encryption key (#9713) https://github.com/fleetdm/fleet/issues/8708 --- changes/8708-fv-api | 1 + cmd/fleet/serve_test.go | 8 +- .../expectedHostDetailResponseJson.json | 1 + .../expectedHostDetailResponseYaml.yml | 1 + .../testdata/expectedListHostsJson.json | 2 + .../testdata/expectedListHostsYaml.yml | 2 + docs/Using-Fleet/Audit-Activities.md | 17 ++ docs/Using-Fleet/Permissions.md | 127 +++++++------ docs/Using-Fleet/REST-API.md | 40 +++++ server/datastore/mysql/hosts.go | 39 +++- server/datastore/mysql/hosts_test.go | 17 +- server/fleet/activities.go | 21 +++ server/fleet/datastore.go | 6 +- server/fleet/hosts.go | 14 +- server/fleet/service.go | 2 + server/mock/datastore_mock.go | 14 +- server/service/handler.go | 1 + server/service/hosts.go | 91 +++++++++- server/service/hosts_test.go | 164 +++++++++++++++++ server/service/integration_enterprise_test.go | 22 --- server/service/integration_mdm_test.go | 170 ++++++++++++++++++ .../service/osquery_utils/gen_queries_doc.go | 2 +- server/service/testing_client.go | 23 +++ 23 files changed, 661 insertions(+), 124 deletions(-) create mode 100644 changes/8708-fv-api diff --git a/changes/8708-fv-api b/changes/8708-fv-api new file mode 100644 index 0000000000..fe5de98afa --- /dev/null +++ b/changes/8708-fv-api @@ -0,0 +1 @@ +* Added an API endpoint to retrieve a host disk encryption key for macOS if Fleet's MDM is enabled. diff --git a/cmd/fleet/serve_test.go b/cmd/fleet/serve_test.go index a9887e0c6e..c2396489b4 100644 --- a/cmd/fleet/serve_test.go +++ b/cmd/fleet/serve_test.go @@ -1007,8 +1007,8 @@ func TestVerifyDiskEncryptionKeysJob(t *testing.T) { now := time.Now() t.Run("able to decrypt", func(t *testing.T) { - ds.GetUnverifiedDiskEncryptionKeysFunc = func(ctx context.Context) ([]fleet.DiskEncryptionKey, error) { - return []fleet.DiskEncryptionKey{ + ds.GetUnverifiedDiskEncryptionKeysFunc = func(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) { + return []fleet.HostDiskEncryptionKey{ {HostID: 1, Base64Encrypted: base64EncryptedKey, UpdatedAt: now}, {HostID: 2, Base64Encrypted: base64EncryptedKey, UpdatedAt: now.Add(time.Hour)}, {HostID: 3, Base64Encrypted: "BAD-KEY", UpdatedAt: now.Add(-time.Hour)}, @@ -1039,8 +1039,8 @@ func TestVerifyDiskEncryptionKeysJob(t *testing.T) { }) t.Run("unable to decrypt", func(t *testing.T) { - ds.GetUnverifiedDiskEncryptionKeysFunc = func(ctx context.Context) ([]fleet.DiskEncryptionKey, error) { - return []fleet.DiskEncryptionKey{{HostID: 1, Base64Encrypted: "RANDOM"}}, nil + ds.GetUnverifiedDiskEncryptionKeysFunc = func(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) { + return []fleet.HostDiskEncryptionKey{{HostID: 1, Base64Encrypted: "RANDOM"}}, nil } calls := 0 diff --git a/cmd/fleetctl/testdata/expectedHostDetailResponseJson.json b/cmd/fleetctl/testdata/expectedHostDetailResponseJson.json index 286baa078f..6cfab926f4 100644 --- a/cmd/fleetctl/testdata/expectedHostDetailResponseJson.json +++ b/cmd/fleetctl/testdata/expectedHostDetailResponseJson.json @@ -39,6 +39,7 @@ "config_tls_refresh": 0, "logger_tls_period": 0, "mdm": { + "encryption_key_available": false, "enrollment_status": null, "server_url": null }, diff --git a/cmd/fleetctl/testdata/expectedHostDetailResponseYaml.yml b/cmd/fleetctl/testdata/expectedHostDetailResponseYaml.yml index b866a0249e..f31c37e7c3 100644 --- a/cmd/fleetctl/testdata/expectedHostDetailResponseYaml.yml +++ b/cmd/fleetctl/testdata/expectedHostDetailResponseYaml.yml @@ -30,6 +30,7 @@ spec: last_enrolled_at: "0001-01-01T00:00:00Z" logger_tls_period: 0 mdm: + encryption_key_available: false enrollment_status: null server_url: null memory: 0 diff --git a/cmd/fleetctl/testdata/expectedListHostsJson.json b/cmd/fleetctl/testdata/expectedListHostsJson.json index e72f2bd20b..7a46a80bdf 100644 --- a/cmd/fleetctl/testdata/expectedListHostsJson.json +++ b/cmd/fleetctl/testdata/expectedListHostsJson.json @@ -40,6 +40,7 @@ "config_tls_refresh": 0, "logger_tls_period": 0, "mdm": { + "encryption_key_available": false, "enrollment_status": null, "server_url": null }, @@ -106,6 +107,7 @@ "config_tls_refresh": 0, "logger_tls_period": 0, "mdm": { + "encryption_key_available": false, "enrollment_status": null, "server_url": null }, diff --git a/cmd/fleetctl/testdata/expectedListHostsYaml.yml b/cmd/fleetctl/testdata/expectedListHostsYaml.yml index 737af4d19b..f40a34b644 100644 --- a/cmd/fleetctl/testdata/expectedListHostsYaml.yml +++ b/cmd/fleetctl/testdata/expectedListHostsYaml.yml @@ -34,6 +34,7 @@ spec: last_enrolled_at: "0001-01-01T00:00:00Z" logger_tls_period: 0 mdm: + encryption_key_available: false enrollment_status: null server_url: null memory: 0 @@ -87,6 +88,7 @@ spec: last_enrolled_at: "0001-01-01T00:00:00Z" logger_tls_period: 0 mdm: + encryption_key_available: false enrollment_status: null server_url: null memory: 0 diff --git a/docs/Using-Fleet/Audit-Activities.md b/docs/Using-Fleet/Audit-Activities.md index 6fe5afb6ba..0ea6eb5f9d 100644 --- a/docs/Using-Fleet/Audit-Activities.md +++ b/docs/Using-Fleet/Audit-Activities.md @@ -583,6 +583,23 @@ This activity contains the following fields: } ``` +### Type `read_host_disk_encryption_key` + +Generated when a user reads the disk encryption key for a host. + +This activity contains the following fields: +- "host_id": ID of the host. +- "host_display_name": Display name of the host. + +#### Example + +```json +{ + "host_id": 1, + "host_display_name": "Anna's MacBook Pro", +} +``` + \ No newline at end of file diff --git a/docs/Using-Fleet/Permissions.md b/docs/Using-Fleet/Permissions.md index 3c3a9a4753..f932df8ec3 100644 --- a/docs/Using-Fleet/Permissions.md +++ b/docs/Using-Fleet/Permissions.md @@ -6,48 +6,45 @@ Users with the Admin role receive all permissions. ## User permissions -| **Action** | Observer | Maintainer | Admin | -| ---------------------------------------------------- | -------- | ---------- | ----- | -| View all [activity](https://fleetdm.com/docs/using-fleet/rest-api#activities) | ✅ | ✅ | ✅ | -| View all hosts | ✅ | ✅ | ✅ | +| **Action** | Observer | Maintainer | Admin | +| ------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ---------- | ----- | +| View all [activity](https://fleetdm.com/docs/using-fleet/rest-api#activities) | ✅ | ✅ | ✅ | +| View all hosts | ✅ | ✅ | ✅ | | Filter hosts using [labels](https://fleetdm.com/docs/using-fleet/rest-api#labels) | ✅ | ✅ | ✅ | -| Target hosts using labels | ✅ | ✅ | ✅ | -| Add and delete hosts | | ✅ | ✅ | -| Transfer hosts between teams\* | | ✅ | ✅ | -| Create, edit, and delete labels | | ✅ | ✅ | -| View all software | ✅ | ✅ | ✅ | -| Filter software by [vulnerabilities](https://fleetdm.com/docs/using-fleet/vulnerability-processing#vulnerability-processing) | ✅ | ✅ | ✅ | -| Filter hosts by software | ✅ | ✅ | ✅ | -| Filter software by team\* | ✅ | ✅ | ✅ | -| Manage [vulnerability automations](https://fleetdm.com/docs/using-fleet/automations#vulnerability-automations) | | | ✅ | -| Run only designated, **observer can run** ,queries as live queries against all hosts | ✅ | ✅ | ✅ | -| Run any query as [live query](https://fleetdm.com/docs/using-fleet/fleet-ui#run-a-query) against all hosts | | ✅ | ✅ | -| Create, edit, and delete queries | | ✅ | ✅ | -| View all queries | ✅ | ✅ | ✅ | -| Add, edit, and remove queries from all schedules | | ✅ | ✅ | -| Create, edit, view, and delete packs | | ✅ | ✅ | -| View all policies | ✅ | ✅ | ✅ | -| Filter hosts using policies | ✅ | ✅ | ✅ | -| Create, edit, and delete policies for all hosts | | ✅ | ✅ | -| Create, edit, and delete policies for all hosts assigned to team\* | | ✅ | ✅ | -| Manage [policy automations](https://fleetdm.com/docs/using-fleet/automations#policy-automations) | | | ✅ | -| Create, edit, view, and delete users | | | ✅ | -| Add and remove team members\* | | | ✅ | -| Create, edit, and delete teams\* | | | ✅ | -| Create, edit, and delete [enroll secrets](https://fleetdm.com/docs/deploying/faq#when-do-i-need-to-deploy-a-new-enroll-secret-to-my-hosts) | | ✅ | ✅ | -| Create, edit, and delete [enroll secrets for teams](https://fleetdm.com/docs/using-fleet/rest-api#get-enroll-secrets-for-a-team)\* | | ✅ | ✅ | -| Edit [organization settings](https://fleetdm.com/docs/using-fleet/configuration-files#organization-settings) | | | ✅ | -| Edit [agent options](https://fleetdm.com/docs/using-fleet/configuration-files#agent-options) | | | ✅ | -| Edit [agent options for hosts assigned to teams](https://fleetdm.com/docs/using-fleet/configuration-files#team-agent-options)\* | | | ✅ | -| Initiate [file carving](https://fleetdm.com/docs/using-fleet/rest-api#file-carving) | | ✅ | ✅ | -| Retrieve contents from file carving | | | ✅ | -| View Apple mobile device management (MDM) certificate information | | | ✅ | -| View Apple business manager (BM) information | | | ✅ | -| Generate Apple mobile device management (MDM) certificate signing request (CSR) | | | ✅ | - - - - +| Target hosts using labels | ✅ | ✅ | ✅ | +| Add and delete hosts | | ✅ | ✅ | +| Transfer hosts between teams\* | | ✅ | ✅ | +| Create, edit, and delete labels | | ✅ | ✅ | +| View all software | ✅ | ✅ | ✅ | +| Filter software by [vulnerabilities](https://fleetdm.com/docs/using-fleet/vulnerability-processing#vulnerability-processing) | ✅ | ✅ | ✅ | +| Filter hosts by software | ✅ | ✅ | ✅ | +| Filter software by team\* | ✅ | ✅ | ✅ | +| Manage [vulnerability automations](https://fleetdm.com/docs/using-fleet/automations#vulnerability-automations) | | | ✅ | +| Run only designated, **observer can run** ,queries as live queries against all hosts | ✅ | ✅ | ✅ | +| Run any query as [live query](https://fleetdm.com/docs/using-fleet/fleet-ui#run-a-query) against all hosts | | ✅ | ✅ | +| Create, edit, and delete queries | | ✅ | ✅ | +| View all queries | ✅ | ✅ | ✅ | +| Add, edit, and remove queries from all schedules | | ✅ | ✅ | +| Create, edit, view, and delete packs | | ✅ | ✅ | +| View all policies | ✅ | ✅ | ✅ | +| Filter hosts using policies | ✅ | ✅ | ✅ | +| Create, edit, and delete policies for all hosts | | ✅ | ✅ | +| Create, edit, and delete policies for all hosts assigned to team\* | | ✅ | ✅ | +| Manage [policy automations](https://fleetdm.com/docs/using-fleet/automations#policy-automations) | | | ✅ | +| Create, edit, view, and delete users | | | ✅ | +| Add and remove team members\* | | | ✅ | +| Create, edit, and delete teams\* | | | ✅ | +| Create, edit, and delete [enroll secrets](https://fleetdm.com/docs/deploying/faq#when-do-i-need-to-deploy-a-new-enroll-secret-to-my-hosts) | | ✅ | ✅ | +| Create, edit, and delete [enroll secrets for teams](https://fleetdm.com/docs/using-fleet/rest-api#get-enroll-secrets-for-a-team)\* | | ✅ | ✅ | +| Edit [organization settings](https://fleetdm.com/docs/using-fleet/configuration-files#organization-settings) | | | ✅ | +| Edit [agent options](https://fleetdm.com/docs/using-fleet/configuration-files#agent-options) | | | ✅ | +| Edit [agent options for hosts assigned to teams](https://fleetdm.com/docs/using-fleet/configuration-files#team-agent-options)\* | | | ✅ | +| Initiate [file carving](https://fleetdm.com/docs/using-fleet/rest-api#file-carving) | | ✅ | ✅ | +| Retrieve contents from file carving | | | ✅ | +| View Apple mobile device management (MDM) certificate information | | | ✅ | +| View Apple business manager (BM) information | | | ✅ | +| Generate Apple mobile device management (MDM) certificate signing request (CSR) | | | ✅ | +| View disk encryption key for macOS hosts enrolled in Fleet's MDM | ✅ | ✅ | ✅ | \*Applies only to Fleet Premium @@ -68,29 +65,29 @@ Users can be a member of multiple teams in Fleet. Users that are members of multiple teams can be assigned different roles for each team. For example, a user can be given access to the "Workstations" team and assigned the "Observer" role. This same user can be given access to the "Servers" team and assigned the "Maintainer" role. -| **Action** | Team observer | Team maintainer | Team admin | -| ------------------------------------------------------------ | -------- | ---------- | ------- | -| View hosts | ✅ | ✅ | ✅ | -| Filter hosts using [labels](https://fleetdm.com/docs/using-fleet/rest-api#labels) | ✅ | ✅ | ✅ | -| Target hosts using labels | ✅ | ✅ | ✅ | -| Add and delete hosts | | ✅ | ✅ | -| Filter software by [vulnerabilities]((https://fleetdm.com/docs/using-fleet/vulnerability-processing#vulnerability-processing)) | ✅ | ✅ | ✅ | -| Filter hosts by software | ✅ | ✅ | ✅ | -| Filter software | ✅ | ✅ | ✅ | -| Run only designated, **observer can run** ,queries as live queries against all hosts | ✅ | ✅ | ✅ | -| Run any query as [live query](https://fleetdm.com/docs/using-fleet/fleet-ui#run-a-query) | | ✅ | ✅ | -| Create, edit, and delete only **self authored** queries | | ✅ | ✅ | -| Add, edit, and remove queries from the schedule | | ✅ | ✅ | -| View policies | ✅ | ✅ | ✅ | -| View global (inherited) policies | ✅ | ✅ | ✅ | -| Filter hosts using policies | ✅ | ✅ | ✅ | -| Create, edit, and delete policies | | ✅ | ✅ | -| Manage [policy automations](https://fleetdm.com/docs/using-fleet/automations#policy-automations) | | | ✅ | -| Add and remove team members | | | ✅ | -| Edit team name | | | ✅ | -| Create, edit, and delete [team enroll secrets](https://fleetdm.com/docs/using-fleet/rest-api#get-enroll-secrets-for-a-team) | | ✅ | ✅ | -| Edit [agent options](https://fleetdm.com/docs/using-fleet/configuration-files#agent-options) | | | ✅ | -| Initiate [file carving](https://fleetdm.com/docs/using-fleet/rest-api#file-carving) | | ✅ | ✅ | - +| **Action** | Team observer | Team maintainer | Team admin | +| -------------------------------------------------------------------------------------------------------------------------------- | ------------- | --------------- | ---------- | +| View hosts | ✅ | ✅ | ✅ | +| Filter hosts using [labels](https://fleetdm.com/docs/using-fleet/rest-api#labels) | ✅ | ✅ | ✅ | +| Target hosts using labels | ✅ | ✅ | ✅ | +| Add and delete hosts | | ✅ | ✅ | +| Filter software by [vulnerabilities](<(https://fleetdm.com/docs/using-fleet/vulnerability-processing#vulnerability-processing)>) | ✅ | ✅ | ✅ | +| Filter hosts by software | ✅ | ✅ | ✅ | +| Filter software | ✅ | ✅ | ✅ | +| Run only designated, **observer can run** ,queries as live queries against all hosts | ✅ | ✅ | ✅ | +| Run any query as [live query](https://fleetdm.com/docs/using-fleet/fleet-ui#run-a-query) | | ✅ | ✅ | +| Create, edit, and delete only **self authored** queries | | ✅ | ✅ | +| Add, edit, and remove queries from the schedule | | ✅ | ✅ | +| View policies | ✅ | ✅ | ✅ | +| View global (inherited) policies | ✅ | ✅ | ✅ | +| Filter hosts using policies | ✅ | ✅ | ✅ | +| Create, edit, and delete policies | | ✅ | ✅ | +| Manage [policy automations](https://fleetdm.com/docs/using-fleet/automations#policy-automations) | | | ✅ | +| Add and remove team members | | | ✅ | +| Edit team name | | | ✅ | +| Create, edit, and delete [team enroll secrets](https://fleetdm.com/docs/using-fleet/rest-api#get-enroll-secrets-for-a-team) | | ✅ | ✅ | +| Edit [agent options](https://fleetdm.com/docs/using-fleet/configuration-files#agent-options) | | | ✅ | +| Initiate [file carving](https://fleetdm.com/docs/using-fleet/rest-api#file-carving) | | ✅ | ✅ | +| View disk encryption key for macOS hosts enrolled in Fleet's MDM | ✅ | ✅ | ✅ | diff --git a/docs/Using-Fleet/REST-API.md b/docs/Using-Fleet/REST-API.md index 897f55d1f7..2c92776aa0 100644 --- a/docs/Using-Fleet/REST-API.md +++ b/docs/Using-Fleet/REST-API.md @@ -1723,6 +1723,7 @@ None. - [Get aggregated host's mobile device management (MDM) and Munki information](#get-aggregated-hosts-macadmin-mobile-device-management-mdm-and-munki-information) - [Get host OS versions](#get-host-os-versions) - [Get hosts report in CSV](#get-hosts-report-in-csv) +- [Get host's disk encryption key](#get-hosts-disk-encryption-key) ### On the different timestamps in the host data structure @@ -1864,6 +1865,7 @@ If `after` is being used with `created_at` or `updated_at`, the table must be sp } }, "mdm": { + "encryption_key_available": false, "enrollment_status": null, "server_url": null } @@ -2243,6 +2245,7 @@ Returns the information of the specified host. } }, "mdm": { + "encryption_key_available": false, "enrollment_status": null, "server_url": null } @@ -2421,6 +2424,7 @@ Returns the information of the host specified using the `uuid`, `osquery_host_id "display_text": "dogfood-ubuntu-box", "display_name": "dogfood-ubuntu-box", "mdm": { + "encryption_key_available": false, "enrollment_status": null, "server_url": null } @@ -2989,6 +2993,42 @@ created_at,updated_at,id,detail_updated_at,label_updated_at,policy_updated_at,la 2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,3,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:21:56Z,false,foo.local2,48ebe4b0-39c3-4a74-a67f-308f7b5dd171,linux,,,,,,0s,0,,,,0,0,,,,,,,,,0,0,0,,,0,0,0,,,, ``` +### Get host's disk encryption key + +Requires the [macadmins osquery extension](https://github.com/macadmins/osquery-extension) which comes bundled +in [Fleet's osquery installers](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). + +Requires Fleet's MDM properly [enabled and configured](./Mobile-device-management.md). + +Retrieves the disk encryption key for a host. + +`GET /api/v1/fleet/hosts/:id/encryption_key` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ------- | ---- | ------------------------------------------------------------------ | +| id | integer | path | **Required** The id of the host to get the disk encryption key for | + + +#### Example + +`GET /api/v1/fleet/hosts/8/encryption_key` + +##### Default response + +`Status: 200` + +```json +{ + "host_id": 8, + "encryption_key": { + "key": "5ADZ-HTZ8-LJJ4-B2F8-JWH3-YPBT", + "updated_at": "2022-12-01T05:31:43Z" + } +} +``` + --- diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 9b5a2870ae..b218315151 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -429,6 +429,7 @@ FROM LEFT JOIN host_updates hu ON (h.id = hu.host_id) LEFT JOIN host_disks hd ON hd.host_id = h.id LEFT JOIN host_mdm hmdm on hmdm.host_id = h.id + LEFT JOIN host_disk_encryption_keys hdek ON hdek.host_id = h.id JOIN ( SELECT count(*) as count @@ -490,6 +491,16 @@ const hostMDMSelect = `, CASE WHEN hmdm.is_server = 1 THEN NULL ELSE hmdm.server_url + END, + 'encryption_key_available', + CASE + /* roberto: this is the only way I have found for MySQL to + * return true and false instead of 0 and 1 in the JSON, the + * unmarshaller was having problems converting int values to + * booleans. + */ + WHEN hdek.decryptable IS NULL OR hdek.decryptable = 0 THEN CAST(FALSE AS JSON) + ELSE CAST(TRUE AS JSON) END ) mdm_host_data ` @@ -685,7 +696,8 @@ func (ds *Datastore) applyHostFilters(opt fleet.HostListOptions, sql string, fil LEFT JOIN host_updates hu ON (h.id = hu.host_id) LEFT JOIN teams t ON (h.team_id = t.id) LEFT JOIN host_disks hd ON hd.host_id = h.id - LEFT JOIN host_mdm hmdm ON hmdm.host_id = h.id + LEFT JOIN host_mdm hmdm ON hmdm.host_id = h.id + LEFT JOIN host_disk_encryption_keys hdek ON hdek.host_id = h.id %s %s %s @@ -1485,6 +1497,7 @@ func (ds *Datastore) SearchHosts(ctx context.Context, filter fleet.TeamFilter, m LEFT JOIN host_updates hu ON (h.id = hu.host_id) LEFT JOIN host_disks hd ON hd.host_id = h.id LEFT JOIN host_mdm hmdm on hmdm.host_id = h.id + LEFT JOIN host_disk_encryption_keys hdek ON hdek.host_id = h.id WHERE TRUE` var args []interface{} @@ -1592,6 +1605,7 @@ func (ds *Datastore) HostByIdentifier(ctx context.Context, identifier string) (* LEFT JOIN host_updates hu ON (h.id = hu.host_id) LEFT JOIN host_disks hd ON hd.host_id = h.id LEFT JOIN host_mdm hmdm ON hmdm.host_id = h.id + LEFT JOIN host_disk_encryption_keys hdek ON hdek.host_id = h.id WHERE ? IN (h.hostname, h.osquery_host_id, h.node_key, h.uuid) LIMIT 1 ` @@ -2348,8 +2362,8 @@ func (ds *Datastore) SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostI } -func (ds *Datastore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]fleet.DiskEncryptionKey, error) { - var keys []fleet.DiskEncryptionKey +func (ds *Datastore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) { + var keys []fleet.HostDiskEncryptionKey err := sqlx.SelectContext(ctx, ds.reader, &keys, ` SELECT base64_encrypted, @@ -2384,6 +2398,25 @@ func (ds *Datastore) SetHostsDiskEncryptionKeyStatus( return err } +func (ds *Datastore) GetHostDiskEncryptionKey(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) { + var key fleet.HostDiskEncryptionKey + err := sqlx.GetContext(ctx, ds.reader, &key, ` + SELECT + host_id, base64_encrypted, decryptable, updated_at + FROM + host_disk_encryption_keys + WHERE host_id = ?`, hostID) + + if err != nil { + if err == sql.ErrNoRows { + msg := fmt.Sprintf("for host %d", hostID) + return nil, ctxerr.Wrap(ctx, notFound("HostDiskEncryptionKey").WithMessage(msg)) + } + return nil, ctxerr.Wrapf(ctx, err, "getting data from host_mdm for host_id %d", hostID) + } + return &key, nil +} + func (ds *Datastore) SetOrUpdateHostOrbitInfo(ctx context.Context, hostID uint, version string) error { return ds.updateOrInsert( ctx, diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 482646722d..ce8995ce59 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -6034,20 +6034,9 @@ func testHostsSetOrUpdateHostDisksEncryptionKey(t *testing.T, ds *Datastore) { require.NoError(t, err) checkEncryptionKey := func(hostID uint, expected string) { - ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { - var actual string - - row := tx.QueryRowxContext( - context.Background(), - "SELECT base64_encrypted FROM host_disk_encryption_keys WHERE host_id = ?", - hostID, - ) - - err := row.Scan(&actual) - require.NoError(t, err) - require.Equal(t, expected, actual) - return nil - }) + actual, err := ds.GetHostDiskEncryptionKey(context.Background(), hostID) + require.NoError(t, err) + require.Equal(t, expected, actual.Base64Encrypted) } h, err := ds.Host(context.Background(), host.ID) diff --git a/server/fleet/activities.go b/server/fleet/activities.go index ad84ecc4a3..7aaf71a5bd 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -47,6 +47,8 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeMDMUnenrolled{}, ActivityTypeEditedMacOSMinVersion{}, + + ActivityTypeReadHostDiskEncryptionKey{}, } type ActivityDetails interface { @@ -708,3 +710,22 @@ func (a ActivityTypeEditedMacOSMinVersion) Documentation() (activity string, det "deadline": "2023-06-01" }` } + +type ActivityTypeReadHostDiskEncryptionKey struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` +} + +func (a ActivityTypeReadHostDiskEncryptionKey) ActivityName() string { + return "read_host_disk_encryption_key" +} + +func (a ActivityTypeReadHostDiskEncryptionKey) Documentation() (activity string, details string, detailsExample string) { + return `Generated when a user reads the disk encryption key for a host.`, + `This activity contains the following fields: +- "host_id": ID of the host. +- "host_display_name": Display name of the host.`, `{ + "host_id": 1, + "host_display_name": "Anna's MacBook Pro", +}` +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 971662834d..6d5d6e71f0 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -642,10 +642,12 @@ type Datastore interface { // GetUnverifiedDiskEncryptionKeys returns all the encryption keys that // are collected but their decryptable status is not known yet (ie: // we're able to decrypt the key using a private key in the server) - GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]DiskEncryptionKey, error) - // SetHostsDiskEncryptionKeyStatus sets the encryptable status for the set + GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]HostDiskEncryptionKey, error) + // SetHostDiskEncryptionKeyStatus sets the encryptable status for the set // of encription keys provided SetHostsDiskEncryptionKeyStatus(ctx context.Context, hostIDs []uint, encryptable bool, threshold time.Time) error + // GetHostDiskEncryptionKey returns the encryption key information for a given host + GetHostDiskEncryptionKey(ctx context.Context, hostID uint) (*HostDiskEncryptionKey, error) // SetOrUpdateHostOrbitInfo inserts of updates the orbit info for a host SetOrUpdateHostOrbitInfo(ctx context.Context, hostID uint, version string) error diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 4357fdedc1..d8bd8174b5 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -220,6 +220,9 @@ type MDMHostData struct { // ServerURL is the server_url stored in the host_mdm table, loaded by // JOIN in datastore ServerURL *string `json:"server_url" db:"-" csv:"mdm.server_url"` + // EncryptionKeyAvailable indicates if Fleet was able to retrieve and + // decode an encryption key for the host. + EncryptionKeyAvailable bool `json:"encryption_key_available" db:"-" csv:"-"` } // Scan implements the Scanner interface for sqlx, to support unmarshaling a @@ -640,9 +643,10 @@ type HostMDMCheckinInfo struct { DisplayName string `json:"display_name" db:"display_name"` } -type DiskEncryptionKey struct { - HostID uint `db:"host_id"` - Base64Encrypted string `db:"base64_encrypted"` - Decryptable *bool `db:"decryptable"` - UpdatedAt time.Time `db:"updated_at"` +type HostDiskEncryptionKey struct { + HostID uint `json:"-" db:"host_id"` + Base64Encrypted string `json:"-" db:"base64_encrypted"` + Decryptable *bool `json:"-" db:"decryptable"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + DecryptedValue string `json:"key" db:"-"` } diff --git a/server/fleet/service.go b/server/fleet/service.go index 28e2b2bab9..5a8e6853fa 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -324,6 +324,8 @@ type Service interface { GetMDMSolution(ctx context.Context, mdmID uint) (*MDMSolution, error) GetMunkiIssue(ctx context.Context, munkiIssueID uint) (*MunkiIssue, error) + HostEncryptionKey(ctx context.Context, id uint) (*HostDiskEncryptionKey, error) + // OSVersions returns a list of operating systems and associated host counts, which may be // filtered using the following optional criteria: team id, platform, or name and version. // Name cannot be used without version, and conversely, version cannot be used without name. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index eacefbfe75..2e8ef90915 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -464,10 +464,12 @@ type SetOrUpdateHostDisksEncryptionFunc func(ctx context.Context, hostID uint, e type SetOrUpdateHostDiskEncryptionKeyFunc func(ctx context.Context, hostID uint, encryptedBase64Key string) error -type GetUnverifiedDiskEncryptionKeysFunc func(ctx context.Context) ([]fleet.DiskEncryptionKey, error) +type GetUnverifiedDiskEncryptionKeysFunc func(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) type SetHostsDiskEncryptionKeyStatusFunc func(ctx context.Context, hostIDs []uint, encryptable bool, threshold time.Time) error +type GetHostDiskEncryptionKeyFunc func(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) + type SetOrUpdateHostOrbitInfoFunc func(ctx context.Context, hostID uint, version string) error type ReplaceHostDeviceMappingFunc func(ctx context.Context, id uint, mappings []*fleet.HostDeviceMapping) error @@ -1216,6 +1218,9 @@ type DataStore struct { SetHostsDiskEncryptionKeyStatusFunc SetHostsDiskEncryptionKeyStatusFunc SetHostsDiskEncryptionKeyStatusFuncInvoked bool + GetHostDiskEncryptionKeyFunc GetHostDiskEncryptionKeyFunc + GetHostDiskEncryptionKeyFuncInvoked bool + SetOrUpdateHostOrbitInfoFunc SetOrUpdateHostOrbitInfoFunc SetOrUpdateHostOrbitInfoFuncInvoked bool @@ -2441,7 +2446,7 @@ func (s *DataStore) SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID return s.SetOrUpdateHostDiskEncryptionKeyFunc(ctx, hostID, encryptedBase64Key) } -func (s *DataStore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]fleet.DiskEncryptionKey, error) { +func (s *DataStore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) { s.GetUnverifiedDiskEncryptionKeysFuncInvoked = true return s.GetUnverifiedDiskEncryptionKeysFunc(ctx) } @@ -2451,6 +2456,11 @@ func (s *DataStore) SetHostsDiskEncryptionKeyStatus(ctx context.Context, hostIDs return s.SetHostsDiskEncryptionKeyStatusFunc(ctx, hostIDs, encryptable, threshold) } +func (s *DataStore) GetHostDiskEncryptionKey(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) { + s.GetHostDiskEncryptionKeyFuncInvoked = true + return s.GetHostDiskEncryptionKeyFunc(ctx, hostID) +} + func (s *DataStore) SetOrUpdateHostOrbitInfo(ctx context.Context, hostID uint, version string) error { s.SetOrUpdateHostOrbitInfoFuncInvoked = true return s.SetOrUpdateHostOrbitInfoFunc(ctx, hostID, version) diff --git a/server/service/handler.go b/server/service/handler.go index c330921e56..b42d8752fc 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -437,6 +437,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/mdm/apple/installers", listMDMAppleInstallersEndpoint, listMDMAppleInstallersRequest{}) ue.GET("/api/_version_/fleet/mdm/apple/devices", listMDMAppleDevicesEndpoint, listMDMAppleDevicesRequest{}) ue.GET("/api/_version_/fleet/mdm/apple/dep/devices", listMDMAppleDEPDevicesEndpoint, listMDMAppleDEPDevicesRequest{}) + ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{}) // host-specific mdm commands ue.PATCH("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/unenroll", mdmAppleCommandRemoveEnrollmentProfileEndpoint, mdmAppleCommandRemoveEnrollmentProfileRequest{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index f3e83d91a7..8771bd7f63 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -11,12 +11,14 @@ import ( "strings" "time" - "github.com/fleetdm/fleet/v4/server/contexts/authz" + "github.com/fleetdm/fleet/v4/server/authz" + authzctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" + apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/gocarina/gocsv" ) @@ -388,7 +390,7 @@ func getHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service } func (svc *Service) GetHost(ctx context.Context, id uint, opts fleet.HostDetailOptions) (*fleet.HostDetail, error) { - alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceToken) + alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) if !alreadyAuthd { // First ensure the user has access to list hosts, then check the specific // host once team_id is loaded. @@ -723,7 +725,7 @@ func refetchHostEndpoint(ctx context.Context, request interface{}, svc fleet.Ser } func (svc *Service) RefetchHost(ctx context.Context, id uint) error { - if !svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceToken) { + if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) { if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { return err } @@ -871,7 +873,7 @@ func listHostDeviceMappingEndpoint(ctx context.Context, request interface{}, svc } func (svc *Service) ListHostDeviceMapping(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - if !svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceToken) { + if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) { if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { return nil, err } @@ -963,7 +965,7 @@ func getMacadminsDataEndpoint(ctx context.Context, request interface{}, svc flee } func (svc *Service) MacadminsData(ctx context.Context, id uint) (*fleet.MacadminsData, error) { - if !svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceToken) { + if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) { if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { return nil, err } @@ -1250,7 +1252,7 @@ func hostsReportEndpoint(ctx context.Context, request interface{}, svc fleet.Ser // for now, only csv format is allowed if req.Format != "csv" { // prevent returning an "unauthorized" error, we want that specific error - if az, ok := authz.FromContext(ctx); ok { + if az, ok := authzctx.FromContext(ctx); ok { az.SetChecked() } err := ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("format", "unsupported or unspecified report format"). @@ -1365,3 +1367,80 @@ func (svc *Service) OSVersions(ctx context.Context, teamID *uint, platform *stri return osVersions, nil } + +//////////////////////////////////////////////////////////////////////////////// +// Encryption Key +//////////////////////////////////////////////////////////////////////////////// + +type getHostEncryptionKeyRequest struct { + ID uint `url:"id"` +} + +type getHostEncryptionKeyResponse struct { + Err error `json:"error,omitempty"` + EncryptionKey *fleet.HostDiskEncryptionKey `json:"encryption_key,omitempty"` + HostID uint `json:"host_id,omitempty"` +} + +func (r getHostEncryptionKeyResponse) error() error { return r.Err } + +func getHostEncryptionKey(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*getHostEncryptionKeyRequest) + key, err := svc.HostEncryptionKey(ctx, req.ID) + if err != nil { + return getHostEncryptionKeyResponse{Err: err}, nil + } + return getHostEncryptionKeyResponse{EncryptionKey: key, HostID: req.ID}, nil +} + +func (svc *Service) HostEncryptionKey(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { + return nil, err + } + + host, err := svc.ds.HostLite(ctx, id) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting host encryption key") + } + + // Permissions to read encryption keys are exactly the same + // as the ones required to read hosts. + if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil { + return nil, err + } + + key, err := svc.ds.GetHostDiskEncryptionKey(ctx, id) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting host encryption key") + } + + if key.Decryptable == nil || !*key.Decryptable { + return nil, ctxerr.Wrap(ctx, notFoundError{}, "getting host encryption key") + } + + cert, _, _, err := svc.config.MDM.AppleSCEP() + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting host encryption key") + } + + decryptedKey, err := apple_mdm.DecryptBase64CMS(key.Base64Encrypted, cert.Leaf, cert.PrivateKey) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting host encryption key") + } + + key.DecryptedValue = string(decryptedKey) + + err = svc.ds.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeReadHostDiskEncryptionKey{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + }, + ) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "create read host disk encryption key activity") + } + + return key, nil +} diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index 82b43bb223..134df616cc 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -2,6 +2,8 @@ package service import ( "context" + "crypto/x509" + "encoding/base64" "errors" "fmt" "testing" @@ -9,14 +11,19 @@ import ( "github.com/WatchBeam/clock" "github.com/fleetdm/fleet/v4/server/authz" + "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" + apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" + nanodep_client "github.com/micromdm/nanodep/client" + "github.com/micromdm/nanodep/tokenpki" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.mozilla.org/pkcs7" ) func TestHostDetails(t *testing.T) { @@ -508,3 +515,160 @@ func TestEmptyTeamOSVersions(t *testing.T) { require.Error(t, err) require.Equal(t, "some unknown error", fmt.Sprint(err)) } + +func TestHostEncryptionKey(t *testing.T) { + cases := []struct { + name string + host *fleet.Host + allowedUsers []*fleet.User + disallowedUsers []*fleet.User + }{ + { + name: "global host", + host: &fleet.Host{ + ID: 1, + Platform: "darwin", + NodeKey: ptr.String("test_key"), + Hostname: "test_hostname", + UUID: "test_uuid", + TeamID: nil, + }, + allowedUsers: []*fleet.User{ + test.UserAdmin, + test.UserMaintainer, + test.UserObserver, + }, + disallowedUsers: []*fleet.User{ + test.UserTeamAdminTeam1, + test.UserTeamMaintainerTeam1, + test.UserTeamObserverTeam1, + test.UserNoRoles, + }, + }, + { + name: "team host", + host: &fleet.Host{ + ID: 2, + Platform: "darwin", + NodeKey: ptr.String("test_key_2"), + Hostname: "test_hostname_2", + UUID: "test_uuid_2", + TeamID: ptr.Uint(1), + }, + allowedUsers: []*fleet.User{ + test.UserAdmin, + test.UserMaintainer, + test.UserObserver, + test.UserTeamAdminTeam1, + test.UserTeamMaintainerTeam1, + test.UserTeamObserverTeam1, + }, + disallowedUsers: []*fleet.User{ + test.UserTeamAdminTeam2, + test.UserTeamMaintainerTeam2, + test.UserTeamObserverTeam2, + test.UserNoRoles, + }, + }, + } + + testBMToken := &nanodep_client.OAuth1Tokens{ + ConsumerKey: "test_consumer", + ConsumerSecret: "test_secret", + AccessToken: "test_access_token", + AccessSecret: "test_access_secret", + AccessTokenExpiry: time.Date(2999, 1, 1, 0, 0, 0, 0, time.UTC), + } + testCert, testKey, err := apple_mdm.NewSCEPCACertKey() + require.NoError(t, err) + testCertPEM := tokenpki.PEMCertificate(testCert.Raw) + testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey) + + fleetCfg := config.TestConfig() + config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, testBMToken) + + recoveryKey := "AAA-BBB-CCC" + encryptedKey, err := pkcs7.Encrypt([]byte(recoveryKey), []*x509.Certificate{testCert}) + require.NoError(t, err) + base64EncryptedKey := base64.StdEncoding.EncodeToString(encryptedKey) + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestServiceWithConfig(t, ds, fleetCfg, nil, nil) + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + require.Equal(t, tt.host.ID, id) + return tt.host, nil + } + + ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return &fleet.HostDiskEncryptionKey{ + Base64Encrypted: base64EncryptedKey, + Decryptable: ptr.Bool(true), + }, nil + } + + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + act := activity.(fleet.ActivityTypeReadHostDiskEncryptionKey) + require.Equal(t, tt.host.ID, act.HostID) + require.EqualValues(t, act.HostDisplayName, tt.host.DisplayName()) + return nil + } + + t.Run("allowed users", func(t *testing.T) { + for _, u := range tt.allowedUsers { + _, err := svc.HostEncryptionKey(test.UserContext(ctx, u), tt.host.ID) + require.NoError(t, err) + } + }) + + t.Run("disallowed users", func(t *testing.T) { + for _, u := range tt.disallowedUsers { + _, err := svc.HostEncryptionKey(test.UserContext(ctx, u), tt.host.ID) + require.Error(t, err) + require.Contains(t, authz.ForbiddenErrorMessage, err.Error()) + } + }) + + t.Run("no user in context", func(t *testing.T) { + _, err := svc.HostEncryptionKey(ctx, tt.host.ID) + require.Error(t, err) + require.Contains(t, authz.ForbiddenErrorMessage, err.Error()) + }) + }) + } + + t.Run("test error cases", func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + ctx = test.UserContext(ctx, test.UserAdmin) + + hostErr := errors.New("host error") + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return nil, hostErr + } + _, err := svc.HostEncryptionKey(ctx, 1) + require.ErrorIs(t, err, hostErr) + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{}, nil + } + + keyErr := errors.New("key error") + ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return nil, keyErr + } + _, err = svc.HostEncryptionKey(ctx, 1) + require.ErrorIs(t, err, keyErr) + ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return &fleet.HostDiskEncryptionKey{Base64Encrypted: "key"}, nil + } + + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return errors.New("activity error") + } + + _, err = svc.HostEncryptionKey(ctx, 1) + require.Error(t, err) + }) +} diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 06465e510c..79cbe546af 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -2257,28 +2257,6 @@ func (s *integrationEnterpriseTestSuite) TestOrbitConfigNudgeSettings() { require.Equal(t, wantCfg.OSVersionRequirements[0].RequiredInstallationDate.String(), "2022-01-04 04:00:00 +0000 UTC") } -// gets the latest activity and checks that it matches any provided properties. -// empty string or 0 id means do not check that property. It returns the ID of that -// latest activity. -func (s *integrationEnterpriseTestSuite) lastActivityMatches(name, details string, id uint) uint { - var listActivities listActivitiesResponse - s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &listActivities, "order_key", "a.id", "order_direction", "desc", "per_page", "1") - require.True(s.T(), len(listActivities.Activities) > 0) - - act := listActivities.Activities[0] - if name != "" { - assert.Equal(s.T(), name, act.Type) - } - if details != "" { - require.NotNil(s.T(), act.Details) - assert.JSONEq(s.T(), details, string(*act.Details)) - } - if id > 0 { - assert.Equal(s.T(), id, act.ID) - } - return act.ID -} - // allEqual compares all fields of a struct. // If a field is a pointer on one side but not on the other, then it follows that pointer. This is useful for optional // arguments. diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 8a4280c235..e3668c6952 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -31,8 +31,10 @@ import ( "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service/mock" "github.com/fleetdm/fleet/v4/server/service/schedule" + "github.com/fleetdm/fleet/v4/server/test" kitlog "github.com/go-kit/kit/log" "github.com/go-kit/kit/log/level" "github.com/google/uuid" @@ -587,6 +589,174 @@ func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() { s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/unenroll", h.ID), nil, http.StatusOK) } +func (s *integrationMDMTestSuite) TestMDMAppleGetEncryptionKey() { + t := s.T() + ctx := context.Background() + + // create a host + host, err := s.ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name()), + NodeKey: ptr.String(t.Name()), + UUID: uuid.New().String(), + Hostname: fmt.Sprintf("%sfoo.local", t.Name()), + Platform: "darwin", + }) + require.NoError(t, err) + + // add an encryption key for the host + cert, _, _, err := s.fleetCfg.MDM.AppleSCEP() + require.NoError(t, err) + parsed, err := x509.ParseCertificate(cert.Certificate[0]) + require.NoError(t, err) + recoveryKey := "AAA-BBB-CCC" + encryptedKey, err := pkcs7.Encrypt([]byte(recoveryKey), []*x509.Certificate{parsed}) + require.NoError(t, err) + base64EncryptedKey := base64.StdEncoding.EncodeToString(encryptedKey) + + err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, base64EncryptedKey) + require.NoError(t, err) + + // request with no token + res := s.DoRawNoAuth("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/encryption_key", host.ID), nil, http.StatusUnauthorized) + res.Body.Close() + + // encryption key not processed yet + resp := getHostEncryptionKeyResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/encryption_key", host.ID), nil, http.StatusNotFound, &resp) + + // unable to decrypt encryption key + err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID}, false, time.Now()) + require.NoError(t, err) + resp = getHostEncryptionKeyResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/encryption_key", host.ID), nil, http.StatusNotFound, &resp) + + // no activities created so far + activities := listActivitiesResponse{} + s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activities) + found := false + for _, activity := range activities.Activities { + if activity.Type == "read_host_disk_encryption_key" { + found = true + } + } + require.False(t, found) + + // decryptable key + checkDecryptableKey := func(u fleet.User) { + err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID}, true, time.Now()) + require.NoError(t, err) + resp = getHostEncryptionKeyResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/encryption_key", host.ID), nil, http.StatusOK, &resp) + require.Equal(t, recoveryKey, resp.EncryptionKey.DecryptedValue) + + // use the admin token to get the activities + currToken := s.token + defer func() { s.token = currToken }() + s.token = s.getTestAdminToken() + s.lastActivityMatches( + "read_host_disk_encryption_key", + fmt.Sprintf(`{"host_display_name": "%s", "host_id": %d}`, host.DisplayName(), host.ID), + 0, + ) + } + + // we're about to mess up with the token, make sure to set it to the + // default value when the test ends + currToken := s.token + t.Cleanup(func() { s.token = currToken }) + + // admins are able to see the host encryption key + s.token = s.getTestAdminToken() + checkDecryptableKey(s.users["admin1@example.com"]) + + // maintainers are able to see the token + u := s.users["user1@example.com"] + s.token = s.getTestToken(u.Email, test.GoodPassword) + checkDecryptableKey(u) + + // observers are able to see the token + u = s.users["user2@example.com"] + s.token = s.getTestToken(u.Email, test.GoodPassword) + checkDecryptableKey(u) + + // add the host to a team + team, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + ID: 4827, + Name: "team1_" + t.Name(), + Description: "desc team1_" + t.Name(), + }) + require.NoError(t, err) + err = s.ds.AddHostsToTeam(ctx, &team.ID, []uint{host.ID}) + require.NoError(t, err) + + // admins are still able to see the token + s.token = s.getTestAdminToken() + checkDecryptableKey(s.users["admin1@example.com"]) + + // maintainers are still able to see the token + u = s.users["user1@example.com"] + s.token = s.getTestToken(u.Email, test.GoodPassword) + checkDecryptableKey(u) + + // observers are still able to see the token + u = s.users["user2@example.com"] + s.token = s.getTestToken(u.Email, test.GoodPassword) + checkDecryptableKey(u) + + // add a team member + u = fleet.User{ + Name: "test team user", + Email: "user1+team@example.com", + GlobalRole: nil, + Teams: []fleet.UserTeam{ + { + Team: *team, + Role: fleet.RoleMaintainer, + }, + }, + } + require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10)) + _, err = s.ds.NewUser(ctx, &u) + require.NoError(t, err) + + // members are able to see the token + s.token = s.getTestToken(u.Email, test.GoodPassword) + checkDecryptableKey(u) + + // create a separate team + team2, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + ID: 4828, + Name: "team2_" + t.Name(), + Description: "desc team2_" + t.Name(), + }) + require.NoError(t, err) + // add a team member + u = fleet.User{ + Name: "test team user", + Email: "user1+team2@example.com", + GlobalRole: nil, + Teams: []fleet.UserTeam{ + { + Team: *team2, + Role: fleet.RoleMaintainer, + }, + }, + } + require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10)) + _, err = s.ds.NewUser(ctx, &u) + require.NoError(t, err) + + // non-members aren't able to see the token + s.token = s.getTestToken(u.Email, test.GoodPassword) + resp = getHostEncryptionKeyResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/encryption_key", host.ID), nil, http.StatusForbidden, &resp) + +} + type device struct { uuid string serial string diff --git a/server/service/osquery_utils/gen_queries_doc.go b/server/service/osquery_utils/gen_queries_doc.go index e6f039dc08..8759a9c83c 100644 --- a/server/service/osquery_utils/gen_queries_doc.go +++ b/server/service/osquery_utils/gen_queries_doc.go @@ -23,7 +23,7 @@ func main() { App: config.AppConfig{ EnableScheduledQueryStats: true, }, - }, &fleet.Features{ + }, nil, &fleet.Features{ EnableSoftwareInventory: true, EnableHostUsers: true, }) diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 151552064f..0d95720da0 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -304,3 +304,26 @@ func (ts *withServer) LoginSSOUser(username, password string) (fleet.Auth, strin require.NoError(t, err) return auth, string(body) } + +// gets the latest activity and checks that it matches any provided properties. +// empty string or 0 id means do not check that property. It returns the ID of that +// latest activity. +func (ts *withServer) lastActivityMatches(name, details string, id uint) uint { + t := ts.s.T() + var listActivities listActivitiesResponse + ts.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &listActivities, "order_key", "a.id", "order_direction", "desc", "per_page", "1") + require.True(t, len(listActivities.Activities) > 0) + + act := listActivities.Activities[0] + if name != "" { + assert.Equal(t, name, act.Type) + } + if details != "" { + require.NotNil(t, act.Details) + assert.JSONEq(t, details, string(*act.Details)) + } + if id > 0 { + assert.Equal(t, id, act.ID) + } + return act.ID +}