Skip to content

Commit 5876a59

Browse files
committed
Merge branch 'feature/#606-required-role-organization-guid'
2 parents b3ab7f3 + 93d0ddb commit 5876a59

14 files changed

Lines changed: 219 additions & 32 deletions

client/src/api/index.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,8 +240,9 @@ export function generateToken() {
240240
return fetchJson("/api/v1/tokens/generate-token");
241241
}
242242

243-
export function createToken(description) {
244-
return postPutJson("/api/v1/tokens", {description: description}, "POST");
243+
export function createToken(description, organizationGUID) {
244+
const body = {description: description, organizationGUID: organizationGUID};
245+
return postPutJson("/api/v1/tokens", body, "POST");
245246
}
246247

247248
export function deleteToken(token) {

client/src/tabs/Tokens.jsx

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {Entities} from "../components/Entities";
55
import I18n from "../locale/I18n";
66
import {Button, ButtonType, Checkbox, Loader} from "@surfnet/sds";
77
import {useNavigate} from "react-router-dom";
8-
import {apiTokens, createToken, deleteToken, generateToken} from "../api";
8+
import {allIdentityProviders, apiTokens, createToken, deleteToken, generateToken} from "../api";
99
import {dateFromEpoch} from "../utils/Date";
1010
import TrashIcon from "@surfnet/sds/icons/functional-icons/bin.svg";
1111
import ChevronLeft from "@surfnet/sds/icons/functional-icons/arrow-left-2.svg";
@@ -16,6 +16,7 @@ import ErrorIndicator from "../components/ErrorIndicator";
1616
import {isEmpty, stopEvent} from "../utils/Utils";
1717
import {Page} from "../components/Page";
1818
import {AUTHORITIES, highestAuthority} from "../utils/UserRole";
19+
import SelectField from "../components/SelectField";
1920

2021
export const Tokens = () => {
2122
const {user, setFlash} = useAppStore(state => state);
@@ -31,6 +32,8 @@ export const Tokens = () => {
3132
const [initial, setInitial] = useState(true);
3233
const [confirmation, setConfirmation] = useState({});
3334
const [confirmationOpen, setConfirmationOpen] = useState(false);
35+
const [identityProviders, setIdentityProviders] = useState([]);
36+
const [organizationGUIDIdentityProvider, setOrganizationGUIDIdentityProvider] = useState({});
3437

3538
const isGuest = authority === AUTHORITIES.GUEST;
3639

@@ -43,6 +46,22 @@ export const Tokens = () => {
4346
setDescription("");
4447
setTokenValue(null);
4548
setConfirmationOpen(false);
49+
setInitial(true);
50+
setDescription("");
51+
setOrganizationGUIDIdentityProvider({});
52+
//Now fetch all IdentityProviders for the superuser
53+
if (user.superUser) {
54+
allIdentityProviders().then(idps => {
55+
const identityProviderOptions = idps
56+
.filter(idp => !isEmpty(idp.institutionGuid))
57+
.map(idp => ({
58+
value: idp.id,
59+
label: idp["name:en"],
60+
institutionGuid: idp.institutionGuid
61+
}))
62+
setIdentityProviders(identityProviderOptions);
63+
});
64+
}
4665
});
4766
}, []);
4867

@@ -70,11 +89,11 @@ export const Tokens = () => {
7089
};
7190

7291
const submitNewToken = () => {
73-
if (isEmpty(description)) {
92+
if (isEmpty(description) || (user.superUser && isEmpty(organizationGUIDIdentityProvider))) {
7493
setInitial(false);
7594
} else {
7695
setLoading(true);
77-
createToken(description).then(() => {
96+
createToken(description, organizationGUIDIdentityProvider.institutionGuid).then(() => {
7897
setFlash(I18n.t("tokens.createFlash"));
7998
fetchTokens();
8099
});
@@ -84,18 +103,26 @@ export const Tokens = () => {
84103

85104
const cancelSideScreen = e => {
86105
stopEvent(e);
106+
setInitial(true);
87107
setNewToken(false);
108+
setDescription("");
109+
setOrganizationGUIDIdentityProvider({});
88110
}
89111

90112
const createNewToken = () => {
91113
setLoading(true);
114+
setInitial(true);
92115
generateToken().then(res => {
93116
setNewToken(true);
94117
setTokenValue(res.token);
95118
setLoading(false);
96119
});
97120
}
98121

122+
const changeOrganizationGUIDIdentityProvider = option => {
123+
setOrganizationGUIDIdentityProvider(option);
124+
}
125+
99126
const renderNewToken = () => {
100127
return (
101128
<Page className={"page new-token"}>
@@ -125,12 +152,30 @@ export const Tokens = () => {
125152
{(!initial && isEmpty(description)) && <ErrorIndicator
126153
msg={I18n.t("tokens.required")}/>}
127154

155+
{user.superUser &&
156+
<SelectField name={I18n.t("roles.identityProvider")}
157+
toolTip={I18n.t("tooltips.roleIdentityProvider")}
158+
value={identityProviders.find(idp => idp.value === organizationGUIDIdentityProvider.value)}
159+
placeholder={I18n.t("roles.identityProviderPlaceholder")}
160+
options={identityProviders}
161+
onChange={changeOrganizationGUIDIdentityProvider}
162+
searchable={true}
163+
required={true}
164+
/>}
165+
{(!initial && isEmpty(organizationGUIDIdentityProvider.institutionGuid) && user.superUser) &&
166+
<ErrorIndicator msg={I18n.t("forms.required", {
167+
attribute: I18n.t("roles.organizationGUID").toLowerCase()
168+
})}/>}
169+
{!isEmpty(organizationGUIDIdentityProvider.institutionGuid) &&
170+
<em className="info">{I18n.t("roles.organizationGUIDValue", {guid: organizationGUIDIdentityProvider.institutionGuid})}</em>}
171+
128172
<section className="actions">
129173
<Button type={ButtonType.Secondary}
130174
txt={I18n.t("forms.cancel")}
131175
onClick={() => setNewToken(false)}/>
132176
<Button txt={I18n.t("forms.save")}
133-
disabled={!initial && isEmpty(description)}
177+
disabled={!initial && (isEmpty(description) ||
178+
(user.superUser && isEmpty(organizationGUIDIdentityProvider.institutionGuid)))}
134179
onClick={() => submitNewToken()}/>
135180
</section>
136181
</div>

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public ResponseEntity<List<APIToken>> apiTokensByInstitution(@Parameter(hidden =
4949
LOG.debug(String.format("GET /tokens for user %s", user.getEduPersonPrincipalName()));
5050
UserPermissions.assertAuthority(user, Authority.INVITER);
5151
List<APIToken> apiTokens = user.isSuperUser() ? apiTokenRepository.findAll() :
52-
user.isInstitutionAdmin() ? apiTokenRepository.findByOrganizationGUID(user.getOrganizationGUID()) :
52+
user.isInstitutionAdmin() ? apiTokenRepository.findByOrganizationGUIDAndSuperUserToken(user.getOrganizationGUID(), false) :
5353
apiTokenRepository.findByOwner(user);
5454
return ResponseEntity.ok(apiTokens);
5555
}
@@ -75,10 +75,13 @@ public ResponseEntity<APIToken> create(@Validated @RequestBody APIToken apiToken
7575
if (!StringUtils.hasText(token)) {
7676
throw new UserRestrictionException();
7777
}
78+
if (user.isSuperUser() && !StringUtils.hasText(apiTokenRequest.getOrganizationGUID())) {
79+
throw new UserRestrictionException();
80+
}
7881
APIToken apiToken;
7982
if (user.isSuperUser() || user.isInstitutionAdmin()) {
8083
apiToken = new APIToken(
81-
user.getOrganizationGUID(),
84+
user.isSuperUser() ? apiTokenRequest.getOrganizationGUID() : user.getOrganizationGUID(),
8285
HashGenerator.hashToken(token),
8386
user.isSuperUser(),
8487
apiTokenRequest.getDescription(),

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

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import invite.repository.ApplicationRepository;
1414
import invite.repository.ApplicationUsageRepository;
1515
import invite.repository.RoleRepository;
16-
import invite.security.InstitutionAdmin;
1716
import invite.security.UserPermissions;
1817
import io.swagger.v3.oas.annotations.Parameter;
1918
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -156,15 +155,12 @@ public ResponseEntity<Role> newRole(@Validated @RequestBody RoleRequest roleRequ
156155
@Parameter(hidden = true) User user) {
157156
LOG.debug(String.format("POST /roles/ for user %s", user.getEduPersonPrincipalName()));
158157
UserPermissions.assertAuthority(user, Authority.INSTITUTION_ADMIN);
159-
//For super_users we allow an organization GUID from the input form
160-
Role role = new Role(roleRequest);
161-
if (InstitutionAdmin.isInstitutionAdmin(user)) {
162-
role.setOrganizationGUID(user.getOrganizationGUID());
163-
} else if (user.isSuperUser()) {
164-
role.setOrganizationGUID(roleRequest.getOrganizationGUID());
165-
} else {
166-
role.setOrganizationGUID(null);
158+
//For super_users we require an organization GUID from the input form
159+
if (user.isSuperUser() && !StringUtils.hasText(roleRequest.getOrganizationGUID())) {
160+
throw new UserRestrictionException();
167161
}
162+
Role role = new Role(roleRequest);
163+
role.setOrganizationGUID(user.isSuperUser() ? roleRequest.getOrganizationGUID() : user.getOrganizationGUID());
168164
role.setShortName(GroupURN.sanitizeRoleShortName(roleRequest.getName()));
169165
role.setIdentifier(UUID.randomUUID().toString());
170166
role.setUrn(GroupURN.urnFromRole(this.groupUrnPrefix, role));

server/src/main/java/invite/internal/InternalInviteController.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package invite.internal;
22

33
import invite.api.*;
4+
import invite.exception.InvalidInputException;
45
import invite.exception.NotFoundException;
6+
import invite.exception.UserRestrictionException;
57
import invite.logging.AccessLogger;
68
import invite.logging.Event;
79
import invite.mail.MailBox;
@@ -31,6 +33,7 @@
3133
import org.springframework.security.access.prepost.PreAuthorize;
3234
import org.springframework.security.core.annotation.AuthenticationPrincipal;
3335
import org.springframework.transaction.annotation.Transactional;
36+
import org.springframework.util.StringUtils;
3437
import org.springframework.validation.annotation.Validated;
3538
import org.springframework.web.bind.annotation.*;
3639

@@ -225,6 +228,14 @@ public ResponseEntity<Role> role(@PathVariable("id") Long id,
225228
)})})})
226229
public ResponseEntity<Role> newRole(@Validated @RequestBody RoleRequest roleRequest,
227230
@Parameter(hidden = true) @AuthenticationPrincipal RemoteUser remoteUser) {
231+
if (!StringUtils.hasText(roleRequest.getOrganizationGUID())) {
232+
roleRequest.setOrganizationGUID(remoteUser.getOrganizationGUIDFallback());
233+
}
234+
List<Map<String, Object>> providers = manage.identityProvidersByInstitutionalGUID(roleRequest.getOrganizationGUID());
235+
if (providers.isEmpty()) {
236+
throw new InvalidInputException("There is no identityProvider with InstitutionalGUID: "+remoteUser.getOrganizationGUIDFallback());
237+
}
238+
228239
Role role = new Role(roleRequest);
229240
role.setRemoteApiUser(remoteUser.getName());
230241

@@ -246,7 +257,8 @@ public ResponseEntity<Role> updateRole(@Validated @RequestBody Role role,
246257
@Parameter(hidden = true) @AuthenticationPrincipal RemoteUser remoteUser) {
247258
LOG.debug(String.format("Update role '%s' by user %s", role.getName(), remoteUser.getName()));
248259

249-
return ResponseEntity.status(HttpStatus.CREATED).body(saveOrUpdate(role, remoteUser));
260+
Role updatedRole = saveOrUpdate(role, remoteUser);
261+
return ResponseEntity.status(HttpStatus.CREATED).body(updatedRole);
250262
}
251263

252264
@DeleteMapping("/roles/{id}")

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
@Repository
1313
public interface APITokenRepository extends JpaRepository<APIToken, Long> {
1414

15-
List<APIToken> findByOrganizationGUID(String organizationGUID);
15+
List<APIToken> findByOrganizationGUIDAndSuperUserToken(String organizationGUID, boolean superUserToken);
1616

1717
List<APIToken> findByOwner(User user);
1818

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public class RemoteUser implements UserDetails, CredentialsContainer, Provisiona
2626
private String username;
2727
private String password;
2828
private String displayName;
29+
private String organizationGUIDFallback;
2930
private List<Scope> scopes = new ArrayList<>();
3031
private List<Application> applications = new ArrayList<>();
3132
private boolean localDevMode;
@@ -34,6 +35,7 @@ public RemoteUser(RemoteUser remoteUser) {
3435
this.username = remoteUser.username;
3536
this.password = remoteUser.password;
3637
this.displayName = remoteUser.displayName;
38+
this.organizationGUIDFallback = remoteUser.organizationGUIDFallback;
3739
this.scopes = remoteUser.scopes;
3840
this.applications = remoteUser.applications;
3941
this.localDevMode = remoteUser.localDevMode;

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,11 @@ public User resolveArgument(MethodParameter methodParameter,
7676
if (owner != null) {
7777
apiUsers.add(apiToken.getOwner());
7878
} else {
79-
//backward compatibility for tokens without an owner
80-
if (apiToken.isSuperUserToken()) {
81-
apiUsers.addAll(userRepository.findBySuperUserTrue() );
82-
} else if (StringUtils.hasText(organizationGUID)) {
79+
//backward compatibility for tokens without an owner or superusers tokens without organizationGUID
80+
if (StringUtils.hasText(organizationGUID)) {
8381
apiUsers.addAll(userRepository.findByOrganizationGUIDAndInstitutionAdmin(organizationGUID, true));
82+
} else if (apiToken.isSuperUserToken()) {
83+
apiUsers.addAll(userRepository.findBySuperUserTrue());
8484
}
8585
}
8686
if (apiUsers.isEmpty()) {

server/src/main/resources/application.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ external-api-configuration:
158158
- username: sp_dashboard
159159
displayName: "SP Dashboard"
160160
password: "secret"
161+
organizationGUIDFallback: "ad93daef-0911-e511-80d0-005056956c1a"
161162
scopes:
162163
- sp_dashboard
163164
applications:
@@ -166,6 +167,7 @@ external-api-configuration:
166167
- username: access
167168
displayName: "Access"
168169
password: "secret"
170+
organizationGUIDFallback: "5ede9c9b-3bbc-ea11-90fe-0050569571ea"
169171
scopes:
170172
- access
171173
applications:
@@ -174,6 +176,7 @@ external-api-configuration:
174176
- username: sp_dashboard_local_dev_mode
175177
displayName: "SP Dashboard"
176178
password: "secret"
179+
organizationGUIDFallback: "ad93daef-0911-e511-80d0-005056956c1a"
177180
scopes:
178181
- sp_dashboard
179182
applications:

server/src/test/java/invite/api/APITokenControllerTest.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import io.restassured.common.mapper.TypeRef;
99
import io.restassured.http.ContentType;
1010
import org.junit.jupiter.api.Test;
11+
import org.springframework.http.HttpStatus;
1112

1213
import java.util.List;
1314
import java.util.Map;
@@ -88,6 +89,54 @@ void create() throws Exception {
8889
assertEquals(HashGenerator.hashToken(token), apiTokenFromDB.getHashedValue());
8990
}
9091

92+
@Test
93+
void createSuperUserToken() throws Exception {
94+
AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/me", SUPER_SUB);
95+
//First get the value, otherwise the creation will fail
96+
given()
97+
.when()
98+
.filter(accessCookieFilter.cookieFilter())
99+
.accept(ContentType.JSON)
100+
.header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken())
101+
.contentType(ContentType.JSON)
102+
.get("/api/v1/tokens/generate-token");
103+
104+
APIToken apiToken = given()
105+
.when()
106+
.filter(accessCookieFilter.cookieFilter())
107+
.accept(ContentType.JSON)
108+
.header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken())
109+
.contentType(ContentType.JSON)
110+
.body(Map.of("description", "test", "organizationGUID", "super-users-rule"))
111+
.post("/api/v1/tokens")
112+
.as(new TypeRef<>() {
113+
});
114+
assertEquals("super-users-rule", apiToken.getOrganizationGUID());
115+
}
116+
117+
@Test
118+
void createSuperUserTokenWithoutOrganizationGUID() throws Exception {
119+
AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/me", SUPER_SUB);
120+
//First get the value, otherwise the creation will fail
121+
given()
122+
.when()
123+
.filter(accessCookieFilter.cookieFilter())
124+
.accept(ContentType.JSON)
125+
.contentType(ContentType.JSON)
126+
.get("/api/v1/tokens/generate-token");
127+
128+
given()
129+
.when()
130+
.filter(accessCookieFilter.cookieFilter())
131+
.accept(ContentType.JSON)
132+
.header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken())
133+
.contentType(ContentType.JSON)
134+
.body(Map.of("description", "test", "organizationGUID", ""))
135+
.post("/api/v1/tokens")
136+
.then()
137+
.statusCode(HttpStatus.FORBIDDEN.value());
138+
}
139+
91140
@Test
92141
void createWithFaultyToken() throws Exception {
93142
super.stubForManageProvidersAllowedByIdP(ORGANISATION_GUID);

0 commit comments

Comments
 (0)