Files
Rajendra Kadam 984b09cc7f Serve custom DDM activations to devices (#50421)
**Related issue:** Resolves #49971

Serves the custom activations stored by #49970 to devices. Stacked on
#50280.

- The manifest advertises a custom activation's own identifier; the
fetch serves its stored JSON with the predicate intact. Secrets and host
variables are expanded at delivery, like configurations.
- `activation_updated_at` folds into the token, so editing only a
predicate re-syncs the host even though the declaration's content didn't
change.
- Management declarations are served under the manifest's `Management`
section, have their own endpoint, and report status.
- Predicate outcomes map per the Figma dev note: `Info.Predicate` →
verified with an explanation, `Error.ActivationFailed` → failed.

### Three things to look at

**Generated activations are renamed to `<declaration
uuid>.activation`.** Removes the collision between an admin-chosen
identifier and one Fleet generates, and collapses the fetch into a
single lookup rather than match-then-trim. Note this differs from the
CoS wording of "byte-for-byte identical to today".

Existing hosts will see their activations renamed once. The sync token
doesn't move for the rename alone — activation identifiers aren't part
of it, confirmed in `MDMAppleDDMDeclarationsToken` — so it happens
lazily on the next real DDM change rather than all at once.

**`activation_updated_at` is not the `max()` the issue describes.** The
issue asks for `max(activation uploaded_at, latest variable change)`.
Variable changes stay on `variables_updated_at`; this is
`GREATEST(uploaded_at, secrets_updated_at)`. Same outcome, since
`EffectiveDDMToken` hashes all the timestamps together, but each signal
stays independently comparable, which is what keeps the reconciler
idempotent.

**The token is computed in three places, not two.** SQL in
`MDMAppleDDMDeclarationsToken`, Go in `EffectiveDDMToken`, and a third
independent copy building the manifest-level token in
`handleDeclarationItems`. All three now carry the new term in the same
order. `ActivationUpdatedAtFoldsIntoToken` asserts the SQL and Go agree
with it populated — they only agreed for the nil case before.

# 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.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

Tested on an ADE-enrolled Mac (macOS 26.5.1) against a local server: the
custom activation is advertised and fetched by its own identifier, an
activation-only edit re-syncs the device even though the declaration is
unchanged, a true predicate enforces the policy on the host, and a false
one comes back verified with the explanation.

That turned up two bugs, both fixed here and both invisible to the test
suite:

- `MDMAppleStoreDDMStatusReport` computed the matching token with
`activation_updated_at` but never selected the column, so it was always
nil. Every status report was silently dropped and profiles sat in
verifying forever.
- Apple splits a predicate outcome across two arrays — the activation
reports `Info.Predicate`, the configuration it gates reports
`Error.ActivationFailed`. Reading only the configuration made a host the
predicate excluded look failed. Now correlated via the activation
identifier in the failure details.

The test payloads for the second one are the real reports the Mac sent,
not hand-written.

Note for anyone testing in the UI: the OS settings tooltip for a
verified profile is hardcoded to "The host applied the setting. Fleet
verified." and doesn't read `detail` yet, so a predicate-excluded
profile reads wrong there. Backend stores the right message; rendering
it is #49973.

New cases in `apple_mdm_ddm_test.go`: the token agreement above; a
custom activation advertised and served with its predicate, with the
generated name no longer resolving for it; a declaration without one
still getting the synthesized activation; management routing plus the
endpoint type guard; the three predicate status outcomes; and a host
outside a declaration's scope getting an empty manifest and an error
fetching the activation by name.

13 existing assertions moved from `<identifier>.activation` to
`<uuid>.activation`.

The `GeneratedActivationStillServedWhenNoCustomOne` case caught a real
bug on its first run: the `LEFT JOIN` returns NULL for declarations
without a custom activation, which `json.RawMessage` can't scan, so
every non-custom activation fetch would have 500'd. That's the
no-regression path.



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

* **New Features**
* Added Apple DDM management declarations and custom activation
delivery.
* Added host-scoped activation resolution, activation identifiers, and
activation update timestamps.
* Added support for activation variables, secrets, assets, and separate
management and configuration endpoints.

* **Bug Fixes**
* Declarations are now redelivered when activation predicates change or
are removed.
* Improved status reporting for predicate exclusions, activation
failures, and management declarations.
* Improved synchronization token updates when variables, assets, or
activations change.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-06 23:02:20 +05:30

2017 lines
63 KiB
Go

package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/google/go-cmp/cmp"
"github.com/jmoiron/sqlx"
)
const (
SCIMMaxStatusLength = 31
SCIMDefaultResourcesPerPage = 100
)
// CreateScimUser creates a new SCIM user in the database
func (ds *Datastore) CreateScimUser(ctx context.Context, user *fleet.ScimUser) (uint, error) {
if err := validateScimUserFields(user); err != nil {
return 0, err
}
var userID uint
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
const insertUserQuery = `
INSERT INTO scim_users (
external_id, user_name, given_name, family_name, department, active
) VALUES (?, ?, ?, ?, ?, ?)`
result, err := tx.ExecContext(
ctx,
insertUserQuery,
user.ExternalID,
user.UserName,
user.GivenName,
user.FamilyName,
user.Department,
user.Active,
)
if err != nil {
if IsDuplicate(err) {
return ctxerr.Wrap(ctx, alreadyExists("ScimUser", user.UserName), "insert scim user")
}
return ctxerr.Wrap(ctx, err, "insert scim user")
}
id, err := result.LastInsertId()
if err != nil {
return ctxerr.Wrap(ctx, err, "insert scim user last insert id")
}
user.ID = uint(id) // nolint:gosec // dismiss G115
userID = user.ID
if err := insertEmails(ctx, tx, user); err != nil {
return ctxerr.Wrap(ctx, err, "insert scim user emails")
}
// FIXME: Consider ways we could lift ancillary actions like this to the service layer,
// perhaps some `WithCallback` pattern to inject these into the SCIM handlers.
if err := maybeAssociateScimUserWithHostMDMIdP(ctx, tx, ds.logger, user); err != nil {
return ctxerr.Wrap(ctx, err, "associate scim user with host mdm idp")
}
return nil
})
return userID, err
}
// ScimUserByID retrieves a SCIM user by ID
func (ds *Datastore) ScimUserByID(ctx context.Context, id uint) (*fleet.ScimUser, error) {
const query = `
SELECT
id, external_id, user_name, given_name, family_name, department, active, updated_at
FROM scim_users
WHERE id = ?
`
user := &fleet.ScimUser{}
err := sqlx.GetContext(ctx, ds.reader(ctx), user, query, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, notFound("scim user").WithID(id)
}
return nil, ctxerr.Wrap(ctx, err, "select scim user")
}
// Get the user's emails
emails, err := ds.getScimUserEmails(ctx, id)
if err != nil {
return nil, err
}
user.Emails = emails
// Get the user's groups
groups, err := ds.getScimUserGroups(ctx, id)
if err != nil {
return nil, err
}
user.Groups = groups
return user, nil
}
// ScimUserByUserName retrieves a SCIM user by username
func (ds *Datastore) ScimUserByUserName(ctx context.Context, userName string) (*fleet.ScimUser, error) {
return scimUserByUserName(ctx, ds.reader(ctx), userName)
}
func scimUserByUserName(ctx context.Context, q sqlx.QueryerContext, userName string) (*fleet.ScimUser, error) {
const query = `
SELECT
id, external_id, user_name, given_name, family_name, department, active, updated_at
FROM scim_users
WHERE user_name = ?
`
user := &fleet.ScimUser{}
err := sqlx.GetContext(ctx, q, user, query, userName)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, notFound("scim user")
}
return nil, ctxerr.Wrap(ctx, err, "select scim user by userName")
}
// Get the user's emails
emails, err := getScimUserEmails(ctx, q, user.ID)
if err != nil {
return nil, err
}
user.Emails = emails
// Get the user's groups
groups, err := getScimUserGroups(ctx, q, user.ID)
if err != nil {
return nil, err
}
user.Groups = groups
return user, nil
}
// ScimUserByUserNameOrEmail finds a SCIM user by username. If it cannot find one, then it tries email, if set.
// If multiple users are found with the same email, we log an error and return nil.
// Emails and groups are NOT populated in this method.
func (ds *Datastore) ScimUserByUserNameOrEmail(ctx context.Context, userName string, email string) (*fleet.ScimUser, error) {
return scimUserByUserNameOrEmail(ctx, ds.reader(ctx), ds.logger, userName, email)
}
func scimUserByUserNameOrEmail(ctx context.Context, q sqlx.QueryerContext, logger *slog.Logger, userName string, email string) (*fleet.ScimUser, error) {
// First, try to find the user by userName
if userName != "" {
user, err := scimUserByUserName(ctx, q, userName)
switch {
case err == nil:
return user, nil
case !fleet.IsNotFound(err):
return nil, ctxerr.Wrap(ctx, err, "select scim user by userName")
}
}
if email == "" {
return nil, notFound("scim user")
}
// Now, try to find the user by using the email as the userName
user, err := scimUserByUserName(ctx, q, email)
switch {
case err == nil:
return user, nil
case !fleet.IsNotFound(err):
return nil, ctxerr.Wrap(ctx, err, "select scim user by userName")
}
// Next, to find the user by email
const query = `
SELECT
scim_users.id, external_id, user_name, given_name, family_name, department, active, scim_users.updated_at
FROM scim_users
JOIN scim_user_emails ON scim_users.id = scim_user_emails.scim_user_id
WHERE scim_user_emails.email = ?
`
var users []fleet.ScimUser
err = sqlx.SelectContext(ctx, q, &users, query, email)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "select scim user by email")
}
if len(users) == 0 {
return nil, notFound("scim user")
}
// If multiple users found, log a message and return nil
if len(users) > 1 {
logger.ErrorContext(ctx, "Multiple SCIM users found with the same email", "email", email)
return nil, nil
}
return &users[0], nil
}
// ScimUserByHostID retrieves a SCIM user associated with a host ID
func (ds *Datastore) ScimUserByHostID(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
user, err := getScimUserLiteByHostID(ctx, ds.reader(ctx), hostID)
if err != nil {
return nil, err
}
// Get the user's emails
emails, err := ds.getScimUserEmails(ctx, user.ID)
if err != nil {
return nil, err
}
user.Emails = emails
// Get the user's groups
groups, err := ds.getScimUserGroups(ctx, user.ID)
if err != nil {
return nil, err
}
user.Groups = groups
return user, nil
}
// returns the ScimUser for the host, without emails and groups filled (only
// the scim_users table attributes).
func getScimUserLiteByHostID(ctx context.Context, q sqlx.QueryerContext, hostID uint) (*fleet.ScimUser, error) {
const query = `
SELECT
su.id, su.external_id, su.user_name, su.given_name, su.family_name, su.department, su.active, su.updated_at
FROM scim_users su
JOIN host_scim_user ON su.id = host_scim_user.scim_user_id
WHERE host_scim_user.host_id = ?
ORDER BY su.id LIMIT 1
`
var user fleet.ScimUser
err := sqlx.GetContext(ctx, q, &user, query, hostID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, notFound("scim user for host").WithID(hostID)
}
return nil, ctxerr.Wrap(ctx, err, "select scim user by host ID")
}
return &user, nil
}
// ReplaceScimUser replaces an existing SCIM user in the database
func (ds *Datastore) ReplaceScimUser(ctx context.Context, user *fleet.ScimUser) ([]fleet.ActivityTypeResentCertificate, error) {
if err := validateScimUserFields(user); err != nil {
return nil, err
}
// Validate that at most one email is marked as primary
primaryCount := 0
for _, email := range user.Emails {
if email.Primary != nil && *email.Primary {
primaryCount++
}
}
if primaryCount > 1 {
return nil, ctxerr.New(ctx, "only one email can be marked as primary")
}
// Get current emails and check if they need to be updated
currentEmails, err := ds.getScimUserEmails(ctx, user.ID)
if err != nil {
return nil, err
}
emailsNeedUpdate := emailsRequireUpdate(currentEmails, user.Emails)
var resentCerts []fleet.ActivityTypeResentCertificate
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
resentCerts = nil
// load the username and department before updating the user, to check if it changed
old := struct {
UserName string `db:"user_name"`
Department *string `db:"department"`
GivenName *string `db:"given_name"`
FamilyName *string `db:"family_name"`
}{}
err := sqlx.GetContext(ctx, tx, &old, `SELECT user_name, department, given_name, family_name FROM scim_users WHERE id = ?`, user.ID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return notFound("scim user").WithID(user.ID)
}
return ctxerr.Wrap(ctx, err, "load existing scim username and department before update")
}
// Update the SCIM user
const updateUserQuery = `
UPDATE scim_users SET
external_id = ?,
user_name = ?,
given_name = ?,
family_name = ?,
department = ?,
active = ?
WHERE id = ?`
result, err := tx.ExecContext(
ctx,
updateUserQuery,
user.ExternalID,
user.UserName,
user.GivenName,
user.FamilyName,
user.Department,
user.Active,
user.ID,
)
if err != nil {
if IsDuplicate(err) {
return ctxerr.Wrap(ctx, alreadyExists("ScimUser", user.UserName), "update scim user")
}
return ctxerr.Wrap(ctx, err, "update scim user")
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return ctxerr.Wrap(ctx, err, "get rows affected for update scim user")
}
if rowsAffected == 0 {
return notFound("scim user").WithID(user.ID)
}
usernameChanged := old.UserName != user.UserName
departmentChanged := !cmp.Equal(old.Department, user.Department)
nameChanged := !cmp.Equal(old.GivenName, user.GivenName) || !cmp.Equal(old.FamilyName, user.FamilyName)
// Only update emails if they've changed
if emailsNeedUpdate {
// We assume that email is not blank/null.
// However, we do not assume that email/type are unique for a user. To keep the code simple, we:
// 1. Delete all existing emails
// 2. Insert all new emails
// This is less efficient and can be optimized if we notice a load on these tables in production.
const deleteEmailsQuery = `DELETE FROM scim_user_emails WHERE scim_user_id = ?`
_, err = tx.ExecContext(ctx, deleteEmailsQuery, user.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete scim user emails")
}
err = insertEmails(ctx, tx, user)
if err != nil {
return err
}
}
// Get the user's groups
groups, err := ds.getScimUserGroups(ctx, user.ID)
if err != nil {
return err
}
user.Groups = groups
// resend profiles that depend on this username if it changed
if usernameChanged || departmentChanged || nameChanged {
certs, err := triggerResendProfilesForIDPUserChange(ctx, tx, user.ID)
if err != nil {
return err
}
resentCerts = append(resentCerts, certs...)
}
return nil
})
if err != nil {
return nil, err
}
return resentCerts, nil
}
func insertEmails(ctx context.Context, tx sqlx.ExtContext, user *fleet.ScimUser) error {
// Insert the user's emails in a single batch if any
if len(user.Emails) > 0 {
// Build the batch insert query
valueStrings := make([]string, 0, len(user.Emails))
valueArgs := make([]interface{}, 0, len(user.Emails)*4)
for i := range user.Emails {
user.Emails[i].ScimUserID = user.ID
valueStrings = append(valueStrings, "(?, ?, ?, ?)")
valueArgs = append(valueArgs,
user.Emails[i].ScimUserID,
user.Emails[i].Email,
user.Emails[i].Primary,
user.Emails[i].Type,
)
}
// Construct the batch insert query
insertEmailQuery := `
INSERT INTO scim_user_emails (
scim_user_id, email, ` + "`primary`" + `, type
) VALUES ` + strings.Join(valueStrings, ",")
// Execute the batch insert
_, err := tx.ExecContext(ctx, insertEmailQuery, valueArgs...)
if err != nil {
return ctxerr.Wrap(ctx, err, "batch insert scim user emails")
}
}
return nil
}
// DeleteScimUser deletes a SCIM user from the database
func (ds *Datastore) DeleteScimUser(ctx context.Context, id uint) ([]fleet.ActivityTypeResentCertificate, error) {
var resentCerts []fleet.ActivityTypeResentCertificate
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
resentCerts = nil
// trigger resend of profiles that depend on this SCIM user (must be done
// _before_ deleting the scim user so that we can find the affected hosts)
certs, err := triggerResendProfilesForIDPUserDeleted(ctx, tx, id)
if err != nil {
return err
}
resentCerts = append(resentCerts, certs...)
// Delete the user
const deleteUserQuery = `DELETE FROM scim_users WHERE id = ?`
result, err := tx.ExecContext(ctx, deleteUserQuery, id)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete scim user")
}
// Check if the user existed
rowsAffected, err := result.RowsAffected()
if err != nil {
return ctxerr.Wrap(ctx, err, "get rows affected for delete scim user")
}
if rowsAffected == 0 {
return notFound("scim user").WithID(id)
}
return nil
})
if err != nil {
return nil, err
}
return resentCerts, nil
}
// ListScimUsers retrieves a list of SCIM users with optional filtering
func (ds *Datastore) ListScimUsers(ctx context.Context, opts fleet.ScimUsersListOptions) (users []fleet.ScimUser, totalResults uint, err error) {
// Default pagination values if not provided
if opts.StartIndex == 0 {
opts.StartIndex = 1
}
if opts.PerPage == 0 {
opts.PerPage = SCIMDefaultResourcesPerPage
}
// Build the base query
baseQuery := `
SELECT DISTINCT
scim_users.id, external_id, user_name, given_name, family_name, department, active, scim_users.updated_at
FROM scim_users
`
// Add joins and where clauses based on filters
var whereClause string
var params []interface{}
if opts.UserNameFilter != nil {
// Filter by username
whereClause = " WHERE scim_users.user_name = ?"
params = append(params, *opts.UserNameFilter)
} else if opts.EmailTypeFilter != nil && opts.EmailValueFilter != nil {
// Filter by email type and value
baseQuery += " LEFT JOIN scim_user_emails ON scim_users.id = scim_user_emails.scim_user_id"
whereClause = " WHERE scim_user_emails.type = ? AND scim_user_emails.email = ?"
params = append(params, *opts.EmailTypeFilter, *opts.EmailValueFilter)
}
// First, get the total count without pagination
countQuery := "SELECT COUNT(DISTINCT id) FROM (" + baseQuery + whereClause + ") AS filtered_users"
err = sqlx.GetContext(ctx, ds.reader(ctx), &totalResults, countQuery, params...)
if err != nil {
return nil, 0, ctxerr.Wrap(ctx, err, "count total scim users")
}
// Add pagination to the main query
query := baseQuery + whereClause + " ORDER BY scim_users.id LIMIT ? OFFSET ?"
params = append(params, opts.PerPage, opts.StartIndex-1)
// Execute the query
err = sqlx.SelectContext(ctx, ds.reader(ctx), &users, query, params...)
if err != nil {
return nil, 0, ctxerr.Wrap(ctx, err, "list scim users")
}
// Process the results
userIDs := make([]uint, 0, len(users))
userMap := make(map[uint]*fleet.ScimUser, len(users))
for i, user := range users {
userIDs = append(userIDs, user.ID)
userMap[user.ID] = &users[i]
}
// If no users found, return empty slice
if len(users) == 0 {
return users, totalResults, nil
}
// Fetch emails for all users in a single query
emailQuery, args, err := sqlx.In(`
SELECT
scim_user_id, email, `+"`primary`"+`, type
FROM scim_user_emails
WHERE scim_user_id IN (?)
ORDER BY email ASC
`, userIDs)
if err != nil {
return nil, 0, ctxerr.Wrap(ctx, err, "prepare emails query")
}
// Convert query for the specific DB dialect
emailQuery = ds.reader(ctx).Rebind(emailQuery)
// Execute the email query
var allEmails []fleet.ScimUserEmail
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &allEmails, emailQuery, args...); err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return nil, 0, ctxerr.Wrap(ctx, err, "select scim user emails")
}
}
// Associate emails with their users
for i := range allEmails {
email := allEmails[i]
if user, ok := userMap[email.ScimUserID]; ok {
user.Emails = append(user.Emails, email)
}
}
// Fetch groups for all users in a single query
groupQuery, groupArgs, err := sqlx.In(`
SELECT
sug.scim_user_id, sg.id, sg.display_name
FROM scim_user_group sug
JOIN scim_groups sg ON sug.group_id = sg.id
WHERE sug.scim_user_id IN (?)
ORDER BY sg.id ASC
`, userIDs)
if err != nil {
return nil, 0, ctxerr.Wrap(ctx, err, "prepare groups query")
}
// Execute the group query
type userGroup struct {
UserID uint `db:"scim_user_id"`
ID uint `db:"id"`
DisplayName string `db:"display_name"`
}
var allUserGroups []userGroup
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &allUserGroups, groupQuery, groupArgs...); err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return nil, 0, ctxerr.Wrap(ctx, err, "select scim user groups")
}
}
// Associate groups with their users
for _, ug := range allUserGroups {
if user, ok := userMap[ug.UserID]; ok {
user.Groups = append(user.Groups, fleet.ScimUserGroup{
ID: ug.ID,
DisplayName: ug.DisplayName,
})
}
}
return users, totalResults, nil
}
// getScimUserEmails retrieves all emails for a SCIM user
func (ds *Datastore) getScimUserEmails(ctx context.Context, userID uint) ([]fleet.ScimUserEmail, error) {
return getScimUserEmails(ctx, ds.reader(ctx), userID)
}
func getScimUserEmails(ctx context.Context, q sqlx.QueryerContext, userID uint) ([]fleet.ScimUserEmail, error) {
const query = `
SELECT
scim_user_id, email, ` + "`primary`" + `, type
FROM scim_user_emails
WHERE scim_user_id = ? ORDER BY email ASC
`
var emails []fleet.ScimUserEmail
err := sqlx.SelectContext(ctx, q, &emails, query, userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, ctxerr.Wrap(ctx, err, "select scim user emails")
}
return emails, nil
}
// getScimUserGroups retrieves all groups for a SCIM user
func (ds *Datastore) getScimUserGroups(ctx context.Context, userID uint) ([]fleet.ScimUserGroup, error) {
return getScimUserGroups(ctx, ds.reader(ctx), userID)
}
func getScimUserGroups(ctx context.Context, q sqlx.QueryerContext, userID uint) ([]fleet.ScimUserGroup, error) {
// A user's effective group membership is the set of groups they are a direct
// member of, plus every ancestor group reachable by walking parent -> child
// edges upward (nested groups, as provisioned by Entra ID). The recursive CTE
// seeds from the user's direct groups and walks up to each parent group. UNION
// (not UNION ALL) dedupes and guarantees termination even if a cycle exists.
const query = `
WITH RECURSIVE user_groups AS (
SELECT group_id FROM scim_user_group WHERE scim_user_id = ?
UNION
SELECT gg.parent_group_id
FROM user_groups ug
JOIN scim_group_group gg ON gg.child_group_id = ug.group_id
)
SELECT sg.id, sg.display_name
FROM scim_groups sg
JOIN user_groups ug ON sg.id = ug.group_id
ORDER BY sg.id ASC
`
var groups []fleet.ScimUserGroup
err := sqlx.SelectContext(ctx, q, &groups, query, userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, ctxerr.Wrap(ctx, err, "select scim user groups")
}
return groups, nil
}
// validateScimUserFields checks if the user fields exceed the maximum allowed length
func validateScimUserFields(user *fleet.ScimUser) error {
if user.ExternalID != nil && len(*user.ExternalID) > fleet.SCIMMaxFieldLength {
return &fleet.SCIMValidationError{Field: "external_id", Message: fmt.Sprintf("exceeds maximum length of %d characters", fleet.SCIMMaxFieldLength)}
}
if len(user.UserName) > fleet.SCIMMaxFieldLength {
return &fleet.SCIMValidationError{Field: "user_name", Message: fmt.Sprintf("exceeds maximum length of %d characters", fleet.SCIMMaxFieldLength)}
}
if user.GivenName != nil && len(*user.GivenName) > fleet.SCIMMaxFieldLength {
return &fleet.SCIMValidationError{Field: "given_name", Message: fmt.Sprintf("exceeds maximum length of %d characters", fleet.SCIMMaxFieldLength)}
}
if user.FamilyName != nil && len(*user.FamilyName) > fleet.SCIMMaxFieldLength {
return &fleet.SCIMValidationError{Field: "family_name", Message: fmt.Sprintf("exceeds maximum length of %d characters", fleet.SCIMMaxFieldLength)}
}
if user.Department != nil && len(*user.Department) > fleet.SCIMMaxFieldLength {
return &fleet.SCIMValidationError{Field: "department", Message: fmt.Sprintf("exceeds maximum length of %d characters", fleet.SCIMMaxFieldLength)}
}
return nil
}
// validateScimGroupFields checks if the group fields exceed the maximum allowed length
func validateScimGroupFields(group *fleet.ScimGroup) error {
if group.ExternalID != nil && len(*group.ExternalID) > fleet.SCIMMaxFieldLength {
return &fleet.SCIMValidationError{Field: "external_id", Message: fmt.Sprintf("exceeds maximum length of %d characters", fleet.SCIMMaxFieldLength)}
}
if len(group.DisplayName) > fleet.SCIMMaxFieldLength {
return &fleet.SCIMValidationError{Field: "display_name", Message: fmt.Sprintf("exceeds maximum length of %d characters", fleet.SCIMMaxFieldLength)}
}
return nil
}
// CreateScimGroup creates a new SCIM group in the database
func (ds *Datastore) CreateScimGroup(ctx context.Context, group *fleet.ScimGroup) (uint, error) {
if err := validateScimGroupFields(group); err != nil {
return 0, err
}
var groupID uint
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
const insertGroupQuery = `
INSERT INTO scim_groups (
external_id, display_name
) VALUES (?, ?)`
result, err := tx.ExecContext(
ctx,
insertGroupQuery,
group.ExternalID,
group.DisplayName,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert scim group")
}
id, err := result.LastInsertId()
if err != nil {
return ctxerr.Wrap(ctx, err, "insert scim group last insert id")
}
group.ID = uint(id) // nolint:gosec // dismiss G115
groupID = group.ID
// Insert nested child group edges if any
if len(group.ChildGroups) > 0 {
if err := insertScimGroupChildren(ctx, tx, group.ID, group.ChildGroups); err != nil {
return err
}
}
// Insert user-group relationships if any
if len(group.ScimUsers) > 0 {
if err := insertScimGroupUsers(ctx, tx, group.ID, group.ScimUsers); err != nil {
return err
}
}
// this is a new group, but it may already be associated with existing
// users (directly, or transitively through nested child groups) - trigger
// a resend of profiles that use the IdP groups variable for the affected
// hosts.
if len(group.ScimUsers) > 0 || len(group.ChildGroups) > 0 {
affectedUsers, err := getTransitiveScimGroupUserIDs(ctx, tx, group.ID)
if err != nil {
return err
}
return triggerResendProfilesForIDPGroupChangeByUsers(ctx, tx, affectedUsers)
}
return nil
})
return groupID, err
}
// insertScimGroupUsers inserts the relationships between a SCIM group and its users
func insertScimGroupUsers(ctx context.Context, tx sqlx.ExtContext, groupID uint, userIDs []uint) error {
if len(userIDs) == 0 {
return nil
}
// TODO: We could consider using string interpolation without placeholders for better performance
// to the extent these queries are dependent only on the group ID and user IDs, which are integers.
// See https://github.com/fleetdm/fleet/pull/30264
batchSize := 10000
return common_mysql.BatchProcessSimple(userIDs, batchSize, func(userIDsInBatch []uint) error {
// Build the batch insert query
valueStrings := make([]string, 0, len(userIDsInBatch))
valueArgs := make([]interface{}, 0, len(userIDsInBatch)*2)
for _, userID := range userIDsInBatch {
valueStrings = append(valueStrings, "(?, ?)")
valueArgs = append(valueArgs, userID, groupID)
}
// Construct the batch insert query
insertQuery := `
INSERT INTO scim_user_group (
scim_user_id, group_id
) VALUES ` + strings.Join(valueStrings, ",") + `
ON DUPLICATE KEY UPDATE created_at = scim_user_group.created_at` // no-op update to avoid duplicate key errors
// Execute the batch insert
_, err := tx.ExecContext(ctx, insertQuery, valueArgs...)
if err != nil {
return ctxerr.Wrap(ctx, err, "batch insert scim group users")
}
return nil
})
}
// ScimGroupByID retrieves a SCIM group by ID
// If excludeUsers is true, the group's users (and nested child groups) will not be fetched
func (ds *Datastore) ScimGroupByID(ctx context.Context, id uint, excludeUsers bool) (*fleet.ScimGroup, error) {
const query = `
SELECT
id, external_id, display_name
FROM scim_groups
WHERE id = ?
`
group := &fleet.ScimGroup{}
err := sqlx.GetContext(ctx, ds.reader(ctx), group, query, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, notFound("scim group").WithID(id)
}
return nil, ctxerr.Wrap(ctx, err, "select scim group")
}
// Get the group's members (users and nested child groups) if not excluded
if !excludeUsers {
users, err := getScimGroupUsers(ctx, ds.reader(ctx), id)
if err != nil {
return nil, err
}
group.ScimUsers = users
children, err := getScimGroupChildren(ctx, ds.reader(ctx), id)
if err != nil {
return nil, err
}
group.ChildGroups = children
}
return group, nil
}
// ScimGroupsExist checks if all the provided SCIM group IDs exist in the datastore.
// If the slice is empty, it returns true. This mirrors ScimUsersExist.
func (ds *Datastore) ScimGroupsExist(ctx context.Context, ids []uint) (bool, error) {
if len(ids) == 0 {
return true, nil
}
// Create a set to track which IDs we've found
foundIDs := make(map[uint]struct{}, len(ids))
batchSize := 10000
err := common_mysql.BatchProcessSimple(ids, batchSize, func(batchIDs []uint) error {
query, args, err := sqlx.In(`
SELECT id
FROM scim_groups
WHERE id IN (?)
`, batchIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare scim groups exist batch query")
}
var foundBatchIDs []uint
err = sqlx.SelectContext(ctx, ds.reader(ctx), &foundBatchIDs, query, args...)
if err != nil {
return ctxerr.Wrap(ctx, err, "check if scim groups exist in batch")
}
for _, id := range foundBatchIDs {
foundIDs[id] = struct{}{}
}
return nil
})
if err != nil {
return false, err
}
// Verify that all requested IDs were found
for _, id := range ids {
if _, ok := foundIDs[id]; !ok {
return false, nil
}
}
return true, nil
}
// insertScimGroupChildren inserts direct parent -> child SCIM group edges
func insertScimGroupChildren(ctx context.Context, tx sqlx.ExtContext, parentGroupID uint, childGroupIDs []uint) error {
if len(childGroupIDs) == 0 {
return nil
}
batchSize := 10000
return common_mysql.BatchProcessSimple(childGroupIDs, batchSize, func(childIDsInBatch []uint) error {
valueStrings := make([]string, 0, len(childIDsInBatch))
valueArgs := make([]any, 0, len(childIDsInBatch)*2)
for _, childID := range childIDsInBatch {
valueStrings = append(valueStrings, "(?, ?)")
valueArgs = append(valueArgs, parentGroupID, childID)
}
insertQuery := `
INSERT INTO scim_group_group (
parent_group_id, child_group_id
) VALUES ` + strings.Join(valueStrings, ",") + `
ON DUPLICATE KEY UPDATE created_at = scim_group_group.created_at` // no-op update to avoid duplicate key errors
if _, err := tx.ExecContext(ctx, insertQuery, valueArgs...); err != nil {
return ctxerr.Wrap(ctx, err, "batch insert scim group children")
}
return nil
})
}
// getScimGroupChildren retrieves the IDs of the direct (nested) child groups of a SCIM group
func getScimGroupChildren(ctx context.Context, q sqlx.QueryerContext, groupID uint) ([]uint, error) {
const query = `
SELECT
child_group_id
FROM scim_group_group
WHERE parent_group_id = ? ORDER BY child_group_id ASC
`
var childIDs []uint
err := sqlx.SelectContext(ctx, q, &childIDs, query, groupID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "select scim group children")
}
return childIDs, nil
}
// getTransitiveScimGroupUserIDs returns the IDs of all SCIM users who are
// effective members of the given group -- that is, direct members of the group
// or of any of its (recursively) nested child groups.
func getTransitiveScimGroupUserIDs(ctx context.Context, q sqlx.QueryerContext, groupID uint) ([]uint, error) {
const query = `
WITH RECURSIVE descendants AS (
SELECT ? AS group_id
UNION
SELECT gg.child_group_id
FROM descendants d
JOIN scim_group_group gg ON gg.parent_group_id = d.group_id
)
SELECT DISTINCT sug.scim_user_id
FROM descendants d
JOIN scim_user_group sug ON sug.group_id = d.group_id
`
var userIDs []uint
err := sqlx.SelectContext(ctx, q, &userIDs, query, groupID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "select transitive scim group users")
}
return userIDs, nil
}
// ScimGroupByDisplayName retrieves a SCIM group by display name
// This method always fetches the group's users
func (ds *Datastore) ScimGroupByDisplayName(ctx context.Context, displayName string) (*fleet.ScimGroup, error) {
const query = `
SELECT
id, external_id, display_name
FROM scim_groups
WHERE display_name = ?
`
group := &fleet.ScimGroup{}
err := sqlx.GetContext(ctx, ds.reader(ctx), group, query, displayName)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, notFound("scim group")
}
return nil, ctxerr.Wrap(ctx, err, "select scim group by displayName")
}
// Get the group's members (users and nested child groups)
users, err := getScimGroupUsers(ctx, ds.reader(ctx), group.ID)
if err != nil {
return nil, err
}
group.ScimUsers = users
children, err := getScimGroupChildren(ctx, ds.reader(ctx), group.ID)
if err != nil {
return nil, err
}
group.ChildGroups = children
return group, nil
}
// getScimGroupUsers retrieves all user IDs for a SCIM group
func getScimGroupUsers(ctx context.Context, q sqlx.QueryerContext, groupID uint) ([]uint, error) {
const query = `
SELECT
scim_user_id
FROM scim_user_group
WHERE group_id = ? ORDER BY scim_user_id ASC
`
var userIDs []uint
err := sqlx.SelectContext(ctx, q, &userIDs, query, groupID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "select scim group users")
}
return userIDs, nil
}
// ReplaceScimGroup replaces an existing SCIM group in the database
func (ds *Datastore) ReplaceScimGroup(ctx context.Context, group *fleet.ScimGroup) error {
if err := validateScimGroupFields(group); err != nil {
return err
}
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// load the display name before updating the group, to check if it changed
var oldDisplayName string
err := sqlx.GetContext(ctx, tx, &oldDisplayName, `SELECT display_name FROM scim_groups WHERE id = ?`, group.ID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return notFound("scim group").WithID(group.ID)
}
return ctxerr.Wrap(ctx, err, "load existing scim group display name before update")
}
// Update the SCIM group
const updateGroupQuery = `
UPDATE scim_groups SET
external_id = ?,
display_name = ?
WHERE id = ?`
result, err := tx.ExecContext(
ctx,
updateGroupQuery,
group.ExternalID,
group.DisplayName,
group.ID,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "update scim group")
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return ctxerr.Wrap(ctx, err, "get rows affected for update scim group")
}
if rowsAffected == 0 {
return notFound("scim group").WithID(group.ID)
}
groupNameChanged := oldDisplayName != group.DisplayName
// Get existing user-group relationships
existingUsers, err := getScimGroupUsers(ctx, tx, group.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get existing scim group users")
}
// Create maps for efficient lookup
existingUserMap := make(map[uint]bool)
for _, userID := range existingUsers {
existingUserMap[userID] = true
}
newUserMap := make(map[uint]bool)
for _, userID := range group.ScimUsers {
newUserMap[userID] = true
}
// Find users to add (in new but not in existing)
var usersToAdd []uint
for _, userID := range group.ScimUsers {
if !existingUserMap[userID] {
usersToAdd = append(usersToAdd, userID)
}
}
// Find users to remove (in existing but not in new)
var usersToRemove []uint
for _, userID := range existingUsers {
if !newUserMap[userID] {
usersToRemove = append(usersToRemove, userID)
}
}
// Add new user-group relationships
if len(usersToAdd) > 0 {
err = insertScimGroupUsers(ctx, tx, group.ID, usersToAdd)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert new scim group users")
}
}
// Remove old user-group relationships
if len(usersToRemove) > 0 {
batchSize := 10000
err = common_mysql.BatchProcessSimple(usersToRemove, batchSize, func(usersToRemoveInBatch []uint) error {
params := make([]interface{}, len(usersToRemoveInBatch)+1)
params[0] = group.ID
for i, userID := range usersToRemoveInBatch {
params[i+1] = userID
}
deleteQuery := "DELETE FROM scim_user_group WHERE group_id = ? AND scim_user_id IN (" +
strings.Repeat("?, ", len(usersToRemoveInBatch)-1) + "?)"
_, err = tx.ExecContext(ctx, deleteQuery, params...)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete removed scim group users")
}
return nil
})
if err != nil {
return err
}
}
// Reconcile nested child group edges the same way. Collect the users whose
// effective membership changed (the whole subtree of each added/removed
// child) so we can resend affected profiles below.
existingChildren, err := getScimGroupChildren(ctx, tx, group.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get existing scim group children")
}
childrenToAdd, childrenToRemove := diffUintSlices(existingChildren, group.ChildGroups)
if len(childrenToAdd) > 0 {
if err = insertScimGroupChildren(ctx, tx, group.ID, childrenToAdd); err != nil {
return ctxerr.Wrap(ctx, err, "insert new scim group children")
}
}
if len(childrenToRemove) > 0 {
batchSize := 10000
err = common_mysql.BatchProcessSimple(childrenToRemove, batchSize, func(childIDsInBatch []uint) error {
params := make([]any, len(childIDsInBatch)+1)
params[0] = group.ID
for i, childID := range childIDsInBatch {
params[i+1] = childID
}
deleteQuery := "DELETE FROM scim_group_group WHERE parent_group_id = ? AND child_group_id IN (" +
strings.Repeat("?, ", len(childIDsInBatch)-1) + "?)"
_, err = tx.ExecContext(ctx, deleteQuery, params...)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete removed scim group children")
}
return nil
})
if err != nil {
return err
}
}
// resend profiles that depend on the updated group to hosts that are
// related to the users in the updated group (only for those users that
// were affected by the group change)
if groupNameChanged {
// if the name of the group changed, all hosts with users part of this
// group (directly or through nested child groups) are affected
affectedUsers, err := getTransitiveScimGroupUserIDs(ctx, tx, group.ID)
if err != nil {
return err
}
err = triggerResendProfilesForIDPGroupChangeByUsers(ctx, tx, affectedUsers)
if err != nil {
return err
}
} else {
affectedUsers := append(append([]uint{}, usersToAdd...), usersToRemove...)
// A child group edge change affects every user in that child's subtree,
// since their effective membership in this group (and its ancestors)
// changed.
for _, childID := range append(append([]uint{}, childrenToAdd...), childrenToRemove...) {
subtreeUsers, err := getTransitiveScimGroupUserIDs(ctx, tx, childID)
if err != nil {
return err
}
affectedUsers = append(affectedUsers, subtreeUsers...)
}
if len(affectedUsers) > 0 {
if err = triggerResendProfilesForIDPGroupChangeByUsers(ctx, tx, affectedUsers); err != nil {
return err
}
}
}
return nil
})
}
// diffUintSlices returns the elements to add (in want but not in have) and to
// remove (in have but not in want). toAdd is deduplicated, preserving order:
// want may come straight from a SCIM payload, which can repeat members.
func diffUintSlices(have, want []uint) (toAdd, toRemove []uint) {
haveSet := make(map[uint]struct{}, len(have))
for _, id := range have {
haveSet[id] = struct{}{}
}
wantSet := make(map[uint]struct{}, len(want))
for _, id := range want {
wantSet[id] = struct{}{}
}
for _, id := range want {
if _, ok := haveSet[id]; !ok {
toAdd = append(toAdd, id)
haveSet[id] = struct{}{}
}
}
for _, id := range have {
if _, ok := wantSet[id]; !ok {
toRemove = append(toRemove, id)
}
}
return toAdd, toRemove
}
// DeleteScimGroup deletes a SCIM group from the database
func (ds *Datastore) DeleteScimGroup(ctx context.Context, id uint) error {
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// trigger resend of profiles that depend on this SCIM group (must be done
// _before_ deleting the scim group so that we can find the affected hosts)
err := triggerResendProfilesForIDPGroupChange(ctx, tx, id)
if err != nil {
return err
}
// Delete the group
const deleteGroupQuery = `DELETE FROM scim_groups WHERE id = ?`
result, err := tx.ExecContext(ctx, deleteGroupQuery, id)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete scim group")
}
// Check if the group existed
rowsAffected, err := result.RowsAffected()
if err != nil {
return ctxerr.Wrap(ctx, err, "get rows affected for delete scim group")
}
if rowsAffected == 0 {
return notFound("scim group").WithID(id)
}
return nil
})
}
// ListScimGroups retrieves a list of SCIM groups with pagination
// If opts.ExcludeUsers is true, the groups' users will not be fetched
func (ds *Datastore) ListScimGroups(ctx context.Context, opts fleet.ScimGroupsListOptions) (groups []fleet.ScimGroup, totalResults uint, err error) {
// Default pagination values if not provided
if opts.StartIndex == 0 {
opts.StartIndex = 1
}
if opts.PerPage == 0 {
opts.PerPage = SCIMDefaultResourcesPerPage
}
// Build the query
baseQuery := `
SELECT DISTINCT
scim_groups.id, external_id, display_name
FROM scim_groups
`
// Add where clause based on filters
var whereClause string
var params []interface{}
if opts.DisplayNameFilter != nil {
whereClause = " WHERE scim_groups.display_name = ?"
params = append(params, *opts.DisplayNameFilter)
}
// First, get the total count without pagination
countQuery := "SELECT COUNT(DISTINCT id) FROM (" + baseQuery + whereClause + ") AS filtered_groups"
err = sqlx.GetContext(ctx, ds.reader(ctx), &totalResults, countQuery, params...)
if err != nil {
return nil, 0, ctxerr.Wrap(ctx, err, "count total scim groups")
}
// Add pagination to the main query
query := baseQuery + whereClause + " ORDER BY scim_groups.id LIMIT ? OFFSET ?"
params = append(params, opts.PerPage, opts.StartIndex-1)
// Execute the query
err = sqlx.SelectContext(ctx, ds.reader(ctx), &groups, query, params...)
if err != nil {
return nil, 0, ctxerr.Wrap(ctx, err, "list scim groups")
}
// Process the results
groupIDs := make([]uint, 0, len(groups))
groupMap := make(map[uint]*fleet.ScimGroup, len(groups))
for i, group := range groups {
groupIDs = append(groupIDs, group.ID)
groupMap[group.ID] = &groups[i]
groups[i].ScimUsers = []uint{} // Initialize empty user list for each group
}
// If no groups found, return empty slice
if len(groups) == 0 {
return groups, totalResults, nil
}
// Skip fetching users if ExcludeUsers is true
if !opts.ExcludeUsers {
// Fetch users for all groups in a single query
userQuery, args, err := sqlx.In(`
SELECT
group_id, scim_user_id
FROM scim_user_group
WHERE group_id IN (?)
ORDER BY scim_user_id ASC
`, groupIDs)
if err != nil {
return nil, 0, ctxerr.Wrap(ctx, err, "prepare users query")
}
// Execute the user query
type groupUser struct {
GroupID uint `db:"group_id"`
UserID uint `db:"scim_user_id"`
}
var allGroupUsers []groupUser
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &allGroupUsers, userQuery, args...); err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return nil, 0, ctxerr.Wrap(ctx, err, "select scim group users")
}
}
// Associate users with their groups
for _, gu := range allGroupUsers {
if group, ok := groupMap[gu.GroupID]; ok {
group.ScimUsers = append(group.ScimUsers, gu.UserID)
}
}
}
return groups, totalResults, nil
}
// ScimLastRequest retrieves the last SCIM request info
func (ds *Datastore) ScimLastRequest(ctx context.Context) (*fleet.ScimLastRequest, error) {
const query = `
SELECT
status, details, updated_at
FROM scim_last_request
ORDER BY id LIMIT 1
`
var lastRequest fleet.ScimLastRequest
err := sqlx.GetContext(ctx, ds.reader(ctx), &lastRequest, query)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, ctxerr.Wrap(ctx, err, "select scim last request")
}
return &lastRequest, nil
}
// UpdateScimLastRequest updates the last SCIM request information
// If no row exists, it creates a new one
func (ds *Datastore) UpdateScimLastRequest(ctx context.Context, lastRequest *fleet.ScimLastRequest) error {
if lastRequest == nil {
return nil
}
if len(lastRequest.Status) > SCIMMaxStatusLength {
return fmt.Errorf("status exceeds maximum length of %d characters", SCIMMaxStatusLength)
}
if len(lastRequest.Details) > fleet.SCIMMaxFieldLength {
return fmt.Errorf("details exceeds maximum length of %d characters", fleet.SCIMMaxFieldLength)
}
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// Try to update first. We always update the timestamp since success requests all look the same.
const updateQuery = `
UPDATE scim_last_request
SET status = ?, details = ?, updated_at = NOW(6)
`
result, err := tx.ExecContext(
ctx,
updateQuery,
lastRequest.Status,
lastRequest.Details,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "update scim last request")
}
// Check if any rows were affected by the update
rowsAffected, err := result.RowsAffected()
if err != nil {
return ctxerr.Wrap(ctx, err, "get rows affected for update scim last request")
}
// If no rows were affected, insert a new row
if rowsAffected == 0 {
const insertQuery = `
INSERT INTO scim_last_request (
status, details
) VALUES (?, ?)
`
_, err = tx.ExecContext(
ctx,
insertQuery,
lastRequest.Status,
lastRequest.Details,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert scim last request")
}
}
return nil
})
}
func getHostIDsHavingScimIDPUser(ctx context.Context, tx sqlx.ExtContext, scimUserID uint) ([]uint, error) {
// get all hosts that have this user as IdP user - this means that we only
// consider hosts where this user id is the smallest user id associated with
// the host (which is the one we consider as the IdP user of the host, see
// the query in ScimUserByHostID)
const getAssociatedHostIDsQuery = `
SELECT DISTINCT
hsu.host_id
FROM
host_scim_user hsu
LEFT JOIN host_scim_user extra_hsu ON
hsu.host_id = extra_hsu.host_id AND
hsu.scim_user_id != extra_hsu.scim_user_id AND
extra_hsu.scim_user_id < hsu.scim_user_id
WHERE
hsu.scim_user_id = ? AND
extra_hsu.host_id IS NULL
`
var hostIDs []uint
err := sqlx.SelectContext(ctx, tx, &hostIDs, getAssociatedHostIDsQuery, scimUserID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get scim user host IDs")
}
return hostIDs, nil
}
func getHostIDsHavingScimIDPUsers(ctx context.Context, tx sqlx.ExtContext, scimUserIDs []uint) ([]uint, error) {
// get all hosts that have any of those users as IdP user - this means that
// we only consider hosts where the user id is the smallest user id
// associated with the host (which is the one we consider as the IdP user of
// the host, see the query in ScimUserByHostID)
const getAssociatedHostIDsQuery = `
SELECT DISTINCT
hsu.host_id
FROM
host_scim_user hsu
LEFT JOIN host_scim_user extra_hsu ON
hsu.host_id = extra_hsu.host_id AND
hsu.scim_user_id != extra_hsu.scim_user_id AND
extra_hsu.scim_user_id < hsu.scim_user_id
WHERE
hsu.scim_user_id IN (?) AND
extra_hsu.host_id IS NULL
`
stmt, args, err := sqlx.In(getAssociatedHostIDsQuery, scimUserIDs)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "prepare get scim users host IDs")
}
var hostIDs []uint
err = sqlx.SelectContext(ctx, tx, &hostIDs, stmt, args...)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get scim users host IDs")
}
return hostIDs, nil
}
func triggerResendProfilesForIDPUserChange(ctx context.Context, tx sqlx.ExtContext, updatedScimUserID uint) ([]fleet.ActivityTypeResentCertificate, error) {
hostIDs, err := getHostIDsHavingScimIDPUser(ctx, tx, updatedScimUserID)
if err != nil {
return nil, err
}
vars := []fleet.FleetVarName{
fleet.FleetVarHostEndUserIDPUsername,
fleet.FleetVarHostEndUserIDPUsernameLocalPart,
fleet.FleetVarHostEndUserIDPDepartment,
fleet.FleetVarHostEndUserIDPFullname,
}
resentCerts, err := selectCertTemplatesToResend(ctx, tx, hostIDs, fleetVarNamesToDBVars(vars))
if err != nil {
return nil, err
}
if err := triggerResendProfilesUsingVariables(ctx, tx, hostIDs, vars); err != nil {
return nil, err
}
return resentCerts, nil
}
func triggerResendProfilesForIDPUserDeleted(ctx context.Context, tx sqlx.ExtContext, deletedScimUserID uint) ([]fleet.ActivityTypeResentCertificate, error) {
hostIDs, err := getHostIDsHavingScimIDPUser(ctx, tx, deletedScimUserID)
if err != nil {
return nil, err
}
vars := []fleet.FleetVarName{
fleet.FleetVarHostEndUserIDPUsername,
fleet.FleetVarHostEndUserIDPUsernameLocalPart,
fleet.FleetVarHostEndUserIDPGroups,
fleet.FleetVarHostEndUserIDPDepartment,
fleet.FleetVarHostEndUserIDPFullname,
}
resentCerts, err := selectCertTemplatesToResend(ctx, tx, hostIDs, fleetVarNamesToDBVars(vars))
if err != nil {
return nil, err
}
if err := triggerResendProfilesUsingVariables(ctx, tx, hostIDs, vars); err != nil {
return nil, err
}
return resentCerts, nil
}
func triggerResendProfilesForIDPGroupChange(ctx context.Context, tx sqlx.ExtContext, updatedScimGroupID uint) error {
// get the updated list of effective users for that group (direct members plus
// members of any nested child groups)
userIDs, err := getTransitiveScimGroupUserIDs(ctx, tx, updatedScimGroupID)
if err != nil {
return err
}
if len(userIDs) == 0 {
return nil
}
// get hosts that have any of those users as IdP user
hostIDs, err := getHostIDsHavingScimIDPUsers(ctx, tx, userIDs)
if err != nil {
return err
}
return triggerResendProfilesUsingVariables(ctx, tx, hostIDs,
[]fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups})
}
func triggerResendProfilesForIDPGroupChangeByUsers(ctx context.Context, tx sqlx.ExtContext, scimUserIDs []uint) error {
if len(scimUserIDs) == 0 {
return nil
}
hostIDs, err := getHostIDsHavingScimIDPUsers(ctx, tx, scimUserIDs)
if err != nil {
return err
}
return triggerResendProfilesUsingVariables(ctx, tx, hostIDs,
[]fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups})
}
func triggerResendProfilesForIDPUserAddedToHost(ctx context.Context, tx sqlx.ExtContext, hostID, updatedScimUserID uint) ([]fleet.ActivityTypeResentCertificate, error) {
// check that this user is indeed the scim IdP user for this host (and not an
// extra, unused one)
user, err := getScimUserLiteByHostID(ctx, tx, hostID)
if err != nil {
return nil, err
}
if updatedScimUserID != user.ID {
// host is not impacted, updated user is not its IdP user
return nil, nil
}
vars := []fleet.FleetVarName{
fleet.FleetVarHostEndUserIDPUsername,
fleet.FleetVarHostEndUserIDPUsernameLocalPart,
fleet.FleetVarHostEndUserIDPDepartment,
fleet.FleetVarHostEndUserIDPGroups,
fleet.FleetVarHostEndUserIDPFullname,
}
resentCerts, err := selectCertTemplatesToResend(ctx, tx, []uint{hostID}, fleetVarNamesToDBVars(vars))
if err != nil {
return nil, err
}
if err := triggerResendProfilesUsingVariables(ctx, tx, []uint{hostID}, vars); err != nil {
return nil, err
}
return resentCerts, nil
}
func selectCertTemplatesToResend(ctx context.Context, tx sqlx.ExtContext, hostIDs []uint, vars []any) ([]fleet.ActivityTypeResentCertificate, error) {
if len(hostIDs) == 0 || len(vars) == 0 {
return nil, nil
}
const query = `
SELECT DISTINCT
h.id AS host_id,
COALESCE(h.computer_name, '') AS computer_name,
COALESCE(h.hostname, '') AS hostname,
COALESCE(h.hardware_model, '') AS hardware_model,
COALESCE(h.hardware_serial, '') AS hardware_serial,
ct.id AS certificate_template_id,
ct.name AS certificate_name
FROM
host_certificate_templates hct
JOIN hosts h
ON h.uuid = hct.host_uuid
JOIN certificate_templates ct
ON ct.id = hct.certificate_template_id AND
ct.team_id = COALESCE(h.team_id, 0)
JOIN mdm_configuration_profile_variables mcpv
ON mcpv.certificate_template_id = ct.id
JOIN fleet_variables fv
ON mcpv.fleet_variable_id = fv.id
WHERE
h.id IN (:host_ids) AND
hct.operation_type = :operation_type_install AND
hct.status IS NOT NULL AND
fv.name IN (:affected_vars)
`
namedParams := map[string]any{
"host_ids": hostIDs,
"operation_type_install": fleet.MDMOperationTypeInstall,
"affected_vars": vars,
}
stmt, args, err := sqlx.Named(query, namedParams)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "prepare select cert templates to resend names")
}
stmt, args, err = sqlx.In(stmt, args...)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "prepare select cert templates to resend arguments")
}
type row struct {
HostID uint `db:"host_id"`
ComputerName string `db:"computer_name"`
Hostname string `db:"hostname"`
HardwareModel string `db:"hardware_model"`
HardwareSerial string `db:"hardware_serial"`
CertificateTemplateID uint `db:"certificate_template_id"`
CertificateName string `db:"certificate_name"`
}
var rows []row
if err := sqlx.SelectContext(ctx, tx, &rows, stmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "select cert templates to resend")
}
activities := make([]fleet.ActivityTypeResentCertificate, 0, len(rows))
for _, r := range rows {
activities = append(activities, fleet.ActivityTypeResentCertificate{
HostID: r.HostID,
HostDisplayName: fleet.HostDisplayName(r.ComputerName, r.Hostname, r.HardwareModel, r.HardwareSerial),
CertificateTemplateID: r.CertificateTemplateID,
CertificateName: r.CertificateName,
Automated: true,
})
}
return activities, nil
}
func fleetVarNamesToDBVars(vars []fleet.FleetVarName) []any {
result := make([]any, len(vars))
for i, v := range vars {
result[i] = "FLEET_VAR_" + string(v)
}
return result
}
func triggerResendProfilesUsingVariables(ctx context.Context, tx sqlx.ExtContext, hostIDs []uint, affectedVars []fleet.FleetVarName) error {
if len(hostIDs) == 0 || len(affectedVars) == 0 {
return nil
}
// NOTE: this cannot reuse bulkSetPendingMDMAppleHostProfilesDB, as this
// (complex) function is based on changes it can detect itself, such as a
// profile content change, label membership changes, etc. It does not receive
// a list of host/profile to update, but relies on its own diff.
//
// In the case here where variable values change, we want a simple "resend"
// with the new values, so we don't need the complex diff logic, we only set
// to "pending" the profiles that depend on the variables that were already
// installed on the affected hosts. ReconcileAppleProfilesBatched will take care of
// resending as appropriate based on label membershup and all at the time it
// runs.
const appleUpdateStatusQuery = `
UPDATE
host_mdm_apple_profiles hmap
JOIN hosts h
ON h.uuid = hmap.host_uuid
JOIN mdm_apple_configuration_profiles macp
ON (macp.team_id = h.team_id OR (COALESCE(macp.team_id, 0) = 0 AND h.team_id IS NULL)) AND
macp.profile_uuid = hmap.profile_uuid
JOIN mdm_configuration_profile_variables mcpv
ON mcpv.apple_profile_uuid = macp.profile_uuid
JOIN fleet_variables fv
ON mcpv.fleet_variable_id = fv.id
SET
hmap.status = NULL,
hmap.detail = NULL,
hmap.command_uuid = ''
WHERE
h.id IN (:host_ids) AND
hmap.operation_type = :operation_type_install AND
hmap.status IS NOT NULL AND
fv.name IN (:affected_vars)
`
const windowsUpdateStatusQuery = `
UPDATE
host_mdm_windows_profiles hmwp
JOIN hosts h
ON h.uuid = hmwp.host_uuid
JOIN mdm_windows_configuration_profiles mwcp
ON (mwcp.team_id = h.team_id OR (COALESCE(mwcp.team_id, 0) = 0 AND h.team_id IS NULL)) AND
mwcp.profile_uuid = hmwp.profile_uuid
JOIN mdm_configuration_profile_variables mcpv
ON mcpv.windows_profile_uuid = mwcp.profile_uuid
JOIN fleet_variables fv
ON mcpv.fleet_variable_id = fv.id
SET
hmwp.status = NULL,
hmwp.command_uuid = '',
hmwp.detail = NULL
WHERE
h.id IN (:host_ids) AND
hmwp.operation_type = :operation_type_install AND
hmwp.status IS NOT NULL AND
fv.name IN (:affected_vars)
`
const declarationUpdateStatusQuery = `
UPDATE
host_mdm_apple_declarations hmad
JOIN hosts h
ON h.uuid = hmad.host_uuid
JOIN mdm_apple_declarations mad
ON (mad.team_id = h.team_id OR (COALESCE(mad.team_id, 0) = 0 AND h.team_id IS NULL)) AND
mad.declaration_uuid = hmad.declaration_uuid
LEFT JOIN mdm_apple_ddm_activations act
ON act.declaration_uuid = mad.declaration_uuid
JOIN mdm_configuration_profile_variables mcpv
ON mcpv.apple_declaration_uuid = mad.declaration_uuid
OR mcpv.apple_ddm_activation_uuid = act.activation_uuid
JOIN fleet_variables fv
ON mcpv.fleet_variable_id = fv.id
SET
hmad.status = NULL,
hmad.detail = NULL
WHERE
h.id IN (:host_ids) AND
hmad.operation_type = :operation_type_install AND
hmad.status IS NOT NULL AND
fv.name IN (:affected_vars)
`
const certTemplateUpdateStatusQuery = `
UPDATE
host_certificate_templates hct
JOIN hosts h
ON h.uuid = hct.host_uuid
JOIN certificate_templates ct
ON ct.id = hct.certificate_template_id AND
ct.team_id = COALESCE(h.team_id, 0)
JOIN mdm_configuration_profile_variables mcpv
ON mcpv.certificate_template_id = ct.id
JOIN fleet_variables fv
ON mcpv.fleet_variable_id = fv.id
SET
hct.status = :cert_pending_status,
hct.uuid = UUID_TO_BIN(UUID(), true),
hct.fleet_challenge = NULL,
hct.not_valid_before = NULL,
hct.not_valid_after = NULL,
hct.serial = NULL,
hct.detail = NULL,
hct.retry_count = 0
WHERE
h.id IN (:host_ids) AND
hct.operation_type = :operation_type_install AND
hct.status IS NOT NULL AND
fv.name IN (:affected_vars)
`
vars := make([]any, len(affectedVars))
for i, v := range affectedVars {
vars[i] = "FLEET_VAR_" + string(v)
}
namedParams := map[string]any{
"host_ids": hostIDs,
"operation_type_install": fleet.MDMOperationTypeInstall,
"affected_vars": vars,
}
const androidUpdateStatusQuery = `
UPDATE
host_mdm_android_profiles hmap
JOIN hosts h
ON h.uuid = hmap.host_uuid
JOIN mdm_android_configuration_profiles macp
ON (macp.team_id = COALESCE(h.team_id, 0)) AND
macp.profile_uuid = hmap.profile_uuid
JOIN mdm_configuration_profile_variables mcpv
ON mcpv.android_profile_uuid = macp.profile_uuid
JOIN fleet_variables fv
ON mcpv.fleet_variable_id = fv.id
SET
hmap.status = NULL,
hmap.detail = NULL
WHERE
h.id IN (:host_ids) AND
hmap.operation_type = :operation_type_install AND
hmap.status IS NOT NULL AND
fv.name IN (:affected_vars)
`
for _, query := range []string{appleUpdateStatusQuery, windowsUpdateStatusQuery, declarationUpdateStatusQuery, androidUpdateStatusQuery} {
updateStmt, args, err := sqlx.Named(query, namedParams)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare resend profiles replace names")
}
updateStmt, args, err = sqlx.In(updateStmt, args...)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare resend profiles arguments")
}
_, err = tx.ExecContext(ctx, updateStmt, args...)
if err != nil {
return ctxerr.Wrap(ctx, err, "execute resend profiles")
}
}
// Resend certificate templates that use affected variables.
certParams := map[string]any{
"host_ids": hostIDs,
"operation_type_install": fleet.MDMOperationTypeInstall,
"affected_vars": vars,
"cert_pending_status": fleet.CertificateTemplatePending,
}
certStmt, certArgs, err := sqlx.Named(certTemplateUpdateStatusQuery, certParams)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare resend certificate templates replace names")
}
certStmt, certArgs, err = sqlx.In(certStmt, certArgs...)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare resend certificate templates arguments")
}
if _, err = tx.ExecContext(ctx, certStmt, certArgs...); err != nil {
return ctxerr.Wrap(ctx, err, "execute resend certificate templates")
}
// Queue make_android_app_available jobs for managed app configs that use affected variables,
// scoped to the teams of the affected hosts.
if err := queueManagedConfigResendJobs(ctx, tx, hostIDs, vars); err != nil {
return ctxerr.Wrap(ctx, err, "queue managed config resend jobs")
}
// Re-enqueue host name templates that use an affected IdP variable.
if err := triggerResendDeviceNamesForIDPChange(ctx, tx, hostIDs, affectedVars); err != nil {
return ctxerr.Wrap(ctx, err, "resend host name templates for idp change")
}
return nil
}
// triggerResendDeviceNamesForIDPChange re-queues host-name enforcement rows so the
// device-name cron re-resolves with the updated IdP value and enqueues a fresh
// DeviceName command.
func triggerResendDeviceNamesForIDPChange(ctx context.Context, tx sqlx.ExtContext, hostIDs []uint, affectedVars []fleet.FleetVarName) error {
if len(hostIDs) == 0 {
return nil
}
// Restrict to the affected variables that are actually supported in host name
// templates.
varNames := make([]string, 0, len(affectedVars))
for _, v := range affectedVars {
if fleet.IsHostNameTemplateIDPVar(string(v)) {
varNames = append(varNames, string(v))
}
}
if len(varNames) == 0 {
return nil
}
// A host's governing template is its team template, or the global "No team"
// template when it has no team. Match it against the changed variables with a
// single alternation (the names are [A-Z_], safe to embed in the pattern); the
// pattern value is bound as a parameter.
const selectStmt = `
SELECT h.id
FROM hosts h
LEFT JOIN teams t ON t.id = h.team_id
WHERE h.id IN (?)
AND COALESCE(CASE WHEN h.team_id IS NULL
THEN ` + deviceNameNoTeamTemplateExpr + `
ELSE t.config->>'$.mdm.name_template' END, '') REGEXP ?`
// The trailing word boundary keeps a changed HOST_END_USER_IDP_USERNAME from
// matching a template that only uses HOST_END_USER_IDP_USERNAME_LOCAL_PART, the
// same guard the secret-change path uses.
stmt, args, err := sqlx.In(selectStmt, hostIDs, "FLEET_VAR_("+strings.Join(varNames, "|")+`)\b`)
if err != nil {
return ctxerr.Wrap(ctx, err, "build select device name hosts for idp change")
}
var affectedHostIDs []uint
if err := sqlx.SelectContext(ctx, tx, &affectedHostIDs, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "select device name hosts for idp change")
}
if len(affectedHostIDs) == 0 {
return nil
}
return reconcileHostDeviceNamesForHostsDB(ctx, tx, affectedHostIDs)
}
// queueManagedConfigResendJobs finds android app configs that reference any of
// the affected fleet variables and inserts worker jobs to re-push the managed
// configuration with the updated values.
func queueManagedConfigResendJobs(ctx context.Context, tx sqlx.ExtContext, hostIDs []uint, affectedVars []any) error {
if len(hostIDs) == 0 {
return nil
}
// Find app configs that use any of the affected variables.
const findAffectedApps = `
SELECT DISTINCT
aac.application_id,
vat.id AS app_team_id
FROM
mdm_configuration_profile_variables mcpv
JOIN android_app_configurations aac
ON mcpv.android_app_configuration_id = aac.id
JOIN fleet_variables fv
ON mcpv.fleet_variable_id = fv.id
JOIN vpp_apps_teams vat
ON vat.adam_id = aac.application_id AND vat.global_or_team_id = aac.global_or_team_id AND vat.platform = 'android'
JOIN hosts h
ON aac.global_or_team_id = COALESCE(h.team_id, 0)
WHERE
fv.name IN (?) AND
h.id IN (?)
`
findStmt, findArgs, err := sqlx.In(findAffectedApps, affectedVars, hostIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare find affected app configs")
}
type affectedApp struct {
ApplicationID string `db:"application_id"`
AppTeamID uint `db:"app_team_id"`
}
var apps []affectedApp
if err := sqlx.SelectContext(ctx, tx, &apps, findStmt, findArgs...); err != nil {
return ctxerr.Wrap(ctx, err, "find affected app configs")
}
if len(apps) == 0 {
return nil
}
// Get the enterprise name from the DB.
var enterpriseID string
if err := sqlx.GetContext(ctx, tx, &enterpriseID, `SELECT enterprise_id FROM android_enterprises WHERE enterprise_id != '' LIMIT 1`); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// No enterprise configured — nothing to do.
return nil
}
return ctxerr.Wrap(ctx, err, "get android enterprise id")
}
enterpriseName := "enterprises/" + enterpriseID
// Insert a job for each affected app config.
const insertJob = `
INSERT INTO jobs (name, args, state, error)
VALUES (?, ?, 'queued', '')
`
for _, app := range apps {
args, err := json.Marshal(map[string]any{
"task": "make_android_app_available",
"application_id": app.ApplicationID,
"app_team_id": app.AppTeamID,
"enterprise_name": enterpriseName,
"app_config_changed": true,
})
if err != nil {
return ctxerr.Wrap(ctx, err, "marshal job args for managed config resend")
}
if _, err := tx.ExecContext(ctx, insertJob, "software_worker", json.RawMessage(args)); err != nil {
return ctxerr.Wrap(ctx, err, "insert managed config resend job")
}
}
return nil
}
// emailsRequireUpdate compares two slices of emails and returns true if they are different
// and require an update in the database.
func emailsRequireUpdate(currentEmails, newEmails []fleet.ScimUserEmail) bool {
if len(currentEmails) != len(newEmails) {
return true
}
// Create maps for efficient comparison
currentEmailMap := make(map[string]fleet.ScimUserEmail)
for i := range currentEmails {
key := currentEmails[i].GenerateComparisonKey()
currentEmailMap[key] = currentEmails[i]
}
// Check if all new emails exist in current emails with the same attributes
for i := range newEmails {
key := newEmails[i].GenerateComparisonKey()
if _, exists := currentEmailMap[key]; !exists {
return true
}
}
return false
}
// ScimUsersExist checks if all the provided SCIM user IDs exist in the datastore
// If the slice is empty, it returns true
// This method processes IDs in batches to handle large numbers of IDs efficiently
func (ds *Datastore) ScimUsersExist(ctx context.Context, ids []uint) (bool, error) {
if len(ids) == 0 {
return true, nil
}
// Create a map to track which IDs we've found
foundIDs := make(map[uint]bool, len(ids))
batchSize := 10000
err := common_mysql.BatchProcessSimple(ids, batchSize, func(batchIDs []uint) error {
query, args, err := sqlx.In(`
SELECT id
FROM scim_users
WHERE id IN (?)
`, batchIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare scim users exist batch query")
}
var foundBatchIDs []uint
err = sqlx.SelectContext(ctx, ds.reader(ctx), &foundBatchIDs, query, args...)
if err != nil {
return ctxerr.Wrap(ctx, err, "check if scim users exist in batch")
}
// Mark found IDs
for _, id := range foundBatchIDs {
foundIDs[id] = true
}
return nil
})
if err != nil {
return false, err
}
// Check if all IDs were found
for _, id := range ids {
if !foundIDs[id] {
return false, nil
}
}
return true, nil
}