diff --git a/api/v2.0/swagger.yaml b/api/v2.0/swagger.yaml index 585315189ca..d29f37c6d87 100644 --- a/api/v2.0/swagger.yaml +++ b/api/v2.0/swagger.yaml @@ -490,6 +490,34 @@ paths: $ref: '#/responses/404' '500': $ref: '#/responses/500' + '/projects/{project_name_or_id}/permissions/effective': + get: + summary: Get the current user's effective permissions for a project + description: Returns the subset of project-level permissions that the calling user actually holds. Used to restrict robot account permission selection to the user's own access level. + tags: + - project + operationId: getEffectivePermissions + parameters: + - $ref: '#/parameters/requestId' + - $ref: '#/parameters/isResourceName' + - $ref: '#/parameters/projectNameOrId' + responses: + '200': + description: The effective permissions of the current user in the project. + schema: + type: array + items: + $ref: '#/definitions/Permission' + '400': + $ref: '#/responses/400' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '500': + $ref: '#/responses/500' '/projects/{project_name_or_id}/members': get: summary: Get all project member information @@ -3216,6 +3244,79 @@ paths: $ref: '#/responses/404' '500': $ref: '#/responses/500' + /roles: + get: + summary: Get roles + description: List the roles with the specified level and permissions. + tags: + - role + operationId: ListRole + parameters: + - $ref: '#/parameters/requestId' + - $ref: '#/parameters/query' + - $ref: '#/parameters/sort' + - $ref: '#/parameters/page' + - $ref: '#/parameters/pageSize' + responses: + '200': + description: Success + headers: + X-Total-Count: + description: The total count of roles + type: integer + Link: + description: Link refers to the previous page and next page + type: string + schema: + type: array + items: + $ref: '#/definitions/Role' + '400': + $ref: '#/responses/400' + '404': + $ref: '#/responses/404' + '500': + $ref: '#/responses/500' + post: + summary: Create a role + description: Create a role + tags: + - role + operationId: CreateRole + parameters: + - $ref: '#/parameters/requestId' + - name: role + in: body + description: The JSON object of a role. + required: true + schema: + $ref: '#/definitions/RoleCreate' + responses: + '201': + description: Created + headers: + X-Request-Id: + description: The ID of the corresponding request for the response + type: string + Location: + description: The location of the resource + type: string + schema: + $ref: '#/definitions/RoleCreated' + '400': + $ref: '#/responses/400' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '500': + $ref: '#/responses/500' + + + + '/quotas': get: summary: List quotas @@ -3429,6 +3530,82 @@ paths: $ref: '#/responses/404' '500': $ref: '#/responses/500' + /roles/{role_id}: + get: + summary: Get a role + description: This endpoint returns specific role information by role ID. + tags: + - role + operationId: GetRoleByID + parameters: + - $ref: '#/parameters/requestId' + - $ref: '#/parameters/roleId' + responses: + '200': + description: Return matched role information. + schema: + $ref: '#/definitions/Role' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '500': + $ref: '#/responses/500' + put: + summary: Update a role account + description: This endpoint updates specific role information by role ID. + tags: + - role + operationId: UpdateRole + parameters: + - $ref: '#/parameters/requestId' + - $ref: '#/parameters/roleId' + - name: role + in: body + description: The JSON object of a role. + required: true + schema: + $ref: '#/definitions/Role' + responses: + '200': + $ref: '#/responses/200' + '400': + $ref: '#/responses/400' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '409': + $ref: '#/responses/409' + '500': + $ref: '#/responses/500' + delete: + summary: Delete a role account + description: This endpoint deletes specific role information by role ID. + tags: + - role + operationId: DeleteRole + parameters: + - $ref: '#/parameters/requestId' + - $ref: '#/parameters/roleId' + responses: + '200': + $ref: '#/responses/200' + '400': + $ref: '#/responses/400' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '500': + $ref: '#/responses/500' + /replication/policies: get: summary: List replication policies @@ -6352,6 +6529,12 @@ parameters: description: Robot ID required: true type: integer + roleId: + name: role_id + in: path + description: Role ID + required: true + type: integer gcId: name: gc_id in: path @@ -7960,6 +8143,101 @@ definitions: type: array items: $ref: '#/definitions/Access' + Role: + type: object + properties: + id: + type: integer + format: int64 + description: The ID of the role + name: + type: string + description: The name of the role + role_mask: + type: integer + description: The mask of the role, not used + role_code: + type: string + description: The code of the role, not used + permissions: + type: array + items: + $ref: '#/definitions/RolePermission' + is_builtin: + type: boolean + description: Whether this is a built-in role that cannot be modified or deleted + description: + type: string + description: Human-readable description of the role + modified: + type: boolean + description: Whether the role has been modified since creation + created_by: + type: string + description: Username of the admin who created the role + created_at: + type: string + format: date-time + description: When the role was created + modified_by: + type: string + description: Username of the admin who last modified the role + modified_at: + type: string + format: date-time + description: When the role was last modified + RoleCreate: + type: object + description: The request for role creation. + properties: + name: + type: string + description: The name of the role + role_mask: + type: integer + description: The mask of the role, not used + role_code: + type: string + description: The code of the role, not used + description: + type: string + description: Human-readable description of the role + permissions: + type: array + items: + $ref: '#/definitions/RolePermission' + RoleCreated: + type: object + description: The response for role creation. + properties: + id: + type: integer + format: int64 + description: The ID of the role + name: + type: string + description: The name of the role + secret: + type: string + description: The secret of the role + creation_time: + type: string + format: date-time + description: The creation time of the role. + RolePermission: + type: object + properties: + kind: + type: string + description: The kind of the permission + namespace: + type: string + description: The namespace of the permission + access: + type: array + items: + $ref: '#/definitions/Access' + Access: type: object properties: @@ -7972,6 +8250,7 @@ definitions: effect: type: string description: The effect of the access + RobotCreateV1: type: object properties: @@ -9687,6 +9966,12 @@ definitions: description: The project level permissions items: $ref: '#/definitions/Permission' + role: + type: array + description: The role project level permissions + items: + $ref: '#/definitions/Permission' + OIDCCliSecretReq: type: object properties: diff --git a/init_permissions.sql:Zone.Identifier b/init_permissions.sql:Zone.Identifier new file mode 100644 index 00000000000..d6c1ec68296 Binary files /dev/null and b/init_permissions.sql:Zone.Identifier differ diff --git a/make/harbor.yml copy.tmpl b/make/harbor.yml copy.tmpl new file mode 100644 index 00000000000..4be60f6b2e7 --- /dev/null +++ b/make/harbor.yml copy.tmpl @@ -0,0 +1,326 @@ +# Configuration file of Harbor + +# The IP address or hostname to access admin UI and registry service. +# DO NOT use localhost or 127.0.0.1, because Harbor needs to be accessed by external clients. +hostname: reg.mydomain.com + +# http related config +http: + # port for http, default is 80. If https enabled, this port will redirect to https port + port: 80 + +# https related config +https: + # https port for harbor, default is 443 + port: 443 + # The path of cert and key files for nginx + certificate: /your/certificate/path + private_key: /your/private/key/path + # enable strong ssl ciphers (default: false) + # strong_ssl_ciphers: false + +# # Harbor will set ipv4 enabled only by default if this block is not configured +# # Otherwise, please uncomment this block to configure your own ip_family stacks +# ip_family: +# # ipv6Enabled set to true if ipv6 is enabled in docker network, currently it affected the nginx related component +# ipv6: +# enabled: false +# # ipv4Enabled set to true by default, currently it affected the nginx related component +# ipv4: +# enabled: true + +# # Uncomment following will enable tls communication between all harbor components +# internal_tls: +# # set enabled to true means internal tls is enabled +# enabled: true +# # put your cert and key files on dir +# dir: /etc/harbor/tls/internal + + +# Uncomment external_url if you want to enable external proxy +# And when it enabled the hostname will no longer used +# external_url: https://reg.mydomain.com:8433 + +# The initial password of Harbor admin +# It only works in first time to install harbor +# Remember Change the admin password from UI after launching Harbor. +harbor_admin_password: Harbor12345 + +# Harbor DB configuration +database: + # The password for the user('postgres' by default) of Harbor DB. Change this before any production use. + password: root123 + # The maximum number of connections in the idle connection pool. If it <=0, no idle connections are retained. + max_idle_conns: 100 + # The maximum number of open connections to the database. If it <= 0, then there is no limit on the number of open connections. + # Note: the default number of connections is 1024 for postgres of harbor. + max_open_conns: 900 + # The maximum amount of time a connection may be reused. Expired connections may be closed lazily before reuse. If it <= 0, connections are not closed due to a connection's age. + # The value is a duration string. A duration string is a possibly signed sequence of decimal numbers, each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". + conn_max_lifetime: 5m + # The maximum amount of time a connection may be idle. Expired connections may be closed lazily before reuse. If it <= 0, connections are not closed due to a connection's idle time. + # The value is a duration string. A duration string is a possibly signed sequence of decimal numbers, each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". + conn_max_idle_time: 0 + +# The default data volume +data_volume: /data + +# Harbor Storage settings by default is using /data dir on local filesystem +# Uncomment storage_service setting If you want to using external storage +# storage_service: +# # ca_bundle is the path to the custom root ca certificate, which will be injected into the truststore +# # of registry's containers. This is usually needed when the user hosts a internal storage with self signed certificate. +# ca_bundle: + +# # storage backend, default is filesystem, options include filesystem, azure, gcs, s3, swift and oss +# # for more info about this configuration please refer https://distribution.github.io/distribution/about/configuration/ +# # and https://distribution.github.io/distribution/storage-drivers/ +# filesystem: +# maxthreads: 100 +# # set disable to true when you want to disable registry redirect +# redirect: +# disable: false + +# Trivy configuration +# +# Trivy DB contains vulnerability information from NVD, Red Hat, and many other upstream vulnerability databases. +# It is downloaded by Trivy from the GitHub release page https://github.com/aquasecurity/trivy-db/releases and cached +# in the local file system. In addition, the database contains the update timestamp so Trivy can detect whether it +# should download a newer version from the Internet or use the cached one. Currently, the database is updated every +# 12 hours and published as a new release to GitHub. +trivy: + # ignoreUnfixed The flag to display only fixed vulnerabilities + ignore_unfixed: false + # skipUpdate The flag to enable or disable Trivy DB downloads from GitHub + # + # You might want to enable this flag in test or CI/CD environments to avoid GitHub rate limiting issues. + # If the flag is enabled you have to download the `trivy-offline.tar.gz` archive manually, extract `trivy.db` and + # `metadata.json` files and mount them in the `/home/scanner/.cache/trivy/db` path. + skip_update: false + # + # skipJavaDBUpdate If the flag is enabled you have to manually download the `trivy-java.db` file and mount it in the + # `/home/scanner/.cache/trivy/java-db/trivy-java.db` path + skip_java_db_update: false + # + # The offline_scan option prevents Trivy from sending API requests to identify dependencies. + # Scanning JAR files and pom.xml may require Internet access for better detection, but this option tries to avoid it. + # For example, the offline mode will not try to resolve transitive dependencies in pom.xml when the dependency doesn't + # exist in the local repositories. It means a number of detected vulnerabilities might be fewer in offline mode. + # It would work if all the dependencies are in local. + # This option doesn't affect DB download. You need to specify "skip-update" as well as "offline-scan" in an air-gapped environment. + offline_scan: false + # + # Comma-separated list of what security issues to detect. Possible values are `vuln`, `config` and `secret`. Defaults to `vuln`. + security_check: vuln + # + # insecure The flag to skip verifying registry certificate + insecure: false + # + # timeout The duration to wait for scan completion. + # There is upper bound of 30 minutes defined in scan job. So if this `timeout` is larger than 30m0s, it will also timeout at 30m0s. + timeout: 5m0s + # + # github_token The GitHub access token to download Trivy DB + # + # Anonymous downloads from GitHub are subject to the limit of 60 requests per hour. Normally such rate limit is enough + # for production operations. If, for any reason, it's not enough, you could increase the rate limit to 5000 + # requests per hour by specifying the GitHub access token. For more details on GitHub rate limiting please consult + # https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting + # + # You can create a GitHub token by following the instructions in + # https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line + # + # github_token: xxx + +jobservice: + # Maximum number of job workers in job service + max_job_workers: 10 + # Maximum hours of task duration in job service, default 24 + max_job_duration_hours: 24 + # The jobLoggers backend name, only support "STD_OUTPUT", "FILE" and/or "DB" + job_loggers: + - STD_OUTPUT + - FILE + # - DB + # The jobLogger sweeper duration (ignored if `jobLogger` is `stdout`) + logger_sweeper_duration: 1 #days + +notification: + # Maximum retry count for webhook job + webhook_job_max_retry: 3 + # HTTP client timeout for webhook job + webhook_job_http_client_timeout: 3 #seconds + +# Log configurations +log: + # options are debug, info, warning, error, fatal + level: info + # configs for logs in local storage + local: + # Log files are rotated log_rotate_count times before being removed. If count is 0, old versions are removed rather than rotated. + rotate_count: 50 + # Log files are rotated only if they grow bigger than log_rotate_size bytes. If size is followed by k, the size is assumed to be in kilobytes. + # If the M is used, the size is in megabytes, and if G is used, the size is in gigabytes. So size 100, size 100k, size 100M and size 100G + # are all valid. + rotate_size: 200M + # The directory on your host that store log + location: /var/log/harbor + + # Uncomment following lines to enable external syslog endpoint. + # external_endpoint: + # # protocol used to transmit log to external endpoint, options is tcp or udp + # protocol: tcp + # # The host of external endpoint + # host: localhost + # # Port of external endpoint + # port: 5140 + +#This attribute is for migrator to detect the version of the .cfg file, DO NOT MODIFY! +_version: 2.14.0 + +# Uncomment external_database if using external database. +# external_database: +# harbor: +# host: harbor_db_host +# port: harbor_db_port +# db_name: harbor_db_name +# username: harbor_db_username +# password: harbor_db_password +# ssl_mode: disable +# max_idle_conns: 2 +# max_open_conns: 0 + +# Uncomment redis if need to customize redis db +# redis: +# # db_index 0 is for core, it's unchangeable +# # registry_db_index: 1 +# # jobservice_db_index: 2 +# # trivy_db_index: 5 +# # it's optional, the db for harbor business misc, by default is 0, uncomment it if you want to change it. +# # harbor_db_index: 6 +# # it's optional, the db for harbor cache layer, by default is 0, uncomment it if you want to change it. +# # cache_layer_db_index: 7 + +# Uncomment external_redis if using external Redis server +# external_redis: +# # support redis, redis+sentinel +# # host for redis: : +# # host for redis+sentinel: +# # :,:,: +# host: redis:6379 +# password: +# # Redis AUTH command was extended in Redis 6, it is possible to use it in the two-arguments AUTH form. +# # there's a known issue when using external redis username ref:https://github.com/goharbor/harbor/issues/18892 +# # if you care about the image pull/push performance, please refer to this https://github.com/goharbor/harbor/wiki/Harbor-FAQs#external-redis-username-password-usage +# # username: +# # sentinel_master_set must be set to support redis+sentinel +# #sentinel_master_set: +# # tls configuration for redis connection +# # only server-authentication is supported +# # mtls for redis connection is not supported +# # tls connection will be disable by default +# tlsOptions: +# enable: false +# # if it is a self-signed ca, please set the ca path specifically. +# rootCA: +# # db_index 0 is for core, it's unchangeable +# registry_db_index: 1 +# jobservice_db_index: 2 +# trivy_db_index: 5 +# idle_timeout_seconds: 30 +# # it's optional, the db for harbor business misc, by default is 0, uncomment it if you want to change it. +# # harbor_db_index: 6 +# # it's optional, the db for harbor cache layer, by default is 0, uncomment it if you want to change it. +# # cache_layer_db_index: 7 + +# Uncomment uaa for trusting the certificate of uaa instance that is hosted via self-signed cert. +# uaa: +# ca_file: /path/to/ca + +# Global proxy +# Config http proxy for components, e.g. http://my.proxy.com:3128 +# Components doesn't need to connect to each others via http proxy. +# Remove component from `components` array if want disable proxy +# for it. If you want use proxy for replication, MUST enable proxy +# for core and jobservice, and set `http_proxy` and `https_proxy`. +# Add domain to the `no_proxy` field, when you want disable proxy +# for some special registry. +proxy: + http_proxy: + https_proxy: + no_proxy: + components: + - core + - jobservice + - trivy + +# metric: +# enabled: false +# port: 9090 +# path: /metrics + +# Trace related config +# only can enable one trace provider(jaeger or otel) at the same time, +# and when using jaeger as provider, can only enable it with agent mode or collector mode. +# if using jaeger collector mode, uncomment endpoint and uncomment username, password if needed +# if using jaeger agetn mode uncomment agent_host and agent_port +# trace: +# enabled: true +# # set sample_rate to 1 if you wanna sampling 100% of trace data; set 0.5 if you wanna sampling 50% of trace data, and so forth +# sample_rate: 1 +# # # namespace used to differentiate different harbor services +# # namespace: +# # # attributes is a key value dict contains user defined attributes used to initialize trace provider +# # attributes: +# # application: harbor +# # # jaeger should be 1.26 or newer. +# # jaeger: +# # endpoint: http://hostname:14268/api/traces +# # username: +# # password: +# # agent_host: hostname +# # # export trace data by jaeger.thrift in compact mode +# # agent_port: 6831 +# # otel: +# # endpoint: hostname:4318 +# # url_path: /v1/traces +# # compression: false +# # insecure: true +# # # timeout is in seconds +# # timeout: 10 + +# Enable purge _upload directories +upload_purging: + enabled: true + # remove files in _upload directories which exist for a period of time, default is one week. + age: 168h + # the interval of the purge operations + interval: 24h + dryrun: false + +# Cache layer configurations +# If this feature enabled, harbor will cache the resource +# `project/project_metadata/repository/artifact/manifest` in the redis +# which can especially help to improve the performance of high concurrent +# manifest pulling. +# NOTICE +# If you are deploying Harbor in HA mode, make sure that all the harbor +# instances have the same behaviour, all with caching enabled or disabled, +# otherwise it can lead to potential data inconsistency. +cache: + # not enabled by default + enabled: false + # keep cache for one day by default + expire_hours: 24 + +# Harbor core configurations +# Uncomment to enable the following harbor core related configuration items. +# core: +# # The provider for updating project quota(usage), there are 2 options, redis or db, +# # by default is implemented by db but you can switch the updation via redis which +# # can improve the performance of high concurrent pushing to the same project, +# # and reduce the database connections spike and occupies. +# # By redis will bring up some delay for quota usage updation for display, so only +# # suggest switch provider to redis if you were ran into the db connections spike around +# # the scenario of high concurrent pushing to same project, no improvement for other scenes. +# quota_update_provider: redis # Or db diff --git a/make/harbor.yml.tmpl b/make/harbor.yml.tmpl index a22aa81600d..7b091b8cf23 100644 --- a/make/harbor.yml.tmpl +++ b/make/harbor.yml.tmpl @@ -2,7 +2,7 @@ # The IP address or hostname to access admin UI and registry service. # DO NOT use localhost or 127.0.0.1, because Harbor needs to be accessed by external clients. -hostname: reg.mydomain.com +hostname: localhost # http related config http: diff --git a/make/migrations/postgresql/0190_2.16.0_schema.up.sql b/make/migrations/postgresql/0190_2.16.0_schema.up.sql index b62d5784a4b..4fb48e087cf 100644 --- a/make/migrations/postgresql/0190_2.16.0_schema.up.sql +++ b/make/migrations/postgresql/0190_2.16.0_schema.up.sql @@ -1 +1,382 @@ ALTER TABLE artifact_accessory ADD COLUMN IF NOT EXISTS source varchar(50) DEFAULT 'local'; + +ALTER TABLE role ADD COLUMN IF NOT EXISTS is_builtin BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE role ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE role ADD COLUMN IF NOT EXISTS modified BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE role ADD COLUMN IF NOT EXISTS created_by VARCHAR(255); +ALTER TABLE role ADD COLUMN IF NOT EXISTS created_at TIMESTAMP WITH TIME ZONE; +ALTER TABLE role ADD COLUMN IF NOT EXISTS modified_by VARCHAR(255); +ALTER TABLE role ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP WITH TIME ZONE; + +-- Mark all roles seeded by migrations as built-in (immutable) +UPDATE role SET is_builtin = TRUE +WHERE name IN ('projectAdmin', 'developer', 'guest', 'maintainer', 'limitedGuest'); + +-- Seed permissions for built-in roles into permission_policy + role_permission. +-- These were previously hardcoded in Go (rolePoliciesMap); storing them in DB lets +-- the anti-escalation check compare any role's permissions against the caller's. +DO $$ +DECLARE + ResourceSelf VARCHAR(50) := ''; + ResourceMember VARCHAR(50) := 'member'; + ResourceMetadata VARCHAR(50) := 'metadata'; + ResourceLog VARCHAR(50) := 'log'; + ResourceLabel VARCHAR(50) := 'label'; + ResourceQuota VARCHAR(50) := 'quota'; + ResourceRepository VARCHAR(50) := 'repository'; + ResourceTagRetention VARCHAR(50) := 'tag-retention'; + ResourceImmutableTag VARCHAR(50) := 'immutable-tag'; + ResourceConfiguration VARCHAR(50) := 'configuration'; + ResourceRobot VARCHAR(50) := 'robot'; + ResourceNotificationPolicy VARCHAR(50) := 'notification-policy'; + ResourceScan VARCHAR(50) := 'scan'; + ResourceSBOM VARCHAR(50) := 'sbom'; + ResourceScanner VARCHAR(50) := 'scanner'; + ResourceArtifact VARCHAR(50) := 'artifact'; + ResourceArtifactAddition VARCHAR(50) := 'artifact-addition'; + ResourceTag VARCHAR(50) := 'tag'; + ResourceAccessory VARCHAR(50) := 'accessory'; + ResourceArtifactLabel VARCHAR(50) := 'artifact-label'; + ResourcePreatPolicy VARCHAR(50) := 'preheat-policy'; + ResourceExportCVE VARCHAR(50) := 'export-cve'; + + ActionCreate VARCHAR(50) := 'create'; + ActionRead VARCHAR(50) := 'read'; + ActionUpdate VARCHAR(50) := 'update'; + ActionDelete VARCHAR(50) := 'delete'; + ActionList VARCHAR(50) := 'list'; + ActionPull VARCHAR(50) := 'pull'; + ActionPush VARCHAR(50) := 'push'; + ActionOperate VARCHAR(50) := 'operate'; + ActionStop VARCHAR(50) := 'stop'; + + PROJECT_ADMIN VARCHAR(50) := 'projectAdmin'; + MAINTAINER VARCHAR(50) := 'maintainer'; + DEVELOPER VARCHAR(50) := 'developer'; + GUEST VARCHAR(50) := 'guest'; + LIMITED_GUEST VARCHAR(50) := 'limitedGuest'; + + +BEGIN + + -- Temporary table + + CREATE TEMPORARY TABLE "#perms" (resource VARCHAR(50), action VARCHAR(50), role_name VARCHAR(50)); + INSERT INTO "#perms" (resource, action, role_name) VALUES + (ResourceSelf, ActionRead, PROJECT_ADMIN), + (ResourceSelf, ActionUpdate, PROJECT_ADMIN), + (ResourceSelf, ActionDelete, PROJECT_ADMIN), + + (ResourceMember, ActionCreate, PROJECT_ADMIN), + (ResourceMember, ActionRead, PROJECT_ADMIN), + (ResourceMember, ActionUpdate, PROJECT_ADMIN), + (ResourceMember, ActionDelete, PROJECT_ADMIN), + (ResourceMember, ActionList, PROJECT_ADMIN), + + (ResourceMetadata, ActionCreate, PROJECT_ADMIN), + (ResourceMetadata, ActionRead, PROJECT_ADMIN), + (ResourceMetadata, ActionUpdate, PROJECT_ADMIN), + (ResourceMetadata, ActionDelete, PROJECT_ADMIN), + + (ResourceLog, ActionList, PROJECT_ADMIN), + + (ResourceLabel, ActionCreate, PROJECT_ADMIN), + (ResourceLabel, ActionRead, PROJECT_ADMIN), + (ResourceLabel, ActionUpdate, PROJECT_ADMIN), + (ResourceLabel, ActionDelete, PROJECT_ADMIN), + (ResourceLabel, ActionList, PROJECT_ADMIN), + + (ResourceQuota, ActionRead, PROJECT_ADMIN), + + (ResourceRepository, ActionCreate, PROJECT_ADMIN), + (ResourceRepository, ActionRead, PROJECT_ADMIN), + (ResourceRepository, ActionUpdate, PROJECT_ADMIN), + (ResourceRepository, ActionDelete, PROJECT_ADMIN), + (ResourceRepository, ActionList, PROJECT_ADMIN), + (ResourceRepository, ActionPull, PROJECT_ADMIN), + (ResourceRepository, ActionPush, PROJECT_ADMIN), + + (ResourceTagRetention, ActionCreate, PROJECT_ADMIN), + (ResourceTagRetention, ActionRead, PROJECT_ADMIN), + (ResourceTagRetention, ActionUpdate, PROJECT_ADMIN), + (ResourceTagRetention, ActionDelete, PROJECT_ADMIN), + (ResourceTagRetention, ActionList, PROJECT_ADMIN), + (ResourceTagRetention, ActionOperate, PROJECT_ADMIN), + + (ResourceImmutableTag, ActionCreate, PROJECT_ADMIN), + (ResourceImmutableTag, ActionUpdate, PROJECT_ADMIN), + (ResourceImmutableTag, ActionDelete, PROJECT_ADMIN), + (ResourceImmutableTag, ActionList, PROJECT_ADMIN), + + (ResourceConfiguration, ActionRead, PROJECT_ADMIN), + (ResourceConfiguration, ActionUpdate, PROJECT_ADMIN), + + (ResourceRobot, ActionCreate, PROJECT_ADMIN), + (ResourceRobot, ActionRead, PROJECT_ADMIN), + (ResourceRobot, ActionUpdate, PROJECT_ADMIN), + (ResourceRobot, ActionDelete, PROJECT_ADMIN), + (ResourceRobot, ActionList, PROJECT_ADMIN), + + (ResourceNotificationPolicy, ActionCreate, PROJECT_ADMIN), + (ResourceNotificationPolicy, ActionUpdate, PROJECT_ADMIN), + (ResourceNotificationPolicy, ActionDelete, PROJECT_ADMIN), + (ResourceNotificationPolicy, ActionList, PROJECT_ADMIN), + (ResourceNotificationPolicy, ActionRead, PROJECT_ADMIN), + + (ResourceScan, ActionCreate, PROJECT_ADMIN), + (ResourceScan, ActionRead, PROJECT_ADMIN), + (ResourceScan, ActionStop, PROJECT_ADMIN), + (ResourceSBOM, ActionCreate, PROJECT_ADMIN), + (ResourceSBOM, ActionStop, PROJECT_ADMIN), + (ResourceSBOM, ActionRead, PROJECT_ADMIN), + + (ResourceScanner, ActionRead, PROJECT_ADMIN), + (ResourceScanner, ActionCreate, PROJECT_ADMIN), + + (ResourceArtifact, ActionCreate, PROJECT_ADMIN), + (ResourceArtifact, ActionRead, PROJECT_ADMIN), + (ResourceArtifact, ActionDelete, PROJECT_ADMIN), + (ResourceArtifact, ActionList, PROJECT_ADMIN), + (ResourceArtifactAddition, ActionRead, PROJECT_ADMIN), + + (ResourceTag, ActionList, PROJECT_ADMIN), + (ResourceTag, ActionCreate, PROJECT_ADMIN), + (ResourceTag, ActionDelete, PROJECT_ADMIN), + + (ResourceAccessory, ActionList, PROJECT_ADMIN), + + (ResourceArtifactLabel, ActionCreate, PROJECT_ADMIN), + (ResourceArtifactLabel, ActionDelete, PROJECT_ADMIN), + + (ResourcePreatPolicy, ActionCreate, PROJECT_ADMIN), + (ResourcePreatPolicy, ActionRead, PROJECT_ADMIN), + (ResourcePreatPolicy, ActionUpdate, PROJECT_ADMIN), + (ResourcePreatPolicy, ActionDelete, PROJECT_ADMIN), + (ResourcePreatPolicy, ActionList, PROJECT_ADMIN), + + (ResourceExportCVE, ActionCreate, PROJECT_ADMIN), + (ResourceExportCVE, ActionRead, PROJECT_ADMIN), + (ResourceExportCVE, ActionList, PROJECT_ADMIN), + + (ResourceSelf, ActionRead, MAINTAINER), + + (ResourceMember, ActionRead, MAINTAINER), + (ResourceMember, ActionList, MAINTAINER), + + (ResourceMetadata, ActionRead, MAINTAINER), + + (ResourceQuota, ActionRead, MAINTAINER), + + (ResourceLabel, ActionCreate, MAINTAINER), + (ResourceLabel, ActionRead, MAINTAINER), + (ResourceLabel, ActionUpdate, MAINTAINER), + (ResourceLabel, ActionDelete, MAINTAINER), + (ResourceLabel, ActionList, MAINTAINER), + + (ResourceRepository, ActionCreate, MAINTAINER), + (ResourceRepository, ActionRead, MAINTAINER), + (ResourceRepository, ActionUpdate, MAINTAINER), + (ResourceRepository, ActionDelete, MAINTAINER), + (ResourceRepository, ActionList, MAINTAINER), + (ResourceRepository, ActionPush, MAINTAINER), + (ResourceRepository, ActionPull, MAINTAINER), + + (ResourceTagRetention, ActionCreate, MAINTAINER), + (ResourceTagRetention, ActionRead, MAINTAINER), + (ResourceTagRetention, ActionUpdate, MAINTAINER), + (ResourceTagRetention, ActionDelete, MAINTAINER), + (ResourceTagRetention, ActionList, MAINTAINER), + (ResourceTagRetention, ActionOperate, MAINTAINER), + + (ResourceAccessory, ActionList, MAINTAINER), + + (ResourceImmutableTag, ActionCreate, MAINTAINER), + (ResourceImmutableTag, ActionUpdate, MAINTAINER), + (ResourceImmutableTag, ActionDelete, MAINTAINER), + (ResourceImmutableTag, ActionList, MAINTAINER), + + (ResourceConfiguration, ActionRead, MAINTAINER), + + (ResourceRobot, ActionRead, MAINTAINER), + (ResourceRobot, ActionList, MAINTAINER), + + (ResourceNotificationPolicy, ActionRead, MAINTAINER), + (ResourceNotificationPolicy, ActionList, MAINTAINER), + + (ResourceScan, ActionCreate, MAINTAINER), + (ResourceScan, ActionRead, MAINTAINER), + (ResourceScan, ActionStop, MAINTAINER), + (ResourceSBOM, ActionCreate, MAINTAINER), + (ResourceSBOM, ActionStop, MAINTAINER), + (ResourceSBOM, ActionRead, MAINTAINER), + + (ResourceScanner, ActionRead, MAINTAINER), + + (ResourceArtifact, ActionCreate, MAINTAINER), + (ResourceArtifact, ActionRead, MAINTAINER), + (ResourceArtifact, ActionDelete, MAINTAINER), + (ResourceArtifact, ActionList, MAINTAINER), + (ResourceArtifactAddition, ActionRead, MAINTAINER), + + (ResourceTag, ActionList, MAINTAINER), + (ResourceTag, ActionCreate, MAINTAINER), + (ResourceTag, ActionDelete, MAINTAINER), + + (ResourceArtifactLabel, ActionCreate, MAINTAINER), + (ResourceArtifactLabel, ActionDelete, MAINTAINER), + + (ResourceExportCVE, ActionCreate, MAINTAINER), + (ResourceExportCVE, ActionRead, MAINTAINER), + (ResourceExportCVE, ActionList, MAINTAINER), + + (ResourceSelf, ActionRead, DEVELOPER), + + (ResourceMember, ActionRead, DEVELOPER), + (ResourceMember, ActionList, DEVELOPER), + + (ResourceLabel, ActionRead, DEVELOPER), + (ResourceLabel, ActionList, DEVELOPER), + + (ResourceQuota, ActionRead, DEVELOPER), + + (ResourceRepository, ActionCreate, DEVELOPER), + (ResourceRepository, ActionRead, DEVELOPER), + (ResourceRepository, ActionUpdate, DEVELOPER), + (ResourceRepository, ActionList, DEVELOPER), + (ResourceRepository, ActionPush, DEVELOPER), + (ResourceRepository, ActionPull, DEVELOPER), + + (ResourceTagRetention, ActionCreate, DEVELOPER), + (ResourceTagRetention, ActionRead, DEVELOPER), + (ResourceTagRetention, ActionUpdate, DEVELOPER), + (ResourceTagRetention, ActionDelete, DEVELOPER), + (ResourceTagRetention, ActionList, DEVELOPER), + (ResourceTagRetention, ActionOperate, DEVELOPER), + + (ResourceConfiguration, ActionRead, DEVELOPER), + + (ResourceRobot, ActionRead, DEVELOPER), + (ResourceRobot, ActionList, DEVELOPER), + + (ResourceScan, ActionRead, DEVELOPER), + (ResourceSBOM, ActionRead, DEVELOPER), + + (ResourceScanner, ActionRead, DEVELOPER), + + (ResourceArtifact, ActionCreate, DEVELOPER), + (ResourceArtifact, ActionRead, DEVELOPER), + (ResourceArtifact, ActionList, DEVELOPER), + (ResourceArtifactAddition, ActionRead, DEVELOPER), + + (ResourceTag, ActionList, DEVELOPER), + (ResourceTag, ActionCreate, DEVELOPER), + + (ResourceAccessory, ActionList, DEVELOPER), + + (ResourceArtifactLabel, ActionCreate, DEVELOPER), + (ResourceArtifactLabel, ActionDelete, DEVELOPER), + + (ResourceExportCVE, ActionCreate, DEVELOPER), + (ResourceExportCVE, ActionRead, DEVELOPER), + (ResourceExportCVE, ActionList, DEVELOPER), + + (ResourceSelf, ActionRead, GUEST), + + (ResourceMember, ActionRead, GUEST), + (ResourceMember, ActionList, GUEST), + + (ResourceLabel, ActionRead, GUEST), + (ResourceLabel, ActionList, GUEST), + + (ResourceQuota, ActionRead, GUEST), + + (ResourceRepository, ActionRead, GUEST), + (ResourceRepository, ActionList, GUEST), + (ResourceRepository, ActionPull, GUEST), + + (ResourceConfiguration, ActionRead, GUEST), + + (ResourceRobot, ActionRead, GUEST), + (ResourceRobot, ActionList, GUEST), + + (ResourceScan, ActionRead, GUEST), + (ResourceSBOM, ActionRead, GUEST), + + (ResourceScanner, ActionRead, GUEST), + + (ResourceTag, ActionList, GUEST), + (ResourceAccessory, ActionList, GUEST), + + (ResourceArtifact, ActionRead, GUEST), + (ResourceArtifact, ActionList, GUEST), + (ResourceArtifactAddition, ActionRead, GUEST), + + (ResourceSelf, ActionRead, LIMITED_GUEST), + + (ResourceQuota, ActionRead, LIMITED_GUEST), + + (ResourceRepository, ActionList, LIMITED_GUEST), + (ResourceRepository, ActionRead, LIMITED_GUEST), + (ResourceRepository, ActionPull, LIMITED_GUEST), + + (ResourceConfiguration, ActionRead, LIMITED_GUEST), + + (ResourceScan, ActionRead, LIMITED_GUEST), + (ResourceSBOM, ActionRead, LIMITED_GUEST), + + (ResourceScanner, ActionRead, LIMITED_GUEST), + + (ResourceTag, ActionList, LIMITED_GUEST), + (ResourceAccessory, ActionList, LIMITED_GUEST), + + (ResourceArtifact, ActionRead, LIMITED_GUEST), + (ResourceArtifact, ActionList, LIMITED_GUEST), + (ResourceArtifactAddition, ActionRead, LIMITED_GUEST); + + + DECLARE + Cur_1 CURSOR FOR SELECT resource, action, role_name FROM "#perms"; + tmp_role_id INT; + tmp_policy_id INT; + tmp_permission_id INT; + + BEGIN + FOR r IN Cur_1 LOOP + + SELECT role_id into tmp_role_id FROM role WHERE name = r.role_name; + + IF EXISTS( + SELECT * FROM permission_policy + WHERE scope = 'project-role' and resource = r.resource and action = r.action) THEN + + BEGIN + SELECT id into tmp_policy_id FROM permission_policy + WHERE scope = 'project-role' and resource = r.resource and action = r.action; + END; + + ELSE + BEGIN + INSERT INTO permission_policy ( scope, resource, action, creation_time ) VALUES + ('project-role', r.resource, r.action, NOW()) RETURNING id INTO tmp_policy_id; + END; + + END IF; + + + IF NOT EXISTS( + SELECT * FROM role_permission + WHERE role_type = 'project-role' and role_id = tmp_role_id and permission_policy_id = tmp_policy_id) THEN + + BEGIN + INSERT INTO role_permission (role_type, role_id, permission_policy_id, creation_time) VALUES + ('project-role', tmp_role_id, tmp_policy_id, NOW()) RETURNING id INTO tmp_permission_id; + END; + + END IF; + + END LOOP; + + + END; + +END $$; diff --git a/make/photon/registry/config.yml b/make/photon/registry/config.yml new file mode 100644 index 00000000000..c760cd567f7 --- /dev/null +++ b/make/photon/registry/config.yml @@ -0,0 +1,22 @@ +version: 0.1 +log: + fields: + service: registry +storage: + cache: + blobdescriptor: inmemory + filesystem: + rootdirectory: /var/lib/registry +http: + addr: :5000 + headers: + X-Content-Type-Options: [nosniff] +auth: + htpasswd: + realm: basic-realm + path: /etc/registry +health: + storagedriver: + enabled: true + interval: 10s + threshold: 3 diff --git a/make/prepare b/make/prepare index 8742c150e97..7380c330578 100755 --- a/make/prepare +++ b/make/prepare @@ -1,5 +1,6 @@ #!/bin/bash set -e +set -x # If compiling source code this dir is harbor's make dir. # If installing harbor via package, this dir is harbor's root dir. diff --git a/src/common/const.go b/src/common/const.go index 78bc8cb819b..39db128ce80 100644 --- a/src/common/const.go +++ b/src/common/const.go @@ -143,6 +143,8 @@ const ( RobotPrefix = "robot$" // System admin defined the robot name prefix. RobotNamePrefix = "robot_name_prefix" + // System admin defined the role name prefix. + RoleNamePrefix = "role_name_prefix" // Scanner robot name prefix RobotScannerNamePrefix = "robot_scanner_name_prefix" // Use this prefix to index user who tries to login with web hook token. diff --git a/src/common/models/base.go b/src/common/models/base.go index 94a8f57ac80..a617090422c 100644 --- a/src/common/models/base.go +++ b/src/common/models/base.go @@ -20,7 +20,7 @@ import ( func init() { orm.RegisterModel( - new(Role), + // new(Role), new(OIDCUser), ) } diff --git a/src/common/models/role.go b/src/common/models/role.go deleted file mode 100644 index 1452d390a50..00000000000 --- a/src/common/models/role.go +++ /dev/null @@ -1,23 +0,0 @@ -// 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 models - -// Role holds the details of a role. -type Role struct { - RoleID int `orm:"pk;auto;column(role_id)" json:"role_id"` - RoleCode string `orm:"column(role_code)" json:"role_code"` - Name string `orm:"column(name)" json:"role_name"` - RoleMask int `orm:"column(role_mask)" json:"role_mask"` -} diff --git a/src/common/rbac/const.go b/src/common/rbac/const.go index f23189db970..c71fa69ebbd 100644 --- a/src/common/rbac/const.go +++ b/src/common/rbac/const.go @@ -88,6 +88,7 @@ type scope string const ( ScopeSystem = scope("System") ScopeProject = scope("Project") + ScopeRole = scope("Role") ) // RobotPermissionProvider defines the permission provider for robot account @@ -157,6 +158,10 @@ func (n *NolimitProvider) GetPermissions(s scope) []*types.Policy { &types.Policy{Resource: ResourceMember, Action: ActionList}, &types.Policy{Resource: ResourceMember, Action: ActionDelete}) } + if s == ScopeRole { + return append(n.BaseProvider.GetPermissions(ScopeRole)) + } + return []*types.Policy{} } @@ -311,5 +316,102 @@ var ( {Resource: ResourceQuota, Action: ActionRead}, }, + ScopeRole: { + {Resource: ResourceSelf, Action: ActionRead}, + {Resource: ResourceSelf, Action: ActionUpdate}, + {Resource: ResourceSelf, Action: ActionDelete}, + + {Resource: ResourceMember, Action: ActionCreate}, + {Resource: ResourceMember, Action: ActionRead}, + {Resource: ResourceMember, Action: ActionUpdate}, + {Resource: ResourceMember, Action: ActionDelete}, + {Resource: ResourceMember, Action: ActionList}, + + {Resource: ResourceMetadata, Action: ActionCreate}, + {Resource: ResourceMetadata, Action: ActionRead}, + {Resource: ResourceMetadata, Action: ActionUpdate}, + {Resource: ResourceMetadata, Action: ActionDelete}, + {Resource: ResourceMetadata, Action: ActionList}, + + {Resource: ResourceLog, Action: ActionList}, + + {Resource: ResourceLabel, Action: ActionCreate}, + {Resource: ResourceLabel, Action: ActionRead}, + {Resource: ResourceLabel, Action: ActionUpdate}, + {Resource: ResourceLabel, Action: ActionDelete}, + {Resource: ResourceLabel, Action: ActionList}, + + {Resource: ResourceQuota, Action: ActionRead}, + + {Resource: ResourceRepository, Action: ActionCreate}, + {Resource: ResourceRepository, Action: ActionRead}, + {Resource: ResourceRepository, Action: ActionUpdate}, + {Resource: ResourceRepository, Action: ActionDelete}, + {Resource: ResourceRepository, Action: ActionList}, + {Resource: ResourceRepository, Action: ActionPull}, + {Resource: ResourceRepository, Action: ActionPush}, + + {Resource: ResourceTagRetention, Action: ActionCreate}, + {Resource: ResourceTagRetention, Action: ActionRead}, + {Resource: ResourceTagRetention, Action: ActionUpdate}, + {Resource: ResourceTagRetention, Action: ActionDelete}, + {Resource: ResourceTagRetention, Action: ActionList}, + {Resource: ResourceTagRetention, Action: ActionOperate}, + + {Resource: ResourceImmutableTag, Action: ActionCreate}, + {Resource: ResourceImmutableTag, Action: ActionUpdate}, + {Resource: ResourceImmutableTag, Action: ActionDelete}, + {Resource: ResourceImmutableTag, Action: ActionList}, + + {Resource: ResourceConfiguration, Action: ActionRead}, + {Resource: ResourceConfiguration, Action: ActionUpdate}, + + {Resource: ResourceRobot, Action: ActionCreate}, + {Resource: ResourceRobot, Action: ActionRead}, + {Resource: ResourceRobot, Action: ActionUpdate}, + {Resource: ResourceRobot, Action: ActionDelete}, + {Resource: ResourceRobot, Action: ActionList}, + + {Resource: ResourceNotificationPolicy, Action: ActionCreate}, + {Resource: ResourceNotificationPolicy, Action: ActionUpdate}, + {Resource: ResourceNotificationPolicy, Action: ActionDelete}, + {Resource: ResourceNotificationPolicy, Action: ActionList}, + {Resource: ResourceNotificationPolicy, Action: ActionRead}, + + {Resource: ResourceScan, Action: ActionCreate}, + {Resource: ResourceScan, Action: ActionRead}, + {Resource: ResourceScan, Action: ActionStop}, + {Resource: ResourceSBOM, Action: ActionCreate}, + {Resource: ResourceSBOM, Action: ActionStop}, + {Resource: ResourceSBOM, Action: ActionRead}, + + {Resource: ResourceScanner, Action: ActionRead}, + {Resource: ResourceScanner, Action: ActionCreate}, + + {Resource: ResourceArtifact, Action: ActionCreate}, + {Resource: ResourceArtifact, Action: ActionRead}, + {Resource: ResourceArtifact, Action: ActionDelete}, + {Resource: ResourceArtifact, Action: ActionList}, + {Resource: ResourceArtifactAddition, Action: ActionRead}, + + {Resource: ResourceTag, Action: ActionList}, + {Resource: ResourceTag, Action: ActionCreate}, + {Resource: ResourceTag, Action: ActionDelete}, + + {Resource: ResourceAccessory, Action: ActionList}, + + {Resource: ResourceArtifactLabel, Action: ActionCreate}, + {Resource: ResourceArtifactLabel, Action: ActionDelete}, + + {Resource: ResourcePreatPolicy, Action: ActionCreate}, + {Resource: ResourcePreatPolicy, Action: ActionRead}, + {Resource: ResourcePreatPolicy, Action: ActionUpdate}, + {Resource: ResourcePreatPolicy, Action: ActionDelete}, + {Resource: ResourcePreatPolicy, Action: ActionList}, + + {Resource: ResourceExportCVE, Action: ActionCreate}, + {Resource: ResourceExportCVE, Action: ActionRead}, + {Resource: ResourceExportCVE, Action: ActionList}, + }, } ) diff --git a/src/common/rbac/project/evaluator.go b/src/common/rbac/project/evaluator.go index 38f5753c048..60ecdc79b46 100644 --- a/src/common/rbac/project/evaluator.go +++ b/src/common/rbac/project/evaluator.go @@ -19,6 +19,7 @@ import ( "github.com/goharbor/harbor/src/common/models" "github.com/goharbor/harbor/src/controller/project" + "github.com/goharbor/harbor/src/controller/role" "github.com/goharbor/harbor/src/lib/log" "github.com/goharbor/harbor/src/pkg/permission/evaluator" "github.com/goharbor/harbor/src/pkg/permission/evaluator/namespace" @@ -31,22 +32,28 @@ import ( type RBACUserBuilder func(context.Context, *proModels.Project) types.RBACUser // NewBuilderForUser create a builder for the local user -func NewBuilderForUser(user *models.User, ctl project.Controller) RBACUserBuilder { +func NewBuilderForUser(user *models.User, ctl project.Controller, ctl_r role.Controller) RBACUserBuilder { return func(ctx context.Context, p *proModels.Project) types.RBACUser { if user == nil { - // anonymous access - return &rbacUser{ - project: p, - username: "anonymous", - } + return &rbacUser{project: p, username: "anonymous"} } - roles, err := ctl.ListRoles(ctx, p.ProjectID, user) + roleIDs, err := ctl.ListRoles(ctx, p.ProjectID, user) if err != nil { log.Errorf("failed to list roles: %v", err) return nil } + var roles []*role.Role + for _, roleID := range roleIDs { + r, err := ctl_r.Get(ctx, int64(roleID), &role.Option{WithPermission: true}) + if err != nil { + log.Errorf("failed to get role %d: %v", roleID, err) + return nil + } + roles = append(roles, r) + } + return &rbacUser{ project: p, username: user.Username, @@ -76,9 +83,7 @@ func NewEvaluator(ctl project.Controller, builders ...RBACUserBuilder) evaluator return namespace.New(NamespaceKind, func(ctx context.Context, ns types.Namespace) evaluator.Evaluator { p, err := ctl.Get(ctx, ns.Identity().(int64), project.Metadata(true)) if err != nil { - if err != nil { - log.Warningf("Failed to get info of project %d for permission evaluator, error: %v", ns.Identity(), err) - } + log.Warningf("Failed to get info of project %d for permission evaluator, error: %v", ns.Identity(), err) return nil } diff --git a/src/common/rbac/project/evaluator_test.go b/src/common/rbac/project/evaluator_test.go index ff2e147f983..ac7af651d69 100644 --- a/src/common/rbac/project/evaluator_test.go +++ b/src/common/rbac/project/evaluator_test.go @@ -19,103 +19,139 @@ import ( "testing" "github.com/stretchr/testify/assert" + testifymock "github.com/stretchr/testify/mock" "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/common/models" "github.com/goharbor/harbor/src/common/rbac" + roleCtl "github.com/goharbor/harbor/src/controller/role" + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg/permission/types" proModels "github.com/goharbor/harbor/src/pkg/project/models" - projecttesting "github.com/goharbor/harbor/src/testing/controller/project" "github.com/goharbor/harbor/src/testing/mock" + mocksProject "github.com/goharbor/harbor/src/testing/controller/project" ) +// stubRoleCtl implements roleCtl.Controller for tests. +// Only Get() needs real behaviour; all other methods are no-ops. +type stubRoleCtl struct { + testifymock.Mock +} + +func (s *stubRoleCtl) Get(ctx context.Context, id int64, option *roleCtl.Option) (*roleCtl.Role, error) { + args := s.Called(ctx, id, option) + r, _ := args.Get(0).(*roleCtl.Role) + return r, args.Error(1) +} +func (s *stubRoleCtl) Create(ctx context.Context, r *roleCtl.Role) (int64, error) { return 0, nil } +func (s *stubRoleCtl) Delete(ctx context.Context, id int64, opt ...*roleCtl.Option) error { + return nil +} +func (s *stubRoleCtl) Update(ctx context.Context, r *roleCtl.Role, opt *roleCtl.Option) error { + return nil +} +func (s *stubRoleCtl) List(ctx context.Context, query *q.Query, opt *roleCtl.Option) ([]*roleCtl.Role, error) { + return nil, nil +} +func (s *stubRoleCtl) Count(ctx context.Context, query *q.Query) (int64, error) { return 0, nil } + var ( public = &proModels.Project{ ProjectID: 1, Name: "public_project", OwnerID: 1, - Metadata: map[string]string{ - "public": "true", - }, + Metadata: map[string]string{"public": "true"}, } - private = &proModels.Project{ ProjectID: 2, Name: "private_project", OwnerID: 1, - Metadata: map[string]string{ - "public": "false", - }, + Metadata: map[string]string{"public": "false"}, } ) -func TestAnonymousAccess(t *testing.T) { - assert := assert.New(t) - - { - // anonymous to access public project - ctl := &projecttesting.Controller{} - mock.OnAnything(ctl, "Get").Return(public, nil) - - resource := NewNamespace(public.ProjectID).Resource(rbac.ResourceRepository) - - evaluator := NewEvaluator(ctl, NewBuilderForUser(nil, ctl)) - assert.True(evaluator.HasPermission(context.TODO(), resource, rbac.ActionPull)) +// builtinRole returns a *roleCtl.Role with the permissions for a built-in role ID, +// sourced from the same static map used by the upstream. +func builtinRole(roleID int) *roleCtl.Role { + nameMap := map[int]string{ + common.RoleProjectAdmin: "projectAdmin", + common.RoleMaintainer: "maintainer", + common.RoleDeveloper: "developer", + common.RoleGuest: "guest", + common.RoleLimitedGuest: "limitedGuest", } - - { - // anonymous to access private project - ctl := &projecttesting.Controller{} - mock.OnAnything(ctl, "Get").Return(private, nil) - - resource := NewNamespace(private.ProjectID).Resource(rbac.ResourceRepository) - - evaluator := NewEvaluator(ctl, NewBuilderForUser(nil, ctl)) - assert.False(evaluator.HasPermission(context.TODO(), resource, rbac.ActionPull)) + policies := rolePoliciesMap[nameMap[roleID]] + access := make([]*types.Policy, len(policies)) + copy(access, policies) + return &roleCtl.Role{ + Permissions: []*roleCtl.Permission{{ + Kind: roleCtl.LEVELROLE, + Namespace: "*", + Access: access, + }}, } } +func TestAnonymousAccess(t *testing.T) { + // anonymous can pull from a public project + ctl := &mocksProject.Controller{} + ctl_r := &stubRoleCtl{} + mock.OnAnything(ctl, "Get").Return(public, nil) + resource := NewNamespace(public.ProjectID).Resource(rbac.ResourceRepository) + assert.True(t, + NewEvaluator(ctl, NewBuilderForUser(nil, ctl, ctl_r)).HasPermission(context.TODO(), resource, rbac.ActionPull)) + + // anonymous cannot pull from a private project + ctl2 := &mocksProject.Controller{} + ctl_r2 := &stubRoleCtl{} + mock.OnAnything(ctl2, "Get").Return(private, nil) + resource2 := NewNamespace(private.ProjectID).Resource(rbac.ResourceRepository) + assert.False(t, + NewEvaluator(ctl2, NewBuilderForUser(nil, ctl2, ctl_r2)).HasPermission(context.TODO(), resource2, rbac.ActionPull)) +} + func TestProjectRoleAccess(t *testing.T) { - assert := assert.New(t) + user := &models.User{UserID: 1, Username: "username"} + // projectAdmin can push { - ctl := &projecttesting.Controller{} + ctl := &mocksProject.Controller{} + ctl_r := &stubRoleCtl{} mock.OnAnything(ctl, "Get").Return(public, nil) mock.OnAnything(ctl, "ListRoles").Return([]int{common.RoleProjectAdmin}, nil) + ctl_r.On("Get", testifymock.Anything, int64(common.RoleProjectAdmin), testifymock.Anything). + Return(builtinRole(common.RoleProjectAdmin), nil) - user := &models.User{ - UserID: 1, - Username: "username", - } - evaluator := NewEvaluator(ctl, NewBuilderForUser(user, ctl)) + evaluator := NewEvaluator(ctl, NewBuilderForUser(user, ctl, ctl_r)) resource := NewNamespace(public.ProjectID).Resource(rbac.ResourceRepository) - assert.True(evaluator.HasPermission(context.TODO(), resource, rbac.ActionPush)) + assert.True(t, evaluator.HasPermission(context.TODO(), resource, rbac.ActionPush)) } + // guest cannot push { - ctl := &projecttesting.Controller{} + ctl := &mocksProject.Controller{} + ctl_r := &stubRoleCtl{} mock.OnAnything(ctl, "Get").Return(public, nil) mock.OnAnything(ctl, "ListRoles").Return([]int{common.RoleGuest}, nil) + ctl_r.On("Get", testifymock.Anything, int64(common.RoleGuest), testifymock.Anything). + Return(builtinRole(common.RoleGuest), nil) - user := &models.User{ - UserID: 1, - Username: "username", - } - evaluator := NewEvaluator(ctl, NewBuilderForUser(user, ctl)) + evaluator := NewEvaluator(ctl, NewBuilderForUser(user, ctl, ctl_r)) resource := NewNamespace(public.ProjectID).Resource(rbac.ResourceRepository) - assert.False(evaluator.HasPermission(context.TODO(), resource, rbac.ActionPush)) + assert.False(t, evaluator.HasPermission(context.TODO(), resource, rbac.ActionPush)) } } func BenchmarkProjectEvaluator(b *testing.B) { - ctl := &projecttesting.Controller{} + user := &models.User{UserID: 1, Username: "username"} + ctl := &mocksProject.Controller{} + ctl_r := &stubRoleCtl{} mock.OnAnything(ctl, "Get").Return(public, nil) mock.OnAnything(ctl, "ListRoles").Return([]int{common.RoleProjectAdmin}, nil) + ctl_r.On("Get", testifymock.Anything, int64(common.RoleProjectAdmin), testifymock.Anything). + Return(builtinRole(common.RoleProjectAdmin), nil) - user := &models.User{ - UserID: 1, - Username: "username", - } - evaluator := NewEvaluator(ctl, NewBuilderForUser(user, ctl)) + evaluator := NewEvaluator(ctl, NewBuilderForUser(user, ctl, ctl_r)) resource := NewNamespace(public.ProjectID).Resource(rbac.ResourceRepository) b.ResetTimer() @@ -125,15 +161,15 @@ func BenchmarkProjectEvaluator(b *testing.B) { } func BenchmarkProjectEvaluatorParallel(b *testing.B) { - ctl := &projecttesting.Controller{} + user := &models.User{UserID: 1, Username: "username"} + ctl := &mocksProject.Controller{} + ctl_r := &stubRoleCtl{} mock.OnAnything(ctl, "Get").Return(public, nil) mock.OnAnything(ctl, "ListRoles").Return([]int{common.RoleProjectAdmin}, nil) + ctl_r.On("Get", testifymock.Anything, int64(common.RoleProjectAdmin), testifymock.Anything). + Return(builtinRole(common.RoleProjectAdmin), nil) - user := &models.User{ - UserID: 1, - Username: "username", - } - evaluator := NewEvaluator(ctl, NewBuilderForUser(user, ctl)) + evaluator := NewEvaluator(ctl, NewBuilderForUser(user, ctl, ctl_r)) resource := NewNamespace(public.ProjectID).Resource(rbac.ResourceRepository) b.RunParallel(func(pb *testing.PB) { diff --git a/src/common/rbac/project/rbac_role.go b/src/common/rbac/project/rbac_role.go index c40ba4ce709..8d9968baeb0 100644 --- a/src/common/rbac/project/rbac_role.go +++ b/src/common/rbac/project/rbac_role.go @@ -15,336 +15,35 @@ package project import ( - "github.com/goharbor/harbor/src/common" - "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/controller/role" "github.com/goharbor/harbor/src/pkg/permission/types" ) -var ( - rolePoliciesMap = map[string][]*types.Policy{ - "projectAdmin": { - {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, - {Resource: rbac.ResourceSelf, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceSelf, Action: rbac.ActionDelete}, - - {Resource: rbac.ResourceMember, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceMember, Action: rbac.ActionRead}, - {Resource: rbac.ResourceMember, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceMember, Action: rbac.ActionDelete}, - {Resource: rbac.ResourceMember, Action: rbac.ActionList}, - - {Resource: rbac.ResourceMetadata, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceMetadata, Action: rbac.ActionRead}, - {Resource: rbac.ResourceMetadata, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceMetadata, Action: rbac.ActionDelete}, - - {Resource: rbac.ResourceLog, Action: rbac.ActionList}, - - {Resource: rbac.ResourceLabel, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceLabel, Action: rbac.ActionRead}, - {Resource: rbac.ResourceLabel, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceLabel, Action: rbac.ActionDelete}, - {Resource: rbac.ResourceLabel, Action: rbac.ActionList}, - - {Resource: rbac.ResourceQuota, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceRepository, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionDelete}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionPush}, - - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionRead}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionDelete}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionList}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionOperate}, - - {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionDelete}, - {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionList}, - - {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, - {Resource: rbac.ResourceConfiguration, Action: rbac.ActionUpdate}, - - {Resource: rbac.ResourceRobot, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceRobot, Action: rbac.ActionRead}, - {Resource: rbac.ResourceRobot, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceRobot, Action: rbac.ActionDelete}, - {Resource: rbac.ResourceRobot, Action: rbac.ActionList}, - - {Resource: rbac.ResourceNotificationPolicy, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceNotificationPolicy, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceNotificationPolicy, Action: rbac.ActionDelete}, - {Resource: rbac.ResourceNotificationPolicy, Action: rbac.ActionList}, - {Resource: rbac.ResourceNotificationPolicy, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceScan, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceScan, Action: rbac.ActionRead}, - {Resource: rbac.ResourceScan, Action: rbac.ActionStop}, - {Resource: rbac.ResourceSBOM, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceSBOM, Action: rbac.ActionStop}, - {Resource: rbac.ResourceSBOM, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceScanner, Action: rbac.ActionRead}, - {Resource: rbac.ResourceScanner, Action: rbac.ActionCreate}, - - {Resource: rbac.ResourceArtifact, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceArtifact, Action: rbac.ActionRead}, - {Resource: rbac.ResourceArtifact, Action: rbac.ActionDelete}, - {Resource: rbac.ResourceArtifact, Action: rbac.ActionList}, - {Resource: rbac.ResourceArtifactAddition, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceTag, Action: rbac.ActionList}, - {Resource: rbac.ResourceTag, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceTag, Action: rbac.ActionDelete}, - - {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, - - {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete}, - - {Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionCreate}, - {Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionRead}, - {Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionDelete}, - {Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionList}, - - {Resource: rbac.ResourceExportCVE, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceExportCVE, Action: rbac.ActionRead}, - {Resource: rbac.ResourceExportCVE, Action: rbac.ActionList}, - }, - - "maintainer": { - {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceMember, Action: rbac.ActionRead}, - {Resource: rbac.ResourceMember, Action: rbac.ActionList}, - - {Resource: rbac.ResourceMetadata, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceQuota, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceLabel, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceLabel, Action: rbac.ActionRead}, - {Resource: rbac.ResourceLabel, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceLabel, Action: rbac.ActionDelete}, - {Resource: rbac.ResourceLabel, Action: rbac.ActionList}, - - {Resource: rbac.ResourceRepository, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionDelete}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionPush}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, - - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionRead}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionDelete}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionList}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionOperate}, - - {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, - - {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionDelete}, - {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionList}, - - {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceRobot, Action: rbac.ActionRead}, - {Resource: rbac.ResourceRobot, Action: rbac.ActionList}, - - {Resource: rbac.ResourceNotificationPolicy, Action: rbac.ActionRead}, - {Resource: rbac.ResourceNotificationPolicy, Action: rbac.ActionList}, - - {Resource: rbac.ResourceScan, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceScan, Action: rbac.ActionRead}, - {Resource: rbac.ResourceScan, Action: rbac.ActionStop}, - {Resource: rbac.ResourceSBOM, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceSBOM, Action: rbac.ActionStop}, - {Resource: rbac.ResourceSBOM, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceScanner, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceArtifact, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceArtifact, Action: rbac.ActionRead}, - {Resource: rbac.ResourceArtifact, Action: rbac.ActionDelete}, - {Resource: rbac.ResourceArtifact, Action: rbac.ActionList}, - {Resource: rbac.ResourceArtifactAddition, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceTag, Action: rbac.ActionList}, - {Resource: rbac.ResourceTag, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceTag, Action: rbac.ActionDelete}, - - {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete}, - - {Resource: rbac.ResourceExportCVE, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceExportCVE, Action: rbac.ActionRead}, - {Resource: rbac.ResourceExportCVE, Action: rbac.ActionList}, - }, - - "developer": { - {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceMember, Action: rbac.ActionRead}, - {Resource: rbac.ResourceMember, Action: rbac.ActionList}, - - {Resource: rbac.ResourceLabel, Action: rbac.ActionRead}, - {Resource: rbac.ResourceLabel, Action: rbac.ActionList}, - - {Resource: rbac.ResourceQuota, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceRepository, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionPush}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, - - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionRead}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionUpdate}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionDelete}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionList}, - {Resource: rbac.ResourceTagRetention, Action: rbac.ActionOperate}, - - {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceRobot, Action: rbac.ActionRead}, - {Resource: rbac.ResourceRobot, Action: rbac.ActionList}, - - {Resource: rbac.ResourceScan, Action: rbac.ActionRead}, - {Resource: rbac.ResourceSBOM, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceScanner, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceArtifact, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceArtifact, Action: rbac.ActionRead}, - {Resource: rbac.ResourceArtifact, Action: rbac.ActionList}, - {Resource: rbac.ResourceArtifactAddition, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceTag, Action: rbac.ActionList}, - {Resource: rbac.ResourceTag, Action: rbac.ActionCreate}, - - {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, - - {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete}, - - {Resource: rbac.ResourceExportCVE, Action: rbac.ActionCreate}, - {Resource: rbac.ResourceExportCVE, Action: rbac.ActionRead}, - {Resource: rbac.ResourceExportCVE, Action: rbac.ActionList}, - }, - - "guest": { - {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceMember, Action: rbac.ActionRead}, - {Resource: rbac.ResourceMember, Action: rbac.ActionList}, - - {Resource: rbac.ResourceLabel, Action: rbac.ActionRead}, - {Resource: rbac.ResourceLabel, Action: rbac.ActionList}, - - {Resource: rbac.ResourceQuota, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, - - {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceRobot, Action: rbac.ActionRead}, - {Resource: rbac.ResourceRobot, Action: rbac.ActionList}, - - {Resource: rbac.ResourceScan, Action: rbac.ActionRead}, - {Resource: rbac.ResourceSBOM, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceScanner, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceTag, Action: rbac.ActionList}, - {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, - - {Resource: rbac.ResourceArtifact, Action: rbac.ActionRead}, - {Resource: rbac.ResourceArtifact, Action: rbac.ActionList}, - {Resource: rbac.ResourceArtifactAddition, Action: rbac.ActionRead}, - }, - - "limitedGuest": { - {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceQuota, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, - {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, - - {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceScan, Action: rbac.ActionRead}, - {Resource: rbac.ResourceSBOM, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceScanner, Action: rbac.ActionRead}, - - {Resource: rbac.ResourceTag, Action: rbac.ActionList}, - {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, - - {Resource: rbac.ResourceArtifact, Action: rbac.ActionRead}, - {Resource: rbac.ResourceArtifact, Action: rbac.ActionList}, - {Resource: rbac.ResourceArtifactAddition, Action: rbac.ActionRead}, - }, - } -) - // projectRBACRole implement the RBACRole interface type projectRBACRole struct { projectID int64 - roleID int + role *role.Role } -// GetRoleName returns role name for the visitor role func (role *projectRBACRole) GetRoleName() string { - switch role.roleID { - case common.RoleProjectAdmin: - return "projectAdmin" - case common.RoleMaintainer: - return "maintainer" - case common.RoleDeveloper: - return "developer" - case common.RoleGuest: - return "guest" - case common.RoleLimitedGuest: - return "limitedGuest" - default: - return "" - } + + return role.role.Name } // GetPolicies returns policies for the visitor role func (role *projectRBACRole) GetPolicies() []*types.Policy { - policies := []*types.Policy{} - - roleName := role.GetRoleName() - if roleName == "" { - return policies - } + policies := []*types.Policy{} namespace := NewNamespace(role.projectID) - for _, policy := range rolePoliciesMap[roleName] { - policies = append(policies, &types.Policy{ - Resource: namespace.Resource(policy.Resource), - Action: policy.Action, - Effect: policy.Effect, - }) - } + for _, permission := range role.role.Permissions { + for _, policy := range permission.Access { + policies = append(policies, &types.Policy{ + Resource: namespace.Resource(policy.Resource), + Action: policy.Action, + Effect: policy.Effect, + }) + } + } return policies } diff --git a/src/common/rbac/project/rbac_user.go b/src/common/rbac/project/rbac_user.go index 6a716de78bf..dda546ebfbb 100644 --- a/src/common/rbac/project/rbac_user.go +++ b/src/common/rbac/project/rbac_user.go @@ -15,6 +15,7 @@ package project import ( + "github.com/goharbor/harbor/src/controller/role" "github.com/goharbor/harbor/src/pkg/permission/types" "github.com/goharbor/harbor/src/pkg/project/models" ) @@ -22,7 +23,7 @@ import ( type rbacUser struct { project *models.Project username string - projectRoles []int + projectRoles []*role.Role policies []*types.Policy } @@ -43,10 +44,11 @@ func (pru *rbacUser) GetPolicies() []*types.Policy { } // GetRoles returns roles of the visitor + func (pru *rbacUser) GetRoles() []types.RBACRole { roles := []types.RBACRole{} - for _, roleID := range pru.projectRoles { - roles = append(roles, &projectRBACRole{projectID: pru.project.ProjectID, roleID: roleID}) + for _, role := range pru.projectRoles { + roles = append(roles, &projectRBACRole{projectID: pru.project.ProjectID, role: role}) } return roles diff --git a/src/common/rbac/project/rbac_util.go b/src/common/rbac/project/rbac_util.go index d93e44d32bd..aac7bcfc3e4 100644 --- a/src/common/rbac/project/rbac_util.go +++ b/src/common/rbac/project/rbac_util.go @@ -15,6 +15,7 @@ package project import ( + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/pkg/permission/types" ) @@ -41,6 +42,289 @@ var ( {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, } + rolePoliciesMap = map[string][]*types.Policy{ + "projectAdmin": { + {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, + {Resource: rbac.ResourceSelf, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceSelf, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceMember, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceMember, Action: rbac.ActionRead}, + {Resource: rbac.ResourceMember, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceMember, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceMember, Action: rbac.ActionList}, + + {Resource: rbac.ResourceMetadata, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceMetadata, Action: rbac.ActionRead}, + {Resource: rbac.ResourceMetadata, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceMetadata, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceMetadata, Action: rbac.ActionList}, + + {Resource: rbac.ResourceLog, Action: rbac.ActionList}, + + {Resource: rbac.ResourceLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionRead}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceQuota, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRepository, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPush}, + + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionRead}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionList}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionOperate}, + + {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionList}, + + {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, + {Resource: rbac.ResourceConfiguration, Action: rbac.ActionUpdate}, + + {Resource: rbac.ResourceRobot, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionList}, + + {Resource: rbac.ResourceNotificationPolicy, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceNotificationPolicy, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceNotificationPolicy, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceNotificationPolicy, Action: rbac.ActionList}, + {Resource: rbac.ResourceNotificationPolicy, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceScan, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceScan, Action: rbac.ActionRead}, + {Resource: rbac.ResourceScan, Action: rbac.ActionStop}, + {Resource: rbac.ResourceSBOM, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceSBOM, Action: rbac.ActionStop}, + {Resource: rbac.ResourceSBOM, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceScanner, Action: rbac.ActionRead}, + {Resource: rbac.ResourceScanner, Action: rbac.ActionCreate}, + + {Resource: rbac.ResourceArtifact, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceArtifact, Action: rbac.ActionRead}, + {Resource: rbac.ResourceArtifact, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceArtifact, Action: rbac.ActionList}, + {Resource: rbac.ResourceArtifactAddition, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceTag, Action: rbac.ActionList}, + {Resource: rbac.ResourceTag, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceTag, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, + + {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionCreate}, + {Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionRead}, + {Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionDelete}, + {Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionList}, + + {Resource: rbac.ResourceExportCVE, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceExportCVE, Action: rbac.ActionRead}, + {Resource: rbac.ResourceExportCVE, Action: rbac.ActionList}, + }, + + "maintainer": { + {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceMember, Action: rbac.ActionRead}, + {Resource: rbac.ResourceMember, Action: rbac.ActionList}, + + {Resource: rbac.ResourceMetadata, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceQuota, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionRead}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepository, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPush}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, + + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionRead}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionList}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionOperate}, + + {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, + + {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionList}, + + {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRobot, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionList}, + + {Resource: rbac.ResourceNotificationPolicy, Action: rbac.ActionRead}, + {Resource: rbac.ResourceNotificationPolicy, Action: rbac.ActionList}, + + {Resource: rbac.ResourceScan, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceScan, Action: rbac.ActionRead}, + {Resource: rbac.ResourceScan, Action: rbac.ActionStop}, + {Resource: rbac.ResourceSBOM, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceSBOM, Action: rbac.ActionStop}, + {Resource: rbac.ResourceSBOM, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceScanner, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceArtifact, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceArtifact, Action: rbac.ActionRead}, + {Resource: rbac.ResourceArtifact, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceArtifact, Action: rbac.ActionList}, + {Resource: rbac.ResourceArtifactAddition, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceTag, Action: rbac.ActionList}, + {Resource: rbac.ResourceTag, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceTag, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceExportCVE, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceExportCVE, Action: rbac.ActionRead}, + {Resource: rbac.ResourceExportCVE, Action: rbac.ActionList}, + }, + + "developer": { + {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceMember, Action: rbac.ActionRead}, + {Resource: rbac.ResourceMember, Action: rbac.ActionList}, + + {Resource: rbac.ResourceLabel, Action: rbac.ActionRead}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceQuota, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRepository, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPush}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, + + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionRead}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionList}, + {Resource: rbac.ResourceTagRetention, Action: rbac.ActionOperate}, + + {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRobot, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionList}, + + {Resource: rbac.ResourceScan, Action: rbac.ActionRead}, + {Resource: rbac.ResourceSBOM, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceScanner, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceArtifact, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceArtifact, Action: rbac.ActionRead}, + {Resource: rbac.ResourceArtifact, Action: rbac.ActionList}, + {Resource: rbac.ResourceArtifactAddition, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceTag, Action: rbac.ActionList}, + {Resource: rbac.ResourceTag, Action: rbac.ActionCreate}, + + {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, + + {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceExportCVE, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceExportCVE, Action: rbac.ActionRead}, + {Resource: rbac.ResourceExportCVE, Action: rbac.ActionList}, + }, + + "guest": { + {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceMember, Action: rbac.ActionRead}, + {Resource: rbac.ResourceMember, Action: rbac.ActionList}, + + {Resource: rbac.ResourceLabel, Action: rbac.ActionRead}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceQuota, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, + + {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRobot, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionList}, + + {Resource: rbac.ResourceScan, Action: rbac.ActionRead}, + {Resource: rbac.ResourceSBOM, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceScanner, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceTag, Action: rbac.ActionList}, + {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, + + {Resource: rbac.ResourceArtifact, Action: rbac.ActionRead}, + {Resource: rbac.ResourceArtifact, Action: rbac.ActionList}, + {Resource: rbac.ResourceArtifactAddition, Action: rbac.ActionRead}, + }, + + "limitedGuest": { + {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceQuota, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, + + {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceScan, Action: rbac.ActionRead}, + {Resource: rbac.ResourceSBOM, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceScanner, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceTag, Action: rbac.ActionList}, + {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, + + {Resource: rbac.ResourceArtifact, Action: rbac.ActionRead}, + {Resource: rbac.ResourceArtifact, Action: rbac.ActionList}, + {Resource: rbac.ResourceArtifactAddition, Action: rbac.ActionRead}, + }, + } + // sub policies for the projects subPoliciesForProject = computeSubPoliciesForProject() ) @@ -61,6 +345,7 @@ func getPoliciesForPublicProject(projectID int64) []*types.Policy { } // GetPoliciesOfProject returns all policies for projectNamespace of the project + func GetPoliciesOfProject(projectID int64) []*types.Policy { policies := []*types.Policy{} @@ -72,16 +357,19 @@ func GetPoliciesOfProject(projectID int64) []*types.Policy { Effect: policy.Effect, }) } - return policies } +/* This just fetches all the possible policies that can apply to a project */ func computeSubPoliciesForProject() []*types.Policy { var results []*types.Policy mp := map[string]bool{} + for _, policies := range rolePoliciesMap { + for _, policy := range policies { + if !mp[policy.String()] { results = append(results, policy) mp[policy.String()] = true diff --git a/src/common/security/local/context.go b/src/common/security/local/context.go index 1ca6e2ef320..98f154c3b24 100644 --- a/src/common/security/local/context.go +++ b/src/common/security/local/context.go @@ -21,6 +21,7 @@ import ( "github.com/goharbor/harbor/src/common/models" rbac_project "github.com/goharbor/harbor/src/common/rbac/project" "github.com/goharbor/harbor/src/controller/project" + "github.com/goharbor/harbor/src/controller/role" "github.com/goharbor/harbor/src/pkg/permission/evaluator" "github.com/goharbor/harbor/src/pkg/permission/evaluator/admin" "github.com/goharbor/harbor/src/pkg/permission/types" @@ -33,6 +34,7 @@ const ContextName = "local" type SecurityContext struct { user *models.User ctl project.Controller + ctl_r role.Controller evaluator evaluator.Evaluator once sync.Once } @@ -40,8 +42,9 @@ type SecurityContext struct { // NewSecurityContext ... func NewSecurityContext(user *models.User) *SecurityContext { return &SecurityContext{ - user: user, - ctl: project.Ctl, + user: user, + ctl: project.Ctl, + ctl_r: role.Ctl, } } @@ -91,7 +94,7 @@ func (s *SecurityContext) Can(ctx context.Context, action types.Action, resource evaluators = evaluators.Add(admin.New(s.GetUsername())) } - evaluators = evaluators.Add(rbac_project.NewEvaluator(s.ctl, rbac_project.NewBuilderForUser(s.user, s.ctl))) + evaluators = evaluators.Add(rbac_project.NewEvaluator(s.ctl, rbac_project.NewBuilderForUser(s.user, s.ctl, s.ctl_r))) s.evaluator = evaluators }) diff --git a/src/controller/event/handler/init.go b/src/controller/event/handler/init.go index ecfc527cc35..88a33d4a2df 100644 --- a/src/controller/event/handler/init.go +++ b/src/controller/event/handler/init.go @@ -67,6 +67,9 @@ func init() { _ = notifier.Subscribe(event.TopicDeleteTag, &auditlog.Handler{}) _ = notifier.Subscribe(event.TopicCreateRobot, &auditlog.Handler{}) _ = notifier.Subscribe(event.TopicDeleteRobot, &auditlog.Handler{}) + _ = notifier.Subscribe(event.TopicCreateRole, &auditlog.Handler{}) + _ = notifier.Subscribe(event.TopicDeleteRole, &auditlog.Handler{}) + _ = notifier.Subscribe(event.TopicUpdateRole, &auditlog.Handler{}) _ = notifier.Subscribe(event.TopicCommonEvent, &auditlog.Handler{}) // internal diff --git a/src/controller/event/metadata/role.go b/src/controller/event/metadata/role.go new file mode 100644 index 00000000000..5fef63af9dc --- /dev/null +++ b/src/controller/event/metadata/role.go @@ -0,0 +1,101 @@ +// 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 metadata + +import ( + "context" + "fmt" + "time" + + "github.com/goharbor/harbor/src/common/security" + event2 "github.com/goharbor/harbor/src/controller/event" + "github.com/goharbor/harbor/src/lib/config" + "github.com/goharbor/harbor/src/pkg/notifier/event" + "github.com/goharbor/harbor/src/pkg/role/model" +) + +// CreateRoleEventMetadata is the metadata from which the create role event can be resolved +type CreateRoleEventMetadata struct { + Ctx context.Context + Role *model.Role +} + +// Resolve to the event from the metadata +func (c *CreateRoleEventMetadata) Resolve(event *event.Event) error { + data := &event2.CreateRoleEvent{ + EventType: event2.TopicCreateRole, + Role: c.Role, + OccurAt: time.Now(), + } + cx, exist := security.FromContext(c.Ctx) + if exist { + data.Operator = cx.GetUsername() + } + data.Role.Name = fmt.Sprintf("%s%s", config.RolePrefix(c.Ctx), data.Role.Name) + event.Topic = event2.TopicCreateRole + event.Data = data + return nil +} + +// UpdateRoleEventMetadata is the metadata from which the update role event can be resolved +type UpdateRoleEventMetadata struct { + Ctx context.Context + Role *model.Role +} + +// Resolve to the event from the metadata +func (u *UpdateRoleEventMetadata) Resolve(event *event.Event) error { + data := &event2.UpdateRoleEvent{ + EventType: event2.TopicUpdateRole, + Role: u.Role, + OccurAt: time.Now(), + } + cx, exist := security.FromContext(u.Ctx) + if exist { + data.Operator = cx.GetUsername() + } + data.Role.Name = fmt.Sprintf("%s%s", config.RolePrefix(u.Ctx), data.Role.Name) + event.Topic = event2.TopicUpdateRole + event.Data = data + return nil +} + +// DeleteRoleEventMetadata is the metadata from which the delete role event can be resolved +type DeleteRoleEventMetadata struct { + Ctx context.Context + Role *model.Role + Operator string +} + +// Resolve to the event from the metadata +func (d *DeleteRoleEventMetadata) Resolve(event *event.Event) error { + data := &event2.DeleteRoleEvent{ + EventType: event2.TopicDeleteRole, + Role: d.Role, + OccurAt: time.Now(), + } + if d.Operator != "" { + data.Operator = d.Operator + } else { + cx, exist := security.FromContext(d.Ctx) + if exist { + data.Operator = cx.GetUsername() + } + } + data.Role.Name = fmt.Sprintf("%s%s", config.RolePrefix(d.Ctx), data.Role.Name) + event.Topic = event2.TopicDeleteRole + event.Data = data + return nil +} diff --git a/src/controller/event/topic.go b/src/controller/event/topic.go index a9e6c56a492..71580699dd9 100644 --- a/src/controller/event/topic.go +++ b/src/controller/event/topic.go @@ -24,6 +24,7 @@ import ( "github.com/goharbor/harbor/src/pkg/auditext/model" proModels "github.com/goharbor/harbor/src/pkg/project/models" robotModel "github.com/goharbor/harbor/src/pkg/robot/model" + roleModel "github.com/goharbor/harbor/src/pkg/role/model" v1 "github.com/goharbor/harbor/src/pkg/scan/rest/v1" ) @@ -50,11 +51,15 @@ const ( TopicTagRetention = "TAG_RETENTION" TopicCreateRobot = "CREATE_ROBOT" TopicDeleteRobot = "DELETE_ROBOT" + TopicCreateRole = "CREATE_ROLE" + TopicDeleteRole = "DELETE_ROLE" + TopicUpdateRole = "UPDATE_ROLE" TopicCommonEvent = "COMMON_API" ResourceTypeProject = "project" ResourceTypeArtifact = "artifact" ResourceTypeRepository = "repository" ResourceTypeRobot = "robot" + ResourceTypeRole = "role" ResourceTypeTag = "tag" ) @@ -451,3 +456,82 @@ func (c *DeleteRobotEvent) String() string { return fmt.Sprintf("Name-%s Operator-%s OccurAt-%s", c.Robot.Name, c.Operator, c.OccurAt.Format("2006-01-02 15:04:05")) } + +// CreateRobotEvent is the creating robot event +type CreateRoleEvent struct { + EventType string + Role *roleModel.Role + Operator string + OccurAt time.Time +} + +// ResolveToAuditLog ... +func (c *CreateRoleEvent) ResolveToAuditLog() (*model.AuditLogExt, error) { + auditLog := &model.AuditLogExt{ + OpTime: c.OccurAt, + Operation: rbac.ActionCreate.String(), + Username: c.Operator, + ResourceType: ResourceTypeRole, + IsSuccessful: true, + OperationDescription: fmt.Sprintf("create role: %s", c.Role.Name), + Resource: c.Role.Name} + return auditLog, nil +} + +func (c *CreateRoleEvent) String() string { + return fmt.Sprintf("Name-%s Operator-%s OccurAt-%s", + c.Role.Name, c.Operator, c.OccurAt.Format("2006-01-02 15:04:05")) +} + +// DeleteRobotEvent is the deleting robot event +type DeleteRoleEvent struct { + EventType string + Role *roleModel.Role + Operator string + OccurAt time.Time +} + +// ResolveToAuditLog ... +func (c *DeleteRoleEvent) ResolveToAuditLog() (*model.AuditLogExt, error) { + auditLog := &model.AuditLogExt{ + OpTime: c.OccurAt, + Operation: rbac.ActionDelete.String(), + Username: c.Operator, + ResourceType: ResourceTypeRole, + IsSuccessful: true, + OperationDescription: fmt.Sprintf("delete role: %s", c.Role.Name), + Resource: c.Role.Name} + return auditLog, nil +} + +func (c *DeleteRoleEvent) String() string { + return fmt.Sprintf("Name-%s Operator-%s OccurAt-%s", + c.Role.Name, c.Operator, c.OccurAt.Format("2006-01-02 15:04:05")) +} + +// UpdateRoleEvent is the updating role event +type UpdateRoleEvent struct { + EventType string + Role *roleModel.Role + Operator string + OccurAt time.Time +} + +// ResolveToAuditLog ... +func (u *UpdateRoleEvent) ResolveToAuditLog() (*model.AuditLogExt, error) { + auditLog := &model.AuditLogExt{ + OpTime: u.OccurAt, + Operation: rbac.ActionUpdate.String(), + Username: u.Operator, + ResourceType: ResourceTypeRole, + IsSuccessful: true, + OperationDescription: fmt.Sprintf("update role: %s", u.Role.Name), + Resource: u.Role.Name, + } + return auditLog, nil +} + +func (u *UpdateRoleEvent) String() string { + return fmt.Sprintf("Name-%s Operator-%s OccurAt-%s", + u.Role.Name, u.Operator, u.OccurAt.Format("2006-01-02 15:04:05")) +} diff --git a/src/controller/member/controller.go b/src/controller/member/controller.go index bf632100938..3a6a383c8d8 100644 --- a/src/controller/member/controller.go +++ b/src/controller/member/controller.go @@ -235,7 +235,8 @@ func isValidRole(role int) bool { common.RoleLimitedGuest: return true default: - return false + // Custom role IDs are assigned sequentially above the built-in range + return role > common.RoleLimitedGuest } } diff --git a/src/controller/role/controller.go b/src/controller/role/controller.go new file mode 100644 index 00000000000..097bc3131a6 --- /dev/null +++ b/src/controller/role/controller.go @@ -0,0 +1,492 @@ +// 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 role + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/goharbor/harbor/src/controller/event/metadata" + "github.com/goharbor/harbor/src/lib/cache" + "github.com/goharbor/harbor/src/lib/config" + "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/lib/orm" + "github.com/goharbor/harbor/src/lib/log" + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg" + "github.com/goharbor/harbor/src/pkg/notification" + "github.com/goharbor/harbor/src/pkg/permission/types" + "github.com/goharbor/harbor/src/pkg/project" + "github.com/goharbor/harbor/src/pkg/rbac" + rbac_model "github.com/goharbor/harbor/src/pkg/rbac/model" + role "github.com/goharbor/harbor/src/pkg/role" + "github.com/goharbor/harbor/src/pkg/role/model" +) + +const ( + // roleVersionCacheKey is the Redis key holding a global "version token" that + // changes on every in-app role write. Nodes poll it (throttled) to detect + // changes made on other nodes and invalidate their L1 cache accordingly. + roleVersionCacheKey = "role:version" + // roleCacheL1TTL / roleCacheL2TTL bound how long a stale entry can survive an + // out-of-band change (e.g. a direct SQL UPDATE to role_permission that bypasses + // the controller and therefore does not bump the version token). + roleCacheL1TTL = 30 * time.Minute + roleCacheL2TTL = 30 * time.Minute + // roleVersionCheckInterval throttles how often a node re-reads the version + // token from Redis, bounding cross-node propagation of in-app changes while + // keeping the hot Get() path at L1's zero-network-hop cost. + roleVersionCheckInterval = time.Second +) + +// roleCacheDisabled, when set via ROLE_CACHE_DISABLED=true, makes Get() bypass the +// L1/L2 permission cache entirely and read the role (and its permissions) straight +// from the DB on every call — i.e. one role + role_permission query per Can(). +// Used as the no-cache baseline in performance comparisons; off in production. +var roleCacheDisabled = strings.EqualFold(os.Getenv("ROLE_CACHE_DISABLED"), "true") + +// roleEntry is the L1 (process-local) cache value. It carries the local +// generation it was cached under (invalidated when the global version token +// changes) and an absolute expiry (the out-of-band TTL backstop). +type roleEntry struct { + role *Role + gen int64 + expiresAt time.Time +} + +var ( + // Ctl is a global variable for the default role account controller implementation + Ctl = NewController() +) + +// Controller to handle the requests related with role +type Controller interface { + // Get ... + Get(ctx context.Context, id int64, option *Option) (*Role, error) + + // Count returns the total count of roles according to the query + Count(ctx context.Context, query *q.Query) (total int64, err error) + + // Create ... + Create(ctx context.Context, r *Role) (int64, error) + + // Delete ... + Delete(ctx context.Context, id int64, option ...*Option) error + + // Update ... + Update(ctx context.Context, r *Role, option *Option) error + + // List ... + List(ctx context.Context, query *q.Query, option *Option) ([]*Role, error) +} + +// controller ... +type controller struct { + roleMgr role.Manager + proMgr project.Manager + rbacMgr rbac.Manager + localMap sync.Map // L1: id -> *roleEntry, zero-alloc reads + warmOnce sync.Once + + // Throttle state for the cross-node version check (lock-free). + versionCheckedAt atomic.Int64 // unixnano of the last version fetch + lastVersion atomic.Pointer[string] // last-seen global version token + localGen atomic.Int64 // bumped whenever the token changes +} + +// NewController ... +func NewController() Controller { + return &controller{ + roleMgr: role.Mgr, + proMgr: pkg.ProjectMgr, + rbacMgr: rbac.Mgr, + } +} + +// roleCache returns the shared Redis-backed cache (same instance used by the quota +// controller). Falls back to nil if the cache has not been initialized yet. +func (d *controller) roleCache() cache.Cache { + return cache.Default() +} + +func roleCacheKey(id int64) string { + return fmt.Sprintf("role:%d", id) +} + +// currentGen returns this node's local cache generation, refreshing it from the +// global version token at most once per roleVersionCheckInterval. A change in the +// token (written by an in-app role write on any node) bumps the local generation, +// which invalidates every L1 entry cached under the previous generation. The +// throttle keeps the hot Get() path at L1's zero-network-hop cost. +func (d *controller) currentGen(ctx context.Context) int64 { + now := time.Now().UnixNano() + last := d.versionCheckedAt.Load() + if now-last > int64(roleVersionCheckInterval) && d.versionCheckedAt.CompareAndSwap(last, now) { + if c := d.roleCache(); c != nil { + var token string + if err := c.Fetch(ctx, roleVersionCacheKey, &token); err == nil { + if p := d.lastVersion.Load(); p == nil || *p != token { + d.localGen.Add(1) + d.lastVersion.Store(&token) + } + } + } + } + return d.localGen.Load() +} + +// bumpVersion writes a fresh global version token so other nodes invalidate their +// L1 caches on their next throttled check, and advances this node's own generation +// immediately (so the writer does not wait out its own throttle window). +func (d *controller) bumpVersion(ctx context.Context) { + token := strconv.FormatInt(time.Now().UnixNano(), 10) + if c := d.roleCache(); c != nil { + _ = c.Save(ctx, roleVersionCacheKey, token) // no TTL: the version key persists + } + d.lastVersion.Store(&token) + d.localGen.Add(1) +} + +// storeL1 caches a role in the process-local L1, stamped with the generation it +// was cached under and an absolute expiry (the out-of-band TTL backstop). +func (d *controller) storeL1(id int64, r *Role, gen int64) { + d.localMap.Store(id, &roleEntry{role: r, gen: gen, expiresAt: time.Now().Add(roleCacheL1TTL)}) +} + +// warmCache loads all roles with their permissions into L1 and L2. +// Fires once in background on first Get(). +func (d *controller) warmCache(ctx context.Context) { + roles, err := d.roleMgr.List(ctx, &q.Query{PageSize: -1}) + if err != nil { + log.Warningf("failed to warm role permission cache: %v", err) + return + } + gen := d.currentGen(ctx) + for _, r := range roles { + populated, err := d.populate(ctx, r, &Option{WithPermission: true}) + if err != nil || populated == nil { + continue + } + if c := d.roleCache(); c != nil { + _ = c.Save(ctx, roleCacheKey(r.ID), populated, roleCacheL2TTL) // L2 + } + d.storeL1(r.ID, populated, gen) // L1 + } + log.Debugf("role permission cache warmed with %d roles", len(roles)) +} + +// invalidateRole is called after an in-app Create/Update/Delete (which have +// already committed to the DB). Order matters: +// 1. bump the global version token so other nodes drop their L1 entries on their +// next throttled check (<= roleVersionCheckInterval); +// 2. delete the changed role's L2 key so the next reader re-reads the DB for the +// role whose data actually changed (other roles' L2 entries survive and +// re-promote cheaply once their L1 is gen-invalidated); +// 3. drop this node's local L1 entry. +// +// Residual race (bounded, documented): a reader that read the DB *before* this +// write commits can repopulate the role's L2 key *after* step 2, re-caching stale +// data. That window is bounded by the L2 TTL (roleCacheL2TTL) and cleared by the +// next write. Closing it fully would require versioned L2 payloads or pub/sub. +func (d *controller) invalidateRole(ctx context.Context, id int64) { + d.bumpVersion(ctx) + if c := d.roleCache(); c != nil { + _ = c.Delete(ctx, roleCacheKey(id)) + } + d.localMap.Delete(id) +} + +// Get returns a role (optionally with permissions). When permissions are +// requested it is served from a two-level cache. Read order: refresh the +// cross-node version (throttled) -> L1 -> L2 -> DB. +func (d *controller) Get(ctx context.Context, id int64, option *Option) (*Role, error) { + if option != nil && option.WithPermission && !roleCacheDisabled { + // Trigger background warm on first call — does not block. + d.warmOnce.Do(func() { go d.warmCache(orm.Context()) }) + + // Refresh the cross-node version first so L1 validity is judged against + // the latest token (throttled to one Redis read per interval at most). + gen := d.currentGen(ctx) + + // L1: process-local — valid only if cached under the current generation + // and not past its TTL. + if v, ok := d.localMap.Load(id); ok { + e := v.(*roleEntry) + if e.gen == gen && time.Now().Before(e.expiresAt) { + return e.role, nil + } + } + + // L2: Redis (honours its own TTL — expired keys are already gone). + if c := d.roleCache(); c != nil { + var cached Role + if err := c.Fetch(ctx, roleCacheKey(id), &cached); err == nil { + d.storeL1(id, &cached, gen) // promote to L1 + return &cached, nil + } + } + + // DB: source of truth. Populate both layers. + r, err := d.roleMgr.Get(ctx, id) + if err != nil { + return nil, err + } + populated, err := d.populate(ctx, r, option) + if err != nil { + return nil, err + } + if populated != nil { + if c := d.roleCache(); c != nil { + _ = c.Save(ctx, roleCacheKey(id), populated, roleCacheL2TTL) + } + d.storeL1(id, populated, gen) + } + return populated, nil + } + + // Without permissions there is nothing cacheable — read straight through. + r, err := d.roleMgr.Get(ctx, id) + if err != nil { + return nil, err + } + return d.populate(ctx, r, option) +} + +// Count ... +func (d *controller) Count(ctx context.Context, query *q.Query) (int64, error) { + return d.roleMgr.Count(ctx, query) +} + +// Create ... +func (d *controller) Create(ctx context.Context, r *Role) (int64, error) { + + name := r.Name + + rCreate := &model.Role{ + Name: name, + RoleMask: r.RoleMask, + RoleCode: r.RoleCode, + Description: r.Description, + CreatedBy: r.CreatedBy, + } + roleID, err := d.roleMgr.Create(ctx, rCreate) + if err != nil { + return 0, err + } + r.ID = roleID + if err := d.createPermission(ctx, r); err != nil { + return 0, err + } + // fire event + notification.AddEvent(ctx, &metadata.CreateRoleEventMetadata{ + Ctx: ctx, + Role: rCreate, + }) + d.invalidateRole(ctx, roleID) + return roleID, nil +} + +// Delete ... +func (d *controller) Delete(ctx context.Context, id int64, option ...*Option) error { + rDelete, err := d.roleMgr.Get(ctx, id) + if err != nil { + return err + } + if rDelete.IsBuiltin { + return errors.ForbiddenError(nil).WithMessagef("cannot delete built-in role %d", id) + } + if err := d.roleMgr.Delete(ctx, id); err != nil { + return err + } + if err := d.rbacMgr.DeletePermissionsByRole(ctx, ROLETYPE, id); err != nil { + return err + } + // fire event + deleteMetadata := &metadata.DeleteRoleEventMetadata{ + Ctx: ctx, + Role: rDelete, + } + if len(option) != 0 && option[0].Operator != "" { + deleteMetadata.Operator = option[0].Operator + } + notification.AddEvent(ctx, deleteMetadata) + d.invalidateRole(ctx, id) + return nil +} + +// Update ... +func (d *controller) Update(ctx context.Context, r *Role, option *Option) error { + if r == nil { + return errors.New("cannot update a nil role").WithCode(errors.BadRequestCode) + } + existing, err := d.roleMgr.Get(ctx, r.ID) + if err != nil { + return err + } + if existing.IsBuiltin { + return errors.ForbiddenError(nil).WithMessagef("cannot modify built-in role %d", r.ID) + } + // update role record fields + if err := d.roleMgr.Update(ctx, &model.Role{ + ID: r.ID, + Description: r.Description, + }, "description"); err != nil { + return err + } + // update the permission + if option != nil && option.WithPermission { + if err := d.rbacMgr.DeletePermissionsByRole(ctx, ROLETYPE, r.ID); err != nil && !errors.IsNotFoundErr(err) { + return err + } + if err := d.createPermission(ctx, r); err != nil { + return err + } + } + // fire event + notification.AddEvent(ctx, &metadata.UpdateRoleEventMetadata{ + Ctx: ctx, + Role: existing, + }) + d.invalidateRole(ctx, r.ID) + return nil +} + +// List ... +func (d *controller) List(ctx context.Context, query *q.Query, option *Option) ([]*Role, error) { + role, err := d.roleMgr.List(ctx, query) + if err != nil { + return nil, err + } + var roles []*Role + for _, r := range role { + rb, err := d.populate(ctx, r, option) + if err != nil { + return nil, err + } + roles = append(roles, rb) + } + return roles, nil +} + +func (d *controller) createPermission(ctx context.Context, r *Role) error { + if r == nil { + return nil + } + + for _, per := range r.Permissions { + policy := &rbac_model.PermissionPolicy{} + policy.Scope = "/project/*" + + for _, access := range per.Access { + policy.Resource = access.Resource.String() + policy.Action = access.Action.String() + policy.Effect = access.Effect.String() + + policyID, err := d.rbacMgr.CreateRbacPolicy(ctx, policy) + if err != nil { + return err + } + + _, err = d.rbacMgr.CreatePermission(ctx, &rbac_model.RolePermission{ + RoleType: ROLETYPE, + RoleID: r.ID, + PermissionPolicyID: policyID, + }) + if err != nil { + return err + } + } + } + return nil +} + +func (d *controller) populate(ctx context.Context, r *model.Role, option *Option) (*Role, error) { + if r == nil { + return nil, nil + } + role := &Role{ + Role: *r, + } + role.setLevel() + role.setEditable() + // for the v2 role, add prefix to the role name + if role.Editable { + role.Name = fmt.Sprintf("%s%s", config.RolePrefix(ctx), r.Name) + } else { + role.Name = r.Name + } + if option != nil && option.WithPermission { + if err := d.populatePermissions(ctx, role); err != nil { + return nil, err + } + } + return role, nil +} + +func (d *controller) populatePermissions(ctx context.Context, r *Role) error { + if r == nil { + return nil + } + rolePermissions, err := d.rbacMgr.GetPermissionsByRole(ctx, ROLETYPE, r.ID) + + if err != nil { + log.Errorf("failed to get permissions of role %d: %v", r.ID, err) + return err + } + if len(rolePermissions) == 0 { + return nil + } + + // scope: accesses + accessMap := make(map[string][]*types.Policy) + + // group by scope + for _, rp := range rolePermissions { + _, exist := accessMap[rp.Scope] + if !exist { + accessMap[rp.Scope] = []*types.Policy{{ + Resource: types.Resource(rp.Resource), + Action: types.Action(rp.Action), + Effect: types.Effect(rp.Effect), + }} + } else { + accesses := accessMap[rp.Scope] + accesses = append(accesses, &types.Policy{ + Resource: types.Resource(rp.Resource), + Action: types.Action(rp.Action), + Effect: types.Effect(rp.Effect), + }) + accessMap[rp.Scope] = accesses + } + } + + var permissions []*Permission + for scope, accesses := range accessMap { + p := &Permission{} + p.Scope = scope + p.Kind = LEVELROLE + p.Namespace = "*" + p.Access = accesses + permissions = append(permissions, p) + } + r.Permissions = permissions + return nil +} diff --git a/src/controller/role/controller_test.go b/src/controller/role/controller_test.go new file mode 100644 index 00000000000..c2e81f8a94a --- /dev/null +++ b/src/controller/role/controller_test.go @@ -0,0 +1,442 @@ +package role + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/pkg/permission/types" + "github.com/goharbor/harbor/src/pkg/role/model" + "github.com/goharbor/harbor/src/testing/mock" + testproject "github.com/goharbor/harbor/src/testing/pkg/project" + testrbac "github.com/goharbor/harbor/src/testing/pkg/rbac" + testrole "github.com/goharbor/harbor/src/testing/pkg/role" +) + +type ControllerTestSuite struct { + suite.Suite + roleMgr *testrole.Manager + rbacMgr *testrbac.Manager + proMgr *testproject.Manager + c controller +} + +func (suite *ControllerTestSuite) SetupTest() { + suite.roleMgr = &testrole.Manager{} + suite.rbacMgr = &testrbac.Manager{} + suite.proMgr = &testproject.Manager{} + suite.c = controller{ + roleMgr: suite.roleMgr, + rbacMgr: suite.rbacMgr, + proMgr: suite.proMgr, + } +} + +func (suite *ControllerTestSuite) TestDeleteBuiltinRole() { + suite.roleMgr.On("Get", mock.Anything, int64(1)).Return(&model.Role{ + ID: 1, + Name: "projectAdmin", + IsBuiltin: true, + }, nil) + + err := suite.c.Delete(context.TODO(), int64(1)) + suite.Require().NotNil(err) + suite.True(errors.IsErr(err, errors.ForbiddenCode)) + suite.roleMgr.AssertNotCalled(suite.T(), "Delete", mock.Anything, mock.Anything) +} + +func (suite *ControllerTestSuite) TestDeleteCustomRole() { + suite.roleMgr.On("Get", mock.Anything, int64(2)).Return(&model.Role{ + ID: 2, + Name: "myCustomRole", + IsBuiltin: false, + }, nil) + suite.roleMgr.On("Delete", mock.Anything, int64(2)).Return(nil) + suite.rbacMgr.On("DeletePermissionsByRole", mock.Anything, ROLETYPE, int64(2)).Return(nil) + + err := suite.c.Delete(context.TODO(), int64(2)) + suite.Nil(err) +} + +func (suite *ControllerTestSuite) TestUpdateNilRole() { + err := suite.c.Update(context.TODO(), nil, nil) + suite.Require().NotNil(err) + suite.True(errors.IsErr(err, errors.BadRequestCode)) +} + +func (suite *ControllerTestSuite) TestUpdateBuiltinRole() { + suite.roleMgr.On("Get", mock.Anything, int64(1)).Return(&model.Role{ + ID: 1, + Name: "projectAdmin", + IsBuiltin: true, + }, nil) + + err := suite.c.Update(context.TODO(), &Role{ + Role: model.Role{ID: 1, Name: "projectAdmin"}, + }, &Option{WithPermission: true}) + suite.Require().NotNil(err) + suite.True(errors.IsErr(err, errors.ForbiddenCode)) + suite.rbacMgr.AssertNotCalled(suite.T(), "DeletePermissionsByRole", mock.Anything, mock.Anything, mock.Anything) +} + +func (suite *ControllerTestSuite) TestUpdateCustomRole() { + suite.roleMgr.On("Get", mock.Anything, int64(2)).Return(&model.Role{ + ID: 2, + Name: "myCustomRole", + IsBuiltin: false, + }, nil) + suite.roleMgr.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(nil) + suite.rbacMgr.On("DeletePermissionsByRole", mock.Anything, ROLETYPE, int64(2)).Return(nil) + suite.rbacMgr.On("CreateRbacPolicy", mock.Anything, mock.Anything).Return(int64(1), nil) + suite.rbacMgr.On("CreatePermission", mock.Anything, mock.Anything).Return(int64(1), nil) + + err := suite.c.Update(context.TODO(), &Role{ + Role: model.Role{ID: 2, Name: "myCustomRole"}, + Permissions: []*Permission{ + { + Access: []*types.Policy{ + {Resource: "repository", Action: "pull"}, + }, + }, + }, + }, &Option{WithPermission: true}) + suite.Nil(err) +} + +func TestControllerTestSuite(t *testing.T) { + suite.Run(t, &ControllerTestSuite{}) +} + +// legacy TODO tests preserved below +/* +import ( + "context" + "os" + "testing" + + "github.com/aliyun/alibaba-cloud-sdk-go/sdk/utils" + "github.com/stretchr/testify/suite" + + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/security" + "github.com/goharbor/harbor/src/common/utils/test" + "github.com/goharbor/harbor/src/lib/config" + "github.com/goharbor/harbor/src/lib/q" + _ "github.com/goharbor/harbor/src/pkg/config/inmemory" + "github.com/goharbor/harbor/src/pkg/permission/types" + proModels "github.com/goharbor/harbor/src/pkg/project/models" + rbac_model "github.com/goharbor/harbor/src/pkg/rbac/model" + "github.com/goharbor/harbor/src/pkg/robot/model" + htesting "github.com/goharbor/harbor/src/testing" + testsec "github.com/goharbor/harbor/src/testing/common/security" + "github.com/goharbor/harbor/src/testing/mock" + "github.com/goharbor/harbor/src/testing/pkg/project" + "github.com/goharbor/harbor/src/testing/pkg/rbac" + "github.com/goharbor/harbor/src/testing/pkg/robot" +) + +type ControllerTestSuite struct { + htesting.Suite +} + +func (suite *ControllerTestSuite) TestGet() { + projectMgr := &project.Manager{} + rbacMgr := &rbac.Manager{} + robotMgr := &robot.Manager{} + + c := controller{robotMgr: robotMgr, rbacMgr: rbacMgr, proMgr: projectMgr} + ctx := context.TODO() + projectMgr.On("Get", mock.Anything, mock.Anything).Return(&proModels.Project{ProjectID: 1, Name: "library"}, nil) + robotMgr.On("Get", mock.Anything, mock.Anything).Return(&model.Robot{ + Name: "library+test", + Description: "test get method", + ProjectID: 1, + Secret: utils.GetNonce(), + }, nil) + rbacMgr.On("GetPermissionsByRole", mock.Anything, mock.Anything, mock.Anything).Return([]*rbac_model.UniversalRolePermission{ + { + RoleType: ROBOTTYPE, + RoleID: 1, + Scope: "/project/1", + Resource: "repository", + Action: "pull", + }, + { + RoleType: ROBOTTYPE, + RoleID: 1, + Scope: "/project/1", + Resource: "repository", + Action: "push", + }, + }, nil) + robot, err := c.Get(ctx, int64(1), &Option{ + WithPermission: true, + }) + suite.Nil(err) + + suite.Equal("project", robot.Permissions[0].Kind) + suite.Equal("library", robot.Permissions[0].Namespace) + suite.Equal("pull", robot.Permissions[0].Access[0].Action.String()) + suite.Equal("project", robot.Level) + +} + +func (suite *ControllerTestSuite) TestCount() { + projectMgr := &project.Manager{} + rbacMgr := &rbac.Manager{} + robotMgr := &robot.Manager{} + + c := controller{robotMgr: robotMgr, rbacMgr: rbacMgr, proMgr: projectMgr} + ctx := context.TODO() + + robotMgr.On("Count", mock.Anything, mock.Anything).Return(int64(1), nil) + + ct, err := c.Count(ctx, nil) + suite.Nil(err) + suite.Equal(int64(1), ct) +} + +func (suite *ControllerTestSuite) TestCreate() { + secretKeyPath := "/tmp/secretkey" + _, err := test.GenerateKey(secretKeyPath) + suite.Nil(err) + defer os.Remove(secretKeyPath) + suite.T().Setenv("KEY_PATH", secretKeyPath) + + conf := map[string]any{ + common.RobotTokenDuration: "30", + } + config.InitWithSettings(conf) + + projectMgr := &project.Manager{} + rbacMgr := &rbac.Manager{} + robotMgr := &robot.Manager{} + + c := controller{robotMgr: robotMgr, rbacMgr: rbacMgr, proMgr: projectMgr} + secCtx := &testsec.Context{} + secCtx.On("GetUsername").Return("security-context-user") + ctx := security.NewContext(context.Background(), secCtx) + projectMgr.On("Get", mock.Anything, mock.Anything).Return(&proModels.Project{ProjectID: 1, Name: "library"}, nil) + robotMgr.On("Create", mock.Anything, mock.Anything).Return(int64(1), nil) + rbacMgr.On("CreateRbacPolicy", mock.Anything, mock.Anything, mock.Anything).Return(int64(1), nil) + rbacMgr.On("CreatePermission", mock.Anything, mock.Anything, mock.Anything).Return(int64(1), nil) + + id, _, err := c.Create(ctx, &Robot{ + Robot: model.Robot{ + Name: "testcreate", + Description: "testcreate", + Duration: 0, + }, + ProjectName: "library", + Level: LEVELPROJECT, + Permissions: []*Permission{ + { + Kind: "project", + Namespace: "library", + Access: []*types.Policy{ + { + Resource: "repository", + Action: "push", + }, + { + Resource: "repository", + Action: "pull", + }, + }, + }, + }, + }) + suite.Nil(err) + suite.Equal(int64(1), id) +} + +func (suite *ControllerTestSuite) TestDelete() { + projectMgr := &project.Manager{} + rbacMgr := &rbac.Manager{} + robotMgr := &robot.Manager{} + + c := controller{robotMgr: robotMgr, rbacMgr: rbacMgr, proMgr: projectMgr} + ctx := context.TODO() + + robotMgr.On("Get", mock.Anything, mock.Anything).Return(&model.Robot{ + Name: "library+test", + Description: "test get method", + ProjectID: 1, + Secret: utils.GetNonce(), + }, nil) + robotMgr.On("Delete", mock.Anything, mock.Anything).Return(nil) + rbacMgr.On("DeletePermissionsByRole", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + err := c.Delete(ctx, int64(1)) + suite.Nil(err) +} + +func (suite *ControllerTestSuite) TestUpdate() { + projectMgr := &project.Manager{} + rbacMgr := &rbac.Manager{} + robotMgr := &robot.Manager{} + + c := controller{robotMgr: robotMgr, rbacMgr: rbacMgr, proMgr: projectMgr} + ctx := context.TODO() + + conf := map[string]any{ + common.RobotPrefix: "robot$", + } + config.InitWithSettings(conf) + + robotMgr.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + projectMgr.On("Get", mock.Anything, mock.Anything).Return(&proModels.Project{ProjectID: 1, Name: "library"}, nil) + rbacMgr.On("DeletePermissionsByRole", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + rbacMgr.On("CreateRbacPolicy", mock.Anything, mock.Anything, mock.Anything).Return(int64(1), nil) + rbacMgr.On("CreatePermission", mock.Anything, mock.Anything, mock.Anything).Return(int64(1), nil) + + err := c.Update(ctx, &Robot{ + Robot: model.Robot{ + Name: "testcreate", + Description: "testcreate", + Duration: 0, + }, + ProjectName: "library", + Level: LEVELPROJECT, + Permissions: []*Permission{ + { + Kind: "project", + Namespace: "library", + Access: []*types.Policy{ + { + Resource: "repository", + Action: "push", + }, + { + Resource: "repository", + Action: "pull", + }, + }, + }, + }, + }, &Option{ + WithPermission: true, + }) + suite.Nil(err) +} + +func (suite *ControllerTestSuite) TestList() { + projectMgr := &project.Manager{} + rbacMgr := &rbac.Manager{} + robotMgr := &robot.Manager{} + + c := controller{robotMgr: robotMgr, rbacMgr: rbacMgr, proMgr: projectMgr} + ctx := context.TODO() + + projectMgr.On("Get", mock.Anything, mock.Anything).Return(&proModels.Project{ProjectID: 1, Name: "library"}, nil) + robotMgr.On("List", mock.Anything, mock.Anything).Return([]*model.Robot{ + { + Name: "test", + Description: "test list method", + ProjectID: 1, + Secret: utils.GetNonce(), + }, + }, nil) + rbacMgr.On("GetPermissionsByRole", mock.Anything, mock.Anything, mock.Anything).Return([]*rbac_model.UniversalRolePermission{ + { + RoleType: ROBOTTYPE, + RoleID: 1, + Scope: "/project/1", + Resource: "repository", + Action: "pull", + }, + { + RoleType: ROBOTTYPE, + RoleID: 1, + Scope: "/project/1", + Resource: "repository", + Action: "push", + }, + }, nil) + projectMgr.On("Get", mock.Anything, mock.Anything).Return(&proModels.Project{ProjectID: 1, Name: "library"}, nil) + rs, err := c.List(ctx, &q.Query{ + Keywords: map[string]any{ + "name": "test3", + }, + }, &Option{ + WithPermission: true, + }) + suite.Nil(err) + suite.Equal("project", rs[0].Permissions[0].Kind) + suite.Equal("library", rs[0].Permissions[0].Namespace) + suite.Equal("pull", rs[0].Permissions[0].Access[0].Action.String()) + suite.Equal("project", rs[0].Level) + +} + +func (suite *ControllerTestSuite) TestToScope() { + projectMgr := &project.Manager{} + rbacMgr := &rbac.Manager{} + robotMgr := &robot.Manager{} + + c := controller{robotMgr: robotMgr, rbacMgr: rbacMgr, proMgr: projectMgr} + ctx := context.TODO() + + projectMgr.On("Get", mock.Anything, mock.Anything).Return(&proModels.Project{ProjectID: 1, Name: "library"}, nil) + + p := &Permission{ + Kind: "system", + Namespace: "/", + } + scope, err := c.toScope(ctx, p) + suite.Nil(err) + suite.Equal("/system", scope) + + p = &Permission{ + Kind: "system", + Namespace: "&", + } + _, err = c.toScope(ctx, p) + suite.NotNil(err) + + p = &Permission{ + Kind: "project", + Namespace: "library", + } + scope, err = c.toScope(ctx, p) + suite.Nil(err) + suite.Equal("/project/1", scope) + + p = &Permission{ + Kind: "project", + Namespace: "*", + } + scope, err = c.toScope(ctx, p) + suite.Nil(err) + suite.Equal("/project/*", scope) + +} + +func (suite *ControllerTestSuite) TestIsValidSec() { + sec := "1234abcdABCD" + suite.True(IsValidSec(sec)) + sec = "1234abcd" + suite.False(IsValidSec(sec)) + sec = "123abc" + suite.False(IsValidSec(sec)) + // secret of length 128 characters long should be ok + sec = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcd" + suite.True(IsValidSec(sec)) + // secret of length larger than 128 characters long, such as 129 characters long, should return false + sec = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcde" + suite.False(IsValidSec(sec)) +} + +func (suite *ControllerTestSuite) TestCreateSec() { + _, pwd, _, err := CreateSec() + suite.Nil(err) + suite.True(IsValidSec(pwd)) +} +func TestControllerTestSuite(t *testing.T) { + suite.Run(t, &ControllerTestSuite{}) +} + +*/ diff --git a/src/controller/role/model.go b/src/controller/role/model.go new file mode 100644 index 00000000000..85f2fc9b673 --- /dev/null +++ b/src/controller/role/model.go @@ -0,0 +1,64 @@ +// 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 role + +import ( + "github.com/goharbor/harbor/src/pkg/permission/types" + "github.com/goharbor/harbor/src/pkg/role/model" +) + +const ( + LEVELROLE = "project-role" + + // SCOPEALLPROJECT ... + SCOPEALLPROJECT = "/project/*" + + // ROLETYPE ... + ROLETYPE = "project-role" +) + +// Role ... +type Role struct { + model.Role + Level string + Editable bool `json:"editable"` + Permissions []*Permission `json:"permissions"` +} + +// IsSysLevel, true is a system level robot, others are project level. + +// setLevel = project-role +func (r *Role) setLevel() { + r.Level = LEVELROLE +} + +// setEditable, no secret and no permissions should be a old format robot, and it's not editable. +func (r *Role) setEditable() { + r.Editable = true +} + +// Permission ... +type Permission struct { + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Access []*types.Policy `json:"access"` + Scope string `json:"-"` +} + +// Option ... +type Option struct { + WithPermission bool + Operator string +} diff --git a/src/controller/role/model_test.go b/src/controller/role/model_test.go new file mode 100644 index 00000000000..d31413373cc --- /dev/null +++ b/src/controller/role/model_test.go @@ -0,0 +1,120 @@ +package role + +//TODO + +/* +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/goharbor/harbor/src/pkg/permission/types" + "github.com/goharbor/harbor/src/pkg/robot/model" + htesting "github.com/goharbor/harbor/src/testing" +) + +type ModelTestSuite struct { + htesting.Suite +} + +func (suite *ModelTestSuite) TestSetLevel() { + r := Robot{ + Robot: model.Robot{ + ProjectID: 0, + }, + } + r.setLevel() + + suite.Equal(LEVELSYSTEM, r.Level) + + r = Robot{ + Robot: model.Robot{ + ProjectID: 1, + }, + } + r.setLevel() + suite.Equal(LEVELPROJECT, r.Level) +} + +func (suite *ModelTestSuite) TestIsSysLevel() { + r := Robot{ + Robot: model.Robot{ + ProjectID: 0, + }, + } + r.setLevel() + suite.True(r.IsSysLevel()) + + r = Robot{ + Robot: model.Robot{ + ProjectID: 1, + }, + } + r.setLevel() + suite.False(r.IsSysLevel()) +} + +func (suite *ModelTestSuite) TestSetEditable() { + r := Robot{ + Robot: model.Robot{ + ProjectID: 0, + }, + } + r.setEditable() + suite.False(r.Editable) + + r = Robot{ + Robot: model.Robot{ + Name: "testcreate", + Description: "testcreate", + Duration: 0, + }, + ProjectName: "library", + Level: LEVELPROJECT, + Permissions: []*Permission{ + { + Kind: "project", + Namespace: "library", + Access: []*types.Policy{ + { + Resource: "repository", + Action: "push", + }, + { + Resource: "repository", + Action: "pull", + }, + }, + }, + }, + } + r.setEditable() + suite.True(r.Editable) +} + +func (suite *ModelTestSuite) TestIsCoverAll() { + p := &Permission{ + Kind: "project", + Namespace: "library", + Access: []*types.Policy{ + { + Resource: "repository", + Action: "push", + }, + { + Resource: "repository", + Action: "pull", + }, + }, + Scope: "/project/*", + } + suite.True(p.IsCoverAll()) + + p.Scope = "/system" + suite.False(p.IsCoverAll()) +} + +func TestModelTestSuite(t *testing.T) { + suite.Run(t, &ModelTestSuite{}) +} +*/ diff --git a/src/lib/config/userconfig.go b/src/lib/config/userconfig.go index 99f41658ef9..7bfa7027ba6 100644 --- a/src/lib/config/userconfig.go +++ b/src/lib/config/userconfig.go @@ -217,6 +217,11 @@ func RobotPrefix(ctx context.Context) string { return DefaultMgr().Get(ctx, common.RobotNamePrefix).GetString() } +// RolePrefix user defined role name prefix. +func RolePrefix(ctx context.Context) string { + return DefaultMgr().Get(ctx, common.RoleNamePrefix).GetString() +} + // SplitAndTrim ... func SplitAndTrim(s, sep string) []string { res := make([]string, 0) diff --git a/src/pkg/permission/evaluator/evaluator.go b/src/pkg/permission/evaluator/evaluator.go index 0f31fbfdbd7..0e32d8f3ce9 100644 --- a/src/pkg/permission/evaluator/evaluator.go +++ b/src/pkg/permission/evaluator/evaluator.go @@ -56,6 +56,7 @@ func (evaluators Evaluators) Add(newEvaluators ...Evaluator) Evaluators { // HasPermission returns true when one of evaluator has action permission for the resource func (evaluators Evaluators) HasPermission(ctx context.Context, resource types.Resource, action types.Action) bool { + for _, evaluator := range evaluators { if evaluator != nil && evaluator.HasPermission(ctx, resource, action) { return true diff --git a/src/pkg/permission/evaluator/rbac/casbin_adapter.go b/src/pkg/permission/evaluator/rbac/casbin_adapter.go index d39cbf1efed..3078b47a3ce 100644 --- a/src/pkg/permission/evaluator/rbac/casbin_adapter.go +++ b/src/pkg/permission/evaluator/rbac/casbin_adapter.go @@ -32,6 +32,7 @@ func policyLinesOfRole(rbacRole types.RBACRole) []string { lines := []string{} roleName := rbacRole.GetRoleName() + // returns empty policy lines if role name is empty if roleName == "" { return lines @@ -72,8 +73,12 @@ func (a *adapter) getPolicyLines() []string { lines = append(lines, policyLinesOfRBACUser(a.rbacUser)...) + // lines = append(lines, policyLinesOfRole(role)...) for _, role := range a.rbacUser.GetRoles() { - lines = append(lines, policyLinesOfRole(role)...) + for _, policy := range role.GetPolicies() { + line := fmt.Sprintf("p, %s, %s, %s, %s", role.GetRoleName(), policy.Resource, policy.Action, policy.GetEffect()) + lines = append(lines, line) + } lines = append(lines, fmt.Sprintf("g, %s, %s", username, role.GetRoleName())) } diff --git a/src/pkg/role/dao/dao.go b/src/pkg/role/dao/dao.go new file mode 100644 index 00000000000..d4280ea76a5 --- /dev/null +++ b/src/pkg/role/dao/dao.go @@ -0,0 +1,131 @@ +// 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/lib/errors" + "github.com/goharbor/harbor/src/lib/orm" + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg/role/model" +) + +// DAO defines the interface to access the role data model +type DAO interface { + // Create ... + Create(ctx context.Context, r *model.Role) (int64, error) + + // Update ... + Update(ctx context.Context, r *model.Role, props ...string) error + + // Get ... + Get(ctx context.Context, id int64) (*model.Role, error) + + // Count returns the total count of roles according to the query + Count(ctx context.Context, query *q.Query) (total int64, err error) + + // List ... + List(ctx context.Context, query *q.Query) ([]*model.Role, error) + + // Delete ... + Delete(ctx context.Context, id int64) error +} + +// New creates a default implementation for Dao +func New() DAO { + return &dao{} +} + +type dao struct{} + +func (d *dao) Create(ctx context.Context, r *model.Role) (int64, error) { + ormer, err := orm.FromContext(ctx) + if err != nil { + return 0, err + } + id, err := ormer.Insert(r) + if err != nil { + return 0, orm.WrapConflictError(err, "role %s already exists", r.Name) + } + return id, err +} + +func (d *dao) Update(ctx context.Context, r *model.Role, props ...string) error { + ormer, err := orm.FromContext(ctx) + if err != nil { + return err + } + n, err := ormer.Update(r, props...) + if err != nil { + return err + } + if n == 0 { + return errors.NotFoundError(nil).WithMessagef("role %d not found", r.ID) + } + return nil +} + +func (d *dao) Get(ctx context.Context, id int64) (*model.Role, error) { + r := &model.Role{ + ID: id, + } + ormer, err := orm.FromContext(ctx) + if err != nil { + return nil, err + } + if err := ormer.Read(r); err != nil { + return nil, orm.WrapNotFoundError(err, "role %d not found", id) + } + return r, nil +} + +func (d *dao) Count(ctx context.Context, query *q.Query) (int64, error) { + qs, err := orm.QuerySetterForCount(ctx, &model.Role{}, query) + if err != nil { + return 0, err + } + return qs.Count() +} + +func (d *dao) Delete(ctx context.Context, id int64) error { + ormer, err := orm.FromContext(ctx) + if err != nil { + return err + } + n, err := ormer.Delete(&model.Role{ + ID: id, + }) + if err != nil { + return err + } + if n == 0 { + return errors.NotFoundError(nil).WithMessagef("role %d not found", id) + } + return nil +} + +func (d *dao) List(ctx context.Context, query *q.Query) ([]*model.Role, error) { + roles := []*model.Role{} + + qs, err := orm.QuerySetter(ctx, &model.Role{}, query) + if err != nil { + return nil, err + } + if _, err = qs.All(&roles); err != nil { + return nil, err + } + return roles, nil +} diff --git a/src/pkg/role/dao/dao_test.go b/src/pkg/role/dao/dao_test.go new file mode 100644 index 00000000000..7a7d3d52c17 --- /dev/null +++ b/src/pkg/role/dao/dao_test.go @@ -0,0 +1,133 @@ +package dao + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "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/role/model" + htesting "github.com/goharbor/harbor/src/testing" +) + +type DaoTestSuite struct { + htesting.Suite + dao DAO + + roleID1 int64 + roleID2 int64 + roleID3 int64 + roleID4 int64 +} + +func (suite *DaoTestSuite) SetupSuite() { + suite.Suite.SetupSuite() + suite.dao = New() + suite.Suite.ClearTables = []string{"role"} + suite.roles() +} + +func (suite *DaoTestSuite) roles() { + var err error + suite.roleID1, err = suite.dao.Create(orm.Context(), &model.Role{ + Name: "test1", + }) + suite.Nil(err) + + suite.roleID2, err = suite.dao.Create(orm.Context(), &model.Role{ + Name: "test2", + }) + suite.Nil(err) + + suite.roleID3, err = suite.dao.Create(orm.Context(), &model.Role{ + Name: "test3", + }) + suite.Nil(err) + + suite.roleID4, err = suite.dao.Create(orm.Context(), &model.Role{ + Name: "test4", + }) + suite.Nil(err) +} + +func (suite *DaoTestSuite) TestCreate() { + r := &model.Role{ + Name: "test1", + } + _, err := suite.dao.Create(orm.Context(), r) + suite.NotNil(err) + suite.True(errors.IsErr(err, errors.ConflictCode)) +} + +func (suite *DaoTestSuite) TestDelete() { + err := suite.dao.Delete(orm.Context(), 1234) + suite.Require().NotNil(err) + suite.True(errors.IsErr(err, errors.NotFoundCode)) + + err = suite.dao.Delete(orm.Context(), suite.roleID2) + suite.Nil(err) +} + +func (suite *DaoTestSuite) TestList() { + roles, err := suite.dao.List(orm.Context(), &q.Query{ + Keywords: map[string]any{ + "name": "test3", + }, + }) + suite.Require().Nil(err) + suite.Equal(suite.roleID3, roles[0].ID) + + r := &model.Role{ + Name: "testvisible", + } + _, err = suite.dao.Create(orm.Context(), r) + suite.Nil(err) + roles, err = suite.dao.List(orm.Context(), &q.Query{ + Keywords: map[string]any{ + "name": "testvisible", + }, + }) + suite.Equal(len(roles), 0) +} + +func (suite *DaoTestSuite) TestGet() { + _, err := suite.dao.Get(orm.Context(), 1234) + suite.Require().NotNil(err) + suite.True(errors.IsErr(err, errors.NotFoundCode)) + + r, err := suite.dao.Get(orm.Context(), suite.roleID3) + suite.Nil(err) + suite.Equal("test3", r.Name) +} + +func (suite *DaoTestSuite) TestCount() { + // nil query + total, err := suite.dao.Count(orm.Context(), nil) + suite.Nil(err) + suite.True(total > 0) + + // query by name + total, err = suite.dao.Count(orm.Context(), &q.Query{ + Keywords: map[string]any{ + "name": "test3", + }, + }) + suite.Nil(err) + suite.Equal(int64(1), total) +} + +func (suite *DaoTestSuite) TestUpdate() { + r := &model.Role{ + ID: suite.roleID3, + } + + err := suite.dao.Update(orm.Context(), r) + suite.Nil(err) + +} + +func TestDaoTestSuite(t *testing.T) { + suite.Run(t, &DaoTestSuite{}) +} diff --git a/src/pkg/role/manager.go b/src/pkg/role/manager.go new file mode 100644 index 00000000000..dd5973760a9 --- /dev/null +++ b/src/pkg/role/manager.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 role + +import ( + "context" + + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg/role/dao" + "github.com/goharbor/harbor/src/pkg/role/model" +) + +var ( + // Mgr is a global variable for the default role manager implementation + Mgr = NewManager() +) + +// Manager ... +type Manager interface { + // Get ... + Get(ctx context.Context, id int64) (*model.Role, error) + + // Count returns the total count of robots according to the query + Count(ctx context.Context, query *q.Query) (total int64, err error) + + // Create ... + Create(ctx context.Context, m *model.Role) (int64, error) + + // Delete ... + Delete(ctx context.Context, id int64) error + + // Update ... + Update(ctx context.Context, m *model.Role, props ...string) error + + // List ... + List(ctx context.Context, query *q.Query) ([]*model.Role, error) +} + +var _ Manager = &manager{} + +type manager struct { + dao dao.DAO +} + +// NewManager return a new instance of defaultRoleManager +func NewManager() Manager { + return &manager{ + dao: dao.New(), + } +} + +// Get ... +func (m *manager) Get(ctx context.Context, id int64) (*model.Role, error) { + return m.dao.Get(ctx, id) +} + +// Count ... +func (m *manager) Count(ctx context.Context, query *q.Query) (total int64, err error) { + return m.dao.Count(ctx, query) +} + +// Create ... +func (m *manager) Create(ctx context.Context, r *model.Role) (int64, error) { + return m.dao.Create(ctx, r) +} + +// Delete ... +func (m *manager) Delete(ctx context.Context, id int64) error { + return m.dao.Delete(ctx, id) +} + +// Update ... +func (m *manager) Update(ctx context.Context, r *model.Role, props ...string) error { + return m.dao.Update(ctx, r, props...) +} + +// List ... +func (m *manager) List(ctx context.Context, query *q.Query) ([]*model.Role, error) { + return m.dao.List(ctx, query) +} diff --git a/src/pkg/role/manager_test.go b/src/pkg/role/manager_test.go new file mode 100644 index 00000000000..ed77ad6591e --- /dev/null +++ b/src/pkg/role/manager_test.go @@ -0,0 +1,71 @@ +package role + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/goharbor/harbor/src/pkg/role/model" + "github.com/goharbor/harbor/src/testing/mock" + "github.com/goharbor/harbor/src/testing/pkg/role/dao" +) + +type managerTestSuite struct { + suite.Suite + mgr *manager + dao *dao.DAO +} + +func (m *managerTestSuite) SetupTest() { + m.dao = &dao.DAO{} + m.mgr = &manager{ + dao: m.dao, + } +} + +func (m *managerTestSuite) TestCreate() { + m.dao.On("Create", mock.Anything, mock.Anything).Return(int64(1), nil) + _, err := m.mgr.Create(context.Background(), &model.Role{}) + m.Nil(err) + m.dao.AssertExpectations(m.T()) +} + +func (m *managerTestSuite) 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 *managerTestSuite) TestDelete() { + m.dao.On("Delete", mock.Anything, mock.Anything).Return(nil) + err := m.mgr.Delete(context.Background(), 1) + m.Nil(err) + m.dao.AssertExpectations(m.T()) +} + +func (m *managerTestSuite) TestUpdate() { + m.dao.On("Update", mock.Anything, mock.Anything).Return(nil) + err := m.mgr.Update(context.Background(), &model.Role{}) + m.Nil(err) + m.dao.AssertExpectations(m.T()) +} + +func (m *managerTestSuite) TestList() { + m.dao.On("List", mock.Anything, mock.Anything).Return([]*model.Role{ + { + ID: 1, + Name: "role", + }, + }, nil) + rpers, err := m.mgr.List(context.Background(), nil) + m.Nil(err) + m.Equal(1, len(rpers)) + m.dao.AssertExpectations(m.T()) +} + +func TestManager(t *testing.T) { + suite.Run(t, &managerTestSuite{}) +} diff --git a/src/pkg/role/model/model.go b/src/pkg/role/model/model.go new file mode 100644 index 00000000000..72ccc020a0d --- /dev/null +++ b/src/pkg/role/model/model.go @@ -0,0 +1,67 @@ +// 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 model + +import ( + "encoding/json" + "time" + + "github.com/beego/beego/v2/client/orm" + + "github.com/goharbor/harbor/src/lib/errors" +) + +func init() { + orm.RegisterModel(&Role{}) +} + +// Role holds the details of a role. +type Role struct { + ID int64 `orm:"pk;auto;column(role_id)" json:"id"` + Name string `orm:"column(name)" json:"name" sort:"default"` + RoleMask int64 `orm:"column(role_mask)" json:"role_mask"` + RoleCode string `orm:"column(role_code)" json:"role_code"` + IsBuiltin bool `orm:"column(is_builtin)" json:"is_builtin"` + Description string `orm:"column(description)" json:"description"` + Modified bool `orm:"column(modified)" json:"modified"` + CreatedBy string `orm:"column(created_by)" json:"created_by"` + CreatedAt time.Time `orm:"column(created_at);auto_now_add;type(datetime)" json:"created_at"` + ModifiedBy string `orm:"column(modified_by)" json:"modified_by"` + ModifiedAt time.Time `orm:"column(modified_at);auto_now;type(datetime)" json:"modified_at"` +} + +// TableName ... +func (r *Role) TableName() string { + return "role" +} + +// FromJSON parses role from json data +func (r *Role) FromJSON(jsonData string) error { + if len(jsonData) == 0 { + return errors.New("empty json data to parse") + } + + return json.Unmarshal([]byte(jsonData), r) +} + +// ToJSON marshals role to JSON data +func (r *Role) ToJSON() (string, error) { + data, err := json.Marshal(r) + if err != nil { + return "", err + } + + return string(data), nil +} diff --git a/src/portal/src/app/base/base.module.ts b/src/portal/src/app/base/base.module.ts index 6beb77fe643..e1b7033db87 100644 --- a/src/portal/src/app/base/base.module.ts +++ b/src/portal/src/app/base/base.module.ts @@ -51,6 +51,15 @@ const routes: Routes = [ m => m.UserModule ), }, + // MGS TODO + { + path: 'roles', + canActivate: [SystemAdminGuard], + loadChildren: () => + import( + './left-side-nav/roles/roles.module' + ).then(m => m.RolesModule), + }, { path: 'robot-accounts', canActivate: [SystemAdminGuard], diff --git a/src/portal/src/app/base/harbor-shell/harbor-shell.component.html b/src/portal/src/app/base/harbor-shell/harbor-shell.component.html index 14987f75e43..47907c83918 100644 --- a/src/portal/src/app/base/harbor-shell/harbor-shell.component.html +++ b/src/portal/src/app/base/harbor-shell/harbor-shell.component.html @@ -52,6 +52,16 @@ clrVerticalNavIcon> {{ 'SIDE_NAV.SYSTEM_MGMT.USER' | translate }} + + + + Roles + {{ roleInfo[p.current_user_role_id] ? (roleInfo[p.current_user_role_id] | translate) - : '-' - }} + : p.current_user_role_ids + }} {{ projectTypeMap[p.registry_id ? 1 : 0] | translate }} diff --git a/src/portal/src/app/base/left-side-nav/roles/add-role/add-role.component.html b/src/portal/src/app/base/left-side-nav/roles/add-role/add-role.component.html new file mode 100644 index 00000000000..a15243c85d0 --- /dev/null +++ b/src/portal/src/app/base/left-side-nav/roles/add-role/add-role.component.html @@ -0,0 +1,130 @@ + + + +

