chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:39 +08:00
commit a0df89c693
5252 changed files with 523444 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
package authorization
import (
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/slicesx"
)
// NewAdministratorsOnlyResourceControl will create a new administrators only resource control associated to the resource specified by the
// identifier and type parameters
func NewAdministratorsOnlyResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType) *portainer.ResourceControl {
return &portainer.ResourceControl{
Type: resourceType,
ResourceID: resourceIdentifier,
SubResourceIDs: []string{},
UserAccesses: []portainer.UserResourceAccess{},
TeamAccesses: []portainer.TeamResourceAccess{},
AdministratorsOnly: true,
Public: false,
System: false,
}
}
// NewPrivateResourceControl will create a new private resource control associated to the resource specified by the
// identifier and type parameters. It automatically assigns it to the user specified by the userID parameter.
func NewPrivateResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, userID portainer.UserID) *portainer.ResourceControl {
return &portainer.ResourceControl{
Type: resourceType,
ResourceID: resourceIdentifier,
SubResourceIDs: []string{},
UserAccesses: []portainer.UserResourceAccess{
{
UserID: userID,
AccessLevel: portainer.ReadWriteAccessLevel,
},
},
TeamAccesses: []portainer.TeamResourceAccess{},
AdministratorsOnly: false,
Public: false,
System: false,
}
}
// NewSystemResourceControl creates a new public resource control with the System flag set to true.
// These resource controls are not persisted and are created on the fly by the Portainer API.
func NewSystemResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType) *portainer.ResourceControl {
return &portainer.ResourceControl{
Type: resourceType,
ResourceID: resourceIdentifier,
SubResourceIDs: []string{},
UserAccesses: []portainer.UserResourceAccess{},
TeamAccesses: []portainer.TeamResourceAccess{},
AdministratorsOnly: false,
Public: true,
System: true,
}
}
// NewPublicResourceControl creates a new public resource control.
func NewPublicResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType) *portainer.ResourceControl {
return &portainer.ResourceControl{
Type: resourceType,
ResourceID: resourceIdentifier,
SubResourceIDs: []string{},
UserAccesses: []portainer.UserResourceAccess{},
TeamAccesses: []portainer.TeamResourceAccess{},
AdministratorsOnly: false,
Public: true,
System: false,
}
}
// NewRestrictedResourceControl creates a new resource control with user and team access restrictions.
func NewRestrictedResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, userIDs []portainer.UserID, teamIDs []portainer.TeamID) *portainer.ResourceControl {
userAccesses := make([]portainer.UserResourceAccess, 0)
teamAccesses := make([]portainer.TeamResourceAccess, 0)
for _, id := range userIDs {
access := portainer.UserResourceAccess{
UserID: id,
AccessLevel: portainer.ReadWriteAccessLevel,
}
userAccesses = append(userAccesses, access)
}
for _, id := range teamIDs {
access := portainer.TeamResourceAccess{
TeamID: id,
AccessLevel: portainer.ReadWriteAccessLevel,
}
teamAccesses = append(teamAccesses, access)
}
return &portainer.ResourceControl{
Type: resourceType,
ResourceID: resourceIdentifier,
SubResourceIDs: []string{},
UserAccesses: userAccesses,
TeamAccesses: teamAccesses,
AdministratorsOnly: false,
Public: false,
System: false,
}
}
// UserCanAccessResource validates that a user has permissions defined in the specified resource control
// based on their identifier and the team(s) they belong to.
func UserCanAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool {
if resourceControl == nil {
return false
}
for _, authorizedUserAccess := range resourceControl.UserAccesses {
if userID == authorizedUserAccess.UserID {
return true
}
}
for _, authorizedTeamAccess := range resourceControl.TeamAccesses {
if slices.Contains(userTeamIDs, authorizedTeamAccess.TeamID) {
return true
}
}
return resourceControl.Public
}
// GetResourceControlByResourceIDAndType retrieves the first matching resource control in a set of resource controls
// based on the specified id and resource type parameters.
func GetResourceControlByResourceIDAndType(resourceID string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) *portainer.ResourceControl {
for i := range resourceControls {
if resourceID == resourceControls[i].ResourceID && resourceType == resourceControls[i].Type {
return &resourceControls[i]
}
if slices.Contains(resourceControls[i].SubResourceIDs, resourceID) {
return &resourceControls[i]
}
}
return nil
}
// TeamIDs extracts the TeamID from each membership.
func TeamIDs(memberships []portainer.TeamMembership) []portainer.TeamID {
return slicesx.Map(memberships, func(m portainer.TeamMembership) portainer.TeamID {
return m.TeamID
})
}
+194
View File
@@ -0,0 +1,194 @@
package authorization
import (
portainer "github.com/portainer/portainer/api"
)
// ResolvedAccess represents the result of dynamic authorization resolution.
// It contains both the computed role and its authorizations for convenience.
type ResolvedAccess struct {
Role *portainer.Role
Authorizations portainer.Authorizations
Source AccessSource
}
// AccessSource captures which layers contributed to a resolved access decision.
//
// - GroupID is set when access was granted via the user-group or team-group
// layer, OR (in EE) via a policy targeting the environment group.
// - TeamID is set when access was granted via either of the team layers; the
// ID of the team whose membership matched.
type AccessSource struct {
GroupID portainer.EndpointGroupID
TeamID portainer.TeamID
}
// ResolverInput contains all the data needed to resolve user access to an endpoint.
// This struct is used to pass data to the resolution functions without requiring
// database access, making it easier to test and allowing callers to control data fetching.
type ResolverInput struct {
User *portainer.User
Endpoint *portainer.Endpoint
EndpointGroup portainer.EndpointGroup
UserMemberships []portainer.TeamMembership
Roles []portainer.Role
}
// ResolveUserEndpointAccess resolves a user's effective access to an endpoint.
// It checks access in precedence order:
// 1. User → Endpoint direct access
// 2. User → Endpoint Group access (inherited)
// 3. User's Teams → Endpoint access
// 4. User's Teams → Endpoint Group access (inherited)
func ResolveUserEndpointAccess(input ResolverInput) *ResolvedAccess {
group := input.EndpointGroup
// 1. Check user → endpoint direct access
if role := GetRoleFromUserAccessPolicies(
input.User.ID,
input.Endpoint.UserAccessPolicies,
input.Roles,
); role != nil {
return &ResolvedAccess{
Role: role,
Authorizations: role.Authorizations,
}
}
// 2. Check user → endpoint group access (inherited)
if role := GetRoleFromUserAccessPolicies(
input.User.ID,
group.UserAccessPolicies,
input.Roles,
); role != nil {
return &ResolvedAccess{
Role: role,
Authorizations: role.Authorizations,
Source: AccessSource{GroupID: group.ID},
}
}
// 3. Check user's teams → endpoint access
if role, teamID := getTeamRoleWithSource(
input.UserMemberships,
input.Endpoint.TeamAccessPolicies,
input.Roles,
); role != nil {
return &ResolvedAccess{
Role: role,
Authorizations: role.Authorizations,
Source: AccessSource{TeamID: teamID},
}
}
// 4. Check user's teams → endpoint group access (inherited)
if role, teamID := getTeamRoleWithSource(
input.UserMemberships,
group.TeamAccessPolicies,
input.Roles,
); role != nil {
return &ResolvedAccess{
Role: role,
Authorizations: role.Authorizations,
Source: AccessSource{GroupID: group.ID, TeamID: teamID},
}
}
return nil
}
// GetRoleFromUserAccessPolicies returns the role for a user from user access policies.
// Returns nil if the user is not in the policies.
func GetRoleFromUserAccessPolicies(
userID portainer.UserID,
policies portainer.UserAccessPolicies,
roles []portainer.Role,
) *portainer.Role {
if policies == nil {
return nil
}
policy, ok := policies[userID]
if !ok {
return nil
}
return FindRoleByID(policy.RoleID, roles)
}
// GetRoleFromTeamAccessPolicies returns the highest priority role for a user
// based on their team memberships and the team access policies.
// If a user belongs to multiple teams with access, the role with highest priority wins.
// Returns nil if none of the user's teams have access.
func GetRoleFromTeamAccessPolicies(
memberships []portainer.TeamMembership,
policies portainer.TeamAccessPolicies,
roles []portainer.Role,
) *portainer.Role {
role, _ := getTeamRoleWithSource(memberships, policies, roles)
return role
}
// getTeamRoleWithSource is GetRoleFromTeamAccessPolicies plus the matching team ID.
func getTeamRoleWithSource(
memberships []portainer.TeamMembership,
policies portainer.TeamAccessPolicies,
roles []portainer.Role,
) (*portainer.Role, portainer.TeamID) {
if policies == nil || len(memberships) == 0 {
return nil, 0
}
var (
best *portainer.Role
bestTeamID portainer.TeamID
)
for _, membership := range memberships {
policy, ok := policies[membership.TeamID]
if !ok {
continue
}
role := FindRoleByID(policy.RoleID, roles)
if role == nil {
continue
}
if best == nil || role.Priority > best.Priority {
best = role
bestTeamID = membership.TeamID
}
}
return best, bestTeamID
}
// GetHighestPriorityRole returns the role with the highest priority from a slice.
// In Portainer's role system, higher priority numbers = higher priority (lower access usually gives higher priority).
// Current role priorities from highest to lowest: Read-only User (6), Standard User (5),
// Namespace Operator (4), Helpdesk (3), Operator (2), Environment Administrator (1).
// Returns nil if the slice is empty.
func GetHighestPriorityRole(roles []*portainer.Role) *portainer.Role {
if len(roles) == 0 {
return nil
}
highest := roles[0]
for _, role := range roles[1:] {
if role.Priority > highest.Priority {
highest = role
}
}
return highest
}
// FindRoleByID finds a role by its ID in a slice of roles.
// Returns nil if the role is not found.
func FindRoleByID(roleID portainer.RoleID, roles []portainer.Role) *portainer.Role {
for i := range roles {
if roles[i].ID == roleID {
return &roles[i]
}
}
return nil
}
+465
View File
@@ -0,0 +1,465 @@
package authorization
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
// Test role fixtures
// In Portainer's role system, higher priority numbers = higher priority (more powerful).
// Order from highest to lowest: Read-only (4), Helpdesk (3), Operator (2), Admin (1).
var (
roleAdmin = portainer.Role{
ID: 1,
Name: "Environment Administrator",
Priority: 1,
Authorizations: portainer.Authorizations{"admin": true},
}
roleOperator = portainer.Role{
ID: 2,
Name: "Operator",
Priority: 2,
Authorizations: portainer.Authorizations{"operator": true},
}
roleHelpdesk = portainer.Role{
ID: 3,
Name: "Helpdesk",
Priority: 3,
Authorizations: portainer.Authorizations{"helpdesk": true},
}
roleReadOnly = portainer.Role{
ID: 4,
Name: "Read-only",
Priority: 4,
Authorizations: portainer.Authorizations{"readonly": true},
}
allRoles = []portainer.Role{roleAdmin, roleOperator, roleHelpdesk, roleReadOnly}
)
func TestResolveUserEndpointAccess_UserEndpointAccess(t *testing.T) {
t.Parallel()
is := assert.New(t)
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 1,
UserAccessPolicies: portainer.UserAccessPolicies{
1: {RoleID: roleOperator.ID},
},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: portainer.EndpointGroup{},
UserMemberships: []portainer.TeamMembership{},
Roles: allRoles,
}
access := ResolveUserEndpointAccess(input)
is.NotNil(access)
is.Equal(roleOperator.ID, access.Role.ID)
is.Equal("Operator", access.Role.Name)
}
func TestResolveUserEndpointAccess_UserGroupAccess(t *testing.T) {
t.Parallel()
is := assert.New(t)
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{}, // No direct access
}
groups := []portainer.EndpointGroup{
{
ID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{
1: {RoleID: roleHelpdesk.ID}, // User has access via group
},
},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: groups[0],
UserMemberships: []portainer.TeamMembership{},
Roles: allRoles,
}
access := ResolveUserEndpointAccess(input)
is.NotNil(access)
is.Equal(roleHelpdesk.ID, access.Role.ID)
is.Equal("Helpdesk", access.Role.Name)
}
func TestResolveUserEndpointAccess_TeamEndpointAccess(t *testing.T) {
t.Parallel()
is := assert.New(t)
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 1,
UserAccessPolicies: portainer.UserAccessPolicies{}, // No user access
TeamAccessPolicies: portainer.TeamAccessPolicies{
100: {RoleID: roleReadOnly.ID}, // Team 100 has access
},
}
memberships := []portainer.TeamMembership{
{UserID: 1, TeamID: 100}, // User is in team 100
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: portainer.EndpointGroup{},
UserMemberships: memberships,
Roles: allRoles,
}
access := ResolveUserEndpointAccess(input)
is.NotNil(access)
is.Equal(roleReadOnly.ID, access.Role.ID)
}
func TestResolveUserEndpointAccess_TeamGroupAccess(t *testing.T) {
t.Parallel()
is := assert.New(t)
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{}, // No direct team access
}
groups := []portainer.EndpointGroup{
{
ID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{
100: {RoleID: roleOperator.ID}, // Team 100 has group access
},
},
}
memberships := []portainer.TeamMembership{
{UserID: 1, TeamID: 100},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: groups[0],
UserMemberships: memberships,
Roles: allRoles,
}
access := ResolveUserEndpointAccess(input)
is.NotNil(access)
is.Equal(roleOperator.ID, access.Role.ID)
}
func TestResolveUserEndpointAccess_Precedence(t *testing.T) {
t.Parallel()
is := assert.New(t)
t.Run("User endpoint access takes precedence over group access", func(t *testing.T) {
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{
1: {RoleID: roleOperator.ID}, // Direct access
},
}
groups := []portainer.EndpointGroup{
{
ID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{
1: {RoleID: roleAdmin.ID}, // Group access (higher role, but lower precedence)
},
},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: groups[0],
Roles: allRoles,
}
access := ResolveUserEndpointAccess(input)
is.NotNil(access)
is.Equal(roleOperator.ID, access.Role.ID, "Direct endpoint access should take precedence")
})
t.Run("User access takes precedence over team access", func(t *testing.T) {
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 1,
UserAccessPolicies: portainer.UserAccessPolicies{
1: {RoleID: roleHelpdesk.ID},
},
TeamAccessPolicies: portainer.TeamAccessPolicies{
100: {RoleID: roleAdmin.ID}, // Team has higher role
},
}
memberships := []portainer.TeamMembership{
{UserID: 1, TeamID: 100},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
UserMemberships: memberships,
Roles: allRoles,
}
access := ResolveUserEndpointAccess(input)
is.NotNil(access)
is.Equal(roleHelpdesk.ID, access.Role.ID, "User access should take precedence over team access")
})
t.Run("Team endpoint access takes precedence over team group access", func(t *testing.T) {
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 10,
TeamAccessPolicies: portainer.TeamAccessPolicies{
100: {RoleID: roleReadOnly.ID}, // Direct team endpoint access
},
}
groups := []portainer.EndpointGroup{
{
ID: 10,
TeamAccessPolicies: portainer.TeamAccessPolicies{
100: {RoleID: roleAdmin.ID}, // Team group access (higher role)
},
},
}
memberships := []portainer.TeamMembership{
{UserID: 1, TeamID: 100},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: groups[0],
UserMemberships: memberships,
Roles: allRoles,
}
access := ResolveUserEndpointAccess(input)
is.NotNil(access)
is.Equal(roleReadOnly.ID, access.Role.ID, "Team endpoint access should take precedence over team group access")
})
}
func TestResolveUserEndpointAccess_NoAccess(t *testing.T) {
t.Parallel()
is := assert.New(t)
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
}
groups := []portainer.EndpointGroup{
{
ID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: groups[0],
UserMemberships: []portainer.TeamMembership{},
Roles: allRoles,
}
access := ResolveUserEndpointAccess(input)
is.Nil(access)
}
func TestResolveUserEndpointAccess_MultipleTeams_HighestPriorityWins(t *testing.T) {
t.Parallel()
is := assert.New(t)
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 1,
TeamAccessPolicies: portainer.TeamAccessPolicies{
100: {RoleID: roleReadOnly.ID}, // Highest priority (4)
200: {RoleID: roleAdmin.ID}, // Lowest priority (1)
300: {RoleID: roleOperator.ID}, // Medium priority (2)
},
}
memberships := []portainer.TeamMembership{
{UserID: 1, TeamID: 100},
{UserID: 1, TeamID: 200},
{UserID: 1, TeamID: 300},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: portainer.EndpointGroup{},
UserMemberships: memberships,
Roles: allRoles,
}
access := ResolveUserEndpointAccess(input)
is.NotNil(access)
is.Equal(roleReadOnly.ID, access.Role.ID, "Highest priority role should be selected when user is in multiple teams")
}
func TestResolveUserEndpointAccess_Source(t *testing.T) {
t.Parallel()
is := assert.New(t)
user := &portainer.User{ID: 1}
memberships := []portainer.TeamMembership{{UserID: 1, TeamID: 100}}
t.Run("user-endpoint resolution flags neither group nor team", func(t *testing.T) {
access := ResolveUserEndpointAccess(ResolverInput{
User: user, Roles: allRoles,
Endpoint: &portainer.Endpoint{ID: 1, GroupID: 10, UserAccessPolicies: portainer.UserAccessPolicies{1: {RoleID: roleOperator.ID}}},
})
is.NotNil(access)
is.Equal(portainer.EndpointGroupID(0), access.Source.GroupID)
is.Equal(portainer.TeamID(0), access.Source.TeamID)
})
t.Run("user-group resolution flags the group", func(t *testing.T) {
access := ResolveUserEndpointAccess(ResolverInput{
User: user, Roles: allRoles,
Endpoint: &portainer.Endpoint{ID: 1, GroupID: 10},
EndpointGroup: portainer.EndpointGroup{ID: 10, UserAccessPolicies: portainer.UserAccessPolicies{1: {RoleID: roleOperator.ID}}},
})
is.NotNil(access)
is.Equal(portainer.EndpointGroupID(10), access.Source.GroupID)
is.Equal(portainer.TeamID(0), access.Source.TeamID)
})
t.Run("team-endpoint resolution flags the team only", func(t *testing.T) {
access := ResolveUserEndpointAccess(ResolverInput{
User: user, Roles: allRoles, UserMemberships: memberships,
Endpoint: &portainer.Endpoint{ID: 1, GroupID: 10, TeamAccessPolicies: portainer.TeamAccessPolicies{100: {RoleID: roleOperator.ID}}},
})
is.NotNil(access)
is.Equal(portainer.EndpointGroupID(0), access.Source.GroupID)
is.Equal(portainer.TeamID(100), access.Source.TeamID)
})
t.Run("team-group resolution flags both group and team", func(t *testing.T) {
access := ResolveUserEndpointAccess(ResolverInput{
User: user, Roles: allRoles, UserMemberships: memberships,
Endpoint: &portainer.Endpoint{ID: 1, GroupID: 10},
EndpointGroup: portainer.EndpointGroup{ID: 10, TeamAccessPolicies: portainer.TeamAccessPolicies{100: {RoleID: roleOperator.ID}}},
})
is.NotNil(access)
is.Equal(portainer.EndpointGroupID(10), access.Source.GroupID)
is.Equal(portainer.TeamID(100), access.Source.TeamID)
})
}
func TestResolveUserEndpointAccess(t *testing.T) {
t.Parallel()
is := assert.New(t)
t.Run("Returns resolved access with role and authorizations", func(t *testing.T) {
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
UserAccessPolicies: portainer.UserAccessPolicies{
1: {RoleID: roleOperator.ID},
},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
Roles: allRoles,
}
access := ResolveUserEndpointAccess(input)
is.NotNil(access)
is.Equal(roleOperator.ID, access.Role.ID)
is.True(access.Authorizations["operator"])
})
t.Run("Returns nil when no access", func(t *testing.T) {
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{ID: 1}
input := ResolverInput{
User: user,
Endpoint: endpoint,
Roles: allRoles,
}
access := ResolveUserEndpointAccess(input)
is.Nil(access)
})
}
func TestFindRoleByID(t *testing.T) {
t.Parallel()
is := assert.New(t)
t.Run("Finds existing role", func(t *testing.T) {
role := FindRoleByID(roleOperator.ID, allRoles)
is.NotNil(role)
is.Equal(roleOperator.ID, role.ID)
})
t.Run("Returns nil for non-existent role", func(t *testing.T) {
role := FindRoleByID(999, allRoles)
is.Nil(role)
})
t.Run("Returns nil for empty roles slice", func(t *testing.T) {
role := FindRoleByID(1, []portainer.Role{})
is.Nil(role)
})
}
func TestGetHighestPriorityRole(t *testing.T) {
t.Parallel()
is := assert.New(t)
t.Run("Returns nil for empty slice", func(t *testing.T) {
result := GetHighestPriorityRole([]*portainer.Role{})
is.Nil(result)
})
t.Run("Returns single role", func(t *testing.T) {
result := GetHighestPriorityRole([]*portainer.Role{&roleOperator})
is.Equal(roleOperator.ID, result.ID)
})
t.Run("Returns highest priority from multiple roles", func(t *testing.T) {
result := GetHighestPriorityRole([]*portainer.Role{&roleReadOnly, &roleAdmin, &roleOperator})
is.Equal(roleReadOnly.ID, result.ID)
})
}