SAAD: DDM Asset table migration (#48866)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #48566 

# Checklist for submitter

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

- [ ] 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. Coming in bigger backend story.

- [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

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

## Summary by CodeRabbit

* **New Features**
* Added support for tracking Apple declaration assets, including a new
asset record and a link table for associating assets with declarations.
* Added a new timestamp on declarations to reflect the latest asset
update time.

* **Bug Fixes**
* Strengthened database constraints to prevent duplicate asset entries
and enforce valid asset/declaration references.
* Improved delete behavior so referenced declarations clean up related
links automatically.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Magnus Jensen
2026-07-08 09:17:33 +02:00
committed by GitHub
parent 31a6bb8734
commit ace8cf046b
3 changed files with 153 additions and 2 deletions
@@ -0,0 +1,64 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20260707140752, Down_20260707140752)
}
func Up_20260707140752(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE TABLE mdm_apple_declaration_assets (
asset_uuid varchar(37) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
team_id int unsigned NOT NULL,
identifier varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
name varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
raw_json mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
secrets_updated_at datetime(6) NULL DEFAULT NULL,
-- generated token drives DDM ServerToken/sync; mirrors mdm_apple_declarations.token
token binary(16) GENERATED ALWAYS AS (UNHEX(MD5(CONCAT(raw_json, IFNULL(secrets_updated_at, ''))))) STORED,
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
uploaded_at timestamp(6) NULL DEFAULT NULL,
PRIMARY KEY (asset_uuid),
UNIQUE KEY idx_mdm_apple_decl_asset_team_identifier (team_id, identifier),
UNIQUE KEY idx_mdm_apple_decl_asset_team_name (team_id, name)
);
`)
if err != nil {
return fmt.Errorf("creating mdm_apple_declaration_assets table: %w", err)
}
_, err = tx.Exec(`
CREATE TABLE mdm_apple_declaration_asset_references (
declaration_uuid varchar(37) COLLATE utf8mb4_unicode_ci NOT NULL,
asset_uuid varchar(37) COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (declaration_uuid, asset_uuid),
-- deleting the referencing config drops the edge; the asset FK is RESTRICT (default)
CONSTRAINT FOREIGN KEY (declaration_uuid) REFERENCES mdm_apple_declarations (declaration_uuid) ON DELETE CASCADE,
CONSTRAINT FOREIGN KEY (asset_uuid) REFERENCES mdm_apple_declaration_assets (asset_uuid)
);
`)
if err != nil {
return fmt.Errorf("creating mdm_apple_declaration_asset_references table: %w", err)
}
_, err = tx.Exec(`
-- ties a referencing config's per-host token to its assets (mirrors variables_updated_at); the reconciler
-- sets it to max(referenced assets' uploaded_at) so an asset edit changes the config token and re-syncs the host
ALTER TABLE host_mdm_apple_declarations
ADD COLUMN assets_updated_at datetime(6) NULL DEFAULT NULL;
`)
if err != nil {
return fmt.Errorf("adding assets_updated_at column to host_mdm_apple_declarations table: %w", err)
}
return nil
}
func Down_20260707140752(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,58 @@
package tables
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUp_20260707140752(t *testing.T) {
db := applyUpToPrev(t)
// Apply current migration.
applyNext(t, db)
// Insert a row into mdm_apple_declaration_assets and check for unique constraints
_, err := db.ExecContext(t.Context(), "INSERT INTO mdm_apple_declaration_assets (asset_uuid, team_id, identifier, name, raw_json) VALUES ('uuid1', 1, 'identifier1', 'name1', '{}')")
require.NoError(t, err)
// Attempt to insert a duplicate row with the same team_id and identifier
_, err = db.ExecContext(t.Context(), "INSERT INTO mdm_apple_declaration_assets (asset_uuid, team_id, identifier, name, raw_json) VALUES ('uuid2', 1, 'identifier1', 'name2', '{}')")
require.Error(t, err)
// Attempt to insert a duplicate row with the same team_id and name
_, err = db.ExecContext(t.Context(), "INSERT INTO mdm_apple_declaration_assets (asset_uuid, team_id, identifier, name, raw_json) VALUES ('uuid3', 1, 'identifier3', 'name1', '{}')")
require.Error(t, err)
// Same identifier and name but different team_id should succeed
_, err = db.ExecContext(t.Context(), "INSERT INTO mdm_apple_declaration_assets (asset_uuid, team_id, identifier, name, raw_json) VALUES ('uuid4', 2, 'identifier1', 'name1', '{}')")
require.NoError(t, err)
// Insert declaration
_, err = db.ExecContext(t.Context(), "INSERT INTO mdm_apple_declarations (declaration_uuid, identifier, name, team_id, raw_json) VALUES ('decl_uuid1', 'identifier1', 'name1', 1, '{}')")
require.NoError(t, err)
// Insert a reference to the asset
_, err = db.ExecContext(t.Context(), "INSERT INTO mdm_apple_declaration_asset_references (declaration_uuid, asset_uuid) VALUES ('decl_uuid1', 'uuid1')")
require.NoError(t, err)
// Verify that mdm_apple_declaration_asset_references table has the correct foreign key constraints
_, err = db.ExecContext(t.Context(), "INSERT INTO mdm_apple_declaration_asset_references (declaration_uuid, asset_uuid) VALUES ('decl_uuid2', 'uuid1')")
require.Error(t, err) // Should fail because 'decl_uuid2' does not exist in mdm_apple_declarations
_, err = db.ExecContext(t.Context(), "INSERT INTO mdm_apple_declaration_asset_references (declaration_uuid, asset_uuid) VALUES ('decl_uuid1', 'uuid-none')")
require.Error(t, err) // Should fail because 'uuid-none' does not exist in mdm_apple_declaration_assets
// Verify deleting asset is not allowed
_, err = db.ExecContext(t.Context(), "DELETE FROM mdm_apple_declaration_assets WHERE asset_uuid = 'uuid1'")
require.Error(t, err) // Should fail due to foreign key constraint
// Verify deleting declaration cascades to references
_, err = db.ExecContext(t.Context(), "DELETE FROM mdm_apple_declarations WHERE declaration_uuid = 'decl_uuid1'")
require.NoError(t, err)
// Verify that the reference has been deleted
var count int
err = db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM mdm_apple_declaration_asset_references WHERE declaration_uuid = 'decl_uuid1'").Scan(&count)
require.NoError(t, err)
require.Equal(t, 0, count)
}
File diff suppressed because one or more lines are too long