diff --git a/api/http/handler/edgestacks/edgestack_status_update.go b/api/http/handler/edgestacks/edgestack_status_update.go index 2deb5a44af..d05b7a3b3d 100644 --- a/api/http/handler/edgestacks/edgestack_status_update.go +++ b/api/http/handler/edgestacks/edgestack_status_update.go @@ -90,6 +90,17 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req return httperror.InternalServerError("Unable to retrieve Edge stack from the database", err) } + relation, err := tx.EndpointRelation().EndpointRelation(payload.EndpointID) + if err != nil && !dataservices.IsErrObjectNotFound(err) { + return httperror.InternalServerError("Unable to retrieve relation object from the database", fmt.Errorf("%w. Environment ID: %d", err, payload.EndpointID)) + } + + // Removal reports must still go through after the relation was cleared. + isRemovalReport := *payload.Status == portainer.EdgeStackStatusRemoved || *payload.Status == portainer.EdgeStackStatusRemoving + if (relation == nil || !relation.EdgeStacks[stack.ID]) && !isRemovalReport { + return httperror.Forbidden("Permission denied to update status for this Edge stack", fmt.Errorf("Edge stack %d is not assigned to environment %d", stack.ID, payload.EndpointID)) + } + if err := handler.updateEdgeStackStatus(tx, stack, stack.ID, payload); err != nil { return httperror.InternalServerError("Unable to update Edge stack status", err) } diff --git a/api/http/handler/edgestacks/edgestack_status_update_test.go b/api/http/handler/edgestacks/edgestack_status_update_test.go index 2017363d0a..272ad817cf 100644 --- a/api/http/handler/edgestacks/edgestack_status_update_test.go +++ b/api/http/handler/edgestacks/edgestack_status_update_test.go @@ -78,6 +78,72 @@ func TestUpdateStatusAndInspect(t *testing.T) { } } +func TestUpdateStatusForUnassignedEdgeStack(t *testing.T) { + t.Parallel() + handler, _ := setupHandler(t) + + endpoint := createEndpoint(t, handler.DataStore) + unrelatedEndpoint := createEndpointWithId(t, handler.DataStore, 6) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + newStatus := portainer.EdgeStackStatusRunning + payload := updateStatusPayload{ + Status: &newStatus, + EndpointID: unrelatedEndpoint.ID, + } + + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + r := bytes.NewBuffer(jsonPayload) + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r) + require.NoError(t, err) + + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, unrelatedEndpoint.EdgeID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusForbidden, rec.Code) + + _, err = handler.DataStore.EdgeStackStatus().Read(edgeStack.ID, unrelatedEndpoint.ID) + require.True(t, handler.DataStore.IsErrObjectNotFound(err)) +} + +func TestUpdateStatusRemovedAllowedAfterUnassignment(t *testing.T) { + t.Parallel() + handler, _ := setupHandler(t) + + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + // Simulate the endpoint being unassigned from the stack (e.g. edge group changed) + // before the agent has torn down and reported its final status back. + relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpoint.ID) + require.NoError(t, err) + + delete(relation.EdgeStacks, edgeStack.ID) + require.NoError(t, handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)) + + newStatus := portainer.EdgeStackStatusRemoved + payload := updateStatusPayload{ + Status: &newStatus, + EndpointID: endpoint.ID, + } + + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + r := bytes.NewBuffer(jsonPayload) + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r) + require.NoError(t, err) + + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) +} + func TestUpdateStatusWithInvalidPayload(t *testing.T) { t.Parallel() handler, _ := setupHandler(t) diff --git a/api/http/handler/endpointedge/endpointedge_stack_inspect.go b/api/http/handler/endpointedge/endpointedge_stack_inspect.go index 70e5f5a5d8..b5b70bc77b 100644 --- a/api/http/handler/endpointedge/endpointedge_stack_inspect.go +++ b/api/http/handler/endpointedge/endpointedge_stack_inspect.go @@ -68,6 +68,15 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http. // WARNING: this variable must not be mutated edgeStack := s.(*portainer.EdgeStack) + relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpoint.ID) + if err != nil && !handler.DataStore.IsErrObjectNotFound(err) { + return httperror.InternalServerError("Unable to retrieve relation object from the database", fmt.Errorf("%w. Environment ID: %d", err, endpoint.ID)) + } + + if relation == nil || !relation.EdgeStacks[edgeStack.ID] { + return httperror.Forbidden("Permission denied to access this Edge stack", fmt.Errorf("Edge stack %d is not assigned to environment %d", edgeStack.ID, endpoint.ID)) + } + fileName := edgeStack.EntryPoint if endpointutils.IsDockerEndpoint(endpoint) { if fileName == "" { diff --git a/api/http/handler/endpointedge/endpointedge_stack_inspect_test.go b/api/http/handler/endpointedge/endpointedge_stack_inspect_test.go new file mode 100644 index 0000000000..9bbb5fdf19 --- /dev/null +++ b/api/http/handler/endpointedge/endpointedge_stack_inspect_test.go @@ -0,0 +1,84 @@ +package endpointedge + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/stretchr/testify/require" +) + +func TestEndpointEdgeStackInspectRejectsUnassignedStack(t *testing.T) { + t.Parallel() + handler := mustSetupHandler(t) + + endpointID := portainer.EndpointID(10) + require.NoError(t, createEndpoint(handler, portainer.Endpoint{ + ID: endpointID, + Name: "endpoint-10", + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "https://portainer.io:9443", + EdgeID: "edge-id", + }, portainer.EndpointRelation{EndpointID: endpointID})) + + edgeStackID := portainer.EdgeStackID(99) + edgeStack := &portainer.EdgeStack{ + ID: edgeStackID, + Name: "unassigned-stack", + EntryPoint: "docker-compose.yml", + } + require.NoError(t, handler.DataStore.EdgeStack().Create(edgeStackID, edgeStack)) + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/endpoints/%d/edge/stacks/%d", endpointID, edgeStackID), nil) + require.NoError(t, err) + + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "edge-id") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusForbidden, rec.Code) +} + +func TestEndpointEdgeStackInspectAllowsAssignedStack(t *testing.T) { + t.Parallel() + handler := mustSetupHandler(t) + + fileName := "docker-compose.yml" + edgeStackID := portainer.EdgeStackID(100) + + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(strconv.Itoa(int(edgeStackID)), fileName, []byte("version: '3'")) + require.NoError(t, err) + + edgeStack := &portainer.EdgeStack{ + ID: edgeStackID, + Name: "assigned-stack", + EntryPoint: fileName, + ProjectPath: projectPath, + } + require.NoError(t, handler.DataStore.EdgeStack().Create(edgeStackID, edgeStack)) + + endpointID := portainer.EndpointID(11) + require.NoError(t, createEndpoint(handler, portainer.Endpoint{ + ID: endpointID, + Name: "endpoint-11", + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "https://portainer.io:9443", + EdgeID: "edge-id", + }, portainer.EndpointRelation{ + EndpointID: endpointID, + EdgeStacks: map[portainer.EdgeStackID]bool{edgeStackID: true}, + })) + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/endpoints/%d/edge/stacks/%d", endpointID, edgeStackID), nil) + require.NoError(t, err) + + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "edge-id") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) +}