mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-03-03 19:00:52 +01:00
@@ -10,6 +10,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))
|
||||
|
||||
## [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
|
||||
- Not found error when using browse command in empty hg repository ([#1355](https://github.com/scm-manager/scm-manager/pull/1355))
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
- /user/user/
|
||||
- /user/group/
|
||||
- /user/admin/
|
||||
- /user/profile/
|
||||
|
||||
BIN
docs/de/user/profile/assets/api-key-created.png
Normal file
BIN
docs/de/user/profile/assets/api-key-created.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/de/user/profile/assets/api-key-overview.png
Normal file
BIN
docs/de/user/profile/assets/api-key-overview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
50
docs/de/user/profile/index.md
Normal file
50
docs/de/user/profile/index.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
@@ -12,6 +12,7 @@
|
||||
- /user/user/
|
||||
- /user/group/
|
||||
- /user/admin/
|
||||
- /user/profile/
|
||||
|
||||
- section: Administration
|
||||
entries:
|
||||
|
||||
BIN
docs/en/user/profile/assets/api-key-created.png
Normal file
BIN
docs/en/user/profile/assets/api-key-created.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/en/user/profile/assets/api-key-overview.png
Normal file
BIN
docs/en/user/profile/assets/api-key-overview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
49
docs/en/user/profile/index.md
Normal file
49
docs/en/user/profile/index.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
@@ -21,7 +21,7 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.user;
|
||||
|
||||
//~--- non-JDK imports --------------------------------------------------------
|
||||
@@ -50,7 +50,7 @@ import java.security.Principal;
|
||||
@StaticPermissions(
|
||||
value = "user",
|
||||
globalPermissions = {"create", "list", "autocomplete"},
|
||||
permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys"},
|
||||
permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys", "changeApiKeys"},
|
||||
custom = true, customGlobal = true
|
||||
)
|
||||
@XmlRootElement(name = "users")
|
||||
|
||||
@@ -85,6 +85,9 @@ public class VndMediaType {
|
||||
public static final String REPOSITORY_ROLE = PREFIX + "repositoryRole" + 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() {
|
||||
}
|
||||
|
||||
|
||||
115
scm-it/src/test/java/sonia/scm/it/ApiKeyITCase.java
Normal file
115
scm-it/src/test/java/sonia/scm/it/ApiKeyITCase.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@ const Footer: FC<Props> = ({ me, version, links }) => {
|
||||
<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?.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} />
|
||||
</FooterSection>
|
||||
<FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}>
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"informationNavLink": "Information",
|
||||
"changePasswordNavLink": "Passwort ändern",
|
||||
"publicKeysNavLink": "Öffentliche Schlüssel",
|
||||
"apiKeysNavLink": "API Schlüssel",
|
||||
"settingsNavLink": "Einstellungen",
|
||||
"username": "Benutzername",
|
||||
"displayName": "Anzeigename",
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"generalNavLink": "Generell",
|
||||
"setPasswordNavLink": "Passwort",
|
||||
"setPermissionsNavLink": "Berechtigungen",
|
||||
"setPublicKeyNavLink": "Öffentliche Schlüssel"
|
||||
"setPublicKeyNavLink": "Öffentliche Schlüssel",
|
||||
"setApiKeyNavLink": "API Schlüssel"
|
||||
}
|
||||
},
|
||||
"createUser": {
|
||||
@@ -70,5 +71,27 @@
|
||||
"addKey": "Schlüssel hinzufügen",
|
||||
"delete": "Löschen",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +70,10 @@
|
||||
"profile": {
|
||||
"navigationLabel": "Profile",
|
||||
"informationNavLink": "Information",
|
||||
"changePasswordNavLink": "Change password",
|
||||
"changePasswordNavLink": "Change Password",
|
||||
"settingsNavLink": "Settings",
|
||||
"publicKeysNavLink": "Public Keys",
|
||||
"apiKeysNavLink": "API Keys",
|
||||
"username": "Username",
|
||||
"displayName": "Display Name",
|
||||
"mail": "E-Mail",
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"generalNavLink": "General",
|
||||
"setPasswordNavLink": "Password",
|
||||
"setPermissionsNavLink": "Permissions",
|
||||
"setPublicKeyNavLink": "Public Keys"
|
||||
"setPublicKeyNavLink": "Public Keys",
|
||||
"setApiKeyNavLink": "API Keys"
|
||||
}
|
||||
},
|
||||
"createUser": {
|
||||
@@ -70,5 +71,27 @@
|
||||
"addKey": "Add key",
|
||||
"delete": "Delete",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ import ProfileInfo from "./ProfileInfo";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import SetPublicKeys from "../users/components/publicKeys/SetPublicKeys";
|
||||
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";
|
||||
|
||||
type Props = RouteComponentProps &
|
||||
@@ -65,6 +67,11 @@ class Profile extends React.Component<Props> {
|
||||
return !!me?._links?.publicKeys;
|
||||
};
|
||||
|
||||
canManageApiKeys = () => {
|
||||
const { me } = this.props;
|
||||
return !!me?._links?.apiKeys;
|
||||
};
|
||||
|
||||
render() {
|
||||
const url = urls.matchedUrl(this.props);
|
||||
|
||||
@@ -100,6 +107,9 @@ class Profile extends React.Component<Props> {
|
||||
{this.canManagePublicKeys() && (
|
||||
<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} />
|
||||
</PrimaryContentColumn>
|
||||
<SecondaryNavigationColumn>
|
||||
@@ -118,6 +128,7 @@ class Profile extends React.Component<Props> {
|
||||
>
|
||||
<NavLink to={`${url}/settings/password`} label={t("profile.changePasswordNavLink")} />
|
||||
<SetPublicKeyNavLink user={me} publicKeyUrl={`${url}/settings/publicKeys`} />
|
||||
<SetApiKeyNavLink user={me} apiKeyUrl={`${url}/settings/apiKeys`} />
|
||||
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
|
||||
</SubNavigation>
|
||||
)}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import { Select } from "@scm-manager/ui-components";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
availableRoles: string[];
|
||||
availableRoles?: string[];
|
||||
handleRoleChange: (p: string) => void;
|
||||
role: string;
|
||||
label?: string;
|
||||
|
||||
145
scm-ui/ui-webapp/src/users/components/apiKeys/AddApiKey.tsx
Normal file
145
scm-ui/ui-webapp/src/users/components/apiKeys/AddApiKey.tsx
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
110
scm-ui/ui-webapp/src/users/components/apiKeys/SetApiKeys.tsx
Normal file
110
scm-ui/ui-webapp/src/users/components/apiKeys/SetApiKeys.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -21,7 +21,7 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.api;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
@@ -42,7 +42,7 @@ public class NotSupportedExceptionMapper implements ExceptionMapper<NotSupported
|
||||
|
||||
@Override
|
||||
public Response toResponse(NotSupportedException exception) {
|
||||
LOG.debug("illegal media type");
|
||||
LOG.debug("illegal media type", exception);
|
||||
ErrorDto error = new ErrorDto();
|
||||
error.setTransactionId(MDC.get("transaction_id"));
|
||||
error.setMessage("illegal media type");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
* 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;
|
||||
@@ -61,8 +61,10 @@ public abstract class BrowserResultToFileObjectDtoMapper extends BaseFileObjectD
|
||||
@Override
|
||||
void applyEnrichers(Links.Builder links, Embedded.Builder embeddedBuilder, NamespaceAndName namespaceAndName, BrowserResult browserResult, FileObject fileObject) {
|
||||
EdisonHalAppender appender = new EdisonHalAppender(links, embeddedBuilder);
|
||||
// we call enrichers, which are only responsible for top level browseresults
|
||||
applyEnrichers(appender, browserResult, namespaceAndName);
|
||||
if (browserResult.getFile().equals(fileObject)) {
|
||||
// we call enrichers, which are only responsible for top level browseresults
|
||||
applyEnrichers(appender, browserResult, namespaceAndName);
|
||||
}
|
||||
// we call enrichers, which are responsible for all file object top level browse result and its children
|
||||
applyEnrichers(appender, fileObject, namespaceAndName, browserResult, browserResult.getRevision());
|
||||
}
|
||||
|
||||
@@ -86,5 +86,7 @@ public class MapperModule extends AbstractModule {
|
||||
bind(ScmPathInfoStore.class).in(ServletScopes.REQUEST);
|
||||
|
||||
bind(PluginDtoMapper.class).to(Mappers.getMapperClass(PluginDtoMapper.class));
|
||||
|
||||
bind(ApiKeyToApiKeyDtoMapper.class).to(Mappers.getMapperClass(ApiKeyToApiKeyDtoMapper.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ public class MeDtoFactory extends HalAppenderMapper {
|
||||
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) {
|
||||
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
|
||||
}
|
||||
if (UserPermissions.changeApiKeys(user).isPermitted()) {
|
||||
linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self()));
|
||||
}
|
||||
|
||||
Embedded.Builder embeddedBuilder = embeddedBuilder();
|
||||
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user);
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||
@@ -35,6 +35,7 @@ import sonia.scm.user.UserManager;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Provider;
|
||||
import javax.validation.Valid;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.GET;
|
||||
@@ -61,11 +62,14 @@ public class MeResource {
|
||||
private final UserManager userManager;
|
||||
private final PasswordService passwordService;
|
||||
|
||||
private final Provider<ApiKeyResource> apiKeyResource;
|
||||
|
||||
@Inject
|
||||
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService) {
|
||||
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResource) {
|
||||
this.meDtoFactory = meDtoFactory;
|
||||
this.userManager = userManager;
|
||||
this.passwordService = passwordService;
|
||||
this.apiKeyResource = apiKeyResource;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,4 +122,9 @@ public class MeResource {
|
||||
);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@Path("api_keys")
|
||||
public ApiKeyResource apiKeys() {
|
||||
return apiKeyResource.get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
return new UserCollectionLinks(scmPathInfoStore.get());
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -131,7 +131,7 @@ public class UserResource {
|
||||
*
|
||||
* <strong>Note:</strong> This method requires "user" privilege.
|
||||
*
|
||||
* @param name name of the user to be modified
|
||||
* @param name name of the user to be modified
|
||||
* @param user user object to modify
|
||||
*/
|
||||
@PUT
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.lifecycle.modules;
|
||||
|
||||
//~--- non-JDK imports --------------------------------------------------------
|
||||
@@ -33,6 +33,7 @@ import org.apache.shiro.authc.credential.DefaultPasswordService;
|
||||
import org.apache.shiro.authc.credential.PasswordService;
|
||||
import org.apache.shiro.authc.pam.AuthenticationStrategy;
|
||||
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.guice.web.ShiroWebModule;
|
||||
import org.apache.shiro.realm.Realm;
|
||||
@@ -48,6 +49,7 @@ import javax.servlet.ServletContext;
|
||||
import org.apache.shiro.mgt.RememberMeManager;
|
||||
import sonia.scm.security.DisabledRememberMeManager;
|
||||
import sonia.scm.security.ScmAtLeastOneSuccessfulStrategy;
|
||||
import sonia.scm.security.ScmPermissionResolver;
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -94,7 +96,7 @@ public class ScmSecurityModule extends ShiroWebModule
|
||||
|
||||
// expose password service to global injector
|
||||
expose(PasswordService.class);
|
||||
|
||||
|
||||
// disable remember me cookie generation
|
||||
bind(RememberMeManager.class).to(DisabledRememberMeManager.class);
|
||||
|
||||
@@ -102,6 +104,7 @@ public class ScmSecurityModule extends ShiroWebModule
|
||||
bind(ModularRealmAuthenticator.class);
|
||||
bind(Authenticator.class).to(ModularRealmAuthenticator.class);
|
||||
bind(AuthenticationStrategy.class).to(ScmAtLeastOneSuccessfulStrategy.class);
|
||||
bind(PermissionResolver.class).to(ScmPermissionResolver.class);
|
||||
|
||||
// bind realm
|
||||
for (Class<? extends Realm> realm : extensionProcessor.byExtensionPoint(Realm.class))
|
||||
@@ -116,7 +119,7 @@ public class ScmSecurityModule extends ShiroWebModule
|
||||
|
||||
// disable access to mustache resources
|
||||
addFilterChain("/**.mustache", filterConfig(ROLES, "nobody"));
|
||||
|
||||
|
||||
// disable session
|
||||
addFilterChain("/**", NO_SESSION_CREATION);
|
||||
}
|
||||
|
||||
48
scm-webapp/src/main/java/sonia/scm/security/ApiKey.java
Normal file
48
scm-webapp/src/main/java/sonia/scm/security/ApiKey.java
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
111
scm-webapp/src/main/java/sonia/scm/security/ApiKeyRealm.java
Normal file
111
scm-webapp/src/main/java/sonia/scm/security/ApiKeyRealm.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
219
scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java
Normal file
219
scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -250,6 +250,7 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector
|
||||
builder.add(getUserAutocompletePermission());
|
||||
builder.add(getGroupAutocompletePermission());
|
||||
builder.add(getChangeOwnPasswordPermission(user));
|
||||
builder.add(getApiKeyPermission(user));
|
||||
}
|
||||
|
||||
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(ImmutableSet.of(Role.USER));
|
||||
@@ -266,6 +267,10 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector
|
||||
return UserPermissions.changePassword(user).asShiroString();
|
||||
}
|
||||
|
||||
private String getApiKeyPermission(User user) {
|
||||
return UserPermissions.changeApiKeys(user).asShiroString();
|
||||
}
|
||||
|
||||
private String getUserAutocompletePermission() {
|
||||
return UserPermissions.autocomplete().asShiroString();
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.security;
|
||||
|
||||
//~--- non-JDK imports --------------------------------------------------------
|
||||
@@ -57,9 +57,9 @@ import java.util.Set;
|
||||
@Singleton
|
||||
public class DefaultRealm extends AuthorizingRealm
|
||||
{
|
||||
|
||||
|
||||
private static final String SEPARATOR = System.getProperty("line.separator", "\n");
|
||||
|
||||
|
||||
/**
|
||||
* the logger for DefaultRealm
|
||||
*/
|
||||
@@ -68,6 +68,7 @@ public class DefaultRealm extends AuthorizingRealm
|
||||
/** Field description */
|
||||
@VisibleForTesting
|
||||
static final String REALM = "DefaultRealm";
|
||||
private final ScmPermissionResolver permissionResolver;
|
||||
|
||||
//~--- constructors ---------------------------------------------------------
|
||||
|
||||
@@ -90,11 +91,18 @@ public class DefaultRealm extends AuthorizingRealm
|
||||
matcher.setPasswordService(service);
|
||||
setCredentialsMatcher(helper.wrapCredentialsMatcher(matcher));
|
||||
setAuthenticationTokenClass(UsernamePasswordToken.class);
|
||||
permissionResolver = new ScmPermissionResolver();
|
||||
setPermissionResolver(permissionResolver);
|
||||
|
||||
// we cache in the AuthorizationCollector
|
||||
setCachingEnabled(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ScmPermissionResolver getPermissionResolver() {
|
||||
return permissionResolver;
|
||||
}
|
||||
|
||||
//~--- methods --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -168,13 +176,13 @@ public class DefaultRealm extends AuthorizingRealm
|
||||
|
||||
private void log( PrincipalCollection collection, AuthorizationInfo original, AuthorizationInfo filtered ) {
|
||||
StringBuilder buffer = new StringBuilder("authorization summary: ");
|
||||
|
||||
|
||||
buffer.append(SEPARATOR).append("username : ").append(collection.getPrimaryPrincipal());
|
||||
buffer.append(SEPARATOR).append("roles : ");
|
||||
append(buffer, original.getRoles());
|
||||
append(buffer, original.getRoles());
|
||||
buffer.append(SEPARATOR).append("scope : ");
|
||||
append(buffer, collection.oneByType(Scope.class));
|
||||
|
||||
append(buffer, collection.oneByType(Scope.class));
|
||||
|
||||
if ( filtered != null ) {
|
||||
buffer.append(SEPARATOR).append("permissions (filtered by scope): ");
|
||||
append(buffer, filtered);
|
||||
@@ -183,21 +191,21 @@ public class DefaultRealm extends AuthorizingRealm
|
||||
buffer.append(SEPARATOR).append("permissions: ");
|
||||
}
|
||||
append(buffer, original);
|
||||
|
||||
|
||||
LOG.trace(buffer.toString());
|
||||
}
|
||||
|
||||
|
||||
private void append(StringBuilder buffer, AuthorizationInfo authz) {
|
||||
append(buffer, authz.getStringPermissions());
|
||||
append(buffer, authz.getObjectPermissions());
|
||||
append(buffer, authz.getObjectPermissions());
|
||||
}
|
||||
|
||||
|
||||
private void append(StringBuilder buffer, Iterable<?> iterable){
|
||||
if (iterable != null){
|
||||
for ( Object item : iterable )
|
||||
{
|
||||
buffer.append(SEPARATOR).append(" - ").append(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -21,28 +21,26 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.security;
|
||||
|
||||
import com.google.common.collect.Collections2;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
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.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
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}.
|
||||
*
|
||||
*
|
||||
* @author Sebastian Sdorra
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@@ -50,16 +48,16 @@ public final class Scopes {
|
||||
|
||||
/** Key of scope in the claims of a token **/
|
||||
public final static String CLAIMS_KEY = "scope";
|
||||
|
||||
|
||||
private Scopes() {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns scope from a token claims. If the claims does not contain a scope object, the method will return an empty
|
||||
* scope.
|
||||
*
|
||||
*
|
||||
* @param claims token claims
|
||||
*
|
||||
*
|
||||
* @return scope of claims
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -70,11 +68,11 @@ public final class Scopes {
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adds a scope to a token claims. The method will add the scope to the claims, if the scope is non null and not
|
||||
* Adds a scope to a token claims. The method will add the scope to the claims, if the scope is non null and not
|
||||
* empty.
|
||||
*
|
||||
*
|
||||
* @param claims token claims
|
||||
* @param scope scope
|
||||
*/
|
||||
@@ -83,55 +81,51 @@ public final class Scopes {
|
||||
claims.put(CLAIMS_KEY, ImmutableSet.copyOf(scope));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Filter permissions from {@link AuthorizationInfo} by scope values. Only permission definitions from the scope will
|
||||
* be returned and only if a permission from the {@link AuthorizationInfo} implies the requested scope permission.
|
||||
*
|
||||
* Limit permissions from {@link AuthorizationInfo} by scope values. Permission definitions from the
|
||||
* {@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 authz authorization info
|
||||
* @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);
|
||||
Predicate<Permission> predicate = implies(authzPermissions);
|
||||
Set<Permission> filteredPermissions = resolve(resolver, ImmutableList.copyOf(scope))
|
||||
Set<Permission> filteredPermissions = authzPermissions
|
||||
.stream()
|
||||
.filter(predicate)
|
||||
.map(p -> asScmWildcardPermission(p))
|
||||
.map(p -> p.limit(scope))
|
||||
.flatMap(Collection::stream)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
|
||||
Set<String> roles = ImmutableSet.copyOf(nullToEmpty(authz.getRoles()));
|
||||
SimpleAuthorizationInfo authzFiltered = new SimpleAuthorizationInfo(roles);
|
||||
authzFiltered.setObjectPermissions(filteredPermissions);
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
private static Predicate<Permission> implies(Iterable<Permission> authzPermissions){
|
||||
return (scopePermission) -> {
|
||||
for ( Permission authzPermission : authzPermissions ) {
|
||||
if (authzPermission.implies(scopePermission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
private static List<Permission> authzPermissions(PermissionResolver resolver, AuthorizationInfo authz){
|
||||
|
||||
private static List<Permission> authzPermissions(ScmPermissionResolver resolver, AuthorizationInfo authz){
|
||||
List<Permission> authzPermissions = Lists.newArrayList();
|
||||
authzPermissions.addAll(nullToEmpty(authz.getObjectPermissions()));
|
||||
authzPermissions.addAll(resolve(resolver, authz.getStringPermissions()));
|
||||
return authzPermissions;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import com.github.sdorra.shiro.ShiroRule;
|
||||
@@ -41,6 +41,8 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import sonia.scm.ContextEntry;
|
||||
import sonia.scm.group.GroupCollector;
|
||||
import sonia.scm.security.ApiKey;
|
||||
import sonia.scm.security.ApiKeyService;
|
||||
import sonia.scm.user.InvalidPasswordException;
|
||||
import sonia.scm.user.User;
|
||||
import sonia.scm.user.UserManager;
|
||||
@@ -52,13 +54,17 @@ import java.io.UnsupportedEncodingException;
|
||||
import java.net.URI;
|
||||
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.assertTrue;
|
||||
import static org.mockito.Mockito.any;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.MockitoAnnotations.initMocks;
|
||||
|
||||
@@ -87,8 +93,13 @@ public class MeResourceTest {
|
||||
@Mock
|
||||
private UserManager userManager;
|
||||
|
||||
@Mock
|
||||
private ApiKeyService apiKeyService;
|
||||
|
||||
@InjectMocks
|
||||
private MeDtoFactory meDtoFactory;
|
||||
@InjectMocks
|
||||
private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper;
|
||||
|
||||
private ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
|
||||
|
||||
@@ -96,6 +107,8 @@ public class MeResourceTest {
|
||||
private PasswordService passwordService;
|
||||
private User originalUser;
|
||||
|
||||
private MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
@Before
|
||||
public void prepareEnvironment() {
|
||||
initMocks(this);
|
||||
@@ -106,7 +119,9 @@ public class MeResourceTest {
|
||||
when(groupCollector.collect("trillian")).thenReturn(ImmutableSet.of("group1", "group2"));
|
||||
when(userManager.isTypeDefault(userCaptor.capture())).thenCallRealMethod();
|
||||
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(scmPathInfoStore.get()).thenReturn(uriInfo);
|
||||
dispatcher.addSingletonResource(meResource);
|
||||
@@ -118,14 +133,14 @@ public class MeResourceTest {
|
||||
|
||||
MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2);
|
||||
request.accept(VndMediaType.ME);
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
|
||||
assertTrue(response.getContentAsString().contains("\"name\":\"trillian\""));
|
||||
assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"/v2/me/\"}"));
|
||||
assertTrue(response.getContentAsString().contains("\"delete\":{\"href\":\"/v2/users/trillian\"}"));
|
||||
assertThat(response.getContentAsString()).contains("\"name\":\"trillian\"");
|
||||
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/\"}");
|
||||
assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/users/trillian\"}");
|
||||
assertThat(response.getContentAsString()).contains("\"apiKeys\":{\"href\":\"/v2/me/api_keys\"}");
|
||||
}
|
||||
|
||||
private void applyUserToSubject(User user) {
|
||||
@@ -149,7 +164,6 @@ public class MeResourceTest {
|
||||
.put("/" + MeResource.ME_PATH_V2 + "password")
|
||||
.contentType(VndMediaType.PASSWORD_CHANGE)
|
||||
.content(content.getBytes());
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
when(passwordService.encryptPassword(newPassword)).thenReturn(encryptedNewPassword);
|
||||
when(passwordService.encryptPassword(oldPassword)).thenReturn(encryptedOldPassword);
|
||||
@@ -174,7 +188,6 @@ public class MeResourceTest {
|
||||
.put("/" + MeResource.ME_PATH_V2 + "password")
|
||||
.contentType(VndMediaType.PASSWORD_CHANGE)
|
||||
.content(content.getBytes());
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
@@ -190,7 +203,6 @@ public class MeResourceTest {
|
||||
.put("/" + MeResource.ME_PATH_V2 + "password")
|
||||
.contentType(VndMediaType.PASSWORD_CHANGE)
|
||||
.content(content.getBytes());
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
@@ -206,7 +218,6 @@ public class MeResourceTest {
|
||||
.put("/" + MeResource.ME_PATH_V2 + "password")
|
||||
.contentType(VndMediaType.PASSWORD_CHANGE)
|
||||
.content(content.getBytes());
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
doThrow(new InvalidPasswordException(ContextEntry.ContextBuilder.entity("passwortChange", "-")))
|
||||
.when(userManager).changePasswordForLoggedInUser(any(), any());
|
||||
@@ -216,6 +227,80 @@ public class MeResourceTest {
|
||||
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) {
|
||||
User user = new User();
|
||||
|
||||
@@ -80,6 +80,8 @@ public class ResourceLinksMock {
|
||||
lenient().when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(pathInfo));
|
||||
lenient().when(resourceLinks.namespacePermission()).thenReturn(new ResourceLinks.NamespacePermissionLinks(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;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import com.github.sdorra.shiro.ShiroRule;
|
||||
|
||||
105
scm-webapp/src/test/java/sonia/scm/security/ApiKeyRealmTest.java
Normal file
105
scm-webapp/src/test/java/sonia/scm/security/ApiKeyRealmTest.java
Normal 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;
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -167,8 +167,8 @@ public class DefaultAuthorizationCollectorTest {
|
||||
|
||||
AuthorizationInfo authInfo = collector.collect();
|
||||
assertThat(authInfo.getRoles(), Matchers.contains(Role.USER));
|
||||
assertThat(authInfo.getStringPermissions(), hasSize(4));
|
||||
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "user:read:trillian"));
|
||||
assertThat(authInfo.getStringPermissions(), hasSize(5));
|
||||
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "user:read:trillian", "user:changeApiKeys:trillian"));
|
||||
assertThat(authInfo.getObjectPermissions(), nullValue());
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ public class DefaultAuthorizationCollectorTest {
|
||||
AuthorizationInfo authInfo = collector.collect();
|
||||
assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER));
|
||||
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();
|
||||
assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER));
|
||||
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:system:one",
|
||||
"repository:group:two",
|
||||
"user:read:trillian"));
|
||||
"user:read:trillian",
|
||||
"user:changeApiKeys:trillian"));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,7 +335,7 @@ public class DefaultAuthorizationCollectorTest {
|
||||
AuthorizationInfo authInfo = collector.collect();
|
||||
assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER));
|
||||
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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -21,29 +21,32 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.security;
|
||||
|
||||
import com.google.common.collect.Collections2;
|
||||
import com.google.common.collect.Sets;
|
||||
import java.util.Set;
|
||||
import org.apache.shiro.authz.AuthorizationInfo;
|
||||
import org.apache.shiro.authz.Permission;
|
||||
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 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}.
|
||||
*
|
||||
*
|
||||
* @author Sebastian Sdorra
|
||||
*/
|
||||
public class ScopesTest {
|
||||
|
||||
private final WildcardPermissionResolver resolver = new WildcardPermissionResolver();
|
||||
private final ScmPermissionResolver resolver = new ScmPermissionResolver();
|
||||
|
||||
/**
|
||||
* Tests that filter keep roles.
|
||||
@@ -51,11 +54,11 @@ public class ScopesTest {
|
||||
@Test
|
||||
public void testFilterKeepRoles(){
|
||||
AuthorizationInfo authz = authz("repository:read:123");
|
||||
|
||||
|
||||
AuthorizationInfo filtered = Scopes.filter(resolver, authz, Scope.empty());
|
||||
assertThat(filtered.getRoles(), containsInAnyOrder("unit", "test"));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tests filter with a simple allow.
|
||||
*/
|
||||
@@ -63,10 +66,18 @@ public class ScopesTest {
|
||||
public void testFilterSimpleAllow() {
|
||||
Scope scope = Scope.valueOf("repository:read:123");
|
||||
AuthorizationInfo authz = authz("repository:*", "user:*:me");
|
||||
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -74,12 +85,12 @@ public class ScopesTest {
|
||||
public void testFilterSimpleDeny() {
|
||||
Scope scope = Scope.valueOf("repository:read:123");
|
||||
AuthorizationInfo authz = authz("user:*:me");
|
||||
|
||||
|
||||
AuthorizationInfo filtered = Scopes.filter(resolver, authz, scope);
|
||||
assertThat(filtered.getStringPermissions(), is(nullValue()));
|
||||
assertThat(filtered.getObjectPermissions(), is(emptyCollectionOf(Permission.class)));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tests filter with a multiple scope entries.
|
||||
*/
|
||||
@@ -87,10 +98,10 @@ public class ScopesTest {
|
||||
public void testFilterMultiple() {
|
||||
Scope scope = Scope.valueOf("repo:read,modify:1", "repo:read:2", "repo:*:3", "repo:modify:4");
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tests filter with admin permissions.
|
||||
*/
|
||||
@@ -98,10 +109,10 @@ public class ScopesTest {
|
||||
public void testFilterAdmin(){
|
||||
Scope scope = Scope.valueOf("repository:*", "user:*:me");
|
||||
AuthorizationInfo authz = authz("*");
|
||||
|
||||
|
||||
assertPermissions(Scopes.filter(resolver, authz, scope), "repository:*", "user:*:me");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tests filter with requested admin permissions from a non admin.
|
||||
*/
|
||||
@@ -109,29 +120,27 @@ public class ScopesTest {
|
||||
public void testFilterRequestAdmin(){
|
||||
Scope scope = Scope.valueOf("*");
|
||||
AuthorizationInfo authz = authz("repository:*");
|
||||
|
||||
assertThat(
|
||||
Scopes.filter(resolver, authz, scope).getObjectPermissions(),
|
||||
is(emptyCollectionOf(Permission.class))
|
||||
);
|
||||
|
||||
assertPermissions(Scopes.filter(resolver, authz, scope),
|
||||
"repository:*");
|
||||
}
|
||||
|
||||
|
||||
private void assertPermissions(AuthorizationInfo authz, Object... permissions) {
|
||||
assertThat(authz.getStringPermissions(), is(nullValue()));
|
||||
assertThat(
|
||||
Collections2.transform(authz.getObjectPermissions(), Permission::toString),
|
||||
Collections2.transform(authz.getObjectPermissions(), Permission::toString),
|
||||
containsInAnyOrder(permissions)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
private AuthorizationInfo authz( String... values ) {
|
||||
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(Sets.newHashSet("unit", "test"));
|
||||
Set<Permission> permissions = Sets.newLinkedHashSet();
|
||||
for ( String value : values ) {
|
||||
permissions.add(new WildcardPermission(value));
|
||||
permissions.add(new ScmWildcardPermission(value));
|
||||
}
|
||||
info.setObjectPermissions(permissions);
|
||||
return info;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user