// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package properties import ( "fmt" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/request" ) // MigrateBackfillCPADisplayName backfills the CPA display_name attribute on // every CPA PropertyField that is missing one (absent key or empty string). // // This is the only public entry point that performs writes to CPA fields // without going through the access-control layer. It does so deliberately: // the backfill is a one-shot system migration and the access-control layer // would otherwise reject writes against fields whose source plugin is not the // caller (e.g. UAS-managed CPA fields with attrs["protected"]=true). Confining // the bypass to this single, named, side-effect-bounded method avoids // introducing a general "skip access control" surface that other code could // reach for. // // The method is idempotent at the field level: fields that already have a // non-empty display_name are skipped. The caller (app/migrations.go) is // responsible for the System-key idempotency wrapper that prevents the whole // migration from running twice. // // Returns the number of fields that were backfilled and the number that were // skipped, so the caller can log a summary. func (ps *PropertyService) MigrateBackfillCPADisplayName(rctx request.CTX) (backfilled int, skipped int, err error) { group, err := ps.Group(model.AccessControlPropertyGroupName) if err != nil { return 0, 0, fmt.Errorf("MigrateBackfillCPADisplayName: failed to get CPA property group: %w", err) } groupID := group.ID const cpaFieldLimit = 20 var fieldsToUpdate []*model.PropertyField // Use the unexported searchPropertyFields to bypass access control. // AC would filter out (or strip options from) protected fields when // the caller is not the source plugin, which would corrupt the // fields we then try to write back. CPA creation is capped at 20 // active fields, so a single page covers the full migration scope. fields, searchErr := ps.searchPropertyFields(groupID, model.PropertyFieldSearchOpts{ PerPage: cpaFieldLimit, }) if searchErr != nil { return 0, 0, fmt.Errorf("MigrateBackfillCPADisplayName: failed to search CPA fields: %w", searchErr) } for _, pf := range fields { cpaField, convErr := model.NewCPAFieldFromPropertyField(pf) if convErr != nil { return 0, 0, fmt.Errorf("MigrateBackfillCPADisplayName: failed to convert property field %q: %w", pf.ID, convErr) } // Backfill if display_name is absent OR empty-string. This covers // fields created before display_name existed, fields created after // without an explicit display_name (stored as ""), and fields // patched with display_name="". if cpaField.Attrs.DisplayName != "" { skipped++ continue } cpaField.Attrs.DisplayName = cpaField.Name fieldsToUpdate = append(fieldsToUpdate, cpaField.ToPropertyField()) } if len(fieldsToUpdate) > 0 { // Use the unexported updatePropertyFields for the same reason as // searchPropertyFields above: the AC layer rejects writes from the // system to fields owned by a source plugin. if _, _, _, updateErr := ps.updatePropertyFields(rctx, groupID, fieldsToUpdate); updateErr != nil { return 0, 0, fmt.Errorf("MigrateBackfillCPADisplayName: failed to update CPA fields: %w", updateErr) } } return len(fieldsToUpdate), skipped, nil }