diff --git a/Gemfile b/Gemfile
index 8570ae96550..06d09b1abca 100644
--- a/Gemfile
+++ b/Gemfile
@@ -341,7 +341,7 @@ gem 'gitlab_chronic_duration', '~> 0.12' # rubocop:todo Gemfile/MissingFeatureCa
gem 'rack-proxy', '~> 0.7.7' # rubocop:todo Gemfile/MissingFeatureCategory
-gem 'cssbundling-rails', '1.3.3', feature_category: :shared
+gem 'cssbundling-rails', '1.4.0', feature_category: :shared
gem 'autoprefixer-rails', '10.2.5.1' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'terser', '1.0.2' # rubocop:todo Gemfile/MissingFeatureCategory
diff --git a/Gemfile.checksum b/Gemfile.checksum
index 6b8b1ec22fd..bd5e0cd2a9d 100644
--- a/Gemfile.checksum
+++ b/Gemfile.checksum
@@ -96,7 +96,7 @@
{"name":"creole","version":"0.5.0","platform":"ruby","checksum":"951701e2d80760f156b1cb2a93471ca97c076289becc067a33b745133ed32c03"},
{"name":"crystalball","version":"0.7.0","platform":"ruby","checksum":"6e729f372a5071daec877adb40c5df4cb25fe21f350635e2a9624373fc151ef2"},
{"name":"css_parser","version":"1.14.0","platform":"ruby","checksum":"f2ce6148cd505297b07bdbe7a5db4cce5cf530071f9b732b9a23538d6cdc0113"},
-{"name":"cssbundling-rails","version":"1.3.3","platform":"ruby","checksum":"4aa13311e52a40bc0eb32ca651f44db8df03df273553cf9aeb022570607e1855"},
+{"name":"cssbundling-rails","version":"1.4.0","platform":"ruby","checksum":"082034653af0ec53d7662e4cd2f518f36167fe7c014dbcf37a941a4a8324f7db"},
{"name":"cvss-suite","version":"3.0.1","platform":"ruby","checksum":"b5ca9e9e94032a42fd0dc28c1e305378b62c949e35ed7111fc4a1d76f68ad3f9"},
{"name":"danger","version":"9.4.2","platform":"ruby","checksum":"43e552c6731030235a30fdeafe703d2e2ab9c30917154489cb0ecd9ad3259d80"},
{"name":"danger-gitlab","version":"8.0.0","platform":"ruby","checksum":"497dd7d0f6513913de651019223d8058cf494df10acbd17de92b175dfa04a3a8"},
diff --git a/Gemfile.lock b/Gemfile.lock
index 1808663e510..001210ea374 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -435,7 +435,7 @@ GEM
git
css_parser (1.14.0)
addressable
- cssbundling-rails (1.3.3)
+ cssbundling-rails (1.4.0)
railties (>= 6.0.0)
cvss-suite (3.0.1)
danger (9.4.2)
@@ -1861,7 +1861,7 @@ DEPENDENCIES
countries (~> 4.0.0)
creole (~> 0.5.0)
crystalball (~> 0.7.0)
- cssbundling-rails (= 1.3.3)
+ cssbundling-rails (= 1.4.0)
csv_builder!
cvss-suite (~> 3.0.1)
database_cleaner-active_record (~> 2.1.0)
diff --git a/app/assets/javascripts/ci/runner/components/registration/google_cloud_registration_instructions.vue b/app/assets/javascripts/ci/runner/components/registration/google_cloud_registration_instructions.vue
index 901062b7ee0..dadd1ba4e76 100644
--- a/app/assets/javascripts/ci/runner/components/registration/google_cloud_registration_instructions.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/google_cloud_registration_instructions.vue
@@ -16,6 +16,7 @@ import { s__, __ } from '~/locale';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import runnerForRegistrationQuery from '../../graphql/register/runner_for_registration.query.graphql';
+import provisionGoogleCloudRunnerGroup from '../../graphql/register/provision_google_cloud_runner_group.query.graphql';
import provisionGoogleCloudRunnerProject from '../../graphql/register/provision_google_cloud_runner_project.query.graphql';
import {
I18N_FETCH_ERROR,
@@ -159,6 +160,8 @@ export default {
provisioningSteps: [],
setupBashScript: '',
showAlert: false,
+ group: null,
+ project: null,
};
},
apollo: {
@@ -215,6 +218,29 @@ export default {
return !this.projectPath || this.invalidFields.length > 0;
},
},
+ group: {
+ query: provisionGoogleCloudRunnerGroup,
+ variables() {
+ return {
+ fullPath: this.groupPath,
+ cloudProjectId: this.projectId,
+ region: this.region,
+ zone: this.zone,
+ machineType: this.machineType,
+ runnerToken: this.token,
+ };
+ },
+ result({ data }) {
+ this.provisioningSteps = data.group.runnerCloudProvisioning?.provisioningSteps;
+ this.setupBashScript = data.group.runnerCloudProvisioning?.projectSetupShellScript;
+ },
+ error(error) {
+ captureException({ error, component: this.$options.name });
+ },
+ skip() {
+ return !this.groupPath || this.invalidFields.length > 0;
+ },
+ },
},
computed: {
isRunnerOnline() {
diff --git a/app/assets/javascripts/ci/runner/graphql/register/provision_google_cloud_runner_group.query.graphql b/app/assets/javascripts/ci/runner/graphql/register/provision_google_cloud_runner_group.query.graphql
new file mode 100644
index 00000000000..016039cd955
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/graphql/register/provision_google_cloud_runner_group.query.graphql
@@ -0,0 +1,27 @@
+query provisionGoogleCloudRunnerGroup(
+ $fullPath: ID!
+ $cloudProjectId: GoogleCloudProject!
+ $region: GoogleCloudRegion!
+ $zone: GoogleCloudZone!
+ $machineType: GoogleCloudMachineType!
+ $runnerToken: String
+) {
+ group(fullPath: $fullPath) {
+ id
+ runnerCloudProvisioning(provider: GOOGLE_CLOUD, cloudProjectId: $cloudProjectId) {
+ ... on CiRunnerGoogleCloudProvisioning {
+ projectSetupShellScript
+ provisioningSteps(
+ region: $region
+ zone: $zone
+ ephemeralMachineType: $machineType
+ runnerToken: $runnerToken
+ ) {
+ title
+ languageIdentifier
+ instructions
+ }
+ }
+ }
+ }
+}
diff --git a/app/graphql/resolvers/projects/deploy_key_resolver.rb b/app/graphql/resolvers/projects/deploy_key_resolver.rb
new file mode 100644
index 00000000000..1dc6dbe2427
--- /dev/null
+++ b/app/graphql/resolvers/projects/deploy_key_resolver.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ class DeployKeyResolver < BaseResolver
+ include LooksAhead
+ type Types::AccessLevels::DeployKeyType, null: true
+
+ def resolve_with_lookahead(**args)
+ apply_lookahead(Autocomplete::DeployKeysWithWriteAccessFinder.new(current_user,
+ object).execute(title_search_term: args[:title_query]))
+ end
+
+ def preloads
+ {
+ user: [:user]
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 76faea29f79..4ee6e2429b7 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -704,6 +704,17 @@ module Types
alpha: { milestone: '16.9' },
null: true
+ field :available_deploy_keys, Types::AccessLevels::DeployKeyType.connection_type,
+ resolver: Resolvers::Projects::DeployKeyResolver,
+ description: 'List of available deploy keys',
+ extras: [:lookahead],
+ null: true,
+ authorize: :admin_project do
+ argument :title_query, GraphQL::Types::String,
+ required: false,
+ description: 'Term by which to search deploy key titles'
+ end
+
def protectable_branches
ProtectableDropdown.new(project, :branches).protectable_ref_names
end
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index 14c750072c2..142308db6c4 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -28,7 +28,6 @@ module Avatarable
mount_uploader :avatar, AvatarUploader
after_initialize :add_avatar_to_batch
- after_commit :clear_avatar_caches
end
module ShadowMethods
@@ -130,10 +129,4 @@ module Avatarable
def avatar_mounter
strong_memoize(:avatar_mounter) { _mounter(:avatar) }
end
-
- def clear_avatar_caches
- return unless respond_to?(:verified_emails) && verified_emails.any? && avatar_changed?
-
- Gitlab::AvatarCache.delete_by_email(*verified_emails)
- end
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 1ede7749791..805a7f906db 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1117,23 +1117,7 @@ class MergeRequest < ApplicationRecord
merge_request_diff.persisted? || create_merge_request_diff
end
- def eager_fetch_ref!
- return unless valid?
-
- # has_internal_id normally attempts to allocate the iid in the
- # before_create hook, but we need the iid to be available before
- # that to fetch the ref into the target project.
- track_target_project_iid!
- ensure_target_project_iid!
-
- fetch_ref!
- # Prevent the after_create hook from fetching the source branch again.
- @skip_fetch_ref = true
- end
-
def create_merge_request_diff
- # Callers such as MergeRequests::BuildService may not call eager_fetch_ref!. Just
- # in case they haven't, we fetch the ref.
fetch_ref! unless skip_fetch_ref
# n+1: https://gitlab.com/gitlab-org/gitlab/-/issues/19377
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index c81a879ad1a..8c99d0f6856 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -73,6 +73,7 @@ module Groups
end
end
+ transfer_labels if Feature.enabled?(:group_labels_transfer)
remove_paid_features_for_projects(old_root_ancestor_id)
post_update_hooks(@updated_project_ids, old_root_ancestor_id)
propagate_integrations
@@ -81,6 +82,14 @@ module Groups
true
end
+ def transfer_labels
+ @group.all_projects.each_batch(of: 10) do |projects|
+ projects.each do |project|
+ Labels::TransferService.new(current_user, @group, project).execute
+ end
+ end
+ end
+
# Overridden in EE
def post_update_hooks(updated_project_ids, old_root_ancestor_id)
refresh_project_authorizations
diff --git a/app/services/project_access_tokens/rotate_service.rb b/app/services/resource_access_tokens/rotate_service.rb
similarity index 56%
rename from app/services/project_access_tokens/rotate_service.rb
rename to app/services/resource_access_tokens/rotate_service.rb
index 63d8d2a82cc..a55ddcb795f 100644
--- a/app/services/project_access_tokens/rotate_service.rb
+++ b/app/services/resource_access_tokens/rotate_service.rb
@@ -1,20 +1,20 @@
# frozen_string_literal: true
-module ProjectAccessTokens
+module ResourceAccessTokens
class RotateService < ::PersonalAccessTokens::RotateService
extend ::Gitlab::Utils::Override
def initialize(current_user, token, resource = nil)
@current_user = current_user
@token = token
- @project = resource
+ @resource = resource
end
def execute(params = {})
super
end
- attr_reader :project
+ attr_reader :resource
private
@@ -44,15 +44,34 @@ module ProjectAccessTokens
end
def valid_access_level?
- return true if current_user.can_admin_all_resources?
- return false unless current_user.can?(:manage_resource_access_tokens, project)
+ return true if admin_all_resources?
+ return false unless can_manage_tokens?
- token_access_level = project.team.max_member_access(token.user.id).to_i
- current_user_access_level = project.team.max_member_access(current_user.id).to_i
+ token_access_level <= current_user_access_level
+ end
- return true if token_access_level.to_i <= current_user_access_level
+ def admin_all_resources?
+ current_user.can_admin_all_resources?
+ end
- false
+ def can_manage_tokens?
+ current_user.can?(:manage_resource_access_tokens, resource)
+ end
+
+ def token_access_level
+ if resource.is_a? Project
+ resource.team.max_member_access(token.user.id).to_i
+ else
+ resource.max_member_access_for_user(token.user).to_i
+ end
+ end
+
+ def current_user_access_level
+ if resource.is_a? Project
+ resource.team.max_member_access(current_user.id).to_i
+ else
+ resource.max_member_access_for_user(current_user).to_i
+ end
end
end
end
diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb
index ac7b05bc7ea..4ccd508184b 100644
--- a/app/uploaders/avatar_uploader.rb
+++ b/app/uploaders/avatar_uploader.rb
@@ -8,6 +8,9 @@ class AvatarUploader < GitlabUploader
MIME_ALLOWLIST = %w[image/png image/jpeg image/gif image/bmp image/tiff image/vnd.microsoft.icon].freeze
+ after :store, :clear_avatar_caches
+ after :remove, :clear_avatar_caches
+
def exists?
model.avatar.file && model.avatar.file.present?
end
@@ -37,4 +40,10 @@ class AvatarUploader < GitlabUploader
def dynamic_segment
File.join(model.class.underscore, mounted_as.to_s, model.id.to_s)
end
+
+ def clear_avatar_caches(*)
+ return unless model.respond_to?(:verified_emails) && model.verified_emails.any?
+
+ Gitlab::AvatarCache.delete_by_email(*model.verified_emails)
+ end
end
diff --git a/config/feature_flags/gitlab_com_derisk/group_labels_transfer.yml b/config/feature_flags/gitlab_com_derisk/group_labels_transfer.yml
new file mode 100644
index 00000000000..d569c524963
--- /dev/null
+++ b/config/feature_flags/gitlab_com_derisk/group_labels_transfer.yml
@@ -0,0 +1,9 @@
+---
+name: group_labels_transfer
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/354890
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/146292
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/443874
+milestone: '16.10'
+group: group::project management
+type: gitlab_com_derisk
+default_enabled: false
diff --git a/doc/administration/license.md b/doc/administration/license.md
index 53d44bf6dbf..9ec30a925e1 100644
--- a/doc/administration/license.md
+++ b/doc/administration/license.md
@@ -110,15 +110,15 @@ You may have connectivity issues due to the following reasons:
curl --verbose "https://customers.gitlab.com/"
```
- - Use `nslookup` to identify the target IP addresses that your GitLab instance will need to access.
-
- ```shell
- nslookup customers.gitlab.com
- ```
-
- If the curl command returns an error, either:
- - [Configure a proxy](https://docs.gitlab.com/omnibus/settings/environment-variables.html) in `gitlab.rb` to point to your server.
- - Contact your network administrator to make changes to an existing proxy or firewall.
+ - Check your firewall or proxy. The domain `https://customers.gitlab.com` is
+ fronted by Cloudflare. Ensure your firewall or proxy allows traffic to the Cloudflare
+ [IPv4](https://www.cloudflare.com/ips-v4/) and
+ [IPv6](https://www.cloudflare.com/ips-v6/) ranges for activation to work.
+ - [Configure a proxy](https://docs.gitlab.com/omnibus/settings/environment-variables.html)
+ in `gitlab.rb` to point to your server.
+
+ Contact your network administrator to make changes to an existing proxy or firewall.
- If an SSL inspection appliance is used, you must add the appliance's root CA certificate to `/etc/gitlab/trusted-certs` on your instance, then run `gitlab-ctl reconfigure`.
- **Customers Portal is not operational**:
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 3fbf53bc454..55780c7c05f 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -9052,6 +9052,29 @@ Some of the types in the schema exist solely to model connections. Each connecti
has a distinct, named type, with a distinct named edge type. These are listed separately
below.
+#### `AccessLevelDeployKeyConnection`
+
+The connection type for [`AccessLevelDeployKey`](#accessleveldeploykey).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `edges` | [`[AccessLevelDeployKeyEdge]`](#accessleveldeploykeyedge) | A list of edges. |
+| `nodes` | [`[AccessLevelDeployKey]`](#accessleveldeploykey) | A list of nodes. |
+| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
+
+#### `AccessLevelDeployKeyEdge`
+
+The edge type for [`AccessLevelDeployKey`](#accessleveldeploykey).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
+| `node` | [`AccessLevelDeployKey`](#accessleveldeploykey) | The item at the end of the edge. |
+
#### `AchievementConnection`
The connection type for [`Achievement`](#achievement).
@@ -25377,6 +25400,22 @@ Returns [`[AutocompletedUser!]`](#autocompleteduser).
| ---- | ---- | ----------- |
| `search` | [`String`](#string) | Query to search users by name, username, or public email. |
+##### `Project.availableDeployKeys`
+
+List of available deploy keys.
+
+Returns [`AccessLevelDeployKeyConnection`](#accessleveldeploykeyconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#pagination-arguments):
+`before: String`, `after: String`, `first: Int`, and `last: Int`.
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `titleQuery` | [`String`](#string) | Term by which to search deploy key titles. |
+
##### `Project.board`
A single board of the project.
diff --git a/doc/integration/azure.md b/doc/integration/azure.md
index 333ab263553..dce98a52b5c 100644
--- a/doc/integration/azure.md
+++ b/doc/integration/azure.md
@@ -12,7 +12,7 @@ DETAILS:
You can enable the Microsoft Azure OAuth 2.0 OmniAuth provider and sign in to
GitLab with your Microsoft Azure credentials. You can configure the provider that uses
-[the earlier Azure Active Directory v1.0 endpoint](https://learn.microsoft.com/en-us/azure/active-directory/azuread-dev/v1-protocols-oauth-code),
+[the earlier Azure Active Directory v1.0 endpoint](https://learn.microsoft.com/en-us/previous-versions/azure/active-directory/azuread-dev/v1-protocols-oauth-code),
or the provider that uses the v2.0 endpoint.
NOTE:
@@ -177,7 +177,7 @@ an Azure application and get a client ID and secret key.
1. Sign in to the [Azure portal](https://portal.azure.com).
1. If you have multiple Azure Active Directory tenants, switch to the desired tenant. Note the tenant ID.
-1. [Register an application](https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app)
+1. [Register an application](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app)
and provide the following information:
- The redirect URI, which requires the URL of the Azure OAuth callback of your GitLab
installation. For example:
@@ -195,7 +195,7 @@ In some Microsoft documentation, the terms are named `Application ID` and
## Add API permissions (scopes)
-If you're using the v2.0 endpoint, after you create the application, [configure it to expose a web API](https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-configure-app-expose-web-apis).
+If you're using the v2.0 endpoint, after you create the application, [configure it to expose a web API](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-configure-app-expose-web-apis).
Add the following delegated permissions under the Microsoft Graph API:
- `email`
@@ -263,7 +263,7 @@ Alternatively, add the `User.Read.All` application permission.
]
```
- For [alternative Azure clouds](https://learn.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud),
+ For [alternative Azure clouds](https://learn.microsoft.com/en-us/entra/identity-platform/authentication-national-cloud),
configure `base_azure_url` under the `args` section. For example, for Azure Government Community Cloud (GCC):
```ruby
@@ -303,7 +303,7 @@ Alternatively, add the `User.Read.All` application permission.
tenant_id: "" } }
```
- For [alternative Azure clouds](https://learn.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud),
+ For [alternative Azure clouds](https://learn.microsoft.com/en-us/entra/identity-platform/authentication-national-cloud),
configure `base_azure_url` under the `args` section. For example, for Azure Government Community Cloud (GCC):
```yaml
@@ -315,7 +315,7 @@ Alternatively, add the `User.Read.All` application permission.
base_azure_url: "https://login.microsoftonline.us" } }
```
- You can also optionally add the `scope` for [OAuth 2.0 scopes](https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow) parameter to the `args` section. The default is `openid profile email`.
+ You can also optionally add the `scope` for [OAuth 2.0 scopes](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow) parameter to the `args` section. The default is `openid profile email`.
1. Save the configuration file.
diff --git a/doc/integration/github.md b/doc/integration/github.md
index dab94fc91c2..a72ae880ebb 100644
--- a/doc/integration/github.md
+++ b/doc/integration/github.md
@@ -20,7 +20,7 @@ To enable the GitHub OmniAuth provider, you need an OAuth 2.0 client ID and clie
secret from GitHub:
1. Sign in to GitHub.
-1. [Create an OAuth App](https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app)
+1. [Create an OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
and provide the following information:
- The URL of your GitLab instance, such as `https://gitlab.example.com`.
- The authorization callback URL, such as, `https://gitlab.example.com/users/auth`.
diff --git a/doc/integration/gitpod.md b/doc/integration/gitpod.md
index a670b70f476..53b08e7154b 100644
--- a/doc/integration/gitpod.md
+++ b/doc/integration/gitpod.md
@@ -30,7 +30,7 @@ To use the GitLab Gitpod integration, it must be enabled for your GitLab instanc
1. It's [enabled in their user settings](#enable-gitpod-in-your-user-settings).
For more information about Gitpod, see the Gitpod [features](https://www.gitpod.io/) and
-[documentation](https://www.gitpod.io/docs/).
+[documentation](https://www.gitpod.io/docs).
## Enable Gitpod in your user settings
diff --git a/doc/integration/index.md b/doc/integration/index.md
index 6d35221e600..4dfd35ead89 100644
--- a/doc/integration/index.md
+++ b/doc/integration/index.md
@@ -41,15 +41,15 @@ You can also integrate GitLab with the following security partners:
- [Anchore](https://docs.anchore.com/current/docs/configuration/integration/ci_cd/gitlab/)
- [Bridgecrew](https://docs.bridgecrew.io/docs/integrate-with-gitlab-self-managed)
- [Checkmarx](https://checkmarx.atlassian.net/wiki/spaces/SD/pages/1929937052/GitLab+Integration)
+- [CodeSecure](https://codesecure.com/our-integrations/codesonar-sast-gitlab-ci-pipeline/)
- [Deepfactor](https://www.deepfactor.io/docs/integrate-deepfactor-scanner-in-your-ci-cd-pipelines/#gitlab)
- [Fortify](https://www.microfocus.com/en-us/fortify-integrations/gitlab)
-- [GrammaTech](https://www.grammatech.com/codesonar-gitlab-integration)
- [Indeni](https://docs.cloudrail.app/#/integrations/gitlab)
- [Jscrambler](https://docs.jscrambler.com/code-integrity/documentation/gitlab-ci-integration)
- [Mend](https://www.mend.io/gitlab/)
-- [Semgrep](https://semgrep.dev/for/gitlab)
+- [Semgrep](https://semgrep.dev/for/gitlab/)
- [StackHawk](https://docs.stackhawk.com/continuous-integration/gitlab.html)
-- [Tenable](https://docs.tenable.com/tenableio/Content/ContainerSecurity/GetStarted.htm)
+- [Tenable](https://docs.tenable.com/vulnerability-management/Content/ContainerSecurity/GetStarted.htm)
- [Venafi](https://marketplace.venafi.com/xchange/620d2d6ed419fb06a5c5bd36/solution/6292c2ef7550f2ee553cf223)
- [Veracode](https://community.veracode.com/s/knowledgeitem/gitlab-ci-MCEKSYPRWL35BRTGOVI55SK5RI4A)
diff --git a/doc/integration/jira/connect-app.md b/doc/integration/jira/connect-app.md
index 18ef46f38e6..d6434a0c5cc 100644
--- a/doc/integration/jira/connect-app.md
+++ b/doc/integration/jira/connect-app.md
@@ -19,7 +19,7 @@ You can use the GitLab for Jira Cloud app to link top-level groups or subgroups.
To set up the GitLab for Jira Cloud app on GitLab.com, [install the GitLab for Jira Cloud app](#install-the-gitlab-for-jira-cloud-app).
-After you set up the app, you can use the [project toolchain](https://support.atlassian.com/jira-software-cloud/docs/what-is-the-project-toolchain-in-jira)
+After you set up the app, you can use the [project toolchain](https://support.atlassian.com/jira-software-cloud/docs/what-is-the-project-toolchain-in-jira/)
developed and maintained by Atlassian to [link GitLab repositories to Jira projects](https://support.atlassian.com/jira-software-cloud/docs/link-repositories-to-a-project/#Link-repositories-using-the-toolchain-feature).
The project toolchain does not affect how development information is synced between GitLab and Jira Cloud.
diff --git a/doc/integration/omniauth.md b/doc/integration/omniauth.md
index 4e1cc0b4107..e18fe3e7f97 100644
--- a/doc/integration/omniauth.md
+++ b/doc/integration/omniauth.md
@@ -515,7 +515,7 @@ then override the icon in one of two ways:
- **Embed an image directly in a configuration file**: This example creates a Base64-encoded
version of your image you can serve through a
- [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs):
+ [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs):
1. Encode your image file with a GNU `base64` command (such as `base64 -w 0 `)
which returns a single-line `` string.
diff --git a/doc/integration/openid_connect_provider.md b/doc/integration/openid_connect_provider.md
index ff73c0d6fbf..55fa4f81262 100644
--- a/doc/integration/openid_connect_provider.md
+++ b/doc/integration/openid_connect_provider.md
@@ -15,7 +15,7 @@ to sign in to other services.
## Introduction to OpenID Connect
-[OpenID Connect](https://openid.net/connect/) \(OIDC) is a simple identity layer on top of the
+[OpenID Connect](https://openid.net/developers/how-connect-works/) \(OIDC) is a simple identity layer on top of the
OAuth 2.0 protocol. It allows clients to:
- Verify the identity of the end-user based on the authentication performed by GitLab.
@@ -25,7 +25,7 @@ OIDC performs many of the same tasks as OpenID 2.0, but is API-friendly and usab
mobile applications.
On the client side, you can use [OmniAuth::OpenIDConnect](https://github.com/omniauth/omniauth_openid_connect) for Rails
-applications, or any of the other available [client implementations](https://openid.net/developers/libraries/#connect).
+applications, or any of the other available [client implementations](https://openid.net/developers/certified-openid-connect-implementations/).
The GitLab implementation uses the [doorkeeper-openid_connect](https://github.com/doorkeeper-gem/doorkeeper-openid_connect "Doorkeeper::OpenidConnect website") gem, refer
to its README for more details about which parts of the specifications
diff --git a/doc/integration/saml.md b/doc/integration/saml.md
index 8980e96da63..091c28c5563 100644
--- a/doc/integration/saml.md
+++ b/doc/integration/saml.md
@@ -716,7 +716,7 @@ Some IdPs have documentation on how to use them as the IdP in SAML configuration
For example:
- [Active Directory Federation Services (ADFS)](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-relying-party-trust)
-- [Auth0](https://auth0.com/docs/authenticate/protocols/saml/saml-sso-integrations/configure-auth0-saml-identity-provider)
+- [Auth0](https://auth0.com/docs/authenticate/single-sign-on/outbound-single-sign-on/configure-auth0-saml-identity-provider)
If you have any questions on configuring your IdP in a SAML configuration, contact
your provider's support.
diff --git a/doc/user/ai_features.md b/doc/user/ai_features.md
index c258542a51a..4f34a6c57f2 100644
--- a/doc/user/ai_features.md
+++ b/doc/user/ai_features.md
@@ -19,7 +19,7 @@ Some features are still in development. View details about [support for each sta
| Goal | Feature | Tier/Offering/Status |
|---|---|---|
| Helps you write code more efficiently by showing code suggestions as you type.
[Watch overview](https://www.youtube.com/watch?v=hCAyCTacdAQ) | [Code Suggestions](project/repository/code_suggestions/index.md) | **Tier:** Premium or Ultimate with [GitLab Duo Pro](../subscriptions/subscription-add-ons.md)
**Offering:** GitLab.com, Self-managed, GitLab Dedicated |
-| Processes and generates text and code in a conversational manner. Helps you quickly identify useful information in large volumes of text in issues, epics, code, and GitLab documentation. | [Chat](gitlab_duo_chat.md) | **Beta Access** subject to the [Testing Agreement](https://handbook.gitlab.com/handbook/legal/testing-agreement/):
- GitLab.com, Self-managed, GitLab Dedicated
- Premium and Ultimate tiers
**Status:** Beta |
+| Processes and generates text and code in a conversational manner. Helps you quickly identify useful information in large volumes of text in issues, epics, code, and GitLab documentation. | [Chat](gitlab_duo_chat.md) | **Tier:** Premium, Ultimate
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
**Status:** Beta (Subject to the [Testing Agreement](https://handbook.gitlab.com/handbook/legal/testing-agreement/)) |
| Helps you discover or recall Git commands when and where you need them. | [Git suggestions](../editor_extensions/gitlab_cli/index.md#gitlab-duo-commands) | **Tier:** Ultimate
**Offering:** GitLab.com
**Status:** Experiment |
| Assists with quickly getting everyone up to speed on lengthy conversations to help ensure you are all on the same page. | [Discussion summary](#summarize-issue-discussions-with-discussion-summary) | **Tier:** Ultimate
**Offering:** GitLab.com
**Status:** Experiment |
| Generates issue descriptions. | [Issue description generation](#summarize-an-issue-with-issue-description-generation) | **Tier:** Ultimate
**Offering:** GitLab.com
**Status:** Experiment |
diff --git a/lib/api/resource_access_tokens.rb b/lib/api/resource_access_tokens.rb
index 1e1b5d77cfd..7a477c6fed5 100644
--- a/lib/api/resource_access_tokens.rb
+++ b/lib/api/resource_access_tokens.rb
@@ -153,13 +153,8 @@ module API
token = find_token(resource, params[:token_id]) if resource_accessible
if token
- response = if source_type == "project"
- ::ProjectAccessTokens::RotateService.new(current_user, token, resource)
+ response = ::ResourceAccessTokens::RotateService.new(current_user, token, resource)
.execute(declared_params)
- else
- ::PersonalAccessTokens::RotateService.new(current_user, token)
- .execute(declared_params)
- end
if response.success?
status :ok
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 08d2f1dc6a4..9e80cb7b23a 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -20841,6 +20841,9 @@ msgstr ""
msgid "Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later."
msgstr ""
+msgid "Failed to fetch Namespace: %{fullPath}"
+msgstr ""
+
msgid "Failed to fetch the iteration for this issue. Please try again."
msgstr ""
diff --git a/spec/frontend/ci/runner/components/registration/google_cloud_registration_instructions_spec.js b/spec/frontend/ci/runner/components/registration/google_cloud_registration_instructions_spec.js
index 908a28e1d51..333e2e179b7 100644
--- a/spec/frontend/ci/runner/components/registration/google_cloud_registration_instructions_spec.js
+++ b/spec/frontend/ci/runner/components/registration/google_cloud_registration_instructions_spec.js
@@ -9,10 +9,12 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import GoogleCloudRegistrationInstructions from '~/ci/runner/components/registration/google_cloud_registration_instructions.vue';
import runnerForRegistrationQuery from '~/ci/runner/graphql/register/runner_for_registration.query.graphql';
import provisionGoogleCloudRunnerQueryProject from '~/ci/runner/graphql/register/provision_google_cloud_runner_project.query.graphql';
+import provisionGoogleCloudRunnerQueryGroup from '~/ci/runner/graphql/register/provision_google_cloud_runner_group.query.graphql';
import {
runnerForRegistration,
mockAuthenticationToken,
- googleCloudRunnerProvisionResponse,
+ projectRunnerCloudProvisioningSteps,
+ groupRunnerCloudProvisioningSteps,
} from '../../mock_data';
Vue.use(VueApollo);
@@ -34,10 +36,18 @@ const mockRunnerWithoutTokenResponse = {
},
};
-const mockGoogleCloudRunnerProvisionResponse = {
+const mockProjectRunnerCloudSteps = {
data: {
project: {
- ...googleCloudRunnerProvisionResponse,
+ ...projectRunnerCloudProvisioningSteps,
+ },
+ },
+};
+
+const mockGroupRunnerCloudSteps = {
+ data: {
+ group: {
+ ...groupRunnerCloudProvisioningSteps,
},
},
};
@@ -64,20 +74,36 @@ describe('GoogleCloudRegistrationInstructions', () => {
const findInstructionsButton = () => wrapper.findByTestId('show-instructions-button');
const findAlert = () => wrapper.findComponent(GlAlert);
+ const fillInGoogleForm = () => {
+ findProjectIdInput().vm.$emit('input', 'dev-gcp-xxx-integrati-xxxxxxxx');
+ findRegionInput().vm.$emit('input', 'us-central1');
+ findZoneInput().vm.$emit('input', 'us-central1');
+
+ findInstructionsButton().vm.$emit('click');
+
+ return waitForPromises();
+ };
+
const runnerWithTokenResolver = jest.fn().mockResolvedValue(mockRunnerResponse);
const runnerWithoutTokenResolver = jest.fn().mockResolvedValue(mockRunnerWithoutTokenResponse);
- const googleCloudRunnerResolver = jest
- .fn()
- .mockResolvedValue(mockGoogleCloudRunnerProvisionResponse);
+ const projectInstructionsResolver = jest.fn().mockResolvedValue(mockProjectRunnerCloudSteps);
+ const groupInstructionsResolver = jest.fn().mockResolvedValue(mockGroupRunnerCloudSteps);
const defaultHandlers = [[runnerForRegistrationQuery, runnerWithTokenResolver]];
+ const defaultProps = {
+ runnerId: mockRunnerId,
+ projectPath: 'test/project',
+ };
- const createComponent = (mountFn = shallowMountExtended, handlers = defaultHandlers) => {
+ const createComponent = (
+ mountFn = shallowMountExtended,
+ handlers = defaultHandlers,
+ props = defaultProps,
+ ) => {
wrapper = mountFn(GoogleCloudRegistrationInstructions, {
apolloProvider: createMockApollo(handlers),
propsData: {
- runnerId: mockRunnerId,
- projectPath: 'test/project',
+ ...props,
},
});
};
@@ -149,7 +175,7 @@ describe('GoogleCloudRegistrationInstructions', () => {
it('Hides an alert when the form is valid', async () => {
createComponent(mountExtended, [
- [provisionGoogleCloudRunnerQueryProject, googleCloudRunnerResolver],
+ [provisionGoogleCloudRunnerQueryProject, projectInstructionsResolver],
]);
findProjectIdInput().vm.$emit('input', 'dev-gcp-xxx-integrati-xxxxxxxx');
@@ -164,20 +190,32 @@ describe('GoogleCloudRegistrationInstructions', () => {
expect(findAlert().exists()).toBe(false);
});
- it('Shows a modal with the correspondent scripts', async () => {
+ it('Shows a modal with the correspondent scripts for a project', async () => {
createComponent(shallowMountExtended, [
- [provisionGoogleCloudRunnerQueryProject, googleCloudRunnerResolver],
+ [provisionGoogleCloudRunnerQueryProject, projectInstructionsResolver],
]);
- findProjectIdInput().vm.$emit('input', 'dev-gcp-xxx-integrati-xxxxxxxx');
- findRegionInput().vm.$emit('input', 'us-central1');
- findZoneInput().vm.$emit('input', 'us-central1');
+ await fillInGoogleForm();
- findInstructionsButton().vm.$emit('click');
+ expect(projectInstructionsResolver).toHaveBeenCalled();
+ expect(groupInstructionsResolver).not.toHaveBeenCalled();
- await waitForPromises();
+ expect(findModalBashInstructions().text()).not.toBeNull();
+ expect(findModalTerrarformInstructions().text()).not.toBeNull();
+ expect(findModalTerrarformApplyInstructions().text).not.toBeNull();
+ });
- expect(googleCloudRunnerResolver).toHaveBeenCalled();
+ it('Shows a modal with the correspondent scripts for a group', async () => {
+ createComponent(
+ shallowMountExtended,
+ [[provisionGoogleCloudRunnerQueryGroup, groupInstructionsResolver]],
+ { runnerId: mockRunnerId, groupPath: 'groups/test' },
+ );
+
+ await fillInGoogleForm();
+
+ expect(groupInstructionsResolver).toHaveBeenCalled();
+ expect(projectInstructionsResolver).not.toHaveBeenCalled();
expect(findModalBashInstructions().text()).not.toBeNull();
expect(findModalTerrarformInstructions().text()).not.toBeNull();
diff --git a/spec/frontend/ci/runner/mock_data.js b/spec/frontend/ci/runner/mock_data.js
index 803da30e249..addfb121ebc 100644
--- a/spec/frontend/ci/runner/mock_data.js
+++ b/spec/frontend/ci/runner/mock_data.js
@@ -387,7 +387,7 @@ export const mockAuthenticationToken = 'MOCK_AUTHENTICATION_TOKEN';
export const newRunnerPath = '/runners/new';
export const runnerInstallHelpPage = 'https://docs.example.com/runner/install/';
-export const googleCloudRunnerProvisionResponse = {
+export const projectRunnerCloudProvisioningSteps = {
__typename: 'Project',
id: 'gid://gitlab/Project/1',
runnerCloudProvisioning: {
@@ -410,6 +410,29 @@ export const googleCloudRunnerProvisionResponse = {
},
};
+export const groupRunnerCloudProvisioningSteps = {
+ __typename: 'Group',
+ id: 'gid://gitlab/Group/24',
+ runnerCloudProvisioning: {
+ __typename: 'CiRunnerGoogleCloudProvisioning',
+ projectSetupShellScript: '#!/bin/bash echo "hello world!"',
+ provisioningSteps: [
+ {
+ __typename: 'CiRunnerCloudProvisioningStep',
+ title: 'Save the Terraform script to a file',
+ languageIdentifier: 'terraform',
+ instructions: 'mock instructions...',
+ },
+ {
+ __typename: 'CiRunnerCloudProvisioningStep',
+ title: 'Apply the Terraform script',
+ languageIdentifier: 'shell',
+ instructions: 'mock instructions...',
+ },
+ ],
+ },
+};
+
export {
allRunnersData,
allRunnersWithCreatorData,
diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb
index 229d7cf99d1..42b84a76607 100644
--- a/spec/graphql/types/project_type_spec.rb
+++ b/spec/graphql/types/project_type_spec.rb
@@ -43,7 +43,7 @@ RSpec.describe GitlabSchema.types['Project'], feature_category: :groups_and_proj
incident_management_timeline_event_tags visible_forks inherited_ci_variables autocomplete_users
ci_cd_settings detailed_import_status value_streams ml_models
allows_multiple_merge_request_assignees allows_multiple_merge_request_reviewers is_forked
- protectable_branches
+ protectable_branches available_deploy_keys
]
expect(described_class).to include_graphql_fields(*expected_fields)
@@ -1219,4 +1219,59 @@ RSpec.describe GitlabSchema.types['Project'], feature_category: :groups_and_proj
end
end
end
+
+ describe 'available_deploy_keys' do
+ let(:query) do
+ %(
+ query {
+ project(fullPath: "#{project.full_path}") {
+ availableDeployKeys{
+ nodes{
+ id
+ title
+ user {
+ username
+ }
+ }
+ }
+ }
+ }
+ )
+ end
+
+ subject { GitlabSchema.execute(query, context: { current_user: current_user }).as_json }
+
+ let_it_be(:project) { create :project }
+ let_it_be(:deploy_key) { create(:deploy_keys_project, :write_access, project: project).deploy_key }
+ let_it_be(:maintainer) { create(:user) }
+ let(:available_deploy_keys) { subject.dig('data', 'project', 'availableDeployKeys', 'nodes') }
+
+ context 'when there are deploy keys' do
+ before_all do
+ project.add_maintainer(maintainer)
+ end
+
+ context 'and the current user has access' do
+ let(:current_user) { maintainer }
+
+ it 'returns the deploy keys' do
+ expect(available_deploy_keys[0]).to include({
+ 'id' => deploy_key.to_global_id.to_s,
+ 'title' => deploy_key.title,
+ 'user' => {
+ 'username' => deploy_key.user.username
+ }
+ })
+ end
+ end
+
+ context 'and the current user does not have access' do
+ let(:current_user) { create(:user) }
+
+ it 'does not return any deploy keys' do
+ expect(available_deploy_keys).to be_nil
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index e6dc1139363..f7a2daa6da1 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -4752,30 +4752,6 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
end
end
- describe '#eager_fetch_ref!' do
- let(:project) { create(:project, :repository) }
-
- # We use build instead of create to test that an IID is allocated
- subject { build(:merge_request, source_project: project) }
-
- it 'fetches the ref and expires the ancestor cache' do
- expect(subject).to receive(:expire_ancestor_cache).and_call_original
- expect(subject.iid).to be_nil
-
- expect { subject.eager_fetch_ref! }.to change { subject.iid.to_i }.by(1)
-
- expect(subject.target_project.repository.ref_exists?(subject.ref_path)).to be_truthy
- end
-
- it 'only fetches the ref once after saved' do
- expect(subject.target_project.repository).to receive(:fetch_source_branch!).once.and_call_original
-
- subject.save!
-
- expect(subject.target_project.repository.ref_exists?(subject.ref_path)).to be_truthy
- end
- end
-
describe 'removing a merge request' do
it 'refreshes the number of open merge requests of the target project' do
project = subject.target_project
diff --git a/spec/requests/api/resource_access_tokens_spec.rb b/spec/requests/api/resource_access_tokens_spec.rb
index 70d66538375..7497add0adc 100644
--- a/spec/requests/api/resource_access_tokens_spec.rb
+++ b/spec/requests/api/resource_access_tokens_spec.rb
@@ -575,21 +575,14 @@ RSpec.describe API::ResourceAccessTokens, feature_category: :system_access do
context 'when service raises an error' do
let(:error_message) { 'boom!' }
- let(:personal_token_service) { PersonalAccessTokens::RotateService }
- let(:project_token_service) { ProjectAccessTokens::RotateService }
+ let(:resource_token_service) { ResourceAccessTokens::RotateService }
before do
resource.add_maintainer(project_bot)
resource.add_owner(user)
- if source_type == 'project'
- allow_next_instance_of(project_token_service) do |service|
- allow(service).to receive(:execute).and_return(ServiceResponse.error(message: error_message))
- end
- else
- allow_next_instance_of(personal_token_service) do |service|
- allow(service).to receive(:execute).and_return(ServiceResponse.error(message: error_message))
- end
+ allow_next_instance_of(resource_token_service) do |service|
+ allow(service).to receive(:execute).and_return(ServiceResponse.error(message: error_message))
end
end
diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb
index 8a866b632bd..8f18013eaf8 100644
--- a/spec/services/groups/transfer_service_spec.rb
+++ b/spec/services/groups/transfer_service_spec.rb
@@ -141,6 +141,39 @@ RSpec.describe Groups::TransferService, :sidekiq_inline, feature_category: :grou
end
end
+ context 'transferring labels' do
+ let(:new_parent_group) { create(:group, :private) }
+ let(:parent_group) { create(:group) }
+ let(:group) { create(:group, parent: parent_group) }
+ let(:project) { create(:project, group: group) }
+
+ before do
+ group.add_owner(user)
+ parent_group.add_owner(user)
+ new_parent_group.add_owner(user)
+ end
+
+ context 'when the feature flag "group_labels_transfer" is disabled' do
+ before do
+ stub_feature_flags(group_labels_transfer: false)
+ end
+
+ it 'does not use Labels::TransferService' do
+ expect(Labels::TransferService).not_to receive(:new)
+
+ transfer_service.execute(new_parent_group)
+ end
+ end
+
+ it 'delegates transfer to Labels::TransferService' do
+ expect_next_instance_of(Labels::TransferService, user, project.group, project) do |labels_transfer_service|
+ expect(labels_transfer_service).to receive(:execute).once.and_call_original
+ end
+
+ transfer_service.execute(new_parent_group)
+ end
+ end
+
describe '#execute' do
context 'when transforming a group into a root group' do
let_it_be_with_reload(:group) { create(:group, :public, :nested) }
diff --git a/spec/services/project_access_tokens/rotate_service_spec.rb b/spec/services/project_access_tokens/rotate_service_spec.rb
deleted file mode 100644
index 10e29be4979..00000000000
--- a/spec/services/project_access_tokens/rotate_service_spec.rb
+++ /dev/null
@@ -1,189 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-RSpec.describe ProjectAccessTokens::RotateService, feature_category: :system_access do
- describe '#execute' do
- let_it_be(:token, reload: true) { create(:personal_access_token) }
- let(:current_user) { create(:user) }
- let(:project) { create(:project, group: create(:group)) }
- let(:error_message) { 'Not eligible to rotate token with access level higher than the user' }
-
- subject(:response) { described_class.new(current_user, token, project).execute }
-
- shared_examples_for 'rotates token succesfully' do
- it "rotates user's own token", :freeze_time do
- expect(response).to be_success
-
- new_token = response.payload[:personal_access_token]
-
- expect(new_token.token).not_to eq(token.token)
- expect(new_token.expires_at).to eq(Date.today + 1.week)
- expect(new_token.user).to eq(token.user)
- end
- end
-
- context 'when user tries to rotate token with different access level' do
- before do
- project.add_guest(token.user)
- end
-
- context 'when current user is an owner' do
- before do
- project.add_owner(current_user)
- end
-
- it_behaves_like "rotates token succesfully"
-
- context 'when creating the new token fails' do
- let(:error_message) { 'boom!' }
-
- before do
- allow_next_instance_of(PersonalAccessToken) do |token|
- allow(token).to receive_message_chain(:errors, :full_messages, :to_sentence).and_return(error_message)
- allow(token).to receive_message_chain(:errors, :clear)
- allow(token).to receive_message_chain(:errors, :empty?).and_return(false)
- end
- end
-
- it 'returns an error' do
- expect(response).to be_error
- expect(response.message).to eq(error_message)
- end
-
- it 'reverts the changes' do
- expect { response }.not_to change { token.reload.revoked? }.from(false)
- end
- end
- end
-
- context 'when current user is not an owner' do
- context 'when current user is maintainer' do
- before do
- project.add_maintainer(current_user)
- end
-
- context 'when access level is not owner' do
- it_behaves_like "rotates token succesfully"
- end
-
- context 'when access level is owner' do
- before do
- project.add_owner(token.user)
- end
-
- it "does not rotate token with higher priviledge" do
- response
-
- expect(response).to be_error
- expect(response.message).to eq(error_message)
- end
- end
- end
-
- context 'when current user is not maintainer' do
- before do
- project.add_developer(current_user)
- end
-
- it 'does not rotate the token' do
- response
-
- expect(response).to be_error
- expect(response.message).to eq(error_message)
- end
- end
- end
-
- context 'when current user is admin' do
- let(:current_user) { create(:admin) }
-
- context 'when admin mode enabled', :enable_admin_mode do
- it_behaves_like "rotates token succesfully"
- end
-
- context 'when admin mode not enabled' do
- it 'does not rotate the token' do
- response
-
- expect(response).to be_error
- expect(response.message).to eq(error_message)
- end
- end
- end
-
- context 'when nested membership' do
- let_it_be(:project_bot) { create(:user, :project_bot) }
- let(:token) { create(:personal_access_token, user: project_bot) }
- let(:top_level_group) { create(:group) }
- let(:sub_group) { create(:group, parent: top_level_group) }
- let(:project) { create(:project, group: sub_group) }
-
- before do
- project.add_maintainer(project_bot)
- end
-
- context 'when current user is an owner' do
- before do
- project.add_owner(current_user)
- end
-
- it_behaves_like "rotates token succesfully"
-
- context 'when its a bot user' do
- let_it_be(:bot_user) { create(:user, :project_bot) }
- let_it_be(:bot_user_membership) do
- create(:project_member, :developer, user: bot_user, project: create(:project))
- end
-
- let_it_be(:token, reload: true) { create(:personal_access_token, user: bot_user) }
-
- it 'updates membership expires at' do
- response
-
- new_token = response.payload[:personal_access_token]
- expect(bot_user_membership.reload.expires_at).to eq(new_token.expires_at)
- end
- end
- end
-
- context 'when current user is not an owner' do
- context 'when current user is maintainer' do
- before do
- project.add_maintainer(current_user)
- end
-
- context 'when access level is not owner' do
- it_behaves_like "rotates token succesfully"
- end
-
- context 'when access level is owner' do
- before do
- project.add_owner(token.user)
- end
-
- it "does not rotate token with higher priviledge" do
- response
-
- expect(response).to be_error
- expect(response.message).to eq(error_message)
- end
- end
- end
-
- context 'when current user is not maintainer' do
- before do
- project.add_developer(current_user)
- end
-
- it 'does not rotate the token' do
- response
-
- expect(response).to be_error
- expect(response.message).to eq(error_message)
- end
- end
- end
- end
- end
- end
-end
diff --git a/spec/services/resource_access_tokens/rotate_service_spec.rb b/spec/services/resource_access_tokens/rotate_service_spec.rb
new file mode 100644
index 00000000000..67ec8816c9c
--- /dev/null
+++ b/spec/services/resource_access_tokens/rotate_service_spec.rb
@@ -0,0 +1,157 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ResourceAccessTokens::RotateService, feature_category: :system_access do
+ shared_examples_for 'rotates token successfully' do
+ it "rotates user's own token", :freeze_time do
+ expect(response).to be_success
+
+ new_token = response.payload[:personal_access_token]
+
+ expect(new_token.token).not_to eq(token.token)
+ expect(new_token.expires_at).to eq(Date.today + 1.week)
+ expect(new_token.user).to eq(token.user)
+ end
+
+ it 'updates membership expires_at' do
+ response
+
+ new_token = response.payload[:personal_access_token]
+ expect(bot_user.members.first.reload.expires_at).to eq(new_token.expires_at)
+ end
+ end
+
+ shared_examples 'token rotation access level check' do |source_type|
+ before do
+ resource.add_guest(token.user)
+ end
+
+ context 'when current user is an owner' do
+ before do
+ resource.add_owner(current_user)
+ end
+
+ it_behaves_like "rotates token successfully"
+
+ context 'when creating the new token fails' do
+ let(:error_message) { 'boom!' }
+
+ before do
+ allow_next_instance_of(PersonalAccessToken) do |token|
+ allow(token).to receive_message_chain(:errors, :full_messages, :to_sentence).and_return(error_message)
+ allow(token).to receive_message_chain(:errors, :clear)
+ allow(token).to receive_message_chain(:errors, :empty?).and_return(false)
+ end
+ end
+
+ it 'returns an error' do
+ expect(response).to be_error
+ expect(response.message).to eq(error_message)
+ end
+
+ it 'reverts the changes' do
+ expect { response }.not_to change { token.reload.revoked? }.from(false)
+ end
+ end
+ end
+
+ context 'when current user is maintainer' do
+ before do
+ resource.add_maintainer(current_user)
+ end
+
+ context 'and token user is not owner' do
+ if source_type == 'project'
+ it_behaves_like "rotates token successfully"
+ elsif source_type == 'group'
+ it 'cannot rotate the token' do
+ response
+
+ expect(response).to be_error
+ expect(response.message).to eq(error_message)
+ end
+ end
+ end
+
+ context 'and token user is owner' do
+ before do
+ resource.add_owner(token.user)
+ end
+
+ it "cannot rotate token with higher privilege" do
+ response
+
+ expect(response).to be_error
+ expect(response.message).to eq(error_message)
+ end
+ end
+ end
+
+ context 'when current user is neither owner or maintainer' do
+ before do
+ resource.add_developer(current_user)
+ end
+
+ it 'cannot rotate the token' do
+ response
+
+ expect(response).to be_error
+ expect(response.message).to eq(error_message)
+ end
+ end
+
+ context 'when current user is admin' do
+ let(:current_user) { create(:admin) }
+
+ context 'with admin mode', :enable_admin_mode do
+ it_behaves_like "rotates token successfully"
+ end
+
+ context 'without admin mode' do
+ it 'cannot rotate the token' do
+ response
+
+ expect(response).to be_error
+ expect(response.message).to eq(error_message)
+ end
+ end
+ end
+ end
+
+ describe '#execute' do
+ subject(:response) { described_class.new(current_user, token, resource).execute }
+
+ let(:current_user) { create(:user) }
+ let(:error_message) { 'Not eligible to rotate token with access level higher than the user' }
+ let(:bot_user) { create(:user, :project_bot) }
+ let(:token) { create(:personal_access_token, user: bot_user) }
+
+ context 'for project' do
+ let_it_be(:resource) { create(:project, group: create(:group)) }
+
+ it_behaves_like 'token rotation access level check', 'project'
+
+ context 'with a nested membership' do
+ let(:top_level_group) { create(:group) }
+ let(:sub_group) { create(:group, parent: top_level_group) }
+ let(:resource) { create(:project, group: sub_group) }
+
+ it_behaves_like 'token rotation access level check', 'project'
+ end
+ end
+
+ context 'for group' do
+ let(:resource) { create(:group) }
+
+ it_behaves_like 'token rotation access level check', 'group'
+
+ context 'with a nested membership' do
+ let(:top_level_group) { create(:group) }
+ let(:resource) { create(:group, parent: top_level_group) }
+
+ it_behaves_like 'token rotation access level check', 'group'
+ end
+ end
+ end
+end
diff --git a/spec/uploaders/avatar_uploader_spec.rb b/spec/uploaders/avatar_uploader_spec.rb
index 333f23d7947..79786d17de4 100644
--- a/spec/uploaders/avatar_uploader_spec.rb
+++ b/spec/uploaders/avatar_uploader_spec.rb
@@ -45,6 +45,32 @@ RSpec.describe AvatarUploader do
expect(uploader.absolute_path.scan(storage_path).size).to eq(1)
expect(uploader.absolute_path).to eq(absolute_path)
end
+
+ describe "avatar cache" do
+ subject(:user) { create(:user) }
+
+ let(:file_path) do
+ File.join("spec", "fixtures", "rails_sample.png")
+ end
+
+ let(:file) { fixture_file_upload(file_path) }
+
+ it "clears the cache on upload" do
+ expect(Gitlab::AvatarCache).to receive(:delete_by_email).with(*user.verified_emails).once
+
+ user.avatar = file
+ user.save!
+ end
+
+ it "clears the cache on removal" do
+ user.avatar = file
+ user.save!
+
+ expect(Gitlab::AvatarCache).to receive(:delete_by_email).with(*user.verified_emails).once
+
+ user.avatar.remove!
+ end
+ end
end
context 'accept allowlist file content type' do