Skip to content

Commit

Permalink
WIP for #312
Browse files Browse the repository at this point in the history
  • Loading branch information
oharsta committed Oct 8, 2024
1 parent 9265076 commit 473f23a
Show file tree
Hide file tree
Showing 16 changed files with 143 additions and 13 deletions.
4 changes: 4 additions & 0 deletions client/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ export function allApplications() {
return fetchJson("/api/v1/manage/applications")
}

export function organizationGUIDValidation(organizationGUID) {
return fetchJson(`/api/v1/manage/organization-guid-validation/${organizationGUID}`, {}, {}, false);
}

//Roles
export function rolesByApplication() {
return fetchJson("/api/v1/roles");
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Tabs.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const Tabs = ({children, className, activeTab, tabChanged}) => {
return (
<>
<div className="tabs-container">
{<div className={`tabs ${className}`}>
{<div className={`tabs ${className || ""}`}>

{filteredChildren.map(child => {
const {label, name, notifier, readOnly} = child.props;
Expand Down
3 changes: 3 additions & 0 deletions client/src/locale/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ const en = {
name: "Name",
namePlaceHolder: "The name of the role",
shortName: "Short name",
organizationGUID: "Organization GUID",
identityProvider: "Identity provider: {{name}}",
landingPage: "(Custom) landing page",
userRoleCount: "# Users",
landingPagePlaceHolder: "https://landingpage.com",
Expand Down Expand Up @@ -392,6 +394,7 @@ const en = {
userRoleIcon: "User role accepted by {{name}} at {{createdAt}}",
invitationIcon: "Invitation for {{email}} sent at {{createdAt}} with expiration date {{expiryDate}}",
roleShortName: "The unique short name of the role within a provisioning. It is used to format the urn and therefore not all characters are allowed.",
organizationGUID: "The Manage organizational identifier to scope the visibility of roles of the institution admin. Only specify a value if you are creating or editing this role on behalf of an institution admin",
roleUrn: "The urn of the role. It is based on the sanitized name and the role identifier. It is used as the unique global identifier of this role and therefore not all characters are allowed.",
manageService: "The required application from SURFconext, with may have an optional provisioning",
defaultExpiryDays: "The default number of days the role will expire, from the moment a user has accepted the invitation for this role",
Expand Down
3 changes: 3 additions & 0 deletions client/src/locale/nl.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ const nl = {
name: "Naam",
namePlaceHolder: "Naam van de rol",
shortName: "Korte naam",
organizationGUID: "Organization GUID",
identityProvider: "Identity provider: {{name}}",
landingPage: "(Aangepaste) landingspagina",
userRoleCount: "# Gebruikers",
landingPagePlaceHolder: "https://landingspagina.nl",
Expand Down Expand Up @@ -391,6 +393,7 @@ const nl = {
userRoleIcon: "Gebruikersrol geaccepteerd door {{name}} op {{createdAt}}",
invitationIcon: "Uitnodiging aan {{email}} verstuurd op {{createdAt}} met verloopdatum {{expiryDate}}",
roleShortName: "Een unieke korte naam voor de rol binnen een provisioning. Wordt gebruikt in de urn, daarom zijn niet alle tekens toegestaan.",
organizationGUID: "The Manage organizational identifier to scope the visibility of roles of the institution admin. Only specify a value if you are creating or editing this role on behalf of an institution admin",
roleUrn: "De urn van deze rol. Deze is gebaseerd op de opgeschoonde naam en de rol-identifier. Hij wordt gebruikt als de unieke globale identifier van deze rol en daarom zijn niet alle tekens toegestaan.",
manageService: "De vereiste applicatie uit SURFconext, die optioneel een provisioning heeft.",
defaultExpiryDays: "Het standaardaantal dagen waarna de rol verloopt, gerekend vanaf het moment dat de gebruiker de uitnodiging voor de rol accepteert.",
Expand Down
42 changes: 41 additions & 1 deletion client/src/pages/RoleForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createRole,
deleteRole,
me,
organizationGUIDValidation,
roleByID,
updateRole,
validate
Expand Down Expand Up @@ -51,6 +52,8 @@ export const RoleForm = () => {
const [alreadyExists, setAlreadyExists] = useState({});
const [confirmation, setConfirmation] = useState({});
const [confirmationOpen, setConfirmationOpen] = useState(false);
const [validOrganizationGUID, setValidOrganizationGUID] = useState(true);
const [organizationGUIDIdentityProvider, setOrganizationGUIDIdentityProvider] = useState(null);
const [customRoleExpiryDate, setCustomRoleExpiryDate] = useState(false);
const [applications, setApplications] = useState([]);
const [allowedToEditApplication, setAllowedToEditApplication] = useState(true);
Expand Down Expand Up @@ -211,10 +214,26 @@ export const RoleForm = () => {
}
};

const validateOrganizationGUID = e => {
const organizationGUID = e.target.value;
if (!isEmpty(organizationGUID)) {
organizationGUIDValidation(organizationGUID)
.then(idp => {
setOrganizationGUIDIdentityProvider(idp);
setValidOrganizationGUID(true);
})
.catch(() => {
setOrganizationGUIDIdentityProvider(null);
setValidOrganizationGUID(false);
})
}
}

const isValid = () => {
return required.every(attr => !isEmpty(role[attr]))
&& Object.values(alreadyExists).every(attr => !attr)
&& !isEmpty(applications)
&& validOrganizationGUID
&& !isEmpty(applications[0])
&& applications.every(app => !app || (!app.invalid && !isEmpty(app.landingPage)))
&& role.defaultExpiryDays > 0;
Expand Down Expand Up @@ -267,13 +286,34 @@ export const RoleForm = () => {
attribute: I18n.t("roles.name").toLowerCase()
})}/>}

{!isNewRole && <InputField
{!isNewRole &&
<InputField
name={I18n.t("roles.urn")}
value={urnFromRole(config.groupUrnPrefix, role)}
disabled={true}
toolTip={I18n.t("tooltips.roleUrn")}
/>}

{user.superUser &&
<InputField
name={I18n.t("roles.organizationGUID")}
value={role.organizationGUID}
onChange={e => {
setRole({...role, organizationGUID: e.target.value});
setValidOrganizationGUID(true);
setOrganizationGUIDIdentityProvider(null);
}}
onBlur={validateOrganizationGUID}
toolTip={I18n.t("tooltips.organizationGUID")}
/>}
{!validOrganizationGUID &&
<ErrorIndicator msg={I18n.t("forms.invalid", {
value: role.organizationGUID,
attribute: I18n.t("roles.organizationGUID").toLowerCase()
})}/>}
{!isEmpty(organizationGUIDIdentityProvider) &&
<p className="info">{I18n.t("roles.identityProvider", {name: organizationGUIDIdentityProvider["name:en"]})}</p>}

<InputField name={I18n.t("roles.description")}
value={role.description || ""}
placeholder={I18n.t("roles.descriptionPlaceHolder")}
Expand Down
5 changes: 5 additions & 0 deletions client/src/pages/RoleForm.scss
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@
}
}

p.info {
grid-column-start: first;
margin-top: 4px;
}

span.label {
grid-column-start: first;
font-weight: 600;
Expand Down
2 changes: 1 addition & 1 deletion client/src/styles/vars.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
$background: #eaebf0;
$br: 6px;
// Screen
$medium: 1024px;
$medium: 1280px;
$tablet-max: 824px;
21 changes: 13 additions & 8 deletions server/src/main/java/access/api/ManageController.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package access.api;

import access.config.Config;
import access.exception.NotFoundException;
import access.manage.EntityType;
import access.manage.Manage;
import access.model.Application;
import access.model.Authority;
import access.model.User;
import access.repository.ApplicationRepository;
import access.repository.RoleRepository;
import access.security.UserPermissions;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
Expand All @@ -18,14 +18,12 @@
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

import static access.SwaggerOpenIdConfig.API_TOKENS_SCHEME_NAME;
Expand All @@ -43,13 +41,11 @@ public class ManageController {
private static final Log LOG = LogFactory.getLog(ManageController.class);

private final Manage manage;
private final RoleRepository roleRepository;
private final ApplicationRepository applicationRepository;

@Autowired
public ManageController(Manage manage, RoleRepository roleRepository, ApplicationRepository applicationRepository) {
public ManageController(Manage manage, ApplicationRepository applicationRepository) {
this.manage = manage;
this.roleRepository = roleRepository;
this.applicationRepository = applicationRepository;
}

Expand All @@ -71,6 +67,15 @@ public ResponseEntity<List<Map<String, Object>>> providers(@Parameter(hidden = t
return ResponseEntity.ok(providers);
}

@GetMapping("organization-guid-validation/{organizationGUID}")
public ResponseEntity<Map<String, Object>> organizationGUIDValidation(@Parameter(hidden = true) User user,
@PathVariable("organizationGUID") String organizationGUID) {
UserPermissions.assertSuperUser(user);
Map<String, Object> identityProvider = manage.identityProviderByInstitutionalGUID(organizationGUID)
.orElseThrow(() -> new NotFoundException("No identity provider with organizationGUID: " + organizationGUID));
return ResponseEntity.ok(identityProvider);
}

@GetMapping("applications")
public ResponseEntity<Map<String, List<Map<String, Object>>>> applications(@Parameter(hidden = true) User user) {
UserPermissions.assertInstitutionAdmin(user);
Expand Down
12 changes: 11 additions & 1 deletion server/src/main/java/access/api/RoleController.java
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ public ResponseEntity<List<Role>> rolesByApplication(@Parameter(hidden = true) U
manageIdentifiers.addAll(roleManageIdentifiers);

List<Role> roles = new ArrayList<>();
//TODO feature toggle see application.yml feature.enforce-institution-admin-role-visibility
//findByOrganizationGUID_ApplicationUsagesApplicationManageId
manageIdentifiers.forEach(manageId -> roles.addAll(roleRepository.findByApplicationUsagesApplicationManageId(manageId)));
return ResponseEntity.ok(manage.addManageMetaData(roles));
}
Expand Down Expand Up @@ -150,13 +152,16 @@ public ResponseEntity<Role> newRole(@Validated @RequestBody Role role,
if (user != null) {
//OpenID connect login with User
UserPermissions.assertAuthority(user, Authority.INSTITUTION_ADMIN);
//For super_users this is NULL, which is ok
role.setOrganizationGUID(user.getOrganizationGUID());
} else {
//API user with Basic Authentication
RemoteUserPermissions.assertScopeAccess(remoteUser, Scope.sp_dashboard);
}

role.setShortName(GroupURN.sanitizeRoleShortName(role.getShortName()));
role.setIdentifier(UUID.randomUUID().toString());

Provisionable provisionable = user != null ? user : remoteUser;

LOG.debug(String.format("New role '%s' by user %s", role.getName(), provisionable.getName()));
Expand Down Expand Up @@ -216,9 +221,14 @@ private ResponseEntity<Role> saveOrUpdate(Role role, User user, RemoteUser remot
boolean nameChanged = false;
if (!isNew) {
Role previousRole = roleRepository.findById(role.getId()).orElseThrow(() -> new NotFoundException("Role not found"));
//We don't allow shortName or identifier changes after creation
//We don't allow shortName, identifier or organizationGUID changes after creation
role.setShortName(previousRole.getShortName());
role.setIdentifier(previousRole.getIdentifier());
if (user != null && user.isSuperUser()) {
role.setOrganizationGUID(role.getOrganizationGUID());
} else {
role.setOrganizationGUID(previousRole.getOrganizationGUID());
}
previousApplicationIdentifiers.addAll(previousRole.applicationIdentifiers());
if (immutableApplicationUsages) {
role.setApplicationUsages(previousRole.getApplicationUsages());
Expand Down
4 changes: 4 additions & 0 deletions server/src/main/java/access/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,10 @@ public boolean updateAttributes(Map<String, Object> attributes) {
changed = changed || !Objects.equals(this.email, newEmail);
this.email = newEmail;

String newSubjectId = (String) attributes.get("subject_id");
changed = changed || !Objects.equals(this.subjectId, newSubjectId);
this.subjectId = newSubjectId;

this.lastActivity = Instant.now();

String currentName = this.name;
Expand Down
2 changes: 2 additions & 0 deletions server/src/main/java/access/repository/RoleRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public interface RoleRepository extends JpaRepository<Role, Long> {

List<Role> findByApplicationUsagesApplicationManageId(String manageId);

List<Role> findByOrganizationGUID_ApplicationUsagesApplicationManageId(String organizationGUID, String manageId);

List<Role> findByName(String name);

}
3 changes: 3 additions & 0 deletions server/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ config:
past-date-allowed: True

Check warning on line 120 in server/src/main/resources/application.yml

View workflow job for this annotation

GitHub Actions / Test documentation and generate openapi html documentation

120:22 [truthy] truthy value should be one of [false, true]
eduid-idp-schac-home-organization: "test.eduid.nl"

feature:
enforce-institution-admin-role-visibility: True

Check warning on line 124 in server/src/main/resources/application.yml

View workflow job for this annotation

GitHub Actions / Test documentation and generate openapi html documentation

124:46 [truthy] truthy value should be one of [false, true]

# We don't encode in-memory passwords, so we need to prefix them with {noop}
external-api-configuration:
remote-users:
Expand Down
4 changes: 4 additions & 0 deletions server/src/test/java/access/AbstractTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -589,9 +589,13 @@ public void doSeed() {
Role calendar =
new Role("Calendar", "Calendar desc",
application("5", EntityType.OIDC10_RP), 365, false, false);
calendar.setOrganizationGUID(ORGANISATION_GUID);

Role mail =
new Role("Mail", "Mail desc",
application("5", EntityType.OIDC10_RP), 365, false, false);
mail.setOrganizationGUID(ORGANISATION_GUID);

doSave(this.roleRepository, wiki, network, storage, research, calendar, mail);

UserRole wikiManager =
Expand Down
40 changes: 40 additions & 0 deletions server/src/test/java/access/api/ManageControllerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,44 @@ void applicationsByInstitutionAdmin() throws Exception {
assertEquals(4, result.get("provisionings").size());
}

@Test
void organizationGUIDValidation() throws Exception {
AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/login", SUPER_SUB);

String postPath = "/manage/api/internal/search/%s";
Map<String, Object> localIdentityProvider = localManage.identityProviderByInstitutionalGUID(ORGANISATION_GUID).get();
stubFor(post(urlPathMatching(String.format(postPath, EntityType.SAML20_IDP.collectionName()))).willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody(objectMapper.writeValueAsString(List.of(localIdentityProvider)))));


Map<String, Object> identityProvider = given()
.when()
.filter(accessCookieFilter.cookieFilter())
.accept(ContentType.JSON)
.header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken())
.contentType(ContentType.JSON)
.pathParam("organizationGUID", ORGANISATION_GUID)
.get("/api/v1/manage/organization-guid-validation/{organizationGUID}")
.as(new TypeRef<>() {
});
assertEquals(ORGANISATION_GUID, identityProvider.get("institutionGuid"));
}

@Test
void organizationGUIDValidationNotAllowed() throws Exception {
stubForManageProvidersAllowedByIdP(ORGANISATION_GUID);
AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/login", INSTITUTION_ADMIN_SUB);

given()
.when()
.filter(accessCookieFilter.cookieFilter())
.accept(ContentType.JSON)
.header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken())
.contentType(ContentType.JSON)
.pathParam("organizationGUID", ORGANISATION_GUID)
.get("/api/v1/manage/organization-guid-validation/{organizationGUID}")
.then()
.statusCode(403);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,11 @@ void search() {
assertEquals(3, roles.size());
}

@Test
void findByOrganizationGUID_ApplicationUsagesApplicationManageId() {
//mysql> select r.id, r.name,r.organization_guid, a.manage_id, a.manage_type from roles r
// inner join application_usages au on au.role_id = r.id
// inner join applications a on a.id = au.application_id;
}

}
2 changes: 1 addition & 1 deletion welcome/src/styles/vars.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
$background: #eaebf0;
$br: 6px;
// Screen
$medium: 1024px;
$medium: 1280px;
$tablet-max: 824px;

0 comments on commit 473f23a

Please sign in to comment.