Feature/profile navigation (#1464)
- Fix bug where profile settings wasn't shown if user cannot change password - Add missing "ApiKey" entry in the single user menu Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
@@ -6,10 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Add repository import via dump file for Subversion ([#1471](https://github.com/scm-manager/scm-manager/pull/1471))
|
||||
- Add support for permalinks to lines in source code view ([#1472](https://github.com/scm-manager/scm-manager/pull/1472))
|
||||
|
||||
### Fixed
|
||||
- Add "Api Key" page link to sub-navigation of "User" and "Me" sections ([#1464](https://github.com/scm-manager/scm-manager/pull/1464))
|
||||
|
||||
## [2.11.1] - 2020-12-07
|
||||
### Fixed
|
||||
- Initialization of new git repository with master set as default branch ([#1467](https://github.com/scm-manager/scm-manager/issues/1467) and [#1470](https://github.com/scm-manager/scm-manager/pull/1470))
|
||||
|
||||
BIN
docs/de/user/user/assets/user-settings-apikeys.png
Normal file
|
After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 296 KiB |
@@ -28,3 +28,8 @@ Für die einzelnen Rechte sind Tooltips verfügbar, welche Auskunft über die Au
|
||||
Es können öffentliche Schlüssel (Public Keys) zu Benutzern hinzugefügt werden, um die Changeset Signaturen damit zu verifizieren.
|
||||
|
||||

|
||||
|
||||
### 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. Weitere Informationen zu API Schlüsseln befinden sich im [Abschnitt Profil](../profile/#api-schlüssel).
|
||||
|
||||

|
||||
|
||||
BIN
docs/en/user/user/assets/user-settings-apikeys.png
Normal file
|
After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 288 KiB |
@@ -28,3 +28,9 @@ There is a tooltip for each permission that provide some more details about the
|
||||
Add public keys to users to enable changeset signature verification.
|
||||
|
||||

|
||||
|
||||
### 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. Read more about API keys in the [Profile section](../profile/#api-keys).
|
||||
|
||||

|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React from "react";
|
||||
import { Route, RouteComponentProps, withRouter } from "react-router-dom";
|
||||
import { Redirect, Route, RouteComponentProps, Switch, withRouter } from "react-router-dom";
|
||||
import { getMe } from "../modules/auth";
|
||||
import { compose } from "redux";
|
||||
import { connect } from "react-redux";
|
||||
@@ -37,16 +37,16 @@ import {
|
||||
SecondaryNavigationColumn,
|
||||
SecondaryNavigation,
|
||||
SubNavigation,
|
||||
StateMenuContextProvider
|
||||
StateMenuContextProvider,
|
||||
urls
|
||||
} from "@scm-manager/ui-components";
|
||||
import ChangeUserPassword from "./ChangeUserPassword";
|
||||
import ProfileInfo from "./ProfileInfo";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import SetPublicKeys from "../users/components/publicKeys/SetPublicKeys";
|
||||
import SetPublicKeyNavLink from "../users/components/navLinks/SetPublicKeysNavLink";
|
||||
import SetPublicKeysNavLink from "../users/components/navLinks/SetPublicKeysNavLink";
|
||||
import SetApiKeys from "../users/components/apiKeys/SetApiKeys";
|
||||
import SetApiKeyNavLink from "../users/components/navLinks/SetApiKeysNavLink";
|
||||
import { urls } from "@scm-manager/ui-components";
|
||||
import SetApiKeysNavLink from "../users/components/navLinks/SetApiKeysNavLink";
|
||||
|
||||
type Props = RouteComponentProps &
|
||||
WithTranslation & {
|
||||
@@ -57,24 +57,22 @@ type Props = RouteComponentProps &
|
||||
};
|
||||
|
||||
class Profile extends React.Component<Props> {
|
||||
mayChangePassword = () => {
|
||||
const { me } = this.props;
|
||||
return !!me?._links?.password;
|
||||
};
|
||||
mayChangePassword = () => !!this.props.me?._links?.password;
|
||||
canManagePublicKeys = () => !!this.props.me?._links?.publicKeys;
|
||||
canManageApiKeys = () => !!this.props.me?._links?.apiKeys;
|
||||
|
||||
canManagePublicKeys = () => {
|
||||
shouldRenderNavigation = () => {
|
||||
const { me } = this.props;
|
||||
return !!me?._links?.publicKeys;
|
||||
};
|
||||
|
||||
canManageApiKeys = () => {
|
||||
const { me } = this.props;
|
||||
return !!me?._links?.apiKeys;
|
||||
return (
|
||||
!!me?._links?.password ||
|
||||
!!me?._links?.publicKeys ||
|
||||
!!me?._links?.apiKeys ||
|
||||
binder.hasExtension("profile.route")
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const url = urls.matchedUrl(this.props);
|
||||
|
||||
const { me, t } = this.props;
|
||||
|
||||
if (!me) {
|
||||
@@ -101,6 +99,19 @@ class Profile extends React.Component<Props> {
|
||||
<CustomQueryFlexWrappedColumns>
|
||||
<PrimaryContentColumn>
|
||||
<Route path={url} exact render={() => <ProfileInfo me={me} />} />
|
||||
{this.shouldRenderNavigation() && (
|
||||
<Switch>
|
||||
{this.mayChangePassword() && (
|
||||
<Redirect exact from={`${url}/settings/`} to={`${url}/settings/password`} />
|
||||
)}
|
||||
{this.canManagePublicKeys() && (
|
||||
<Redirect exact from={`${url}/settings/`} to={`${url}/settings/publicKeys`} />
|
||||
)}
|
||||
{this.canManageApiKeys() && (
|
||||
<Redirect exact from={`${url}/settings/`} to={`${url}/settings/apiKeys`} />
|
||||
)}
|
||||
</Switch>
|
||||
)}
|
||||
{this.mayChangePassword() && (
|
||||
<Route path={`${url}/settings/password`} render={() => <ChangeUserPassword me={me} />} />
|
||||
)}
|
||||
@@ -120,15 +131,17 @@ class Profile extends React.Component<Props> {
|
||||
label={t("profile.informationNavLink")}
|
||||
title={t("profile.informationNavLink")}
|
||||
/>
|
||||
{this.mayChangePassword() && (
|
||||
{this.shouldRenderNavigation() && (
|
||||
<SubNavigation
|
||||
to={`${url}/settings/password`}
|
||||
to={`${url}/settings/`}
|
||||
label={t("profile.settingsNavLink")}
|
||||
title={t("profile.settingsNavLink")}
|
||||
>
|
||||
<NavLink to={`${url}/settings/password`} label={t("profile.changePasswordNavLink")} />
|
||||
<SetPublicKeyNavLink user={me} publicKeyUrl={`${url}/settings/publicKeys`} />
|
||||
<SetApiKeyNavLink user={me} apiKeyUrl={`${url}/settings/apiKeys`} />
|
||||
{this.mayChangePassword() && (
|
||||
<NavLink to={`${url}/settings/password`} label={t("profile.changePasswordNavLink")} />
|
||||
)}
|
||||
<SetPublicKeysNavLink user={me} publicKeyUrl={`${url}/settings/publicKeys`} />
|
||||
<SetApiKeysNavLink user={me} apiKeyUrl={`${url}/settings/apiKeys`} />
|
||||
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
|
||||
</SubNavigation>
|
||||
)}
|
||||
|
||||
@@ -26,3 +26,4 @@ export { default as EditUserNavLink } from "./EditUserNavLink";
|
||||
export { default as SetPasswordNavLink } from "./SetPasswordNavLink";
|
||||
export { default as SetPermissionsNavLink } from "./SetPermissionsNavLink";
|
||||
export { default as SetPublicKeysNavLink } from "./SetPublicKeysNavLink";
|
||||
export { default as SetApiKeysNavLink } from "./SetApiKeysNavLink";
|
||||
|
||||
@@ -36,18 +36,25 @@ import {
|
||||
SecondaryNavigationColumn,
|
||||
SecondaryNavigation,
|
||||
SubNavigation,
|
||||
StateMenuContextProvider
|
||||
StateMenuContextProvider,
|
||||
urls
|
||||
} from "@scm-manager/ui-components";
|
||||
import { Details } from "./../components/table";
|
||||
import EditUser from "./EditUser";
|
||||
import { fetchUserByName, getFetchUserFailure, getUserByName, isFetchUserPending } from "../modules/users";
|
||||
import { EditUserNavLink, SetPasswordNavLink, SetPermissionsNavLink, SetPublicKeysNavLink } from "./../components/navLinks";
|
||||
import {
|
||||
EditUserNavLink,
|
||||
SetPasswordNavLink,
|
||||
SetPermissionsNavLink,
|
||||
SetPublicKeysNavLink,
|
||||
SetApiKeysNavLink
|
||||
} from "./../components/navLinks";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import { mustGetUsersLink } from "../../modules/indexResource";
|
||||
import SetUserPassword from "../components/SetUserPassword";
|
||||
import SetPermissions from "../../permissions/components/SetPermissions";
|
||||
import SetPublicKeys from "../components/publicKeys/SetPublicKeys";
|
||||
import { urls } from "@scm-manager/ui-components";
|
||||
import SetApiKeys from "../components/apiKeys/SetApiKeys";
|
||||
|
||||
type Props = RouteComponentProps &
|
||||
WithTranslation & {
|
||||
@@ -96,10 +103,8 @@ class SingleUser extends React.Component<Props> {
|
||||
path={`${url}/settings/permissions`}
|
||||
component={() => <SetPermissions selectedPermissionsLink={user._links.permissions} />}
|
||||
/>
|
||||
<Route
|
||||
path={`${url}/settings/publickeys`}
|
||||
component={() => <SetPublicKeys user={user} />}
|
||||
/>
|
||||
<Route path={`${url}/settings/publickeys`} component={() => <SetPublicKeys user={user} />} />
|
||||
<Route path={`${url}/settings/apiKeys`} component={() => <SetApiKeys user={user} />} />
|
||||
<ExtensionPoint name="user.route" props={extensionProps} renderAll={true} />
|
||||
</PrimaryContentColumn>
|
||||
<SecondaryNavigationColumn>
|
||||
@@ -121,6 +126,7 @@ class SingleUser extends React.Component<Props> {
|
||||
<SetPasswordNavLink user={user} passwordUrl={`${url}/settings/password`} />
|
||||
<SetPermissionsNavLink user={user} permissionsUrl={`${url}/settings/permissions`} />
|
||||
<SetPublicKeysNavLink user={user} publicKeyUrl={`${url}/settings/publickeys`} />
|
||||
<SetApiKeysNavLink user={user} apiKeyUrl={`${url}/settings/apiKeys`} />
|
||||
<ExtensionPoint name="user.setting" props={extensionProps} renderAll={true} />
|
||||
</SubNavigation>
|
||||
</SecondaryNavigation>
|
||||
|
||||
@@ -47,11 +47,11 @@ public class ApiKeyCollectionToDtoMapper {
|
||||
this.resourceLinks = resourceLinks;
|
||||
}
|
||||
|
||||
public HalRepresentation map(Collection<ApiKey> keys) {
|
||||
List<ApiKeyDto> dtos = keys.stream().map(apiKeyDtoMapper::map).collect(toList());
|
||||
public HalRepresentation map(Collection<ApiKey> keys, String user) {
|
||||
List<ApiKeyDto> dtos = keys.stream().map(key -> apiKeyDtoMapper.map(key, user)).collect(toList());
|
||||
final Links.Builder links = Links.linkingTo()
|
||||
.self(resourceLinks.apiKeyCollection().self())
|
||||
.single(link("create", resourceLinks.apiKeyCollection().create()));
|
||||
.self(resourceLinks.apiKeyCollection().self(user))
|
||||
.single(link("create", resourceLinks.apiKeyCollection().create(user)));
|
||||
return new HalRepresentation(links.build(), Embedded.embedded("keys", dtos));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import io.swagger.v3.oas.annotations.media.ExampleObject;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import sonia.scm.ContextEntry;
|
||||
import sonia.scm.security.ApiKey;
|
||||
import sonia.scm.security.ApiKeyService;
|
||||
@@ -48,13 +49,17 @@ 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 {
|
||||
/**
|
||||
* Use {@link UserApiKeyResource} instead.
|
||||
* @deprecated
|
||||
*/
|
||||
@Deprecated
|
||||
public class ApiKeyResource {
|
||||
|
||||
private final ApiKeyService apiKeyService;
|
||||
private final ApiKeyCollectionToDtoMapper apiKeyCollectionMapper;
|
||||
@@ -90,7 +95,8 @@ public class ApiKeyResource {
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
public HalRepresentation getForCurrentUser() {
|
||||
return apiKeyCollectionMapper.map(apiKeyService.getKeys());
|
||||
String currentUser = getCurrentUser();
|
||||
return apiKeyCollectionMapper.map(apiKeyService.getKeys(currentUser), currentUser);
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -121,11 +127,13 @@ public class ApiKeyResource {
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
public ApiKeyDto get(@PathParam("id") String id) {
|
||||
String currentUser = getCurrentUser();
|
||||
|
||||
return apiKeyService
|
||||
.getKeys()
|
||||
.getKeys(currentUser)
|
||||
.stream()
|
||||
.filter(key -> key.getId().equals(id))
|
||||
.map(apiKeyMapper::map).findAny()
|
||||
.map(key -> apiKeyMapper.map(key, currentUser)).findAny()
|
||||
.orElseThrow(() -> notFound(ContextEntry.ContextBuilder.entity(ApiKey.class, id)));
|
||||
}
|
||||
|
||||
@@ -171,10 +179,12 @@ public class ApiKeyResource {
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
public Response create(@Valid ApiKeyDto apiKey) {
|
||||
final ApiKeyService.CreationResult newKey = apiKeyService.createNewKey(apiKey.getDisplayName(), apiKey.getPermissionRole());
|
||||
String currentUser = getCurrentUser();
|
||||
|
||||
final ApiKeyService.CreationResult newKey = apiKeyService.createNewKey(currentUser, apiKey.getDisplayName(), apiKey.getPermissionRole());
|
||||
return Response.status(CREATED)
|
||||
.entity(newKey.getToken())
|
||||
.location(URI.create(resourceLinks.apiKey().self(newKey.getId())))
|
||||
.location(URI.create(resourceLinks.apiKey().self(newKey.getId(), currentUser)))
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -185,7 +195,10 @@ public class ApiKeyResource {
|
||||
@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);
|
||||
apiKeyService.remove(getCurrentUser(), id);
|
||||
}
|
||||
|
||||
private String getCurrentUser() {
|
||||
return SecurityUtils.getSubject().getPrincipals().getPrimaryPrincipal().toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,13 +39,13 @@ public abstract class ApiKeyToApiKeyDtoMapper {
|
||||
@Inject
|
||||
private ResourceLinks resourceLinks;
|
||||
|
||||
abstract ApiKeyDto map(ApiKey key);
|
||||
abstract ApiKeyDto map(ApiKey key, String user);
|
||||
|
||||
@ObjectFactory
|
||||
ApiKeyDto createDto(ApiKey key) {
|
||||
ApiKeyDto createDto(ApiKey key, String user) {
|
||||
Links.Builder links = Links.linkingTo()
|
||||
.self(resourceLinks.apiKey().self(key.getId()))
|
||||
.single(link("delete", resourceLinks.apiKey().delete(key.getId())));
|
||||
.self(resourceLinks.apiKey().self(key.getId(), user))
|
||||
.single(link("delete", resourceLinks.apiKey().delete(key.getId(), user)));
|
||||
return new ApiKeyDto(links.build());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ import com.google.common.base.Strings;
|
||||
import de.otto.edison.hal.Embedded;
|
||||
import de.otto.edison.hal.Links;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.apache.shiro.subject.PrincipalCollection;
|
||||
import org.apache.shiro.subject.Subject;
|
||||
import sonia.scm.group.GroupCollector;
|
||||
import sonia.scm.user.EMail;
|
||||
import sonia.scm.user.User;
|
||||
@@ -59,8 +57,7 @@ public class MeDtoFactory extends HalAppenderMapper {
|
||||
}
|
||||
|
||||
public MeDto create() {
|
||||
PrincipalCollection principals = getPrincipalCollection();
|
||||
User user = principals.oneByType(User.class);
|
||||
User user = SecurityUtils.getSubject().getPrincipals().oneByType(User.class);
|
||||
|
||||
MeDto dto = createDto(user);
|
||||
mapUserProperties(user, dto);
|
||||
@@ -79,18 +76,12 @@ public class MeDtoFactory extends HalAppenderMapper {
|
||||
dto.setMail(user.getMail());
|
||||
}
|
||||
|
||||
private PrincipalCollection getPrincipalCollection() {
|
||||
Subject subject = SecurityUtils.getSubject();
|
||||
return subject.getPrincipals();
|
||||
}
|
||||
|
||||
private void setGeneratedMail(User user, MeDto dto) {
|
||||
if (Strings.isNullOrEmpty(user.getMail())) {
|
||||
dto.setFallbackMail(eMail.getMailOrFallback(user));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private MeDto createDto(User user) {
|
||||
Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self());
|
||||
if (UserPermissions.delete(user).isPermitted()) {
|
||||
@@ -106,7 +97,7 @@ public class MeDtoFactory extends HalAppenderMapper {
|
||||
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
|
||||
}
|
||||
if (UserPermissions.changeApiKeys(user).isPermitted()) {
|
||||
linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self()));
|
||||
linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self(user.getName())));
|
||||
}
|
||||
|
||||
Embedded.Builder embeddedBuilder = embeddedBuilder();
|
||||
|
||||
@@ -64,14 +64,14 @@ public class MeResource {
|
||||
private final UserManager userManager;
|
||||
private final PasswordService passwordService;
|
||||
|
||||
private final Provider<ApiKeyResource> apiKeyResource;
|
||||
private final Provider<ApiKeyResource> apiKeyResourceProvider;
|
||||
|
||||
@Inject
|
||||
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResource) {
|
||||
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResourceProvider) {
|
||||
this.meDtoFactory = meDtoFactory;
|
||||
this.userManager = userManager;
|
||||
this.passwordService = passwordService;
|
||||
this.apiKeyResource = apiKeyResource;
|
||||
this.apiKeyResourceProvider = apiKeyResourceProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,6 +142,6 @@ public class MeResource {
|
||||
|
||||
@Path("api_keys")
|
||||
public ApiKeyResource apiKeys() {
|
||||
return apiKeyResource.get();
|
||||
return apiKeyResourceProvider.get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,10 +101,12 @@ class ResourceLinks {
|
||||
static class UserLinks {
|
||||
private final LinkBuilder userLinkBuilder;
|
||||
private final LinkBuilder publicKeyLinkBuilder;
|
||||
private final LinkBuilder apiKeyLinkBuilder;
|
||||
|
||||
UserLinks(ScmPathInfo pathInfo) {
|
||||
userLinkBuilder = new LinkBuilder(pathInfo, UserRootResource.class, UserResource.class);
|
||||
publicKeyLinkBuilder = new LinkBuilder(pathInfo, UserPublicKeyResource.class);
|
||||
apiKeyLinkBuilder = new LinkBuilder(pathInfo, UserApiKeyResource.class);
|
||||
}
|
||||
|
||||
String self(String name) {
|
||||
@@ -134,6 +136,10 @@ class ResourceLinks {
|
||||
public String publicKeys(String name) {
|
||||
return publicKeyLinkBuilder.method("findAll").parameters(name).href();
|
||||
}
|
||||
|
||||
public String apiKeys(String name) {
|
||||
return apiKeyLinkBuilder.method("findAll").parameters(name).href();
|
||||
}
|
||||
}
|
||||
|
||||
interface WithPermissionLinks {
|
||||
@@ -220,15 +226,15 @@ class ResourceLinks {
|
||||
private final LinkBuilder collectionLinkBuilder;
|
||||
|
||||
ApiKeyCollectionLinks(ScmPathInfo pathInfo) {
|
||||
this.collectionLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, ApiKeyResource.class);
|
||||
this.collectionLinkBuilder = new LinkBuilder(pathInfo, UserRootResource.class, UserApiKeyResource.class);
|
||||
}
|
||||
|
||||
String self() {
|
||||
return collectionLinkBuilder.method("apiKeys").parameters().method("getForCurrentUser").parameters().href();
|
||||
String self(String username) {
|
||||
return collectionLinkBuilder.method("apiKeys").parameters().method("findAll").parameters(username).href();
|
||||
}
|
||||
|
||||
String create() {
|
||||
return collectionLinkBuilder.method("apiKeys").parameters().method("create").parameters().href();
|
||||
String create(String username) {
|
||||
return collectionLinkBuilder.method("apiKeys").parameters().method("create").parameters(username).href();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,15 +246,15 @@ class ResourceLinks {
|
||||
private final LinkBuilder apiKeyLinkBuilder;
|
||||
|
||||
ApiKeyLinks(ScmPathInfo pathInfo) {
|
||||
this.apiKeyLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, ApiKeyResource.class);
|
||||
this.apiKeyLinkBuilder = new LinkBuilder(pathInfo, UserRootResource.class, UserApiKeyResource.class);
|
||||
}
|
||||
|
||||
String self(String id) {
|
||||
return apiKeyLinkBuilder.method("apiKeys").parameters().method("get").parameters(id).href();
|
||||
String self(String id, String name) {
|
||||
return apiKeyLinkBuilder.method("apiKeys").parameters(name).method("get").parameters(id).href();
|
||||
}
|
||||
|
||||
String delete(String id) {
|
||||
return apiKeyLinkBuilder.method("apiKeys").parameters().method("delete").parameters(id).href();
|
||||
String delete(String id, String name) {
|
||||
return apiKeyLinkBuilder.method("apiKeys").parameters(name).method("delete").parameters(id).href();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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.ExampleObject;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||
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;
|
||||
|
||||
@Path("v2/users/{id}/api_keys")
|
||||
public class UserApiKeyResource {
|
||||
|
||||
private final ApiKeyService apiKeyService;
|
||||
private final ApiKeyCollectionToDtoMapper apiKeyCollectionMapper;
|
||||
private final ApiKeyToApiKeyDtoMapper apiKeyMapper;
|
||||
private final ResourceLinks resourceLinks;
|
||||
|
||||
@Inject
|
||||
public UserApiKeyResource(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 all api keys for user", description = "Returns all registered api keys for the given username.", tags = "User", operationId = "get_all_api_keys")
|
||||
@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 findAll(@PathParam("id") String id) {
|
||||
return apiKeyCollectionMapper.map(apiKeyService.getKeys(id), id);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{keyId}")
|
||||
@Produces(VndMediaType.API_KEY)
|
||||
@Operation(summary = "Get single api key for user", description = "Returns a single registered api key with the given id for user.", tags = "User", operationId = "get_single_api_key")
|
||||
@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 / key for given id not 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, @PathParam("keyId") String keyId) {
|
||||
return apiKeyService
|
||||
.getKeys(id)
|
||||
.stream()
|
||||
.filter(key -> key.getId().equals(keyId))
|
||||
.map(key -> apiKeyMapper.map(key, id))
|
||||
.findAny()
|
||||
.orElseThrow(() -> notFound(ContextEntry.ContextBuilder.entity(ApiKey.class, keyId)));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("")
|
||||
@Consumes(VndMediaType.API_KEY)
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@Operation(
|
||||
summary = "Create new api key for user",
|
||||
description = "Creates a new api key for the given user with the role specified in the given key.",
|
||||
tags = "User",
|
||||
operationId = "create_api_key",
|
||||
requestBody = @RequestBody(
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.API_KEY,
|
||||
schema = @Schema(implementation = CreateApiKeyDto.class),
|
||||
examples = @ExampleObject(
|
||||
name = "Create a new api key named readKey with READ permission role.",
|
||||
value = "{\n \"displayName\":\"readKey\",\n \"permissionRole\":\"READ\"\n}",
|
||||
summary = "Create new api key"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
@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, @PathParam("id") String id) {
|
||||
final ApiKeyService.CreationResult newKey = apiKeyService.createNewKey(id, apiKey.getDisplayName(), apiKey.getPermissionRole());
|
||||
return Response.status(CREATED)
|
||||
.entity(newKey.getToken())
|
||||
.location(URI.create(resourceLinks.apiKey().self(newKey.getId(), id)))
|
||||
.build();
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{keyId}")
|
||||
@Operation(summary = "Delete api key", description = "Deletes the api key with the given id for user.", tags = "User", operationId = "delete_api_key")
|
||||
@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, @PathParam("keyId") String keyId) {
|
||||
apiKeyService.remove(id, keyId);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -32,7 +32,7 @@ import javax.inject.Provider;
|
||||
import javax.ws.rs.Path;
|
||||
|
||||
/**
|
||||
* RESTful Web Service Resource to manage users.
|
||||
* RESTful Web Service Resource to manage users.
|
||||
*/
|
||||
@OpenAPIDefinition(tags = {
|
||||
@Tag(name = "User", description = "User related endpoints")
|
||||
@@ -44,12 +44,14 @@ public class UserRootResource {
|
||||
|
||||
private final Provider<UserCollectionResource> userCollectionResource;
|
||||
private final Provider<UserResource> userResource;
|
||||
private final Provider<UserApiKeyResource> userApiKeyResource;
|
||||
|
||||
@Inject
|
||||
public UserRootResource(Provider<UserCollectionResource> userCollectionResource,
|
||||
Provider<UserResource> userResource) {
|
||||
Provider<UserResource> userResource, Provider<UserApiKeyResource> userApiKeyResource) {
|
||||
this.userCollectionResource = userCollectionResource;
|
||||
this.userResource = userResource;
|
||||
this.userApiKeyResource = userApiKeyResource;
|
||||
}
|
||||
|
||||
@Path("")
|
||||
@@ -61,4 +63,9 @@ public class UserRootResource {
|
||||
public UserResource getUserResource() {
|
||||
return userResource.get();
|
||||
}
|
||||
|
||||
@Path("{id}/api_keys")
|
||||
public UserApiKeyResource apiKeys() {
|
||||
return userApiKeyResource.get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ public abstract class UserToUserDtoMapper extends BaseMapper<User, UserDto> {
|
||||
if (UserPermissions.modify(user).isPermitted()) {
|
||||
linksBuilder.single(link("update", resourceLinks.user().update(user.getName())));
|
||||
linksBuilder.single(link("publicKeys", resourceLinks.user().publicKeys(user.getName())));
|
||||
linksBuilder.single(link("apiKeys", resourceLinks.user().apiKeys(user.getName())));
|
||||
if (user.isExternal()) {
|
||||
linksBuilder.single(link("convertToInternal", resourceLinks.user().toInternal(user.getName())));
|
||||
} else {
|
||||
|
||||
@@ -30,7 +30,6 @@ 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;
|
||||
@@ -80,19 +79,18 @@ public class ApiKeyService {
|
||||
this.passphraseGenerator = passphraseGenerator;
|
||||
}
|
||||
|
||||
public CreationResult createNewKey(String name, String permissionRole) {
|
||||
String user = currentUser();
|
||||
UserPermissions.changeApiKeys(user).check();
|
||||
public CreationResult createNewKey(String username, String keyDisplayName, String permissionRole) {
|
||||
UserPermissions.changeApiKeys(username).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);
|
||||
ApiKeyWithPassphrase key = new ApiKeyWithPassphrase(id, keyDisplayName, permissionRole, hashedPassphrase, now());
|
||||
doSynchronized(username, true, () -> {
|
||||
persistKey(keyDisplayName, username, key);
|
||||
return null;
|
||||
});
|
||||
String token = tokenHandler.createToken(user, new ApiKey(key), passphrase);
|
||||
LOG.info("created new api key for user {} with role {}", user, permissionRole);
|
||||
String token = tokenHandler.createToken(username, new ApiKey(key), passphrase);
|
||||
LOG.info("created new api key for user {} with role {}", username, permissionRole);
|
||||
return new CreationResult(token, id);
|
||||
}
|
||||
|
||||
@@ -105,18 +103,17 @@ public class ApiKeyService {
|
||||
store.put(user, newApiKeyCollection);
|
||||
}
|
||||
|
||||
public void remove(String id) {
|
||||
String user = currentUser();
|
||||
UserPermissions.changeApiKeys(user).check();
|
||||
doSynchronized(user, true, () -> {
|
||||
if (!containsId(user, id)) {
|
||||
public void remove(String username, String id) {
|
||||
UserPermissions.changeApiKeys(username).check();
|
||||
doSynchronized(username, true, () -> {
|
||||
if (!containsId(username, id)) {
|
||||
return null;
|
||||
}
|
||||
store.getOptional(user).ifPresent(
|
||||
store.getOptional(username).ifPresent(
|
||||
apiKeyCollection -> {
|
||||
ApiKeyCollection newApiKeyCollection = apiKeyCollection.remove(key -> id.equals(key.getId()));
|
||||
store.put(user, newApiKeyCollection);
|
||||
LOG.info("removed api key for user {}", user);
|
||||
store.put(username, newApiKeyCollection);
|
||||
LOG.info("removed api key for user {}", username);
|
||||
}
|
||||
);
|
||||
return null;
|
||||
@@ -154,8 +151,8 @@ public class ApiKeyService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public Collection<ApiKey> getKeys() {
|
||||
return store.getOptional(currentUser())
|
||||
public Collection<ApiKey> getKeys(String user) {
|
||||
return store.getOptional(user)
|
||||
.map(ApiKeyCollection::getKeys)
|
||||
.map(Collection::stream)
|
||||
.orElse(Stream.empty())
|
||||
@@ -163,10 +160,6 @@ public class ApiKeyService {
|
||||
.collect(toList());
|
||||
}
|
||||
|
||||
private String currentUser() {
|
||||
return ThreadContext.getSubject().getPrincipals().getPrimaryPrincipal().toString();
|
||||
}
|
||||
|
||||
private boolean containsId(String user, String id) {
|
||||
return store
|
||||
.getOptional(user)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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 org.junit.Test;
|
||||
import sonia.scm.security.ApiKey;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Collections;
|
||||
|
||||
import static java.time.Instant.now;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ApiKeyCollectionToDtoMapperTest {
|
||||
|
||||
public static final ApiKey API_KEY = new ApiKey("1", "key 1", "READ", now());
|
||||
|
||||
private final ApiKeyToApiKeyDtoMapper apiKeyDtoMapper = mock(ApiKeyToApiKeyDtoMapper.class);
|
||||
private final ApiKeyCollectionToDtoMapper apiKeyCollectionToDtoMapper = new ApiKeyCollectionToDtoMapper(apiKeyDtoMapper, ResourceLinksMock.createMock(URI.create("/")));
|
||||
|
||||
@Test
|
||||
public void shouldMapCollection() {
|
||||
ApiKeyDto expectedApiKeyDto = new ApiKeyDto();
|
||||
when(apiKeyDtoMapper.map(API_KEY, "user")).thenReturn(expectedApiKeyDto);
|
||||
|
||||
HalRepresentation halRepresentation = apiKeyCollectionToDtoMapper.map(Collections.singletonList(API_KEY), "user");
|
||||
|
||||
assertThat(halRepresentation.getEmbedded().hasItem("keys")).isTrue();
|
||||
assertThat(halRepresentation.getEmbedded().getItemsBy("keys")).containsExactly(expectedApiKeyDto);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldEmbedLinks() {
|
||||
ApiKeyDto expectedApiKeyDto = new ApiKeyDto();
|
||||
when(apiKeyDtoMapper.map(API_KEY, "user")).thenReturn(expectedApiKeyDto);
|
||||
|
||||
HalRepresentation halRepresentation = apiKeyCollectionToDtoMapper.map(Collections.singletonList(API_KEY), "user");
|
||||
|
||||
assertThat(halRepresentation.getLinks().getLinkBy("self")).isPresent();
|
||||
assertThat(halRepresentation.getLinks().getLinkBy("create")).isPresent();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -79,8 +79,9 @@ public class MeResourceTest {
|
||||
@Rule
|
||||
public ShiroRule shiro = new ShiroRule();
|
||||
|
||||
private RestDispatcher dispatcher = new RestDispatcher();
|
||||
|
||||
private final RestDispatcher dispatcher = new RestDispatcher();
|
||||
private final MockHttpResponse response = new MockHttpResponse();
|
||||
private final ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
|
||||
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/"));
|
||||
|
||||
@Mock
|
||||
@@ -105,13 +106,10 @@ public class MeResourceTest {
|
||||
@InjectMocks
|
||||
private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper;
|
||||
|
||||
private ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
|
||||
|
||||
@Mock
|
||||
private PasswordService passwordService;
|
||||
private User originalUser;
|
||||
|
||||
private MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
@Before
|
||||
public void prepareEnvironment() {
|
||||
@@ -144,7 +142,7 @@ public class MeResourceTest {
|
||||
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\"}");
|
||||
assertThat(response.getContentAsString()).contains("\"apiKeys\":{\"href\":\"/v2/users/trillian/api_keys\"}");
|
||||
}
|
||||
|
||||
private void applyUserToSubject(User user) {
|
||||
@@ -233,7 +231,7 @@ public class MeResourceTest {
|
||||
|
||||
@Test
|
||||
public void shouldGetAllApiKeys() throws URISyntaxException, UnsupportedEncodingException {
|
||||
when(apiKeyService.getKeys())
|
||||
when(apiKeyService.getKeys("trillian"))
|
||||
.thenReturn(asList(
|
||||
new ApiKey("1", "key 1", "READ", now()),
|
||||
new ApiKey("2", "key 2", "WRITE", now())));
|
||||
@@ -245,13 +243,13 @@ public class MeResourceTest {
|
||||
|
||||
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\"}");
|
||||
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/users/trillian/api_keys\"}");
|
||||
assertThat(response.getContentAsString()).contains("\"create\":{\"href\":\"/v2/users/trillian/api_keys\"}");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGetSingleApiKey() throws URISyntaxException, UnsupportedEncodingException {
|
||||
when(apiKeyService.getKeys())
|
||||
when(apiKeyService.getKeys("trillian"))
|
||||
.thenReturn(asList(
|
||||
new ApiKey("1", "key 1", "READ", now()),
|
||||
new ApiKey("2", "key 2", "WRITE", now())));
|
||||
@@ -263,13 +261,14 @@ public class MeResourceTest {
|
||||
|
||||
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\"}");
|
||||
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/users/trillian/api_keys/1\"}");
|
||||
assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/users/trillian/api_keys/1\"}");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateNewApiKey() throws URISyntaxException, UnsupportedEncodingException {
|
||||
when(apiKeyService.createNewKey("guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1"));
|
||||
when(apiKeyService.createNewKey("trillian","guide", "READ"))
|
||||
.thenReturn(new ApiKeyService.CreationResult("abc", "1"));
|
||||
|
||||
final MockHttpRequest request = MockHttpRequest
|
||||
.post("/" + MeResource.ME_PATH_V2 + "api_keys/")
|
||||
@@ -280,12 +279,13 @@ public class MeResourceTest {
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(201);
|
||||
assertThat(response.getContentAsString()).isEqualTo("abc");
|
||||
assertThat(response.getOutputHeaders().get("Location")).containsExactly(URI.create("/v2/me/api_keys/1"));
|
||||
assertThat(response.getOutputHeaders().get("Location")).containsExactly(URI.create("/v2/users/trillian/api_keys/1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldIgnoreInvalidNewApiKey() throws URISyntaxException, UnsupportedEncodingException {
|
||||
when(apiKeyService.createNewKey("guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1"));
|
||||
when(apiKeyService.createNewKey("trillian","guide", "READ"))
|
||||
.thenReturn(new ApiKeyService.CreationResult("abc", "1"));
|
||||
|
||||
final MockHttpRequest request = MockHttpRequest
|
||||
.post("/" + MeResource.ME_PATH_V2 + "api_keys/")
|
||||
@@ -303,9 +303,10 @@ public class MeResourceTest {
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(204);
|
||||
verify(apiKeyService).remove("1");
|
||||
verify(apiKeyService).remove("trillian","1");
|
||||
}
|
||||
|
||||
|
||||
private User createDummyUser(String name) {
|
||||
User user = new User();
|
||||
user.setName(name);
|
||||
|
||||
@@ -21,14 +21,13 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import sonia.scm.repository.NamespaceAndName;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
@@ -78,6 +77,12 @@ public class ResourceLinksTest {
|
||||
assertEquals(BASE_URL + UserRootResource.USERS_PATH_V2, url);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateCorrectUserApiKeysUrl() {
|
||||
String url = resourceLinks.user().apiKeys("ich");
|
||||
assertEquals(BASE_URL + UserRootResource.USERS_PATH_V2 + "ich/api_keys", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateCorrectGroupSelfUrl() {
|
||||
String url = resourceLinks.group().self("nobodies");
|
||||
@@ -179,6 +184,7 @@ public class ResourceLinksTest {
|
||||
String url = resourceLinks.source().sourceWithPath("foo", "bar", "rev", "file");
|
||||
assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "foo/bar/sources/rev/file", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateCorrectPermissionCollectionUrl() {
|
||||
String url = resourceLinks.source().selfWithoutRevision("space", "repo");
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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 com.google.inject.util.Providers;
|
||||
import org.jboss.resteasy.mock.MockHttpRequest;
|
||||
import org.jboss.resteasy.mock.MockHttpResponse;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import sonia.scm.security.ApiKey;
|
||||
import sonia.scm.security.ApiKeyService;
|
||||
import sonia.scm.web.RestDispatcher;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
|
||||
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.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.MockitoAnnotations.initMocks;
|
||||
|
||||
public class UserApiKeyResourceTest {
|
||||
|
||||
private final RestDispatcher dispatcher = new RestDispatcher();
|
||||
private final MockHttpResponse response = new MockHttpResponse();
|
||||
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/"));
|
||||
|
||||
@Mock
|
||||
private ApiKeyService apiKeyService;
|
||||
|
||||
@InjectMocks
|
||||
private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper;
|
||||
|
||||
@Before
|
||||
public void prepareEnvironment() {
|
||||
initMocks(this);
|
||||
ApiKeyCollectionToDtoMapper apiKeyCollectionMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks);
|
||||
UserApiKeyResource userApiKeyResource = new UserApiKeyResource(apiKeyService, apiKeyCollectionMapper, apiKeyMapper, resourceLinks);
|
||||
UserRootResource userRootResource = new UserRootResource(null, null, Providers.of(userApiKeyResource));
|
||||
dispatcher.addSingletonResource(userRootResource);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGetAllApiKeys() throws URISyntaxException, UnsupportedEncodingException {
|
||||
when(apiKeyService.getKeys("trillian"))
|
||||
.thenReturn(asList(
|
||||
new ApiKey("1", "key 1", "READ", now()),
|
||||
new ApiKey("2", "key 2", "WRITE", now())));
|
||||
|
||||
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "trillian/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/users/trillian/api_keys\"}");
|
||||
assertThat(response.getContentAsString()).contains("\"create\":{\"href\":\"/v2/users/trillian/api_keys\"}");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGetSingleApiKey() throws URISyntaxException, UnsupportedEncodingException {
|
||||
when(apiKeyService.getKeys("trillian"))
|
||||
.thenReturn(asList(
|
||||
new ApiKey("1", "key 1", "READ", now()),
|
||||
new ApiKey("2", "key 2", "WRITE", now())));
|
||||
|
||||
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "trillian/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/users/trillian/api_keys/1\"}");
|
||||
assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/users/trillian/api_keys/1\"}");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateNewApiKey() throws URISyntaxException, UnsupportedEncodingException {
|
||||
when(apiKeyService.createNewKey("trillian", "guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1"));
|
||||
|
||||
final MockHttpRequest request = MockHttpRequest
|
||||
.post("/" + UserRootResource.USERS_PATH_V2 + "trillian/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/users/trillian/api_keys/1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldIgnoreInvalidNewApiKey() throws URISyntaxException {
|
||||
when(apiKeyService.createNewKey("trillian", "guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1"));
|
||||
|
||||
final MockHttpRequest request = MockHttpRequest
|
||||
.post("/" + UserRootResource.USERS_PATH_V2 + "trillian/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("/" + UserRootResource.USERS_PATH_V2 + "trillian/api_keys/1");
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(204);
|
||||
verify(apiKeyService).remove("trillian", "1");
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import com.github.sdorra.shiro.ShiroRule;
|
||||
import com.github.sdorra.shiro.SubjectAware;
|
||||
import com.google.common.io.Resources;
|
||||
import com.google.inject.util.Providers;
|
||||
import com.sun.mail.iap.Argument;
|
||||
import org.apache.shiro.authc.credential.PasswordService;
|
||||
import org.jboss.resteasy.mock.MockHttpRequest;
|
||||
import org.jboss.resteasy.mock.MockHttpResponse;
|
||||
@@ -42,6 +41,7 @@ import org.mockito.Mock;
|
||||
import sonia.scm.ContextEntry;
|
||||
import sonia.scm.NotFoundException;
|
||||
import sonia.scm.PageResult;
|
||||
import sonia.scm.security.ApiKeyService;
|
||||
import sonia.scm.security.PermissionAssigner;
|
||||
import sonia.scm.security.PermissionDescriptor;
|
||||
import sonia.scm.user.ChangePasswordNotAllowedException;
|
||||
@@ -83,7 +83,7 @@ public class UserRootResourceTest {
|
||||
@Rule
|
||||
public ShiroRule shiro = new ShiroRule();
|
||||
|
||||
private RestDispatcher dispatcher = new RestDispatcher();
|
||||
private final RestDispatcher dispatcher = new RestDispatcher();
|
||||
|
||||
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/"));
|
||||
|
||||
@@ -92,6 +92,8 @@ public class UserRootResourceTest {
|
||||
@Mock
|
||||
private UserManager userManager;
|
||||
@Mock
|
||||
private ApiKeyService apiKeyService;
|
||||
@Mock
|
||||
private PermissionAssigner permissionAssigner;
|
||||
@InjectMocks
|
||||
private UserDtoToUserMapperImpl dtoToUserMapper;
|
||||
@@ -99,6 +101,8 @@ public class UserRootResourceTest {
|
||||
private UserToUserDtoMapperImpl userToDtoMapper;
|
||||
@InjectMocks
|
||||
private PermissionCollectionToDtoMapper permissionCollectionToDtoMapper;
|
||||
@InjectMocks
|
||||
private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<User> userCaptor;
|
||||
@@ -122,8 +126,10 @@ public class UserRootResourceTest {
|
||||
userCollectionToDtoMapper, resourceLinks, passwordService);
|
||||
UserPermissionResource userPermissionResource = new UserPermissionResource(permissionAssigner, permissionCollectionToDtoMapper);
|
||||
UserResource userResource = new UserResource(dtoToUserMapper, userToDtoMapper, userManager, passwordService, userPermissionResource);
|
||||
ApiKeyCollectionToDtoMapper apiKeyCollectionToDtoMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks);
|
||||
UserApiKeyResource userApiKeyResource = new UserApiKeyResource(apiKeyService, apiKeyCollectionToDtoMapper, apiKeyMapper, resourceLinks);
|
||||
UserRootResource userRootResource = new UserRootResource(Providers.of(userCollectionResource),
|
||||
Providers.of(userResource));
|
||||
Providers.of(userResource), Providers.of(userApiKeyResource));
|
||||
|
||||
dispatcher.addSingletonResource(userRootResource);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class ApiKeyServiceTest {
|
||||
|
||||
@Test
|
||||
void shouldCreateNewKeyAndStoreItHashed() {
|
||||
service.createNewKey("1", "READ");
|
||||
service.createNewKey("dent","1", "READ");
|
||||
|
||||
ApiKeyCollection apiKeys = store.get("dent");
|
||||
|
||||
@@ -105,7 +105,7 @@ class ApiKeyServiceTest {
|
||||
|
||||
@Test
|
||||
void shouldReturnRoleForKey() {
|
||||
String newKey = service.createNewKey("1", "READ").getToken();
|
||||
String newKey = service.createNewKey("dent","1", "READ").getToken();
|
||||
|
||||
ApiKeyService.CheckResult role = service.check(newKey);
|
||||
|
||||
@@ -114,20 +114,20 @@ class ApiKeyServiceTest {
|
||||
|
||||
@Test
|
||||
void shouldHandleNewUser() {
|
||||
assertThat(service.getKeys()).isEmpty();
|
||||
assertThat(service.getKeys("zaphod")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotReturnAnythingWithWrongKey() {
|
||||
service.createNewKey("1", "READ");
|
||||
service.createNewKey("dent","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");
|
||||
ApiKeyService.CreationResult firstKey = service.createNewKey("dent","1", "READ");
|
||||
ApiKeyService.CreationResult secondKey = service.createNewKey("dent","2", "WRITE");
|
||||
|
||||
ApiKeyCollection apiKeys = store.get("dent");
|
||||
|
||||
@@ -136,16 +136,16 @@ class ApiKeyServiceTest {
|
||||
assertThat(service.check(firstKey.getToken())).extracting("permissionRole").isEqualTo("READ");
|
||||
assertThat(service.check(secondKey.getToken())).extracting("permissionRole").isEqualTo("WRITE");
|
||||
|
||||
assertThat(service.getKeys()).extracting("id")
|
||||
assertThat(service.getKeys("dent")).extracting("id")
|
||||
.contains(firstKey.getId(), secondKey.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRemoveKey() {
|
||||
String firstKey = service.createNewKey("first", "READ").getToken();
|
||||
String secondKey = service.createNewKey("second", "WRITE").getToken();
|
||||
String firstKey = service.createNewKey("dent","first", "READ").getToken();
|
||||
String secondKey = service.createNewKey("dent","second", "WRITE").getToken();
|
||||
|
||||
service.remove("1");
|
||||
service.remove("dent","1");
|
||||
|
||||
assertThrows(AuthorizationException.class, () -> service.check(firstKey));
|
||||
assertThat(service.check(secondKey)).extracting("permissionRole").isEqualTo("WRITE");
|
||||
@@ -153,23 +153,23 @@ class ApiKeyServiceTest {
|
||||
|
||||
@Test
|
||||
void shouldFailWhenAddingSameNameTwice() {
|
||||
String firstKey = service.createNewKey("1", "READ").getToken();
|
||||
String firstKey = service.createNewKey("dent","1", "READ").getToken();
|
||||
|
||||
assertThrows(AlreadyExistsException.class, () -> service.createNewKey("1", "WRITE"));
|
||||
assertThrows(AlreadyExistsException.class, () -> service.createNewKey("dent","1", "WRITE"));
|
||||
|
||||
assertThat(service.check(firstKey)).extracting("permissionRole").isEqualTo("READ");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreCorrectPassphraseWithWrongName() {
|
||||
String firstKey = service.createNewKey("1", "READ").getToken();
|
||||
String firstKey = service.createNewKey("dent","1", "READ").getToken();
|
||||
|
||||
assertThrows(AuthorizationException.class, () -> service.check("dent", "other", firstKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeleteTokensWhenUserIsDeleted() {
|
||||
service.createNewKey("1", "READ").getToken();
|
||||
service.createNewKey("dent","1", "READ").getToken();
|
||||
|
||||
assertThat(store.get("dent").getKeys()).hasSize(1);
|
||||
|
||||
|
||||