mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-09 23:15:43 +01:00
Make auth expiration for LFS configurable (#1697)
When SCM-Manager is used behind a reverse proxy like Nginx it may be the case, that lfs PUT requests are buffered by the reverse proxy and will be sent to the SCM-Manager after the whole file has been received. Due to the expiration time of 5 minutes for the authentivation token that had been requested by Git before the upload has been started, this request from the proxy to SCM-Manager fails if the upload from the client to the reverse proxy took longer than these 5 minutes. To solve this, we make this expiration time configurable, so that whenever you have very large files or small bandwidth the expiration timeout can be increased.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 175 KiB |
@@ -20,4 +20,12 @@ Unter dem Eintrag Git können die folgenden Git-spezifischen Einstellungen vorge
|
|||||||
Bitte beachten Sie, dass dieser Name aufgrund von Git-Spezifika nicht bei leeren Repositories genutzt
|
Bitte beachten Sie, dass dieser Name aufgrund von Git-Spezifika nicht bei leeren Repositories genutzt
|
||||||
werden kann (hier wird immer der Git-interne Default Name genutzt, derzeit also `master`).
|
werden kann (hier wird immer der Git-interne Default Name genutzt, derzeit also `master`).
|
||||||
|
|
||||||
|
- LFS Autorisierungsablaufzeit
|
||||||
|
|
||||||
|
Ablaufzeit für den Autorisierungstoken in Minuten, der für LFS Speicheranfragen ausgestellt wird.
|
||||||
|
Wenn der SCM-Manager hinter einem Reverse-Proxy mit Zwischenspeicherung (z. B. Nginx) betrieben wird,
|
||||||
|
sollte dieser Wert auf die Zeit gesetzt werden, die ein LFS-Upload maximal benötigen kann. Treten
|
||||||
|
während eines länger laufenden LFS "Pushs" Autorisierungsfehler auf, sollte dieser Wert erhöht werden.
|
||||||
|
Der Default-Wert beträgt 5 Minuten.
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 190 KiB |
@@ -19,4 +19,11 @@ In the git section there are the following git specific settings:
|
|||||||
Please mind, that due to git internals this cannot work for empty repositories (here git
|
Please mind, that due to git internals this cannot work for empty repositories (here git
|
||||||
will always use its internal default branch, so at the time being `master`).
|
will always use its internal default branch, so at the time being `master`).
|
||||||
|
|
||||||
|
- LFS authorization expiration
|
||||||
|
|
||||||
|
Sets the expiration time of the authorization token generated for LFS put requests in minutes.
|
||||||
|
If SCM-Manager is run behind a reverse proxy that buffers http requests (eg. Nginx), this
|
||||||
|
should set up to the time, an LFS upload may take at maximum. If you experience errors during
|
||||||
|
long-running LFS push requests, this may have to be increased. The default value is 5 minutes.
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
2
gradle/changelog/lfs_auth_expiration.yaml
Normal file
2
gradle/changelog/lfs_auth_expiration.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- type: fixed
|
||||||
|
description: Added option to increase LFS authorization token timeout ([#1697](https://github.com/scm-manager/scm-manager/pull/1697))
|
||||||
@@ -185,7 +185,7 @@ public class GitNonFastForwardITCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void setNonFastForwardDisallowed(boolean nonFastForwardDisallowed) {
|
private static void setNonFastForwardDisallowed(boolean nonFastForwardDisallowed) {
|
||||||
String config = String.format("{'disabled': false, 'gcExpression': null, 'defaultBranch': 'main', 'nonFastForwardDisallowed': %s}", nonFastForwardDisallowed)
|
String config = String.format("{'disabled': false, 'gcExpression': null, 'defaultBranch': 'main', 'lfsWriteAuthorizationExpirationInMinutes': 5, 'nonFastForwardDisallowed': %s}", nonFastForwardDisallowed)
|
||||||
.replace('\'', '"');
|
.replace('\'', '"');
|
||||||
|
|
||||||
given(VndMediaType.PREFIX + "gitConfig" + VndMediaType.SUFFIX)
|
given(VndMediaType.PREFIX + "gitConfig" + VndMediaType.SUFFIX)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import lombok.NoArgsConstructor;
|
|||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
import org.hibernate.validator.constraints.Length;
|
import org.hibernate.validator.constraints.Length;
|
||||||
|
|
||||||
|
import javax.validation.constraints.Min;
|
||||||
import javax.validation.constraints.NotEmpty;
|
import javax.validation.constraints.NotEmpty;
|
||||||
import javax.validation.constraints.Pattern;
|
import javax.validation.constraints.Pattern;
|
||||||
|
|
||||||
@@ -52,6 +53,9 @@ public class GitConfigDto extends HalRepresentation implements UpdateGitConfigDt
|
|||||||
@Pattern(regexp = VALID_BRANCH_NAMES)
|
@Pattern(regexp = VALID_BRANCH_NAMES)
|
||||||
private String defaultBranch;
|
private String defaultBranch;
|
||||||
|
|
||||||
|
@Min(1)
|
||||||
|
private int lfsWriteAuthorizationExpirationInMinutes;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@SuppressWarnings("squid:S1185") // We want to have this method available in this package
|
@SuppressWarnings("squid:S1185") // We want to have this method available in this package
|
||||||
protected HalRepresentation add(Links links) {
|
protected HalRepresentation add(Links links) {
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ public class GitConfig extends RepositoryConfig {
|
|||||||
@XmlElement(name = "default-branch")
|
@XmlElement(name = "default-branch")
|
||||||
private String defaultBranch = FALLBACK_BRANCH;
|
private String defaultBranch = FALLBACK_BRANCH;
|
||||||
|
|
||||||
|
@XmlElement(name = "lfs-write-authorization-expiration")
|
||||||
|
private int lfsWriteAuthorizationExpirationInMinutes = 5;
|
||||||
|
|
||||||
public String getGcExpression() {
|
public String getGcExpression() {
|
||||||
return gcExpression;
|
return gcExpression;
|
||||||
}
|
}
|
||||||
@@ -82,6 +85,14 @@ public class GitConfig extends RepositoryConfig {
|
|||||||
this.defaultBranch = defaultBranch;
|
this.defaultBranch = defaultBranch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getLfsWriteAuthorizationExpirationInMinutes() {
|
||||||
|
return lfsWriteAuthorizationExpirationInMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLfsWriteAuthorizationExpirationInMinutes(int lfsWriteAuthorizationExpirationInMinutes) {
|
||||||
|
this.lfsWriteAuthorizationExpirationInMinutes = lfsWriteAuthorizationExpirationInMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@XmlTransient // Only for permission checks, don't serialize to XML
|
@XmlTransient // Only for permission checks, don't serialize to XML
|
||||||
public String getId() {
|
public String getId() {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ package sonia.scm.web.lfs;
|
|||||||
import com.github.sdorra.ssp.PermissionCheck;
|
import com.github.sdorra.ssp.PermissionCheck;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import sonia.scm.repository.GitConfig;
|
||||||
|
import sonia.scm.repository.GitRepositoryHandler;
|
||||||
import sonia.scm.repository.Repository;
|
import sonia.scm.repository.Repository;
|
||||||
import sonia.scm.repository.RepositoryPermissions;
|
import sonia.scm.repository.RepositoryPermissions;
|
||||||
import sonia.scm.security.AccessToken;
|
import sonia.scm.security.AccessToken;
|
||||||
@@ -43,10 +45,12 @@ public class LfsAccessTokenFactory {
|
|||||||
private static final Logger LOG = LoggerFactory.getLogger(LfsAccessTokenFactory.class);
|
private static final Logger LOG = LoggerFactory.getLogger(LfsAccessTokenFactory.class);
|
||||||
|
|
||||||
private final AccessTokenBuilderFactory tokenBuilderFactory;
|
private final AccessTokenBuilderFactory tokenBuilderFactory;
|
||||||
|
private final GitRepositoryHandler handler;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
LfsAccessTokenFactory(AccessTokenBuilderFactory tokenBuilderFactory) {
|
LfsAccessTokenFactory(AccessTokenBuilderFactory tokenBuilderFactory, GitRepositoryHandler handler) {
|
||||||
this.tokenBuilderFactory = tokenBuilderFactory;
|
this.tokenBuilderFactory = tokenBuilderFactory;
|
||||||
|
this.handler = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
AccessToken createReadAccessToken(Repository repository) {
|
AccessToken createReadAccessToken(Repository repository) {
|
||||||
@@ -67,7 +71,7 @@ public class LfsAccessTokenFactory {
|
|||||||
permissions.add(push.asShiroString());
|
permissions.add(push.asShiroString());
|
||||||
}
|
}
|
||||||
|
|
||||||
return createToken(Scope.valueOf(permissions));
|
return createToken(Scope.valueOf(permissions), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
AccessToken createWriteAccessToken(Repository repository) {
|
AccessToken createWriteAccessToken(Repository repository) {
|
||||||
@@ -80,15 +84,22 @@ public class LfsAccessTokenFactory {
|
|||||||
PermissionCheck push = RepositoryPermissions.push(repository);
|
PermissionCheck push = RepositoryPermissions.push(repository);
|
||||||
push.check();
|
push.check();
|
||||||
|
|
||||||
return createToken(Scope.valueOf(read.asShiroString(), pull.asShiroString(), push.asShiroString()));
|
int lfsAuthorizationTimeoutInMinutes = getConfiguredLfsAuthorizationTimeoutInMinutes();
|
||||||
|
|
||||||
|
return createToken(Scope.valueOf(read.asShiroString(), pull.asShiroString(), push.asShiroString()), lfsAuthorizationTimeoutInMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AccessToken createToken(Scope scope) {
|
private AccessToken createToken(Scope scope, int expiration) {
|
||||||
LOG.trace("create access token with scope: {}", scope);
|
LOG.trace("create access token with scope: {}", scope);
|
||||||
return tokenBuilderFactory
|
return tokenBuilderFactory
|
||||||
.create()
|
.create()
|
||||||
.expiresIn(5, TimeUnit.MINUTES)
|
.expiresIn(expiration, TimeUnit.MINUTES)
|
||||||
.scope(scope)
|
.scope(scope)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int getConfiguredLfsAuthorizationTimeoutInMinutes() {
|
||||||
|
GitConfig repositoryConfig = handler.getConfig();
|
||||||
|
return repositoryConfig.getLfsWriteAuthorizationExpirationInMinutes();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
/*
|
|
||||||
* MIT License
|
|
||||||
*
|
|
||||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
|
||||||
*
|
|
||||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
* of this software and associated documentation files (the "Software"), to deal
|
|
||||||
* in the Software without restriction, including without limitation the rights
|
|
||||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
* copies of the Software, and to permit persons to whom the Software is
|
|
||||||
* furnished to do so, subject to the following conditions:
|
|
||||||
*
|
|
||||||
* The above copyright notice and this permission notice shall be included in all
|
|
||||||
* copies or substantial portions of the Software.
|
|
||||||
*
|
|
||||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
* SOFTWARE.
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
|
||||||
import { Links } from "@scm-manager/ui-types";
|
|
||||||
import { InputField, Checkbox, validation as validator } from "@scm-manager/ui-components";
|
|
||||||
|
|
||||||
type Configuration = {
|
|
||||||
repositoryDirectory?: string;
|
|
||||||
gcExpression?: string;
|
|
||||||
nonFastForwardDisallowed: boolean;
|
|
||||||
defaultBranch: string;
|
|
||||||
_links: Links;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = WithTranslation & {
|
|
||||||
initialConfiguration: Configuration;
|
|
||||||
readOnly: boolean;
|
|
||||||
|
|
||||||
onConfigurationChange: (p1: Configuration, p2: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type State = Configuration & {};
|
|
||||||
|
|
||||||
class GitConfigurationForm extends React.Component<Props, State> {
|
|
||||||
constructor(props: Props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
...props.initialConfiguration
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onGcExpressionChange = (value: string) => {
|
|
||||||
this.setState(
|
|
||||||
{
|
|
||||||
gcExpression: value
|
|
||||||
},
|
|
||||||
() => this.props.onConfigurationChange(this.state, true)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
onNonFastForwardDisallowed = (value: boolean) => {
|
|
||||||
this.setState(
|
|
||||||
{
|
|
||||||
nonFastForwardDisallowed: value
|
|
||||||
},
|
|
||||||
() => this.props.onConfigurationChange(this.state, true)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
onDefaultBranchChange = (value: string) => {
|
|
||||||
this.setState(
|
|
||||||
{
|
|
||||||
defaultBranch: value
|
|
||||||
},
|
|
||||||
() => this.props.onConfigurationChange(this.state, this.isValidDefaultBranch())
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
isValidDefaultBranch = () => {
|
|
||||||
return validator.isBranchValid(this.state.defaultBranch);
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { gcExpression, nonFastForwardDisallowed, defaultBranch } = this.state;
|
|
||||||
const { readOnly, t } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<InputField
|
|
||||||
name="gcExpression"
|
|
||||||
label={t("scm-git-plugin.config.gcExpression")}
|
|
||||||
helpText={t("scm-git-plugin.config.gcExpressionHelpText")}
|
|
||||||
value={gcExpression}
|
|
||||||
onChange={this.onGcExpressionChange}
|
|
||||||
disabled={readOnly}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
name="nonFastForwardDisallowed"
|
|
||||||
label={t("scm-git-plugin.config.nonFastForwardDisallowed")}
|
|
||||||
helpText={t("scm-git-plugin.config.nonFastForwardDisallowedHelpText")}
|
|
||||||
checked={nonFastForwardDisallowed}
|
|
||||||
onChange={this.onNonFastForwardDisallowed}
|
|
||||||
disabled={readOnly}
|
|
||||||
/>
|
|
||||||
<InputField
|
|
||||||
name="defaultBranch"
|
|
||||||
label={t("scm-git-plugin.config.defaultBranch")}
|
|
||||||
helpText={t("scm-git-plugin.config.defaultBranchHelpText")}
|
|
||||||
value={defaultBranch}
|
|
||||||
onChange={this.onDefaultBranchChange}
|
|
||||||
disabled={readOnly}
|
|
||||||
validationError={!this.isValidDefaultBranch()}
|
|
||||||
errorMessage={t("scm-git-plugin.config.defaultBranchValidationError")}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withTranslation("plugins")(GitConfigurationForm);
|
|
||||||
@@ -21,26 +21,80 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React, { FC, useEffect } from "react";
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Title, Configuration } from "@scm-manager/ui-components";
|
import { Title, ConfigurationForm, InputField, Checkbox, validation } from "@scm-manager/ui-components";
|
||||||
import GitConfigurationForm from "./GitConfigurationForm";
|
import { useConfigLink } from "@scm-manager/ui-api";
|
||||||
|
import { HalRepresentation } from "@scm-manager/ui-types";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
type Props = WithTranslation & {
|
type Props = {
|
||||||
link: string;
|
link: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class GitGlobalConfiguration extends React.Component<Props> {
|
type Configuration = HalRepresentation & {
|
||||||
render() {
|
repositoryDirectory?: string;
|
||||||
const { link, t } = this.props;
|
gcExpression?: string;
|
||||||
|
nonFastForwardDisallowed: boolean;
|
||||||
|
defaultBranch: string;
|
||||||
|
lfsWriteAuthorizationExpirationInMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GitGlobalConfiguration: FC<Props> = ({ link }) => {
|
||||||
|
const [t] = useTranslation("plugins");
|
||||||
|
|
||||||
|
const { initialConfiguration, isReadOnly, update, ...formProps } = useConfigLink(link);
|
||||||
|
const { formState, handleSubmit, register, reset } = useForm<Configuration>({ mode: "onChange" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialConfiguration) {
|
||||||
|
reset(initialConfiguration);
|
||||||
|
}
|
||||||
|
}, [initialConfiguration]);
|
||||||
|
|
||||||
|
const isValidDefaultBranch = (value: string) => {
|
||||||
|
return validation.isBranchValid(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<ConfigurationForm
|
||||||
|
isValid={formState.isValid}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
onSubmit={handleSubmit(update)}
|
||||||
|
{...formProps}
|
||||||
|
>
|
||||||
<Title title={t("scm-git-plugin.config.title")} />
|
<Title title={t("scm-git-plugin.config.title")} />
|
||||||
<Configuration link={link} render={(props: any) => <GitConfigurationForm {...props} />} />
|
<InputField
|
||||||
</div>
|
label={t("scm-git-plugin.config.gcExpression")}
|
||||||
|
helpText={t("scm-git-plugin.config.gcExpressionHelpText")}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
{...register("gcExpression")}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={t("scm-git-plugin.config.nonFastForwardDisallowed")}
|
||||||
|
helpText={t("scm-git-plugin.config.nonFastForwardDisallowedHelpText")}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
{...register("nonFastForwardDisallowed")}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label={t("scm-git-plugin.config.defaultBranch")}
|
||||||
|
helpText={t("scm-git-plugin.config.defaultBranchHelpText")}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
validationError={!!formState.errors.defaultBranch}
|
||||||
|
errorMessage={t("scm-git-plugin.config.defaultBranchValidationError")}
|
||||||
|
{...register("defaultBranch", { validate: isValidDefaultBranch })}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
type="number"
|
||||||
|
label={t("scm-git-plugin.config.lfsWriteAuthorizationExpirationInMinutes")}
|
||||||
|
helpText={t("scm-git-plugin.config.lfsWriteAuthorizationExpirationInMinutesHelpText")}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
validationError={!!formState.errors.lfsWriteAuthorizationExpirationInMinutes}
|
||||||
|
errorMessage={t("scm-git-plugin.config.lfsWriteAuthorizationExpirationInMinutesValidationError")}
|
||||||
|
{...register("lfsWriteAuthorizationExpirationInMinutes", { min: 1, required: true })}
|
||||||
|
/>
|
||||||
|
</ConfigurationForm>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default withTranslation("plugins")(GitGlobalConfiguration);
|
export default GitGlobalConfiguration;
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
"defaultBranch": "Default Branch",
|
"defaultBranch": "Default Branch",
|
||||||
"defaultBranchHelpText": "Dieser Name wird bei der Initialisierung neuer Git Repositories genutzt. Er hat keine weiteren Auswirkungen (insbesondere hat er keinen Einfluss auf den Branchnamen bei leeren Repositories).",
|
"defaultBranchHelpText": "Dieser Name wird bei der Initialisierung neuer Git Repositories genutzt. Er hat keine weiteren Auswirkungen (insbesondere hat er keinen Einfluss auf den Branchnamen bei leeren Repositories).",
|
||||||
"defaultBranchValidationError": "Dies ist kein valider Branchname",
|
"defaultBranchValidationError": "Dies ist kein valider Branchname",
|
||||||
|
"lfsWriteAuthorizationExpirationInMinutes": "Ablaufzeit für LFS Autorisierung",
|
||||||
|
"lfsWriteAuthorizationExpirationInMinutesHelpText": "Ablaufzeit für den Autorisierungstoken in Minuten, der für LFS Speicheranfragen ausgestellt wird. Wenn der SCM-Manager hinter einem Reverse-Proxy mit Zwischenspeicherung (z. B. Nginx) betrieben wird, sollte dieser Wert auf die Zeit gesetzt werden, die ein LFS-Upload maximal benötigen kann.",
|
||||||
|
"lfsWriteAuthorizationExpirationInMinutesValidationError": "Has to be at least 1 minute",
|
||||||
"disabled": "Deaktiviert",
|
"disabled": "Deaktiviert",
|
||||||
"disabledHelpText": "Aktiviere oder deaktiviere das Git Plugin",
|
"disabledHelpText": "Aktiviere oder deaktiviere das Git Plugin",
|
||||||
"submit": "Speichern"
|
"submit": "Speichern"
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
"defaultBranch": "Default Branch",
|
"defaultBranch": "Default Branch",
|
||||||
"defaultBranchHelpText": "This name will be used for the initialization of new git repositories. It has no effect otherwise (especially this cannot change the initial branch name for empty repositories).",
|
"defaultBranchHelpText": "This name will be used for the initialization of new git repositories. It has no effect otherwise (especially this cannot change the initial branch name for empty repositories).",
|
||||||
"defaultBranchValidationError": "This is not a valid branch name",
|
"defaultBranchValidationError": "This is not a valid branch name",
|
||||||
|
"lfsWriteAuthorizationExpirationInMinutes": "LFS authorization expiration",
|
||||||
|
"lfsWriteAuthorizationExpirationInMinutesHelpText": "Expiration time of the authorization token generated for LFS put requests in minutes. If SCM-Manager is run behind a reverse proxy that buffers http requests (eg. Nginx), this should set up to the time, an LFS upload may take at maximum.",
|
||||||
|
"lfsWriteAuthorizationExpirationInMinutesValidationError": "Has to be at least 1 minute",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"disabledHelpText": "Enable or disable the Git plugin",
|
"disabledHelpText": "Enable or disable the Git plugin",
|
||||||
"submit": "Submit"
|
"submit": "Submit"
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import sonia.scm.repository.RepositoryManager;
|
|||||||
import sonia.scm.store.ConfigurationStore;
|
import sonia.scm.store.ConfigurationStore;
|
||||||
import sonia.scm.store.ConfigurationStoreFactory;
|
import sonia.scm.store.ConfigurationStoreFactory;
|
||||||
import sonia.scm.web.GitVndMediaType;
|
import sonia.scm.web.GitVndMediaType;
|
||||||
|
import sonia.scm.web.JsonMockHttpRequest;
|
||||||
import sonia.scm.web.RestDispatcher;
|
import sonia.scm.web.RestDispatcher;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletResponse;
|
import javax.servlet.http.HttpServletResponse;
|
||||||
@@ -127,10 +128,12 @@ public class GitConfigResourceTest {
|
|||||||
|
|
||||||
String responseString = response.getContentAsString();
|
String responseString = response.getContentAsString();
|
||||||
|
|
||||||
assertTrue(responseString.contains("\"disabled\":false"));
|
assertThat(responseString)
|
||||||
assertTrue(responseString.contains("\"gcExpression\":\"valid Git GC Cron Expression\""));
|
.contains("\"disabled\":false")
|
||||||
assertTrue(responseString.contains("\"self\":{\"href\":\"/v2/config/git"));
|
.contains("\"gcExpression\":\"valid Git GC Cron Expression\"")
|
||||||
assertTrue(responseString.contains("\"update\":{\"href\":\"/v2/config/git"));
|
.contains("\"self\":{\"href\":\"/v2/config/git")
|
||||||
|
.contains("\"update\":{\"href\":\"/v2/config/git")
|
||||||
|
.contains("\"lfsWriteAuthorizationExpirationInMinutes\":5");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -324,9 +327,9 @@ public class GitConfigResourceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private MockHttpResponse put() throws URISyntaxException {
|
private MockHttpResponse put() throws URISyntaxException {
|
||||||
MockHttpRequest request = MockHttpRequest.put("/" + GitConfigResource.GIT_CONFIG_PATH_V2)
|
JsonMockHttpRequest request = JsonMockHttpRequest.put("/" + GitConfigResource.GIT_CONFIG_PATH_V2)
|
||||||
.contentType(GitVndMediaType.GIT_CONFIG)
|
.contentType(GitVndMediaType.GIT_CONFIG)
|
||||||
.content("{\"disabled\":true, \"defaultBranch\":\"main\"}".getBytes());
|
.json("{'disabled':true, 'defaultBranch':'main', 'lfsWriteAuthorizationExpirationInMinutes':5}");
|
||||||
|
|
||||||
MockHttpResponse response = new MockHttpResponse();
|
MockHttpResponse response = new MockHttpResponse();
|
||||||
dispatcher.invoke(request, response);
|
dispatcher.invoke(request, response);
|
||||||
@@ -337,6 +340,7 @@ public class GitConfigResourceTest {
|
|||||||
GitConfig config = new GitConfig();
|
GitConfig config = new GitConfig();
|
||||||
config.setGcExpression("valid Git GC Cron Expression");
|
config.setGcExpression("valid Git GC Cron Expression");
|
||||||
config.setDisabled(false);
|
config.setDisabled(false);
|
||||||
|
config.setLfsWriteAuthorizationExpirationInMinutes(5);
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.web.lfs;
|
||||||
|
|
||||||
|
import org.apache.shiro.authz.UnauthorizedException;
|
||||||
|
import org.github.sdorra.jse.ShiroExtension;
|
||||||
|
import org.github.sdorra.jse.SubjectAware;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Answers;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import sonia.scm.repository.GitConfig;
|
||||||
|
import sonia.scm.repository.GitRepositoryHandler;
|
||||||
|
import sonia.scm.repository.Repository;
|
||||||
|
import sonia.scm.repository.RepositoryTestData;
|
||||||
|
import sonia.scm.security.AccessToken;
|
||||||
|
import sonia.scm.security.AccessTokenBuilder;
|
||||||
|
import sonia.scm.security.AccessTokenBuilderFactory;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@ExtendWith(ShiroExtension.class)
|
||||||
|
class LfsAccessTokenFactoryTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AccessTokenBuilderFactory tokenBuilderFactory;
|
||||||
|
@Mock(answer = Answers.RETURNS_SELF)
|
||||||
|
private AccessTokenBuilder tokenBuilder;
|
||||||
|
@Mock
|
||||||
|
private GitRepositoryHandler handler;
|
||||||
|
@InjectMocks
|
||||||
|
private LfsAccessTokenFactory factory;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AccessToken createdTokenFromMock;
|
||||||
|
|
||||||
|
private Repository repository = RepositoryTestData.createHeartOfGold();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void initRepository() {
|
||||||
|
repository.setId("42");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class WithPermissions {
|
||||||
|
@BeforeEach
|
||||||
|
void initTokenBuilder() {
|
||||||
|
when(tokenBuilderFactory.create()).thenReturn(tokenBuilder);
|
||||||
|
when(tokenBuilder.build()).thenReturn(createdTokenFromMock);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SubjectAware(
|
||||||
|
value = "trillian",
|
||||||
|
permissions = "repository:read,pull:42"
|
||||||
|
)
|
||||||
|
void shouldCreateReadToken() {
|
||||||
|
AccessToken readAccessToken = factory.createReadAccessToken(repository);
|
||||||
|
|
||||||
|
assertThat(readAccessToken).isSameAs(createdTokenFromMock);
|
||||||
|
|
||||||
|
verify(tokenBuilder).expiresIn(5, TimeUnit.MINUTES);
|
||||||
|
verify(tokenBuilder).scope(argThat(scope -> {
|
||||||
|
assertThat(scope.iterator()).toIterable()
|
||||||
|
.containsExactly("repository:read:42", "repository:pull:42");
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SubjectAware(
|
||||||
|
value = "trillian",
|
||||||
|
permissions = "repository:read,pull,push:42"
|
||||||
|
)
|
||||||
|
void shouldCreateReadTokenWithPushIfPermitted() {
|
||||||
|
AccessToken readAccessToken = factory.createReadAccessToken(repository);
|
||||||
|
|
||||||
|
assertThat(readAccessToken).isSameAs(createdTokenFromMock);
|
||||||
|
|
||||||
|
verify(tokenBuilder).expiresIn(5, TimeUnit.MINUTES);
|
||||||
|
verify(tokenBuilder).scope(argThat(scope -> {
|
||||||
|
assertThat(scope.iterator()).toIterable()
|
||||||
|
.containsExactly("repository:read:42", "repository:pull:42", "repository:push:42");
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SubjectAware(
|
||||||
|
value = "trillian",
|
||||||
|
permissions = "repository:read,pull,push:42"
|
||||||
|
)
|
||||||
|
void shouldCreateWriteToken() {
|
||||||
|
GitConfig config = new GitConfig();
|
||||||
|
config.setLfsWriteAuthorizationExpirationInMinutes(23);
|
||||||
|
when(handler.getConfig()).thenReturn(config);
|
||||||
|
|
||||||
|
AccessToken writeAccessToken = factory.createWriteAccessToken(repository);
|
||||||
|
|
||||||
|
assertThat(writeAccessToken).isSameAs(createdTokenFromMock);
|
||||||
|
|
||||||
|
verify(tokenBuilder).expiresIn(23, TimeUnit.MINUTES);
|
||||||
|
verify(tokenBuilder).scope(argThat(scope -> {
|
||||||
|
assertThat(scope.iterator()).toIterable()
|
||||||
|
.containsExactly("repository:read:42", "repository:pull:42", "repository:push:42");
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SubjectAware(
|
||||||
|
value = "trillian",
|
||||||
|
permissions = "repository:read:42"
|
||||||
|
)
|
||||||
|
void shouldFailToCreateReadTokenWithoutPullPermission() {
|
||||||
|
assertThrows(UnauthorizedException.class, () -> factory.createReadAccessToken(repository));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SubjectAware(
|
||||||
|
value = "trillian",
|
||||||
|
permissions = "repository:pull:42"
|
||||||
|
)
|
||||||
|
void shouldFailToCreateReadTokenWithoutReadPermission() {
|
||||||
|
assertThrows(UnauthorizedException.class, () -> factory.createReadAccessToken(repository));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SubjectAware(
|
||||||
|
value = "trillian",
|
||||||
|
permissions = "repository:pull,push:42"
|
||||||
|
)
|
||||||
|
void shouldFailToCreateWriteTokenWithoutReadPermission() {
|
||||||
|
assertThrows(UnauthorizedException.class, () -> factory.createWriteAccessToken(repository));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SubjectAware(
|
||||||
|
value = "trillian",
|
||||||
|
permissions = "repository:read,push:42"
|
||||||
|
)
|
||||||
|
void shouldFailToCreateWriteTokenWithoutPullPermission() {
|
||||||
|
assertThrows(UnauthorizedException.class, () -> factory.createWriteAccessToken(repository));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SubjectAware(
|
||||||
|
value = "trillian",
|
||||||
|
permissions = "repository:read,pull:42"
|
||||||
|
)
|
||||||
|
void shouldFailToCreateWriteTokenWithoutPushPermission() {
|
||||||
|
assertThrows(UnauthorizedException.class, () -> factory.createWriteAccessToken(repository));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user