Skip to content

Commit d54d5f2

Browse files
committed
Fixes #462
1 parent b37e9b9 commit d54d5f2

File tree

14 files changed

+149
-29
lines changed

14 files changed

+149
-29
lines changed

client/src/components/UserMenu.jsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {stopEvent} from "../utils/Utils";
66
import {UserInfo} from "@surfnet/sds";
77
import {useAppStore} from "../stores/AppStore";
88
import {logout} from "../api";
9-
import {highestAuthority} from "../utils/UserRole";
9+
import {AUTHORITIES, highestAuthority} from "../utils/UserRole";
1010

1111

1212
export const UserMenu = ({user, config, actions}) => {
@@ -32,12 +32,17 @@ export const UserMenu = ({user, config, actions}) => {
3232
}
3333

3434
const renderMenu = adminLinks => {
35+
const authority = highestAuthority(user);
36+
const apiTokenLink = authority === AUTHORITIES.INVITER || authority === AUTHORITIES.MANAGER;
3537
return (<>
3638
<ul>
3739
{user.superUser && adminLinks.map(l =>
3840
<li key={l}>
3941
<Link onClick={toggleUserMenu} to={`/${l}`}>{I18n.t(`header.links.${l}`)}</Link>
4042
</li>)}
43+
{apiTokenLink && <li>
44+
<Link onClick={toggleUserMenu} to={`/tokens`}>{I18n.t(`header.links.tokens`)}</Link>
45+
</li>}
4146
<li>
4247
<Link onClick={toggleUserMenu} to={`/profile`}>{I18n.t(`header.links.profile`)}</Link>
4348
</li>

client/src/locale/en.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const en = {
1+
const en = {
22
code: "EN",
33
name: "English",
44
select_locale: "Change language to English",
@@ -38,6 +38,7 @@ const en = {
3838
access: "Invite",
3939
help: "Help",
4040
profile: "Profile",
41+
tokens: "Tokens",
4142
logout: "Log out"
4243
},
4344
},
@@ -400,6 +401,7 @@ const en = {
400401
createFlash: "API token has been created",
401402
submit: "Submit",
402403
required: "The description is required for an API token",
404+
userTokens: "Your personal API tokens"
403405
},
404406
tooltips: {
405407
userIcon: "User {{name}} provisioned at {{createdAt}} with last activity on {{lastActivity}}",

client/src/locale/nl.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const nl = {
3838
access: "Invite",
3939
help: "Help",
4040
profile: "Profiel",
41+
tokens: "Tokens",
4142
logout: "Uitloggen"
4243
},
4344
},
@@ -400,6 +401,7 @@ const nl = {
400401
createFlash: "API-token is aangemaakt",
401402
submit: "Opslaan",
402403
required: "Een omschrijving is verplicht voor een API-token",
404+
userTokens: "Je persoonlijke API-tokens"
403405
},
404406
tooltips: {
405407
userIcon: "Gebruiker {{name}} geprovisiond op {{createdAt}}, laatst actief op {{lastActivity}}",

client/src/pages/App.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {Inviter} from "./Inviter";
2626
import {Application} from "./Application";
2727
import {System} from "./System";
2828
import {flushSync} from "react-dom";
29+
import {UserTokens} from "./UserTokens";
2930

3031

3132
export const App = () => {
@@ -113,6 +114,7 @@ export const App = () => {
113114
<Route path="inviter" element={<Inviter/>}/>
114115
<Route path="roles/:id/:tab?" element={<Role/>}/>
115116
<Route path="applications/:manageId" element={<Application/>}/>
117+
<Route path="tokens" element={<UserTokens/>}/>
116118
<Route path="invitation/accept"
117119
element={<Invitation authenticated={true}/>}/>
118120
<Route path="login" element={<Login/>}/>

client/src/tabs/Tokens.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import InputField from "../components/InputField";
1515
import ErrorIndicator from "../components/ErrorIndicator";
1616
import {isEmpty, stopEvent} from "../utils/Utils";
1717
import {Page} from "../components/Page";
18+
import {AUTHORITIES, highestAuthority} from "../utils/UserRole";
1819

1920
export const Tokens = () => {
2021
const {user, setFlash} = useAppStore(state => state);
@@ -23,6 +24,7 @@ export const Tokens = () => {
2324
const [tokenValue, setTokenValue] = useState(null);
2425
const [description, setDescription] = useState("");
2526
const [newToken, setNewToken] = useState(false);
27+
const [isRegularUser, setIsRegularUser] = useState(null);
2628
const [loading, setLoading] = useState(true);
2729
const [initial, setInitial] = useState(true);
2830
const [confirmation, setConfirmation] = useState({});
@@ -41,7 +43,10 @@ export const Tokens = () => {
4143
}, []);
4244

4345
useEffect(() => {
44-
if (user.superUser || user.institutionAdmin) {
46+
const authority = highestAuthority(user);
47+
const isGuest = authority === AUTHORITIES.GUEST;
48+
if (!isGuest) {
49+
setIsRegularUser(authority === AUTHORITIES.INVITER || authority === AUTHORITIES.MANAGER);
4550
fetchTokens();
4651
} else {
4752
navigate("/404");
@@ -156,7 +161,7 @@ export const Tokens = () => {
156161
header: I18n.t("tokens.createdAt"),
157162
mapper: token => dateFromEpoch(token.createdAt)
158163
},
159-
{
164+
isRegularUser ? null : {
160165
key: "organizationGUID",
161166
header: I18n.t("tokens.organizationGUID"),
162167
mapper: token => token.organizationGUID

client/src/tabs/Tokens.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ div.mod-tokens {
5252
}
5353

5454
td.trash {
55-
text-align: center;
55+
text-align: right;
56+
padding-right: 10px;
5657
}
5758

5859

server/src/main/java/invite/api/APITokenController.java

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import invite.exception.NotFoundException;
55
import invite.exception.UserRestrictionException;
66
import invite.model.APIToken;
7+
import invite.model.Authority;
78
import invite.model.User;
89
import invite.repository.APITokenRepository;
910
import invite.security.UserPermissions;
@@ -21,6 +22,7 @@
2122

2223
import java.util.List;
2324
import java.util.Map;
25+
import java.util.Objects;
2426

2527
import static invite.SwaggerOpenIdConfig.API_TOKENS_SCHEME_NAME;
2628
import static invite.SwaggerOpenIdConfig.OPEN_ID_SCHEME_NAME;
@@ -44,16 +46,18 @@ public APITokenController(APITokenRepository apiTokenRepository) {
4446
@GetMapping("")
4547
public ResponseEntity<List<APIToken>> apiTokensByInstitution(@Parameter(hidden = true) User user) {
4648
LOG.debug(String.format("GET /tokens for user %s", user.getEduPersonPrincipalName()));
47-
UserPermissions.assertInstitutionAdmin(user);
48-
List<APIToken> apiTokens = user.isSuperUser() ? apiTokenRepository.findAll() : apiTokenRepository.findByOrganizationGUID(user.getOrganizationGUID());
49+
UserPermissions.assertAuthority(user, Authority.INVITER);
50+
List<APIToken> apiTokens = user.isSuperUser() ? apiTokenRepository.findAll() :
51+
user.isInstitutionAdmin() ? apiTokenRepository.findByOrganizationGUID(user.getOrganizationGUID()) :
52+
apiTokenRepository.findByOwner(user);
4953
return ResponseEntity.ok(apiTokens);
5054
}
5155

5256
@GetMapping("generate-token")
5357
public ResponseEntity<Map<String, String>> generateToken(@Parameter(hidden = true) User user,
5458
@Parameter(hidden = true) HttpServletRequest request) {
5559
LOG.debug(String.format("GET /tokens/generateToken for user %s", user.getEduPersonPrincipalName()));
56-
UserPermissions.assertInstitutionAdmin(user);
60+
UserPermissions.assertAuthority(user, Authority.INVITER);
5761
String token = HashGenerator.generateToken();
5862
request.getSession().setAttribute(TOKEN_KEY, token);
5963
return ResponseEntity.ok(Map.of("token", token));
@@ -64,28 +68,37 @@ public ResponseEntity<APIToken> create(@Validated @RequestBody APIToken apiToken
6468
@Parameter(hidden = true) User user,
6569
@Parameter(hidden = true) HttpServletRequest request) {
6670
LOG.debug(String.format("POST /tokens/create for user %s", user.getEduPersonPrincipalName()));
67-
UserPermissions.assertInstitutionAdmin(user);
71+
UserPermissions.assertAuthority(user, Authority.INVITER);
6872
String token = (String) request.getSession().getAttribute(TOKEN_KEY);
6973
if (!StringUtils.hasText(token)) {
7074
throw new UserRestrictionException();
7175
}
72-
APIToken apiToken = new APIToken(
73-
user.getOrganizationGUID(),
74-
HashGenerator.hashToken(token),
75-
user.isSuperUser(),
76-
apiTokenRequest.getDescription());
77-
return ResponseEntity.ok(apiTokenRepository.save(apiToken));
76+
APIToken apiToken;
77+
if (user.isSuperUser() || user.isInstitutionAdmin()) {
78+
apiToken = new APIToken(
79+
user.getOrganizationGUID(),
80+
HashGenerator.hashToken(token),
81+
user.isSuperUser(),
82+
apiTokenRequest.getDescription());
83+
} else {
84+
apiToken = new APIToken(HashGenerator.hashToken(token), apiTokenRequest.getDescription(), user);
85+
}
86+
apiToken = apiTokenRepository.save(apiToken);
87+
return ResponseEntity.ok(apiToken);
7888
}
7989

8090
@DeleteMapping("/{id}")
8191
public ResponseEntity<Void> deleteToken(@PathVariable("id") Long id, @Parameter(hidden = true) User user) {
82-
LOG.debug(String.format("DETELE /tokens/deleteToken with id %s for user %s", id.toString(), user.getEduPersonPrincipalName()));
83-
UserPermissions.assertInstitutionAdmin(user);
92+
LOG.debug(String.format("DELETE /tokens/deleteToken with id %s for user %s", id.toString(), user.getEduPersonPrincipalName()));
93+
UserPermissions.assertAuthority(user, Authority.INVITER);
8494
APIToken apiToken = apiTokenRepository.findById(id).orElseThrow(() -> new NotFoundException("API token not found"));
8595
if (apiToken.isSuperUserToken() && !user.isSuperUser()) {
8696
throw new UserRestrictionException();
8797
}
88-
if (!user.isSuperUser() && !apiToken.getOrganizationGUID().equals(user.getOrganizationGUID())) {
98+
if (user.isInstitutionAdmin() && !apiToken.getOrganizationGUID().equals(user.getOrganizationGUID())) {
99+
throw new UserRestrictionException();
100+
}
101+
if (!user.isSuperUser() && !user.isInstitutionAdmin() && !Objects.equals(user.getId(), apiToken.getOwner().getId())) {
89102
throw new UserRestrictionException();
90103
}
91104
apiTokenRepository.delete(apiToken);

server/src/main/java/invite/model/APIToken.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package invite.model;
22

3+
import com.fasterxml.jackson.annotation.JsonIgnore;
34
import com.fasterxml.jackson.annotation.JsonProperty;
45
import jakarta.persistence.*;
56
import jakarta.validation.constraints.NotBlank;
@@ -37,6 +38,11 @@ public class APIToken implements Serializable {
3738
@Column(name = "created_at")
3839
private Instant createdAt;
3940

41+
@ManyToOne(fetch = FetchType.EAGER)
42+
@JoinColumn(name = "owner_id")
43+
private User owner;
44+
45+
4046
public APIToken(String organizationGUID, String hashedValue, boolean superUserToken, String description) {
4147
this.organizationGUID = organizationGUID;
4248
this.hashedValue = hashedValue;
@@ -45,5 +51,10 @@ public APIToken(String organizationGUID, String hashedValue, boolean superUserTo
4551
this.createdAt = Instant.now();
4652
}
4753

48-
54+
public APIToken(String hashedValue, String description, User owner) {
55+
this.hashedValue = hashedValue;
56+
this.description = description;
57+
this.owner = owner;
58+
this.createdAt = Instant.now();
59+
}
4960
}

server/src/main/java/invite/repository/APITokenRepository.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package invite.repository;
22

33
import invite.model.APIToken;
4+
import invite.model.User;
45
import org.springframework.data.jpa.repository.JpaRepository;
56
import org.springframework.stereotype.Repository;
67

@@ -12,7 +13,7 @@ public interface APITokenRepository extends JpaRepository<APIToken, Long> {
1213

1314
List<APIToken> findByOrganizationGUID(String organizationGUID);
1415

15-
List<APIToken> findBySuperUserTokenTrue();
16+
List<APIToken> findByOwner(User user);
1617

1718
Optional<APIToken> findByHashedValue(String hashedValue);
1819
}

server/src/main/java/invite/security/UserHandlerMethodArgumentResolver.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,16 @@ public User resolveArgument(MethodParameter methodParameter,
7272
String organizationGUID = apiToken.getOrganizationGUID();
7373
List<User> apiUsers = apiToken.isSuperUserToken() ?
7474
userRepository.findBySuperUserTrue() :
75-
userRepository.findByOrganizationGUIDAndInstitutionAdmin(organizationGUID, true);
75+
StringUtils.hasText(organizationGUID) ?
76+
userRepository.findByOrganizationGUIDAndInstitutionAdmin(organizationGUID, true) :
77+
List.of(apiToken.getOwner());
7678
if (apiUsers.isEmpty()) {
7779
//we don't want to return null as this is not part of the happy-path
7880
throw new UserRestrictionException();
7981
}
80-
//Does not make any difference security-wise which user we return
81-
User user = apiUsers.get(0);
82+
//For superusers and institution admins it does not make any difference security-wise which user we return
83+
//For inviters and managers the user is linked to the API token
84+
User user = apiUsers.getFirst();
8285
if (StringUtils.hasText(organizationGUID)) {
8386
//The overhead is needed / justified for API usage as this are stateless
8487
addInstitutionAdminAttributes(user, organizationGUID);

0 commit comments

Comments
 (0)