Add DDM custom activations schema (#50133)
**Related issue:** Resolves #49966 Adds the schema for custom DDM activations (parent story #48222). - **Creates `mdm_apple_ddm_activations`** — stores the activation JSON as-is (`mediumtext`, so the generated `token` column hashes the exact stored bytes) with a `declaration_uuid` FK to `mdm_apple_declarations` that cascades on delete. - **Extends `mdm_configuration_profile_variables`** with `apple_ddm_activation_uuid` so activations can carry Fleet variables (needed by #49970). - **Adds `activation_updated_at`** to `host_mdm_apple_declarations` so a changed activation regenerates the declaration's effective token, mirroring `variables_updated_at` / `assets_updated_at`. - **Drops `mdm_apple_declaration_activation_references`** — created with the original DDM tables in `20240327115530_AddDDMTables.go`, never written to by any code path, so it is empty in every deployment. ### Deviations from the SQL in #49966 The `declaration_uuid` FK is the one addition, [confirmed with @MagnusHJensen](https://github.com/fleetdm/fleet/issues/49966): it keeps the 1:1 lifecycle enforced by the database rather than requiring cleanup in every delete path. `configuration_identifier` is kept alongside it for validation and DDM serving. Its unique key doubles as the FK's backing index. The rest are corrections needed for the specced SQL to work, all following the precedent in `20260409153715_AddDDMVariablesSupport.go`: - **`ck_mdm_configuration_profile_variables_exactly_one` is dropped and re-added** to count the new column. That constraint requires exactly one owner column to be non-null; adding a seventh without updating it means any row setting `apple_ddm_activation_uuid` sums to 0, fails the check, and is rejected. - **`UNIQUE (apple_ddm_activation_uuid, fleet_variable_id)` added** to match the six existing owner columns. That table's write path is `INSERT ... ON DUPLICATE KEY UPDATE`, which needs a unique key to collide on. - **`activation_updated_at` is `DATETIME(6)`, not `TIMESTAMP(6)`** — its siblings are `datetime(6)` and `EffectiveDDMToken` formats them into the token string, so `TIMESTAMP`'s session-timezone conversion on read would change tokens and re-push declarations to every host. - **`team_id` gets `DEFAULT '0'`** to match `mdm_apple_declarations`, where 0 is Unassigned. ### Note for #49970 `declaration_uuid` is `NOT NULL`, so the upload path must populate it in addition to `configuration_identifier`. The declaration UUID prefix has no separator (`MDMAppleDeclarationUUIDPrefix = "d"`, 1 char + 36-char UUID = the full `varchar(37)`). # Checklist for submitter - [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. No changes file: this sub-task adds schema only and ships no user-visible behavior. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually `TestUp_20260729115013` covers: the stale table is present before and gone after; pre-existing `mdm_configuration_profile_variables` rows survive the check constraint replacement (that `ADD CONSTRAINT` revalidates every existing row); an activation attaches to a declaration and gets its generated token; the 1:1 unique key and the FK both reject bad inserts; a variable row binds to an activation (the case the old constraint would have rejected); the constraint still rejects two-owner and zero-owner rows; and deleting the declaration cascades to the activation and through it to the activation's variable rows. Also ran the full migrations suite (`MYSQL_TEST=1 go test ./server/datastore/mysql/migrations/...`) to confirm no other migration is disturbed, and verified the regenerated `schema.sql` diff contains only changes from this migration. ## 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`). Neither modified table has an `ON UPDATE CURRENT_TIMESTAMP` column, so no rows have their timestamps touched. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for Apple DDM custom activations. * Added activation-specific tokens and timestamps to support reliable declaration updates. * Enabled configuration variables to be associated with a specific activation. * Added validation to prevent duplicate or invalid activation associations. * Activations and related settings are now automatically removed when their declaration is deleted. * **Tests** * Added coverage for activation creation, uniqueness, validation, associations, token generation, and cascading cleanup. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260729115013, Down_20260729115013)
|
||||
}
|
||||
|
||||
func Up_20260729115013(tx *sql.Tx) error {
|
||||
// This table was created alongside the original DDM tables to model
|
||||
// many-to-many activation references, but was never written to by any code
|
||||
// path. Custom activations are 1:1 with their configuration declaration, so
|
||||
// drop it rather than carry it forward.
|
||||
if _, err := tx.Exec(`DROP TABLE IF EXISTS mdm_apple_declaration_activation_references`); err != nil {
|
||||
return fmt.Errorf("dropping mdm_apple_declaration_activation_references table: %w", err)
|
||||
}
|
||||
|
||||
// Custom activations let admins override the activation Fleet otherwise
|
||||
// generates for a DDM configuration declaration, mainly to attach a
|
||||
// Predicate. The activation JSON is stored as-is (mediumtext, not json, so
|
||||
// the generated token hashes the exact stored bytes) and is never
|
||||
// interpreted by Fleet beyond validation of its envelope.
|
||||
//
|
||||
// declaration_uuid is the authoritative link to the configuration this
|
||||
// activation gates: it is 1:1 (enforced by its unique key) and cascades on
|
||||
// delete so removing a DDM profile removes its activation. The
|
||||
// configuration_identifier column keeps the declaration's Identifier
|
||||
// alongside it, since the activation JSON references its configuration by
|
||||
// Identifier rather than by UUID.
|
||||
_, err := tx.Exec(`
|
||||
CREATE TABLE mdm_apple_ddm_activations (
|
||||
activation_uuid varchar(37) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
|
||||
team_id int unsigned NOT NULL DEFAULT '0',
|
||||
identifier varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
raw_json mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
declaration_uuid varchar(37) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
configuration_identifier varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
secrets_updated_at datetime(6) DEFAULT NULL,
|
||||
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
uploaded_at timestamp(6) NULL DEFAULT NULL,
|
||||
token binary(16) GENERATED ALWAYS AS
|
||||
(unhex(md5(concat(raw_json, ifnull(secrets_updated_at, ''))))) STORED,
|
||||
|
||||
PRIMARY KEY (activation_uuid),
|
||||
UNIQUE KEY idx_mdm_apple_ddm_activation_team_identifier (team_id, identifier),
|
||||
UNIQUE KEY idx_mdm_apple_ddm_activation_team_config (team_id, configuration_identifier),
|
||||
UNIQUE KEY idx_mdm_apple_ddm_activation_declaration (declaration_uuid),
|
||||
CONSTRAINT fk_mdm_apple_ddm_activations_declaration_uuid
|
||||
FOREIGN KEY (declaration_uuid) REFERENCES mdm_apple_declarations (declaration_uuid) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating mdm_apple_ddm_activations table: %w", err)
|
||||
}
|
||||
|
||||
// Activations can carry Fleet variables, so they need an owner column in
|
||||
// the profile variables table. The unique key is what makes that table's
|
||||
// ON DUPLICATE KEY UPDATE write path work, and the check constraint has to
|
||||
// be replaced to count the new column, otherwise every row setting it is
|
||||
// rejected.
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE mdm_configuration_profile_variables
|
||||
ADD COLUMN apple_ddm_activation_uuid varchar(37) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
ADD UNIQUE KEY idx_mdm_config_profile_vars_ddm_activation_variable (apple_ddm_activation_uuid, fleet_variable_id),
|
||||
ADD CONSTRAINT mdm_config_profile_variables_ddm_activation_fk
|
||||
FOREIGN KEY (apple_ddm_activation_uuid) REFERENCES mdm_apple_ddm_activations (activation_uuid) ON DELETE CASCADE,
|
||||
DROP CHECK ck_mdm_configuration_profile_variables_exactly_one,
|
||||
ADD CONSTRAINT ck_mdm_configuration_profile_variables_exactly_one
|
||||
CHECK ((
|
||||
(IF(apple_profile_uuid IS NULL, 0, 1) +
|
||||
IF(windows_profile_uuid IS NULL, 0, 1) +
|
||||
IF(apple_declaration_uuid IS NULL, 0, 1) +
|
||||
IF(android_profile_uuid IS NULL, 0, 1) +
|
||||
IF(certificate_template_id IS NULL, 0, 1) +
|
||||
IF(android_app_configuration_id IS NULL, 0, 1) +
|
||||
IF(apple_ddm_activation_uuid IS NULL, 0, 1)) = 1
|
||||
))
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extending mdm_configuration_profile_variables for DDM activations: %w", err)
|
||||
}
|
||||
|
||||
// Tracks when a host's activation last changed so the declaration's
|
||||
// effective token can be regenerated, mirroring variables_updated_at and
|
||||
// assets_updated_at. DATETIME(6) rather than TIMESTAMP because
|
||||
// EffectiveDDMToken formats these values into the token string and
|
||||
// TIMESTAMP would apply session timezone conversion on read.
|
||||
_, err = tx.Exec(`ALTER TABLE host_mdm_apple_declarations ADD COLUMN activation_updated_at DATETIME(6) DEFAULT NULL`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding activation_updated_at to host_mdm_apple_declarations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20260729115013(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUp_20260729115013(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
// The stale activation references table is present before the migration.
|
||||
var staleTableCount int
|
||||
err := db.Get(&staleTableCount, `
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = 'mdm_apple_declaration_activation_references'`)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, staleTableCount)
|
||||
|
||||
// Seed a declaration and a profile variable row bound to it. Replacing the
|
||||
// check constraint revalidates every existing row, so this guards against
|
||||
// the migration failing (or dropping data) on a non-empty table.
|
||||
const declUUID = "db7e0a0e6-0000-0000-0000-000000000001"
|
||||
execNoErr(t, db, `
|
||||
INSERT INTO mdm_apple_declarations (declaration_uuid, team_id, identifier, name, raw_json, uploaded_at)
|
||||
VALUES (?, 0, 'com.fleet.test.config', 'Test config', '{"Type":"com.apple.configuration.passcode.settings"}', NOW(6))`,
|
||||
declUUID)
|
||||
|
||||
var fleetVarID uint
|
||||
require.NoError(t, db.Get(&fleetVarID, `SELECT id FROM fleet_variables ORDER BY id LIMIT 1`))
|
||||
execNoErr(t, db, `
|
||||
INSERT INTO mdm_configuration_profile_variables (apple_declaration_uuid, fleet_variable_id)
|
||||
VALUES (?, ?)`, declUUID, fleetVarID)
|
||||
|
||||
applyNext(t, db)
|
||||
|
||||
// Stale table is gone, and the pre-existing variable row survived the
|
||||
// check constraint swap.
|
||||
err = db.Get(&staleTableCount, `
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = 'mdm_apple_declaration_activation_references'`)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, staleTableCount)
|
||||
|
||||
var existingVars int
|
||||
require.NoError(t, db.Get(&existingVars, `
|
||||
SELECT COUNT(*) FROM mdm_configuration_profile_variables WHERE apple_declaration_uuid = ?`, declUUID))
|
||||
require.Equal(t, 1, existingVars)
|
||||
|
||||
// An activation can be attached to the declaration, and its token is
|
||||
// generated from the stored JSON.
|
||||
const actUUID = "ab7e0a0e6-0000-0000-0000-000000000001"
|
||||
execNoErr(t, db, `
|
||||
INSERT INTO mdm_apple_ddm_activations
|
||||
(activation_uuid, team_id, identifier, raw_json, declaration_uuid, configuration_identifier, uploaded_at)
|
||||
VALUES (?, 0, 'com.fleet.test.config.activation', ?, ?, 'com.fleet.test.config', NOW(6))`,
|
||||
actUUID,
|
||||
`{"Type":"com.apple.activation.simple","Payload":{"StandardConfigurations":["com.fleet.test.config"]}}`,
|
||||
declUUID)
|
||||
|
||||
var token []byte
|
||||
require.NoError(t, db.Get(&token, `SELECT token FROM mdm_apple_ddm_activations WHERE activation_uuid = ?`, actUUID))
|
||||
require.Len(t, token, 16)
|
||||
|
||||
// Only one activation per declaration.
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO mdm_apple_ddm_activations
|
||||
(activation_uuid, team_id, identifier, raw_json, declaration_uuid, configuration_identifier)
|
||||
VALUES ('a-dupe', 0, 'com.fleet.test.other.activation', '{}', ?, 'com.fleet.test.other')`, declUUID)
|
||||
require.Error(t, err)
|
||||
|
||||
// An activation must reference a declaration that exists.
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO mdm_apple_ddm_activations
|
||||
(activation_uuid, team_id, identifier, raw_json, declaration_uuid, configuration_identifier)
|
||||
VALUES ('a-orphan', 0, 'com.fleet.test.orphan.activation', '{}', 'd-does-not-exist', 'com.fleet.test.orphan')`)
|
||||
require.Error(t, err)
|
||||
|
||||
// Fleet variables can be associated with an activation. This is the case
|
||||
// the check constraint would reject if it hadn't been replaced.
|
||||
execNoErr(t, db, `
|
||||
INSERT INTO mdm_configuration_profile_variables (apple_ddm_activation_uuid, fleet_variable_id)
|
||||
VALUES (?, ?)`, actUUID, fleetVarID)
|
||||
|
||||
// The constraint still requires exactly one owner: neither two nor zero.
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO mdm_configuration_profile_variables (apple_ddm_activation_uuid, apple_declaration_uuid, fleet_variable_id)
|
||||
VALUES (?, ?, ?)`, actUUID, declUUID, fleetVarID)
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = db.Exec(`INSERT INTO mdm_configuration_profile_variables (fleet_variable_id) VALUES (?)`, fleetVarID)
|
||||
require.Error(t, err)
|
||||
|
||||
// host_mdm_apple_declarations tracks when the activation last changed.
|
||||
execNoErr(t, db, `
|
||||
INSERT INTO host_mdm_apple_declarations
|
||||
(host_uuid, declaration_uuid, declaration_identifier, declaration_name, token, activation_updated_at)
|
||||
VALUES ('host-uuid-1', ?, 'com.fleet.test.config', 'Test config', UNHEX(MD5('t')), NOW(6))`, declUUID)
|
||||
|
||||
var activationUpdatedAt *string
|
||||
require.NoError(t, db.Get(&activationUpdatedAt, `
|
||||
SELECT activation_updated_at FROM host_mdm_apple_declarations WHERE host_uuid = 'host-uuid-1'`))
|
||||
require.NotNil(t, activationUpdatedAt)
|
||||
|
||||
// Deleting the declaration cascades to the activation and, through it, to
|
||||
// the activation's variable rows.
|
||||
execNoErr(t, db, `DELETE FROM mdm_apple_declarations WHERE declaration_uuid = ?`, declUUID)
|
||||
|
||||
var remainingActivations int
|
||||
require.NoError(t, db.Get(&remainingActivations, `SELECT COUNT(*) FROM mdm_apple_ddm_activations`))
|
||||
require.Equal(t, 0, remainingActivations)
|
||||
|
||||
var remainingVars int
|
||||
require.NoError(t, db.Get(&remainingVars, `
|
||||
SELECT COUNT(*) FROM mdm_configuration_profile_variables WHERE apple_ddm_activation_uuid = ?`, actUUID))
|
||||
require.Equal(t, 0, remainingVars)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user