Files
a33481653d macos password sync feature branch (#47422)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #45524

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

For unreleased bug fixes in a release candidate, one of:

- [ ] Confirmed that the fix is not expected to adversely impact load
test results
- [ ] Alerted the release DRI if additional load testing is needed

## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).

## New Fleet configuration settings

- [ ] Setting(s) is/are explicitly excluded from GitOps

If you didn't check the box above, follow this checklist for
GitOps-enabled settings:

- [x] Verified that the setting is exported via `fleetctl
generate-gitops`
- [x] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [x] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [x] Verified that any relevant UI is disabled when GitOps mode is
enabled


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added Apple Platform SSO (PSSO) for macOS with device registration,
sign-in, and public discovery (JWKS + Apple app-site association)
protected by single-use nonces.
* Added Apple account provisioning (Platform SSO password sync)
configuration with masked client-secret handling and GitOps support.
* Added a host-scoped PSSO device registration token variable for Apple
MDM profile generation.
* **Bug Fixes**
* Fixed macOS packaging to correctly build, embed, and sign the Platform
SSO extension.
* Resetting device Apple MDM data now also clears stored PSSO enrollment
records.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Magnus Jensen <magnus@fleetdm.com>
2026-07-09 14:57:48 -04:00

179 lines
5.7 KiB
Go

package mysql
import (
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplePSSO(t *testing.T) {
ds := CreateMySQLDS(t)
cases := []struct {
name string
fn func(t *testing.T, ds *Datastore)
}{
{"SetOrUpdateAndGet", testPSSOSetOrUpdateAndGet},
{"ReRegistrationKeepsOldKeys", testPSSOReRegistrationKeepsOldKeys},
{"RejectsKIDOwnedByAnotherHost", testPSSORejectsKIDOwnedByAnotherHost},
{"DeleteDevice", testPSSODeleteDevice},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer TruncateTables(t, ds)
c.fn(t, ds)
})
}
}
func testPSSOSetOrUpdateAndGet(t *testing.T, ds *Datastore) {
ctx := t.Context()
const hostUUID = "ABCDEFGH-0000-0000-0000-111111111111"
keys := []fleet.PSSOKey{
{KID: "kid-sign-1", KeyType: fleet.PSSOKeyTypeSigning, PEM: "sign-pem-1"},
{KID: "kid-enc-1", KeyType: fleet.PSSOKeyTypeEncryption, PEM: "enc-pem-1"},
}
require.NoError(t, ds.SetOrUpdatePSSODevice(ctx, hostUUID, keys))
device, err := ds.GetPSSODevice(ctx, hostUUID)
require.NoError(t, err)
assert.Equal(t, hostUUID, device.HostUUID)
assert.False(t, device.CreatedAt.IsZero())
assert.False(t, device.UpdatedAt.IsZero())
_, err = ds.GetPSSODevice(ctx, "unregistered-uuid")
require.Error(t, err)
assert.True(t, fleet.IsNotFound(err))
signKey, err := ds.GetPSSOKey(ctx, "kid-sign-1")
require.NoError(t, err)
assert.Equal(t, hostUUID, signKey.HostUUID)
assert.Equal(t, fleet.PSSOKeyTypeSigning, signKey.KeyType)
assert.Equal(t, "sign-pem-1", signKey.PEM)
encKey, err := ds.GetPSSOKey(ctx, "kid-enc-1")
require.NoError(t, err)
assert.Equal(t, fleet.PSSOKeyTypeEncryption, encKey.KeyType)
assert.Equal(t, "enc-pem-1", encKey.PEM)
_, err = ds.GetPSSOKey(ctx, "no-such-kid")
require.Error(t, err)
assert.True(t, fleet.IsNotFound(err))
listed, err := ds.ListPSSOKeys(ctx, hostUUID)
require.NoError(t, err)
assert.Len(t, listed, 2)
listed, err = ds.ListPSSOKeys(ctx, "unregistered-uuid")
require.NoError(t, err)
assert.Empty(t, listed)
// Upserting the same kid updates the row in place.
require.NoError(t, ds.SetOrUpdatePSSODevice(ctx, hostUUID, []fleet.PSSOKey{
{KID: "kid-sign-1", KeyType: fleet.PSSOKeyTypeSigning, PEM: "sign-pem-1-rotated"},
}))
signKey, err = ds.GetPSSOKey(ctx, "kid-sign-1")
require.NoError(t, err)
assert.Equal(t, "sign-pem-1-rotated", signKey.PEM)
listed, err = ds.ListPSSOKeys(ctx, hostUUID)
require.NoError(t, err)
assert.Len(t, listed, 2)
}
func testPSSOReRegistrationKeepsOldKeys(t *testing.T, ds *Datastore) {
ctx := t.Context()
const hostUUID = "ABCDEFGH-0000-0000-0000-222222222222"
require.NoError(t, ds.SetOrUpdatePSSODevice(ctx, hostUUID, []fleet.PSSOKey{
{KID: "kid-sign-old", KeyType: fleet.PSSOKeyTypeSigning, PEM: "sign-pem-old"},
{KID: "kid-enc-old", KeyType: fleet.PSSOKeyTypeEncryption, PEM: "enc-pem-old"},
}))
// Re-register with fresh keys: old keys must remain resolvable.
require.NoError(t, ds.SetOrUpdatePSSODevice(ctx, hostUUID, []fleet.PSSOKey{
{KID: "kid-sign-new", KeyType: fleet.PSSOKeyTypeSigning, PEM: "sign-pem-new"},
{KID: "kid-enc-new", KeyType: fleet.PSSOKeyTypeEncryption, PEM: "enc-pem-new"},
}))
for _, kid := range []string{"kid-sign-old", "kid-enc-old", "kid-sign-new", "kid-enc-new"} {
key, err := ds.GetPSSOKey(ctx, kid)
require.NoError(t, err, "kid %s", kid)
assert.Equal(t, hostUUID, key.HostUUID)
}
listed, err := ds.ListPSSOKeys(ctx, hostUUID)
require.NoError(t, err)
assert.Len(t, listed, 4)
}
func testPSSORejectsKIDOwnedByAnotherHost(t *testing.T, ds *Datastore) {
ctx := t.Context()
const (
hostUUID1 = "ABCDEFGH-0000-0000-0000-555555555555"
hostUUID2 = "ABCDEFGH-0000-0000-0000-666666666666"
)
require.NoError(t, ds.SetOrUpdatePSSODevice(ctx, hostUUID1, []fleet.PSSOKey{
{KID: "kid-shared", KeyType: fleet.PSSOKeyTypeSigning, PEM: "host1-key"},
}))
// A second host must not be able to claim (and overwrite) a kid host1 owns.
err := ds.SetOrUpdatePSSODevice(ctx, hostUUID2, []fleet.PSSOKey{
{KID: "kid-shared", KeyType: fleet.PSSOKeyTypeSigning, PEM: "host2-key"},
})
require.Error(t, err)
var conflict *fleet.ConflictError
require.ErrorAs(t, err, &conflict)
// host1's key row is untouched.
key, err := ds.GetPSSOKey(ctx, "kid-shared")
require.NoError(t, err)
assert.Equal(t, hostUUID1, key.HostUUID)
assert.Equal(t, "host1-key", key.PEM)
// The whole registration rolled back: host2 got no device row.
_, err = ds.GetPSSODevice(ctx, hostUUID2)
assert.True(t, fleet.IsNotFound(err))
}
func testPSSODeleteDevice(t *testing.T, ds *Datastore) {
ctx := t.Context()
const (
hostUUID1 = "ABCDEFGH-0000-0000-0000-333333333333"
hostUUID2 = "ABCDEFGH-0000-0000-0000-444444444444"
)
require.NoError(t, ds.SetOrUpdatePSSODevice(ctx, hostUUID1, []fleet.PSSOKey{
{KID: "kid-sign-h1", KeyType: fleet.PSSOKeyTypeSigning, PEM: "p"},
{KID: "kid-enc-h1", KeyType: fleet.PSSOKeyTypeEncryption, PEM: "p"},
}))
require.NoError(t, ds.SetOrUpdatePSSODevice(ctx, hostUUID2, []fleet.PSSOKey{
{KID: "kid-sign-h2", KeyType: fleet.PSSOKeyTypeSigning, PEM: "p"},
}))
require.NoError(t, ds.DeletePSSODevice(ctx, hostUUID1))
_, err := ds.GetPSSODevice(ctx, hostUUID1)
assert.True(t, fleet.IsNotFound(err))
// Keys cascade with the device row.
_, err = ds.GetPSSOKey(ctx, "kid-sign-h1")
assert.True(t, fleet.IsNotFound(err))
listed, err := ds.ListPSSOKeys(ctx, hostUUID1)
require.NoError(t, err)
assert.Empty(t, listed)
// Other hosts are untouched.
_, err = ds.GetPSSODevice(ctx, hostUUID2)
require.NoError(t, err)
_, err = ds.GetPSSOKey(ctx, "kid-sign-h2")
require.NoError(t, err)
// Deleting an unregistered host is a no-op.
require.NoError(t, ds.DeletePSSODevice(ctx, "never-registered"))
}