Adds authorization test cases for the creator permission level

This commit is contained in:
Miguel de la Cruz
2026-08-26 17:02:10 +02:00
parent d3cb63aadf
commit 9a4c047b33
3 changed files with 445 additions and 34 deletions
+239
View File
@@ -631,6 +631,30 @@ func TestCreatePropertyField(t *testing.T) {
})
})
t.Run("admin can set permission level=creator on a post-object field", func(t *testing.T) {
creatorLevel := model.PermissionLevelCreator
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "channel",
TargetID: th.BasicChannel.Id,
PermissionField: &creatorLevel,
PermissionValues: &creatorLevel,
PermissionOptions: &creatorLevel,
}
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.NotNil(t, createdField.PermissionField)
require.Equal(t, model.PermissionLevelCreator, *createdField.PermissionField)
require.NotNil(t, createdField.PermissionValues)
require.Equal(t, model.PermissionLevelCreator, *createdField.PermissionValues)
require.NotNil(t, createdField.PermissionOptions)
require.Equal(t, model.PermissionLevelCreator, *createdField.PermissionOptions)
})
t.Run("permission level=creator on a user-object field should fail", func(t *testing.T) {
// User-object fields have no entity creator, so the level is rejected
// rather than silently resolving to admin.
@@ -909,6 +933,123 @@ func TestCreatePropertyField(t *testing.T) {
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
t.Run("non-admin cannot pin the creator permission level", func(t *testing.T) {
// createPropertyField pins all three slots to the object type's default
// for non-admin callers, so a submitted creator level is discarded
// rather than honoured.
th.LoginBasic(t)
creatorLevel := model.PermissionLevelCreator
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "channel",
TargetID: th.BasicChannel.Id,
PermissionField: &creatorLevel,
PermissionValues: &creatorLevel,
PermissionOptions: &creatorLevel,
}
created, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.Equal(t, model.PermissionLevelMember, *created.PermissionField)
require.Equal(t, model.PermissionLevelMember, *created.PermissionValues)
require.Equal(t, model.PermissionLevelMember, *created.PermissionOptions)
})
t.Run("channel admin can create the PostAttributes field shape", func(t *testing.T) {
// The motivating use case, end to end: a channel admin defines a post
// attribute on their channel whose field and options only the
// channel's admins may manage, and whose values each post's author may
// set on their own post.
channelAdmin := th.CreateUser(t)
th.LinkUserToTeam(t, channelAdmin, th.BasicTeam)
_, appErr := th.App.AddUserToChannel(th.Context, channelAdmin, th.BasicChannel, false)
require.Nil(t, appErr)
_, appErr = th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, channelAdmin.Id,
model.ChannelUserRoleId+" "+model.ChannelAdminRoleId)
require.Nil(t, appErr)
client := th.CreateClient()
_, _, err := client.Login(context.Background(), channelAdmin.Email, channelAdmin.Password)
require.NoError(t, err)
adminLevel := model.PermissionLevelAdmin
creatorLevel := model.PermissionLevelCreator
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
TargetType: "channel",
TargetID: th.BasicChannel.Id,
PermissionField: &adminLevel,
PermissionValues: &creatorLevel,
PermissionOptions: &adminLevel,
}
created, resp, err := client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionField)
require.Equal(t, model.PermissionLevelCreator, *created.PermissionValues)
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionOptions)
// Re-read: the pin happens before the store write, so echoing the
// request body back would look identical to a genuine persist.
fields, resp, err := client.GetPropertyFields(context.Background(), group.Name, "post", model.PropertyFieldSearch{
TargetType: "channel",
TargetID: th.BasicChannel.Id,
PerPage: 200,
})
require.NoError(t, err)
CheckOKStatus(t, resp)
var fetched *model.PropertyField
for _, f := range fields {
if f.ID == created.ID {
fetched = f
break
}
}
require.NotNil(t, fetched, "the created field should be readable back")
require.Equal(t, model.PermissionLevelAdmin, *fetched.PermissionField)
require.Equal(t, model.PermissionLevelCreator, *fetched.PermissionValues)
require.Equal(t, model.PermissionLevelAdmin, *fetched.PermissionOptions)
})
t.Run("system admin can create the PostAttributes field shape", func(t *testing.T) {
// The motivating use case, as it can actually be provisioned today:
// field and options managed by the channel's admins, values settable by
// each post's author.
adminLevel := model.PermissionLevelAdmin
creatorLevel := model.PermissionLevelCreator
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
TargetType: "channel",
TargetID: th.BasicChannel.Id,
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []map[string]any{
{"name": "confidential"},
{"name": "public"},
},
},
PermissionField: &adminLevel,
PermissionValues: &creatorLevel,
PermissionOptions: &adminLevel,
}
created, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionField)
require.Equal(t, model.PermissionLevelCreator, *created.PermissionValues)
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionOptions)
})
}
func TestGetPropertyFields(t *testing.T) {
@@ -2935,6 +3076,55 @@ func TestPatchPropertyField(t *testing.T) {
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
t.Run("field creator can patch their own field", func(t *testing.T) {
creatorLevel := model.PermissionLevelCreator
field, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "channel",
TargetID: th.BasicChannel.Id,
CreatedBy: th.BasicUser.Id,
PermissionField: &creatorLevel,
PermissionValues: &sysadminLevel,
PermissionOptions: &creatorLevel,
}, false, "")
require.Nil(t, appErr)
th.LoginBasic(t)
newName := model.NewId()
patched, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", field.ID, &model.PropertyFieldPatch{Name: &newName})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Equal(t, newName, patched.Name)
})
t.Run("non-creator member cannot patch a creator-gated field", func(t *testing.T) {
creatorLevel := model.PermissionLevelCreator
field, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "channel",
TargetID: th.BasicChannel.Id,
CreatedBy: th.BasicUser.Id,
PermissionField: &creatorLevel,
PermissionValues: &sysadminLevel,
PermissionOptions: &creatorLevel,
}, false, "")
require.Nil(t, appErr)
th.LoginBasic2(t)
newName := model.NewId()
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", field.ID, &model.PropertyFieldPatch{Name: &newName})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}
func TestDeletePropertyField(t *testing.T) {
@@ -3157,6 +3347,55 @@ func TestDeletePropertyField(t *testing.T) {
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
t.Run("field creator can delete their own field", func(t *testing.T) {
// permission_field backs this handler as well as the patch handler, so
// the creator level covers deletion too. Intended, and pinned here so
// it is not mistaken for a bug and "fixed" later.
creatorLevel := model.PermissionLevelCreator
field, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "channel",
TargetID: th.BasicChannel.Id,
CreatedBy: th.BasicUser.Id,
PermissionField: &creatorLevel,
PermissionValues: &sysadminLevel,
PermissionOptions: &creatorLevel,
}, false, "")
require.Nil(t, appErr)
th.LoginBasic(t)
resp, err := th.Client.DeletePropertyField(context.Background(), group.Name, "post", field.ID)
require.NoError(t, err)
CheckOKStatus(t, resp)
})
t.Run("non-creator member cannot delete a creator-gated field", func(t *testing.T) {
creatorLevel := model.PermissionLevelCreator
field, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "channel",
TargetID: th.BasicChannel.Id,
CreatedBy: th.BasicUser.Id,
PermissionField: &creatorLevel,
PermissionValues: &sysadminLevel,
PermissionOptions: &creatorLevel,
}, false, "")
require.Nil(t, appErr)
th.LoginBasic2(t)
resp, err := th.Client.DeletePropertyField(context.Background(), group.Name, "post", field.ID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}
func TestIsOptionsOnlyPatch(t *testing.T) {
+9
View File
@@ -679,6 +679,15 @@ func (a *App) hasPropertyFieldPermissionLevel(rctx request.CTX, userID string, f
case string(model.PropertyFieldTargetLevelChannel):
return a.hasChannelPropertyAdmin(rctx, userID, field.TargetID)
}
case model.PermissionLevelCreator:
// The entity gated here is the field definition itself, so its creator
// is CreatedBy — not the creator of whatever the field is scoped to.
if field.CreatedBy != "" && field.CreatedBy == userID {
return true
}
// Falls back to the admin arm above rather than restating the
// per-TargetType cascade. Terminates: the admin case never recurses.
return a.hasPropertyFieldPermissionLevel(rctx, userID, field, model.PermissionLevelAdmin)
}
return false
}
+197 -34
View File
@@ -3019,7 +3019,15 @@ func TestSessionHasPermissionToSetPropertyFieldValues_PostCreator(t *testing.T)
PermissionOptions: model.NewPointer(model.PermissionLevelSysadmin),
}
post := th.CreatePost(t, th.BasicChannel) // authored by BasicUser
// The author must hold no admin role in the post's channel, or this test
// would pass through the admin arm and prove nothing about the creator arm.
// th.BasicUser is unsuitable: CreateChannel grants SchemeAdmin to whoever
// creates a channel, so BasicUser is a channel admin of BasicChannel.
author := th.CreateUser(t)
th.LinkUserToTeam(t, author, th.BasicTeam)
th.AddUserToChannel(t, author, th.BasicChannel)
post := th.CreatePost(t, th.BasicChannel, func(p *model.Post) { p.UserId = author.Id })
// BasicUser2 is a team member but not a channel member out of InitBasic, so
// add them: denials should land on "in the channel but not the author"
@@ -3066,7 +3074,7 @@ func TestSessionHasPermissionToSetPropertyFieldValues_PostCreator(t *testing.T)
}{
{
name: "author can set values on their own post",
session: session(th.BasicUser),
session: session(author),
postID: post.Id,
allowed: true,
},
@@ -3104,7 +3112,7 @@ func TestSessionHasPermissionToSetPropertyFieldValues_PostCreator(t *testing.T)
},
{
name: "unknown post denies",
session: session(th.BasicUser),
session: session(author),
postID: model.NewId(),
allowed: false,
},
@@ -3171,12 +3179,19 @@ func TestSessionHasPermissionToSetPropertyFieldValues_ChannelCreator(t *testing.
require.Equal(t, th.BasicUser.Id, th.BasicChannel.CreatorId, "BasicChannel is expected to be created by BasicUser")
// CreateChannel grants SchemeAdmin to whoever creates a channel, so
// BasicUser starts out a channel admin of BasicChannel — which would let
// the creator rows below pass through the admin arm and prove nothing.
// Demote to a plain member so CreatorId is the only thing that can grant.
_, appErr := th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id, model.ChannelUserRoleId)
require.Nil(t, appErr)
th.AddUserToChannel(t, th.BasicUser2, th.BasicChannel)
channelAdmin := th.CreateUser(t)
th.LinkUserToTeam(t, channelAdmin, th.BasicTeam)
th.AddUserToChannel(t, channelAdmin, th.BasicChannel)
_, appErr := th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, channelAdmin.Id,
_, appErr = th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, channelAdmin.Id,
model.ChannelUserRoleId+" "+model.ChannelAdminRoleId)
require.Nil(t, appErr)
@@ -3278,10 +3293,12 @@ func TestSessionHasPermissionToSetPropertyFieldValues_ChannelCreator(t *testing.
allowed: true,
},
{
name: "the other participant in a direct channel is not its creator",
// Not the DM's creator, but DM participants administer their own
// DM (hasChannelPropertyAdmin), so this passes the admin arm.
name: "the other participant in a direct channel passes via the admin arm",
session: session(th.BasicUser2),
channelID: dmChannel.Id,
allowed: false,
allowed: true,
},
{
name: "system admin can set values on a direct channel",
@@ -3290,10 +3307,14 @@ func TestSessionHasPermissionToSetPropertyFieldValues_ChannelCreator(t *testing.
allowed: true,
},
{
name: "a group channel has no creator, so no participant qualifies",
// A group channel genuinely has no CreatorId, so nobody can pass
// the creator arm here — but its participants administer it, so a
// member still passes the admin arm. The creator arm is isolated
// by the empty-caller row below, which is not a member.
name: "a group channel has no creator, so a participant passes only via the admin arm",
session: session(th.BasicUser),
channelID: gmChannel.Id,
allowed: false,
allowed: true,
},
{
name: "empty caller denies",
@@ -3320,37 +3341,179 @@ func TestSessionHasPermissionToSetPropertyFieldValues_ChannelCreator(t *testing.
}
}
// TestPropertyFieldCreatorLevelIsFailClosed pins the intermediate state while
// the creator level is being landed. The values slot now resolves (see
// TestSessionHasPermissionToSetPropertyFieldValues_PostCreator), but the field
// and options dispatcher has no creator case yet, so a field gating its own
// definition on creator grants nothing.
//
// Flip these expectations to true when the field/options resolver lands. Until
// then this also catches a permissive `default:` branch being added to
// hasPropertyFieldPermissionLevel.
func TestPropertyFieldCreatorLevelIsFailClosed(t *testing.T) {
func TestPropertyFieldCreatorLevel_FieldAndOptions(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
groupID := registerTestPropertyGroup(t, th)
authorSession := model.Session{UserId: th.BasicUser.Id, Roles: model.SystemUserRoleId}
field := &model.PropertyField{
ID: model.NewId(),
GroupID: groupID,
Name: "post field creator",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypePost,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: th.BasicChannel.Id,
CreatedBy: th.BasicUser.Id,
PermissionField: model.NewPointer(model.PermissionLevelCreator),
PermissionValues: model.NewPointer(model.PermissionLevelSysadmin),
PermissionOptions: model.NewPointer(model.PermissionLevelCreator),
// For the field and options slots the entity is the field itself, so the
// creator is its CreatedBy — not the creator of whatever the field is
// scoped to. ObjectType stays post because validation only permits the
// creator level on post- and channel-object fields.
fieldFor := func(target model.PropertyFieldTargetLevel, targetID, createdBy string) *model.PropertyField {
return &model.PropertyField{
ID: model.NewId(),
GroupID: groupID,
Name: "creator field " + string(target),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypePost,
TargetType: string(target),
TargetID: targetID,
CreatedBy: createdBy,
PermissionField: model.NewPointer(model.PermissionLevelCreator),
PermissionValues: model.NewPointer(model.PermissionLevelSysadmin),
PermissionOptions: model.NewPointer(model.PermissionLevelCreator),
}
}
assert.False(t, th.App.SessionHasPermissionToEditPropertyField(th.Context, authorSession, field))
assert.False(t, th.App.SessionHasPermissionToManagePropertyFieldOptions(th.Context, authorSession, field))
// The creator must hold no admin role in any of the three scopes, or the
// creator rows below would also pass through the admin arm and prove
// nothing. th.BasicUser is unsuitable: CreateChannel grants SchemeAdmin to
// whoever creates a channel, so BasicUser is a channel admin of
// BasicChannel.
fieldCreator := th.CreateUser(t)
th.LinkUserToTeam(t, fieldCreator, th.BasicTeam)
th.AddUserToChannel(t, fieldCreator, th.BasicChannel)
channelField := fieldFor(model.PropertyFieldTargetLevelChannel, th.BasicChannel.Id, fieldCreator.Id)
teamField := fieldFor(model.PropertyFieldTargetLevelTeam, th.BasicTeam.Id, fieldCreator.Id)
systemField := fieldFor(model.PropertyFieldTargetLevelSystem, "", fieldCreator.Id)
// Plugin-created, migration-created and mmctl --local fields all carry an
// empty CreatedBy, so such a field must fall back to admin-only rather
// than matching anybody.
orphanField := fieldFor(model.PropertyFieldTargetLevelChannel, th.BasicChannel.Id, "")
th.AddUserToChannel(t, th.BasicUser2, th.BasicChannel)
channelAdmin := th.CreateUser(t)
th.LinkUserToTeam(t, channelAdmin, th.BasicTeam)
th.AddUserToChannel(t, channelAdmin, th.BasicChannel)
_, appErr := th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, channelAdmin.Id,
model.ChannelUserRoleId+" "+model.ChannelAdminRoleId)
require.Nil(t, appErr)
teamAdmin := th.CreateUser(t)
th.LinkUserToTeam(t, teamAdmin, th.BasicTeam)
_, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdmin.Id,
model.TeamUserRoleId+" "+model.TeamAdminRoleId)
require.Nil(t, appErr)
session := func(u *model.User) model.Session {
return model.Session{UserId: u.Id, Roles: model.SystemUserRoleId}
}
testCases := []struct {
name string
session model.Session
field *model.PropertyField
allowed bool
}{
{
name: "field creator can edit their own channel-scoped field",
session: session(fieldCreator),
field: channelField,
allowed: true,
},
{
name: "another channel member cannot",
session: session(th.BasicUser2),
field: channelField,
allowed: false,
},
{
name: "channel admin can edit a field they did not create",
session: session(channelAdmin),
field: channelField,
allowed: true,
},
{
name: "team admin can edit a channel-scoped field",
session: session(teamAdmin),
field: channelField,
allowed: true,
},
{
name: "system admin can edit a channel-scoped field",
session: session(th.SystemAdminUser),
field: channelField,
allowed: true,
},
{
name: "field creator can edit their own team-scoped field",
session: session(fieldCreator),
field: teamField,
allowed: true,
},
{
name: "plain team member cannot edit a team-scoped field",
session: session(th.BasicUser2),
field: teamField,
allowed: false,
},
{
name: "team admin can edit a team-scoped field they did not create",
session: session(teamAdmin),
field: teamField,
allowed: true,
},
{
// HasPermissionToTeam falls back to the system check, so a sysadmin
// passes without being a team member at all.
name: "system admin can edit a team-scoped field",
session: session(th.SystemAdminUser),
field: teamField,
allowed: true,
},
{
name: "field creator can edit their own system-scoped field",
session: session(fieldCreator),
field: systemField,
allowed: true,
},
{
name: "non-admin cannot edit a system-scoped field they did not create",
session: session(th.BasicUser2),
field: systemField,
allowed: false,
},
{
name: "system admin can edit a system-scoped field they did not create",
session: session(th.SystemAdminUser),
field: systemField,
allowed: true,
},
{
name: "a field with no recorded creator falls back to admin only",
session: session(fieldCreator),
field: orphanField,
allowed: false,
},
{
name: "channel admin can still edit a field with no recorded creator",
session: session(channelAdmin),
field: orphanField,
allowed: true,
},
{
// The empty-creator guard: the field has CreatedBy "" and this
// caller has UserId "". Comparing them with bare equality would
// match and grant access, so the guard must reject an empty creator
// before comparing.
name: "empty caller does not match a field with no recorded creator",
session: model.Session{},
field: orphanField,
allowed: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Both slots are pinned to creator, so edit and manage-options must
// resolve identically; they share the same dispatcher.
assert.Equal(t, tc.allowed, th.App.SessionHasPermissionToEditPropertyField(th.Context, tc.session, tc.field), "edit")
assert.Equal(t, tc.allowed, th.App.SessionHasPermissionToManagePropertyFieldOptions(th.Context, tc.session, tc.field), "manage options")
})
}
}