mirror of
https://github.com/mattermost/mattermost.git
synced 2026-11-03 12:48:37 -06:00
Make CPA create/patch/delete template-aware
createCPAField now creates a template field (canonical schema) plus a linked user field, returning the user field ID for backwards compat. patchCPAField routes schema attrs (options, ldap, saml) through the template for linked fields; type changes unlink + delete the template first to work around the service-layer constraint that blocks template type changes while dependents exist. deleteCPAField cleans up the linked template after removing the user field. mmctl: remove the hardcoded TargetType:"user" from cpaFieldCreateCmdF now that the server stamps both fields authoritatively. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e4c923eb2
commit
8cc7789213
2 files changed
+207
-43
No files matched your search
@@ -18,6 +18,40 @@ import (
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app"
|
||||
)
|
||||
|
||||
// cpaUserFieldAttrs are the attrs that belong on the user-linked field rather
|
||||
// than the template. All other CPA attrs are schema-level and live on the
|
||||
// template; these three control how the field is displayed and who can set it.
|
||||
var cpaUserFieldAttrs = map[string]struct{}{
|
||||
model.PropertyFieldAttrVisibility: {},
|
||||
model.PropertyFieldAttrManaged: {},
|
||||
model.PropertyFieldAttrSortOrder: {},
|
||||
}
|
||||
|
||||
// splitCPAAttrs partitions a CPA attrs map into the schema portion (goes on
|
||||
// the template) and the display portion (stays on the user-linked field).
|
||||
// display_name and value_type are included in both: the template carries the
|
||||
// canonical value, and the user field keeps its own copy for backward
|
||||
// compatibility with callers that read user-field attrs directly.
|
||||
func splitCPAAttrs(attrs model.StringInterface) (schema, display model.StringInterface) {
|
||||
schema = make(model.StringInterface)
|
||||
display = make(model.StringInterface)
|
||||
for k, v := range attrs {
|
||||
if _, isUserOnly := cpaUserFieldAttrs[k]; isUserOnly {
|
||||
display[k] = v
|
||||
} else {
|
||||
schema[k] = v
|
||||
}
|
||||
}
|
||||
// Copy display_name and value_type into the display map so the user field
|
||||
// keeps them even though they are also on the template.
|
||||
for _, k := range []string{model.PropertyFieldAttrDisplayName, model.PropertyFieldAttrValueType} {
|
||||
if v, ok := attrs[k]; ok {
|
||||
display[k] = v
|
||||
}
|
||||
}
|
||||
return schema, display
|
||||
}
|
||||
|
||||
func (api *API) InitCustomProfileAttributes() {
|
||||
api.BaseRoutes.CustomProfileAttributesFields.Handle("", api.APISessionRequired(listCPAFields)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.CustomProfileAttributesFields.Handle("", api.APISessionRequired(createCPAField)).Methods(http.MethodPost)
|
||||
@@ -71,62 +105,90 @@ func createCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "property_field", pf)
|
||||
|
||||
// CPA fields are system-scoped; only a system administrator may create
|
||||
// them. This mirrors the scope-based permission check the shared generic
|
||||
// handler enforces for system-typed fields.
|
||||
// CPA fields are system-scoped; only a system administrator may create them.
|
||||
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
}
|
||||
|
||||
// Translate to PropertyField and route through the generic property API.
|
||||
// Server-controlled fields (group, type, target shape, creator) are
|
||||
// stamped here; ID/TargetID/Protected are stripped so a caller can't
|
||||
// inject them. Permissions and timestamps are filled in by lower layers.
|
||||
field := pf.ToPropertyField()
|
||||
group, appErr := c.App.GetPropertyGroup(c.AppContext, model.AccessControlPropertyGroupName)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
field.ID = ""
|
||||
field.GroupID = group.ID
|
||||
field.ObjectType = model.PropertyFieldObjectTypeUser
|
||||
field.TargetType = string(model.PropertyFieldTargetLevelSystem)
|
||||
field.TargetID = ""
|
||||
field.Protected = false
|
||||
field.CreatedBy = c.AppContext.Session().UserId
|
||||
field.UpdatedBy = c.AppContext.Session().UserId
|
||||
|
||||
// Nil-fill permission levels the generic property API would otherwise pin
|
||||
// (createPropertyField in properties.go): without these, PermissionValues
|
||||
// stays nil and SessionHasPermissionToSetPropertyFieldValues denies
|
||||
// everyone -- including sysadmin -- from ever setting this field's value.
|
||||
// Values uses its own default (DefaultPropertyFieldValuesPermissionLevel):
|
||||
// unlike Field/Options, a value is gated by its own target rather than the
|
||||
// field's TargetType, so the system-TargetType sysadmin clause doesn't apply.
|
||||
defaultLevel := app.DefaultPropertyFieldPermissionLevel(field)
|
||||
defaultValuesLevel := app.DefaultPropertyFieldValuesPermissionLevel(field)
|
||||
if field.PermissionField == nil {
|
||||
field.PermissionField = &defaultLevel
|
||||
}
|
||||
if field.PermissionValues == nil {
|
||||
field.PermissionValues = &defaultValuesLevel
|
||||
}
|
||||
if field.PermissionOptions == nil {
|
||||
field.PermissionOptions = &defaultLevel
|
||||
}
|
||||
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
callerID := c.AppContext.Session().UserId
|
||||
|
||||
createdField, appErr := c.App.CreatePropertyField(rctx, field, false, connectionID)
|
||||
rawField := pf.ToPropertyField()
|
||||
schemaAttrs, displayAttrs := splitCPAAttrs(rawField.Attrs)
|
||||
|
||||
// Step 1: create the template — the canonical schema definition.
|
||||
tmplField := &model.PropertyField{
|
||||
GroupID: group.ID,
|
||||
Name: pf.Name,
|
||||
Type: rawField.Type,
|
||||
ObjectType: model.PropertyFieldObjectTypeTemplate,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
TargetID: "",
|
||||
Protected: false,
|
||||
CreatedBy: callerID,
|
||||
UpdatedBy: callerID,
|
||||
Attrs: schemaAttrs,
|
||||
}
|
||||
defaultLevel := app.DefaultPropertyFieldPermissionLevel(tmplField)
|
||||
if tmplField.PermissionField == nil {
|
||||
tmplField.PermissionField = &defaultLevel
|
||||
}
|
||||
if tmplField.PermissionOptions == nil {
|
||||
tmplField.PermissionOptions = &defaultLevel
|
||||
}
|
||||
|
||||
createdTmpl, appErr := c.App.CreatePropertyField(rctx, tmplField, false, connectionID)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
cpaField, convErr := model.NewCPAFieldFromPropertyField(createdField)
|
||||
// Step 2: create the linked user field that binds the template to Users.
|
||||
// The app layer copies Type, options, ldap, and saml from the template.
|
||||
userField := &model.PropertyField{
|
||||
GroupID: group.ID,
|
||||
Name: pf.Name,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
TargetID: "",
|
||||
LinkedFieldID: &createdTmpl.ID,
|
||||
Protected: false,
|
||||
CreatedBy: callerID,
|
||||
UpdatedBy: callerID,
|
||||
Attrs: displayAttrs,
|
||||
}
|
||||
// PermissionValues governs who can set values; unlike Field/Options it uses
|
||||
// a per-object-type default because the system-TargetType sysadmin clause
|
||||
// does not apply to value writes.
|
||||
defaultValuesLevel := app.DefaultPropertyFieldValuesPermissionLevel(userField)
|
||||
userFieldPermLevel := app.DefaultPropertyFieldPermissionLevel(userField)
|
||||
if userField.PermissionField == nil {
|
||||
userField.PermissionField = &userFieldPermLevel
|
||||
}
|
||||
if userField.PermissionValues == nil {
|
||||
userField.PermissionValues = &defaultValuesLevel
|
||||
}
|
||||
if userField.PermissionOptions == nil {
|
||||
userField.PermissionOptions = &userFieldPermLevel
|
||||
}
|
||||
|
||||
createdUserField, appErr := c.App.CreatePropertyField(rctx, userField, false, connectionID)
|
||||
if appErr != nil {
|
||||
// Best-effort rollback: remove the orphaned template.
|
||||
_ = c.App.DeletePropertyField(rctx, group.ID, createdTmpl.ID, false, connectionID)
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
cpaField, convErr := model.NewCPAFieldFromPropertyField(createdUserField)
|
||||
if convErr != nil {
|
||||
c.Err = model.NewAppError("createCPAField", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(convErr)
|
||||
return
|
||||
@@ -224,9 +286,89 @@ func patchCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
auditRec.AddEventPriorState(&orig)
|
||||
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
|
||||
// If this user field is linked to a template, route schema changes to the
|
||||
// template first. Linked user fields cannot have their Type or options
|
||||
// changed directly — those are inherited from the template.
|
||||
if existingField.LinkedFieldID != nil && *existingField.LinkedFieldID != "" {
|
||||
typeIsChanging := patch.Type != nil && *patch.Type != existingField.Type
|
||||
|
||||
if typeIsChanging {
|
||||
// Type changes cannot go through the template while the linked user field
|
||||
// exists (the service blocks template type changes when count > 0). Instead:
|
||||
// 1. Unlink the user field (allowed at any time).
|
||||
// 2. Delete the now-orphaned template.
|
||||
// 3. Fall through to apply the full patch (including Type) to the
|
||||
// now-unlinked user field — normal update rules apply.
|
||||
templateID := *existingField.LinkedFieldID
|
||||
|
||||
unlinkField := *existingField
|
||||
if existingField.Attrs != nil {
|
||||
unlinkField.Attrs = maps.Clone(existingField.Attrs)
|
||||
}
|
||||
emptyStr := ""
|
||||
unlinkField.LinkedFieldID = &emptyStr
|
||||
unlinkField.UpdatedBy = c.AppContext.Session().UserId
|
||||
if _, _, unlinkErr := c.App.UpdatePropertyField(rctx, group.ID, &unlinkField, false, connectionID); unlinkErr != nil {
|
||||
c.Err = unlinkErr
|
||||
return
|
||||
}
|
||||
existingField.LinkedFieldID = nil
|
||||
|
||||
_ = c.App.DeletePropertyField(rctx, group.ID, templateID, false, connectionID)
|
||||
// Fall through: patch (including Type) is now applied to the unlinked user field.
|
||||
} else {
|
||||
tmpl, tmplErr := c.App.GetPropertyField(rctx, group.ID, *existingField.LinkedFieldID)
|
||||
if tmplErr != nil {
|
||||
c.Err = tmplErr
|
||||
return
|
||||
}
|
||||
|
||||
// Build a template-specific patch: schema attrs only (options, ldap, saml,
|
||||
// display_name, value_type, …). Display attrs (visibility, managed,
|
||||
// sort_order) stay on the user field.
|
||||
tmplPatch := &model.PropertyFieldPatch{
|
||||
Name: patch.Name,
|
||||
Type: patch.Type,
|
||||
}
|
||||
if patch.Attrs != nil {
|
||||
schemaAttrs, _ := splitCPAAttrs(*patch.Attrs)
|
||||
if len(schemaAttrs) > 0 {
|
||||
tmplPatch.Attrs = &schemaAttrs
|
||||
}
|
||||
}
|
||||
|
||||
if tmplPatch.Name != nil || tmplPatch.Type != nil || tmplPatch.Attrs != nil {
|
||||
tmpl.Patch(tmplPatch, true)
|
||||
tmpl.UpdatedBy = c.AppContext.Session().UserId
|
||||
if _, _, tmplUpdateErr := c.App.UpdatePropertyField(rctx, group.ID, tmpl, false, connectionID); tmplUpdateErr != nil {
|
||||
c.Err = tmplUpdateErr
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Strip options from the user-field patch: the linked field inherits
|
||||
// options from the template and cannot carry its own.
|
||||
if patch.Attrs != nil {
|
||||
displayOnly := maps.Clone(*patch.Attrs)
|
||||
delete(displayOnly, model.PropertyFieldAttributeOptions)
|
||||
delete(displayOnly, model.PropertyFieldAttributeOptionsCount)
|
||||
delete(displayOnly, model.PropertyFieldAttributeOptionsOmitted)
|
||||
if len(displayOnly) > 0 {
|
||||
patch.Attrs = &displayOnly
|
||||
} else {
|
||||
patch.Attrs = nil
|
||||
}
|
||||
}
|
||||
// Type is inherited from the template; do not attempt to set it on the
|
||||
// linked field (it would conflict with the stored value until a refresh).
|
||||
patch.Type = nil
|
||||
}
|
||||
}
|
||||
|
||||
existingField.Patch(patch, true)
|
||||
existingField.UpdatedBy = c.AppContext.Session().UserId
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
|
||||
updatedField, clearedIDs, updateErr := c.App.UpdatePropertyField(rctx, group.ID, existingField, false, connectionID)
|
||||
if updateErr != nil {
|
||||
@@ -293,11 +435,32 @@ func deleteCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
|
||||
// Capture the template ID before deleting the user field; the template
|
||||
// cannot be deleted while it still has active linked dependents.
|
||||
templateID := ""
|
||||
if existingField.LinkedFieldID != nil && *existingField.LinkedFieldID != "" {
|
||||
templateID = *existingField.LinkedFieldID
|
||||
}
|
||||
|
||||
if deleteErr := c.App.DeletePropertyField(rctx, group.ID, c.Params.FieldId, false, connectionID); deleteErr != nil {
|
||||
c.Err = deleteErr
|
||||
return
|
||||
}
|
||||
|
||||
// Now that the linked user field is gone, delete its template too.
|
||||
if templateID != "" {
|
||||
if deleteErr := c.App.DeletePropertyField(rctx, group.ID, templateID, false, connectionID); deleteErr != nil {
|
||||
// Log but do not fail: the user field is already gone and the
|
||||
// template is now orphaned; the admin can clean it up via the
|
||||
// generic property API.
|
||||
c.Logger.Warn("Failed to delete CPA template after deleting linked user field",
|
||||
mlog.String("template_id", templateID),
|
||||
mlog.Err(deleteErr),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// CPA-specific websocket event (backward compat)
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldDeleted, "", "", "", nil, "")
|
||||
message.Add("field_id", c.Params.FieldId)
|
||||
|
||||
@@ -155,12 +155,13 @@ func cpaFieldCreateCmdF(c client.Client, cmd *cobra.Command, args []string) erro
|
||||
name := args[0]
|
||||
fieldType := args[1]
|
||||
|
||||
// Build PropertyField object
|
||||
// Build PropertyField object. TargetType is omitted here because the
|
||||
// server stamps it authoritatively; the CPA create endpoint now creates
|
||||
// both a template and a linked user field internally.
|
||||
field := &model.PropertyField{
|
||||
Name: name,
|
||||
Type: model.PropertyFieldType(fieldType),
|
||||
TargetType: "user", // CPA fields target users
|
||||
Attrs: make(model.StringInterface),
|
||||
Name: name,
|
||||
Type: model.PropertyFieldType(fieldType),
|
||||
Attrs: make(model.StringInterface),
|
||||
}
|
||||
|
||||
// Build attrs from flags
|
||||
|
||||
Reference in new issue
Block a user