From b162814bd944d24662946d38307d7204c4dccd30 Mon Sep 17 00:00:00 2001 From: LP B Date: Tue, 13 Jan 2026 17:17:06 +0100 Subject: [PATCH] fix(uac): async SnapshotRaw data not filtered by UAC (#1540) --- api/dataservices/interface.go | 1 + api/dataservices/user/tx.go | 12 ++ api/dataservices/user/user.go | 12 ++ api/http/handler/docker/dashboard.go | 39 ++-- .../handler/docker/utils/filter_by_uac.go | 37 ---- api/http/handler/docker/utils/get_stacks.go | 14 +- .../handler/docker/utils/get_stacks_test.go | 81 ++++---- api/http/proxy/factory/docker/transport.go | 4 +- .../proxy/factory/docker/transport_test.go | 8 +- api/internal/authorization/access_control.go | 4 + api/uac/configs.go | 30 +++ api/uac/configs_test.go | 70 +++++++ api/uac/containergroup.go | 4 + api/uac/containers.go | 30 +++ api/uac/containers_test.go | 70 +++++++ api/uac/custom_template.go | 4 + api/uac/generic_rc_getter.go | 184 ++++++++++++++++++ api/uac/generic_rc_getter_test.go | 170 ++++++++++++++++ api/uac/networks.go | 30 +++ api/uac/networks_test.go | 70 +++++++ api/uac/secrets.go | 30 +++ api/uac/secrets_test.go | 70 +++++++ api/uac/services.go | 30 +++ api/uac/stacks.go | 27 +++ api/uac/uac.go | 53 +++++ api/uac/uac_test.go | 76 ++++++++ api/uac/volumes.go | 32 +++ api/uac/volumes_test.go | 70 +++++++ .../EnvironmentList/EnvironmentList.tsx | 1 - .../environments/environment.service/index.ts | 9 +- .../environments/queries/useEnvironment.ts | 8 +- 31 files changed, 1166 insertions(+), 114 deletions(-) delete mode 100644 api/http/handler/docker/utils/filter_by_uac.go create mode 100644 api/uac/configs.go create mode 100644 api/uac/configs_test.go create mode 100644 api/uac/containergroup.go create mode 100644 api/uac/containers.go create mode 100644 api/uac/containers_test.go create mode 100644 api/uac/custom_template.go create mode 100644 api/uac/generic_rc_getter.go create mode 100644 api/uac/generic_rc_getter_test.go create mode 100644 api/uac/networks.go create mode 100644 api/uac/networks_test.go create mode 100644 api/uac/secrets.go create mode 100644 api/uac/secrets_test.go create mode 100644 api/uac/services.go create mode 100644 api/uac/stacks.go create mode 100644 api/uac/uac.go create mode 100644 api/uac/uac_test.go create mode 100644 api/uac/volumes.go create mode 100644 api/uac/volumes_test.go diff --git a/api/dataservices/interface.go b/api/dataservices/interface.go index 9255c6361..8b2b3a308 100644 --- a/api/dataservices/interface.go +++ b/api/dataservices/interface.go @@ -223,6 +223,7 @@ type ( UserService interface { BaseCRUD[portainer.User, portainer.UserID] UserByUsername(username string) (*portainer.User, error) + UserIDByUsername(username string) (portainer.UserID, error) UsersByRole(role portainer.UserRole) ([]portainer.User, error) } diff --git a/api/dataservices/user/tx.go b/api/dataservices/user/tx.go index 38e201d5a..5162b947f 100644 --- a/api/dataservices/user/tx.go +++ b/api/dataservices/user/tx.go @@ -36,6 +36,18 @@ func (service ServiceTx) UserByUsername(username string) (*portainer.User, error return nil, err } +func (service ServiceTx) UserIDByUsername(username string) (portainer.UserID, error) { + user, err := service.UserByUsername(username) + if err != nil { + return 0, err + } + + if user == nil { + return 0, dserrors.ErrObjectNotFound + } + return user.ID, nil +} + // UsersByRole return an array containing all the users with the specified role. func (service ServiceTx) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { var users = make([]portainer.User, 0) diff --git a/api/dataservices/user/user.go b/api/dataservices/user/user.go index c3dc302b2..2ab1a523d 100644 --- a/api/dataservices/user/user.go +++ b/api/dataservices/user/user.go @@ -65,6 +65,18 @@ func (service *Service) UserByUsername(username string) (*portainer.User, error) return nil, err } +func (service *Service) UserIDByUsername(username string) (portainer.UserID, error) { + user, err := service.UserByUsername(username) + if err != nil { + return 0, err + } + + if user == nil { + return 0, dserrors.ErrObjectNotFound + } + return user.ID, nil +} + // UsersByRole return an array containing all the users with the specified role. func (service *Service) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { var users = make([]portainer.User, 0) diff --git a/api/http/handler/docker/dashboard.go b/api/http/handler/docker/dashboard.go index 4120db02f..37653708a 100644 --- a/api/http/handler/docker/dashboard.go +++ b/api/http/handler/docker/dashboard.go @@ -1,6 +1,7 @@ package docker import ( + "errors" "net/http" "github.com/docker/docker/api/types" @@ -15,6 +16,7 @@ import ( "github.com/portainer/portainer/api/http/handler/docker/utils" "github.com/portainer/portainer/api/http/middlewares" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/uac" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/response" ) @@ -57,16 +59,22 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.H if err != nil { return httperror.InternalServerError("Unable to retrieve user details from request context", err) } + user, err := tx.User().Read(context.UserID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user", err) + } + + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + return err + } containers, err := cli.ContainerList(r.Context(), container.ListOptions{All: true}) if err != nil { return httperror.InternalServerError("Unable to retrieve Docker containers", err) } - containers, err = utils.FilterByResourceControl(tx, containers, portainer.ContainerResourceControl, context, func(c types.Container) string { - return c.ID - }) - if err != nil { + if containers, err = uac.FilterByResourceControl(containers, user, context.UserMemberships, uac.ContainerResourceControlGetter(tx, endpoint.ID)); err != nil { return err } @@ -94,14 +102,9 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.H return httperror.InternalServerError("Unable to retrieve Docker services", err) } - filteredServices, err := utils.FilterByResourceControl(tx, servicesRes, portainer.ServiceResourceControl, context, func(c swarm.Service) string { - return c.ID - }) - if err != nil { + if services, err = uac.FilterByResourceControl(servicesRes, user, context.UserMemberships, uac.ServiceResourceControlGetter(tx, endpoint.ID)); err != nil { return err } - - services = filteredServices } volumesRes, err := cli.VolumeList(r.Context(), volume.ListOptions{}) @@ -109,10 +112,13 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.H return httperror.InternalServerError("Unable to retrieve Docker volumes", err) } - volumes, err := utils.FilterByResourceControl(tx, volumesRes.Volumes, portainer.NetworkResourceControl, context, func(c *volume.Volume) string { - return c.Name - }) - if err != nil { + var volumes []*volume.Volume + if volumes, err = uac.FilterByResourceControl(volumesRes.Volumes, user, context.UserMemberships, func(item *volume.Volume) (*portainer.ResourceControl, error) { + if item == nil { + return nil, errors.New("Found nil volume in volumes list") + } + return uac.VolumeResourceControlGetter(tx, endpoint.ID)(*item) + }); err != nil { return err } @@ -121,10 +127,7 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.H return httperror.InternalServerError("Unable to retrieve Docker networks", err) } - networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c network.Summary) string { - return c.Name - }) - if err != nil { + if networks, err = uac.FilterByResourceControl(networks, user, context.UserMemberships, uac.NetworkResourceControlGetter(tx, endpoint.ID)); err != nil { return err } diff --git a/api/http/handler/docker/utils/filter_by_uac.go b/api/http/handler/docker/utils/filter_by_uac.go deleted file mode 100644 index a01eec70a..000000000 --- a/api/http/handler/docker/utils/filter_by_uac.go +++ /dev/null @@ -1,37 +0,0 @@ -package utils - -import ( - "fmt" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" - "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/authorization" - "github.com/portainer/portainer/api/slicesx" -) - -// filterByResourceControl filters a list of items based on the user's role and the resource control associated to the item. -func FilterByResourceControl[T any](tx dataservices.DataStoreTx, items []T, rcType portainer.ResourceControlType, securityContext *security.RestrictedRequestContext, idGetter func(T) string) ([]T, error) { - if securityContext.IsAdmin { - return items, nil - } - - userTeamIDs := slicesx.Map(securityContext.UserMemberships, func(membership portainer.TeamMembership) portainer.TeamID { - return membership.TeamID - }) - - filteredItems := make([]T, 0) - for _, item := range items { - resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(idGetter(item), portainer.ContainerResourceControl) - if err != nil { - return nil, fmt.Errorf("Unable to retrieve resource control: %w", err) - } - - if resourceControl == nil || authorization.UserCanAccessResource(securityContext.UserID, userTeamIDs, resourceControl) { - filteredItems = append(filteredItems, item) - } - - } - - return filteredItems, nil -} diff --git a/api/http/handler/docker/utils/get_stacks.go b/api/http/handler/docker/utils/get_stacks.go index ea076374e..fd924fb68 100644 --- a/api/http/handler/docker/utils/get_stacks.go +++ b/api/http/handler/docker/utils/get_stacks.go @@ -9,6 +9,7 @@ import ( "github.com/portainer/portainer/api/dataservices" dockerconsts "github.com/portainer/portainer/api/docker/consts" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/uac" ) type StackViewModel struct { @@ -27,6 +28,11 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest return nil, fmt.Errorf("Unable to retrieve stacks: %w", err) } + user, err := tx.User().Read(securityContext.UserID) + if err != nil { + return nil, fmt.Errorf("Unable to retrieve user: %w", err) + } + stacksNameSet := map[string]*StackViewModel{} for i := range stacks { @@ -71,9 +77,11 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest stacksList = append(stacksList, *stack) } - return FilterByResourceControl(tx, stacksList, portainer.StackResourceControl, securityContext, func(c StackViewModel) string { - return c.Name - }) + return uac.FilterByResourceControl(stacksList, user, securityContext.UserMemberships, + func(item StackViewModel) (*portainer.ResourceControl, error) { + return uac.StackResourceControlGetter(tx, environmentID)(*item.InternalStack) + }, + ) } func isHiddenStack(labels map[string]string) bool { diff --git a/api/http/handler/docker/utils/get_stacks_test.go b/api/http/handler/docker/utils/get_stacks_test.go index a50150144..e6ca1e059 100644 --- a/api/http/handler/docker/utils/get_stacks_test.go +++ b/api/http/handler/docker/utils/get_stacks_test.go @@ -6,15 +6,18 @@ import ( "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/swarm" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" dockerconsts "github.com/portainer/portainer/api/docker/consts" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/testhelpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestHandler_getDockerStacks(t *testing.T) { + is := require.New(t) + environment := &portainer.Endpoint{ ID: 1, SecuritySettings: portainer.EndpointSecuritySettings{ @@ -54,44 +57,52 @@ func TestHandler_getDockerStacks(t *testing.T) { Type: portainer.DockerComposeStack, } - datastore := testhelpers.NewDatastore( - testhelpers.WithEndpoints([]portainer.Endpoint{*environment}), - testhelpers.WithStacks([]portainer.Stack{ - stack1, + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.Endpoint().Create(environment)) + is.NoError(tx.Stack().Create(&stack1)) + is.NoError(tx.Stack().Create(&portainer.Stack{ + ID: 2, + Name: "stack2", + EndpointID: 2, + Type: portainer.DockerSwarmStack, + })) + is.NoError(tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + }, environment.ID, containers, services) + require.NoError(t, err) + assert.Len(t, stacksList, 3) + + expectedStacks := []StackViewModel{ + { + InternalStack: &stack1, + ID: 1, + Name: "stack1", + IsExternal: false, + Type: portainer.DockerComposeStack, + }, { - ID: 2, Name: "stack2", - EndpointID: 2, + IsExternal: true, + Type: portainer.DockerComposeStack, + }, + { + Name: "stack3", + IsExternal: true, Type: portainer.DockerSwarmStack, }, - }), - ) + } - stacksList, err := GetDockerStacks(datastore, &security.RestrictedRequestContext{ - IsAdmin: true, - }, environment.ID, containers, services) - require.NoError(t, err) - assert.Len(t, stacksList, 3) + assert.ElementsMatch(t, expectedStacks, stacksList) + return nil + })) - expectedStacks := []StackViewModel{ - { - InternalStack: &stack1, - ID: 1, - Name: "stack1", - IsExternal: false, - Type: portainer.DockerComposeStack, - }, - { - Name: "stack2", - IsExternal: true, - Type: portainer.DockerComposeStack, - }, - { - Name: "stack3", - IsExternal: true, - Type: portainer.DockerSwarmStack, - }, - } - - assert.ElementsMatch(t, expectedStacks, stacksList) } diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go index 4d0e9acf8..4ce761a6e 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -588,7 +588,7 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r // the resourceID may be the resource name (as it's a valid proxy call to use the name and not the UUID) // so get the real resource ID and retry with it - resourceID, err = getRealResourceID(client, resourceType, resourceID) + resourceID, err = getDockerResourceUUID(client, resourceType, resourceID) if err != nil { return nil, err } @@ -619,7 +619,7 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r return transport.executeDockerRequest(request) } -func getRealResourceID(client *dockerclient.Client, resourceType portainer.ResourceControlType, resourceId string) (string, error) { +func getDockerResourceUUID(client *dockerclient.Client, resourceType portainer.ResourceControlType, resourceId string) (string, error) { switch resourceType { case portainer.NetworkResourceControl: network, err := client.NetworkInspect(context.Background(), resourceId, network.InspectOptions{}) diff --git a/api/http/proxy/factory/docker/transport_test.go b/api/http/proxy/factory/docker/transport_test.go index 578a3c449..f900d6fde 100644 --- a/api/http/proxy/factory/docker/transport_test.go +++ b/api/http/proxy/factory/docker/transport_test.go @@ -134,17 +134,17 @@ func TestTransport_getRealResourceID(t *testing.T) { test := func(rctype portainer.ResourceControlType, name string, id string, errOnUnknown bool) { // by id - got, err := getRealResourceID(client, rctype, id) + got, err := getDockerResourceUUID(client, rctype, id) require.NoError(t, err) require.Equal(t, id, got) // by name - got, err = getRealResourceID(client, rctype, name) + got, err = getDockerResourceUUID(client, rctype, name) require.NoError(t, err) require.Equal(t, id, got) // unknown for this type - _, err = getRealResourceID(client, rctype, "unknown") + _, err = getDockerResourceUUID(client, rctype, "unknown") if errOnUnknown { require.Error(t, err) } else { @@ -160,7 +160,7 @@ func TestTransport_getRealResourceID(t *testing.T) { test(portainer.SecretResourceControl, "mysecret", "v9i7o4ivg33u4z3jfyxto162d", true) // validate that other types are not supported - _, err = getRealResourceID(client, portainer.ContainerGroupResourceControl, "") + _, err = getDockerResourceUUID(client, portainer.ContainerGroupResourceControl, "") require.Error(t, err) } diff --git a/api/internal/authorization/access_control.go b/api/internal/authorization/access_control.go index 136d5bbe4..89ef1b880 100644 --- a/api/internal/authorization/access_control.go +++ b/api/internal/authorization/access_control.go @@ -72,6 +72,10 @@ func NewPublicResourceControl(resourceIdentifier string, resourceType portainer. } } +func NewEmptyRestrictedResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType) *portainer.ResourceControl { + return NewRestrictedResourceControl(resourceIdentifier, resourceType, []portainer.UserID{}, []portainer.TeamID{}) +} + // NewRestrictedResourceControl will create a new resource control with user and team accesses restrictions. func NewRestrictedResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, userIDs []portainer.UserID, teamIDs []portainer.TeamID) *portainer.ResourceControl { userAccesses := make([]portainer.UserResourceAccess, 0) diff --git a/api/uac/configs.go b/api/uac/configs.go new file mode 100644 index 000000000..e1ed14df7 --- /dev/null +++ b/api/uac/configs.go @@ -0,0 +1,30 @@ +package uac + +import ( + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" +) + +func ConfigResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, +) func(item swarm.Config) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[swarm.Config]{ + RCType: portainer.ConfigResourceControl, + IDGetter: ConfigResourceControlID, + LabelsGetter: ConfigLabels, + }) +} + +func ConfigResourceControlID(item swarm.Config) string { + return item.ID +} + +func ConfigLabels(item swarm.Config) map[string]string { + return item.Spec.Labels +} diff --git a/api/uac/configs_test.go b/api/uac/configs_test.go new file mode 100644 index 000000000..a690e39d7 --- /dev/null +++ b/api/uac/configs_test.go @@ -0,0 +1,70 @@ +package uac + +import ( + "testing" + + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/stretchr/testify/require" +) + +func TestConfigResourceControlGetter(t *testing.T) { + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + envID := portainer.EndpointID(1) + configID := "config" + stackName := "stack" + stackRCID := stackutils.ResourceControlID(envID, stackName) + serviceID := "service" + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(configID, portainer.ConfigResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(stackRCID, portainer.StackResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(serviceID, portainer.ServiceResourceControl))) + return nil + })) + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + // by direct ID + rc, err := ConfigResourceControlGetter(tx, envID)(swarm.Config{ID: configID}) + is.NoError(err) + is.NotNil(rc) + is.Equal(configID, rc.ResourceID) + + // by compose stack label + rc, err = ConfigResourceControlGetter(tx, envID)( + swarm.Config{ID: "unknown", Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Labels: map[string]string{consts.ComposeStackNameLabel: stackName}}}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by swarm stack label + rc, err = ConfigResourceControlGetter(tx, envID)( + swarm.Config{ID: "unknown", Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Labels: map[string]string{consts.SwarmStackNameLabel: stackName}}}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by service ID + rc, err = ConfigResourceControlGetter(tx, envID)( + swarm.Config{ID: "unknown", Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Labels: map[string]string{consts.SwarmServiceIDLabel: serviceID}}}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(serviceID, rc.ResourceID) + + return nil + })) + +} diff --git a/api/uac/containergroup.go b/api/uac/containergroup.go new file mode 100644 index 000000000..30fd6cdac --- /dev/null +++ b/api/uac/containergroup.go @@ -0,0 +1,4 @@ +package uac + +// TODO: implement UAC rules for Container groups +// See usage of portainer.ContainerGroupResourceControl diff --git a/api/uac/containers.go b/api/uac/containers.go new file mode 100644 index 000000000..d7c551fde --- /dev/null +++ b/api/uac/containers.go @@ -0,0 +1,30 @@ +package uac + +import ( + "github.com/docker/docker/api/types/container" + portainer "github.com/portainer/portainer/api" +) + +func ContainerResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, +) func(item container.Summary) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[container.Summary]{ + RCType: portainer.ContainerResourceControl, + IDGetter: ContainerResourceControlID, + LabelsGetter: ContainerLabels, + }) +} + +func ContainerResourceControlID(item container.Summary) string { + return item.ID +} + +func ContainerLabels(item container.Summary) map[string]string { + return item.Labels +} diff --git a/api/uac/containers_test.go b/api/uac/containers_test.go new file mode 100644 index 000000000..0032b6059 --- /dev/null +++ b/api/uac/containers_test.go @@ -0,0 +1,70 @@ +package uac + +import ( + "testing" + + "github.com/docker/docker/api/types/container" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/stretchr/testify/require" +) + +func TestContainerResourceControlGetter(t *testing.T) { + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + envID := portainer.EndpointID(1) + containerID := "container" + stackName := "stack" + stackRCID := stackutils.ResourceControlID(envID, stackName) + serviceID := "service" + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(containerID, portainer.ContainerResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(stackRCID, portainer.StackResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(serviceID, portainer.ServiceResourceControl))) + return nil + })) + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + // by direct ID + rc, err := ContainerResourceControlGetter(tx, envID)(container.Summary{ID: containerID}) + is.NoError(err) + is.NotNil(rc) + is.Equal(containerID, rc.ResourceID) + + // by compose stack label + rc, err = ContainerResourceControlGetter(tx, envID)( + container.Summary{ID: "unknown", Labels: map[string]string{consts.ComposeStackNameLabel: stackName}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by swarm stack label + rc, err = ContainerResourceControlGetter(tx, envID)( + container.Summary{ID: "unknown", Labels: map[string]string{consts.SwarmStackNameLabel: stackName}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by service ID + rc, err = ContainerResourceControlGetter(tx, envID)( + container.Summary{ID: "unknown", Labels: map[string]string{consts.SwarmServiceIDLabel: serviceID}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(serviceID, rc.ResourceID) + + return nil + })) + +} diff --git a/api/uac/custom_template.go b/api/uac/custom_template.go new file mode 100644 index 000000000..12e998dfe --- /dev/null +++ b/api/uac/custom_template.go @@ -0,0 +1,4 @@ +package uac + +// TODO: implement UAC rules for Custom templates +// See usage of portainer.CustomTemplateResourceControl diff --git a/api/uac/generic_rc_getter.go b/api/uac/generic_rc_getter.go new file mode 100644 index 000000000..04ae933ef --- /dev/null +++ b/api/uac/generic_rc_getter.go @@ -0,0 +1,184 @@ +package uac + +import ( + "strings" + + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/slicesx" + "github.com/rs/zerolog/log" +) + +type rcServiceLike interface { + ResourceControlByResourceIDAndType(resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) +} + +type teamServiceLike interface { + TeamByName(name string) (*portainer.Team, error) +} + +type userServiceLike interface { + UserIDByUsername(name string) (portainer.UserID, error) +} + +type txLike[ + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +] interface { + ResourceControl() RCS + Team() TS + User() US +} + +type ResourceContext[T any] struct { + RCType portainer.ResourceControlType + IDGetter func(T) string + LabelsGetter func(T) map[string]string +} + +func genericResourcControlGetter[ + T any, + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, + context ResourceContext[T], +) func(T) (*portainer.ResourceControl, error) { + return func(item T) (*portainer.ResourceControl, error) { + resourceID := context.IDGetter(item) + resourceType := context.RCType + + if rc, err := tx.ResourceControl().ResourceControlByResourceIDAndType( + resourceID, resourceType, + ); err != nil && !dataservices.IsErrObjectNotFound(err) { + return nil, err + } else if rc != nil { + return rc, nil + } + + if context.LabelsGetter == nil { + return authorization.NewEmptyRestrictedResourceControl(resourceID, resourceType), nil + } + + labels := context.LabelsGetter(item) + if labels == nil { + return authorization.NewEmptyRestrictedResourceControl(resourceID, resourceType), nil + } + + if serviceId, ok := labels[consts.SwarmServiceIDLabel]; ok { + if rc, err := tx.ResourceControl().ResourceControlByResourceIDAndType( + ServiceResourceControlID(swarm.Service{ID: serviceId}), + portainer.ServiceResourceControl, + ); err != nil && !dataservices.IsErrObjectNotFound(err) { + return nil, err + } else if rc != nil { + return rc, nil + } + } + + if stackName, ok := labels[consts.SwarmStackNameLabel]; ok { + if rc, err := tx.ResourceControl().ResourceControlByResourceIDAndType( + StackResourceControlID(endpointID, stackName), + portainer.StackResourceControl, + ); err != nil && !dataservices.IsErrObjectNotFound(err) { + return nil, err + } else if rc != nil { + return rc, nil + } + } + + if stackName, ok := labels[consts.ComposeStackNameLabel]; ok { + if rc, err := tx.ResourceControl().ResourceControlByResourceIDAndType( + StackResourceControlID(endpointID, stackName), + portainer.StackResourceControl, + ); err != nil && !dataservices.IsErrObjectNotFound(err) { + return nil, err + } else if rc != nil { + return rc, nil + } + } + + return rcFromPortainerLabels(tx, labels, resourceID, resourceType) + } +} + +const ( + publicRCLabel = "io.portainer.accesscontrol.public" + userRCLabel = "io.portainer.accesscontrol.users" + teamRCLabel = "io.portainer.accesscontrol.teams" +) + +// translation of rules from transport.newResourceControlFromPortainerLabels(resourceLabelsObject, resourceIdentifier, resourceType) +func rcFromPortainerLabels[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, labels map[string]string, resourceID string, resourceType portainer.ResourceControlType, +) (*portainer.ResourceControl, error) { + if _, ok := labels[publicRCLabel]; ok { + return authorization.NewPublicResourceControl(resourceID, resourceType), nil + } + + teamNames := make([]string, 0) + userNames := make([]string, 0) + + if teams, ok := labels[teamRCLabel]; ok { + teamNames = getUniqueElements(teams) + } + + if users, ok := labels[userRCLabel]; ok { + userNames = getUniqueElements(users) + } + + if len(teamNames) == 0 && len(userNames) == 0 { + return authorization.NewEmptyRestrictedResourceControl(resourceID, resourceType), nil + } + + teamIDs := make([]portainer.TeamID, 0) + userIDs := make([]portainer.UserID, 0) + for _, name := range teamNames { + team, err := tx.Team().TeamByName(name) + if err != nil { + log.Warn(). + Str("name", name). + Str("resource_id", resourceID). + Msg("unknown team name in access control label, ignoring access control rule for this team") + + continue + } + + teamIDs = append(teamIDs, team.ID) + } + + for _, name := range userNames { + userID, err := tx.User().UserIDByUsername(name) + if err != nil { + log.Warn(). + Str("name", name). + Str("resource_id", resourceID). + Msg("unknown user name in access control label, ignoring access control rule for this user") + continue + } + + userIDs = append(userIDs, userID) + } + + return authorization.NewRestrictedResourceControl(resourceID, resourceType, userIDs, teamIDs), nil +} + +func getUniqueElements(items string) []string { + xs := strings.Split(items, ",") + xs = slicesx.Map(xs, strings.TrimSpace) + xs = slicesx.FilterInPlace(xs, func(x string) bool { return len(x) > 0 }) + + return slicesx.Unique(xs) +} diff --git a/api/uac/generic_rc_getter_test.go b/api/uac/generic_rc_getter_test.go new file mode 100644 index 000000000..3f664a2f4 --- /dev/null +++ b/api/uac/generic_rc_getter_test.go @@ -0,0 +1,170 @@ +package uac + +import ( + "testing" + + "github.com/docker/docker/api/types/container" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/stretchr/testify/require" +) + +func TestGenericResourcControlGetter(t *testing.T) { + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + endpointID := portainer.EndpointID(1) + composeStackName := "compose-stack" + composeStackRCID := StackResourceControlID(endpointID, composeStackName) + swarmStackName := "swarm-stack" + swarmStackRCID := StackResourceControlID(endpointID, swarmStackName) + serviceID := "service" + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + // created on container create + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl("container", portainer.ContainerResourceControl))) + // created on compose stack create + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(composeStackRCID, portainer.StackResourceControl))) + // created on swarm stack create + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(swarmStackRCID, portainer.StackResourceControl))) + // created a swarm service create + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(serviceID, portainer.ServiceResourceControl))) + return nil + }) + is.NoError(err) + + err = store.ViewTx(func(tx dataservices.DataStoreTx) error { + context := ResourceContext[container.Summary]{RCType: portainer.ContainerResourceControl, IDGetter: ContainerResourceControlID, LabelsGetter: ContainerLabels} + + // trying to get UAC for a container created through Portainer + rc, err := genericResourcControlGetter(tx, 1, context)(container.Summary{ID: "container"}) + is.NoError(err) + is.NotNil(rc) + + // trying to get UAC for an external container + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ID: "unknown"}) + is.NoError(err) + is.NotNil(rc) + is.Empty(rc.UserAccesses) + is.Empty(rc.TeamAccesses) + + // trying to get UAC for a container from a compose stack + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ID: "by-compose-stack-name-label", Labels: map[string]string{consts.ComposeStackNameLabel: composeStackName}}) + is.NoError(err) + is.NotNil(rc) + is.Equal(composeStackRCID, rc.ResourceID) + + // trying to get UAC for a container from a swarm stack + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ID: "by-swarm-stack-name-label", Labels: map[string]string{consts.SwarmStackNameLabel: swarmStackName}}) + is.NoError(err) + is.NotNil(rc) + is.Equal(swarmStackRCID, rc.ResourceID) + + // trying to get UAC for a container from a swarm service + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ID: "by-swarm-service-id-label", Labels: map[string]string{consts.SwarmServiceIDLabel: serviceID}}) + is.NoError(err) + is.NotNil(rc) + is.Equal(serviceID, rc.ResourceID) + + return nil + }) + + is.NoError(err) + +} + +func TestGenericResourcControlGetterWithPortainerLabels(t *testing.T) { + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.User().Create(&portainer.User{Role: portainer.AdministratorRole, Username: "admin"})) + is.NoError(tx.User().Create(&portainer.User{Role: portainer.AdministratorRole, Username: "user"})) + is.NoError(tx.Team().Create(&portainer.Team{Name: "team"})) + is.NoError(tx.TeamMembership().Create(&portainer.TeamMembership{UserID: 2, TeamID: 1})) + return nil + })) + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + context := ResourceContext[container.Summary]{RCType: portainer.ContainerResourceControl, IDGetter: ContainerResourceControlID, LabelsGetter: ContainerLabels} + + rc, err := genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "public", + Labels: map[string]string{publicRCLabel: "true"}, + }) + is.NoError(err) + is.NotNil(rc) + is.True(rc.Public) + + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "team", + Labels: map[string]string{teamRCLabel: "team"}, + }) + is.NoError(err) + is.NotNil(rc) + is.Contains(rc.TeamAccesses, portainer.TeamResourceAccess{TeamID: 1, AccessLevel: portainer.ReadWriteAccessLevel}) + + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "no-team", + Labels: map[string]string{teamRCLabel: "team2"}, + }) + is.NoError(err) + is.NotNil(rc) + is.Empty(rc.UserAccesses) + is.Empty(rc.TeamAccesses) + + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "user", + Labels: map[string]string{userRCLabel: "user"}, + }) + is.NoError(err) + is.NotNil(rc) + is.Contains(rc.UserAccesses, portainer.UserResourceAccess{UserID: 2, AccessLevel: portainer.ReadWriteAccessLevel}) + + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "no-user", + Labels: map[string]string{userRCLabel: "user2"}, + }) + is.NoError(err) + is.NotNil(rc) + is.Empty(rc.UserAccesses) + is.Empty(rc.TeamAccesses) + + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "warn-on-unknown", + Labels: map[string]string{userRCLabel: "user2,user3", teamRCLabel: "team2,team3"}, + }) + is.NoError(err) + is.NotNil(rc) + is.Empty(rc.UserAccesses) + is.Empty(rc.TeamAccesses) + + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "empty-when-no-label", + }) + is.NoError(err) + is.NotNil(rc) + is.Empty(rc.UserAccesses) + is.Empty(rc.TeamAccesses) + + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "empty-when-empty-labels", + Labels: map[string]string{}, + }) + is.NoError(err) + is.NotNil(rc) + is.Empty(rc.UserAccesses) + is.Empty(rc.TeamAccesses) + + return nil + })) +} diff --git a/api/uac/networks.go b/api/uac/networks.go new file mode 100644 index 000000000..a2537c28f --- /dev/null +++ b/api/uac/networks.go @@ -0,0 +1,30 @@ +package uac + +import ( + "github.com/docker/docker/api/types/network" + portainer "github.com/portainer/portainer/api" +) + +func NetworkResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, +) func(item network.Summary) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[network.Summary]{ + RCType: portainer.NetworkResourceControl, + IDGetter: NetworkResourceControlID, + LabelsGetter: NetworkLabels, + }) +} + +func NetworkResourceControlID(item network.Summary) string { + return item.ID +} + +func NetworkLabels(item network.Summary) map[string]string { + return item.Labels +} diff --git a/api/uac/networks_test.go b/api/uac/networks_test.go new file mode 100644 index 000000000..825b3cff4 --- /dev/null +++ b/api/uac/networks_test.go @@ -0,0 +1,70 @@ +package uac + +import ( + "testing" + + "github.com/docker/docker/api/types/network" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/stretchr/testify/require" +) + +func TestNetworkResourceControlGetter(t *testing.T) { + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + envID := portainer.EndpointID(1) + networkID := "network" + stackName := "stack" + stackRCID := stackutils.ResourceControlID(envID, stackName) + serviceID := "service" + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(networkID, portainer.NetworkResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(stackRCID, portainer.StackResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(serviceID, portainer.ServiceResourceControl))) + return nil + })) + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + // by direct ID + rc, err := NetworkResourceControlGetter(tx, envID)(network.Inspect{ID: networkID}) + is.NoError(err) + is.NotNil(rc) + is.Equal(networkID, rc.ResourceID) + + // by compose stack label + rc, err = NetworkResourceControlGetter(tx, envID)( + network.Inspect{ID: "unknown", Labels: map[string]string{consts.ComposeStackNameLabel: stackName}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by swarm stack label + rc, err = NetworkResourceControlGetter(tx, envID)( + network.Inspect{ID: "unknown", Labels: map[string]string{consts.SwarmStackNameLabel: stackName}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by service ID + rc, err = NetworkResourceControlGetter(tx, envID)( + network.Inspect{ID: "unknown", Labels: map[string]string{consts.SwarmServiceIDLabel: serviceID}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(serviceID, rc.ResourceID) + + return nil + })) + +} diff --git a/api/uac/secrets.go b/api/uac/secrets.go new file mode 100644 index 000000000..39fb65fc0 --- /dev/null +++ b/api/uac/secrets.go @@ -0,0 +1,30 @@ +package uac + +import ( + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" +) + +func SecretResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, +) func(item swarm.Secret) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[swarm.Secret]{ + RCType: portainer.SecretResourceControl, + IDGetter: SecretResourceControlID, + LabelsGetter: SecretLabels, + }) +} + +func SecretResourceControlID(item swarm.Secret) string { + return item.ID +} + +func SecretLabels(item swarm.Secret) map[string]string { + return item.Spec.Labels +} diff --git a/api/uac/secrets_test.go b/api/uac/secrets_test.go new file mode 100644 index 000000000..bbeeeb411 --- /dev/null +++ b/api/uac/secrets_test.go @@ -0,0 +1,70 @@ +package uac + +import ( + "testing" + + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/stretchr/testify/require" +) + +func TestSecretResourceControlGetter(t *testing.T) { + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + envID := portainer.EndpointID(1) + secretID := "secret" + stackName := "stack" + stackRCID := stackutils.ResourceControlID(envID, stackName) + serviceID := "service" + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(secretID, portainer.SecretResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(stackRCID, portainer.StackResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(serviceID, portainer.ServiceResourceControl))) + return nil + })) + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + // by direct ID + rc, err := SecretResourceControlGetter(tx, envID)(swarm.Secret{ID: secretID}) + is.NoError(err) + is.NotNil(rc) + is.Equal(secretID, rc.ResourceID) + + // by compose stack label + rc, err = SecretResourceControlGetter(tx, envID)( + swarm.Secret{ID: "unknown", Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Labels: map[string]string{consts.ComposeStackNameLabel: stackName}}}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by swarm stack label + rc, err = SecretResourceControlGetter(tx, envID)( + swarm.Secret{ID: "unknown", Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Labels: map[string]string{consts.SwarmStackNameLabel: stackName}}}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by service ID + rc, err = SecretResourceControlGetter(tx, envID)( + swarm.Secret{ID: "unknown", Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Labels: map[string]string{consts.SwarmServiceIDLabel: serviceID}}}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(serviceID, rc.ResourceID) + + return nil + })) + +} diff --git a/api/uac/services.go b/api/uac/services.go new file mode 100644 index 000000000..961d6abb7 --- /dev/null +++ b/api/uac/services.go @@ -0,0 +1,30 @@ +package uac + +import ( + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" +) + +func ServiceResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, +) func(item swarm.Service) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[swarm.Service]{ + RCType: portainer.ServiceResourceControl, + IDGetter: ServiceResourceControlID, + LabelsGetter: ServiceLabels, + }) +} + +func ServiceResourceControlID(item swarm.Service) string { + return item.ID +} + +func ServiceLabels(item swarm.Service) map[string]string { + return item.Spec.Labels +} diff --git a/api/uac/stacks.go b/api/uac/stacks.go new file mode 100644 index 000000000..956db612a --- /dev/null +++ b/api/uac/stacks.go @@ -0,0 +1,27 @@ +package uac + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/stacks/stackutils" +) + +func StackResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, +) func(item portainer.Stack) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[portainer.Stack]{ + RCType: portainer.StackResourceControl, + IDGetter: func(s portainer.Stack) string { return StackResourceControlID(s.EndpointID, s.Name) }, + // stacks don't have labels so we don't pass a getter + }) +} + +// TODO: replace usage of stackutils function to this package +func StackResourceControlID(endpointID portainer.EndpointID, name string) string { + return stackutils.ResourceControlID(endpointID, name) +} diff --git a/api/uac/uac.go b/api/uac/uac.go new file mode 100644 index 000000000..3f2e51286 --- /dev/null +++ b/api/uac/uac.go @@ -0,0 +1,53 @@ +package uac + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/slicesx" +) + +// FilterByResourceControl filters a list of items based on the user's role and the resource control associated to the item. +// UAC rules +// * UAC in DB (direct) +// * UAC inherited (from swarm service | swarm stack | compose stack) +// * UAC defined in labels (UAC on external resources) +func FilterByResourceControl[T any]( + items []T, + user *portainer.User, + userMemberships []portainer.TeamMembership, + resourceControlGetter func(item T) (*portainer.ResourceControl, error), +) ([]T, error) { + filteredItems := make([]T, 0) + + if user == nil { + return filteredItems, nil + } + + if canBypassUAC(user) { + return items, nil + } + + userTeamIDs := slicesx.Map(userMemberships, func(membership portainer.TeamMembership) portainer.TeamID { + return membership.TeamID + }) + + for _, item := range items { + rc, err := resourceControlGetter(item) + if err != nil { + return nil, fmt.Errorf("Unable to retrieve resource control: %w", err) + } + + // TODO: move UserCanAccessResource function to the UAC package + if authorization.UserCanAccessResource(user.ID, userTeamIDs, rc) { + filteredItems = append(filteredItems, item) + } + } + + return filteredItems, nil +} + +func canBypassUAC(user *portainer.User) bool { + return user != nil && user.Role == portainer.AdministratorRole +} diff --git a/api/uac/uac_test.go b/api/uac/uac_test.go new file mode 100644 index 000000000..619c023e7 --- /dev/null +++ b/api/uac/uac_test.go @@ -0,0 +1,76 @@ +package uac + +import ( + "testing" + + "github.com/docker/docker/api/types/container" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/stretchr/testify/require" +) + +func TestCanBypassUAC(t *testing.T) { + is := require.New(t) + + admin := &portainer.User{Role: portainer.AdministratorRole} + user := &portainer.User{Role: portainer.StandardUserRole} + + is.False(canBypassUAC(nil)) + is.False(canBypassUAC(user)) + is.True(canBypassUAC(admin)) +} + +func TestFilterByResourceControl(t *testing.T) { + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + endpointID := portainer.EndpointID(1) + stackRCID := StackResourceControlID(endpointID, "stack") + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.ResourceControl().Create(authorization.NewPrivateResourceControl(stackRCID, portainer.StackResourceControl, 2))) + + is.NoError(tx.ResourceControl().Create(authorization.NewAdministratorsOnlyResourceControl("admin", portainer.ContainerResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPrivateResourceControl("private", portainer.ContainerResourceControl, 2))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl("public", portainer.ContainerResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewSystemResourceControl("system", portainer.ContainerResourceControl))) + return nil + })) + + admin := &portainer.User{ID: 1, Role: portainer.AdministratorRole} + std := &portainer.User{ID: 2, Role: portainer.StandardUserRole} + std2 := &portainer.User{ID: 3, Role: portainer.StandardUserRole} + + containers := []container.Summary{{ID: "admin"}, {ID: "private"}, {ID: "public"}, {ID: "system"}, + {ID: "bylabel", Labels: map[string]string{consts.ComposeStackNameLabel: "stack"}}, + } + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + c, err := FilterByResourceControl(containers, admin, []portainer.TeamMembership{}, ContainerResourceControlGetter(tx, endpointID)) + is.NoError(err) + is.Len(c, len(containers)) + + c, err = FilterByResourceControl(containers, std, []portainer.TeamMembership{}, ContainerResourceControlGetter(tx, endpointID)) + is.NoError(err) + is.Len(c, 4) + is.Contains(c, container.Summary{ID: "private"}) + is.Contains(c, container.Summary{ID: "public"}) + is.Contains(c, container.Summary{ID: "system"}) + is.Contains(c, container.Summary{ID: "bylabel", Labels: map[string]string{consts.ComposeStackNameLabel: "stack"}}) + + c, err = FilterByResourceControl(containers, std2, []portainer.TeamMembership{}, ContainerResourceControlGetter(tx, endpointID)) + is.NoError(err) + is.Len(c, 2) + is.Contains(c, container.Summary{ID: "public"}) + is.Contains(c, container.Summary{ID: "system"}) + + return nil + })) + +} diff --git a/api/uac/volumes.go b/api/uac/volumes.go new file mode 100644 index 000000000..34dab92a5 --- /dev/null +++ b/api/uac/volumes.go @@ -0,0 +1,32 @@ +package uac + +import ( + "github.com/docker/docker/api/types/volume" + portainer "github.com/portainer/portainer/api" +) + +func VolumeResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, +) func(item volume.Volume) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[volume.Volume]{ + RCType: portainer.VolumeResourceControl, + IDGetter: VolumeResourceControlID, + LabelsGetter: VolumeLabels, + }) +} + +// TODO: use a copy of getVolumeResourceID() +// or change the key (+ migrate) to not require using the dockerID/swarm nodeID +func VolumeResourceControlID(item volume.Volume) string { + return item.Name +} + +func VolumeLabels(item volume.Volume) map[string]string { + return item.Labels +} diff --git a/api/uac/volumes_test.go b/api/uac/volumes_test.go new file mode 100644 index 000000000..fa825fce7 --- /dev/null +++ b/api/uac/volumes_test.go @@ -0,0 +1,70 @@ +package uac + +import ( + "testing" + + "github.com/docker/docker/api/types/volume" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/stretchr/testify/require" +) + +func TestVolumeResourceControlGetter(t *testing.T) { + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + envID := portainer.EndpointID(1) + volumeName := "volume" + stackName := "stack" + stackRCID := stackutils.ResourceControlID(envID, stackName) + serviceID := "service" + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(volumeName, portainer.VolumeResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(stackRCID, portainer.StackResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(serviceID, portainer.ServiceResourceControl))) + return nil + })) + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + // by direct ID + rc, err := VolumeResourceControlGetter(tx, envID)(volume.Volume{Name: volumeName}) + is.NoError(err) + is.NotNil(rc) + is.Equal(volumeName, rc.ResourceID) + + // by compose stack label + rc, err = VolumeResourceControlGetter(tx, envID)( + volume.Volume{Name: "unknown", Labels: map[string]string{consts.ComposeStackNameLabel: stackName}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by swarm stack label + rc, err = VolumeResourceControlGetter(tx, envID)( + volume.Volume{Name: "unknown", Labels: map[string]string{consts.SwarmStackNameLabel: stackName}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by service ID + rc, err = VolumeResourceControlGetter(tx, envID)( + volume.Volume{Name: "unknown", Labels: map[string]string{consts.SwarmServiceIDLabel: serviceID}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(serviceID, rc.ResourceID) + + return nil + })) + +} diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx index 160e68684..9884048b3 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx @@ -110,7 +110,6 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) { updateInformation: isBE, edgeAsync: getEdgeAsyncValue(connectionTypes), platformTypes, - excludeSnapshotRaw: true, }; const queryWithSort = { diff --git a/app/react/portainer/environments/environment.service/index.ts b/app/react/portainer/environments/environment.service/index.ts index 6d4e22d63..7989da6ee 100644 --- a/app/react/portainer/environments/environment.service/index.ts +++ b/app/react/portainer/environments/environment.service/index.ts @@ -42,7 +42,6 @@ export interface BaseEnvironmentsQueryParams { edgeAsync?: boolean; edgeDeviceUntrusted?: boolean; excludeSnapshots?: boolean; - excludeSnapshotRaw?: boolean; provisioned?: boolean; name?: string; agentVersions?: string[]; @@ -121,14 +120,10 @@ export async function getAgentVersions() { } } -export async function getEndpoint( - id: EnvironmentId, - excludeSnapshot = true, - excludeSnapshotRaw = true -) { +export async function getEndpoint(id: EnvironmentId, excludeSnapshot = true) { try { const { data: endpoint } = await axios.get(buildUrl(id), { - params: { excludeSnapshot, excludeSnapshotRaw }, + params: { excludeSnapshot }, }); return endpoint; } catch (e) { diff --git a/app/react/portainer/environments/queries/useEnvironment.ts b/app/react/portainer/environments/queries/useEnvironment.ts index b2b8fd6bd..8ad47b79a 100644 --- a/app/react/portainer/environments/queries/useEnvironment.ts +++ b/app/react/portainer/environments/queries/useEnvironment.ts @@ -13,17 +13,11 @@ export function useEnvironment( options?: { autoRefreshRate?: number; excludeSnapshot?: boolean; - excludeSnapshotRaw?: boolean; } ) { return useQuery( environmentQueryKeys.item(environmentId!), - () => - getEndpoint( - environmentId!, - options?.excludeSnapshot ?? undefined, - options?.excludeSnapshotRaw ?? undefined - ), + () => getEndpoint(environmentId!, options?.excludeSnapshot ?? undefined), { select, ...withError('Failed loading environment'),