Merge with upstream

This commit is contained in:
Florian Scholdei
2020-10-08 00:47:45 +02:00
69 changed files with 3031 additions and 1020 deletions

View File

@@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Align actionbar item horizontal and enforce correct margin between them ([#1358](https://github.com/scm-manager/scm-manager/pull/1358)) - Align actionbar item horizontal and enforce correct margin between them ([#1358](https://github.com/scm-manager/scm-manager/pull/1358))
## [2.6.1] - 2020-09-30 ## [2.6.1] - 2020-09-30
### Added
- Users can create API keys with limited permissions ([#1359](https://github.com/scm-manager/scm-manager/pull/1359))
### Fixed ### Fixed
- Not found error when using browse command in empty hg repository ([#1355](https://github.com/scm-manager/scm-manager/pull/1355)) - Not found error when using browse command in empty hg repository ([#1355](https://github.com/scm-manager/scm-manager/pull/1355))
@@ -23,6 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Permissions can be specified for namespaces ([#1335](https://github.com/scm-manager/scm-manager/pull/1335)) - Permissions can be specified for namespaces ([#1335](https://github.com/scm-manager/scm-manager/pull/1335))
- Show update info on admin information page ([#1342](https://github.com/scm-manager/scm-manager/pull/1342)) - Show update info on admin information page ([#1342](https://github.com/scm-manager/scm-manager/pull/1342))
### Changed
- Rework modal to use react portal ([#1349](https://github.com/scm-manager/scm-manager/pull/1349))
### Fixed ### Fixed
- Missing synchronization during repository creation ([#1328](https://github.com/scm-manager/scm-manager/pull/1328)) - Missing synchronization during repository creation ([#1328](https://github.com/scm-manager/scm-manager/pull/1328))
- Missing BranchCreatedEvent for mercurial ([#1334](https://github.com/scm-manager/scm-manager/pull/1334)) - Missing BranchCreatedEvent for mercurial ([#1334](https://github.com/scm-manager/scm-manager/pull/1334))

View File

@@ -4,3 +4,4 @@
- /user/user/ - /user/user/
- /user/group/ - /user/group/
- /user/admin/ - /user/admin/
- /user/profile/

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -0,0 +1,50 @@
---
title: Profil
partiallyActive: true
---
Über den Link zum Profil im Footer können Einstellungen zum eigenen Konto vorgenommen werden.
## Passwort ändern
Hier kann das Passwort für das Konto geändert werden, wenn es sich um ein lokales Konto handelt (wenn die Anmeldung
also nicht über ein Fremdsystem erfolgt). Um die Änderung zu autorisieren, muss zunächst das aktuelle Passwort
eingegeben werden. Danach muss das neue Passwort zweimal eingegeben werden.
## Öffentliche Schlüssel
Zum Prüfen von Signaturen für z. B. Commits können hier die entsprechenden öffentlichen Schlüssel hinterlegt werden.
Zudem können hier die vom SCM-Manager erstellten Signaturschlüssel heruntergeladen werden.
## API Schlüssel
Zur Nutzung in anderen Systemen wie z. B. CI Systemen können sogenannte API Schlüssel erstellt werden. Sie können für
den Zugriff auf Repositories über die REST API sowie über SCM-Clients genutzt werden. Dazu wird ein Anzeigename sowie
eine [Rolle](../admin/roles/) ausgewählt. Der Anzeigename ist ausschließlich zur Verwaltung gedacht und hat keine
weitere technische Bewandnis. Mithilfe der Rolle können die Berechtigungen eingeschränkt werden, die bei einer Anmeldung
zur Verfügung stehen.
Hat z. B. ein Konto schreibende Rechte für ein Repository und wird ein API-Schlüssel mit der Rolle "READ" erzeugt, so
kann über diesen Schlüssel nur lesend auf das Repository zugegriffen werden. Eine Ausweitung der Rechte hingegen ist
selbstverständlich nicht möglich. Es kann also mithilfe eines API-Schlüssels mit der Rolle "WRITE" nicht schreibend auf
ein Repository zugegriffen werden, für das bei dem Konto nur ein lesender Zugriff gestattet ist.
![API Key Overview](assets/api-key-overview.png)
Nach der Erstellung eines Schlüssels, wird dieser **einmalig** angezeigt. Nachdem dieses Fenster
geschlossen wurde, kann der Schlüssel nicht mehr abgerufen und nicht wiederhergestellt werden.
![API Key Created](assets/api-key-created.png)
### Beispiel REST API
Um einen Schlüssel mit der REST API zu nutzen, muss der Schlüssel als Cookie mit dem Namen „X-Bearer-Token“
übergeben werden. Für die Nutzung mit curl sieht ein Aufruf z. B. wie folgt aus:
```
curl -v localhost:8081/scm/api/v2/repositories/ -H "Cookie: X-Bearer-Token=eyJhcGlLZXlJZCI...RTRHeCJ9"
```
### Zugriff mit SCM-Client
Für einen Zugriff mit einem SCM-Client (z. B. `git`, `hg` oder `svn`) muss der Schlüssel als Passwort übergeben werden.

View File

@@ -12,6 +12,7 @@
- /user/user/ - /user/user/
- /user/group/ - /user/group/
- /user/admin/ - /user/admin/
- /user/profile/
- section: Administration - section: Administration
entries: entries:

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -0,0 +1,49 @@
---
title: Profile
partiallyActive: true
---
Settings for the active user account can be managed with the link "Profile" in the footer.
## Change password
Here the password for the current account can be changed when it is a local account (when the login is not managed by an
external system). To authorize the change, the current password has to be put first. Then the new password has to be
entered twice.
## Öffentliche Schlüssel
To check signatures for example for commits, public keys can be stored here. Additionally the keys created by
SCM-Manager can be accessed here, too.
## API keys
To access SCM-Manager from other systems like for example CI servers, API keys can be created. They can be used to call
the REST APi and for the access with SCM clients. To create a key you have to specify a display name and a
[role](../admin/roles/). The display name is solely to keep track of your keys. The role limits the permissions granted
when the SCM-Manager is accessed with such a key.
If, for exapmple, an account has write access for a repository and an API key with the role "READ" is created for this
account, this repository can only be accessed read only using this key. Of course it is not possible to extend
permissions. So you cannot create an API key with the role "WRITE" to get write access to a repository, where the
original account has only read access for.
![API Key Overview](assets/api-key-overview.png)
After the creation of a key, it will be displayed **once only**. After the window has been closed, the key cannot be
retrieved or reconstructed again.
![API Key Created](assets/api-key-created.png)
### Example for the REST API
To use an API key for the REST API, the key has to sent as a cookie with the name “X-Bearer-Token”. Using curl, this
can be done like this:
```
curl -v localhost:8081/scm/api/v2/repositories/ -H "Cookie: X-Bearer-Token=eyJhcGlLZXlJZCI...RTRHeCJ9"
```
### Access with an SCM-Client
For access with an SCM client like `git`, `hg`, or `svn` the key simply has to be passed as a password.

View File

@@ -905,7 +905,7 @@
<!-- test libraries --> <!-- test libraries -->
<mockito.version>3.5.10</mockito.version> <mockito.version>3.5.10</mockito.version>
<hamcrest.version>2.1</hamcrest.version> <hamcrest.version>2.1</hamcrest.version>
<junit.version>5.6.2</junit.version> <junit.version>5.7.0</junit.version>
<!-- logging libraries --> <!-- logging libraries -->
<slf4j.version>1.7.30</slf4j.version> <slf4j.version>1.7.30</slf4j.version>

View File

@@ -50,7 +50,7 @@ import java.security.Principal;
@StaticPermissions( @StaticPermissions(
value = "user", value = "user",
globalPermissions = {"create", "list", "autocomplete"}, globalPermissions = {"create", "list", "autocomplete"},
permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys"}, permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys", "changeApiKeys"},
custom = true, customGlobal = true custom = true, customGlobal = true
) )
@XmlRootElement(name = "users") @XmlRootElement(name = "users")

View File

@@ -85,6 +85,9 @@ public class VndMediaType {
public static final String REPOSITORY_ROLE = PREFIX + "repositoryRole" + SUFFIX; public static final String REPOSITORY_ROLE = PREFIX + "repositoryRole" + SUFFIX;
public static final String REPOSITORY_ROLE_COLLECTION = PREFIX + "repositoryRoleCollection" + SUFFIX; public static final String REPOSITORY_ROLE_COLLECTION = PREFIX + "repositoryRoleCollection" + SUFFIX;
public static final String API_KEY = PREFIX + "apiKey" + SUFFIX;
public static final String API_KEY_COLLECTION = PREFIX + "apiKeyCollection" + SUFFIX;
private VndMediaType() { private VndMediaType() {
} }

View File

@@ -0,0 +1,115 @@
/*
* 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.it;
import io.restassured.RestAssured;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import sonia.scm.it.utils.RepositoryUtil;
import sonia.scm.it.utils.RestUtil;
import sonia.scm.it.utils.TestData;
import sonia.scm.repository.client.api.RepositoryClient;
import sonia.scm.repository.client.api.RepositoryClientException;
import sonia.scm.web.VndMediaType;
import javax.ws.rs.core.MediaType;
import java.io.IOException;
import java.util.Objects;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static sonia.scm.it.utils.RepositoryUtil.addAndCommitRandomFile;
import static sonia.scm.it.utils.RestUtil.given;
import static sonia.scm.it.utils.TestData.WRITE;
public class ApiKeyITCase {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Before
public void prepareEnvironment() {
TestData.createDefault();
TestData.createNotAdminUser("user", "user");
TestData.createUserPermission("user", WRITE, "git");
}
@After
public void cleanup() {
TestData.cleanup();
}
@Test
public void shouldCloneWithRestrictedApiKey() throws IOException {
String passphrase = registerApiKey();
RepositoryClient client = RepositoryUtil.createRepositoryClient("git", temporaryFolder.newFolder(), "user", passphrase);
assertEquals(1, Objects.requireNonNull(client.getWorkingCopy().list()).length);
}
@Test
public void shouldFailToCommit() throws IOException {
String passphrase = registerApiKey();
RepositoryClient client = RepositoryUtil.createRepositoryClient("git", temporaryFolder.newFolder(), "user", passphrase);
assertThrows(RepositoryClientException.class, () -> addAndCommitRandomFile(client, "user"));
}
public String registerApiKey() {
String apiKeysUrl = given(VndMediaType.ME, "user", "user")
.when()
.get(RestUtil.createResourceUrl("me/"))
.then()
.statusCode(200)
.extract()
.body().jsonPath().getString("_links.apiKeys.href");
String createUrl = given(VndMediaType.API_KEY_COLLECTION)
.when()
.get(apiKeysUrl)
.then()
.statusCode(200)
.extract()
.body().jsonPath().getString("_links.create.href");
String passphrase = new String(RestAssured.given()
.contentType(VndMediaType.API_KEY)
.accept(MediaType.TEXT_PLAIN)
.auth().preemptive().basic("user", "user")
.when()
.body("{\"displayName\":\"integration test\",\"permissionRole\":\"READ\"}")
.post(createUrl)
.then()
.statusCode(201)
.extract()
.body()
.asByteArray());
return passphrase;
}
}

View File

@@ -47242,575 +47242,30 @@ exports[`Storyshots MarkdownView Xml Code Block 1`] = `
</div> </div>
`; `;
exports[`Storyshots Modal|ConfirmAlert Default 1`] = ` exports[`Storyshots Modal|ConfirmAlert Default 1`] = `null`;
<div
className="modal is-active" exports[`Storyshots Modal|ConfirmAlert WithButton 1`] = `
> Array [
<div
className="modal-background"
/>
<div
className="modal-card"
>
<header
className="modal-card-head has-background-light"
>
<p
className="modal-card-title is-marginless"
>
Are you sure about that?
</p>
<button <button
aria-label="close"
className="delete"
onClick={[Function]} onClick={[Function]}
/>
</header>
<section
className="modal-card-body"
>
Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
hostesss really oblong Infinite Improbability thing into the starship against which behavior accordance with
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
ordinary mob.
</section>
<footer
className="modal-card-foot"
> >
Open ConfirmAlert
</button>,
<div <div
className="field is-grouped" id="modalRoot"
> />,
<p ]
className="control"
>
<a
className="button is-info is-outlined"
onClick={[Function]}
>
Cancel
</a>
</p>
<p
className="control"
>
<a
className="button is-info"
onClick={[Function]}
>
Submit
</a>
</p>
</div>
</footer>
</div>
</div>
`; `;
exports[`Storyshots Modal|Modal Closeable 1`] = ` exports[`Storyshots Modal|Modal Closeable 1`] = `null`;
<div
className="modal is-active"
>
<div
className="modal-background"
/>
<div
className="modal-card"
>
<header
className="modal-card-head has-background-light"
>
<p
className="modal-card-title is-marginless"
>
Hitchhiker Modal
</p>
<button
aria-label="close"
className="delete"
onClick={[Function]}
/>
</header>
<section
className="modal-card-body"
>
<p>
Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
hostesss really oblong Infinite Improbability thing into the starship against which behavior accordance.with
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
ordinary mob.
</p>
</section>
</div>
</div>
`;
exports[`Storyshots Modal|Modal Default 1`] = ` exports[`Storyshots Modal|Modal Default 1`] = `null`;
<div
className="modal is-active"
>
<div
className="modal-background"
/>
<div
className="modal-card"
>
<header
className="modal-card-head has-background-light"
>
<p
className="modal-card-title is-marginless"
>
Hitchhiker Modal
</p>
<button
aria-label="close"
className="delete"
onClick={[Function]}
/>
</header>
<section
className="modal-card-body"
>
<p>
Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
hostesss really oblong Infinite Improbability thing into the starship against which behavior accordance.with
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
ordinary mob.
</p>
</section>
</div>
</div>
`;
exports[`Storyshots Modal|Modal Long content 1`] = ` exports[`Storyshots Modal|Modal Long content 1`] = `null`;
<div
className="modal is-active"
>
<div
className="modal-background"
/>
<div
className="modal-card"
>
<header
className="modal-card-head has-background-light"
>
<p
className="modal-card-title is-marginless"
>
Hitchhiker Modal
</p>
<button
aria-label="close"
className="delete"
onClick={[Function]}
/>
</header>
<section
className="modal-card-body"
>
<h1
className="title"
>
Marvin
</h1>
<h2
className="subtitle"
>
The Paranoid Android
</h2>
<hr />
<div
className="notification is-info"
>
The following content comes from the awesome exports[`Storyshots Modal|Modal With form elements 1`] = `null`;
<a exports[`Storyshots Modal|Modal With long tooltips 1`] = `null`;
href="https://hitchhikers.fandom.com/wiki/Main_Page"
rel="noopener noreferrer"
target="_blank"
>
Hitchhikers Wiki
</a>
</div>
<hr />
<div
className="has-text-centered"
>
<img
alt="Marvin"
src="https://vignette.wikia.nocookie.net/hitchhikers/images/a/a4/Marvin.jpg/revision/latest/scale-to-width-down/150?cb=20100530114055"
/>
</div>
<hr />
<p
className="content"
>
Marvin, more fully known as Marvin the Paranoid Android, is an incredibly brilliant but overwhelmingly depressed robot manufactured by the Sirius Cybernetics Corporation and unwilling servant to the crew of the Heart of Gold.
</p>
<hr />
<div
className="content"
>
<h4>
Physical Appearance
</h4>
<p>
In the novels, Marvin is described thusly: "...though it was beautifully constructed and polished it looked somehow as if the various parts of its more or less humanoid body didn't quite fit properly. In fact, they fit perfectly well, but something in its bearing suggested that they might have fitted better."
</p>
<p>
On the radio show, there's no physical description of Marvin, though his voice is digitally altered to sound more robotic, and any scene that focuses on him is accompanied by sounds of mechanical clanking and hissing.
</p>
<p>
In the TV series, Marvin is built in the style of a 1950's robot similar to Robbie the Robot from Forbidden Planet or Twiki from Buck Rogers. His body is blocky and angular, with a pair of clamp-claw hands, shuffling feet and a squarish head with a dour face.
</p>
<p>
In the movie, Marvin is a short, stout robot built of smooth, white plastic. His arms are much longer than his legs, and his head is a massive sphere with only a pair of triangle eyes for a face. His large head and simian-like proportions give Marvin a perpetual slouch, adding to his melancholy personality. At the start of the film his eyes glow, but at the end he is shot but unharmed, leaving a hole in his head and dimming his eyes. This is probably the most depressing and unacceptable manifestation of Marvin ever conceived, and thus paradoxically the most accurate.
</p>
</div>
<hr />
<div
className="content"
>
<h4>
Personality
</h4>
<p>
Marvin the robot has a prototype version of the Genuine People Personality (GPP) software from SCC, allowing him sentience and the ability to feel emotions and develop a personality. He's also incredibly smart, having a "brain the size of a planet" capable of computing extremely complex mathematics, as well as solving difficult problems and operating high-tech devices.
</p>
<p>
However, despite being so smart, Marvin is typically made to perform menial tasks and labour such as escorting people, opening doors, picking up pieces of paper, and other tasks well beneath his skills. Even extremely hard tasks, such as computing for the vast Krikkit robot army, are trivial for Marvin. All this leaves him extremely bored, frustrated, and overwhelmingly depressed. Because of this, all modern GPP-capable machines, such as Eddie the computer and the Heart of Gold's automatic doors, are programmed to be extremely cheerful and happy, much to Marvin's disgust.
</p>
<p>
Marvin hates everyone and everything he comes into contact with, having no respect for anybody and will criticise and insult others at any opportunity, or otherwise rant and complain for hours on end about his own problems, such as the terrible pain he suffers in all the diodes down his left side. His contempt for everyone is often justified, as almost every person he comes across, even those who consider him a friend, (such as Arthur and Trillian, who treat him more kindly than Ford and Zaphod) treat Marvin as an expendable servant, even sending him to his death more than once (such as when Zaphod ordered Marvin to fight the gigantic, heavy-duty Frogstar Scout Robot Class D so he could escape). Being a robot, he still does what he's told (he won't enjoy it, nor will he let you forget it, but he'll do it anyway), though he'd much rather sulk in a corner by himself.
</p>
<p>
Several times in the series Marvin ends up alone and isolated for extremely long periods of time, sometimes spanning millions of years, either by sheer bad luck (such as the explosion that propelled everyone but Marvin to Milliways in the far-off future) or because his unpleasantly depressing personality drives them away or, in more than one case, makes them commit suicide. In his spare time (which he has a lot of), Marvin will attempt to occupy himself by composing songs and writing poetry. Of course, none of them are particularly cheerful, or even that good.
</p>
</div>
</section>
</div>
</div>
`;
exports[`Storyshots Modal|Modal With form elements 1`] = `
<div
className="modal is-active"
>
<div
className="modal-background"
/>
<div
className="modal-card"
>
<header
className="modal-card-head has-background-light"
>
<p
className="modal-card-title is-marginless"
>
Hitchhiker Modal
</p>
<button
aria-label="close"
className="delete"
onClick={[Function]}
/>
</header>
<section
className="modal-card-body"
>
<div
className="Modalstories__RadioList-sc-2lb0wg-1 bDdhEj"
>
<label
className="Radio__StyledRadio-ays4vp-0 cKfIQb radio"
>
<input
checked={true}
onChange={[Function]}
type="radio"
/>
One
<span
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 OUPvb is-inline-block has-tooltip-multiline"
data-tooltip="The first one"
>
<i
className="fas fa-question-circle has-text-blue-light"
/>
</span>
</label>
<label
className="Radio__StyledRadio-ays4vp-0 cKfIQb radio"
>
<input
checked={false}
onChange={[Function]}
type="radio"
/>
Two
<span
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 OUPvb is-inline-block has-tooltip-multiline"
data-tooltip="The second one"
>
<i
className="fas fa-question-circle has-text-blue-light"
/>
</span>
</label>
</div>
<hr />
<p>
Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
hostesss really oblong Infinite Improbability thing into the starship against which behavior accordance.with
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
ordinary mob.
</p>
<hr />
<div
className="field"
>
<label
className="label"
>
Text
</label>
<div
className="control"
>
<textarea
className="textarea"
disabled={false}
onChange={[Function]}
onKeyDown={[Function]}
/>
</div>
</div>
<hr />
<div
className="field is-grouped"
>
<div
className="control"
>
<button
className="button is-default"
onClick={[Function]}
type="button"
>
One
</button>
</div>
<div
className="control"
>
<button
className="button is-default"
onClick={[Function]}
type="button"
>
Two
</button>
</div>
</div>
</section>
</div>
</div>
`;
exports[`Storyshots Modal|Modal With long tooltips 1`] = `
<div
className="modal is-active"
>
<div
className="modal-background"
/>
<div
className="modal-card"
>
<header
className="modal-card-head has-background-light"
>
<p
className="modal-card-title is-marginless"
>
Hitchhiker Modal
</p>
<button
aria-label="close"
className="delete"
onClick={[Function]}
/>
</header>
<section
className="modal-card-body"
>
<div
className="notification is-info"
>
This story exists because we had a problem, that long tooltips causes a horizontal scrollbar on the modal.
</div>
<hr />
<p>
The following elements will have a verly long help text, which has triggered the scrollbar in the past.
</p>
<hr />
<div
className="Modalstories__TopAndBottomMargin-sc-2lb0wg-0 cBXOgT"
>
<div
className="field"
>
<div
className="control"
onClick={[Function]}
onKeyDown={[Function]}
>
<label
className="checkbox"
>
<span
className="gwt-Anchor"
tabIndex={0}
>
<i
className="is-outlined fa-check-square has-text-link fa"
/>
</span>
Checkbox
<span
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 OUPvb is-inline-block has-tooltip-multiline"
data-tooltip="Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
hostesss really oblong Infinite Improbability thing into the starship against which behavior accordance.with
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
ordinary mob."
>
<i
className="fas fa-question-circle has-text-blue-light"
/>
</span>
</label>
</div>
</div>
</div>
<hr />
<div
className="Modalstories__TopAndBottomMargin-sc-2lb0wg-0 cBXOgT"
>
<label
className="Radio__StyledRadio-ays4vp-0 cKfIQb radio"
>
<input
checked={false}
onChange={[Function]}
type="radio"
/>
Radio button
<span
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 OUPvb is-inline-block has-tooltip-multiline"
data-tooltip="Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
hostesss really oblong Infinite Improbability thing into the starship against which behavior accordance.with
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
ordinary mob."
>
<i
className="fas fa-question-circle has-text-blue-light"
/>
</span>
</label>
</div>
<hr />
<div
className="Modalstories__TopAndBottomMargin-sc-2lb0wg-0 cBXOgT"
>
<div
className="field"
>
<label
className="label"
>
Input
<span
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 OUPvb is-inline-block has-tooltip-multiline"
data-tooltip="Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
hostesss really oblong Infinite Improbability thing into the starship against which behavior accordance.with
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
ordinary mob."
>
<i
className="fas fa-question-circle has-text-blue-light"
/>
</span>
</label>
<div
className="control"
>
<input
className="input"
onChange={[Function]}
onKeyPress={[Function]}
placeholder=""
type="text"
/>
</div>
</div>
</div>
<hr />
<div
className="Modalstories__TopAndBottomMargin-sc-2lb0wg-0 cBXOgT"
>
<div
className="field"
>
<label
className="label"
>
Textarea
<span
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 OUPvb is-inline-block has-tooltip-multiline"
data-tooltip="Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
hostesss really oblong Infinite Improbability thing into the starship against which behavior accordance.with
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
ordinary mob."
>
<i
className="fas fa-question-circle has-text-blue-light"
/>
</span>
</label>
<div
className="control"
>
<textarea
className="textarea"
disabled={false}
onChange={[Function]}
onKeyDown={[Function]}
/>
</div>
</div>
</div>
<hr />
<p>
If this modal has no horizontal scrollbar the issue is fixed
</p>
</section>
</div>
</div>
`;
exports[`Storyshots Navigation|Secondary Active when match 1`] = ` exports[`Storyshots Navigation|Secondary Active when match 1`] = `
<div <div

View File

@@ -100,6 +100,7 @@ const Footer: FC<Props> = ({ me, version, links }) => {
<NavLink to="/me" label={t("footer.user.profile")} testId="footer-user-profile" /> <NavLink to="/me" label={t("footer.user.profile")} testId="footer-user-profile" />
{me?._links?.password && <NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />} {me?._links?.password && <NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />}
{me?._links?.publicKeys && <NavLink to="/me/settings/publicKeys" label={t("profile.publicKeysNavLink")} />} {me?._links?.publicKeys && <NavLink to="/me/settings/publicKeys" label={t("profile.publicKeysNavLink")} />}
{me?._links?.apiKeys && <NavLink to="/me/settings/apiKeys" label={t("profile.apiKeysNavLink")} />}
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} /> <ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
</FooterSection> </FooterSection>
<FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}> <FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}>

View File

@@ -25,7 +25,7 @@
import { storiesOf } from "@storybook/react"; import { storiesOf } from "@storybook/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import * as React from "react"; import * as React from "react";
import ConfirmAlert from "./ConfirmAlert"; import ConfirmAlert, { confirmAlert } from "./ConfirmAlert";
const body = const body =
"Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows\n " + "Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows\n " +
@@ -40,11 +40,21 @@ const buttons = [
onClick: () => null onClick: () => null
}, },
{ {
label: "Submit", label: "Submit"
onClick: () => {}
} }
]; ];
storiesOf("Modal|ConfirmAlert", module) storiesOf("Modal|ConfirmAlert", module)
.addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>) .addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
.add("Default", () => <ConfirmAlert message={body} title={"Are you sure about that?"} buttons={buttons} />); .add("Default", () => <ConfirmAlert message={body} title={"Are you sure about that?"} buttons={buttons} />)
.add("WithButton", () => {
const buttonClick = () => {
confirmAlert({ message: body, title: "Are you sure about that?", buttons });
};
return (
<>
<button onClick={buttonClick}>Open ConfirmAlert</button>
<div id="modalRoot" />
</>
);
});

View File

@@ -22,6 +22,7 @@
* SOFTWARE. * SOFTWARE.
*/ */
import * as React from "react"; import * as React from "react";
import { FC, useState } from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import Modal from "./Modal"; import Modal from "./Modal";
import classNames from "classnames"; import classNames from "classnames";
@@ -29,33 +30,34 @@ import classNames from "classnames";
type Button = { type Button = {
className?: string; className?: string;
label: string; label: string;
onClick: () => void | null; onClick?: () => void | null;
}; };
type Props = { type Props = {
title: string; title: string;
message: string; message: string;
buttons: Button[]; buttons: Button[];
close?: () => void;
}; };
class ConfirmAlert extends React.Component<Props> { export const ConfirmAlert: FC<Props> = ({ title, message, buttons, close }) => {
handleClickButton = (button: Button) => { const [showModal, setShowModal] = useState(true);
const onClose = () => {
if (typeof close === "function") {
close();
} else {
setShowModal(false);
}
};
const handleClickButton = (button: Button) => {
if (button.onClick) { if (button.onClick) {
button.onClick(); button.onClick();
} }
this.close(); onClose();
}; };
close = () => {
const container = document.getElementById("modalRoot");
if (container) {
ReactDOM.unmountComponentAtNode(container);
}
};
render() {
const { title, message, buttons } = this.props;
const body = <>{message}</>; const body = <>{message}</>;
const footer = ( const footer = (
@@ -65,7 +67,7 @@ class ConfirmAlert extends React.Component<Props> {
<a <a
className={classNames("button", "is-info", button.className)} className={classNames("button", "is-info", button.className)}
key={i} key={i}
onClick={() => this.handleClickButton(button)} onClick={() => handleClickButton(button)}
> >
{button.label} {button.label}
</a> </a>
@@ -74,14 +76,25 @@ class ConfirmAlert extends React.Component<Props> {
</div> </div>
); );
return <Modal title={title} closeFunction={() => this.close()} body={body} active={true} footer={footer} />; return (
} (showModal && <Modal title={title} closeFunction={onClose} body={body} active={true} footer={footer} />) || null
} );
};
/**
* @deprecated Please use {@link ConfirmAlert} directly.
*/
export function confirmAlert(properties: Props) { export function confirmAlert(properties: Props) {
const root = document.getElementById("modalRoot"); const root = document.getElementById("modalRoot");
if (root) { if (root) {
ReactDOM.render(<ConfirmAlert {...properties} />, root); const close = () => {
const container = document.getElementById("modalRoot");
if (container) {
ReactDOM.unmountComponentAtNode(container);
}
};
const props = { ...properties, close };
ReactDOM.render(<ConfirmAlert {...props} />, root);
} }
} }

View File

@@ -22,7 +22,10 @@
* SOFTWARE. * SOFTWARE.
*/ */
import * as React from "react"; import * as React from "react";
import {FC} from "react";
import classNames from "classnames"; import classNames from "classnames";
import usePortalRootElement from "../usePortalRootElement";
import ReactDOM from "react-dom";
type Props = { type Props = {
title: string; title: string;
@@ -31,16 +34,15 @@ type Props = {
footer?: any; footer?: any;
active: boolean; active: boolean;
className?: string; className?: string;
headColor: string; headColor?: string;
}; };
class Modal extends React.Component<Props> { export const Modal: FC<Props> = ({ title, closeFunction, body, footer, active, className, headColor = "light" }) => {
static defaultProps = { const portalRootElement = usePortalRootElement("modalsRoot");
headColor: "light"
};
render() { if (!portalRootElement) {
const { title, closeFunction, body, footer, active, className, headColor } = this.props; return null;
}
const isActive = active ? "is-active" : null; const isActive = active ? "is-active" : null;
@@ -49,7 +51,7 @@ class Modal extends React.Component<Props> {
showFooter = <footer className="modal-card-foot">{footer}</footer>; showFooter = <footer className="modal-card-foot">{footer}</footer>;
} }
return ( const modalElement = (
<div className={classNames("modal", className, isActive)}> <div className={classNames("modal", className, isActive)}>
<div className="modal-background" /> <div className="modal-background" />
<div className="modal-card"> <div className="modal-card">
@@ -62,7 +64,8 @@ class Modal extends React.Component<Props> {
</div> </div>
</div> </div>
); );
}
} return ReactDOM.createPortal(modalElement, portalRootElement);
};
export default Modal; export default Modal;

View File

@@ -30,5 +30,10 @@ jest.mock("react-i18next", () => ({
t: (key: string) => key t: (key: string) => key
}; };
return Component; return Component;
},
useTranslation: (ns: string) => {
return [
(key: string) => key
];
} }
})); }));

View File

@@ -71,6 +71,7 @@
"informationNavLink": "Information", "informationNavLink": "Information",
"changePasswordNavLink": "Passwort ändern", "changePasswordNavLink": "Passwort ändern",
"publicKeysNavLink": "Öffentliche Schlüssel", "publicKeysNavLink": "Öffentliche Schlüssel",
"apiKeysNavLink": "API Schlüssel",
"settingsNavLink": "Einstellungen", "settingsNavLink": "Einstellungen",
"username": "Benutzername", "username": "Benutzername",
"displayName": "Anzeigename", "displayName": "Anzeigename",

View File

@@ -38,7 +38,8 @@
"generalNavLink": "Generell", "generalNavLink": "Generell",
"setPasswordNavLink": "Passwort", "setPasswordNavLink": "Passwort",
"setPermissionsNavLink": "Berechtigungen", "setPermissionsNavLink": "Berechtigungen",
"setPublicKeyNavLink": "Öffentliche Schlüssel" "setPublicKeyNavLink": "Öffentliche Schlüssel",
"setApiKeyNavLink": "API Schlüssel"
} }
}, },
"createUser": { "createUser": {
@@ -70,5 +71,27 @@
"addKey": "Schlüssel hinzufügen", "addKey": "Schlüssel hinzufügen",
"delete": "Löschen", "delete": "Löschen",
"download": "Herunterladen" "download": "Herunterladen"
},
"apiKey": {
"noStoredKeys": "Es wurden keine Schlüssel gefunden.",
"displayName": "Anzeigename",
"permissionRole": {
"label": "Berechtigte Rolle",
"help": "Mit der Rolle können Sie die Berechtigung für diesen Schlüssel einschränken"
},
"created": "Eingetragen an",
"addKey": "Schlüssel hinzufügen",
"delete": "Löschen",
"download": "Herunterladen",
"text1": "Erstelle und verwalte Personal Access Token um auf die REST API zuzugreifen oder diese als Passwort für SCM-Clients zu nutzen. Die Rechte der Token sind auf Repositories und die gewählte Rolle beschränkt.",
"manageRoles": "Sie können die Rollenberechtigungen in der Administration unter „Berechtigungsrollen“ einsehen und neue Rollen anlegen.",
"text2": "Um den Token in REST-Abfragen zu nutzen, übergeben Sie diesen als Cookie mit dem Namen „X-Bearer-Token“. Sie können den Token auch anstelle Ihres Passworts nutzen, um sich mit SCM-Clients anzumelden.",
"modal": {
"title": "Schlüssel erzeugt",
"text1": "Ihr neuer API-Schlüssel ist bereit. Sie können diesen als Token für Zugriffe auf die REST-Schnittstelle nutzen oder anstelle Ihres Passworts zum Login mit SCM-Clients nutzen.",
"text2": "Sichern Sie Ihren API-Schlüssel jetzt! Er wird hier einmalig angezeigt und kann später nicht mehr wiederbeschafft werden.",
"clipboard": "In die Zwischenablage kopieren",
"close": "Schließen"
}
} }
} }

View File

@@ -70,9 +70,10 @@
"profile": { "profile": {
"navigationLabel": "Profile", "navigationLabel": "Profile",
"informationNavLink": "Information", "informationNavLink": "Information",
"changePasswordNavLink": "Change password", "changePasswordNavLink": "Change Password",
"settingsNavLink": "Settings", "settingsNavLink": "Settings",
"publicKeysNavLink": "Public Keys", "publicKeysNavLink": "Public Keys",
"apiKeysNavLink": "API Keys",
"username": "Username", "username": "Username",
"displayName": "Display Name", "displayName": "Display Name",
"mail": "E-Mail", "mail": "E-Mail",

View File

@@ -38,7 +38,8 @@
"generalNavLink": "General", "generalNavLink": "General",
"setPasswordNavLink": "Password", "setPasswordNavLink": "Password",
"setPermissionsNavLink": "Permissions", "setPermissionsNavLink": "Permissions",
"setPublicKeyNavLink": "Public Keys" "setPublicKeyNavLink": "Public Keys",
"setApiKeyNavLink": "API Keys"
} }
}, },
"createUser": { "createUser": {
@@ -70,5 +71,27 @@
"addKey": "Add key", "addKey": "Add key",
"delete": "Delete", "delete": "Delete",
"download": "Download" "download": "Download"
},
"apiKey": {
"noStoredKeys": "No keys found.",
"displayName": "Display Name",
"permissionRole": {
"label": "Permitted Role",
"help": "The api key will be restricted to permissions of this role"
},
"created": "Created on",
"addKey": "Add key",
"delete": "Delete",
"download": "Download",
"text1": "Create and manage personal access tokens to access the REST API or use as a password for SCM clients. The privileges of these tokens are limited to repositories and the selected role.",
"manageRoles": "You may view and create roles in the administration view “Permission Roles”.",
"text2": "To use the token in a REST request, pass it as a cookie named “X-Bearer-Token”. You may use the token as your password for SCM clients, too.",
"modal": {
"title": "Key created",
"text1": "Your new API key is ready. You can use it as a bearer token for REST calls or as a password for SCM clients.",
"text2": "Store your API key in a safe place now! It is only displayed now and cannot be recovered later.",
"clipboard": "Copy to clipboard",
"close": "Close"
}
} }
} }

View File

@@ -21,69 +21,68 @@
* 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, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { compose } from "redux"; import { useHistory } from "react-router-dom";
import { withRouter } from "react-router-dom"; import { useTranslation } from "react-i18next";
import { WithTranslation, withTranslation } from "react-i18next";
import { History } from "history";
import { RepositoryRole } from "@scm-manager/ui-types"; import { RepositoryRole } from "@scm-manager/ui-types";
import { confirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components"; import { ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
import { deleteRole, getDeleteRoleFailure, isDeleteRolePending } from "../modules/roles"; import { deleteRole, getDeleteRoleFailure, isDeleteRolePending } from "../modules/roles";
type Props = WithTranslation & { type Props = {
loading: boolean; loading: boolean;
error: Error; error: Error;
role: RepositoryRole; role: RepositoryRole;
confirmDialog?: boolean; confirmDialog?: boolean;
deleteRole: (role: RepositoryRole, callback?: () => void) => void; deleteRole: (role: RepositoryRole, callback?: () => void) => void;
// context props
history: History;
}; };
class DeleteRepositoryRole extends React.Component<Props> { const DeleteRepositoryRole: FC<Props> = ({ confirmDialog = true, deleteRole, role, loading, error }: Props) => {
static defaultProps = { const [showConfirmAlert, setShowConfirmAlert] = useState(false);
confirmDialog: true const [t] = useTranslation("admin");
const history = useHistory();
const roleDeleted = () => {
history.push("/admin/roles/");
}; };
roleDeleted = () => { const deleteRoleCallback = () => {
this.props.history.push("/admin/roles/"); deleteRole(role, roleDeleted);
}; };
deleteRole = () => { const confirmDelete = () => {
this.props.deleteRole(this.props.role, this.roleDeleted); setShowConfirmAlert(true);
}; };
confirmDelete = () => { const isDeletable = () => {
const { t } = this.props; return role._links.delete;
confirmAlert({ };
title: t("repositoryRole.delete.confirmAlert.title"),
message: t("repositoryRole.delete.confirmAlert.message"), const action = confirmDialog ? confirmDelete : deleteRoleCallback;
buttons: [
if (!isDeletable()) {
return null;
}
if (showConfirmAlert) {
return (
<ConfirmAlert
title={t("repositoryRole.delete.confirmAlert.title")}
message={t("repositoryRole.delete.confirmAlert.message")}
buttons={[
{ {
className: "is-outlined", className: "is-outlined",
label: t("repositoryRole.delete.confirmAlert.submit"), label: t("repositoryRole.delete.confirmAlert.submit"),
onClick: () => this.deleteRole() onClick: () => deleteRoleCallback()
}, },
{ {
label: t("repositoryRole.delete.confirmAlert.cancel"), label: t("repositoryRole.delete.confirmAlert.cancel"),
onClick: () => null onClick: () => null
} }
] ]}
}); close={() => setShowConfirmAlert(false)}
}; />
);
isDeletable = () => {
return this.props.role._links.delete;
};
render() {
const { loading, error, confirmDialog, t } = this.props;
const action = confirmDialog ? this.confirmDelete : this.deleteRole;
if (!this.isDeletable()) {
return null;
} }
return ( return (
@@ -93,8 +92,7 @@ class DeleteRepositoryRole extends React.Component<Props> {
<Level right={<DeleteButton label={t("repositoryRole.delete.button")} action={action} loading={loading} />} /> <Level right={<DeleteButton label={t("repositoryRole.delete.button")} action={action} loading={loading} />} />
</> </>
); );
} };
}
const mapStateToProps = (state: any, ownProps: Props) => { const mapStateToProps = (state: any, ownProps: Props) => {
const loading = isDeleteRolePending(state, ownProps.role.name); const loading = isDeleteRolePending(state, ownProps.role.name);
@@ -113,8 +111,4 @@ const mapDispatchToProps = (dispatch: any) => {
}; };
}; };
export default compose( export default connect(mapStateToProps, mapDispatchToProps)(DeleteRepositoryRole);
connect(mapStateToProps, mapDispatchToProps),
withRouter,
withTranslation("admin")
)(DeleteRepositoryRole);

View File

@@ -44,6 +44,8 @@ import ProfileInfo from "./ProfileInfo";
import { ExtensionPoint } from "@scm-manager/ui-extensions"; import { ExtensionPoint } from "@scm-manager/ui-extensions";
import SetPublicKeys from "../users/components/publicKeys/SetPublicKeys"; import SetPublicKeys from "../users/components/publicKeys/SetPublicKeys";
import SetPublicKeyNavLink from "../users/components/navLinks/SetPublicKeysNavLink"; import SetPublicKeyNavLink from "../users/components/navLinks/SetPublicKeysNavLink";
import SetApiKeys from "../users/components/apiKeys/SetApiKeys";
import SetApiKeyNavLink from "../users/components/navLinks/SetApiKeysNavLink";
import { urls } from "@scm-manager/ui-components"; import { urls } from "@scm-manager/ui-components";
type Props = RouteComponentProps & type Props = RouteComponentProps &
@@ -65,6 +67,11 @@ class Profile extends React.Component<Props> {
return !!me?._links?.publicKeys; return !!me?._links?.publicKeys;
}; };
canManageApiKeys = () => {
const { me } = this.props;
return !!me?._links?.apiKeys;
};
render() { render() {
const url = urls.matchedUrl(this.props); const url = urls.matchedUrl(this.props);
@@ -100,6 +107,9 @@ class Profile extends React.Component<Props> {
{this.canManagePublicKeys() && ( {this.canManagePublicKeys() && (
<Route path={`${url}/settings/publicKeys`} render={() => <SetPublicKeys user={me} />} /> <Route path={`${url}/settings/publicKeys`} render={() => <SetPublicKeys user={me} />} />
)} )}
{this.canManageApiKeys() && (
<Route path={`${url}/settings/apiKeys`} render={() => <SetApiKeys user={me} />} />
)}
<ExtensionPoint name="profile.route" props={extensionProps} renderAll={true} /> <ExtensionPoint name="profile.route" props={extensionProps} renderAll={true} />
</PrimaryContentColumn> </PrimaryContentColumn>
<SecondaryNavigationColumn> <SecondaryNavigationColumn>
@@ -118,6 +128,7 @@ class Profile extends React.Component<Props> {
> >
<NavLink to={`${url}/settings/password`} label={t("profile.changePasswordNavLink")} /> <NavLink to={`${url}/settings/password`} label={t("profile.changePasswordNavLink")} />
<SetPublicKeyNavLink user={me} publicKeyUrl={`${url}/settings/publicKeys`} /> <SetPublicKeyNavLink user={me} publicKeyUrl={`${url}/settings/publicKeys`} />
<SetApiKeyNavLink user={me} apiKeyUrl={`${url}/settings/apiKeys`} />
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} /> <ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
</SubNavigation> </SubNavigation>
)} )}

View File

@@ -21,69 +21,68 @@
* 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, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { compose } from "redux"; import { useHistory } from "react-router-dom";
import { withRouter } from "react-router-dom"; import { useTranslation } from "react-i18next";
import { WithTranslation, withTranslation } from "react-i18next";
import { History } from "history";
import { Group } from "@scm-manager/ui-types"; import { Group } from "@scm-manager/ui-types";
import { confirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components"; import { ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
import { deleteGroup, getDeleteGroupFailure, isDeleteGroupPending } from "../modules/groups"; import { deleteGroup, getDeleteGroupFailure, isDeleteGroupPending } from "../modules/groups";
type Props = WithTranslation & { type Props = {
loading: boolean; loading: boolean;
error: Error; error: Error;
group: Group; group: Group;
confirmDialog?: boolean; confirmDialog?: boolean;
deleteGroup: (group: Group, callback?: () => void) => void; deleteGroup: (group: Group, callback?: () => void) => void;
// context props
history: History;
}; };
export class DeleteGroup extends React.Component<Props> { export const DeleteGroup: FC<Props> = ({ confirmDialog = true, group, deleteGroup, loading, error }) => {
static defaultProps = { const [showConfirmAlert, setShowConfirmAlert] = useState(false);
confirmDialog: true const [t] = useTranslation("groups");
const history = useHistory();
const deleteGroupCallback = () => {
deleteGroup(group, groupDeleted);
}; };
deleteGroup = () => { const groupDeleted = () => {
this.props.deleteGroup(this.props.group, this.groupDeleted); history.push("/groups/");
}; };
groupDeleted = () => { const confirmDelete = () => {
this.props.history.push("/groups/"); setShowConfirmAlert(true);
}; };
confirmDelete = () => { const isDeletable = () => {
const { t } = this.props; return group._links.delete;
confirmAlert({ };
title: t("deleteGroup.confirmAlert.title"),
message: t("deleteGroup.confirmAlert.message"), const action = confirmDialog ? confirmDelete : deleteGroupCallback;
buttons: [
if (!isDeletable()) {
return null;
}
if (showConfirmAlert) {
return (
<ConfirmAlert
title={t("deleteGroup.confirmAlert.title")}
message={t("deleteGroup.confirmAlert.message")}
buttons={[
{ {
className: "is-outlined", className: "is-outlined",
label: t("deleteGroup.confirmAlert.submit"), label: t("deleteGroup.confirmAlert.submit"),
onClick: () => this.deleteGroup() onClick: () => deleteGroupCallback()
}, },
{ {
label: t("deleteGroup.confirmAlert.cancel"), label: t("deleteGroup.confirmAlert.cancel"),
onClick: () => null onClick: () => null
} }
] ]}
}); close={() => setShowConfirmAlert(false)}
}; />
);
isDeletable = () => {
return this.props.group._links.delete;
};
render() {
const { loading, error, confirmDialog, t } = this.props;
const action = confirmDialog ? this.confirmDelete : this.deleteGroup;
if (!this.isDeletable()) {
return null;
} }
return ( return (
@@ -93,8 +92,7 @@ export class DeleteGroup extends React.Component<Props> {
<Level right={<DeleteButton label={t("deleteGroup.button")} action={action} loading={loading} />} /> <Level right={<DeleteButton label={t("deleteGroup.button")} action={action} loading={loading} />} />
</> </>
); );
} };
}
const mapStateToProps = (state: any, ownProps: Props) => { const mapStateToProps = (state: any, ownProps: Props) => {
const loading = isDeleteGroupPending(state, ownProps.group.name); const loading = isDeleteGroupPending(state, ownProps.group.name);
@@ -113,8 +111,4 @@ const mapDispatchToProps = (dispatch: any) => {
}; };
}; };
export default compose( export default connect(mapStateToProps, mapDispatchToProps)(DeleteGroup);
connect(mapStateToProps, mapDispatchToProps),
withRouter,
withTranslation("groups")
)(DeleteGroup);

View File

@@ -21,66 +21,68 @@
* 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, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { compose } from "redux"; import { useHistory } from "react-router-dom";
import { RouteComponentProps, withRouter } from "react-router-dom"; import { useTranslation } from "react-i18next";
import { WithTranslation, withTranslation } from "react-i18next";
import { Repository } from "@scm-manager/ui-types"; import { Repository } from "@scm-manager/ui-types";
import { confirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components"; import { ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
import { deleteRepo, getDeleteRepoFailure, isDeleteRepoPending } from "../modules/repos"; import { deleteRepo, getDeleteRepoFailure, isDeleteRepoPending } from "../modules/repos";
type Props = RouteComponentProps & type Props = {
WithTranslation & {
loading: boolean; loading: boolean;
error: Error; error: Error;
repository: Repository; repository: Repository;
confirmDialog?: boolean; confirmDialog?: boolean;
deleteRepo: (p1: Repository, p2: () => void) => void; deleteRepo: (p1: Repository, p2: () => void) => void;
};
const DeleteRepo: FC<Props> = ({ confirmDialog = true, repository, deleteRepo, loading, error }: Props) => {
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [t] = useTranslation("repos");
const history = useHistory();
const deleted = () => {
history.push("/repos/");
}; };
class DeleteRepo extends React.Component<Props> { const deleteRepoCallback = () => {
static defaultProps = { deleteRepo(repository, deleted);
confirmDialog: true
}; };
deleted = () => { const confirmDelete = () => {
this.props.history.push("/repos/"); setShowConfirmAlert(true);
}; };
deleteRepo = () => { const isDeletable = () => {
this.props.deleteRepo(this.props.repository, this.deleted); return repository._links.delete;
}; };
confirmDelete = () => { const action = confirmDialog ? confirmDelete : deleteRepoCallback;
const { t } = this.props;
confirmAlert({ if (!isDeletable()) {
title: t("deleteRepo.confirmAlert.title"), return null;
message: t("deleteRepo.confirmAlert.message"), }
buttons: [
if (showConfirmAlert) {
return (
<ConfirmAlert
title={t("deleteRepo.confirmAlert.title")}
message={t("deleteRepo.confirmAlert.message")}
buttons={[
{ {
className: "is-outlined", className: "is-outlined",
label: t("deleteRepo.confirmAlert.submit"), label: t("deleteRepo.confirmAlert.submit"),
onClick: () => this.deleteRepo() onClick: () => deleteRepoCallback()
}, },
{ {
label: t("deleteRepo.confirmAlert.cancel"), label: t("deleteRepo.confirmAlert.cancel"),
onClick: () => null onClick: () => null
} }
] ]}
}); close={() => setShowConfirmAlert(false)}
}; />
);
isDeletable = () => {
return this.props.repository._links.delete;
};
render() {
const { loading, error, confirmDialog, t } = this.props;
const action = confirmDialog ? this.confirmDelete : this.deleteRepo;
if (!this.isDeletable()) {
return null;
} }
return ( return (
@@ -98,8 +100,7 @@ class DeleteRepo extends React.Component<Props> {
/> />
</> </>
); );
} };
}
const mapStateToProps = (state: any, ownProps: Props) => { const mapStateToProps = (state: any, ownProps: Props) => {
const { namespace, name } = ownProps.repository; const { namespace, name } = ownProps.repository;
@@ -119,4 +120,4 @@ const mapDispatchToProps = (dispatch: any) => {
}; };
}; };
export default compose(connect(mapStateToProps, mapDispatchToProps), withRouter, withTranslation("repos"))(DeleteRepo); export default connect(mapStateToProps, mapDispatchToProps)(DeleteRepo);

View File

@@ -26,7 +26,7 @@ import { WithTranslation, withTranslation } from "react-i18next";
import { Select } from "@scm-manager/ui-components"; import { Select } from "@scm-manager/ui-components";
type Props = WithTranslation & { type Props = WithTranslation & {
availableRoles: string[]; availableRoles?: string[];
handleRoleChange: (p: string) => void; handleRoleChange: (p: string) => void;
role: string; role: string;
label?: string; label?: string;

View File

@@ -21,16 +21,19 @@
* 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"; // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
import React, { FC } from "react";
// eslint-disable-next-line no-restricted-imports
import { mount, shallow } from "@scm-manager/ui-tests/enzyme-router"; import { mount, shallow } from "@scm-manager/ui-tests/enzyme-router";
// eslint-disable-next-line no-restricted-imports
import "@scm-manager/ui-tests/enzyme"; import "@scm-manager/ui-tests/enzyme";
// eslint-disable-next-line no-restricted-imports
import "@scm-manager/ui-tests/i18n"; import "@scm-manager/ui-tests/i18n";
import DeletePermissionButton from "./DeletePermissionButton"; import DeletePermissionButton from "./DeletePermissionButton";
import { confirmAlert } from "@scm-manager/ui-components";
jest.mock("@scm-manager/ui-components", () => ({ jest.mock("@scm-manager/ui-components", () => ({
confirmAlert: jest.fn(), ConfirmAlert: (({ children }) => <div className="modal">{children}</div>) as FC<never>,
DeleteButton: require.requireActual("@scm-manager/ui-components").DeleteButton DeleteButton: require.requireActual("@scm-manager/ui-components").DeleteButton
})); }));
@@ -40,6 +43,9 @@ describe("DeletePermissionButton", () => {
_links: {} _links: {}
}; };
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-empty-function
const navLink = shallow(<DeletePermissionButton permission={permission} deletePermission={() => {}} />); const navLink = shallow(<DeletePermissionButton permission={permission} deletePermission={() => {}} />);
expect(navLink.text()).toBe(""); expect(navLink.text()).toBe("");
}); });
@@ -53,6 +59,9 @@ describe("DeletePermissionButton", () => {
} }
}; };
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-empty-function
const deleteIcon = mount(<DeletePermissionButton permission={permission} deletePermission={() => {}} />); const deleteIcon = mount(<DeletePermissionButton permission={permission} deletePermission={() => {}} />);
expect(deleteIcon.html()).not.toBe(""); expect(deleteIcon.html()).not.toBe("");
}); });
@@ -66,10 +75,13 @@ describe("DeletePermissionButton", () => {
} }
}; };
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-empty-function
const button = mount(<DeletePermissionButton permission={permission} deletePermission={() => {}} />); const button = mount(<DeletePermissionButton permission={permission} deletePermission={() => {}} />);
button.find(".fa-trash").simulate("click"); button.find(".fa-trash").simulate("click");
expect(confirmAlert.mock.calls.length).toBe(1); expect(button.find(".modal")).toBeTruthy();
}); });
it("should call the delete permission function with delete url", () => { it("should call the delete permission function with delete url", () => {
@@ -82,11 +94,14 @@ describe("DeletePermissionButton", () => {
}; };
let calledUrl = null; let calledUrl = null;
function capture(permission) { function capture(permission) {
calledUrl = permission._links.delete.href; calledUrl = permission._links.delete.href;
} }
const button = mount( const button = mount(
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
<DeletePermissionButton permission={permission} confirmDialog={false} deletePermission={capture} /> <DeletePermissionButton permission={permission} confirmDialog={false} deletePermission={capture} />
); );
button.find(".fa-trash").simulate("click"); button.find(".fa-trash").simulate("click");

View File

@@ -21,12 +21,12 @@
* 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, useState } from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Permission } from "@scm-manager/ui-types"; import { Permission } from "@scm-manager/ui-types";
import { confirmAlert } from "@scm-manager/ui-components"; import { ConfirmAlert } from "@scm-manager/ui-components";
type Props = WithTranslation & { type Props = {
permission: Permission; permission: Permission;
namespace: string; namespace: string;
repoName: string; repoName: string;
@@ -35,45 +35,55 @@ type Props = WithTranslation & {
loading: boolean; loading: boolean;
}; };
class DeletePermissionButton extends React.Component<Props> { const DeletePermissionButton: FC<Props> = ({
static defaultProps = { confirmDialog = true,
confirmDialog: true permission,
namespace,
deletePermission,
repoName
}) => {
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [t] = useTranslation("repos");
const deletePermissionCallback = () => {
deletePermission(permission, namespace, repoName);
}; };
deletePermission = () => { const confirmDelete = () => {
this.props.deletePermission(this.props.permission, this.props.namespace, this.props.repoName); setShowConfirmAlert(true);
}; };
confirmDelete = () => { const isDeletable = () => {
const { t } = this.props; return permission._links.delete;
confirmAlert({ };
title: t("permission.delete-permission-button.confirm-alert.title"),
message: t("permission.delete-permission-button.confirm-alert.message"), const action = confirmDialog ? confirmDelete : deletePermissionCallback;
buttons: [
if (!isDeletable()) {
return null;
}
if (showConfirmAlert) {
return (
<ConfirmAlert
title={t("permission.delete-permission-button.confirm-alert.title")}
message={t("permission.delete-permission-button.confirm-alert.message")}
buttons={[
{ {
className: "is-outlined", className: "is-outlined",
label: t("permission.delete-permission-button.confirm-alert.submit"), label: t("permission.delete-permission-button.confirm-alert.submit"),
onClick: () => this.deletePermission() onClick: () => deletePermissionCallback()
}, },
{ {
label: t("permission.delete-permission-button.confirm-alert.cancel"), label: t("permission.delete-permission-button.confirm-alert.cancel"),
onClick: () => null onClick: () => null
} }
] ]}
}); close={() => setShowConfirmAlert(false)}
}; />
);
isDeletable = () => {
return this.props.permission._links.delete;
};
render() {
const { confirmDialog } = this.props;
const action = confirmDialog ? this.confirmDelete : this.deletePermission;
if (!this.isDeletable()) {
return null;
} }
return ( return (
<a className="level-item" onClick={action}> <a className="level-item" onClick={action}>
<span className="icon is-small"> <span className="icon is-small">
@@ -81,7 +91,6 @@ class DeletePermissionButton extends React.Component<Props> {
</span> </span>
</a> </a>
); );
} };
}
export default withTranslation("repos")(DeletePermissionButton); export default DeletePermissionButton;

View File

@@ -0,0 +1,145 @@
/*
* 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, { FC, useEffect, useState } from "react";
import { apiClient, ErrorNotification, InputField, Level, Loading, SubmitButton } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { CONTENT_TYPE_API_KEY } from "./SetApiKeys";
import { connect } from "react-redux";
import {
fetchAvailablePermissionsIfNeeded,
getAvailableRepositoryRoles
} from "../../../repos/permissions/modules/permissions";
import { RepositoryRole } from "@scm-manager/ui-types";
import { getRepositoryRolesLink, getRepositoryVerbsLink } from "../../../modules/indexResource";
import RoleSelector from "../../../repos/permissions/components/RoleSelector";
import ApiKeyCreatedModal from "./ApiKeyCreatedModal";
type Props = {
createLink: string;
refresh: () => void;
repositoryRolesLink: string;
repositoryVerbsLink: string;
fetchAvailablePermissionsIfNeeded: (repositoryRolesLink: string, repositoryVerbsLink: string) => void;
availableRepositoryRoles?: RepositoryRole[];
};
const AddApiKey: FC<Props> = ({
createLink,
refresh,
fetchAvailablePermissionsIfNeeded,
repositoryRolesLink,
repositoryVerbsLink,
availableRepositoryRoles
}) => {
const [t] = useTranslation("users");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<undefined | Error>();
const [displayName, setDisplayName] = useState("");
const [permissionRole, setPermissionRole] = useState("");
const [addedKey, setAddedKey] = useState("");
useEffect(() => {
if (!availableRepositoryRoles) {
fetchAvailablePermissionsIfNeeded(repositoryRolesLink, repositoryVerbsLink);
}
}, [repositoryRolesLink, repositoryVerbsLink]);
const isValid = () => {
return !!displayName && !!permissionRole;
};
const resetForm = () => {
setDisplayName("");
setPermissionRole("");
};
const addKey = () => {
setLoading(true);
apiClient
.post(createLink, { displayName: displayName, permissionRole: permissionRole }, CONTENT_TYPE_API_KEY)
.then(response => response.text())
.then(setAddedKey)
.then(() => setLoading(false))
.catch(setError);
};
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
const availableRoleNames = availableRepositoryRoles ? availableRepositoryRoles.map(r => r.name) : [];
const closeModal = () => {
resetForm();
refresh();
setAddedKey("");
};
const newKeyModal = addedKey && <ApiKeyCreatedModal addedKey={addedKey} close={closeModal} />;
return (
<>
{newKeyModal}
<InputField label={t("apiKey.displayName")} value={displayName} onChange={setDisplayName} />
<RoleSelector
loading={!availableRoleNames}
availableRoles={availableRoleNames}
label={t("apiKey.permissionRole.label")}
helpText={t("apiKey.permissionRole.help")}
handleRoleChange={setPermissionRole}
role={permissionRole}
/>
<Level
right={<SubmitButton label={t("apiKey.addKey")} loading={loading} disabled={!isValid()} action={addKey} />}
/>
</>
);
};
const mapStateToProps = (state: any, ownProps: Props) => {
const availableRepositoryRoles = getAvailableRepositoryRoles(state);
const repositoryRolesLink = getRepositoryRolesLink(state);
const repositoryVerbsLink = getRepositoryVerbsLink(state);
return {
availableRepositoryRoles,
repositoryRolesLink,
repositoryVerbsLink
};
};
const mapDispatchToProps = (dispatch: any) => {
return {
fetchAvailablePermissionsIfNeeded: (repositoryRolesLink: string, repositoryVerbsLink: string) => {
dispatch(fetchAvailablePermissionsIfNeeded(repositoryRolesLink, repositoryVerbsLink));
}
};
};
export default connect(mapStateToProps, mapDispatchToProps)(AddApiKey);

View File

@@ -0,0 +1,86 @@
/*
* 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, { FC, useRef, useState } from "react";
import { Button, Icon, Modal } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
type Props = {
addedKey: string;
close: () => void;
};
const KeyArea = styled.textarea`
white-space: nowrap;
overflow: auto;
font-family: "Courier New", Monaco, Menlo, "Ubuntu Mono", "source-code-pro", monospace;
height: 3rem;
`;
const NoLeftMargin = styled.div`
margin-left: -1rem;
`;
const ApiKeyCreatedModal: FC<Props> = ({ addedKey, close }) => {
const [t] = useTranslation("users");
const [copied, setCopied] = useState(false);
const keyRef = useRef(null);
const copy = () => {
keyRef.current.select();
document.execCommand("copy");
setCopied(true);
};
const newPassphraseModalContent = (
<div className={"media-content"}>
<p>{t("apiKey.modal.text1")}</p>
<p>
<b>{t("apiKey.modal.text2")}</b>
</p>
<hr />
<div className={"columns"}>
<div className={"column is-11"}>
<KeyArea wrap={"soft"} ref={keyRef} className={"input"} value={addedKey} />
</div>
<NoLeftMargin className={"column is-1"}>
<Icon className={"is-hidden-mobile fa-2x"} name={copied ? "clipboard-check" : "clipboard"} title={t("apiKey.modal.clipboard")} onClick={copy} />
</NoLeftMargin>
</div>
</div>
);
return (
<Modal
body={newPassphraseModalContent}
closeFunction={close}
title={t("apiKey.modal.title")}
footer={<Button label={t("apiKey.modal.close")} action={close} />}
active={true}
/>
);
};
export default ApiKeyCreatedModal;

View File

@@ -0,0 +1,63 @@
/*
* 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, { FC } from "react";
import { DateFromNow, Icon } from "@scm-manager/ui-components";
import { ApiKey } from "./SetApiKeys";
import { Link } from "@scm-manager/ui-types";
import { useTranslation } from "react-i18next";
type Props = {
apiKey: ApiKey;
onDelete: (link: string) => void;
};
export const ApiKeyEntry: FC<Props> = ({ apiKey, onDelete }) => {
const [t] = useTranslation("users");
let deleteButton;
if (apiKey?._links?.delete) {
deleteButton = (
<a className="level-item" onClick={() => onDelete((apiKey._links.delete as Link).href)}>
<span className="icon is-small">
<Icon name="trash" className="fas" title={t("apiKey.delete")} />
</span>
</a>
);
}
return (
<>
<tr>
<td>{apiKey.displayName}</td>
<td>{apiKey.permissionRole}</td>
<td className="is-hidden-mobile">
<DateFromNow date={apiKey.created}/>
</td>
<td className="is-darker">{deleteButton}</td>
</tr>
</>
);
};
export default ApiKeyEntry;

View File

@@ -0,0 +1,62 @@
/*
* 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, { FC } from "react";
import { useTranslation } from "react-i18next";
import { ApiKey, ApiKeysCollection } from "./SetApiKeys";
import ApiKeyEntry from "./ApiKeyEntry";
import { Notification } from "@scm-manager/ui-components";
type Props = {
apiKeys?: ApiKeysCollection;
onDelete: (link: string) => void;
};
const ApiKeyTable: FC<Props> = ({ apiKeys, onDelete }) => {
const [t] = useTranslation("users");
if (apiKeys?._embedded?.keys?.length === 0) {
return <Notification type="info">{t("apiKey.noStoredKeys")}</Notification>;
}
return (
<table className="card-table table is-hoverable is-fullwidth">
<thead>
<tr>
<th>{t("apiKey.displayName")}</th>
<th>{t("apiKey.permissionRole.label")}</th>
<th>{t("apiKey.created")}</th>
<th />
</tr>
</thead>
<tbody>
{apiKeys?._embedded?.keys?.map((apiKey: ApiKey, index: number) => {
return <ApiKeyEntry key={index} onDelete={onDelete} apiKey={apiKey} />;
})}
</tbody>
</table>
);
};
export default ApiKeyTable;

View File

@@ -0,0 +1,110 @@
/*
* 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 { Collection, Links, User, Me } from "@scm-manager/ui-types";
import React, { FC, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { apiClient, ErrorNotification, Loading } from "@scm-manager/ui-components";
import ApiKeyTable from "./ApiKeyTable";
import AddApiKey from "./AddApiKey";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
export type ApiKeysCollection = Collection & {
_embedded: {
keys: ApiKey[];
};
};
export type ApiKey = {
id: string;
displayName: string;
permissionRole: string;
created: string;
_links: Links;
};
export const CONTENT_TYPE_API_KEY = "application/vnd.scmm-apiKey+json;v=2";
type Props = {
user: User | Me;
};
const Subtitle = styled.div`
margin-bottom: 1rem;
`;
const SetApiKeys: FC<Props> = ({ user }) => {
const [t] = useTranslation("users");
const [error, setError] = useState<undefined | Error>();
const [loading, setLoading] = useState(false);
const [apiKeys, setApiKeys] = useState<ApiKeysCollection | undefined>(undefined);
useEffect(() => {
fetchApiKeys();
}, [user]);
const fetchApiKeys = () => {
setLoading(true);
apiClient
.get((user._links.apiKeys as Link).href)
.then(r => r.json())
.then(setApiKeys)
.then(() => setLoading(false))
.catch(setError);
};
const onDelete = (link: string) => {
apiClient
.delete(link)
.then(fetchApiKeys)
.catch(setError);
};
const createLink = (apiKeys?._links?.create as Link)?.href;
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
return (
<>
<div className={"media-content"}>
<p>{t("apiKey.text1")} <Link to={"/admin/roles/"}>{t("apiKey.manageRoles")}</Link></p>
<p>{t("apiKey.text2")}</p>
</div>
<hr />
<ApiKeyTable apiKeys={apiKeys} onDelete={onDelete} />
<hr />
<Subtitle className={"media-content"}><h2 className={"title is-4"}>Create new key</h2></Subtitle>
{createLink && <AddApiKey createLink={createLink} refresh={fetchApiKeys} />}
</>
);
};
export default SetApiKeys;

View File

@@ -0,0 +1,43 @@
/*
* 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, { FC } from "react";
import { Link, User, Me } from "@scm-manager/ui-types";
import { NavLink } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
type Props = {
user: User | Me;
apiKeyUrl: string;
};
const SetApiKeyNavLink: FC<Props> = ({ user, apiKeyUrl }) => {
const [t] = useTranslation("users");
if ((user?._links?.apiKeys as Link)?.href) {
return <NavLink to={apiKeyUrl} label={t("singleUser.menu.setApiKeyNavLink")} />;
}
return null;
};
export default SetApiKeyNavLink;

View File

@@ -21,69 +21,68 @@
* 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, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { compose } from "redux"; import { useHistory } from "react-router-dom";
import { withRouter } from "react-router-dom"; import { useTranslation } from "react-i18next";
import { WithTranslation, withTranslation } from "react-i18next";
import { History } from "history";
import { User } from "@scm-manager/ui-types"; import { User } from "@scm-manager/ui-types";
import { confirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components"; import { ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
import { deleteUser, getDeleteUserFailure, isDeleteUserPending } from "../modules/users"; import { deleteUser, getDeleteUserFailure, isDeleteUserPending } from "../modules/users";
type Props = WithTranslation & { type Props = {
loading: boolean; loading: boolean;
error: Error; error: Error;
user: User; user: User;
confirmDialog?: boolean; confirmDialog?: boolean;
deleteUser: (user: User, callback?: () => void) => void; deleteUser: (user: User, callback?: () => void) => void;
// context props
history: History;
}; };
class DeleteUser extends React.Component<Props> { const DeleteUser: FC<Props> = ({ confirmDialog = true, loading, error, user, deleteUser }) => {
static defaultProps = { const [showConfirmAlert, setShowConfirmAlert] = useState(false);
confirmDialog: true const [t] = useTranslation("users");
const history = useHistory();
const userDeleted = () => {
history.push("/users/");
}; };
userDeleted = () => { const deleteUserCallback = () => {
this.props.history.push("/users/"); deleteUser(user, userDeleted);
}; };
deleteUser = () => { const confirmDelete = () => {
this.props.deleteUser(this.props.user, this.userDeleted); setShowConfirmAlert(true);
}; };
confirmDelete = () => { const isDeletable = () => {
const { t } = this.props; return user._links.delete;
confirmAlert({ };
title: t("deleteUser.confirmAlert.title"),
message: t("deleteUser.confirmAlert.message"), const action = confirmDialog ? confirmDelete : deleteUserCallback;
buttons: [
if (!isDeletable()) {
return null;
}
if (showConfirmAlert) {
return (
<ConfirmAlert
title={t("deleteUser.confirmAlert.title")}
message={t("deleteUser.confirmAlert.message")}
buttons={[
{ {
className: "is-outlined", className: "is-outlined",
label: t("deleteUser.confirmAlert.submit"), label: t("deleteUser.confirmAlert.submit"),
onClick: () => this.deleteUser() onClick: () => deleteUserCallback()
}, },
{ {
label: t("deleteUser.confirmAlert.cancel"), label: t("deleteUser.confirmAlert.cancel"),
onClick: () => null onClick: () => null
} }
] ]}
}); close={() => setShowConfirmAlert(false)}
}; />
);
isDeletable = () => {
return this.props.user._links.delete;
};
render() {
const { loading, error, confirmDialog, t } = this.props;
const action = confirmDialog ? this.confirmDelete : this.deleteUser;
if (!this.isDeletable()) {
return null;
} }
return ( return (
@@ -93,8 +92,7 @@ class DeleteUser extends React.Component<Props> {
<Level right={<DeleteButton label={t("deleteUser.button")} action={action} loading={loading} />} /> <Level right={<DeleteButton label={t("deleteUser.button")} action={action} loading={loading} />} />
</> </>
); );
} };
}
const mapStateToProps = (state: any, ownProps: Props) => { const mapStateToProps = (state: any, ownProps: Props) => {
const loading = isDeleteUserPending(state, ownProps.user.name); const loading = isDeleteUserPending(state, ownProps.user.name);
@@ -113,4 +111,4 @@ const mapDispatchToProps = (dispatch: any) => {
}; };
}; };
export default compose(connect(mapStateToProps, mapDispatchToProps), withRouter, withTranslation("users"))(DeleteUser); export default connect(mapStateToProps, mapDispatchToProps)(DeleteUser);

View File

@@ -42,7 +42,7 @@ public class NotSupportedExceptionMapper implements ExceptionMapper<NotSupported
@Override @Override
public Response toResponse(NotSupportedException exception) { public Response toResponse(NotSupportedException exception) {
LOG.debug("illegal media type"); LOG.debug("illegal media type", exception);
ErrorDto error = new ErrorDto(); ErrorDto error = new ErrorDto();
error.setTransactionId(MDC.get("transaction_id")); error.setTransactionId(MDC.get("transaction_id"));
error.setMessage("illegal media type"); error.setMessage("illegal media type");

View File

@@ -0,0 +1,57 @@
/*
* 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.api.v2.resources;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import sonia.scm.security.ApiKey;
import javax.inject.Inject;
import java.util.Collection;
import java.util.List;
import static de.otto.edison.hal.Link.link;
import static java.util.stream.Collectors.toList;
public class ApiKeyCollectionToDtoMapper {
private final ApiKeyToApiKeyDtoMapper apiKeyDtoMapper;
private final ResourceLinks resourceLinks;
@Inject
public ApiKeyCollectionToDtoMapper(ApiKeyToApiKeyDtoMapper apiKeyDtoMapper, ResourceLinks resourceLinks) {
this.apiKeyDtoMapper = apiKeyDtoMapper;
this.resourceLinks = resourceLinks;
}
public HalRepresentation map(Collection<ApiKey> keys) {
List<ApiKeyDto> dtos = keys.stream().map(apiKeyDtoMapper::map).collect(toList());
final Links.Builder links = Links.linkingTo()
.self(resourceLinks.apiKeyCollection().self())
.single(link("create", resourceLinks.apiKeyCollection().create()));
return new HalRepresentation(links.build(), Embedded.embedded("keys", dtos));
}
}

View File

@@ -0,0 +1,49 @@
/*
* 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.api.v2.resources;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.validation.constraints.NotEmpty;
import java.time.Instant;
@Getter
@Setter
@NoArgsConstructor
public class ApiKeyDto extends HalRepresentation {
@NotEmpty
private String displayName;
@NotEmpty
private String permissionRole;
private Instant created;
public ApiKeyDto(Links links) {
super(links);
}
}

View File

@@ -0,0 +1,173 @@
/*
* 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.api.v2.resources;
import de.otto.edison.hal.HalRepresentation;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.ContextEntry;
import sonia.scm.security.ApiKey;
import sonia.scm.security.ApiKeyService;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.net.URI;
import static javax.ws.rs.core.Response.Status.CREATED;
import static sonia.scm.NotFoundException.notFound;
public class ApiKeyResource {
private final ApiKeyService apiKeyService;
private final ApiKeyCollectionToDtoMapper apiKeyCollectionMapper;
private final ApiKeyToApiKeyDtoMapper apiKeyMapper;
private final ResourceLinks resourceLinks;
@Inject
public ApiKeyResource(ApiKeyService apiKeyService, ApiKeyCollectionToDtoMapper apiKeyCollectionMapper, ApiKeyToApiKeyDtoMapper apiKeyMapper, ResourceLinks links) {
this.apiKeyService = apiKeyService;
this.apiKeyCollectionMapper = apiKeyCollectionMapper;
this.apiKeyMapper = apiKeyMapper;
this.resourceLinks = links;
}
@GET
@Path("")
@Produces(VndMediaType.API_KEY_COLLECTION)
@Operation(summary = "Get the api keys for the current user", description = "Returns the registered api keys for the logged in user.", tags = "User")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.API_KEY_COLLECTION,
schema = @Schema(implementation = HalRepresentation.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public HalRepresentation getForCurrentUser() {
return apiKeyCollectionMapper.map(apiKeyService.getKeys());
}
@GET
@Path("{id}")
@Produces(VndMediaType.API_KEY)
@Operation(summary = "Get one api key for the current user", description = "Returns the registered api key with the given id for the logged in user.", tags = "User")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.API_KEY,
schema = @Schema(implementation = HalRepresentation.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(
responseCode = "404",
description = "not found, no api key with the given id for the current user available",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public ApiKeyDto get(@PathParam("id") String id) {
return apiKeyService
.getKeys()
.stream()
.filter(key -> key.getId().equals(id))
.map(apiKeyMapper::map).findAny()
.orElseThrow(() -> notFound(ContextEntry.ContextBuilder.entity(ApiKey.class, id)));
}
@POST
@Path("")
@Consumes(VndMediaType.API_KEY)
@Produces(MediaType.TEXT_PLAIN)
@Operation(summary = "Create new api key for the current user", description = "Creates a new api key for the given user with the role specified in the given key.", tags = "User")
@ApiResponse(
responseCode = "201",
description = "create success",
headers = @Header(
name = "Location",
description = "uri to the created user",
schema = @Schema(type = "string")
),
content = @Content(
mediaType = MediaType.TEXT_PLAIN
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "409", description = "conflict, a key with the given display name already exists")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response create(@Valid ApiKeyDto apiKey) {
final ApiKeyService.CreationResult newKey = apiKeyService.createNewKey(apiKey.getDisplayName(), apiKey.getPermissionRole());
return Response.status(CREATED)
.entity(newKey.getToken())
.location(URI.create(resourceLinks.apiKey().self(newKey.getId())))
.build();
}
@DELETE
@Path("{id}")
@Operation(summary = "Delete api key", description = "Deletes the api key with the given id for the current user.", tags = "User")
@ApiResponse(responseCode = "204", description = "delete success or nothing to delete")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "500", description = "internal server error")
public void delete(@PathParam("id") String id) {
apiKeyService.remove(id);
}
}

View File

@@ -0,0 +1,51 @@
/*
* 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.api.v2.resources;
import de.otto.edison.hal.Links;
import org.mapstruct.Mapper;
import org.mapstruct.ObjectFactory;
import sonia.scm.security.ApiKey;
import javax.inject.Inject;
import static de.otto.edison.hal.Link.link;
@Mapper
public abstract class ApiKeyToApiKeyDtoMapper {
@Inject
private ResourceLinks resourceLinks;
abstract ApiKeyDto map(ApiKey key);
@ObjectFactory
ApiKeyDto createDto(ApiKey key) {
Links.Builder links = Links.linkingTo()
.self(resourceLinks.apiKey().self(key.getId()))
.single(link("delete", resourceLinks.apiKey().delete(key.getId())));
return new ApiKeyDto(links.build());
}
}

View File

@@ -61,8 +61,10 @@ public abstract class BrowserResultToFileObjectDtoMapper extends BaseFileObjectD
@Override @Override
void applyEnrichers(Links.Builder links, Embedded.Builder embeddedBuilder, NamespaceAndName namespaceAndName, BrowserResult browserResult, FileObject fileObject) { void applyEnrichers(Links.Builder links, Embedded.Builder embeddedBuilder, NamespaceAndName namespaceAndName, BrowserResult browserResult, FileObject fileObject) {
EdisonHalAppender appender = new EdisonHalAppender(links, embeddedBuilder); EdisonHalAppender appender = new EdisonHalAppender(links, embeddedBuilder);
if (browserResult.getFile().equals(fileObject)) {
// we call enrichers, which are only responsible for top level browseresults // we call enrichers, which are only responsible for top level browseresults
applyEnrichers(appender, browserResult, namespaceAndName); applyEnrichers(appender, browserResult, namespaceAndName);
}
// we call enrichers, which are responsible for all file object top level browse result and its children // we call enrichers, which are responsible for all file object top level browse result and its children
applyEnrichers(appender, fileObject, namespaceAndName, browserResult, browserResult.getRevision()); applyEnrichers(appender, fileObject, namespaceAndName, browserResult, browserResult.getRevision());
} }

View File

@@ -86,5 +86,7 @@ public class MapperModule extends AbstractModule {
bind(ScmPathInfoStore.class).in(ServletScopes.REQUEST); bind(ScmPathInfoStore.class).in(ServletScopes.REQUEST);
bind(PluginDtoMapper.class).to(Mappers.getMapperClass(PluginDtoMapper.class)); bind(PluginDtoMapper.class).to(Mappers.getMapperClass(PluginDtoMapper.class));
bind(ApiKeyToApiKeyDtoMapper.class).to(Mappers.getMapperClass(ApiKeyToApiKeyDtoMapper.class));
} }
} }

View File

@@ -94,6 +94,9 @@ public class MeDtoFactory extends HalAppenderMapper {
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) { if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) {
linksBuilder.single(link("password", resourceLinks.me().passwordChange())); linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
} }
if (UserPermissions.changeApiKeys(user).isPermitted()) {
linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self()));
}
Embedded.Builder embeddedBuilder = embeddedBuilder(); Embedded.Builder embeddedBuilder = embeddedBuilder();
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user); applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user);

View File

@@ -35,6 +35,7 @@ import sonia.scm.user.UserManager;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Provider;
import javax.validation.Valid; import javax.validation.Valid;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.GET; import javax.ws.rs.GET;
@@ -61,11 +62,14 @@ public class MeResource {
private final UserManager userManager; private final UserManager userManager;
private final PasswordService passwordService; private final PasswordService passwordService;
private final Provider<ApiKeyResource> apiKeyResource;
@Inject @Inject
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService) { public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResource) {
this.meDtoFactory = meDtoFactory; this.meDtoFactory = meDtoFactory;
this.userManager = userManager; this.userManager = userManager;
this.passwordService = passwordService; this.passwordService = passwordService;
this.apiKeyResource = apiKeyResource;
} }
/** /**
@@ -118,4 +122,9 @@ public class MeResource {
); );
return Response.noContent().build(); return Response.noContent().build();
} }
@Path("api_keys")
public ApiKeyResource apiKeys() {
return apiKeyResource.get();
}
} }

View File

@@ -204,6 +204,46 @@ class ResourceLinks {
} }
} }
public ApiKeyCollectionLinks apiKeyCollection() {
return new ApiKeyCollectionLinks(scmPathInfoStore.get());
}
static class ApiKeyCollectionLinks {
private final LinkBuilder collectionLinkBuilder;
ApiKeyCollectionLinks(ScmPathInfo pathInfo) {
this.collectionLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, ApiKeyResource.class);
}
String self() {
return collectionLinkBuilder.method("apiKeys").parameters().method("getForCurrentUser").parameters().href();
}
String create() {
return collectionLinkBuilder.method("apiKeys").parameters().method("create").parameters().href();
}
}
public ApiKeyLinks apiKey() {
return new ApiKeyLinks(scmPathInfoStore.get());
}
static class ApiKeyLinks {
private final LinkBuilder apiKeyLinkBuilder;
ApiKeyLinks(ScmPathInfo pathInfo) {
this.apiKeyLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, ApiKeyResource.class);
}
String self(String id) {
return apiKeyLinkBuilder.method("apiKeys").parameters().method("get").parameters(id).href();
}
String delete(String id) {
return apiKeyLinkBuilder.method("apiKeys").parameters().method("delete").parameters(id).href();
}
}
UserCollectionLinks userCollection() { UserCollectionLinks userCollection() {
return new UserCollectionLinks(scmPathInfoStore.get()); return new UserCollectionLinks(scmPathInfoStore.get());
} }

View File

@@ -33,6 +33,7 @@ import org.apache.shiro.authc.credential.DefaultPasswordService;
import org.apache.shiro.authc.credential.PasswordService; import org.apache.shiro.authc.credential.PasswordService;
import org.apache.shiro.authc.pam.AuthenticationStrategy; import org.apache.shiro.authc.pam.AuthenticationStrategy;
import org.apache.shiro.authc.pam.ModularRealmAuthenticator; import org.apache.shiro.authc.pam.ModularRealmAuthenticator;
import org.apache.shiro.authz.permission.PermissionResolver;
import org.apache.shiro.crypto.hash.DefaultHashService; import org.apache.shiro.crypto.hash.DefaultHashService;
import org.apache.shiro.guice.web.ShiroWebModule; import org.apache.shiro.guice.web.ShiroWebModule;
import org.apache.shiro.realm.Realm; import org.apache.shiro.realm.Realm;
@@ -48,6 +49,7 @@ import javax.servlet.ServletContext;
import org.apache.shiro.mgt.RememberMeManager; import org.apache.shiro.mgt.RememberMeManager;
import sonia.scm.security.DisabledRememberMeManager; import sonia.scm.security.DisabledRememberMeManager;
import sonia.scm.security.ScmAtLeastOneSuccessfulStrategy; import sonia.scm.security.ScmAtLeastOneSuccessfulStrategy;
import sonia.scm.security.ScmPermissionResolver;
/** /**
* *
@@ -102,6 +104,7 @@ public class ScmSecurityModule extends ShiroWebModule
bind(ModularRealmAuthenticator.class); bind(ModularRealmAuthenticator.class);
bind(Authenticator.class).to(ModularRealmAuthenticator.class); bind(Authenticator.class).to(ModularRealmAuthenticator.class);
bind(AuthenticationStrategy.class).to(ScmAtLeastOneSuccessfulStrategy.class); bind(AuthenticationStrategy.class).to(ScmAtLeastOneSuccessfulStrategy.class);
bind(PermissionResolver.class).to(ScmPermissionResolver.class);
// bind realm // bind realm
for (Class<? extends Realm> realm : extensionProcessor.byExtensionPoint(Realm.class)) for (Class<? extends Realm> realm : extensionProcessor.byExtensionPoint(Realm.class))

View File

@@ -0,0 +1,48 @@
/*
* 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.security;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.time.Instant;
@Getter
@AllArgsConstructor
public class ApiKey {
private final String id;
private final String displayName;
private final String permissionRole;
private final Instant created;
ApiKey(ApiKeyWithPassphrase apiKeyWithPassphrase) {
this(
apiKeyWithPassphrase.getId(),
apiKeyWithPassphrase.getDisplayName(),
apiKeyWithPassphrase.getPermissionRole(),
apiKeyWithPassphrase.getCreated()
);
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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.security;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.commons.collections.CollectionUtils;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
import java.util.ArrayList;
import java.util.Collection;
import java.util.function.Predicate;
import static java.util.Collections.singletonList;
import static java.util.stream.Collectors.toList;
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "keys")
class ApiKeyCollection {
@XmlElement(name = "key")
private Collection<ApiKeyWithPassphrase> keys;
public ApiKeyCollection add(ApiKeyWithPassphrase key) {
Collection<ApiKeyWithPassphrase> newKeys;
if (CollectionUtils.isEmpty(keys)) {
newKeys = singletonList(key);
} else {
newKeys = new ArrayList<>(keys.size() + 1);
newKeys.addAll(keys);
newKeys.add(key);
}
return new ApiKeyCollection(newKeys);
}
public ApiKeyCollection remove(Predicate<ApiKeyWithPassphrase> predicate) {
Collection<ApiKeyWithPassphrase> newKeys = keys.stream().filter(key -> !predicate.test(key)).collect(toList());
return new ApiKeyCollection(newKeys);
}
}

View File

@@ -0,0 +1,111 @@
/*
* 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.security;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authc.credential.AllowAllCredentialsMatcher;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.realm.AuthenticatingRealm;
import sonia.scm.plugin.Extension;
import sonia.scm.repository.RepositoryRole;
import sonia.scm.repository.RepositoryRoleManager;
import javax.inject.Inject;
import javax.inject.Singleton;
import static com.google.common.base.Preconditions.checkArgument;
@Singleton
@Extension
public class ApiKeyRealm extends AuthenticatingRealm {
private final ApiKeyService apiKeyService;
private final DAORealmHelper helper;
private final RepositoryRoleManager repositoryRoleManager;
@Inject
public ApiKeyRealm(ApiKeyService apiKeyService, DAORealmHelperFactory helperFactory, RepositoryRoleManager repositoryRoleManager) {
this.apiKeyService = apiKeyService;
this.helper = helperFactory.create("ApiTokenRealm");
this.repositoryRoleManager = repositoryRoleManager;
setAuthenticationTokenClass(BearerToken.class);
setCredentialsMatcher(new AllowAllCredentialsMatcher());
}
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof UsernamePasswordToken || token instanceof BearerToken;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) {
checkArgument(
token instanceof BearerToken || token instanceof UsernamePasswordToken,
"%s is required", BearerToken.class);
String password = getPassword(token);
ApiKeyService.CheckResult check = apiKeyService.check(password);
return buildAuthenticationInfo(token, check);
}
private AuthenticationInfo buildAuthenticationInfo(AuthenticationToken token, ApiKeyService.CheckResult check) {
RepositoryRole repositoryRole = determineRole(check);
Scope scope = createScope(repositoryRole);
return helper
.authenticationInfoBuilder(check.getUser())
.withSessionId(getPrincipal(token))
.withScope(scope)
.build();
}
private String getPassword(AuthenticationToken token) {
if (token instanceof BearerToken) {
return ((BearerToken) token).getCredentials();
} else {
return new String(((UsernamePasswordToken) token).getPassword());
}
}
private RepositoryRole determineRole(ApiKeyService.CheckResult check) {
RepositoryRole repositoryRole = repositoryRoleManager.get(check.getPermissionRole());
if (repositoryRole == null) {
throw new AuthorizationException("api key has unknown role: " + check.getPermissionRole());
}
return repositoryRole;
}
private Scope createScope(RepositoryRole repositoryRole) {
return Scope.valueOf("repository:" + String.join(",", repositoryRole.getVerbs()) + ":*");
}
private SessionId getPrincipal(AuthenticationToken token) {
if (token instanceof BearerToken) {
return ((BearerToken) token).getPrincipal();
} else {
return SessionId.valueOf((token.getPrincipal()).toString());
}
}
}

View File

@@ -0,0 +1,219 @@
/*
* 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.security;
import com.github.legman.Subscribe;
import com.google.common.util.concurrent.Striped;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.apache.shiro.authc.credential.PasswordService;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.util.ThreadContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.ContextEntry;
import sonia.scm.HandlerEventType;
import sonia.scm.store.DataStore;
import sonia.scm.store.DataStoreFactory;
import sonia.scm.user.UserEvent;
import sonia.scm.user.UserPermissions;
import javax.inject.Inject;
import java.security.SecureRandom;
import java.util.Collection;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.function.Supplier;
import java.util.stream.Stream;
import static java.time.Instant.now;
import static java.util.Collections.emptyList;
import static java.util.stream.Collectors.toList;
import static org.apache.commons.lang.RandomStringUtils.random;
import static sonia.scm.AlreadyExistsException.alreadyExists;
public class ApiKeyService {
private static final Logger LOG = LoggerFactory.getLogger(ApiKeyService.class);
private static final int PASSPHRASE_LENGTH = 20;
private final DataStore<ApiKeyCollection> store;
private final PasswordService passwordService;
private final KeyGenerator keyGenerator;
private final Supplier<String> passphraseGenerator;
private final ApiKeyTokenHandler tokenHandler;
private final Striped<ReadWriteLock> locks = Striped.readWriteLock(10);
@Inject
ApiKeyService(DataStoreFactory storeFactory, KeyGenerator keyGenerator, PasswordService passwordService, ApiKeyTokenHandler tokenHandler) {
this(storeFactory, passwordService, keyGenerator, tokenHandler, () -> random(PASSPHRASE_LENGTH, 0, 0, true, true, null, new SecureRandom()));
}
ApiKeyService(DataStoreFactory storeFactory, PasswordService passwordService, KeyGenerator keyGenerator, ApiKeyTokenHandler tokenHandler, Supplier<String> passphraseGenerator) {
this.store = storeFactory.withType(ApiKeyCollection.class).withName("apiKeys").build();
this.passwordService = passwordService;
this.keyGenerator = keyGenerator;
this.tokenHandler = tokenHandler;
this.passphraseGenerator = passphraseGenerator;
}
public CreationResult createNewKey(String name, String permissionRole) {
String user = currentUser();
UserPermissions.changeApiKeys(user).check();
String passphrase = passphraseGenerator.get();
String hashedPassphrase = passwordService.encryptPassword(passphrase);
String id = keyGenerator.createKey();
ApiKeyWithPassphrase key = new ApiKeyWithPassphrase(id, name, permissionRole, hashedPassphrase, now());
doSynchronized(user, true, () -> {
persistKey(name, user, key);
return null;
});
String token = tokenHandler.createToken(user, new ApiKey(key), passphrase);
LOG.info("created new api key for user {} with role {}", user, permissionRole);
return new CreationResult(token, id);
}
public void persistKey(String name, String user, ApiKeyWithPassphrase key) {
if (containsName(user, name)) {
throw alreadyExists(ContextEntry.ContextBuilder.entity(ApiKeyWithPassphrase.class, name));
}
ApiKeyCollection apiKeyCollection = store.getOptional(user).orElse(new ApiKeyCollection(emptyList()));
ApiKeyCollection newApiKeyCollection = apiKeyCollection.add(key);
store.put(user, newApiKeyCollection);
}
public void remove(String id) {
String user = currentUser();
UserPermissions.changeApiKeys(user).check();
doSynchronized(user, true, () -> {
if (!containsId(user, id)) {
return null;
}
store.getOptional(user).ifPresent(
apiKeyCollection -> {
ApiKeyCollection newApiKeyCollection = apiKeyCollection.remove(key -> id.equals(key.getId()));
store.put(user, newApiKeyCollection);
LOG.info("removed api key for user {}", user);
}
);
return null;
});
}
CheckResult check(String tokenAsString) {
return check(tokenHandler.readToken(tokenAsString)
.orElseThrow(AuthorizationException::new));
}
private CheckResult check(ApiKeyTokenHandler.Token token) {
return check(token.getUser(), token.getApiKeyId(), token.getPassphrase());
}
CheckResult check(String user, String id, String passphrase) {
return doSynchronized(user, false, () -> store
.get(user)
.getKeys()
.stream()
.filter(key -> key.getId().equals(id))
.filter(key -> passwordsMatch(user, passphrase, key))
.map(ApiKeyWithPassphrase::getPermissionRole)
.map(role -> new CheckResult(user, role))
.findAny()
.orElseThrow(AuthorizationException::new));
}
private boolean passwordsMatch(String user, String passphrase, ApiKeyWithPassphrase key) {
boolean result = passwordService.passwordsMatch(passphrase, key.getPassphrase());
if (!result) {
// this can only happen with a forged api key, so it may be relevant enough to issue a warning
LOG.warn("got invalid api key for user {} with key id {}", user, key.getId());
}
return result;
}
public Collection<ApiKey> getKeys() {
return store.getOptional(currentUser())
.map(ApiKeyCollection::getKeys)
.map(Collection::stream)
.orElse(Stream.empty())
.map(ApiKey::new)
.collect(toList());
}
private String currentUser() {
return ThreadContext.getSubject().getPrincipals().getPrimaryPrincipal().toString();
}
private boolean containsId(String user, String id) {
return store
.getOptional(user)
.map(ApiKeyCollection::getKeys)
.orElse(emptyList())
.stream()
.anyMatch(key -> key.getId().equals(id));
}
private boolean containsName(String user, String name) {
return store
.getOptional(user)
.map(ApiKeyCollection::getKeys)
.orElse(emptyList())
.stream()
.anyMatch(key -> key.getDisplayName().equals(name));
}
private <T> T doSynchronized(String user, boolean write, Supplier<T> callback) {
final ReadWriteLock lockFactory = locks.get(user);
Lock lock = write ? lockFactory.writeLock() : lockFactory.readLock();
lock.lock();
try {
return callback.get();
} finally {
lock.unlock();
}
}
@Subscribe
public void cleanupForDeletedUser(UserEvent userEvent) {
if (userEvent.getEventType() == HandlerEventType.DELETE) {
store.remove(userEvent.getItem().getId());
}
}
@Getter
@AllArgsConstructor
public static class CreationResult {
private final String token;
private final String id;
}
@Getter
@AllArgsConstructor
public static class CheckResult {
private final String user;
private final String permissionRole;
}
}

View File

@@ -0,0 +1,84 @@
/*
* 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.security;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.io.Decoder;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.DecodingException;
import io.jsonwebtoken.io.Encoder;
import io.jsonwebtoken.io.Encoders;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.Optional;
import static java.util.Optional.empty;
import static java.util.Optional.of;
class ApiKeyTokenHandler {
private static final Encoder<byte[], String> encoder = Encoders.BASE64URL;
private static final Decoder<String, byte[]> decoder = Decoders.BASE64URL;
private static final Logger LOG = LoggerFactory.getLogger(ApiKeyTokenHandler.class);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
String createToken(String user, ApiKey apiKey, String passphrase) {
final Token token = new Token(apiKey.getId(), user, passphrase);
try {
return encoder.encode(OBJECT_MAPPER.writeValueAsBytes(token));
} catch (JsonProcessingException e) {
LOG.error("could not serialize token");
throw new TokenSerializationException(e);
}
}
Optional<Token> readToken(String token) {
try {
return of(OBJECT_MAPPER.readValue(decoder.decode(token), Token.class));
} catch (IOException | DecodingException e) {
LOG.warn("error reading api token", e);
return empty();
}
}
@AllArgsConstructor
@Getter
public static class Token {
private final String apiKeyId;
private final String user;
private final String passphrase;
}
private static class TokenSerializationException extends RuntimeException {
public TokenSerializationException(Throwable cause) {
super(cause);
}
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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.security;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import sonia.scm.xml.XmlInstantAdapter;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import java.time.Instant;
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@XmlAccessorType(XmlAccessType.FIELD)
class ApiKeyWithPassphrase {
private String id;
@XmlElement(name = "display-name")
private String displayName;
@XmlElement(name = "permission-role")
private String permissionRole;
private String passphrase;
@XmlJavaTypeAdapter(XmlInstantAdapter.class)
private Instant created;
}

View File

@@ -250,6 +250,7 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector
builder.add(getUserAutocompletePermission()); builder.add(getUserAutocompletePermission());
builder.add(getGroupAutocompletePermission()); builder.add(getGroupAutocompletePermission());
builder.add(getChangeOwnPasswordPermission(user)); builder.add(getChangeOwnPasswordPermission(user));
builder.add(getApiKeyPermission(user));
} }
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(ImmutableSet.of(Role.USER)); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(ImmutableSet.of(Role.USER));
@@ -266,6 +267,10 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector
return UserPermissions.changePassword(user).asShiroString(); return UserPermissions.changePassword(user).asShiroString();
} }
private String getApiKeyPermission(User user) {
return UserPermissions.changeApiKeys(user).asShiroString();
}
private String getUserAutocompletePermission() { private String getUserAutocompletePermission() {
return UserPermissions.autocomplete().asShiroString(); return UserPermissions.autocomplete().asShiroString();
} }

View File

@@ -68,6 +68,7 @@ public class DefaultRealm extends AuthorizingRealm
/** Field description */ /** Field description */
@VisibleForTesting @VisibleForTesting
static final String REALM = "DefaultRealm"; static final String REALM = "DefaultRealm";
private final ScmPermissionResolver permissionResolver;
//~--- constructors --------------------------------------------------------- //~--- constructors ---------------------------------------------------------
@@ -90,11 +91,18 @@ public class DefaultRealm extends AuthorizingRealm
matcher.setPasswordService(service); matcher.setPasswordService(service);
setCredentialsMatcher(helper.wrapCredentialsMatcher(matcher)); setCredentialsMatcher(helper.wrapCredentialsMatcher(matcher));
setAuthenticationTokenClass(UsernamePasswordToken.class); setAuthenticationTokenClass(UsernamePasswordToken.class);
permissionResolver = new ScmPermissionResolver();
setPermissionResolver(permissionResolver);
// we cache in the AuthorizationCollector // we cache in the AuthorizationCollector
setCachingEnabled(false); setCachingEnabled(false);
} }
@Override
public ScmPermissionResolver getPermissionResolver() {
return permissionResolver;
}
//~--- methods -------------------------------------------------------------- //~--- methods --------------------------------------------------------------
/** /**

View File

@@ -0,0 +1,34 @@
/*
* 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.security;
import org.apache.shiro.authz.permission.PermissionResolver;
public class ScmPermissionResolver implements PermissionResolver {
@Override
public ScmWildcardPermission resolvePermission(String permissionString) {
return new ScmWildcardPermission(permissionString);
}
}

View File

@@ -0,0 +1,154 @@
/*
* 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.security;
import org.apache.commons.collections.CollectionUtils;
import org.apache.shiro.authz.permission.WildcardPermission;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static java.util.Collections.singleton;
import static java.util.Optional.empty;
import static java.util.Optional.of;
public class ScmWildcardPermission extends WildcardPermission {
public ScmWildcardPermission(String permissionString) {
super(permissionString);
}
/**
* Limits this permission to the given scope. This will result in a collection of new permissions. This
* collection can be empty (but this will not return <code>null</code>). Three examples:
* <table>
* <tr>
* <th>This permission</th>
* <th>Scope</th>
* <th>Resulting permission(s)</th>
* </tr>
* <tr>
* <td><code>repository:*:42</code></td>
* <td><code>repository:read,pull:*</code></td>
* <td><code>repository:read,pull:42</code></td>
* </tr>
* <tr>
* <td><code>repository:read:*</code></td>
* <td><code>repository:*:42</code>, <code>repository:*:1337</code></td>
* <td><code>repository:read:42</code>, <code>repository:read:1337</code></td>
* </tr>
* <tr>
* <td><code>user:*:*</code></td>
* <td><code>repository:read,pull:*</code></td>
* <td><i>empty</i></td>
* </tr>
* </table>
* @param scope The scope this permission should be limited to.
* @return A collection with the resulting permissions (mind that this can be empty, but not <code>null</code>).
*/
Collection<ScmWildcardPermission> limit(Scope scope) {
Collection<ScmWildcardPermission> result = new ArrayList<>();
for (String s : scope) {
limit(s).ifPresent(result::add);
}
return result;
}
/**
* Limits this permission to a scope with a single permission. For examples see {@link #limit(String)}.
* @param scope The single scope.
* @return An {@link Optional} with the resulting permission if there was a overlap between this and the scope, or
* an empty {@link Optional} otherwise.
*/
Optional<ScmWildcardPermission> limit(String scope) {
return limit(new ScmWildcardPermission(scope));
}
/**
* Limits this permission to a scope with a single permission. For examples see {@link #limit(String)}.
* @param scope The single scope.
* @return An {@link Optional} with the resulting permission if there was a overlap between this and the scope, or
* an empty {@link Optional} otherwise.
*/
Optional<ScmWildcardPermission> limit(ScmWildcardPermission scope) {
// if one permission is a subset of the other, we can return the smaller one.
if (this.implies(scope)) {
return of(scope);
}
if (scope.implies(this)) {
return of(this);
}
// First we check, whether the subjects are the same. We do not use permissions with different subjects, so we
// either have both this the same subject, or we have no overlap.
final List<Set<String>> theseParts = getParts();
final List<Set<String>> scopeParts = scope.getParts();
if (!getEntries(theseParts, 0).equals(getEntries(scopeParts, 0))) {
return empty();
}
String subject = getEntries(scopeParts, 0).iterator().next();
// Now we create the intersections of verbs and ids to create the resulting permission
// (if not one of the resulting sets is empty)
Collection<String> verbs = intersect(theseParts, scopeParts, 1);
Collection<String> ids = intersect(theseParts, scopeParts, 2);
if (verbs.isEmpty() || ids.isEmpty()) {
return empty();
}
return of(new ScmWildcardPermission(subject + ":" + String.join(",", verbs) + ":" + String.join(",", ids)));
}
private Collection<String> intersect(List<Set<String>> theseParts, List<Set<String>> scopeParts, int position) {
final Set<String> theseEntries = getEntries(theseParts, position);
final Set<String> scopeEntries = getEntries(scopeParts, position);
if (isWildcard(theseEntries)) {
return scopeEntries;
}
if (isWildcard(scopeEntries)) {
return theseEntries;
}
return CollectionUtils.intersection(theseEntries, scopeEntries);
}
/**
* Handles "shortened" permissions like <code>repository:read</code> that should be <code>repository:read:*</code>.
*/
private Set<String> getEntries(List<Set<String>> theseParts, int position) {
if (position >= theseParts.size()) {
return singleton(WILDCARD_TOKEN);
}
return theseParts.get(position);
}
private boolean isWildcard(Set<String> entries) {
return entries.size() == 1 && entries.contains(WILDCARD_TOKEN);
}
}

View File

@@ -25,20 +25,18 @@
package sonia.scm.security; package sonia.scm.security;
import com.google.common.collect.Collections2; import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.Permission;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.Permission;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.authz.permission.PermissionResolver;
/** /**
* Util methods for {@link Scope}. * Util methods for {@link Scope}.
@@ -85,21 +83,24 @@ public final class Scopes {
} }
/** /**
* Filter permissions from {@link AuthorizationInfo} by scope values. Only permission definitions from the scope will * Limit permissions from {@link AuthorizationInfo} by scope values. Permission definitions from the
* be returned and only if a permission from the {@link AuthorizationInfo} implies the requested scope permission. * {@link AuthorizationInfo} will be returned, if a permission from the scope implies the original permission.
* If a permission from the {@link AuthorizationInfo} exceeds the permissions defined by the scope, it will
* be reduced. If the latter computation results in an empty permission, it will be omitted.
* *
* @param resolver permission resolver * @param resolver permission resolver
* @param authz authorization info * @param authz authorization info
* @param scope scope * @param scope scope
* *
* @return filtered {@link AuthorizationInfo} * @return limited {@link AuthorizationInfo}
*/ */
public static AuthorizationInfo filter(PermissionResolver resolver, AuthorizationInfo authz, Scope scope) { public static AuthorizationInfo filter(ScmPermissionResolver resolver, AuthorizationInfo authz, Scope scope) {
List<Permission> authzPermissions = authzPermissions(resolver, authz); List<Permission> authzPermissions = authzPermissions(resolver, authz);
Predicate<Permission> predicate = implies(authzPermissions); Set<Permission> filteredPermissions = authzPermissions
Set<Permission> filteredPermissions = resolve(resolver, ImmutableList.copyOf(scope))
.stream() .stream()
.filter(predicate) .map(p -> asScmWildcardPermission(p))
.map(p -> p.limit(scope))
.flatMap(Collection::stream)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
Set<String> roles = ImmutableSet.copyOf(nullToEmpty(authz.getRoles())); Set<String> roles = ImmutableSet.copyOf(nullToEmpty(authz.getRoles()));
@@ -108,26 +109,19 @@ public final class Scopes {
return authzFiltered; return authzFiltered;
} }
public static ScmWildcardPermission asScmWildcardPermission(Permission p) {
return p instanceof ScmWildcardPermission ? (ScmWildcardPermission) p : new ScmWildcardPermission(p.toString());
}
private static <T> Collection<T> nullToEmpty(Collection<T> collection) { private static <T> Collection<T> nullToEmpty(Collection<T> collection) {
return collection != null ? collection : Collections.emptySet(); return collection != null ? collection : Collections.emptySet();
} }
private static Collection<Permission> resolve(PermissionResolver resolver, Collection<String> permissions) { private static Collection<ScmWildcardPermission> resolve(ScmPermissionResolver resolver, Collection<String> permissions) {
return Collections2.transform(nullToEmpty(permissions), resolver::resolvePermission); return Collections2.transform(nullToEmpty(permissions), resolver::resolvePermission);
} }
private static Predicate<Permission> implies(Iterable<Permission> authzPermissions){ private static List<Permission> authzPermissions(ScmPermissionResolver resolver, AuthorizationInfo authz){
return (scopePermission) -> {
for ( Permission authzPermission : authzPermissions ) {
if (authzPermission.implies(scopePermission)) {
return true;
}
}
return false;
};
}
private static List<Permission> authzPermissions(PermissionResolver resolver, AuthorizationInfo authz){
List<Permission> authzPermissions = Lists.newArrayList(); List<Permission> authzPermissions = Lists.newArrayList();
authzPermissions.addAll(nullToEmpty(authz.getObjectPermissions())); authzPermissions.addAll(nullToEmpty(authz.getObjectPermissions()));
authzPermissions.addAll(resolve(resolver, authz.getStringPermissions())); authzPermissions.addAll(resolve(resolver, authz.getStringPermissions()));

View File

@@ -41,6 +41,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import sonia.scm.ContextEntry; import sonia.scm.ContextEntry;
import sonia.scm.group.GroupCollector; import sonia.scm.group.GroupCollector;
import sonia.scm.security.ApiKey;
import sonia.scm.security.ApiKeyService;
import sonia.scm.user.InvalidPasswordException; import sonia.scm.user.InvalidPasswordException;
import sonia.scm.user.User; import sonia.scm.user.User;
import sonia.scm.user.UserManager; import sonia.scm.user.UserManager;
@@ -52,13 +54,17 @@ import java.io.UnsupportedEncodingException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import static com.google.inject.util.Providers.of;
import static java.time.Instant.now;
import static java.util.Arrays.asList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.any; import static org.mockito.Mockito.any;
import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy; import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks; import static org.mockito.MockitoAnnotations.initMocks;
@@ -87,8 +93,13 @@ public class MeResourceTest {
@Mock @Mock
private UserManager userManager; private UserManager userManager;
@Mock
private ApiKeyService apiKeyService;
@InjectMocks @InjectMocks
private MeDtoFactory meDtoFactory; private MeDtoFactory meDtoFactory;
@InjectMocks
private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper;
private ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class); private ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
@@ -96,6 +107,8 @@ public class MeResourceTest {
private PasswordService passwordService; private PasswordService passwordService;
private User originalUser; private User originalUser;
private MockHttpResponse response = new MockHttpResponse();
@Before @Before
public void prepareEnvironment() { public void prepareEnvironment() {
initMocks(this); initMocks(this);
@@ -106,7 +119,9 @@ public class MeResourceTest {
when(groupCollector.collect("trillian")).thenReturn(ImmutableSet.of("group1", "group2")); when(groupCollector.collect("trillian")).thenReturn(ImmutableSet.of("group1", "group2"));
when(userManager.isTypeDefault(userCaptor.capture())).thenCallRealMethod(); when(userManager.isTypeDefault(userCaptor.capture())).thenCallRealMethod();
when(userManager.getDefaultType()).thenReturn("xml"); when(userManager.getDefaultType()).thenReturn("xml");
MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService); ApiKeyCollectionToDtoMapper apiKeyCollectionMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks);
ApiKeyResource apiKeyResource = new ApiKeyResource(apiKeyService, apiKeyCollectionMapper, apiKeyMapper, resourceLinks);
MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService, of(apiKeyResource));
when(uriInfo.getApiRestUri()).thenReturn(URI.create("/")); when(uriInfo.getApiRestUri()).thenReturn(URI.create("/"));
when(scmPathInfoStore.get()).thenReturn(uriInfo); when(scmPathInfoStore.get()).thenReturn(uriInfo);
dispatcher.addSingletonResource(meResource); dispatcher.addSingletonResource(meResource);
@@ -118,14 +133,14 @@ public class MeResourceTest {
MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2); MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2);
request.accept(VndMediaType.ME); request.accept(VndMediaType.ME);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_OK, response.getStatus()); assertEquals(HttpServletResponse.SC_OK, response.getStatus());
assertTrue(response.getContentAsString().contains("\"name\":\"trillian\"")); assertThat(response.getContentAsString()).contains("\"name\":\"trillian\"");
assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"/v2/me/\"}")); assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/\"}");
assertTrue(response.getContentAsString().contains("\"delete\":{\"href\":\"/v2/users/trillian\"}")); assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/users/trillian\"}");
assertThat(response.getContentAsString()).contains("\"apiKeys\":{\"href\":\"/v2/me/api_keys\"}");
} }
private void applyUserToSubject(User user) { private void applyUserToSubject(User user) {
@@ -149,7 +164,6 @@ public class MeResourceTest {
.put("/" + MeResource.ME_PATH_V2 + "password") .put("/" + MeResource.ME_PATH_V2 + "password")
.contentType(VndMediaType.PASSWORD_CHANGE) .contentType(VndMediaType.PASSWORD_CHANGE)
.content(content.getBytes()); .content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
when(passwordService.encryptPassword(newPassword)).thenReturn(encryptedNewPassword); when(passwordService.encryptPassword(newPassword)).thenReturn(encryptedNewPassword);
when(passwordService.encryptPassword(oldPassword)).thenReturn(encryptedOldPassword); when(passwordService.encryptPassword(oldPassword)).thenReturn(encryptedOldPassword);
@@ -174,7 +188,6 @@ public class MeResourceTest {
.put("/" + MeResource.ME_PATH_V2 + "password") .put("/" + MeResource.ME_PATH_V2 + "password")
.contentType(VndMediaType.PASSWORD_CHANGE) .contentType(VndMediaType.PASSWORD_CHANGE)
.content(content.getBytes()); .content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -190,7 +203,6 @@ public class MeResourceTest {
.put("/" + MeResource.ME_PATH_V2 + "password") .put("/" + MeResource.ME_PATH_V2 + "password")
.contentType(VndMediaType.PASSWORD_CHANGE) .contentType(VndMediaType.PASSWORD_CHANGE)
.content(content.getBytes()); .content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -206,7 +218,6 @@ public class MeResourceTest {
.put("/" + MeResource.ME_PATH_V2 + "password") .put("/" + MeResource.ME_PATH_V2 + "password")
.contentType(VndMediaType.PASSWORD_CHANGE) .contentType(VndMediaType.PASSWORD_CHANGE)
.content(content.getBytes()); .content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
doThrow(new InvalidPasswordException(ContextEntry.ContextBuilder.entity("passwortChange", "-"))) doThrow(new InvalidPasswordException(ContextEntry.ContextBuilder.entity("passwortChange", "-")))
.when(userManager).changePasswordForLoggedInUser(any(), any()); .when(userManager).changePasswordForLoggedInUser(any(), any());
@@ -216,6 +227,80 @@ public class MeResourceTest {
assertEquals(HttpServletResponse.SC_BAD_REQUEST, response.getStatus()); assertEquals(HttpServletResponse.SC_BAD_REQUEST, response.getStatus());
} }
@Test
public void shouldGetAllApiKeys() throws URISyntaxException, UnsupportedEncodingException {
when(apiKeyService.getKeys())
.thenReturn(asList(
new ApiKey("1", "key 1", "READ", now()),
new ApiKey("2", "key 2", "WRITE", now())));
MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2 + "api_keys");
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\",\"permissionRole\":\"READ\"");
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 2\",\"permissionRole\":\"WRITE\"");
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/api_keys\"}");
assertThat(response.getContentAsString()).contains("\"create\":{\"href\":\"/v2/me/api_keys\"}");
}
@Test
public void shouldGetSingleApiKey() throws URISyntaxException, UnsupportedEncodingException {
when(apiKeyService.getKeys())
.thenReturn(asList(
new ApiKey("1", "key 1", "READ", now()),
new ApiKey("2", "key 2", "WRITE", now())));
MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2 + "api_keys/1");
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\"");
assertThat(response.getContentAsString()).contains("\"permissionRole\":\"READ\"");
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/api_keys/1\"}");
assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/me/api_keys/1\"}");
}
@Test
public void shouldCreateNewApiKey() throws URISyntaxException, UnsupportedEncodingException {
when(apiKeyService.createNewKey("guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1"));
final MockHttpRequest request = MockHttpRequest
.post("/" + MeResource.ME_PATH_V2 + "api_keys/")
.contentType(VndMediaType.API_KEY)
.content("{\"displayName\":\"guide\",\"permissionRole\":\"READ\"}".getBytes());
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(201);
assertThat(response.getContentAsString()).isEqualTo("abc");
assertThat(response.getOutputHeaders().get("Location")).containsExactly(URI.create("/v2/me/api_keys/1"));
}
@Test
public void shouldIgnoreInvalidNewApiKey() throws URISyntaxException, UnsupportedEncodingException {
when(apiKeyService.createNewKey("guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1"));
final MockHttpRequest request = MockHttpRequest
.post("/" + MeResource.ME_PATH_V2 + "api_keys/")
.contentType(VndMediaType.API_KEY)
.content("{\"displayName\":\"guide\",\"pemissionRole\":\"\"}".getBytes());
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(400);
}
@Test
public void shouldDeleteExistingApiKey() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.delete("/" + MeResource.ME_PATH_V2 + "api_keys/1");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(204);
verify(apiKeyService).remove("1");
}
private User createDummyUser(String name) { private User createDummyUser(String name) {
User user = new User(); User user = new User();

View File

@@ -80,6 +80,8 @@ public class ResourceLinksMock {
lenient().when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(pathInfo)); lenient().when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(pathInfo));
lenient().when(resourceLinks.namespacePermission()).thenReturn(new ResourceLinks.NamespacePermissionLinks(pathInfo)); lenient().when(resourceLinks.namespacePermission()).thenReturn(new ResourceLinks.NamespacePermissionLinks(pathInfo));
lenient().when(resourceLinks.adminInfo()).thenReturn(new ResourceLinks.AdminInfoLinks(pathInfo)); lenient().when(resourceLinks.adminInfo()).thenReturn(new ResourceLinks.AdminInfoLinks(pathInfo));
lenient().when(resourceLinks.apiKeyCollection()).thenReturn(new ResourceLinks.ApiKeyCollectionLinks(pathInfo));
lenient().when(resourceLinks.apiKey()).thenReturn(new ResourceLinks.ApiKeyLinks(pathInfo));
return resourceLinks; return resourceLinks;
} }

