diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 19f2440a4..111d61d6f 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -416,14 +416,15 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { log.Fatal(err) } kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager() - proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, authorizationService) + + userActivityStore := initUserActivityStore(*flags.Data) + + proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, authorizationService, userActivityStore) composeStackManager := initComposeStackManager(*flags.Assets, *flags.Data, reverseTunnelService, proxyManager) kubernetesDeployer := initKubernetesDeployer(dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets) - userActivityStore := initUserActivityStore(*flags.Data) - if dataStore.IsNew() { err = updateSettingsFromFlags(dataStore, flags) if err != nil { diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go index af1a81e48..0c65b6c96 100644 --- a/api/http/handler/customtemplates/customtemplate_create.go +++ b/api/http/handler/customtemplates/customtemplate_create.go @@ -12,6 +12,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/authorization" ) @@ -23,7 +24,7 @@ func (handler *Handler) customTemplateCreate(w http.ResponseWriter, r *http.Requ tokenData, err := security.RetrieveTokenData(r) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user details from authentication token", err} + return &httperror.HandlerError{http.StatusInternalServerError, "unable to retrieve user details from authentication token", err} } customTemplate, err := handler.createCustomTemplate(method, r) @@ -128,6 +129,8 @@ func (handler *Handler) createCustomTemplateFromFileContent(r *http.Request) (*p } customTemplate.ProjectPath = projectPath + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return customTemplate, nil } @@ -207,6 +210,8 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) ( return nil, err } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return customTemplate, nil } @@ -286,5 +291,7 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po } customTemplate.ProjectPath = projectPath + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return customTemplate, nil } diff --git a/api/http/handler/customtemplates/customtemplate_delete.go b/api/http/handler/customtemplates/customtemplate_delete.go index 24500894a..e5e41e015 100644 --- a/api/http/handler/customtemplates/customtemplate_delete.go +++ b/api/http/handler/customtemplates/customtemplate_delete.go @@ -11,6 +11,7 @@ import ( bolterrors "github.com/portainer/portainer/api/bolt/errors" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" ) func (handler *Handler) customTemplateDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -58,6 +59,8 @@ func (handler *Handler) customTemplateDelete(w http.ResponseWriter, r *http.Requ } } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, nil) + return response.Empty(w) } diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go index fd10d6e23..cbc6bee59 100644 --- a/api/http/handler/customtemplates/customtemplate_update.go +++ b/api/http/handler/customtemplates/customtemplate_update.go @@ -14,6 +14,7 @@ import ( portainer "github.com/portainer/portainer/api" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" ) type customTemplateUpdatePayload struct { @@ -103,5 +104,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist custom template changes inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, customTemplate) } diff --git a/api/http/handler/customtemplates/handler.go b/api/http/handler/customtemplates/handler.go index ff8fce4a5..29b831d69 100644 --- a/api/http/handler/customtemplates/handler.go +++ b/api/http/handler/customtemplates/handler.go @@ -5,17 +5,22 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" ) +const ( + handlerActivityContext = "Portainer" +) + // Handler is the HTTP handler used to handle endpoint group operations. type Handler struct { *mux.Router - DataStore portainer.DataStore - FileService portainer.FileService - GitService portainer.GitService + DataStore portainer.DataStore + FileService portainer.FileService + GitService portainer.GitService + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage endpoint group operations. diff --git a/api/http/handler/dockerhub/dockerhub_update.go b/api/http/handler/dockerhub/dockerhub_update.go index e12d3ad24..545821044 100644 --- a/api/http/handler/dockerhub/dockerhub_update.go +++ b/api/http/handler/dockerhub/dockerhub_update.go @@ -8,7 +8,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/useractivity" + consts "github.com/portainer/portainer/api/useractivity" ) type dockerhubUpdatePayload struct { @@ -49,5 +51,8 @@ func (handler *Handler) dockerhubUpdate(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Dockerhub changes inside the database", err} } + payload.Password = consts.RedactedValue + useractivity.LogHttpActivity(handler.UserActivityStore, "Portainer", r, payload) + return response.Empty(w) } diff --git a/api/http/handler/dockerhub/handler.go b/api/http/handler/dockerhub/handler.go index f1328acb8..055deb573 100644 --- a/api/http/handler/dockerhub/handler.go +++ b/api/http/handler/dockerhub/handler.go @@ -5,7 +5,7 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" ) @@ -16,7 +16,8 @@ func hideFields(dockerHub *portainer.DockerHub) { // Handler is the HTTP handler used to handle DockerHub operations. type Handler struct { *mux.Router - DataStore portainer.DataStore + DataStore portainer.DataStore + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage Dockerhub operations. diff --git a/api/http/handler/edgegroups/edgegroup_create.go b/api/http/handler/edgegroups/edgegroup_create.go index 3e767891e..32b2c7f80 100644 --- a/api/http/handler/edgegroups/edgegroup_create.go +++ b/api/http/handler/edgegroups/edgegroup_create.go @@ -8,7 +8,8 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/useractivity" ) type edgeGroupCreatePayload struct { @@ -80,5 +81,7 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Edge group inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, "Portainer", r, payload) + return response.JSON(w, edgeGroup) } diff --git a/api/http/handler/edgegroups/edgegroup_delete.go b/api/http/handler/edgegroups/edgegroup_delete.go index a45486bfc..28dc4e296 100644 --- a/api/http/handler/edgegroups/edgegroup_delete.go +++ b/api/http/handler/edgegroups/edgegroup_delete.go @@ -9,6 +9,7 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -42,6 +43,8 @@ func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the Edge group from the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, "Portainer", r, nil) + return response.Empty(w) } diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go index e71f48457..1e83bb3c6 100644 --- a/api/http/handler/edgegroups/edgegroup_update.go +++ b/api/http/handler/edgegroups/edgegroup_update.go @@ -8,8 +8,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/edge" ) @@ -115,6 +116,8 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) } } + useractivity.LogHttpActivity(handler.UserActivityStore, "Portainer", r, payload) + return response.JSON(w, edgeGroup) } diff --git a/api/http/handler/edgegroups/handler.go b/api/http/handler/edgegroups/handler.go index 374ca4ab2..d072a8cca 100644 --- a/api/http/handler/edgegroups/handler.go +++ b/api/http/handler/edgegroups/handler.go @@ -5,14 +5,15 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" ) // Handler is the HTTP handler used to handle endpoint group operations. type Handler struct { *mux.Router - DataStore portainer.DataStore + DataStore portainer.DataStore + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage endpoint group operations. diff --git a/api/http/handler/edgejobs/edgejob_create.go b/api/http/handler/edgejobs/edgejob_create.go index e329316a7..202a9b777 100644 --- a/api/http/handler/edgejobs/edgejob_create.go +++ b/api/http/handler/edgejobs/edgejob_create.go @@ -11,7 +11,8 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/useractivity" ) // POST /api/edge_jobs?method=file|string @@ -77,6 +78,8 @@ func (handler *Handler) createEdgeJobFromFileContent(w http.ResponseWriter, r *h return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule Edge job", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, "Portainer", r, payload) + return response.JSON(w, edgeJob) } @@ -135,6 +138,8 @@ func (handler *Handler) createEdgeJobFromFile(w http.ResponseWriter, r *http.Req return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule Edge job", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, "Portainer", r, payload) + return response.JSON(w, edgeJob) } diff --git a/api/http/handler/edgejobs/edgejob_delete.go b/api/http/handler/edgejobs/edgejob_delete.go index 66efdd55e..6eb9f6f03 100644 --- a/api/http/handler/edgejobs/edgejob_delete.go +++ b/api/http/handler/edgejobs/edgejob_delete.go @@ -7,8 +7,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) func (handler *Handler) edgeJobDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -37,5 +38,7 @@ func (handler *Handler) edgeJobDelete(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the Edge job from the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, "Portainer", r, nil) + return response.Empty(w) } diff --git a/api/http/handler/edgejobs/edgejob_tasklogs_clear.go b/api/http/handler/edgejobs/edgejob_tasklogs_clear.go index e49f3c978..5d57e8497 100644 --- a/api/http/handler/edgejobs/edgejob_tasklogs_clear.go +++ b/api/http/handler/edgejobs/edgejob_tasklogs_clear.go @@ -7,8 +7,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) // DELETE request on /api/edge_jobs/:id/tasks/:taskID/logs @@ -49,5 +50,7 @@ func (handler *Handler) edgeJobTasksClear(w http.ResponseWriter, r *http.Request return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Edge job changes in the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, "Portainer", r, nil) + return response.Empty(w) } diff --git a/api/http/handler/edgejobs/edgejob_tasklogs_collect.go b/api/http/handler/edgejobs/edgejob_tasklogs_collect.go index cb09aa2db..117761ff6 100644 --- a/api/http/handler/edgejobs/edgejob_tasklogs_collect.go +++ b/api/http/handler/edgejobs/edgejob_tasklogs_collect.go @@ -6,8 +6,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) // POST request on /api/edge_jobs/:id/tasks/:taskID/logs @@ -43,5 +44,7 @@ func (handler *Handler) edgeJobTasksCollect(w http.ResponseWriter, r *http.Reque return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Edge job changes in the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, "Portainer", r, nil) + return response.Empty(w) } diff --git a/api/http/handler/edgejobs/edgejob_update.go b/api/http/handler/edgejobs/edgejob_update.go index 26d756320..7bea82822 100644 --- a/api/http/handler/edgejobs/edgejob_update.go +++ b/api/http/handler/edgejobs/edgejob_update.go @@ -9,8 +9,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) type edgeJobUpdatePayload struct { @@ -57,6 +58,8 @@ func (handler *Handler) edgeJobUpdate(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Edge job changes inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, "Portainer", r, payload) + return response.JSON(w, edgeJob) } diff --git a/api/http/handler/edgejobs/handler.go b/api/http/handler/edgejobs/handler.go index 35800b6e3..c48dae07e 100644 --- a/api/http/handler/edgejobs/handler.go +++ b/api/http/handler/edgejobs/handler.go @@ -5,7 +5,7 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" ) @@ -15,6 +15,7 @@ type Handler struct { DataStore portainer.DataStore FileService portainer.FileService ReverseTunnelService portainer.ReverseTunnelService + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage Edge job operations. diff --git a/api/http/handler/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go index 0f3224e95..428940d60 100644 --- a/api/http/handler/edgestacks/edgestack_create.go +++ b/api/http/handler/edgestacks/edgestack_create.go @@ -11,8 +11,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/edge" ) @@ -128,6 +129,8 @@ func (handler *Handler) createSwarmStackFromFileContent(r *http.Request) (*porta return nil, err } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return stack, nil } @@ -206,6 +209,8 @@ func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*por return nil, err } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return stack, nil } @@ -272,6 +277,8 @@ func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request) (*portai return nil, err } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return stack, nil } diff --git a/api/http/handler/edgestacks/edgestack_delete.go b/api/http/handler/edgestacks/edgestack_delete.go index ee01443f5..f0ff35143 100644 --- a/api/http/handler/edgestacks/edgestack_delete.go +++ b/api/http/handler/edgestacks/edgestack_delete.go @@ -6,8 +6,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/edge" ) @@ -60,5 +61,7 @@ func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request) } } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, nil) + return response.Empty(w) } diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go index ccc65bf44..383625b6b 100644 --- a/api/http/handler/edgestacks/edgestack_update.go +++ b/api/http/handler/edgestacks/edgestack_update.go @@ -9,8 +9,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/edge" ) @@ -145,6 +146,8 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, stack) } diff --git a/api/http/handler/edgestacks/handler.go b/api/http/handler/edgestacks/handler.go index 2e0580d6d..b8223426f 100644 --- a/api/http/handler/edgestacks/handler.go +++ b/api/http/handler/edgestacks/handler.go @@ -5,17 +5,22 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" ) +const ( + handlerActivityContext = "Portainer" +) + // Handler is the HTTP handler used to handle endpoint group operations. type Handler struct { *mux.Router - requestBouncer *security.RequestBouncer - DataStore portainer.DataStore - FileService portainer.FileService - GitService portainer.GitService + requestBouncer *security.RequestBouncer + DataStore portainer.DataStore + FileService portainer.FileService + GitService portainer.GitService + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage endpoint group operations. diff --git a/api/http/handler/endpointgroups/endpointgroup_create.go b/api/http/handler/endpointgroups/endpointgroup_create.go index f50bf9736..1fd2ba967 100644 --- a/api/http/handler/endpointgroups/endpointgroup_create.go +++ b/api/http/handler/endpointgroups/endpointgroup_create.go @@ -8,7 +8,8 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/useractivity" ) type endpointGroupCreatePayload struct { @@ -88,5 +89,7 @@ func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Reque } } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, endpointGroup) } diff --git a/api/http/handler/endpointgroups/endpointgroup_delete.go b/api/http/handler/endpointgroups/endpointgroup_delete.go index 39ab4ba1e..74fe43aa9 100644 --- a/api/http/handler/endpointgroups/endpointgroup_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_delete.go @@ -7,8 +7,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) // DELETE request on /api/endpoint_groups/:id @@ -77,5 +78,7 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque } } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, nil) + return response.Empty(w) } diff --git a/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go b/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go index cce2e737a..d45fc1eb1 100644 --- a/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go +++ b/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go @@ -6,8 +6,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) // PUT request on /api/endpoint_groups/:id/endpoints/:endpointId @@ -48,5 +49,7 @@ func (handler *Handler) endpointGroupAddEndpoint(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, endpoint.Name) + return response.Empty(w) } diff --git a/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go b/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go index 595be6df9..1b8befade 100644 --- a/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go @@ -6,8 +6,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) // DELETE request on /api/endpoint_groups/:id/endpoints/:endpointId @@ -48,5 +49,7 @@ func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *ht return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, endpoint.Name) + return response.Empty(w) } diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go index 72cf3b3f6..953639084 100644 --- a/api/http/handler/endpointgroups/endpointgroup_update.go +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -7,8 +7,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/tag" ) @@ -132,5 +133,7 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque } } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, endpointGroup) } diff --git a/api/http/handler/endpointgroups/handler.go b/api/http/handler/endpointgroups/handler.go index f17538b46..591580d33 100644 --- a/api/http/handler/endpointgroups/handler.go +++ b/api/http/handler/endpointgroups/handler.go @@ -5,16 +5,21 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" ) +const ( + handlerActivityContext = "Portainer" +) + // Handler is the HTTP handler used to handle endpoint group operations. type Handler struct { *mux.Router AuthorizationService *authorization.Service DataStore portainer.DataStore + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage endpoint group operations. diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index bbdb0ca11..5d177e08d 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -17,7 +17,9 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/http/client" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/edge" + consts "github.com/portainer/portainer/api/useractivity" ) type endpointCreatePayload struct { @@ -194,6 +196,9 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) * handler.AuthorizationService.TriggerUsersAuthUpdate() + payload.AzureAuthenticationKey = consts.RedactedValue + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + return response.JSON(w, endpoint) } diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index d16e2f50d..d1de6d466 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -7,8 +7,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) // DELETE request on /api/endpoints/:id @@ -98,9 +99,11 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * } } } - + handler.AuthorizationService.TriggerUsersAuthUpdate() + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, nil) + return response.Empty(w) } diff --git a/api/http/handler/endpoints/endpoint_extension_add.go b/api/http/handler/endpoints/endpoint_extension_add.go index e99e10caa..303b3cef1 100644 --- a/api/http/handler/endpoints/endpoint_extension_add.go +++ b/api/http/handler/endpoints/endpoint_extension_add.go @@ -10,8 +10,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) type endpointExtensionAddPayload struct { @@ -73,5 +74,7 @@ func (handler *Handler) endpointExtensionAdd(w http.ResponseWriter, r *http.Requ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + return response.JSON(w, extension) } diff --git a/api/http/handler/endpoints/endpoint_extension_remove.go b/api/http/handler/endpoints/endpoint_extension_remove.go index 99edf1bc8..d814a9f9d 100644 --- a/api/http/handler/endpoints/endpoint_extension_remove.go +++ b/api/http/handler/endpoints/endpoint_extension_remove.go @@ -8,8 +8,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) // DELETE request on /api/endpoints/:id/extensions/:extensionType @@ -42,5 +43,7 @@ func (handler *Handler) endpointExtensionRemove(w http.ResponseWriter, r *http.R return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, nil) + return response.Empty(w) } diff --git a/api/http/handler/endpoints/endpoint_force_update_service.go b/api/http/handler/endpoints/endpoint_force_update_service.go index babb4948f..0f7d25a7a 100644 --- a/api/http/handler/endpoints/endpoint_force_update_service.go +++ b/api/http/handler/endpoints/endpoint_force_update_service.go @@ -2,15 +2,17 @@ package endpoints import ( "context" - dockertypes "github.com/docker/docker/api/types" "net/http" "strings" + dockertypes "github.com/docker/docker/api/types" + httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) type forceUpdateServicePayload struct { @@ -65,9 +67,11 @@ func (handler *Handler) endpointForceUpdateService(w http.ResponseWriter, r *htt } newService, err := dockerClient.ServiceUpdate(context.Background(), payload.ServiceID, service.Version, service.Spec, dockertypes.ServiceUpdateOptions{QueryRegistry: true}) - if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Error force update service", err} } + + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + return response.JSON(w, newService) } diff --git a/api/http/handler/endpoints/endpoint_pools_access_update.go b/api/http/handler/endpoints/endpoint_pools_access_update.go index 68f153b9b..538bf9bc7 100644 --- a/api/http/handler/endpoints/endpoint_pools_access_update.go +++ b/api/http/handler/endpoints/endpoint_pools_access_update.go @@ -1,24 +1,25 @@ package endpoints import ( - "net/http" - "fmt" "errors" + "fmt" + "net/http" "strings" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" ) type resourcePoolUpdatePayload struct { - UsersToAdd []int - TeamsToAdd []int - UsersToRemove []int - TeamsToRemove []int + UsersToAdd []int + TeamsToAdd []int + UsersToRemove []int + TeamsToRemove []int } func (payload *resourcePoolUpdatePayload) Validate(r *http.Request) error { @@ -117,7 +118,7 @@ func (handler *Handler) endpointPoolsAccessUpdate(w http.ResponseWriter, r *http } } - if (payload.TeamsToAdd != nil && len(payload.TeamsToAdd) > 0) || + if (payload.TeamsToAdd != nil && len(payload.TeamsToAdd) > 0) || (payload.TeamsToRemove != nil && len(payload.TeamsToRemove) > 0) { handler.AuthorizationService.TriggerEndpointAuthUpdate(endpointID) } @@ -127,5 +128,7 @@ func (handler *Handler) endpointPoolsAccessUpdate(w http.ResponseWriter, r *http return &httperror.HandlerError{http.StatusInternalServerError, "There are 1 or more errors when updating resource pool access", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + return response.Empty(w) } diff --git a/api/http/handler/endpoints/endpoint_settings_update.go b/api/http/handler/endpoints/endpoint_settings_update.go index 1011cf500..8684e4e7f 100644 --- a/api/http/handler/endpoints/endpoint_settings_update.go +++ b/api/http/handler/endpoints/endpoint_settings_update.go @@ -8,6 +8,7 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) type endpointSettingsUpdatePayload struct { @@ -93,6 +94,8 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re return &httperror.HandlerError{http.StatusInternalServerError, "Failed persisting endpoint in database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + if updateAuthorizations { err := handler.AuthorizationService.UpdateUsersAuthorizations() if err != nil { diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index e834fea5f..006d020c4 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -6,8 +6,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/snapshot" ) @@ -49,5 +50,7 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, nil) + return response.Empty(w) } diff --git a/api/http/handler/endpoints/endpoint_snapshots.go b/api/http/handler/endpoints/endpoint_snapshots.go index 3fd83d071..ac49e5928 100644 --- a/api/http/handler/endpoints/endpoint_snapshots.go +++ b/api/http/handler/endpoints/endpoint_snapshots.go @@ -6,7 +6,8 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/snapshot" ) @@ -45,5 +46,7 @@ func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request } } + useractivity.LogHttpActivity(handler.UserActivityStore, "Portainer", r, nil) + return response.Empty(w) } diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index f985deb41..b9685450e 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -13,8 +13,10 @@ import ( bolterrors "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/http/client" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/edge" "github.com/portainer/portainer/api/internal/tag" + consts "github.com/portainer/portainer/api/useractivity" ) type endpointUpdatePayload struct { @@ -306,5 +308,9 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * } } + redacted := consts.RedactedValue + payload.AzureAuthenticationKey = &redacted + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + return response.JSON(w, endpoint) } diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 0e518eb80..905413542 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -1,9 +1,10 @@ package endpoints import ( - "github.com/portainer/portainer/api/docker" "net/http" + "github.com/portainer/portainer/api/docker" + "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" portainer "github.com/portainer/portainer/api" @@ -33,6 +34,7 @@ type Handler struct { K8sClientFactory *cli.ClientFactory ComposeStackManager portainer.ComposeStackManager DockerClientFactory *docker.ClientFactory + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage endpoint operations. diff --git a/api/http/handler/licenses/handler.go b/api/http/handler/licenses/handler.go index c81dc9cdf..47844b070 100644 --- a/api/http/handler/licenses/handler.go +++ b/api/http/handler/licenses/handler.go @@ -5,14 +5,19 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" ) +const ( + handlerActivityContext = "Portainer" +) + // Handler is the HTTP handler used to handle Edge job operations. type Handler struct { *mux.Router - LicenseService portainer.LicenseService + LicenseService portainer.LicenseService + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage Edge job operations. diff --git a/api/http/handler/licenses/licenses_attach.go b/api/http/handler/licenses/licenses_attach.go index 6d4174d0e..087f6426b 100644 --- a/api/http/handler/licenses/licenses_attach.go +++ b/api/http/handler/licenses/licenses_attach.go @@ -8,6 +8,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/liblicense" + "github.com/portainer/portainer/api/http/useractivity" ) type ( @@ -52,5 +53,7 @@ func (handler *Handler) licensesAttach(w http.ResponseWriter, r *http.Request) * resp.Licenses = append(resp.Licenses, license) } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, resp) } diff --git a/api/http/handler/licenses/licenses_delete.go b/api/http/handler/licenses/licenses_delete.go index 8c933a5d3..f31498502 100644 --- a/api/http/handler/licenses/licenses_delete.go +++ b/api/http/handler/licenses/licenses_delete.go @@ -7,6 +7,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api/http/useractivity" ) type ( @@ -46,5 +47,7 @@ func (handler *Handler) licensesDelete(w http.ResponseWriter, r *http.Request) * } } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, resp) } diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 382597135..93d6a12fc 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -5,12 +5,16 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/registryproxy" "github.com/portainer/portainer/api/http/security" ) +const ( + handlerActivityContext = "Portainer" +) + func hideFields(registry *portainer.Registry) { registry.Password = "" registry.ManagementConfiguration = nil @@ -22,17 +26,19 @@ type Handler struct { requestBouncer *security.RequestBouncer registryProxyService *registryproxy.Service - DataStore portainer.DataStore - FileService portainer.FileService - ProxyManager *proxy.Manager + DataStore portainer.DataStore + FileService portainer.FileService + ProxyManager *proxy.Manager + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage registry operations. -func NewHandler(bouncer *security.RequestBouncer) *Handler { +func NewHandler(bouncer *security.RequestBouncer, userActivityStore portainer.UserActivityStore) *Handler { h := &Handler{ Router: mux.NewRouter(), requestBouncer: bouncer, - registryProxyService: registryproxy.NewService(), + registryProxyService: registryproxy.NewService(userActivityStore), + UserActivityStore: userActivityStore, } h.Handle("/registries", diff --git a/api/http/handler/registries/registry_configure.go b/api/http/handler/registries/registry_configure.go index 16bc4a30f..5e953c609 100644 --- a/api/http/handler/registries/registry_configure.go +++ b/api/http/handler/registries/registry_configure.go @@ -8,8 +8,10 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" + consts "github.com/portainer/portainer/api/useractivity" ) type registryConfigurePayload struct { @@ -135,5 +137,8 @@ func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err} } + payload.Password = consts.RedactedValue + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.Empty(w) } diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go index fc0444e42..a1dff207b 100644 --- a/api/http/handler/registries/registry_create.go +++ b/api/http/handler/registries/registry_create.go @@ -8,7 +8,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/useractivity" + consts "github.com/portainer/portainer/api/useractivity" ) type registryCreatePayload struct { @@ -61,6 +63,9 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the registry inside the database", err} } + payload.Password = consts.RedactedValue + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + hideFields(registry) return response.JSON(w, registry) } diff --git a/api/http/handler/registries/registry_delete.go b/api/http/handler/registries/registry_delete.go index 877ca4a39..7924d7a31 100644 --- a/api/http/handler/registries/registry_delete.go +++ b/api/http/handler/registries/registry_delete.go @@ -6,8 +6,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) // DELETE request on /api/registries/:id @@ -29,5 +30,7 @@ func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the registry from the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, nil) + return response.Empty(w) } diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go index b4a166d73..7090e117e 100644 --- a/api/http/handler/registries/registry_update.go +++ b/api/http/handler/registries/registry_update.go @@ -9,16 +9,18 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + useractivityhttp "github.com/portainer/portainer/api/http/useractivity" + "github.com/portainer/portainer/api/useractivity" ) type registryUpdatePayload struct { - Name *string - URL *string - Authentication *bool - Username *string - Password *string - UserAccessPolicies portainer.UserAccessPolicies - TeamAccessPolicies portainer.TeamAccessPolicies + Name *string `json:",omitempty"` + URL *string `json:",omitempty"` + Authentication *bool `json:",omitempty"` + Username *string `json:",omitempty"` + Password *string `json:",omitempty"` + UserAccessPolicies portainer.UserAccessPolicies `json:",omitempty"` + TeamAccessPolicies portainer.TeamAccessPolicies `json:",omitempty"` } func (payload *registryUpdatePayload) Validate(r *http.Request) error { @@ -95,6 +97,12 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err} } + if payload.Password != nil { + *payload.Password = useractivity.RedactedValue + } + + useractivityhttp.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, registry) } diff --git a/api/http/handler/resourcecontrols/handler.go b/api/http/handler/resourcecontrols/handler.go index d0dc65b19..de9595ee8 100644 --- a/api/http/handler/resourcecontrols/handler.go +++ b/api/http/handler/resourcecontrols/handler.go @@ -5,14 +5,19 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" ) +const ( + handlerActivityContext = "Portainer" +) + // Handler is the HTTP handler used to handle resource control operations. type Handler struct { *mux.Router - DataStore portainer.DataStore + DataStore portainer.DataStore + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage resource control operations. diff --git a/api/http/handler/resourcecontrols/resourcecontrol_create.go b/api/http/handler/resourcecontrols/resourcecontrol_create.go index 115ed38f9..2e851a23d 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_create.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_create.go @@ -8,7 +8,8 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/useractivity" ) type resourceControlCreatePayload struct { @@ -116,5 +117,7 @@ func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Req return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the resource control inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, resourceControl) } diff --git a/api/http/handler/resourcecontrols/resourcecontrol_delete.go b/api/http/handler/resourcecontrols/resourcecontrol_delete.go index 394974923..5cbc85254 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_delete.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_delete.go @@ -6,8 +6,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) // DELETE request on /api/resource_controls/:id @@ -29,5 +30,7 @@ func (handler *Handler) resourceControlDelete(w http.ResponseWriter, r *http.Req return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the resource control from the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, nil) + return response.Empty(w) } diff --git a/api/http/handler/resourcecontrols/resourcecontrol_update.go b/api/http/handler/resourcecontrols/resourcecontrol_update.go index b200a290d..d8ff0a720 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_update.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_update.go @@ -7,10 +7,11 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" ) type resourceControlUpdatePayload struct { @@ -92,5 +93,7 @@ func (handler *Handler) resourceControlUpdate(w http.ResponseWriter, r *http.Req return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control changes inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, resourceControl) } diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index ec32f1efe..659124db7 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -5,7 +5,7 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" ) @@ -15,6 +15,10 @@ func hideFields(settings *portainer.Settings) { settings.OAuthSettings.ClientSecret = "" } +const ( + handlerActivityContext = "Portainer" +) + // Handler is the HTTP handler used to handle settings operations. type Handler struct { *mux.Router @@ -24,6 +28,7 @@ type Handler struct { JWTService portainer.JWTService LDAPService portainer.LDAPService SnapshotService portainer.SnapshotService + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage settings operations. diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 05448e22f..e99239f55 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -11,6 +11,7 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/useractivity" ) type settingsUpdatePayload struct { @@ -153,6 +154,16 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist settings changes inside the database", err} } + if payload.LDAPSettings != nil { + payload.LDAPSettings.Password = "" + } + + if payload.OAuthSettings != nil { + payload.OAuthSettings.ClientSecret = "" + } + + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, settings) } diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index a7433b553..b0c08979f 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -16,6 +16,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" ) // this is coming from libcompose @@ -96,6 +97,8 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + doCleanUp = false return handler.decorateStackResponse(w, stack, userID) } @@ -192,6 +195,8 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + doCleanUp = false return handler.decorateStackResponse(w, stack, userID) } @@ -278,6 +283,8 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + doCleanUp = false return handler.decorateStackResponse(w, stack, userID) } diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index fc00adcef..d0ecd7275 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -11,6 +11,7 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" endpointutils "github.com/portainer/portainer/api/internal/endpoint" ) @@ -87,6 +88,8 @@ func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Req Output: output, } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + return response.JSON(w, resp) } diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 1ae86fbbe..e717420f2 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -14,6 +14,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" ) type swarmStackFromFileContentPayload struct { @@ -91,6 +92,8 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + doCleanUp = false return handler.decorateStackResponse(w, stack, userID) } @@ -191,6 +194,8 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + doCleanUp = false return handler.decorateStackResponse(w, stack, userID) } @@ -285,6 +290,8 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + doCleanUp = false return handler.decorateStackResponse(w, stack, userID) } diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index 356269c58..e06271621 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -37,6 +37,7 @@ type Handler struct { KubernetesDeployer portainer.KubernetesDeployer KubernetesClientFactory *cli.ClientFactory AuthorizationService *authorization.Service + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage stack operations. diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index 1e7690769..8250a1e99 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -12,6 +12,7 @@ import ( bolterrors "github.com/portainer/portainer/api/bolt/errors" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/stackutils" ) @@ -106,6 +107,8 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove stack files from disk", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, nil) + return response.Empty(w) } diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go index 1428cb813..efa7e15ec 100644 --- a/api/http/handler/stacks/stack_migrate.go +++ b/api/http/handler/stacks/stack_migrate.go @@ -12,13 +12,14 @@ import ( bolterrors "github.com/portainer/portainer/api/bolt/errors" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/stackutils" ) type stackMigratePayload struct { - EndpointID int - SwarmID string - Name string + EndpointID int `json:",omitempty"` + SwarmID string `json:",omitempty"` + Name string `json:",omitempty"` } func (payload *stackMigratePayload) Validate(r *http.Request) error { @@ -131,6 +132,8 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + return response.JSON(w, stack) } diff --git a/api/http/handler/stacks/stack_start.go b/api/http/handler/stacks/stack_start.go index d1375a38f..97fa2b747 100644 --- a/api/http/handler/stacks/stack_start.go +++ b/api/http/handler/stacks/stack_start.go @@ -8,6 +8,7 @@ import ( portainer "github.com/portainer/portainer/api" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/stackutils" httperror "github.com/portainer/libhttp/error" @@ -84,6 +85,8 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update stack status", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, nil) + return response.JSON(w, stack) } diff --git a/api/http/handler/stacks/stack_stop.go b/api/http/handler/stacks/stack_stop.go index 88b898271..89e634c3f 100644 --- a/api/http/handler/stacks/stack_stop.go +++ b/api/http/handler/stacks/stack_stop.go @@ -5,6 +5,7 @@ import ( "net/http" httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/stackutils" "github.com/portainer/portainer/api/http/security" @@ -75,6 +76,8 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update stack status", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, nil) + return response.JSON(w, stack) } diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index 4f64eb938..0414d3074 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -14,6 +14,7 @@ import ( bolterrors "github.com/portainer/portainer/api/bolt/errors" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/stackutils" ) @@ -145,6 +146,8 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + return nil } @@ -176,5 +179,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + return nil } diff --git a/api/http/handler/tags/handler.go b/api/http/handler/tags/handler.go index f7b6fd75d..a3916618b 100644 --- a/api/http/handler/tags/handler.go +++ b/api/http/handler/tags/handler.go @@ -5,14 +5,19 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" ) +const ( + handlerActivityContext = "Portainer" +) + // Handler is the HTTP handler used to handle tag operations. type Handler struct { *mux.Router - DataStore portainer.DataStore + DataStore portainer.DataStore + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage tag operations. diff --git a/api/http/handler/tags/tag_create.go b/api/http/handler/tags/tag_create.go index 5a2d1e400..3b1b12e47 100644 --- a/api/http/handler/tags/tag_create.go +++ b/api/http/handler/tags/tag_create.go @@ -8,7 +8,8 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/useractivity" ) type tagCreatePayload struct { @@ -52,5 +53,7 @@ func (handler *Handler) tagCreate(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the tag inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, tag) } diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go index 287af24bd..bf479bb77 100644 --- a/api/http/handler/tags/tag_delete.go +++ b/api/http/handler/tags/tag_delete.go @@ -6,8 +6,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/edge" ) @@ -99,6 +100,8 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the tag from the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, nil) + return response.Empty(w) } diff --git a/api/http/handler/teammemberships/handler.go b/api/http/handler/teammemberships/handler.go index 9a77f9dbf..aaf4daa7f 100644 --- a/api/http/handler/teammemberships/handler.go +++ b/api/http/handler/teammemberships/handler.go @@ -5,16 +5,21 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" ) +const ( + handlerActivityContext = "Portainer" +) + // Handler is the HTTP handler used to handle team membership operations. type Handler struct { *mux.Router AuthorizationService *authorization.Service DataStore portainer.DataStore + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage team membership operations. diff --git a/api/http/handler/teammemberships/teammembership_create.go b/api/http/handler/teammemberships/teammembership_create.go index 8d2c202d5..087c27f41 100644 --- a/api/http/handler/teammemberships/teammembership_create.go +++ b/api/http/handler/teammemberships/teammembership_create.go @@ -7,9 +7,10 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" ) type teamMembershipCreatePayload struct { @@ -76,8 +77,10 @@ func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Requ if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} } - + handler.AuthorizationService.TriggerUserAuthUpdate(payload.UserID) + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, membership) } diff --git a/api/http/handler/teammemberships/teammembership_delete.go b/api/http/handler/teammemberships/teammembership_delete.go index 607edb335..5706f49f4 100644 --- a/api/http/handler/teammemberships/teammembership_delete.go +++ b/api/http/handler/teammemberships/teammembership_delete.go @@ -6,10 +6,11 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" ) // DELETE request on /api/team_memberships/:id @@ -47,5 +48,7 @@ func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Requ handler.AuthorizationService.TriggerUserAuthUpdate(int(membership.UserID)) + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, nil) + return response.Empty(w) } diff --git a/api/http/handler/teammemberships/teammembership_update.go b/api/http/handler/teammemberships/teammembership_update.go index 07476722c..7881a8274 100644 --- a/api/http/handler/teammemberships/teammembership_update.go +++ b/api/http/handler/teammemberships/teammembership_update.go @@ -7,10 +7,11 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" ) type teamMembershipUpdatePayload struct { @@ -78,5 +79,7 @@ func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Requ handler.AuthorizationService.TriggerUserAuthUpdate(payload.UserID) handler.AuthorizationService.TriggerUserAuthUpdate(previousUserID) + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, membership) } diff --git a/api/http/handler/teams/handler.go b/api/http/handler/teams/handler.go index af54eb717..cdbd8cc69 100644 --- a/api/http/handler/teams/handler.go +++ b/api/http/handler/teams/handler.go @@ -5,18 +5,23 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/kubernetes/cli" ) +const ( + handlerActivityContext = "Portainer" +) + // Handler is the HTTP handler used to handle team operations. type Handler struct { *mux.Router AuthorizationService *authorization.Service DataStore portainer.DataStore K8sClientFactory *cli.ClientFactory + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage team operations. diff --git a/api/http/handler/teams/team_create.go b/api/http/handler/teams/team_create.go index 583087733..0058023ae 100644 --- a/api/http/handler/teams/team_create.go +++ b/api/http/handler/teams/team_create.go @@ -8,8 +8,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) type teamCreatePayload struct { @@ -47,5 +48,7 @@ func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the team inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, team) } diff --git a/api/http/handler/teams/team_delete.go b/api/http/handler/teams/team_delete.go index 56c4cc382..0bba60432 100644 --- a/api/http/handler/teams/team_delete.go +++ b/api/http/handler/teams/team_delete.go @@ -6,8 +6,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) // DELETE request on /api/teams/:id @@ -71,8 +72,10 @@ func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *http } } } - + handler.AuthorizationService.TriggerUsersAuthUpdate() + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, nil) + return response.Empty(w) } diff --git a/api/http/handler/teams/team_memberships.go b/api/http/handler/teams/team_memberships.go index 75c6f1389..e7e21c509 100644 --- a/api/http/handler/teams/team_memberships.go +++ b/api/http/handler/teams/team_memberships.go @@ -6,7 +6,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) diff --git a/api/http/handler/teams/team_update.go b/api/http/handler/teams/team_update.go index eba25b11d..56db81800 100644 --- a/api/http/handler/teams/team_update.go +++ b/api/http/handler/teams/team_update.go @@ -6,8 +6,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) type teamUpdatePayload struct { @@ -47,5 +48,7 @@ func (handler *Handler) teamUpdate(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusNotFound, "Unable to persist team changes inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload) + return response.JSON(w, team) } diff --git a/api/http/handler/upload/handler.go b/api/http/handler/upload/handler.go index dd6a459a1..385aab8e4 100644 --- a/api/http/handler/upload/handler.go +++ b/api/http/handler/upload/handler.go @@ -2,7 +2,7 @@ package upload import ( httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" "net/http" @@ -13,7 +13,8 @@ import ( // Handler is the HTTP handler used to handle upload operations. type Handler struct { *mux.Router - FileService portainer.FileService + FileService portainer.FileService + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage upload operations. diff --git a/api/http/handler/upload/upload_tls.go b/api/http/handler/upload/upload_tls.go index aa16544fb..fe9f060e2 100644 --- a/api/http/handler/upload/upload_tls.go +++ b/api/http/handler/upload/upload_tls.go @@ -6,8 +6,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/useractivity" ) // POST request on /api/upload/tls/{certificate:(?:ca|cert|key)}?folder= @@ -44,5 +45,7 @@ func (handler *Handler) uploadTLS(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist certificate file on disk", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, "", r, nil) + return response.Empty(w) } diff --git a/api/http/handler/useractivity/authlogs_csv.go b/api/http/handler/useractivity/authlogs_csv.go index d8a4935db..8668fbbb2 100644 --- a/api/http/handler/useractivity/authlogs_csv.go +++ b/api/http/handler/useractivity/authlogs_csv.go @@ -50,13 +50,15 @@ func (handler *Handler) authLogsCSV(w http.ResponseWriter, r *http.Request) *htt } opts := portainer.AuthLogsQuery{ - BeforeTimestamp: int64(before), - AfterTimestamp: int64(after), - SortBy: sortBy, - SortDesc: sortDesc, - Keyword: keyword, - ContextTypes: contextTypes, - ActivityTypes: activityTypes, + UserActivityLogBaseQuery: portainer.UserActivityLogBaseQuery{ + BeforeTimestamp: int64(before), + AfterTimestamp: int64(after), + SortBy: sortBy, + SortDesc: sortDesc, + Keyword: keyword, + }, + ContextTypes: contextTypes, + ActivityTypes: activityTypes, } logs, _, err := handler.UserActivityStore.GetAuthLogs(opts) diff --git a/api/http/handler/useractivity/authlogs_list.go b/api/http/handler/useractivity/authlogs_list.go index fa1a0a040..7bd70156c 100644 --- a/api/http/handler/useractivity/authlogs_list.go +++ b/api/http/handler/useractivity/authlogs_list.go @@ -61,15 +61,17 @@ func (handler *Handler) authLogsList(w http.ResponseWriter, r *http.Request) *ht } opts := portainer.AuthLogsQuery{ - Offset: offset, - Limit: limit, - BeforeTimestamp: int64(before), - AfterTimestamp: int64(after), - SortBy: sortBy, - SortDesc: sortDesc, - Keyword: keyword, - ContextTypes: contextTypes, - ActivityTypes: activityTypes, + UserActivityLogBaseQuery: portainer.UserActivityLogBaseQuery{ + Offset: offset, + Limit: limit, + BeforeTimestamp: int64(before), + AfterTimestamp: int64(after), + SortBy: sortBy, + SortDesc: sortDesc, + Keyword: keyword, + }, + ContextTypes: contextTypes, + ActivityTypes: activityTypes, } logs, totalCount, err := handler.UserActivityStore.GetAuthLogs(opts) diff --git a/api/http/handler/useractivity/handler.go b/api/http/handler/useractivity/handler.go index 87d3d5709..d7008e8e5 100644 --- a/api/http/handler/useractivity/handler.go +++ b/api/http/handler/useractivity/handler.go @@ -26,5 +26,10 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { h.Handle("/useractivity/authlogs.csv", bouncer.AdminAccess(httperror.LoggerHandler(h.authLogsCSV))).Methods(http.MethodGet) + h.Handle("/useractivity/logs", + bouncer.AdminAccess(httperror.LoggerHandler(h.logsList))).Methods(http.MethodGet) + h.Handle("/useractivity/logs.csv", + bouncer.AdminAccess(httperror.LoggerHandler(h.logsCSV))).Methods(http.MethodGet) + return h } diff --git a/api/http/handler/useractivity/logs_csv.go b/api/http/handler/useractivity/logs_csv.go new file mode 100644 index 000000000..4b5a9ba9a --- /dev/null +++ b/api/http/handler/useractivity/logs_csv.go @@ -0,0 +1,64 @@ +package useractivity + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/useractivity" +) + +// @id LogsCSV +// @summary Download user activity logs as CSV +// @description Download user activity logs as CSV by provided query +// @description **Access policy**: admin +// @tags useractivity +// @security jwt +// @produce text/csv +// @param before query int false "Results before timestamp (unix)" +// @param after query int false "Results after timestamp (unix)" +// @param sortBy query string false "Sort by this column" Enum("Timestamp", "Context", "Username", "Action") +// @param sortDesc query bool false "Sort order, if true will return results by descending order" +// @param keyword query string false "Query logs by this keyword" +// @success 200 string "Success" +// @failure 500 "Server error" +// @router /useractivity/logs.csv [get] +func (handler *Handler) logsCSV(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + before, _ := request.RetrieveNumericQueryParameter(r, "before", true) + after, _ := request.RetrieveNumericQueryParameter(r, "after", true) + sortBy, _ := request.RetrieveQueryParameter(r, "sortBy", true) + sortDesc, _ := request.RetrieveBooleanQueryParameter(r, "sortDesc", true) + keyword, _ := request.RetrieveQueryParameter(r, "keyword", true) + + opts := portainer.UserActivityLogBaseQuery{ + BeforeTimestamp: int64(before), + AfterTimestamp: int64(after), + SortBy: sortBy, + SortDesc: sortDesc, + Keyword: keyword, + } + + logs, _, err := handler.UserActivityStore.GetUserActivityLogs(opts) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve logs", + Err: err, + } + } + + err = useractivity.MarshalLogsToCSV(w, logs) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to marshal logs to csv", + Err: err, + } + } + + w.Header().Set("Content-Disposition", "attachment; filename=\"logs.csv\"") + w.Header().Set("Content-Type", "text/csv") + + return nil +} diff --git a/api/http/handler/useractivity/logs_list.go b/api/http/handler/useractivity/logs_list.go new file mode 100644 index 000000000..e1ce21fa1 --- /dev/null +++ b/api/http/handler/useractivity/logs_list.go @@ -0,0 +1,66 @@ +package useractivity + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" +) + +type logsListResponse struct { + Logs []*portainer.UserActivityLog `json:"logs"` + TotalCount int `json:"totalCount"` +} + +// @id LogsList +// @summary List user activity logs +// @description List logs by provided query +// @description **Access policy**: admin +// @tags useractivity +// @security jwt +// @produce json +// @param offset query int false "Pagination offset" +// @param limit query int false "Limit results" +// @param before query int false "Results before timestamp (unix)" +// @param after query int false "Results after timestamp (unix)" +// @param sortBy query string false "Sort by this column" Enum("Timestamp", "Context", "Username", "Action") +// @param sortDesc query bool false "Sort order, if true will return results by descending order" +// @param keyword query string false "Query logs by this keyword" +// @success 200 {object} logsListResponse "Success" +// @failure 500 "Server error" +// @router /useractivity/activitylogs [get] +func (handler *Handler) logsList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + offset, _ := request.RetrieveNumericQueryParameter(r, "offset", true) + limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true) + before, _ := request.RetrieveNumericQueryParameter(r, "before", true) + after, _ := request.RetrieveNumericQueryParameter(r, "after", true) + sortBy, _ := request.RetrieveQueryParameter(r, "sortBy", true) + sortDesc, _ := request.RetrieveBooleanQueryParameter(r, "sortDesc", true) + keyword, _ := request.RetrieveQueryParameter(r, "keyword", true) + + opts := portainer.UserActivityLogBaseQuery{ + Offset: offset, + Limit: limit, + BeforeTimestamp: int64(before), + AfterTimestamp: int64(after), + SortBy: sortBy, + SortDesc: sortDesc, + Keyword: keyword, + } + + logs, totalCount, err := handler.UserActivityStore.GetUserActivityLogs(opts) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve authentication logs", + Err: err, + } + } + + return response.JSON(w, logsListResponse{ + Logs: logs, + TotalCount: totalCount, + }) +} diff --git a/api/http/handler/users/admin_init.go b/api/http/handler/users/admin_init.go index d15ed7d7a..769d61d42 100644 --- a/api/http/handler/users/admin_init.go +++ b/api/http/handler/users/admin_init.go @@ -8,8 +8,10 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/authorization" + consts "github.com/portainer/portainer/api/useractivity" ) type adminInitPayload struct { @@ -60,5 +62,8 @@ func (handler *Handler) adminInit(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} } + payload.Password = consts.RedactedValue + useractivity.LogHttpActivity(handler.UserActivityStore, "", r, payload) + return response.JSON(w, user) } diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go index 8b559d95c..ec3d09820 100644 --- a/api/http/handler/users/handler.go +++ b/api/http/handler/users/handler.go @@ -31,6 +31,7 @@ type Handler struct { CryptoService portainer.CryptoService DataStore portainer.DataStore K8sClientFactory *cli.ClientFactory + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage user operations. diff --git a/api/http/handler/users/user_create.go b/api/http/handler/users/user_create.go index 36f9d9039..0900f71dd 100644 --- a/api/http/handler/users/user_create.go +++ b/api/http/handler/users/user_create.go @@ -8,11 +8,13 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" "github.com/portainer/portainer/api/internal/authorization" + consts "github.com/portainer/portainer/api/useractivity" ) type userCreatePayload struct { @@ -84,6 +86,9 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} } + payload.Password = consts.RedactedValue + useractivity.LogHttpActivity(handler.UserActivityStore, "", r, payload) + hideFields(user) return response.JSON(w, user) } diff --git a/api/http/handler/users/user_delete.go b/api/http/handler/users/user_delete.go index 9f5647915..c608f2a0f 100644 --- a/api/http/handler/users/user_delete.go +++ b/api/http/handler/users/user_delete.go @@ -2,8 +2,8 @@ package users import ( "errors" - "net/http" "fmt" + "net/http" "strings" httperror "github.com/portainer/libhttp/error" @@ -12,6 +12,7 @@ import ( portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" ) // DELETE request on /api/users/:id @@ -42,12 +43,24 @@ func (handler *Handler) userDelete(w http.ResponseWriter, r *http.Request) *http } if user.Role == portainer.AdministratorRole { - return handler.deleteAdminUser(w, user) + responseErr := handler.deleteAdminUser(w, user) + if responseErr != nil { + return responseErr + } + + useractivity.LogHttpActivity(handler.UserActivityStore, "", r, nil) + return nil } handler.AuthorizationService.TriggerUserAuthUpdate(int(user.ID)) - return handler.deleteUser(w, user) + responseErr := handler.deleteUser(w, user) + if err != nil { + return responseErr + } + + useractivity.LogHttpActivity(handler.UserActivityStore, "", r, nil) + return nil } func (handler *Handler) deleteAdminUser(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError { diff --git a/api/http/handler/users/user_update.go b/api/http/handler/users/user_update.go index be7941e42..bd6ea532f 100644 --- a/api/http/handler/users/user_update.go +++ b/api/http/handler/users/user_update.go @@ -8,10 +8,12 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" + consts "github.com/portainer/portainer/api/useractivity" ) type userUpdatePayload struct { @@ -94,5 +96,8 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http handler.AuthorizationService.TriggerUserAuthUpdate(int(user.ID)) + payload.Password = consts.RedactedValue + useractivity.LogHttpActivity(handler.UserActivityStore, "", r, payload) + return response.JSON(w, user) } diff --git a/api/http/handler/users/user_update_password.go b/api/http/handler/users/user_update_password.go index c0556dfd1..ca44e646f 100644 --- a/api/http/handler/users/user_update_password.go +++ b/api/http/handler/users/user_update_password.go @@ -8,10 +8,12 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" + consts "github.com/portainer/portainer/api/useractivity" ) type userUpdatePasswordPayload struct { @@ -73,5 +75,9 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user changes inside the database", err} } + payload.Password = consts.RedactedValue + payload.NewPassword = consts.RedactedValue + useractivity.LogHttpActivity(handler.UserActivityStore, "", r, payload) + return response.Empty(w) } diff --git a/api/http/handler/webhooks/handler.go b/api/http/handler/webhooks/handler.go index aa5046cfe..7d90032e6 100644 --- a/api/http/handler/webhooks/handler.go +++ b/api/http/handler/webhooks/handler.go @@ -15,6 +15,7 @@ type Handler struct { *mux.Router DataStore portainer.DataStore DockerClientFactory *docker.ClientFactory + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage settings operations. diff --git a/api/http/handler/webhooks/webhook_create.go b/api/http/handler/webhooks/webhook_create.go index dc00dcd38..3691bb8a0 100644 --- a/api/http/handler/webhooks/webhook_create.go +++ b/api/http/handler/webhooks/webhook_create.go @@ -9,8 +9,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) type webhookCreatePayload struct { @@ -47,6 +48,15 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusConflict, "A webhook for this resource already exists", errors.New("A webhook for this resource already exists")} } + endpointID := portainer.EndpointID(payload.EndpointID) + + endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + token, err := uuid.NewV4() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Error creating unique token", err} @@ -55,7 +65,7 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h webhook = &portainer.Webhook{ Token: token.String(), ResourceID: payload.ResourceID, - EndpointID: portainer.EndpointID(payload.EndpointID), + EndpointID: endpointID, WebhookType: portainer.WebhookType(payload.WebhookType), } @@ -64,5 +74,7 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the webhook inside the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, payload) + return response.JSON(w, webhook) } diff --git a/api/http/handler/webhooks/webhook_delete.go b/api/http/handler/webhooks/webhook_delete.go index 305dbca6b..760974584 100644 --- a/api/http/handler/webhooks/webhook_delete.go +++ b/api/http/handler/webhooks/webhook_delete.go @@ -6,7 +6,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) // DELETE request on /api/webhook/:serviceID @@ -16,10 +18,26 @@ func (handler *Handler) webhookDelete(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusBadRequest, "Invalid webhook id", err} } + webhook, err := handler.DataStore.Webhook().Webhook(portainer.WebhookID(id)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a webhook with this token", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve webhook from the database", err} + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(webhook.EndpointID) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + err = handler.DataStore.Webhook().DeleteWebhook(portainer.WebhookID(id)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the webhook from the database", err} } + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, nil) + return response.Empty(w) } diff --git a/api/http/handler/webhooks/webhook_execute.go b/api/http/handler/webhooks/webhook_execute.go index e07ae8b2e..d2b2a553b 100644 --- a/api/http/handler/webhooks/webhook_execute.go +++ b/api/http/handler/webhooks/webhook_execute.go @@ -10,7 +10,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" ) diff --git a/api/http/handler/websocket/attach.go b/api/http/handler/websocket/attach.go index 6da16659a..c4ca0c638 100644 --- a/api/http/handler/websocket/attach.go +++ b/api/http/handler/websocket/attach.go @@ -10,8 +10,9 @@ import ( "github.com/gorilla/websocket" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" ) // websocketAttach handles GET requests on /websocket/attach?id=&endpointId=&nodeName=&token= @@ -56,6 +57,9 @@ func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during websocket attach operation", err} } + params.endpoint = nil + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, params) + return nil } diff --git a/api/http/handler/websocket/exec.go b/api/http/handler/websocket/exec.go index 0abbcb263..bef3ab04f 100644 --- a/api/http/handler/websocket/exec.go +++ b/api/http/handler/websocket/exec.go @@ -3,17 +3,19 @@ package websocket import ( "bytes" "encoding/json" - "github.com/portainer/portainer/api/bolt/errors" "net" "net/http" "net/http/httputil" "time" + "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/useractivity" + "github.com/asaskevich/govalidator" "github.com/gorilla/websocket" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" ) type execStartOperationPayload struct { @@ -63,6 +65,9 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during websocket exec operation", err} } + params.endpoint = nil + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, params) + return nil } diff --git a/api/http/handler/websocket/handler.go b/api/http/handler/websocket/handler.go index 97338c576..acc581d2b 100644 --- a/api/http/handler/websocket/handler.go +++ b/api/http/handler/websocket/handler.go @@ -20,6 +20,7 @@ type Handler struct { authorizationService *authorization.Service requestBouncer *security.RequestBouncer connectionUpgrader websocket.Upgrader + UserActivityStore portainer.UserActivityStore } // NewHandler creates a handler to manage websocket operations. diff --git a/api/http/handler/websocket/pod.go b/api/http/handler/websocket/pod.go index 0d68c777d..632d3b268 100644 --- a/api/http/handler/websocket/pod.go +++ b/api/http/handler/websocket/pod.go @@ -13,6 +13,7 @@ import ( portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" ) // websocketPodExec handles GET requests on /websocket/pod?token=&endpointId=&namespace=&podName=&containerName=&command= @@ -102,12 +103,18 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to proxy websocket request to agent", err} } + + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, nil) + return nil } else if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { err := handler.proxyEdgeAgentWebsocketRequest(w, r, params) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to proxy websocket request to Edge agent", err} } + + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, nil) + return nil } @@ -128,6 +135,8 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) go streamFromWebsocketToWriter(websocketConn, stdinWriter, errorChan) go streamFromReaderToWebsocket(websocketConn, stdoutReader, errorChan) + useractivity.LogHttpActivity(handler.UserActivityStore, endpoint.Name, r, nil) + err = cli.StartExecProcess(namespace, podName, containerName, commandArray, stdinReader, stdoutWriter) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start exec process inside container", err} diff --git a/api/http/proxy/factory/azure.go b/api/http/proxy/factory/azure.go index 84c9c6495..5ac31b2d3 100644 --- a/api/http/proxy/factory/azure.go +++ b/api/http/proxy/factory/azure.go @@ -8,13 +8,13 @@ import ( "github.com/portainer/portainer/api/http/proxy/factory/azure" ) -func newAzureProxy(endpoint *portainer.Endpoint, dataStore portainer.DataStore) (http.Handler, error) { +func newAzureProxy(userActivityStore portainer.UserActivityStore, endpoint *portainer.Endpoint, dataStore portainer.DataStore) (http.Handler, error) { remoteURL, err := url.Parse(azureAPIBaseURL) if err != nil { return nil, err } proxy := newSingleHostReverseProxyWithHostHeader(remoteURL) - proxy.Transport = azure.NewTransport(&endpoint.AzureCredentials, dataStore, endpoint) + proxy.Transport = azure.NewTransport(&endpoint.AzureCredentials, userActivityStore, dataStore, endpoint) return proxy, nil } diff --git a/api/http/proxy/factory/azure/transport.go b/api/http/proxy/factory/azure/transport.go index e90695e73..6bc594000 100644 --- a/api/http/proxy/factory/azure/transport.go +++ b/api/http/proxy/factory/azure/transport.go @@ -7,8 +7,10 @@ import ( "sync" "time" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/client" + "github.com/portainer/portainer/api/http/useractivity" + "github.com/portainer/portainer/api/http/utils" ) type ( @@ -18,12 +20,13 @@ type ( } Transport struct { - credentials *portainer.AzureCredentials - client *client.HTTPClient - token *azureAPIToken - mutex sync.Mutex - dataStore portainer.DataStore - endpoint *portainer.Endpoint + credentials *portainer.AzureCredentials + client *client.HTTPClient + token *azureAPIToken + mutex sync.Mutex + dataStore portainer.DataStore + endpoint *portainer.Endpoint + userActivityStore portainer.UserActivityStore } azureRequestContext struct { @@ -37,7 +40,7 @@ type ( // NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport // interface for proxying requests to the Azure API. -func NewTransport(credentials *portainer.AzureCredentials, dataStore portainer.DataStore, endpoint *portainer.Endpoint) *Transport { +func NewTransport(credentials *portainer.AzureCredentials, userActivityStore portainer.UserActivityStore, dataStore portainer.DataStore, endpoint *portainer.Endpoint) *Transport { return &Transport{ credentials: credentials, client: client.NewHTTPClient(), @@ -67,7 +70,19 @@ func (transport *Transport) proxyAzureRequest(request *http.Request) (*http.Resp return transport.proxyContainerGroupRequest(request) } - return http.DefaultTransport.RoundTrip(request) + body, err := utils.CopyBody(request) + if err != nil { + return nil, err + } + + resp, err := http.DefaultTransport.RoundTrip(request) + + // log if request is success + if err == nil && (200 <= resp.StatusCode && resp.StatusCode < 300) { + useractivity.LogProxyActivity(transport.userActivityStore, transport.endpoint.Name, request, body) + } + + return resp, err } func (transport *Transport) authenticate() error { diff --git a/api/http/proxy/factory/docker.go b/api/http/proxy/factory/docker.go index 513b5731d..ba8582c5d 100644 --- a/api/http/proxy/factory/docker.go +++ b/api/http/proxy/factory/docker.go @@ -61,6 +61,7 @@ func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (h ReverseTunnelService: factory.reverseTunnelService, SignatureService: factory.signatureService, DockerClientFactory: factory.dockerClientFactory, + UserActivityStore: factory.userActivityStore, } dockerTransport, err := docker.NewTransport(transportParameters, httpTransport) diff --git a/api/http/proxy/factory/docker/configs.go b/api/http/proxy/factory/docker/configs.go index 74d10759d..3b5b9c8e7 100644 --- a/api/http/proxy/factory/docker/configs.go +++ b/api/http/proxy/factory/docker/configs.go @@ -2,13 +2,19 @@ package docker import ( "context" + "encoding/json" + "log" "net/http" "github.com/docker/docker/client" + "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + useractivityhttp "github.com/portainer/portainer/api/http/useractivity" + "github.com/portainer/portainer/api/http/utils" "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/useractivity" ) const ( @@ -72,6 +78,31 @@ func (transport *Transport) configInspectOperation(response *http.Response, exec return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor) } +func (transport *Transport) decorateConfigCreationOperation(request *http.Request) (*http.Response, error) { + body, err := utils.CopyBody(request) + if err != nil { + return nil, err + } + + response, err := transport.decorateGenericResourceCreationOperation(request, configObjectIdentifier, portainer.ConfigResourceControl, false) + + if err == nil && (200 <= response.StatusCode && response.StatusCode < 300) { + transport.logCreateConfigOperation(request, body) + } + + return response, err +} + +func (transport *Transport) logCreateConfigOperation(request *http.Request, body []byte) { + cleanBody, err := hideConfigInfo(body) + if err != nil { + log.Printf("[ERROR] [http,docker,config] [message: failed cleaning request body] [error: %s]", err) + return + } + + useractivityhttp.LogHttpActivity(transport.userActivityStore, transport.endpoint.Name, request, cleanBody) +} + // selectorConfigLabels retrieve the labels object associated to the config object. // Labels are available under the "Spec.Labels" property. // API schema references: @@ -85,3 +116,23 @@ func selectorConfigLabels(responseObject map[string]interface{}) map[string]inte } return nil } + +// hideConfigInfo removes the confidential properties from the secret payload and returns the new payload +// it will read the request body and recreate it +func hideConfigInfo(body []byte) (interface{}, error) { + type requestPayload struct { + Data string + Labels interface{} + Name string + } + + var payload requestPayload + err := json.Unmarshal(body, &payload) + if err != nil { + return nil, errors.Wrap(err, "failed parsing body") + } + + payload.Data = useractivity.RedactedValue + + return payload, nil +} diff --git a/api/http/proxy/factory/docker/containers.go b/api/http/proxy/factory/docker/containers.go index 68a90d55b..9396cc20a 100644 --- a/api/http/proxy/factory/docker/containers.go +++ b/api/http/proxy/factory/docker/containers.go @@ -215,7 +215,7 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) } - response, err := transport.executeDockerRequest(request) + response, err := transport.executeDockerRequest(request, true) if err != nil { return response, err } diff --git a/api/http/proxy/factory/docker/secrets.go b/api/http/proxy/factory/docker/secrets.go index 148073c02..017ef3480 100644 --- a/api/http/proxy/factory/docker/secrets.go +++ b/api/http/proxy/factory/docker/secrets.go @@ -2,13 +2,19 @@ package docker import ( "context" + "encoding/json" + "log" "net/http" "github.com/docker/docker/client" + "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + useractivityhttp "github.com/portainer/portainer/api/http/useractivity" + "github.com/portainer/portainer/api/http/utils" "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/useractivity" ) const ( @@ -72,6 +78,21 @@ func (transport *Transport) secretInspectOperation(response *http.Response, exec return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor) } +func (transport *Transport) decorateSecretCreationOperation(request *http.Request) (*http.Response, error) { + body, err := utils.CopyBody(request) + if err != nil { + return nil, err + } + + response, err := transport.decorateGenericResourceCreationOperation(request, secretObjectIdentifier, portainer.SecretResourceControl, false) + + if err == nil && (200 <= response.StatusCode && response.StatusCode < 300) { + transport.logCreateSecretOperation(request, body) + } + + return response, err +} + // selectorSecretLabels retrieve the labels object associated to the secret object. // Labels are available under the "Spec.Labels" property. // API schema references: @@ -85,3 +106,33 @@ func selectorSecretLabels(responseObject map[string]interface{}) map[string]inte } return nil } + +func (transport *Transport) logCreateSecretOperation(request *http.Request, body []byte) { + cleanBody, err := hideSecretInfo(body) + if err != nil { + log.Printf("[ERROR] [http,docker,secrets] [message: failed cleaning request body] [error: %s]", err) + return + } + + useractivityhttp.LogHttpActivity(transport.userActivityStore, transport.endpoint.Name, request, cleanBody) +} + +// hideSecretInfo removes the confidential properties from the secret payload and returns the new payload +// it will read the request body and recreate it +func hideSecretInfo(body []byte) (interface{}, error) { + type createSecretRequestPayload struct { + Data string + Labels interface{} + Name string + } + + var payload createSecretRequestPayload + err := json.Unmarshal(body, &payload) + if err != nil { + return nil, errors.Wrap(err, "failed parsing body") + } + + payload.Data = useractivity.RedactedValue + + return payload, nil +} diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go index 3804cf79a..8df5eb18d 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -3,18 +3,21 @@ package docker import ( "encoding/base64" "encoding/json" - "errors" "log" "net/http" "path" "regexp" "strings" + "github.com/pkg/errors" + "github.com/docker/docker/client" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/http/proxy/factory/responseutils" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" + "github.com/portainer/portainer/api/http/utils" "github.com/portainer/portainer/api/internal/authorization" ) @@ -31,6 +34,7 @@ type ( reverseTunnelService portainer.ReverseTunnelService dockerClient *client.Client dockerClientFactory *docker.ClientFactory + userActivityStore portainer.UserActivityStore } // TransportParameters is used to create a new Transport @@ -40,6 +44,7 @@ type ( SignatureService portainer.DigitalSignatureService ReverseTunnelService portainer.ReverseTunnelService DockerClientFactory *docker.ClientFactory + UserActivityStore portainer.UserActivityStore } restrictedDockerOperationContext struct { @@ -73,6 +78,7 @@ func NewTransport(parameters *TransportParameters, httpTransport *http.Transport dockerClientFactory: parameters.DockerClientFactory, HTTPTransport: httpTransport, dockerClient: dockerClient, + userActivityStore: parameters.UserActivityStore, } return transport, nil @@ -125,13 +131,29 @@ func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Res case strings.HasPrefix(requestPath, "/v2"): return transport.proxyAgentRequest(request) default: - return transport.executeDockerRequest(request) + return transport.executeDockerRequest(request, true) } } -func (transport *Transport) executeDockerRequest(request *http.Request) (*http.Response, error) { +func (transport *Transport) executeDockerRequest(request *http.Request, shouldLog bool) (*http.Response, error) { + var body []byte + + if shouldLog { + bodyBytes, err := utils.CopyBody(request) + if err != nil { + return nil, err + } + + body = bodyBytes + } + response, err := transport.HTTPTransport.RoundTrip(request) + // log if request is success + if shouldLog && err == nil && (200 <= response.StatusCode && response.StatusCode < 300) { + useractivity.LogProxyActivity(transport.userActivityStore, transport.endpoint.Name, request, body) + } + if transport.endpoint.Type != portainer.EdgeAgentOnDockerEnvironment { return response, err } @@ -166,13 +188,13 @@ func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response, return transport.restrictedResourceOperation(r, resourceID, portainer.VolumeResourceControl, true) } - return transport.executeDockerRequest(r) + return transport.executeDockerRequest(r, true) } func (transport *Transport) proxyConfigRequest(request *http.Request) (*http.Response, error) { switch requestPath := request.URL.Path; requestPath { case "/configs/create": - return transport.decorateGenericResourceCreationOperation(request, configObjectIdentifier, portainer.ConfigResourceControl) + return transport.decorateConfigCreationOperation(request) case "/configs": return transport.rewriteOperation(request, transport.configListOperation) @@ -223,7 +245,7 @@ func (transport *Transport) proxyContainerRequest(request *http.Request) (*http. return transport.restrictedResourceOperation(request, containerID, portainer.ContainerResourceControl, false) } - return transport.executeDockerRequest(request) + return transport.executeDockerRequest(request, true) } } @@ -253,7 +275,7 @@ func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Re } return transport.restrictedResourceOperation(request, serviceID, portainer.ServiceResourceControl, false) } - return transport.executeDockerRequest(request) + return transport.executeDockerRequest(request, true) } } @@ -277,7 +299,7 @@ func (transport *Transport) proxyVolumeRequest(request *http.Request) (*http.Res func (transport *Transport) proxyNetworkRequest(request *http.Request) (*http.Response, error) { switch requestPath := request.URL.Path; requestPath { case "/networks/create": - return transport.decorateGenericResourceCreationOperation(request, networkObjectIdentifier, portainer.NetworkResourceControl) + return transport.decorateGenericResourceCreationOperation(request, networkObjectIdentifier, portainer.NetworkResourceControl, true) case "/networks": return transport.rewriteOperation(request, transport.networkListOperation) @@ -298,7 +320,7 @@ func (transport *Transport) proxyNetworkRequest(request *http.Request) (*http.Re func (transport *Transport) proxySecretRequest(request *http.Request) (*http.Response, error) { switch requestPath := request.URL.Path; requestPath { case "/secrets/create": - return transport.decorateGenericResourceCreationOperation(request, secretObjectIdentifier, portainer.SecretResourceControl) + return transport.decorateSecretCreationOperation(request) case "/secrets": return transport.rewriteOperation(request, transport.secretListOperation) @@ -324,7 +346,7 @@ func (transport *Transport) proxyNodeRequest(request *http.Request) (*http.Respo return transport.administratorOperation(request) } - return transport.executeDockerRequest(request) + return transport.executeDockerRequest(request, true) } func (transport *Transport) proxySwarmRequest(request *http.Request) (*http.Response, error) { @@ -343,7 +365,7 @@ func (transport *Transport) proxyTaskRequest(request *http.Request) (*http.Respo return transport.rewriteOperation(request, transport.taskListOperation) default: // assume /tasks/{id} - return transport.executeDockerRequest(request) + return transport.executeDockerRequest(request, true) } } @@ -359,7 +381,7 @@ func (transport *Transport) proxyImageRequest(request *http.Request) (*http.Resp if path.Base(requestPath) == "push" && request.Method == http.MethodPost { return transport.replaceRegistryAuthenticationHeader(request) } - return transport.executeDockerRequest(request) + return transport.executeDockerRequest(request, true) } } @@ -396,7 +418,7 @@ func (transport *Transport) replaceRegistryAuthenticationHeader(request *http.Re request.Header.Set("X-Registry-Auth", header) } - return transport.decorateGenericResourceCreationOperation(request, serviceObjectIdentifier, portainer.ServiceResourceControl) + return transport.decorateGenericResourceCreationOperation(request, serviceObjectIdentifier, portainer.ServiceResourceControl, true) } func (transport *Transport) restrictedResourceOperation(request *http.Request, resourceID string, resourceType portainer.ResourceControlType, volumeBrowseRestrictionCheck bool) (*http.Response, error) { @@ -430,7 +452,7 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r _, endpointResourceAccess := user.EndpointAuthorizations[transport.endpoint.ID][portainer.EndpointResourcesAccess] if endpointResourceAccess { - return transport.executeDockerRequest(request) + return transport.executeDockerRequest(request, true) } teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) @@ -469,7 +491,7 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r } } - return transport.executeDockerRequest(request) + return transport.executeDockerRequest(request, true) } // rewriteOperationWithLabelFiltering will create a new operation context with data that will be used @@ -515,7 +537,7 @@ func (transport *Transport) interceptAndRewriteRequest(request *http.Request, op return nil, err } - return transport.executeDockerRequest(request) + return transport.executeDockerRequest(request, true) } // decorateGenericResourceCreationResponse extracts the response as a JSON object, extracts the resource identifier from that object based @@ -551,13 +573,13 @@ func (transport *Transport) decorateGenericResourceCreationResponse(response *ht return responseutils.RewriteResponse(response, responseObject, http.StatusOK) } -func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) { +func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, log bool) (*http.Response, error) { tokenData, err := security.RetrieveTokenData(request) if err != nil { return nil, err } - response, err := transport.executeDockerRequest(request) + response, err := transport.executeDockerRequest(request, log) if err != nil { return response, err } @@ -593,7 +615,7 @@ func (transport *Transport) executeGenericResourceDeletionOperation(request *htt } func (transport *Transport) executeRequestAndRewriteResponse(request *http.Request, operation restrictedOperationRequest, executor *operationExecutor) (*http.Response, error) { - response, err := transport.executeDockerRequest(request) + response, err := transport.executeDockerRequest(request, true) if err != nil { return response, err } @@ -614,7 +636,7 @@ func (transport *Transport) administratorOperation(request *http.Request) (*http return responseutils.WriteAccessDeniedResponse() } - return transport.executeDockerRequest(request) + return transport.executeDockerRequest(request, true) } func (transport *Transport) createRegistryAccessContext(request *http.Request) (*registryAccessContext, error) { diff --git a/api/http/proxy/factory/docker/volumes.go b/api/http/proxy/factory/docker/volumes.go index c1bd3d993..f7618f9f5 100644 --- a/api/http/proxy/factory/docker/volumes.go +++ b/api/http/proxy/factory/docker/volumes.go @@ -130,7 +130,7 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt } } - response, err := transport.executeDockerRequest(request) + response, err := transport.executeDockerRequest(request, true) if err != nil { return response, err } diff --git a/api/http/proxy/factory/docker_unix.go b/api/http/proxy/factory/docker_unix.go index 32a572d30..076f9565b 100644 --- a/api/http/proxy/factory/docker_unix.go +++ b/api/http/proxy/factory/docker_unix.go @@ -17,6 +17,7 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine ReverseTunnelService: factory.reverseTunnelService, SignatureService: factory.signatureService, DockerClientFactory: factory.dockerClientFactory, + UserActivityStore: factory.userActivityStore, } proxy := &dockerLocalProxy{} diff --git a/api/http/proxy/factory/docker_windows.go b/api/http/proxy/factory/docker_windows.go index fb71b91d1..760f50600 100644 --- a/api/http/proxy/factory/docker_windows.go +++ b/api/http/proxy/factory/docker_windows.go @@ -18,6 +18,7 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine ReverseTunnelService: factory.reverseTunnelService, SignatureService: factory.signatureService, DockerClientFactory: factory.dockerClientFactory, + UserActivityStore: factory.userActivityStore, } proxy := &dockerLocalProxy{} diff --git a/api/http/proxy/factory/factory.go b/api/http/proxy/factory/factory.go index 87d8d2837..44e199a35 100644 --- a/api/http/proxy/factory/factory.go +++ b/api/http/proxy/factory/factory.go @@ -24,6 +24,7 @@ type ( kubernetesClientFactory *cli.ClientFactory kubernetesTokenCacheManager *kubernetes.TokenCacheManager authService *authorization.Service + userActivityStore portainer.UserActivityStore } ) @@ -36,6 +37,7 @@ func NewProxyFactory( kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, authService *authorization.Service, + userActivityStore portainer.UserActivityStore, ) *ProxyFactory { return &ProxyFactory{ dataStore: dataStore, @@ -45,6 +47,7 @@ func NewProxyFactory( kubernetesClientFactory: kubernetesClientFactory, kubernetesTokenCacheManager: kubernetesTokenCacheManager, authService: authService, + userActivityStore: userActivityStore, } } @@ -64,7 +67,7 @@ func (factory *ProxyFactory) NewLegacyExtensionProxy(extensionAPIURL string) (ht func (factory *ProxyFactory) NewEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) { switch endpoint.Type { case portainer.AzureEnvironment: - return newAzureProxy(endpoint, factory.dataStore) + return newAzureProxy(factory.userActivityStore, endpoint, factory.dataStore) case portainer.EdgeAgentOnKubernetesEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.KubernetesLocalEnvironment: return factory.newKubernetesProxy(endpoint) } @@ -74,5 +77,5 @@ func (factory *ProxyFactory) NewEndpointProxy(endpoint *portainer.Endpoint) (htt // NewGitlabProxy returns a new HTTP proxy to a Gitlab API server func (factory *ProxyFactory) NewGitlabProxy(gitlabAPIUri string) (http.Handler, error) { - return newGitlabProxy(gitlabAPIUri) + return newGitlabProxy(gitlabAPIUri, factory.userActivityStore) } diff --git a/api/http/proxy/factory/gitlab.go b/api/http/proxy/factory/gitlab.go index cbe28411c..61495812c 100644 --- a/api/http/proxy/factory/gitlab.go +++ b/api/http/proxy/factory/gitlab.go @@ -4,16 +4,17 @@ import ( "net/http" "net/url" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy/factory/gitlab" ) -func newGitlabProxy(uri string) (http.Handler, error) { +func newGitlabProxy(uri string, userActivityStore portainer.UserActivityStore) (http.Handler, error) { url, err := url.Parse(uri) if err != nil { return nil, err } proxy := newSingleHostReverseProxyWithHostHeader(url) - proxy.Transport = gitlab.NewTransport() + proxy.Transport = gitlab.NewTransport(userActivityStore) return proxy, nil } diff --git a/api/http/proxy/factory/gitlab/transport.go b/api/http/proxy/factory/gitlab/transport.go index 4857ed710..f1054b425 100644 --- a/api/http/proxy/factory/gitlab/transport.go +++ b/api/http/proxy/factory/gitlab/transport.go @@ -3,17 +3,23 @@ package gitlab import ( "errors" "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/useractivity" + "github.com/portainer/portainer/api/http/utils" ) type Transport struct { - httpTransport *http.Transport + httpTransport *http.Transport + userActivityStore portainer.UserActivityStore } // NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport // interface for proxying requests to the Gitlab API. -func NewTransport() *Transport { +func NewTransport(userActivityStore portainer.UserActivityStore) *Transport { return &Transport{ - httpTransport: &http.Transport{}, + userActivityStore: userActivityStore, + httpTransport: &http.Transport{}, } } @@ -30,5 +36,18 @@ func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, er } r.Header.Set("Private-Token", token) - return transport.httpTransport.RoundTrip(r) + + body, err := utils.CopyBody(request) + if err != nil { + return nil, err + } + + resp, err := transport.httpTransport.RoundTrip(r) + + // log if request is success + if err == nil && (200 <= resp.StatusCode && resp.StatusCode < 300) { + useractivity.LogProxyActivity(transport.userActivityStore, "Portainer", r, body) + } + + return resp, err } diff --git a/api/http/proxy/factory/kubernetes.go b/api/http/proxy/factory/kubernetes.go index fcb4f01de..c05fae5e5 100644 --- a/api/http/proxy/factory/kubernetes.go +++ b/api/http/proxy/factory/kubernetes.go @@ -39,7 +39,7 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin return nil, err } - transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint.ID) + transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.userActivityStore) if err != nil { return nil, err } @@ -72,7 +72,7 @@ func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endp endpointURL.Scheme = "http" proxy := newSingleHostReverseProxyWithHostHeader(endpointURL) - proxy.Transport = kubernetes.NewEdgeTransport(factory.reverseTunnelService, endpoint.ID, tokenManager) + proxy.Transport = kubernetes.NewEdgeTransport(factory.reverseTunnelService, endpoint, tokenManager, factory.userActivityStore) return proxy, nil } @@ -103,8 +103,7 @@ func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.En } proxy := newSingleHostReverseProxyWithHostHeader(remoteURL) - proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, - tlsConfig, tokenManager, endpoint.ID) + proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.userActivityStore) return proxy, nil } diff --git a/api/http/proxy/factory/kubernetes/configs.go b/api/http/proxy/factory/kubernetes/configs.go new file mode 100644 index 000000000..ee62ab283 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/configs.go @@ -0,0 +1,70 @@ +package kubernetes + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/pkg/errors" + useractivityhttp "github.com/portainer/portainer/api/http/useractivity" + "github.com/portainer/portainer/api/http/utils" + "github.com/portainer/portainer/api/useractivity" +) + +func (transport *baseTransport) proxyConfigRequest(request *http.Request, requestPath string) (*http.Response, error) { + switch { + case request.Method == "POST" || request.Method == "PUT": // create or update + return transport.decorateConfigWriteOperation(request) + default: + return transport.executeKubernetesRequest(request, true) + } +} + +func (transport *baseTransport) decorateConfigWriteOperation(request *http.Request) (*http.Response, error) { + body, err := utils.CopyBody(request) + if err != nil { + return nil, err + } + + response, err := transport.executeKubernetesRequest(request, false) + + if err == nil && (200 <= response.StatusCode && response.StatusCode < 300) { + transport.logCreateConfigOperation(request, body) + } + + return response, err +} + +func (transport *baseTransport) logCreateConfigOperation(request *http.Request, body []byte) { + cleanBody, err := hideConfigInfo(body) + if err != nil { + log.Printf("[ERROR] [http,docker,config] [message: failed cleaning request body] [error: %s]", err) + return + } + + useractivityhttp.LogHttpActivity(transport.userActivityStore, transport.endpoint.Name, request, cleanBody) +} + +// hideConfigInfo removes the confidential properties from the secret payload and returns the new payload +// it will read the request body and recreate it +func hideConfigInfo(body []byte) (interface{}, error) { + type requestPayload struct { + Metadata interface{} `json:"metadata"` + Data map[string]string `json:"data"` + BinaryData interface{} `json:"binaryData"` + } + + var payload requestPayload + err := json.Unmarshal(body, &payload) + if err != nil { + return nil, errors.Wrap(err, "failed parsing body") + } + + for key := range payload.Data { + payload.Data[key] = useractivity.RedactedValue + } + + payload.BinaryData = useractivity.RedactedValue + + return payload, nil +} diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index da8f901d9..a515abc5e 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -5,49 +5,122 @@ import ( "fmt" "log" "net/http" + "regexp" + "strings" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/useractivity" + "github.com/portainer/portainer/api/http/utils" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/crypto" ) type ( + baseTransport struct { + httpTransport *http.Transport + tokenManager *tokenManager + endpoint *portainer.Endpoint + userActivityStore portainer.UserActivityStore + } + localTransport struct { - httpTransport *http.Transport - tokenManager *tokenManager - endpointIdentifier portainer.EndpointID + *baseTransport } agentTransport struct { - httpTransport *http.Transport - tokenManager *tokenManager - signatureService portainer.DigitalSignatureService - endpointIdentifier portainer.EndpointID + *baseTransport + signatureService portainer.DigitalSignatureService } edgeTransport struct { - httpTransport *http.Transport - tokenManager *tokenManager + *baseTransport reverseTunnelService portainer.ReverseTunnelService - endpointIdentifier portainer.EndpointID } ) +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *baseTransport) prepareRoundTrip(request *http.Request) error { + token, err := getRoundTripToken(request, transport.tokenManager, transport.endpoint.ID) + if err != nil { + return err + } + + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + return nil +} + +// proxyKubernetesRequest intercepts a Kubernetes API request and apply logic based +// on the requested operation. +func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*http.Response, error) { + apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/api/v[0-9](\.[0-9])?`) + requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "") + + switch { + case strings.EqualFold(requestPath, "/namespaces"): + return transport.executeKubernetesRequest(request, true) + case strings.HasPrefix(requestPath, "/namespaces"): + return transport.proxyNamespacedRequest(request, requestPath) + default: + return transport.executeKubernetesRequest(request, true) + } +} + +func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fullRequestPath string) (*http.Response, error) { + re := regexp.MustCompile(`/namespaces/([^/]*)/`) + requestPath := re.ReplaceAllString(fullRequestPath, "") + + switch { + case strings.HasPrefix(requestPath, "configmaps"): + return transport.proxyConfigRequest(request, requestPath) + default: + return transport.executeKubernetesRequest(request, true) + } +} + +func (transport *baseTransport) executeKubernetesRequest(request *http.Request, shouldLog bool) (*http.Response, error) { + var body []byte + + if shouldLog { + bodyBytes, err := utils.CopyBody(request) + if err != nil { + return nil, err + } + + body = bodyBytes + } + + resp, err := transport.httpTransport.RoundTrip(request) + + // log if request is success + if shouldLog && err == nil && (200 <= resp.StatusCode && resp.StatusCode < 300) { + useractivity.LogProxyActivity(transport.userActivityStore, transport.endpoint.Name, request, body) + } + + return resp, err +} + +func (transport *baseTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport.proxyKubernetesRequest(request) +} + // NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API -func NewLocalTransport(tokenManager *tokenManager, - endpointIdentifier portainer.EndpointID) (*localTransport, error) { +func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, userActivityStore portainer.UserActivityStore) (*localTransport, error) { config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true) if err != nil { return nil, err } transport := &localTransport{ - httpTransport: &http.Transport{ - TLSClientConfig: config, + baseTransport: &baseTransport{ + httpTransport: &http.Transport{ + TLSClientConfig: config, + }, + tokenManager: tokenManager, + endpoint: endpoint, + userActivityStore: userActivityStore, }, - tokenManager: tokenManager, - endpointIdentifier: endpointIdentifier, } return transport, nil @@ -55,26 +128,26 @@ func NewLocalTransport(tokenManager *tokenManager, // RoundTrip is the implementation of the the http.RoundTripper interface func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) { - token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier) + err := transport.prepareRoundTrip(request) if err != nil { return nil, err } - request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - - return transport.httpTransport.RoundTrip(request) + return transport.baseTransport.RoundTrip(request) } // NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent -func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, - tokenManager *tokenManager, endpointIdentifier portainer.EndpointID) *agentTransport { +func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager, endpoint *portainer.Endpoint, userActivityStore portainer.UserActivityStore) *agentTransport { transport := &agentTransport{ - httpTransport: &http.Transport{ - TLSClientConfig: tlsConfig, + baseTransport: &baseTransport{ + httpTransport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + tokenManager: tokenManager, + endpoint: endpoint, + userActivityStore: userActivityStore, }, - tokenManager: tokenManager, - signatureService: signatureService, - endpointIdentifier: endpointIdentifier, + signatureService: signatureService, } return transport @@ -82,13 +155,11 @@ func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsCo // RoundTrip is the implementation of the the http.RoundTripper interface func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) { - token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier) + err := transport.prepareRoundTrip(request) if err != nil { return nil, err } - request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) - signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) if err != nil { return nil, err @@ -97,16 +168,19 @@ func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Respons request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey()) request.Header.Set(portainer.PortainerAgentSignatureHeader, signature) - return transport.httpTransport.RoundTrip(request) + return transport.baseTransport.RoundTrip(request) } // NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent -func NewEdgeTransport(reverseTunnelService portainer.ReverseTunnelService, endpointIdentifier portainer.EndpointID, tokenManager *tokenManager) *edgeTransport { +func NewEdgeTransport(reverseTunnelService portainer.ReverseTunnelService, endpoint *portainer.Endpoint, tokenManager *tokenManager, userActivityStore portainer.UserActivityStore) *edgeTransport { transport := &edgeTransport{ - httpTransport: &http.Transport{}, - tokenManager: tokenManager, + baseTransport: &baseTransport{ + httpTransport: &http.Transport{}, + tokenManager: tokenManager, + endpoint: endpoint, + userActivityStore: userActivityStore, + }, reverseTunnelService: reverseTunnelService, - endpointIdentifier: endpointIdentifier, } return transport @@ -114,29 +188,23 @@ func NewEdgeTransport(reverseTunnelService portainer.ReverseTunnelService, endpo // RoundTrip is the implementation of the the http.RoundTripper interface func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) { - token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier) + err := transport.prepareRoundTrip(request) if err != nil { return nil, err } - request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) - - response, err := transport.httpTransport.RoundTrip(request) + response, err := transport.baseTransport.RoundTrip(request) if err == nil { - transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpointIdentifier) + transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpoint.ID) } else { - transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpointIdentifier) + transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpoint.ID) } return response, err } -func getRoundTripToken( - request *http.Request, - tokenManager *tokenManager, - endpointIdentifier portainer.EndpointID, -) (string, error) { +func getRoundTripToken(request *http.Request, tokenManager *tokenManager, endpointIdentifier portainer.EndpointID) (string, error) { tokenData, err := security.RetrieveTokenData(request) if err != nil { return "", err diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index c48cdb556..2fa8c1a58 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -36,6 +36,7 @@ func NewManager( kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, authService *authorization.Service, + userActivityStore portainer.UserActivityStore, ) *Manager { return &Manager{ endpointProxies: cmap.New(), @@ -49,6 +50,7 @@ func NewManager( kubernetesClientFactory, kubernetesTokenCacheManager, authService, + userActivityStore, ), } } diff --git a/api/http/registryproxy/custom.go b/api/http/registryproxy/custom.go index d1ba07a97..e84c3781f 100644 --- a/api/http/registryproxy/custom.go +++ b/api/http/registryproxy/custom.go @@ -6,14 +6,17 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/http/useractivity" + "github.com/portainer/portainer/api/http/utils" ) type customTransport struct { - config *portainer.RegistryManagementConfiguration - httpTransport *http.Transport + config *portainer.RegistryManagementConfiguration + httpTransport *http.Transport + userActivityStore portainer.UserActivityStore } -func newCustomRegistryProxy(uri string, config *portainer.RegistryManagementConfiguration) (http.Handler, error) { +func newCustomRegistryProxy(uri string, config *portainer.RegistryManagementConfiguration, userActivityStore portainer.UserActivityStore) (http.Handler, error) { scheme := "http" if config.TLSConfig.TLS { scheme = "https" @@ -28,8 +31,9 @@ func newCustomRegistryProxy(uri string, config *portainer.RegistryManagementConf proxy := newSingleHostReverseProxyWithHostHeader(url) proxy.Transport = &customTransport{ - config: config, - httpTransport: &http.Transport{}, + config: config, + httpTransport: &http.Transport{}, + userActivityStore: userActivityStore, } if config.TLSConfig.TLS { @@ -62,21 +66,33 @@ func (transport *customTransport) RoundTrip(request *http.Request) (*http.Respon clonedRequest.SetBasicAuth(transport.config.Username, transport.config.Password) } + body, err := utils.CopyBody(request) + if err != nil { + return nil, err + } + response, err := transport.httpTransport.RoundTrip(clonedRequest) if err != nil { - return response, err + return nil, err } if response.StatusCode == http.StatusUnauthorized { token, err := requestToken(response, transport.config) if err != nil { - return response, err + return nil, err } request.Header.Set("Authorization", "Bearer "+*token) - response, err := transport.httpTransport.RoundTrip(request) - return response, err + response, err = transport.httpTransport.RoundTrip(request) + if err != nil { + return nil, err + } } - return response, nil + // log if request is success + if err == nil && (200 <= response.StatusCode && response.StatusCode < 300) { + useractivity.LogProxyActivity(transport.userActivityStore, "Portainer", request, body) + } + + return response, err } diff --git a/api/http/registryproxy/gitlab.go b/api/http/registryproxy/gitlab.go index e3dd47d7b..cb75cb51c 100644 --- a/api/http/registryproxy/gitlab.go +++ b/api/http/registryproxy/gitlab.go @@ -6,14 +6,17 @@ import ( "net/url" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/useractivity" + "github.com/portainer/portainer/api/http/utils" ) type gitlabTransport struct { - config *portainer.RegistryManagementConfiguration - httpTransport *http.Transport + config *portainer.RegistryManagementConfiguration + httpTransport *http.Transport + userActivityStore portainer.UserActivityStore } -func newGitlabRegistryProxy(uri string, config *portainer.RegistryManagementConfiguration) (http.Handler, error) { +func newGitlabRegistryProxy(uri string, config *portainer.RegistryManagementConfiguration, userActivityStore portainer.UserActivityStore) (http.Handler, error) { url, err := url.Parse(uri) if err != nil { return nil, err @@ -21,8 +24,9 @@ func newGitlabRegistryProxy(uri string, config *portainer.RegistryManagementConf proxy := newSingleHostReverseProxyWithHostHeader(url) proxy.Transport = &gitlabTransport{ - config: config, - httpTransport: &http.Transport{}, + config: config, + httpTransport: &http.Transport{}, + userActivityStore: userActivityStore, } return proxy, nil @@ -38,10 +42,25 @@ func (transport *gitlabTransport) RoundTrip(request *http.Request) (*http.Respon if token == "" { return nil, errors.New("No gitlab token provided") } + r, err := http.NewRequest(request.Method, request.URL.String(), nil) if err != nil { return nil, err } + r.Header.Set("Private-Token", token) - return transport.httpTransport.RoundTrip(r) + + body, err := utils.CopyBody(request) + if err != nil { + return nil, err + } + + resp, err := transport.httpTransport.RoundTrip(r) + + // log if request is success + if err == nil && (200 <= resp.StatusCode && resp.StatusCode < 300) { + useractivity.LogProxyActivity(transport.userActivityStore, "Portainer", r, body) + } + + return resp, err } diff --git a/api/http/registryproxy/proxy.go b/api/http/registryproxy/proxy.go index a4006f079..acad044fe 100644 --- a/api/http/registryproxy/proxy.go +++ b/api/http/registryproxy/proxy.go @@ -4,19 +4,21 @@ import ( "net/http" "strings" - "github.com/orcaman/concurrent-map" + cmap "github.com/orcaman/concurrent-map" portainer "github.com/portainer/portainer/api" ) // Service represents a service used to manage registry proxies. type Service struct { - proxies cmap.ConcurrentMap + proxies cmap.ConcurrentMap + userActivityStore portainer.UserActivityStore } // NewService returns a pointer to a Service. -func NewService() *Service { +func NewService(userActivityStore portainer.UserActivityStore) *Service { return &Service{ - proxies: cmap.New(), + proxies: cmap.New(), + userActivityStore: userActivityStore, } } @@ -37,15 +39,15 @@ func (service *Service) createProxy(key, uri string, config *portainer.RegistryM switch config.Type { case portainer.AzureRegistry: - proxy, err = newTokenSecuredRegistryProxy(uri, config) + proxy, err = newTokenSecuredRegistryProxy(uri, config, service.userActivityStore) case portainer.GitlabRegistry: if strings.Contains(key, "gitlab") { - proxy, err = newGitlabRegistryProxy(uri, config) + proxy, err = newGitlabRegistryProxy(uri, config, service.userActivityStore) } else { - proxy, err = newTokenSecuredRegistryProxy(uri, config) + proxy, err = newTokenSecuredRegistryProxy(uri, config, service.userActivityStore) } default: - proxy, err = newCustomRegistryProxy(uri, config) + proxy, err = newCustomRegistryProxy(uri, config, service.userActivityStore) } if err != nil { diff --git a/api/http/registryproxy/token.go b/api/http/registryproxy/token.go index 33017cd0d..4c583cf0a 100644 --- a/api/http/registryproxy/token.go +++ b/api/http/registryproxy/token.go @@ -6,12 +6,15 @@ import ( "time" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/useractivity" + "github.com/portainer/portainer/api/http/utils" ) type ( tokenSecuredTransport struct { - config *portainer.RegistryManagementConfiguration - client *http.Client + config *portainer.RegistryManagementConfiguration + client *http.Client + userActivityStore portainer.UserActivityStore } genericAuthenticationResponse struct { @@ -23,7 +26,7 @@ type ( } ) -func newTokenSecuredRegistryProxy(uri string, config *portainer.RegistryManagementConfiguration) (http.Handler, error) { +func newTokenSecuredRegistryProxy(uri string, config *portainer.RegistryManagementConfiguration, userActivityStore portainer.UserActivityStore) (http.Handler, error) { url, err := url.Parse("https://" + uri) if err != nil { return nil, err @@ -31,7 +34,8 @@ func newTokenSecuredRegistryProxy(uri string, config *portainer.RegistryManageme proxy := newSingleHostReverseProxyWithHostHeader(url) proxy.Transport = &tokenSecuredTransport{ - config: config, + config: config, + userActivityStore: userActivityStore, client: &http.Client{ Timeout: time.Second * 10, }, @@ -53,6 +57,11 @@ func (transport *tokenSecuredTransport) RoundTrip(request *http.Request) (*http. return nil, err } + body, err := utils.CopyBody(request) + if err != nil { + return nil, err + } + response, err := http.DefaultTransport.RoundTrip(requestCopy) if err != nil { return response, err @@ -65,7 +74,16 @@ func (transport *tokenSecuredTransport) RoundTrip(request *http.Request) (*http. } request.Header.Set("Authorization", "Bearer "+*token) - return http.DefaultTransport.RoundTrip(request) + response, err = http.DefaultTransport.RoundTrip(request) + if err != nil { + return nil, err + } + + } + + // log if request is success + if 200 <= response.StatusCode && response.StatusCode < 300 { + useractivity.LogProxyActivity(transport.userActivityStore, "Portainer", request, body) } return response, nil diff --git a/api/http/server.go b/api/http/server.go index 9be7640b5..c27601fdd 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -125,22 +125,27 @@ func (server *Server) Start() error { customTemplatesHandler.DataStore = server.DataStore customTemplatesHandler.FileService = server.FileService customTemplatesHandler.GitService = server.GitService + customTemplatesHandler.UserActivityStore = server.UserActivityStore var dockerHubHandler = dockerhub.NewHandler(requestBouncer) dockerHubHandler.DataStore = server.DataStore + dockerHubHandler.UserActivityStore = server.UserActivityStore var edgeGroupsHandler = edgegroups.NewHandler(requestBouncer) edgeGroupsHandler.DataStore = server.DataStore + edgeGroupsHandler.UserActivityStore = server.UserActivityStore var edgeJobsHandler = edgejobs.NewHandler(requestBouncer) edgeJobsHandler.DataStore = server.DataStore edgeJobsHandler.FileService = server.FileService edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService + edgeJobsHandler.UserActivityStore = server.UserActivityStore var edgeStacksHandler = edgestacks.NewHandler(requestBouncer) edgeStacksHandler.DataStore = server.DataStore edgeStacksHandler.FileService = server.FileService edgeStacksHandler.GitService = server.GitService + edgeStacksHandler.UserActivityStore = server.UserActivityStore var edgeTemplatesHandler = edgetemplates.NewHandler(requestBouncer) edgeTemplatesHandler.DataStore = server.DataStore @@ -155,6 +160,7 @@ func (server *Server) Start() error { endpointHandler.K8sClientFactory = server.KubernetesClientFactory endpointHandler.ComposeStackManager = server.ComposeStackManager endpointHandler.DockerClientFactory = server.DockerClientFactory + endpointHandler.UserActivityStore = server.UserActivityStore var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer) endpointEdgeHandler.DataStore = server.DataStore @@ -164,6 +170,7 @@ func (server *Server) Start() error { var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer) endpointGroupHandler.AuthorizationService = server.AuthorizationService endpointGroupHandler.DataStore = server.DataStore + endpointGroupHandler.UserActivityStore = server.UserActivityStore var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer) endpointProxyHandler.DataStore = server.DataStore @@ -172,6 +179,7 @@ func (server *Server) Start() error { var licenseHandler = licenses.NewHandler(requestBouncer) licenseHandler.LicenseService = server.LicenseService + licenseHandler.UserActivityStore = server.UserActivityStore var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) @@ -182,13 +190,14 @@ func (server *Server) Start() error { var motdHandler = motd.NewHandler(requestBouncer) - var registryHandler = registries.NewHandler(requestBouncer) + var registryHandler = registries.NewHandler(requestBouncer, server.UserActivityStore) registryHandler.DataStore = server.DataStore registryHandler.FileService = server.FileService registryHandler.ProxyManager = server.ProxyManager var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer) resourceControlHandler.DataStore = server.DataStore + resourceControlHandler.UserActivityStore = server.UserActivityStore var settingsHandler = settings.NewHandler(requestBouncer) settingsHandler.AuthorizationService = server.AuthorizationService @@ -197,6 +206,7 @@ func (server *Server) Start() error { settingsHandler.JWTService = server.JWTService settingsHandler.LDAPService = server.LDAPService settingsHandler.SnapshotService = server.SnapshotService + settingsHandler.UserActivityStore = server.UserActivityStore var stackHandler = stacks.NewHandler(requestBouncer) stackHandler.DataStore = server.DataStore @@ -209,21 +219,25 @@ func (server *Server) Start() error { stackHandler.DockerClientFactory = server.DockerClientFactory stackHandler.KubernetesClientFactory = server.KubernetesClientFactory stackHandler.AuthorizationService = server.AuthorizationService + stackHandler.UserActivityStore = server.UserActivityStore var statusHandler = status.NewHandler(requestBouncer, server.Status) statusHandler.DataStore = server.DataStore var tagHandler = tags.NewHandler(requestBouncer) tagHandler.DataStore = server.DataStore + tagHandler.UserActivityStore = server.UserActivityStore var teamHandler = teams.NewHandler(requestBouncer) teamHandler.AuthorizationService = server.AuthorizationService teamHandler.DataStore = server.DataStore teamHandler.K8sClientFactory = server.KubernetesClientFactory + teamHandler.UserActivityStore = server.UserActivityStore var teamMembershipHandler = teammemberships.NewHandler(requestBouncer) teamMembershipHandler.AuthorizationService = server.AuthorizationService teamMembershipHandler.DataStore = server.DataStore + teamMembershipHandler.UserActivityStore = server.UserActivityStore var templatesHandler = templates.NewHandler(requestBouncer) templatesHandler.DataStore = server.DataStore @@ -232,12 +246,14 @@ func (server *Server) Start() error { var uploadHandler = upload.NewHandler(requestBouncer) uploadHandler.FileService = server.FileService + uploadHandler.UserActivityStore = server.UserActivityStore var userHandler = users.NewHandler(requestBouncer, rateLimiter) userHandler.AuthorizationService = server.AuthorizationService userHandler.DataStore = server.DataStore userHandler.CryptoService = server.CryptoService userHandler.K8sClientFactory = server.KubernetesClientFactory + userHandler.UserActivityStore = server.UserActivityStore var userActivityHandler = useractivity.NewHandler(requestBouncer) userActivityHandler.UserActivityStore = server.UserActivityStore @@ -247,10 +263,12 @@ func (server *Server) Start() error { websocketHandler.SignatureService = server.SignatureService websocketHandler.ReverseTunnelService = server.ReverseTunnelService websocketHandler.KubernetesClientFactory = server.KubernetesClientFactory + websocketHandler.UserActivityStore = server.UserActivityStore var webhookHandler = webhooks.NewHandler(requestBouncer) webhookHandler.DataStore = server.DataStore webhookHandler.DockerClientFactory = server.DockerClientFactory + webhookHandler.UserActivityStore = server.UserActivityStore server.Handler = &handler.Handler{ RoleHandler: roleHandler, diff --git a/api/http/useractivity/useractivity.go b/api/http/useractivity/useractivity.go new file mode 100644 index 000000000..971168e5f --- /dev/null +++ b/api/http/useractivity/useractivity.go @@ -0,0 +1,61 @@ +package useractivity + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" +) + +// LogHttpActivity logs an http request +func LogHttpActivity(store portainer.UserActivityStore, context string, request *http.Request, payload interface{}) { + var body []byte + + if payload != nil { + bodyMarshalled, err := json.Marshal(payload) + if err != nil { + log.Printf("[ERROR] [http,useractivity] [message: failed marshalling payload] [error: %s]", err) + return + } + + body = bodyMarshalled + } + + logActivity(store, context, request, body) +} + +// LogHttpActivity logs an http request to a proxy +// it parses the body as is (without cleaning) +func LogProxyActivity(store portainer.UserActivityStore, context string, request *http.Request, body []byte) { + method := request.Method + isWrite := method == "POST" || method == "DELETE" || method == "PUT" || method == "PATCH" + if !isWrite { + return + } + + logActivity(store, context, request, body) +} + +func logActivity(store portainer.UserActivityStore, context string, request *http.Request, body []byte) { + if context == "" { + context = "Portainer" + } + + username := "" + tokenData, err := security.RetrieveTokenData(request) + if err == nil { + username = tokenData.Username + } + + // ignore binary content + contentTypeHeader := request.Header.Get("Content-Type") + if contentTypeHeader == "application/x-tar" { + body = nil + } + + store.LogUserActivity(username, context, fmt.Sprintf("%s %s", request.Method, request.RequestURI), body) + +} diff --git a/api/http/utils/utils.go b/api/http/utils/utils.go new file mode 100644 index 000000000..c049645eb --- /dev/null +++ b/api/http/utils/utils.go @@ -0,0 +1,27 @@ +package utils + +import ( + "bytes" + "io/ioutil" + "net/http" + + "github.com/pkg/errors" +) + +// CopyBody copies the request body and recreates it +func CopyBody(request *http.Request) ([]byte, error) { + if request.Body == nil { + return nil, nil + } + + bodyBytes, err := ioutil.ReadAll(request.Body) + if err != nil { + return nil, errors.Wrap(err, "unable to read body") + } + + request.Body.Close() + // recreate body to pass to actual request handler + request.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes)) + + return bodyBytes, nil +} diff --git a/api/portainer.go b/api/portainer.go index 2182a7344..1e052e837 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -23,27 +23,18 @@ type ( Authorizations Authorizations } - // AuthActivityLog represents a log entry for user authentication activities AuthActivityLog struct { - ID int `json:"id" storm:"increment"` - Type AuthenticationActivityType `json:"type" storm:"index"` - Timestamp int64 `json:"timestamp" storm:"index"` - Origin string `json:"origin" storm:"index"` - Context AuthenticationMethod `json:"context" storm:"index"` - Username string `json:"username" storm:"index"` + UserActivityLogBase `storm:"inline"` + Type AuthenticationActivityType `json:"type" storm:"index"` + Origin string `json:"origin" storm:"index"` + Context AuthenticationMethod `json:"context" storm:"index"` } - // AuthLogsQuery represent the options used to get UserActivity logs + // AuthLogsQuery represent the options used to get AuthActivity logs AuthLogsQuery struct { - Limit int - Offset int - BeforeTimestamp int64 - AfterTimestamp int64 - SortBy string - SortDesc bool - Keyword string - ContextTypes []AuthenticationMethod - ActivityTypes []AuthenticationActivityType + UserActivityLogBaseQuery + ContextTypes []AuthenticationMethod + ActivityTypes []AuthenticationActivityType } // AuthenticationActivityType represents the type of an authentication action @@ -866,6 +857,31 @@ type ( // UserAccessPolicies represent the association of an access policy and a user UserAccessPolicies map[UserID]AccessPolicy + // AuthActivityLog represents a log entry for user authentication activities + + UserActivityLogBase struct { + ID int `json:"id" storm:"increment"` + Timestamp int64 `json:"timestamp" storm:"index"` + Username string `json:"username" storm:"index"` + } + + UserActivityLogBaseQuery struct { + Limit int + Offset int + BeforeTimestamp int64 + AfterTimestamp int64 + SortBy string + SortDesc bool + Keyword string + } + + UserActivityLog struct { + UserActivityLogBase `storm:"inline"` + Context string `json:"context" storm:"index"` + Action string `json:"action" storm:"index"` + Payload []byte `json:"payload"` + } + // UserID represents a user identifier UserID int @@ -1281,6 +1297,9 @@ type ( UserActivityStore interface { GetAuthLogs(opts AuthLogsQuery) ([]*AuthActivityLog, int, error) LogAuthActivity(username, origin string, context AuthenticationMethod, activityType AuthenticationActivityType) (*AuthActivityLog, error) + + GetUserActivityLogs(opts UserActivityLogBaseQuery) ([]*UserActivityLog, int, error) + LogUserActivity(username, context, action string, payload []byte) (*UserActivityLog, error) } // UserService represents a service for managing user data diff --git a/api/useractivity/activity_log.go b/api/useractivity/activity_log.go new file mode 100644 index 000000000..bd2de1b96 --- /dev/null +++ b/api/useractivity/activity_log.go @@ -0,0 +1,46 @@ +package useractivity + +import ( + "fmt" + "time" + + "github.com/asdine/storm/v3/q" + portainer "github.com/portainer/portainer/api" +) + +func (store *Store) LogUserActivity(username, context, action string, payload []byte) (*portainer.UserActivityLog, error) { + activity := &portainer.UserActivityLog{ + UserActivityLogBase: portainer.UserActivityLogBase{ + Timestamp: time.Now().Unix(), + Username: username, + }, + Context: context, + Action: action, + Payload: payload, + } + + err := store.db.Save(activity) + if err != nil { + return nil, fmt.Errorf("failed saving activity to db: %w", err) + } + + return activity, nil +} + +// GetActivityLogs queries the db for activity logs +// it returns the logs in this page (offset/limit) and the amount of logs in total for this query +func (store *Store) GetUserActivityLogs(opts portainer.UserActivityLogBaseQuery) ([]*portainer.UserActivityLog, int, error) { + matchers := []q.Matcher{} + + if opts.Keyword != "" { + matchers = append(matchers, q.Or(q.Re("Context", opts.Keyword), q.Re("Action", opts.Keyword), q.Re("Payload", opts.Keyword), q.Re("Username", opts.Keyword))) + } + + activities := []*portainer.UserActivityLog{} + count, err := store.getLogs(&activities, &portainer.UserActivityLog{}, opts, matchers) + if err != nil { + return nil, 0, err + } + + return activities, count, nil +} diff --git a/api/useractivity/activity_log_test.go b/api/useractivity/activity_log_test.go new file mode 100644 index 000000000..a07a9aa89 --- /dev/null +++ b/api/useractivity/activity_log_test.go @@ -0,0 +1,268 @@ +package useractivity + +import ( + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func TestAddUserActivity(t *testing.T) { + store, err := setup(t.TempDir()) + if err != nil { + t.Fatalf("Failed setup: %s", err) + } + + defer store.Close() + + expectedPayloadString := "payload" + + expected := portainer.UserActivityLog{ + UserActivityLogBase: portainer.UserActivityLogBase{ + Username: "username", + }, + Context: "context", + Action: "action", + + Payload: []byte(expectedPayloadString), + } + + createdLog, err := store.LogUserActivity(expected.Username, expected.Context, expected.Action, expected.Payload) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + assert.Equal(t, expected.Username, createdLog.Username) + assert.Equal(t, expected.Context, createdLog.Context) + assert.Equal(t, expected.Action, createdLog.Action) + assert.Equal(t, expected.Payload, createdLog.Payload) + assert.Equal(t, expectedPayloadString, string(createdLog.Payload), "stored payload should have the same value") + + var logs []*portainer.UserActivityLog + + err = store.db.All(&logs) + if err != nil { + t.Fatalf("Failed retrieving activities: %s", err) + } + + assert.Equal(t, 1, len(logs), "Store should have one element") + assert.Equal(t, createdLog, logs[0], "logs should be equal") +} + +func TestGetUserActivityLogs(t *testing.T) { + store, err := setup(t.TempDir()) + if err != nil { + t.Fatalf("Failed setup: %s", err) + } + + defer store.Close() + + log1, err := store.LogUserActivity("username1", "context1", "action1", []byte("payload1")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + log2, err := store.LogUserActivity("username2", "context2", "action2", []byte("payload2")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + log3, err := store.LogUserActivity("username3", "context3", "action3", []byte("payload3")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + logs, _, err := store.GetUserActivityLogs(portainer.UserActivityLogBaseQuery{}) + if err != nil { + t.Fatalf("failed fetching logs: %s", err) + } + + assert.Equal(t, []*portainer.UserActivityLog{log1, log2, log3}, logs) +} + +func TestGetUserActivityLogsByTimestamp(t *testing.T) { + store, err := setup(t.TempDir()) + if err != nil { + t.Fatalf("Failed setup: %s", err) + } + + defer store.Close() + + log1, err := store.LogUserActivity("username1", "context1", "action1", []byte("payload1")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + time.Sleep(time.Second * 1) + + log2, err := store.LogUserActivity("username2", "context2", "action2", []byte("payload2")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + time.Sleep(time.Second * 1) + + log3, err := store.LogUserActivity("username3", "context3", "action3", []byte("payload3")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + logs, _, err := store.GetUserActivityLogs(portainer.UserActivityLogBaseQuery{ + BeforeTimestamp: log3.Timestamp - 1, + AfterTimestamp: log1.Timestamp + 1, + }) + + if err != nil { + t.Fatalf("failed fetching logs: %s", err) + } + + assert.Equal(t, log2, logs[0], "logs are not equal") +} + +func TestGetUserActivityLogsByKeyword(t *testing.T) { + store, err := setup(t.TempDir()) + if err != nil { + t.Fatalf("Failed setup: %s", err) + } + + defer store.Close() + + log1, err := store.LogUserActivity("username1", "context1", "action1", []byte("success")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + log2, err := store.LogUserActivity("username2", "context2", "action2", []byte("error")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + log3, err := store.LogUserActivity("username3", "context3", "action3", []byte("success")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + // like + shouldHaveAllLogs, _, err := store.GetUserActivityLogs(portainer.UserActivityLogBaseQuery{ + Keyword: "username", + }) + + if err != nil { + t.Fatalf("failed fetching logs: %s", err) + } + + assert.Equal(t, []*portainer.UserActivityLog{log1, log2, log3}, shouldHaveAllLogs) + + // username + shouldHaveOnlyLog1, _, err := store.GetUserActivityLogs(portainer.UserActivityLogBaseQuery{ + Keyword: "username1", + }) + + if err != nil { + t.Fatalf("failed fetching logs: %s", err) + } + + assert.Equal(t, log1, shouldHaveOnlyLog1[0]) + + // action + shouldHaveOnlyLog3, _, err := store.GetUserActivityLogs(portainer.UserActivityLogBaseQuery{ + Keyword: "action3", + }) + + if err != nil { + t.Fatalf("failed fetching logs: %s", err) + } + + assert.Equal(t, 1, len(shouldHaveOnlyLog3)) + assert.Equal(t, log3, shouldHaveOnlyLog3[0]) +} + +func TestGetUserActivityLogsSortOrderAndPaginate(t *testing.T) { + store, err := setup(t.TempDir()) + if err != nil { + t.Fatalf("Failed setup: %s", err) + } + + defer store.Close() + + log1, err := store.LogUserActivity("username1", "context1", "action1", []byte("payload1")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + log2, err := store.LogUserActivity("username2", "context2", "action2", []byte("payload2")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + log3, err := store.LogUserActivity("username3", "context3", "action3", []byte("payload3")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + log4, err := store.LogUserActivity("username4", "context4", "action4", []byte("payload4")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + shouldBeLog4AndLog3, _, err := store.GetUserActivityLogs(portainer.UserActivityLogBaseQuery{ + SortDesc: true, + SortBy: "Username", + Offset: 0, + Limit: 2, + }) + + if err != nil { + t.Fatalf("failed fetching logs: %s", err) + } + + assert.Equal(t, []*portainer.UserActivityLog{log4, log3}, shouldBeLog4AndLog3) + + shouldBeLog2AndLog1, _, err := store.GetUserActivityLogs(portainer.UserActivityLogBaseQuery{ + SortDesc: true, + SortBy: "Username", + Offset: 2, + Limit: 2, + }) + + if err != nil { + t.Fatalf("failed fetching logs: %s", err) + } + + assert.Equal(t, []*portainer.UserActivityLog{log2, log1}, shouldBeLog2AndLog1) +} + +func TestGetUserActivityLogsDesc(t *testing.T) { + store, err := setup(t.TempDir()) + if err != nil { + t.Fatalf("Failed setup: %s", err) + } + + defer store.Close() + + log1, err := store.LogUserActivity("username1", "context1", "action1", []byte("payload1")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + log2, err := store.LogUserActivity("username2", "context2", "action2", []byte("payload2")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + log3, err := store.LogUserActivity("username3", "context3", "action3", []byte("payload3")) + if err != nil { + t.Fatalf("Failed adding activity log: %s", err) + } + + logs, _, err := store.GetUserActivityLogs(portainer.UserActivityLogBaseQuery{ + SortDesc: true, + }) + + if err != nil { + t.Fatalf("failed fetching logs: %s", err) + } + + assert.Equal(t, []*portainer.UserActivityLog{log3, log2, log1}, logs) +} diff --git a/api/useractivity/auth_log.go b/api/useractivity/auth_log.go index c85a7e68d..beb1789c8 100644 --- a/api/useractivity/auth_log.go +++ b/api/useractivity/auth_log.go @@ -4,7 +4,6 @@ import ( "fmt" "time" - "github.com/asdine/storm/v3" "github.com/asdine/storm/v3/q" portainer "github.com/portainer/portainer/api" ) @@ -12,11 +11,13 @@ import ( // LogAuthActivity logs a new authentication activity log func (store *Store) LogAuthActivity(username, origin string, context portainer.AuthenticationMethod, activityType portainer.AuthenticationActivityType) (*portainer.AuthActivityLog, error) { activity := &portainer.AuthActivityLog{ - Type: activityType, - Timestamp: time.Now().Unix(), - Username: username, - Origin: origin, - Context: context, + Type: activityType, + UserActivityLogBase: portainer.UserActivityLogBase{ + Timestamp: time.Now().Unix(), + Username: username, + }, + Origin: origin, + Context: context, } err := store.db.Save(activity) @@ -30,25 +31,7 @@ func (store *Store) LogAuthActivity(username, origin string, context portainer.A // GetAuthLogs queries the db for authentication activity logs // it returns the logs in this page (offset/limit) and the amount of logs in total for this query func (store *Store) GetAuthLogs(opts portainer.AuthLogsQuery) ([]*portainer.AuthActivityLog, int, error) { - if opts.Limit == 0 { - opts.Limit = 50 - } - - if opts.SortBy == "" { - opts.SortBy = "Timestamp" - } - - matchers := []q.Matcher{ - q.Gte("Timestamp", opts.AfterTimestamp), - } - - if opts.BeforeTimestamp != 0 { - matchers = append(matchers, q.Lte("Timestamp", opts.BeforeTimestamp)) - } - - if opts.Keyword != "" { - matchers = append(matchers, q.Or(q.Re("Origin", opts.Keyword), q.Re("Username", opts.Keyword))) - } + matchers := []q.Matcher{} if len(opts.ContextTypes) > 0 { matchers = append(matchers, q.In("Context", opts.ContextTypes)) @@ -58,22 +41,13 @@ func (store *Store) GetAuthLogs(opts portainer.AuthLogsQuery) ([]*portainer.Auth matchers = append(matchers, q.In("Type", opts.ActivityTypes)) } - query := store.db.Select(matchers...) - - count, err := query.Count(&portainer.AuthActivityLog{}) - if err != nil { - return nil, 0, err - } - - limitedQuery := query.Limit(opts.Limit).Skip(opts.Offset).OrderBy(opts.SortBy) - - if opts.SortDesc { - limitedQuery = limitedQuery.Reverse() + if opts.Keyword != "" { + matchers = append(matchers, q.Or(q.Re("Origin", opts.Keyword), q.Re("Username", opts.Keyword))) } activities := []*portainer.AuthActivityLog{} - err = limitedQuery.Find(&activities) - if err != nil && err != storm.ErrNotFound { + count, err := store.getLogs(&activities, &portainer.AuthActivityLog{}, opts.UserActivityLogBaseQuery, matchers) + if err != nil { return nil, 0, err } diff --git a/api/useractivity/auth_log_test.go b/api/useractivity/auth_log_test.go index d3611649b..7783cc794 100644 --- a/api/useractivity/auth_log_test.go +++ b/api/useractivity/auth_log_test.go @@ -50,15 +50,6 @@ const ( testType = portainer.AuthenticationActivityType(0) ) -func setup(path string) (*Store, error) { - store, err := NewUserActivityStore(path) - if err != nil { - return nil, fmt.Errorf("Failed creating new store: %w", err) - } - - return store, nil -} - func TestAddActivity(t *testing.T) { store, err := setup(t.TempDir()) if err != nil { @@ -80,7 +71,7 @@ func TestAddActivity(t *testing.T) { assert.Equal(t, 1, count, "Store should have one element") } -func TestGetLogs(t *testing.T) { +func TestGetAuthLogs(t *testing.T) { store, err := setup(t.TempDir()) if err != nil { t.Fatalf("Failed setup: %s", err) @@ -111,7 +102,7 @@ func TestGetLogs(t *testing.T) { assert.Equal(t, []*portainer.AuthActivityLog{log1, log2, log3}, logs) } -func TestGetLogsByTimestamp(t *testing.T) { +func TestGetAuthLogsByTimestamp(t *testing.T) { store, err := setup(t.TempDir()) if err != nil { t.Fatalf("Failed setup: %s", err) @@ -139,8 +130,10 @@ func TestGetLogsByTimestamp(t *testing.T) { } logs, _, err := store.GetAuthLogs(portainer.AuthLogsQuery{ - BeforeTimestamp: log3.Timestamp - 1, - AfterTimestamp: log1.Timestamp + 1, + UserActivityLogBaseQuery: portainer.UserActivityLogBaseQuery{ + BeforeTimestamp: log3.Timestamp - 1, + AfterTimestamp: log1.Timestamp + 1, + }, }) if err != nil { @@ -150,7 +143,7 @@ func TestGetLogsByTimestamp(t *testing.T) { assert.Equal(t, log2, logs[0], "logs are not equal") } -func TestGetLogsByKeyword(t *testing.T) { +func TestGetAuthLogsByKeyword(t *testing.T) { store, err := setup(t.TempDir()) if err != nil { t.Fatalf("Failed setup: %s", err) @@ -175,7 +168,9 @@ func TestGetLogsByKeyword(t *testing.T) { // like shouldHaveAllLogs, _, err := store.GetAuthLogs(portainer.AuthLogsQuery{ - Keyword: "username", + UserActivityLogBaseQuery: portainer.UserActivityLogBaseQuery{ + Keyword: "username", + }, }) if err != nil { @@ -186,7 +181,9 @@ func TestGetLogsByKeyword(t *testing.T) { // username shouldHaveOnlyLog1, _, err := store.GetAuthLogs(portainer.AuthLogsQuery{ - Keyword: "username1", + UserActivityLogBaseQuery: portainer.UserActivityLogBaseQuery{ + Keyword: "username1", + }, }) if err != nil { @@ -197,7 +194,9 @@ func TestGetLogsByKeyword(t *testing.T) { // origin shouldHaveOnlyLog3, _, err := store.GetAuthLogs(portainer.AuthLogsQuery{ - Keyword: "endpoint3", + UserActivityLogBaseQuery: portainer.UserActivityLogBaseQuery{ + Keyword: "endpoint3", + }, }) if err != nil { @@ -207,7 +206,7 @@ func TestGetLogsByKeyword(t *testing.T) { assert.Equal(t, log3, shouldHaveOnlyLog3[0]) } -func TestGetLogsByContext(t *testing.T) { +func TestGetAuthLogsByContext(t *testing.T) { store, err := setup(t.TempDir()) if err != nil { t.Fatalf("Failed setup: %s", err) @@ -258,7 +257,7 @@ func TestGetLogsByContext(t *testing.T) { assert.Equal(t, []*portainer.AuthActivityLog{log1, log3}, shouldHaveLog1And3) } -func TestGetLogsByType(t *testing.T) { +func TestGetAuthLogsByType(t *testing.T) { store, err := setup(t.TempDir()) if err != nil { t.Fatalf("Failed setup: %s", err) @@ -309,7 +308,7 @@ func TestGetLogsByType(t *testing.T) { assert.Equal(t, []*portainer.AuthActivityLog{log1, log3}, shouldHaveLog1And3) } -func TestSortOrderAndPaginate(t *testing.T) { +func TestGetAuthLogsSortOrderAndPaginate(t *testing.T) { store, err := setup(t.TempDir()) if err != nil { t.Fatalf("Failed setup: %s", err) @@ -338,10 +337,12 @@ func TestSortOrderAndPaginate(t *testing.T) { } shouldBeLog4AndLog3, _, err := store.GetAuthLogs(portainer.AuthLogsQuery{ - SortDesc: true, - SortBy: "Username", - Offset: 0, - Limit: 2, + UserActivityLogBaseQuery: portainer.UserActivityLogBaseQuery{ + SortDesc: true, + SortBy: "Username", + Offset: 0, + Limit: 2, + }, }) if err != nil { @@ -351,10 +352,12 @@ func TestSortOrderAndPaginate(t *testing.T) { assert.Equal(t, []*portainer.AuthActivityLog{log4, log3}, shouldBeLog4AndLog3) shouldBeLog2AndLog1, _, err := store.GetAuthLogs(portainer.AuthLogsQuery{ - SortDesc: true, - SortBy: "Username", - Offset: 2, - Limit: 2, + UserActivityLogBaseQuery: portainer.UserActivityLogBaseQuery{ + SortDesc: true, + SortBy: "Username", + Offset: 2, + Limit: 2, + }, }) if err != nil { @@ -364,7 +367,7 @@ func TestSortOrderAndPaginate(t *testing.T) { assert.Equal(t, []*portainer.AuthActivityLog{log2, log1}, shouldBeLog2AndLog1) } -func TestGetLogsDesc(t *testing.T) { +func TestGetAuthLogsDesc(t *testing.T) { store, err := setup(t.TempDir()) if err != nil { t.Fatalf("Failed setup: %s", err) @@ -388,7 +391,9 @@ func TestGetLogsDesc(t *testing.T) { } logs, _, err := store.GetAuthLogs(portainer.AuthLogsQuery{ - SortDesc: true, + UserActivityLogBaseQuery: portainer.UserActivityLogBaseQuery{ + SortDesc: true, + }, }) if err != nil { diff --git a/api/useractivity/cleanup.go b/api/useractivity/cleanup.go index dccf8f25f..5780a0ecc 100644 --- a/api/useractivity/cleanup.go +++ b/api/useractivity/cleanup.go @@ -63,6 +63,12 @@ func (store *Store) cleanLogs() error { } log.Printf("[DEBUG] [message: removed %d old auth logs]", count) + count, err = store.cleanLogsByType(&portainer.UserActivityLog{}) + if err != nil { + return fmt.Errorf("failed cleaning user activity logs: %w", err) + } + log.Printf("[DEBUG] [message: removed %d old user activity logs]", count) + return nil } diff --git a/api/useractivity/useractivity.go b/api/useractivity/useractivity.go index 07daf89a3..e94b4e587 100644 --- a/api/useractivity/useractivity.go +++ b/api/useractivity/useractivity.go @@ -5,11 +5,16 @@ import ( "time" storm "github.com/asdine/storm/v3" + "github.com/asdine/storm/v3/q" + portainer "github.com/portainer/portainer/api" ) const ( cleanupInterval = 24 * time.Hour maxLogsAge = 7 + + // RedactedValue is used for cleared fields + RedactedValue = "[REDACTED]" ) // Store is a store for user activities @@ -34,6 +39,16 @@ func NewUserActivityStore(dataPath string) (*Store, error) { return nil, err } + err = db.Init(&portainer.UserActivityLog{}) + if err != nil { + return nil, err + } + + err = db.Init(&portainer.AuthActivityLog{}) + if err != nil { + return nil, err + } + store := &Store{ db: &dbWrapper{ DB: db, @@ -54,3 +69,43 @@ func (store *Store) Close() error { return store.db.Close() } + +func (store *Store) getLogs(activities interface{}, activityLogType interface{}, opts portainer.UserActivityLogBaseQuery, matchers []q.Matcher) (int, error) { + if opts.Limit == 0 { + opts.Limit = 50 + } + + if opts.SortBy == "" { + opts.SortBy = "Timestamp" + } + + matchers = append(matchers, q.Gte("Timestamp", opts.AfterTimestamp)) + + if opts.BeforeTimestamp != 0 { + matchers = append(matchers, q.Lte("Timestamp", opts.BeforeTimestamp)) + } + + query := store.db.Select(matchers...) + + count, err := query.Count(activityLogType) + if err != nil { + return 0, err + } + + if count == 0 { + return 0, nil + } + + limitedQuery := query.Limit(opts.Limit).Skip(opts.Offset).OrderBy(opts.SortBy) + + if opts.SortDesc { + limitedQuery = limitedQuery.Reverse() + } + + err = limitedQuery.Find(activities) + if err != nil && err != storm.ErrNotFound { + return 0, err + } + + return count, nil +} diff --git a/api/useractivity/useractivity_test.go b/api/useractivity/useractivity_test.go index 0bd4a6247..efc33cdb2 100644 --- a/api/useractivity/useractivity_test.go +++ b/api/useractivity/useractivity_test.go @@ -1 +1,12 @@ package useractivity + +import "fmt" + +func setup(path string) (*Store, error) { + store, err := NewUserActivityStore(path) + if err != nil { + return nil, fmt.Errorf("Failed creating new store: %w", err) + } + + return store, nil +} diff --git a/api/useractivity/utils.go b/api/useractivity/utils.go index 23992e93f..9a4840f21 100644 --- a/api/useractivity/utils.go +++ b/api/useractivity/utils.go @@ -68,3 +68,44 @@ func MarshalAuthLogsToCSV(w io.Writer, logs []*portainer.AuthActivityLog) error return csvw.Error() } + +// MarshalLogsToCSV converts a list of logs to a CSV string +func MarshalLogsToCSV(w io.Writer, logs []*portainer.UserActivityLog) error { + var headers = []string{ + "Time", + "Username", + "Endpoint", + "Action", + "Payload", + } + + csvw := csv.NewWriter(w) + + err := csvw.Write(headers) + if err != nil { + return err + } + + for _, log := range logs { + + timestamp := time.Unix(log.Timestamp, 0) + formattedTimestamp := fmt.Sprintf("%d-%02d-%02d %02d:%02d:%02d", + timestamp.Year(), timestamp.Month(), timestamp.Day(), + timestamp.Hour(), timestamp.Minute(), timestamp.Second()) + + err := csvw.Write([]string{ + formattedTimestamp, + log.Username, + log.Context, + log.Action, + string(log.Payload), + }) + if err != nil { + return err + } + } + + csvw.Flush() + + return csvw.Error() +} diff --git a/app/portainer/components/datatables/filter/datatable-filter.controller.js b/app/portainer/components/datatables/filter/datatable-filter.controller.js index 3e55559b0..32c6de64d 100644 --- a/app/portainer/components/datatables/filter/datatable-filter.controller.js +++ b/app/portainer/components/datatables/filter/datatable-filter.controller.js @@ -4,7 +4,7 @@ export default class DatatableFilterController { } onChangeItem(filterValue) { - if (this.state.includes(filterValue)) { + if (this.isChecked(filterValue)) { return this.onChange( this.filterKey, this.state.filter((v) => v !== filterValue) @@ -12,4 +12,8 @@ export default class DatatableFilterController { } return this.onChange(this.filterKey, [...this.state, filterValue]); } + + isChecked(filterValue) { + return this.state.includes(filterValue); + } } diff --git a/app/portainer/components/datatables/filter/datatable-filter.html b/app/portainer/components/datatables/filter/datatable-filter.html index 7703c3786..f824447ff 100644 --- a/app/portainer/components/datatables/filter/datatable-filter.html +++ b/app/portainer/components/datatables/filter/datatable-filter.html @@ -11,13 +11,13 @@