+ {{ (isEditMode ? 'ROLE.EDIT_ROLE_SUMMARY' : 'ROLE.CREATEROLE_SUMMARY') | translate }} +

+
+ {{ + 'BUTTON.CANCEL' | translate + }} + {{ + 'ROLE.BACK' | translate + }} + {{ + 'ROLE.NEXT' | translate + }} + {{ + 'ROLE.FINISH' | translate + }} + + + + {{ + 'ROLE.BASIC_INFO' | translate + }} +
+
+ + +
+ +
+
+ + + +
+ + + {{ 'ROLE.ACCOUNT_EXISTING' | translate }} + + + {{ 'ROLE.NAME_TOOLTIP' | translate }} + + +
+
+ + +
+ +
+
+ +
+
+
+ +
+
+
+ + + + {{ + 'ROLE.SELECT_PERMISSIONS' | translate + }} + +
+
+ + +
+
+
+
diff --git a/src/portal/src/app/base/left-side-nav/roles/add-role/add-role.component.scss b/src/portal/src/app/base/left-side-nav/roles/add-role/add-role.component.scss new file mode 100644 index 00000000000..4d0cb69f733 --- /dev/null +++ b/src/portal/src/app/base/left-side-nav/roles/add-role/add-role.component.scss @@ -0,0 +1,64 @@ +.padding-left-0 { + padding-left: 0; +} + +.no-margin { + margin: 0; +} + +.permission{ + padding-top: 0.1rem; + color: #000; +} + +.padding-left-120{ + padding-left: 126px; +} + +.w-90{ + width: 90%; +} + +.date { + margin-top: -0.9rem; +} + +.input-width-date { + width: 265px; +} + +.flex { + display: flex; + align-items: center; + justify-content: space-between; +} + +.input-width { + width: 16rem; +} + +.description-input { + width: 16rem; + resize: vertical; +} + +.expiration { + margin-left: 1rem; +} + +/* stylelint-disable */ +.showWarning { + color: #b3a000; +} + +.icon { + margin-top: 3px; +} + +.align-center { + align-items: center; +} + +:host::ng-deep.modal-dialog { + width: 46.2rem; +} diff --git a/src/portal/src/app/base/left-side-nav/roles/add-role/add-role.component.spec.ts b/src/portal/src/app/base/left-side-nav/roles/add-role/add-role.component.spec.ts new file mode 100644 index 00000000000..6729bcbe21b --- /dev/null +++ b/src/portal/src/app/base/left-side-nav/roles/add-role/add-role.component.spec.ts @@ -0,0 +1,61 @@ +// 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. +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AddRoleComponent } from './add-role.component'; +import { of } from 'rxjs'; +import { MessageHandlerService } from '../../../../shared/services/message-handler.service'; +import { delay } from 'rxjs/operators'; +import { RoleService } from '../../../../../../ng-swagger-gen/services/role.service'; +import { OperationService } from '../../../../shared/components/operation/operation.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { SharedTestingModule } from '../../../../shared/shared.module'; + +describe('AddRoleComponent', () => { + let component: AddRoleComponent; + let fixture: ComponentFixture; + const fakedRoleService = { + ListRole() { + return of([]).pipe(delay(0)); + }, + }; + const fakedMessageHandlerService = { + showSuccess() {}, + error() {}, + }; + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [AddRoleComponent], + imports: [SharedTestingModule], + providers: [ + OperationService, + { provide: RoleService, useValue: fakedRoleService }, + { + provide: MessageHandlerService, + useValue: fakedMessageHandlerService, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AddRoleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/portal/src/app/base/left-side-nav/roles/add-role/add-role.component.ts b/src/portal/src/app/base/left-side-nav/roles/add-role/add-role.component.ts new file mode 100644 index 00000000000..ad993d58550 --- /dev/null +++ b/src/portal/src/app/base/left-side-nav/roles/add-role/add-role.component.ts @@ -0,0 +1,282 @@ +// 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. +import { + Component, + OnInit, + Input, + OnDestroy, + Output, + EventEmitter, + ViewChild, +} from '@angular/core'; +import { + debounceTime, + distinctUntilChanged, + filter, + finalize, + map, + switchMap, +} from 'rxjs/operators'; +import { MessageHandlerService } from '../../../../shared/services/message-handler.service'; +import { + NEW_EMPTY_ROLE, + onlyHasPushPermission, + PermissionsKinds, +} from '../roles-util'; +import { Role } from '../../../../../../ng-swagger-gen/models/role'; +import { NgForm } from '@angular/forms'; +import { ClrLoadingState, ClrWizard } from '@clr/angular'; +import { Subject, Subscription } from 'rxjs'; +import { RoleService } from '../../../../../../ng-swagger-gen/services/role.service'; +import { OperationService } from '../../../../shared/components/operation/operation.service'; +import { clone, isSameArrayValue } from '../../../../shared/units/utils'; +import { + operateChanges, + OperateInfo, + OperationState, +} from '../../../../shared/components/operation/operate'; +import { InlineAlertComponent } from '../../../../shared/components/inline-alert/inline-alert.component'; +import { errorHandler } from '../../../../shared/units/shared.utils'; +import { PermissionSelectPanelModes } from '../../../../shared/components/role-permissions-panel/role-permissions-panel.component'; +import { Permissions } from '../../../../../../ng-swagger-gen/models/permissions'; + +@Component({ + standalone: false, + selector: 'add-role', + templateUrl: './add-role.component.html', + styleUrls: ['./add-role.component.scss'], +}) +export class AddRoleComponent implements OnInit, OnDestroy { + @Input() projectId: number; + @Input() projectName: string; + isEditMode: boolean = false; + originalRoleForEdit: Role; + @Output() + addSuccess: EventEmitter = new EventEmitter(); + addRoleOpened: boolean = false; + role: Role = clone(NEW_EMPTY_ROLE); + isNameExisting: boolean = false; + loading: boolean = false; + checkNameOnGoing: boolean = false; + @ViewChild(InlineAlertComponent) + inlineAlertComponent: InlineAlertComponent; + @ViewChild('roleBasicForm', { static: true }) roleBasicForm: NgForm; + saveBtnState: ClrLoadingState = ClrLoadingState.DEFAULT; + private _nameSubject: Subject = new Subject(); + private _nameSubscription: Subscription; + + @Input() + roleMetadata: Permissions; + + @ViewChild('wizard') wizard: ClrWizard; + + constructor( + private roleService: RoleService, + private msgHandler: MessageHandlerService, + private operationService: OperationService + ) {} + + ngOnInit(): void { + this.subscribeName(); + } + + ngOnDestroy() { + if (this._nameSubscription) { + this._nameSubscription.unsubscribe(); + this._nameSubscription = null; + } + } + + subscribeName() { + if (!this._nameSubscription) { + this._nameSubscription = this._nameSubject + .pipe( + distinctUntilChanged(), + filter(name => { + if ( + this.isEditMode && + this.originalRoleForEdit && + this.originalRoleForEdit.name === name + ) { + return false; + } + return name?.length > 0; + }), + map(name => { + this.checkNameOnGoing = !!name; + return name; + }), + debounceTime(500), + switchMap(name => { + this.isNameExisting = false; + this.checkNameOnGoing = true; + return this.roleService + .ListRole({ + q: encodeURIComponent( + `Level=${PermissionsKinds.ROLE},ProjectID=${this.projectId},name=${name}` + ), + }) + .pipe( + finalize(() => (this.checkNameOnGoing = false)) + ); + }) + ) + .subscribe(res => { + if (res && res.length > 0) { + this.isNameExisting = true; + } + }); + } + } + + inputName() { + this._nameSubject.next(this.role.name); + } + + cancel() { + this.wizard.reset(); + this.reset(); + this.addRoleOpened = false; + } + + reset() { + this.open(false); + this.role = clone(NEW_EMPTY_ROLE); + } + + resetForEdit(role: Role) { + if (role.is_builtin) { + return; + } + this.open(true); + this.originalRoleForEdit = clone(role); + this.role = clone(role); + this.roleBasicForm.reset({ + name: this.role.name, + description: this.role.description, + }); + } + + open(isEditMode: boolean) { + this.isEditMode = isEditMode; + this.addRoleOpened = true; + this.isNameExisting = false; + this._nameSubject.next(''); + } + + disabled(): boolean { + if (!this.isEditMode) { + return !this.canAdd(); + } + return !this.canEdit(); + } + + canAdd(): boolean { + return ( + this.role?.permissions[0]?.access?.length > 0 && + !this.roleBasicForm.invalid + ); + } + + canEdit(): boolean { + if (!this.canAdd()) { + return false; + } + const permissionsChanged = !isSameArrayValue( + this.role.permissions[0].access, + this.originalRoleForEdit.permissions[0].access + ); + const descriptionChanged = + this.role.description !== this.originalRoleForEdit.description; + return permissionsChanged || descriptionChanged; + } + + save() { + const role: Role = clone(this.role); + role.permissions[0].kind = PermissionsKinds.ROLE; + role.permissions[0].namespace = this.projectName; + if (onlyHasPushPermission(role.permissions[0].access)) { + this.inlineAlertComponent.showInlineError( + 'ROLE.PUSH_PERMISSION_TOOLTIP' + ); + return; + } + this.saveBtnState = ClrLoadingState.LOADING; + if (this.isEditMode) { + const opeMessage = new OperateInfo(); + opeMessage.name = 'ROLE.UPDATE_ROLE'; + opeMessage.data.id = role.id; + opeMessage.state = OperationState.progressing; + opeMessage.data.name = role.name; + this.operationService.publishInfo(opeMessage); + this.roleService + .UpdateRole({ + roleId: this.originalRoleForEdit.id, + role, + }) + .subscribe( + () => { + this.saveBtnState = ClrLoadingState.SUCCESS; + this.addSuccess.emit(null); + this.cancel(); + operateChanges(opeMessage, OperationState.success); + this.msgHandler.showSuccess( + 'ROLE.UPDATE_ROLE_SUCCESSFULLY' + ); + }, + error => { + this.saveBtnState = ClrLoadingState.ERROR; + operateChanges( + opeMessage, + OperationState.failure, + errorHandler(error) + ); + this.inlineAlertComponent.showInlineError(error); + } + ); + } else { + const opeMessage = new OperateInfo(); + opeMessage.name = 'ROLE.ADD_ROLE'; + opeMessage.data.id = role.id; + opeMessage.state = OperationState.progressing; + opeMessage.data.name = `${this.projectName}+${role.name}`; + this.operationService.publishInfo(opeMessage); + this.roleService + .CreateRole({ role }) + .subscribe( + res => { + this.saveBtnState = ClrLoadingState.SUCCESS; + this.addSuccess.emit(res); + this.cancel(); + operateChanges(opeMessage, OperationState.success); + }, + error => { + this.saveBtnState = ClrLoadingState.ERROR; + this.inlineAlertComponent.showInlineError(error); + operateChanges( + opeMessage, + OperationState.failure, + errorHandler(error) + ); + } + ); + } + } + + clrWizardPageOnLoad() { + this.inlineAlertComponent.close(); + } + + protected readonly PermissionSelectPanelModes = PermissionSelectPanelModes; +} diff --git a/src/portal/src/app/base/left-side-nav/roles/roles-util.ts b/src/portal/src/app/base/left-side-nav/roles/roles-util.ts new file mode 100644 index 00000000000..f20fdd89518 --- /dev/null +++ b/src/portal/src/app/base/left-side-nav/roles/roles-util.ts @@ -0,0 +1,202 @@ +// 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. +import { Role } from '../../../../../ng-swagger-gen/models/role'; +import { Access } from '../../../../../ng-swagger-gen/models/access'; +import { RolePermission } from '../../../../../ng-swagger-gen/models/role-permission'; +import { Permission } from '../../../../../ng-swagger-gen/models/permission'; + +export interface FrontRole extends Role { + permissionScope?: { + coverAll?: boolean; + access?: Array; + }; +} + +export interface FrontAccess extends Access { + checked?: boolean; +} + +export enum PermissionsKinds { + ROLE = 'project-role', +} + +export enum Resource { + REPO = 'repository', + ARTIFACT = 'artifact', +} + +export enum Action { + PUSH = 'push', + PULL = 'pull', + READ = 'read', + CREATE = 'create', + LIST = 'list', + STOP = 'stop', + DELETE = 'delete', +} + +export const NAMESPACE_ALL_PROJECTS: string = '*'; + +export const NAMESPACE_SYSTEM: string = '/'; + +export const ACTION_RESOURCE_I18N_MAP = { + push: 'ROLE.PUSH', + pull: 'ROLE.PULL', + read: 'ROLE.READ', + create: 'ROLE.CREATE', + delete: 'ROLE.DELETE', + scan: 'ROLE.SCAN', + stop: 'ROLE.STOP', + list: 'ROLE.LIST', + update: 'ROLE.UPDATE', + 'audit-log': 'ROLE.AUDIT_LOG', + 'preheat-instance': 'ROLE.PREHEAT_INSTANCE', + '': 'ROLE.PROJECT', + project: 'ROLE.PROJECT', + 'replication-policy': 'ROLE.REPLICATION_POLICY', + replication: 'ROLE.REPLICATION', + 'replication-adapter': 'ROLE.REPLICATION_ADAPTER', + registry: 'ROLE.REGISTRY', + 'scan-all': 'ROLE.SCAN_ALL', + 'system-volumes': 'ROLE.SYSTEM_VOLUMES', + 'garbage-collection': 'ROLE.GARBAGE_COLLECTION', + 'purge-audit': 'ROLE.PURGE_AUDIT', + 'jobservice-monitor': 'ROLE.JOBSERVICE_MONITOR', + 'tag-retention': 'ROLE.TAG_RETENTION', + scanner: 'ROLE.SCANNER', + label: 'ROLE.LABEL', + 'export-cve': 'ROLE.EXPORT_CVE', + 'security-hub': 'ROLE.SECURITY_HUB', + catalog: 'ROLE.CATALOG', + metadata: 'ROLE.METADATA', + repository: 'ROLE.REPOSITORY', + artifact: 'ROLE.ARTIFACT', + tag: 'ROLE.TAG', + accessory: 'ROLE.ACCESSORY', + 'artifact-addition': 'ROLE.ARTIFACT_ADDITION', + 'artifact-label': 'ROLE.ARTIFACT_LABEL', + 'preheat-policy': 'ROLE.PREHEAT_POLICY', + 'immutable-tag': 'ROLE.IMMUTABLE_TAG', + log: 'ROLE.LOG', + 'notification-policy': 'ROLE.NOTIFICATION_POLICY', + quota: 'ROLE.QUOTA', + sbom: 'ROLE.SBOM', + role: 'ROLE.ROLE', + user: 'ROLE.USER', + 'user-group': 'ROLE.GROUP', + 'ldap-user': 'ROLE.LDAPUSER', + member: 'ROLE.MEMBER', +}; + +export function convertKey(key: string) { + return ACTION_RESOURCE_I18N_MAP[key] ? ACTION_RESOURCE_I18N_MAP[key] : key; +} + +export enum ExpirationType { + DEFAULT = 'default', + DAYS = 'days', + NEVER = 'never', +} + +export function onlyHasPushPermission(access: Access[]): boolean { + if (access && access.length) { + let hasPushPermission: boolean = false; + let hasPullPermission: boolean = false; + access.forEach(item => { + if ( + item.action === Action.PUSH && + item.resource === Resource.REPO + ) { + hasPushPermission = true; + } + if ( + item.action === Action.PULL && + item.resource === Resource.REPO + ) { + hasPullPermission = true; + } + }); + if (hasPushPermission && !hasPullPermission) { + return true; + } + } + return false; +} + + + +export function isCandidate( + candidatePermissions: Permission[], + permission: Access +): boolean { + if (candidatePermissions?.length) { + for (let i = 0; i < candidatePermissions.length; i++) { + if ( + (candidatePermissions[i].resource ?? '') === (permission.resource ?? '') && + candidatePermissions[i].action === permission.action + ) { + return true; + } + } + } + return false; +} + +export function hasPermission( + permissions: Access[], + permission: Access +): boolean { + if (permissions?.length) { + for (let i = 0; i < permissions.length; i++) { + if ( + (permissions[i].resource ?? '') === (permission.resource ?? '') && + permissions[i].action === permission.action + ) { + return true; + } + } + } + return false; +} + +export const NEW_EMPTY_ROLE: Role = { + permissions: [ + { + access: [], + }, + ], +}; + +export function getRoleAccess(r: Role): Access[] { + let systemPermissions: RolePermission[] = []; + systemPermissions = r.permissions; +/* if (r?.permissions?.length) { + systemPermissions = r.permissions.filter( + item => item.kind === PermissionsKinds.ROLE + ); + } +*/ + if (systemPermissions?.length) { + const map = {}; + systemPermissions.forEach(p => { + if (p?.access?.length) { + p.access.forEach(item => { + map[`${item.resource}@${item.action}`] = item; + }); + } + }); + return Object.values(map); + } + return []; +} diff --git a/src/portal/src/app/base/left-side-nav/roles/roles.component.html b/src/portal/src/app/base/left-side-nav/roles/roles.component.html new file mode 100644 index 00000000000..52b6e4751ff --- /dev/null +++ b/src/portal/src/app/base/left-side-nav/roles/roles.component.html @@ -0,0 +1,170 @@ +

{{ 'ROLE.ROLE_NAV' | translate }}

+
+
+
+
+
+ + + + +
+
+
+
+ + + + {{ 'MEMBER.ACTION' | translate + }} + + + + + + + + + {{ + 'ROLE.NAME' | translate + }} + {{ + 'ROLE.BUILTIN' | translate + }} + {{ + 'ROLE.DESCRIPTION' | translate + }} + {{ + 'ROLE.PERMISSIONS' | translate + }} + {{ + 'ROLE.CREATED_BY' | translate + }} + {{ + 'ROLE.CREATED_AT' | translate + }} + {{ + 'ROLE.MODIFIED_AT' | translate + }} + {{ + 'SYSTEM_ROBOT.NOT_FOUND' | translate + }} + @for (r of roles; track r) { + + {{ getRoleName(r) }} + + + + {{ r.description || '–' }} + + + + {{ 'SCHEDULE.NONE' | translate }} + + + + + + + {{ r.created_by || '–' }} + {{ r.created_at ? (r.created_at | harborDatetime:'short') : '–' }} + + {{ r.modified_at ? (r.modified_at | harborDatetime:'short') : '–' }} + {{ 'ROLE.MODIFIED' | translate }} + + + + } + + + {{ + 'PAGINATION.PAGE_SIZE' | translate + }} + {{ pagination.firstItem + 1 }} + - + {{ pagination.lastItem + 1 }} + {{ 'ROLE.OF' | translate }} + + {{ total }} {{ 'ROLE.ITEMS' | translate }} + + + +
+
+ + + + + diff --git a/src/portal/src/app/base/left-side-nav/roles/roles.component.scss b/src/portal/src/app/base/left-side-nav/roles/roles.component.scss new file mode 100644 index 00000000000..925deb14746 --- /dev/null +++ b/src/portal/src/app/base/left-side-nav/roles/roles.component.scss @@ -0,0 +1,48 @@ +.role-space { + margin-top: 28px; + position: relative; + + clr-icon.red-position { + margin-left: 2px; + } + /* stylelint-disable */ + .rightPos { + position: absolute; + z-index: 100; + right: 35px; + margin-top: 4px; + + .option-left { + padding-left: 16px; + position: relative; + top: 10px; + } + + .option-right { + padding-right: 16px; + + .refresh-btn { + cursor: pointer; + } + } + } +} + +.all-projects { + display: flex; + align-items: center; + height: 16px; +} + + +.icon { + margin-top: 3px; +} + +.projects-col { + min-width: 14rem !important; +} + +.permission-col { + min-width: 8rem !important; +} diff --git a/src/portal/src/app/base/left-side-nav/roles/roles.component.spec.ts b/src/portal/src/app/base/left-side-nav/roles/roles.component.spec.ts new file mode 100644 index 00000000000..a57c8b74fb2 --- /dev/null +++ b/src/portal/src/app/base/left-side-nav/roles/roles.component.spec.ts @@ -0,0 +1,168 @@ +// 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. +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { RolesComponent } from './roles.component'; +import { RoleService } from '../../../../../ng-swagger-gen/services/role.service'; +import { HttpHeaders, HttpResponse } from '@angular/common/http'; +import { of, Subscription } from 'rxjs'; +import { delay } from 'rxjs/operators'; +import { Role } from '../../../../../ng-swagger-gen/models/role'; +import { MessageHandlerService } from '../../../shared/services/message-handler.service'; +import { OperationService } from '../../../shared/components/operation/operation.service'; +import { ConfirmationDialogService } from '../../global-confirmation-dialog/confirmation-dialog.service'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; +import { ClarityModule } from '@clr/angular'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { SysteminfoService } from '../../../../../ng-swagger-gen/services/systeminfo.service'; +import { PermissionsService } from '../../../../../ng-swagger-gen/services/permissions.service'; + +const builtinRole: Role = { + id: 1, + name: 'projectAdmin', + is_builtin: true, + permissions: [], +}; + +const customRole: Role = { + id: 10, + name: 'myCustomRole', + is_builtin: false, + permissions: [], +}; + +const fakedRoleService = { + ListRoleResponse() { + const res: HttpResponse> = new HttpResponse>({ + headers: new HttpHeaders({ 'x-total-count': '2' }), + body: [builtinRole, customRole], + }); + return of(res).pipe(delay(0)); + }, +}; + +const fakedMessageHandlerService = { showSuccess() {}, error() {} }; +const fakedSystemInfoService = { getSystemInfo: () => of({}) }; +const fakedPermissionsService = { getPermissions: () => of({}) }; + +describe('RolesComponent', () => { + let component: RolesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + CommonModule, + ClarityModule, + HttpClientTestingModule, + RouterTestingModule, + BrowserAnimationsModule, + ], + declarations: [RolesComponent], + providers: [ + TranslateService, + ConfirmationDialogService, + OperationService, + { provide: MessageHandlerService, useValue: fakedMessageHandlerService }, + { provide: RoleService, useValue: fakedRoleService }, + { provide: SysteminfoService, useValue: fakedSystemInfoService }, + { provide: PermissionsService, useValue: fakedPermissionsService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RolesComponent); + component = fixture.componentInstance; + component.searchSub = new Subscription(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('isBuiltinSelected', () => { + it('returns false when nothing is selected', () => { + component.selectedRows = []; + expect(component.isBuiltinSelected()).toBeFalse(); + }); + + it('returns false when only custom roles are selected', () => { + component.selectedRows = [customRole]; + expect(component.isBuiltinSelected()).toBeFalse(); + }); + + it('returns true when a built-in role is selected', () => { + component.selectedRows = [builtinRole]; + expect(component.isBuiltinSelected()).toBeTrue(); + }); + + it('returns true when a mix of built-in and custom roles is selected', () => { + component.selectedRows = [builtinRole, customRole]; + expect(component.isBuiltinSelected()).toBeTrue(); + }); + }); + + describe('action button disabled state', () => { + it('edit button is disabled when a built-in role is selected', async () => { + fixture.autoDetectChanges(); + await fixture.whenStable(); + component.selectedRows = [builtinRole]; + fixture.detectChanges(); + const editBtn: HTMLButtonElement = fixture.nativeElement.querySelector( + '#system-robot-edit' + )?.closest('button'); + expect(editBtn?.disabled).toBeTrue(); + }); + + it('edit button is enabled when a custom role is selected', async () => { + fixture.autoDetectChanges(); + await fixture.whenStable(); + component.selectedRows = [customRole]; + fixture.detectChanges(); + const editBtn: HTMLButtonElement = fixture.nativeElement.querySelector( + '#system-robot-edit' + )?.closest('button'); + expect(editBtn?.disabled).toBeFalse(); + }); + + it('delete button is disabled when a built-in role is selected', async () => { + fixture.autoDetectChanges(); + await fixture.whenStable(); + component.selectedRows = [builtinRole]; + fixture.detectChanges(); + const deleteBtn: HTMLButtonElement = fixture.nativeElement.querySelector( + '#system-robot-delete' + )?.closest('button'); + expect(deleteBtn?.disabled).toBeTrue(); + }); + + it('delete button is disabled when a mix of built-in and custom roles is selected', async () => { + fixture.autoDetectChanges(); + await fixture.whenStable(); + component.selectedRows = [builtinRole, customRole]; + fixture.detectChanges(); + const deleteBtn: HTMLButtonElement = fixture.nativeElement.querySelector( + '#system-robot-delete' + )?.closest('button'); + expect(deleteBtn?.disabled).toBeTrue(); + }); + }); +}); diff --git a/src/portal/src/app/base/left-side-nav/roles/roles.component.ts b/src/portal/src/app/base/left-side-nav/roles/roles.component.ts new file mode 100644 index 00000000000..c4f8f884c98 --- /dev/null +++ b/src/portal/src/app/base/left-side-nav/roles/roles.component.ts @@ -0,0 +1,354 @@ +// 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. +import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; + +import { ViewTokenComponent } from '../../../shared/components/view-token/view-token.component'; +import { RoleService } from '../../../../../ng-swagger-gen/services/role.service'; +import { Role } from '../../../../../ng-swagger-gen/models/role'; +import { + clone, + getPageSizeFromLocalStorage, + getSortingString, + PageSizeMapKeys, + setPageSizeToLocalStorage, +} from '../../../shared/units/utils'; +import { ClrDatagridStateInterface, ClrLoadingState } from '@clr/angular'; +import { + catchError, + debounceTime, + distinctUntilChanged, + finalize, + map, + switchMap, +} from 'rxjs/operators'; +import { MessageHandlerService } from '../../../shared/services/message-handler.service'; +import { + FrontRole, + getRoleAccess, + NAMESPACE_ALL_PROJECTS, + NEW_EMPTY_ROLE, + PermissionsKinds, +} from './roles-util'; +import { forkJoin, Observable, of, Subscription } from 'rxjs'; +import { FilterComponent } from '../../../shared/components/filter/filter.component'; +import { HttpErrorResponse } from '@angular/common/http'; +import { + operateChanges, + OperateInfo, + OperationState, +} from '../../../shared/components/operation/operate'; +import { OperationService } from '../../../shared/components/operation/operation.service'; +import { DomSanitizer } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; +import { ConfirmationDialogService } from '../../global-confirmation-dialog/confirmation-dialog.service'; +import { + ConfirmationButtons, + ConfirmationState, + ConfirmationTargets, + PAGE_SIZE_OPTIONS, +} from '../../../shared/entities/shared.const'; +import { errorHandler } from '../../../shared/units/shared.utils'; +import { ConfirmationMessage } from '../../global-confirmation-dialog/confirmation-message'; +import { RolePermission } from '../../../../../ng-swagger-gen/models/role-permission'; +import { SysteminfoService } from '../../../../../ng-swagger-gen/services/systeminfo.service'; +import { Access } from '../../../../../ng-swagger-gen/models/access'; +import { PermissionSelectPanelModes } from '../../../shared/components/role-permissions-panel/role-permissions-panel.component'; +import { PermissionsService } from '../../../../../ng-swagger-gen/services/permissions.service'; +import { Permissions } from '../../../../../ng-swagger-gen/models/permissions'; +import {AddRoleComponent } from './add-role/add-role.component' + +@Component({ + standalone: false, + selector: 'roles', + templateUrl: './roles.component.html', + styleUrls: ['./roles.component.scss'], +}) +export class RolesComponent implements OnInit, OnDestroy { + clrPageSizeOptions: number[] = PAGE_SIZE_OPTIONS; + pageSize: number = getPageSizeFromLocalStorage( + PageSizeMapKeys.SYSTEM_ROBOT_COMPONENT + //TODO create page size for ROBOT + ); + currentPage: number = 1; + total: number = 0; + roles: FrontRole[] = []; + selectedRows: FrontRole[] = []; + loading: boolean = true; + loadingData: boolean = false; + addBtnState: ClrLoadingState = ClrLoadingState.DEFAULT; + @ViewChild(AddRoleComponent) + newRoleComponent: AddRoleComponent; + @ViewChild(FilterComponent, { static: true }) + filterComponent: FilterComponent; + searchSub: Subscription; + searchKey: string; + subscription: Subscription; + deltaTime: number; // the different between server time and local time + + roleMetadata: Permissions; + loadingMetadata: boolean = false; + constructor( + private roleService: RoleService, + private msgHandler: MessageHandlerService, + private operateDialogService: ConfirmationDialogService, + private operationService: OperationService, + private sanitizer: DomSanitizer, + private translate: TranslateService, + private systemInfoService: SysteminfoService, + private permissionService: PermissionsService + ) {} + ngOnInit() { + this.getRolePermissions(); + this.getCurrenTime(); + if (!this.searchSub) { + this.searchSub = this.filterComponent.filterTerms + .pipe( + debounceTime(500), + distinctUntilChanged(), + switchMap(roleSearchName => { + this.currentPage = 1; + this.selectedRows = []; + const queryParam: RoleService.ListRoleParams = { + page: this.currentPage, + pageSize: this.pageSize, + }; + this.searchKey = roleSearchName; + if (this.searchKey) { + queryParam.q = encodeURIComponent( + `name=~${this.searchKey}` + ); + } + this.loading = true; + return this.roleService + .ListRoleResponse(queryParam) + .pipe( + finalize(() => { + this.loading = false; + }) + ); + }) + ) + .subscribe( + response => { + this.total = Number.parseInt( + response.headers.get('x-total-count'), + 10 + ); + this.roles = response.body as Role[]; + }, + error => { + this.msgHandler.handleError(error); + } + ); + } + if (!this.subscription) { + this.subscription = + this.operateDialogService.confirmationConfirm$.subscribe( + message => { + if ( + message && + message.state === ConfirmationState.CONFIRMED && + message.source === ConfirmationTargets.ROLE + ) { + this.deleteRoles(message.data); + } + } + ); + } + } + + ngAfterViewInit() { + console.log("new role component: " + this.newRoleComponent); + } + + + ngOnDestroy() { + if (this.searchSub) { + this.searchSub.unsubscribe(); + this.searchSub = null; + } + if (this.subscription) { + this.subscription.unsubscribe(); + this.subscription = null; + } + } + + getRolePermissions() { + this.loadingData = true; + this.permissionService + .getPermissions() + .pipe(finalize(() => (this.loadingData = false))) + .subscribe(res => { + this.roleMetadata = res; + }); + } + + getCurrenTime() { + this.systemInfoService.getSystemInfo().subscribe(res => { + if (res?.current_time) { + this.deltaTime = + new Date().getTime() - + new Date(res?.current_time).getTime(); + } + }); + } + + clrLoad(state?: ClrDatagridStateInterface) { + if (state && state.page && state.page.size) { + this.pageSize = state.page.size; + setPageSizeToLocalStorage( + PageSizeMapKeys.SYSTEM_ROBOT_COMPONENT, + this.pageSize + ); + } + this.selectedRows = []; + const queryParam: RoleService.ListRoleParams = { + page: this.currentPage, + pageSize: this.pageSize, + sort: getSortingString(state), + }; + if (this.searchKey) { + queryParam.q = encodeURIComponent(`name=~${this.searchKey}`); + } + this.loading = true; + this.roleService + .ListRoleResponse(queryParam) + .pipe(finalize(() => (this.loading = false))) + .subscribe( + response => { + this.total = Number.parseInt( + response.headers.get('x-total-count'), + 10 + ); + this.roles = response.body as Role[]; + }, + err => { + this.msgHandler.error(err); + } + ); + } + openNewRoleModal(isEditMode: boolean) { + if (isEditMode) { + this.newRoleComponent.resetForEdit(clone(this.selectedRows[0])); + } else { + this.newRoleComponent.reset(); + } + } + + getProjects(r: Role): RolePermission[] { + const arr = []; + if (r && r.permissions && r.permissions.length) { + for (let i = 0; i < r.permissions.length; i++) { + if (r.permissions[i].kind === PermissionsKinds.ROLE) { + arr.push(r.permissions[i]); + } + } + } + return arr; + } + + refresh() { + this.currentPage = 1; + this.selectedRows = []; + this.clrLoad(); + } + deleteRoles(roles: Role[]) { + let observableLists: Observable[] = []; + if (roles && roles.length) { + roles.forEach(item => { + observableLists.push(this.deleteRole(item)); + }); + forkJoin(...observableLists).subscribe(resArr => { + let error; + if (resArr && resArr.length) { + resArr.forEach(item => { + if (item instanceof HttpErrorResponse) { + error = errorHandler(item); + } + }); + } + if (error) { + this.msgHandler.handleError(error); + } else { + this.msgHandler.showSuccess( + 'ROLE.DELETE_ROLE_SUCCESS' + ); + } + this.refresh(); + }); + } + } + deleteRole(role: Role): Observable { + let operMessage = new OperateInfo(); + operMessage.name = 'ROLE.DELETE_ROLE'; + operMessage.data.id = role.id; + operMessage.state = OperationState.progressing; + operMessage.data.name = role.name; + this.operationService.publishInfo(operMessage); + return this.roleService.DeleteRole({ roleId: role.id }).pipe( + map(() => { + operateChanges(operMessage, OperationState.success); + }), + catchError(error => { + const message = errorHandler(error); + operateChanges(operMessage, OperationState.failure, message); + return of(error); + }) + ); + } + openDeleteRolesDialog() { + const roleNames = this.selectedRows.map(role => role.name).join(','); + const deletionMessage = new ConfirmationMessage( + 'ROLE.DELETION_TITLE', + 'ROLE.DELETION_SUMMARY', + roleNames, + this.selectedRows, + ConfirmationTargets.ROLE, + ConfirmationButtons.DELETE_CANCEL + ); + this.operateDialogService.openComfirmDialog(deletionMessage); + } + + addSuccess(role: Role) { + if (role) { + this.translate + .get('ROLE.CREATED_SUCCESS', { param: role.name }); + // export to token file + const downLoadUrl = `data:text/json;charset=utf-8, ${encodeURIComponent( + JSON.stringify(role) + )}`; + } + this.refresh(); + } + + + + + isBuiltinSelected(): boolean { + return this.selectedRows.some(r => r.is_builtin); + } + + getRoleName(r: Role): string { + const key = `ROLE.ROLE_NAMES.${r.name}`; + const translated = this.translate.instant(key); + return translated === key ? r.name : translated; + } + + getRoleAccess(r: Role): Access[] { + return getRoleAccess(r); + } + + protected readonly NEW_EMPTY_ROLE = NEW_EMPTY_ROLE; + protected readonly PermissionSelectPanelModes = PermissionSelectPanelModes; +} diff --git a/src/portal/src/app/base/left-side-nav/roles/roles.module.ts b/src/portal/src/app/base/left-side-nav/roles/roles.module.ts new file mode 100644 index 00000000000..2090833025f --- /dev/null +++ b/src/portal/src/app/base/left-side-nav/roles/roles.module.ts @@ -0,0 +1,36 @@ +// 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. +import { NgModule } from '@angular/core'; +import { RolesComponent } from './roles.component'; +import { SharedModule } from '../../../shared/shared.module'; +import { RouterModule, Routes } from '@angular/router'; +import { AddRoleComponent } from './add-role/add-role.component'; +import { RolePermissionsPanelComponent } from '../../../shared/components/role-permissions-panel/role-permissions-panel.component'; + +const routes: Routes = [ + { + path: '', + component: RolesComponent, + }, +]; + +@NgModule({ + declarations: [ + RolesComponent, + AddRoleComponent, + RolePermissionsPanelComponent + ], + imports: [SharedModule, RouterModule.forChild(routes)], +}) +export class RolesModule {} diff --git a/src/portal/src/app/base/left-side-nav/system-robot-accounts/system-robot-accounts.component.ts b/src/portal/src/app/base/left-side-nav/system-robot-accounts/system-robot-accounts.component.ts index 6b646a869ba..4cf6bcf5143 100644 --- a/src/portal/src/app/base/left-side-nav/system-robot-accounts/system-robot-accounts.component.ts +++ b/src/portal/src/app/base/left-side-nav/system-robot-accounts/system-robot-accounts.component.ts @@ -178,6 +178,12 @@ export class SystemRobotAccountsComponent implements OnInit, OnDestroy { ); } } + + ngAfterViewInit() { + console.log("new robot component: " + this.newRobotComponent); + } + + ngOnDestroy() { if (this.searchSub) { this.searchSub.unsubscribe(); diff --git a/src/portal/src/app/base/left-side-nav/system-robot-accounts/system-robot-util.ts b/src/portal/src/app/base/left-side-nav/system-robot-accounts/system-robot-util.ts index dd58a2b130d..511ad7a2347 100644 --- a/src/portal/src/app/base/left-side-nav/system-robot-accounts/system-robot-util.ts +++ b/src/portal/src/app/base/left-side-nav/system-robot-accounts/system-robot-util.ts @@ -63,6 +63,7 @@ export const ACTION_RESOURCE_I18N_MAP = { update: 'ROBOT_ACCOUNT.UPDATE', 'audit-log': 'ROBOT_ACCOUNT.AUDIT_LOG', 'preheat-instance': 'ROBOT_ACCOUNT.PREHEAT_INSTANCE', + '': 'ROBOT_ACCOUNT.PROJECT', project: 'ROBOT_ACCOUNT.PROJECT', 'replication-policy': 'ROBOT_ACCOUNT.REPLICATION_POLICY', replication: 'ROBOT_ACCOUNT.REPLICATION', @@ -147,7 +148,7 @@ export function isCandidate( if (candidatePermissions?.length) { for (let i = 0; i < candidatePermissions.length; i++) { if ( - candidatePermissions[i].resource === permission.resource && + (candidatePermissions[i].resource ?? '') === (permission.resource ?? '') && candidatePermissions[i].action === permission.action ) { return true; @@ -164,7 +165,7 @@ export function hasPermission( if (permissions?.length) { for (let i = 0; i < permissions.length; i++) { if ( - permissions[i].resource === permission.resource && + (permissions[i].resource ?? '') === (permission.resource ?? '') && permissions[i].action === permission.action ) { return true; diff --git a/src/portal/src/app/base/project/member/add-group/add-group.component.html b/src/portal/src/app/base/project/member/add-group/add-group.component.html index 0e8f08eb0e4..f5f89bab11b 100644 --- a/src/portal/src/app/base/project/member/add-group/add-group.component.html +++ b/src/portal/src/app/base/project/member/add-group/add-group.component.html @@ -62,23 +62,42 @@ + +
- @for (projectRoot of projectRoots; track projectRoot) { - - - - - } +
+ @for (r of roles; track r.id) { + + + + + } +
+ + + diff --git a/src/portal/src/app/base/project/member/add-group/add-group.component.scss b/src/portal/src/app/base/project/member/add-group/add-group.component.scss index fe23b549855..17faae93c2b 100644 --- a/src/portal/src/app/base/project/member/add-group/add-group.component.scss +++ b/src/portal/src/app/base/project/member/add-group/add-group.component.scss @@ -27,3 +27,16 @@ background-image: linear-gradient(180deg,#f5f5f5 0,#e8e8e8); background-repeat: repeat-x; } + +.role-radio-list { + max-height: 200px; + overflow-y: auto; + padding-right: 4px; +} + +.role-builtin-badge { + font-size: 10px; + padding: 1px 4px; + margin-left: 4px; + vertical-align: middle; +} diff --git a/src/portal/src/app/base/project/member/add-group/add-group.component.ts b/src/portal/src/app/base/project/member/add-group/add-group.component.ts index afe21b30172..33fee43c8c6 100644 --- a/src/portal/src/app/base/project/member/add-group/add-group.component.ts +++ b/src/portal/src/app/base/project/member/add-group/add-group.component.ts @@ -23,10 +23,8 @@ import { } from '@angular/core'; import { NgForm } from '@angular/forms'; import { AppConfigService } from '../../../../services/app-config.service'; -import { ProjectRootInterface } from '../../../../shared/services'; import { GroupType, - PROJECT_ROOTS, } from '../../../../shared/entities/shared.const'; import { InlineAlertComponent } from '../../../../shared/components/inline-alert/inline-alert.component'; import { UsergroupService } from '../../../../../../ng-swagger-gen/services/usergroup.service'; @@ -35,6 +33,9 @@ import { UserGroup } from 'ng-swagger-gen/models/user-group'; import { ClrLoadingState } from '@clr/angular'; import { MemberService } from 'ng-swagger-gen/services/member.service'; import { MessageHandlerService } from '../../../../shared/services/message-handler.service'; +import { RoleService } from '../../../../../../ng-swagger-gen/services/role.service'; +import { Role } from '../../../../../../ng-swagger-gen/models/role'; + @Component({ selector: 'add-group', @@ -43,7 +44,7 @@ import { MessageHandlerService } from '../../../../shared/services/message-handl standalone: false, }) export class AddGroupComponent implements OnInit, OnDestroy { - projectRoots: ProjectRootInterface[] = PROJECT_ROOTS; + //projectRoots: ProjectRootInterface[] = PROJECT_ROOTS; memberGroup: UserGroup = { group_name: '', }; @@ -59,6 +60,7 @@ export class AddGroupComponent implements OnInit, OnDestroy { inlineAlert: InlineAlertComponent; @Input() projectId: number; + @Input() assignableRoleIds: Set | null = null; @Output() added = new EventEmitter(); checkOnGoing: boolean = false; @@ -72,13 +74,27 @@ export class AddGroupComponent implements OnInit, OnDestroy { groupTooltip: string = 'MEMBER.GROUP_NAME_REQUIRED'; isNameChecked: boolean = false; // this is only for LDAP mode constructor( + private roleService: RoleService, private memberService: MemberService, private appConfigService: AppConfigService, private messageHandlerService: MessageHandlerService, private userGroupService: UsergroupService ) {} + roles: Role[]; + roleSub: Subscription; + ngOnInit(): void { + this.roleSub = this.roleService.ListRole({ + page: 1, + pageSize: 100 + }).subscribe(res => { + if (res) { + this.roles = res; + } + }); + + if (!this.groupCheckerSub) { this.groupCheckerSub = this.groupChecker .pipe( @@ -171,6 +187,10 @@ export class AddGroupComponent implements OnInit, OnDestroy { this.groupSearcherSub.unsubscribe(); this.groupSearcherSub = null; } + if (this.roleSub) { + this.roleSub.unsubscribe(); + this.roleSub = null; + } } createGroupAsMember() { @@ -217,9 +237,11 @@ export class AddGroupComponent implements OnInit, OnDestroy { } openAddGroupModal(): void { + const firstAssignable = this.roles?.find(r => this.isRoleAssignable(r)); this.currentForm.reset({ - member_role: 1, + member_role: firstAssignable?.id ?? 1, }); + this.roleId = firstAssignable?.id ?? 1; this.addGroupOpened = true; this.inlineAlert.close(); this.memberGroup = { @@ -243,6 +265,24 @@ export class AddGroupComponent implements OnInit, OnDestroy { ); } + isRoleAssignable(role: Role): boolean { + return this.assignableRoleIds === null || this.assignableRoleIds.has(role.id); + } + + getRoleDisplayName(role: Role): string { + if (!role.is_builtin) { + return role.name; + } + const keys: Record = { + projectAdmin: 'MEMBER.PROJECT_ADMIN', + maintainer: 'MEMBER.PROJECT_MAINTAINER', + developer: 'MEMBER.DEVELOPER', + guest: 'MEMBER.GUEST', + limitedGuest: 'MEMBER.LIMITED_GUEST', + }; + return keys[role.name] ?? role.name; + } + selectGroup(groupName) { if (this.appConfigService.isLdapMode()) { this.isNameChecked = true; diff --git a/src/portal/src/app/base/project/member/add-member/add-member.component.html b/src/portal/src/app/base/project/member/add-member/add-member.component.html index b6e5395fbb1..daa6a65bf07 100644 --- a/src/portal/src/app/base/project/member/add-member/add-member.component.html +++ b/src/portal/src/app/base/project/member/add-member/add-member.component.html @@ -60,68 +60,45 @@
+ }} + + + + + + +
- - - - - - - - - - - - - - - - - - - - +
+
+ + + + +
+
diff --git a/src/portal/src/app/base/project/member/add-member/add-member.component.scss b/src/portal/src/app/base/project/member/add-member/add-member.component.scss index 0b7fd909c7e..8e8cced70df 100644 --- a/src/portal/src/app/base/project/member/add-member/add-member.component.scss +++ b/src/portal/src/app/base/project/member/add-member/add-member.component.scss @@ -32,4 +32,17 @@ overflow: visible; } +.role-radio-list { + max-height: 200px; + overflow-y: auto; + padding-right: 4px; +} + +.role-builtin-badge { + font-size: 10px; + padding: 1px 4px; + margin-left: 4px; + vertical-align: middle; +} + diff --git a/src/portal/src/app/base/project/member/add-member/add-member.component.ts b/src/portal/src/app/base/project/member/add-member/add-member.component.ts index 33cd2021a8b..8685ecccbb2 100644 --- a/src/portal/src/app/base/project/member/add-member/add-member.component.ts +++ b/src/portal/src/app/base/project/member/add-member/add-member.component.ts @@ -32,6 +32,9 @@ import { MemberService } from 'ng-swagger-gen/services/member.service'; import { UserService } from 'ng-swagger-gen/services/user.service'; import { UserResp } from '../../../../../../ng-swagger-gen/models/user-resp'; import { UserEntity } from '../../../../../../ng-swagger-gen/models/user-entity'; +import { RoleService } from '../../../../../../ng-swagger-gen/services/role.service'; +import { Role } from '../../../../../../ng-swagger-gen/models/role'; + @Component({ selector: 'add-member', @@ -49,6 +52,7 @@ export class AddMemberComponent implements OnInit, OnDestroy { @ViewChild(InlineAlertComponent) inlineAlert: InlineAlertComponent; @Input() projectId: number; + @Input() assignableRoleIds: Set | null = null; @Output() added = new EventEmitter(); isMemberNameValid: boolean = true; memberTooltip: string = 'MEMBER.USERNAME_IS_REQUIRED'; @@ -56,19 +60,36 @@ export class AddMemberComponent implements OnInit, OnDestroy { searcher: Subject = new Subject(); nameCheckerSub: Subscription; searcherSub: Subscription; + roleSub: Subscription; checkOnGoing: boolean = false; searchedUserLists: UserResp[] = []; btnStatus: ClrLoadingState = ClrLoadingState.DEFAULT; roleId: number = 1; // default value is 1(project admin) + roles: Role[]; + + constructor( + private roleService: RoleService, private memberService: MemberService, private userService: UserService, private messageHandlerService: MessageHandlerService, private route: ActivatedRoute ) {} + + + ngOnInit(): void { + this.roleSub = this.roleService.ListRole({ + page: 1, + pageSize: 100, + }).subscribe(res => { + if (res) { + this.roles = res; + } + }); + let resolverData = this.route.snapshot.parent.parent.data; let hasProjectAdminRole: boolean; if (resolverData) { @@ -149,6 +170,10 @@ export class AddMemberComponent implements OnInit, OnDestroy { this.searcherSub.unsubscribe(); this.searcherSub = null; } + if (this.roleSub) { + this.roleSub.unsubscribe(); + this.roleSub = null; + } } onSubmit(): void { @@ -186,6 +211,9 @@ export class AddMemberComponent implements OnInit, OnDestroy { this.searchedUserLists = []; } + + + onCancel() { this.addMemberOpened = false; } @@ -194,9 +222,11 @@ export class AddMemberComponent implements OnInit, OnDestroy { this.searchedUserLists = []; } openAddMemberModal(): void { + const firstAssignable = this.roles?.find(r => this.isRoleAssignable(r)); this.currentForm.reset({ - member_role: 1, + member_role: firstAssignable?.id ?? 1, }); + this.roleId = firstAssignable?.id ?? 1; this.inlineAlert.close(); this.member = {}; this.addMemberOpened = true; @@ -226,4 +256,22 @@ export class AddMemberComponent implements OnInit, OnDestroy { !this.checkOnGoing ); } + + isRoleAssignable(role: Role): boolean { + return this.assignableRoleIds === null || this.assignableRoleIds.has(role.id); + } + + getRoleDisplayName(role: Role): string { + if (!role.is_builtin) { + return role.name; + } + const keys: Record = { + projectAdmin: 'MEMBER.PROJECT_ADMIN', + maintainer: 'MEMBER.PROJECT_MAINTAINER', + developer: 'MEMBER.DEVELOPER', + guest: 'MEMBER.GUEST', + limitedGuest: 'MEMBER.LIMITED_GUEST', + }; + return keys[role.name] ?? role.name; + } } diff --git a/src/portal/src/app/base/project/member/member.component.html b/src/portal/src/app/base/project/member/member.component.html index 9a29fe28cb0..20aa9e22504 100644 --- a/src/portal/src/app/base/project/member/member.component.html +++ b/src/portal/src/app/base/project/member/member.component.html @@ -53,56 +53,38 @@ - - - - - +
+ + + +
+ + + + + + + + + + + + + + +
+ {{ 'AUDIT_LOG.RESOURCE' | translate }} + + {{ convertKey(item) | translate }} +
+ {{ convertKey(resource) | translate }} + + +
+ + + + + +
+ +
+ + + + + + + + + + + + + +
+ {{ 'AUDIT_LOG.RESOURCE' | translate }} + + {{ convertKey(item) | translate }} +
{{ convertKey(resource) | translate }} + +
+
diff --git a/src/portal/src/app/shared/components/role-permissions-panel/role-permissions-panel.component.scss b/src/portal/src/app/shared/components/role-permissions-panel/role-permissions-panel.component.scss new file mode 100644 index 00000000000..c0486014d4a --- /dev/null +++ b/src/portal/src/app/shared/components/role-permissions-panel/role-permissions-panel.component.scss @@ -0,0 +1,20 @@ +.td { + height: 24px; + line-height: 24px; + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.dropdown-menu { + max-width: unset; +} + +:host::ng-deep.trigger clr-icon { + padding: 0; + position: unset !important; +} + +.select-all-for-dropdown { + margin-bottom: 0.25rem; + width: fit-content; +} diff --git a/src/portal/src/app/shared/components/role-permissions-panel/role-permissions-panel.component.spec.ts b/src/portal/src/app/shared/components/role-permissions-panel/role-permissions-panel.component.spec.ts new file mode 100644 index 00000000000..b415b75cbe8 --- /dev/null +++ b/src/portal/src/app/shared/components/role-permissions-panel/role-permissions-panel.component.spec.ts @@ -0,0 +1,84 @@ +// 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. +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, ViewChild } from '@angular/core'; +import { SharedTestingModule } from '../../shared.module'; +import { + PermissionSelectPanelModes, + RolePermissionsPanelComponent, +} from './role-permissions-panel.component'; + +describe('RolePermissionsPanelComponent', () => { + let component: TestHostComponent; + let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SharedTestingModule], + declarations: [TestHostComponent, RolePermissionsPanelComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TestHostComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render right mode', async () => { + component.rolePermissionsPanelComponent.modalOpen = true; + fixture.detectChanges(); + await fixture.whenStable(); + const table = fixture.nativeElement.querySelector('table'); + expect(table).toBeTruthy(); + component.mode = PermissionSelectPanelModes.DROPDOWN; + fixture.detectChanges(); + await fixture.whenStable(); + const clrDropdown = fixture.nativeElement.querySelector('clr-dropdown'); + expect(clrDropdown).toBeTruthy(); + component.mode = PermissionSelectPanelModes.MODAL; + fixture.detectChanges(); + await fixture.whenStable(); + const modal = fixture.nativeElement.querySelector('clr-modal'); + expect(modal).toBeTruthy(); + }); +}); + +// mock a TestHostComponent for RolePermissionsPanelComponent +@Component({ + template: ` + + +
modal
+
+
+ + +
dropDown
+
+
+ + + + `, +}) +class TestHostComponent { + @ViewChild(RolePermissionsPanelComponent) + rolePermissionsPanelComponent: RolePermissionsPanelComponent; + mode = PermissionSelectPanelModes.NORMAL; + protected readonly PermissionSelectPanelModes = PermissionSelectPanelModes; +} diff --git a/src/portal/src/app/shared/components/role-permissions-panel/role-permissions-panel.component.ts b/src/portal/src/app/shared/components/role-permissions-panel/role-permissions-panel.component.ts new file mode 100644 index 00000000000..dc495b27aae --- /dev/null +++ b/src/portal/src/app/shared/components/role-permissions-panel/role-permissions-panel.component.ts @@ -0,0 +1,176 @@ +// 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. +import { + AfterViewInit, + Component, + DoCheck, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + ViewChild, +} from '@angular/core'; +import { + convertKey, + hasPermission, + isCandidate, +} from '../../../base/left-side-nav/roles/roles-util'; +import { Access } from '../../../../../ng-swagger-gen/models/access'; +import { Permission } from '../../../../../ng-swagger-gen/models/permission'; + +@Component({ + standalone: false, + selector: 'role-permissions-panel', + templateUrl: './role-permissions-panel.component.html', + styleUrls: ['./role-permissions-panel.component.scss'], +}) +export class RolePermissionsPanelComponent implements OnChanges, DoCheck { + modalOpen: boolean = false; + + @Input() + mode: PermissionSelectPanelModes = PermissionSelectPanelModes.NORMAL; + + @Input() + dropdownPosition: string = 'bottom-left'; + + @Input() + usedInDatagrid: boolean = false; + + @Input() + candidatePermissions: Permission[] = []; + + candidateActions: string[] = []; + candidateResources: string[] = []; + + @Input() + permissionsModel!: Access[]; + @Output() + permissionsModelChange = new EventEmitter(); + + @ViewChild('dropdownMenu') + dropdownMenu: any; + + @ViewChild('dropdown') + dropdown: ElementRef; + + // to avoid ng check error, getTransform() should always return 'unset' before dropdownMenu appears + dropdownMenuAppeared: boolean = false; + getTransform(): string { + if ( + this.dropdownMenuAppeared && + this.dropdownMenu?.el && + this.dropdown + ) { + const width = this.dropdownMenu.el.nativeElement.offsetWidth; + const height = this.dropdownMenu.el.nativeElement.offsetHeight; + const bcr = this.dropdown.nativeElement.getBoundingClientRect(); + return `translateX(${bcr.x - width}px) translateY(${ + bcr.y - height / 2 + }px)`; + } + return 'unset'; + } + + ngOnChanges(changes: SimpleChanges) { + if (changes && changes['candidatePermissions']) { + this.initCandidates(); + } + } + ngDoCheck() { + this.dropdownMenuAppeared = !!this.dropdownMenu; + } + + initCandidates() { + this.candidateActions = []; + this.candidateResources = []; + this.candidatePermissions?.forEach(item => { + const resource = item?.resource ?? ''; + if (this.candidateResources.indexOf(resource) === -1) { + this.candidateResources.push(resource); + } + if (this.candidateActions.indexOf(item?.action) === -1) { + this.candidateActions.push(item?.action); + } + }); + this.candidateActions.sort(); + this.candidateResources.sort(); + } + + isCandidate(resource: string, action: string): boolean { + return isCandidate(this.candidatePermissions, { resource, action }); + } + + getCheckBoxValue(resource: string, action: string): boolean { + return hasPermission(this.permissionsModel, { resource, action }); + } + + setCheckBoxValue(resource: string, action: string, value: boolean) { + if (value) { + if (!this.permissionsModel) { + this.permissionsModel = []; + } + this.permissionsModel.push({ resource, action }); + } else { + this.permissionsModel = this.permissionsModel.filter(item => { + return item.resource !== resource || item.action !== action; + }); + } + this.permissionsModelChange.emit(this.permissionsModel); + } + + isAllSelected(): boolean { + let flag: boolean = true; + this.candidateActions.forEach(action => { + this.candidateResources.forEach(resource => { + if ( + this.isCandidate(resource, action) && + !hasPermission(this.permissionsModel, { resource, action }) + ) { + flag = false; + } + }); + }); + return flag; + } + + selectAllOrUnselectAll() { + if (this.isAllSelected()) { + this.permissionsModel = []; + } else { + this.permissionsModel = []; + this.candidateActions.forEach(action => { + this.candidateResources.forEach(resource => { + if (this.isCandidate(resource, action)) { + this.permissionsModel.push({ resource, action }); + } + }); + }); + } + this.permissionsModelChange.emit(this.permissionsModel); + } + + convertKey(key: string): string { + return convertKey(key); + } + protected readonly PermissionSelectPanelModes = PermissionSelectPanelModes; +} + +export enum PermissionSelectPanelModes { + DROPDOWN, + MODAL, + NORMAL, +} diff --git a/src/portal/src/app/shared/entities/shared.const.ts b/src/portal/src/app/shared/entities/shared.const.ts index 43c51ecd964..ea20b3e9e17 100644 --- a/src/portal/src/app/shared/entities/shared.const.ts +++ b/src/portal/src/app/shared/entities/shared.const.ts @@ -43,6 +43,7 @@ export const enum ConfirmationTargets { PROJECT_MEMBER, USER, ROBOT_ACCOUNT, + ROLE, POLICY, TOGGLE_CONFIRM, TARGET, @@ -55,6 +56,7 @@ export const enum ConfirmationTargets { SCANNER, REPLICATION, ROBOT_ACCOUNT_ENABLE_OR_DISABLE, + ROLE_ENABLE_OR_DISABLE, INSTANCE, P2P_PROVIDER, P2P_PROVIDER_STOP, diff --git a/src/portal/src/i18n/lang/de-de-lang.json b/src/portal/src/i18n/lang/de-de-lang.json index 16cbf7c2d5b..83a55d08bbc 100644 --- a/src/portal/src/i18n/lang/de-de-lang.json +++ b/src/portal/src/i18n/lang/de-de-lang.json @@ -400,6 +400,82 @@ "GROUP": "User Group", "MEMBER": "Project Member" }, + "ROLE": { + "ROLE_NAV": "Benutzer Rollen", + "NAME": "Name", + "PERMISSIONS": "Berechtigungen", + "TOKEN": "Token", + "NEW_ROLE": "NEUE ROLLE", + "ENABLED_STATE": "Aktivierungszustand", + "NUMBER_REQUIRED": "Feld ist erforderlich und sollte ein Integer größer 0 sein.", + "DESCRIPTION": "Beschreibung", + "ACTION": "Aktion", + "EDIT": "Editieren", + "ITEMS": "Einträge", + "OF": "von", + "DELETE": "Löschen", + "PUSH": "Push", + "PULL": "Pull", + "FILTER_PLACEHOLDER": "Filter Robot-Zugänge", + "ACCOUNT_EXISTING": "Robot-Zugang existiert bereits.", + "ALERT_TEXT": "Dies ist der einzige Zeitpunkt, zu dem das Zugriffstoken kopiert werden kann. Es gibt keine weitere Möglichkeit!", + "CREATED_SUCCESS": "'{{param}}' erfolgreich erstellt.", + "COPY_SUCCESS": "Token von '{{param}}' erfolgreich kopiert.", + "DELETION_TITLE": "Entfernen des Robot-Zugangs bestätigen", + "DELETION_SUMMARY": "Soll der Robot-Zugang {{param}} gelöscht werden?", + "EXPORT_TO_FILE": "Als Datei exportieren", + "EXPIRES_AT": "Läuft ab am", + "INVALID_VALUE": "Der Wert der Ablaufzeit ist ungültig", + "NEVER_EXPIRED": "Läuft nie ab", + "NAME_PREFIX": "Prefix für den Namen der Robot-Zugänge", + "NAME_PREFIX_REQUIRED": "Es ist ein Prefix für den Robot-Zugang erforderlich", + "UPDATE": "Update", + "AUDIT_LOG": "Audit Log", + "PREHEAT_INSTANCE": "Preheat Instance", + "PROJECT": "Project", + "REPLICATION_POLICY": "Replication Policy", + "REPLICATION": "Replication", + "REPLICATION_ADAPTER": "Replication Adapter", + "REGISTRY": "Registry", + "SCAN_ALL": "Scan All", + "SYSTEM_VOLUMES": "System Volumes", + "GARBAGE_COLLECTION": "Garbage Collection", + "PURGE_AUDIT": "Purge Audit", + "JOBSERVICE_MONITOR": "Job Service Monitor", + "TAG_RETENTION": "Tag Retention", + "SCANNER": "Scanner", + "LABEL": "Label", + "EXPORT_CVE": "Export CVE", + "SECURITY_HUB": "Security Hub", + "CATALOG": "Catalog", + "METADATA": "Project Metadata", + "REPOSITORY": "Repository", + "ARTIFACT": "Artifact", + "SCAN": "Scan", + "SBOM": "SBOM", + "TAG": "Tag", + "ACCESSORY": "Accessory", + "ARTIFACT_ADDITION": "Artifact Addition", + "ARTIFACT_LABEL": "Artifact Label", + "PREHEAT_POLICY": "Preheat Policy", + "IMMUTABLE_TAG": "Immutable Tag", + "LOG": "Log", + "NOTIFICATION_POLICY": "Notification Policy", + "QUOTA": "Quota", + "BACK": "Back", + "NEXT": "Next", + "FINISH": "Finish", + "BASIC_INFO": "Basic Information", + "SELECT_PERMISSIONS": "Select Permissions", + "SELECT_SYSTEM_PERMISSIONS": "Select System Permissions", + "SELECT_PROJECT_PERMISSIONS": "Select Project Permissions", + "SYSTEM_PERMISSIONS": "System Permissions", + "Rolle": "Rolle", + "USER": "User", + "LDAPUSER": "LDAP User", + "GROUP": "User Group", + "MEMBER": "Project Member" + }, "WEBHOOK": { "ENABLED_BUTTON": "AKTIVIEREN", "DISABLED_BUTTON": "DEAKTIVIEREN", diff --git a/src/portal/src/i18n/lang/en-us-lang.json b/src/portal/src/i18n/lang/en-us-lang.json index beec465bc1d..3b1b17fd1bf 100644 --- a/src/portal/src/i18n/lang/en-us-lang.json +++ b/src/portal/src/i18n/lang/en-us-lang.json @@ -321,7 +321,8 @@ "REMOVE": "Remove", "GROUP_NAME_REQUIRED": "Group name is required", "NON_EXISTENT_GROUP": "Group name does not exists", - "GROUP_ALREADY_ADDED": "Group name has been already added to this project" + "GROUP_ALREADY_ADDED": "Group name has been already added to this project", + "BUILT_IN": "Built-in" }, "ROBOT_ACCOUNT": { "NAME": "Name", @@ -400,6 +401,111 @@ "GROUP": "User Group", "MEMBER": "Project Member" }, + "ROLE": { + "CREATE": "Create", + "LIST": "List", + "READ": "Read", + "STOP": "Stop", + "ROLE_NAV": "User Roles", + "NAME": "Name", + "PERMISSIONS": "Permissions", + "TOKEN": "Secret", + "NEW_ROLE": "NEW ROLE", + "ENABLED_STATE": "Enabled state", + "NUMBER_REQUIRED": "Field is required and should be an integer other than 0.", + "DESCRIPTION": "Description", + "CREATION": "Created time", + "TOKEN_EXPIRATION": "Robot Token Expiration (Days)", + "ACTION": "Action", + "EDIT": "Edit", + "ITEMS": "items", + "OF": "of", + "DELETE": "Delete", + "PUSH": "Push", + "PULL": "Pull", + "CREATE_ROLE": "Create Role", + "CREATEROLE_SUMMARY": "Define a name and optional description for the new role. You will select its permissions in the next step.", + "EDIT_ROLE": "Edit Role", + "EDIT_ROLE_SUMMARY": "Update the description or permissions of this role. The role name cannot be changed.", + "DELETE_ROLE_SUCCESS": "Deleted role(s) successfully", + "FILTER_PLACEHOLDER": "Filter Robot Accounts", + "ACCOUNT_EXISTING": "Role already exists.", + "ALERT_TEXT": "This is the only time to copy this secret.You won't have another opportunity", + "CREATED_SUCCESS": "Created '{{param}}' successfully.", + "COPY_SUCCESS": "Copy secret successfully of '{{param}}'", + "DELETION_TITLE": "Confirm removal of robot accounts", + "DELETION_SUMMARY": "Do you want to delete robot accounts {{param}}?", + "EXPORT_TO_FILE": "export to file", + "EXPIRES_AT": "Expires At", + "INVALID_VALUE": "The value of the expiration time is invalid", + "NEVER_EXPIRED": "Never Expires", + "NAME_PREFIX": "Robot Name Prefix", + "NAME_PREFIX_REQUIRED": "Robot name prefix is required", + "UPDATE": "Update", + "AUDIT_LOG": "Audit Log", + "PREHEAT_INSTANCE": "Preheat Instance", + "MASK": "Mask", + "ROLE_PERMISSIONS": "Role permissions", + "PROJECT": "Project", + "REPLICATION_POLICY": "Replication Policy", + "REPLICATION": "Replication", + "REPLICATION_ADAPTER": "Replication Adapter", + "REGISTRY": "Registry", + "SCAN_ALL": "Scan All", + "SYSTEM_VOLUMES": "System Volumes", + "GARBAGE_COLLECTION": "Garbage Collection", + "PURGE_AUDIT": "Purge Audit", + "JOBSERVICE_MONITOR": "Job Service Monitor", + "TAG_RETENTION": "Tag Retention", + "SCANNER": "Scanner", + "LABEL": "Label", + "EXPORT_CVE": "Export CVE", + "SECURITY_HUB": "Security Hub", + "CATALOG": "Catalog", + "METADATA": "Project Metadata", + "REPOSITORY": "Repository", + "ARTIFACT": "Artifact", + "SCAN": "Scan", + "SBOM": "SBOM", + "TAG": "Tag", + "ACCESSORY": "Accessory", + "ARTIFACT_ADDITION": "Artifact Addition", + "ARTIFACT_LABEL": "Artifact Label", + "PREHEAT_POLICY": "Preheat Policy", + "IMMUTABLE_TAG": "Immutable Tag", + "LOG": "Log", + "NOTIFICATION_POLICY": "Notification Policy", + "QUOTA": "Quota", + "BACK": "Back", + "NEXT": "Next", + "FINISH": "Finish", + "BASIC_INFO": "Basic Information", + "SELECT_PERMISSIONS": "Select Permissions", + "SELECT_SYSTEM_PERMISSIONS": "Select System Permissions", + "SELECT_PROJECT_PERMISSIONS": "Select Project Permissions", + "SYSTEM_PERMISSIONS": "System Permissions", + "ROLE": "Role", + "USER": "User", + "LDAPUSER": "LDAP User", + "GROUP": "User Group", + "MEMBER": "Project Member", + "BUILTIN": "Built-in", + "MODIFIED": "Modified", + "DESCRIPTION": "Description", + "DESCRIPTION_PLACEHOLDER": "Enter a description for this role", + "CREATED_BY": "Created By", + "CREATED_AT": "Created", + "MODIFIED_BY": "Modified By", + "MODIFIED_AT": "Last Modified", + "ROLE_NAMES": { + "projectAdmin": "Project Admin", + "developer": "Developer", + "guest": "Guest", + "master": "Maintainer", + "limitedGuest": "Limited Guest" + } + }, + "WEBHOOK": { "ENABLED_BUTTON": "ENABLE", "DISABLED_BUTTON": "DEACTIVATE", diff --git a/src/portal/src/i18n/lang/es-es-lang.json b/src/portal/src/i18n/lang/es-es-lang.json index 31bfc810da8..4df0046a0e0 100644 --- a/src/portal/src/i18n/lang/es-es-lang.json +++ b/src/portal/src/i18n/lang/es-es-lang.json @@ -401,6 +401,85 @@ "GROUP": "Usuario Grupo", "MEMBER": "Miembro Proyecto" }, + "ROLE": { + "ROLE_NAV": "Roles utilisador", + "NAME": "Nombre", + "PERMISSIONS": "Permisos", + "TOKEN": "Secreto", + "NEW_ROLE": "NUEVO ROL", + "ENABLED_STATE": "Estado habilitado", + "CREATION": "Hora de creación", + "NUMBER_REQUIRED": "El campo es obligatorio y debe ser un número entero distinto de 0.", + "TOKEN_EXPIRATION": "Vencimiento token del Robot (Días)", + "DESCRIPTION": "Descripción", + "ACTION": "Acción", + "EDIT": "Editar", + "ITEMS": "items", + "OF": "de", + "DELETE": "Borrar", + "PUSH": "Push", + "PULL": "Pull", + "FILTER_PLACEHOLDER": "Filtrar Cuentas Robot", + "ACCOUNT_EXISTING": "La Cuenta Robot ya existe.", + "ALERT_TEXT": "Esta es la única vez que puedes copiar este secreto. No tendrás otra oportunidad", + "CREATED_SUCCESS": "Se creó '{{param}}' exitosamente.", + "COPY_SUCCESS": "Copiar el secreto de '{{param}}' exitosamente", + "DELETION_TITLE": "Confirmar la eliminación de cuentas robot", + "DELETION_SUMMARY": "¿Quieres eliminar cuentas de robot {{param}}?", + "EXPORT_TO_FILE": "exportar a archivo", + "EXPIRES_AT": "Expira a las", + "INVALID_VALUE": "El valor del tiempo de expiración no es válido", + "NEVER_EXPIRED": "Nunca Expira", + "NAME_PREFIX": "Prefijo Nombre de Robot", + "NAME_PREFIX_REQUIRED": "Rrefijo del nombre del robot requerido", + "UPDATE": "Actualizar", + "AUDIT_LOG": " Log de Auditoría", + "PREHEAT_INSTANCE": "Instancia de Preheat", + "PROJECT": "Projecto", + "REPLICATION_POLICY": "Política de Replicación", + "REPLICATION": "Replicación", + "REPLICATION_ADAPTER": "Adaptador de Replicación", + "REGISTRY": "Registro", + "SCAN_ALL": "Escanear Todos", + "SYSTEM_VOLUMES": "Volúmenes de Sistema", + "GARBAGE_COLLECTION": "Garbage Collection", + "PURGE_AUDIT": "Purgar Auditoria", + "JOBSERVICE_MONITOR": "Job Service Monitor", + "TAG_RETENTION": "Tag de Retención", + "SCANNER": "Escaner", + "LABEL": "Etiqueta", + "EXPORT_CVE": "Exportar CVE", + "SECURITY_HUB": "Hub de Seguridad", + "CATALOG": "Catálogo", + "METADATA": "Metadata Proyecto", + "REPOSITORY": "Repositorio", + "ARTIFACT": "Artefacto", + "SCAN": "Escaner", + "SBOM": "SBOM", + "TAG": "Tag", + "ACCESSORY": "Accesorio", + "ARTIFACT_ADDITION": "Adición de Artefacto", + "ARTIFACT_LABEL": "Etiqueta de Artifacto", + "PREHEAT_POLICY": "Política de Preheat", + "IMMUTABLE_TAG": "Tag Inmutable", + "LOG": "Log", + "NOTIFICATION_POLICY": "Política de Notificación", + "QUOTA": "Cuota", + "BACK": "Regresar", + "NEXT": "Siguiente", + "FINISH": "Finalizar", + "BASIC_INFO": "Información Básica", + "SELECT_PERMISSIONS": "Seleccionar Permisos", + "SELECT_SYSTEM_PERMISSIONS": "Seleccionar Permisos Sistema", + "SELECT_PROJECT_PERMISSIONS": "Seleccionar Permisos Proyecto", + "SYSTEM_PERMISSIONS": "Permisos Sistema", + "ROLE": "Rol", + "USER": "Usuario", + "LDAPUSER": "Usuario LDAP", + "GROUP": "Usuario Grupo", + "MEMBER": "Miembro Proyecto" + }, + "WEBHOOK": { "ENABLED_BUTTON": "ACTIVAR", "DISABLED_BUTTON": "DESACTIVAR", diff --git a/src/portal/src/i18n/lang/fr-fr-lang.json b/src/portal/src/i18n/lang/fr-fr-lang.json index d7180e02875..f56372dc987 100644 --- a/src/portal/src/i18n/lang/fr-fr-lang.json +++ b/src/portal/src/i18n/lang/fr-fr-lang.json @@ -401,6 +401,84 @@ "GROUP": "Groupe d'utilisateurs", "MEMBER": "Membre de projet" }, + "ROLE": { + "ROLE_NAV": "Rôle utilisateur", + "NAME": "Nom", + "PERMISSIONS": "Permissions", + "TOKEN": "Jeton", + "NEW_ROLE": "Nouvelle role", + "ENABLED_STATE": "Etat d'activation", + "NUMBER_REQUIRED": "Le champ est requis et doit être un entier autre que 0.", + "DESCRIPTION": "Description", + "CREATION": "Date et heure de création", + "TOKEN_EXPIRATION": "Expiration du jeton du compte robot (jours)", + "ACTION": "Action", + "EDIT": "Éditer", + "ITEMS": "entrées", + "OF": "sur", + "DELETE": "Supprimer", + "PUSH": "Push", + "PULL": "Pull", + "FILTER_PLACEHOLDER": "Filtrer les comptes robot", + "ACCOUNT_EXISTING": "Le compte robot existe déjà.", + "ALERT_TEXT": "C'est le seul moment pour copier votre jeton d'accès personnel. Vous n'aurez pas d'autre opportunité", + "CREATED_SUCCESS": "Création de '{{param}}' effectuée.", + "COPY_SUCCESS": "Copie de '{{param}}' effectuée", + "DELETION_TITLE": "Confirmer la suppression des comptes robot", + "DELETION_SUMMARY": "Voulez-vous supprimer les comptes robot {{param}} ?", + "EXPORT_TO_FILE": "Exporter vers un ficher", + "EXPIRES_AT": "Expire le", + "INVALID_VALUE": "La valeur de la date d'expiration est invalide", + "NEVER_EXPIRED": "Ne jamais expirer", + "NAME_PREFIX": "Préfixe du nom du compte robot", + "NAME_PREFIX_REQUIRED": "Le préfixe du nom du compte robot est obligatoire", + "UPDATE": "Mettre à jour", + "AUDIT_LOG": "Log d'audit", + "PREHEAT_INSTANCE": "Préchauffer l'instance", + "PROJECT": "Projet", + "REPLICATION_POLICY": "Politique de réplication", + "REPLICATION": "Réplication", + "REPLICATION_ADAPTER": "Adaptateur de réplication", + "REGISTRY": "Registre", + "SCAN_ALL": "Scanner tout", + "SYSTEM_VOLUMES": "Volumes système", + "GARBAGE_COLLECTION": "Purge", + "PURGE_AUDIT": "Purger l'audit", + "JOBSERVICE_MONITOR": "Job Service Monitor", + "TAG_RETENTION": "Rétention des tags", + "SCANNER": "Scanner", + "LABEL": "Label", + "EXPORT_CVE": "Exporter les CVE", + "SECURITY_HUB": "Centre de sécurité", + "CATALOG": "Catalogue", + "METADATA": "Métadonnées du projet", + "REPOSITORY": "Dépôt", + "ARTIFACT": "Artefact", + "SCAN": "Scan", + "SBOM": "SBOM", + "TAG": "Tag", + "ACCESSORY": "Accessoire", + "ARTIFACT_ADDITION": "Artefact Addition", + "ARTIFACT_LABEL": "Label d'artefact", + "PREHEAT_POLICY": "Politique de préchauffage", + "IMMUTABLE_TAG": "Tag immutable", + "LOG": "Log", + "NOTIFICATION_POLICY": "Politique de notification", + "QUOTA": "Quota", + "BACK": "Retour", + "NEXT": "Suivant", + "FINISH": "Finir", + "BASIC_INFO": "Informations de base", + "SELECT_PERMISSIONS": "Selectionner les permissions", + "SELECT_SYSTEM_PERMISSIONS": "Selectionner les permissions système", + "SELECT_PROJECT_PERMISSIONS": "Selectionner les permissions projet", + "SYSTEM_PERMISSIONS": "Permissions système", + "ROLE": "Role", + "USER": "Utilisateur", + "LDAPUSER": "Utilisateur LDAP", + "GROUP": "Groupe d'utilisateurs", + "MEMBER": "Membre de projet" + }, "WEBHOOK": { "ENABLED_BUTTON": "Activer", "DISABLED_BUTTON": "Désactiver", diff --git a/src/portal/src/i18n/lang/ko-kr-lang.json b/src/portal/src/i18n/lang/ko-kr-lang.json index 5146d0879b6..1435e39abb4 100644 --- a/src/portal/src/i18n/lang/ko-kr-lang.json +++ b/src/portal/src/i18n/lang/ko-kr-lang.json @@ -400,6 +400,83 @@ "GROUP": "User Group", "MEMBER": "Project Member" }, + "ROBOT_ACCOUNT": { + "NAME": "이름", + "PERMISSIONS": "권한", + "TOKEN": "시크릿", + "NEW_ROLE": "새 로봇 계정", + "ENABLED_STATE": "활성화된 상태", + "NUMBER_REQUIRED": "필드는 필수이며 0이 아닌 정수여야 합니다.", + "DESCRIPTION": "설명", + "CREATION": "생성 시간", + "TOKEN_EXPIRATION": "로봇 토큰 만료(일)", + "ACTION": "동작", + "EDIT": "편집", + "ITEMS": "아이템", + "OF": "of", + "DELETE": "삭제", + "PUSH": "푸시", + "PULL": "풀(Pull)", + "FILTER_PLACEHOLDER": "로봇 계정 필터", + "ACCOUNT_EXISTING": "로봇 계정이 이미 존재합니다.", + "ALERT_TEXT": "이 시크릿을 복사할 수 있는 유일한 기회입니다.다음 기회는 없습니다.", + "CREATED_SUCCESS": "'{{param}}'성공적으로 생성됐습니다.", + "COPY_SUCCESS": "'{{param}}'의 시크릿을 성공적으로 복사했습니다", + "DELETION_TITLE": "로봇 계정 제거 확인", + "DELETION_SUMMARY": "로봇 계정{{param}}을 정말 삭제하시겠습니까?", + "EXPORT_TO_FILE": "파일로 내보내기", + "EXPIRES_AT": "~에 만료", + "INVALID_VALUE": "만료 시간 값이 잘못되었습니다", + "NEVER_EXPIRED": "만료되지 않음", + "NAME_PREFIX": "로봇 이름 접두어", + "NAME_PREFIX_REQUIRED": "로봇 이름 접두어는 필수항목입니다", + "UPDATE": "업데이트", + "AUDIT_LOG": "감사 로그", + "PREHEAT_INSTANCE": "인스턴스 예열", + "PROJECT": "프로젝트", + "REPLICATION_POLICY": "복제 정책", + "REPLICATION": "복제", + "REPLICATION_ADAPTER": "복제 어댑터", + "REGISTRY": "레지스트리", + "SCAN_ALL": "모두 스캔", + "SYSTEM_VOLUMES": "시스템 볼륨", + "GARBAGE_COLLECTION": "가비지 컬렉션", + "PURGE_AUDIT": "퍼지 감사", + "JOBSERVICE_MONITOR": "작업 서비스 모니터링", + "TAG_RETENTION": "태그 유지", + "SCANNER": "스캐너", + "LABEL": "라벨", + "EXPORT_CVE": "CVE 내보내기", + "SECURITY_HUB": "보안 허브", + "CATALOG": "카탈로그", + "METADATA": "프로젝트 메타데이터", + "REPOSITORY": "저장소", + "ARTIFACT": "아티팩트", + "SCAN": "스캔", + "SBOM": "SBOM", + "TAG": "태그", + "ACCESSORY": "액세서리", + "ARTIFACT_ADDITION": "아티팩트 추가", + "ARTIFACT_LABEL": "아티팩트 라벨", + "PREHEAT_POLICY": "예열 정책", + "IMMUTABLE_TAG": "Immutable Tag", + "LOG": "로그", + "NOTIFICATION_POLICY": "알림 정책", + "QUOTA": "할당량", + "BACK": "뒤로", + "NEXT": "다음", + "FINISH": "마지막", + "BASIC_INFO": "기본 정보", + "SELECT_PERMISSIONS": "권한 선택", + "SELECT_SYSTEM_PERMISSIONS": "시스템 권한 선택", + "SELECT_PROJECT_PERMISSIONS": "프로젝트 권한 선택", + "SYSTEM_PERMISSIONS": "시스템 권한", + "ROLE": "Role", + "USER": "User", + "LDAPUSER": "LDAP User", + "GROUP": "User Group", + "MEMBER": "Project Member" + }, "WEBHOOK": { "ENABLED_BUTTON": "활성", "DISABLED_BUTTON": "비활성", diff --git a/src/portal/src/i18n/lang/pt-br-lang.json b/src/portal/src/i18n/lang/pt-br-lang.json index f9832d958f1..220fed7090e 100644 --- a/src/portal/src/i18n/lang/pt-br-lang.json +++ b/src/portal/src/i18n/lang/pt-br-lang.json @@ -400,6 +400,83 @@ "GROUP": "User Group", "MEMBER": "Project Member" }, + "ROLE": { + "NAME": "Nome", + "PERMISSIONS": "Permissões", + "TOKEN": "Token", + "NEW_ROLE": "Nova conta de automação", + "ENABLED_STATE": "Habilitado", + "CREATION": "Criação", + "NUMBER_REQUIRED": "Campo obrigatório. Não pode ser 0 (zero).", + "TOKEN_EXPIRATION": "Expiração do token de automação (em dias)", + "DESCRIPTION": "Descrição", + "ACTION": "AÇÃO", + "EDIT": "Editar", + "ITEMS": "itens", + "OF": "de", + "DELETE": "Remover", + "PUSH": "Push", + "PULL": "Pull", + "FILTER_PLACEHOLDER": "Filtro contas de automação", + "ACCOUNT_EXISTING": "Conta de automação já existe.", + "ALERT_TEXT": "Copie AGORA e resguarde o valor do token de acesso. Não haverá outra oportunidade.", + "CREATED_SUCCESS": "'{{param}}' criado com sucesso.", + "COPY_SUCCESS": "Cópia de segredo feita com sucesso: '{{param}}'", + "DELETION_TITLE": "Confirmar a remoção da conta", + "DELETION_SUMMARY": "Você quer remover a regra {{param}}?", + "EXPORT_TO_FILE": "exportar para arquivo", + "EXPIRES_AT": "Expira em", + "INVALID_VALUE": "Valor do tempo de expiração inválido.", + "NEVER_EXPIRED": "Não expirar nunca", + "NAME_PREFIX": "Prefixo para contas de automação", + "NAME_PREFIX_REQUIRED": "Prefixo é obrigatório.", + "UPDATE": "Update", + "AUDIT_LOG": "Audit Log", + "PREHEAT_INSTANCE": "Preheat Instance", + "PROJECT": "Project", + "REPLICATION_POLICY": "Replication Policy", + "REPLICATION": "Replication", + "REPLICATION_ADAPTER": "Replication Adapter", + "REGISTRY": "Registry", + "SCAN_ALL": "Scan All", + "SYSTEM_VOLUMES": "System Volumes", + "GARBAGE_COLLECTION": "Garbage Collection", + "PURGE_AUDIT": "Purge Audit", + "JOBSERVICE_MONITOR": "Job Service Monitor", + "TAG_RETENTION": "Tag Retention", + "SCANNER": "Scanner", + "LABEL": "Label", + "EXPORT_CVE": "Export CVE", + "SECURITY_HUB": "Security Hub", + "CATALOG": "Catalog", + "METADATA": "Project Metadata", + "REPOSITORY": "Repository", + "ARTIFACT": "Artifact", + "SCAN": "Scan", + "SBOM": "SBOM", + "TAG": "Tag", + "ACCESSORY": "Accessory", + "ARTIFACT_ADDITION": "Artifact Addition", + "ARTIFACT_LABEL": "Artifact Label", + "PREHEAT_POLICY": "Preheat Policy", + "IMMUTABLE_TAG": "Immutable Tag", + "LOG": "Log", + "NOTIFICATION_POLICY": "Notification Policy", + "QUOTA": "Quota", + "BACK": "Back", + "NEXT": "Next", + "FINISH": "Finish", + "BASIC_INFO": "Basic Information", + "SELECT_PERMISSIONS": "Select Permissions", + "SELECT_SYSTEM_PERMISSIONS": "Select System Permissions", + "SELECT_PROJECT_PERMISSIONS": "Select Project Permissions", + "SYSTEM_PERMISSIONS": "System Permissions", + "ROLE": "ROLE", + "USER": "User", + "LDAPUSER": "LDAP User", + "GROUP": "User Group", + "MEMBER": "Project Member" + }, "GROUP": { "GROUP": "Grupo", "IMPORT_LDAP_GROUP": "Importar grupo do LDAP", diff --git a/src/portal/src/i18n/lang/ru-ru-lang.json b/src/portal/src/i18n/lang/ru-ru-lang.json index 27e25087cd5..a06f948e6ec 100644 --- a/src/portal/src/i18n/lang/ru-ru-lang.json +++ b/src/portal/src/i18n/lang/ru-ru-lang.json @@ -547,6 +547,46 @@ "NAME_PREFIX": "Префикс имени робота", "NAME_PREFIX_REQUIRED": "Префикс имени робота обязателен" }, + "ROBOT_ACCOUNT": { + "NAME": "Имя", + "PERMISSIONS": "Разрешения", + "TOKEN": "Секрет", + "NEW_ROLE": "НОВЫЙ АККАУНТ РОБОТА", + "ENABLED_STATE": "Состояние включения", + "NUMBER_REQUIRED": "Поле обязательно и должно быть целым числом, отличным от 0.", + "DESCRIPTION": "Описание", + "CREATETION": "Время создания", + "EXPIRATION": "Срок действия", + "TOKEN_EXPIRATION": "Срок действия токена робота (Дни)", + "ACTION": "Действие", + "EDIT": "Редактировать", + "ITEMS": "элементы", + "OF": "из", + "DISABLE_ACCOUNT": "Деактивировать аккаунт", + "ENABLE_ACCOUNT": "Включить аккаунт", + "DELETE": "Удалить", + "CREAT_ROLE": "Создать аккаунт робота", + "PERMISSIONS_ARTIFACT": "Артефакт", + "PERMISSIONS_HELMCHART": "Диаграмма Helm (Chart Museum)", + "PUSH": "Отправить", + "PULL": "Загрузить", + "FILTER_PLACEHOLDER": "Фильтровать аккаунты роботов", + "ROLE_NAME": "Не может содержать специальные символы(~#$%) и максимальная длина должна быть 255 символов.", + "ACCOUNT_EXISTING": "Аккаунт робота уже существует.", + "ALERT_TEXT": "Это единственный раз, когда вы можете скопировать этот секрет. У вас не будет другого шанса", + "CREATED_SUCCESS": "Успешно создан '{{param}}'.", + "COPY_SUCCESS": "Секрет успешно скопирован для '{{param}}'", + "DELETION_TITLE": "Подтвердите удаление аккаунтов роботов", + "DELETION_SUMMARY": "Вы хотите удалить аккаунты роботов {{param}}?", + "PULL_IS_MUST": "Разрешение на загрузку проверяется по умолчанию и не может быть изменено.", + "EXPORT_TO_FILE": "экспортировать в файл", + "EXPIRES_AT": "Истекает", + "EXPIRATION_TOOLTIP": "Если не установлено, будет использоваться время истечения системной конфигурации", + "INVALID_VALUE": "Значение срока действия недействительно", + "NEVER_EXPIRED": "Никогда не истекает", + "NAME_PREFIX": "Префикс имени робота", + "NAME_PREFIX_REQUIRED": "Префикс имени робота обязателен" + }, "WEBHOOK": { "EDIT_BUTTON": "РЕДАКТИРОВАТЬ", "ENABLED_BUTTON": "ВКЛЮЧИТЬ", diff --git a/src/portal/src/i18n/lang/tr-tr-lang.json b/src/portal/src/i18n/lang/tr-tr-lang.json index 01ac7145a47..82d1f4f1771 100644 --- a/src/portal/src/i18n/lang/tr-tr-lang.json +++ b/src/portal/src/i18n/lang/tr-tr-lang.json @@ -401,6 +401,83 @@ "GROUP": "User Group", "MEMBER": "Project Member" }, + "ROLE": { + "NAME": "İsim", + "PERMISSIONS": "İzinler", + "TOKEN": "Token", + "NEW_ROLE": "YENİ ROBOT HESABI", + "ENABLED_STATE": "Etkin durum", + "NUMBER_REQUIRED": "Alan zorunludur ve 0 dışında bir tam sayı olmalıdır.", + "DESCRIPTION": "Açıklama", + "CREATION": "Created time", + "TOKEN_EXPIRATION": "Robot Token Sona Ermesi (Günler)", + "ACTION": "Eylem", + "EDIT": "Düzenle", + "ITEMS": "parça", + "OF": "of", + "DELETE": "Sil", + "PUSH": "Yükle", + "PULL": "Çek", + "FILTER_PLACEHOLDER": "Robot Hesaplarını Filtrele", + "ACCOUNT_EXISTING": "Robot Hesabı zaten var.", + "ALERT_TEXT": "Kişisel erişim kartınızı kopyalamanın tek zamanı bu. Başka bir fırsatınız olmaz", + "CREATED_SUCCESS": "Başarıyla '{{param}}' oluşturuldu.", + "COPY_SUCCESS": "Token başarıyla kopyala '{{param}}'", + "DELETION_TITLE": "Robot hesaplarının kaldırılmasını onaylayın", + "DELETION_SUMMARY": "Robot hesaplarını silmek istiyor musunuz {{param}}?", + "EXPORT_TO_FILE": "dosyayı dışarı aktar", + "EXPIRES_AT": "Expires At", + "INVALID_VALUE": "The value of the expiration time is invalid", + "NEVER_EXPIRED": "Never Expired", + "NAME_PREFIX": "Robot Name Prefix", + "NAME_PREFIX_REQUIRED": "Robot name prefix is required", + "UPDATE": "Update", + "AUDIT_LOG": "Audit Log", + "PREHEAT_INSTANCE": "Preheat Instance", + "PROJECT": "Project", + "REPLICATION_POLICY": "Replication Policy", + "REPLICATION": "Replication", + "REPLICATION_ADAPTER": "Replication Adapter", + "REGISTRY": "Registry", + "SCAN_ALL": "Scan All", + "SYSTEM_VOLUMES": "System Volumes", + "GARBAGE_COLLECTION": "Garbage Collection", + "PURGE_AUDIT": "Purge Audit", + "JOBSERVICE_MONITOR": "Job Service Monitor", + "TAG_RETENTION": "Tag Retention", + "SCANNER": "Scanner", + "LABEL": "Label", + "EXPORT_CVE": "Export CVE", + "SECURITY_HUB": "Security Hub", + "CATALOG": "Catalog", + "METADATA": "Project Metadata", + "REPOSITORY": "Repository", + "ARTIFACT": "Artifact", + "SCAN": "Scan", + "SBOM": "SBOM", + "TAG": "Tag", + "ACCESSORY": "Accessory", + "ARTIFACT_ADDITION": "Artifact Addition", + "ARTIFACT_LABEL": "Artifact Label", + "PREHEAT_POLICY": "Preheat Policy", + "IMMUTABLE_TAG": "Immutable Tag", + "LOG": "Log", + "NOTIFICATION_POLICY": "Notification Policy", + "QUOTA": "Quota", + "BACK": "Back", + "NEXT": "Next", + "FINISH": "Finish", + "BASIC_INFO": "Basic Information", + "SELECT_PERMISSIONS": "Select Permissions", + "SELECT_SYSTEM_PERMISSIONS": "Select System Permissions", + "SELECT_PROJECT_PERMISSIONS": "Select Project Permissions", + "SYSTEM_PERMISSIONS": "System Permissions", + "ROLE": "Robot Account", + "USER": "User", + "LDAPUSER": "LDAP User", + "GROUP": "User Group", + "MEMBER": "Project Member" + }, "WEBHOOK": { "ENABLED_BUTTON": "AKTİF ET", "DISABLED_BUTTON": "DEVRE DIŞI BIRAK", diff --git a/src/portal/src/i18n/lang/zh-cn-lang.json b/src/portal/src/i18n/lang/zh-cn-lang.json index c32611b48c4..80e84977a92 100644 --- a/src/portal/src/i18n/lang/zh-cn-lang.json +++ b/src/portal/src/i18n/lang/zh-cn-lang.json @@ -400,6 +400,82 @@ "GROUP": "用户组", "MEMBER": "项目成员" }, + "ROLE": { + "NAME": "名称", + "PERMISSIONS": "权限", + "TOKEN": "令牌", + "NEW_ROLE": "添加机器人账户", + "ENABLED_STATE": "启用状态", + "CREATION": "创建时间", + "NUMBER_REQUIRED": "此项为必填项且为不为0的整数。", + "TOKEN_EXPIRATION": "机器人账户令牌过期时间(天)", + "DESCRIPTION": "描述", + "ACTION": "操作", + "EDIT": "编辑", + "OF": "共计", + "ITEMS": "条记录", + "DELETE": "删除", + "PUSH": "推送", + "PULL": "拉取", + "FILTER_PLACEHOLDER": "过滤机器人账户", + "ACCOUNT_EXISTING": "机器人账户已经存在。", + "ALERT_TEXT": "这是唯一一次复制当前令牌的机会", + "CREATED_SUCCESS": "创建账户 '{{param}}' 成功。", + "COPY_SUCCESS": "成功复制 '{{param}}' 的令牌", + "DELETION_TITLE": "删除账户确认", + "DELETION_SUMMARY": "您确认删除机器人账户 {{param}}?", + "EXPORT_TO_FILE": "导出到文件中", + "EXPIRES_AT": "到期日", + "INVALID_VALUE": "无效的过期日期", + "NEVER_EXPIRED": "永不过期", + "NAME_PREFIX": "机器人账户名称前缀", + "NAME_PREFIX_REQUIRED": "机器人账户名称前缀为必填项", + "UPDATE": "更新", + "AUDIT_LOG": "审核日志", + "PREHEAT_INSTANCE": "预热实例", + "PROJECT": "项目", + "REPLICATION_POLICY": "镜像复制策略", + "REPLICATION": "镜像复制", + "REPLICATION_ADAPTER": "镜像复制适配器", + "REGISTRY": "镜像库", + "SCAN_ALL": "扫描全部", + "SYSTEM_VOLUMES": "系统卷", + "GARBAGE_COLLECTION": "垃圾回收", + "PURGE_AUDIT": "日志清除", + "JOBSERVICE_MONITOR": "任务监视器", + "TAG_RETENTION": "Tag 保留", + "SCANNER": "扫描器", + "LABEL": "标签", + "EXPORT_CVE": "导出 CVE", + "SECURITY_HUB": "安全中心", + "CATALOG": "镜像目录", + "METADATA": "项目元数据", + "REPOSITORY": "仓库", + "ARTIFACT": "Artifact", + "SCAN": "扫描", + "SBOM": "软件物料清单", + "TAG": "Tag", + "ACCESSORY": "附件", + "ARTIFACT_ADDITION": "Artifact 额外信息", + "ARTIFACT_LABEL": "Artifact 标签", + "PREHEAT_POLICY": "预热策略", + "IMMUTABLE_TAG": "不可变 Tag", + "LOG": "日志", + "NOTIFICATION_POLICY": "Webhook 策略", + "BACK": "上一步", + "NEXT": "下一步", + "FINISH": "完成", + "BASIC_INFO": "基本信息", + "SELECT_PERMISSIONS": "选择权限", + "SELECT_SYSTEM_PERMISSIONS": "选择系统权限", + "SELECT_PROJECT_PERMISSIONS": "选择项目权限", + "SYSTEM_PERMISSIONS": "系统权限", + "ROLE": "机器人账户", + "USER": "用户", + "LDAPUSER": "LDAP 用户", + "GROUP": "用户组", + "MEMBER": "项目成员" + }, "WEBHOOK": { "ENABLED_BUTTON": "启用", "DISABLED_BUTTON": "停用", diff --git a/src/portal/src/i18n/lang/zh-tw-lang.json b/src/portal/src/i18n/lang/zh-tw-lang.json index a045ed0f110..13e63c5d659 100644 --- a/src/portal/src/i18n/lang/zh-tw-lang.json +++ b/src/portal/src/i18n/lang/zh-tw-lang.json @@ -400,6 +400,83 @@ "GROUP": "使用者群組", "MEMBER": "專案成員" }, + "ROLE": { + "NAME": "名稱", + "PERMISSIONS": "權限", + "TOKEN": "權杖", + "NEW_ROLE": "新增機器人帳號", + "ENABLED_STATE": "啟用狀態", + "NUMBER_REQUIRED": "此欄位為必填,且應為非零整數。", + "DESCRIPTION": "描述", + "CREATION": "建立時間", + "TOKEN_EXPIRATION": "機器人權杖到期時間(天)", + "ACTION": "操作", + "EDIT": "編輯", + "ITEMS": "筆", + "OF": "共計", + "DELETE": "刪除", + "PUSH": "推送", + "PULL": "拉取", + "FILTER_PLACEHOLDER": "篩選機器人帳號", + "ACCOUNT_EXISTING": "機器人帳號已存在。", + "ALERT_TEXT": "這是複製此金鑰的唯一機會,您將無法再次取得。", + "CREATED_SUCCESS": "已成功建立 '{{param}}'。", + "COPY_SUCCESS": "已成功複製 '{{param}}' 的金鑰。", + "DELETION_TITLE": "確認移除機器人帳號", + "DELETION_SUMMARY": "您是否要刪除機器人帳號 {{param}}?", + "EXPORT_TO_FILE": "匯出至檔案", + "EXPIRES_AT": "到期日", + "INVALID_VALUE": "無效的過期日期", + "NEVER_EXPIRED": "永不過期", + "NAME_PREFIX": "機器人名稱前綴", + "NAME_PREFIX_REQUIRED": "機器人名稱前綴為必填項目", + "UPDATE": "Update", + "AUDIT_LOG": "Audit Log", + "PREHEAT_INSTANCE": "Preheat Instance", + "PROJECT": "Project", + "REPLICATION_POLICY": "Replication Policy", + "REPLICATION": "Replication", + "REPLICATION_ADAPTER": "Replication Adapter", + "REGISTRY": "Registry", + "SCAN_ALL": "全部掃描", + "SYSTEM_VOLUMES": "系統磁碟區", + "GARBAGE_COLLECTION": "垃圾回收", + "PURGE_AUDIT": "清除稽核記錄", + "JOBSERVICE_MONITOR": "工作服務監控器", + "TAG_RETENTION": "標籤保留", + "SCANNER": "掃描器", + "LABEL": "標籤", + "EXPORT_CVE": "匯出 CVE", + "SECURITY_HUB": "安全中樞", + "CATALOG": "目錄", + "METADATA": "專案中繼資料", + "REPOSITORY": "儲存庫", + "ARTIFACT": "Artifact", + "SCAN": "掃描", + "SBOM": "SBOM", + "TAG": "標籤", + "ACCESSORY": "附件", + "ARTIFACT_ADDITION": "Artifact 附加項目", + "ARTIFACT_LABEL": "Artifact 標籤", + "PREHEAT_POLICY": "預熱原則", + "IMMUTABLE_TAG": "不可變標籤", + "LOG": "日誌", + "NOTIFICATION_POLICY": "通知原則", + "QUOTA": "配額", + "BACK": "上一步", + "NEXT": "下一步", + "FINISH": "完成", + "BASIC_INFO": "基本資訊", + "SELECT_PERMISSIONS": "選取權限", + "SELECT_SYSTEM_PERMISSIONS": "選取系統權限", + "SELECT_PROJECT_PERMISSIONS": "選取專案權限", + "SYSTEM_PERMISSIONS": "系統權限", + "ROLE": "機器人帳號", + "USER": "使用者", + "LDAPUSER": "LDAP 使用者", + "GROUP": "使用者群組", + "MEMBER": "專案成員" + }, "WEBHOOK": { "ENABLED_BUTTON": "啟用", "DISABLED_BUTTON": "停用", diff --git a/src/server/v2.0/handler/handler.go b/src/server/v2.0/handler/handler.go index 10a22bb6c52..7bd567f982e 100644 --- a/src/server/v2.0/handler/handler.go +++ b/src/server/v2.0/handler/handler.go @@ -44,6 +44,7 @@ func New() http.Handler { PreheatAPI: newPreheatAPI(), IconAPI: newIconAPI(), RobotAPI: newRobotAPI(), + RoleAPI: newRoleAPI(), ReplicationAPI: newReplicationAPI(), RegistryAPI: newRegistryAPI(), SysteminfoAPI: newSystemInfoAPI(), diff --git a/src/server/v2.0/handler/member.go b/src/server/v2.0/handler/member.go index 2243cca82f6..7f39425dcdf 100644 --- a/src/server/v2.0/handler/member.go +++ b/src/server/v2.0/handler/member.go @@ -23,6 +23,7 @@ import ( "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/controller/member" + roleCtl "github.com/goharbor/harbor/src/controller/role" "github.com/goharbor/harbor/src/lib" "github.com/goharbor/harbor/src/lib/errors" memberModels "github.com/goharbor/harbor/src/pkg/member/models" @@ -32,11 +33,15 @@ import ( type memberAPI struct { BaseAPI - ctl member.Controller + ctl member.Controller + roleCtl roleCtl.Controller } func newMemberAPI() *memberAPI { - return &memberAPI{ctl: member.NewController()} + return &memberAPI{ + ctl: member.NewController(), + roleCtl: roleCtl.Ctl, + } } func (m *memberAPI) CreateProjectMember(ctx context.Context, params operation.CreateProjectMemberParams) middleware.Responder { @@ -47,6 +52,9 @@ func (m *memberAPI) CreateProjectMember(ctx context.Context, params operation.Cr if params.ProjectMember == nil { return m.SendError(ctx, errors.BadRequestError(nil).WithMessage("the project member should provide")) } + if err := m.checkNoEscalation(ctx, projectNameOrID, params.ProjectMember.RoleID); err != nil { + return m.SendError(ctx, err) + } req, err := toMemberReq(params.ProjectMember) if err != nil { return m.SendError(ctx, err) @@ -164,6 +172,9 @@ func (m *memberAPI) UpdateProjectMember(ctx context.Context, params operation.Up if params.Mid == 0 { return m.SendError(ctx, errors.BadRequestError(nil).WithMessage("member id can not be empty")) } + if err := m.checkNoEscalation(ctx, projectNameOrID, params.Role.RoleID); err != nil { + return m.SendError(ctx, err) + } err := m.ctl.UpdateRole(ctx, projectNameOrID, int(params.Mid), int(params.Role.RoleID)) if err != nil { @@ -171,3 +182,24 @@ func (m *memberAPI) UpdateProjectMember(ctx context.Context, params operation.Up } return operation.NewUpdateProjectMemberOK() } + +// checkNoEscalation rejects role assignments where the target role has permissions the caller lacks. +func (m *memberAPI) checkNoEscalation(ctx context.Context, projectNameOrID any, roleID int64) error { + r, err := m.roleCtl.Get(ctx, roleID, &roleCtl.Option{WithPermission: true}) + if err != nil { + return err + } + for _, perm := range r.Permissions { + for _, acc := range perm.Access { + has, err := m.HasProjectPermission(ctx, projectNameOrID, acc.Action, acc.Resource) + if err != nil { + return err + } + if !has { + return errors.ForbiddenError(nil).WithMessagef( + "escalation not allowed: you lack %s:%s", acc.Resource, acc.Action) + } + } + } + return nil +} diff --git a/src/server/v2.0/handler/member_test.go b/src/server/v2.0/handler/member_test.go new file mode 100644 index 00000000000..4787d280118 --- /dev/null +++ b/src/server/v2.0/handler/member_test.go @@ -0,0 +1,232 @@ +// 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" + "testing" + + "github.com/stretchr/testify/assert" + testifymock "github.com/stretchr/testify/mock" + + "github.com/goharbor/harbor/src/common/rbac" + rbacProject "github.com/goharbor/harbor/src/common/rbac/project" + "github.com/goharbor/harbor/src/common/security" + roleCtl "github.com/goharbor/harbor/src/controller/role" + "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg/permission/types" + securityMock "github.com/goharbor/harbor/src/testing/common/security" +) + +// --------------------------------------------------------------------------- +// Inline mock for roleCtl.Controller +// Only Get needs real behaviour; the rest are no-ops for these tests. +// --------------------------------------------------------------------------- + +type stubRoleCtl struct { + testifymock.Mock +} + +func (s *stubRoleCtl) Get(ctx context.Context, id int64, option *roleCtl.Option) (*roleCtl.Role, error) { + args := s.Called(ctx, id, option) + r, _ := args.Get(0).(*roleCtl.Role) + return r, args.Error(1) +} + +func (s *stubRoleCtl) Create(ctx context.Context, r *roleCtl.Role) (int64, error) { return 0, nil } +func (s *stubRoleCtl) Delete(ctx context.Context, id int64, option ...*roleCtl.Option) error { + return nil +} +func (s *stubRoleCtl) Update(ctx context.Context, r *roleCtl.Role, option *roleCtl.Option) error { + return nil +} +func (s *stubRoleCtl) List(ctx context.Context, query *q.Query, option *roleCtl.Option) ([]*roleCtl.Role, error) { + return nil, nil +} +func (s *stubRoleCtl) Count(ctx context.Context, query *q.Query) (int64, error) { return 0, nil } + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const testProjectID int64 = 42 + +// policyAccess builds a single-permission role for test fixtures. +func policyAccess(resource rbac.Resource, action rbac.Action) []*types.Policy { + return []*types.Policy{{Resource: resource, Action: action}} +} + +// projectResource returns the full RBAC resource string for a project subresource, +// matching what HasProjectPermission builds internally. +func projectResource(subresource rbac.Resource) rbac.Resource { + return rbacProject.NewNamespace(testProjectID).Resource(subresource) +} + +// newCtxWithSecurity injects sc into a background context, mirroring what the +// handler middleware does at request time. +func newCtxWithSecurity(sc security.Context) context.Context { + return security.NewContext(context.Background(), sc) +} + +// newAPI returns a memberAPI wired to the given role controller stub. +func newAPI(rc *stubRoleCtl) *memberAPI { + return &memberAPI{roleCtl: rc} +} + +// --------------------------------------------------------------------------- +// Tests for checkNoEscalation +// --------------------------------------------------------------------------- + +func TestCheckNoEscalation_RoleWithNoPermissions(t *testing.T) { + sc := &securityMock.Context{} + sc.On("IsSysAdmin").Return(false) + // Can() should never be called — role has zero permissions + + rc := &stubRoleCtl{} + rc.On("Get", testifymock.Anything, int64(10), testifymock.Anything). + Return(&roleCtl.Role{}, nil) + + err := newAPI(rc).checkNoEscalation(newCtxWithSecurity(sc), testProjectID, 10) + assert.NoError(t, err) + sc.AssertNotCalled(t, "Can") +} + +func TestCheckNoEscalation_CallerHasAllPermissions(t *testing.T) { + sc := &securityMock.Context{} + sc.On("IsSysAdmin").Return(false) + // Caller holds repository:pull + sc.On("Can", testifymock.Anything, + rbac.ActionPull, projectResource(rbac.ResourceRepository)). + Return(true) + + rc := &stubRoleCtl{} + rc.On("Get", testifymock.Anything, int64(20), testifymock.Anything). + Return(&roleCtl.Role{ + Permissions: []*roleCtl.Permission{{ + Access: policyAccess(rbac.ResourceRepository, rbac.ActionPull), + }}, + }, nil) + + err := newAPI(rc).checkNoEscalation(newCtxWithSecurity(sc), testProjectID, 20) + assert.NoError(t, err) +} + +func TestCheckNoEscalation_CallerLacksPermission(t *testing.T) { + sc := &securityMock.Context{} + sc.On("IsSysAdmin").Return(false) + // Caller does NOT hold member:create + sc.On("Can", testifymock.Anything, + rbac.ActionCreate, projectResource(rbac.ResourceMember)). + Return(false) + + rc := &stubRoleCtl{} + rc.On("Get", testifymock.Anything, int64(30), testifymock.Anything). + Return(&roleCtl.Role{ + Permissions: []*roleCtl.Permission{{ + Access: policyAccess(rbac.ResourceMember, rbac.ActionCreate), + }}, + }, nil) + + err := newAPI(rc).checkNoEscalation(newCtxWithSecurity(sc), testProjectID, 30) + assert.Error(t, err) + assert.Equal(t, errors.ForbiddenCode, errors.ErrCode(err), "expected ForbiddenError, got: %v", err) +} + +func TestCheckNoEscalation_FirstPermissionPassesSecondFails(t *testing.T) { + sc := &securityMock.Context{} + sc.On("IsSysAdmin").Return(false) + sc.On("Can", testifymock.Anything, + rbac.ActionPull, projectResource(rbac.ResourceRepository)). + Return(true) + sc.On("Can", testifymock.Anything, + rbac.ActionCreate, projectResource(rbac.ResourceMember)). + Return(false) + + rc := &stubRoleCtl{} + rc.On("Get", testifymock.Anything, int64(40), testifymock.Anything). + Return(&roleCtl.Role{ + Permissions: []*roleCtl.Permission{{ + Access: []*types.Policy{ + {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, + {Resource: rbac.ResourceMember, Action: rbac.ActionCreate}, + }, + }}, + }, nil) + + err := newAPI(rc).checkNoEscalation(newCtxWithSecurity(sc), testProjectID, 40) + assert.Error(t, err) + assert.Equal(t, errors.ForbiddenCode, errors.ErrCode(err)) +} + +func TestCheckNoEscalation_RoleCtlGetError(t *testing.T) { + sc := &securityMock.Context{} + sc.On("IsSysAdmin").Return(false) + + rc := &stubRoleCtl{} + rc.On("Get", testifymock.Anything, int64(50), testifymock.Anything). + Return(nil, fmt.Errorf("db error")) + + err := newAPI(rc).checkNoEscalation(newCtxWithSecurity(sc), testProjectID, 50) + assert.Error(t, err) + assert.Contains(t, err.Error(), "db error") +} + +func TestCheckNoEscalation_MultiplePermissionSetsAllowed(t *testing.T) { + sc := &securityMock.Context{} + sc.On("IsSysAdmin").Return(false) + sc.On("Can", testifymock.Anything, + rbac.ActionPull, projectResource(rbac.ResourceRepository)).Return(true) + sc.On("Can", testifymock.Anything, + rbac.ActionPush, projectResource(rbac.ResourceRepository)).Return(true) + sc.On("Can", testifymock.Anything, + rbac.ActionRead, projectResource(rbac.ResourceArtifact)).Return(true) + + rc := &stubRoleCtl{} + rc.On("Get", testifymock.Anything, int64(60), testifymock.Anything). + Return(&roleCtl.Role{ + Permissions: []*roleCtl.Permission{ + {Access: policyAccess(rbac.ResourceRepository, rbac.ActionPull)}, + {Access: policyAccess(rbac.ResourceRepository, rbac.ActionPush)}, + {Access: policyAccess(rbac.ResourceArtifact, rbac.ActionRead)}, + }, + }, nil) + + err := newAPI(rc).checkNoEscalation(newCtxWithSecurity(sc), testProjectID, 60) + assert.NoError(t, err) +} + +func TestCheckNoEscalation_ProjectNameInsteadOfID(t *testing.T) { + // Passing a project name string follows a code path that looks up the project + // from DB (not available in unit tests), so we only verify that numeric IDs work. + // This test documents the expected behavior for coverage completeness. + sc := &securityMock.Context{} + sc.On("IsSysAdmin").Return(false) + + rc := &stubRoleCtl{} + rc.On("Get", testifymock.Anything, int64(70), testifymock.Anything). + Return(&roleCtl.Role{}, nil) + + // numeric int64 → no DB lookup → should work cleanly + err := newAPI(rc).checkNoEscalation(newCtxWithSecurity(sc), testProjectID, 70) + assert.NoError(t, err) +} + +// --------------------------------------------------------------------------- +// Compile-time interface check — fails the build if stubRoleCtl drifts. +// --------------------------------------------------------------------------- + +var _ roleCtl.Controller = (*stubRoleCtl)(nil) diff --git a/src/server/v2.0/handler/model/role.go b/src/server/v2.0/handler/model/role.go new file mode 100644 index 00000000000..b14f139d283 --- /dev/null +++ b/src/server/v2.0/handler/model/role.go @@ -0,0 +1,62 @@ +// 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 model + +import ( + "github.com/go-openapi/strfmt" + "github.com/goharbor/harbor/src/controller/role" + "github.com/goharbor/harbor/src/lib" + "github.com/goharbor/harbor/src/lib/log" + "github.com/goharbor/harbor/src/server/v2.0/models" +) + +// Robot ... +type Role struct { + *role.Role +} + +// ToSwagger ... +func (r *Role) ToSwagger() *models.Role { + perms := []*models.RolePermission{} + for _, p := range r.Permissions { + temp := &models.RolePermission{} + if err := lib.JSONCopy(temp, p); err != nil { + log.Warningf("failed to do JSONCopy on RolePermission, error: %v", err) + } + perms = append(perms, temp) + } + + return &models.Role{ + ID: r.ID, + Name: r.Name, + RoleMask: r.RoleMask, + RoleCode: r.RoleCode, + Permissions: perms, + IsBuiltin: r.IsBuiltin, + Description: r.Description, + Modified: r.Modified, + CreatedBy: r.CreatedBy, + CreatedAt: strfmt.DateTime(r.CreatedAt), + ModifiedBy: r.ModifiedBy, + ModifiedAt: strfmt.DateTime(r.ModifiedAt), + } +} + +// NewRole ... +func NewRole(r *role.Role) *Role { + return &Role{ + Role: r, + } +} diff --git a/src/server/v2.0/handler/permissions.go b/src/server/v2.0/handler/permissions.go index 192e88a2758..6faaf475b4e 100644 --- a/src/server/v2.0/handler/permissions.go +++ b/src/server/v2.0/handler/permissions.go @@ -43,6 +43,11 @@ func newPermissionsAPIAPI() *permissionsAPI { } } +// GetPermissions returns the available permission catalog. +// Accessible to all authenticated users — the catalog is read-only metadata +// (action names such as repository:pull) used by project-level UI components +// (robot accounts, webhooks, role management). System-level permissions are +// only included in the response for system admins. func (p *permissionsAPI) GetPermissions(ctx context.Context, _ permissions.GetPermissionsParams) middleware.Responder { secCtx, ok := security.FromContext(ctx) if !ok { @@ -52,12 +57,12 @@ func (p *permissionsAPI) GetPermissions(ctx context.Context, _ permissions.GetPe return p.SendError(ctx, errors.UnauthorizedError(nil).WithMessage(secCtx.GetUsername())) } - var isSystemAdmin bool - var isProjectAdmin bool + isSystemAdmin := secCtx.IsSysAdmin() - if secCtx.IsSysAdmin() { - isSystemAdmin = true - } else { + // Project-admin check is only needed to decide whether to include project-admin + // scoped permissions; it does not gate access to this endpoint. + var isProjectAdmin bool + if !isSystemAdmin { if sc, ok := secCtx.(*local.SecurityContext); ok { user := sc.User() var err error @@ -67,22 +72,20 @@ func (p *permissionsAPI) GetPermissions(ctx context.Context, _ permissions.GetPe } } } - if !isSystemAdmin && !isProjectAdmin { - return p.SendError(ctx, errors.ForbiddenError(errors.New("only admins(system and project) can access permissions"))) - } + _ = isProjectAdmin // reserved for future scope filtering provider := rbac.GetPermissionProvider() sysPermissions := make([]*types.Policy, 0) proPermissions := provider.GetPermissions(rbac.ScopeProject) if isSystemAdmin { - // project admin cannot see the system level permissions sysPermissions = provider.GetPermissions(rbac.ScopeSystem) } + rolePermissions := provider.GetPermissions(rbac.ScopeRole) - return permissions.NewGetPermissionsOK().WithPayload(p.convertPermissions(sysPermissions, proPermissions)) + return permissions.NewGetPermissionsOK().WithPayload(p.convertPermissions(sysPermissions, proPermissions, rolePermissions)) } -func (p *permissionsAPI) convertPermissions(system, project []*types.Policy) *models.Permissions { +func (p *permissionsAPI) convertPermissions(system, project, role []*types.Policy) *models.Permissions { res := &models.Permissions{} if len(system) > 0 { var sysPermission []*models.Permission @@ -106,5 +109,16 @@ func (p *permissionsAPI) convertPermissions(system, project []*types.Policy) *mo res.Project = proPermission } + if len(role) > 0 { + var rolePermission []*models.Permission + for _, item := range role { + rolePermission = append(rolePermission, &models.Permission{ + Resource: item.Resource.String(), + Action: item.Action.String(), + }) + } + res.Role = rolePermission + } + return res } diff --git a/src/server/v2.0/handler/project.go b/src/server/v2.0/handler/project.go index de75072b8c7..c4178148f7c 100644 --- a/src/server/v2.0/handler/project.go +++ b/src/server/v2.0/handler/project.go @@ -983,3 +983,26 @@ func (a *projectAPI) GetLogExts(ctx context.Context, params operation.GetLogExts WithLink(a.Links(ctx, params.HTTPRequest.URL, total, query.PageNumber, query.PageSize).String()). WithPayload(convertToModelAuditLogExt(logs)) } + +// GetEffectivePermissions returns the subset of ScopeProject permissions the calling user actually holds. +func (a *projectAPI) GetEffectivePermissions(ctx context.Context, params operation.GetEffectivePermissionsParams) middleware.Responder { + projectNameOrID := parseProjectNameOrID(params.ProjectNameOrID, params.XIsResourceName) + + candidates := rbac.GetPermissionProvider().GetPermissions(rbac.ScopeProject) + + var effective []*models.Permission + for _, p := range candidates { + resource := mapRobotToHumanResource(p.Resource) + has, err := a.HasProjectPermission(ctx, projectNameOrID, p.Action, resource) + if err != nil { + continue + } + if has { + effective = append(effective, &models.Permission{ + Resource: p.Resource.String(), + Action: p.Action.String(), + }) + } + } + return operation.NewGetEffectivePermissionsOK().WithPayload(effective) +} diff --git a/src/server/v2.0/handler/robot.go b/src/server/v2.0/handler/robot.go index a49d52cc7b4..31f0b1e4aa1 100644 --- a/src/server/v2.0/handler/robot.go +++ b/src/server/v2.0/handler/robot.go @@ -84,6 +84,9 @@ func (rAPI *robotAPI) CreateRobot(ctx context.Context, params operation.CreateRo switch s := sc.(type) { case *local.SecurityContext: creatorRef = int64(s.User().UserID) + if err := rAPI.validateNoEscalation(ctx, params.Robot.Permissions); err != nil { + return rAPI.SendError(ctx, err) + } case *robotSc.SecurityContext: if s.User() == nil { return rAPI.SendError(ctx, errors.New(nil).WithMessage("invalid security context: empty robot account")) @@ -391,6 +394,16 @@ func (rAPI *robotAPI) updateV2Robot(ctx context.Context, params operation.Update return errors.BadRequestError(nil).WithMessage("cannot update the level or name of robot") } + sc, err := rAPI.GetSecurityContext(ctx) + if err != nil { + return err + } + if _, ok := sc.(*local.SecurityContext); ok { + if err := rAPI.validateNoEscalation(ctx, params.Robot.Permissions); err != nil { + return err + } + } + if r.Duration != *params.Robot.Duration { r.Duration = *params.Robot.Duration if *params.Robot.Duration == -1 { @@ -443,6 +456,37 @@ func containsAccess(policies []*types.Policy, item *models.Access) bool { return false } +// robotToHumanResourceMap maps ScopeProject resource names to their ScopeRole equivalents. +// Needed because robots use "project" for the project entity while human roles use "" (ResourceSelf). +var robotToHumanResourceMap = map[rbac.Resource]rbac.Resource{ + rbac.ResourceProject: rbac.ResourceSelf, +} + +func mapRobotToHumanResource(r rbac.Resource) rbac.Resource { + if mapped, ok := robotToHumanResourceMap[r]; ok { + return mapped + } + return r +} + +// validateNoEscalation ensures that a human user cannot grant a robot more permissions than they hold. +func (rAPI *robotAPI) validateNoEscalation(ctx context.Context, permissions []*models.RobotPermission) error { + for _, perm := range permissions { + for _, acc := range perm.Access { + resource := mapRobotToHumanResource(rbac.Resource(acc.Resource)) + has, err := rAPI.HasProjectPermission(ctx, perm.Namespace, rbac.Action(acc.Action), resource) + if err != nil { + return err + } + if !has { + return errors.ForbiddenError(nil).WithMessagef( + "permission escalation not allowed: you do not have %s:%s", acc.Resource, acc.Action) + } + } + } + return nil +} + // isValidPermissionScope checks if permission slice A is a subset of permission slice B func isValidPermissionScope(creating []*models.RobotPermission, creator []*robot.Permission) bool { creatorMap := make(map[string]*robot.Permission) diff --git a/src/server/v2.0/handler/robot_test.go b/src/server/v2.0/handler/robot_test.go index fdcf326e473..d9724dfa492 100644 --- a/src/server/v2.0/handler/robot_test.go +++ b/src/server/v2.0/handler/robot_test.go @@ -5,11 +5,17 @@ import ( "testing" "github.com/stretchr/testify/assert" + testifymock "github.com/stretchr/testify/mock" "github.com/goharbor/harbor/src/common/rbac" + rbacProject "github.com/goharbor/harbor/src/common/rbac/project" "github.com/goharbor/harbor/src/controller/robot" + "github.com/goharbor/harbor/src/lib/errors" "github.com/goharbor/harbor/src/pkg/permission/types" + projectModels "github.com/goharbor/harbor/src/pkg/project/models" "github.com/goharbor/harbor/src/server/v2.0/models" + "github.com/goharbor/harbor/src/testing/mock" + securityMock "github.com/goharbor/harbor/src/testing/common/security" ) func TestValidLevel(t *testing.T) { @@ -480,3 +486,77 @@ func TestValidPermissionScope(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// mapRobotToHumanResource +// --------------------------------------------------------------------------- + +func TestMapRobotToHumanResource_ProjectMapsToSelf(t *testing.T) { + assert.Equal(t, rbac.ResourceSelf, mapRobotToHumanResource(rbac.ResourceProject)) +} + +func TestMapRobotToHumanResource_RepositoryPassthrough(t *testing.T) { + assert.Equal(t, rbac.ResourceRepository, mapRobotToHumanResource(rbac.ResourceRepository)) +} + +func TestMapRobotToHumanResource_UnknownPassthrough(t *testing.T) { + unknown := rbac.Resource("unknown") + assert.Equal(t, unknown, mapRobotToHumanResource(unknown)) +} + +// --------------------------------------------------------------------------- +// validateNoEscalation +// --------------------------------------------------------------------------- + +func robotPerm(ns, resource, action string) []*models.RobotPermission { + return []*models.RobotPermission{{ + Namespace: ns, + Access: []*models.Access{{Resource: resource, Action: action}}, + }} +} + +// stubProject stubs projectCtlMock.GetByName to return a project with the given ID. +// perm.Namespace is always a string so HasProjectPermission always calls GetByName. +func stubProject(projectID int64) { + mock.OnAnything(projectCtlMock, "GetByName"). + Return(&projectModels.Project{ProjectID: projectID}, nil).Once() +} + +func TestValidateNoEscalation_CallerHasPermission(t *testing.T) { + stubProject(1) + sc := &securityMock.Context{} + sc.On("Can", testifymock.Anything, rbac.ActionPull, + rbacProject.NewNamespace(1).Resource(rbac.ResourceRepository)).Return(true) + + err := (&robotAPI{}).validateNoEscalation(newCtxWithSecurity(sc), robotPerm("testproject", "repository", "pull")) + assert.NoError(t, err) +} + +func TestValidateNoEscalation_CallerLacksPermission(t *testing.T) { + stubProject(1) + sc := &securityMock.Context{} + sc.On("Can", testifymock.Anything, rbac.ActionPush, + rbacProject.NewNamespace(1).Resource(rbac.ResourceRepository)).Return(false) + + err := (&robotAPI{}).validateNoEscalation(newCtxWithSecurity(sc), robotPerm("testproject", "repository", "push")) + assert.Error(t, err) + assert.Equal(t, errors.ForbiddenCode, errors.ErrCode(err)) +} + +func TestValidateNoEscalation_EmptyPermissions(t *testing.T) { + sc := &securityMock.Context{} + err := (&robotAPI{}).validateNoEscalation(newCtxWithSecurity(sc), nil) + assert.NoError(t, err) + sc.AssertNotCalled(t, "Can") +} + +func TestValidateNoEscalation_ProjectResourceMappedToSelf(t *testing.T) { + stubProject(1) + sc := &securityMock.Context{} + // "project" maps to ResourceSelf ("") via mapRobotToHumanResource. + sc.On("Can", testifymock.Anything, rbac.ActionRead, + rbacProject.NewNamespace(1).Resource(rbac.ResourceSelf)).Return(true) + + err := (&robotAPI{}).validateNoEscalation(newCtxWithSecurity(sc), robotPerm("testproject", "project", "read")) + assert.NoError(t, err) +} diff --git a/src/server/v2.0/handler/role.go b/src/server/v2.0/handler/role.go new file mode 100644 index 00000000000..49b24d181aa --- /dev/null +++ b/src/server/v2.0/handler/role.go @@ -0,0 +1,322 @@ +// 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" + "strings" + + "github.com/go-openapi/runtime/middleware" + + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/common/security/local" + "github.com/goharbor/harbor/src/controller/role" + "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/pkg/permission/types" + pkg "github.com/goharbor/harbor/src/pkg/role/model" + "github.com/goharbor/harbor/src/server/v2.0/handler/model" + "github.com/goharbor/harbor/src/server/v2.0/models" + operation "github.com/goharbor/harbor/src/server/v2.0/restapi/operations/role" +) + +func newRoleAPI() *roleAPI { + return &roleAPI{ + roleCtl: role.Ctl, + } +} + +type roleAPI struct { + BaseAPI + roleCtl role.Controller +} + +func (rAPI *roleAPI) CreateRole(ctx context.Context, params operation.CreateRoleParams) middleware.Responder { + if err := rAPI.checkSysAdmin(ctx); err != nil { + return rAPI.SendError(ctx, err) + } + + if err := validateRoleName(params.Role.Name); err != nil { + return rAPI.SendError(ctx, err) + } + + if err := rAPI.validate(params.Role.Permissions); err != nil { + return rAPI.SendError(ctx, err) + } + + sc, err := rAPI.GetSecurityContext(ctx) + if err != nil { + return rAPI.SendError(ctx, err) + } + + r := &role.Role{ + Role: pkg.Role{ + Name: params.Role.Name, + RoleMask: params.Role.RoleMask, + RoleCode: params.Role.RoleCode, + }, + } + + switch s := sc.(type) { + case *local.SecurityContext: + if s.User() == nil { + return rAPI.SendError(ctx, errors.New(nil).WithMessage("invalid security context: empty role account")) + } + r.CreatedBy = s.User().Username + default: + return rAPI.SendError(ctx, errors.New(nil).WithMessage("invalid security context")) + } + r.Description = params.Role.Description + + if err := lib.JSONCopy(&r.Permissions, params.Role.Permissions); err != nil { + log.Warningf("failed to call JSONCopy on role permission when CreateRole, error: %v", err) + } + + rid, err := rAPI.roleCtl.Create(ctx, r) + if err != nil { + return rAPI.SendError(ctx, err) + } + + created, err := rAPI.roleCtl.Get(ctx, rid, nil) + if err != nil { + return rAPI.SendError(ctx, err) + } + + location := fmt.Sprintf("%s/%d", strings.TrimSuffix(params.HTTPRequest.URL.Path, "/"), created.ID) + return operation.NewCreateRoleCreated().WithLocation(location).WithPayload(&models.RoleCreated{ + ID: created.ID, + Name: created.Name, + }) +} + +func (rAPI *roleAPI) DeleteRole(ctx context.Context, params operation.DeleteRoleParams) middleware.Responder { + if err := rAPI.checkSysAdmin(ctx); err != nil { + return rAPI.SendError(ctx, err) + } + + r, err := rAPI.roleCtl.Get(ctx, params.RoleID, nil) + if err != nil { + return rAPI.SendError(ctx, err) + } + + if err := rAPI.roleCtl.Delete(ctx, params.RoleID); err != nil { + // for the version 1 role account, has to ignore the no permission error. + if !r.Editable && errors.IsNotFoundErr(err) { + return operation.NewDeleteRoleOK() + } + return rAPI.SendError(ctx, err) + } + return operation.NewDeleteRoleOK() +} + +func (rAPI *roleAPI) ListRole(ctx context.Context, params operation.ListRoleParams) middleware.Responder { + if err := rAPI.RequireAuthenticated(ctx); err != nil { + return rAPI.SendError(ctx, err) + } + + query, err := rAPI.BuildQuery(ctx, params.Q, params.Sort, params.Page, params.PageSize) + if err != nil { + return rAPI.SendError(ctx, err) + } + + query.Keywords["Visible"] = true + + total, err := rAPI.roleCtl.Count(ctx, query) + if err != nil { + return rAPI.SendError(ctx, err) + } + + roles, err := rAPI.roleCtl.List(ctx, query, &role.Option{ + WithPermission: true, + }) + if err != nil { + return rAPI.SendError(ctx, err) + } + + var results []*models.Role + for _, r := range roles { + results = append(results, model.NewRole(r).ToSwagger()) + } + + return operation.NewListRoleOK(). + WithXTotalCount(total). + WithLink(rAPI.Links(ctx, params.HTTPRequest.URL, total, query.PageNumber, query.PageSize).String()). + WithPayload(results) +} + +func (rAPI *roleAPI) GetRoleByID(ctx context.Context, params operation.GetRoleByIDParams) middleware.Responder { + if err := rAPI.RequireAuthenticated(ctx); err != nil { + return rAPI.SendError(ctx, err) + } + + r, err := rAPI.roleCtl.Get(ctx, params.RoleID, &role.Option{ + WithPermission: true, + }) + if err != nil { + return rAPI.SendError(ctx, err) + } + + return operation.NewGetRoleByIDOK().WithPayload(model.NewRole(r).ToSwagger()) +} + +// checkSysAdmin returns UnauthorizedError for unauthenticated callers and +// ForbiddenError for callers who are not sysadmins. +func (rAPI *roleAPI) checkSysAdmin(ctx context.Context) error { + sc, err := rAPI.GetSecurityContext(ctx) + if err != nil { + return err + } + if !sc.IsAuthenticated() { + return errors.UnauthorizedError(nil) + } + if !sc.IsSysAdmin() { + return errors.ForbiddenError(nil).WithMessage("only sysadmins can manage roles") + } + return nil +} + +func (rAPI *roleAPI) UpdateRole(ctx context.Context, params operation.UpdateRoleParams) middleware.Responder { + var err error + if err := rAPI.checkSysAdmin(ctx); err != nil { + return rAPI.SendError(ctx, err) + } + r, err := rAPI.roleCtl.Get(ctx, params.RoleID, &role.Option{ + WithPermission: true, + }) + if err != nil { + return rAPI.SendError(ctx, err) + } + + if !r.Editable { + err = errors.DeniedError(nil).WithMessage("editing of legacy role is not allowed") + } else { + err = rAPI.updateV2Role(ctx, params, r) + } + if err != nil { + return rAPI.SendError(ctx, err) + } + + return operation.NewUpdateRoleOK() +} + +// more validation +func (rAPI *roleAPI) validate(permissions []*models.RolePermission) error { + + if len(permissions) == 0 { + return errors.New(nil).WithMessage("bad request empty permission").WithCode(errors.BadRequestCode) + } + + for _, perm := range permissions { + if len(perm.Access) == 0 { + return errors.New(nil).WithMessage("bad request empty access").WithCode(errors.BadRequestCode) + } + } + + provider := rbac.GetPermissionProvider() + // to validate the access scope + for _, perm := range permissions { + if perm.Kind == role.LEVELROLE { + + polices := provider.GetPermissions(rbac.ScopeRole) + + for _, acc := range perm.Access { + + if !containsAccess(polices, acc) { + //TODO check here that escalation is not possible + return errors.New(nil).WithMessagef("bad request permission: %s:%s", acc.Resource, acc.Action).WithCode(errors.BadRequestCode) + } + } + } else { + return errors.New(nil).WithMessagef("bad request permission level: %s", perm.Kind).WithCode(errors.BadRequestCode) + } + } + + return nil +} + +func (rAPI *roleAPI) updateV2Role(ctx context.Context, params operation.UpdateRoleParams, r *role.Role) error { + if err := rAPI.validate(params.Role.Permissions); err != nil { + return err + } + + if len(params.Role.Permissions) != 0 { + if err := lib.JSONCopy(&r.Permissions, params.Role.Permissions); err != nil { + log.Warningf("failed to call JSONCopy on role permission when updateV2Role, error: %v", err) + } + } + r.Description = params.Role.Description + + if err := rAPI.roleCtl.Update(ctx, r, &role.Option{ + WithPermission: true, + }); err != nil { + return err + } + return nil +} + +// validateName validates the role name, especially '+' cannot be a valid character +func validateRoleName(name string) error { + return nil +} + +func containsRoleAccess(policies []*types.Policy, item *models.Access) bool { + for _, po := range policies { + if po.Resource.String() == item.Resource && po.Action.String() == item.Action { + return true + } + } + return false +} + +// isValidPermissionScope checks if permission slice A is a subset of permission slice B +func isValidRolePermissionScope(creating []*models.RolePermission, creator []*role.Permission) bool { + creatorMap := make(map[string]*role.Permission) + for _, creatorPerm := range creator { + key := fmt.Sprintf("%s:%s", creatorPerm.Kind, creatorPerm.Namespace) + creatorMap[key] = creatorPerm + } + + hasLessThanOrEqualAccess := func(creating []*models.Access, creator []*types.Policy) bool { + creatorMap := make(map[string]*types.Policy) + for _, creatorP := range creator { + key := fmt.Sprintf("%s:%s:%s", creatorP.Resource, creatorP.Action, creatorP.Effect) + creatorMap[key] = creatorP + } + for _, creatingP := range creating { + key := fmt.Sprintf("%s:%s:%s", creatingP.Resource, creatingP.Action, creatingP.Effect) + if _, found := creatorMap[key]; !found { + return false + } + } + return true + } + + for _, pCreating := range creating { + key := fmt.Sprintf("%s:%s", pCreating.Kind, pCreating.Namespace) + creatorPerm, found := creatorMap[key] + if !found { + allProjects := fmt.Sprintf("%s:*", pCreating.Kind) + if creatorPerm, found = creatorMap[allProjects]; !found { + return false + } + } + if !hasLessThanOrEqualAccess(pCreating.Access, creatorPerm.Access) { + return false + } + } + return true +} diff --git a/src/server/v2.0/handler/role_test.go b/src/server/v2.0/handler/role_test.go new file mode 100644 index 00000000000..9b22e9a3f62 --- /dev/null +++ b/src/server/v2.0/handler/role_test.go @@ -0,0 +1,183 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" + testifymock "github.com/stretchr/testify/mock" + + roleCtl "github.com/goharbor/harbor/src/controller/role" + "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/pkg/permission/types" + "github.com/goharbor/harbor/src/server/v2.0/models" + securityMock "github.com/goharbor/harbor/src/testing/common/security" +) + +// --------------------------------------------------------------------------- +// checkSysAdmin +// --------------------------------------------------------------------------- + +func TestCheckSysAdmin_SysAdmin(t *testing.T) { + sc := &securityMock.Context{} + sc.On("IsAuthenticated").Return(true) + sc.On("IsSysAdmin").Return(true) + + err := (&roleAPI{}).checkSysAdmin(newCtxWithSecurity(sc)) + assert.NoError(t, err) +} + +func TestCheckSysAdmin_NonSysAdminForbidden(t *testing.T) { + sc := &securityMock.Context{} + sc.On("IsAuthenticated").Return(true) + sc.On("IsSysAdmin").Return(false) + + err := (&roleAPI{}).checkSysAdmin(newCtxWithSecurity(sc)) + assert.Error(t, err) + assert.Equal(t, errors.ForbiddenCode, errors.ErrCode(err)) +} + +func TestCheckSysAdmin_UnauthenticatedUnauthorized(t *testing.T) { + sc := &securityMock.Context{} + sc.On("IsAuthenticated").Return(false) + + err := (&roleAPI{}).checkSysAdmin(newCtxWithSecurity(sc)) + assert.Error(t, err) + assert.Equal(t, errors.UnAuthorizedCode, errors.ErrCode(err)) +} + +// --------------------------------------------------------------------------- +// validate +// --------------------------------------------------------------------------- + +func TestValidate_EmptyPermissions(t *testing.T) { + err := (&roleAPI{}).validate(nil) + assert.Error(t, err) + assert.Equal(t, errors.BadRequestCode, errors.ErrCode(err)) +} + +func TestValidate_EmptyAccess(t *testing.T) { + err := (&roleAPI{}).validate([]*models.RolePermission{ + {Kind: roleCtl.LEVELROLE, Access: []*models.Access{}}, + }) + assert.Error(t, err) + assert.Equal(t, errors.BadRequestCode, errors.ErrCode(err)) +} + +func TestValidate_WrongKind(t *testing.T) { + err := (&roleAPI{}).validate([]*models.RolePermission{ + {Kind: "system", Access: []*models.Access{{Resource: "member", Action: "create"}}}, + }) + assert.Error(t, err) + assert.Equal(t, errors.BadRequestCode, errors.ErrCode(err)) +} + +func TestValidate_UnknownResourceAction(t *testing.T) { + err := (&roleAPI{}).validate([]*models.RolePermission{ + {Kind: roleCtl.LEVELROLE, Access: []*models.Access{{Resource: "notaresource", Action: "notanaction"}}}, + }) + assert.Error(t, err) + assert.Equal(t, errors.BadRequestCode, errors.ErrCode(err)) +} + +func TestValidate_ValidPermission(t *testing.T) { + // "member:create" is a known ScopeRole permission (src/common/rbac/const.go) + err := (&roleAPI{}).validate([]*models.RolePermission{ + {Kind: roleCtl.LEVELROLE, Access: []*models.Access{{Resource: "member", Action: "create"}}}, + }) + assert.NoError(t, err) +} + +// --------------------------------------------------------------------------- +// containsRoleAccess +// --------------------------------------------------------------------------- + +func TestContainsRoleAccess_Present(t *testing.T) { + policies := []*types.Policy{ + {Resource: "member", Action: "create"}, + {Resource: "member", Action: "delete"}, + } + assert.True(t, containsRoleAccess(policies, &models.Access{Resource: "member", Action: "create"})) +} + +func TestContainsRoleAccess_Absent(t *testing.T) { + policies := []*types.Policy{ + {Resource: "member", Action: "create"}, + } + assert.False(t, containsRoleAccess(policies, &models.Access{Resource: "member", Action: "delete"})) +} + +func TestContainsRoleAccess_EmptyPolicies(t *testing.T) { + assert.False(t, containsRoleAccess(nil, &models.Access{Resource: "member", Action: "create"})) +} + +// --------------------------------------------------------------------------- +// isValidRolePermissionScope +// --------------------------------------------------------------------------- + +func rolePermissions(kind, ns string, accesses ...[2]string) []*models.RolePermission { + var access []*models.Access + for _, a := range accesses { + access = append(access, &models.Access{Resource: a[0], Action: a[1]}) + } + return []*models.RolePermission{{Kind: kind, Namespace: ns, Access: access}} +} + +func creatorPerms(kind, ns string, accesses ...[2]string) []*roleCtl.Permission { + var policies []*types.Policy + for _, a := range accesses { + policies = append(policies, &types.Policy{Resource: types.Resource(a[0]), Action: types.Action(a[1])}) + } + return []*roleCtl.Permission{{Kind: kind, Namespace: ns, Access: policies}} +} + +func TestIsValidRolePermissionScope_Subset(t *testing.T) { + creating := rolePermissions(roleCtl.LEVELROLE, "*", [2]string{"repository", "pull"}) + creator := creatorPerms(roleCtl.LEVELROLE, "*", [2]string{"repository", "pull"}, [2]string{"repository", "push"}) + assert.True(t, isValidRolePermissionScope(creating, creator)) +} + +func TestIsValidRolePermissionScope_Equal(t *testing.T) { + creating := rolePermissions(roleCtl.LEVELROLE, "*", [2]string{"repository", "pull"}, [2]string{"repository", "push"}) + creator := creatorPerms(roleCtl.LEVELROLE, "*", [2]string{"repository", "pull"}, [2]string{"repository", "push"}) + assert.True(t, isValidRolePermissionScope(creating, creator)) +} + +func TestIsValidRolePermissionScope_ExcessPerm(t *testing.T) { + creating := rolePermissions(roleCtl.LEVELROLE, "*", [2]string{"repository", "pull"}, [2]string{"repository", "push"}) + creator := creatorPerms(roleCtl.LEVELROLE, "*", [2]string{"repository", "pull"}) + assert.False(t, isValidRolePermissionScope(creating, creator)) +} + +func TestIsValidRolePermissionScope_EmptyCreating(t *testing.T) { + creator := creatorPerms(roleCtl.LEVELROLE, "*", [2]string{"repository", "pull"}) + assert.True(t, isValidRolePermissionScope(nil, creator)) +} + +func TestIsValidRolePermissionScope_ScopeNotInCreator(t *testing.T) { + creating := rolePermissions(roleCtl.LEVELROLE, "myns", [2]string{"repository", "pull"}) + creator := creatorPerms(roleCtl.LEVELROLE, "otherns", [2]string{"repository", "pull"}) + assert.False(t, isValidRolePermissionScope(creating, creator)) +} + +func TestIsValidRolePermissionScope_WildcardNamespaceMatches(t *testing.T) { + creating := rolePermissions(roleCtl.LEVELROLE, "specific", [2]string{"repository", "pull"}) + creator := creatorPerms(roleCtl.LEVELROLE, "*", [2]string{"repository", "pull"}) + assert.True(t, isValidRolePermissionScope(creating, creator)) +} + +// compile-time check: securityMock import used +var _ = testifymock.Anything diff --git a/src/testing/controller/role/controller.go b/src/testing/controller/role/controller.go new file mode 100644 index 00000000000..fef4ae28658 --- /dev/null +++ b/src/testing/controller/role/controller.go @@ -0,0 +1,297 @@ +// Code generated by mockery v2.53.3. DO NOT EDIT. + +package project + +import ( + context "context" + + commonmodels "github.com/goharbor/harbor/src/common/models" + + mock "github.com/stretchr/testify/mock" + + models "github.com/goharbor/harbor/src/pkg/role/model" + + project "github.com/goharbor/harbor/src/controller/project" + + q "github.com/goharbor/harbor/src/lib/q" +) + +// 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) + + if len(ret) == 0 { + panic("no return value specified for Count") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) (int64, error)); ok { + return rf(ctx, query) + } + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) int64); ok { + r0 = rf(ctx, query) + } else { + r0 = ret.Get(0).(int64) + } + + 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 *Controller) Create(ctx context.Context, _a1 *models.Role) (int64, error) { + ret := _m.Called(ctx, _a1) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *models.Role) (int64, error)); ok { + return rf(ctx, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, *models.Role) int64); ok { + r0 = rf(ctx, _a1) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(context.Context, *models.Role) 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 *Controller) Delete(ctx context.Context, id int64) error { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Exists provides a mock function with given fields: ctx, projectIDOrName +func (_m *Controller) Exists(ctx context.Context, projectIDOrName interface{}) (bool, error) { + ret := _m.Called(ctx, projectIDOrName) + + if len(ret) == 0 { + panic("no return value specified for Exists") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, interface{}) (bool, error)); ok { + return rf(ctx, projectIDOrName) + } + if rf, ok := ret.Get(0).(func(context.Context, interface{}) bool); ok { + r0 = rf(ctx, projectIDOrName) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, interface{}) error); ok { + r1 = rf(ctx, projectIDOrName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Get provides a mock function with given fields: ctx, projectIDOrName, options +func (_m *Controller) Get(ctx context.Context, projectIDOrName interface{}, options ...project.Option) (*models.Role, error) { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, projectIDOrName) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 *models.Role + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, interface{}, ...project.Option) (*models.Role, error)); ok { + return rf(ctx, projectIDOrName, options...) + } + if rf, ok := ret.Get(0).(func(context.Context, interface{}, ...project.Option) *models.Role); ok { + r0 = rf(ctx, projectIDOrName, options...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Role) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, interface{}, ...project.Option) error); ok { + r1 = rf(ctx, projectIDOrName, options...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetByName provides a mock function with given fields: ctx, projectName, options +func (_m *Controller) GetByName(ctx context.Context, projectName string, options ...project.Option) (*models.Role, error) { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, projectName) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for GetByName") + } + + var r0 *models.Role + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, ...project.Option) (*models.Role, error)); ok { + return rf(ctx, projectName, options...) + } + if rf, ok := ret.Get(0).(func(context.Context, string, ...project.Option) *models.Role); ok { + r0 = rf(ctx, projectName, options...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Role) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, ...project.Option) error); ok { + r1 = rf(ctx, projectName, options...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// List provides a mock function with given fields: ctx, query, options +func (_m *Controller) List(ctx context.Context, query *q.Query, options ...project.Option) ([]*models.Role, error) { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, query) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []*models.Role + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *q.Query, ...project.Option) ([]*models.Role, error)); ok { + return rf(ctx, query, options...) + } + if rf, ok := ret.Get(0).(func(context.Context, *q.Query, ...project.Option) []*models.Role); ok { + r0 = rf(ctx, query, options...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Role) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *q.Query, ...project.Option) error); ok { + r1 = rf(ctx, query, options...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListRoles provides a mock function with given fields: ctx, projectID, u +func (_m *Controller) ListRoles(ctx context.Context, projectID int64, u *commonmodels.User) ([]int, error) { + ret := _m.Called(ctx, projectID, u) + + if len(ret) == 0 { + panic("no return value specified for ListRoles") + } + + var r0 []int + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64, *commonmodels.User) ([]int, error)); ok { + return rf(ctx, projectID, u) + } + if rf, ok := ret.Get(0).(func(context.Context, int64, *commonmodels.User) []int); ok { + r0 = rf(ctx, projectID, u) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64, *commonmodels.User) error); ok { + r1 = rf(ctx, projectID, u) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: ctx, _a1 +func (_m *Controller) Update(ctx context.Context, _a1 *models.Role) error { + ret := _m.Called(ctx, _a1) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.Role) error); ok { + r0 = rf(ctx, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewController creates a new instance of Controller. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewController(t interface { + mock.TestingT + Cleanup(func()) +}) *Controller { + mock := &Controller{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/src/testing/pkg/role/dao/dao.go b/src/testing/pkg/role/dao/dao.go new file mode 100644 index 00000000000..a5f8e5f225e --- /dev/null +++ b/src/testing/pkg/role/dao/dao.go @@ -0,0 +1,191 @@ +// Code generated by mockery v2.53.3. DO NOT EDIT. + +package dao + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + model "github.com/goharbor/harbor/src/pkg/role/model" + + 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) + + if len(ret) == 0 { + panic("no return value specified for Count") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) (int64, error)); ok { + return rf(ctx, query) + } + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) int64); ok { + r0 = rf(ctx, query) + } else { + r0 = ret.Get(0).(int64) + } + + 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, r +func (_m *DAO) Create(ctx context.Context, r *model.Role) (int64, error) { + ret := _m.Called(ctx, r) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *model.Role) (int64, error)); ok { + return rf(ctx, r) + } + if rf, ok := ret.Get(0).(func(context.Context, *model.Role) int64); ok { + r0 = rf(ctx, r) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(context.Context, *model.Role) error); ok { + r1 = rf(ctx, r) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: ctx, id +func (_m *DAO) Delete(ctx context.Context, id int64) error { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64) 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 *DAO) Get(ctx context.Context, id int64) (*model.Role, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 *model.Role + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64) (*model.Role, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, int64) *model.Role); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Role) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { + r1 = rf(ctx, id) + } 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) ([]*model.Role, error) { + ret := _m.Called(ctx, query) + + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []*model.Role + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) ([]*model.Role, error)); ok { + return rf(ctx, query) + } + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) []*model.Role); ok { + r0 = rf(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Role) + } + } + + 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, r, props +func (_m *DAO) Update(ctx context.Context, r *model.Role, props ...string) error { + _va := make([]interface{}, len(props)) + for _i := range props { + _va[_i] = props[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, r) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *model.Role, ...string) error); ok { + r0 = rf(ctx, r, props...) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewDAO creates a new instance of DAO. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewDAO(t interface { + mock.TestingT + Cleanup(func()) +}) *DAO { + mock := &DAO{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/src/testing/pkg/role/manager.go b/src/testing/pkg/role/manager.go new file mode 100644 index 00000000000..fc5c92d6d59 --- /dev/null +++ b/src/testing/pkg/role/manager.go @@ -0,0 +1,208 @@ +// Code generated by mockery v2.53.3. DO NOT EDIT. + +package role + +import ( + context "context" + + model "github.com/goharbor/harbor/src/pkg/role/model" + mock "github.com/stretchr/testify/mock" + + q "github.com/goharbor/harbor/src/lib/q" +) + +// Manager is an autogenerated mock type for the Manager type +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) + + if len(ret) == 0 { + panic("no return value specified for Count") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) (int64, error)); ok { + return rf(ctx, query) + } + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) int64); ok { + r0 = rf(ctx, query) + } else { + r0 = ret.Get(0).(int64) + } + + 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, m +func (_m *Manager) Create(ctx context.Context, m *model.Role) (int64, error) { + ret := _m.Called(ctx, m) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *model.Role) (int64, error)); ok { + return rf(ctx, m) + } + if rf, ok := ret.Get(0).(func(context.Context, *model.Role) int64); ok { + r0 = rf(ctx, m) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(context.Context, *model.Role) error); ok { + r1 = rf(ctx, m) + } 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 int64) error { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteByProjectID provides a mock function with given fields: ctx, projectID +func (_m *Manager) DeleteByProjectID(ctx context.Context, projectID int64) error { + ret := _m.Called(ctx, projectID) + + if len(ret) == 0 { + panic("no return value specified for DeleteByProjectID") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { + r0 = rf(ctx, projectID) + } 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 int64) (*model.Role, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 *model.Role + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64) (*model.Role, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, int64) *model.Role); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Role) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// List provides a mock function with given fields: ctx, query +func (_m *Manager) List(ctx context.Context, query *q.Query) ([]*model.Role, error) { + ret := _m.Called(ctx, query) + + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []*model.Role + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) ([]*model.Role, error)); ok { + return rf(ctx, query) + } + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) []*model.Role); ok { + r0 = rf(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Role) + } + } + + 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, m, props +func (_m *Manager) Update(ctx context.Context, m *model.Role, props ...string) error { + _va := make([]interface{}, len(props)) + for _i := range props { + _va[_i] = props[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, m) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *model.Role, ...string) error); ok { + r0 = rf(ctx, m, props...) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewManager creates a new instance of Manager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewManager(t interface { + mock.TestingT + Cleanup(func()) +}) *Manager { + mock := &Manager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/tests/apitests/python/library/base.py b/tests/apitests/python/library/base.py index 11e90a05d01..fe04fa3d055 100644 --- a/tests/apitests/python/library/base.py +++ b/tests/apitests/python/library/base.py @@ -61,6 +61,7 @@ def _create_client(server, credential, debug, api_type): "replication": v2_swagger_client.ReplicationApi(v2_swagger_client.ApiClient(cfg)), "registry": v2_swagger_client.RegistryApi(v2_swagger_client.ApiClient(cfg)), "robot": v2_swagger_client.RobotApi(v2_swagger_client.ApiClient(cfg)), + "robot": v2_swagger_client.RoleApi(v2_swagger_client.ApiClient(cfg)), "gc": v2_swagger_client.GcApi(v2_swagger_client.ApiClient(cfg)), "retention": v2_swagger_client.RetentionApi(v2_swagger_client.ApiClient(cfg)), "immutable": v2_swagger_client.ImmutableApi(v2_swagger_client.ApiClient(cfg)), diff --git a/tests/integration/.gitignore b/tests/integration/.gitignore new file mode 100644 index 00000000000..558af38b0fd --- /dev/null +++ b/tests/integration/.gitignore @@ -0,0 +1,6 @@ +# local Python virtualenvs used to run the integration tests +test-harbor-role/ +venv/ +.venv/ +__pycache__/ +*.pyc diff --git a/tests/integration/test_custom_roles.py b/tests/integration/test_custom_roles.py new file mode 100644 index 00000000000..bd374bd08ac --- /dev/null +++ b/tests/integration/test_custom_roles.py @@ -0,0 +1,653 @@ +#!/usr/bin/env python3 +""" +Harbor Custom Roles - Automated Test Suite + +Sections: + 1. Role CRUD — create, update, delete; built-in immutability + 2. DB seeding — built-in roles have permissions via ListRole + 3. Member anti-escalation — POST/PUT /projects/{id}/members blocked when caller lacks perms + 4. Group anti-escalation — same check via group payload (LDAP/OIDC/HTTP modes) + 5. Sysadmin bypass — admin can assign any role + 6. Role display flags — is_builtin=true present on all built-in roles + 7. Edge cases — zero-permission custom role, empty roles list fallback + 8. Robot anti-escalation — POST/PUT /robots blocked when caller lacks requested perms + +Usage: + python3 test_custom_roles.py --url https://harbor.example.com \ + --user admin --password YourPassword + +Exit code 0 = all tests passed, 1 = one or more tests failed. +""" + +import argparse +import sys +import requests +import urllib3 + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + +GREEN = '\033[92m' +RED = '\033[91m' +YELLOW = '\033[93m' +BOLD = '\033[1m' +RESET = '\033[0m' + +_passed = 0 +_failed = 0 +_skipped = 0 + + +def ok(msg): + global _passed + _passed += 1 + print(f" {GREEN}PASS{RESET} {msg}") + + +def fail(msg, detail=None): + global _failed + _failed += 1 + print(f" {RED}FAIL{RESET} {msg}") + if detail: + print(f" {RED}{detail}{RESET}") + + +def skip(msg): + global _skipped + _skipped += 1 + print(f" {YELLOW}SKIP{RESET} {msg}") + + +def section(title): + print(f"\n{BOLD}{'─' * 60}{RESET}") + print(f"{BOLD} {title}{RESET}") + print(f"{BOLD}{'─' * 60}{RESET}") + + +def assert_status(label, resp, expected): + if isinstance(expected, int): + expected = [expected] + if resp.status_code in expected: + ok(f"{label} → {resp.status_code}") + return True + else: + fail(f"{label} → expected {expected}, got {resp.status_code}", resp.text[:200]) + return False + + +# --------------------------------------------------------------------------- +# Minimal Harbor API client +# --------------------------------------------------------------------------- + +class Harbor: + def __init__(self, base_url, username, password, verify_ssl=False): + self.base = base_url.rstrip('/') + '/api/v2.0' + self.s = requests.Session() + self.s.verify = verify_ssl + self.s.auth = (username, password) + self.s.headers.update({'Content-Type': 'application/json'}) + + def get(self, path, **kw): return self.s.get(self.base + path, **kw) + def post(self, path, **kw): return self.s.post(self.base + path, **kw) + def put(self, path, **kw): return self.s.put(self.base + path, **kw) + def delete(self, path, **kw): return self.s.delete(self.base + path, **kw) + + def as_user(self, username, password): + return Harbor(self.base.replace('/api/v2.0', ''), username, password, self.s.verify) + + +# --------------------------------------------------------------------------- +# Permission fixtures +# --------------------------------------------------------------------------- + +PULL_ONLY_PERMS = [ + {"resource": "repository", "action": "pull"}, + {"resource": "repository", "action": "list"}, + {"resource": "repository", "action": "read"}, + {"resource": "artifact", "action": "read"}, + {"resource": "artifact", "action": "list"}, + {"resource": "artifact-addition", "action": "read"}, + {"resource": "tag", "action": "list"}, + {"resource": "accessory", "action": "list"}, +] + +PULL_ONLY_PERMS_V2 = PULL_ONLY_PERMS + [ + {"resource": "artifact", "action": "delete"}, +] + +def _role_body(name, access_list, description=""): + return { + "name": name, + "description": description, + "permissions": [{"kind": "project", "namespace": "*", "access": access_list}], + } + + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +def cleanup(admin, state): + pid = state.get('project_id') + if pid: + for mid in state.get('member_ids', []): + admin.delete(f"/projects/{pid}/members/{mid}") + admin.delete(f"/projects/{pid}") + for uid in state.get('user_ids', []): + admin.delete(f"/users/{uid}") + for rid in state.get('custom_role_ids', []): + admin.delete(f"/roles/{rid}") + + +# --------------------------------------------------------------------------- +# Helper: find a role by name in a list +# --------------------------------------------------------------------------- + +def _find(roles, name): + return next((r for r in roles if r['name'] == name), None) + + +def _role_id_from_response(resp, admin, name): + """Extract role id from Location header or by re-listing.""" + loc = resp.headers.get('Location', '') + if loc: + try: + return int(loc.rstrip('/').split('/')[-1]) + except ValueError: + pass + body = resp.json() if resp.content else {} + if body.get('id'): + return body['id'] + # fall back: list and search by name + r = admin.get("/roles", params={"page": 1, "page_size": 100}) + return (_find(r.json(), name) or {}).get('id') + + +# --------------------------------------------------------------------------- +# Test runner +# --------------------------------------------------------------------------- + +def run(admin_url, admin_user, admin_pass): + admin = Harbor(admin_url, admin_user, admin_pass) + state = { + 'project_id': None, + 'user_ids': [], + 'member_ids': [], + 'custom_role_ids': [], + 'builtin': {}, # name → id + } + BUILTIN_NAMES = {'projectAdmin', 'maintainer', 'developer', 'guest', 'limitedGuest'} + + # ── 0. SETUP ──────────────────────────────────────────────────────────── + section("0. Setup") + + # Verify connectivity + r = admin.get("/systeminfo") + if r.status_code != 200: + fail("Cannot reach Harbor API — aborting", r.text[:200]) + return False + + # Collect built-in role IDs + r = admin.get("/roles", params={"page": 1, "page_size": 100}) + if not assert_status("GET /roles", r, 200): + return False + for role in r.json(): + if role['name'] in BUILTIN_NAMES: + state['builtin'][role['name']] = role['id'] + if len(state['builtin']) == 5: + ok(f"All 5 built-in roles found: {state['builtin']}") + else: + fail(f"Expected 5 built-in roles, got {len(state['builtin'])}: {list(state['builtin'])}") + + # Create test project + r = admin.post("/projects", json={"project_name": "cr-test", "metadata": {"public": "false"}}) + if r.status_code in (200, 201): + r2 = admin.get("/projects/cr-test") + state['project_id'] = r2.json()['id'] + ok(f"Created project 'cr-test' (id={state['project_id']})") + else: + fail("Could not create project", r.text[:200]) + return False + + # Create 3 test users: maintainer_user, guest_user, custom_user + users_created = [] + for uname in ["cr-maintainer", "cr-guest", "cr-custom"]: + r = admin.post("/users", json={ + "username": uname, "password": "Test1@3456", + "email": f"{uname}@cr-test.local", "realname": uname, + }) + if r.status_code in (200, 201): + r2 = admin.get("/users/search", params={"username": uname}) + uid = r2.json()[0]['user_id'] + state['user_ids'].append(uid) + users_created.append((uname, uid)) + ok(f"Created user '{uname}' (id={uid})") + else: + fail(f"Could not create user '{uname}'", r.text[:200]) + + if len(users_created) < 3: + skip("Not all test users created — some member tests will be skipped") + + # ── 1. ROLE CRUD ──────────────────────────────────────────────────────── + section("1. Role Management") + + # 1.1 Create custom role + r = admin.post("/roles", json=_role_body("cr-pull-only", PULL_ONLY_PERMS, "pull-only test role")) + if assert_status("POST /roles (create 'cr-pull-only')", r, [200, 201]): + rid = _role_id_from_response(r, admin, "cr-pull-only") + state['custom_role_ids'].append(rid) + ok(f" id={rid}") + + # Verify is_builtin = false + r2 = admin.get(f"/roles/{rid}") + if r2.status_code == 200 and r2.json().get('is_builtin') == False: + ok("Custom role has is_builtin=false") + else: + fail("Custom role should have is_builtin=false", r2.text[:200]) + + # 1.2 Edit custom role + r3 = admin.put(f"/roles/{rid}", json=_role_body("cr-pull-only", PULL_ONLY_PERMS_V2)) + assert_status(f"PUT /roles/{rid} (update permissions)", r3, [200, 204]) + + # Verify update reflected in GET + r4 = admin.get(f"/roles/{rid}") + if r4.status_code == 200: + access = [a for p in r4.json().get('permissions', []) for a in p.get('access', [])] + names = {f"{a['resource']}:{a['action']}" for a in access} + if "artifact:delete" in names: + ok("Updated permission (artifact:delete) visible in GET /roles/{id}") + else: + fail("Updated permission not reflected", str(names)) + else: + skip("Skipping edit/delete tests — role creation failed") + + # 1.3 Delete a throwaway custom role (no members) + r = admin.post("/roles", json=_role_body("cr-throwaway", [])) + if r.status_code in (200, 201): + tid = _role_id_from_response(r, admin, "cr-throwaway") + r2 = admin.delete(f"/roles/{tid}") + assert_status(f"DELETE /roles/{tid} (throwaway role)", r2, [200, 204]) + else: + skip("Could not create throwaway role for delete test") + + # 1.4 Built-in roles are immutable + for name, rid in state['builtin'].items(): + r = admin.delete(f"/roles/{rid}") + if r.status_code in (400, 403): + ok(f"DELETE built-in '{name}' → {r.status_code} (blocked)") + else: + fail(f"DELETE built-in '{name}' should be blocked, got {r.status_code}") + + r = admin.put(f"/roles/{rid}", json=_role_body(name, [])) + if r.status_code in (400, 403): + ok(f"PUT built-in '{name}' → {r.status_code} (blocked)") + else: + fail(f"PUT built-in '{name}' should be blocked, got {r.status_code}") + + # ── 2. DB SEEDING — permissions returned by ListRole ──────────────────── + section("2. Built-in Role Permission Seeding") + + r = admin.get("/roles", params={"page": 1, "page_size": 100}) + all_roles = r.json() + for role in all_roles: + if role['name'] not in BUILTIN_NAMES: + continue + count = sum(len(p.get('access', [])) for p in role.get('permissions', [])) + if count > 0: + ok(f"Built-in '{role['name']}' → {count} permission entries in DB") + else: + fail(f"Built-in '{role['name']}' has 0 permissions — migration 0190 seeding failed?") + + # ── 3. MEMBER ADDITION — anti-escalation ──────────────────────────────── + section("3. Member Addition — Anti-Escalation") + + pid = state['project_id'] + B = state['builtin'] + + if len(users_created) < 3: + skip("Skipping all member tests (test users missing)") + else: + maintainer_name, maintainer_uid = users_created[0] + guest_name, guest_uid = users_created[1] + custom_name, custom_uid = users_created[2] + custom_role_id = state['custom_role_ids'][0] if state['custom_role_ids'] else None + + # Add cr-maintainer as maintainer (as admin) + r = admin.post(f"/projects/{pid}/members", json={ + "role_id": B['maintainer'], + "member_user": {"user_id": maintainer_uid}, + }) + if assert_status(f"Admin adds '{maintainer_name}' as maintainer", r, [200, 201]): + state['member_ids'].append(r.headers.get('Location', '').rstrip('/').split('/')[-1]) + + # ── 3a. Maintainer tries to add projectAdmin (escalation) ───────── + m_client = Harbor(admin_url, maintainer_name, "Test1@3456") + + r = m_client.post(f"/projects/{pid}/members", json={ + "role_id": B['projectAdmin'], + "member_user": {"user_id": guest_uid}, + }) + assert_status("Maintainer→projectAdmin (escalation) blocked", r, 403) + + # ── 3b. Maintainer tries to add guest (valid) ───────────────────── + r = m_client.post(f"/projects/{pid}/members", json={ + "role_id": B['guest'], + "member_user": {"user_id": guest_uid}, + }) + if assert_status("Maintainer→guest (valid assignment)", r, [200, 201]): + guest_mid = r.headers.get('Location', '').rstrip('/').split('/')[-1] + state['member_ids'].append(guest_mid) + + # ── 3c. Maintainer tries to change guest→projectAdmin via PUT ── + r2 = m_client.put(f"/projects/{pid}/members/{guest_mid}", json={ + "role_id": B['projectAdmin'], + }) + assert_status("Maintainer→PUT projectAdmin (escalation) blocked", r2, 403) + + # ── 3d. Maintainer changes guest→developer (valid PUT) ───────── + r3 = m_client.put(f"/projects/{pid}/members/{guest_mid}", json={ + "role_id": B['developer'], + }) + assert_status("Maintainer→PUT developer (valid)", r3, [200, 204]) + + # ── 3e. Admin adds cr-custom with custom pull-only role ─────────── + if custom_role_id: + r = admin.post(f"/projects/{pid}/members", json={ + "role_id": custom_role_id, + "member_user": {"user_id": custom_uid}, + }) + if assert_status(f"Admin assigns custom role to '{custom_name}'", r, [200, 201]): + state['member_ids'].append(r.headers.get('Location', '').rstrip('/').split('/')[-1]) + + # Pull-only user tries to add guest (escalation — guest has more perms) + c_client = Harbor(admin_url, custom_name, "Test1@3456") + r2 = c_client.post(f"/projects/{pid}/members", json={ + "role_id": B['guest'], + "member_user": {"username": admin_user}, + }) + assert_status("Custom(pull-only)→guest (escalation) blocked", r2, 403) + + # Pull-only user tries to add someone with same custom role (valid) + # Create a 4th user for this + r3 = admin.post("/users", json={ + "username": "cr-extra", "password": "Test1@3456", + "email": "cr-extra@cr-test.local", "realname": "cr-extra", + }) + if r3.status_code in (200, 201): + r4 = admin.get("/users/search", params={"username": "cr-extra"}) + extra_uid = r4.json()[0]['user_id'] + state['user_ids'].append(extra_uid) + r5 = c_client.post(f"/projects/{pid}/members", json={ + "role_id": custom_role_id, + "member_user": {"user_id": extra_uid}, + }) + assert_status("Custom(pull-only)→same custom role (valid)", r5, [200, 201]) + if r5.status_code in (200, 201): + state['member_ids'].append( + r5.headers.get('Location', '').rstrip('/').split('/')[-1]) + + # ── 4. +GROUP MODAL — API equivalent ──────────────────────────────────── + section("4. Group Role Assignment — Anti-Escalation") + + # Harbor only supports groups when LDAP/OIDC is configured. + # We test the API directly with a user_group payload in HTTP-auth mode + # (if group type is not supported the endpoint returns 400/422, not 403 — + # so we only run this check when we can confirm group support). + r = admin.get("/systeminfo") + auth_mode = r.json().get('auth_mode', 'db_auth') if r.status_code == 200 else 'db_auth' + if auth_mode in ('ldap_auth', 'http_auth', 'oidc_auth'): + if len(users_created) >= 1: + m_client = Harbor(admin_url, users_created[0][0], "Test1@3456") + r = m_client.post(f"/projects/{pid}/members", json={ + "role_id": B.get('projectAdmin'), + "member_group": {"group_name": "test-group", "group_type": 1}, + }) + assert_status("Maintainer→group as projectAdmin (escalation) blocked", r, 403) + else: + skip(f"Auth mode is '{auth_mode}' — group escalation test skipped (no group support)") + + # ── 5. SYSADMIN BYPASS ─────────────────────────────────────────────────── + section("5. Sysadmin Bypass") + + if pid and len(users_created) >= 1: + _, target_uid = users_created[0] + r = admin.put(f"/projects/{pid}/members/{state['member_ids'][0]}", json={ + "role_id": B['projectAdmin'], + }) + # Admin setting someone to projectAdmin must always succeed (200 or 204) + assert_status("Sysadmin can assign projectAdmin (no restriction)", r, [200, 204]) + + # ── 6. ROLE DISPLAY — translation key check ────────────────────────────── + section("6. Role Display Keys") + + EXPECTED_KEYS = { + 'projectAdmin': 'MEMBER.PROJECT_ADMIN', + 'maintainer': 'MEMBER.PROJECT_MAINTAINER', + 'developer': 'MEMBER.DEVELOPER', + 'guest': 'MEMBER.GUEST', + 'limitedGuest': 'MEMBER.LIMITED_GUEST', + } + r = admin.get("/roles", params={"page": 1, "page_size": 100}) + for role in r.json(): + if role['name'] in EXPECTED_KEYS: + if role.get('is_builtin'): + ok(f"Built-in role '{role['name']}' has is_builtin=true (badge will render)") + else: + fail(f"Built-in role '{role['name']}' missing is_builtin=true flag") + + # ── 7. EDGE CASES ──────────────────────────────────────────────────────── + section("7. Edge Cases") + + # Custom role with zero permissions — anyone can assign it (subset of anything) + r = admin.post("/roles", json=_role_body("cr-empty", [])) + if r.status_code in (200, 201): + empty_rid = _role_id_from_response(r, admin, "cr-empty") + state['custom_role_ids'].append(empty_rid) + if len(users_created) >= 1: + m_client = Harbor(admin_url, users_created[0][0], "Test1@3456") + r2 = m_client.post(f"/projects/{pid}/members", json={ + "role_id": empty_rid, + "member_user": {"user_id": users_created[1][1] if len(users_created) > 1 else users_created[0][1]}, + }) + # maintainer has all superset permissions over empty role — should succeed + if r2.status_code in (200, 201): + ok("Maintainer can assign zero-permission role (subset of everything)") + state['member_ids'].append(r2.headers.get('Location', '').rstrip('/').split('/')[-1]) + elif r2.status_code == 409: + ok("Zero-permission role assignment: member already exists (409 — acceptable)") + else: + fail(f"Maintainer should be able to assign empty role, got {r2.status_code}", r2.text[:200]) + else: + skip("Could not create zero-permission role for edge case test") + + # ── 8. ROBOT ANTI-ESCALATION ───────────────────────────────────────────── + section("8. Robot Anti-Escalation") + + # validateNoEscalation fires when a human (local) user calls POST/PUT /robots. + # It checks each requested robot permission against the caller's own project perms. + # + # Important: only users with robot:create can create robots at all. + # Built-in roles with robot:create: projectAdmin only. + # Maintainer has robot:read + robot:list but NOT robot:create. + # + # To test anti-escalation we create a custom role that has robot:create + + # repository:pull/push but deliberately omits member:create. A user with + # that role can create robots scoped to pull/push but not to member:create. + + project_name = "cr-test" + + def robot_body(name, access_list): + return { + "name": name, + "description": "test robot", + "duration": -1, + "level": "project", + "permissions": [{"kind": "project", "namespace": project_name, "access": access_list}], + } + + robot_ids = [] + + if not pid: + skip("Skipping robot tests (project missing)") + else: + # ── 8a. Sysadmin creates robot with any perms (no restriction) ──── + r = admin.post("/robots", json=robot_body("cr-robot-admin", [ + {"resource": "repository", "action": "pull"}, + {"resource": "repository", "action": "push"}, + {"resource": "member", "action": "create"}, + ])) + if assert_status("Sysadmin creates robot with member:create → 201", r, [200, 201]): + rid = r.json().get('id') + if rid: + robot_ids.append(rid) + + # ── 8b. Create a "robot-manager" custom role ────────────────────── + # Has robot:create/read/update/delete/list + repository:pull/push + # but deliberately omits member:* and configuration:* permissions. + robot_mgr_perms = [ + {"resource": "robot", "action": "create"}, + {"resource": "robot", "action": "read"}, + {"resource": "robot", "action": "update"}, + {"resource": "robot", "action": "delete"}, + {"resource": "robot", "action": "list"}, + {"resource": "repository", "action": "pull"}, + {"resource": "repository", "action": "push"}, + {"resource": "repository", "action": "list"}, + {"resource": "repository", "action": "read"}, + {"resource": "artifact", "action": "read"}, + {"resource": "artifact", "action": "list"}, + {"resource": "tag", "action": "list"}, + ] + r = admin.post("/roles", json=_role_body("cr-robot-mgr", robot_mgr_perms)) + robot_mgr_role_id = None + if r.status_code in (200, 201): + robot_mgr_role_id = _role_id_from_response(r, admin, "cr-robot-mgr") + state['custom_role_ids'].append(robot_mgr_role_id) + ok(f"Created 'cr-robot-mgr' custom role (id={robot_mgr_role_id})") + else: + fail("Could not create robot-manager custom role", r.text[:200]) + + # Create a user and assign robot-manager role + robot_mgr_uid = None + robot_mgr_name = "cr-robotmgr" + r = admin.post("/users", json={ + "username": robot_mgr_name, "password": "Test1@3456", + "email": f"{robot_mgr_name}@cr-test.local", "realname": robot_mgr_name, + }) + if r.status_code in (200, 201): + r2 = admin.get("/users/search", params={"username": robot_mgr_name}) + robot_mgr_uid = r2.json()[0]['user_id'] + state['user_ids'].append(robot_mgr_uid) + ok(f"Created user '{robot_mgr_name}' (id={robot_mgr_uid})") + else: + fail(f"Could not create user '{robot_mgr_name}'", r.text[:200]) + + if robot_mgr_role_id and robot_mgr_uid: + r = admin.post(f"/projects/{pid}/members", json={ + "role_id": robot_mgr_role_id, + "member_user": {"user_id": robot_mgr_uid}, + }) + if assert_status(f"Assign '{robot_mgr_name}' robot-manager role", r, [200, 201]): + state['member_ids'].append(r.headers.get('Location', '').rstrip('/').split('/')[-1]) + + rm_client = Harbor(admin_url, robot_mgr_name, "Test1@3456") + + # ── 8c. robot-manager creates robot within their permissions ────── + r = rm_client.post("/robots", json=robot_body("cr-robot-rm-valid", [ + {"resource": "repository", "action": "pull"}, + {"resource": "repository", "action": "push"}, + ])) + valid_robot_id = None + if assert_status("robot-manager creates robot with pull+push → 201", r, [200, 201]): + valid_robot_id = r.json().get('id') + if valid_robot_id: + robot_ids.append(valid_robot_id) + + # ── 8d. robot-manager tries robot with member:create (escalation) ─ + r = rm_client.post("/robots", json=robot_body("cr-robot-rm-escalate", [ + {"resource": "repository", "action": "pull"}, + {"resource": "member", "action": "create"}, + ])) + assert_status("robot-manager creates robot with member:create → 403 (escalation)", r, 403) + + # ── 8e. robot-manager updates existing robot to add member:delete ─ + if valid_robot_id: + r = rm_client.put(f"/robots/{valid_robot_id}", json=robot_body("cr-robot-rm-valid", [ + {"resource": "repository", "action": "pull"}, + {"resource": "repository", "action": "push"}, + {"resource": "member", "action": "delete"}, + ])) + assert_status("robot-manager updates robot to add member:delete → 403 (escalation)", r, 403) + + # Valid update — stays within role's permissions + r = rm_client.put(f"/robots/{valid_robot_id}", json=robot_body("cr-robot-rm-valid", [ + {"resource": "repository", "action": "pull"}, + ])) + assert_status("robot-manager updates robot to pull-only → 200 (valid)", r, [200, 204]) + + # ── 8f. robot-manager creates robot with zero permissions ───────── + r = rm_client.post("/robots", json=robot_body("cr-robot-rm-empty", [])) + if assert_status("robot-manager creates robot with no permissions → 201", r, [200, 201]): + rid = r.json().get('id') + if rid: + robot_ids.append(rid) + + # ── 8g. Confirm maintainer cannot create robots at all ──────────── + # (not an anti-escalation case — maintainer simply lacks robot:create) + if len(users_created) >= 1: + m_client = Harbor(admin_url, users_created[0][0], "Test1@3456") + r = m_client.post("/robots", json=robot_body("cr-robot-m-attempt", [ + {"resource": "repository", "action": "pull"}, + ])) + if r.status_code in (403, 401): + ok(f"Maintainer cannot create robots (lacks robot:create) → {r.status_code}") + else: + fail(f"Expected 403/401 for maintainer robot creation, got {r.status_code}", r.text[:200]) + + # Cleanup robots + for rid in robot_ids: + admin.delete(f"/robots/{rid}") + if robot_ids: + ok(f"Cleaned up {len(robot_ids)} test robot(s)") + + # ── CLEANUP ────────────────────────────────────────────────────────────── + section("Cleanup") + cleanup(admin, state) + ok("All test artifacts removed") + + # ── SUMMARY ────────────────────────────────────────────────────────────── + print(f"\n{'═' * 60}") + print(f" {BOLD}Results:{RESET} " + f"{GREEN}{_passed} passed{RESET} " + f"{RED}{_failed} failed{RESET} " + f"{YELLOW}{_skipped} skipped{RESET}") + print(f"{'═' * 60}\n") + return _failed == 0 + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser( + description="Harbor Custom Roles — Automated Test Suite", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument("--url", required=True, help="Harbor base URL, e.g. https://harbor.example.com") + parser.add_argument("--user", required=True, help="Admin username") + parser.add_argument("--password", required=True, help="Admin password") + parser.add_argument("--verify-ssl", action="store_true", default=False, + help="Verify TLS certificate (default: skip verification)") + args = parser.parse_args() + + success = run(args.url, args.user, args.password) + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main()