View File

@@ -0,0 +1,105 @@
/*
* 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.security;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authz.AuthorizationException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.RepositoryRole;
import sonia.scm.repository.RepositoryRoleManager;
import static java.util.Collections.singleton;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static sonia.scm.security.BearerToken.valueOf;
@ExtendWith(MockitoExtension.class)
class ApiKeyRealmTest {
@Mock
ApiKeyService apiKeyService;
@Mock
DAORealmHelperFactory helperFactory;
@Mock
DAORealmHelper helper;
@Mock(answer = Answers.RETURNS_SELF)
DAORealmHelper.AuthenticationInfoBuilder authenticationInfoBuilder;
@Mock
RepositoryRoleManager repositoryRoleManager;
ApiKeyRealm realm;
@BeforeEach
void initRealmHelper() {
lenient().when(helperFactory.create("ApiTokenRealm")).thenReturn(helper);
lenient().when(helper.authenticationInfoBuilder(any())).thenReturn(authenticationInfoBuilder);
realm = new ApiKeyRealm(apiKeyService, helperFactory, repositoryRoleManager);
}
@Test
void shouldCreateAuthenticationWithScope() {
when(apiKeyService.check("towel")).thenReturn(new ApiKeyService.CheckResult("ford", "READ"));
when(repositoryRoleManager.get("READ")).thenReturn(new RepositoryRole("guide", singleton("read"), "system"));
realm.doGetAuthenticationInfo(valueOf("towel"));
verify(helper).authenticationInfoBuilder("ford");
verifyScopeSet("repository:read:*");
verify(authenticationInfoBuilder).withSessionId(null);
}
@Test
void shouldFailWithoutBearerToken() {
AuthenticationToken otherToken = mock(AuthenticationToken.class);
assertThrows(IllegalArgumentException.class, () -> realm.doGetAuthenticationInfo(otherToken));
}
@Test
void shouldFailWithUnknownRole() {
when(apiKeyService.check("towel")).thenReturn(new ApiKeyService.CheckResult("ford", "READ"));
when(repositoryRoleManager.get("READ")).thenReturn(null);
BearerToken token = valueOf("towel");
assertThrows(AuthorizationException.class, () -> realm.doGetAuthenticationInfo(token));
}
void verifyScopeSet(String... permissions) {
verify(authenticationInfoBuilder).withScope(argThat(scope -> {
assertThat(scope).containsExactly(permissions);
return true;
}));
}
}

View File

@@ -0,0 +1,181 @@
/*
* 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.security;
import org.apache.shiro.authc.credential.PasswordService;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import sonia.scm.AlreadyExistsException;
import sonia.scm.HandlerEventType;
import sonia.scm.store.DataStore;
import sonia.scm.store.DataStoreFactory;
import sonia.scm.store.InMemoryDataStore;
import sonia.scm.store.InMemoryDataStoreFactory;
import sonia.scm.user.User;
import sonia.scm.user.UserEvent;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class ApiKeyServiceTest {
int nextKey = 1;
int nextId = 1;
PasswordService passwordService = mock(PasswordService.class);
Supplier<String> passphraseGenerator = () -> Integer.toString(nextKey++);
KeyGenerator keyGenerator = () -> Integer.toString(nextId++);
ApiKeyTokenHandler tokenHandler = new ApiKeyTokenHandler();
DataStoreFactory storeFactory = new InMemoryDataStoreFactory(new InMemoryDataStore<ApiKeyCollection>());
DataStore<ApiKeyCollection> store = storeFactory.withType(ApiKeyCollection.class).withName("apiKeys").build();
ApiKeyService service = new ApiKeyService(storeFactory, passwordService, keyGenerator, tokenHandler, passphraseGenerator);
@BeforeEach
void mockPasswordService() {
when(passwordService.encryptPassword(any()))
.thenAnswer(invocationOnMock -> invocationOnMock.getArgument(0) + "-hashed");
when(passwordService.passwordsMatch(any(), any()))
.thenAnswer(invocationOnMock -> invocationOnMock.getArgument(1, String.class).startsWith(invocationOnMock.getArgument(0)));
}
@Nested
class WithLoggedInUser {
@BeforeEach
void mockUser() {
final Subject subject = mock(Subject.class);
ThreadContext.bind(subject);
final PrincipalCollection principalCollection = mock(PrincipalCollection.class);
when(subject.getPrincipals()).thenReturn(principalCollection);
when(principalCollection.getPrimaryPrincipal()).thenReturn("dent");
}
@AfterEach
void unbindSubject() {
ThreadContext.unbindSubject();
}
@Test
void shouldCreateNewKeyAndStoreItHashed() {
service.createNewKey("1", "READ");
ApiKeyCollection apiKeys = store.get("dent");
assertThat(apiKeys.getKeys()).hasSize(1);
ApiKeyWithPassphrase key = apiKeys.getKeys().iterator().next();
assertThat(key.getPermissionRole()).isEqualTo("READ");
assertThat(key.getPassphrase()).isEqualTo("1-hashed");
ApiKeyService.CheckResult role = service.check("dent", "1", "1-hashed");
assertThat(role).extracting("permissionRole").isEqualTo("READ");
}
@Test
void shouldReturnRoleForKey() {
String newKey = service.createNewKey("1", "READ").getToken();
ApiKeyService.CheckResult role = service.check(newKey);
assertThat(role).extracting("permissionRole").isEqualTo("READ");
}
@Test
void shouldHandleNewUser() {
assertThat(service.getKeys()).isEmpty();
}
@Test
void shouldNotReturnAnythingWithWrongKey() {
service.createNewKey("1", "READ");
assertThrows(AuthorizationException.class, () -> service.check("dent", "1", "wrong"));
}
@Test
void shouldAddSecondKey() {
ApiKeyService.CreationResult firstKey = service.createNewKey("1", "READ");
ApiKeyService.CreationResult secondKey = service.createNewKey("2", "WRITE");
ApiKeyCollection apiKeys = store.get("dent");
assertThat(apiKeys.getKeys()).hasSize(2);
assertThat(service.check(firstKey.getToken())).extracting("permissionRole").isEqualTo("READ");
assertThat(service.check(secondKey.getToken())).extracting("permissionRole").isEqualTo("WRITE");
assertThat(service.getKeys()).extracting("id")
.contains(firstKey.getId(), secondKey.getId());
}
@Test
void shouldRemoveKey() {
String firstKey = service.createNewKey("first", "READ").getToken();
String secondKey = service.createNewKey("second", "WRITE").getToken();
service.remove("1");
assertThrows(AuthorizationException.class, () -> service.check(firstKey));
assertThat(service.check(secondKey)).extracting("permissionRole").isEqualTo("WRITE");
}
@Test
void shouldFailWhenAddingSameNameTwice() {
String firstKey = service.createNewKey("1", "READ").getToken();
assertThrows(AlreadyExistsException.class, () -> service.createNewKey("1", "WRITE"));
assertThat(service.check(firstKey)).extracting("permissionRole").isEqualTo("READ");
}
@Test
void shouldIgnoreCorrectPassphraseWithWrongName() {
String firstKey = service.createNewKey("1", "READ").getToken();
assertThrows(AuthorizationException.class, () -> service.check("dent", "other", firstKey));
}
@Test
void shouldDeleteTokensWhenUserIsDeleted() {
service.createNewKey("1", "READ").getToken();
assertThat(store.get("dent").getKeys()).hasSize(1);
service.cleanupForDeletedUser(new UserEvent(HandlerEventType.DELETE, new User("dent")));
assertThat(store.get("dent")).isNull();
}
}
}

View File

@@ -0,0 +1,64 @@
/*
* 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.security;
import io.jsonwebtoken.io.Encoders;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static java.time.Instant.now;
import static org.assertj.core.api.Assertions.assertThat;
class ApiKeyTokenHandlerTest {
ApiKeyTokenHandler handler = new ApiKeyTokenHandler();
@Test
void shouldSerializeAndDeserializeToken() {
String tokenString = handler.createToken("dent", new ApiKey("42", "hg2g", "READ", now()), "some secret");
Optional<ApiKeyTokenHandler.Token> token = handler.readToken(tokenString);
assertThat(token).isNotEmpty();
assertThat(token).get().extracting("user").isEqualTo("dent");
assertThat(token).get().extracting("apiKeyId").isEqualTo("42");
assertThat(token).get().extracting("passphrase").isEqualTo("some secret");
}
@Test
void shouldNotFailWithInvalidTokenEncoding() {
Optional<ApiKeyTokenHandler.Token> token = handler.readToken("invalid token");
assertThat(token).isEmpty();
}
@Test
void shouldNotFailWithInvalidTokenContent() {
Optional<ApiKeyTokenHandler.Token> token = handler.readToken(Encoders.BASE64URL.encode("{\"invalid\":\"token\"}".getBytes()));
assertThat(token).isEmpty();
}
}

View File

@@ -167,8 +167,8 @@ public class DefaultAuthorizationCollectorTest {
AuthorizationInfo authInfo = collector.collect(); AuthorizationInfo authInfo = collector.collect();
assertThat(authInfo.getRoles(), Matchers.contains(Role.USER)); assertThat(authInfo.getRoles(), Matchers.contains(Role.USER));
assertThat(authInfo.getStringPermissions(), hasSize(4)); assertThat(authInfo.getStringPermissions(), hasSize(5));
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "user:read:trillian")); assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "user:read:trillian", "user:changeApiKeys:trillian"));
assertThat(authInfo.getObjectPermissions(), nullValue()); assertThat(authInfo.getObjectPermissions(), nullValue());
} }
@@ -212,7 +212,7 @@ public class DefaultAuthorizationCollectorTest {
AuthorizationInfo authInfo = collector.collect(); AuthorizationInfo authInfo = collector.collect();
assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER)); assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER));
assertThat(authInfo.getObjectPermissions(), nullValue()); assertThat(authInfo.getObjectPermissions(), nullValue());
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian")); assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian", "user:changeApiKeys:trillian"));
} }
/** /**
@@ -244,7 +244,7 @@ public class DefaultAuthorizationCollectorTest {
AuthorizationInfo authInfo = collector.collect(); AuthorizationInfo authInfo = collector.collect();
assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER)); assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER));
assertThat(authInfo.getObjectPermissions(), nullValue()); assertThat(authInfo.getObjectPermissions(), nullValue());
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian")); assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian", "user:changeApiKeys:trillian"));
} }
/** /**
@@ -287,7 +287,8 @@ public class DefaultAuthorizationCollectorTest {
"repository:user:one", "repository:user:one",
"repository:system:one", "repository:system:one",
"repository:group:two", "repository:group:two",
"user:read:trillian")); "user:read:trillian",
"user:changeApiKeys:trillian"));
} }
/** /**
@@ -334,7 +335,7 @@ public class DefaultAuthorizationCollectorTest {
AuthorizationInfo authInfo = collector.collect(); AuthorizationInfo authInfo = collector.collect();
assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER)); assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER));
assertThat(authInfo.getObjectPermissions(), nullValue()); assertThat(authInfo.getObjectPermissions(), nullValue());
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("one:one", "two:two", "user:read:trillian", "user:autocomplete", "group:autocomplete", "user:changePassword:trillian")); assertThat(authInfo.getStringPermissions(), containsInAnyOrder("one:one", "two:two", "user:read:trillian", "user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "user:changeApiKeys:trillian"));
} }
private void authenticate(User user, String group, String... groups) { private void authenticate(User user, String group, String... groups) {

View File

@@ -0,0 +1,106 @@
/*
* 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.security;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
class ScmWildcardPermissionTest {
@Test
void shouldEliminatePermissionsWithDifferentSubject() {
ScmWildcardPermission permission = new ScmWildcardPermission("user:write:*");
Optional<ScmWildcardPermission> limitedPermissions = permission.limit("repository:write:*");
assertThat(limitedPermissions).isEmpty();
}
@Test
void shouldReturnScopeIfPermissionImpliesScope() {
ScmWildcardPermission permission = new ScmWildcardPermission("*");
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:read:42");
assertThat(limitedPermission).get().hasToString("repository:read:42");
}
@Test
void shouldReturnPermissionIfScopeImpliesPermission() {
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42");
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:*:42");
assertThat(limitedPermission).get().hasToString("repository:read:42");
}
@Test
void shouldLimitExplicitParts() {
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read,write:42,43,44");
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:read,write,pull:42");
assertThat(limitedPermission).get().hasToString("repository:read,write:42");
}
@Test
void shouldDetectWildcard() {
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read,write:*");
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:*:42");
assertThat(limitedPermission).get().hasToString("repository:read,write:42");
}
@Test
void shouldHandleMissingEntriesAsWildcard() {
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read,write");
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:*:42");
assertThat(limitedPermission).get().hasToString("repository:read,write:42");
}
@Test
void shouldEliminateEmptyVerbs() {
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42");
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:pull:42");
assertThat(limitedPermission).isEmpty();
}
@Test
void shouldEliminateEmptyId() {
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42");
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:read:23");
assertThat(limitedPermission).isEmpty();
}
}

View File

@@ -26,15 +26,18 @@ package sonia.scm.security;
import com.google.common.collect.Collections2; import com.google.common.collect.Collections2;
import com.google.common.collect.Sets; import com.google.common.collect.Sets;
import java.util.Set;
import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.Permission; import org.apache.shiro.authz.Permission;
import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.authz.permission.WildcardPermission;
import org.apache.shiro.authz.permission.WildcardPermissionResolver;
import org.junit.Test; import org.junit.Test;
import static org.junit.Assert.*;
import static org.hamcrest.Matchers.*; import java.util.Set;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.emptyCollectionOf;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.Assert.assertThat;
/** /**
* Unit tests for {@link Scopes}. * Unit tests for {@link Scopes}.
@@ -43,7 +46,7 @@ import static org.hamcrest.Matchers.*;
*/ */
public class ScopesTest { public class ScopesTest {
private final WildcardPermissionResolver resolver = new WildcardPermissionResolver(); private final ScmPermissionResolver resolver = new ScmPermissionResolver();
/** /**
* Tests that filter keep roles. * Tests that filter keep roles.
@@ -67,6 +70,14 @@ public class ScopesTest {
assertPermissions(Scopes.filter(resolver, authz, scope), "repository:read:123"); assertPermissions(Scopes.filter(resolver, authz, scope), "repository:read:123");
} }
@Test
public void testFilterIntersectingPermissions() {
Scope scope = Scope.valueOf("repository:read,write:*");
AuthorizationInfo authz = authz("repository:*:123");
assertPermissions(Scopes.filter(resolver, authz, scope), "repository:read,write:123");
}
/** /**
* Tests filter with a simple deny. * Tests filter with a simple deny.
*/ */
@@ -88,7 +99,7 @@ public class ScopesTest {
Scope scope = Scope.valueOf("repo:read,modify:1", "repo:read:2", "repo:*:3", "repo:modify:4"); Scope scope = Scope.valueOf("repo:read,modify:1", "repo:read:2", "repo:*:3", "repo:modify:4");
AuthorizationInfo authz = authz("repo:read:*"); AuthorizationInfo authz = authz("repo:read:*");
assertPermissions(Scopes.filter(resolver, authz, scope), "repo:read:2"); assertPermissions(Scopes.filter(resolver, authz, scope), "repo:read:1", "repo:read:2", "repo:read:3");
} }
/** /**
@@ -110,10 +121,8 @@ public class ScopesTest {
Scope scope = Scope.valueOf("*"); Scope scope = Scope.valueOf("*");
AuthorizationInfo authz = authz("repository:*"); AuthorizationInfo authz = authz("repository:*");
assertThat( assertPermissions(Scopes.filter(resolver, authz, scope),
Scopes.filter(resolver, authz, scope).getObjectPermissions(), "repository:*");
is(emptyCollectionOf(Permission.class))
);
} }
private void assertPermissions(AuthorizationInfo authz, Object... permissions) { private void assertPermissions(AuthorizationInfo authz, Object... permissions) {
@@ -128,7 +137,7 @@ public class ScopesTest {
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(Sets.newHashSet("unit", "test")); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(Sets.newHashSet("unit", "test"));
Set<Permission> permissions = Sets.newLinkedHashSet(); Set<Permission> permissions = Sets.newLinkedHashSet();
for ( String value : values ) { for ( String value : values ) {
permissions.add(new WildcardPermission(value)); permissions.add(new ScmWildcardPermission(value));
} }
info.setObjectPermissions(permissions); info.setObjectPermissions(permissions);
return info; return info;