chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices/errors"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id UserAdminCheck
|
||||
// @summary Check administrator account existence
|
||||
// @description Check if an administrator account exists in the database.
|
||||
// @description **Access policy**: public
|
||||
// @tags users
|
||||
// @success 204 "Success"
|
||||
// @failure 404 "User not found"
|
||||
// @router /users/admin/check [get]
|
||||
func (handler *Handler) adminCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
users, err := handler.DataStore.User().UsersByRole(portainer.AdministratorRole)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve users from the database", err)
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
return httperror.NotFound("No administrator account found inside the database", errors.ErrObjectNotFound)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security/setuptoken"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type adminInitPayload struct {
|
||||
// Username for the admin user
|
||||
Username string `validate:"required" example:"admin"`
|
||||
// Password for the admin user
|
||||
Password string `validate:"required" example:"admin-password"`
|
||||
}
|
||||
|
||||
func (payload *adminInitPayload) Validate(r *http.Request) error {
|
||||
if len(payload.Username) == 0 || strings.Contains(payload.Username, " ") {
|
||||
return errors.New("Invalid username. Must not contain any whitespace")
|
||||
}
|
||||
if len(payload.Password) == 0 {
|
||||
return errors.New("Invalid password")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id UserAdminInit
|
||||
// @summary Initialize administrator account
|
||||
// @description Initialize the 'admin' user account.
|
||||
// @description **Access policy**: public (requires the X-Setup-Token header on an uninitialized instance unless --no-setup-token is set)
|
||||
// @tags users
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param X-Setup-Token header string false "Setup token (required when instance is uninitialized and --no-setup-token is not set)"
|
||||
// @param body body adminInitPayload true "User details"
|
||||
// @success 200 {object} portainer.User "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Access denied - invalid or missing setup token"
|
||||
// @failure 409 "Admin user already initialized"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/admin/init [post]
|
||||
func (handler *Handler) adminInit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
if err := setuptoken.Validate(r, handler.SetupToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var payload adminInitPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
users, err := handler.DataStore.User().UsersByRole(portainer.AdministratorRole)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve users from the database", err)
|
||||
}
|
||||
|
||||
if len(users) != 0 {
|
||||
return httperror.Conflict("Unable to create administrator user", errAdminAlreadyInitialized)
|
||||
}
|
||||
|
||||
if !handler.passwordStrengthChecker.Check(payload.Password) {
|
||||
return httperror.BadRequest("Password does not meet the requirements", nil)
|
||||
}
|
||||
|
||||
user := &portainer.User{
|
||||
Username: payload.Username,
|
||||
Role: portainer.AdministratorRole,
|
||||
}
|
||||
|
||||
user.Password, err = handler.CryptoService.Hash(payload.Password)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
|
||||
}
|
||||
|
||||
err = handler.DataStore.User().Create(user)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to persist user inside the database", err)
|
||||
}
|
||||
|
||||
// After the admin user is created, we can notify the endpoint initialization process
|
||||
handler.AdminCreationDone <- struct{}{}
|
||||
|
||||
return response.JSON(w, user)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/http/security/setuptoken"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newAdminInitHandler(t *testing.T) *Handler {
|
||||
t.Helper()
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
||||
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
||||
h := NewHandler(testhelpers.NewTestRequestBouncer(), rateLimiter, apiKeyService, mockPasswordStrengthChecker{})
|
||||
h.DataStore = store
|
||||
h.CryptoService = testhelpers.NewCryptoService()
|
||||
h.AdminCreationDone = make(chan struct{}, 1)
|
||||
return h
|
||||
}
|
||||
|
||||
func Test_adminInit_setupTokenGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("403 without token header", func(t *testing.T) {
|
||||
handler := newAdminInitHandler(t)
|
||||
handler.SetupToken = "secret-token"
|
||||
body := strings.NewReader(`{"Username":"admin","Password":"abcdefgh12"}`)
|
||||
r := httptest.NewRequest(http.MethodPost, "/users/admin/init", body)
|
||||
err := handler.adminInit(httptest.NewRecorder(), r)
|
||||
require.NotNil(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, err.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("403 with wrong token", func(t *testing.T) {
|
||||
handler := newAdminInitHandler(t)
|
||||
handler.SetupToken = "secret-token"
|
||||
body := strings.NewReader(`{"Username":"admin","Password":"abcdefgh12"}`)
|
||||
r := httptest.NewRequest(http.MethodPost, "/users/admin/init", body)
|
||||
r.Header.Set(setuptoken.HeaderName, "wrong")
|
||||
err := handler.adminInit(httptest.NewRecorder(), r)
|
||||
require.NotNil(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, err.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("succeeds with correct token", func(t *testing.T) {
|
||||
handler := newAdminInitHandler(t)
|
||||
handler.SetupToken = "secret-token"
|
||||
body := strings.NewReader(`{"Username":"admin","Password":"abcdefgh12"}`)
|
||||
r := httptest.NewRequest(http.MethodPost, "/users/admin/init", body)
|
||||
r.Header.Set(setuptoken.HeaderName, "secret-token")
|
||||
err := handler.adminInit(httptest.NewRecorder(), r)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
var (
|
||||
errUserAlreadyExists = errors.New("User already exists")
|
||||
errAdminAlreadyInitialized = errors.New("An administrator user already exists")
|
||||
errAdminCannotRemoveSelf = errors.New("Cannot remove your own user account. Contact another administrator")
|
||||
errCannotRemoveLastLocalAdmin = errors.New("Cannot remove the last local administrator account")
|
||||
errCryptoHashFailure = errors.New("Unable to hash data")
|
||||
)
|
||||
|
||||
func hideFields(user *portainer.User) {
|
||||
user.Password = ""
|
||||
}
|
||||
|
||||
// Handler is the HTTP handler used to handle user operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
bouncer security.BouncerService
|
||||
apiKeyService apikey.APIKeyService
|
||||
DataStore dataservices.DataStore
|
||||
CryptoService portainer.CryptoService
|
||||
passwordStrengthChecker security.PasswordStrengthChecker
|
||||
AdminCreationDone chan<- struct{}
|
||||
FileService portainer.FileService
|
||||
SetupToken string
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage user operations.
|
||||
func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, apiKeyService apikey.APIKeyService, passwordStrengthChecker security.PasswordStrengthChecker) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
bouncer: bouncer,
|
||||
apiKeyService: apiKeyService,
|
||||
passwordStrengthChecker: passwordStrengthChecker,
|
||||
}
|
||||
|
||||
adminRouter := h.NewRoute().Subrouter()
|
||||
adminRouter.Use(bouncer.AdminAccess)
|
||||
|
||||
teamLeaderRouter := h.NewRoute().Subrouter()
|
||||
teamLeaderRouter.Use(bouncer.TeamLeaderAccess)
|
||||
|
||||
authenticatedRouter := h.NewRoute().Subrouter()
|
||||
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
|
||||
|
||||
restrictedRouter := h.NewRoute().Subrouter()
|
||||
restrictedRouter.Use(bouncer.RestrictedAccess)
|
||||
|
||||
publicRouter := h.NewRoute().Subrouter()
|
||||
publicRouter.Use(bouncer.PublicAccess)
|
||||
|
||||
adminRouter.Handle("/users", httperror.LoggerHandler(h.userCreate)).Methods(http.MethodPost)
|
||||
restrictedRouter.Handle("/users", httperror.LoggerHandler(h.userList)).Methods(http.MethodGet)
|
||||
|
||||
authenticatedRouter.Handle("/users/me", httperror.LoggerHandler(h.userInspectMe)).Methods(http.MethodGet)
|
||||
restrictedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userInspect)).Methods(http.MethodGet)
|
||||
authenticatedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userUpdate)).Methods(http.MethodPut)
|
||||
adminRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userDelete)).Methods(http.MethodDelete)
|
||||
restrictedRouter.Handle("/users/{id}/tokens", httperror.LoggerHandler(h.userGetAccessTokens)).Methods(http.MethodGet)
|
||||
restrictedRouter.Handle("/users/{id}/tokens", rateLimiter.LimitAccess(httperror.LoggerHandler(h.userCreateAccessToken))).Methods(http.MethodPost)
|
||||
restrictedRouter.Handle("/users/{id}/tokens/{keyID}", httperror.LoggerHandler(h.userRemoveAccessToken)).Methods(http.MethodDelete)
|
||||
restrictedRouter.Handle("/users/{id}/memberships", httperror.LoggerHandler(h.userMemberships)).Methods(http.MethodGet)
|
||||
restrictedRouter.Handle("/users/{id}/effective-access", httperror.LoggerHandler(h.userEffectiveAccess)).Methods(http.MethodGet)
|
||||
authenticatedRouter.Handle("/users/{id}/passwd", rateLimiter.LimitAccess(httperror.LoggerHandler(h.userUpdatePassword))).Methods(http.MethodPut)
|
||||
|
||||
publicRouter.Handle("/users/admin/check", httperror.LoggerHandler(h.adminCheck)).Methods(http.MethodGet)
|
||||
publicRouter.Handle("/users/admin/init", httperror.LoggerHandler(h.adminInit)).Methods(http.MethodPost)
|
||||
|
||||
// Helm repositories
|
||||
authenticatedRouter.Handle("/users/{id}/helm/repositories", httperror.LoggerHandler(h.userGetHelmRepos)).Methods(http.MethodGet)
|
||||
authenticatedRouter.Handle("/users/{id}/helm/repositories", httperror.LoggerHandler(h.userCreateHelmRepo)).Methods(http.MethodPost)
|
||||
authenticatedRouter.Handle("/users/{id}/helm/repositories/{repositoryID}", httperror.LoggerHandler(h.userDeleteHelmRepo)).Methods(http.MethodDelete)
|
||||
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type userCreatePayload struct {
|
||||
Username string `validate:"required" example:"bob"`
|
||||
Password string `validate:"required" example:"cg9Wgky3"`
|
||||
// User role (1 for administrator account and 2 for regular account)
|
||||
Role int `validate:"required" enums:"1,2" example:"2"`
|
||||
}
|
||||
|
||||
func (payload *userCreatePayload) Validate(r *http.Request) error {
|
||||
if len(payload.Username) == 0 || strings.Contains(payload.Username, " ") {
|
||||
return errors.New("Invalid username. Must not contain any whitespace")
|
||||
}
|
||||
|
||||
if payload.Role != 1 && payload.Role != 2 {
|
||||
return errors.New("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id UserCreate
|
||||
// @summary Create a new user
|
||||
// @description Create a new Portainer user.
|
||||
// @description Only administrators can create users.
|
||||
// @description **Access policy**: restricted
|
||||
// @tags users
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param body body userCreatePayload true "User details"
|
||||
// @success 200 {object} portainer.User "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 409 "User already exists"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users [post]
|
||||
func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload userCreatePayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
var user *portainer.User
|
||||
var err error
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
user, err = handler.createUser(tx, payload)
|
||||
return err
|
||||
})
|
||||
|
||||
return response.TxResponse(w, user, err)
|
||||
}
|
||||
|
||||
func (handler *Handler) createUser(tx dataservices.DataStoreTx, payload userCreatePayload) (*portainer.User, error) {
|
||||
user, err := tx.User().UserByUsername(payload.Username)
|
||||
if err != nil && !tx.IsErrObjectNotFound(err) {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve users from the database", err)
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
return nil, httperror.Conflict("Another user with the same username already exists", errUserAlreadyExists)
|
||||
}
|
||||
|
||||
user = &portainer.User{
|
||||
Username: payload.Username,
|
||||
Role: portainer.UserRole(payload.Role),
|
||||
}
|
||||
|
||||
settings, err := tx.Settings().Settings()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err)
|
||||
}
|
||||
|
||||
// When LDAP/OAuth is on, can only add users without password
|
||||
if (settings.AuthenticationMethod == portainer.AuthenticationLDAP || settings.AuthenticationMethod == portainer.AuthenticationOAuth) && payload.Password != "" {
|
||||
errMsg := "a user with password can not be created when authentication method is Oauth or LDAP"
|
||||
return nil, httperror.BadRequest(errMsg, errors.New(errMsg))
|
||||
}
|
||||
|
||||
if settings.AuthenticationMethod == portainer.AuthenticationInternal {
|
||||
if !handler.passwordStrengthChecker.Check(payload.Password) {
|
||||
return nil, httperror.BadRequest("Password does not meet the requirements", nil)
|
||||
}
|
||||
|
||||
user.Password, err = handler.CryptoService.Hash(payload.Password)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.User().Create(user); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist user inside the database", err)
|
||||
}
|
||||
|
||||
hideFields(user)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
)
|
||||
|
||||
type userAccessTokenCreatePayload struct {
|
||||
Password string `validate:"required" example:"password" json:"password"`
|
||||
Description string `validate:"required" example:"github-api-key" json:"description"`
|
||||
}
|
||||
|
||||
func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error {
|
||||
if len(payload.Description) == 0 {
|
||||
return errors.New("invalid description: cannot be empty")
|
||||
}
|
||||
if validate.HasWhitespaceOnly(payload.Description) {
|
||||
return errors.New("invalid description: cannot contain only whitespaces")
|
||||
}
|
||||
if validate.MinStringLength(payload.Description, 128) {
|
||||
return errors.New("invalid description: cannot be longer than 128 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type accessTokenResponse struct {
|
||||
RawAPIKey string `json:"rawAPIKey"`
|
||||
APIKey portainer.APIKey `json:"apiKey"`
|
||||
}
|
||||
|
||||
// @id UserGenerateAPIKey
|
||||
// @summary Generate an API key for a user
|
||||
// @description Generates an API key for a user.
|
||||
// @description Only the calling user can generate a token for themselves.
|
||||
// @description Password is required only for internal authentication.
|
||||
// @description **Access policy**: restricted
|
||||
// @tags users
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "User identifier"
|
||||
// @param body body userAccessTokenCreatePayload true "details"
|
||||
// @success 200 {object} accessTokenResponse "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 401 "Unauthorized"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "User not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/{id}/tokens [post]
|
||||
func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
// specifically require Cookie auth for this endpoint since API-Key based auth is not supported
|
||||
jwt, _ := handler.bouncer.CookieAuthLookup(r)
|
||||
if jwt == nil {
|
||||
jwt, _ = handler.bouncer.JWTAuthLookup(r)
|
||||
}
|
||||
|
||||
if jwt == nil {
|
||||
return httperror.Unauthorized("Auth not supported", errors.New("Authentication required"))
|
||||
}
|
||||
|
||||
var payload userAccessTokenCreatePayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
userID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid user identifier route variable", err)
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
|
||||
if tokenData.ID != portainer.UserID(userID) {
|
||||
return httperror.Forbidden("Permission denied to create user access token", httperrors.ErrUnauthorized)
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().Read(portainer.UserID(userID))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
internalAuth, err := handler.usesInternalAuthentication(portainer.UserID(userID))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to determine the authentication method", err)
|
||||
}
|
||||
|
||||
if internalAuth {
|
||||
// Internal auth requires the password field and must not be empty
|
||||
if len(payload.Password) == 0 {
|
||||
return httperror.BadRequest("Invalid request payload", errors.New("invalid password: cannot be empty"))
|
||||
}
|
||||
|
||||
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again"))
|
||||
}
|
||||
}
|
||||
|
||||
rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Internal Server Error", err)
|
||||
}
|
||||
|
||||
return response.JSONWithStatus(w, accessTokenResponse{rawAPIKey, *apiKey}, http.StatusOK)
|
||||
}
|
||||
|
||||
func (handler *Handler) usesInternalAuthentication(userid portainer.UserID) (bool, error) {
|
||||
// userid 1 is the admin user and always uses internal auth
|
||||
if userid == 1 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// otherwise determine the auth method from the settings
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to retrieve the settings from the database: %w", err)
|
||||
}
|
||||
|
||||
return settings.AuthenticationMethod == portainer.AuthenticationInternal, nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_userCreateAccessToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
// create admin and standard user(s)
|
||||
adminUser := &portainer.User{ID: 1, Password: "password", Username: "admin", Role: portainer.AdministratorRole}
|
||||
err := store.User().Create(adminUser)
|
||||
require.NoError(t, err, "error creating admin user")
|
||||
|
||||
user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole}
|
||||
err = store.User().Create(user)
|
||||
require.NoError(t, err, "error creating user")
|
||||
|
||||
h, jwtService, apiKeyService := newTestHandler(t, store)
|
||||
h.CryptoService = testhelpers.NewCryptoService()
|
||||
|
||||
// generate standard and admin user tokens
|
||||
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
|
||||
|
||||
t.Run("standard user successfully generates API key", func(t *testing.T) {
|
||||
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token"}
|
||||
payload, err := json.Marshal(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload))
|
||||
testhelpers.AddTestSecurityCookie(req, jwt)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
require.NoError(t, err, "ReadAll should not return error")
|
||||
|
||||
var resp accessTokenResponse
|
||||
err = json.Unmarshal(body, &resp)
|
||||
require.NoError(t, err, "response should be json")
|
||||
is.Equal(data.Description, resp.APIKey.Description)
|
||||
is.NotEmpty(resp.RawAPIKey)
|
||||
})
|
||||
|
||||
t.Run("admin cannot generate API key for standard user", func(t *testing.T) {
|
||||
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token-admin"}
|
||||
payload, err := json.Marshal(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload))
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusForbidden, rr.Code)
|
||||
|
||||
_, err = io.ReadAll(rr.Body)
|
||||
require.NoError(t, err, "ReadAll should not return error")
|
||||
})
|
||||
|
||||
t.Run("endpoint cannot generate api-key using api-key auth", func(t *testing.T) {
|
||||
rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test-api-key")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token-fails"}
|
||||
payload, err := json.Marshal(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload))
|
||||
req.Header.Add("x-api-key", rawAPIKey)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusUnauthorized, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
require.NoError(t, err, "ReadAll should not return error")
|
||||
is.JSONEq(`{"message":"Auth not supported","details":"Authentication required"}`, string(body))
|
||||
})
|
||||
}
|
||||
|
||||
func Test_userAccessTokenCreatePayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
payload userAccessTokenCreatePayload
|
||||
shouldFail bool
|
||||
}{
|
||||
{
|
||||
payload: userAccessTokenCreatePayload{Password: "password", Description: "test-token"},
|
||||
shouldFail: false,
|
||||
},
|
||||
{
|
||||
payload: userAccessTokenCreatePayload{Password: "password", Description: ""},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
payload: userAccessTokenCreatePayload{Password: "password", Description: "test token"},
|
||||
shouldFail: false,
|
||||
},
|
||||
{
|
||||
payload: userAccessTokenCreatePayload{Password: "password", Description: "test-token "},
|
||||
shouldFail: false,
|
||||
},
|
||||
{
|
||||
payload: userAccessTokenCreatePayload{Password: "password", Description: `
|
||||
this string is longer than 128 characters and hence this will fail.
|
||||
this string is longer than 128 characters and hence this will fail.
|
||||
this string is longer than 128 characters and hence this will fail.
|
||||
this string is longer than 128 characters and hence this will fail.
|
||||
this string is longer than 128 characters and hence this will fail.
|
||||
this string is longer than 128 characters and hence this will fail.
|
||||
`},
|
||||
shouldFail: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
err := test.payload.Validate(nil)
|
||||
if test.shouldFail {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type mockPasswordStrengthChecker struct{}
|
||||
|
||||
func (m mockPasswordStrengthChecker) Check(string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func newTestHandler(t *testing.T, store *datastore.Store) (*Handler, *jwt.Service, apikey.APIKeyService) {
|
||||
t.Helper()
|
||||
|
||||
jwtService, err := jwt.NewService("1h", store)
|
||||
require.NoError(t, err)
|
||||
|
||||
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
||||
requestBouncer := security.NewRequestBouncer(t.Context(), store, jwtService, apiKeyService)
|
||||
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
||||
passwordChecker := security.NewPasswordStrengthChecker(store.SettingsService)
|
||||
|
||||
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, passwordChecker)
|
||||
h.DataStore = store
|
||||
|
||||
return h, jwtService, apiKeyService
|
||||
}
|
||||
|
||||
func TestConcurrentUserCreation(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
h := &Handler{
|
||||
passwordStrengthChecker: mockPasswordStrengthChecker{},
|
||||
CryptoService: crypto.Service{},
|
||||
DataStore: store,
|
||||
}
|
||||
|
||||
ucp := userCreatePayload{
|
||||
Username: "portainer",
|
||||
Password: "password",
|
||||
Role: int(portainer.AdministratorRole),
|
||||
}
|
||||
|
||||
m, err := json.Marshal(ucp)
|
||||
require.NoError(t, err)
|
||||
|
||||
errGroup := &errgroup.Group{}
|
||||
|
||||
n := 100
|
||||
|
||||
for range n {
|
||||
errGroup.Go(func() error {
|
||||
req, err := http.NewRequest(http.MethodPost, "/users", bytes.NewReader(m))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.userCreate(httptest.NewRecorder(), req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
err = errGroup.Wait()
|
||||
require.Error(t, err)
|
||||
|
||||
users, err := store.User().ReadAll()
|
||||
require.NotEmpty(t, users)
|
||||
require.NoError(t, err)
|
||||
|
||||
userCreated := false
|
||||
for _, u := range users {
|
||||
if u.Username == ucp.Username {
|
||||
require.False(t, userCreated)
|
||||
userCreated = true
|
||||
}
|
||||
}
|
||||
|
||||
require.True(t, userCreated)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id UserDelete
|
||||
// @summary Remove a user
|
||||
// @description Remove a user.
|
||||
// @description **Access policy**: administrator
|
||||
// @tags users
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "User identifier"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "User not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/{id} [delete]
|
||||
func (handler *Handler) userDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
userID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid user identifier route variable", err)
|
||||
}
|
||||
|
||||
if userID == 1 {
|
||||
return httperror.Forbidden("Cannot remove the initial admin account", errors.New("Cannot remove the initial admin account"))
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
|
||||
if tokenData.ID == portainer.UserID(userID) {
|
||||
return httperror.Forbidden("Cannot remove your own user account. Contact another administrator", errAdminCannotRemoveSelf)
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().Read(portainer.UserID(userID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
if user.Role == portainer.AdministratorRole {
|
||||
return handler.deleteAdminUser(w, user)
|
||||
}
|
||||
|
||||
return handler.deleteUser(w, user)
|
||||
}
|
||||
|
||||
func (handler *Handler) deleteAdminUser(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError {
|
||||
if user.Password == "" {
|
||||
return handler.deleteUser(w, user)
|
||||
}
|
||||
|
||||
users, err := handler.DataStore.User().ReadAll()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve users from the database", err)
|
||||
}
|
||||
|
||||
localAdminCount := 0
|
||||
for _, u := range users {
|
||||
if u.Role == portainer.AdministratorRole && u.Password != "" {
|
||||
localAdminCount++
|
||||
}
|
||||
}
|
||||
|
||||
if localAdminCount < 2 {
|
||||
return httperror.InternalServerError("Cannot remove local administrator user", errCannotRemoveLastLocalAdmin)
|
||||
}
|
||||
|
||||
return handler.deleteUser(w, user)
|
||||
}
|
||||
|
||||
func (handler *Handler) deleteUser(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError {
|
||||
err := handler.DataStore.User().Delete(user.ID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to remove user from the database", err)
|
||||
}
|
||||
|
||||
err = handler.DataStore.TeamMembership().DeleteTeamMembershipByUserID(user.ID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to remove user memberships from the database", err)
|
||||
}
|
||||
|
||||
// Remove all of the users persisted API keys
|
||||
apiKeys, err := handler.apiKeyService.GetAPIKeys(user.ID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user API keys from the database", err)
|
||||
}
|
||||
for _, k := range apiKeys {
|
||||
err = handler.apiKeyService.DeleteAPIKey(k.ID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to remove user API key from the database", err)
|
||||
}
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_deleteUserRemovesAccessTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
// create standard user
|
||||
user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole}
|
||||
err := store.User().Create(user)
|
||||
require.NoError(t, err, "error creating user")
|
||||
|
||||
h, _, apiKeyService := newTestHandler(t, store)
|
||||
|
||||
t.Run("standard user deletion removes all associated access tokens", func(t *testing.T) {
|
||||
_, _, err := apiKeyService.GenerateApiKey(*user, "test-user-token")
|
||||
require.NoError(t, err)
|
||||
|
||||
keys, err := apiKeyService.GetAPIKeys(user.ID)
|
||||
require.NoError(t, err)
|
||||
is.Len(keys, 1)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handleErr := h.deleteUser(rr, user)
|
||||
require.Nil(t, handleErr)
|
||||
|
||||
is.Equal(http.StatusNoContent, rr.Code)
|
||||
|
||||
keys, err = apiKeyService.GetAPIKeys(user.ID)
|
||||
require.NoError(t, err)
|
||||
is.Empty(keys)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/pkg/authorization"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// AccessLocation describes which part of the access model granted the user
|
||||
// their effective role on an environment. The frontend maps these enum values
|
||||
// to display strings.
|
||||
type AccessLocation string
|
||||
|
||||
const (
|
||||
AccessLocationEnvironment AccessLocation = "environment"
|
||||
AccessLocationEnvironmentGroup AccessLocation = "environmentGroup"
|
||||
)
|
||||
|
||||
// @id UserEffectiveAccessInspect
|
||||
// @summary Inspect a user's effective access on every environment
|
||||
// @description Returns the resolved role for each environment the user can access,
|
||||
// @description following the policy precedence used by the access viewer
|
||||
// @description (user-endpoint, user-group, team-endpoint, team-group).
|
||||
// @description Environments where the user has no role are omitted.
|
||||
// @description **Access policy**: restricted
|
||||
// @tags users
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "User identifier"
|
||||
// @success 200 {array} EffectiveAccessEntry "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "User not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/{id}/effective-access [get]
|
||||
func (handler *Handler) userEffectiveAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
userID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid user identifier route variable", err)
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) {
|
||||
return httperror.Forbidden("Permission denied to inspect another user's effective access", errors.ErrUnauthorized)
|
||||
}
|
||||
|
||||
entries := make([]EffectiveAccessEntry, 0)
|
||||
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
user, err := tx.User().Read(portainer.UserID(userID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
endpoints, err := tx.Endpoint().ReadAll()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve environments from the database", err)
|
||||
}
|
||||
|
||||
groups, err := tx.EndpointGroup().ReadAll()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
|
||||
}
|
||||
|
||||
roles, err := tx.Role().ReadAll()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve roles from the database", err)
|
||||
}
|
||||
|
||||
teams, err := tx.Team().ReadAll()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve teams from the database", err)
|
||||
}
|
||||
|
||||
memberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve team memberships from the database", err)
|
||||
}
|
||||
|
||||
groupsByID := make(map[portainer.EndpointGroupID]portainer.EndpointGroup, len(groups))
|
||||
for _, g := range groups {
|
||||
groupsByID[g.ID] = g
|
||||
}
|
||||
|
||||
teamsByID := make(map[portainer.TeamID]portainer.Team, len(teams))
|
||||
for _, t := range teams {
|
||||
teamsByID[t.ID] = t
|
||||
}
|
||||
|
||||
for i := range endpoints {
|
||||
endpoint := &endpoints[i]
|
||||
|
||||
access := authorization.ResolveUserEndpointAccess(authorization.ResolverInput{
|
||||
User: user,
|
||||
Endpoint: endpoint,
|
||||
EndpointGroup: groupsByID[endpoint.GroupID],
|
||||
Roles: roles,
|
||||
UserMemberships: memberships,
|
||||
})
|
||||
if access == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, buildEffectiveAccessEntry(access, endpoint, groupsByID, teamsByID))
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return response.TxErrorResponse(err)
|
||||
}
|
||||
|
||||
return response.JSON(w, entries)
|
||||
}
|
||||
|
||||
func buildEffectiveAccessEntry(
|
||||
access *authorization.ResolvedAccess,
|
||||
endpoint *portainer.Endpoint,
|
||||
groupsByID map[portainer.EndpointGroupID]portainer.EndpointGroup,
|
||||
teamsByID map[portainer.TeamID]portainer.Team,
|
||||
) EffectiveAccessEntry {
|
||||
entry := EffectiveAccessEntry{
|
||||
EndpointID: endpoint.ID,
|
||||
EndpointName: endpoint.Name,
|
||||
RoleID: access.Role.ID,
|
||||
RoleName: access.Role.Name,
|
||||
RolePriority: access.Role.Priority,
|
||||
AccessLocation: AccessLocationEnvironment,
|
||||
}
|
||||
|
||||
if access.Source.GroupID != 0 {
|
||||
entry.GroupID = access.Source.GroupID
|
||||
entry.AccessLocation = AccessLocationEnvironmentGroup
|
||||
if g, ok := groupsByID[access.Source.GroupID]; ok {
|
||||
entry.GroupName = g.Name
|
||||
}
|
||||
}
|
||||
|
||||
if access.Source.TeamID != 0 {
|
||||
entry.TeamID = access.Source.TeamID
|
||||
if t, ok := teamsByID[access.Source.TeamID]; ok {
|
||||
entry.TeamName = t.Name
|
||||
}
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
type EffectiveAccessEntry struct {
|
||||
EndpointID portainer.EndpointID `json:"endpointId"`
|
||||
EndpointName string `json:"endpointName"`
|
||||
RoleID portainer.RoleID `json:"roleId"`
|
||||
RoleName string `json:"roleName"`
|
||||
RolePriority int `json:"rolePriority"`
|
||||
GroupID portainer.EndpointGroupID `json:"groupId,omitempty"`
|
||||
GroupName string `json:"groupName,omitempty"`
|
||||
TeamID portainer.TeamID `json:"teamId,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
AccessLocation AccessLocation `json:"accessLocation"`
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_userEffectiveAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
const (
|
||||
standardRoleID portainer.RoleID = 1
|
||||
helpdeskRoleID portainer.RoleID = 2
|
||||
teamID portainer.TeamID = 100
|
||||
groupID = portainer.EndpointGroupID(10)
|
||||
envID = portainer.EndpointID(200)
|
||||
)
|
||||
|
||||
require.NoError(t, store.Role().Create(&portainer.Role{ID: standardRoleID, Name: "Standard user", Priority: 1}))
|
||||
require.NoError(t, store.Role().Create(&portainer.Role{ID: helpdeskRoleID, Name: "Helpdesk", Priority: 5}))
|
||||
|
||||
require.NoError(t, store.EndpointGroup().Create(&portainer.EndpointGroup{ID: groupID, Name: "production"}))
|
||||
require.NoError(t, store.Team().Create(&portainer.Team{ID: teamID, Name: "devs"}))
|
||||
|
||||
adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
|
||||
require.NoError(t, store.User().Create(adminUser))
|
||||
|
||||
targetUser := &portainer.User{ID: 2, Username: "alice", Role: portainer.StandardUserRole}
|
||||
require.NoError(t, store.User().Create(targetUser))
|
||||
|
||||
otherUser := &portainer.User{ID: 3, Username: "bob", Role: portainer.StandardUserRole}
|
||||
require.NoError(t, store.User().Create(otherUser))
|
||||
|
||||
require.NoError(t, store.TeamMembership().Create(&portainer.TeamMembership{ID: 1, UserID: targetUser.ID, TeamID: teamID, Role: portainer.TeamMember}))
|
||||
|
||||
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{
|
||||
ID: envID,
|
||||
Name: "env-1",
|
||||
GroupID: groupID,
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{targetUser.ID: {RoleID: standardRoleID}},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{teamID: {RoleID: helpdeskRoleID}},
|
||||
}))
|
||||
|
||||
h, jwtService, _ := newTestHandler(t, store)
|
||||
|
||||
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
targetJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: targetUser.ID, Username: targetUser.Username, Role: targetUser.Role})
|
||||
otherJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: otherUser.ID, Username: otherUser.Username, Role: otherUser.Role})
|
||||
|
||||
doRequest := func(t *testing.T, jwt string, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
testhelpers.AddTestSecurityCookie(req, jwt)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
t.Run("admin can inspect another user", func(t *testing.T) {
|
||||
rr := doRequest(t, adminJWT, "/users/2/effective-access")
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp []EffectiveAccessEntry
|
||||
require.NoError(t, json.Unmarshal(body, &resp))
|
||||
is.Len(resp, 1)
|
||||
is.Equal(envID, resp[0].EndpointID)
|
||||
is.Equal(standardRoleID, resp[0].RoleID)
|
||||
})
|
||||
|
||||
t.Run("user can inspect themselves", func(t *testing.T) {
|
||||
rr := doRequest(t, targetJWT, "/users/2/effective-access")
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
})
|
||||
|
||||
t.Run("non-admin cannot inspect another user", func(t *testing.T) {
|
||||
rr := doRequest(t, otherJWT, "/users/2/effective-access")
|
||||
is.Equal(http.StatusForbidden, rr.Code)
|
||||
})
|
||||
|
||||
t.Run("returns 404 when target user does not exist", func(t *testing.T) {
|
||||
rr := doRequest(t, adminJWT, "/users/9999/effective-access")
|
||||
is.Equal(http.StatusNotFound, rr.Code)
|
||||
})
|
||||
|
||||
t.Run("returns 400 when id is not numeric", func(t *testing.T) {
|
||||
rr := doRequest(t, adminJWT, "/users/not-a-number/effective-access")
|
||||
is.Equal(http.StatusBadRequest, rr.Code)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id UserGetAPIKeys
|
||||
// @summary Get all API keys for a user
|
||||
// @description Gets all API keys for a user.
|
||||
// @description Only the calling user or admin can retrieve api-keys.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags users
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "User identifier"
|
||||
// @success 200 {array} portainer.APIKey "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "User not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/{id}/tokens [get]
|
||||
func (handler *Handler) userGetAccessTokens(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
userID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid user identifier route variable", err)
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().Read(portainer.UserID(userID))
|
||||
if err != nil {
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
|
||||
if tokenData.ID != portainer.UserID(userID) && (tokenData.Role != portainer.AdministratorRole || user.Role == portainer.AdministratorRole) {
|
||||
return httperror.Forbidden("Permission denied to get user access tokens", httperrors.ErrUnauthorized)
|
||||
}
|
||||
|
||||
apiKeys, err := handler.apiKeyService.GetAPIKeys(portainer.UserID(userID))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Internal Server Error", err)
|
||||
}
|
||||
|
||||
for idx := range apiKeys {
|
||||
hideAPIKeyFields(&apiKeys[idx])
|
||||
}
|
||||
|
||||
return response.JSON(w, apiKeys)
|
||||
}
|
||||
|
||||
// hideAPIKeyFields remove the digest from the API key (it is not needed in the response)
|
||||
func hideAPIKeyFields(apiKey *portainer.APIKey) {
|
||||
apiKey.Digest = ""
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_userGetAccessTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
// create admin and standard user(s)
|
||||
adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
|
||||
err := store.User().Create(adminUser)
|
||||
require.NoError(t, err, "error creating admin user")
|
||||
|
||||
user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole}
|
||||
err = store.User().Create(user)
|
||||
require.NoError(t, err, "error creating user")
|
||||
|
||||
h, jwtService, apiKeyService := newTestHandler(t, store)
|
||||
|
||||
// generate standard and admin user tokens
|
||||
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
|
||||
|
||||
t.Run("standard user can successfully retrieve API key", func(t *testing.T) {
|
||||
_, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-get-token")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/users/2/tokens", nil)
|
||||
testhelpers.AddTestSecurityCookie(req, jwt)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
require.NoError(t, err, "ReadAll should not return error")
|
||||
|
||||
var resp []portainer.APIKey
|
||||
err = json.Unmarshal(body, &resp)
|
||||
require.NoError(t, err, "response should be list json")
|
||||
|
||||
is.Len(resp, 1)
|
||||
if len(resp) == 1 {
|
||||
is.Empty(resp[0].Digest)
|
||||
is.Equal(apiKey.ID, resp[0].ID)
|
||||
is.Equal(apiKey.UserID, resp[0].UserID)
|
||||
is.Equal(apiKey.Prefix, resp[0].Prefix)
|
||||
is.Equal(apiKey.Description, resp[0].Description)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("admin can retrieve standard user API Key", func(t *testing.T) {
|
||||
_, _, err := apiKeyService.GenerateApiKey(*user, "test-get-admin-token")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/users/2/tokens", nil)
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
require.NoError(t, err, "ReadAll should not return error")
|
||||
|
||||
var resp []portainer.APIKey
|
||||
err = json.Unmarshal(body, &resp)
|
||||
require.NoError(t, err, "response should be list json")
|
||||
|
||||
is.NotEmpty(resp)
|
||||
})
|
||||
|
||||
t.Run("user can retrieve API Key using api-key auth", func(t *testing.T) {
|
||||
rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test-api-key")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/users/2/tokens", nil)
|
||||
req.Header.Add("x-api-key", rawAPIKey)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
require.NoError(t, err, "ReadAll should not return error")
|
||||
|
||||
var resp []portainer.APIKey
|
||||
err = json.Unmarshal(body, &resp)
|
||||
require.NoError(t, err, "response should be list json")
|
||||
|
||||
is.NotEmpty(resp)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_hideAPIKeyFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
apiKey := &portainer.APIKey{
|
||||
ID: 1,
|
||||
UserID: 2,
|
||||
Prefix: "abc",
|
||||
Description: "test",
|
||||
Digest: "",
|
||||
}
|
||||
|
||||
hideAPIKeyFields(apiKey)
|
||||
|
||||
require.Empty(t, apiKey.Digest, "digest should be cleared when hiding api key fields")
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
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/pkg/libhelm"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type helmUserRepositoryResponse struct {
|
||||
GlobalRepository string `json:"GlobalRepository"`
|
||||
UserRepositories []portainer.HelmUserRepository `json:"UserRepositories"`
|
||||
}
|
||||
|
||||
type addHelmRepoUrlPayload struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (p *addHelmRepoUrlPayload) Validate(r *http.Request) error {
|
||||
if err := ssrf.CheckURL(r.Context(), p.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return libhelm.ValidateHelmRepositoryURL(p.URL, nil)
|
||||
}
|
||||
|
||||
// @id HelmUserRepositoryCreate
|
||||
// @summary Create a user helm repository
|
||||
// @description Create a user helm repository.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags helm
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "User identifier"
|
||||
// @param payload body addHelmRepoUrlPayload true "Helm Repository"
|
||||
// @success 200 {object} portainer.HelmUserRepository "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/{id}/helm/repositories [post]
|
||||
func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
userIDEndpoint, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid user identifier route variable", err)
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
|
||||
userID := portainer.UserID(userIDEndpoint)
|
||||
if tokenData.ID != userID {
|
||||
return httperror.Forbidden("Couldn't create Helm repositories for another user", httperrors.ErrUnauthorized)
|
||||
}
|
||||
|
||||
p := new(addHelmRepoUrlPayload)
|
||||
err = request.DecodeAndValidateJSONPayload(r, p)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid Helm repository URL", err)
|
||||
}
|
||||
|
||||
// lowercase, remove trailing slash
|
||||
p.URL = strings.TrimSuffix(strings.ToLower(p.URL), "/")
|
||||
|
||||
records, err := handler.DataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to access the DataStore", err)
|
||||
}
|
||||
|
||||
// check if repo already exists - by doing case insensitive comparison
|
||||
for _, record := range records {
|
||||
if strings.EqualFold(record.URL, p.URL) {
|
||||
errMsg := "Helm repo already registered for user"
|
||||
return httperror.BadRequest(errMsg, errors.New(errMsg))
|
||||
}
|
||||
}
|
||||
|
||||
record := portainer.HelmUserRepository{
|
||||
UserID: userID,
|
||||
URL: p.URL,
|
||||
}
|
||||
|
||||
err = handler.DataStore.HelmUserRepository().Create(&record)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to save a user Helm repository URL", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, record)
|
||||
}
|
||||
|
||||
// @id HelmUserRepositoriesList
|
||||
// @summary List a users helm repositories
|
||||
// @description Inspect a user helm repositories.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags helm
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "User identifier"
|
||||
// @success 200 {object} helmUserRepositoryResponse "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/{id}/helm/repositories [get]
|
||||
func (handler *Handler) userGetHelmRepos(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
userIDEndpoint, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid user identifier route variable", err)
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
|
||||
userID := portainer.UserID(userIDEndpoint)
|
||||
if tokenData.ID != userID {
|
||||
return httperror.Forbidden("Couldn't create Helm repositories for another user", httperrors.ErrUnauthorized)
|
||||
}
|
||||
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
|
||||
}
|
||||
|
||||
userRepos, err := handler.DataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to get user Helm repositories", err)
|
||||
}
|
||||
|
||||
resp := helmUserRepositoryResponse{
|
||||
GlobalRepository: settings.HelmRepositoryURL,
|
||||
UserRepositories: userRepos,
|
||||
}
|
||||
|
||||
return response.JSON(w, resp)
|
||||
}
|
||||
|
||||
// @id HelmUserRepositoryDelete
|
||||
// @summary Delete a users helm repositoryies
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags helm
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "User identifier"
|
||||
// @param repositoryID path int true "Repository identifier"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/{id}/helm/repositories/{repositoryID} [delete]
|
||||
func (handler *Handler) userDeleteHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
userIDEndpoint, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid user identifier route variable", err)
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
|
||||
userID := portainer.UserID(userIDEndpoint)
|
||||
if tokenData.ID != userID {
|
||||
return httperror.Forbidden("Couldn't create Helm repositories for another user", httperrors.ErrUnauthorized)
|
||||
}
|
||||
|
||||
repositoryID, err := request.RetrieveNumericRouteVariableValue(r, "repositoryID")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid user identifier route variable", err)
|
||||
}
|
||||
|
||||
userRepos, err := handler.DataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to get user Helm repositories", err)
|
||||
}
|
||||
|
||||
for _, repo := range userRepos {
|
||||
if repo.ID == portainer.HelmUserRepositoryID(repositoryID) && repo.UserID == userID {
|
||||
err = handler.DataStore.HelmUserRepository().Delete(portainer.HelmUserRepositoryID(repositoryID))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to delete user Helm repository", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, nil)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id UserInspect
|
||||
// @summary Inspect a user
|
||||
// @description Retrieve details about a user.
|
||||
// @description User passwords are filtered out, and should never be accessible.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags users
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "User identifier"
|
||||
// @success 200 {object} portainer.User "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "User not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/{id} [get]
|
||||
func (handler *Handler) userInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
userID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid user identifier route variable", err)
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
}
|
||||
|
||||
if !securityContext.IsAdmin && securityContext.UserID != portainer.UserID(userID) {
|
||||
return httperror.Forbidden("Permission denied inspect user", errors.ErrResourceAccessDenied)
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().Read(portainer.UserID(userID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
hideFields(user)
|
||||
return response.JSON(w, user)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type CurrentUserInspectResponse struct {
|
||||
*portainer.User
|
||||
ForceChangePassword bool `json:"forceChangePassword"`
|
||||
}
|
||||
|
||||
// @id CurrentUserInspect
|
||||
// @summary Inspect the current user user
|
||||
// @description Retrieve details about the current user.
|
||||
// @description User passwords are filtered out, and should never be accessible.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags users
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @success 200 {object} portainer.User "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "User not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/me [get]
|
||||
func (handler *Handler) userInspectMe(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().Read(securityContext.UserID)
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
hideFields(user)
|
||||
return response.JSON(
|
||||
w,
|
||||
&CurrentUserInspectResponse{
|
||||
User: user,
|
||||
ForceChangePassword: tokenData.ForceChangePassword,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID portainer.UserID `json:"Id" example:"1"`
|
||||
Username string `json:"Username" example:"bob"`
|
||||
// User role (1 for administrator account and 2 for regular account)
|
||||
Role portainer.UserRole `json:"Role" example:"1"`
|
||||
}
|
||||
|
||||
// @id UserList
|
||||
// @summary List users
|
||||
// @description List Portainer users.
|
||||
// @description Non-administrator users will only be able to list other non-administrator user accounts.
|
||||
// @description User passwords are filtered out, and should never be accessible.
|
||||
// @description **Access policy**: restricted
|
||||
// @tags users
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param environmentId query int false "Identifier of the environment(endpoint) that will be used to filter the authorized users"
|
||||
// @success 200 {array} portainer.User "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users [get]
|
||||
func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
}
|
||||
|
||||
if !securityContext.IsAdmin && !securityContext.IsTeamLeader {
|
||||
return httperror.Forbidden("Permission denied to access users list", err)
|
||||
}
|
||||
|
||||
users, err := handler.DataStore.User().ReadAll()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve users from the database", err)
|
||||
}
|
||||
|
||||
availableUsers := security.FilterUsers(users, securityContext)
|
||||
|
||||
endpointID, _ := request.RetrieveNumericQueryParameter(r, "environmentId", true)
|
||||
if endpointID == 0 {
|
||||
users := sanitizeUsers(availableUsers)
|
||||
return response.JSON(w, users)
|
||||
}
|
||||
|
||||
// filter out users who do not have access to the specific endpoint
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve endpoint from the database", err)
|
||||
}
|
||||
|
||||
endpointGroup, err := handler.DataStore.EndpointGroup().Read(endpoint.GroupID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
|
||||
}
|
||||
|
||||
canAccessEndpoint := make([]User, 0)
|
||||
for _, user := range availableUsers {
|
||||
// the users who have the endpoint authorization
|
||||
if _, ok := user.EndpointAuthorizations[endpoint.ID]; ok {
|
||||
canAccessEndpoint = append(canAccessEndpoint, sanitizeUser(user))
|
||||
continue
|
||||
}
|
||||
|
||||
// the user inherits the endpoint access from team or environment group
|
||||
teamMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve team membership from the database", err)
|
||||
}
|
||||
|
||||
if security.AuthorizedEndpointAccess(endpoint, endpointGroup, user.ID, teamMemberships) {
|
||||
canAccessEndpoint = append(canAccessEndpoint, sanitizeUser(user))
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, canAccessEndpoint)
|
||||
}
|
||||
|
||||
func sanitizeUser(user portainer.User) User {
|
||||
return User{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeUsers(users []portainer.User) []User {
|
||||
u := make([]User, len(users))
|
||||
for i := range users {
|
||||
u[i] = sanitizeUser(users[i])
|
||||
}
|
||||
return u
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_userList(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
// Create admin and standard user(s)
|
||||
adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
|
||||
err := store.User().Create(adminUser)
|
||||
require.NoError(t, err, "error creating admin user")
|
||||
|
||||
h, jwtService, _ := newTestHandler(t, store)
|
||||
|
||||
// Generate admin user tokens
|
||||
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
|
||||
// Case 1: the user is given the endpoint access directly
|
||||
userWithEndpointAccess := &portainer.User{ID: 2, Username: "standard-user-with-endpoint-access", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()}
|
||||
err = store.User().Create(userWithEndpointAccess)
|
||||
require.NoError(t, err, "error creating user")
|
||||
|
||||
userWithoutEndpointAccess := &portainer.User{ID: 3, Username: "standard-user-without-endpoint-access", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()}
|
||||
err = store.User().Create(userWithoutEndpointAccess)
|
||||
require.NoError(t, err, "error creating user")
|
||||
|
||||
// Create environment group
|
||||
endpointGroup := &portainer.EndpointGroup{ID: 1, Name: "default-endpoint-group"}
|
||||
err = store.EndpointGroup().Create(endpointGroup)
|
||||
require.NoError(t, err, "error creating endpoint group")
|
||||
|
||||
// Create endpoint and user access policies
|
||||
userAccessPolicies := make(portainer.UserAccessPolicies, 0)
|
||||
userAccessPolicies[userWithEndpointAccess.ID] = portainer.AccessPolicy{RoleID: portainer.RoleID(userWithEndpointAccess.Role)}
|
||||
|
||||
endpointWithUserAccessPolicy := &portainer.Endpoint{ID: 1, UserAccessPolicies: userAccessPolicies, GroupID: endpointGroup.ID}
|
||||
err = store.Endpoint().Create(endpointWithUserAccessPolicy)
|
||||
require.NoError(t, err, "error creating endpoint")
|
||||
|
||||
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccess.ID, Username: userWithEndpointAccess.Username, Role: userWithEndpointAccess.Role})
|
||||
|
||||
t.Run("admin user can successfully list all users", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/users", nil)
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
require.NoError(t, err, "ReadAll should not return error")
|
||||
|
||||
var resp []portainer.User
|
||||
err = json.Unmarshal(body, &resp)
|
||||
require.NoError(t, err, "response should be list json")
|
||||
|
||||
is.Len(resp, 3)
|
||||
})
|
||||
|
||||
t.Run("admin user can list users who are given the endpoint access directly", func(t *testing.T) {
|
||||
params := url.Values{}
|
||||
params.Add("environmentId", fmt.Sprintf("%d", endpointWithUserAccessPolicy.ID))
|
||||
req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil)
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
require.NoError(t, err, "ReadAll should not return error")
|
||||
|
||||
var resp []portainer.User
|
||||
err = json.Unmarshal(body, &resp)
|
||||
require.NoError(t, err, "response should be list json")
|
||||
|
||||
is.Len(resp, 1)
|
||||
if len(resp) == 1 {
|
||||
is.Equal(userWithEndpointAccess.ID, resp[0].ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("standard user cannot list users", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/users", nil)
|
||||
testhelpers.AddTestSecurityCookie(req, jwt)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusForbidden, rr.Code)
|
||||
})
|
||||
|
||||
// Case 2: the user is under an environment group and the environment group has endpoint access.
|
||||
// the user inherits the endpoint access from the environment group
|
||||
// create user
|
||||
userUnderGroup := &portainer.User{ID: 4, Username: "standard-user-under-environment-group", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()}
|
||||
err = store.User().Create(userUnderGroup)
|
||||
require.NoError(t, err, "error creating user")
|
||||
|
||||
// Create environment group including a user
|
||||
userAccessPoliciesUnderGroup := make(portainer.UserAccessPolicies, 0)
|
||||
userAccessPoliciesUnderGroup[userUnderGroup.ID] = portainer.AccessPolicy{RoleID: portainer.RoleID(userUnderGroup.Role)}
|
||||
|
||||
endpointGroupWithUser := &portainer.EndpointGroup{ID: 2, Name: "endpoint-group-with-user", UserAccessPolicies: userAccessPoliciesUnderGroup}
|
||||
err = store.EndpointGroup().Create(endpointGroupWithUser)
|
||||
require.NoError(t, err, "error creating endpoint group")
|
||||
|
||||
// Create endpoint
|
||||
endpointUnderGroupWithUser := &portainer.Endpoint{ID: 2, GroupID: endpointGroupWithUser.ID}
|
||||
err = store.Endpoint().Create(endpointUnderGroupWithUser)
|
||||
require.NoError(t, err, "error creating endpoint")
|
||||
|
||||
t.Run("admin user can list users who inherit endpoint access from an environment group", func(t *testing.T) {
|
||||
params := url.Values{}
|
||||
params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithUser.ID))
|
||||
req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil)
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
require.NoError(t, err, "ReadAll should not return error")
|
||||
|
||||
var resp []portainer.User
|
||||
err = json.Unmarshal(body, &resp)
|
||||
require.NoError(t, err, "response should be list json")
|
||||
|
||||
is.Len(resp, 1)
|
||||
if len(resp) == 1 {
|
||||
is.Equal(userUnderGroup.ID, resp[0].ID)
|
||||
}
|
||||
})
|
||||
|
||||
// Case 3: the user is under a team and the team is under an environment group.
|
||||
// the environment group is given the endpoint access.
|
||||
// both user and team should inherits the endpoint access from the environment group
|
||||
// create a team including a user
|
||||
teamUnderGroup := &portainer.Team{ID: 1, Name: "team-under-environment-group"}
|
||||
err = store.Team().Create(teamUnderGroup)
|
||||
require.NoError(t, err, "error creating team")
|
||||
|
||||
userUnderTeam := &portainer.User{ID: 4, Username: "standard-user-under-team", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()}
|
||||
err = store.User().Create(userUnderTeam)
|
||||
require.NoError(t, err, "error creating user")
|
||||
|
||||
teamMembership := &portainer.TeamMembership{ID: 1, UserID: userUnderTeam.ID, TeamID: teamUnderGroup.ID}
|
||||
err = store.TeamMembership().Create(teamMembership)
|
||||
require.NoError(t, err, "error creating team membership")
|
||||
|
||||
// Create environment group including a team
|
||||
teamAccessPoliciesUnderGroup := make(portainer.TeamAccessPolicies, 0)
|
||||
teamAccessPoliciesUnderGroup[teamUnderGroup.ID] = portainer.AccessPolicy{RoleID: portainer.RoleID(userUnderTeam.Role)}
|
||||
|
||||
endpointGroupWithTeam := &portainer.EndpointGroup{ID: 3, Name: "endpoint-group-with-team", TeamAccessPolicies: teamAccessPoliciesUnderGroup}
|
||||
err = store.EndpointGroup().Create(endpointGroupWithTeam)
|
||||
require.NoError(t, err, "error creating endpoint group")
|
||||
|
||||
// Create endpoint
|
||||
endpointUnderGroupWithTeam := &portainer.Endpoint{ID: 3, GroupID: endpointGroupWithTeam.ID}
|
||||
err = store.Endpoint().Create(endpointUnderGroupWithTeam)
|
||||
require.NoError(t, err, "error creating endpoint")
|
||||
t.Run("admin user can list users who inherit endpoint access from a team that inherit from an environment group", func(t *testing.T) {
|
||||
params := url.Values{}
|
||||
params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithTeam.ID))
|
||||
req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil)
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
require.NoError(t, err, "ReadAll should not return error")
|
||||
|
||||
var resp []portainer.User
|
||||
err = json.Unmarshal(body, &resp)
|
||||
require.NoError(t, err, "response should be list json")
|
||||
|
||||
is.Len(resp, 1)
|
||||
if len(resp) == 1 {
|
||||
is.Equal(userUnderTeam.ID, resp[0].ID)
|
||||
}
|
||||
})
|
||||
|
||||
// Case 4: the user is under a team and the team is given the endpoint access
|
||||
// the user inherits the endpoint access from the team
|
||||
// create a team including a user
|
||||
teamWithEndpointAccess := &portainer.Team{ID: 2, Name: "team-with-endpoint-access"}
|
||||
err = store.Team().Create(teamWithEndpointAccess)
|
||||
require.NoError(t, err, "error creating team")
|
||||
|
||||
userUnderTeamWithEndpointAccess := &portainer.User{ID: 5, Username: "standard-user-under-team-with-endpoint-access", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()}
|
||||
err = store.User().Create(userUnderTeamWithEndpointAccess)
|
||||
require.NoError(t, err, "error creating user")
|
||||
|
||||
teamMembershipWithEndpointAccess := &portainer.TeamMembership{ID: 2, UserID: userUnderTeamWithEndpointAccess.ID, TeamID: teamWithEndpointAccess.ID}
|
||||
err = store.TeamMembership().Create(teamMembershipWithEndpointAccess)
|
||||
require.NoError(t, err, "error creating team membership")
|
||||
|
||||
// Create environment group
|
||||
endpointGroupWithoutTeam := &portainer.EndpointGroup{ID: 4, Name: "endpoint-group-without-team"}
|
||||
err = store.EndpointGroup().Create(endpointGroupWithoutTeam)
|
||||
require.NoError(t, err, "error creating endpoint group")
|
||||
|
||||
// Create endpoint and team access policies
|
||||
teamAccessPolicies := make(portainer.TeamAccessPolicies, 0)
|
||||
teamAccessPolicies[teamWithEndpointAccess.ID] = portainer.AccessPolicy{RoleID: portainer.RoleID(userUnderTeamWithEndpointAccess.Role)}
|
||||
|
||||
endpointWithTeamAccessPolicy := &portainer.Endpoint{ID: 4, TeamAccessPolicies: teamAccessPolicies, GroupID: endpointGroupWithoutTeam.ID}
|
||||
err = store.Endpoint().Create(endpointWithTeamAccessPolicy)
|
||||
require.NoError(t, err, "error creating endpoint")
|
||||
t.Run("admin user can list users who inherit endpoint access from a team", func(t *testing.T) {
|
||||
params := url.Values{}
|
||||
params.Add("environmentId", fmt.Sprintf("%d", endpointWithTeamAccessPolicy.ID))
|
||||
req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil)
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
require.NoError(t, err, "ReadAll should not return error")
|
||||
|
||||
var resp []portainer.User
|
||||
err = json.Unmarshal(body, &resp)
|
||||
require.NoError(t, err, "response should be list json")
|
||||
|
||||
is.Len(resp, 1)
|
||||
if len(resp) == 1 {
|
||||
is.Equal(userUnderTeamWithEndpointAccess.ID, resp[0].ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id UserMembershipsInspect
|
||||
// @summary Inspect a user memberships
|
||||
// @description Inspect a user memberships.
|
||||
// @description **Access policy**: restricted
|
||||
// @tags users
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "User identifier"
|
||||
// @success 200 {object} portainer.TeamMembership "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/{id}/memberships [get]
|
||||
func (handler *Handler) userMemberships(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
userID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid user identifier route variable", err)
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
|
||||
if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) {
|
||||
return httperror.Forbidden("Permission denied to update user memberships", errors.ErrUnauthorized)
|
||||
}
|
||||
|
||||
memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(portainer.UserID(userID))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to persist membership changes inside the database", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, memberships)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id UserRemoveAPIKey
|
||||
// @summary Remove an api-key associated to a user
|
||||
// @description Remove an api-key associated to a user..
|
||||
// @description Only the calling user or admin can remove api-key.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags users
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @param id path int true "User identifier"
|
||||
// @param keyID path int true "Api Key identifier"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/{id}/tokens/{keyID} [delete]
|
||||
func (handler *Handler) userRemoveAccessToken(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
userID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid user identifier route variable", err)
|
||||
}
|
||||
|
||||
apiKeyID, err := request.RetrieveNumericRouteVariableValue(r, "keyID")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid api-key identifier route variable", err)
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) {
|
||||
return httperror.Forbidden("Permission denied to get user access tokens", httperrors.ErrUnauthorized)
|
||||
}
|
||||
|
||||
_, err = handler.DataStore.User().Read(portainer.UserID(userID))
|
||||
if err != nil {
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
// check if the key exists and the key belongs to the user
|
||||
apiKey, err := handler.apiKeyService.GetAPIKey(portainer.APIKeyID(apiKeyID))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("API Key not found", err)
|
||||
}
|
||||
if apiKey.UserID != portainer.UserID(userID) {
|
||||
return httperror.Forbidden("Permission denied to remove api-key", httperrors.ErrUnauthorized)
|
||||
}
|
||||
|
||||
err = handler.apiKeyService.DeleteAPIKey(portainer.APIKeyID(apiKeyID))
|
||||
if err != nil {
|
||||
if errors.Is(err, apikey.ErrInvalidAPIKey) {
|
||||
return httperror.NotFound("Unable to find an api-key with the specified identifier inside the database", err)
|
||||
}
|
||||
return httperror.InternalServerError("Unable to remove the api-key from the user", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_userRemoveAccessToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
// create admin and standard user(s)
|
||||
adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
|
||||
err := store.User().Create(adminUser)
|
||||
require.NoError(t, err, "error creating admin user")
|
||||
|
||||
user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole}
|
||||
err = store.User().Create(user)
|
||||
require.NoError(t, err, "error creating user")
|
||||
|
||||
h, jwtService, apiKeyService := newTestHandler(t, store)
|
||||
|
||||
// generate standard and admin user tokens
|
||||
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
|
||||
|
||||
t.Run("standard user can successfully delete API key", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
_, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-delete-token")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("%s/%d", "/users/2/tokens", apiKey.ID), nil)
|
||||
testhelpers.AddTestSecurityCookie(req, jwt)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusNoContent, rr.Code)
|
||||
|
||||
keys, err := apiKeyService.GetAPIKeys(user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Empty(t, keys)
|
||||
})
|
||||
|
||||
t.Run("admin can delete a standard user API Key", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
_, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-admin-delete-token")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("%s/%d", "/users/2/tokens", apiKey.ID), nil)
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusNoContent, rr.Code)
|
||||
|
||||
keys, err := apiKeyService.GetAPIKeys(user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
is.Empty(keys)
|
||||
})
|
||||
|
||||
t.Run("user can delete API Key using api-key auth", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
rawAPIKey, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-api-key-auth-deletion")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("%s/%d", "/users/2/tokens", apiKey.ID), nil)
|
||||
req.Header.Add("x-api-key", rawAPIKey)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusNoContent, rr.Code)
|
||||
|
||||
keys, err := apiKeyService.GetAPIKeys(user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
is.Empty(keys)
|
||||
})
|
||||
|
||||
t.Run("user cannot delete another users API Keys using api-key auth", func(t *testing.T) {
|
||||
_, adminAPIKey, err := apiKeyService.GenerateApiKey(*adminUser, "admin-key")
|
||||
require.NoError(t, err)
|
||||
|
||||
rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "user-key")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/users/%d/tokens/%d", user.ID, adminAPIKey.ID), nil)
|
||||
req.Header.Add("x-api-key", rawAPIKey)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusForbidden, rr.Code)
|
||||
|
||||
adminKeyGot, err := apiKeyService.GetAPIKey(adminAPIKey.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
is.Equal(adminAPIKey, adminKeyGot)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type themePayload struct {
|
||||
// Color represents the color theme of the UI
|
||||
Color *string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"`
|
||||
}
|
||||
|
||||
type userUpdatePayload struct {
|
||||
Username string `validate:"required" example:"bob"`
|
||||
Password string `validate:"required" example:"cg9Wgky3"`
|
||||
NewPassword string `validate:"required" example:"asfj2emv"`
|
||||
UseCache *bool `validate:"required" example:"true"`
|
||||
Theme *themePayload
|
||||
|
||||
// User role (1 for administrator account and 2 for regular account)
|
||||
Role int `validate:"required" enums:"1,2" example:"2"`
|
||||
}
|
||||
|
||||
func (payload *userUpdatePayload) Validate(r *http.Request) error {
|
||||
if strings.Contains(payload.Username, " ") {
|
||||
return errors.New("invalid username. Must not contain any whitespace")
|
||||
}
|
||||
|
||||
if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 {
|
||||
return errors.New("invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id UserUpdate
|
||||
// @summary Update a user
|
||||
// @description Update user details. A regular user account can only update his details.
|
||||
// @description A regular user account cannot change their username or role.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags users
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "User identifier"
|
||||
// @param body body userUpdatePayload true "User details"
|
||||
// @success 200 {object} portainer.User "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "User not found"
|
||||
// @failure 409 "Username already exist"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/{id} [put]
|
||||
func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
userID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid user identifier route variable", err)
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
|
||||
if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) {
|
||||
return httperror.Forbidden("Permission denied to update user", httperrors.ErrUnauthorized)
|
||||
}
|
||||
|
||||
var payload userUpdatePayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
if tokenData.Role != portainer.AdministratorRole && payload.Role != 0 {
|
||||
return httperror.Forbidden("Permission denied to update user to administrator role", httperrors.ErrResourceAccessDenied)
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().Read(portainer.UserID(userID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
if payload.Username != "" && payload.Username != user.Username {
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
return httperror.Forbidden("Permission denied. Unable to update username", httperrors.ErrResourceAccessDenied)
|
||||
}
|
||||
|
||||
if sameNameUser, err := handler.DataStore.User().UserByUsername(payload.Username); err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.InternalServerError("Unable to retrieve users from the database", err)
|
||||
} else if sameNameUser != nil && sameNameUser.ID != portainer.UserID(userID) {
|
||||
return httperror.Conflict("Another user with the same username already exists", errUserAlreadyExists)
|
||||
}
|
||||
|
||||
user.Username = payload.Username
|
||||
}
|
||||
|
||||
if payload.Password != "" && payload.NewPassword == "" {
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password as an admin, you only need 'newPassword' in your request"))
|
||||
}
|
||||
|
||||
return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password, you must include both 'password' and 'newPassword' in your request"))
|
||||
}
|
||||
|
||||
if payload.NewPassword != "" {
|
||||
// Non-admins need to supply the previous password
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
if err := handler.CryptoService.CompareHashAndData(user.Password, payload.Password); err != nil {
|
||||
return httperror.Forbidden("Current password doesn't match. Password left unchanged", errors.New("Current password does not match the password provided. Please try again"))
|
||||
}
|
||||
}
|
||||
|
||||
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
|
||||
return httperror.BadRequest("Password does not meet the minimum strength requirements", nil)
|
||||
}
|
||||
|
||||
user.Password, err = handler.CryptoService.Hash(payload.NewPassword)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
|
||||
}
|
||||
user.TokenIssueAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
if payload.Theme != nil {
|
||||
user.ThemeSettings.Color = *cmp.Or(payload.Theme.Color, &user.ThemeSettings.Color)
|
||||
}
|
||||
|
||||
user.UseCache = *cmp.Or(payload.UseCache, &user.UseCache)
|
||||
|
||||
if payload.Role != 0 {
|
||||
user.Role = portainer.UserRole(payload.Role)
|
||||
user.TokenIssueAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
if err := handler.DataStore.User().Update(user.ID, user); err != nil {
|
||||
return httperror.InternalServerError("Unable to persist user changes inside the database", err)
|
||||
}
|
||||
|
||||
// remove all of the users persisted API keys
|
||||
handler.apiKeyService.InvalidateUserKeyCache(user.ID)
|
||||
|
||||
// hide the password field in the response payload
|
||||
user.Password = ""
|
||||
|
||||
return response.JSON(w, user)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type userUpdatePasswordPayload struct {
|
||||
// Current Password
|
||||
Password string `example:"passwd" validate:"required"`
|
||||
// New Password
|
||||
NewPassword string `example:"new_passwd" validate:"required"`
|
||||
}
|
||||
|
||||
func (payload *userUpdatePasswordPayload) Validate(r *http.Request) error {
|
||||
if len(payload.Password) == 0 {
|
||||
return errors.New("Invalid current password")
|
||||
}
|
||||
if len(payload.NewPassword) == 0 {
|
||||
return errors.New("Invalid new password")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id UserUpdatePassword
|
||||
// @summary Update password for a user
|
||||
// @description Update password for the specified user.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags users
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "identifier"
|
||||
// @param body body userUpdatePasswordPayload true "details"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "User not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/{id}/passwd [put]
|
||||
func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
userID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid user identifier route variable", err)
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
|
||||
if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) {
|
||||
return httperror.Forbidden("Permission denied to update user", httperrors.ErrUnauthorized)
|
||||
}
|
||||
|
||||
var payload userUpdatePasswordPayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().Read(portainer.UserID(userID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again"))
|
||||
}
|
||||
|
||||
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
|
||||
return httperror.BadRequest("Password does not meet the minimum strength requirements", nil)
|
||||
}
|
||||
|
||||
user.Password, err = handler.CryptoService.Hash(payload.NewPassword)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
|
||||
}
|
||||
|
||||
user.TokenIssueAt = time.Now().Unix()
|
||||
|
||||
err = handler.DataStore.User().Update(user.ID, user)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to persist user changes inside the database", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_updateUserRemovesAccessTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
// Create standard user
|
||||
user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole}
|
||||
err := store.User().Create(user)
|
||||
require.NoError(t, err, "error creating user")
|
||||
|
||||
h, _, apiKeyService := newTestHandler(t, store)
|
||||
|
||||
t.Run("standard user deletion removes all associated access tokens", func(t *testing.T) {
|
||||
_, _, err := apiKeyService.GenerateApiKey(*user, "test-user-token")
|
||||
require.NoError(t, err)
|
||||
|
||||
keys, err := apiKeyService.GetAPIKeys(user.ID)
|
||||
require.NoError(t, err)
|
||||
is.Len(keys, 1)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handlerErr := h.deleteUser(rr, user)
|
||||
require.Nil(t, handlerErr)
|
||||
|
||||
is.Equal(http.StatusNoContent, rr.Code)
|
||||
|
||||
keys, err = apiKeyService.GetAPIKeys(user.ID)
|
||||
require.NoError(t, err)
|
||||
is.Empty(keys)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user