add config form for public keys

This commit is contained in:
Eduard Heimbuch
2020-07-24 14:59:28 +02:00
parent 13326d6253
commit 4290ca4077
29 changed files with 1416 additions and 23 deletions

View File

@@ -0,0 +1,45 @@
/*
* 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 sonia.scm.BadRequestException;
import sonia.scm.ContextEntry;
import java.util.List;
public class NotPublicKeyException extends BadRequestException {
public NotPublicKeyException(List<ContextEntry> context, String message) {
super(context, message);
}
public NotPublicKeyException(List<ContextEntry> context, String message, Exception cause) {
super(context, message, cause);
}
@Override
public String getCode() {
return "BxS5wX2v71";
}
}

View File

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

View File

@@ -37,7 +37,8 @@
"settingsNavLink": "Einstellungen",
"generalNavLink": "Generell",
"setPasswordNavLink": "Passwort",
"setPermissionsNavLink": "Berechtigungen"
"setPermissionsNavLink": "Berechtigungen",
"setPublicKeyNavLink": "Öffentliche Schlüssel"
}
},
"createUser": {
@@ -60,5 +61,13 @@
"userForm": {
"subtitle": "Benutzer bearbeiten",
"button": "Speichern"
},
"publicKey": {
"noStoredKeys": "Es wurden keine Schlüssel gefunden.",
"displayName": "Anzeigename",
"raw": "Schlüssel",
"created": "Eingetragen an",
"addKey": "Schlüssel hinzufügen",
"delete": "Löschen"
}
}

View File

@@ -37,7 +37,8 @@
"settingsNavLink": "Settings",
"generalNavLink": "General",
"setPasswordNavLink": "Password",
"setPermissionsNavLink": "Permissions"
"setPermissionsNavLink": "Permissions",
"setPublicKeyNavLink": "Public Keys"
}
},
"createUser": {
@@ -60,5 +61,13 @@
"userForm": {
"subtitle": "Edit User",
"button": "Submit"
},
"publicKey": {
"noStoredKeys": "No keys found.",
"displayName": "Display Name",
"raw": "Key",
"created": "Created on",
"addKey": "Add key",
"delete": "Delete"
}
}

View File

@@ -42,6 +42,8 @@ import {
import ChangeUserPassword from "./ChangeUserPassword";
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";
type Props = RouteComponentProps &
WithTranslation & {
@@ -93,6 +95,7 @@ class Profile extends React.Component<Props> {
<PrimaryContentColumn>
<Route path={url} exact render={() => <ProfileInfo me={me} />} />
<Route path={`${url}/settings/password`} render={() => <ChangeUserPassword me={me} />} />
<Route path={`${url}/settings/publicKeys`} render={() => <SetPublicKeys user={me}/>} />
<ExtensionPoint name="profile.route" props={extensionProps} renderAll={true} />
</PrimaryContentColumn>
<SecondaryNavigationColumn>
@@ -109,6 +112,7 @@ class Profile extends React.Component<Props> {
title={t("profile.settingsNavLink")}
>
<NavLink to={`${url}/settings/password`} label={t("profile.changePasswordNavLink")} />
<SetPublicKeyNavLink user={me} publicKeyUrl={`${url}/settings/publicKeys`} />
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
</SubNavigation>
</SecondaryNavigation>

View File

@@ -0,0 +1,43 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Link, User, Me } from "@scm-manager/ui-types";
import { NavLink } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
type Props = {
user: User | Me;
publicKeyUrl: string;
};
const SetPublicKeyNavLink: FC<Props> = ({ user, publicKeyUrl }) => {
const [t] = useTranslation("users");
if ((user?._links?.publicKeys as Link)?.href) {
return <NavLink to={publicKeyUrl} label={t("singleUser.menu.setPublicKeyNavLink")} />;
}
return null;
};
export default SetPublicKeyNavLink;

View File

@@ -25,3 +25,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";

View File

@@ -0,0 +1,89 @@
/*
* 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, useState } from "react";
import { User, Link, Links, Collection } from "@scm-manager/ui-types/src";
import {
ErrorNotification,
InputField,
Level,
Textarea,
SubmitButton,
apiClient,
Loading
} from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { CONTENT_TYPE_PUBLIC_KEY } from "./SetPublicKeys";
type Props = {
createLink: string;
refresh: () => void;
};
const AddPublicKey: FC<Props> = ({ createLink, refresh }) => {
const [t] = useTranslation("users");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<undefined | Error>();
const [displayName, setDisplayName] = useState("");
const [raw, setRaw] = useState("");
const isValid = () => {
return !!displayName && !!raw;
};
const resetForm = () => {
setDisplayName("");
setRaw("");
};
const addKey = () => {
setLoading(true);
apiClient
.post(createLink, { displayName: displayName, raw: raw }, CONTENT_TYPE_PUBLIC_KEY)
.then(resetForm)
.then(refresh)
.then(() => setLoading(false))
.catch(setError);
};
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
return (
<>
<InputField label={t("publicKey.displayName")} value={displayName} onChange={setDisplayName} />
<Textarea name="raw" label={t("publicKey.raw")} value={raw} onChange={setRaw} />
<Level
right={<SubmitButton label={t("publicKey.addKey")} loading={loading} disabled={!isValid()} action={addKey} />}
/>
</>
);
};
export default AddPublicKey;

View File

@@ -0,0 +1,61 @@
/*
* 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, DeleteButton } from "@scm-manager/ui-components/src";
import { PublicKey } from "./SetPublicKeys";
import { useTranslation } from "react-i18next";
import { Link } from "@scm-manager/ui-types";
import { formatPublicKey } from "./formatPublicKey";
type Props = {
publicKey: PublicKey;
onDelete: (link: string) => void;
};
export const PublicKeyEntry: FC<Props> = ({ publicKey, onDelete }) => {
const [t] = useTranslation("users");
let deleteButton;
if (publicKey?._links?.delete) {
deleteButton = (
<DeleteButton label={t("publicKey.delete")} action={() => onDelete((publicKey._links.delete as Link).href)} />
);
}
return (
<>
<tr>
<td>{publicKey.displayName}</td>
<td>
<DateFromNow date={publicKey.created} />
</td>
<td className="is-hidden-mobile">{formatPublicKey(publicKey.raw)}</td>
<td>{deleteButton}</td>
</tr>
</>
);
};
export default PublicKeyEntry;

View File

@@ -0,0 +1,62 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { PublicKey, PublicKeysCollection } from "./SetPublicKeys";
import PublicKeyEntry from "./PublicKeyEntry";
import { Notification } from "@scm-manager/ui-components";
type Props = {
publicKeys?: PublicKeysCollection;
onDelete: (link: string) => void;
};
const PublicKeyTable: FC<Props> = ({ publicKeys, onDelete }) => {
const [t] = useTranslation("users");
if (publicKeys?._embedded?.keys?.length === 0) {
return <Notification type="info">{t("publicKey.noStoredKeys")}</Notification>;
}
return (
<table className="card-table table is-hoverable is-fullwidth">
<thead>
<tr>
<th>{t("publicKey.displayName")}</th>
<th>{t("publicKey.created")}</th>
<th className="is-hidden-mobile">{t("publicKey.raw")}</th>
<th />
</tr>
</thead>
<tbody>
{publicKeys?._embedded?.keys?.map((publicKey: PublicKey, index: number) => {
return <PublicKeyEntry key={index} onDelete={onDelete} publicKey={publicKey} />;
})}
</tbody>
</table>
);
};
export default PublicKeyTable;

View File

@@ -0,0 +1,94 @@
/*
* 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, Link, Links, User, Me } from "@scm-manager/ui-types";
import React, { FC, useEffect, useState } from "react";
import AddPublicKey from "./AddPublicKey";
import PublicKeyTable from "./PublicKeyTable";
import { apiClient, ErrorNotification, Loading } from "@scm-manager/ui-components";
export type PublicKeysCollection = Collection & {
_embedded: {
keys: PublicKey[];
};
};
export type PublicKey = {
displayName: string;
raw: string;
created?: string;
_links: Links;
};
export const CONTENT_TYPE_PUBLIC_KEY = "application/vnd.scmm-publicKey+json;v=2";
type Props = {
user: User | Me;
};
const SetPublicKeys: FC<Props> = ({ user }) => {
const [error, setError] = useState<undefined | Error>();
const [loading, setLoading] = useState(false);
const [publicKeys, setPublicKeys] = useState<PublicKeysCollection | undefined>(undefined);
useEffect(() => {
fetchPublicKeys();
}, [user]);
const fetchPublicKeys = () => {
setLoading(true);
apiClient
.get((user._links.publicKeys as Link).href)
.then(r => r.json())
.then(setPublicKeys)
.then(() => setLoading(false))
.catch(setError);
};
const onDelete = (link: string) => {
apiClient
.delete(link)
.then(fetchPublicKeys)
.catch(setError);
};
const createLink = (publicKeys?._links?.create as Link)?.href;
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
return (
<>
<PublicKeyTable publicKeys={publicKeys} onDelete={onDelete} />
{createLink && <AddPublicKey createLink={createLink} refresh={fetchPublicKeys} />}
</>
);
};
export default SetPublicKeys;

View File

@@ -0,0 +1,51 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { formatPublicKey } from "./formatPublicKey";
describe("format authorized key tests", () => {
it("should format the given key", () => {
const key = "ssh-rsa ACB0DEFGHIJKLMOPQRSTUVWXYZ tricia@hitchhiker.com";
expect(formatPublicKey(key)).toEqual("ssh-rsa ... tricia@hitchhiker.com");
});
it("should use the first chars of the key without prefix", () => {
const key = "ACB0DEFGHIJKLMOPQRSTUVWXYZ tricia@hitchhiker.com";
expect(formatPublicKey(key)).toEqual("ACB0DEF... tricia@hitchhiker.com");
});
it("should use the last chars of the key without suffix", () => {
const key = "ssh-rsa ACB0DEFGHIJKLMOPQRSTUVWXYZ";
expect(formatPublicKey(key)).toEqual("ssh-rsa ...TUVWXYZ");
});
it("should use a few chars from the beginning and a few from the end, if the key has no prefix and suffix", () => {
const key = "ACB0DEFGHIJKLMOPQRSTUVWXYZ0123456789";
expect(formatPublicKey(key)).toEqual("ACB0DEF...3456789");
});
it("should return the whole string for a short key", () => {
const key = "ABCDE";
expect(formatPublicKey(key)).toEqual("ABCDE");
});
});

View File

@@ -0,0 +1,43 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
export const formatPublicKey = (key: string) => {
const parts = key.split(/\s+/);
if (parts.length === 3) {
return parts[0] + " ... " + parts[2];
} else if (parts.length === 2) {
if (parts[0].length >= parts[1].length) {
return parts[0].substring(0, 7) + "... " + parts[1];
} else {
const keyLength = parts[1].length;
return parts[0] + " ..." + parts[1].substring(keyLength - 7);
}
} else {
const keyLength = parts[0].length;
if (keyLength < 15) {
return parts[0];
}
return parts[0].substring(0, 7) + "..." + parts[0].substring(keyLength - 7);
}
};

View File

@@ -41,11 +41,13 @@ import {
import { Details } from "./../components/table";
import EditUser from "./EditUser";
import { fetchUserByName, getFetchUserFailure, getUserByName, isFetchUserPending } from "../modules/users";
import { EditUserNavLink, SetPasswordNavLink, SetPermissionsNavLink } from "./../components/navLinks";
import { EditUserNavLink, SetPasswordNavLink, SetPermissionsNavLink, SetPublicKeysNavLink } from "./../components/navLinks";
import { WithTranslation, withTranslation } from "react-i18next";
import { getUsersLink } from "../../modules/indexResource";
import SetUserPassword from "../components/SetUserPassword";
import SetPermissions from "../../permissions/components/SetPermissions";
import AddPublicKey from "../components/publicKeys/AddPublicKey";
import SetPublicKeys from "../components/publicKeys/SetPublicKeys";
type Props = RouteComponentProps &
WithTranslation & {
@@ -105,6 +107,10 @@ 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} />}
/>
<ExtensionPoint name="user.route" props={extensionProps} renderAll={true} />
</PrimaryContentColumn>
<SecondaryNavigationColumn>
@@ -123,6 +129,7 @@ class SingleUser extends React.Component<Props> {
<EditUserNavLink user={user} editUrl={`${url}/settings/general`} />
<SetPasswordNavLink user={user} passwordUrl={`${url}/settings/password`} />
<SetPermissionsNavLink user={user} permissionsUrl={`${url}/settings/permissions`} />
<SetPublicKeysNavLink user={user} publicKeyUrl={`${url}/settings/publickeys`} />
<ExtensionPoint name="user.setting" props={extensionProps} renderAll={true} />
</SubNavigation>
</SecondaryNavigation>

View File

@@ -27,6 +27,7 @@ package sonia.scm.api.v2.resources;
import com.google.inject.AbstractModule;
import com.google.inject.servlet.ServletScopes;
import org.mapstruct.factory.Mappers;
import sonia.scm.security.gpg.PublicKeyMapper;
import sonia.scm.web.api.RepositoryToHalMapper;
public class MapperModule extends AbstractModule {
@@ -35,6 +36,7 @@ public class MapperModule extends AbstractModule {
bind(UserDtoToUserMapper.class).to(Mappers.getMapperClass(UserDtoToUserMapper.class));
bind(UserToUserDtoMapper.class).to(Mappers.getMapperClass(UserToUserDtoMapper.class));
bind(UserCollectionToDtoMapper.class);
bind(PublicKeyMapper.class).to(Mappers.getMapperClass(PublicKeyMapper.class));
bind(GroupDtoToGroupMapper.class).to(Mappers.getMapperClass(GroupDtoToGroupMapper.class));
bind(GroupToGroupDtoMapper.class).to(Mappers.getMapperClass(GroupToGroupDtoMapper.class));

View File

@@ -89,6 +89,9 @@ public class MeDtoFactory extends HalAppenderMapper {
if (UserPermissions.modify(user).isPermitted()) {
linksBuilder.single(link("update", resourceLinks.me().update(user.getName())));
}
if (UserPermissions.changePublicKeys(user).isPermitted()) {
linksBuilder.single(link("publicKeys", resourceLinks.user().publicKeys(user.getName())));
}
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted() && !Authentications.isSubjectAnonymous(user.getName())) {
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
}

View File

@@ -25,6 +25,7 @@
package sonia.scm.api.v2.resources;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.security.gpg.PublicKeyResource;
import javax.inject.Inject;
import java.net.URI;
@@ -99,9 +100,11 @@ class ResourceLinks {
static class UserLinks {
private final LinkBuilder userLinkBuilder;
private final LinkBuilder publicKeyLinkBuilder;
UserLinks(ScmPathInfo pathInfo) {
userLinkBuilder = new LinkBuilder(pathInfo, UserRootResource.class, UserResource.class);
publicKeyLinkBuilder = new LinkBuilder(pathInfo, PublicKeyResource.class);
}
String self(String name) {
@@ -119,6 +122,10 @@ class ResourceLinks {
public String passwordChange(String name) {
return userLinkBuilder.method("getUserResource").parameters(name).method("overwritePassword").parameters().href();
}
public String publicKeys(String name) {
return publicKeyLinkBuilder.method("findAll").parameters(name).href();
}
}
interface WithPermissionLinks {

View File

@@ -65,6 +65,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())));
if (userManager.isTypeDefault(user)) {
linksBuilder.single(link("password", resourceLinks.user().passwordChange(user.getName())));
}

View File

@@ -0,0 +1,86 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.security.gpg;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Link;
import de.otto.edison.hal.Links;
import sonia.scm.api.v2.resources.LinkBuilder;
import sonia.scm.api.v2.resources.ScmPathInfoStore;
import sonia.scm.user.UserPermissions;
import javax.inject.Inject;
import javax.inject.Provider;
import java.util.List;
import java.util.stream.Collectors;
import static de.otto.edison.hal.Links.linkingTo;
public class PublicKeyCollectionMapper {
private final Provider<ScmPathInfoStore> scmPathInfoStore;
private final PublicKeyMapper mapper;
@Inject
public PublicKeyCollectionMapper(Provider<ScmPathInfoStore> scmPathInfoStore, PublicKeyMapper mapper) {
this.scmPathInfoStore = scmPathInfoStore;
this.mapper = mapper;
}
HalRepresentation map(String username, List<RawGpgKey> keys) {
List<RawGpgKeyDto> dtos = keys.stream()
.map(mapper::map)
.collect(Collectors.toList());
Links.Builder builder = linkingTo();
builder.self(selfLink(username));
if (hasCreatePermissions(username)) {
builder.single(Link.link("create", createLink(username)));
}
return new HalRepresentation(builder.build(), Embedded.embedded("keys", dtos));
}
private boolean hasCreatePermissions(String username) {
return UserPermissions.changePublicKeys(username).isPermitted();
}
private String createLink(String username) {
return new LinkBuilder(scmPathInfoStore.get().get(), PublicKeyResource.class)
.method("create")
.parameters(username)
.href();
}
private String selfLink(String username) {
return new LinkBuilder(scmPathInfoStore.get().get(), PublicKeyResource.class)
.method("findAll")
.parameters(username)
.href();
}
}

View File

@@ -0,0 +1,79 @@
/*
* 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.gpg;
import com.google.common.annotations.VisibleForTesting;
import de.otto.edison.hal.Link;
import de.otto.edison.hal.Links;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.ObjectFactory;
import sonia.scm.api.v2.resources.LinkBuilder;
import sonia.scm.api.v2.resources.ScmPathInfoStore;
import sonia.scm.user.UserPermissions;
import javax.inject.Inject;
import javax.inject.Provider;
import static de.otto.edison.hal.Links.linkingTo;
@Mapper
public abstract class PublicKeyMapper {
@Inject
private Provider<ScmPathInfoStore> scmPathInfoStore;
@VisibleForTesting
void setScmPathInfoStore(Provider<ScmPathInfoStore> scmPathInfoStore) {
this.scmPathInfoStore = scmPathInfoStore;
}
@Mapping(target = "attributes", ignore = true)
abstract RawGpgKeyDto map(RawGpgKey rawGpgKey);
@ObjectFactory
RawGpgKeyDto createDto(RawGpgKey rawGpgKey) {
Links.Builder linksBuilder = linkingTo();
linksBuilder.self(createSelfLink(rawGpgKey));
if (UserPermissions.changePublicKeys(rawGpgKey.getOwner()).isPermitted()) {
linksBuilder.single(Link.link("delete", createDeleteLink(rawGpgKey)));
}
return new RawGpgKeyDto(linksBuilder.build());
}
private String createSelfLink(RawGpgKey rawGpgKey) {
return new LinkBuilder(scmPathInfoStore.get().get(), PublicKeyResource.class)
.method("findById")
.parameters(rawGpgKey.getId())
.href();
}
private String createDeleteLink(RawGpgKey rawGpgKey) {
return new LinkBuilder(scmPathInfoStore.get().get(), PublicKeyResource.class)
.method("deleteById")
.parameters(rawGpgKey.getId())
.href();
}
}

View File

@@ -0,0 +1,191 @@
/*
* 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.gpg;
import de.otto.edison.hal.HalRepresentation;
import io.swagger.v3.oas.annotations.Operation;
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.api.v2.resources.ErrorDto;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
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.Context;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo;
import java.util.Optional;
@Path("v2/public_keys")
public class PublicKeyResource {
private static final String MEDIA_TYPE = VndMediaType.PREFIX + "publicKey" + VndMediaType.SUFFIX;
private static final String MEDIA_TYPE_COLLECTION = VndMediaType.PREFIX + "publicKeyCollection" + VndMediaType.SUFFIX;
private final PublicKeyMapper mapper;
private final PublicKeyCollectionMapper collectionMapper;
private final PublicKeyStore store;
@Inject
public PublicKeyResource(PublicKeyMapper mapper, PublicKeyCollectionMapper collectionMapper, PublicKeyStore store) {
this.mapper = mapper;
this.collectionMapper = collectionMapper;
this.store = store;
}
@GET
@Path("{username}")
@Produces(MEDIA_TYPE_COLLECTION)
@Operation(
summary = "Get all public keys for user",
description = "Returns all keys for the given username.",
tags = "User",
operationId = "get_all_public_keys"
)
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = MEDIA_TYPE_COLLECTION,
schema = @Schema(implementation = HalRepresentation.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public HalRepresentation findAll(@PathParam("username") String username) {
return collectionMapper.map(username, store.findByUsername(username));
}
@GET
@Path("{id}")
@Produces(MEDIA_TYPE)
@Operation(
summary = "Get single key for user",
description = "Returns a single public key for username by id.",
tags = "User",
operationId = "get_single_public_key"
)
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = MEDIA_TYPE,
schema = @Schema(implementation = RawGpgKeyDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege")
@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 Response findById(@PathParam("id") String id) {
Optional<RawGpgKey> byId = store.findById(id);
if (byId.isPresent()) {
return Response.ok(mapper.map(byId.get())).build();
}
return Response.status(Response.Status.NOT_FOUND).build();
}
@POST
@Path("{username}")
@Consumes(MEDIA_TYPE)
@Operation(
summary = "Create new key",
description = "Creates new key for user.",
tags = "User",
operationId = "create_public_key"
)
@ApiResponse(responseCode = "201", description = "create success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response create(@Context UriInfo uriInfo, @PathParam("username") String username, RawGpgKeyDto publicKey) {
String id = store.add(publicKey.getDisplayName(), username, publicKey.getRaw()).getId();
UriBuilder builder = uriInfo.getAbsolutePathBuilder();
builder.path(id);
return Response.created(builder.build()).build();
}
@DELETE
@Path("delete/{id}")
@Operation(
summary = "Deletes public key",
description = "Deletes public key for user.",
tags = "User",
operationId = "delete_public_key"
)
@ApiResponse(responseCode = "204", description = "delete success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response deleteById(@PathParam("id") String id) {
store.delete(id);
return Response.noContent().build();
}
}

View File

@@ -24,18 +24,20 @@
package sonia.scm.security.gpg;
import com.google.common.annotations.VisibleForTesting;
import org.apache.shiro.SecurityUtils;
import org.bouncycastle.openpgp.PGPException;
import sonia.scm.ContextEntry;
import sonia.scm.security.NotPublicKeyException;
import sonia.scm.store.DataStore;
import sonia.scm.store.DataStoreFactory;
import sonia.scm.user.UserPermissions;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.stream.Collectors;
@Singleton
public class PublicKeyStore {
@@ -43,26 +45,22 @@ public class PublicKeyStore {
private static final String STORE_NAME = "gpg_public_keys";
private final DataStore<RawGpgKey> store;
private final Supplier<String> currentUserSupplier;
@Inject
public PublicKeyStore(DataStoreFactory dataStoreFactory) {
this(
dataStoreFactory.withType(RawGpgKey.class).withName(STORE_NAME).build(),
() -> SecurityUtils.getSubject().getPrincipal().toString()
);
this.store = dataStoreFactory.withType(RawGpgKey.class).withName(STORE_NAME).build();
}
@VisibleForTesting
PublicKeyStore(DataStore<RawGpgKey> store, Supplier<String> currentUserSupplier) {
this.store = store;
this.currentUserSupplier = currentUserSupplier;
}
public RawGpgKey add(String displayName, String username, String rawKey) {
UserPermissions.modify(username).check();
if (!rawKey.contains("PUBLIC KEY")) {
throw new NotPublicKeyException(ContextEntry.ContextBuilder.entity(RawGpgKey.class, displayName).build(), "The provided key is not a public key");
}
public RawGpgKey add(String displayName, String rawKey) {
try {
String id = Keys.resolveIdFromKey(rawKey);
RawGpgKey key = new RawGpgKey(id, displayName, currentUserSupplier.get(), rawKey, Instant.now());
RawGpgKey key = new RawGpgKey(id, displayName, username, rawKey, Instant.now());
store.put(id, key);
@@ -72,8 +70,23 @@ public class PublicKeyStore {
}
}
public void delete(String id) {
RawGpgKey rawGpgKey = store.get(id);
if (rawGpgKey != null) {
UserPermissions.modify(rawGpgKey.getOwner()).check();
store.remove(id);
}
}
public Optional<RawGpgKey> findById(String id) {
return store.getOptional(id);
}
public List<RawGpgKey> findByUsername(String username) {
return store.getAll().values()
.stream()
.filter(rawGpgKey -> username.equalsIgnoreCase(rawGpgKey.getOwner()))
.collect(Collectors.toList());
}
}

View File

@@ -32,6 +32,7 @@ import sonia.scm.xml.XmlInstantAdapter;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import java.time.Instant;
import java.util.Objects;
@@ -40,6 +41,7 @@ import java.util.Objects;
@NoArgsConstructor
@AllArgsConstructor
@XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement
public class RawGpgKey {
private String id;

View File

@@ -0,0 +1,48 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.security.gpg;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.Instant;
@Getter
@Setter
@NoArgsConstructor
@SuppressWarnings("squid:S2160") // we do not need equals for dto
public class RawGpgKeyDto extends HalRepresentation {
private String displayName;
private String raw;
private Instant created;
RawGpgKeyDto(Links links) {
super(links);
}
}

View File

@@ -198,6 +198,28 @@ class MeDtoFactoryTest {
assertThat(dto.getLinks().getLinkBy("password")).isNotPresent();
}
@Test
void shouldAppendPublicKeysLink() {
User user = UserTestData.createTrillian();
prepareSubject(user);
when(subject.isPermitted("user:changePublicKeys:trillian")).thenReturn(true);
MeDto dto = meDtoFactory.create();
assertThat(dto.getLinks().getLinkBy("publicKeys").get().getHref()).isEqualTo("https://scm.hitchhiker.com/scm/v2/public_keys/trillian");
}
@Test
void shouldNotAppendPublicKeysLink() {
User user = UserTestData.createTrillian();
prepareSubject(user);
when(subject.isPermitted("user:changePublicKeys:trillian")).thenReturn(false);
MeDto dto = meDtoFactory.create();
assertThat(dto.getLinks().getLinkBy("publicKeys")).isNotPresent();
}
@Test
void shouldAppendLinks() {
prepareSubject(UserTestData.createTrillian());

View File

@@ -0,0 +1,110 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.security.gpg;
import com.google.common.collect.Lists;
import com.google.inject.util.Providers;
import de.otto.edison.hal.HalRepresentation;
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.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.api.v2.resources.ScmPathInfoStore;
import java.io.IOException;
import java.net.URI;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PublicKeyCollectionMapperTest {
private PublicKeyCollectionMapper collectionMapper;
@Mock
private PublicKeyMapper mapper;
@Mock
private Subject subject;
@BeforeEach
void setUpObjectUnderTest() {
ScmPathInfoStore pathInfoStore = new ScmPathInfoStore();
pathInfoStore.set(() -> URI.create("/"));
collectionMapper = new PublicKeyCollectionMapper(Providers.of(pathInfoStore), mapper);
ThreadContext.bind(subject);
}
@AfterEach
void cleanThreadContext() {
ThreadContext.unbindSubject();
}
@Test
void shouldMapToCollection() throws IOException {
when(mapper.map(any(RawGpgKey.class))).then(ic -> new RawGpgKeyDto());
RawGpgKey one = createPublicKey("one");
RawGpgKey two = createPublicKey("two");
List<RawGpgKey> keys = Lists.newArrayList(one, two);
HalRepresentation collection = collectionMapper.map("trillian", keys);
List<HalRepresentation> embedded = collection.getEmbedded().getItemsBy("keys");
assertThat(embedded).hasSize(2);
assertThat(collection.getLinks().getLinkBy("self").get().getHref()).isEqualTo("/v2/public_keys/trillian");
}
@Test
void shouldAddCreateLinkIfTheUserIsPermitted() {
when(subject.isPermitted("user:changePublicKeys:trillian")).thenReturn(true);
HalRepresentation collection = collectionMapper.map("trillian", Lists.newArrayList());
assertThat(collection.getLinks().getLinkBy("create").get().getHref()).isEqualTo("/v2/public_keys/trillian");
}
@Test
void shouldNotAddCreateLinkWithoutPermission() {
HalRepresentation collection = collectionMapper.map("trillian", Lists.newArrayList());
assertThat(collection.getLinks().getLinkBy("create")).isNotPresent();
}
private RawGpgKey createPublicKey(String displayName) throws IOException {
String raw = GPGTestHelper.readKey("single.asc");
return new RawGpgKey(displayName, displayName, "trillian", raw, Instant.now());
}
}

View File

@@ -0,0 +1,92 @@
/*
* 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.gpg;
import com.google.inject.util.Providers;
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.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.api.v2.resources.ScmPathInfoStore;
import java.io.IOException;
import java.net.URI;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PublicKeyMapperTest {
@Mock
private Subject subject;
private final PublicKeyMapper mapper = new PublicKeyMapperImpl();
ScmPathInfoStore pathInfoStore = new ScmPathInfoStore();
@BeforeEach
void setup() {
ThreadContext.bind(subject);
pathInfoStore.set(() -> URI.create("/"));
mapper.setScmPathInfoStore(Providers.of(pathInfoStore));
}
@AfterEach
void tearDownSubject() {
ThreadContext.unbindSubject();
}
@Test
void shouldMapKeyToDto() throws IOException {
when(subject.isPermitted("user:changePublicKeys:trillian")).thenReturn(true);
String raw = GPGTestHelper.readKey("single.asc");
RawGpgKey key = new RawGpgKey("1", "key_42", "trillian", raw, Instant.now());
RawGpgKeyDto dto = mapper.map(key);
assertThat(dto.getDisplayName()).isEqualTo(key.getDisplayName());
assertThat(dto.getRaw()).isEqualTo(key.getRaw());
assertThat(dto.getCreated()).isEqualTo(key.getCreated());
assertThat(dto.getLinks().getLinkBy("self").get().getHref()).isEqualTo("/v2/public_keys/1");
assertThat(dto.getLinks().getLinkBy("delete").get().getHref()).isEqualTo("/v2/public_keys/delete/1");
}
@Test
void shouldNotAppendDeleteLink() throws IOException {
String raw = GPGTestHelper.readKey("single.asc");
RawGpgKey key = new RawGpgKey("1", "key_42", "trillian", raw, Instant.now());
RawGpgKeyDto dto = mapper.map(key);
assertThat(dto.getLinks().getLinkBy("delete")).isNotPresent();
}
}

View File

@@ -0,0 +1,142 @@
/*
* 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.gpg;
import de.otto.edison.hal.HalRepresentation;
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.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PublicKeyResourceTest {
@Mock
private PublicKeyStore store;
@Mock
private PublicKeyCollectionMapper collectionMapper;
@Mock
private PublicKeyMapper mapper;
@InjectMocks
private PublicKeyResource resource;
@Mock
private Subject subject;
@BeforeEach
void setUpSubject() {
ThreadContext.bind(subject);
}
@AfterEach
void clearSubject() {
ThreadContext.unbindSubject();
}
@Test
void shouldFindAll() {
List<RawGpgKey> keys = new ArrayList<>();
when(store.findByUsername("trillian")).thenReturn(keys);
HalRepresentation collection = new HalRepresentation();
when(collectionMapper.map("trillian", keys)).thenReturn(collection);
HalRepresentation result = resource.findAll("trillian");
assertThat(result).isSameAs(collection);
}
@Test
void shouldFindById() {
RawGpgKey key = new RawGpgKey("42");
when(store.findById("42")).thenReturn(Optional.of(key));
RawGpgKeyDto dto = new RawGpgKeyDto();
when(mapper.map(key)).thenReturn(dto);
Response response = resource.findById("42");
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getEntity()).isSameAs(dto);
}
@Test
void shouldReturn404IfIdDoesNotExists() {
when(store.findById("42")).thenReturn(Optional.empty());
Response response = resource.findById("42");
assertThat(response.getStatus()).isEqualTo(404);
}
@Test
void shouldAddToStore() throws URISyntaxException, IOException {
String raw = GPGTestHelper.readKey("single.asc");
UriInfo uriInfo = mock(UriInfo.class);
UriBuilder builder = mock(UriBuilder.class);
when(uriInfo.getAbsolutePathBuilder()).thenReturn(builder);
when(builder.path("42")).thenReturn(builder);
when(builder.build()).thenReturn(new URI("/v2/public_keys/42"));
RawGpgKey key = new RawGpgKey("42");
RawGpgKeyDto dto = new RawGpgKeyDto();
dto.setDisplayName("key_42");
dto.setRaw(raw);
when(store.add(dto.getDisplayName(), "trillian", dto.getRaw())).thenReturn(key);
Response response = resource.create(uriInfo, "trillian", dto);
assertThat(response.getStatus()).isEqualTo(201);
assertThat(response.getLocation().toASCIIString()).isEqualTo("/v2/public_keys/42");
}
@Test
void shouldDeleteFromStore() {
Response response = resource.deleteById("42");
assertThat(response.getStatus()).isEqualTo(204);
verify(store).delete("42");
}
}

View File

@@ -24,23 +24,65 @@
package sonia.scm.security.gpg;
import org.apache.shiro.authz.AuthorizationException;
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.Test;
import sonia.scm.store.InMemoryDataStore;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.security.NotPublicKeyException;
import sonia.scm.store.DataStoreFactory;
import sonia.scm.store.InMemoryDataStoreFactory;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.doThrow;
@ExtendWith(MockitoExtension.class)
class PublicKeyStoreTest {
@Mock
private Subject subject;
private PublicKeyStore keyStore;
private final DataStoreFactory dataStoreFactory = new InMemoryDataStoreFactory();
@BeforeEach
void setUpKeyStore() {
keyStore = new PublicKeyStore(new InMemoryDataStore<>(), () -> "trillian");
keyStore = new PublicKeyStore(dataStoreFactory);
}
@BeforeEach
void bindSubject() {
ThreadContext.bind(subject);
}
@AfterEach
void tearDownSubject() {
ThreadContext.unbindSubject();
}
@Test
void shouldThrowAuthorizationExceptionOnAdd() throws IOException {
doThrow(AuthorizationException.class).when(subject).checkPermission("user:modify:zaphod");
String rawKey = GPGTestHelper.readKey("single.asc");
assertThrows(AuthorizationException.class, () -> keyStore.add("zaphods key", "zaphod", rawKey));
}
@Test
void shouldOnlyStorePublicKeys() throws IOException {
String rawKey = GPGTestHelper.readKey("single.asc").replace("PUBLIC", "PRIVATE");
assertThrows(NotPublicKeyException.class, () -> keyStore.add("SCM Package Key", "trillian", rawKey));
}
@Test
@@ -48,7 +90,7 @@ class PublicKeyStoreTest {
String rawKey = GPGTestHelper.readKey("single.asc");
Instant now = Instant.now();
RawGpgKey key = keyStore.add("SCM Package Key", rawKey);
RawGpgKey key = keyStore.add("SCM Package Key", "trillian", rawKey);
assertThat(key.getId()).isEqualTo("0x975922F193B07D6E");
assertThat(key.getDisplayName()).isEqualTo("SCM Package Key");
assertThat(key.getOwner()).isEqualTo("trillian");
@@ -59,9 +101,44 @@ class PublicKeyStoreTest {
@Test
void shouldFindStoredKeyById() throws IOException {
String rawKey = GPGTestHelper.readKey("single.asc");
keyStore.add("SCM Package Key", rawKey);
keyStore.add("SCM Package Key", "trillian", rawKey);
Optional<RawGpgKey> key = keyStore.findById("0x975922F193B07D6E");
assertThat(key).isPresent();
}
@Test
void shouldDeleteKey() throws IOException {
String rawKey = GPGTestHelper.readKey("single.asc");
keyStore.add("SCM Package Key", "trillian", rawKey);
Optional<RawGpgKey> key = keyStore.findById("0x975922F193B07D6E");
assertThat(key).isPresent();
keyStore.delete("0x975922F193B07D6E");
key = keyStore.findById("0x975922F193B07D6E");
assertThat(key).isNotPresent();
}
@Test
void shouldReturnEmptyListIfNoKeysAvailable() {
List<RawGpgKey> keys = keyStore.findByUsername("zaphod");
assertThat(keys).isEmpty();
assertThat(keys).isInstanceOf(List.class);
}
@Test
void shouldFindAllKeysForUser() throws IOException {
String singleKey = GPGTestHelper.readKey("single.asc");
keyStore.add("SCM Single Key", "trillian", singleKey);
String multiKey = GPGTestHelper.readKey("subkeys.asc");
keyStore.add("SCM Multi Key", "trillian", multiKey);
List<RawGpgKey> keys = keyStore.findByUsername("trillian");
assertThat(keys.size()).isEqualTo(2);
}
}