Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
444f662b8d
commit
a865379008
|
@ -45,6 +45,7 @@ docs lint:
|
|||
image: "registry.gitlab.com/gitlab-org/gitlab-docs/lint:vale-2.3.3-markdownlint-0.23.2"
|
||||
stage: test
|
||||
needs: []
|
||||
allow_failure: true
|
||||
script:
|
||||
- scripts/lint-doc.sh
|
||||
# Prepare docs for build
|
||||
|
|
|
@ -63,6 +63,8 @@ linters:
|
|||
- "app/views/admin/users/new.html.haml"
|
||||
- "app/views/admin/users/projects.html.haml"
|
||||
- "app/views/admin/users/show.html.haml"
|
||||
- 'app/views/authentication/_authenticate.html.haml'
|
||||
- 'app/views/authentication/_register.html.haml'
|
||||
- "app/views/clusters/clusters/_cluster.html.haml"
|
||||
- "app/views/clusters/clusters/new.html.haml"
|
||||
- "app/views/dashboard/milestones/index.html.haml"
|
||||
|
@ -311,8 +313,6 @@ linters:
|
|||
- "app/views/shared/web_hooks/_form.html.haml"
|
||||
- "app/views/shared/web_hooks/_hook.html.haml"
|
||||
- "app/views/shared/wikis/_pages_wiki_page.html.haml"
|
||||
- "app/views/u2f/_authenticate.html.haml"
|
||||
- "app/views/u2f/_register.html.haml"
|
||||
- "app/views/users/_deletion_guidance.html.haml"
|
||||
- "ee/app/views/admin/_namespace_plan_info.html.haml"
|
||||
- "ee/app/views/admin/application_settings/_templates.html.haml"
|
||||
|
|
2
Gemfile
2
Gemfile
|
@ -512,3 +512,5 @@ gem 'json_schemer', '~> 0.2.12'
|
|||
gem 'oj', '~> 3.10.6'
|
||||
gem 'multi_json', '~> 1.14.1'
|
||||
gem 'yajl-ruby', '~> 1.4.1', require: 'yajl'
|
||||
|
||||
gem 'webauthn', '~> 2.3'
|
||||
|
|
25
Gemfile.lock
25
Gemfile.lock
|
@ -73,6 +73,7 @@ GEM
|
|||
public_suffix (>= 2.0.2, < 5.0)
|
||||
aes_key_wrap (1.0.1)
|
||||
akismet (3.0.0)
|
||||
android_key_attestation (0.3.0)
|
||||
apollo_upload_server (2.0.2)
|
||||
graphql (>= 1.8)
|
||||
rails (>= 4.2)
|
||||
|
@ -93,6 +94,7 @@ GEM
|
|||
encryptor (~> 3.0.0)
|
||||
attr_required (1.0.1)
|
||||
awesome_print (1.8.0)
|
||||
awrence (1.1.1)
|
||||
aws-eventstream (1.1.0)
|
||||
aws-partitions (1.345.0)
|
||||
aws-sdk-cloudformation (1.41.0)
|
||||
|
@ -167,6 +169,7 @@ GEM
|
|||
activemodel (>= 4.0.0)
|
||||
activesupport (>= 4.0.0)
|
||||
mime-types (>= 1.16)
|
||||
cbor (0.5.9.6)
|
||||
character_set (1.4.0)
|
||||
charlock_holmes (0.7.6)
|
||||
childprocess (3.0.0)
|
||||
|
@ -189,6 +192,9 @@ GEM
|
|||
contracts (0.11.0)
|
||||
cork (0.3.0)
|
||||
colored2 (~> 3.1)
|
||||
cose (1.0.0)
|
||||
cbor (~> 0.5.9)
|
||||
openssl-signature_algorithm (~> 0.4.0)
|
||||
countries (3.0.0)
|
||||
i18n_data (~> 0.8.0)
|
||||
sixarm_ruby_unaccent (~> 1.1)
|
||||
|
@ -802,6 +808,8 @@ GEM
|
|||
validate_email
|
||||
validate_url
|
||||
webfinger (>= 1.0.1)
|
||||
openssl (2.2.0)
|
||||
openssl-signature_algorithm (0.4.0)
|
||||
opentracing (0.5.0)
|
||||
optimist (3.0.1)
|
||||
org-ruby (0.9.12)
|
||||
|
@ -1026,6 +1034,8 @@ GEM
|
|||
rubyzip (2.0.0)
|
||||
rugged (0.28.4.1)
|
||||
safe_yaml (1.0.4)
|
||||
safety_net_attestation (0.4.0)
|
||||
jwt (~> 2.0)
|
||||
sanitize (5.2.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.8.0)
|
||||
|
@ -1050,6 +1060,7 @@ GEM
|
|||
scss_lint (0.56.0)
|
||||
rake (>= 0.9, < 13)
|
||||
sass (~> 3.5.3)
|
||||
securecompare (1.0.0)
|
||||
seed-fu (2.3.7)
|
||||
activerecord (>= 3.1)
|
||||
activesupport (>= 3.1)
|
||||
|
@ -1135,6 +1146,9 @@ GEM
|
|||
parslet (~> 1.8.0)
|
||||
toml-rb (1.0.0)
|
||||
citrus (~> 3.0, > 3.0)
|
||||
tpm-key_attestation (0.9.0)
|
||||
bindata (~> 2.4)
|
||||
openssl-signature_algorithm (~> 0.4.0)
|
||||
truncato (0.7.11)
|
||||
htmlentities (~> 4.3.1)
|
||||
nokogiri (>= 1.7.0, <= 2.0)
|
||||
|
@ -1186,6 +1200,16 @@ GEM
|
|||
vmstat (2.3.0)
|
||||
warden (1.2.8)
|
||||
rack (>= 2.0.6)
|
||||
webauthn (2.3.0)
|
||||
android_key_attestation (~> 0.3.0)
|
||||
awrence (~> 1.1)
|
||||
bindata (~> 2.4)
|
||||
cbor (~> 0.5.9)
|
||||
cose (~> 1.0)
|
||||
openssl (~> 2.0)
|
||||
safety_net_attestation (~> 0.4.0)
|
||||
securecompare (~> 1.0)
|
||||
tpm-key_attestation (~> 0.9.0)
|
||||
webfinger (1.1.0)
|
||||
activesupport
|
||||
httpclient (>= 2.4)
|
||||
|
@ -1472,6 +1496,7 @@ DEPENDENCIES
|
|||
validates_hostname (~> 1.0.10)
|
||||
version_sorter (~> 2.2.4)
|
||||
vmstat (~> 2.3.0)
|
||||
webauthn (~> 2.3)
|
||||
webmock (~> 3.5.1)
|
||||
webpack-rails (~> 0.9.10)
|
||||
wikicloth (= 0.8.1)
|
||||
|
|
|
@ -1,14 +1,23 @@
|
|||
import $ from 'jquery';
|
||||
import initU2F from './u2f';
|
||||
import initWebauthn from './webauthn';
|
||||
import U2FRegister from './u2f/register';
|
||||
import WebAuthnRegister from './webauthn/register';
|
||||
|
||||
export const mount2faAuthentication = () => {
|
||||
// Soon this will conditionally mount a webauthn app (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26692)
|
||||
initU2F();
|
||||
if (gon.webauthn) {
|
||||
initWebauthn();
|
||||
} else {
|
||||
initU2F();
|
||||
}
|
||||
};
|
||||
|
||||
export const mount2faRegistration = () => {
|
||||
// Soon this will conditionally mount a webauthn app (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26692)
|
||||
const u2fRegister = new U2FRegister($('#js-register-u2f'), gon.u2f);
|
||||
u2fRegister.start();
|
||||
if (gon.webauthn) {
|
||||
const webauthnRegister = new WebAuthnRegister($('#js-register-token-2fa'), gon.webauthn);
|
||||
webauthnRegister.start();
|
||||
} else {
|
||||
const u2fRegister = new U2FRegister($('#js-register-token-2fa'), gon.u2f);
|
||||
u2fRegister.start();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -40,7 +40,6 @@ export default class U2FAuthenticate {
|
|||
this.signRequests = u2fParams.sign_requests.map(request => omit(request, 'challenge'));
|
||||
|
||||
this.templates = {
|
||||
setup: '#js-authenticate-token-2fa-setup',
|
||||
inProgress: '#js-authenticate-token-2fa-in-progress',
|
||||
error: '#js-authenticate-token-2fa-error',
|
||||
authenticated: '#js-authenticate-token-2fa-authenticated',
|
||||
|
@ -86,7 +85,7 @@ export default class U2FAuthenticate {
|
|||
renderError(error) {
|
||||
this.renderTemplate('error', {
|
||||
error_message: error.message(),
|
||||
error_code: error.errorCode,
|
||||
error_name: error.errorCode,
|
||||
});
|
||||
return this.container.find('#js-token-2fa-try-again').on('click', this.renderInProgress);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import $ from 'jquery';
|
||||
import { template as lodashTemplate } from 'lodash';
|
||||
import { __ } from '~/locale';
|
||||
import importU2FLibrary from './util';
|
||||
import U2FError from './error';
|
||||
|
||||
|
@ -24,11 +25,10 @@ export default class U2FRegister {
|
|||
this.signRequests = u2fParams.sign_requests;
|
||||
|
||||
this.templates = {
|
||||
notSupported: '#js-register-u2f-not-supported',
|
||||
setup: '#js-register-u2f-setup',
|
||||
inProgress: '#js-register-u2f-in-progress',
|
||||
error: '#js-register-u2f-error',
|
||||
registered: '#js-register-u2f-registered',
|
||||
message: '#js-register-2fa-message',
|
||||
setup: '#js-register-token-2fa-setup',
|
||||
error: '#js-register-token-2fa-error',
|
||||
registered: '#js-register-token-2fa-registered',
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -65,18 +65,22 @@ export default class U2FRegister {
|
|||
|
||||
renderSetup() {
|
||||
this.renderTemplate('setup');
|
||||
return this.container.find('#js-setup-u2f-device').on('click', this.renderInProgress);
|
||||
return this.container.find('#js-setup-token-2fa-device').on('click', this.renderInProgress);
|
||||
}
|
||||
|
||||
renderInProgress() {
|
||||
this.renderTemplate('inProgress');
|
||||
this.renderTemplate('message', {
|
||||
message: __(
|
||||
'Trying to communicate with your device. Plug it in (if needed) and press the button on the device now.',
|
||||
),
|
||||
});
|
||||
return this.register();
|
||||
}
|
||||
|
||||
renderError(error) {
|
||||
this.renderTemplate('error', {
|
||||
error_message: error.message(),
|
||||
error_code: error.errorCode,
|
||||
error_name: error.errorCode,
|
||||
});
|
||||
return this.container.find('#js-token-2fa-try-again').on('click', this.renderSetup);
|
||||
}
|
||||
|
@ -89,6 +93,10 @@ export default class U2FRegister {
|
|||
}
|
||||
|
||||
renderNotSupported() {
|
||||
return this.renderTemplate('notSupported');
|
||||
return this.renderTemplate('message', {
|
||||
message: __(
|
||||
"Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer).",
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
import WebAuthnError from './error';
|
||||
import WebAuthnFlow from './flow';
|
||||
import { supported, convertGetParams, convertGetResponse } from './util';
|
||||
|
||||
// Authenticate WebAuthn devices for users to authenticate with.
|
||||
//
|
||||
// State Flow #1: setup -> in_progress -> authenticated -> POST to server
|
||||
// State Flow #2: setup -> in_progress -> error -> setup
|
||||
export default class WebAuthnAuthenticate {
|
||||
constructor(container, form, webauthnParams, fallbackButton, fallbackUI) {
|
||||
this.container = container;
|
||||
this.webauthnParams = convertGetParams(JSON.parse(webauthnParams.options));
|
||||
this.renderInProgress = this.renderInProgress.bind(this);
|
||||
|
||||
this.form = form;
|
||||
this.fallbackButton = fallbackButton;
|
||||
this.fallbackUI = fallbackUI;
|
||||
if (this.fallbackButton) {
|
||||
this.fallbackButton.addEventListener('click', this.switchToFallbackUI.bind(this));
|
||||
}
|
||||
|
||||
this.flow = new WebAuthnFlow(container, {
|
||||
inProgress: '#js-authenticate-token-2fa-in-progress',
|
||||
error: '#js-authenticate-token-2fa-error',
|
||||
authenticated: '#js-authenticate-token-2fa-authenticated',
|
||||
});
|
||||
|
||||
this.container.on('click', '#js-token-2fa-try-again', this.renderInProgress);
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!supported()) {
|
||||
this.switchToFallbackUI();
|
||||
} else {
|
||||
this.renderInProgress();
|
||||
}
|
||||
}
|
||||
|
||||
authenticate() {
|
||||
navigator.credentials
|
||||
.get({ publicKey: this.webauthnParams })
|
||||
.then(resp => {
|
||||
const convertedResponse = convertGetResponse(resp);
|
||||
this.renderAuthenticated(JSON.stringify(convertedResponse));
|
||||
})
|
||||
.catch(err => {
|
||||
this.flow.renderError(new WebAuthnError(err, 'authenticate'));
|
||||
});
|
||||
}
|
||||
|
||||
renderInProgress() {
|
||||
this.flow.renderTemplate('inProgress');
|
||||
this.authenticate();
|
||||
}
|
||||
|
||||
renderAuthenticated(deviceResponse) {
|
||||
this.flow.renderTemplate('authenticated');
|
||||
const container = this.container[0];
|
||||
container.querySelector('#js-device-response').value = deviceResponse;
|
||||
container.querySelector(this.form).submit();
|
||||
this.fallbackButton.classList.add('hidden');
|
||||
}
|
||||
|
||||
switchToFallbackUI() {
|
||||
this.fallbackButton.classList.add('hidden');
|
||||
this.container[0].classList.add('hidden');
|
||||
this.fallbackUI.classList.remove('hidden');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { __ } from '~/locale';
|
||||
import { isHTTPS, FLOW_AUTHENTICATE, FLOW_REGISTER } from './util';
|
||||
|
||||
export default class WebAuthnError {
|
||||
constructor(error, flowType) {
|
||||
this.error = error;
|
||||
this.errorName = error.name || 'UnknownError';
|
||||
this.message = this.message.bind(this);
|
||||
this.httpsDisabled = !isHTTPS();
|
||||
this.flowType = flowType;
|
||||
}
|
||||
|
||||
message() {
|
||||
if (this.errorName === 'NotSupportedError') {
|
||||
return __('Your device is not compatible with GitLab. Please try another device');
|
||||
} else if (this.errorName === 'InvalidStateError' && this.flowType === FLOW_AUTHENTICATE) {
|
||||
return __('This device has not been registered with us.');
|
||||
} else if (this.errorName === 'InvalidStateError' && this.flowType === FLOW_REGISTER) {
|
||||
return __('This device has already been registered with us.');
|
||||
} else if (this.errorName === 'SecurityError' && this.httpsDisabled) {
|
||||
return __(
|
||||
'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.',
|
||||
);
|
||||
}
|
||||
|
||||
return __('There was a problem communicating with your device.');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import { template } from 'lodash';
|
||||
|
||||
/**
|
||||
* Generic abstraction for WebAuthnFlows, especially for register / authenticate
|
||||
*/
|
||||
export default class WebAuthnFlow {
|
||||
constructor(container, templates) {
|
||||
this.container = container;
|
||||
this.templates = templates;
|
||||
}
|
||||
|
||||
renderTemplate(name, params) {
|
||||
const templateString = document.querySelector(this.templates[name]).innerHTML;
|
||||
const compiledTemplate = template(templateString);
|
||||
this.container.html(compiledTemplate(params));
|
||||
}
|
||||
|
||||
renderError(error) {
|
||||
this.renderTemplate('error', {
|
||||
error_message: error.message(),
|
||||
error_name: error.errorName,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import $ from 'jquery';
|
||||
import WebAuthnAuthenticate from './authenticate';
|
||||
|
||||
export default () => {
|
||||
const webauthnAuthenticate = new WebAuthnAuthenticate(
|
||||
$('#js-authenticate-token-2fa'),
|
||||
'#js-login-token-2fa-form',
|
||||
gon.webauthn,
|
||||
document.querySelector('#js-login-2fa-device'),
|
||||
document.querySelector('.js-2fa-form'),
|
||||
);
|
||||
webauthnAuthenticate.start();
|
||||
};
|
|
@ -0,0 +1,78 @@
|
|||
import { __ } from '~/locale';
|
||||
import WebAuthnError from './error';
|
||||
import WebAuthnFlow from './flow';
|
||||
import { supported, isHTTPS, convertCreateParams, convertCreateResponse } from './util';
|
||||
|
||||
// Register WebAuthn devices for users to authenticate with.
|
||||
//
|
||||
// State Flow #1: setup -> in_progress -> registered -> POST to server
|
||||
// State Flow #2: setup -> in_progress -> error -> setup
|
||||
export default class WebAuthnRegister {
|
||||
constructor(container, webauthnParams) {
|
||||
this.container = container;
|
||||
this.renderInProgress = this.renderInProgress.bind(this);
|
||||
this.webauthnOptions = convertCreateParams(webauthnParams.options);
|
||||
|
||||
this.flow = new WebAuthnFlow(container, {
|
||||
message: '#js-register-2fa-message',
|
||||
setup: '#js-register-token-2fa-setup',
|
||||
error: '#js-register-token-2fa-error',
|
||||
registered: '#js-register-token-2fa-registered',
|
||||
});
|
||||
|
||||
this.container.on('click', '#js-token-2fa-try-again', this.renderInProgress);
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!supported()) {
|
||||
// we show a special error message when the user visits the site
|
||||
// using a non-ssl connection as this makes WebAuthn unavailable in
|
||||
// any case, regardless of the used browser
|
||||
this.renderNotSupported(!isHTTPS());
|
||||
} else {
|
||||
this.renderSetup();
|
||||
}
|
||||
}
|
||||
|
||||
register() {
|
||||
navigator.credentials
|
||||
.create({
|
||||
publicKey: this.webauthnOptions,
|
||||
})
|
||||
.then(cred => this.renderRegistered(JSON.stringify(convertCreateResponse(cred))))
|
||||
.catch(err => this.flow.renderError(new WebAuthnError(err, 'register')));
|
||||
}
|
||||
|
||||
renderSetup() {
|
||||
this.flow.renderTemplate('setup');
|
||||
this.container.find('#js-setup-token-2fa-device').on('click', this.renderInProgress);
|
||||
}
|
||||
|
||||
renderInProgress() {
|
||||
this.flow.renderTemplate('message', {
|
||||
message: __(
|
||||
'Trying to communicate with your device. Plug it in (if needed) and press the button on the device now.',
|
||||
),
|
||||
});
|
||||
return this.register();
|
||||
}
|
||||
|
||||
renderRegistered(deviceResponse) {
|
||||
this.flow.renderTemplate('registered');
|
||||
// Prefer to do this instead of interpolating using Underscore templates
|
||||
// because of JSON escaping issues.
|
||||
this.container.find('#js-device-response').val(deviceResponse);
|
||||
}
|
||||
|
||||
renderNotSupported(noHttps) {
|
||||
const message = noHttps
|
||||
? __(
|
||||
'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.',
|
||||
)
|
||||
: __(
|
||||
"Your browser doesn't support WebAuthn. Please use a supported browser, e.g. Chrome (67+) or Firefox (60+).",
|
||||
);
|
||||
|
||||
this.flow.renderTemplate('message', { message });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
export function supported() {
|
||||
return Boolean(
|
||||
navigator.credentials &&
|
||||
navigator.credentials.create &&
|
||||
navigator.credentials.get &&
|
||||
window.PublicKeyCredential,
|
||||
);
|
||||
}
|
||||
|
||||
export function isHTTPS() {
|
||||
return window.location.protocol.startsWith('https');
|
||||
}
|
||||
|
||||
export const FLOW_AUTHENTICATE = 'authenticate';
|
||||
export const FLOW_REGISTER = 'register';
|
||||
|
||||
// adapted from https://stackoverflow.com/a/21797381/8204697
|
||||
function base64ToBuffer(base64) {
|
||||
const binaryString = window.atob(base64);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
// adapted from https://stackoverflow.com/a/9458996/8204697
|
||||
function bufferToBase64(buffer) {
|
||||
if (typeof buffer === 'string') {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of the given object with the id property converted to buffer
|
||||
*
|
||||
* @param {Object} param
|
||||
*/
|
||||
function convertIdToBuffer({ id, ...rest }) {
|
||||
return {
|
||||
...rest,
|
||||
id: base64ToBuffer(id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of the given array with all `id`s of the items converted to buffer
|
||||
*
|
||||
* @param {Array} items
|
||||
*/
|
||||
function convertIdsToBuffer(items) {
|
||||
return items.map(convertIdToBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object with keys of the given props, and values from the given object converted to base64
|
||||
*
|
||||
* @param {String} obj
|
||||
* @param {Array} props
|
||||
*/
|
||||
function convertPropertiesToBase64(obj, props) {
|
||||
return props.reduce(
|
||||
(acc, property) => Object.assign(acc, { [property]: bufferToBase64(obj[property]) }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
export function convertGetParams({ allowCredentials, challenge, ...rest }) {
|
||||
return {
|
||||
...rest,
|
||||
...(allowCredentials ? { allowCredentials: convertIdsToBuffer(allowCredentials) } : {}),
|
||||
challenge: base64ToBuffer(challenge),
|
||||
};
|
||||
}
|
||||
|
||||
export function convertGetResponse(webauthnResponse) {
|
||||
return {
|
||||
type: webauthnResponse.type,
|
||||
id: webauthnResponse.id,
|
||||
rawId: bufferToBase64(webauthnResponse.rawId),
|
||||
response: convertPropertiesToBase64(webauthnResponse.response, [
|
||||
'clientDataJSON',
|
||||
'authenticatorData',
|
||||
'signature',
|
||||
'userHandle',
|
||||
]),
|
||||
clientExtensionResults: webauthnResponse.getClientExtensionResults(),
|
||||
};
|
||||
}
|
||||
|
||||
export function convertCreateParams({ challenge, user, excludeCredentials, ...rest }) {
|
||||
return {
|
||||
...rest,
|
||||
challenge: base64ToBuffer(challenge),
|
||||
user: convertIdToBuffer(user),
|
||||
...(excludeCredentials ? { excludeCredentials: convertIdsToBuffer(excludeCredentials) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function convertCreateResponse(webauthnResponse) {
|
||||
return {
|
||||
type: webauthnResponse.type,
|
||||
id: webauthnResponse.id,
|
||||
rawId: bufferToBase64(webauthnResponse.rawId),
|
||||
clientExtensionResults: webauthnResponse.getClientExtensionResults(),
|
||||
response: convertPropertiesToBase64(webauthnResponse.response, [
|
||||
'clientDataJSON',
|
||||
'attestationObject',
|
||||
]),
|
||||
};
|
||||
}
|
|
@ -2,7 +2,14 @@
|
|||
/* eslint-disable vue/no-v-html */
|
||||
import { escape } from 'lodash';
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import { GlDeprecatedButton, GlTooltipDirective, GlLoadingIcon, GlIcon } from '@gitlab/ui';
|
||||
import {
|
||||
GlDeprecatedButton,
|
||||
GlTooltipDirective,
|
||||
GlSafeHtmlDirective,
|
||||
GlLoadingIcon,
|
||||
GlIcon,
|
||||
GlButton,
|
||||
} from '@gitlab/ui';
|
||||
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
|
||||
import FileIcon from '~/vue_shared/components/file_icon.vue';
|
||||
import { truncateSha } from '~/lib/utils/text_utility';
|
||||
|
@ -21,9 +28,11 @@ export default {
|
|||
GlIcon,
|
||||
FileIcon,
|
||||
DiffStats,
|
||||
GlButton,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
SafeHtml: GlSafeHtmlDirective,
|
||||
},
|
||||
props: {
|
||||
discussionPath: {
|
||||
|
@ -77,6 +86,21 @@ export default {
|
|||
|
||||
return this.discussionPath;
|
||||
},
|
||||
submoduleDiffCompareLinkText() {
|
||||
if (this.diffFile.submodule_compare) {
|
||||
const truncatedOldSha = escape(truncateSha(this.diffFile.submodule_compare.old_sha));
|
||||
const truncatedNewSha = escape(truncateSha(this.diffFile.submodule_compare.new_sha));
|
||||
return sprintf(
|
||||
s__('Compare %{oldCommitId}...%{newCommitId}'),
|
||||
{
|
||||
oldCommitId: `<span class="commit-sha">${truncatedOldSha}</span>`,
|
||||
newCommitId: `<span class="commit-sha">${truncatedNewSha}</span>`,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
filePath() {
|
||||
if (this.diffFile.submodule) {
|
||||
return `${this.diffFile.file_path} @ ${truncateSha(this.diffFile.blob.id)}`;
|
||||
|
@ -311,5 +335,18 @@ export default {
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="diffFile.submodule_compare"
|
||||
class="file-actions d-none d-sm-flex align-items-center flex-wrap"
|
||||
>
|
||||
<gl-button
|
||||
v-gl-tooltip.hover
|
||||
v-safe-html="submoduleDiffCompareLinkText"
|
||||
class="submodule-compare"
|
||||
:title="s__('Compare submodule commit revisions')"
|
||||
:href="diffFile.submodule_compare.url"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -26,7 +26,7 @@ export default {
|
|||
|
||||
<div
|
||||
v-if="showVersion"
|
||||
class="table-section section-50 gl-display-flex gl-justify-content-md-end"
|
||||
class="table-section section-50 gl-display-flex gl-md-justify-content-end"
|
||||
data-testid="version-pattern"
|
||||
>
|
||||
<span class="gl-text-body">{{ dependency.version_pattern }}</span>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
/* eslint-disable vue/no-v-html */
|
||||
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
|
||||
import { deprecatedCreateFlash as Flash } from '~/flash';
|
||||
|
@ -12,6 +12,9 @@ export default {
|
|||
components: {
|
||||
DeprecatedModal,
|
||||
},
|
||||
directives: {
|
||||
SafeHtml,
|
||||
},
|
||||
props: {
|
||||
issueCount: {
|
||||
type: Number,
|
||||
|
@ -125,7 +128,7 @@ Once deleted, it cannot be undone or recovered.`),
|
|||
@submit="onSubmit"
|
||||
>
|
||||
<template #body="props">
|
||||
<p v-html="props.text"></p>
|
||||
<p v-safe-html="props.text"></p>
|
||||
</template>
|
||||
</deprecated-modal>
|
||||
</template>
|
||||
|
|
|
@ -14,10 +14,20 @@ import createTestReportsStore from './stores/test_reports';
|
|||
|
||||
Vue.use(Translate);
|
||||
|
||||
const SELECTORS = {
|
||||
PIPELINE_DETAILS: '.js-pipeline-details-vue',
|
||||
PIPELINE_GRAPH: '#js-pipeline-graph-vue',
|
||||
PIPELINE_HEADER: '#js-pipeline-header-vue',
|
||||
PIPELINE_TESTS: '#js-pipeline-tests-detail',
|
||||
};
|
||||
|
||||
const createPipelinesDetailApp = mediator => {
|
||||
if (!document.querySelector(SELECTORS.PIPELINE_GRAPH)) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-new
|
||||
new Vue({
|
||||
el: '#js-pipeline-graph-vue',
|
||||
el: SELECTORS.PIPELINE_GRAPH,
|
||||
components: {
|
||||
pipelineGraph,
|
||||
},
|
||||
|
@ -47,9 +57,12 @@ const createPipelinesDetailApp = mediator => {
|
|||
};
|
||||
|
||||
const createPipelineHeaderApp = mediator => {
|
||||
if (!document.querySelector(SELECTORS.PIPELINE_HEADER)) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-new
|
||||
new Vue({
|
||||
el: '#js-pipeline-header-vue',
|
||||
el: SELECTORS.PIPELINE_HEADER,
|
||||
components: {
|
||||
pipelineHeader,
|
||||
},
|
||||
|
@ -93,9 +106,8 @@ const createPipelineHeaderApp = mediator => {
|
|||
};
|
||||
|
||||
const createTestDetails = () => {
|
||||
const el = document.querySelector('#js-pipeline-tests-detail');
|
||||
const el = document.querySelector(SELECTORS.PIPELINE_TESTS);
|
||||
const { summaryEndpoint, suiteEndpoint } = el?.dataset || {};
|
||||
|
||||
const testReportsStore = createTestReportsStore({
|
||||
summaryEndpoint,
|
||||
suiteEndpoint,
|
||||
|
@ -115,7 +127,7 @@ const createTestDetails = () => {
|
|||
};
|
||||
|
||||
export default () => {
|
||||
const { dataset } = document.querySelector('.js-pipeline-details-vue');
|
||||
const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS);
|
||||
const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
|
||||
mediator.fetchPipeline();
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ export default function deviseState() {
|
|||
return stateKey.pipelineFailed;
|
||||
} else if (this.workInProgress) {
|
||||
return stateKey.workInProgress;
|
||||
} else if (this.hasMergeableDiscussionsState) {
|
||||
} else if (this.hasMergeableDiscussionsState && !this.autoMergeEnabled) {
|
||||
return stateKey.unresolvedDiscussions;
|
||||
} else if (this.isPipelineBlocked) {
|
||||
return stateKey.pipelineBlocked;
|
||||
|
|
|
@ -193,6 +193,10 @@ table {
|
|||
}
|
||||
}
|
||||
|
||||
.detected {
|
||||
width: 9%;
|
||||
}
|
||||
|
||||
.status {
|
||||
width: 8%;
|
||||
}
|
||||
|
@ -202,7 +206,7 @@ table {
|
|||
}
|
||||
|
||||
.identifier {
|
||||
width: 12%;
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
.scanner {
|
||||
|
|
|
@ -151,18 +151,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
.todos-filters {
|
||||
.dropdown-menu-toggle {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.dropdown-menu-toggle-sort {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(lg) {
|
||||
.todos-filters {
|
||||
.filter-categories {
|
||||
|
@ -206,6 +194,10 @@
|
|||
.dropdown-menu-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-menu-toggle-sort {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -257,7 +257,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
table.u2f-registrations {
|
||||
table.u2f-registrations,
|
||||
.webauthn-registrations {
|
||||
th:not(:last-child),
|
||||
td:not(:last-child) {
|
||||
border-right: solid 1px transparent;
|
||||
|
|
|
@ -112,27 +112,10 @@
|
|||
top: 66vh;
|
||||
}
|
||||
|
||||
// Remove when https://gitlab.com/gitlab-org/gitlab-ui/-/issues/871
|
||||
// gets fixed on GitLab UI
|
||||
.gl-sm-w-auto\! {
|
||||
@media (min-width: $breakpoint-sm) {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.gl-shadow-x0-y0-b3-s1-blue-500 {
|
||||
box-shadow: inset 0 0 3px $gl-border-size-1 $blue-500;
|
||||
}
|
||||
|
||||
// remove when https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1692 is merged
|
||||
.gl-border-t-transparent {
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
.gl-align-items-flex-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.gl-sm-align-items-flex-end {
|
||||
@media (min-width: $breakpoint-sm) {
|
||||
|
@ -152,15 +135,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.gl-align-items-stretch {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.gl-min-h-6 {
|
||||
min-height: $gl-spacing-scale-6;
|
||||
}
|
||||
|
||||
.gl-justify-content-md-end {
|
||||
.gl-md-justify-content-end {
|
||||
@media (min-width: $breakpoint-md) {
|
||||
width: auto !important;
|
||||
}
|
||||
|
|
|
@ -11,7 +11,13 @@ module Authenticates2FAForAdminMode
|
|||
return handle_locked_user(user) unless user.can?(:log_in)
|
||||
|
||||
session[:otp_user_id] = user.id
|
||||
setup_u2f_authentication(user)
|
||||
push_frontend_feature_flag(:webauthn)
|
||||
|
||||
if user.two_factor_webauthn_enabled?
|
||||
setup_webauthn_authentication(user)
|
||||
else
|
||||
setup_u2f_authentication(user)
|
||||
end
|
||||
|
||||
render 'admin/sessions/two_factor', layout: 'application'
|
||||
end
|
||||
|
@ -24,7 +30,11 @@ module Authenticates2FAForAdminMode
|
|||
if user_params[:otp_attempt].present? && session[:otp_user_id]
|
||||
admin_mode_authenticate_with_two_factor_via_otp(user)
|
||||
elsif user_params[:device_response].present? && session[:otp_user_id]
|
||||
admin_mode_authenticate_with_two_factor_via_u2f(user)
|
||||
if user.two_factor_webauthn_enabled?
|
||||
admin_mode_authenticate_with_two_factor_via_webauthn(user)
|
||||
else
|
||||
admin_mode_authenticate_with_two_factor_via_u2f(user)
|
||||
end
|
||||
elsif user && user.valid_password?(user_params[:password])
|
||||
admin_mode_prompt_for_two_factor(user)
|
||||
else
|
||||
|
@ -52,18 +62,17 @@ module Authenticates2FAForAdminMode
|
|||
|
||||
def admin_mode_authenticate_with_two_factor_via_u2f(user)
|
||||
if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge])
|
||||
# Remove any lingering user data from login
|
||||
session.delete(:otp_user_id)
|
||||
session.delete(:challenge)
|
||||
|
||||
# The admin user has successfully passed 2fa, enable admin mode ignoring password
|
||||
enable_admin_mode
|
||||
admin_handle_two_factor_success
|
||||
else
|
||||
user.increment_failed_attempts!
|
||||
Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=U2F")
|
||||
flash.now[:alert] = _('Authentication via U2F device failed.')
|
||||
admin_handle_two_factor_failure(user, 'U2F')
|
||||
end
|
||||
end
|
||||
|
||||
admin_mode_prompt_for_two_factor(user)
|
||||
def admin_mode_authenticate_with_two_factor_via_webauthn(user)
|
||||
if Webauthn::AuthenticateService.new(user, user_params[:device_response], session[:challenge]).execute
|
||||
admin_handle_two_factor_success
|
||||
else
|
||||
admin_handle_two_factor_failure(user, 'WebAuthn')
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -81,4 +90,21 @@ module Authenticates2FAForAdminMode
|
|||
flash.now[:alert] = _('Invalid login or password')
|
||||
render :new
|
||||
end
|
||||
|
||||
def admin_handle_two_factor_success
|
||||
# Remove any lingering user data from login
|
||||
session.delete(:otp_user_id)
|
||||
session.delete(:challenge)
|
||||
|
||||
# The admin user has successfully passed 2fa, enable admin mode ignoring password
|
||||
enable_admin_mode
|
||||
end
|
||||
|
||||
def admin_handle_two_factor_failure(user, method)
|
||||
user.increment_failed_attempts!
|
||||
Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}")
|
||||
flash.now[:alert] = _('Authentication via %{method} device failed.') % { method: method }
|
||||
|
||||
admin_mode_prompt_for_two_factor(user)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -23,8 +23,14 @@ module AuthenticatesWithTwoFactor
|
|||
|
||||
session[:otp_user_id] = user.id
|
||||
session[:user_updated_at] = user.updated_at
|
||||
push_frontend_feature_flag(:webauthn)
|
||||
|
||||
if user.two_factor_webauthn_enabled?
|
||||
setup_webauthn_authentication(user)
|
||||
else
|
||||
setup_u2f_authentication(user)
|
||||
end
|
||||
|
||||
setup_u2f_authentication(user)
|
||||
render 'devise/sessions/two_factor'
|
||||
end
|
||||
|
||||
|
@ -46,7 +52,11 @@ module AuthenticatesWithTwoFactor
|
|||
if user_params[:otp_attempt].present? && session[:otp_user_id]
|
||||
authenticate_with_two_factor_via_otp(user)
|
||||
elsif user_params[:device_response].present? && session[:otp_user_id]
|
||||
authenticate_with_two_factor_via_u2f(user)
|
||||
if user.two_factor_webauthn_enabled?
|
||||
authenticate_with_two_factor_via_webauthn(user)
|
||||
else
|
||||
authenticate_with_two_factor_via_u2f(user)
|
||||
end
|
||||
elsif user && user.valid_password?(user_params[:password])
|
||||
prompt_for_two_factor(user)
|
||||
end
|
||||
|
@ -89,16 +99,17 @@ module AuthenticatesWithTwoFactor
|
|||
# Authenticate using the response from a U2F (universal 2nd factor) device
|
||||
def authenticate_with_two_factor_via_u2f(user)
|
||||
if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge])
|
||||
# Remove any lingering user data from login
|
||||
clear_two_factor_attempt!
|
||||
|
||||
remember_me(user) if user_params[:remember_me] == '1'
|
||||
sign_in(user, message: :two_factor_authenticated, event: :authentication)
|
||||
handle_two_factor_success(user)
|
||||
else
|
||||
user.increment_failed_attempts!
|
||||
Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=U2F")
|
||||
flash.now[:alert] = _('Authentication via U2F device failed.')
|
||||
prompt_for_two_factor(user)
|
||||
handle_two_factor_failure(user, 'U2F')
|
||||
end
|
||||
end
|
||||
|
||||
def authenticate_with_two_factor_via_webauthn(user)
|
||||
if Webauthn::AuthenticateService.new(user, user_params[:device_response], session[:challenge]).execute
|
||||
handle_two_factor_success(user)
|
||||
else
|
||||
handle_two_factor_failure(user, 'WebAuthn')
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -116,8 +127,39 @@ module AuthenticatesWithTwoFactor
|
|||
sign_requests: sign_requests })
|
||||
end
|
||||
end
|
||||
|
||||
def setup_webauthn_authentication(user)
|
||||
if user.webauthn_registrations.present?
|
||||
|
||||
webauthn_registration_ids = user.webauthn_registrations.pluck(:credential_xid)
|
||||
|
||||
get_options = WebAuthn::Credential.options_for_get(allow: webauthn_registration_ids,
|
||||
user_verification: 'discouraged',
|
||||
extensions: { appid: WebAuthn.configuration.origin })
|
||||
|
||||
session[:credentialRequestOptions] = get_options
|
||||
session[:challenge] = get_options.challenge
|
||||
gon.push(webauthn: { options: get_options.to_json })
|
||||
end
|
||||
end
|
||||
|
||||
# rubocop: enable CodeReuse/ActiveRecord
|
||||
|
||||
def handle_two_factor_success(user)
|
||||
# Remove any lingering user data from login
|
||||
clear_two_factor_attempt!
|
||||
|
||||
remember_me(user) if user_params[:remember_me] == '1'
|
||||
sign_in(user, message: :two_factor_authenticated, event: :authentication)
|
||||
end
|
||||
|
||||
def handle_two_factor_failure(user, method)
|
||||
user.increment_failed_attempts!
|
||||
Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}")
|
||||
flash.now[:alert] = _('Authentication via %{method} device failed.') % { method: method }
|
||||
prompt_for_two_factor(user)
|
||||
end
|
||||
|
||||
def handle_changed_user(user)
|
||||
clear_two_factor_attempt!
|
||||
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
||||
skip_before_action :check_two_factor_requirement
|
||||
before_action do
|
||||
push_frontend_feature_flag(:webauthn)
|
||||
end
|
||||
|
||||
def show
|
||||
unless current_user.two_factor_enabled?
|
||||
|
@ -33,7 +36,12 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
|||
|
||||
@qr_code = build_qr_code
|
||||
@account_string = account_string
|
||||
setup_u2f_registration
|
||||
|
||||
if Feature.enabled?(:webauthn)
|
||||
setup_webauthn_registration
|
||||
else
|
||||
setup_u2f_registration
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
|
@ -48,7 +56,13 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
|||
else
|
||||
@error = _('Invalid pin code')
|
||||
@qr_code = build_qr_code
|
||||
setup_u2f_registration
|
||||
|
||||
if Feature.enabled?(:webauthn)
|
||||
setup_webauthn_registration
|
||||
else
|
||||
setup_u2f_registration
|
||||
end
|
||||
|
||||
render 'show'
|
||||
end
|
||||
end
|
||||
|
@ -56,7 +70,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
|||
# A U2F (universal 2nd factor) device's information is stored after successful
|
||||
# registration, which is then used while 2FA authentication is taking place.
|
||||
def create_u2f
|
||||
@u2f_registration = U2fRegistration.register(current_user, u2f_app_id, u2f_registration_params, session[:challenges])
|
||||
@u2f_registration = U2fRegistration.register(current_user, u2f_app_id, device_registration_params, session[:challenges])
|
||||
|
||||
if @u2f_registration.persisted?
|
||||
session.delete(:challenges)
|
||||
|
@ -68,6 +82,21 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def create_webauthn
|
||||
@webauthn_registration = Webauthn::RegisterService.new(current_user, device_registration_params, session[:challenge]).execute
|
||||
if @webauthn_registration.persisted?
|
||||
session.delete(:challenge)
|
||||
|
||||
redirect_to profile_two_factor_auth_path, notice: s_("Your WebAuthn device was registered!")
|
||||
else
|
||||
@qr_code = build_qr_code
|
||||
|
||||
setup_webauthn_registration
|
||||
|
||||
render :show
|
||||
end
|
||||
end
|
||||
|
||||
def codes
|
||||
Users::UpdateService.new(current_user, user: current_user).execute! do |user|
|
||||
@codes = user.generate_otp_backup_codes!
|
||||
|
@ -112,11 +141,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
|||
# Actual communication is performed using a Javascript API
|
||||
def setup_u2f_registration
|
||||
@u2f_registration ||= U2fRegistration.new
|
||||
@u2f_registrations = current_user.u2f_registrations
|
||||
@registrations = u2f_registrations
|
||||
u2f = U2F::U2F.new(u2f_app_id)
|
||||
|
||||
registration_requests = u2f.registration_requests
|
||||
sign_requests = u2f.authentication_requests(@u2f_registrations.map(&:key_handle))
|
||||
sign_requests = u2f.authentication_requests(current_user.u2f_registrations.map(&:key_handle))
|
||||
session[:challenges] = registration_requests.map(&:challenge)
|
||||
|
||||
gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id,
|
||||
|
@ -124,8 +153,53 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
|||
sign_requests: sign_requests })
|
||||
end
|
||||
|
||||
def u2f_registration_params
|
||||
params.require(:u2f_registration).permit(:device_response, :name)
|
||||
def device_registration_params
|
||||
params.require(:device_registration).permit(:device_response, :name)
|
||||
end
|
||||
|
||||
def setup_webauthn_registration
|
||||
@registrations = webauthn_registrations
|
||||
@webauthn_registration ||= WebauthnRegistration.new
|
||||
|
||||
unless current_user.webauthn_xid
|
||||
current_user.user_detail.update!(webauthn_xid: WebAuthn.generate_user_id)
|
||||
end
|
||||
|
||||
options = webauthn_options
|
||||
session[:challenge] = options.challenge
|
||||
|
||||
gon.push(webauthn: { options: options, app_id: u2f_app_id })
|
||||
end
|
||||
|
||||
# Adds delete path to u2f registrations
|
||||
# to reduce logic in view template
|
||||
def u2f_registrations
|
||||
current_user.u2f_registrations.map do |u2f_registration|
|
||||
{
|
||||
name: u2f_registration.name,
|
||||
created_at: u2f_registration.created_at,
|
||||
delete_path: profile_u2f_registration_path(u2f_registration)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def webauthn_registrations
|
||||
current_user.webauthn_registrations.map do |webauthn_registration|
|
||||
{
|
||||
name: webauthn_registration.name,
|
||||
created_at: webauthn_registration.created_at,
|
||||
delete_path: profile_webauthn_registration_path(webauthn_registration)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def webauthn_options
|
||||
WebAuthn::Credential.options_for_create(
|
||||
user: { id: current_user.webauthn_xid, name: current_user.username },
|
||||
exclude: current_user.webauthn_registrations.map { |c| c.credential_xid },
|
||||
authenticator_selection: { user_verification: 'discouraged' },
|
||||
rp: { name: 'GitLab' }
|
||||
)
|
||||
end
|
||||
|
||||
def groups_notification(groups)
|
||||
|
@ -133,6 +207,6 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
|||
leave_group_links = groups.map { |group| view_context.link_to (s_("leave %{group_name}") % { group_name: group.full_name }), leave_group_members_path(group), remote: false, method: :delete}.to_sentence
|
||||
|
||||
s_(%{The group settings for %{group_links} require you to enable Two-Factor Authentication for your account. You can %{leave_group_links}.})
|
||||
.html_safe % { group_links: group_links.html_safe, leave_group_links: leave_group_links.html_safe }
|
||||
.html_safe % { group_links: group_links.html_safe, leave_group_links: leave_group_links.html_safe }
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Profiles::WebauthnRegistrationsController < Profiles::ApplicationController
|
||||
def destroy
|
||||
webauthn_registration = current_user.webauthn_registrations.find(params[:id])
|
||||
webauthn_registration.destroy
|
||||
|
||||
redirect_to profile_two_factor_auth_path, status: :found, notice: _("Successfully deleted WebAuthn device.")
|
||||
end
|
||||
end
|
|
@ -6,6 +6,9 @@ class ProfilesController < Profiles::ApplicationController
|
|||
before_action :user
|
||||
before_action :authorize_change_username!, only: :update_username
|
||||
skip_before_action :require_email, only: [:show, :update]
|
||||
before_action do
|
||||
push_frontend_feature_flag(:webauthn)
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
|
|
@ -43,6 +43,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
|
|||
:discussion_locked,
|
||||
label_ids: [],
|
||||
assignee_ids: [],
|
||||
reviewer_ids: [],
|
||||
update_task: [:index, :checked, :line_number, :line_source]
|
||||
]
|
||||
end
|
||||
|
|
|
@ -42,7 +42,7 @@ class ProjectsController < Projects::ApplicationController
|
|||
|
||||
before_action only: [:edit] do
|
||||
push_frontend_feature_flag(:service_desk_custom_address, @project)
|
||||
push_frontend_feature_flag(:approval_suggestions, @project)
|
||||
push_frontend_feature_flag(:approval_suggestions, @project, default_enabled: true)
|
||||
end
|
||||
|
||||
layout :determine_layout
|
||||
|
|
|
@ -29,6 +29,9 @@ class SessionsController < Devise::SessionsController
|
|||
before_action :save_failed_login, if: :action_new_and_failed_login?
|
||||
before_action :load_recaptcha
|
||||
before_action :set_invite_params, only: [:new]
|
||||
before_action do
|
||||
push_frontend_feature_flag(:webauthn)
|
||||
end
|
||||
|
||||
after_action :log_failed_login, if: :action_new_and_failed_login?
|
||||
after_action :verify_known_sign_in, only: [:create]
|
||||
|
@ -293,7 +296,9 @@ class SessionsController < Devise::SessionsController
|
|||
def authentication_method
|
||||
if user_params[:otp_attempt]
|
||||
"two-factor"
|
||||
elsif user_params[:device_response]
|
||||
elsif user_params[:device_response] && Feature.enabled?(:webauthn)
|
||||
"two-factor-via-webauthn-device"
|
||||
elsif user_params[:device_response] && !Feature.enabled?(:webauthn)
|
||||
"two-factor-via-u2f-device"
|
||||
else
|
||||
"standard"
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
class IssuableSeverityEnum < BaseEnum
|
||||
graphql_name 'IssuableSeverity'
|
||||
description 'Incident severity'
|
||||
|
||||
::IssuableSeverity.severities.keys.each do |severity|
|
||||
value severity.upcase, value: severity, description: "#{severity.titleize} severity"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -105,6 +105,9 @@ module Types
|
|||
Types::AlertManagement::AlertType,
|
||||
null: true,
|
||||
description: 'Alert associated to this issue'
|
||||
|
||||
field :severity, Types::IssuableSeverityEnum, null: true,
|
||||
description: 'Severity level of the incident'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -100,20 +100,43 @@ module DiffHelper
|
|||
end
|
||||
|
||||
def submodule_link(blob, ref, repository = @repository)
|
||||
project_url, tree_url = submodule_links(blob, ref, repository)
|
||||
commit_id = if tree_url.nil?
|
||||
Commit.truncate_sha(blob.id)
|
||||
else
|
||||
link_to Commit.truncate_sha(blob.id), tree_url
|
||||
end
|
||||
urls = submodule_links(blob, ref, repository)
|
||||
|
||||
folder_name = truncate(blob.name, length: 40)
|
||||
folder_name = link_to(folder_name, urls.web) if urls&.web
|
||||
|
||||
commit_id = Commit.truncate_sha(blob.id)
|
||||
commit_id = link_to(commit_id, urls.tree) if urls&.tree
|
||||
|
||||
[
|
||||
content_tag(:span, link_to(truncate(blob.name, length: 40), project_url)),
|
||||
content_tag(:span, folder_name),
|
||||
'@',
|
||||
content_tag(:span, commit_id, class: 'commit-sha')
|
||||
].join(' ').html_safe
|
||||
end
|
||||
|
||||
def submodule_diff_compare_link(diff_file)
|
||||
compare_url = submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository, diff_file)&.compare
|
||||
|
||||
link = ""
|
||||
|
||||
if compare_url
|
||||
|
||||
link_text = [
|
||||
_('Compare'),
|
||||
' ',
|
||||
content_tag(:span, Commit.truncate_sha(diff_file.old_blob.id), class: 'commit-sha'),
|
||||
'...',
|
||||
content_tag(:span, Commit.truncate_sha(diff_file.blob.id), class: 'commit-sha')
|
||||
].join('').html_safe
|
||||
|
||||
tooltip = _('Compare submodule commit revisions')
|
||||
link = content_tag(:span, link_to(link_text, compare_url, class: 'btn has-tooltip', title: tooltip), class: 'submodule-compare')
|
||||
end
|
||||
|
||||
link
|
||||
end
|
||||
|
||||
def diff_file_blob_raw_url(diff_file, only_path: false)
|
||||
project_raw_url(@project, tree_join(diff_file.content_sha, diff_file.file_path), only_path: only_path)
|
||||
end
|
||||
|
|
|
@ -6,12 +6,12 @@ module SubmoduleHelper
|
|||
VALID_SUBMODULE_PROTOCOLS = %w[http https git ssh].freeze
|
||||
|
||||
# links to files listing for submodule if submodule is a project on this server
|
||||
def submodule_links(submodule_item, ref = nil, repository = @repository)
|
||||
repository.submodule_links.for(submodule_item, ref)
|
||||
def submodule_links(submodule_item, ref = nil, repository = @repository, diff_file = nil)
|
||||
repository.submodule_links.for(submodule_item, ref, diff_file)
|
||||
end
|
||||
|
||||
def submodule_links_for_url(submodule_item_id, url, repository)
|
||||
return [nil, nil] unless url
|
||||
def submodule_links_for_url(submodule_item_id, url, repository, old_submodule_item_id = nil)
|
||||
return [nil, nil, nil] unless url
|
||||
|
||||
if url == '.' || url == './'
|
||||
url = File.join(Gitlab.config.gitlab.url, repository.project.full_path)
|
||||
|
@ -34,21 +34,24 @@ module SubmoduleHelper
|
|||
project.sub!(/\.git\z/, '')
|
||||
|
||||
if self_url?(url, namespace, project)
|
||||
[url_helpers.namespace_project_path(namespace, project),
|
||||
url_helpers.namespace_project_tree_path(namespace, project, submodule_item_id)]
|
||||
[
|
||||
url_helpers.namespace_project_path(namespace, project),
|
||||
url_helpers.namespace_project_tree_path(namespace, project, submodule_item_id),
|
||||
(url_helpers.namespace_project_compare_path(namespace, project, to: submodule_item_id, from: old_submodule_item_id) if old_submodule_item_id)
|
||||
]
|
||||
elsif relative_self_url?(url)
|
||||
relative_self_links(url, submodule_item_id, repository.project)
|
||||
relative_self_links(url, submodule_item_id, old_submodule_item_id, repository.project)
|
||||
elsif gist_github_dot_com_url?(url)
|
||||
gist_github_com_tree_links(namespace, project, submodule_item_id)
|
||||
elsif github_dot_com_url?(url)
|
||||
github_com_tree_links(namespace, project, submodule_item_id)
|
||||
github_com_tree_links(namespace, project, submodule_item_id, old_submodule_item_id)
|
||||
elsif gitlab_dot_com_url?(url)
|
||||
gitlab_com_tree_links(namespace, project, submodule_item_id)
|
||||
gitlab_com_tree_links(namespace, project, submodule_item_id, old_submodule_item_id)
|
||||
else
|
||||
[sanitize_submodule_url(url), nil]
|
||||
[sanitize_submodule_url(url), nil, nil]
|
||||
end
|
||||
else
|
||||
[sanitize_submodule_url(url), nil]
|
||||
[sanitize_submodule_url(url), nil, nil]
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -79,22 +82,30 @@ module SubmoduleHelper
|
|||
url.start_with?('../', './')
|
||||
end
|
||||
|
||||
def gitlab_com_tree_links(namespace, project, commit)
|
||||
def gitlab_com_tree_links(namespace, project, commit, old_commit)
|
||||
base = ['https://gitlab.com/', namespace, '/', project].join('')
|
||||
[base, [base, '/-/tree/', commit].join('')]
|
||||
[
|
||||
base,
|
||||
[base, '/-/tree/', commit].join(''),
|
||||
([base, '/-/compare/', old_commit, '...', commit].join('') if old_commit)
|
||||
]
|
||||
end
|
||||
|
||||
def gist_github_com_tree_links(namespace, project, commit)
|
||||
base = ['https://gist.github.com/', namespace, '/', project].join('')
|
||||
[base, [base, commit].join('/')]
|
||||
[base, [base, commit].join('/'), nil]
|
||||
end
|
||||
|
||||
def github_com_tree_links(namespace, project, commit)
|
||||
def github_com_tree_links(namespace, project, commit, old_commit)
|
||||
base = ['https://github.com/', namespace, '/', project].join('')
|
||||
[base, [base, '/tree/', commit].join('')]
|
||||
[
|
||||
base,
|
||||
[base, '/tree/', commit].join(''),
|
||||
([base, '/compare/', old_commit, '...', commit].join('') if old_commit)
|
||||
]
|
||||
end
|
||||
|
||||
def relative_self_links(relative_path, commit, project)
|
||||
def relative_self_links(relative_path, commit, old_commit, project)
|
||||
relative_path = relative_path.rstrip
|
||||
absolute_project_path = "/" + project.full_path
|
||||
|
||||
|
@ -107,7 +118,7 @@ module SubmoduleHelper
|
|||
target_namespace_path = File.dirname(submodule_project_path)
|
||||
|
||||
if target_namespace_path == '/' || target_namespace_path.start_with?(absolute_project_path)
|
||||
return [nil, nil]
|
||||
return [nil, nil, nil]
|
||||
end
|
||||
|
||||
target_namespace_path.sub!(%r{^/}, '')
|
||||
|
@ -116,10 +127,11 @@ module SubmoduleHelper
|
|||
begin
|
||||
[
|
||||
url_helpers.namespace_project_path(target_namespace_path, submodule_base),
|
||||
url_helpers.namespace_project_tree_path(target_namespace_path, submodule_base, commit)
|
||||
url_helpers.namespace_project_tree_path(target_namespace_path, submodule_base, commit),
|
||||
(url_helpers.namespace_project_compare_path(target_namespace_path, submodule_base, to: commit, from: old_commit) if old_commit)
|
||||
]
|
||||
rescue ActionController::UrlGenerationError
|
||||
[nil, nil]
|
||||
[nil, nil, nil]
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -108,6 +108,14 @@ module Ci
|
|||
Ci::BuildTraceChunkFlushWorker.perform_async(id)
|
||||
end
|
||||
|
||||
def persisted?
|
||||
!redis?
|
||||
end
|
||||
|
||||
def live?
|
||||
redis?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_data
|
||||
|
@ -170,14 +178,6 @@ module Ci
|
|||
save! if changed?
|
||||
end
|
||||
|
||||
def persisted?
|
||||
!redis?
|
||||
end
|
||||
|
||||
def live?
|
||||
redis?
|
||||
end
|
||||
|
||||
def full?
|
||||
size == CHUNK_SIZE
|
||||
end
|
||||
|
|
|
@ -262,7 +262,7 @@ module Ci
|
|||
|
||||
scope :internal, -> { where(source: internal_sources) }
|
||||
scope :no_child, -> { where.not(source: :parent_pipeline) }
|
||||
scope :ci_sources, -> { where(config_source: Enums::Ci::Pipeline.ci_config_sources_values) }
|
||||
scope :ci_sources, -> { where(source: Enums::Ci::Pipeline.ci_sources.values) }
|
||||
scope :for_user, -> (user) { where(user: user) }
|
||||
scope :for_sha, -> (sha) { where(sha: sha) }
|
||||
scope :for_source_sha, -> (source_sha) { where(source_sha: source_sha) }
|
||||
|
@ -1033,7 +1033,11 @@ module Ci
|
|||
end
|
||||
|
||||
def cacheable?
|
||||
Enums::Ci::Pipeline.ci_config_sources.key?(config_source.to_sym)
|
||||
!dangling?
|
||||
end
|
||||
|
||||
def dangling?
|
||||
Enums::Ci::Pipeline.dangling_sources.key?(source.to_sym)
|
||||
end
|
||||
|
||||
def source_ref_path
|
||||
|
|
|
@ -36,6 +36,23 @@ module Enums
|
|||
}
|
||||
end
|
||||
|
||||
# Dangling sources are those events that generate pipelines for which
|
||||
# we don't want to directly affect the ref CI status.
|
||||
# - when a webide pipeline fails it does not change the ref CI status to failed
|
||||
# - when a child pipeline (from parent_pipeline source) fails it affects its
|
||||
# parent pipeline. It's up to the parent to affect the ref CI status
|
||||
# - when an ondemand_dast_scan pipeline runs it is for testing purpose and should
|
||||
# not affect the ref CI status.
|
||||
def self.dangling_sources
|
||||
sources.slice(:webide, :parent_pipeline, :ondemand_dast_scan)
|
||||
end
|
||||
|
||||
# CI sources are those pipeline events that affect the CI status of the ref
|
||||
# they run for. By definition it excludes dangling pipelines.
|
||||
def self.ci_sources
|
||||
sources.except(*dangling_sources.keys)
|
||||
end
|
||||
|
||||
# Returns the `Hash` to use for creating the `config_sources` enum for
|
||||
# `Ci::Pipeline`.
|
||||
def self.config_sources
|
||||
|
@ -50,24 +67,6 @@ module Enums
|
|||
parameter_source: 7
|
||||
}
|
||||
end
|
||||
|
||||
def self.ci_config_sources
|
||||
config_sources.slice(
|
||||
:unknown_source,
|
||||
:repository_source,
|
||||
:auto_devops_source,
|
||||
:remote_source,
|
||||
:external_project_source
|
||||
)
|
||||
end
|
||||
|
||||
def self.ci_config_sources_values
|
||||
ci_config_sources.values
|
||||
end
|
||||
|
||||
def self.non_ci_config_source_values
|
||||
config_sources.values - ci_config_sources.values
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -177,6 +177,10 @@ module Issuable
|
|||
assignees.count > 1
|
||||
end
|
||||
|
||||
def allows_reviewers?
|
||||
false
|
||||
end
|
||||
|
||||
def supports_time_tracking?
|
||||
is_a?(TimeTrackable) && !incident?
|
||||
end
|
||||
|
@ -185,6 +189,12 @@ module Issuable
|
|||
is_a?(Issue) && super
|
||||
end
|
||||
|
||||
def severity
|
||||
return IssuableSeverity::DEFAULT unless incident?
|
||||
|
||||
issuable_severity&.severity || IssuableSeverity::DEFAULT
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def description_max_length_for_new_records_is_valid
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class IssuableSeverity < ApplicationRecord
|
||||
DEFAULT = 'unknown'
|
||||
|
||||
belongs_to :issue
|
||||
|
||||
validates :issue, presence: true, uniqueness: true
|
||||
|
|
|
@ -60,6 +60,7 @@ class Issue < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
has_one :issuable_severity
|
||||
has_one :sentry_issue
|
||||
has_one :alert_management_alert, class_name: 'AlertManagement::Alert'
|
||||
has_and_belongs_to_many :self_managed_prometheus_alert_events, join_table: :issues_self_managed_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany
|
||||
|
|
|
@ -19,6 +19,7 @@ class LfsObjectsProject < ApplicationRecord
|
|||
}
|
||||
|
||||
scope :project_id_in, ->(ids) { where(project_id: ids) }
|
||||
scope :lfs_object_in, -> (lfs_objects) { where(lfs_object: lfs_objects) }
|
||||
|
||||
private
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ class MembersPreloader
|
|||
ActiveRecord::Associations::Preloader.new.preload(members, :source)
|
||||
ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :status)
|
||||
ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :u2f_registrations)
|
||||
ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :webauthn_registrations)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -955,8 +955,9 @@ class MergeRequest < ApplicationRecord
|
|||
self.class.wip_title(self.title)
|
||||
end
|
||||
|
||||
def mergeable?(skip_ci_check: false)
|
||||
return false unless mergeable_state?(skip_ci_check: skip_ci_check)
|
||||
def mergeable?(skip_ci_check: false, skip_discussions_check: false)
|
||||
return false unless mergeable_state?(skip_ci_check: skip_ci_check,
|
||||
skip_discussions_check: skip_discussions_check)
|
||||
|
||||
check_mergeability
|
||||
|
||||
|
@ -1658,6 +1659,10 @@ class MergeRequest < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def allows_reviewers?
|
||||
Feature.enabled?(:merge_request_reviewers, project)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def with_rebase_lock
|
||||
|
|
|
@ -280,10 +280,9 @@ class Project < ApplicationRecord
|
|||
# The relation :all_pipelines is intended to be used when we want to get the
|
||||
# whole list of pipelines associated to the project
|
||||
has_many :all_pipelines, class_name: 'Ci::Pipeline', inverse_of: :project
|
||||
# The relation :ci_pipelines is intended to be used when we want to get only
|
||||
# those pipeline which are directly related to CI. There are
|
||||
# other pipelines, like webide ones, that we won't retrieve
|
||||
# if we use this relation.
|
||||
# The relation :ci_pipelines includes all those that directly contribute to the
|
||||
# latest status of a ref. This does not include dangling pipelines such as those
|
||||
# from webide, child pipelines, etc.
|
||||
has_many :ci_pipelines,
|
||||
-> { ci_sources },
|
||||
class_name: 'Ci::Pipeline',
|
||||
|
@ -2709,9 +2708,11 @@ class Project < ApplicationRecord
|
|||
end
|
||||
|
||||
def oids(objects, oids: [])
|
||||
collection = oids.any? ? objects.where(oid: oids) : objects
|
||||
objects = objects.where(oid: oids) if oids.any?
|
||||
|
||||
collection.pluck(:oid)
|
||||
[].tap do |out|
|
||||
objects.each_batch { |relation| out.concat(relation.pluck(:oid)) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -113,6 +113,7 @@ class User < ApplicationRecord
|
|||
has_many :personal_access_tokens, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
|
||||
has_many :identities, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent
|
||||
has_many :u2f_registrations, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
|
||||
has_many :webauthn_registrations
|
||||
has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
|
||||
has_one :user_synced_attributes_metadata, autosave: true
|
||||
has_one :aws_role, class_name: 'Aws::Role'
|
||||
|
@ -286,6 +287,7 @@ class User < ApplicationRecord
|
|||
delegate :path, to: :namespace, allow_nil: true, prefix: true
|
||||
delegate :job_title, :job_title=, to: :user_detail, allow_nil: true
|
||||
delegate :bio, :bio=, :bio_html, to: :user_detail, allow_nil: true
|
||||
delegate :webauthn_xid, :webauthn_xid=, to: :user_detail, allow_nil: true
|
||||
|
||||
accepts_nested_attributes_for :user_preference, update_only: true
|
||||
accepts_nested_attributes_for :user_detail, update_only: true
|
||||
|
@ -434,14 +436,21 @@ class User < ApplicationRecord
|
|||
FROM u2f_registrations AS u2f
|
||||
WHERE u2f.user_id = users.id
|
||||
) OR users.otp_required_for_login = ?
|
||||
OR
|
||||
EXISTS (
|
||||
SELECT *
|
||||
FROM webauthn_registrations AS webauthn
|
||||
WHERE webauthn.user_id = users.id
|
||||
)
|
||||
SQL
|
||||
|
||||
where(with_u2f_registrations, true)
|
||||
end
|
||||
|
||||
def self.without_two_factor
|
||||
joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id")
|
||||
.where("u2f.id IS NULL AND users.otp_required_for_login = ?", false)
|
||||
joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id
|
||||
LEFT OUTER JOIN webauthn_registrations AS webauthn ON webauthn.user_id = users.id")
|
||||
.where("u2f.id IS NULL AND webauthn.id IS NULL AND users.otp_required_for_login = ?", false)
|
||||
end
|
||||
|
||||
#
|
||||
|
@ -754,11 +763,12 @@ class User < ApplicationRecord
|
|||
otp_backup_codes: nil
|
||||
)
|
||||
self.u2f_registrations.destroy_all # rubocop: disable Cop/DestroyAll
|
||||
self.webauthn_registrations.destroy_all # rubocop: disable Cop/DestroyAll
|
||||
end
|
||||
end
|
||||
|
||||
def two_factor_enabled?
|
||||
two_factor_otp_enabled? || two_factor_u2f_enabled?
|
||||
two_factor_otp_enabled? || two_factor_webauthn_u2f_enabled?
|
||||
end
|
||||
|
||||
def two_factor_otp_enabled?
|
||||
|
@ -773,6 +783,16 @@ class User < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def two_factor_webauthn_u2f_enabled?
|
||||
two_factor_u2f_enabled? || two_factor_webauthn_enabled?
|
||||
end
|
||||
|
||||
def two_factor_webauthn_enabled?
|
||||
return false unless Feature.enabled?(:webauthn)
|
||||
|
||||
(webauthn_registrations.loaded? && webauthn_registrations.any?) || (!webauthn_registrations.loaded? && webauthn_registrations.exists?)
|
||||
end
|
||||
|
||||
def namespace_move_dir_allowed
|
||||
if namespace&.any_project_has_container_registry_tags?
|
||||
errors.add(:username, _('cannot be changed if a personal project has container registry tags.'))
|
||||
|
|
|
@ -12,11 +12,23 @@ class DiffFileBaseEntity < Grape::Entity
|
|||
expose :submodule?, as: :submodule
|
||||
|
||||
expose :submodule_link do |diff_file, options|
|
||||
memoized_submodule_links(diff_file, options).first
|
||||
memoized_submodule_links(diff_file, options)&.web
|
||||
end
|
||||
|
||||
expose :submodule_tree_url do |diff_file|
|
||||
memoized_submodule_links(diff_file, options).last
|
||||
memoized_submodule_links(diff_file, options)&.tree
|
||||
end
|
||||
|
||||
expose :submodule_compare do |diff_file|
|
||||
url = memoized_submodule_links(diff_file, options)&.compare
|
||||
|
||||
next unless url
|
||||
|
||||
{
|
||||
url: url,
|
||||
old_sha: diff_file.old_blob&.id,
|
||||
new_sha: diff_file.blob&.id
|
||||
}
|
||||
end
|
||||
|
||||
expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file|
|
||||
|
@ -96,11 +108,9 @@ class DiffFileBaseEntity < Grape::Entity
|
|||
|
||||
def memoized_submodule_links(diff_file, options)
|
||||
strong_memoize(:submodule_links) do
|
||||
if diff_file.submodule?
|
||||
options[:submodule_links].for(diff_file.blob, diff_file.content_sha)
|
||||
else
|
||||
[]
|
||||
end
|
||||
next unless diff_file.submodule?
|
||||
|
||||
options[:submodule_links].for(diff_file.blob, diff_file.content_sha, diff_file)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ class MergeRequestBasicEntity < Grape::Entity
|
|||
expose :milestone, using: API::Entities::Milestone
|
||||
expose :labels, using: LabelEntity
|
||||
expose :assignees, using: API::Entities::UserBasic
|
||||
expose :reviewers, if: -> (m) { m.allows_reviewers? }, using: API::Entities::UserBasic
|
||||
expose :task_status, :task_status_short
|
||||
expose :lock_version, :lock_version
|
||||
end
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class MergeRequestReviewerEntity < ::API::Entities::UserBasic
|
||||
expose :can_merge do |reviewer, options|
|
||||
options[:merge_request]&.can_be_merged_by?(reviewer)
|
||||
end
|
||||
end
|
||||
|
||||
MergeRequestReviewerEntity.prepend_if_ee('EE::MergeRequestReviewerEntity')
|
|
@ -4,4 +4,8 @@ class MergeRequestSidebarExtrasEntity < IssuableSidebarExtrasEntity
|
|||
expose :assignees do |merge_request|
|
||||
MergeRequestAssigneeEntity.represent(merge_request.assignees, merge_request: merge_request)
|
||||
end
|
||||
|
||||
expose :reviewers, if: -> (m) { m.allows_reviewers? } do |merge_request|
|
||||
MergeRequestReviewerEntity.represent(merge_request.reviewers, merge_request: merge_request)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -46,7 +46,7 @@ class IssuableBaseService < BaseService
|
|||
params[:assignee_ids] = params[:assignee_ids].first(1)
|
||||
end
|
||||
|
||||
assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) }
|
||||
assignee_ids = params[:assignee_ids].select { |assignee_id| user_can_read?(issuable, assignee_id) }
|
||||
|
||||
if params[:assignee_ids].map(&:to_s) == [IssuableFinder::Params::NONE]
|
||||
params[:assignee_ids] = []
|
||||
|
@ -57,15 +57,15 @@ class IssuableBaseService < BaseService
|
|||
end
|
||||
end
|
||||
|
||||
def assignee_can_read?(issuable, assignee_id)
|
||||
new_assignee = User.find_by_id(assignee_id)
|
||||
def user_can_read?(issuable, user_id)
|
||||
user = User.find_by_id(user_id)
|
||||
|
||||
return false unless new_assignee
|
||||
return false unless user
|
||||
|
||||
ability_name = :"read_#{issuable.to_ability_name}"
|
||||
resource = issuable.persisted? ? issuable : project
|
||||
|
||||
can?(new_assignee, ability_name, resource)
|
||||
can?(user, ability_name, resource)
|
||||
end
|
||||
|
||||
def filter_milestone
|
||||
|
|
|
@ -97,6 +97,28 @@ module MergeRequests
|
|||
unless merge_request.can_allow_collaboration?(current_user)
|
||||
params.delete(:allow_collaboration)
|
||||
end
|
||||
|
||||
filter_reviewer(merge_request)
|
||||
end
|
||||
|
||||
def filter_reviewer(merge_request)
|
||||
return if params[:reviewer_ids].blank?
|
||||
|
||||
unless can_admin_issuable?(merge_request) && merge_request.allows_reviewers?
|
||||
params.delete(:reviewer_ids)
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
reviewer_ids = params[:reviewer_ids].select { |reviewer_id| user_can_read?(merge_request, reviewer_id) }
|
||||
|
||||
if params[:reviewer_ids].map(&:to_s) == [IssuableFinder::Params::NONE]
|
||||
params[:reviewer_ids] = []
|
||||
elsif reviewer_ids.any?
|
||||
params[:reviewer_ids] = reviewer_ids
|
||||
else
|
||||
params.delete(:reviewer_ids)
|
||||
end
|
||||
end
|
||||
|
||||
def merge_request_metrics_service(merge_request)
|
||||
|
|
|
@ -10,13 +10,14 @@ module MergeRequests
|
|||
class MergeService < MergeRequests::MergeBaseService
|
||||
delegate :merge_jid, :state, to: :@merge_request
|
||||
|
||||
def execute(merge_request)
|
||||
def execute(merge_request, options = {})
|
||||
if project.merge_requests_ff_only_enabled && !self.is_a?(FfMergeService)
|
||||
FfMergeService.new(project, current_user, params).execute(merge_request)
|
||||
return
|
||||
end
|
||||
|
||||
@merge_request = merge_request
|
||||
@options = options
|
||||
|
||||
validate!
|
||||
|
||||
|
@ -55,7 +56,7 @@ module MergeRequests
|
|||
error =
|
||||
if @merge_request.should_be_rebased?
|
||||
'Only fast-forward merge is allowed for your project. Please update your source branch'
|
||||
elsif !@merge_request.mergeable?
|
||||
elsif !@merge_request.mergeable?(skip_discussions_check: @options[:skip_discussions_check])
|
||||
'Merge request is not mergeable'
|
||||
elsif !@merge_request.squash && project.squash_always?
|
||||
'This project requires squashing commits when merge requests are accepted.'
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Webauthn
|
||||
class AuthenticateService < BaseService
|
||||
def initialize(user, device_response, challenge)
|
||||
@user = user
|
||||
@device_response = device_response
|
||||
@challenge = challenge
|
||||
end
|
||||
|
||||
def execute
|
||||
parsed_device_response = Gitlab::Json.parse(@device_response)
|
||||
|
||||
# appid is set for legacy U2F devices, will be used in a future iteration
|
||||
# rp_id = @app_id
|
||||
# unless parsed_device_response['clientExtensionResults'] && parsed_device_response['clientExtensionResults']['appid']
|
||||
# rp_id = URI(@app_id).host
|
||||
# end
|
||||
|
||||
webauthn_credential = WebAuthn::Credential.from_get(parsed_device_response)
|
||||
encoded_raw_id = Base64.strict_encode64(webauthn_credential.raw_id)
|
||||
stored_webauthn_credential = @user.webauthn_registrations.find_by_credential_xid(encoded_raw_id)
|
||||
|
||||
encoder = WebAuthn.configuration.encoder
|
||||
|
||||
if stored_webauthn_credential &&
|
||||
validate_webauthn_credential(webauthn_credential) &&
|
||||
verify_webauthn_credential(webauthn_credential, stored_webauthn_credential, @challenge, encoder)
|
||||
|
||||
stored_webauthn_credential.update!(counter: webauthn_credential.sign_count)
|
||||
return true
|
||||
end
|
||||
|
||||
false
|
||||
rescue JSON::ParserError, WebAuthn::SignCountVerificationError, WebAuthn::Error
|
||||
false
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that webauthn_credential is syntactically valid
|
||||
#
|
||||
# duplicated from WebAuthn::PublicKeyCredential#verify
|
||||
# which can't be used here as we need to call WebAuthn::AuthenticatorAssertionResponse#verify instead
|
||||
# (which is done in #verify_webauthn_credential)
|
||||
def validate_webauthn_credential(webauthn_credential)
|
||||
webauthn_credential.type == WebAuthn::TYPE_PUBLIC_KEY &&
|
||||
webauthn_credential.raw_id && webauthn_credential.id &&
|
||||
webauthn_credential.raw_id == WebAuthn.standard_encoder.decode(webauthn_credential.id)
|
||||
end
|
||||
|
||||
##
|
||||
# Verifies that webauthn_credential matches stored_credential with the given challenge
|
||||
#
|
||||
def verify_webauthn_credential(webauthn_credential, stored_credential, challenge, encoder)
|
||||
webauthn_credential.response.verify(
|
||||
encoder.decode(challenge),
|
||||
public_key: encoder.decode(stored_credential.public_key),
|
||||
sign_count: stored_credential.counter)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Webauthn
|
||||
class RegisterService < BaseService
|
||||
def initialize(user, params, challenge)
|
||||
@user = user
|
||||
@params = params
|
||||
@challenge = challenge
|
||||
end
|
||||
|
||||
def execute
|
||||
registration = WebauthnRegistration.new
|
||||
|
||||
begin
|
||||
webauthn_credential = WebAuthn::Credential.from_create(Gitlab::Json.parse(@params[:device_response]))
|
||||
webauthn_credential.verify(@challenge)
|
||||
|
||||
registration.update(
|
||||
credential_xid: Base64.strict_encode64(webauthn_credential.raw_id),
|
||||
public_key: webauthn_credential.public_key,
|
||||
counter: webauthn_credential.sign_count,
|
||||
name: @params[:name],
|
||||
user: @user
|
||||
)
|
||||
rescue JSON::ParserError
|
||||
registration.errors.add(:base, _('Your WebAuthn device did not send a valid JSON response.'))
|
||||
rescue WebAuthn::Error => e
|
||||
registration.errors.add(:base, e.message)
|
||||
end
|
||||
|
||||
registration
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,4 +1,4 @@
|
|||
= form_tag(admin_session_path, { method: :post, class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if current_user.two_factor_u2f_enabled?}" }) do
|
||||
= form_tag(admin_session_path, { method: :post, class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if current_user.two_factor_webauthn_u2f_enabled?}" }) do
|
||||
.form-group
|
||||
= label_tag :user_otp_attempt, _('Two-Factor Authentication code')
|
||||
= text_field_tag 'user[otp_attempt]', nil, class: 'form-control', required: true, autofocus: true, autocomplete: 'off', title: _('This field is required.')
|
||||
|
|
|
@ -11,5 +11,5 @@
|
|||
.login-body
|
||||
- if current_user.two_factor_otp_enabled?
|
||||
= render 'admin/sessions/two_factor_otp'
|
||||
- if current_user.two_factor_u2f_enabled?
|
||||
= render 'u2f/authenticate', render_remember_me: false, target_path: admin_session_path
|
||||
- if current_user.two_factor_webauthn_u2f_enabled?
|
||||
= render 'authentication/authenticate', render_remember_me: false, target_path: admin_session_path
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
%script#js-authenticate-token-2fa-error{ type: "text/template" }
|
||||
%div
|
||||
%p <%= error_message %> (#{_("error code:")} <%= error_code %>)
|
||||
%p <%= error_message %> (<%= error_name %>)
|
||||
%a.btn.btn-block.btn-warning#js-token-2fa-try-again= _("Try again?")
|
||||
|
||||
%script#js-authenticate-token-2fa-authenticated{ type: "text/template" }
|
|
@ -0,0 +1,37 @@
|
|||
#js-register-token-2fa
|
||||
|
||||
-# haml-lint:disable InlineJavaScript
|
||||
%script#js-register-2fa-message{ type: "text/template" }
|
||||
%p <%= message %>
|
||||
|
||||
%script#js-register-token-2fa-setup{ type: "text/template" }
|
||||
- if current_user.two_factor_otp_enabled?
|
||||
.row.gl-mb-3
|
||||
.col-md-5
|
||||
%button#js-setup-token-2fa-device.btn.btn-info= _("Set up new device")
|
||||
.col-md-7
|
||||
%p= _("Your device needs to be set up. Plug it in (if needed) and click the button on the left.")
|
||||
- else
|
||||
.row.gl-mb-3
|
||||
.col-md-4
|
||||
%button#js-setup-token-2fa-device.btn.btn-info.btn-block{ disabled: true }= _("Set up new device")
|
||||
.col-md-8
|
||||
%p= _("You need to register a two-factor authentication app before you can set up a device.")
|
||||
|
||||
%script#js-register-token-2fa-error{ type: "text/template" }
|
||||
%div
|
||||
%p
|
||||
%span <%= error_message %> (<%= error_name %>)
|
||||
%a.btn.btn-warning#js-token-2fa-try-again= _("Try again?")
|
||||
|
||||
%script#js-register-token-2fa-registered{ type: "text/template" }
|
||||
.row.gl-mb-3
|
||||
.col-md-12
|
||||
%p= _("Your device was successfully set up! Give it a name and register it with the GitLab server.")
|
||||
= form_tag(target_path, method: :post) do
|
||||
.row.gl-mb-3
|
||||
.col-md-3
|
||||
= text_field_tag 'device_registration[name]', nil, class: 'form-control', placeholder: _("Pick a name")
|
||||
.col-md-3
|
||||
= hidden_field_tag 'device_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
|
||||
= submit_tag _("Register device"), class: "btn btn-success"
|
|
@ -3,7 +3,7 @@
|
|||
.login-box
|
||||
.login-body
|
||||
- if @user.two_factor_otp_enabled?
|
||||
= form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_u2f_enabled?}" }) do |f|
|
||||
= form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_u2f_enabled?}" }) do |f|
|
||||
- resource_params = params[resource_name].presence || params
|
||||
= f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
|
||||
%div
|
||||
|
@ -12,6 +12,5 @@
|
|||
%p.form-text.text-muted.hint Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.
|
||||
.prepend-top-20
|
||||
= f.submit "Verify code", class: "btn btn-success", data: { qa_selector: 'verify_code_button' }
|
||||
|
||||
- if @user.two_factor_u2f_enabled?
|
||||
= render "u2f/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path
|
||||
- if @user.two_factor_webauthn_u2f_enabled?
|
||||
= render "authentication/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
- page_title _('Two-Factor Authentication'), _('Account')
|
||||
- add_to_breadcrumbs(_('Two-Factor Authentication'), profile_account_path)
|
||||
- @content_class = "limit-container-width" unless fluid_layout
|
||||
- webauthn_enabled = Feature.enabled?(:webauthn)
|
||||
|
||||
.js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path }
|
||||
.row.gl-mt-3
|
||||
|
@ -18,7 +19,7 @@
|
|||
%div
|
||||
= link_to _('Disable two-factor authentication'), profile_two_factor_auth_path,
|
||||
method: :delete,
|
||||
data: { confirm: _('Are you sure? This will invalidate your registered applications and U2F devices.') },
|
||||
data: { confirm: webauthn_enabled ? _('Are you sure? This will invalidate your registered applications and U2F / WebAuthn devices.') : _('Are you sure? This will invalidate your registered applications and U2F devices.') },
|
||||
class: 'btn btn-danger gl-mr-3'
|
||||
= form_tag codes_profile_two_factor_auth_path, {style: 'display: inline-block', method: :post} do |f|
|
||||
= submit_tag _('Regenerate recovery codes'), class: 'btn'
|
||||
|
@ -58,22 +59,35 @@
|
|||
.row.gl-mt-3
|
||||
.col-lg-4
|
||||
%h4.gl-mt-0
|
||||
= _('Register Universal Two-Factor (U2F) Device')
|
||||
- if webauthn_enabled
|
||||
= _('Register WebAuthn Device')
|
||||
- else
|
||||
= _('Register Universal Two-Factor (U2F) Device')
|
||||
%p
|
||||
= _('Use a hardware device to add the second factor of authentication.')
|
||||
%p
|
||||
= _("As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser.")
|
||||
- if webauthn_enabled
|
||||
= _("As WebAuthn devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a WebAuthn device. That way you'll always be able to log in - even when you're using an unsupported browser.")
|
||||
- else
|
||||
= _("As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser.")
|
||||
.col-lg-8
|
||||
- if @u2f_registration.errors.present?
|
||||
= form_errors(@u2f_registration)
|
||||
= render "u2f/register"
|
||||
- registration = webauthn_enabled ? @webauthn_registration : @u2f_registration
|
||||
- if registration.errors.present?
|
||||
= form_errors(registration)
|
||||
- if webauthn_enabled
|
||||
= render "authentication/register", target_path: create_webauthn_profile_two_factor_auth_path
|
||||
- else
|
||||
= render "authentication/register", target_path: create_u2f_profile_two_factor_auth_path
|
||||
|
||||
%hr
|
||||
|
||||
%h5
|
||||
= _('U2F Devices (%{length})') % { length: @u2f_registrations.length }
|
||||
- if webauthn_enabled
|
||||
= _('WebAuthn Devices (%{length})') % { length: @registrations.length }
|
||||
- else
|
||||
= _('U2F Devices (%{length})') % { length: @registrations.length }
|
||||
|
||||
- if @u2f_registrations.present?
|
||||
- if @registrations.present?
|
||||
.table-responsive
|
||||
%table.table.table-bordered.u2f-registrations
|
||||
%colgroup
|
||||
|
@ -86,12 +100,15 @@
|
|||
%th= s_('2FADevice|Registered On')
|
||||
%th
|
||||
%tbody
|
||||
- @u2f_registrations.each do |registration|
|
||||
- @registrations.each do |registration|
|
||||
%tr
|
||||
%td= registration.name.presence || html_escape_once(_("<no name set>")).html_safe
|
||||
%td= registration.created_at.to_date.to_s(:medium)
|
||||
%td= link_to _('Delete'), profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.') }
|
||||
%td= registration[:name].presence || html_escape_once(_("<no name set>")).html_safe
|
||||
%td= registration[:created_at].to_date.to_s(:medium)
|
||||
%td= link_to _('Delete'), registration[:delete_path], method: :delete, class: "btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.') }
|
||||
|
||||
- else
|
||||
.settings-message.text-center
|
||||
= _("You don't have any U2F devices registered yet.")
|
||||
- if webauthn_enabled
|
||||
= _("You don't have any WebAuthn devices registered yet.")
|
||||
- else
|
||||
= _("You don't have any U2F devices registered yet.")
|
||||
|
|
|
@ -9,6 +9,10 @@
|
|||
.file-header-content
|
||||
= render "projects/diffs/file_header", diff_file: diff_file, url: "##{file_hash}"
|
||||
|
||||
- if diff_file.submodule?
|
||||
.file-actions.d-none.d-sm-block
|
||||
= submodule_diff_compare_link(diff_file)
|
||||
|
||||
- unless diff_file.submodule?
|
||||
- blob = diff_file.blob
|
||||
.file-actions.d-none.d-sm-block
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
#js-register-u2f
|
||||
|
||||
-# haml-lint:disable InlineJavaScript
|
||||
%script#js-register-u2f-not-supported{ type: "text/template" }
|
||||
%p= _("Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer).")
|
||||
|
||||
%script#js-register-u2f-setup{ type: "text/template" }
|
||||
- if current_user.two_factor_otp_enabled?
|
||||
.row.gl-mb-3
|
||||
.col-md-4
|
||||
%button#js-setup-u2f-device.btn.btn-info.btn-block= _("Set up new U2F device")
|
||||
.col-md-8
|
||||
%p= _("Your U2F device needs to be set up. Plug it in (if not already) and click the button on the left.")
|
||||
- else
|
||||
.row.gl-mb-3
|
||||
.col-md-4
|
||||
%button#js-setup-u2f-device.btn.btn-info.btn-block{ disabled: true }= _("Set up new U2F device")
|
||||
.col-md-8
|
||||
%p= _("You need to register a two-factor authentication app before you can set up a U2F device.")
|
||||
|
||||
%script#js-register-u2f-in-progress{ type: "text/template" }
|
||||
%p= _("Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.")
|
||||
|
||||
%script#js-register-u2f-error{ type: "text/template" }
|
||||
%div
|
||||
%p
|
||||
%span <%= error_message %> (#{_("error code:")} <%= error_code %>)
|
||||
%a.btn.btn-warning#js-token-2fa-try-again= _("Try again?")
|
||||
|
||||
%script#js-register-u2f-registered{ type: "text/template" }
|
||||
.row.gl-mb-3
|
||||
.col-md-12
|
||||
%p= _("Your device was successfully set up! Give it a name and register it with the GitLab server.")
|
||||
= form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do
|
||||
.row.gl-mb-3
|
||||
.col-md-3
|
||||
= text_field_tag 'u2f_registration[name]', nil, class: 'form-control', placeholder: _("Pick a name")
|
||||
.col-md-3
|
||||
= hidden_field_tag 'u2f_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
|
||||
= submit_tag _("Register U2F device"), class: "btn btn-success"
|
|
@ -897,7 +897,7 @@
|
|||
:urgency: :low
|
||||
:resource_boundary: :unknown
|
||||
:weight: 1
|
||||
:idempotent:
|
||||
:idempotent: true
|
||||
:tags: []
|
||||
- :name: pipeline_background:ci_daily_build_group_report_results
|
||||
:feature_category: :continuous_integration
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Ci
|
||||
class BuildTraceChunkFlushWorker # rubocop:disable Scalability/IdempotentWorker
|
||||
class BuildTraceChunkFlushWorker
|
||||
include ApplicationWorker
|
||||
include PipelineBackgroundQueue
|
||||
|
||||
idempotent!
|
||||
|
||||
# rubocop: disable CodeReuse/ActiveRecord
|
||||
def perform(chunk_id)
|
||||
::Ci::BuildTraceChunk.find_by(id: chunk_id).try do |chunk|
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Optimise index on audit events for CSV export
|
||||
merge_request: 41266
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Prevent MRs to be dropped from Merge Trains for open discussions
|
||||
merge_request: 39957
|
||||
author:
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: WebAuthn support (behind feature flag)
|
||||
merge_request: 26692
|
||||
author: Jan Beckmann
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Replace bootstrap alerts in ee/app/views/groups/push_rules/edit.html.haml
|
||||
merge_request: 41069
|
||||
author: Jacopo Beschi @jacopo-beschi
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Exposes Incident's severity via GraphQL
|
||||
merge_request: 40945
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Correctly preserve LFS objects in design or wiki repositories
|
||||
merge_request: 41352
|
||||
author:
|
||||
type: fixed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add link to compare changes intoduced by a git submodule update
|
||||
merge_request: 37740
|
||||
author: Daniel Seemer @Phaiax
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Replace v-html to v-safe-html directive
|
||||
merge_request: 41305
|
||||
author: Kazuya Kojima
|
||||
type: other
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
name: merge_request_reviewers
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40488
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/245190
|
||||
group: group::source code
|
||||
type: development
|
||||
default_enabled: false
|
|
@ -0,0 +1,35 @@
|
|||
WebAuthn.configure do |config|
|
||||
# This value needs to match `window.location.origin` evaluated by
|
||||
# the User Agent during registration and authentication ceremonies.
|
||||
config.origin = Settings.gitlab['base_url']
|
||||
|
||||
# Relying Party name for display purposes
|
||||
# config.rp_name = "Example Inc."
|
||||
|
||||
# Optionally configure a client timeout hint, in milliseconds.
|
||||
# This hint specifies how long the browser should wait for any
|
||||
# interaction with the user.
|
||||
# This hint may be overridden by the browser.
|
||||
# https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-timeout
|
||||
# config.credential_options_timeout = 120_000
|
||||
|
||||
# You can optionally specify a different Relying Party ID
|
||||
# (https://www.w3.org/TR/webauthn/#relying-party-identifier)
|
||||
# if it differs from the default one.
|
||||
#
|
||||
# In this case the default would be "auth.example.com", but you can set it to
|
||||
# the suffix "example.com"
|
||||
#
|
||||
# config.rp_id = "example.com"
|
||||
|
||||
# Configure preferred binary-to-text encoding scheme. This should match the encoding scheme
|
||||
# used in your client-side (user agent) code before sending the credential to the server.
|
||||
# Supported values: `:base64url` (default), `:base64` or `false` to disable all encoding.
|
||||
#
|
||||
config.encoding = :base64
|
||||
|
||||
# Possible values: "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "RS256", "RS384", "RS512", "RS1"
|
||||
# Default: ["ES256", "PS256", "RS256"]
|
||||
#
|
||||
# config.algorithms << "ES384"
|
||||
end
|
|
@ -63,9 +63,11 @@ resource :profile, only: [:show, :update] do
|
|||
post :create_u2f
|
||||
post :codes
|
||||
patch :skip
|
||||
post :create_webauthn
|
||||
end
|
||||
end
|
||||
|
||||
resources :u2f_registrations, only: [:destroy]
|
||||
resources :webauthn_registrations, only: [:destroy]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddCreatedAtIndexToAuditEvents < ActiveRecord::Migration[6.0]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
INDEX_NAME = 'idx_audit_events_on_entity_id_desc_author_id_created_at'
|
||||
OLD_INDEX_NAME = 'index_audit_events_on_entity_id_entity_type_id_desc_author_id'
|
||||
|
||||
def up
|
||||
add_concurrent_index(:audit_events, [:entity_id, :entity_type, :id, :author_id, :created_at], order: { id: :desc }, name: INDEX_NAME)
|
||||
remove_concurrent_index_by_name(:audit_events, OLD_INDEX_NAME)
|
||||
end
|
||||
|
||||
def down
|
||||
add_concurrent_index(:audit_events, [:entity_id, :entity_type, :id, :author_id], order: { id: :desc }, name: OLD_INDEX_NAME)
|
||||
remove_concurrent_index_by_name(:audit_events, INDEX_NAME)
|
||||
end
|
||||
end
|
|
@ -0,0 +1 @@
|
|||
5c065dc7905fd1292e270d2248810d71fa71d6b6996e9d60c463a7eb36042881
|
|
@ -19075,6 +19075,8 @@ CREATE UNIQUE INDEX epic_user_mentions_on_epic_id_and_note_id_index ON public.ep
|
|||
|
||||
CREATE UNIQUE INDEX epic_user_mentions_on_epic_id_index ON public.epic_user_mentions USING btree (epic_id) WHERE (note_id IS NULL);
|
||||
|
||||
CREATE INDEX idx_audit_events_on_entity_id_desc_author_id_created_at ON public.audit_events USING btree (entity_id, entity_type, id DESC, author_id, created_at);
|
||||
|
||||
CREATE INDEX idx_ci_pipelines_artifacts_locked ON public.ci_pipelines USING btree (ci_ref_id, id) WHERE (locked = 1);
|
||||
|
||||
CREATE INDEX idx_container_scanning_findings ON public.vulnerability_occurrences USING btree (id) WHERE (report_type = 2);
|
||||
|
@ -19267,8 +19269,6 @@ CREATE INDEX index_approvers_on_user_id ON public.approvers USING btree (user_id
|
|||
|
||||
CREATE UNIQUE INDEX index_atlassian_identities_on_extern_uid ON public.atlassian_identities USING btree (extern_uid);
|
||||
|
||||
CREATE INDEX index_audit_events_on_entity_id_entity_type_id_desc_author_id ON public.audit_events USING btree (entity_id, entity_type, id DESC, author_id);
|
||||
|
||||
CREATE INDEX index_award_emoji_on_awardable_type_and_awardable_id ON public.award_emoji USING btree (awardable_type, awardable_id);
|
||||
|
||||
CREATE INDEX index_award_emoji_on_user_id_and_name ON public.award_emoji USING btree (user_id, name);
|
||||
|
|
|
@ -15,7 +15,9 @@ See [Geo current limitations](../replication/index.md#current-limitations) for m
|
|||
|
||||
CAUTION: **Warning:**
|
||||
Disaster recovery for multi-secondary configurations is in **Alpha**.
|
||||
For the latest updates, check the multi-secondary [Disaster Recovery epic](https://gitlab.com/groups/gitlab-org/-/epics/65).
|
||||
For the latest updates, check the [Disaster Recovery epic for complete maturity](https://gitlab.com/groups/gitlab-org/-/epics/590).
|
||||
Multi-secondary configurations require the complete re-synchronization and re-configuration of all non-promoted secondaries and
|
||||
will cause downtime.
|
||||
|
||||
## Promoting a **secondary** Geo node in single-secondary configurations
|
||||
|
||||
|
|
|
@ -588,8 +588,9 @@ database encryption. Proceed with caution.
|
|||
1. On the **GitLab server**, make the following changes to `/etc/gitlab/gitlab.rb`:
|
||||
|
||||
```ruby
|
||||
gitlab_pages['enable'] = false
|
||||
pages_external_url "http://<pages_server_URL>"
|
||||
gitlab_pages['enable'] = false
|
||||
gitlab_rails['pages_enabled']=false
|
||||
gitlab_rails['pages_path'] = "/mnt/pages"
|
||||
```
|
||||
|
||||
|
|
|
@ -11,8 +11,7 @@ If you use a cloud-managed service, or provide your own PostgreSQL instance:
|
|||
|
||||
1. Set up PostgreSQL according to the
|
||||
[database requirements document](../../install/requirements.md#database).
|
||||
1. Set up a `gitlab` username with a password of your choice. The `gitlab` user
|
||||
needs privileges to create the `gitlabhq_production` database.
|
||||
1. Set up a `gitlab` user with a password of your choice, create the `gitlabhq_production` database, and make the user an owner of the database. You can see an example of this setup in the [installation from source documentation](../../install/installation.md#6-database).
|
||||
1. If you are using a cloud-managed service, you may need to grant additional
|
||||
roles to your `gitlab` user:
|
||||
- Amazon RDS requires the [`rds_superuser`](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Appendix.PostgreSQL.CommonDBATasks.html#Appendix.PostgreSQL.CommonDBATasks.Roles) role.
|
||||
|
|
|
@ -5679,6 +5679,11 @@ type EpicIssue implements Noteable {
|
|||
"""
|
||||
relativePosition: Int
|
||||
|
||||
"""
|
||||
Severity level of the incident
|
||||
"""
|
||||
severity: IssuableSeverity
|
||||
|
||||
"""
|
||||
State of the issue
|
||||
"""
|
||||
|
@ -6927,6 +6932,11 @@ type Group {
|
|||
"""
|
||||
severity: [VulnerabilitySeverity!]
|
||||
|
||||
"""
|
||||
List vulnerabilities by sort order
|
||||
"""
|
||||
sort: VulnerabilitySort = severity_desc
|
||||
|
||||
"""
|
||||
Filter vulnerabilities by state
|
||||
"""
|
||||
|
@ -7263,6 +7273,36 @@ type InstanceSecurityDashboard {
|
|||
): VulnerabilitySeveritiesCount
|
||||
}
|
||||
|
||||
"""
|
||||
Incident severity
|
||||
"""
|
||||
enum IssuableSeverity {
|
||||
"""
|
||||
Critical severity
|
||||
"""
|
||||
CRITICAL
|
||||
|
||||
"""
|
||||
High severity
|
||||
"""
|
||||
HIGH
|
||||
|
||||
"""
|
||||
Low severity
|
||||
"""
|
||||
LOW
|
||||
|
||||
"""
|
||||
Medium severity
|
||||
"""
|
||||
MEDIUM
|
||||
|
||||
"""
|
||||
Unknown severity
|
||||
"""
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
"""
|
||||
State of a GitLab issue or merge request
|
||||
"""
|
||||
|
@ -7509,6 +7549,11 @@ type Issue implements Noteable {
|
|||
"""
|
||||
relativePosition: Int
|
||||
|
||||
"""
|
||||
Severity level of the incident
|
||||
"""
|
||||
severity: IssuableSeverity
|
||||
|
||||
"""
|
||||
State of the issue
|
||||
"""
|
||||
|
@ -12689,6 +12734,11 @@ type Project {
|
|||
"""
|
||||
severity: [VulnerabilitySeverity!]
|
||||
|
||||
"""
|
||||
List vulnerabilities by sort order
|
||||
"""
|
||||
sort: VulnerabilitySort = severity_desc
|
||||
|
||||
"""
|
||||
Filter vulnerabilities by state
|
||||
"""
|
||||
|
@ -13451,6 +13501,11 @@ type Query {
|
|||
"""
|
||||
severity: [VulnerabilitySeverity!]
|
||||
|
||||
"""
|
||||
List vulnerabilities by sort order
|
||||
"""
|
||||
sort: VulnerabilitySort = severity_desc
|
||||
|
||||
"""
|
||||
Filter vulnerabilities by state
|
||||
"""
|
||||
|
@ -18389,6 +18444,21 @@ enum VulnerabilitySeverity {
|
|||
UNKNOWN
|
||||
}
|
||||
|
||||
"""
|
||||
Vulnerability sort values
|
||||
"""
|
||||
enum VulnerabilitySort {
|
||||
"""
|
||||
Severity in ascending order
|
||||
"""
|
||||
severity_asc
|
||||
|
||||
"""
|
||||
Severity in descending order
|
||||
"""
|
||||
severity_desc
|
||||
}
|
||||
|
||||
"""
|
||||
The state of the vulnerability.
|
||||
"""
|
||||
|
|
|
@ -15844,6 +15844,20 @@
|
|||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "severity",
|
||||
"description": "Severity level of the incident",
|
||||
"args": [
|
||||
|
||||
],
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "IssuableSeverity",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "state",
|
||||
"description": "State of the issue",
|
||||
|
@ -19047,6 +19061,16 @@
|
|||
},
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"description": "List vulnerabilities by sort order",
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "VulnerabilitySort",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": "severity_desc"
|
||||
},
|
||||
{
|
||||
"name": "after",
|
||||
"description": "Returns the elements in the list that come after the specified cursor.",
|
||||
|
@ -20075,6 +20099,47 @@
|
|||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "IssuableSeverity",
|
||||
"description": "Incident severity",
|
||||
"fields": null,
|
||||
"inputFields": null,
|
||||
"interfaces": null,
|
||||
"enumValues": [
|
||||
{
|
||||
"name": "UNKNOWN",
|
||||
"description": "Unknown severity",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "LOW",
|
||||
"description": "Low severity",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "MEDIUM",
|
||||
"description": "Medium severity",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "HIGH",
|
||||
"description": "High severity",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "CRITICAL",
|
||||
"description": "Critical severity",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "IssuableState",
|
||||
|
@ -20727,6 +20792,20 @@
|
|||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "severity",
|
||||
"description": "Severity level of the incident",
|
||||
"args": [
|
||||
|
||||
],
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "IssuableSeverity",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "state",
|
||||
"description": "State of the issue",
|
||||
|
@ -37132,6 +37211,16 @@
|
|||
},
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"description": "List vulnerabilities by sort order",
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "VulnerabilitySort",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": "severity_desc"
|
||||
},
|
||||
{
|
||||
"name": "after",
|
||||
"description": "Returns the elements in the list that come after the specified cursor.",
|
||||
|
@ -39461,6 +39550,16 @@
|
|||
},
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"description": "List vulnerabilities by sort order",
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "VulnerabilitySort",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": "severity_desc"
|
||||
},
|
||||
{
|
||||
"name": "after",
|
||||
"description": "Returns the elements in the list that come after the specified cursor.",
|
||||
|
@ -54034,6 +54133,29 @@
|
|||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "VulnerabilitySort",
|
||||
"description": "Vulnerability sort values",
|
||||
"fields": null,
|
||||
"inputFields": null,
|
||||
"interfaces": null,
|
||||
"enumValues": [
|
||||
{
|
||||
"name": "severity_desc",
|
||||
"description": "Severity in descending order",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "severity_asc",
|
||||
"description": "Severity in ascending order",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "VulnerabilityState",
|
||||
|
|
|
@ -950,6 +950,7 @@ Relationship between an epic and an issue
|
|||
| `reference` | String! | Internal reference of the issue. Returned in shortened format by default |
|
||||
| `relationPath` | String | URI path of the epic-issue relation |
|
||||
| `relativePosition` | Int | Relative position of the issue (used for positioning in epic tree and issue boards) |
|
||||
| `severity` | IssuableSeverity | Severity level of the incident |
|
||||
| `state` | IssueState! | State of the issue |
|
||||
| `statusPagePublishedIncident` | Boolean | Indicates whether an issue is published to the status page |
|
||||
| `subscribed` | Boolean! | Indicates the currently logged in user is subscribed to the issue |
|
||||
|
@ -1123,6 +1124,7 @@ Represents a Group Membership
|
|||
| `milestone` | Milestone | Milestone of the issue |
|
||||
| `reference` | String! | Internal reference of the issue. Returned in shortened format by default |
|
||||
| `relativePosition` | Int | Relative position of the issue (used for positioning in epic tree and issue boards) |
|
||||
| `severity` | IssuableSeverity | Severity level of the incident |
|
||||
| `state` | IssueState! | State of the issue |
|
||||
| `statusPagePublishedIncident` | Boolean | Indicates whether an issue is published to the status page |
|
||||
| `subscribed` | Boolean! | Indicates the currently logged in user is subscribed to the issue |
|
||||
|
|
|
@ -680,7 +680,7 @@ Example response:
|
|||
}
|
||||
```
|
||||
|
||||
Users on GitLab [Starter, Bronze, or higher](https://about.gitlab.com/pricing/) will also see
|
||||
Users on GitLab [Starter, Bronze, or higher](https://about.gitlab.com/pricing/) can also see
|
||||
the `weight` parameter:
|
||||
|
||||
```json
|
||||
|
@ -692,7 +692,7 @@ the `weight` parameter:
|
|||
}
|
||||
```
|
||||
|
||||
Users on GitLab [Ultimate](https://about.gitlab.com/pricing/) will additionally see
|
||||
Users on GitLab [Ultimate](https://about.gitlab.com/pricing/) can also see
|
||||
the `epic` property:
|
||||
|
||||
```javascript
|
||||
|
@ -712,171 +712,20 @@ the `epic` property:
|
|||
}
|
||||
```
|
||||
|
||||
**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
|
||||
NOTE: **Note:**
|
||||
The `assignee` column is deprecated. We now show it as a single-sized array `assignees` to conform
|
||||
to the GitLab EE API.
|
||||
|
||||
**Note**: The `closed_by` attribute was [introduced in GitLab 10.6](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/17042). This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists.
|
||||
NOTE: **Note:**
|
||||
The `closed_by` attribute was [introduced in GitLab 10.6](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/17042).
|
||||
This value is only present for issues closed after GitLab 10.6 and if the user account
|
||||
that closed the issue still exists.
|
||||
|
||||
**Note**: The `epic_iid` attribute is deprecated and [will be removed in version 5](https://gitlab.com/gitlab-org/gitlab/-/issues/35157).
|
||||
NOTE: **Note:**
|
||||
The `epic_iid` attribute is deprecated, and [will be removed in version 5](https://gitlab.com/gitlab-org/gitlab/-/issues/35157).
|
||||
Please use `iid` of the `epic` attribute instead.
|
||||
|
||||
## Single Issue
|
||||
|
||||
Only for administrators. Get a single issue.
|
||||
|
||||
The preferred way to do this is by using [personal access tokens](../user/profile/personal_access_tokens.md).
|
||||
|
||||
```plaintext
|
||||
GET /issues/:id
|
||||
```
|
||||
|
||||
| Attribute | Type | Required | Description |
|
||||
|-------------|---------|----------|--------------------------------------|
|
||||
| `id` | integer | yes | The ID of the issue |
|
||||
|
||||
```shell
|
||||
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/issues/41"
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id" : 1,
|
||||
"milestone" : {
|
||||
"due_date" : null,
|
||||
"project_id" : 4,
|
||||
"state" : "closed",
|
||||
"description" : "Rerum est voluptatem provident consequuntur molestias similique ipsum dolor.",
|
||||
"iid" : 3,
|
||||
"id" : 11,
|
||||
"title" : "v3.0",
|
||||
"created_at" : "2016-01-04T15:31:39.788Z",
|
||||
"updated_at" : "2016-01-04T15:31:39.788Z",
|
||||
"closed_at" : "2016-01-05T15:31:46.176Z"
|
||||
},
|
||||
"author" : {
|
||||
"state" : "active",
|
||||
"web_url" : "https://gitlab.example.com/root",
|
||||
"avatar_url" : null,
|
||||
"username" : "root",
|
||||
"id" : 1,
|
||||
"name" : "Administrator"
|
||||
},
|
||||
"description" : "Omnis vero earum sunt corporis dolor et placeat.",
|
||||
"state" : "closed",
|
||||
"iid" : 1,
|
||||
"assignees" : [{
|
||||
"avatar_url" : null,
|
||||
"web_url" : "https://gitlab.example.com/lennie",
|
||||
"state" : "active",
|
||||
"username" : "lennie",
|
||||
"id" : 9,
|
||||
"name" : "Dr. Luella Kovacek"
|
||||
}],
|
||||
"assignee" : {
|
||||
"avatar_url" : null,
|
||||
"web_url" : "https://gitlab.example.com/lennie",
|
||||
"state" : "active",
|
||||
"username" : "lennie",
|
||||
"id" : 9,
|
||||
"name" : "Dr. Luella Kovacek"
|
||||
},
|
||||
"labels" : [],
|
||||
"upvotes": 4,
|
||||
"downvotes": 0,
|
||||
"merge_requests_count": 0,
|
||||
"title" : "Ut commodi ullam eos dolores perferendis nihil sunt.",
|
||||
"updated_at" : "2016-01-04T15:31:46.176Z",
|
||||
"created_at" : "2016-01-04T15:31:46.176Z",
|
||||
"closed_at" : null,
|
||||
"closed_by" : null,
|
||||
"subscribed": false,
|
||||
"user_notes_count": 1,
|
||||
"due_date": null,
|
||||
"web_url": "http://example.com/my-group/my-project/issues/1",
|
||||
"references": {
|
||||
"short": "#1",
|
||||
"relative": "#1",
|
||||
"full": "my-group/my-project#1"
|
||||
},
|
||||
"time_stats": {
|
||||
"time_estimate": 0,
|
||||
"total_time_spent": 0,
|
||||
"human_time_estimate": null,
|
||||
"human_total_time_spent": null
|
||||
},
|
||||
"confidential": false,
|
||||
"discussion_locked": false,
|
||||
"_links": {
|
||||
"self": "http://example.com/api/v4/projects/1/issues/2",
|
||||
"notes": "http://example.com/api/v4/projects/1/issues/2/notes",
|
||||
"award_emoji": "http://example.com/api/v4/projects/1/issues/2/award_emoji",
|
||||
"project": "http://example.com/api/v4/projects/1"
|
||||
},
|
||||
"task_completion_status":{
|
||||
"count":0,
|
||||
"completed_count":0
|
||||
},
|
||||
"weight": null,
|
||||
"has_tasks": false,
|
||||
"_links": {
|
||||
"self": "http://gitlab.dummy:3000/api/v4/projects/1/issues/1",
|
||||
"notes": "http://gitlab.dummy:3000/api/v4/projects/1/issues/1/notes",
|
||||
"award_emoji": "http://gitlab.dummy:3000/api/v4/projects/1/issues/1/award_emoji",
|
||||
"project": "http://gitlab.dummy:3000/api/v4/projects/1"
|
||||
},
|
||||
"references": {
|
||||
"short": "#1",
|
||||
"relative": "#1",
|
||||
"full": "gitlab-org/gitlab-test#1"
|
||||
},
|
||||
"subscribed": true,
|
||||
"moved_to_id": null,
|
||||
"epic_iid": null,
|
||||
"epic": null
|
||||
}
|
||||
```
|
||||
|
||||
Users on GitLab [Starter, Bronze, or higher](https://about.gitlab.com/pricing/) will also see
|
||||
the `weight` parameter:
|
||||
|
||||
```json
|
||||
{
|
||||
"project_id" : 4,
|
||||
"description" : "Omnis vero earum sunt corporis dolor et placeat.",
|
||||
"weight": null,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Users on GitLab [Ultimate](https://about.gitlab.com/pricing/) will additionally see
|
||||
the `epic` property:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"project_id" : 4,
|
||||
"description" : "Omnis vero earum sunt corporis dolor et placeat.",
|
||||
"epic": {
|
||||
"epic_iid" : 5, //deprecated, use `iid` of the `epic` attribute
|
||||
"epic": {
|
||||
"id" : 42,
|
||||
"iid" : 5,
|
||||
"title": "My epic epic",
|
||||
"url" : "/groups/h5bp/-/epics/5",
|
||||
"group_id": 8
|
||||
},
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
|
||||
|
||||
**Note**: The `closed_by` attribute was [introduced in GitLab 10.6](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/17042). This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists.
|
||||
|
||||
**Note**: The `epic_iid` attribute is deprecated and [will be removed in version 5](https://gitlab.com/gitlab-org/gitlab/-/issues/35157).
|
||||
Please use `iid` of the `epic` attribute instead.
|
||||
|
||||
## Single Project Issue
|
||||
## Single project issue
|
||||
|
||||
Get a single project issue.
|
||||
|
||||
|
|
|
@ -288,6 +288,29 @@ Implemented using Redis methods [PFADD](https://redis.io/commands/pfadd) and [PF
|
|||
end
|
||||
```
|
||||
|
||||
1. Track event in API using `increment_unique_values(event_name, values)` helper method.
|
||||
|
||||
In order to be able to track the event, Usage Ping must be enabled and the event feature `usage_data_<event_name>` must be enabled.
|
||||
|
||||
Arguments:
|
||||
|
||||
- `event_name`: event name.
|
||||
- `values`: values counted, one value or array of values.
|
||||
|
||||
Example usage:
|
||||
|
||||
```ruby
|
||||
get ':id/registry/repositories' do
|
||||
repositories = ContainerRepositoriesFinder.new(
|
||||
user: current_user, subject: user_group
|
||||
).execute
|
||||
|
||||
increment_unique_values('i_list_repositories', current_user.id)
|
||||
|
||||
present paginate(repositories), with: Entities::ContainerRegistry::Repository, tags: params[:tags], tags_count: params[:tags_count]
|
||||
end
|
||||
```
|
||||
|
||||
1. Track event using base module `Gitlab::UsageDataCounters::HLLRedisCounter.track_event(entity_id, event_name)`.
|
||||
|
||||
Arguments:
|
||||
|
|
|
@ -10,7 +10,9 @@ The DevOps Report gives you an overview of your entire instance's adoption of
|
|||
[Concurrent DevOps](https://about.gitlab.com/topics/concurrent-devops/)
|
||||
from planning to monitoring.
|
||||
|
||||
This displays the usage of these GitLab features over
|
||||
## DevOps Score
|
||||
|
||||
DevOps Score displays the usage of GitLab's major features on your instance over
|
||||
the last 30 days, averaged over the number of active users in that time period. It also
|
||||
provides a Lead score per feature, which is calculated based on GitLab's analysis
|
||||
of top-performing instances based on [usage ping data](../settings/usage_statistics.md#usage-ping-core-only) that GitLab has
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
Before Width: | Height: | Size: 30 KiB |
Binary file not shown.
After Width: | Height: | Size: 73 KiB |
|
@ -309,15 +309,29 @@ rating.
|
|||
|
||||
### Enabling Security Approvals within a project
|
||||
|
||||
To enable Security Approvals, a [project approval rule](../project/merge_requests/merge_request_approvals.md#adding--editing-a-default-approval-rule)
|
||||
must be created with the case-sensitive name `Vulnerability-Check`. This approval group must be set
|
||||
with the number of approvals required greater than zero. You must have Maintainer or Owner [permissions](../permissions.md#project-members-permissions) to manage approval rules.
|
||||
To enable the `Vulnerability-Check` or `License-Check` Security Approvals, a [project approval rule](../project/merge_requests/merge_request_approvals.md#adding--editing-a-default-approval-rule)
|
||||
must be created. A [security scanner job](#security-scanning-tools) must be enabled for
|
||||
`Vulnerability-Check`, and a [license scanning](../compliance/license_compliance/index.md#configuration)
|
||||
job must be enabled for `License-Check`. When the proper jobs aren't configured, the following
|
||||
appears:
|
||||
|
||||
![Unconfigured Approval Rules](img/unconfigured_security_approval_rules_and_jobs_v13_4.png)
|
||||
|
||||
If at least one security scanner is enabled, you will be able to enable the `Vulnerability-Check` approval rule. If a license scanning job is enabled, you will be able to enable the `License-Check` rule.
|
||||
|
||||
![Unconfigured Approval Rules with valid pipeline jobs](img/unconfigured_security_approval_rules_and_enabled_jobs_v13_4.png)
|
||||
|
||||
For this approval group, you must set the number of approvals required to greater than zero. You
|
||||
must have Maintainer or Owner [permissions](../permissions.md#project-members-permissions)
|
||||
to manage approval rules.
|
||||
|
||||
Follow these steps to enable `Vulnerability-Check`:
|
||||
|
||||
1. Navigate to your project's **Settings > General** and expand **Merge request approvals**.
|
||||
1. Click **Add approval rule**, or **Edit**.
|
||||
- Add or change the **Rule name** to `Vulnerability-Check` (case sensitive).
|
||||
1. Click **Enable**, or **Edit**.
|
||||
1. Add or change the **Rule name** to `Vulnerability-Check` (case sensitive).
|
||||
|
||||
![Vulnerability Check Approver Rule](img/vulnerability-check_v13_0.png)
|
||||
![Vulnerability Check Approver Rule](img/vulnerability-check_v13_4.png)
|
||||
|
||||
Once this group is added to your project, the approval rule is enabled for all merge requests.
|
||||
|
||||
|
@ -334,32 +348,14 @@ An approval is optional when the security report:
|
|||
- Contains no new vulnerabilities when compared to the target branch.
|
||||
- Contains only new vulnerabilities of `low` or `medium` severity.
|
||||
|
||||
## Enabling License Approvals within a project
|
||||
### Enabling License Approvals within a project
|
||||
|
||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13067) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.3.
|
||||
|
||||
`License-Check` is an approval rule you can enable to allow an individual or group to approve a
|
||||
merge request that contains a `denied` license.
|
||||
|
||||
You can enable `License-Check` one of two ways:
|
||||
|
||||
- Create a [project approval rule](../project/merge_requests/merge_request_approvals.md#multiple-approval-rules-premium)
|
||||
with the case-sensitive name `License-Check`.
|
||||
- Create an approval group in the [project policies section for License Compliance](../compliance/license_compliance/index.md#policies).
|
||||
You must set this approval group's number of approvals required to greater than zero. Once you
|
||||
enable this group in your project, the approval rule is enabled for all merge requests.
|
||||
|
||||
Any code changes cause the approvals required to reset.
|
||||
|
||||
An approval is required when a license report:
|
||||
|
||||
- Contains a dependency that includes a software license that is `denied`.
|
||||
- Is not generated during pipeline execution.
|
||||
|
||||
An approval is optional when a license report:
|
||||
|
||||
- Contains no software license violations.
|
||||
- Contains only new licenses that are `allowed` or unknown.
|
||||
`License-Check` is a [security approval rule](#enabling-security-approvals-within-a-project)
|
||||
you can enable to allow an individual or group to approve a merge request that contains a `denied`
|
||||
license. For instructions on enabling this rule, see
|
||||
[Enabling license approvals within a project](../compliance/license_compliance/index.md#enabling-license-approvals-within-a-project).
|
||||
|
||||
## Working in an offline environment
|
||||
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 73 KiB |
|
@ -724,17 +724,21 @@ Developers of the project can view the policies configured in a project.
|
|||
|
||||
![View Policies](img/policies_v13_0.png)
|
||||
|
||||
### Enabling License Approvals within a project
|
||||
## Enabling License Approvals within a project
|
||||
|
||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13067) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.3.
|
||||
|
||||
`License-Check` is an approval rule you can enable to allow an approver, individual, or group to
|
||||
approve a merge request that contains a `denied` license.
|
||||
`License-Check` is a [security approval](../../application_security/index.md#enabling-security-approvals-within-a-project) rule you can enable to allow an individual or group to approve a
|
||||
merge request that contains a `denied` license.
|
||||
|
||||
You can enable `License-Check` one of two ways:
|
||||
|
||||
- Create a [project approval rule](../../project/merge_requests/merge_request_approvals.md#multiple-approval-rules-premium)
|
||||
with the case-sensitive name `License-Check`.
|
||||
1. Navigate to your project's **Settings > General** and expand **Merge request approvals**.
|
||||
1. Click **Enable** or **Edit**.
|
||||
1. Add or change the **Rule name** to `License-Check` (case sensitive).
|
||||
|
||||
![License Check Approver Rule](img/license-check_v13_4.png)
|
||||
|
||||
- Create an approval group in the [project policies section for License Compliance](#policies).
|
||||
You must set this approval group's number of approvals required to greater than zero. Once you
|
||||
enable this group in your project, the approval rule is enabled for all merge requests.
|
||||
|
|
|
@ -92,7 +92,8 @@ module HamlLint
|
|||
File.open(path_to_file).any? do |line|
|
||||
result = line.match(MARKDOWN_HEADER)
|
||||
|
||||
string_to_anchor(result[:header]) == anchor if result
|
||||
# TODO:Do an exact match for anchors (Follow-up https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39850)
|
||||
anchor.start_with?(string_to_anchor(result[:header])) if result
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -537,6 +537,20 @@ module API
|
|||
)
|
||||
end
|
||||
|
||||
# @param event_name [String] the event name
|
||||
# @param values [Array|String] the values counted
|
||||
def increment_unique_values(event_name, values)
|
||||
return unless values.present?
|
||||
|
||||
feature_name = "usage_data_#{event_name}"
|
||||
return unless Feature.enabled?(feature_name)
|
||||
return unless Gitlab::CurrentSettings.usage_ping_enabled?
|
||||
|
||||
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(values, event_name)
|
||||
rescue => error
|
||||
Gitlab::AppLogger.warn("Redis tracking event failed for event: #{event_name}, message: #{error.message}")
|
||||
end
|
||||
|
||||
def with_api_params(&block)
|
||||
yield({ api: true, request: request })
|
||||
end
|
||||
|
|
|
@ -116,6 +116,7 @@ module API
|
|||
|
||||
entity = current_user&.admin? ? Entities::UserWithAdmin : Entities::UserBasic
|
||||
users = users.preload(:identities, :u2f_registrations) if entity == Entities::UserWithAdmin
|
||||
users = users.preload(:identities, :webauthn_registrations) if entity == Entities::UserWithAdmin
|
||||
users, options = with_custom_attributes(users, { with: entity, current_user: current_user })
|
||||
|
||||
users = users.preload(:user_detail)
|
||||
|
|
|
@ -25,7 +25,7 @@ module Gitlab
|
|||
private
|
||||
|
||||
def remove_orphan_references
|
||||
invalid_references = project.lfs_objects_projects.where(lfs_object: orphan_objects) # rubocop:disable CodeReuse/ActiveRecord
|
||||
invalid_references = project.lfs_objects_projects.lfs_object_in(orphan_objects)
|
||||
|
||||
if dry_run
|
||||
log_info("Found invalid references: #{invalid_references.count}")
|
||||
|
@ -41,26 +41,22 @@ module Gitlab
|
|||
end
|
||||
end
|
||||
|
||||
def lfs_oids_from_repository
|
||||
project.repository.gitaly_blob_client.get_all_lfs_pointers.map(&:lfs_oid)
|
||||
end
|
||||
def orphan_objects
|
||||
# Get these first so racing with a git push can't remove any LFS objects
|
||||
oids = project.lfs_objects_oids
|
||||
|
||||
def orphan_oids
|
||||
lfs_oids_from_database - lfs_oids_from_repository
|
||||
end
|
||||
repos = [
|
||||
project.repository,
|
||||
project.design_repository,
|
||||
project.wiki.repository
|
||||
].select(&:exists?)
|
||||
|
||||
def lfs_oids_from_database
|
||||
oids = []
|
||||
|
||||
project.lfs_objects.each_batch do |relation|
|
||||
oids += relation.pluck(:oid) # rubocop:disable CodeReuse/ActiveRecord
|
||||
repos.flat_map do |repo|
|
||||
oids -= repo.gitaly_blob_client.get_all_lfs_pointers.map(&:lfs_oid)
|
||||
end
|
||||
|
||||
oids
|
||||
end
|
||||
|
||||
def orphan_objects
|
||||
LfsObject.where(oid: orphan_oids) # rubocop:disable CodeReuse/ActiveRecord
|
||||
# The remaining OIDs are not used by any repository, so are orphans
|
||||
LfsObject.for_oids(oids)
|
||||
end
|
||||
|
||||
def log_info(msg)
|
||||
|
|
|
@ -24,11 +24,11 @@ module Gitlab
|
|||
end
|
||||
|
||||
def web_url
|
||||
@submodule_links.first
|
||||
@submodule_links&.web
|
||||
end
|
||||
|
||||
def tree_url
|
||||
@submodule_links.last
|
||||
@submodule_links&.tree
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,14 +4,18 @@ module Gitlab
|
|||
class SubmoduleLinks
|
||||
include Gitlab::Utils::StrongMemoize
|
||||
|
||||
Urls = Struct.new(:web, :tree, :compare)
|
||||
|
||||
def initialize(repository)
|
||||
@repository = repository
|
||||
@cache_store = {}
|
||||
end
|
||||
|
||||
def for(submodule, sha)
|
||||
def for(submodule, sha, diff_file = nil)
|
||||
submodule_url = submodule_url_for(sha, submodule.path)
|
||||
SubmoduleHelper.submodule_links_for_url(submodule.id, submodule_url, repository)
|
||||
old_submodule_id = old_submodule_id(submodule_url, diff_file)
|
||||
urls = SubmoduleHelper.submodule_links_for_url(submodule.id, submodule_url, repository, old_submodule_id)
|
||||
Urls.new(*urls) if urls.any?
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -29,5 +33,15 @@ module Gitlab
|
|||
urls = submodule_urls_for(sha)
|
||||
urls && urls[path]
|
||||
end
|
||||
|
||||
def old_submodule_id(submodule_url, diff_file)
|
||||
return unless diff_file&.old_blob && diff_file&.old_content_sha
|
||||
|
||||
# if the submodule url has changed from old_sha to sha, a compare link does not make sense
|
||||
#
|
||||
old_submodule_url = submodule_url_for(diff_file.old_content_sha, diff_file.old_blob.path)
|
||||
|
||||
diff_file.old_blob.id if old_submodule_url == submodule_url
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue