From d4cd2b87bd9d1ef4c1b2afec07076f2cd1f4f5d2 Mon Sep 17 00:00:00 2001 From: Daniel Jiang Date: Fri, 9 Apr 2021 19:40:29 +0800 Subject: [PATCH] API for users to new model This commit moves the legacy apis related to users to new model. Some funcs under common/dao are left b/c they are used by other module, which should also be shifted to leverage managers. We'll handle them separately. Signed-off-by: Daniel Jiang --- api/v2.0/legacy_swagger.yaml | 433 ------------ api/v2.0/swagger.yaml | 450 ++++++++++++- src/common/security/local/context.go | 5 +- src/controller/project/controller.go | 5 +- src/controller/user/controller.go | 134 ++++ src/core/api/harborapi_test.go | 7 - src/core/api/user.go | 651 ------------------ src/core/api/user_test.go | 702 -------------------- src/lib/orm/error.go | 2 +- src/pkg/oidc/dao/meta.go | 100 +++ src/pkg/oidc/dao/meta_test.go | 92 +++ src/pkg/oidc/metamanager.go | 90 +++ src/pkg/oidc/metamanager_test.go | 68 ++ src/pkg/oidc/secret.go | 35 +- src/pkg/oidc/secret_test.go | 10 +- src/pkg/repository/manager_test.go | 3 +- src/pkg/user/dao/dao.go | 51 ++ src/pkg/user/dao/dao_test.go | 97 ++- src/pkg/user/manager.go | 76 ++- src/pkg/user/manager_test.go | 60 ++ src/pkg/user/models/user.go | 6 +- src/server/v2.0/handler/handler.go | 1 + src/server/v2.0/handler/model/user.go | 55 ++ src/server/v2.0/handler/user.go | 479 +++++++++++++ src/server/v2.0/handler/user_test.go | 94 +++ src/server/v2.0/route/legacy.go | 7 - src/testing/controller/config/controller.go | 165 +++++ src/testing/controller/controller.go | 1 + src/testing/controller/user/controller.go | 198 ++++++ src/testing/pkg/oidc/dao/meta_dao.go | 106 +++ src/testing/pkg/pkg.go | 2 + src/testing/pkg/user/dao/dao.go | 104 +++ src/testing/pkg/user/manager.go | 119 ++++ tests/apitests/python/library/base.py | 7 +- tests/apitests/python/library/user.py | 59 +- 35 files changed, 2610 insertions(+), 1864 deletions(-) create mode 100644 src/controller/user/controller.go delete mode 100644 src/core/api/user.go delete mode 100644 src/core/api/user_test.go create mode 100644 src/pkg/oidc/dao/meta.go create mode 100644 src/pkg/oidc/dao/meta_test.go create mode 100644 src/pkg/oidc/metamanager.go create mode 100644 src/pkg/oidc/metamanager_test.go create mode 100644 src/pkg/user/manager_test.go create mode 100644 src/server/v2.0/handler/model/user.go create mode 100644 src/server/v2.0/handler/user.go create mode 100644 src/server/v2.0/handler/user_test.go create mode 100644 src/testing/controller/config/controller.go create mode 100644 src/testing/controller/user/controller.go create mode 100644 src/testing/pkg/oidc/dao/meta_dao.go create mode 100644 src/testing/pkg/user/dao/dao.go diff --git a/api/v2.0/legacy_swagger.yaml b/api/v2.0/legacy_swagger.yaml index 13dfc740b..e113344f8 100644 --- a/api/v2.0/legacy_swagger.yaml +++ b/api/v2.0/legacy_swagger.yaml @@ -356,366 +356,6 @@ paths: description: User need to log in first. '500': description: Unexpected internal errors. - /users: - get: - summary: Get registered users of Harbor. - description: | - This endpoint is for user to search registered users, support for filtering results with username.Notice, by now this operation is only for administrator. - parameters: - - name: username - in: query - type: string - required: false - description: Username for filtering results. - - name: email - in: query - type: string - required: false - description: Email for filtering results. - - name: page - in: query - type: integer - format: int32 - required: false - description: 'The page number, default is 1.' - - name: page_size - in: query - type: integer - format: int32 - required: false - description: The size of per page. - tags: - - Products - responses: - '200': - description: Searched for users of Harbor successfully. - schema: - type: array - items: - $ref: '#/definitions/User' - headers: - X-Total-Count: - description: The total count of available items - type: integer - Link: - description: Link to previous page and next page - type: string - '400': - description: Invalid user ID. - '401': - description: User need to log in first. - '403': - description: User does not have permission of admin role. - '500': - description: Unexpected internal errors. - post: - summary: Creates a new user account. - description: | - This endpoint is to create a user if the user does not already exist. - parameters: - - name: user - in: body - description: New created user. - required: true - schema: - $ref: '#/definitions/User' - tags: - - Products - responses: - '201': - description: User created successfully. - headers: - Location: - type: string - description: The URL of the created resource - '400': - description: Unsatisfied with constraints of the user creation. - '403': - description: User registration can only be used by admin role user when self-registration is off. - '415': - $ref: '#/responses/UnsupportedMediaType' - '500': - description: Unexpected internal errors. - /users/current: - get: - summary: Get current user info. - description: | - This endpoint is to get the current user information. - tags: - - Products - responses: - '200': - description: Get current user information successfully. - schema: - $ref: '#/definitions/User' - '401': - description: User need to log in first. - /users/current/permissions: - get: - summary: Get current user permissions. - description: | - This endpoint is to get the current user permissions. - parameters: - - name: scope - in: query - type: string - required: false - description: Get permissions of the scope - - name: relative - in: query - type: boolean - required: false - description: | - If true, the resources in the response are relative to the scope, - eg for resource '/project/1/repository' if relative is 'true' then the resource in response will be 'repository'. - tags: - - Products - responses: - '200': - description: Get current user permission successfully. - schema: - type: array - items: - $ref: '#/definitions/Permission' - '401': - description: User need to log in first. - '500': - description: Internal errors. - /users/search: - get: - summary: Search users by username - description: | - This endpoint is to search the users by username. - parameters: - - name: username - in: query - type: string - required: true - description: Username for filtering results. - - name: page - in: query - type: integer - format: int32 - required: false - description: 'The page number, default is 1.' - - name: page_size - in: query - type: integer - format: int32 - required: false - description: The size of per page. - tags: - - Products - responses: - '200': - description: Search users by username, email successfully. - schema: - type: array - items: - $ref: '#/definitions/UserSearch' - headers: - X-Total-Count: - description: The total count of available items - type: integer - Link: - description: Link to previous page and next page - type: string - '500': - description: Unexpected internal errors. - '/users/{user_id}': - get: - summary: Get a user's profile. - description: | - Get user's profile with user id. - parameters: - - name: user_id - in: path - type: integer - format: int - required: true - description: Registered user ID - tags: - - Products - responses: - '200': - description: Get user's profile successfully. - schema: - $ref: '#/definitions/User' - '400': - description: Invalid user ID. - '401': - description: User need to log in first. - '403': - description: User does not have permission of admin role. - '404': - description: User ID does not exist. - '500': - description: Unexpected internal errors. - put: - summary: Update a registered user to change his profile. - description: | - This endpoint let a registered user change his profile. - parameters: - - name: user_id - in: path - type: integer - format: int - required: true - description: Registered user ID - - name: profile - in: body - description: 'Only email, realname and comment can be modified.' - required: true - schema: - $ref: '#/definitions/UserProfile' - tags: - - Products - responses: - '200': - description: Updated user's profile successfully. - '400': - description: Invalid user ID. - '401': - description: User need to log in first. - '403': - description: User does not have permission of admin role. - '404': - description: User ID does not exist. - '500': - description: Unexpected internal errors. - delete: - summary: Mark a registered user as be removed. - description: | - This endpoint let administrator of Harbor mark a registered user as - be removed.It actually won't be deleted from DB. - parameters: - - name: user_id - in: path - type: integer - format: int - required: true - description: User ID for marking as to be removed. - tags: - - Products - responses: - '200': - description: Marked user as be removed successfully. - '400': - description: Invalid user ID. - '401': - description: User need to log in first. - '403': - description: User does not have permission of admin role. - '404': - description: User ID does not exist. - '500': - description: Unexpected internal errors. - '/users/{user_id}/password': - put: - summary: Change the password on a user that already exists. - description: | - This endpoint is for user to update password. Users with the admin role can change any user's password. Guest users can change only their own password. - parameters: - - name: user_id - in: path - type: integer - format: int - required: true - description: Registered user ID. - - name: password - in: body - description: Password to be updated, the attribute 'old_password' is optional when the API is called by the system administrator. - required: true - schema: - $ref: '#/definitions/Password' - tags: - - Products - responses: - '200': - description: Updated password successfully. - '400': - description: Invalid user ID; Old password is blank; New password is blank. - '401': - description: Don't have authority to change password. Please check login status. - '403': - description: The caller does not have permission to update the password of the user with given ID, or the old password in request body is not correct. - '500': - description: Unexpected internal errors. - '/users/{user_id}/sysadmin': - put: - summary: Update a registered user to change to be an administrator of Harbor. - description: | - This endpoint let a registered user change to be an administrator - of Harbor. - parameters: - - name: user_id - in: path - type: integer - format: int - required: true - description: Registered user ID - - name: sysadmin_flag - in: body - description: Toggle a user to admin or not. - required: true - schema: - $ref: '#/definitions/SysAdminFlag' - tags: - - Products - responses: - '200': - description: Updated user's admin role successfully. - '400': - description: Invalid user ID. - '401': - description: User need to log in first. - '403': - description: User does not have permission of admin role. - '404': - description: User ID does not exist. - '500': - description: Unexpected internal errors. - '/users/{user_id}/cli_secret': - put: - summary: Set CLI secret for a user. - description: | - This endpoint let user generate a new CLI secret for himself. This API only works when auth mode is set to 'OIDC'. - Once this API returns with successful status, the old secret will be invalid, as there will be only one CLI secret - for a user. - parameters: - - name: user_id - in: path - type: integer - format: int - required: true - description: User ID - - name: input_secret - in: body - description: JSON object that includes the new secret - required: true - schema: - type: object - properties: - secret: - type: string - description: The new secret - tags: - - Products - responses: - '200': - description: The secret is successfully updated - '400': - description: Invalid user ID. Or user is not onboarded via OIDC authentication. Or the secret does not meet the standard. - '401': - description: User need to log in first. - '403': - description: Non-admin user can only generate the cli secret of himself. - '404': - description: User ID does not exist. - '412': - description: The auth mode of the system is not "oidc_auth", or the user is not onboarded via OIDC AuthN. - '500': - description: Unexpected internal errors. /labels: get: summary: List labels according to the query strings. @@ -1117,61 +757,6 @@ definitions: type: string description: 'Whether this project reuse the system level CVE allowlist as the allowlist of its own. The valid values are "true", "false". If it is set to "true" the actual allowlist associate with this project, if any, will be ignored.' - User: - type: object - properties: - user_id: - type: integer - format: int - description: The ID of the user. - username: - type: string - email: - type: string - password: - type: string - realname: - type: string - comment: - type: string - deleted: - type: boolean - role_name: - type: string - role_id: - type: integer - format: int - sysadmin_flag: - type: boolean - admin_role_in_auth: - type: boolean - description: indicate the admin privilege is grant by authenticator (LDAP), is always false unless it is the current login user - reset_uuid: - type: string - Salt: - type: string - creation_time: - type: string - update_time: - type: string - UserSearch: - type: object - properties: - user_id: - type: integer - format: int - description: The ID of the user. - username: - type: string - Password: - type: object - properties: - old_password: - type: string - description: The user's existing password. - new_password: - type: string - description: New password for marking as to be updated. Role: type: object properties: @@ -1226,24 +811,6 @@ definitions: type: integer format: int32 description: 'The count of the total repositories, only be seen when the user is admin.' - SysAdminFlag: - type: object - properties: - sysadmin_flag: - type: boolean - description: 'true-admin, false-not admin.' - UserProfile: - type: object - properties: - email: - type: string - description: The new email. - realname: - type: string - description: The new realname. - comment: - type: string - description: The new comment. LdapConf: type: object properties: diff --git a/api/v2.0/swagger.yaml b/api/v2.0/swagger.yaml index 8adeebca7..7c68343ec 100644 --- a/api/v2.0/swagger.yaml +++ b/api/v2.0/swagger.yaml @@ -4286,7 +4286,326 @@ paths: $ref: '#/responses/403' '500': $ref: '#/responses/500' - + /users: + get: + summary: List users + tags: + - users + operationId: listUsers + parameters: + - $ref: '#/parameters/requestId' + - $ref: '#/parameters/query' + - $ref: '#/parameters/sort' + - $ref: '#/parameters/page' + - $ref: '#/parameters/pageSize' + responses: + '200': + description: return the list of users. + schema: + type: array + items: + $ref: '#/definitions/UserResp' + headers: + X-Total-Count: + description: The total count of users + type: integer + Link: + description: Link to previous page and next page + type: string + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '500': + $ref: '#/responses/500' + post: + summary: Create a local user. + description: This API can be used only when the authentication mode is for local DB. When self registration is disabled. + tags: + - users + operationId: createUser + parameters: + - $ref: '#/parameters/requestId' + - name: userReq + in: body + description: The new user + required: true + schema: + $ref: '#/definitions/UserCreationReq' + responses: + '201': + $ref: '#/responses/201' + '400': + $ref: '#/responses/400' + '401': + $ref: '#/responses/401' + '403': + description: When the self registration is disabled, non-admin does not have permission to create user. When self registration is enabled, this API can only be called from UI portal, calling it via script will get a 403 error. + '409': + $ref: '#/responses/409' + '500': + $ref: '#/responses/500' + /users/current: + get: + summary: Get current user info. + tags: + - users + operationId: getCurrentUserInfo + parameters: + - $ref: '#/parameters/requestId' + responses: + '200': + description: Get current user information successfully. + schema: + $ref: '#/definitions/UserResp' + '401': + $ref: '#/responses/401' + '500': + $ref: '#/responses/500' + /users/search: + get: + summary: Search users by username + description: | + This endpoint is to search the users by username. It's open for all authenticated requests. + tags: + - users + operationId: searchUsers + parameters: + - $ref: '#/parameters/requestId' + - $ref: '#/parameters/page' + - $ref: '#/parameters/pageSize' + - name: username + in: query + type: string + required: true + description: Username for filtering results. + responses: + '200': + description: Search users by username successfully. + schema: + type: array + items: + $ref: '#/definitions/UserSearchRespItem' + headers: + X-Total-Count: + description: The total count of available items + type: integer + Link: + description: Link to previous page and next page + type: string + '401': + $ref: '#/responses/401' + '500': + $ref: '#/responses/500' + '/users/{user_id}': + get: + summary: Get a user's profile. + parameters: + - $ref: '#/parameters/requestId' + - name: user_id + in: path + type: integer + format: int + required: true + tags: + - users + operationId: getUser + responses: + '200': + description: Get user's info successfully. + schema: + $ref: '#/definitions/UserResp' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '500': + $ref: '#/responses/500' + put: + summary: Update user's profile. + parameters: + - $ref: '#/parameters/requestId' + - name: user_id + in: path + type: integer + format: int + required: true + description: Registered user ID + - name: profile + in: body + description: 'Only email, realname and comment can be modified.' + required: true + schema: + $ref: '#/definitions/UserProfile' + tags: + - users + operationId: updateUserProfile + responses: + '200': + $ref: '#/responses/200' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '500': + $ref: '#/responses/500' + delete: + summary: Mark a registered user as be removed. + description: | + This endpoint let administrator of Harbor mark a registered user as removed.It actually won't be deleted from DB. + parameters: + - name: user_id + in: path + type: integer + format: int + required: true + description: User ID for marking as to be removed. + tags: + - users + operationId: deleteUser + responses: + '200': + $ref: '#/responses/200' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '500': + $ref: '#/responses/500' + /users/{user_id}/sysadmin: + put: + summary: Update a registered user to change to be an administrator of Harbor. + tags: + - users + operationId: setUserSysAdmin + parameters: + - name: user_id + in: path + type: integer + format: int + required: true + - name: sysadmin_flag + in: body + description: Toggle a user to admin or not. + required: true + schema: + $ref: '#/definitions/UserSysAdminFlag' + responses: + '200': + $ref: '#/responses/200' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '500': + description: Unexpected internal errors. + '/users/{user_id}/password': + put: + summary: Change the password on a user that already exists. + description: | + This endpoint is for user to update password. Users with the admin role can change any user's password. Regular users can change only their own password. + tags: + - users + operationId: updateUserPassword + parameters: + - name: user_id + in: path + type: integer + format: int + required: true + - name: password + in: body + description: Password to be updated, the attribute 'old_password' is optional when the API is called by the system administrator. + required: true + schema: + $ref: '#/definitions/PasswordReq' + responses: + '200': + $ref: '#/responses/200' + '400': + description: Invalid user ID; Password does not meet requirement + '401': + $ref: '#/responses/401' + '403': + description: The caller does not have permission to update the password of the user with given ID, or the old password in request body is not correct. + '500': + $ref: '#/responses/500' + /users/current/permissions: + get: + summary: Get current user permissions. + tags: + - users + operationId: getCurrentUserPermissions + parameters: + - name: scope + in: query + type: string + required: false + description: The scope for the permission + - name: relative + in: query + type: boolean + required: false + description: | + If true, the resources in the response are relative to the scope, + eg for resource '/project/1/repository' if relative is 'true' then the resource in response will be 'repository'. + responses: + '200': + description: Get current user permission successfully. + schema: + type: array + items: + $ref: '#/definitions/Permission' + '401': + description: User need to log in first. + '500': + description: Internal errors. + '/users/{user_id}/cli_secret': + put: + summary: Set CLI secret for a user. + description: >- + This endpoint let user generate a new CLI secret for himself. This API only works when auth mode is set to 'OIDC'. + Once this API returns with successful status, the old secret will be invalid, as there will be only one CLI secret + for a user. + tags: + - users + operationId: setCliSecret + parameters: + - $ref: '#/parameters/requestId' + - name: user_id + in: path + type: integer + format: int + required: true + description: User ID + - name: secret + in: body + required: true + schema: + $ref: '#/definitions/OIDCCliSecretReq' + responses: + '200': + description: The secret is successfully updated + '400': + description: Invalid user ID. Or user is not onboarded via OIDC authentication. Or the secret does not meet the standard. + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '412': + description: The auth mode of the system is not "oidc_auth", or the user is not onboarded via OIDC AuthN. + '500': + $ref: '#/responses/500' parameters: query: name: q @@ -4433,7 +4752,6 @@ parameters: required: true type: integer format: int64 - responses: '200': description: Success @@ -7043,3 +7361,131 @@ definitions: editable: type: boolean description: The configure item can be updated or not + UserProfile: + type: object + properties: + email: + type: string + realname: + type: string + comment: + type: string + UserCreationReq: + type: object + properties: + email: + type: string + realname: + type: string + comment: + type: string + password: + type: string + username: + type: string + OIDCUserInfo: + type: object + properties: + id: + type: integer + format: int + description: the ID of the OIDC info record + user_id: + type: integer + format: int + description: the ID of the user + subiss: + type: string + description: the concatenation of sub and issuer in the ID token + secret: + type: string + description: the secret of the OIDC user that can be used for CLI to push/pull artifacts + creation_time: + type: string + format: date-time + description: The creation time of the OIDC user info record. + update_time: + type: string + format: date-time + description: The update time of the OIDC user info record. + UserResp: + type: object + properties: + email: + type: string + realname: + type: string + comment: + type: string + user_id: + type: integer + format: int + username: + type: string + sysadmin_flag: + type: boolean + x-omitempty: false + admin_role_in_auth: + type: boolean + x-omitempty: false + description: indicate the admin privilege is grant by authenticator (LDAP), is always false unless it is the current login user + oidc_user_meta: + $ref: '#/definitions/OIDCUserInfo' + creation_time: + type: string + format: date-time + description: The creation time of the user. + update_time: + type: string + format: date-time + description: The update time of the user. + UserSysAdminFlag: + type: object + properties: + sysadmin_flag: + type: boolean + description: 'true-admin, false-not admin.' + UserSearch: + type: object + properties: + user_id: + type: integer + format: int + description: The ID of the user. + username: + type: string + PasswordReq: + type: object + properties: + old_password: + type: string + description: The user's existing password. + new_password: + type: string + description: New password for marking as to be updated. + UserSearchRespItem: + type: object + properties: + user_id: + type: integer + format: int + description: The ID of the user. + username: + type: string + Permission: + type: object + properties: + resource: + type: string + description: The permission resoruce + action: + type: string + description: The permission action + OIDCCliSecretReq: + type: object + properties: + secret: + type: string + description: The new secret + + diff --git a/src/common/security/local/context.go b/src/common/security/local/context.go index 17f0778ba..3e287c2b7 100644 --- a/src/common/security/local/context.go +++ b/src/common/security/local/context.go @@ -26,6 +26,9 @@ import ( "github.com/goharbor/harbor/src/pkg/permission/types" ) +// ContextName the name of the security context. +const ContextName = "local" + // SecurityContext implements security.Context interface based on database type SecurityContext struct { user *models.User @@ -44,7 +47,7 @@ func NewSecurityContext(user *models.User) *SecurityContext { // Name returns the name of the security context func (s *SecurityContext) Name() string { - return "local" + return ContextName } // IsAuthenticated returns true if the user has been authenticated diff --git a/src/controller/project/controller.go b/src/controller/project/controller.go index 9a1ff0fa8..f49bf33df 100644 --- a/src/controller/project/controller.go +++ b/src/controller/project/controller.go @@ -17,6 +17,7 @@ package project import ( "context" + commonmodels "github.com/goharbor/harbor/src/common/models" event "github.com/goharbor/harbor/src/controller/event/metadata" "github.com/goharbor/harbor/src/controller/event/operator" "github.com/goharbor/harbor/src/lib/errors" @@ -61,7 +62,7 @@ type Controller interface { // Update update the project Update(ctx context.Context, project *models.Project) error // ListRoles lists the roles of user for the specific project - ListRoles(ctx context.Context, projectID int64, u *user.User) ([]int, error) + ListRoles(ctx context.Context, projectID int64, u *commonmodels.User) ([]int, error) } // NewController creates an instance of the default project controller @@ -241,7 +242,7 @@ func (c *controller) Update(ctx context.Context, p *models.Project) error { return nil } -func (c *controller) ListRoles(ctx context.Context, projectID int64, u *user.User) ([]int, error) { +func (c *controller) ListRoles(ctx context.Context, projectID int64, u *commonmodels.User) ([]int, error) { if u == nil { return nil, nil } diff --git a/src/controller/user/controller.go b/src/controller/user/controller.go new file mode 100644 index 000000000..2fa242770 --- /dev/null +++ b/src/controller/user/controller.go @@ -0,0 +1,134 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package user + +import ( + "context" + "fmt" + + "github.com/goharbor/harbor/src/common/security" + "github.com/goharbor/harbor/src/common/security/local" + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg/oidc" + "github.com/goharbor/harbor/src/pkg/user" + "github.com/goharbor/harbor/src/pkg/user/models" +) + +var ( + // Ctl is a global user controller instance + Ctl = NewController() +) + +// Controller provides functions to support API/middleware for user management and query +type Controller interface { + // SetSysAdmin ... + SetSysAdmin(ctx context.Context, id int, adminFlag bool) error + // VerifyPassword ... + VerifyPassword(ctx context.Context, username string, password string) (bool, error) + // UpdatePassword ... + UpdatePassword(ctx context.Context, id int, password string) error + // List ... + List(ctx context.Context, query *q.Query) ([]*models.User, error) + // Create ... + Create(ctx context.Context, u *models.User) (int, error) + // Count ... + Count(ctx context.Context, query *q.Query) (int64, error) + // Get ... + Get(ctx context.Context, id int, opt *Option) (*models.User, error) + // Delete ... + Delete(ctx context.Context, id int) error + // UpdateProfile update the profile based on the ID and data in the model in parm, only a subset of attributes in the model + // will be update, see the implementation of manager. + UpdateProfile(ctx context.Context, u *models.User) error + // SetCliSecret sets the OIDC CLI secret for a user + SetCliSecret(ctx context.Context, id int, secret string) error +} + +// NewController ... +func NewController() Controller { + return &controller{ + mgr: user.New(), + oidcMetaMgr: oidc.NewMetaMgr(), + } +} + +// Option option for getting User info +type Option struct { + WithOIDCInfo bool +} + +type controller struct { + mgr user.Manager + oidcMetaMgr oidc.MetaManager +} + +func (c *controller) SetCliSecret(ctx context.Context, id int, secret string) error { + return c.oidcMetaMgr.SetCliSecretByUserID(ctx, id, secret) +} + +func (c *controller) Create(ctx context.Context, u *models.User) (int, error) { + return c.mgr.Create(ctx, u) +} + +func (c *controller) UpdateProfile(ctx context.Context, u *models.User) error { + return c.mgr.UpdateProfile(ctx, u) +} + +func (c *controller) Get(ctx context.Context, id int, opt *Option) (*models.User, error) { + u, err := c.mgr.Get(ctx, id) + if err != nil { + return nil, err + } + sctx, ok := security.FromContext(ctx) + if !ok { + return nil, fmt.Errorf("can't find security context") + } + lsc, ok := sctx.(*local.SecurityContext) + if ok && lsc.User().UserID == id { + u.AdminRoleInAuth = lsc.User().AdminRoleInAuth + } + if opt != nil && opt.WithOIDCInfo { + oidcMeta, err := c.oidcMetaMgr.GetByUserID(ctx, id) + if err != nil { + return nil, err + } + u.OIDCUserMeta = oidcMeta + } + return u, nil +} + +func (c *controller) Count(ctx context.Context, query *q.Query) (int64, error) { + return c.mgr.Count(ctx, query) +} + +func (c *controller) Delete(ctx context.Context, id int) error { + return c.mgr.Delete(ctx, id) +} + +func (c *controller) List(ctx context.Context, query *q.Query) ([]*models.User, error) { + return c.mgr.List(ctx, query) +} + +func (c *controller) UpdatePassword(ctx context.Context, id int, password string) error { + return c.mgr.UpdatePassword(ctx, id, password) +} + +func (c *controller) VerifyPassword(ctx context.Context, username, password string) (bool, error) { + return c.mgr.VerifyLocalPassword(ctx, username, password) +} + +func (c *controller) SetSysAdmin(ctx context.Context, id int, adminFlag bool) error { + return c.mgr.SetSysAdminFlag(ctx, id, adminFlag) +} diff --git a/src/core/api/harborapi_test.go b/src/core/api/harborapi_test.go index 4cb8fc22a..cc71eeac2 100644 --- a/src/core/api/harborapi_test.go +++ b/src/core/api/harborapi_test.go @@ -94,18 +94,11 @@ func init() { beego.TestBeegoInit(apppath) beego.Router("/api/health", &HealthAPI{}, "get:CheckHealth") - beego.Router("/api/users/:id", &UserAPI{}, "get:Get") - beego.Router("/api/users", &UserAPI{}, "get:List;post:Post;delete:Delete;put:Put") - beego.Router("/api/users/search", &UserAPI{}, "get:Search") - beego.Router("/api/users/:id([0-9]+)/password", &UserAPI{}, "put:ChangePassword") - beego.Router("/api/users/:id/permissions", &UserAPI{}, "get:ListUserPermissions") - beego.Router("/api/users/:id/sysadmin", &UserAPI{}, "put:ToggleUserAdminRole") beego.Router("/api/projects/:id([0-9]+)/metadatas/?:name", &MetadataAPI{}, "get:Get") beego.Router("/api/projects/:id([0-9]+)/metadatas/", &MetadataAPI{}, "post:Post") beego.Router("/api/projects/:id([0-9]+)/metadatas/:name", &MetadataAPI{}, "put:Put;delete:Delete") beego.Router("/api/projects/:pid([0-9]+)/members/?:pmid([0-9]+)", &ProjectMemberAPI{}) beego.Router("/api/statistics", &StatisticAPI{}) - beego.Router("/api/users/?:id", &UserAPI{}) beego.Router("/api/email/ping", &EmailAPI{}, "post:Ping") beego.Router("/api/labels", &LabelAPI{}, "post:Post;get:List") beego.Router("/api/labels/:id([0-9]+", &LabelAPI{}, "get:Get;put:Put;delete:Delete") diff --git a/src/core/api/user.go b/src/core/api/user.go deleted file mode 100644 index 2035b47bc..000000000 --- a/src/core/api/user.go +++ /dev/null @@ -1,651 +0,0 @@ -// Copyright 2018 Project Harbor Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package api - -import ( - "context" - "errors" - "fmt" - "github.com/goharbor/harbor/src/common/rbac/system" - "github.com/goharbor/harbor/src/controller/config" - "github.com/goharbor/harbor/src/lib/orm" - "net/http" - "regexp" - "strconv" - - "github.com/goharbor/harbor/src/common" - "github.com/goharbor/harbor/src/common/dao" - "github.com/goharbor/harbor/src/common/models" - "github.com/goharbor/harbor/src/common/rbac" - "github.com/goharbor/harbor/src/common/security/local" - "github.com/goharbor/harbor/src/common/utils" - "github.com/goharbor/harbor/src/lib" - "github.com/goharbor/harbor/src/lib/log" - "github.com/goharbor/harbor/src/pkg/permission/types" -) - -// UserAPI handles request to /api/users/{} -type UserAPI struct { - BaseController - currentUserID int - userID int - SelfRegistration bool - AuthMode string - secretKey string - resource types.Resource -} - -type passwordReq struct { - OldPassword string `json:"old_password"` - NewPassword string `json:"new_password"` -} - -type userSearch struct { - UserID int `json:"user_id"` - Username string `json:"username"` -} - -type secretReq struct { - Secret string `json:"secret"` -} - -// Prepare validates the URL and parms -func (ua *UserAPI) Prepare() { - ua.BaseController.Prepare() - mode, err := config.AuthMode(orm.Context()) - if err != nil { - log.Errorf("failed to get auth mode: %v", err) - ua.SendInternalServerError(errors.New("")) - return - } - - ua.AuthMode = mode - if mode == common.OIDCAuth { - key, err := config.SecretKey() - if err != nil { - log.Errorf("failed to get secret key: %v", err) - ua.SendInternalServerError(fmt.Errorf("failed to get secret key: %v", err)) - return - } - ua.secretKey = key - } - - self, err := config.SelfRegistration(orm.Context()) - if err != nil { - log.Errorf("failed to get self registration: %v", err) - ua.SendInternalServerError(errors.New("")) - return - } - - ua.SelfRegistration = self - - if !ua.SecurityCtx.IsAuthenticated() { - if ua.Ctx.Input.IsPost() && ua.SelfRegistration { - return - } - ua.SendUnAuthorizedError(errors.New("UnAuthorize")) - return - } - - user, err := dao.GetUser(models.User{ - Username: ua.SecurityCtx.GetUsername(), - }) - if err != nil { - ua.SendInternalServerError(fmt.Errorf("failed to get user %s: %v", - ua.SecurityCtx.GetUsername(), err)) - return - } - - if user == nil { - log.Errorf("User with username %s does not exist in DB.", ua.SecurityCtx.GetUsername()) - ua.SendInternalServerError(fmt.Errorf("user %s does not exist in DB", ua.SecurityCtx.GetUsername())) - return - } - - ua.currentUserID = user.UserID - id := ua.Ctx.Input.Param(":id") - if id == "current" { - ua.userID = ua.currentUserID - } else if len(id) > 0 { - var err error - ua.userID, err = strconv.Atoi(id) - if err != nil { - log.Errorf("Invalid user id, error: %v", err) - ua.SendBadRequestError(errors.New("invalid user Id")) - return - } - userQuery := models.User{UserID: ua.userID} - u, err := dao.GetUser(userQuery) - if err != nil { - log.Errorf("Error occurred in GetUser, error: %v", err) - ua.SendInternalServerError(errors.New("internal error")) - return - } - if u == nil { - log.Errorf("User with Id: %d does not exist", ua.userID) - ua.SendNotFoundError(errors.New("")) - return - } - } - - ua.resource = system.NewNamespace().Resource(rbac.ResourceUser) -} - -// Get ... -func (ua *UserAPI) Get() { - if ua.userID == ua.currentUserID || ua.SecurityCtx.Can(ua.Context(), rbac.ActionRead, ua.resource) { - userQuery := models.User{UserID: ua.userID} - u, err := dao.GetUser(userQuery) - if err != nil { - log.Errorf("Error occurred in GetUser, error: %v", err) - ua.SendInternalServerError(err) - return - } - u.Password = "" - if ua.userID == ua.currentUserID { - sc := ua.SecurityCtx - switch lsc := sc.(type) { - case *local.SecurityContext: - u.AdminRoleInAuth = lsc.User().AdminRoleInAuth - } - } - if ua.AuthMode == common.OIDCAuth { - o, err := ua.getOIDCUserInfo() - if err != nil { - ua.SendInternalServerError(err) - return - } - u.OIDCUserMeta = o - } - ua.Data["json"] = u - ua.ServeJSON() - return - } - - log.Errorf("Current user, id: %d does not have admin role, can not view other user's detail", ua.currentUserID) - ua.SendForbiddenError(errors.New("user does not have admin role")) - return -} - -// List ... -func (ua *UserAPI) List() { - if !ua.SecurityCtx.Can(ua.Context(), rbac.ActionList, ua.resource) { - log.Errorf("Current user, id: %d does not have admin role, can not list users", ua.currentUserID) - ua.SendForbiddenError(errors.New("user does not have admin role")) - return - } - - page, size, err := ua.GetPaginationParams() - if err != nil { - ua.SendBadRequestError(err) - return - } - - query := &models.UserQuery{ - Username: ua.GetString("username"), - Email: ua.GetString("email"), - Pagination: &models.Pagination{ - Page: page, - Size: size, - }, - } - - total, err := dao.GetTotalOfUsers(query) - if err != nil { - ua.SendInternalServerError(fmt.Errorf("failed to get total of users: %v", err)) - return - } - - users, err := dao.ListUsers(query) - if err != nil { - ua.SendInternalServerError(fmt.Errorf("failed to get users: %v", err)) - return - } - for i := range users { - user := &users[i] - user.Password = "" - } - ua.SetPaginationHeader(total, page, size) - ua.Data["json"] = users - ua.ServeJSON() -} - -// Search ... -func (ua *UserAPI) Search() { - page, size, err := ua.GetPaginationParams() - if err != nil { - ua.SendBadRequestError(err) - return - } - query := &models.UserQuery{ - Username: ua.GetString("username"), - Pagination: &models.Pagination{ - Page: page, - Size: size, - }, - } - if len(query.Username) == 0 { - ua.SendBadRequestError(errors.New("username is required")) - return - } - - total, err := dao.GetTotalOfUsers(query) - if err != nil { - ua.SendInternalServerError(fmt.Errorf("failed to get total of users: %v", err)) - return - } - - users, err := dao.ListUsers(query) - if err != nil { - ua.SendInternalServerError(fmt.Errorf("failed to get users: %v", err)) - return - } - - var userSearches []userSearch - for _, user := range users { - userSearches = append(userSearches, userSearch{UserID: user.UserID, Username: user.Username}) - } - - ua.SetPaginationHeader(total, page, size) - ua.Data["json"] = userSearches - ua.ServeJSON() -} - -// Put ... -func (ua *UserAPI) Put() { - if !ua.modifiable(ua.Context()) { - ua.SendForbiddenError(fmt.Errorf("User with ID %d cannot be modified", ua.userID)) - return - } - user := models.User{} - if err := ua.DecodeJSONReq(&user); err != nil { - ua.SendBadRequestError(err) - return - } - user.UserID = ua.userID - err := commonValidate(user) - if err != nil { - log.Warningf("Bad request in change user profile: %v", err) - ua.SendBadRequestError(fmt.Errorf("change user profile error:" + err.Error())) - return - } - userQuery := models.User{UserID: ua.userID} - u, err := dao.GetUser(userQuery) - if err != nil { - log.Errorf("Error occurred in GetUser, error: %v", err) - ua.SendInternalServerError(errors.New("internal error")) - return - } - if u == nil { - log.Errorf("User with Id: %d does not exist", ua.userID) - ua.SendNotFoundError(errors.New("")) - return - } - if u.Email != user.Email { - emailExist, err := dao.UserExists(user, "email") - if err != nil { - log.Errorf("Error occurred in change user profile: %v", err) - ua.SendInternalServerError(errors.New("internal error")) - return - } - if emailExist { - log.Warning("email has already been used!") - ua.SendConflictError(errors.New("email has already been used")) - return - } - } - if err := dao.ChangeUserProfile(user); err != nil { - log.Errorf("Failed to update user profile, error: %v", err) - ua.SendInternalServerError(err) - return - } -} - -// Post ... -func (ua *UserAPI) Post() { - - if !(ua.AuthMode == common.DBAuth) { - ua.SendForbiddenError(errors.New("")) - return - } - - if !(ua.SelfRegistration || ua.SecurityCtx.Can(ua.Context(), rbac.ActionCreate, ua.resource)) { - log.Warning("Registration can only be used by admin role user when self-registration is off.") - ua.SendForbiddenError(errors.New("")) - return - } - - if !ua.SecurityCtx.Can(ua.Context(), rbac.ActionCreate, ua.resource) && !lib.GetCarrySession(ua.Ctx.Request.Context()) { - ua.SendForbiddenError(errors.New("self-registration cannot be triggered via API")) - return - } - - user := models.User{} - if err := ua.DecodeJSONReq(&user); err != nil { - ua.SendBadRequestError(err) - return - } - err := validate(user) - if err != nil { - log.Warningf("Bad request in Register: %v", err) - ua.RenderError(http.StatusBadRequest, "register error:"+err.Error()) - return - } - - if !ua.SecurityCtx.Can(ua.Context(), rbac.ActionCreate, ua.resource) && user.SysAdminFlag { - msg := "Non-admin cannot create an admin user." - log.Errorf(msg) - ua.SendForbiddenError(errors.New(msg)) - return - } - - userExist, err := dao.UserExists(user, "username") - if err != nil { - log.Errorf("Error occurred in Register: %v", err) - ua.SendInternalServerError(errors.New("internal error")) - return - } - if userExist { - log.Warning("username has already been used!") - ua.SendConflictError(errors.New("username has already been used")) - return - } - emailExist, err := dao.UserExists(user, "email") - if err != nil { - log.Errorf("Error occurred in change user profile: %v", err) - ua.SendInternalServerError(errors.New("internal error")) - return - } - if emailExist { - log.Warning("email has already been used!") - ua.SendConflictError(errors.New("email has already been used")) - return - } - - userID, err := dao.Register(user) - if err != nil { - log.Errorf("Error occurred in Register: %v", err) - ua.SendInternalServerError(errors.New("internal error")) - return - } - - ua.Redirect(http.StatusCreated, strconv.FormatInt(userID, 10)) -} - -// Delete ... -func (ua *UserAPI) Delete() { - if !ua.SecurityCtx.Can(ua.Context(), rbac.ActionDelete, ua.resource) || ua.AuthMode != common.DBAuth || ua.userID == 1 || ua.currentUserID == ua.userID { - ua.SendForbiddenError(fmt.Errorf("User with ID: %d cannot be removed, auth mode: %s, current user ID: %d", ua.userID, ua.AuthMode, ua.currentUserID)) - return - } - - var err error - err = dao.DeleteUser(ua.userID) - if err != nil { - log.Errorf("Failed to delete data from database, error: %v", err) - ua.SendInternalServerError(errors.New("failed to delete User")) - return - } -} - -// ChangePassword handles PUT to /api/users/{}/password -func (ua *UserAPI) ChangePassword() { - if !ua.modifiable(ua.Context()) { - ua.SendForbiddenError(fmt.Errorf("User with ID: %d is not modifiable", ua.userID)) - return - } - - changePwdOfOwn := ua.userID == ua.currentUserID - - var req passwordReq - if err := ua.DecodeJSONReq(&req); err != nil { - ua.SendBadRequestError(err) - return - } - - if changePwdOfOwn && len(req.OldPassword) == 0 { - ua.SendBadRequestError(errors.New("empty old_password")) - return - } - - if err := validateSecret(req.NewPassword); err != nil { - ua.SendBadRequestError(err) - return - } - - user, err := dao.GetUser(models.User{UserID: ua.userID}) - if err != nil { - ua.SendInternalServerError(fmt.Errorf("failed to get user %d: %v", ua.userID, err)) - return - } - if user == nil { - ua.SendNotFoundError(fmt.Errorf("user %d not found", ua.userID)) - return - } - if changePwdOfOwn { - if user.Password != utils.Encrypt(req.OldPassword, user.Salt, user.PasswordVersion) { - log.Info("incorrect old_password") - ua.SendForbiddenError(errors.New("incorrect old_password")) - return - } - } - if user.Password == utils.Encrypt(req.NewPassword, user.Salt, user.PasswordVersion) { - ua.SendBadRequestError(errors.New("the new password can not be same with the old one")) - return - } - - updatedUser := models.User{ - UserID: ua.userID, - Password: req.NewPassword, - PasswordVersion: user.PasswordVersion, - } - if err = dao.ChangeUserPassword(updatedUser); err != nil { - ua.SendInternalServerError(fmt.Errorf("failed to change password of user %d: %v", ua.userID, err)) - return - } -} - -// ToggleUserAdminRole handles PUT api/users/{}/sysadmin -func (ua *UserAPI) ToggleUserAdminRole() { - if !ua.SecurityCtx.Can(ua.Context(), rbac.ActionUpdate, ua.resource) { - log.Warningf("current user, id: %d does not have admin role, can not update other user's role", ua.currentUserID) - ua.RenderError(http.StatusForbidden, "User does not have admin role") - return - } - userQuery := models.User{UserID: ua.userID} - if err := ua.DecodeJSONReq(&userQuery); err != nil { - ua.SendBadRequestError(err) - return - } - if err := dao.ToggleUserAdminRole(userQuery.UserID, userQuery.SysAdminFlag); err != nil { - log.Errorf("Error occurred in ToggleUserAdminRole: %v", err) - ua.SendInternalServerError(errors.New("internal error")) - return - } -} - -// ListUserPermissions handles GET to /api/users/{}/permissions -func (ua *UserAPI) ListUserPermissions() { - if ua.userID != ua.currentUserID { - log.Warningf("Current user, id: %d can not view other user's permissions", ua.currentUserID) - ua.RenderError(http.StatusForbidden, "User does not have permission") - return - } - - relative := ua.Ctx.Input.Query("relative") == "true" - - scope := rbac.Resource(ua.Ctx.Input.Query("scope")) - policies := []*types.Policy{} - - ctx := ua.Ctx.Request.Context() - if ns, ok := types.NamespaceFromResource(scope); ok { - for _, policy := range ns.GetPolicies() { - if ua.SecurityCtx.Can(ctx, policy.Action, policy.Resource) { - policies = append(policies, policy) - } - } - } - - results := []map[string]string{} - for _, policy := range policies { - var resource rbac.Resource - - // for resource `/project/1/repository` if `relative` is `true` then the resource in response will be `repository` - if relative { - relativeResource, err := policy.Resource.RelativeTo(scope) - if err != nil { - continue - } - resource = relativeResource - } else { - resource = policy.Resource - } - - results = append(results, map[string]string{ - "resource": resource.String(), - "action": policy.Action.String(), - }) - } - - ua.Data["json"] = results - ua.ServeJSON() - return -} - -// SetCLISecret handles request PUT /api/users/:id/cli_secret to update the CLI secret of the user -func (ua *UserAPI) SetCLISecret() { - if ua.AuthMode != common.OIDCAuth { - ua.SendPreconditionFailedError(errors.New("the auth mode has to be oidc auth")) - return - } - if ua.userID != ua.currentUserID && !ua.SecurityCtx.Can(ua.Context(), rbac.ActionUpdate, ua.resource) { - ua.SendForbiddenError(errors.New("")) - return - } - oidcData, err := dao.GetOIDCUserByUserID(ua.userID) - if err != nil { - log.Errorf("Failed to get OIDC User meta for user, id: %d, error: %v", ua.userID, err) - ua.SendInternalServerError(errors.New("failed to get OIDC meta data for user")) - return - } - if oidcData == nil { - log.Errorf("User is not onboarded via OIDC AuthN, user id: %d", ua.userID) - ua.SendPreconditionFailedError(errors.New("user is not onboarded via OIDC AuthN")) - return - } - - s := &secretReq{} - if err := ua.DecodeJSONReq(s); err != nil { - ua.SendBadRequestError(err) - return - } - if err := validateSecret(s.Secret); err != nil { - ua.SendBadRequestError(err) - return - } - - encSec, err := utils.ReversibleEncrypt(s.Secret, ua.secretKey) - if err != nil { - log.Errorf("Failed to encrypt secret, error: %v", err) - ua.SendInternalServerError(errors.New("failed to encrypt secret")) - return - } - oidcData.Secret = encSec - err = dao.UpdateOIDCUserSecret(oidcData) - if err != nil { - log.Errorf("Failed to update secret in DB, error: %v", err) - ua.SendInternalServerError(errors.New("failed to update secret in DB")) - return - } -} - -func (ua *UserAPI) getOIDCUserInfo() (*models.OIDCUser, error) { - o, err := dao.GetOIDCUserByUserID(ua.userID) - if err != nil || o == nil { - return nil, err - } - if len(o.Secret) > 0 { - p, err := utils.ReversibleDecrypt(o.Secret, ua.secretKey) - if err != nil { - return nil, err - } - o.PlainSecret = p - } - return o, nil -} - -// modifiable returns whether the modify is allowed based on current auth mode and context -func (ua *UserAPI) modifiable(ctx context.Context) bool { - if ua.AuthMode == common.DBAuth { - // When the auth mode is local DB, admin can modify anyone, non-admin can modify himself. - return ua.SecurityCtx.Can(ctx, rbac.ActionUpdate, ua.resource) || ua.userID == ua.currentUserID - } - // When the auth mode is external IDM backend, only the super user can modify himself, - // because he's the only one whose information is stored in local DB. - return ua.userID == 1 && ua.userID == ua.currentUserID - -} - -// validate only validate when user register -func validate(user models.User) error { - - if utils.IsIllegalLength(user.Username, 1, 255) { - return fmt.Errorf("username with illegal length") - } - if utils.IsContainIllegalChar(user.Username, []string{",", "~", "#", "$", "%"}) { - return fmt.Errorf("username contains illegal characters") - } - - if err := validateSecret(user.Password); err != nil { - return err - } - - return commonValidate(user) -} - -func validateSecret(in string) error { - hasLower := regexp.MustCompile(`[a-z]`) - hasUpper := regexp.MustCompile(`[A-Z]`) - hasNumber := regexp.MustCompile(`[0-9]`) - if len(in) >= 8 && hasLower.MatchString(in) && hasUpper.MatchString(in) && hasNumber.MatchString(in) { - return nil - } - return errors.New("the password or secret must longer than 8 chars with at least 1 uppercase letter, 1 lowercase letter and 1 number") -} - -// commonValidate validates email, realname, comment information when user register or change their profile -func commonValidate(user models.User) error { - - if len(user.Email) > 0 { - if m, _ := regexp.MatchString(`^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$`, user.Email); !m { - return fmt.Errorf("email with illegal format") - } - } else { - return fmt.Errorf("Email can't be empty") - } - - if utils.IsIllegalLength(user.Realname, 1, 255) { - return fmt.Errorf("realname with illegal length") - } - - if utils.IsContainIllegalChar(user.Realname, []string{",", "~", "#", "$", "%"}) { - return fmt.Errorf("realname contains illegal characters") - } - if utils.IsIllegalLength(user.Comment, -1, 30) { - return fmt.Errorf("comment with illegal length") - } - return nil - -} diff --git a/src/core/api/user_test.go b/src/core/api/user_test.go deleted file mode 100644 index 930e788ea..000000000 --- a/src/core/api/user_test.go +++ /dev/null @@ -1,702 +0,0 @@ -// Copyright 2018 Project Harbor Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -package api - -import ( - "context" - "fmt" - "github.com/goharbor/harbor/src/controller/config" - securitytesting "github.com/goharbor/harbor/src/testing/common/security" - "github.com/goharbor/harbor/src/testing/mock" - "net/http" - "testing" - - "github.com/goharbor/harbor/src/common/dao" - "github.com/stretchr/testify/require" - - "github.com/goharbor/harbor/src/common/api" - "github.com/goharbor/harbor/src/common/models" - "github.com/goharbor/harbor/src/testing/apitests/apilib" - "github.com/stretchr/testify/assert" - - "github.com/astaxie/beego" - "github.com/goharbor/harbor/src/common" -) - -var testUser0002ID, testUser0003ID int -var testUser0002, testUser0003 apilib.User -var testUser0002Auth, testUser0003Auth *usrInfo - -func TestUsersPost(t *testing.T) { - - fmt.Println("Testing User Add") - - assert := assert.New(t) - apiTest := newHarborAPI() - config.Upload(map[string]interface{}{ - common.AUTHMode: "db_auth", - common.SelfRegistration: false, - }) - - // case 1: register a new user without authentication - t.Log("case 1: Register user without authentication") - code, err := apiTest.UsersPost(testUser0002) - if err != nil { - t.Error("Error occurred while add a test User", err.Error()) - t.Log(err) - } else { - assert.Equal(http.StatusUnauthorized, code, "case 1: Add user status should be 401 for unauthenticated request") - } - - config.Upload(map[string]interface{}{ - common.SelfRegistration: true, - }) - - // case 2: register a new user without admin auth, expect 403, because self registration is on - t.Log("case 2: Register user without admin auth") - code, err = apiTest.UsersPost(testUser0002) - if err != nil { - t.Error("Error occurred while add a test User", err.Error()) - t.Log(err) - } else { - // Should be 403 as only admin can call this API, otherwise it has to be called from browser, with session id - assert.Equal(http.StatusForbidden, code, "case 2: Add user status should be 403") - } - - // case 3: register a new user with admin auth, but username is empty, expect 400 - t.Log("case 3: Register user with admin auth, but username is empty") - code, err = apiTest.UsersPost(testUser0002, *admin) - if err != nil { - t.Error("Error occurred while add a user", err.Error()) - t.Log(err) - } else { - assert.Equal(400, code, "case 3: Add user status should be 400") - } - - // case 4: register a new user with admin auth, but bad username format, expect 400 - testUser0002.Username = "test@$" - t.Log("case 4: Register user with admin auth, but bad username format") - code, err = apiTest.UsersPost(testUser0002, *admin) - if err != nil { - t.Error("Error occurred while add a user", err.Error()) - t.Log(err) - } else { - assert.Equal(400, code, "case 4: Add user status should be 400") - } - - // case 5: register a new user with admin auth, but bad userpassword format, expect 400 - testUser0002.Username = "testUser0002" - t.Log("case 5: Register user with admin auth, but empty password.") - code, err = apiTest.UsersPost(testUser0002, *admin) - if err != nil { - t.Error("Error occurred while add a user", err.Error()) - t.Log(err) - } else { - assert.Equal(400, code, "case 5: Add user status should be 400") - } - - // case 6: register a new user with admin auth, but email is empty, expect 400 - testUser0002.Password = "testUser0002" - t.Log("case 6: Register user with admin auth, but email is empty") - code, err = apiTest.UsersPost(testUser0002, *admin) - if err != nil { - t.Error("Error occurred while add a user", err.Error()) - t.Log(err) - } else { - assert.Equal(400, code, "case 6: Add user status should be 400") - } - - // case 7: register a new user with admin auth, but bad email format, expect 400 - testUser0002.Email = "test..." - t.Log("case 7: Register user with admin auth, but bad email format") - code, err = apiTest.UsersPost(testUser0002, *admin) - if err != nil { - t.Error("Error occurred while add a user", err.Error()) - t.Log(err) - } else { - assert.Equal(400, code, "case 7: Add user status should be 400") - } - - // case 7: register a new user with admin auth, but userrealname is empty, expect 400 - /* - testUser0002.Email = "testUser0002@mydomain.com" - fmt.Println("Register user with admin auth, but user realname is empty") - code, err = apiTest.UsersPost(testUser0002, *admin) - if err != nil { - t.Error("Error occurred while add a user", err.Error()) - t.Log(err) - } else { - assert.Equal(400, code, "Add user status should be 400") - } - */ - // case 8: register a new user with admin auth, but bad userrealname format, expect 400 - testUser0002.Email = "testUser0002@mydomain.com" - testUser0002.Realname = "test$com" - t.Log("case 8: Register user with admin auth, but bad user realname format") - code, err = apiTest.UsersPost(testUser0002, *admin) - if err != nil { - t.Error("Error occurred while add a user", err.Error()) - t.Log(err) - - } else { - assert.Equal(400, code, "case 8: Add user status should be 400") - } - - // case 9: register a new user with admin auth, but bad user comment, expect 400 - testUser0002.Realname = "testUser0002" - testUser0002.Comment = "vmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm" - t.Log("case 9: Register user with admin auth, but user comment length is illegal") - code, err = apiTest.UsersPost(testUser0002, *admin) - if err != nil { - t.Error("Error occurred while add a user", err.Error()) - t.Log(err) - } else { - assert.Equal(400, code, "case 9: Add user status should be 400") - } - testUser0002.Comment = "test user" - - // case 10: register an admin using non-admin user, expect 403 - t.Log("case 10: Register admin user with non admin auth") - testUser0002.HasAdminRole = true - code, err = apiTest.UsersPost(testUser0002) - if err != nil { - t.Error("Error occurred while add a user", err.Error()) - t.Log(err) - } else { - assert.Equal(http.StatusForbidden, code, "case 10: Add user status should be 403") - } - testUser0002.HasAdminRole = false - - // case 11: register a new user with admin auth, expect 201 - t.Log("case 11: Register user with admin auth, right parameters") - code, err = apiTest.UsersPost(testUser0002, *admin) - if err != nil { - t.Error("Error occurred while add a user", err.Error()) - t.Log(err) - } else { - assert.Equal(201, code, "case 11: Add user status should be 201") - } - - // case 12: register duplicate user with admin auth, expect 409 - t.Log("case 12: Register duplicate user with admin auth") - code, err = apiTest.UsersPost(testUser0002, *admin) - if err != nil { - t.Error("Error occurred while add a user", err.Error()) - t.Log(err) - } else { - assert.Equal(409, code, "case 12: Add user status should be 409") - } - - // case 13: register a new user with admin auth, but duplicate email, expect 409 - t.Log("case 13: Register user with admin auth, but duplicate email") - testUser0002.Username = "testUsertest" - testUser0002.Email = "testUser0002@mydomain.com" - code, err = apiTest.UsersPost(testUser0002, *admin) - if err != nil { - t.Error("Error occurred while add a user", err.Error()) - t.Log(err) - } else { - assert.Equal(409, code, "case 13: Add user status should be 409") - } -} - -func TestUsersGet(t *testing.T) { - - fmt.Println("Testing User Get") - assert := assert.New(t) - apiTest := newHarborAPI() - - testUser0002.Username = "testUser0002" - // case 1: Get user2 with common auth, but no userid in path, expect 403 - - testUser0002Auth = &usrInfo{"testUser0002", "testUser0002"} - code, users, err := apiTest.UsersGet(testUser0002.Username, *testUser0002Auth) - if err != nil { - t.Error("Error occurred while get users", err.Error()) - t.Log(err) - } else { - assert.Equal(403, code, "Get users status should be 403") - } - // case 2: Get user2 with admin auth, expect 200 - code, users, err = apiTest.UsersGet(testUser0002.Username, *admin) - if err != nil { - t.Error("Error occurred while get users", err.Error()) - t.Log(err) - } else { - assert.Equal(200, code, "Get users status should be 200") - assert.Equal(1, len(users), "Get users record should be 1 ") - testUser0002ID = users[0].UserId - } -} - -func TestUsersSearch(t *testing.T) { - - fmt.Println("Testing User Search") - assert := assert.New(t) - apiTest := newHarborAPI() - - testUser0002.Username = "testUser0002" - // case 1: Search user2 without auth, expect 401 - - testUser0002Auth = &usrInfo{"testUser0002", "testUser0002"} - code, users, err := apiTest.UsersSearch(testUser0002.Username) - if err != nil { - t.Error("Error occurred while search users", err.Error()) - t.Log(err) - } else { - assert.Equal(401, code, "Search users status should be 401") - } - // case 2: Search user2 with with common auth, expect 200 - code, users, err = apiTest.UsersSearch(testUser0002.Username, *testUser0002Auth) - if err != nil { - t.Error("Error occurred while search users", err.Error()) - t.Log(err) - } else { - assert.Equal(200, code, "Search users status should be 200") - assert.Equal(1, len(users), "Search users record should be 1 ") - testUser0002ID = users[0].UserID - } -} - -func TestUsersGetByID(t *testing.T) { - - fmt.Println("Testing User GetByID") - assert := assert.New(t) - apiTest := newHarborAPI() - - // case 1: Get user2 with userID and his own auth, expect 200 - code, user, err := apiTest.UsersGetByID(testUser0002ID, *testUser0002Auth) - if err != nil { - t.Error("Error occurred while get users", err.Error()) - t.Log(err) - } else { - assert.Equal(200, code, "Get users status should be 200") - assert.Equal(testUser0002.Username, user.Username, "Get users username should be equal") - assert.Equal(testUser0002.Email, user.Email, "Get users email should be equal") - } - // case 2: Get user2 with user3 auth, expect 403 - testUser0003.Username = "testUser0003" - testUser0003.Email = "testUser0003@mydomain.com" - testUser0003.Password = "testUser0003" - testUser0003.Realname = "testUser0003" - code, err = apiTest.UsersPost(testUser0003, *admin) - if err != nil { - t.Error("Error occurred while add a user", err.Error()) - t.Log(err) - } else { - assert.Equal(201, code, "Add user status should be 201") - } - - testUser0003Auth = &usrInfo{"testUser0003", "testUser0003"} - code, user, err = apiTest.UsersGetByID(testUser0002ID, *testUser0003Auth) - if err != nil { - t.Error("Error occurred while get users", err.Error()) - t.Log(err) - } else { - assert.Equal(403, code, "Get users status should be 403") - } - // case 3: Get user that does not exist with user2 auth, expect 404 not found. - code, user, err = apiTest.UsersGetByID(1000, *testUser0002Auth) - if err != nil { - t.Error("Error occurred while get users", err.Error()) - t.Log(err) - } else { - assert.Equal(404, code, "Get users status should be 404") - } - // Get user3ID in order to delete at the last of the test - code, users, err := apiTest.UsersGet(testUser0003.Username, *admin) - if err != nil { - t.Error("Error occurred while get users", err.Error()) - t.Log(err) - } else { - assert.Equal(200, code, "Get users status should be 200") - assert.Equal(1, len(users), "Get users record should be 1") - testUser0003ID = users[0].UserId - } -} - -func TestUsersPut(t *testing.T) { - fmt.Println("Testing User Put") - assert := assert.New(t) - apiTest := newHarborAPI() - var profile apilib.UserProfile - // case 1: change user2 profile with user3 auth - code, err := apiTest.UsersPut(testUser0002ID, profile, *testUser0003Auth) - if err != nil { - t.Error("Error occurred while change user profile", err.Error()) - t.Log(err) - } else { - assert.Equal(403, code, "Change user profile status should be 403") - } - // case 2: change user3 profile with user2 auth - realname := "new realname" - email := "new_email@mydomain.com" - comment := "new comment" - profile.UserID = testUser0003ID - profile.Realname = realname - profile.Email = email - profile.Comment = comment - code, err = apiTest.UsersPut(testUser0002ID, profile, *testUser0002Auth) - if err != nil { - t.Error("Error occurred while change user profile", err.Error()) - t.Log(err) - } else { - assert.Equal(200, code, "Change user profile status should be 200") - } - _, user, err := apiTest.UsersGetByID(testUser0003ID, *testUser0003Auth) - if err != nil { - t.Error("Error occurred while get user", err.Error()) - } else { - assert.NotEqual(realname, user.Realname) - assert.NotEqual(email, user.Email) - assert.NotEqual(comment, user.Comment) - } - // case 3: change user2 profile with user2 auth, but bad parameters format. - profile = apilib.UserProfile{} - code, err = apiTest.UsersPut(testUser0002ID, profile, *testUser0002Auth) - if err != nil { - t.Error("Error occurred while change user profile", err.Error()) - t.Log(err) - } else { - assert.Equal(400, code, "Change user profile status should be 400") - } - // case 4: change user2 profile with user2 auth, but duplicate email. - profile.Realname = "test user" - profile.Email = "testUser0003@mydomain.com" - profile.Comment = "change profile" - code, err = apiTest.UsersPut(testUser0002ID, profile, *testUser0002Auth) - if err != nil { - t.Error("Error occurred while change user profile", err.Error()) - t.Log(err) - } else { - assert.Equal(409, code, "Change user profile status should be 409") - } - // case 5: change user2 profile with user2 auth, right parameters format. - profile.Realname = "test user" - profile.Email = "testUser0002@vmware.com" - profile.Comment = "change profile" - code, err = apiTest.UsersPut(testUser0002ID, profile, *testUser0002Auth) - if err != nil { - t.Error("Error occurred while change user profile", err.Error()) - t.Log(err) - } else { - assert.Equal(200, code, "Change user profile status should be 200") - testUser0002.Email = profile.Email - } -} - -func TestUsersToggleAdminRole(t *testing.T) { - fmt.Println("Testing Toggle User Admin Role") - assert := assert.New(t) - apiTest := newHarborAPI() - // case 1: toggle user2 admin role without admin auth - code, err := apiTest.UsersToggleAdminRole(testUser0002ID, *testUser0002Auth, true) - if err != nil { - t.Error("Error occurred while toggle user admin role", err.Error()) - t.Log(err) - } else { - assert.Equal(403, code, "Toggle user admin role status should be 403") - } - // case 2: toggle user2 admin role with admin auth - code, err = apiTest.UsersToggleAdminRole(testUser0002ID, *admin, true) - if err != nil { - t.Error("Error occurred while toggle user admin role", err.Error()) - t.Log(err) - } else { - assert.Equal(200, code, "Toggle user admin role status should be 200") - } -} - -func buildChangeUserPasswordURL(id int) string { - return fmt.Sprintf("/api/users/%d/password", id) -} - -func TestUsersUpdatePassword(t *testing.T) { - fmt.Println("Testing Update User Password") - oldPassword := "old_Passw0rd" - newPassword := "new_Passw0rd" - - user01 := models.User{ - Username: "user01_for_testing_change_password", - Email: "user01_for_testing_change_password@test.com", - Password: oldPassword, - } - id, err := dao.Register(user01) - require.Nil(t, err) - user01.UserID = int(id) - defer dao.DeleteUser(user01.UserID) - - user02 := models.User{ - Username: "user02_for_testing_change_password", - Email: "user02_for_testing_change_password@test.com", - Password: oldPassword, - } - id, err = dao.Register(user02) - require.Nil(t, err) - user02.UserID = int(id) - defer dao.DeleteUser(user02.UserID) - - cases := []*codeCheckingCase{ - // unauthorized - { - request: &testingRequest{ - method: http.MethodPut, - url: buildChangeUserPasswordURL(user01.UserID), - }, - code: http.StatusUnauthorized, - }, - // 404 - { - request: &testingRequest{ - method: http.MethodPut, - url: buildChangeUserPasswordURL(10000), - credential: &usrInfo{ - Name: user01.Username, - Passwd: user01.Password, - }, - }, - code: http.StatusNotFound, - }, - // 403, a normal user tries to change password of others - { - request: &testingRequest{ - method: http.MethodPut, - url: buildChangeUserPasswordURL(user02.UserID), - credential: &usrInfo{ - Name: user01.Username, - Passwd: user01.Password, - }, - }, - code: http.StatusForbidden, - }, - // 400, empty old password - { - request: &testingRequest{ - method: http.MethodPut, - url: buildChangeUserPasswordURL(user01.UserID), - bodyJSON: &passwordReq{}, - credential: &usrInfo{ - Name: user01.Username, - Passwd: user01.Password, - }, - }, - code: http.StatusBadRequest, - }, - // 400, empty new password - { - request: &testingRequest{ - method: http.MethodPut, - url: buildChangeUserPasswordURL(user01.UserID), - bodyJSON: &passwordReq{ - OldPassword: oldPassword, - }, - credential: &usrInfo{ - Name: user01.Username, - Passwd: user01.Password, - }, - }, - code: http.StatusBadRequest, - }, - // 403, incorrect old password - { - request: &testingRequest{ - method: http.MethodPut, - url: buildChangeUserPasswordURL(user01.UserID), - bodyJSON: &passwordReq{ - OldPassword: "incorrect_old_password", - NewPassword: newPassword, - }, - credential: &usrInfo{ - Name: user01.Username, - Passwd: user01.Password, - }, - }, - code: http.StatusForbidden, - }, - // 200, normal user change own password - { - request: &testingRequest{ - method: http.MethodPut, - url: buildChangeUserPasswordURL(user01.UserID), - bodyJSON: &passwordReq{ - OldPassword: oldPassword, - NewPassword: newPassword, - }, - credential: &usrInfo{ - Name: user01.Username, - Passwd: user01.Password, - }, - }, - code: http.StatusOK, - }, - // 400, admin user change password of others. - // the new password is same with the old one - { - request: &testingRequest{ - method: http.MethodPut, - url: buildChangeUserPasswordURL(user01.UserID), - bodyJSON: &passwordReq{ - NewPassword: newPassword, - }, - credential: admin, - }, - code: http.StatusBadRequest, - }, - // 200, admin user change password of others - { - request: &testingRequest{ - method: http.MethodPut, - url: buildChangeUserPasswordURL(user01.UserID), - bodyJSON: &passwordReq{ - NewPassword: "another_new_Passw0rd", - }, - credential: admin, - }, - code: http.StatusOK, - }, - } - - runCodeCheckingCases(t, cases...) -} - -func TestUsersDelete(t *testing.T) { - - fmt.Println("Testing User Delete") - assert := assert.New(t) - apiTest := newHarborAPI() - - t.Log("delete user-case 1") - // case 1:delete user without admin auth - code, err := apiTest.UsersDelete(testUser0002ID, *testUser0003Auth) - if err != nil { - t.Error("Error occurred while delete test user", err.Error()) - t.Log(err) - } else { - assert.Equal(403, code, "Delete test user status should be 403") - } - // case 2: delete user with admin auth, user2 has already been toggled to admin, but can not delete himself - t.Log("delete user-case 2") - code, err = apiTest.UsersDelete(testUser0002ID, *testUser0002Auth) - if err != nil { - t.Error("Error occurred while delete test user", err.Error()) - t.Log(err) - } else { - assert.Equal(403, code, "Delete test user status should be 403") - } - // case 3: delete user with admin auth - t.Log("delete user-case 3") - code, err = apiTest.UsersDelete(testUser0002ID, *admin) - if err != nil { - t.Error("Error occurred while delete test user", err.Error()) - t.Log(err) - } else { - assert.Equal(200, code, "Delete test user status should be 200") - } - // delete user3 with admin auth - code, err = apiTest.UsersDelete(testUser0003ID, *admin) - if err != nil { - t.Error("Error occurred while delete test user", err.Error()) - t.Log(err) - } else { - assert.Equal(200, code, "Delete test user status should be 200") - } -} - -func TestModifiable(t *testing.T) { - t.Log("Test modifiable.") - assert := assert.New(t) - base := BaseController{ - BaseAPI: api.BaseAPI{ - Controller: beego.Controller{}, - }, - } - - security := &securitytesting.Context{} - security.On("Can", mock.Anything, mock.Anything, mock.Anything).Return(false).Once() - base.SecurityCtx = security - - ua1 := &UserAPI{ - BaseController: base, - currentUserID: 3, - userID: 4, - SelfRegistration: false, - AuthMode: "db_auth", - } - assert.False(ua1.modifiable(context.TODO())) - - security.On("Can", mock.Anything, mock.Anything, mock.Anything).Return(true).Once() - ua2 := &UserAPI{ - BaseController: base, - currentUserID: 3, - userID: 4, - SelfRegistration: false, - AuthMode: "db_auth", - } - assert.True(ua2.modifiable(context.TODO())) - - security.On("Can", mock.Anything, mock.Anything, mock.Anything).Return(false).Once() - ua3 := &UserAPI{ - BaseController: base, - currentUserID: 3, - userID: 4, - SelfRegistration: false, - AuthMode: "ldap_auth", - } - assert.False(ua3.modifiable(context.TODO())) - - security.On("Can", mock.Anything, mock.Anything, mock.Anything).Return(true).Once() - ua4 := &UserAPI{ - BaseController: base, - currentUserID: 1, - userID: 1, - SelfRegistration: false, - AuthMode: "ldap_auth", - } - assert.True(ua4.modifiable(context.TODO())) -} - -func TestUsersCurrentPermissions(t *testing.T) { - fmt.Println("Testing Get Users Current Permissions") - - assert := assert.New(t) - apiTest := newHarborAPI() - - httpStatusCode, permissions, err := apiTest.UsersGetPermissions("current", "/project/1", *projAdmin) - assert.Nil(err) - assert.Equal(int(200), httpStatusCode, "httpStatusCode should be 200") - assert.NotEmpty(permissions, "permissions should not be empty") - - httpStatusCode, permissions, err = apiTest.UsersGetPermissions("current", "/unsupport-scope", *projAdmin) - assert.Nil(err) - assert.Equal(int(200), httpStatusCode, "httpStatusCode should be 200") - assert.Empty(permissions, "permissions should be empty") - - httpStatusCode, _, err = apiTest.UsersGetPermissions(projAdminID, "/project/1", *projAdmin) - assert.Nil(err) - assert.Equal(int(200), httpStatusCode, "httpStatusCode should be 200") - - httpStatusCode, _, err = apiTest.UsersGetPermissions(projDeveloperID, "/project/1", *projAdmin) - assert.Nil(err) - assert.Equal(int(403), httpStatusCode, "httpStatusCode should be 403") -} - -func TestValidateSecret(t *testing.T) { - assert.NotNil(t, validateSecret("")) - assert.NotNil(t, validateSecret("12345678")) - assert.NotNil(t, validateSecret("passw0rd")) - assert.NotNil(t, validateSecret("PASSW0RD")) - assert.NotNil(t, validateSecret("Sh0rt")) - assert.Nil(t, validateSecret("Passw0rd")) - assert.Nil(t, validateSecret("Thisis1Valid_password")) -} diff --git a/src/lib/orm/error.go b/src/lib/orm/error.go index 7b7a9c975..db9792e23 100644 --- a/src/lib/orm/error.go +++ b/src/lib/orm/error.go @@ -56,7 +56,7 @@ func AsNotFoundError(err error, messageFormat string, args ...interface{}) *erro return nil } -// AsConflictError checks whether the err is duplicate key error. If it it, wrap it +// AsConflictError checks whether the err is duplicate key error. If it is, wrap it // as a src/internal/error.Error with conflict error code, else return nil func AsConflictError(err error, messageFormat string, args ...interface{}) *errors.Error { if IsDuplicateKeyError(err) { diff --git a/src/pkg/oidc/dao/meta.go b/src/pkg/oidc/dao/meta.go new file mode 100644 index 000000000..2c280ba41 --- /dev/null +++ b/src/pkg/oidc/dao/meta.go @@ -0,0 +1,100 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dao + +import ( + "context" + + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/lib/orm" + "github.com/goharbor/harbor/src/lib/q" +) + +// MetaDAO is the data access object for OIDC user meta +type MetaDAO interface { + // Create ... + Create(ctx context.Context, oidcUser *models.OIDCUser) (int, error) + // GetByUsername get the oidc meta record by the user's username + GetByUsername(ctx context.Context, username string) (*models.OIDCUser, error) + // Update ... + Update(ctx context.Context, oidcUser *models.OIDCUser, props ...string) error + // List provides a way to query with flexible filter + List(ctx context.Context, query *q.Query) ([]*models.OIDCUser, error) +} + +// NewMetaDao returns an instance of the default MetaDAO +func NewMetaDao() MetaDAO { + return &metaDAO{} +} + +type metaDAO struct{} + +func (md *metaDAO) GetByUsername(ctx context.Context, username string) (*models.OIDCUser, error) { + sql := `SELECT oidc_user.id, oidc_user.user_id, oidc_user.secret, oidc_user.token, + oidc_user.creation_time, oidc_user.update_time FROM oidc_user + JOIN harbor_user ON oidc_user.user_id = harbor_user.user_id + WHERE harbor_user.username = ?` + ormer, err := orm.FromContext(ctx) + if err != nil { + return nil, err + } + res := &models.OIDCUser{} + if err := ormer.Raw(sql, username).QueryRow(res); err != nil { + return nil, err + } + return res, nil +} + +func (md *metaDAO) Update(ctx context.Context, oidcUser *models.OIDCUser, props ...string) error { + ormer, err := orm.FromContext(ctx) + if err != nil { + return err + } + n, err := ormer.Update(oidcUser, props...) + if err != nil { + return err + } + if n == 0 { + return errors.NotFoundError(nil).WithMessage("oidc user data with id %d not found", oidcUser.ID) + } + return nil +} + +func (md *metaDAO) List(ctx context.Context, query *q.Query) ([]*models.OIDCUser, error) { + qs, err := orm.QuerySetter(ctx, &models.OIDCUser{}, query) + if err != nil { + return nil, err + } + + var res []*models.OIDCUser + if _, err := qs.All(&res); err != nil { + return nil, err + } + + return res, nil +} + +func (md *metaDAO) Create(ctx context.Context, oidcUser *models.OIDCUser) (int, error) { + ormer, err := orm.FromContext(ctx) + if err != nil { + return 0, err + } + id, err := ormer.Insert(oidcUser) + if e := orm.AsConflictError(err, "The OIDC info for user %d exists, subissuer: %s", oidcUser.UserID, oidcUser.SubIss); e != nil { + err = e + } + return int(id), err +} diff --git a/src/pkg/oidc/dao/meta_test.go b/src/pkg/oidc/dao/meta_test.go new file mode 100644 index 000000000..d10637d96 --- /dev/null +++ b/src/pkg/oidc/dao/meta_test.go @@ -0,0 +1,92 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dao + +import ( + "fmt" + "testing" + + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/lib/orm" + "github.com/goharbor/harbor/src/lib/q" + htesting "github.com/goharbor/harbor/src/testing" + "github.com/stretchr/testify/suite" +) + +type MetaDaoTestSuite struct { + htesting.Suite + dao MetaDAO + userID int + username string +} + +func (suite *MetaDaoTestSuite) SetupSuite() { + suite.Suite.SetupSuite() + suite.ClearSQLs = []string{} + suite.dao = NewMetaDao() + suite.userID = 1234 + suite.username = "oidc_meta_testuser" + suite.ExecSQL("INSERT INTO harbor_user (user_id, username,password,realname) VALUES(?,?,'test','test')", suite.userID, suite.username) + ctx := orm.Context() + _, err := suite.dao.Create(ctx, &models.OIDCUser{ + UserID: suite.userID, + SubIss: `ca4bb144-4b5c-4d1b-9469-69cb3768af8fhttps://sso.andrea.muellerpublic.de/auth/realms/harbor`, + Secret: `7uBP9yqtdnVAhoA243GSv8nOXBWygqzaaEdq9Kqla+q4hOaBZmEMH9vUJi4Yjbh3`, + Token: `xxxx`, + }) + suite.Nil(err) + suite.appendClearSQL(suite.userID) +} + +func (suite *MetaDaoTestSuite) TestList() { + ctx := orm.Context() + l, err := suite.dao.List(ctx, q.New(q.KeyWords{"user_id": suite.userID})) + suite.Nil(err) + suite.Equal(1, len(l)) + suite.Equal("xxxx", l[0].Token) +} + +func (suite *MetaDaoTestSuite) TestGetByUsername() { + ctx := orm.Context() + ou, err := suite.dao.GetByUsername(ctx, suite.username) + suite.Nil(err) + suite.Equal(suite.userID, ou.UserID) + suite.Equal("xxxx", ou.Token) +} + +func (suite *MetaDaoTestSuite) TestUpdate() { + ctx := orm.Context() + l, err := suite.dao.List(ctx, q.New(q.KeyWords{"user_id": suite.userID})) + suite.Nil(err) + id := l[0].ID + ou := &models.OIDCUser{ + ID: id, + Secret: "newsecret", + } + err = suite.dao.Update(ctx, ou, "secret") + suite.Nil(err) + l, err = suite.dao.List(ctx, q.New(q.KeyWords{"user_id": suite.userID})) + suite.Nil(err) + suite.Equal("newsecret", l[0].Secret) +} + +func (suite *MetaDaoTestSuite) appendClearSQL(uid int) { + suite.ClearSQLs = append(suite.ClearSQLs, fmt.Sprintf("DELETE FROM oidc_user WHERE user_id = %d", uid)) + suite.ClearSQLs = append(suite.ClearSQLs, fmt.Sprintf("DELETE FROM harbor_user WHERE user_id = %d", uid)) +} + +func TestMetaDaoTestSuite(t *testing.T) { + suite.Run(t, &MetaDaoTestSuite{}) +} diff --git a/src/pkg/oidc/metamanager.go b/src/pkg/oidc/metamanager.go new file mode 100644 index 000000000..04c899155 --- /dev/null +++ b/src/pkg/oidc/metamanager.go @@ -0,0 +1,90 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package oidc + +import ( + "context" + + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/utils" + "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/lib/log" + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg/oidc/dao" +) + +// MetaManager is used for managing user's OIDC info +type MetaManager interface { + // Create creates the oidc user meta record, returns the ID of the record in DB + Create(ctx context.Context, oidcUser *models.OIDCUser) (int, error) + // GetByUserID gets the oidc meta record by user's ID + GetByUserID(ctx context.Context, uid int) (*models.OIDCUser, error) + // SetCliSecretByUserID updates the cli secret of a user based on the user ID + SetCliSecretByUserID(ctx context.Context, uid int, secret string) error +} + +type metaManager struct { + dao dao.MetaDAO +} + +func (m *metaManager) Create(ctx context.Context, oidcUser *models.OIDCUser) (int, error) { + return m.dao.Create(ctx, oidcUser) +} + +func (m *metaManager) GetByUserID(ctx context.Context, uid int) (*models.OIDCUser, error) { + logger := log.GetLogger(ctx) + l, err := m.dao.List(ctx, q.New(q.KeyWords{"user_id": uid})) + if err != nil { + return nil, err + } + if len(l) == 0 { + return nil, errors.NotFoundError(nil).WithMessage("oidc info for user %d not found", uid) + } + if len(l) > 1 { + logger.Warningf("%d records of oidc user Info found for user %d", len(l), uid) + } + res := l[0] + key, err := keyLoader.encryptKey() + if err != nil { + return nil, err + } + p, err := utils.ReversibleDecrypt(res.Secret, key) + if err != nil { + return nil, err + } + res.PlainSecret = p + return res, nil +} + +func (m *metaManager) SetCliSecretByUserID(ctx context.Context, uid int, secret string) error { + ou, err := m.GetByUserID(ctx, uid) + if err != nil { + return err + } + key, err := keyLoader.encryptKey() + if err != nil { + return err + } + s, err := utils.ReversibleEncrypt(secret, key) + if err != nil { + return err + } + return m.dao.Update(ctx, &models.OIDCUser{ID: ou.ID, Secret: s}, "secret") +} + +// NewMetaMgr returns a default implementation of MetaManager +func NewMetaMgr() MetaManager { + return &metaManager{dao: dao.NewMetaDao()} +} diff --git a/src/pkg/oidc/metamanager_test.go b/src/pkg/oidc/metamanager_test.go new file mode 100644 index 000000000..3dcb30c24 --- /dev/null +++ b/src/pkg/oidc/metamanager_test.go @@ -0,0 +1,68 @@ +package oidc + +import ( + "context" + "testing" + + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/testing/mock" + tdao "github.com/goharbor/harbor/src/testing/pkg/oidc/dao" + testifymock "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +// encrypt "secret1" using key "naa4JtarA1Zsc3uY" (set in helper_test) +var encSecret = "6FvOrx1O9TKBdalX4gMQrrKNZ99KIyg=" + +type metaMgrTestSuite struct { + suite.Suite + mgr MetaManager + dao *tdao.MetaDAO +} + +func (m *metaMgrTestSuite) SetupTest() { + m.dao = &tdao.MetaDAO{} + m.mgr = &metaManager{ + dao: m.dao, + } +} + +func (m *metaMgrTestSuite) TestGetByUserID() { + { + m.dao.On("List", mock.Anything, testifymock.MatchedBy( + func(query *q.Query) bool { + return query.Keywords["user_id"] == 8 + })).Return([]*models.OIDCUser{}, nil) + _, err := m.mgr.GetByUserID(context.Background(), 8) + m.NotNil(err) + } + { + m.dao.On("List", mock.Anything, testifymock.MatchedBy( + func(query *q.Query) bool { + return query.Keywords["user_id"] == 9 + })).Return([]*models.OIDCUser{ + + {ID: 1, UserID: 9, Secret: encSecret, Token: "token1"}, + {ID: 2, UserID: 9, Secret: "secret", Token: "token2"}, + }, nil) + ou, err := m.mgr.GetByUserID(context.Background(), 9) + m.Nil(err) + m.Equal(encSecret, ou.Secret) + m.Equal("secret1", ou.PlainSecret) + } +} + +func (m *metaMgrTestSuite) TestUpdateSecret() { + m.dao.On("List", mock.Anything, mock.Anything).Return([]*models.OIDCUser{ + {ID: 1, UserID: 9, Secret: encSecret, Token: "token1"}, + }, nil) + m.dao.On("Update", mock.Anything, mock.Anything, "secret").Return(nil) + err := m.mgr.SetCliSecretByUserID(context.Background(), 9, "new") + m.Nil(err) + m.dao.AssertExpectations(m.T()) +} + +func TestManager(t *testing.T) { + suite.Run(t, &metaMgrTestSuite{}) +} diff --git a/src/pkg/oidc/secret.go b/src/pkg/oidc/secret.go index 98126bd0b..8bfefe7dc 100644 --- a/src/pkg/oidc/secret.go +++ b/src/pkg/oidc/secret.go @@ -34,28 +34,37 @@ type SecretManager interface { VerifySecret(ctx context.Context, username string, secret string) (*models.User, error) } -type defaultManager struct { - sync.Mutex +type keyGetter struct { + sync.RWMutex key string } -var m SecretManager = &defaultManager{} - -func (dm *defaultManager) getEncryptKey() (string, error) { - if dm.key == "" { - dm.Lock() - defer dm.Unlock() - if dm.key == "" { - key, err := config.SecretKey() +func (kg *keyGetter) encryptKey() (string, error) { + kg.RLock() + if kg.key == "" { + kg.RUnlock() + kg.Lock() + defer kg.Unlock() + if kg.key == "" { + k, err := config.SecretKey() if err != nil { return "", err } - dm.key = key + kg.key = k } + } else { + defer kg.RUnlock() } - return dm.key, nil + return kg.key, nil } +var keyLoader = &keyGetter{} + +type defaultManager struct { +} + +var m SecretManager = &defaultManager{} + // VerifySecret verifies the secret and the token associated with it, it refreshes the token in the DB if it's // refreshed during the verification. It returns a populated user model based on the ID token associated with the secret. func (dm *defaultManager) VerifySecret(ctx context.Context, username string, secret string) (*models.User, error) { @@ -74,7 +83,7 @@ func (dm *defaultManager) VerifySecret(ctx context.Context, username string, sec if oidcUser == nil { return nil, fmt.Errorf("user is not onboarded as OIDC user, username: %s", username) } - key, err := dm.getEncryptKey() + key, err := keyLoader.encryptKey() if err != nil { return nil, fmt.Errorf("failed to load the key for encryption/decryption: %v", err) } diff --git a/src/pkg/oidc/secret_test.go b/src/pkg/oidc/secret_test.go index 9829bcc32..299ba440e 100644 --- a/src/pkg/oidc/secret_test.go +++ b/src/pkg/oidc/secret_test.go @@ -15,13 +15,13 @@ func TestSecretVerifyError(t *testing.T) { assert.Equal(t, sve, err) } -func TestDefaultManagerGetEncryptKey(t *testing.T) { - d := &defaultManager{} - k, err := d.getEncryptKey() +func TestGetEncryptKey(t *testing.T) { + kl := &keyGetter{} + k, err := kl.encryptKey() assert.Nil(t, err) assert.Equal(t, "naa4JtarA1Zsc3uY", k) - d2 := &defaultManager{key: "oldkey"} - k2, err := d2.getEncryptKey() + kl2 := &keyGetter{key: "oldkey"} + k2, err := kl2.encryptKey() assert.Nil(t, err) assert.Equal(t, "oldkey", k2) } diff --git a/src/pkg/repository/manager_test.go b/src/pkg/repository/manager_test.go index b422e2ac6..b03da76dc 100644 --- a/src/pkg/repository/manager_test.go +++ b/src/pkg/repository/manager_test.go @@ -16,11 +16,12 @@ package repository import ( "context" + "testing" + "github.com/goharbor/harbor/src/common/models" "github.com/goharbor/harbor/src/testing/pkg/repository/dao" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" - "testing" ) type managerTestSuite struct { diff --git a/src/pkg/user/dao/dao.go b/src/pkg/user/dao/dao.go index fcba3843e..81f3fe9f1 100644 --- a/src/pkg/user/dao/dao.go +++ b/src/pkg/user/dao/dao.go @@ -17,6 +17,7 @@ package dao import ( "context" + "github.com/goharbor/harbor/src/lib/errors" "github.com/goharbor/harbor/src/lib/orm" "github.com/goharbor/harbor/src/lib/q" "github.com/goharbor/harbor/src/pkg/user/models" @@ -24,8 +25,14 @@ import ( // DAO is the data access object interface for user type DAO interface { + // Create create a user record in the table, it will return the ID of the user + Create(ctx context.Context, user *models.User) (int, error) // List list users List(ctx context.Context, query *q.Query) ([]*models.User, error) + // Count counts the number of users + Count(ctx context.Context, query *q.Query) (int64, error) + // Update updates the user record based on the model the parm props are the columns will be updated + Update(ctx context.Context, user *models.User, props ...string) error } // New returns an instance of the default DAO @@ -33,8 +40,52 @@ func New() DAO { return &dao{} } +func init() { + // TODO beegoorm.RegisterModel(new(models.User)) +} + type dao struct{} +func (d *dao) Count(ctx context.Context, query *q.Query) (int64, error) { + query = q.MustClone(query) + query.Keywords["deleted"] = false + qs, err := orm.QuerySetterForCount(ctx, &models.User{}, query) + if err != nil { + return 0, err + } + return qs.Count() +} + +func (d *dao) Create(ctx context.Context, user *models.User) (int, error) { + if user.UserID > 0 { + return 0, errors.BadRequestError(nil).WithMessage("user ID is set when creating user: %d", user.UserID) + } + ormer, err := orm.FromContext(ctx) + if err != nil { + return 0, err + } + id, err := ormer.Insert(user) + if err != nil { + return 0, orm.WrapConflictError(err, "user %s or email %s already exists", user.Username, user.Email) + } + return int(id), nil +} + +func (d *dao) Update(ctx context.Context, user *models.User, props ...string) error { + ormer, err := orm.FromContext(ctx) + if err != nil { + return err + } + n, err := ormer.Update(user, props...) + if err != nil { + return err + } + if n == 0 { + return errors.NotFoundError(nil).WithMessage("user with id %d not found", user.UserID) + } + return nil +} + // List list users func (d *dao) List(ctx context.Context, query *q.Query) ([]*models.User, error) { query = q.MustClone(query) diff --git a/src/pkg/user/dao/dao_test.go b/src/pkg/user/dao/dao_test.go index 87d7f0275..b38977dec 100644 --- a/src/pkg/user/dao/dao_test.go +++ b/src/pkg/user/dao/dao_test.go @@ -1,5 +1,4 @@ // Copyright Project Harbor Authors -// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -15,10 +14,12 @@ package dao import ( + "fmt" "testing" "github.com/goharbor/harbor/src/lib/orm" "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg/user/models" htesting "github.com/goharbor/harbor/src/testing" "github.com/stretchr/testify/suite" ) @@ -30,9 +31,48 @@ type DaoTestSuite struct { func (suite *DaoTestSuite) SetupSuite() { suite.Suite.SetupSuite() + suite.ClearSQLs = []string{} suite.dao = New() } +func (suite *DaoTestSuite) TestCount() { + ctx := orm.Context() + { + n, err := suite.dao.Count(ctx, nil) + suite.Nil(err) + users, err := suite.dao.List(orm.Context(), nil) + suite.Nil(err) + suite.Equal(len(users), int(n)) + } + { + + n, err := suite.dao.Count(ctx, nil) + suite.Nil(err) + id, err := suite.dao.Create(ctx, &models.User{ + Username: "testuser2", + Realname: "user test", + Email: "testuser@test.com", + Password: "somepassword", + PasswordVersion: "sha256", + }) + suite.Nil(err) + defer suite.appendClearSQL(id) + n2, err := suite.dao.Count(ctx, nil) + suite.Nil(err) + suite.Equal(n+1, n2) + err2 := suite.dao.Update(ctx, &models.User{ + UserID: id, + Deleted: true, + }) + suite.Nil(err2) + n3, err := suite.dao.Count(ctx, nil) + suite.Nil(err) + suite.Equal(n, n3) + + } + +} + func (suite *DaoTestSuite) TestList() { { users, err := suite.dao.List(orm.Context(), q.New(q.KeyWords{"user_id": 1})) @@ -47,6 +87,61 @@ func (suite *DaoTestSuite) TestList() { } } +func (suite *DaoTestSuite) TestCreate() { + cases := []struct { + name string + input *models.User + hasError bool + }{ + { + name: "create with user ID", + input: &models.User{ + UserID: 3, + Username: "testuser", + Realname: "user test", + Email: "testuser@test.com", + Password: "somepassword", + PasswordVersion: "sha256", + }, + hasError: true, + }, + { + name: "create without user ID", + input: &models.User{ + Username: "testuser", + Realname: "user test", + Email: "testuser@test.com", + Password: "somepassword", + PasswordVersion: "sha256", + }, + hasError: false, + }, + } + for _, c := range cases { + suite.Run(c.name, func() { + ctx := orm.Context() + id, err := suite.dao.Create(ctx, c.input) + defer suite.appendClearSQL(id) + if c.hasError { + suite.NotNil(err) + } else { + suite.Nil(err) + l, err2 := suite.dao.List(ctx, q.New(q.KeyWords{"user_id": id})) + suite.Nil(err2) + suite.Equal(c.input.Username, l[0].Username) + suite.Equal(c.input.Password, l[0].Password) + suite.Equal(c.input.Email, l[0].Email) + suite.Equal(c.input.Realname, l[0].Realname) + suite.Equal(c.input.PasswordVersion, l[0].PasswordVersion) + } + }) + } +} + +func (suite *DaoTestSuite) appendClearSQL(uid int) { + suite.ClearSQLs = append(suite.ClearSQLs, fmt.Sprintf("DELETE FROM harbor_user WHERE user_id = %d", uid)) +} + func TestDaoTestSuite(t *testing.T) { suite.Run(t, &DaoTestSuite{}) } diff --git a/src/pkg/user/manager.go b/src/pkg/user/manager.go index c4df246dd..0fe6faa3a 100644 --- a/src/pkg/user/manager.go +++ b/src/pkg/user/manager.go @@ -16,17 +16,16 @@ package user import ( "context" + "fmt" "strings" + "github.com/goharbor/harbor/src/common/utils" "github.com/goharbor/harbor/src/lib/errors" "github.com/goharbor/harbor/src/lib/q" "github.com/goharbor/harbor/src/pkg/user/dao" "github.com/goharbor/harbor/src/pkg/user/models" ) -// User alias to models.User -type User = models.User - var ( // Mgr is the global project manager Mgr = New() @@ -40,6 +39,20 @@ type Manager interface { GetByName(ctx context.Context, username string) (*models.User, error) // List users according to the query List(ctx context.Context, query *q.Query) (models.Users, error) + // Count counts the number of users according to the query + Count(ctx context.Context, query *q.Query) (int64, error) + // Create creates the user, the password of input should be plaintext + Create(ctx context.Context, user *models.User) (int, error) + // Delete deletes the user by updating user's delete flag and update the name and Email + Delete(ctx context.Context, id int) error + // SetSysAdminFlag sets the system admin flag of the user in local DB + SetSysAdminFlag(ctx context.Context, id int, admin bool) error + // UpdateProfile updates the user's profile + UpdateProfile(ctx context.Context, user *models.User) error + // UpdatePassword updates user's password + UpdatePassword(ctx context.Context, id int, newPassword string) error + // VerifyLocalPassword verifies the password against the record in DB based on the input + VerifyLocalPassword(ctx context.Context, username, password string) (bool, error) } // New returns a default implementation of Manager @@ -51,6 +64,54 @@ type manager struct { dao dao.DAO } +func (m *manager) Delete(ctx context.Context, id int) error { + u, err := m.Get(ctx, id) + if err != nil { + return err + } + u.Username = fmt.Sprintf("%s#%d", u.Username, u.UserID) + u.Email = fmt.Sprintf("%s#%d", u.Email, u.UserID) + u.Deleted = true + return m.dao.Update(ctx, u, "username", "email", "deleted") +} + +func (m *manager) VerifyLocalPassword(ctx context.Context, username, password string) (bool, error) { + u, err := m.GetByName(ctx, username) + if err != nil { + return false, err + } + return utils.Encrypt(password, u.Salt, u.PasswordVersion) == u.Password, nil +} + +func (m *manager) Count(ctx context.Context, query *q.Query) (int64, error) { + return m.dao.Count(ctx, query) +} + +func (m *manager) UpdateProfile(ctx context.Context, user *models.User) error { + return m.dao.Update(ctx, user, "email", "realname", "comment") +} + +func (m *manager) UpdatePassword(ctx context.Context, id int, newPassword string) error { + user := &models.User{ + UserID: id, + } + injectPasswd(user, newPassword) + return m.dao.Update(ctx, user, "salt", "password", "password_version") +} + +func (m *manager) SetSysAdminFlag(ctx context.Context, id int, admin bool) error { + u := &models.User{ + UserID: id, + SysAdminFlag: admin, + } + return m.dao.Update(ctx, u, "sysadmin_flag") +} + +func (m *manager) Create(ctx context.Context, user *models.User) (int, error) { + injectPasswd(user, user.Password) + return m.dao.Create(ctx, user) +} + // Get get user by user id func (m *manager) Get(ctx context.Context, id int) (*models.User, error) { users, err := m.dao.List(ctx, q.New(q.KeyWords{"user_id": id})) @@ -93,11 +154,16 @@ func (m *manager) List(ctx context.Context, query *q.Query) (models.Users, error break } } - if excludeAdmin { // Exclude admin account when not filter by UserIDs, see https://github.com/goharbor/harbor/issues/2527 query.Keywords["user_id__gt"] = 1 } - return m.dao.List(ctx, query) } + +func injectPasswd(u *models.User, password string) { + salt := utils.GenerateRandomString() + u.Password = utils.Encrypt(password, salt, utils.SHA256) + u.Salt = salt + u.PasswordVersion = utils.SHA256 +} diff --git a/src/pkg/user/manager_test.go b/src/pkg/user/manager_test.go new file mode 100644 index 000000000..c8e96cc1c --- /dev/null +++ b/src/pkg/user/manager_test.go @@ -0,0 +1,60 @@ +package user + +import ( + "context" + "testing" + + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/utils" + "github.com/goharbor/harbor/src/testing/mock" + "github.com/goharbor/harbor/src/testing/pkg/user/dao" + "github.com/stretchr/testify/assert" + testifymock "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +type mgrTestSuite struct { + suite.Suite + mgr Manager + dao *dao.DAO +} + +func (m *mgrTestSuite) SetupTest() { + m.dao = &dao.DAO{} + m.mgr = &manager{ + dao: m.dao, + } +} + +func (m *mgrTestSuite) TestCount() { + m.dao.On("Count", mock.Anything, mock.Anything).Return(int64(1), nil) + n, err := m.mgr.Count(context.Background(), nil) + m.Nil(err) + m.Equal(int64(1), n) + m.dao.AssertExpectations(m.T()) +} + +func (m *mgrTestSuite) TestSetAdminFlag() { + id := 9 + m.dao.On("Update", mock.Anything, testifymock.MatchedBy( + func(u *models.User) bool { + return u.UserID == 9 && u.SysAdminFlag + }), "sysadmin_flag").Return(nil) + err := m.mgr.SetSysAdminFlag(context.Background(), id, true) + m.Nil(err) + m.dao.AssertExpectations(m.T()) +} + +func TestManager(t *testing.T) { + suite.Run(t, &mgrTestSuite{}) +} + +func TestInjectPasswd(t *testing.T) { + u := &models.User{ + UserID: 9, + } + p := "pass" + injectPasswd(u, p) + assert.Equal(t, "sha256", u.PasswordVersion) + assert.Equal(t, utils.Encrypt(p, u.Salt, "sha256"), u.Password) +} diff --git a/src/pkg/user/models/user.go b/src/pkg/user/models/user.go index a17cfa86e..d8bc7739a 100644 --- a/src/pkg/user/models/user.go +++ b/src/pkg/user/models/user.go @@ -15,11 +15,13 @@ package models import ( - "github.com/goharbor/harbor/src/common/models" + // "time" + + commonmodels "github.com/goharbor/harbor/src/common/models" ) // User ... -type User = models.User +type User = commonmodels.User // Users the collection for User type Users []*User diff --git a/src/server/v2.0/handler/handler.go b/src/server/v2.0/handler/handler.go index e8b4cfcf0..5d828ca25 100644 --- a/src/server/v2.0/handler/handler.go +++ b/src/server/v2.0/handler/handler.go @@ -58,6 +58,7 @@ func New() http.Handler { SystemCVEAllowlistAPI: newSystemCVEAllowListAPI(), ConfigureAPI: newConfigAPI(), UsergroupAPI: newUserGroupAPI(), + UsersAPI: newUsersAPI(), }) if err != nil { log.Fatal(err) diff --git a/src/server/v2.0/handler/model/user.go b/src/server/v2.0/handler/model/user.go new file mode 100644 index 000000000..81b38ab60 --- /dev/null +++ b/src/server/v2.0/handler/model/user.go @@ -0,0 +1,55 @@ +package model + +import ( + "github.com/go-openapi/strfmt" + "github.com/goharbor/harbor/src/pkg/user/models" + svrmodels "github.com/goharbor/harbor/src/server/v2.0/models" +) + +// User ... +type User struct { + *models.User +} + +// ToSearchRespItem ... +func (u *User) ToSearchRespItem() *svrmodels.UserSearchRespItem { + return &svrmodels.UserSearchRespItem{ + UserID: int64(u.UserID), + Username: u.Username, + } +} + +// ToUserProfile ... +func (u *User) ToUserProfile() *svrmodels.UserProfile { + return &svrmodels.UserProfile{ + Email: u.Email, + Realname: u.Realname, + Comment: u.Comment, + } +} + +// ToUserResp ... +func (u *User) ToUserResp() *svrmodels.UserResp { + res := &svrmodels.UserResp{ + Email: u.Email, + Realname: u.Realname, + Comment: u.Comment, + UserID: int64(u.UserID), + Username: u.Username, + SysadminFlag: u.SysAdminFlag, + AdminRoleInAuth: u.AdminRoleInAuth, + CreationTime: strfmt.DateTime(u.CreationTime), + UpdateTime: strfmt.DateTime(u.UpdateTime), + } + if u.OIDCUserMeta != nil { + res.OidcUserMeta = &svrmodels.OIDCUserInfo{ + ID: u.OIDCUserMeta.ID, + UserID: int64(u.OIDCUserMeta.UserID), + Subiss: u.OIDCUserMeta.SubIss, + Secret: u.OIDCUserMeta.PlainSecret, + CreationTime: strfmt.DateTime(u.OIDCUserMeta.CreationTime), + UpdateTime: strfmt.DateTime(u.OIDCUserMeta.UpdateTime), + } + } + return res +} diff --git a/src/server/v2.0/handler/user.go b/src/server/v2.0/handler/user.go new file mode 100644 index 000000000..78ee9dbab --- /dev/null +++ b/src/server/v2.0/handler/user.go @@ -0,0 +1,479 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handler + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/go-openapi/runtime/middleware" + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/common/rbac/system" + "github.com/goharbor/harbor/src/common/security" + "github.com/goharbor/harbor/src/common/security/local" + "github.com/goharbor/harbor/src/common/utils" + "github.com/goharbor/harbor/src/controller/config" + "github.com/goharbor/harbor/src/controller/user" + "github.com/goharbor/harbor/src/lib" + "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/lib/log" + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg/permission/types" + usermodels "github.com/goharbor/harbor/src/pkg/user/models" + "github.com/goharbor/harbor/src/server/v2.0/handler/model" + "github.com/goharbor/harbor/src/server/v2.0/models" + "github.com/goharbor/harbor/src/server/v2.0/restapi" + "github.com/goharbor/harbor/src/server/v2.0/restapi/operations/users" +) + +var userResource = system.NewNamespace().Resource(rbac.ResourceUser) + +type usersAPI struct { + BaseAPI + ctl user.Controller + getAuth func(ctx context.Context) (string, error) // For testing +} + +func newUsersAPI() restapi.UsersAPI { + return &usersAPI{ + ctl: user.Ctl, + getAuth: config.AuthMode, + } +} + +func (u *usersAPI) SetCliSecret(ctx context.Context, params users.SetCliSecretParams) middleware.Responder { + uid := int(params.UserID) + if err := u.requireForCLISecret(ctx, uid); err != nil { + return u.SendError(ctx, err) + } + if err := requireValidSecret(params.Secret.Secret); err != nil { + return u.SendError(ctx, err) + } + if err := u.ctl.SetCliSecret(ctx, uid, params.Secret.Secret); err != nil { + log.G(ctx).Errorf("Failed to set CLI secret, error: %v", err) + return u.SendError(ctx, err) + } + return users.NewSetCliSecretOK() +} + +func (u *usersAPI) CreateUser(ctx context.Context, params users.CreateUserParams) middleware.Responder { + if err := u.requireCreatable(ctx); err != nil { + return u.SendError(ctx, err) + } + if err := requireValidSecret(params.UserReq.Password); err != nil { + return u.SendError(ctx, err) + } + m := &usermodels.User{ + Username: params.UserReq.Username, + Realname: params.UserReq.Realname, + Email: params.UserReq.Email, + Comment: params.UserReq.Comment, + Password: params.UserReq.Password, + } + if err := validateUserProfile(m); err != nil { + return u.SendError(ctx, err) + } + uid, err := u.ctl.Create(ctx, m) + if err != nil { + log.G(ctx).Errorf("Failed to create user, error: %v", err) + return u.SendError(ctx, err) + } + location := fmt.Sprintf("%s/%d", strings.TrimSuffix(params.HTTPRequest.URL.Path, "/"), uid) + return users.NewCreateUserCreated().WithLocation(location) + +} + +func (u *usersAPI) ListUsers(ctx context.Context, params users.ListUsersParams) middleware.Responder { + if err := u.RequireSystemAccess(ctx, rbac.ActionList, userResource); err != nil { + return u.SendError(ctx, err) + } + query, err := u.BuildQuery(ctx, params.Q, params.Sort, params.Page, params.PageSize) + if err != nil { + return u.SendError(ctx, err) + } + values := params.HTTPRequest.URL.Query() + for _, k := range []string{"username", "email"} { + if v := values.Get(k); v != "" { + query.Keywords[k] = &q.FuzzyMatchValue{Value: v} + } + } + total, err := u.ctl.Count(ctx, query) + if err != nil { + return u.SendError(ctx, err) + } + payload := make([]*models.UserResp, 0) + if total > 0 { + ul, err := u.ctl.List(ctx, query) + if err != nil { + return u.SendError(ctx, err) + } + payload = make([]*models.UserResp, len(ul)) + for i, u := range ul { + m := &model.User{ + User: u, + } + payload[i] = m.ToUserResp() + } + } + return users.NewListUsersOK(). + WithPayload(payload). + WithLink(u.Links(ctx, params.HTTPRequest.URL, total, query.PageNumber, query.PageSize).String()). + WithXTotalCount(total) +} + +func (u *usersAPI) GetCurrentUserPermissions(ctx context.Context, params users.GetCurrentUserPermissionsParams) middleware.Responder { + if err := u.RequireAuthenticated(ctx); err != nil { + u.SendError(ctx, err) + } + scope := "" + if params.Scope != nil { + scope = *params.Scope + } + var policies []*types.Policy + sctx, _ := security.FromContext(ctx) + if ns, ok := types.NamespaceFromResource(rbac.Resource(scope)); ok { + for _, policy := range ns.GetPolicies() { + if sctx.Can(ctx, policy.Action, policy.Resource) { + policies = append(policies, policy) + } + } + } + var res []*models.Permission + relative := lib.BoolValue(params.Relative) + for _, policy := range policies { + var resource rbac.Resource + // for resource `/project/1/repository` if `relative` is `true` then the resource in response will be `repository` + if relative { + relativeResource, err := policy.Resource.RelativeTo(rbac.Resource(scope)) + if err != nil { + continue + } + resource = relativeResource + } else { + resource = policy.Resource + } + res = append(res, &models.Permission{ + Resource: resource.String(), + Action: policy.Action.String(), + }) + } + return users.NewGetCurrentUserPermissionsOK().WithPayload(res) +} + +func (u *usersAPI) DeleteUser(ctx context.Context, params users.DeleteUserParams) middleware.Responder { + uid := int(params.UserID) + if err := u.requireDeletable(ctx, uid); err != nil { + return u.SendError(ctx, err) + } + if err := u.ctl.Delete(ctx, uid); err != nil { + log.G(ctx).Errorf("Failed to delete user %d, error: %v", uid, err) + return u.SendError(ctx, err) + } + return users.NewDeleteUserOK() +} + +func (u *usersAPI) GetCurrentUserInfo(ctx context.Context, params users.GetCurrentUserInfoParams) middleware.Responder { + if err := u.RequireAuthenticated(ctx); err != nil { + return u.SendError(ctx, err) + } + sctx, _ := security.FromContext(ctx) + lsc, ok := sctx.(*local.SecurityContext) + if !ok { + return u.SendError(ctx, errors.PreconditionFailedError(nil).WithMessage("get current user not available for security context: %s", sctx.Name())) + } + resp, err := u.getUserByID(ctx, lsc.User().UserID) + if err != nil { + + return u.SendError(ctx, err) + } + return users.NewGetCurrentUserInfoOK().WithPayload(resp) +} + +func (u *usersAPI) GetUser(ctx context.Context, params users.GetUserParams) middleware.Responder { + uid := int(params.UserID) + if err := u.requireReadable(ctx, uid); err != nil { + return u.SendError(ctx, err) + } + resp, err := u.getUserByID(ctx, uid) + if err != nil { + log.G(ctx).Errorf("Failed to get user info for ID %d, error: %v", uid, err) + return u.SendError(ctx, err) + } + return users.NewGetUserOK().WithPayload(resp) +} + +func (u *usersAPI) getUserByID(ctx context.Context, id int) (*models.UserResp, error) { + auth, err := u.getAuth(ctx) + if err != nil { + return nil, err + } + + opt := &user.Option{ + WithOIDCInfo: auth == common.OIDCAuth, + } + + us, err := u.ctl.Get(ctx, id, opt) + if err != nil { + return nil, err + } + m := &model.User{ + User: us, + } + return m.ToUserResp(), nil +} + +func (u *usersAPI) UpdateUserProfile(ctx context.Context, params users.UpdateUserProfileParams) middleware.Responder { + uid := int(params.UserID) + if err := u.requireModifiable(ctx, uid); err != nil { + return u.SendError(ctx, err) + } + m := &usermodels.User{ + UserID: uid, + Realname: params.Profile.Realname, + Email: params.Profile.Email, + Comment: params.Profile.Comment, + } + if err := validateUserProfile(m); err != nil { + return u.SendError(ctx, err) + } + if err := u.ctl.UpdateProfile(ctx, m); err != nil { + log.G(ctx).Errorf("Failed to update user profile, error: %v", err) + return u.SendError(ctx, err) + } + return users.NewUpdateUserProfileOK() +} + +func (u *usersAPI) SearchUsers(ctx context.Context, params users.SearchUsersParams) middleware.Responder { + if err := u.RequireAuthenticated(ctx); err != nil { + return u.SendError(ctx, err) + } + query, err := u.BuildQuery(ctx, nil, nil, params.Page, params.PageSize) + if err != nil { + return u.SendError(ctx, err) + } + query.Keywords["username"] = &q.FuzzyMatchValue{Value: params.Username} + total, err := u.ctl.Count(ctx, query) + if err != nil { + return u.SendError(ctx, err) + } + if total == 0 { + return users.NewSearchUsersOK().WithXTotalCount(0).WithPayload([]*models.UserSearchRespItem{}) + } + l, err := u.ctl.List(ctx, query) + if err != nil { + return u.SendError(ctx, err) + } + var result []*models.UserSearchRespItem + for _, us := range l { + m := &model.User{User: us} + result = append(result, m.ToSearchRespItem()) + } + return users.NewSearchUsersOK(). + WithXTotalCount(total). + WithPayload(result). + WithLink(u.Links(ctx, params.HTTPRequest.URL, total, query.PageNumber, query.PageSize).String()) +} + +func (u *usersAPI) UpdateUserPassword(ctx context.Context, params users.UpdateUserPasswordParams) middleware.Responder { + uid := int(params.UserID) + if err := u.requireModifiable(ctx, uid); err != nil { + return u.SendError(ctx, err) + } + sctx, _ := security.FromContext(ctx) + if matchUserID(sctx, uid) { + ok, err := u.ctl.VerifyPassword(ctx, sctx.GetUsername(), params.Password.OldPassword) + if err != nil { + log.G(ctx).Errorf("Failed to verify password for user: %s, error: %v", sctx.GetUsername(), err) + return u.SendError(ctx, errors.UnknownError(nil).WithMessage("Failed to verify password")) + } + if !ok { + return u.SendError(ctx, errors.ForbiddenError(nil).WithMessage("Current password is incorrect")) + } + } + newPwd := params.Password.NewPassword + if err := requireValidSecret(newPwd); err != nil { + return u.SendError(ctx, err) + } + ok, err := u.ctl.VerifyPassword(ctx, sctx.GetUsername(), newPwd) + if err != nil { + log.G(ctx).Errorf("Failed to verify password for user: %s, error: %v", sctx.GetUsername(), err) + return u.SendError(ctx, errors.UnknownError(nil).WithMessage("Failed to verify password")) + } + if ok { + return u.SendError(ctx, errors.BadRequestError(nil).WithMessage("New password is identical to old password")) + } + err2 := u.ctl.UpdatePassword(ctx, uid, params.Password.NewPassword) + if err2 != nil { + log.G(ctx).Errorf("Failed to update password, error: %v", err) + return u.SendError(ctx, err) + } + return users.NewUpdateUserPasswordOK() +} + +func (u *usersAPI) SetUserSysAdmin(ctx context.Context, params users.SetUserSysAdminParams) middleware.Responder { + id := int(params.UserID) + if err := u.RequireSystemAccess(ctx, rbac.ActionUpdate, rbac.ResourceUser); err != nil { + return u.SendError(ctx, err) + } + if err := u.ctl.SetSysAdmin(ctx, id, params.SysadminFlag.SysadminFlag); err != nil { + return u.SendError(ctx, err) + } + return users.NewSetUserSysAdminOK() +} + +func (u *usersAPI) requireForCLISecret(ctx context.Context, id int) error { + a, err := u.getAuth(ctx) + if err != nil { + log.G(ctx).Errorf("Failed to get authmode, error: %v", err) + return err + } + if a != common.OIDCAuth { + return errors.PreconditionFailedError(nil).WithMessage("unable to update CLI secret under authmode: %s", a) + } + sctx, ok := security.FromContext(ctx) + if !ok || !sctx.IsAuthenticated() { + return errors.UnauthorizedError(nil) + } + if !matchUserID(sctx, id) && !sctx.Can(ctx, rbac.ActionUpdate, userResource) { + return errors.ForbiddenError(nil).WithMessage("Not authorized to update the CLI secret for user: %d", id) + } + return nil +} + +func (u *usersAPI) requireCreatable(ctx context.Context) error { + a, err := u.getAuth(ctx) + if err != nil { + log.G(ctx).Errorf("Failed to get authmode, error: %v", err) + return err + } + if a != common.DBAuth { + return errors.ForbiddenError(nil).WithMessage("creating local user is not allowed under auth mode: %s", a) + } + sr, err := config.SelfRegistration(ctx) + if err != nil { + log.G(ctx).Errorf("Failed to get self registration flag, error: %v", err) + return err + } + accessErr := u.RequireSystemAccess(ctx, rbac.ActionCreate, rbac.ResourceUser) + if !sr { + return accessErr + } + if accessErr != nil && !lib.GetCarrySession(ctx) { + return errors.ForbiddenError(nil).WithMessage("self-registration cannot be triggered via API") + } + return nil +} + +func (u *usersAPI) requireReadable(ctx context.Context, id int) error { + sctx, ok := security.FromContext(ctx) + if !ok || !sctx.IsAuthenticated() { + return errors.UnauthorizedError(nil) + } + if !matchUserID(sctx, id) && !sctx.Can(ctx, rbac.ActionRead, userResource) { + return errors.ForbiddenError(nil).WithMessage("Not authorized to read user: %d", id) + } + return nil +} + +func (u *usersAPI) requireDeletable(ctx context.Context, id int) error { + sctx, ok := security.FromContext(ctx) + if !ok || !sctx.IsAuthenticated() { + return errors.UnauthorizedError(nil) + } + a, err := u.getAuth(ctx) + if err != nil { + log.G(ctx).Errorf("Failed to get authmode, error: %v", err) + return err + } + if a != common.DBAuth { + return errors.ForbiddenError(nil).WithMessage("Deleting user is not allowed under auth mode: %s", a) + } + if !sctx.Can(ctx, rbac.ActionDelete, userResource) { + return errors.ForbiddenError(nil).WithMessage("Not authorized to delete users") + } + if matchUserID(sctx, id) || id == 1 { + return errors.ForbiddenError(nil).WithMessage("User with ID %d cannot be deleted", id) + } + return nil +} + +func (u *usersAPI) requireModifiable(ctx context.Context, id int) error { + a, err := u.getAuth(ctx) + if err != nil { + return err + } + sctx, ok := security.FromContext(ctx) + if !ok || !sctx.IsAuthenticated() { + return errors.UnauthorizedError(nil) + } + if !modifiable(ctx, a, id) { + return errors.ForbiddenError(nil).WithMessage("User with ID %d can't be updated", id) + } + return nil +} + +func modifiable(ctx context.Context, authMode string, id int) bool { + sctx, _ := security.FromContext(ctx) + if authMode == common.DBAuth { + + // In db auth, admin can update anyone's info, and regular user can update his own + return sctx.Can(ctx, rbac.ActionUpdate, userResource) || matchUserID(sctx, id) + } + // In none db auth, only the local admin's password can be updated. + return id == 1 && sctx.Can(ctx, rbac.ActionUpdate, userResource) +} + +func matchUserID(sctx security.Context, id int) bool { + if localSCtx, ok := sctx.(*local.SecurityContext); ok { + return localSCtx.User().UserID == id + } + return false +} + +func requireValidSecret(in string) error { + hasLower := regexp.MustCompile(`[a-z]`) + hasUpper := regexp.MustCompile(`[A-Z]`) + hasNumber := regexp.MustCompile(`[0-9]`) + if len(in) >= 8 && hasLower.MatchString(in) && hasUpper.MatchString(in) && hasNumber.MatchString(in) { + return nil + } + return errors.BadRequestError(nil).WithMessage("the password or secret must be longer than 8 chars with at least 1 uppercase letter, 1 lowercase letter and 1 number") +} + +func validateUserProfile(user *usermodels.User) error { + if len(user.Email) > 0 { + if m, _ := regexp.MatchString(`^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$`, user.Email); !m { + return errors.BadRequestError(nil).WithMessage("email with illegal format") + } + } else { + return errors.BadRequestError(nil).WithMessage("email can't be empty") + } + + if utils.IsIllegalLength(user.Realname, 1, 255) { + return errors.BadRequestError(nil).WithMessage("realname with illegal length") + } + + if utils.IsContainIllegalChar(user.Realname, []string{",", "~", "#", "$", "%"}) { + return errors.BadRequestError(nil).WithMessage("realname contains illegal characters") + } + if utils.IsIllegalLength(user.Comment, -1, 30) { + return errors.BadRequestError(nil).WithMessage("comment with illegal length") + } + return nil + +} diff --git a/src/server/v2.0/handler/user_test.go b/src/server/v2.0/handler/user_test.go new file mode 100644 index 000000000..b14b29573 --- /dev/null +++ b/src/server/v2.0/handler/user_test.go @@ -0,0 +1,94 @@ +package handler + +import ( + "context" + "testing" + + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/server/v2.0/models" + "github.com/goharbor/harbor/src/server/v2.0/restapi" + usertesting "github.com/goharbor/harbor/src/testing/controller/user" + "github.com/goharbor/harbor/src/testing/mock" + htesting "github.com/goharbor/harbor/src/testing/server/v2.0/handler" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +func TestRequireValidSecret(t *testing.T) { + cases := []struct { + in string + hasError bool + }{ + {"", true}, + {"12345678", true}, + {"passw0rd", true}, + {"PASSW0RD", true}, + {"Sh0rt", true}, + {"Passw0rd", false}, + {"Thisis1Valid_password", false}, + } + for _, c := range cases { + e := requireValidSecret(c.in) + assert.Equal(t, c.hasError, e != nil) + } +} + +type UserTestSuite struct { + htesting.Suite + uCtl *usertesting.Controller +} + +func (uts *UserTestSuite) SetupSuite() { + uts.uCtl = &usertesting.Controller{} + uts.Config = &restapi.Config{ + UsersAPI: &usersAPI{ + ctl: uts.uCtl, + getAuth: func(ctx context.Context) (string, error) { + return common.DBAuth, nil + }, + }, + } + uts.Suite.SetupSuite() + uts.Security.On("IsAuthenticated").Return(true) + +} + +func (uts *UserTestSuite) TestUpdateUserPassword() { + + body := models.PasswordReq{ + OldPassword: "Harbor12345", + NewPassword: "Passw0rd", + } + { + url := "/users/2/password" + uts.Security.On("Can", mock.Anything, mock.Anything, mock.Anything).Return(false).Times(1) + res, err := uts.Suite.PutJSON(url, &body) + uts.NoError(err) + uts.Equal(403, res.StatusCode) + } + { + url := "/users/1/password" + uts.Security.On("Can", mock.Anything, mock.Anything, mock.Anything).Return(true).Times(1) + uts.Security.On("GetUsername").Return("admin").Times(1) + + uts.uCtl.On("VerifyPassword", mock.Anything, "admin", "Passw0rd").Return(true, nil).Times(1) + res, err := uts.Suite.PutJSON(url, &body) + uts.NoError(err) + uts.Equal(400, res.StatusCode) + } + { + url := "/users/1/password" + uts.Security.On("Can", mock.Anything, mock.Anything, mock.Anything).Return(true).Times(1) + uts.Security.On("GetUsername").Return("admin").Times(1) + + uts.uCtl.On("VerifyPassword", mock.Anything, "admin", mock.Anything).Return(false, nil).Times(1) + uts.uCtl.On("UpdatePassword", mock.Anything, mock.Anything, mock.Anything).Return(nil) + res, err := uts.Suite.PutJSON(url, &body) + uts.NoError(err) + uts.Equal(200, res.StatusCode) + } +} + +func TestUserTestSuite(t *testing.T) { + suite.Run(t, &UserTestSuite{}) +} diff --git a/src/server/v2.0/route/legacy.go b/src/server/v2.0/route/legacy.go index 5de3ded1e..b5e2569d4 100755 --- a/src/server/v2.0/route/legacy.go +++ b/src/server/v2.0/route/legacy.go @@ -24,13 +24,6 @@ import ( func registerLegacyRoutes() { version := APIVersion beego.Router("/api/"+version+"/projects/:pid([0-9]+)/members/?:pmid([0-9]+)", &api.ProjectMemberAPI{}) - beego.Router("/api/"+version+"/users/:id", &api.UserAPI{}, "get:Get;delete:Delete;put:Put") - beego.Router("/api/"+version+"/users", &api.UserAPI{}, "get:List;post:Post") - beego.Router("/api/"+version+"/users/search", &api.UserAPI{}, "get:Search") - beego.Router("/api/"+version+"/users/:id([0-9]+)/password", &api.UserAPI{}, "put:ChangePassword") - beego.Router("/api/"+version+"/users/:id/permissions", &api.UserAPI{}, "get:ListUserPermissions") - beego.Router("/api/"+version+"/users/:id/sysadmin", &api.UserAPI{}, "put:ToggleUserAdminRole") - beego.Router("/api/"+version+"/users/:id/cli_secret", &api.UserAPI{}, "put:SetCLISecret") beego.Router("/api/"+version+"/email/ping", &api.EmailAPI{}, "post:Ping") beego.Router("/api/"+version+"/health", &api.HealthAPI{}, "get:CheckHealth") beego.Router("/api/"+version+"/projects/:id([0-9]+)/metadatas/?:name", &api.MetadataAPI{}, "get:Get") diff --git a/src/testing/controller/config/controller.go b/src/testing/controller/config/controller.go new file mode 100644 index 000000000..61259ce90 --- /dev/null +++ b/src/testing/controller/config/controller.go @@ -0,0 +1,165 @@ +// Code generated by mockery v2.1.0. DO NOT EDIT. + +package config + +import ( + context "context" + + libconfig "github.com/goharbor/harbor/src/lib/config" + metadata "github.com/goharbor/harbor/src/lib/config/metadata" + + mock "github.com/stretchr/testify/mock" +) + +// Controller is an autogenerated mock type for the Controller type +type Controller struct { + mock.Mock +} + +// AllConfigs provides a mock function with given fields: ctx +func (_m *Controller) AllConfigs(ctx context.Context) (map[string]interface{}, error) { + ret := _m.Called(ctx) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context) map[string]interface{}); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Get provides a mock function with given fields: ctx, item +func (_m *Controller) Get(ctx context.Context, item string) *metadata.ConfigureValue { + ret := _m.Called(ctx, item) + + var r0 *metadata.ConfigureValue + if rf, ok := ret.Get(0).(func(context.Context, string) *metadata.ConfigureValue); ok { + r0 = rf(ctx, item) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*metadata.ConfigureValue) + } + } + + return r0 +} + +// GetBool provides a mock function with given fields: ctx, item +func (_m *Controller) GetBool(ctx context.Context, item string) bool { + ret := _m.Called(ctx, item) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, string) bool); ok { + r0 = rf(ctx, item) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// GetInt provides a mock function with given fields: ctx, item +func (_m *Controller) GetInt(ctx context.Context, item string) int { + ret := _m.Called(ctx, item) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, string) int); ok { + r0 = rf(ctx, item) + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} + +// GetManager provides a mock function with given fields: +func (_m *Controller) GetManager() libconfig.Manager { + ret := _m.Called() + + var r0 libconfig.Manager + if rf, ok := ret.Get(0).(func() libconfig.Manager); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(libconfig.Manager) + } + } + + return r0 +} + +// GetString provides a mock function with given fields: ctx, item +func (_m *Controller) GetString(ctx context.Context, item string) string { + ret := _m.Called(ctx, item) + + var r0 string + if rf, ok := ret.Get(0).(func(context.Context, string) string); ok { + r0 = rf(ctx, item) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// Load provides a mock function with given fields: ctx +func (_m *Controller) Load(ctx context.Context) error { + ret := _m.Called(ctx) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(ctx) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateUserConfigs provides a mock function with given fields: ctx, conf +func (_m *Controller) UpdateUserConfigs(ctx context.Context, conf map[string]interface{}) error { + ret := _m.Called(ctx, conf) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, map[string]interface{}) error); ok { + r0 = rf(ctx, conf) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UserConfigs provides a mock function with given fields: ctx +func (_m *Controller) UserConfigs(ctx context.Context) (map[string]*libconfig.Value, error) { + ret := _m.Called(ctx) + + var r0 map[string]*libconfig.Value + if rf, ok := ret.Get(0).(func(context.Context) map[string]*libconfig.Value); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]*libconfig.Value) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/src/testing/controller/controller.go b/src/testing/controller/controller.go index 2a45b4715..01fccd41a 100644 --- a/src/testing/controller/controller.go +++ b/src/testing/controller/controller.go @@ -26,3 +26,4 @@ package controller //go:generate mockery --case snake --dir ../../controller/proxy --name RemoteInterface --output ./proxy --outpkg proxy //go:generate mockery --case snake --dir ../../controller/retention --name Controller --output ./retention --outpkg retention //go:generate mockery --case snake --dir ../../controller/config --name Controller --output ./config --outpkg config +//go:generate mockery --case snake --dir ../../controller/user --name Controller --output ./user --outpkg user diff --git a/src/testing/controller/user/controller.go b/src/testing/controller/user/controller.go new file mode 100644 index 000000000..3ec52a611 --- /dev/null +++ b/src/testing/controller/user/controller.go @@ -0,0 +1,198 @@ +// Code generated by mockery v2.1.0. DO NOT EDIT. + +package user + +import ( + context "context" + + models "github.com/goharbor/harbor/src/common/models" + mock "github.com/stretchr/testify/mock" + + q "github.com/goharbor/harbor/src/lib/q" + + user "github.com/goharbor/harbor/src/controller/user" +) + +// Controller is an autogenerated mock type for the Controller type +type Controller struct { + mock.Mock +} + +// Count provides a mock function with given fields: ctx, query +func (_m *Controller) Count(ctx context.Context, query *q.Query) (int64, error) { + ret := _m.Called(ctx, query) + + var r0 int64 + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) int64); ok { + r0 = rf(ctx, query) + } else { + r0 = ret.Get(0).(int64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *q.Query) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Create provides a mock function with given fields: ctx, u +func (_m *Controller) Create(ctx context.Context, u *models.User) (int, error) { + ret := _m.Called(ctx, u) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, *models.User) int); ok { + r0 = rf(ctx, u) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.User) error); ok { + r1 = rf(ctx, u) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: ctx, id +func (_m *Controller) Delete(ctx context.Context, id int) error { + ret := _m.Called(ctx, id) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: ctx, id, opt +func (_m *Controller) Get(ctx context.Context, id int, opt *user.Option) (*models.User, error) { + ret := _m.Called(ctx, id, opt) + + var r0 *models.User + if rf, ok := ret.Get(0).(func(context.Context, int, *user.Option) *models.User); ok { + r0 = rf(ctx, id, opt) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.User) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int, *user.Option) error); ok { + r1 = rf(ctx, id, opt) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// List provides a mock function with given fields: ctx, query +func (_m *Controller) List(ctx context.Context, query *q.Query) ([]*models.User, error) { + ret := _m.Called(ctx, query) + + var r0 []*models.User + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) []*models.User); ok { + r0 = rf(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.User) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *q.Query) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SetCliSecret provides a mock function with given fields: ctx, id, secret +func (_m *Controller) SetCliSecret(ctx context.Context, id int, secret string) error { + ret := _m.Called(ctx, id, secret) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, string) error); ok { + r0 = rf(ctx, id, secret) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SetSysAdmin provides a mock function with given fields: ctx, id, adminFlag +func (_m *Controller) SetSysAdmin(ctx context.Context, id int, adminFlag bool) error { + ret := _m.Called(ctx, id, adminFlag) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, bool) error); ok { + r0 = rf(ctx, id, adminFlag) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdatePassword provides a mock function with given fields: ctx, id, password +func (_m *Controller) UpdatePassword(ctx context.Context, id int, password string) error { + ret := _m.Called(ctx, id, password) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, string) error); ok { + r0 = rf(ctx, id, password) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateProfile provides a mock function with given fields: ctx, u +func (_m *Controller) UpdateProfile(ctx context.Context, u *models.User) error { + ret := _m.Called(ctx, u) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.User) error); ok { + r0 = rf(ctx, u) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// VerifyPassword provides a mock function with given fields: ctx, username, password +func (_m *Controller) VerifyPassword(ctx context.Context, username string, password string) (bool, error) { + ret := _m.Called(ctx, username, password) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, string, string) bool); ok { + r0 = rf(ctx, username, password) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, username, password) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/src/testing/pkg/oidc/dao/meta_dao.go b/src/testing/pkg/oidc/dao/meta_dao.go new file mode 100644 index 000000000..6143b1bea --- /dev/null +++ b/src/testing/pkg/oidc/dao/meta_dao.go @@ -0,0 +1,106 @@ +// Code generated by mockery v2.1.0. DO NOT EDIT. + +package oidc + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + models "github.com/goharbor/harbor/src/common/models" + + q "github.com/goharbor/harbor/src/lib/q" +) + +// MetaDAO is an autogenerated mock type for the MetaDAO type +type MetaDAO struct { + mock.Mock +} + +// Create provides a mock function with given fields: ctx, oidcUser +func (_m *MetaDAO) Create(ctx context.Context, oidcUser *models.OIDCUser) (int, error) { + ret := _m.Called(ctx, oidcUser) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, *models.OIDCUser) int); ok { + r0 = rf(ctx, oidcUser) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.OIDCUser) error); ok { + r1 = rf(ctx, oidcUser) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetByUsername provides a mock function with given fields: ctx, username +func (_m *MetaDAO) GetByUsername(ctx context.Context, username string) (*models.OIDCUser, error) { + ret := _m.Called(ctx, username) + + var r0 *models.OIDCUser + if rf, ok := ret.Get(0).(func(context.Context, string) *models.OIDCUser); ok { + r0 = rf(ctx, username) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.OIDCUser) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, username) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// List provides a mock function with given fields: ctx, query +func (_m *MetaDAO) List(ctx context.Context, query *q.Query) ([]*models.OIDCUser, error) { + ret := _m.Called(ctx, query) + + var r0 []*models.OIDCUser + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) []*models.OIDCUser); ok { + r0 = rf(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.OIDCUser) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *q.Query) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: ctx, oidcUser, props +func (_m *MetaDAO) Update(ctx context.Context, oidcUser *models.OIDCUser, props ...string) error { + _va := make([]interface{}, len(props)) + for _i := range props { + _va[_i] = props[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, oidcUser) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.OIDCUser, ...string) error); ok { + r0 = rf(ctx, oidcUser, props...) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/src/testing/pkg/pkg.go b/src/testing/pkg/pkg.go index 080d85eb4..2a8a4fbd4 100644 --- a/src/testing/pkg/pkg.go +++ b/src/testing/pkg/pkg.go @@ -28,6 +28,8 @@ package pkg //go:generate mockery --case snake --dir ../../pkg/task --name Manager --output ./task --outpkg task //go:generate mockery --case snake --dir ../../pkg/task --name ExecutionManager --output ./task --outpkg task //go:generate mockery --case snake --dir ../../pkg/user --name Manager --output ./user --outpkg user +//go:generate mockery --case snake --dir ../../pkg/user/dao --name DAO --output ./user/dao --outpkg dao +//go:generate mockery --case snake --dir ../../pkg/oidc/dao --name MetaDAO --output ./oidc/dao --outpkg oidc //go:generate mockery --case snake --dir ../../pkg/rbac --name Manager --output ./rbac --outpkg rbac //go:generate mockery --case snake --dir ../../pkg/rbac/dao --name DAO --output ./rbac/dao --outpkg dao //go:generate mockery --case snake --dir ../../pkg/robot --name Manager --output ./robot --outpkg robot diff --git a/src/testing/pkg/user/dao/dao.go b/src/testing/pkg/user/dao/dao.go new file mode 100644 index 000000000..3b05da982 --- /dev/null +++ b/src/testing/pkg/user/dao/dao.go @@ -0,0 +1,104 @@ +// Code generated by mockery v2.1.0. DO NOT EDIT. + +package dao + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + models "github.com/goharbor/harbor/src/common/models" + + q "github.com/goharbor/harbor/src/lib/q" +) + +// DAO is an autogenerated mock type for the DAO type +type DAO struct { + mock.Mock +} + +// Count provides a mock function with given fields: ctx, query +func (_m *DAO) Count(ctx context.Context, query *q.Query) (int64, error) { + ret := _m.Called(ctx, query) + + var r0 int64 + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) int64); ok { + r0 = rf(ctx, query) + } else { + r0 = ret.Get(0).(int64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *q.Query) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Create provides a mock function with given fields: ctx, user +func (_m *DAO) Create(ctx context.Context, user *models.User) (int, error) { + ret := _m.Called(ctx, user) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, *models.User) int); ok { + r0 = rf(ctx, user) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.User) error); ok { + r1 = rf(ctx, user) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// List provides a mock function with given fields: ctx, query +func (_m *DAO) List(ctx context.Context, query *q.Query) ([]*models.User, error) { + ret := _m.Called(ctx, query) + + var r0 []*models.User + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) []*models.User); ok { + r0 = rf(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.User) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *q.Query) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: ctx, user, props +func (_m *DAO) Update(ctx context.Context, user *models.User, props ...string) error { + _va := make([]interface{}, len(props)) + for _i := range props { + _va[_i] = props[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, user) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.User, ...string) error); ok { + r0 = rf(ctx, user, props...) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/src/testing/pkg/user/manager.go b/src/testing/pkg/user/manager.go index 8833b1e62..fa1a04006 100644 --- a/src/testing/pkg/user/manager.go +++ b/src/testing/pkg/user/manager.go @@ -18,6 +18,62 @@ type Manager struct { mock.Mock } +// Count provides a mock function with given fields: ctx, query +func (_m *Manager) Count(ctx context.Context, query *q.Query) (int64, error) { + ret := _m.Called(ctx, query) + + var r0 int64 + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) int64); ok { + r0 = rf(ctx, query) + } else { + r0 = ret.Get(0).(int64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *q.Query) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Create provides a mock function with given fields: ctx, _a1 +func (_m *Manager) Create(ctx context.Context, _a1 *models.User) (int, error) { + ret := _m.Called(ctx, _a1) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, *models.User) int); ok { + r0 = rf(ctx, _a1) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.User) error); ok { + r1 = rf(ctx, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: ctx, id +func (_m *Manager) Delete(ctx context.Context, id int) error { + ret := _m.Called(ctx, id) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Get provides a mock function with given fields: ctx, id func (_m *Manager) Get(ctx context.Context, id int) (*models.User, error) { ret := _m.Called(ctx, id) @@ -86,3 +142,66 @@ func (_m *Manager) List(ctx context.Context, query *q.Query) (usermodels.Users, return r0, r1 } + +// SetSysAdminFlag provides a mock function with given fields: ctx, id, admin +func (_m *Manager) SetSysAdminFlag(ctx context.Context, id int, admin bool) error { + ret := _m.Called(ctx, id, admin) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, bool) error); ok { + r0 = rf(ctx, id, admin) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdatePassword provides a mock function with given fields: ctx, id, newPassword +func (_m *Manager) UpdatePassword(ctx context.Context, id int, newPassword string) error { + ret := _m.Called(ctx, id, newPassword) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, string) error); ok { + r0 = rf(ctx, id, newPassword) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateProfile provides a mock function with given fields: ctx, _a1 +func (_m *Manager) UpdateProfile(ctx context.Context, _a1 *models.User) error { + ret := _m.Called(ctx, _a1) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.User) error); ok { + r0 = rf(ctx, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// VerifyLocalPassword provides a mock function with given fields: ctx, username, password +func (_m *Manager) VerifyLocalPassword(ctx context.Context, username string, password string) (bool, error) { + ret := _m.Called(ctx, username, password) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, string, string) bool); ok { + r0 = rf(ctx, username, password) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, username, password) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/tests/apitests/python/library/base.py b/tests/apitests/python/library/base.py index 492023855..fbab5481b 100644 --- a/tests/apitests/python/library/base.py +++ b/tests/apitests/python/library/base.py @@ -29,7 +29,9 @@ def get_endpoint(): def _create_client(server, credential, debug, api_type="products"): cfg = None - if api_type in ('projectv2', 'artifact', 'repository', 'scanner', 'scan', 'scanall', 'preheat', 'quota', 'replication', 'registry', 'robot', 'gc', 'retention', "immutable", "system_cve_allowlist", "configure"): + if api_type in ('projectv2', 'artifact', 'repository', 'scanner', 'scan', 'scanall', 'preheat', 'quota', + 'replication', 'registry', 'robot', 'gc', 'retention', "immutable", "system_cve_allowlist", + "configure", "users"): cfg = v2_swagger_client.Configuration() else: cfg = swagger_client.Configuration() @@ -70,6 +72,7 @@ def _create_client(server, credential, debug, api_type="products"): "immutable": v2_swagger_client.ImmutableApi(v2_swagger_client.ApiClient(cfg)), "system_cve_allowlist": v2_swagger_client.SystemCVEAllowlistApi(v2_swagger_client.ApiClient(cfg)), "configure": v2_swagger_client.ConfigureApi(v2_swagger_client.ApiClient(cfg)), + "users": v2_swagger_client.UsersApi(v2_swagger_client.ApiClient(cfg)), }.get(api_type,'Error: Wrong API type') def _assert_status_code(expect_code, return_code, err_msg = r"HTTPS status code s not as we expected. Expected {}, while actual HTTPS status code is {}."): @@ -121,8 +124,6 @@ def restart_process(process): raise Exception("Failed to start process {}.".format(full_process_name)) run_command_with_popen("ps aux |grep " + full_process_name) - - def run_command_with_popen(command): print("Command: ", command) diff --git a/tests/apitests/python/library/user.py b/tests/apitests/python/library/user.py index bbc065721..2611f6a5f 100644 --- a/tests/apitests/python/library/user.py +++ b/tests/apitests/python/library/user.py @@ -1,28 +1,28 @@ # -*- coding: utf-8 -*- import base -import swagger_client -from swagger_client.rest import ApiException +import v2_swagger_client +from v2_swagger_client.rest import ApiException -class User(base.Base): + +class User(base.Base, object): + + def __init__(self): + super(User, self).__init__(api_type = "users") def create_user(self, name=None, - email = None, user_password=None, realname = None, role_id = None, expect_status_code=201, **kwargs): + email=None, user_password=None, realname=None, expect_status_code=201, **kwargs): if name is None: name = base._random_name("user") if realname is None: realname = base._random_name("realname") if email is None: - email = '%s@%s.com' % (realname,"vmware") + email = '%s@%s.com' % (realname, "vmware") if user_password is None: user_password = "Harbor12345678" - if role_id is None: - role_id = 0 - - user = swagger_client.User(username = name, email = email, password = user_password, realname = realname, role_id = role_id) - + user_req = v2_swagger_client.UserCreationReq(username=name, email=email, password=user_password, realname=realname) try: - _, status_code, header = self._get_client(**kwargs).users_post_with_http_info(user) + _, status_code, header = self._get_client(**kwargs).create_user_with_http_info(user_req) except ApiException as e: base._assert_status_code(expect_status_code, e.status) else: @@ -30,17 +30,21 @@ class User(base.Base): return base._get_id_from_header(header), name def get_users(self, user_name=None, email=None, page=None, page_size=None, expect_status_code=200, **kwargs): - params={} + query = [] if user_name is not None: - params["username"] = user_name + query.append("username=" + user_name) if email is not None: - params["email"] = email + query.append("email=" + email) + + params = {} + if len(query) > 0: + params["q"] = ",".join(query) if page is not None: params["page"] = page if page_size is not None: params["page_size"] = page_size try: - data, status_code, _ = self._get_client(**kwargs).users_get_with_http_info(**params) + data, status_code, _ = self._get_client(**kwargs).list_users_with_http_info(**params) except ApiException as e: base._assert_status_code(expect_status_code, e.status) else: @@ -48,44 +52,43 @@ class User(base.Base): return data def get_user_by_id(self, user_id, **kwargs): - data, status_code, _ = self._get_client(**kwargs).users_user_id_get_with_http_info(user_id) + data, status_code, _ = self._get_client(**kwargs).get_user_with_http_info(user_id) base._assert_status_code(200, status_code) return data def get_user_by_name(self, name, expect_status_code=200, **kwargs): - users = self.get_users(user_name=name, expect_status_code=expect_status_code , **kwargs) + users = self.get_users(user_name=name, expect_status_code=expect_status_code, **kwargs) for user in users: if user.username == name: return user return None - def get_user_current(self, **kwargs): - data, status_code, _ = self._get_client(**kwargs).users_current_get_with_http_info() + data, status_code, _ = self._get_client(**kwargs).get_current_user_info_with_http_info() base._assert_status_code(200, status_code) return data - def delete_user(self, user_id, expect_status_code = 200, **kwargs): - _, status_code, _ = self._get_client(**kwargs).users_user_id_delete_with_http_info(user_id) + def delete_user(self, user_id, expect_status_code=200, **kwargs): + _, status_code, _ = self._get_client(**kwargs).delete_user_with_http_info(user_id) base._assert_status_code(expect_status_code, status_code) return user_id def update_user_pwd(self, user_id, new_password=None, old_password=None, **kwargs): if old_password is None: - old_password = "" - password = swagger_client.Password(old_password, new_password) - _, status_code, _ = self._get_client(**kwargs).users_user_id_password_put_with_http_info(user_id, password) + old_password = "" + password = v2_swagger_client.PasswordReq(old_password=old_password, new_password=new_password) + _, status_code, _ = self._get_client(**kwargs).update_user_password_with_http_info(user_id, password) base._assert_status_code(200, status_code) return user_id def update_user_profile(self, user_id, email=None, realname=None, comment=None, **kwargs): - user_rofile = swagger_client.UserProfile(email, realname, comment) - _, status_code, _ = self._get_client(**kwargs).users_user_id_put_with_http_info(user_id, user_rofile) + user_profile = v2_swagger_client.UserProfile(email=email, realname=realname, comment=comment) + _, status_code, _ = self._get_client(**kwargs).update_user_profile_with_http_info(user_id, user_profile) base._assert_status_code(200, status_code) return user_id def update_user_role_as_sysadmin(self, user_id, IsAdmin, **kwargs): - sysadmin_flag = swagger_client.SysAdminFlag(IsAdmin) - _, status_code, _ = self._get_client(**kwargs).users_user_id_sysadmin_put_with_http_info(user_id, sysadmin_flag) + sysadmin_flag = v2_swagger_client.UserSysAdminFlag(sysadmin_flag=IsAdmin) + _, status_code, _ = self._get_client(**kwargs).set_user_sys_admin_with_http_info(user_id, sysadmin_flag) base._assert_status_code(200, status_code) return user_id