chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:39 +08:00
commit a0df89c693
5252 changed files with 523444 additions and 0 deletions
+31
View File
@@ -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)
}
+90
View File
@@ -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)
}
+65
View File
@@ -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)
})
}
+88
View File
@@ -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
}
+111
View File
@@ -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)
}
+110
View File
@@ -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")
}
+201
View File
@@ -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)
}
+54
View File
@@ -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)
}
+58
View File
@@ -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,
},
)
}
+105
View File
@@ -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
}
+260
View File
@@ -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)
})
}
+159
View File
@@ -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)
})
}