diff --git a/client/src/components/welcome/ServiceNl.jsx b/client/src/components/welcome/ServiceNl.jsx
index 19864671b..2f0ae68ca 100644
--- a/client/src/components/welcome/ServiceNl.jsx
+++ b/client/src/components/welcome/ServiceNl.jsx
@@ -1,20 +1,24 @@
import React from "react";
import "./welcome.scss";
+import {ROLES} from "../../utils/UserRole";
-export default function ServiceNl() {
+export default function ServiceNl(role) {
- const responsibilities = [
+ const responsibilities = role === ROLES.SERVICE_ADMIN ? [
"De configuratie van de dienst bekijken en beheren",
"De acceptable use policy en privacy policy van de dienst beheren",
- "Zien welke organisaties en samenwerkingen deze dienst kunnen gebruiken",
+ "Beheer welke organisaties en samenwerkingen deze dienst kunnen gebruiken",
"De LDAP-informatie bekijken en het bind password instellen",
"Dienstbeheerders van deze dienst beheren"
+ ] : [
+ "Koppelverzoeken beheren",
+ "Samenwerkingen ontkoppelen"
]
return (
-
Als dienstbeheerder kun je het volgende doen:
+
Als {role === ROLES.SERVICE_ADMIN ? "dienstbeheerder" : "dienstmanager"} kun je het volgende doen:
{responsibilities.map((r, i) => - {r}
)}
diff --git a/client/src/forms/constants.js b/client/src/forms/constants.js
index a4045db5d..69cf07fa3 100644
--- a/client/src/forms/constants.js
+++ b/client/src/forms/constants.js
@@ -1,3 +1,3 @@
-export const collaborationAccessTypes = ["open", "closed", "on_acceptance"];
export const collaborationRoles = ["member", "admin"];
export const organisationRoles = ["manager", "admin"];
+export const serviceRoles = ["manager", "admin"];
diff --git a/client/src/locale/en.js b/client/src/locale/en.js
index 0e35fcb05..f31cbca9f 100644
--- a/client/src/locale/en.js
+++ b/client/src/locale/en.js
@@ -216,6 +216,7 @@ const en = {
noResults: "No users were found...",
noResultsInvitations: "No users or invitations were found...",
activity: "User history",
+ loading: "Please wait, loading all history....",
showSsh: "Show",
ssh: {
title: "Public SSH keys of {{name}}"
@@ -318,7 +319,7 @@ const en = {
add: "Are you sure you want to make {{service}} available for all members of {{name}}?",
remove: "Are you sure you want to remove {{service}} from {{name}}?",
check: "I know about the
AUP of {{name}}",
- noPolicy: "This service did not provide a privacy policy!"
+ noPolicy: "This service did not provide a privacy policy"
},
statuses: {
active: "Enabled by organisation",
@@ -570,7 +571,7 @@ const en = {
},
serviceConnectionRequests: {
backToServiceConnectionRequests: "Back to all connection requests",
- title: "Outstanding connection requests",
+ title: "Connection requests",
searchPlaceHolder: "Search for connection requests...",
noEntities: "No service connection requests were found",
edit: "Approve / decline",
@@ -596,9 +597,9 @@ const en = {
},
serviceAdmins: {
searchPlaceHolder: "Search for service admins..",
- noEntities: "There are no service admins",
+ noEntities: "There are no service admins or managers",
new: "Invite admins",
- title: "Service admins"
+ title: "Service admins & managers"
},
userTokens: {
backToUserTokens: "Back to all service tokens",
@@ -690,11 +691,11 @@ const en = {
explanation: "Please fill in your motivation for joining collaboration
{{name}}.",
title: "Request membership of {{name}}",
alreadyMember: "You are already a member of {{name}}",
- motivation: "Your motivation",
- motivationPlaceholder: "Describe your motivation to join in order for an admin to grant you access.",
+ motivation: "Your reason",
+ motivationPlaceholder: "Describe your reason to join in order for an admin to grant you access.",
policyConfirmation: "{{collaboration}} has an AUP which you can read (more about) at
here.",
feedback: {
- info: "Your request has been sent to the collaboration manager of
{{name}} who will review your application. Their decision will be communicated to you by email",
+ info: "Your request has been sent to the collaboration managers of
{{name}} who will review your application. Their decision will be communicated to you by email.",
},
},
profile: {
@@ -1053,6 +1054,12 @@ const en = {
tokenUpdated: "Token of {{name}} was updated",
tokenDeleted: "Token of {{name}} was deleted",
},
+ scim_token: {
+ preTitle: "For security reasons, the current SCIM token can not be displayed. ",
+ title: "Change the SCIM token.",
+ confirmation: "Please enter the SCIM token for {{name}}. It will be stored encrypted.",
+ success: "SCIM token has been updated.",
+ },
ldap: {
preTitle: "For security reasons, the current password can not be displayed. ",
title: "Reset the LDAP password.",
@@ -1190,6 +1197,8 @@ const en = {
collaborationCreationAllowedTooltip: "Allows users from your organisation to create a collaboration without requiring approval from an organisation admin or manager",
serviceConnectionRequiresApproval: "Service connection request must be approved by the Organisation admin",
serviceConnectionRequiresApprovalTooltip: "If checked then service connection requests must first be approved by an Organisation admin",
+ accepted_user_policyPlaceholder: "The Acceptable Use Policy (AUP) of the organisation",
+ accepted_user_policyTooltip: "Users outside your organisation must accept this AUP before joining one of this organisation's collaboration for the first time.",
collaborationCreationLabel: "Users from these domains",
collaborationCreationIsAllowed: "Can create collaborations",
collaborationCreationNotAllowed: "Can request collaborations",
@@ -1338,8 +1347,6 @@ const en = {
title: "Join request from {{requester}} for collaboration {{collaboration}}",
message: "Motivation",
messageTooltip: "The motivation from {{name}} for this join request",
- reference: "Reference",
- referenceTooltip: "The references {{name}} has within collaboration {{collaboration}}",
collaborationName: "Collaboration",
userName: "User",
decline: "Decline",
@@ -1349,6 +1356,10 @@ const en = {
rejectionReason: "Please enter the reason to decline this join request",
rejectionReasonLabel: "Reason for declination",
rejectionReasonNote: "Note that the reason is included in the email to the requester",
+ why: "Why do you want to join this collaboration?",
+ invited: "I was invited to apply",
+ projectMember: "I am a member of this project",
+ other: "Other, please explain",
flash: {
declined: "Join request for collaboration {{name}} was denied.",
accepted: "Join request for collaboration {{name}} was accepted.",
@@ -1430,6 +1441,8 @@ const en = {
groupsTooltip: "Select the groups the invitees become member of.",
groups: "Group membership",
requiredEmail: "At least one email address is required for an invitation for a collaboration.",
+ existingInvitation: "There is already an outstanding invitation for: {{emails}}",
+ existingInvitations: "There are already outstanding invitations for: {{emails}}",
requiredRole: "You must choose the intended role for the collaboration membership.",
requiredExpiryDate: "The expiry date for an invitation is required",
message: "Message",
@@ -1650,7 +1663,7 @@ const en = {
service: {
title: "Review the information in order to continue",
info: "You are about to log in to
{{name}}. Before you can continue you must review the acceptable use policy (AUP) and privacy policy of the service. Confirm below whether you accept them.",
- noPrivacyPolicy: "No privacy policy provided!",
+ noPrivacyPolicy: "No privacy policy provided",
noAup: "No AUP provided",
agreeWithTerms: "I hereby certify that I have read these terms and that I accept them",
firstLogin: "Proceed to log in and afterwards you'll return here to view the policies of {{name}}",
@@ -1670,6 +1683,11 @@ const en = {
multipleInfo: "Services used by this collaboration require that you agree to their acceptable use policies.",
singleCheck: "I agree to the service's acceptable use policy",
multipleCheck: "I agree to the services' acceptable use policies"
+ },
+ organisation: {
+ title: "Organisation AUP",
+ info: "You are not a member of organisation
{{name}}. Before you can continue you must review the AUP of this organisation. Confirm below whether you accept them.",
+ check: "I agree to the organisation acceptable use policy"
}
},
collaborationRequest: {
@@ -1712,9 +1730,11 @@ const en = {
retract: "Retract",
approveConfirmation: "Are you sure you want to approve this request?",
declineConfirmation: "Are you sure you want to decline this request?",
+ deleteConfirmation: "Are you sure you want to delete this request?",
flash: {
declined: "Service Connect request for service {{name}} was declined",
accepted: "Service Connect request for service {{name}} was accepted",
+ deleted: "Service Connect request for service {{name}} was deleted",
notFound: "This request has already been accepted / declined."
}
},
@@ -1735,10 +1755,12 @@ const en = {
actions: {
"1": "New",
"2": "Updated",
- "3": "Deleted"
+ "3": "Deleted",
+ approved: "Approved",
+ denied: "Denied"
},
overview: "{{action}} {{collection}}{{name}}",
- none: "No history",
+ none: "No history to display",
key: "Attribute",
oldValue: "Old value",
newValue: "New value",
@@ -1755,7 +1777,8 @@ const en = {
searchPlaceholder: "Search... ",
includeServices: "Show services",
includeMembers: "Show members",
- includeCOProperties: "Show properties",
+ includeConnections: "Show connections",
+ includeProperties: "Show properties",
tables: {
"api_keys": "Organisation API tokens",
"aups": "Acceptable User Policy",
@@ -1769,6 +1792,7 @@ const en = {
"join_requests": "Join request",
"organisation_invitations": "Organisation invitation",
"organisation_memberships": "Organisation membership",
+ "organisation_aups": "Organisation AUP",
"organisations": "Organisation",
"organisations_services": "Organisation service",
"service_connection_requests": "Service connection request",
@@ -1874,6 +1898,7 @@ const en = {
runExpiredMemberships: "Run the job to expire memberships that have an expiration date before today",
runSuspendedCollaborations: "Run the job to suspend collaborations that have had no activity for X days",
runOrphanUsers: "Run the job to delete orphan users (e.g. no membership, join request and collaboration requests)",
+ runInvitationReminders: "Run the job to send reminder mails to all invitations that expire in X days",
runDailyJobs: "Trigger",
showDailyJobs: "Show",
showDailyJobsInfo: "Show all the scheduled Cron jobs",
@@ -1882,6 +1907,7 @@ const en = {
jobName: "Name",
jobNextRun: "Next scheduled run-time",
runOutdatedRequestsInfo: "Run the cron job to report all open outdated join requests and new collaboration requests for the configured threshold",
+ runOpenRequestsInfo: "Run the weekly cron job to report all open requests for CO, Org and Service admins",
runOutdatedRequests: "Run",
runCleanedRequestsInfo: "Run the cron job to delete all outdated approved / denied join requests and collaboration requests for the configured threshold",
runCleanedRequests: "Delete",
@@ -1963,6 +1989,22 @@ const en = {
date: "Date",
user: "User",
action: "Action"
+ },
+ invitationReminders: {
+ invitations: "Invitations resent",
+ serviceInvitations: "Service invitations resent",
+ organisationInvitations: "Organisation invitations resent",
+ },
+ openRequests: {
+ recipient: "Recipient",
+ service_requests: "Service requests",
+ service_connection_requests: "Service connection requests",
+ join_requests: "Join requets",
+ collaboration_requests: "Collaboration requests",
+ collaboration_name: "CO name",
+ requester: "Requester",
+ organisation_name: "ORG name",
+ service_name: "Service name",
}
},
access: {
@@ -1976,6 +2018,7 @@ const en = {
coAdmin: "admin",
coMember: "member",
serviceAdmin: "service admin",
+ serviceManager: "service manager",
user: "user"
},
actionRoles: {
@@ -1985,13 +2028,14 @@ const en = {
coMember: "Member",
platformAdmin: "Platform admin",
user: "User",
- serviceAdmin: "Service admin"
+ serviceAdmin: "Service admin",
+ serviceManager: "Service manager"
},
welcomeDialog: {
title: "Welcome to {{name}}",
hi: "Hi,",
label: "",
- roleServiceAdmin: "You are invited to become a service
admin",
+ roleServiceAdmin: "You are invited to become a service
{{role}}",
roleOrganisationAdmin: "You are invited to become an organisation
admin",
roleOrganisationManager: "You are invited to become an organisation
manager",
roleCollaborationAdmin: "You are invited to become
admin of this collaboration",
@@ -2025,7 +2069,7 @@ const en = {
},
feedback: {
title: "Provide Feedback",
- info: "Like what you see? Have a suggestion? Let us know what you think here!",
+ info: "Like what you see? Have a suggestion? Let us know what you think here",
disclaimer: "We will use this information to fix problems, improve our products and help you. " +
"We may follow up with you regarding your feedback. " +
"Please make sure the feedback does not contain any confidential, sensitive, or personal information. " +
@@ -2068,7 +2112,7 @@ const en = {
manager: "Manager",
platformAdmin: "Goddess divinity",
oneAdminWarning: "An organisation requires at least one admin.",
- serviceGroupConnectedNotDeletable: "Connected service group may not be deleted"
+ serviceGroupConnectedNotDeletable: "It is not allowed to delete a Service group for a service that is currently connected; disconnect the service first."
},
notFound: {
invitationNotFound: "This invitation does not exist (anymore). The invitation has probably already been used. Ask the person who invited you to send you a new one.",
@@ -2143,6 +2187,8 @@ const en = {
info4: "They will send you a
reset token",
info5: "With that token you can reconfigure your two factor authentication",
select: "Select who to ask for a reset token:",
+ organisationNamePlatformAdmin: "",
+ displayNamePlatformAdmin: "SRAM support",
respondent: "Your request will be sent to:",
message: "Message to the admin(s)",
sendMail: "Request a reset token",
@@ -2193,6 +2239,8 @@ const en = {
expiryDate: "Expiration date",
expiryDateTooltip: "The expiration date of the membership. After this date the membership will be suspended and this member can no longer use the services",
update: "Update",
+ alreadyMember: "You are already a member of this CO and do not need to accept a new invitation.",
+ alreadyMemberHeader: "Already member",
status: {
name: "Your membership",
active: "Active",
@@ -2205,20 +2253,23 @@ const en = {
}
},
serviceDetail: {
- deleteMemberConfirmation: "Are you sure you want to delete the selected service admins and invitations?",
+ deleteMemberConfirmation: "Are you sure you want to delete the selected service members and invitations?",
deleteYourselfMemberConfirmation: "Are you sure you want to leave this service? You will have to be re-invited by an admin to rejoin.",
resendInvitations: "Are you sure you want to resend all selected invitation?",
+ downgradeYourselfMemberConfirmation: "Are you sure you don't want to be an admin anymore? You won't be able to revert this.",
flash: {
- entitiesDeleted: "Admins and invitations have been deleted",
+ entitiesDeleted: "Members and invitations have been deleted",
invitesResend: "Invitations for service {{name}} were resent.",
+ memberUpdated: "The role of membership of {{name}} was updated to {{role}}",
},
gone: {
member: "This membership does no longer exists. After closing this popup the memberships will be refreshed.",
invitation: "This invitation has already been accepted / rejected and does no longer exists. After closing this popup the invitations will be refreshed."
},
admin: "Service admin",
+ manager: "Service manager",
intendedRole: "Role in the service",
- intendedRoleTooltip: "The only role within a service is admin"
+ intendedRoleTooltip: "The service manager role is limited to managing collaboration connections"
},
scim: {
scimEnabled: "SCIM server provisioning",
@@ -2414,11 +2465,13 @@ const en = {
types: {
joinRequest: "Collaboration join request",
collaborationRequest: "New collaboration request",
- serviceRequest: "Service registration"
+ serviceRequest: "Service registration",
+ serviceConnectionRequest: "Service connection request"
},
name: "Name",
description: "Description",
- organisationName: "Organisation / institution"
+ organisationName: "Organisation / institution",
+ notApplicable: "N/A"
},
units: {
column: "Units",
@@ -2453,8 +2506,8 @@ const en = {
],
neverBeenBeforeTitle: "Have you never been here before?",
neverBeenBefore: [
- "Learn what
SRAM is all about.",
- "If someone pointed you to
{{serviceName}}, reach out to them on how to gain access via a research collaboration, or reach out to your institution."
+ "Learn what
SRAM is all about.",
+ "If someone pointed you to
{{serviceName}}, reach out to them on how to gain access via a research collaboration, or reach out to your institution."
],
ticketInfoTitle: "Session information"
},
@@ -2468,4 +2521,4 @@ const en = {
}
};
-export default en;
\ No newline at end of file
+export default en;
diff --git a/client/src/locale/nl.js b/client/src/locale/nl.js
index 9a6493c7c..4dcf3a21c 100644
--- a/client/src/locale/nl.js
+++ b/client/src/locale/nl.js
@@ -215,7 +215,8 @@ const nl = {
moreResults: "Er zijn nog meer zoekresultaten, verfijn je zoekterm.",
noResults: "Geen gebruikers gevonden...",
noResultsInvitations: "Geen gebruikers of uitnodigingen gevonden...",
- activity: "User-historie",
+ activity: "Gebruikersgeschiedenis",
+ loading: "Geduld, alle historie wordt geladen....",
showSsh: "Toon",
ssh: {
title: "Publieke SSH-sleutels van {{name}}"
@@ -318,7 +319,7 @@ const nl = {
add: "Weet je zeker dat je {{service}} beschikbaar wil maken voor alle leden van {{name}}?",
remove: "Weet je zeker dat je {{service}} wil ontkoppelen van {{name}}?",
check: "Ik ben bekend met de
AUP van {{name}}",
- noPolicy: "Deze dienst heeft geen privacy policy opgegeven!"
+ noPolicy: "Deze dienst heeft geen privacy policy opgegeven"
},
statuses: {
active: "Ingeschakeld door organisatie",
@@ -570,7 +571,7 @@ const nl = {
},
serviceConnectionRequests: {
backToServiceConnectionRequests: "Terug naar alle koppelverzoeken",
- title: "Openstaande koppelverzoeken",
+ title: "Koppelverzoeken",
searchPlaceHolder: "Zoek koppelverzoeken...",
noEntities: "Geen koppelverzoeken gevonden",
edit: "Goed- / afkeuren",
@@ -596,9 +597,9 @@ const nl = {
},
serviceAdmins: {
searchPlaceHolder: "Zoek naar dienstbeheerders..",
- noEntities: "Er zijn geen dienstbeheerders",
+ noEntities: "Er zijn geen dienstbeheerders of dienstmanagers",
new: "Nodig beheerders uit",
- title: "Dienstbeheerders"
+ title: "Dienstbeheerders & dienstmanagers"
},
userTokens: {
backToUserTokens: "Terug naar alle diensttokens",
@@ -690,11 +691,11 @@ const nl = {
explanation: "Schrijf je motivatie om lid te worden van samenwerking
{{name}}.",
title: "Verzoek tot lidmaatschap van {{name}}",
alreadyMember: "Je bent reeds lid van {{name}}",
- motivation: "Motivatie om lid te worden van {{name}}?",
- motivationPlaceholder: "Omschrijf je motivatie om lid te worden zodat een beheerder je verzoek kan honoreren.",
+ motivation: "Je reden om lid te worden van {{name}}?",
+ motivationPlaceholder: "Omschrijf de reden om lid te worden zodat een beheerder je verzoek kan honoreren.",
policyConfirmation: "{{collaboration}} heeft een AUP waar je
hier meer over kan lezen.",
feedback: {
- info: "Je verzoek is verzonden naar de beheerder van
{{name}} die je aanvraag zal beoordelen. De beslissing wordt je per e-mail meegedeeld",
+ info: "Je verzoek is verzonden naar de beheerders van
{{name}} die je aanvraag zullen beoordelen. De beslissing wordt je per e-mail meegedeeld.",
},
},
profile: {
@@ -771,8 +772,8 @@ const nl = {
shortNamePlaceHolder: "Korte naam van de samenwerking",
shortNameTooltip: "Ken korte namen toe aan de samenwerkingen zodat die namen bruikbaar zijn in de via ldap te koppelen diensten (zoals Linux groepsnamen).
" +
"Alleen getallen, alfanumerieke karakers en de lage streep zijn toegstaan.",
- globalUrn: "Globale urn",
- globalUrnTooltip: "Binnen het platform unieke en onaanpasbare urn, gebaseerd op de korte naam van de organisatie en de samenwerking.",
+ globalUrn: "Platform identifier",
+ globalUrnTooltip: "Binnen het platform unieke en onaanpasbare identifier, gebaseerd op de korte naam van de organisatie en de samenwerking.",
identifier: "Identifier",
identifierTooltip: "Gegenereerde, unieke en niet aanpasbare identifier van een samenwerking die wordt gebruikt als identifier voor externe systemen",
joinRequestUrlTooltip: "URL voor niet-leden om zich aan te melden voor deze samenwerking. De URL kunt je bijvoorbeeld e-mailen of publiceren op een website.",
@@ -987,7 +988,7 @@ const nl = {
networkSyntaxError: "Dit is geen geldig IPv4- of IPv6-adres",
networkReservedError: "Dit is een gereserveerd IPv{{version}}-adres",
networkNotGlobal: "Alleen globale unicast-adressen kunnen worden ingevoerd",
- networkInfo: "Laagste IP: {lower}, hoogste IP: {higher}, # adressen: {num_addresses}, versie: IPv{version}",
+ networkInfo: "Laagste IP: {{lower}}, hoogste IP: {{higher}}, # adressen: {{num_addresses}}, versie: IPv{{version}}",
automaticConnectionAllowed: "Samenwerkingen mogen koppelen zonder jouw toestemming",
automaticConnectionAllowedTooltip: "Indien ingeschakeld mag een samenwerking deze dienst koppelen zonder toestemming van de diensteigenaar (jou). Er wordt dan geen koppelverzoek ter goedkeuring voorgelegd.",
automaticConnectionAllowedOrganisations: "Vertrouwde / je eigen organisaties",
@@ -1053,6 +1054,12 @@ const nl = {
tokenUpdated: "Token van dienst {{name}} is bijgewerkt",
tokenDeleted: "Token van dienst {{name}} is verwijderd",
},
+ scim_token: {
+ preTitle: "Om veiligheidsredenen kan het huidige SCIM token niet worden weergegeven. ",
+ title: "Verander het SCIM token.",
+ confirmation: "Voer het SCIM token in voor {{name}}. Het zal encrypted worden opgeslagen.",
+ success: "SCIM token is veranderd.",
+ },
ldap: {
preTitle: "Om veiligheidsredenen kan het huidige wachtwoord niet worden weergegeven. ",
title: "Reset LDAP-wachtwoord.",
@@ -1190,6 +1197,8 @@ const nl = {
collaborationCreationAllowedTooltip: "Sta toe dat gebruikers van de organisatie samenwerkingen aanmaken zonder goedkeuring van de organisatiebeheerder of -manager",
serviceConnectionRequiresApproval: "Verzoek voor dienst koppeling moet worden goedgekeurd door de organisatie beheerder",
serviceConnectionRequiresApprovalTooltip: "Indien geselecteerd, dan moet een verzoek voor een dienst koppeling eerst worden goedgekeurd door de organisatie beheerder",
+ accepted_user_policyPlaceholder: "De acceptable use policy (AUP) van de organisatie.",
+ accepted_user_policyTooltip: "Gebruikers van buiten de organsatie moeten deze AUP accepteren wanneer ze voor het eerste lid worden van een samenwerking van deze organisatie.",
collaborationCreationLabel: "Gebruikers van deze domeinen",
collaborationCreationIsAllowed: "Kunnen samenwerkingen aanmaken",
collaborationCreationNotAllowed: "Kunnen samenwerkingen aanvragen",
@@ -1236,7 +1245,6 @@ const nl = {
tooltip: "Dit bericht wordt getoond aan gebruikers van de organisatie wanneer ze een samenewerking aanvragen of aanmaken",
template: "Hoi!,\n\n" +
"Je kunt een samenwerking **aanmaken/aanvragen**. Omschrijf waarom je deze samenwerking wilt gaan gebruiken. We kunnen contact met je opnemen over je aanvraag.",
-
tabs: {
write: "Markdown",
preview: "Voorbeeld"
@@ -1288,7 +1296,7 @@ const nl = {
name: "Naam",
description: "Omschrijving",
short_name: "Korte naam",
- global_urn: "Globale urn",
+ global_urn: "Platform identifier",
accepted_user_policy: "AUP",
created_at: "Sinds",
actions: "",
@@ -1339,8 +1347,6 @@ const nl = {
title: "Verzoek van {{requester}} om lid te worden van samenwerking {{collaboration}}",
message: "Onderbouwing",
messageTooltip: "De onderbouwing van {{name}} voor dit verzoek",
- reference: "Bekende",
- referenceTooltip: "{{name}} kent de volgende mensen binnen samenwerking {{collaboration}}",
collaborationName: "Samenwerking",
userName: "Gebruiker",
decline: "Afwijzen",
@@ -1350,6 +1356,10 @@ const nl = {
rejectionReason: "Voeg de reden voor de afwijzing toe",
rejectionReasonLabel: "Reden voor afwijzing",
rejectionReasonNote: "Let op dat de reden wordt opgenomen in de e-mail naar de aanvrager",
+ why: "Waarom wil je lid worden van deze samenwerking?",
+ invited: "Ik ben uitgenodigd om lid te worden",
+ projectMember: "Ik ben lid van dit project",
+ other: "Anders, gaarne uitleggen",
flash: {
declined: "Verzoek voor lidmaatschap van samenwerking {{name}} is afgewezen.",
accepted: "Verzoek voor lidmaatschap van samenwerking {{name}} is goedgekeurd.",
@@ -1431,6 +1441,8 @@ const nl = {
groupsTooltip: "De groepen waar alle genodigden lid van worden.",
groups: "Groepslidmaatschap",
requiredEmail: "Je dient minimaal één e-mailadres op te geven waar je de uitnodiging om lid te worden naartoe wil sturen.",
+ existingInvitation: "Er is al een bestaande uitnodigingen voor: {{emails}}",
+ existingInvitations: "Er zijn al bestaande uitnodigingen voor: {{emails}}",
requiredRole: "Je moet een rol kiezen voor het uit te nodigen lid.",
requiredExpiryDate: "De geldigheidsdatum van de uitnodiging is verplicht",
message: "Bericht",
@@ -1558,8 +1570,8 @@ const nl = {
collaboration: "Samenwerking",
autoProvisionMembers: "Maak leden van de samenwerking automatisch lid",
autoProvisionMembersTooltip: "Vink aan om automatisch alle bestaande leden en nieuwe leden toe te voegen aan deze groep",
- global_urn: "Globale urn",
- globalUrnTooltip: "Binnen het platform unieke en onaanpasbare urn, gebaseerd op de korte naam van de organisatie, de samenwerking en de groep.",
+ global_urn: "Platform identifier",
+ globalUrnTooltip: "Binnen het platform unieke en onaanpasbare identifier, gebaseerd op de korte naam van de organisatie, de samenwerking en de groep.",
alreadyExists: "Een groep met {{attribute}} {{value}} bestaat al.",
required: "{{attribute}} is een verplicht veld voor een groep",
uri: "URI",
@@ -1651,7 +1663,7 @@ const nl = {
service: {
title: "Bekijk de informatie om verder te gaan",
info: "Je staat op het punt om in te loggen op
{{name}}. Voordat je verder kunt gaan, moet je het beleid voor acceptabel gebruik (AUP) en het privacyverklaring van de service lezen. Bevestig hieronder of je ze accepteert.",
- noPrivacyPolicy: "Geen privacyverklaring verstrekt!",
+ noPrivacyPolicy: "Geen privacyverklaring verstrekt",
noAup: "Geen AUP verstrekt",
agreeWithTerms: "Ik verklaar dat ik deze voorwaarden heb gelezen en accepteer",
firstLogin: "Inloggen. Daarna kom je hier terug om de voorwaarden van {{name}} te bekijken.",
@@ -1671,6 +1683,11 @@ const nl = {
multipleInfo: "Diensten gebruikt binnen deze samenwerking vereisen dat je akkoord gaat met de acceptable use policies.",
singleCheck: "Ik ga akkoord met de hierboven genoemde acceptable use policy",
multipleCheck: "Ik ga akkoord met de hierboven genoemde acceptable use policies"
+ },
+ organisation: {
+ title: "Organisatie AUP",
+ info: "Je bent geen lid van de organisatie
{{name}}. Voordat je verder kunt gaan, moet je het beleid voor acceptabel gebruik (AUP) van de organisatie lezen. Bevestig hieronder of je ze accepteert.",
+ check: "Ik ga akkoord met de hierboven genoemde acceptable use policies"
}
},
collaborationRequest: {
@@ -1713,9 +1730,11 @@ const nl = {
retract: "Intrekken",
approveConfirmation: "Weet je zeker dat je dit verzoek wil goedkeuren?",
declineConfirmation: "Weet je zeker dat je dit verzoek wil afwijzen?",
+ deleteConfirmation: "Weet je zeker dat je dit verzoek wil verwijderen?",
flash: {
declined: "Dienstkoppelverzoek voor {{name}} is afgewezen",
accepted: "Dienstkoppelverzoek voor {{name}} is geaccepteerd",
+ deleted: "Dienstkoppelverzoek voor {{name}} is verwijderd",
notFound: "Dit verzoek is reeds geaccepteerd/afgewezen."
}
},
@@ -1731,15 +1750,17 @@ const nl = {
resultsLimited: "Meer resultaten dan we kunnen tonen; pas de zoekopdracht aan."
},
history: {
- changes: "Historie",
+ changes: "Geschiedenis",
detail: "Detail",
actions: {
"1": "Nieuwe",
"2": "Gewijzigd",
- "3": "Verwijderd"
+ "3": "Verwijderd",
+ approved: "Goedgekeurd",
+ denied: "Afgewezen"
},
overview: "{{action}} {{collection}}{{name}}",
- none: "Geen historie",
+ none: "Geen geschiedenis weer te geven",
key: "Attribuut",
oldValue: "Oude waarde",
newValue: "Nieuwe waarde",
@@ -1756,7 +1777,8 @@ const nl = {
searchPlaceholder: "Zoek...",
includeServices: "Toon diensten",
includeMembers: "Toon leden",
- includeCOProperties: "Toon eigenschappen",
+ includeConnections: "Show connections",
+ includeProperties: "Toon eigenschappen",
tables: {
"api_keys": "Organisatie-API-tokens",
"aups": "Acceptable User Policy",
@@ -1770,6 +1792,7 @@ const nl = {
"join_requests": "Lidmaatschapsverzoek",
"organisation_invitations": "Organisatieuitnodiging",
"organisation_memberships": "Organisatielidmaatschap",
+ "organisation_aups": "Organisatie AUP",
"organisations": "Organisatie",
"organisations_services": "Dienst",
"service_connection_requests": "Koppelverzoek",
@@ -1845,7 +1868,7 @@ const nl = {
invitation_form: "Uitnodigingsdetails",
invitation_preview: "Uitnodigingspreview",
form: "Details",
- history: "Historie"
+ history: "Geschiedenis"
},
error_dialog: {
title: "Onverwachte fout",
@@ -1875,6 +1898,7 @@ const nl = {
runExpiredMemberships: "Run the job to expire memberships that have an expiration date before today",
runSuspendedCollaborations: "Run the job to suspend collaborations that have had no activity for X days",
runOrphanUsers: "Run the job to delete orphan users (e.g. no membership, join request and collaboration requests)",
+ runInvitationReminders: "Run the job to send reminder mails to all invitations that expire in X days",
runDailyJobs: "Trigger",
showDailyJobs: "Show",
showDailyJobsInfo: "Toon alle ingeplande Cron jobs",
@@ -1883,6 +1907,7 @@ const nl = {
jobName: "Naam",
jobNextRun: "Geplande run-time",
runOutdatedRequestsInfo: "Voer de cron-taak uit om alle verouderde open join-verzoeken en nieuwe samenwerkingsverzoeken voor de geconfigureerde drempel te rapporteren",
+ runOpenRequestsInfo: "Run the weekly cron job to report all open requests for CO, Org and Service admins",
runOutdatedRequests: "Run",
runCleanedRequestsInfo: "Voer de cron-taak uit om alle verouderde goedgekeurde / geweigerde aanmeldingsverzoeken en samenwerkingsverzoeken voor de geconfigureerde drempel te verwijderen",
runCleanedRequests: "Verwijder",
@@ -1964,6 +1989,22 @@ const nl = {
date: "Date",
user: "User",
action: "Action"
+ },
+ invitationReminders: {
+ invitations: "CO invitations resend",
+ serviceInvitations: "Service invitations resend",
+ organisationInvitations: "ORG invitations resend",
+ },
+ openRequests: {
+ recipient: "Recipient",
+ service_requests: "Service requests",
+ service_connection_requests: "Service connection requests",
+ join_requests: "Join requets",
+ collaboration_requests: "Collaboration requests",
+ collaboration_name: "CO name",
+ requester: "Requester",
+ organisation_name: "ORG name",
+ service_name: "Service name",
}
},
access: {
@@ -1977,6 +2018,7 @@ const nl = {
coAdmin: "beheerder",
coMember: "lid",
serviceAdmin: "dienstbeheerder",
+ serviceManager: "dienstmanager",
user: "gebruiker"
},
actionRoles: {
@@ -1986,7 +2028,8 @@ const nl = {
coMember: "Lid",
platformAdmin: "Platformbeheerder",
user: "Gebruiker",
- serviceAdmin: "Dienstbeheerder"
+ serviceAdmin: "Dienstbeheerder",
+ serviceManager: "Dienstmanager"
},
welcomeDialog: {
title: "Welkom bij {{name}}",
@@ -2004,7 +2047,7 @@ const nl = {
toggleRole: "Wissel van rol",
infoMember: "Houd er rekening mee dat wanneer je lid wordt van deze samenwerking, je persoonlijke gegevens gedeeld kunnen worden met de volgende diensten. Neem even de tijd om de door de dienst opgegeven beleidsdocumenten door te nemen.",
infoAdmin: "Houd er rekening mee dat wanneer je beheerder wordt van deze samenwerking, je persoonlijke gegevens gedeeld kunnen worden met de volgende diensten. Neem even de tijd om de door de dienst opgegeven beleidsdocumenten door te nemen.",
- infoJoinRequest: "Bijna zover! Voordat je kunt verzoeken lid te worden van deze samenwerking moet je de acceptable use policy (AUP) en het privacyverklaring van de dienst lezen. Geef hieronder aan of je deze accepteert.",
+ infoJoinRequest: " Voordat je kunt verzoeken lid te worden van deze samenwerking moet je de acceptable use policy (AUP) en het privacyverklaring van de dienst lezen. Geef hieronder aan of je deze accepteert.",
purpose: "Doel van deze samenwerking",
noServices: "Nog geen diensten gekoppeld.",
proceed: "Ga door naar {{name}}"
@@ -2026,7 +2069,7 @@ const nl = {
},
feedback: {
title: "Feedback geven",
- info: "Loop je ergens tegenaan? Heb je een suggestie? Laat ons hier weten wat je ervan vindt!",
+ info: "Loop je ergens tegenaan? Heb je een suggestie? Laat ons hier weten wat je ervan vindt",
disclaimer: "We zullen deze informatie gebruiken om problemen op te lossen, ons product te verbeteren en je te helpen. " +
"We kunnen contact met je opnemen over je feedback. " +
"Zorg dat je feedback geen vertrouwelijke, gevoelige of persoonlijke informatie bevat. " +
@@ -2069,7 +2112,7 @@ const nl = {
manager: "Manager",
platformAdmin: "Goddess divinity",
oneAdminWarning: "Een organisatie heeft ten minste 1 admin nodig.",
- serviceGroupConnectedNotDeletable: "Gekoppelde dienst gropp mag niet worden verwijderd"
+ serviceGroupConnectedNotDeletable: "Dienstgroep van een gekoppelde dienst mag niet worden verwijderd; ontkoppel de dienst eerst."
},
notFound: {
invitationNotFound: "Deze uitnodiging bestaat niet (meer). Waarschijnlijk is de uitnodiging al eerder gebruikt. Vraag aan de uitnodiger of je een nieuwe uitnodiging kunt krijgen.",
@@ -2144,6 +2187,8 @@ const nl = {
info4: "Ze zullen je een
reset token sturen",
info5: "Met dat token kan je je tweefactorauthenticatie opnieuw instellen",
select: "Selecteer aan wie de reset te vragen:",
+ organisationNamePlatformAdmin: "",
+ displayNamePlatformAdmin: "SRAM support",
respondent: "Je verzoek wordt verzonden naar:",
message: "Bericht voor de beheerder(s)",
sendMail: "Vraag reset aan",
@@ -2194,6 +2239,8 @@ const nl = {
expiryDate: "Einddatum",
expiryDateTooltip: "De einddatum van dit lidmaatschap. Hierna verloopt het lidmaatschap en kan de gebruiker geen gebruikmaken van de diensten van deze samenwerking.",
update: "Opslaan",
+ alreadyMember: "je bent al een lid van deze samenwerking en je hoeft geen nieuwe uitnodiging te accepteren.",
+ alreadyMemberHeader: "Reeds lid",
status: {
name: "Je lidmaatschap",
active: "Actief",
@@ -2206,20 +2253,23 @@ const nl = {
}
},
serviceDetail: {
- deleteMemberConfirmation: "Weet je zeker dat je alle geselecteerde beheerders en uitnodigingen wil verwijderen?",
+ deleteMemberConfirmation: "Weet je zeker dat je alle geselecteerde leden en uitnodigingen wil verwijderen?",
deleteYourselfMemberConfirmation: "Weet je zeker dat je deze dienst wil verlaten? Je kan dit niet terugdraaien.",
resendInvitations: "Weet je zeker dat je alle geselecteerde uitnodigingen opnieuw wil versturen?",
+ downgradeYourselfMemberConfirmation: "Weet je zeker dat je geen beheerder meer wil zijn? Je kan dit niet terugdraaien.",
flash: {
- entitiesDeleted: "Beheerders en uitnodigingen zijn verwijderd",
+ entitiesDeleted: "Leden en uitnodigingen zijn verwijderd",
invitesResend: "Uitnodigingen voor organisatie {{name}} zijn opnieuw verzonden.",
+ memberUpdated: "De rol of lidmaatschap van {{name}} is bijgewerkt naar {{role}}.",
},
gone: {
member: "Dit lidmaatschap bestaat niet meer. Na het sluiten van deze pop-up worden de lidmaatschappen vernieuwd.",
invitation: "Deze uitnodiging is al geaccepteerd/afgewezen en bestaat niet meer. Na het sluiten van deze pop-up worden de uitnodigingen ververst."
},
admin: "Dienstbeheerder",
+ manager: "Dienstmanager",
intendedRole: "Rol binnen de dienst",
- intendedRoleTooltip: "De enige rol binnen een dienst is beheerder"
+ intendedRoleTooltip: "De dienstmanager rol is gelimiteerd tot het beheren van koppelingen met samenwerkingen"
},
scim: {
scimEnabled: "SCIM server provisioning",
@@ -2415,11 +2465,13 @@ const nl = {
types: {
joinRequest: "Lidmaatschapsverzoek",
collaborationRequest: "Nieuw samenwerkingsverzoek",
- serviceRequest: "Dienstregistratie"
+ serviceRequest: "Dienstregistratie",
+ serviceConnectionRequest: "Dienstkoppelverzoek"
},
name: "Naam",
description: "Omschrijving",
- organisationName: "Organisatie / instelling"
+ organisationName: "Organisatie / instelling",
+ notApplicable: "N/A"
},
units: {
column: "Units",
@@ -2454,10 +2506,10 @@ const nl = {
],
neverBeenBeforeTitle: "Ben je hier nog nooit geweest?",
neverBeenBefore: [
- "Lees wat je met
SRAM allemaal kan doen.",
- "Als iemand je hebt gewezen op
{{serviceName}}, neem dan contact met hen op om toegang te krijgen tot de juiste samenwerking, of neem contact op met je instelling."
+ "Lees wat je met
SRAM allemaal kan doen.",
+ "Als iemand je heeft gewezen op
{{serviceName}}, neem dan contact met hen op om toegang te krijgen tot de juiste samenwerking, of neem contact op met je instelling."
],
- ticketInfoTitle: "Sessie informatie"
+ ticketInfoTitle: "Sessieinformatie"
},
collaborationsOverview: {
welcome: "Welkom {{name}}",
@@ -2469,4 +2521,4 @@ const nl = {
}
};
-export default nl;
\ No newline at end of file
+export default nl;
diff --git a/client/src/pages/App.jsx b/client/src/pages/App.jsx
index fd45114dd..a37980b0e 100644
--- a/client/src/pages/App.jsx
+++ b/client/src/pages/App.jsx
@@ -143,6 +143,16 @@ class App extends React.Component {
}
}));
}).catch(() => this.handleBackendDown());
+ this.aprilFools();
+ }
+
+ aprilFools = () => {
+ const date = new Date();
+ if (date.getMonth() === 3 && date.getDate() === 1) {
+ const styleTag = document.createElement("style");
+ document.head.appendChild(styleTag);
+ styleTag.sheet.insertRule("body, h1, h2, h3, h4, h5, ::-webkit-input-placeholder, .sds--branding--textual { font-family: 'Comic Sans', 'Comic Sans MS', 'Chalkboard SE', 'Comic Neue', cursive, Courier !important; }", 0);
+ }
}
componentWillUnmount() {
diff --git a/client/src/pages/CollaborationDetail.jsx b/client/src/pages/CollaborationDetail.jsx
index c7c1f4899..d8bb69a1b 100644
--- a/client/src/pages/CollaborationDetail.jsx
+++ b/client/src/pages/CollaborationDetail.jsx
@@ -7,7 +7,7 @@ import {
collaborationIdByIdentifier,
collaborationLiteById,
createCollaborationMembershipRole,
- deleteCollaborationMembership,
+ deleteCollaborationMembership, deleteInvitationByHash,
health,
invitationAccept,
invitationByHash,
@@ -16,7 +16,6 @@ import {
} from "../api";
import "./CollaborationDetail.scss";
import I18n from "../locale/I18n";
-import {collaborationRoles} from "../forms/constants";
import {AppStore} from "../stores/AppStore";
import UnitHeader from "../components/redesign/UnitHeader";
import Tabs from "../components/Tabs";
@@ -56,12 +55,10 @@ class CollaborationDetail extends React.Component {
constructor(props, context) {
super(props, context);
- this.roleOptions = collaborationRoles.map(role => ({
- value: role, label: I18n.t(`profile.${role}`)
- }));
this.state = {
invitation: null,
serviceEmails: {},
+ adminEmails: [],
collaboration: null,
schacHomeOrganisation: null,
userTokens: null,
@@ -102,12 +99,16 @@ class CollaborationDetail extends React.Component {
const params = this.props.match.params;
if (params.hash) {
invitationByHash(params.hash, true).then(res => {
+ const {user} = this.props;
const invitation = res["invitation"];
+ const membership = (user.collaboration_memberships || []).find(m => m.collaboration_id === invitation.collaboration_id);
const serviceEmails = res["service_emails"];
+ const adminEmails = res["admin_emails"];
this.setState({
invitation: invitation,
collaboration: invitation.collaboration,
serviceEmails: serviceEmails,
+ adminEmails: adminEmails,
loading: false,
firstTime: true,
adminOfCollaboration: false,
@@ -115,7 +116,8 @@ class CollaborationDetail extends React.Component {
confirmationDialogOpen: false,
tab: "about",
isInvitation: true,
- tabs: [this.getAboutTab(invitation.collaboration, true, false)]
+ tabs: [this.getAboutTab(invitation.collaboration, true, false)],
+ alreadyCollaborationMembership: !isEmpty(membership)
});
}).catch(() => this.props.history.push(`/404?eo=${ErrorOrigins.invitationNotFound}`));
} else if (params.id) {
@@ -370,7 +372,7 @@ class CollaborationDetail extends React.Component {
this.getJoinRequestsTab(collaboration),] :
[this.getAboutTab(collaboration, showMemberView, isJoinRequest),
this.getMembersTab(collaboration, showMemberView, isJoinRequest),
- this.getGroupsTab(collaboration, showMemberView, isJoinRequest),];
+ this.getGroupsTab(collaboration, showMemberView, isJoinRequest)];
this.addUserTokenTab(userTokens, services, isJoinRequest, tabs, collaboration);
return tabs.filter(tab => tab !== null);
@@ -398,6 +400,9 @@ class CollaborationDetail extends React.Component {
}
getMembersTab = (collaboration, showMemberView, isJoinRequest = false) => {
+ if (isJoinRequest) {
+ return null;
+ }
const expiredInvitations = (collaboration.invitations || []).some(inv => isInvitationExpired(inv));
return (
{
+ if (isJoinRequest) {
+ return null;
+ }
return (
{
const usedServices = removeDuplicates(collaboration.services.concat(collaboration.organisation.services), "id");
- const openServiceConnectionRequests = (collaboration.service_connection_requests || []).length;
+ const openServiceConnectionRequests = (collaboration.service_connection_requests || [])
+ .filter(r => r.status === "open")
+ .length;
return (
}
@@ -560,7 +570,7 @@ class CollaborationDetail extends React.Component {
}
getMemberIconListItem = collaboration => {
- const memberCount = collaboration.collaboration_memberships.length;
+ const memberCount = collaboration.collaboration_memberships_count;
const groupCount = collaboration.groups.length;
return (
{
@@ -722,6 +732,14 @@ class CollaborationDetail extends React.Component {
);
}
+ alreadyMemberConfirmation = invitation => {
+ this.setState({loading: true})
+ deleteInvitationByHash(invitation.hash).then(() => {
+ const path = encodeURIComponent(`/collaborations/${invitation.collaboration_id}`);
+ this.props.history.push(`/refresh-route/${path}`);
+ });
+ }
+
doAcceptInvitation = () => {
const {invitation, isInvitation} = this.state;
if (isInvitation) {
@@ -750,7 +768,7 @@ class CollaborationDetail extends React.Component {
}
getMembershipStatus = (collaboration, user) => {
- if (!user || !collaboration) {
+ if (!user || !collaboration || isEmpty(collaboration.collaboration_memberships)) {
return null;
}
const membership = collaboration.collaboration_memberships.find(cm => cm.user_id === user.id);
@@ -787,16 +805,18 @@ class CollaborationDetail extends React.Component {
Icon:
, value: collaborationStatus
})
}
- return (
- {this.getIconListItems(iconListItems)}
- );
+ return (
+
+ {this.getIconListItems(iconListItems)}
+
+ );
}
render() {
@@ -819,7 +839,9 @@ class CollaborationDetail extends React.Component {
isWarning,
isInvitation,
invitation,
- serviceEmails
+ serviceEmails,
+ adminEmails,
+ alreadyCollaborationMembership
} = this.state;
if (loading) {
return
;
@@ -832,19 +854,29 @@ class CollaborationDetail extends React.Component {
} else {
role = adminOfCollaboration ? ROLES.COLL_ADMIN : ROLES.COLL_MEMBER;
}
+
return (<>
{(adminOfCollaboration && showMemberView) && this.getUnitHeader(user, config, collaboration, allowedToEdit, showMemberView, adminOfCollaboration)}
{(!showMemberView || !adminOfCollaboration) && this.getUnitHeaderForMemberNew(user, config, collaboration, allowedToEdit, showMemberView, collaborationJoinRequest, alreadyMember, adminOfCollaboration)}
- {!collaborationJoinRequest &&
}
-
+ {(!collaborationJoinRequest && !alreadyCollaborationMembership) &&
+ }
+ {alreadyCollaborationMembership &&
+ this.alreadyMemberConfirmation(invitation)}
+ confirmationHeader={I18n.t("organisationMembership.alreadyMemberHeader")}
+ confirmationTxt={I18n.t("confirmationDialog.ok")}
+ question={I18n.t("organisationMembership.alreadyMember")}/>
+ }
refreshUser(callback)}
@@ -867,7 +899,6 @@ class CollaborationDetail extends React.Component {
>)
}
-
}
export default CollaborationDetail;
\ No newline at end of file
diff --git a/client/src/pages/Home.jsx b/client/src/pages/Home.jsx
index 94155d559..27bba3d58 100644
--- a/client/src/pages/Home.jsx
+++ b/client/src/pages/Home.jsx
@@ -52,11 +52,7 @@ class Home extends React.Component {
const nbrOrganisations = user.organisation_memberships.length;
const nbrCollaborations = user.collaboration_memberships.length;
const nbrServices = user.service_memberships.length;
- const canStayInHome = !isEmpty(user.service_requests) ||
- !isEmpty(user.collaboration_requests) ||
- !isEmpty(user.join_requests) ||
- nbrServices > 0 ||
- (user.organisation_from_user_schac_home && redirect);
+ const canStayInHome = nbrServices > 0 || (user.organisation_from_user_schac_home && redirect);
let hasAnyRoles = true;
switch (role) {
case ROLES.PLATFORM_ADMIN:
@@ -97,23 +93,31 @@ class Home extends React.Component {
default:
hasAnyRoles = false;
}
+ const collaborationTabPresent = tabs.some(t => t.key === "collaborations")
if (isUserServiceAdmin(user) && !user.admin) {
- if (!isEmpty(user.organisation_from_user_schac_home) && !tabs.some(t => t.key === "collaborations")) {
- tabs.push(this.getEmptyCollaborationsTab())
+ if (nbrCollaborations > 0 && !collaborationTabPresent) {
+ tabs.push(this.getCollaborationsTab());
+ } else if (!isEmpty(user.organisation_from_user_schac_home) && !collaborationTabPresent) {
+ tabs.push(this.getEmptyCollaborationsTab());
}
if (nbrServices === 1 && tabs.length === 0 && !redirect) {
setTimeout(() => this.props.history.push(`/services/${user.service_memberships[0].service_id}`), 50);
return;
} else {
tabs.push(this.getServicesTab(nbrServices));
- tab = tabs[0].key;
+ if (nbrServices > 0 && nbrCollaborations === 0) {
+ tab = "services";
+ } else {
+ tab = tabs[0].key;
+ }
+
}
}
const tabSuggestion = this.addRequestsTabs(user, this.refreshUserHook, tabs, tab);
if (role === ROLES.USER) {
tab = tabSuggestion;
}
- if (isEmpty(tabs) && !hasAnyRoles) {
+ if (tabs.length === 1 && tab === "my_requests" && !hasAnyRoles) {
this.props.history.push("/welcome");
return;
}
diff --git a/client/src/pages/NewInvitation.jsx b/client/src/pages/NewInvitation.jsx
index c0b49a879..58791d2af 100644
--- a/client/src/pages/NewInvitation.jsx
+++ b/client/src/pages/NewInvitation.jsx
@@ -3,11 +3,11 @@ import moment from "moment";
import "react-datepicker/dist/react-datepicker.css";
-import {collaborationById, collaborationInvitations, collaborationInvitationsPreview} from "../api";
+import {collaborationById, collaborationInvitations, collaborationInvitationsPreview, invitationExists} from "../api";
import I18n from "../locale/I18n";
import InputField from "../components/InputField";
import Button from "../components/Button";
-import {isEmpty, stopEvent} from "../utils/Utils";
+import {isEmpty, splitListSemantically, stopEvent} from "../utils/Utils";
import ConfirmationDialog from "../components/ConfirmationDialog";
import {setFlash} from "../utils/Flash";
import {validEmailRegExp} from "../validations/regExps";
@@ -29,8 +29,7 @@ class NewInvitation extends React.Component {
constructor(props, context) {
super(props, context);
this.intendedRolesOptions = collaborationRoles.map(role => ({
- value: role,
- label: I18n.t(`collaboration.${role}`)
+ value: role, label: I18n.t(`collaboration.${role}`)
}));
const email = getParameterByName("email", window.location.search);
const administrators = !isEmpty(email) && validEmailRegExp.test(email.trim()) ? [email.trim()] : [];
@@ -51,13 +50,14 @@ class NewInvitation extends React.Component {
initial: true,
confirmationDialogOpen: false,
confirmationDialogAction: () => this.setState({confirmationDialogOpen: false}),
- cancelDialogAction: () => this.setState({confirmationDialogOpen: false},
- () => this.props.history.push(`/collaborations/${this.props.match.params.collaboration_id}`)),
+ cancelDialogAction: () => this.setState({confirmationDialogOpen: false}, () => this.props.history.push(`/collaborations/${this.props.match.params.collaboration_id}`)),
leavePage: true,
htmlPreview: "",
activeTab: "invitation_form",
loading: true,
- isAdminView: false
+ isAdminView: false,
+ existingInvitations: [],
+ validating: false
};
}
@@ -85,25 +85,16 @@ class NewInvitation extends React.Component {
updateAppStore = (collaboration, user) => {
const orgManager = isUserAllowed(ROLES.ORG_MANAGER, user, collaboration.organisation_id, null);
AppStore.update(s => {
- s.breadcrumb.paths = orgManager ? [
- {path: "/", value: I18n.t("breadcrumb.home")},
- {
- path: `/organisations/${collaboration.organisation_id}`,
- value: I18n.t("breadcrumb.organisation", {name: collaboration.organisation.name})
- },
- {
- path: `/collaborations/${collaboration.id}`,
- value: I18n.t("breadcrumb.collaboration", {name: collaboration.name})
- },
- {value: I18n.t("breadcrumb.invite")}
- ] : [
- {path: "/", value: I18n.t("breadcrumb.home")},
- {
- path: `/collaborations/${collaboration.id}`,
- value: I18n.t("breadcrumb.collaboration", {name: collaboration.name})
- },
- {value: I18n.t("breadcrumb.invite")}
- ];
+ s.breadcrumb.paths = orgManager ? [{path: "/", value: I18n.t("breadcrumb.home")}, {
+ path: `/organisations/${collaboration.organisation_id}`,
+ value: I18n.t("breadcrumb.organisation", {name: collaboration.organisation.name})
+ }, {
+ path: `/collaborations/${collaboration.id}`,
+ value: I18n.t("breadcrumb.collaboration", {name: collaboration.name})
+ }, {value: I18n.t("breadcrumb.invite")}] : [{path: "/", value: I18n.t("breadcrumb.home")}, {
+ path: `/collaborations/${collaboration.id}`,
+ value: I18n.t("breadcrumb.collaboration", {name: collaboration.name})
+ }, {value: I18n.t("breadcrumb.invite")}];
});
}
@@ -112,14 +103,22 @@ class NewInvitation extends React.Component {
};
isValid = () => {
- const {administrators, fileEmails, intended_role, expiry_date} = this.state;
- return (!isEmpty(administrators) || !isEmpty(fileEmails)) && !isEmpty(intended_role) && !isEmpty(expiry_date);
+ const {administrators, fileEmails, intended_role, expiry_date, existingInvitations, validating} = this.state;
+ return (!isEmpty(administrators) || !isEmpty(fileEmails)) && !isEmpty(intended_role) && !isEmpty(expiry_date)
+ && isEmpty(existingInvitations) && !validating;
};
doSubmit = () => {
const {
- administrators, message, collaboration, expiry_date, fileEmails, intended_role,
- selectedGroup, membership_expiry_date, isAdminView
+ administrators,
+ message,
+ collaboration,
+ expiry_date,
+ fileEmails,
+ intended_role,
+ selectedGroup,
+ membership_expiry_date,
+ isAdminView
} = this.state;
if (this.isValid()) {
this.setState({loading: true});
@@ -153,12 +152,23 @@ class NewInvitation extends React.Component {
stopEvent(e);
const {administrators} = this.state;
const newAdministrators = administrators.filter(currentMail => currentMail !== email);
+ this.validateDuplicates(newAdministrators);
this.setState({administrators: newAdministrators});
};
+ validateDuplicates(newAdministrators) {
+ const collaborationId = this.props.match.params.collaboration_id;
+ this.setState({validating: true});
+ invitationExists(newAdministrators, collaborationId)
+ .then(existingInvitations => this.setState({
+ existingInvitations: existingInvitations, initial: isEmpty(existingInvitations), validating: false
+ }))
+ }
+
addEmails = emails => {
const {administrators} = this.state;
const uniqueEmails = [...new Set(administrators.concat(emails))];
+ this.validateDuplicates(uniqueEmails);
this.setState({administrators: uniqueEmails});
};
@@ -199,12 +209,10 @@ class NewInvitation extends React.Component {
}
};
- preview = disabledSubmit => (
-
-
- {this.renderActions(disabledSubmit)}
-
- );
+ preview = disabledSubmit => (
+
+ {this.renderActions(disabledSubmit)}
+
);
selectedGroupsChanged = selectedOptions => {
if (selectedOptions === null) {
@@ -215,18 +223,22 @@ class NewInvitation extends React.Component {
}
}
- invitationForm = (fileInputKey, fileName, fileTypeError, fileEmails, initial, administrators,
- intended_role, message, expiry_date, disabledSubmit, groups, selectedGroup, membership_expiry_date) =>
+ invitationForm = (fileInputKey, fileName, fileTypeError, fileEmails, initial, administrators, intended_role, message, expiry_date, disabledSubmit, groups, selectedGroup, membership_expiry_date, existingInvitations) =>
+ error={!initial && isEmpty(administrators) && isEmpty(fileEmails)}
+ autoFocus={true}/>
{(!initial && isEmpty(administrators) && isEmpty(fileEmails)) &&
}
+ {!isEmpty(existingInvitations) && }
+
option.value === intended_role)}
options={this.intendedRolesOptions}
name={I18n.t("invitation.intendedRole")}
@@ -267,19 +279,16 @@ class NewInvitation extends React.Component {
maxDate={moment().add(31, "day").toDate()}
name={I18n.t("invitation.expiryDate")}
toolTip={I18n.t("invitation.expiryDateTooltip")}/>
- {(!initial && isEmpty(expiry_date)) &&
- }
+ {(!initial && isEmpty(expiry_date)) && }
{this.renderActions(disabledSubmit)}
;
- renderActions = disabledSubmit => (
-
- );
+ renderActions = disabledSubmit => ();
render() {
const {
@@ -300,29 +309,27 @@ class NewInvitation extends React.Component {
fileEmails,
groups,
selectedGroup,
- loading
+ loading,
+ existingInvitations
} = this.state;
if (loading) {
return
}
const disabledSubmit = (!initial && !this.isValid());
- return (
- <>
-
-
-
-
{I18n.t("tabs.invitation_form")}
-
- {this.invitationForm(fileInputKey, fileName, fileTypeError, fileEmails, initial,
- administrators, intended_role, message, expiry_date, disabledSubmit, groups,
- selectedGroup, membership_expiry_date)}
-
+ return (<>
+
+
+
+
{I18n.t("tabs.invitation_form")}
+
+ {this.invitationForm(fileInputKey, fileName, fileTypeError, fileEmails, initial, administrators, intended_role, message, expiry_date, disabledSubmit, groups, selectedGroup, membership_expiry_date, existingInvitations)}
- >);
+
+ >);
}
}
diff --git a/client/src/pages/NewOrganisationInvitation.jsx b/client/src/pages/NewOrganisationInvitation.jsx
index daec8f011..ffc2ad975 100644
--- a/client/src/pages/NewOrganisationInvitation.jsx
+++ b/client/src/pages/NewOrganisationInvitation.jsx
@@ -3,11 +3,16 @@ import moment from "moment";
import "react-datepicker/dist/react-datepicker.css";
-import {organisationById, organisationInvitations, organisationInvitationsPreview} from "../api";
+import {
+ organisationById,
+ organisationInvitationExists,
+ organisationInvitations,
+ organisationInvitationsPreview
+} from "../api";
import I18n from "../locale/I18n";
import InputField from "../components/InputField";
import Button from "../components/Button";
-import {isEmpty, stopEvent} from "../utils/Utils";
+import {isEmpty, splitListSemantically, stopEvent} from "../utils/Utils";
import ConfirmationDialog from "../components/ConfirmationDialog";
import {setFlash} from "../utils/Flash";
import {validEmailRegExp} from "../validations/regExps";
@@ -52,7 +57,9 @@ class NewOrganisationInvitation extends React.Component {
leavePage: true,
activeTab: "invitation_form",
htmlPreview: "",
- loading: true
+ loading: true,
+ existingInvitations: [],
+ validating: false
};
}
@@ -83,8 +90,8 @@ class NewOrganisationInvitation extends React.Component {
};
isValid = () => {
- const {administrators, fileEmails} = this.state;
- return !isEmpty(administrators) || !isEmpty(fileEmails);
+ const {administrators, fileEmails, validating, existingInvitations} = this.state;
+ return (!isEmpty(administrators) || !isEmpty(fileEmails)) && !validating && isEmpty(existingInvitations);
};
doSubmit = () => {
@@ -120,15 +127,30 @@ class NewOrganisationInvitation extends React.Component {
stopEvent(e);
const {administrators} = this.state;
const newAdministrators = administrators.filter(currentMail => currentMail !== email);
+ this.validateDuplicates(newAdministrators);
this.setState({administrators: newAdministrators});
};
addEmails = emails => {
const {administrators} = this.state;
const uniqueEmails = [...new Set(administrators.concat(emails))];
+ this.validateDuplicates(uniqueEmails);
this.setState({administrators: uniqueEmails});
};
+ validateDuplicates(newAdministrators) {
+ const organisationId = this.props.match.params.organisation_id;
+ this.setState({validating: true});
+ organisationInvitationExists(newAdministrators, organisationId)
+ .then(existingInvitations =>
+ this.setState({
+ existingInvitations: existingInvitations,
+ initial: isEmpty(existingInvitations),
+ validating: false
+ })
+ );
+ }
+
tabChanged = activeTab => {
this.setState({activeTab: activeTab});
if (activeTab === "invitation_preview") {
@@ -156,17 +178,22 @@ class NewOrganisationInvitation extends React.Component {
invitationForm = (organisation, message, fileInputKey, fileName, fileTypeError, fileEmails, initial, administrators, expiry_date,
- disabledSubmit, intended_role, units) =>
+ disabledSubmit, intended_role, units, existingInvitations) =>
+ emails={administrators}
+ autoFocus={true}/>
{(!initial && isEmpty(administrators) && isEmpty(fileEmails)) && }
+ {!isEmpty(existingInvitations) && }
+
option.value === intended_role)}
options={this.intendedRolesOptions}
small={true}
@@ -209,7 +236,7 @@ class NewOrganisationInvitation extends React.Component {
const {
initial, administrators, expiry_date, organisation,
confirmationDialogOpen, confirmationDialogAction, cancelDialogAction, leavePage, message, fileName,
- fileTypeError, fileEmails, fileInputKey, intended_role, loading, units
+ fileTypeError, fileEmails, fileInputKey, intended_role, loading, units, existingInvitations
} = this.state;
if (loading) {
return
@@ -227,7 +254,7 @@ class NewOrganisationInvitation extends React.Component {
{I18n.t("tabs.invitation_form")}
{this.invitationForm(organisation, message, fileInputKey, fileName, fileTypeError, fileEmails, initial,
- administrators, expiry_date, disabledSubmit, intended_role, units)}
+ administrators, expiry_date, disabledSubmit, intended_role, units, existingInvitations)}
>);
diff --git a/client/src/pages/NewServiceInvitation.jsx b/client/src/pages/NewServiceInvitation.jsx
index 3aa04f04d..697ce9b2d 100644
--- a/client/src/pages/NewServiceInvitation.jsx
+++ b/client/src/pages/NewServiceInvitation.jsx
@@ -3,11 +3,11 @@ import moment from "moment";
import "react-datepicker/dist/react-datepicker.css";
-import {serviceById, serviceInvitations} from "../api";
+import {serviceById, serviceInvitationExists, serviceInvitations} from "../api";
import I18n from "../locale/I18n";
import InputField from "../components/InputField";
import Button from "../components/Button";
-import {isEmpty, stopEvent} from "../utils/Utils";
+import {isEmpty, splitListSemantically, stopEvent} from "../utils/Utils";
import ConfirmationDialog from "../components/ConfirmationDialog";
import {setFlash} from "../utils/Flash";
import {validEmailRegExp} from "../validations/regExps";
@@ -20,6 +20,7 @@ import {AppStore} from "../stores/AppStore";
import SpinnerField from "../components/redesign/SpinnerField";
import EmailField from "../components/EmailField";
import ErrorIndicator from "../components/redesign/ErrorIndicator";
+import {serviceRoles} from "../forms/constants";
class NewServiceInvitation extends React.Component {
@@ -27,10 +28,10 @@ class NewServiceInvitation extends React.Component {
super(props, context);
const email = getParameterByName("email", window.location.search);
const administrators = !isEmpty(email) && validEmailRegExp.test(email.trim()) ? [email.trim()] : [];
- this.intendedRolesOptions = [{
- value: "manager",
- label: I18n.t("serviceDetail.admin")
- }];
+ this.intendedRolesOptions = serviceRoles.map(role => ({
+ value: role,
+ label: I18n.t(`serviceDetail.${role}`)
+ }));
this.state = {
service: undefined,
administrators: administrators,
@@ -39,6 +40,7 @@ class NewServiceInvitation extends React.Component {
fileTypeError: false,
fileInputKey: new Date().getMilliseconds(),
message: "",
+ intended_role: "admin",
expiry_date: moment().add(16, "days").toDate(),
initial: true,
confirmationDialogOpen: false,
@@ -48,7 +50,9 @@ class NewServiceInvitation extends React.Component {
leavePage: true,
activeTab: "invitation_form",
htmlPreview: "",
- loading: true
+ loading: true,
+ existingInvitations: [],
+ validating: false
};
}
@@ -79,17 +83,18 @@ class NewServiceInvitation extends React.Component {
};
isValid = () => {
- const {administrators, fileEmails} = this.state;
- return !isEmpty(administrators) || !isEmpty(fileEmails);
+ const {administrators, fileEmails, existingInvitations, validating} = this.state;
+ return (!isEmpty(administrators) || !isEmpty(fileEmails)) && isEmpty(existingInvitations) && !validating;
};
doSubmit = () => {
if (this.isValid()) {
- const {administrators, message, service, expiry_date, fileEmails} = this.state;
+ const {administrators, message, service, expiry_date, fileEmails, intended_role} = this.state;
this.setState({loading: true});
serviceInvitations({
administrators: administrators.concat(fileEmails),
message,
+ intended_role,
expiry_date: expiry_date.getTime() / 1000,
service_id: service.id
}).then(() => {
@@ -110,35 +115,55 @@ class NewServiceInvitation extends React.Component {
}
};
+ validateDuplicates(newAdministrators) {
+ const serviceId = this.props.match.params.service_id;
+ this.setState({validating: true});
+ serviceInvitationExists(newAdministrators, serviceId)
+ .then(existingInvitations =>
+ this.setState({
+ existingInvitations: existingInvitations,
+ initial: isEmpty(existingInvitations),
+ validating: false
+ })
+ )
+ }
+
removeMail = email => e => {
stopEvent(e);
const {administrators} = this.state;
const newAdministrators = administrators.filter(currentMail => currentMail !== email);
+ this.validateDuplicates(newAdministrators);
this.setState({administrators: newAdministrators});
};
addEmails = emails => {
const {administrators} = this.state;
const uniqueEmails = [...new Set(administrators.concat(emails))];
+ this.validateDuplicates(uniqueEmails);
this.setState({administrators: uniqueEmails});
};
invitationForm = (service, message, fileInputKey, fileName, fileTypeError, fileEmails, initial, administrators, expiry_date,
- disabledSubmit) =>
+ disabledSubmit, intended_role, existingInvitations) =>
+ emails={administrators}
+ autoFocus={true}/>
{(!initial && isEmpty(administrators) && isEmpty(fileEmails)) && }
- }
+
+ option.value === intended_role)}
options={this.intendedRolesOptions}
small={true}
- disabled={true}
+ onChange={selectedOption => this.setState({intended_role: selectedOption ? selectedOption.value : null})}
toolTip={I18n.t("serviceDetail.intendedRoleTooltip")}
name={I18n.t("serviceDetail.intendedRole")}/>
@@ -171,7 +196,7 @@ class NewServiceInvitation extends React.Component {
const {
initial, administrators, expiry_date, service,
confirmationDialogOpen, confirmationDialogAction, cancelDialogAction, leavePage, message, fileName,
- fileTypeError, fileEmails, fileInputKey, intended_role, loading
+ fileTypeError, fileEmails, fileInputKey, intended_role, loading, existingInvitations
} = this.state;
if (loading) {
return
@@ -189,7 +214,7 @@ class NewServiceInvitation extends React.Component {
{I18n.t("tabs.invitation_form")}
{this.invitationForm(service, message, fileInputKey, fileName, fileTypeError, fileEmails, initial,
- administrators, expiry_date, disabledSubmit, intended_role)}
+ administrators, expiry_date, disabledSubmit, intended_role, existingInvitations)}
>);
diff --git a/client/src/pages/NotFound.scss b/client/src/pages/NotFound.scss
index 5d5929cd4..bfedba7fe 100644
--- a/client/src/pages/NotFound.scss
+++ b/client/src/pages/NotFound.scss
@@ -11,7 +11,7 @@
.mod-inner-not-found {
margin: 0 auto;
display: flex;
- flex-direction: column;
+ flex-direction: column;
@media (max-width: $compact-medium) {
margin: 0 10px;
@@ -20,11 +20,11 @@
}
svg {
- margin-top: 25px;
+ margin: 25px auto;
width: 480px;
height: auto;
@media (max-width: $compact-medium) {
- width: 90vw;
+ width: 90vw;
}
}
diff --git a/client/src/pages/OrganisationDetail.jsx b/client/src/pages/OrganisationDetail.jsx
index fb5c3ed7a..0bb0c00ea 100644
--- a/client/src/pages/OrganisationDetail.jsx
+++ b/client/src/pages/OrganisationDetail.jsx
@@ -384,7 +384,13 @@ class OrganisationDetail extends React.Component {
breadcrumbName={I18n.t("breadcrumb.organisation", {name: organisation.name})}
firstTime={user.admin ? this.onBoarding : undefined}
name={organisation.name}
- actions={this.getActions(user, organisation, adminOfOrganisation)}/>
+ actions={this.getActions(user, organisation, adminOfOrganisation)}>
+ {organisation.accepted_user_policy &&
+
+ {I18n.t("aup.title")}
+
+ }
+
{tabs}
diff --git a/client/src/pages/OrganisationForm.jsx b/client/src/pages/OrganisationForm.jsx
index 664ba926a..3990fd61a 100644
--- a/client/src/pages/OrganisationForm.jsx
+++ b/client/src/pages/OrganisationForm.jsx
@@ -17,7 +17,7 @@ import {ReactComponent as OrganisationsIcon} from "../icons/organisations.svg";
import {isEmpty, stopEvent} from "../utils/Utils";
import ConfirmationDialog from "../components/ConfirmationDialog";
import {setFlash} from "../utils/Flash";
-import {sanitizeShortName, validSchacHomeRegExp} from "../validations/regExps";
+import {sanitizeShortName, validSchacHomeRegExp, validUrlRegExp} from "../validations/regExps";
import {AppStore} from "../stores/AppStore";
import UnitHeader from "../components/redesign/UnitHeader";
import CroppedImageField from "../components/redesign/CroppedImageField";
@@ -41,6 +41,7 @@ class OrganisationForm extends React.Component {
this.state = {
name: "",
description: "",
+ accepted_user_policy: "",
short_name: "",
schac_home_organisations: [],
schac_home_organisation: "",
@@ -56,6 +57,7 @@ class OrganisationForm extends React.Component {
administrators: [],
message: "",
required: ["name", "short_name", "logo"],
+ invalidInputs: {},
alreadyExists: {},
duplicatedUnit: false,
initial: true,
@@ -210,16 +212,28 @@ class OrganisationForm extends React.Component {
};
isValid = () => {
- const {required, alreadyExists, administrators, isNew, duplicatedUnit} = this.state;
- const inValid = Object.values(alreadyExists).some(val => val) || required.some(attr => isEmpty(this.state[attr])) || duplicatedUnit;
+ const {required, alreadyExists, administrators, isNew, duplicatedUnit,invalidInputs } = this.state;
+ const inValid = Object.values(alreadyExists).some(val => val) || required.some(attr => isEmpty(this.state[attr]))
+ || duplicatedUnit || Object.keys(invalidInputs).some(key => invalidInputs[key]);
return !inValid && (!isNew || !isEmpty(administrators));
};
doSubmit = () => {
if (this.isValid()) {
const {
- name, short_name, administrators, message, schac_home_organisations, description, logo,
- on_boarding_msg, category, services_restricted, units, service_connection_requires_approval
+ name,
+ short_name,
+ administrators,
+ message,
+ schac_home_organisations,
+ description,
+ accepted_user_policy,
+ logo,
+ on_boarding_msg,
+ category,
+ services_restricted,
+ units,
+ service_connection_requires_approval
} = this.state;
this.setState({loading: true});
createOrganisation({
@@ -231,6 +245,7 @@ class OrganisationForm extends React.Component {
administrators,
message,
description,
+ accepted_user_policy,
services_restricted,
service_connection_requires_approval,
logo,
@@ -261,6 +276,7 @@ class OrganisationForm extends React.Component {
const {
name,
description,
+ accepted_user_policy,
organisation,
schac_home_organisations,
collaboration_creation_allowed,
@@ -278,6 +294,7 @@ class OrganisationForm extends React.Component {
id: organisation.id,
name,
description,
+ accepted_user_policy,
units: units.filter(unit => !isEmpty(unit.name)),
schac_home_organisations,
collaboration_creation_allowed,
@@ -312,13 +329,23 @@ class OrganisationForm extends React.Component {
this.setState({administrators: uniqueEmails});
};
+ validateURI = name => e => {
+ const uri = e.target.value;
+ const {invalidInputs} = this.state;
+ const inValid = !isEmpty(uri) && !validUrlRegExp.test(uri);
+ this.setState({invalidInputs: {...invalidInputs, [name]: inValid}});
+ };
+
+
render() {
const {
name,
description,
+ accepted_user_policy,
initial,
alreadyExists,
administrators,
+ invalidInputs,
message,
confirmationDialogOpen,
confirmationDialogAction,
@@ -407,6 +434,19 @@ class OrganisationForm extends React.Component {
placeholder={I18n.t("organisation.descriptionPlaceholder")} multiline={true}
name={I18n.t("organisation.description")}/>
+
this.setState({accepted_user_policy: e.target.value})}
+ placeholder={I18n.t("organisation.accepted_user_policyPlaceholder")}
+ externalLink={true}
+ error={invalidInputs.accepted_user_policy}
+ toolTip={I18n.t("organisation.accepted_user_policyTooltip")}
+ name={I18n.t("service.accepted_user_policy")}
+ onBlur={this.validateURI("accepted_user_policy")}/>
+ {invalidInputs["accepted_user_policy"] &&
+ }
+
+
this.setState({logo: s})}
isNew={isNew} title={I18n.t("organisation.logo")} value={logo}
initial={initial} secondRow={true}/>
diff --git a/client/src/pages/Profile.jsx b/client/src/pages/Profile.jsx
index a818a63b7..6036de5f8 100644
--- a/client/src/pages/Profile.jsx
+++ b/client/src/pages/Profile.jsx
@@ -11,15 +11,17 @@ import {auditLogsMe} from "../api";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import Activity from "../components/Activity";
import {filterAuditLogs} from "../utils/AuditLog";
+import {Loader} from "@surfnet/sds";
class Profile extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
- loading: true,
+ loading: false,
auditLogs: [],
filteredAuditLogs: [],
+ loadingAuditLogs: true,
tab: "details",
tabs: [],
query: "",
@@ -27,18 +29,18 @@ class Profile extends React.Component {
}
componentDidMount = () => {
+ AppStore.update(s => {
+ s.breadcrumb.paths = [
+ {path: "/", value: I18n.t("breadcrumb.home")},
+ {path: "", value: I18n.t("breadcrumb.profile")}
+ ];
+ })
auditLogsMe().then(res => {
this.setState({
auditLogs: res,
filteredAuditLogs: res,
- loading: false
+ loadingAuditLogs: false
});
- AppStore.update(s => {
- s.breadcrumb.paths = [
- {path: "/", value: I18n.t("breadcrumb.home")},
- {path: "", value: I18n.t("breadcrumb.profile")}
- ];
- })
});
};
@@ -52,21 +54,18 @@ class Profile extends React.Component {
});
}
- getHistoryTab = (filteredAuditLogs, query) => {
+ getHistoryTab = (filteredAuditLogs, query, loadingAuditLogs) => {
+ const {user} = this.props;
return (}>
-
+
+ {!loadingAuditLogs &&
-
+ }
+ {!loadingAuditLogs &&
}
+ {loadingAuditLogs && {I18n.t("models.allUsers.loading")} }/>}
)
}
@@ -88,10 +87,13 @@ class Profile extends React.Component {
}
render() {
- const {tab, filteredAuditLogs, query} = this.state;
+ const {tab, filteredAuditLogs, query, loadingAuditLogs} = this.state;
const {user} = this.props;
const meProps = {...this.props}
- const tabs = [this.getDetailsTab(meProps), this.getHistoryTab(filteredAuditLogs, query),]
+ const tabs = [
+ this.getDetailsTab(meProps),
+ this.getHistoryTab(filteredAuditLogs, query, loadingAuditLogs)
+ ]
return (
{
const submitDisabled = respondents.filter(respondent => respondent.selected).length === 0;
- const groupedRespondents = Object.groupBy(respondents, ({unit}) => unit)
+ const groupedRespondents = Object.groupBy(respondents, ({unit}) => unit);
return (
{I18n.t("mfa.lost.title")}
@@ -299,11 +299,13 @@ class SecondFactorAuthentication extends React.Component {
{I18n.t("mfa.lost.respondent")}
-
{respondents[0].unit}
+
+ {isEmpty(respondents[0].unit) ? I18n.t("mfa.lost.organisationNamePlatformAdmin") : respondents[0].unit}
+
+ info={isEmpty(respondents[0].name) ? I18n.t("mfa.lost.displayNamePlatformAdmin") : respondents[0].name}/>
}
}
- {action === "register" &&
-
-
-
- {I18n.t("mfa.register.contactUs")}
-
-
- }
{I18n.t(`mfa.${action}.info2`)}
{!update &&
diff --git a/client/src/pages/Service.jsx b/client/src/pages/Service.jsx
index 869737b05..d5462b536 100644
--- a/client/src/pages/Service.jsx
+++ b/client/src/pages/Service.jsx
@@ -28,7 +28,6 @@ import {Chip, Tooltip} from "@surfnet/sds";
import UnitHeader from "../components/redesign/UnitHeader";
import {ReactComponent as TrashIcon} from "@surfnet/sds/icons/functional-icons/bin.svg";
import {AppStore} from "../stores/AppStore";
-import RadioButton from "../components/redesign/RadioButton";
import CroppedImageField from "../components/redesign/CroppedImageField";
import SpinnerField from "../components/redesign/SpinnerField";
import ErrorIndicator from "../components/redesign/ErrorIndicator";
@@ -66,9 +65,6 @@ class Service extends React.Component {
non_member_users_access_allowed: false,
allow_restricted_orgs: false,
ldap_identifier: "",
- research_scholarship_compliant: null,
- code_of_conduct_compliant: null,
- sirtfi_compliant: null,
token_enabled: false,
token_validity_days: "",
pam_web_sso_enabled: false,
@@ -79,8 +75,7 @@ class Service extends React.Component {
ip_networks: [],
administrators: [],
message: "",
- required: ["name", "entity_id", "abbreviation", "logo", "security_email",
- "research_scholarship_compliant", "code_of_conduct_compliant", "sirtfi_compliant"],
+ required: ["name", "entity_id", "abbreviation", "logo", "security_email", "connection_type"],
alreadyExists: {},
initial: true,
invalidInputs: {},
@@ -93,7 +88,7 @@ class Service extends React.Component {
isServiceAdmin: false,
hasAdministrators: false,
providing_organisation: "",
- connection_type: "openIDConnect",
+ connection_type: null,
redirect_urls: [],
saml_metadata_url: "",
saml_metadata: "",
@@ -111,7 +106,7 @@ class Service extends React.Component {
if (isServiceRequest) {
const required = this.state.required
.filter(attr => attr !== "entity_id" || isServiceRequestDetails)
- .concat("providing_organisation");
+ .concat(["providing_organisation", "connection_type"]);
this.setState({required: required})
}
if (!isServiceRequest && !user.admin) {
@@ -470,9 +465,8 @@ class Service extends React.Component {
serviceDetailTab = (title, name, isAdmin, alreadyExists, initial, entity_id, abbreviation, description, uri, automatic_connection_allowed,
access_allowed_for_all, non_member_users_access_allowed, contact_email, support_email, security_email, invalidInputs, contactEmailRequired,
- accepted_user_policy, uri_info, privacy_policy, service, disabledSubmit, allow_restricted_orgs, sirtfi_compliant, token_enabled, pam_web_sso_enabled,
- token_validity_days, code_of_conduct_compliant,
- research_scholarship_compliant, config, ip_networks, administrators, message, logo, isServiceAdmin,
+ accepted_user_policy, uri_info, privacy_policy, service, disabledSubmit, allow_restricted_orgs, token_enabled, pam_web_sso_enabled,
+ token_validity_days, config, ip_networks, administrators, message, logo, isServiceAdmin,
providing_organisation, connection_type, redirect_urls, saml_metadata_url, samlMetaDataFile, comments, isServiceRequestDetails, disableEverything,
ldap_identifier) => {
const ldapBindAccount = config.ldap_bind_account;
@@ -610,6 +604,9 @@ class Service extends React.Component {
values={["openIDConnect", "saml2URL", "saml2File", "none"]}
onChange={value => this.setState({connection_type: value})}
labelResolver={label => I18n.t(`service.protocols.${label}`)}/>
+ {(!initial && isEmpty(connection_type)) &&
+ }
}
{(isServiceRequest && connection_type === "openIDConnect") &&
}
-
- this.setState({sirtfi_compliant: val})}/>
- {(!initial && isEmpty(sirtfi_compliant)) &&
- }
-
- this.setState({code_of_conduct_compliant: val})}/>
- {(!initial && isEmpty(code_of_conduct_compliant)) &&
- }
-
- this.setState({research_scholarship_compliant: val})}/>
- {(!initial && isEmpty(research_scholarship_compliant)) &&
- }
-
-
{I18n.t("service.contactSupport")}
@@ -1040,12 +999,9 @@ class Service extends React.Component {
access_allowed_for_all,
non_member_users_access_allowed,
allow_restricted_orgs,
- sirtfi_compliant,
token_enabled,
pam_web_sso_enabled,
token_validity_days,
- code_of_conduct_compliant,
- research_scholarship_compliant,
ip_networks,
administrators,
message,
@@ -1093,9 +1049,9 @@ class Service extends React.Component {
{this.serviceDetailTab(title, name, isAdmin, alreadyExists, initial, entity_id, abbreviation, description, uri, automatic_connection_allowed,
access_allowed_for_all, non_member_users_access_allowed, contact_email, support_email, security_email, invalidInputs, contactEmailRequired, accepted_user_policy, uri_info, privacy_policy,
- service, disabledSubmit, allow_restricted_orgs, sirtfi_compliant, token_enabled, pam_web_sso_enabled, token_validity_days, code_of_conduct_compliant,
- research_scholarship_compliant, config, ip_networks, administrators, message, logo, isServiceAdmin, providing_organisation,
- connection_type, redirect_urls, saml_metadata_url, samlMetaDataFile, comments, isServiceRequestDetails, disableEverything, ldap_identifier)}
+ service, disabledSubmit, allow_restricted_orgs, token_enabled, pam_web_sso_enabled, token_validity_days, config, ip_networks,
+ administrators, message, logo, isServiceAdmin, providing_organisation, connection_type, redirect_urls, saml_metadata_url,
+ samlMetaDataFile, comments, isServiceRequestDetails, disableEverything, ldap_identifier)}
>)
;
diff --git a/client/src/pages/ServiceDetail.jsx b/client/src/pages/ServiceDetail.jsx
index 5e2e8d49f..8c22cedff 100644
--- a/client/src/pages/ServiceDetail.jsx
+++ b/client/src/pages/ServiceDetail.jsx
@@ -25,8 +25,8 @@ import {ReactComponent as UserAdminIcon} from "../icons/users.svg";
import {ReactComponent as ConnectedIcon} from "../icons/groups.svg";
import ServiceOrganisations from "../components/redesign/ServiceOrganisations";
import SpinnerField from "../components/redesign/SpinnerField";
-import {capitalize, isEmpty, removeDuplicates, splitListSemantically, stopEvent} from "../utils/Utils";
-import {actionMenuUserRole, isUserServiceAdmin} from "../utils/UserRole";
+import {capitalize, isEmpty, removeDuplicates, stopEvent} from "../utils/Utils";
+import {actionMenuUserRole, isUserServiceAdmin, isUserServiceManager} from "../utils/UserRole";
import ServiceConnectionRequests from "../components/redesign/ServiceConnectionRequests";
import {ReactComponent as GroupsIcon} from "../icons/ticket-group.svg";
import ServiceGroups from "../components/redesign/ServiceGroups";
@@ -93,7 +93,7 @@ class ServiceDetail extends React.Component {
}).catch(() => this.props.history.push("/404"));
} else if (params.id) {
const {user} = this.props;
- const userServiceAdmin = isUserServiceAdmin(user, {id: parseInt(params.id, 10)}) || user.admin;
+ const userServiceAdmin = isUserServiceManager(user, {id: parseInt(params.id, 10)}) || user.admin;
if (userServiceAdmin) {
Promise.all([serviceById(params.id), searchOrganisations("*"),
allServiceConnectionRequests(params.id)])
@@ -180,10 +180,12 @@ class ServiceDetail extends React.Component {
if (res.message && res.message.indexOf("already a member") > -1) {
this.setState({errorOccurred: true, firstTime: false}, () =>
setFlash(I18n.t("organisationInvitation.flash.alreadyMember"), "error"));
+ } else {
+ this.props.history.push("/404");
}
});
} else {
- throw e;
+ this.props.history.push("/404");
}
});
} else {
@@ -194,8 +196,8 @@ class ServiceDetail extends React.Component {
refresh = callback => {
const params = this.props.match.params;
const {user} = this.props;
- const userServiceAdmin = isUserServiceAdmin(user, {id: parseInt(params.id, 10)}) || user.admin;
- if (userServiceAdmin) {
+ const userServiceManager = isUserServiceManager(user, {id: parseInt(params.id, 10)}) || user.admin;
+ if (userServiceManager) {
Promise.all([serviceById(params.id), allServiceConnectionRequests(params.id)])
.then(res => {
this.setState({
@@ -220,7 +222,7 @@ class ServiceDetail extends React.Component {
}
};
- getDetailsTab = (service, user, serviceAdmin, showServiceAdminView) => {
+ getDetailsTab = (service, user, serviceAdmin, serviceManager, showServiceAdminView) => {
return (}>
@@ -230,7 +232,8 @@ class ServiceDetail extends React.Component {
user={user}
showServiceAdminView={showServiceAdminView}
userAdmin={user.admin}
- serviceAdmin={serviceAdmin}/>
+ serviceAdmin={serviceAdmin}
+ serviceManager={serviceManager}/>
)
}
@@ -256,32 +259,24 @@ class ServiceDetail extends React.Component {
label={I18n.t("home.tabs.serviceAdmins", {count: service.service_memberships.length})}
icon={}
notifier={expiredInvitations}>
-
)
}
- getTokenTab = service => {
- const openInvitations = (service.service_invitations || []).length;
- return (
}
- notifier={openInvitations > 0 ? openInvitations : null}>
-
-
)
- }
-
- getServiceGroupsTab = (service) => {
+ getServiceGroupsTab = (service, userServiceAdmin) => {
return (
}>
- {}
)
}
- getCollaborationsTab = (service, showServiceAdminView) => {
+ getCollaborationsTab = (service, userServiceAdmin, showServiceAdminView) => {
const collaborations = this.allCollaborationsForService(service);
return (
this.tabChanged("organisations")}
collaborations={collaborations}
refresh={this.refresh}
@@ -316,8 +312,9 @@ class ServiceDetail extends React.Component {
}
getServiceConnectionRequestTab = (service, serviceConnectionRequests) => {
- serviceConnectionRequests = (serviceConnectionRequests || []).filter(scr => !scr.pending_organisation_approval);
- const nbr = serviceConnectionRequests.length;
+ serviceConnectionRequests = (serviceConnectionRequests || [])
+ .filter(scr => !scr.pending_organisation_approval)
+ const nbr = serviceConnectionRequests.filter(scr => scr.status === "open").length;
return (
{
- const compliancies = [];
- if (service.sirtfi_compliant) {
- compliancies.push("Sirtfi")
- }
- if (service.code_of_conduct_compliant) {
- compliancies.push("CoCo")
- }
- if (service.research_scholarship_compliant) {
- compliancies.push("R&S")
- }
- if (compliancies.length === 0) {
- return I18n.t("service.none");
- }
- return splitListSemantically(compliancies, I18n.t("service.compliancySeparator"));
-
- }
-
doDeleteMe = () => {
this.setState({confirmationDialogOpen: false, loading: true});
const {user} = this.props;
@@ -498,31 +477,34 @@ class ServiceDetail extends React.Component {
if (loading) {
return
;
}
- const {user, invitation} = this.props;
+ const {user} = this.props;
+ const {invitation, isInvitation} = this.state;
let tabs = [];
const params = this.props.match.params;
const userServiceAdmin = isUserServiceAdmin(user, {id: parseInt(params.id, 10)}) || user.admin;
+ const userServiceManager = isUserServiceManager(user, {id: parseInt(params.id, 10)}) || userServiceAdmin;
if (params.hash) {
tabs = [this.getAdminsTab(service)];
- } else if (userServiceAdmin) {
+ } else if (userServiceManager) {
tabs = [
- this.getDetailsTab(service, user, userServiceAdmin, showServiceAdminView),
+ this.getDetailsTab(service, user, userServiceAdmin, userServiceManager, showServiceAdminView),
this.getAdminsTab(service),
- this.getServiceGroupsTab(service),
- this.getCollaborationsTab(service, showServiceAdminView),
+ this.getServiceGroupsTab(service, userServiceAdmin),
+ this.getCollaborationsTab(service, userServiceAdmin, showServiceAdminView),
this.getOrganisationsTab(service, organisations, user.admin, userServiceAdmin, showServiceAdminView),
];
- if (serviceConnectionRequests.length > 0) {
+ if (serviceConnectionRequests.filter(scr => !scr.pending_organisation_approval).length > 0) {
tabs.push(this.getServiceConnectionRequestTab(service, serviceConnectionRequests));
}
}
- if (!userServiceAdmin) {
+ if (!userServiceManager) {
tabs.push(this.getAboutTab(service));
}
const iconListItems = [
{
Icon:
,
- value:
{I18n.t("service.abbreviation")}: {service.abbreviation}
+ value:
{I18n.t("service.abbreviation")}: {service.abbreviation}
},
{
Icon:
,
@@ -548,8 +530,8 @@ class ServiceDetail extends React.Component {
-
- {!invitation && this.getIconListItems(iconListItems)}
+ {!isInvitation && this.getIconListItems(iconListItems)}
diff --git a/client/src/pages/ServiceOverview.jsx b/client/src/pages/ServiceOverview.jsx
index b2aed2bc5..5a990aaaa 100644
--- a/client/src/pages/ServiceOverview.jsx
+++ b/client/src/pages/ServiceOverview.jsx
@@ -6,6 +6,7 @@ import {
ipNetworks,
requestDeleteService,
resetLdapPassword,
+ resetScimBearerToken,
serviceAbbreviationExists,
serviceAupDelete,
serviceEntityIdExists,
@@ -33,7 +34,6 @@ import {
} from "../validations/regExps";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {Tooltip} from "@surfnet/sds";
-import RadioButton from "../components/redesign/RadioButton";
import DOMPurify from "dompurify";
import {ReactComponent as ChevronLeft} from "../icons/chevron-left.svg";
import Entities from "../components/redesign/Entities";
@@ -67,6 +67,7 @@ class ServiceOverview extends React.Component {
tokenValue: null,
warning: false,
isServiceAdmin: false,
+ isServiceManager: false,
hasAdministrators: false,
currentTab: "general",
createNewServiceToken: false,
@@ -77,7 +78,9 @@ class ServiceOverview extends React.Component {
initial: true,
sweepSuccess: null,
sweepResults: null,
- originalSCIMConfiguration: {}
+ originalSCIMConfiguration: {},
+ scimBearerToken: null,
+ scimTokenChange: false
}
}
@@ -89,7 +92,7 @@ class ServiceOverview extends React.Component {
}
componentDidMount = nextProps => {
- const {service, serviceAdmin} = nextProps ? nextProps : this.props;
+ const {service, serviceAdmin, serviceManager} = nextProps ? nextProps : this.props;
const {params} = this.props.match;
let tab = params.subTab || this.state.currentTab;
if (!toc.includes(tab)) {
@@ -101,6 +104,7 @@ class ServiceOverview extends React.Component {
service: {...service},
hasAdministrators: service.service_memberships.length > 0,
isServiceAdmin: serviceAdmin,
+ isServiceManager: serviceManager,
currentTab: tab,
originalSCIMConfiguration: {scim_bearer_token: service.scim_bearer_token, scim_url: service.scim_url},
loading: false
@@ -140,7 +144,9 @@ class ServiceOverview extends React.Component {
cancelDialogAction = () => {
this.setState({confirmationDialogOpen: false}, () => setTimeout(() => this.setState({
ldapPassword: null,
- tokenValue: null
+ tokenValue: null,
+ scimTokenChange: false,
+ scimBearerToken: null
}), 5));
}
@@ -154,6 +160,9 @@ class ServiceOverview extends React.Component {
confirmationDialogAction: () => this.resetAups(false),
confirmationHeader: I18n.t("confirmationDialog.title"),
lastAdminWarning: false,
+ scimTokenChange: false,
+ sweepSuccess: null,
+ ldapPassword: null,
confirmationDialogQuestion: I18n.t("service.aup.confirmation", {name: service.name}),
confirmationTxt: I18n.t("confirmationDialog.confirm"),
});
@@ -209,11 +218,13 @@ class ServiceOverview extends React.Component {
confirmationDialogAction: () => this.ldapResetAction(false),
warning: false,
lastAdminWarning: false,
+ scimTokenChange: false,
+ sweepSuccess: null,
+ ldapPassword: null,
confirmationHeader: I18n.t("confirmationDialog.title"),
confirmationDialogQuestion: I18n.t("service.ldap.confirmation", {name: service.name}),
confirmationTxt: I18n.t("confirmationDialog.confirm"),
});
-
} else {
resetLdapPassword(service).then(res => {
this.setState({
@@ -223,6 +234,8 @@ class ServiceOverview extends React.Component {
cancelDialogAction: null,
confirmationDialogQuestion: I18n.t("service.ldap.info"),
ldapPassword: res.ldap_password,
+ scimTokenChange: false,
+ sweepSuccess: null,
tokenValue: null,
loading: false,
confirmationDialogAction: this.cancelDialogAction
@@ -232,9 +245,51 @@ class ServiceOverview extends React.Component {
}
renderLdapPassword = ldapPassword => {
- return (
-
-
);
+ return (
+
+
+
+ );
+ }
+
+ renderScimTokenChange = scimBearerToken => {
+ return (
+
+ this.setState({scimBearerToken: e.target.value})}/>
+
+ );
+ }
+
+ scimTokenChangeAction = showConfirmation => {
+ const {service} = this.state;
+ if (showConfirmation) {
+ this.setState({
+ confirmationDialogOpen: true,
+ cancelDialogAction: this.cancelDialogAction,
+ confirmationDialogAction: () => this.scimTokenChangeAction(false),
+ warning: false,
+ lastAdminWarning: false,
+ scimTokenChange: true,
+ sweepSuccess: null,
+ ldapPassword: null,
+ confirmationHeader: I18n.t("confirmationDialog.title"),
+ confirmationDialogQuestion: I18n.t("service.scim_token.confirmation", {name: service.name}),
+ confirmationTxt: I18n.t("confirmationDialog.confirm"),
+ });
+
+ } else {
+ const {scimBearerToken} = this.state;
+ resetScimBearerToken(service, scimBearerToken).then(() => {
+ setFlash(I18n.t("service.scim_token.success"));
+ this.setState({
+ confirmationDialogOpen: false,
+ scimTokenChange: false,
+ loading: false
+ });
+ })
+ }
}
validateServiceName = e => serviceNameExists(e.target.value, this.props.service.name).then(json => {
@@ -312,6 +367,10 @@ class ServiceOverview extends React.Component {
confirmationDialogQuestion: I18n.t("service.deleteWarning"),
confirmationTxt: I18n.t("confirmationDialog.ok"),
cancelDialogAction: null,
+ lastAdminWarning: false,
+ scimTokenChange: false,
+ sweepSuccess: null,
+ ldapPassword: null,
confirmationHeader: I18n.t("confirmationDialog.title"),
confirmationDialogAction: this.closeConfirmationDialog
});
@@ -322,6 +381,10 @@ class ServiceOverview extends React.Component {
confirmationDialogQuestion: I18n.t(`service.${userServiceAdmin ? "requestDeleteConfirmation" : "deleteConfirmation"}`,
{name: service.name}),
warning: true,
+ lastAdminWarning: false,
+ scimTokenChange: false,
+ sweepSuccess: null,
+ ldapPassword: null,
confirmationTxt: I18n.t("confirmationDialog.confirm"),
cancelDialogAction: this.closeConfirmationDialog,
confirmationHeader: I18n.t("confirmationDialog.title"),
@@ -386,6 +449,9 @@ class ServiceOverview extends React.Component {
cancelButtonLabel: I18n.t("forms.ignore"),
confirmationTxt: I18n.t("confirmationDialog.confirm"),
warning: false,
+ lastAdminWarning: false,
+ scimTokenChange: false,
+ ldapPassword: null,
cancelDialogAction: () => this.setState({confirmationDialogOpen: false}, () => this.doSweep(service, true)),
confirmationDialogOpen: true
});
@@ -396,6 +462,9 @@ class ServiceOverview extends React.Component {
loading: false,
sweepSuccess: true,
sweepResults: res,
+ lastAdminWarning: false,
+ scimTokenChange: false,
+ ldapPassword: null,
confirmationDialogQuestion: null,
cancelButtonLabel: I18n.t("confirmationDialog.cancel"),
confirmationDialogAction: () => this.setState({confirmationDialogOpen: false}),
@@ -494,6 +563,10 @@ class ServiceOverview extends React.Component {
this.setState({
confirmationDialogOpen: true,
leavePage: false,
+ lastAdminWarning: false,
+ scimTokenChange: false,
+ sweepSuccess: null,
+ ldapPassword: null,
confirmationDialogQuestion: question,
warning: true,
confirmationTxt: I18n.t("confirmationDialog.confirm"),
@@ -587,6 +660,10 @@ class ServiceOverview extends React.Component {
leavePage: false,
confirmationDialogQuestion: I18n.t("serviceDetails.tokenDeleteConfirmation"),
warning: true,
+ lastAdminWarning: false,
+ scimTokenChange: false,
+ sweepSuccess: null,
+ ldapPassword: null,
confirmationTxt: I18n.t("confirmationDialog.confirm"),
cancelDialogAction: this.closeConfirmationDialog,
confirmationHeader: I18n.t("confirmationDialog.title"),
@@ -598,46 +675,51 @@ class ServiceOverview extends React.Component {
renderButtons = (isAdmin, isServiceAdmin, disabledSubmit, currentTab, showServiceAdminView, createNewServiceToken, service) => {
const invalidTabsMsg = this.getInvalidTabs();
return <>
- {((isAdmin || isServiceAdmin) && !createNewServiceToken) &&
+ {!createNewServiceToken &&
{invalidTabsMsg && {invalidTabsMsg}}
- {((isAdmin || isServiceAdmin) && currentTab === "general") &&
+ {(currentTab === "general") &&
}
>
}
- renderPamWebLogin = (service, createNewServiceToken, tokenType) => {
+ renderPamWebLogin = (service, isAdmin, isServiceAdmin, createNewServiceToken, tokenType) => {
if (createNewServiceToken) {
return this.renderNewServiceTokenForm();
}
- return (
- this.setState({
- "service": {
- ...service, pam_web_sso_enabled: e.target.checked
- }
- })}
- tooltip={I18n.t("userTokens.pamWebSSOEnabledTooltip")}
- info={I18n.t("userTokens.pamWebSSOEnabled")}
- />
- {this.renderServiceTokens(service, service.pam_web_sso_enabled, tokenType, I18n.t("userTokens.pamWebSSOEnabled"))}
-
)
+ return (
+
+ this.setState({
+ "service": {
+ ...service, pam_web_sso_enabled: e.target.checked
+ }
+ })}
+ readOnly={!isAdmin && !isServiceAdmin}
+ tooltip={I18n.t("userTokens.pamWebSSOEnabledTooltip")}
+ info={I18n.t("userTokens.pamWebSSOEnabled")}
+ />
+ {this.renderServiceTokens(service, isServiceAdmin, service.pam_web_sso_enabled, tokenType, I18n.t("userTokens.pamWebSSOEnabled"))}
+
+ )
}
- renderSCIMClient = (service, isAdmin, showServiceAdminView, createNewServiceToken, tokenType) => {
+ renderSCIMClient = (service, isAdmin, isServiceAdmin, showServiceAdminView, createNewServiceToken, tokenType) => {
if (createNewServiceToken) {
return this.renderNewServiceTokenForm();
}
@@ -653,7 +735,7 @@ class ServiceOverview extends React.Component {
}
})}
/>
- {this.renderServiceTokens(service, service.scim_client_enabled, tokenType, I18n.t("scim.scimClientEnabled"))}
+ {this.renderServiceTokens(service, isServiceAdmin, service.scim_client_enabled, tokenType, I18n.t("scim.scimClientEnabled"))}
)
}
@@ -662,7 +744,7 @@ class ServiceOverview extends React.Component {
{sweepSuccess ? : }
{I18n.t(`service.sweep.${sweepSuccess ? "success" : "failure"}`,
- {url: sweepResults.scim_url})}
+ {url: sweepResults?.scim_url})}
{(!isEmpty(sweepResults) && sweepResults.error) &&
{I18n.t("service.sweep.response")}
{sweepResults.error}
@@ -671,7 +753,7 @@ class ServiceOverview extends React.Component {
);
}
- renderSCIMServer = (service, isAdmin, showServiceAdminView, alreadyExists, invalidInputs, initial) => {
+ renderSCIMServer = (service, isAdmin, isServiceAdmin, showServiceAdminView, alreadyExists, invalidInputs, initial) => {
let sweepScimDailyRate = null;
if (service.sweep_scim_enabled && service.sweep_scim_daily_rate && service.sweep_scim_daily_rate.value) {
sweepScimDailyRate = service.sweep_scim_daily_rate
@@ -699,7 +781,7 @@ class ServiceOverview extends React.Component {
toolTip={I18n.t("scim.scimURLTooltip")}
error={invalidInputs.scim_url || (!initial && isEmpty(service.scim_url) && service.scim_enabled)}
onBlur={this.validateURI("scim_url")}
- disabled={!service.scim_enabled}/>
+ disabled={!service.scim_enabled || (!isAdmin && !isServiceAdmin)}/>
{invalidInputs.scim_url &&
}
{(!initial && isEmpty(service.scim_url) && service.scim_enabled) &&
@@ -707,18 +789,27 @@ class ServiceOverview extends React.Component {
attribute: I18n.t("scim.scimURL")
})}/>}
-
this.changeServiceProperty("scim_bearer_token")(e)}
- toolTip={I18n.t("scim.scimBearerTokenTooltip")}
- disabled={!service.scim_enabled}/>
+ {(service.scim_enabled && (isAdmin || isServiceAdmin)) &&
+
+
+
+
}
this.setState({
"service": {
...service,
@@ -732,7 +823,7 @@ class ServiceOverview extends React.Component {
value={(service.sweep_remove_orphans && service.sweep_scim_enabled) || false}
tooltip={I18n.t("scim.scimSweepDeleteOrphansTooltip")}
info={I18n.t("scim.scimSweepDeleteOrphans")}
- readOnly={!service.scim_enabled || !service.sweep_scim_enabled}
+ readOnly={!service.scim_enabled || !service.sweep_scim_enabled || (!isAdmin && !isServiceAdmin)}
onChange={e => this.setState({
"service": {
...service, sweep_remove_orphans: e.target.checked
@@ -746,7 +837,7 @@ class ServiceOverview extends React.Component {
}))}
name={I18n.t("scim.sweepScimDailyRate")}
toolTip={I18n.t("scim.sweepScimDailyRateTooltip")}
- disabled={!service.scim_enabled || !service.sweep_scim_enabled}
+ disabled={!service.scim_enabled || !service.sweep_scim_enabled || (!isAdmin && !isServiceAdmin)}
onChange={item => this.setState({
"service": {
...service, sweep_scim_daily_rate: item
@@ -798,7 +889,7 @@ class ServiceOverview extends React.Component {
);
}
- renderTokens = (config, service, isAdmin, createNewServiceToken, tokenType) => {
+ renderTokens = (config, service, isAdmin, isServiceAdmin, createNewServiceToken, tokenType) => {
if (createNewServiceToken) {
return this.renderNewServiceTokenForm();
}
@@ -808,6 +899,7 @@ class ServiceOverview extends React.Component {
value={service.token_enabled || false}
tooltip={I18n.t("userTokens.tokenEnabledTooltip")}
info={I18n.t("userTokens.tokenEnabled")}
+ readOnly={!isAdmin && !isServiceAdmin}
onChange={e => this.setState({
"service": {
...service,
@@ -831,14 +923,14 @@ class ServiceOverview extends React.Component {
...service, token_validity_days: e.target.value.replace(/\D/, "")
}
})}
- disabled={!service.token_enabled}/>
+ disabled={!service.token_enabled || (!isAdmin && !isServiceAdmin)}/>
- {this.renderServiceTokens(service, service.token_enabled, tokenType,
+ {this.renderServiceTokens(service, isServiceAdmin, service.token_enabled, tokenType,
I18n.t("userTokens.tokenEnabled").toLowerCase())}
)
}
- renderServiceTokens = (service, enabled, tokenType, action) => {
+ renderServiceTokens = (service, isServiceAdmin, enabled, tokenType, action) => {
const columns = [{
key: "hashed_token",
header: I18n.t("serviceDetails.hashedToken"),
@@ -855,14 +947,17 @@ class ServiceOverview extends React.Component {
key: "created_at",
header: I18n.t("models.userTokens.createdAt"),
mapper: serviceToken => dateFromEpoch(serviceToken.created_at)
- }, {
- nonSortable: true,
- key: "trash",
- header: "",
- mapper: serviceToken => this.removeServiceToken(serviceToken)}>
+ }]
+ if (isServiceAdmin) {
+ columns.push({
+ nonSortable: true,
+ key: "trash",
+ header: "",
+ mapper: serviceToken => this.removeServiceToken(serviceToken)}>
- },]
+ })
+ }
const customNoEntities = enabled ? I18n.t("serviceDetails.noTokens") : I18n.t("serviceDetails.enableTokens", {action: action});
return <>
@@ -880,7 +975,7 @@ class ServiceOverview extends React.Component {
hideTitle={true}
displaySearch={false}
{...this.props}/>
- {enabled &&
+ {(enabled && isServiceAdmin) &&
}
{service.ldap_enabled &&
}
)}
- {ldap_enabled &&
+ {(ldap_enabled) &&
}
@@ -1018,34 +1114,6 @@ class ServiceOverview extends React.Component {
{invalidInputs.accepted_user_policy &&
}
- this.setState({"service": {...service, sirtfi_compliant: val}})}/>
-
- this.setState({
- "service": {
- ...service, code_of_conduct_compliant: val
- }
- })}/>
-
- this.setState({
- "service": {
- ...service, research_scholarship_compliant: val
- }
- })}/>
)
}
@@ -1121,6 +1189,7 @@ class ServiceOverview extends React.Component {
isNew={false}
title={I18n.t("service.logo")}
value={service.logo}
+ disabled={!isAdmin && !isServiceAdmin}
initial={false}
secondRow={true}/>
@@ -1216,15 +1285,15 @@ class ServiceOverview extends React.Component {
case "policy":
return this.renderPolicy(service, isAdmin, isServiceAdmin, invalidInputs, alreadyExists);
case "SCIMServer":
- return this.renderSCIMServer(service, isAdmin, showServiceAdminView, alreadyExists, invalidInputs, initial);
+ return this.renderSCIMServer(service, isAdmin, isServiceAdmin, showServiceAdminView, alreadyExists, invalidInputs, initial);
case "SCIMClient":
- return this.renderSCIMClient(service, isAdmin, showServiceAdminView, createNewServiceToken, "scim");
+ return this.renderSCIMClient(service, isAdmin, isServiceAdmin, showServiceAdminView, createNewServiceToken, "scim");
case "ldap":
return this.renderLdap(config, service, isAdmin, isServiceAdmin);
case "tokens":
- return this.renderTokens(config, service, isAdmin, createNewServiceToken, "introspection");
+ return this.renderTokens(config, service, isAdmin, isServiceAdmin, createNewServiceToken, "introspection");
case "pamWebLogin":
- return this.renderPamWebLogin(service, createNewServiceToken, "pam");
+ return this.renderPamWebLogin(service, isAdmin, isServiceAdmin, createNewServiceToken, "pam");
default:
throw new Error(`unknown-tab: ${currentTab}`);
}
@@ -1252,7 +1321,10 @@ class ServiceOverview extends React.Component {
createNewServiceToken,
initial,
sweepResults,
- sweepSuccess
+ sweepSuccess,
+ scimTokenChange,
+ scimBearerToken
+
} = this.state;
if (loading) {
return
@@ -1265,13 +1337,15 @@ class ServiceOverview extends React.Component {
cancel={cancelDialogAction}
cancelButtonLabel={cancelButtonLabel}
isWarning={warning}
- largeWidth={!isEmpty(tokenValue)}
+ largeWidth={!isEmpty(tokenValue) || scimTokenChange}
confirmationTxt={confirmationTxt}
+ disabledConfirm={scimTokenChange && isEmpty(scimBearerToken)}
confirmationHeader={confirmationHeader}
confirm={confirmationDialogAction}
question={confirmationDialogQuestion}>
{ldapPassword && this.renderLdapPassword(ldapPassword)}
{!isEmpty(sweepSuccess) && this.renderScimResults(service, sweepSuccess, sweepResults)}
+ {scimTokenChange && this.renderScimTokenChange(scimBearerToken)}
{this.sidebar(currentTab)}
diff --git a/client/src/pages/System.jsx b/client/src/pages/System.jsx
index cb00dd354..8ccf8bcba 100644
--- a/client/src/pages/System.jsx
+++ b/client/src/pages/System.jsx
@@ -19,6 +19,8 @@ import {
getResetTOTPRequestedUsers,
getSuspendedUsers,
health,
+ invitationReminders,
+ openRequests,
outstandingRequests,
parseMetaData,
plscSync,
@@ -77,9 +79,11 @@ class System extends React.Component {
suspendedUsers: {},
suspendedCollaborations: {},
expiredCollaborations: {},
+ invitationReminders: [],
deletedUsers: {},
expiredMemberships: {},
outstandingRequests: {},
+ openRequests: {},
parsedMetaData: {},
parsedMetaDataView: false,
cleanedRequests: {},
@@ -132,7 +136,9 @@ class System extends React.Component {
expiredCollaborations: {},
deletedUsers: {},
expiredMemberships: {},
+ invitationReminders: [],
outstandingRequests: {},
+ openRequests: {},
parsedMetaData: {},
cleanedRequests: {},
databaseStats: [],
@@ -152,8 +158,8 @@ class System extends React.Component {
window.location.href = window.location.href;
}
- getCronTab = (suspendedUsers, outstandingRequests, cleanedRequests, expiredCollaborations, suspendedCollaborations,
- expiredMemberships, deletedUsers, sweepResults, cronJobs, parsedMetaData, parsedMetaDataView) => {
+ getCronTab = (suspendedUsers, outstandingRequests, openRequests, cleanedRequests, expiredCollaborations, suspendedCollaborations,
+ expiredMemberships, invitationReminders, deletedUsers, sweepResults, cronJobs, parsedMetaData, parsedMetaDataView) => {
return (}>
@@ -166,10 +172,14 @@ class System extends React.Component {
{this.renderSuspendedCollaborationsResults(suspendedCollaborations)}
{this.renderExpiredMemberships()}
{this.renderExpiredMembershipsResults(expiredMemberships)}
+ {this.renderInvitationReminders()}
+ {this.renderInvitationRemindersResults(invitationReminders)}
{this.renderOrphanUsers()}
{this.renderOrphanUsersResults(deletedUsers)}
{this.renderOutstandingRequests()}
{this.renderOutstandingRequestsResults(outstandingRequests)}
+ {this.renderOpenRequests()}
+ {this.renderOpenRequestsResults(openRequests)}
{this.renderCleanedRequests()}
{this.renderCleanedRequestsResults(cleanedRequests)}
{this.renderParsedMetaData()}
@@ -270,7 +280,7 @@ class System extends React.Component {
-
+
)
@@ -522,6 +532,13 @@ class System extends React.Component {
});
}
+ doInvitationReminders = () => {
+ this.setState({busy: true})
+ invitationReminders().then(res => {
+ this.setState({invitationReminders: res, busy: false});
+ });
+ }
+
doOrphanUsers = () => {
this.setState({busy: true})
deleteOrphanUsers().then(res => {
@@ -543,6 +560,13 @@ class System extends React.Component {
});
}
+ doOpenRequests = () => {
+ this.setState({busy: true})
+ openRequests().then(res => {
+ this.setState({openRequests: res, busy: false});
+ });
+ }
+
doCleanupNonOpenRequests = () => {
this.setState({busy: true})
cleanupNonOpenRequests().then(res => {
@@ -675,6 +699,21 @@ class System extends React.Component {
);
}
+ renderInvitationReminders = () => {
+ const {invitationReminders} = this.state;
+ return (
+
+
{I18n.t("system.runInvitationReminders")}
+
+ {isEmpty(invitationReminders) && }
+ {!isEmpty(invitationReminders) && }
+
+
+ );
+ }
+
renderOrphanUsers = () => {
const {deletedUsers} = this.state;
return (
@@ -720,6 +759,21 @@ class System extends React.Component {
);
}
+ renderOpenRequests = () => {
+ const {openRequests} = this.state;
+ return (
+
+
{I18n.t("system.runOpenRequestsInfo")}
+
+ {isEmpty(openRequests) && }
+ {!isEmpty(openRequests) && }
+
+
+ );
+ }
+
renderCleanedRequests = () => {
const {cleanedRequests} = this.state;
return (
@@ -919,6 +973,42 @@ class System extends React.Component {
)
}
+ renderInvitationRemindersResults = invitationReminders => {
+ return (
+
+ {!isEmpty(invitationReminders) &&
+
+
+
+ {I18n.t("system.invitationReminders.invitations")} |
+ {I18n.t("system.invitationReminders.organisationInvitations")} |
+ {I18n.t("system.invitationReminders.serviceInvitations")} |
+
+
+
+
+
+
+ {invitationReminders.invitations.map(email => {email})}
+
+ |
+
+
+ {invitationReminders.organisation_invitations.map(email => {email})}
+
+ |
+
+
+ {invitationReminders.service_invitations.map(email => {email})}
+
+ |
+
+
+
+
}
+
)
+ }
+
renderSuspendedCollaborationsResults = suspendedCollaborations => {
return (
@@ -992,6 +1082,59 @@ class System extends React.Component {
)
}
+ renderOpenRequestsResults = openRequests => {
+ return (
+
+ {!isEmpty(openRequests) &&
+
+
+
+ {I18n.t("system.openRequests.recipient")} |
+ {I18n.t("system.openRequests.service_requests")} |
+ {I18n.t("system.openRequests.service_connection_requests")} |
+ {I18n.t("system.openRequests.join_requests")} |
+ {I18n.t("system.openRequests.collaboration_requests")} |
+
+
+
+ {Object.keys(openRequests).map((recipient, index) =>
+
+ {recipient} |
+ {openRequests[recipient].service_requests.map((sr, i) =>
+
+ {I18n.t("system.openRequests.service_name")}: {sr.name}
+ {I18n.t("system.openRequests.requester")}: {sr.requester}
+
+ )} |
+ {openRequests[recipient].service_connection_requests.map((scr, i) =>
+
+ {I18n.t("system.openRequests.organisation_name")}: {scr.organisation}
+ {I18n.t("system.openRequests.service_name")}: {scr.service}
+ {I18n.t("system.openRequests.requester")}: {scr.requester}
+
+ )} |
+ {openRequests[recipient].join_requests.map((jr, i) =>
+
+ {I18n.t("system.openRequests.collaboration_name")}: {jr.name}
+ {I18n.t("system.openRequests.requester")}: {jr.requester}
+
+ )} |
+ {openRequests[recipient].collaboration_requests.map((cr, i) =>
+
+ {I18n.t("system.openRequests.collaboration_name")}: {cr.name}
+ {I18n.t("system.openRequests.requester")}: {cr.requester}
+
+ )} |
+
+ )}
+
+
+
+
}
+
+ )
+ }
+
renderSweepResults = sweepResults => {
const sweepJson = JSON.stringify(sweepResults);
return (
@@ -1164,6 +1307,7 @@ class System extends React.Component {
cancelDialogAction,
confirmationDialogAction,
outstandingRequests,
+ openRequests,
confirmationDialogQuestion,
busy,
tab,
@@ -1177,6 +1321,7 @@ class System extends React.Component {
expiredCollaborations,
suspendedCollaborations,
expiredMemberships,
+ invitationReminders,
sweepResults,
cronJobs,
parsedMetaData,
@@ -1201,8 +1346,8 @@ class System extends React.Component {
}
const tabs = [
this.getValidationTab(validationData, showOrganisationsWithoutAdmin, showServicesWithoutAdmin),
- this.getCronTab(suspendedUsers, outstandingRequests, cleanedRequests, expiredCollaborations,
- suspendedCollaborations, expiredMemberships, deletedUsers, sweepResults, cronJobs, parsedMetaData, parsedMetaDataView),
+ this.getCronTab(suspendedUsers, outstandingRequests, openRequests, cleanedRequests, expiredCollaborations,
+ suspendedCollaborations, expiredMemberships, invitationReminders, deletedUsers, sweepResults, cronJobs, parsedMetaData, parsedMetaDataView),
config.seed_allowed ? this.getSeedTab(seedResult, demoSeedResult) : null,
this.getDatabaseTab(databaseStats, config),
this.getActivityTab(filteredAuditLogs, limit, query, config, selectedTables, serverQuery),
diff --git a/client/src/pages/System.scss b/client/src/pages/System.scss
index 76fbb0159..3f48d65a1 100644
--- a/client/src/pages/System.scss
+++ b/client/src/pages/System.scss
@@ -104,6 +104,9 @@
text-align: left;
width: 50%;
+ &.invitation-reminders {
+ width: 33%;
+ }
}
}
@@ -204,116 +207,163 @@
margin: 25px;
}
- }
- .table-selection {
- display: flex;
-
- .select-field {
- width: 100%;
- margin-top: 0;
- }
- div.action-container {
+ .table-selection {
display: flex;
- button {
- margin: auto 0 0 15px;
+ .select-field {
+ width: 100%;
+ margin-top: 0;
}
- }
- }
+ div.action-container {
+ display: flex;
- .toggle-json {
- display: flex;
- margin: 15px 0;
+ button {
+ margin: auto 0 0 15px;
+ }
+ }
- .copy-to-clipboard {
- margin-left: 25px;
}
- }
- .search {
- position: relative;
- display: flex;
+ .toggle-json {
+ display: flex;
+ margin: 15px 0;
- &.no-clear-logs {
- margin-left: auto;
+ .copy-to-clipboard {
+ margin-left: 25px;
+ }
}
- @media (max-width: $compact-medium) {
- margin-left: 0;
- }
+ .search {
+ position: relative;
+ display: flex;
- svg.fa-search, svg.fa-magnifying-glass {
- position: absolute;
- font-size: 16px;
- color: $blue;
- top: 7px;
- right: 9px;
- }
+ &.no-clear-logs {
+ margin-left: auto;
+ }
- &.server-side {
- height: 48px;
- margin-top: auto;
- margin-left: 15px;
+ @media (max-width: $compact-medium) {
+ margin-left: 0;
+ }
svg.fa-search, svg.fa-magnifying-glass {
- top: 14px;
+ position: absolute;
+ font-size: 16px;
+ color: $blue;
+ top: 7px;
+ right: 9px;
}
- }
- input[type=text] {
- flex-grow: 2;
- border: 1px solid $lighter-grey;
- padding: 0 15px 0 10px;
- min-height: 38px;
- font-size: 16px;
- border-radius: $br;
- min-width: 560px;
+ &.server-side {
+ height: 48px;
+ margin-top: auto;
+ margin-left: 15px;
- @media (max-width: $compact-medium) {
- min-width: 0;
+ svg.fa-search, svg.fa-magnifying-glass {
+ top: 14px;
+ }
}
+ input[type=text] {
+ flex-grow: 2;
+ border: 1px solid $lighter-grey;
+ padding: 0 15px 0 10px;
+ min-height: 38px;
+ font-size: 16px;
+ border-radius: $br;
+ min-width: 560px;
+
+ @media (max-width: $compact-medium) {
+ min-width: 0;
+ }
+
- &:focus {
- outline: none;
- box-shadow: 1px 1px 1px $blue-hover, -1px -1px 1px $blue-hover;
+ &:focus {
+ outline: none;
+ box-shadow: 1px 1px 1px $blue-hover, -1px -1px 1px $blue-hover;
+ }
}
+
}
- }
+ table.suspended-users {
- table.suspended-users {
+ margin-bottom: 25px;
- margin-bottom: 25px;
+ thead {
+ th {
+ &.name {
+ width: 25%;
+ }
+
+ &.email {
+ width: 25%;
+ }
+
+ &.lastLogin {
+ width: 25%;
+ }
+
+ &.actions {
+ width: 25%;
+ }
+ }
+ }
+
+ tbody {
+ td {
+ vertical-align: middle;
+ }
+ }
+ }
- thead {
+ .invitation_reminders {
+ display: flex;
+ flex-direction: column;
+ }
+
+ table.open-requests {
th {
- &.name {
- width: 25%;
+ padding-left: 5px !important;
+
+ &.recipient {
+ width: 15%;
+ }
+
+ &.service_requests {
+ width: 20%;
}
- &.email {
- width: 25%;
+ &.service_connection_requests {
+ width: 20%;
}
- &.lastLogin {
- width: 25%;
+ &.join_requests {
+ width: 20%;
}
- &.actions {
- width: 25%;
+ &.collaboration_requests {
+ width: 20%;
}
}
- }
- tbody {
td {
- vertical-align: middle;
+ padding-left: 5px !important;
+ }
+
+ .open-requests {
+ display: flex;
+ flex-direction: column;
+
+ &:not(:last-child) {
+ padding-bottom: 4px;
+ border-bottom: 1px solid $lighter-grey;
+ }
+
}
}
- }
+ }
}
diff --git a/client/src/pages/UserDetail.jsx b/client/src/pages/UserDetail.jsx
index a3f7c22af..45d69fd42 100644
--- a/client/src/pages/UserDetail.jsx
+++ b/client/src/pages/UserDetail.jsx
@@ -27,6 +27,7 @@ import {Link} from "react-router-dom";
import Button from "../components/Button";
import ConfirmationDialog from "../components/ConfirmationDialog";
import {isUserAllowed, ROLES} from "../utils/UserRole";
+import {Loader} from "@surfnet/sds";
class UserDetail extends React.Component {
@@ -267,18 +268,9 @@ class UserDetail extends React.Component {
}>
- {!loadingAuditLogs &&
}
- {!loadingAuditLogs &&
}
- {loadingAuditLogs && }
+ {!loadingAuditLogs && }
+ {loadingAuditLogs && {I18n.t("models.allUsers.loading")}}/>}
)
diff --git a/client/src/utils/SocketIO.js b/client/src/utils/SocketIO.js
index 2a3d1891a..e7edaeb3c 100644
--- a/client/src/utils/SocketIO.js
+++ b/client/src/utils/SocketIO.js
@@ -18,4 +18,5 @@ export const SUBSCRIPTION_ID_COOKIE_NAME = "subscription_id";
export const JOIN_REQUEST_TYPE = "joinRequest";
export const COLLABORATION_REQUEST_TYPE = "collaborationRequest";
-export const SERVICE_TYPE_REQUEST = "serviceRequest";
\ No newline at end of file
+export const SERVICE_REQUEST_TYPE = "serviceRequest";
+export const SERVICE_CONNECTION_REQUEST_TYPE = "serviceConnectionRequest";
\ No newline at end of file
diff --git a/client/src/utils/UserRole.js b/client/src/utils/UserRole.js
index 028a8cf84..4b484e833 100644
--- a/client/src/utils/UserRole.js
+++ b/client/src/utils/UserRole.js
@@ -1,7 +1,12 @@
import I18n from "../locale/I18n";
import {ChipType} from "@surfnet/sds";
import {isEmpty} from "./Utils";
-import {COLLABORATION_REQUEST_TYPE, JOIN_REQUEST_TYPE, SERVICE_TYPE_REQUEST} from "./SocketIO";
+import {
+ COLLABORATION_REQUEST_TYPE,
+ JOIN_REQUEST_TYPE,
+ SERVICE_CONNECTION_REQUEST_TYPE,
+ SERVICE_REQUEST_TYPE
+} from "./SocketIO";
export const ROLES = {
@@ -11,6 +16,7 @@ export const ROLES = {
COLL_ADMIN: "coAdmin",
COLL_MEMBER: "coMember",
SERVICE_ADMIN: "serviceAdmin",
+ SERVICE_MANAGER: "serviceManager",
USER: "user"
}
@@ -64,7 +70,6 @@ export function rawGlobalUserRole(user, organisation, collaboration, service, me
if (user.admin) {
return ROLES.PLATFORM_ADMIN;
}
-
if (user.organisation_memberships && user.organisation_memberships.find(m => m.role === "admin" &&
((!organisation && !membershipRequired) || (organisation && m.organisation_id === organisation.id)))) {
return ROLES.ORG_ADMIN;
@@ -73,6 +78,14 @@ export function rawGlobalUserRole(user, organisation, collaboration, service, me
((!organisation && !membershipRequired) || (organisation && m.organisation_id === organisation.id)))) {
return ROLES.ORG_MANAGER;
}
+ if (user.service_memberships && user.service_memberships.find(m => m.role === "admin" &&
+ ((!service && !membershipRequired) || (service && m.service_id === service.id)))) {
+ return ROLES.SERVICE_ADMIN;
+ }
+ if (user.service_memberships && user.service_memberships.length > 0 &&
+ ((!service && !membershipRequired) || (service && user.service_memberships.find(m => m.service_id === service.id)))) {
+ return ROLES.SERVICE_MANAGER;
+ }
if (user.collaboration_memberships && user.collaboration_memberships.find(m => m.role === "admin" &&
((!collaboration && !membershipRequired) || (collaboration && m.collaboration_id === collaboration.id)))) {
return ROLES.COLL_ADMIN;
@@ -81,15 +94,17 @@ export function rawGlobalUserRole(user, organisation, collaboration, service, me
((!collaboration && !membershipRequired) || (collaboration && user.collaboration_memberships.find(m => m.collaboration_id === collaboration.id)))) {
return ROLES.COLL_MEMBER;
}
- if (user.service_memberships && user.service_memberships.length > 0 &&
- ((!service && !membershipRequired) || (service && user.service_memberships.find(m => m.service_id === service.id)))) {
- return ROLES.SERVICE_ADMIN;
- }
return ROLES.USER;
}
export function isUserServiceAdmin(user, service) {
- return user.service_memberships.some(m => !service || m.service_id === service.id)
+ return user.service_memberships
+ .some(m => !service || (m.service_id === service.id && m.role === "admin"))
+}
+
+export function isUserServiceManager(user, service) {
+ return user.service_memberships
+ .some(m => !service || m.service_id === service.id)
}
export function globalUserRole(user) {
@@ -123,9 +138,13 @@ export function getUserRequests(user) {
requests.push(...user.collaboration_requests);
}
if (!isEmpty(user.service_requests)) {
- user.service_requests.forEach(serviceRequest => serviceRequest.requestType = SERVICE_TYPE_REQUEST);
+ user.service_requests.forEach(serviceRequest => serviceRequest.requestType = SERVICE_REQUEST_TYPE);
requests.push(...user.service_requests);
}
+ if (!isEmpty(user.service_connection_requests)) {
+ user.service_connection_requests.forEach(serviceConnectionRequest => serviceConnectionRequest.requestType = SERVICE_CONNECTION_REQUEST_TYPE);
+ requests.push(...user.service_connection_requests);
+ }
return requests;
}
diff --git a/client/src/utils/Utils.js b/client/src/utils/Utils.js
index cf4380415..510bdbe61 100644
--- a/client/src/utils/Utils.js
+++ b/client/src/utils/Utils.js
@@ -37,7 +37,11 @@ export function groupBy(arr, key) {
}, {});
}
-export function sortObjects(objects, attribute, reverse) {
+export function sortObjects(objects, attribute, reverse, customSort = null) {
+ //Check if the column has a custom sort function
+ if (!isEmpty(customSort) && typeof customSort === "function") {
+ return [...objects].sort((a, b) => customSort(a, b, reverse));
+ }
return [...objects].sort((a, b) => {
const val1 = valueForSort(attribute, a);
const val2 = valueForSort(attribute, b);
@@ -125,3 +129,8 @@ export const capitalize = str => {
return isEmpty(str) ? str : (str.charAt(0).toUpperCase() + str.slice(1));
}
+export const statusCustomSort = (o1, o2, reverse) => {
+ const comparison = o1.status === "open" ? -1 : o2.status === "open" ? 1 : o1.status.localeCompare(o2.status);
+ return reverse ? comparison * -1 : comparison;
+};
+
diff --git a/client/yarn.lock b/client/yarn.lock
index e3e387ace..91e8142f5 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -1102,7 +1102,7 @@
resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
-"@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
+"@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
version "7.22.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.6.tgz#57d64b9ae3cff1d67eb067ae117dac087f5bd438"
integrity sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==
@@ -1402,31 +1402,67 @@
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.2.6.tgz#d21ace437cc919cdd8f1640302fa8851e65e75c0"
integrity sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==
-"@floating-ui/dom@^1.0.0", "@floating-ui/dom@^1.0.1":
+"@floating-ui/core@^1.6.0":
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1"
+ integrity sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==
+ dependencies:
+ "@floating-ui/utils" "^0.2.1"
+
+"@floating-ui/dom@^1.0.1":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.2.6.tgz#bcf0c7bada97c20d9d1255b889f35bac838c63fe"
integrity sha512-02vxFDuvuVPs22iJICacezYJyf7zwwOCWkPNkWNBr1U0Qt1cKFYzWvxts0AmqcOQGwt/3KJWcWIgtbUU38keyw==
dependencies:
"@floating-ui/core" "^1.2.6"
-"@fortawesome/fontawesome-common-types@6.5.1":
- version "6.5.1"
- resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz#fdb1ec4952b689f5f7aa0bffe46180bb35490032"
- integrity sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==
+"@floating-ui/dom@^1.6.1":
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.1.tgz#d552e8444f77f2d88534372369b3771dc3a2fa5d"
+ integrity sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==
+ dependencies:
+ "@floating-ui/core" "^1.6.0"
+ "@floating-ui/utils" "^0.2.1"
-"@fortawesome/fontawesome-svg-core@^6.5.1":
- version "6.5.1"
- resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.1.tgz#9d56d46bddad78a7ebb2043a97957039fcebcf0a"
- integrity sha512-MfRCYlQPXoLlpem+egxjfkEuP9UQswTrlCOsknus/NcMoblTH2g0jPrapbcIb04KGA7E2GZxbAccGZfWoYgsrQ==
+"@floating-ui/react-dom@^2.0.8":
+ version "2.0.8"
+ resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.8.tgz#afc24f9756d1b433e1fe0d047c24bd4d9cefaa5d"
+ integrity sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==
dependencies:
- "@fortawesome/fontawesome-common-types" "6.5.1"
+ "@floating-ui/dom" "^1.6.1"
-"@fortawesome/free-solid-svg-icons@^6.5.1":
- version "6.5.1"
- resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.1.tgz#737b8d787debe88b400ab7528f47be333031274a"
- integrity sha512-S1PPfU3mIJa59biTtXJz1oI0+KAXW6bkAb31XKhxdxtuXDiUIFsih4JR1v5BbxY7hVHsD1RKq+jRkVRaf773NQ==
+"@floating-ui/react@^0.26.2":
+ version "0.26.8"
+ resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.8.tgz#9f8dc9d21aa35456ccc32b536d853d365ce8b9d9"
+ integrity sha512-fOZb8BnJBrVohGPZ8RthDM5cHD9SnBKgY/U7LFXHhuwafSZD7TVmCX67+ezkkwxFbWpQGTEbgcjuHUDRonGy1g==
dependencies:
- "@fortawesome/fontawesome-common-types" "6.5.1"
+ "@floating-ui/react-dom" "^2.0.8"
+ "@floating-ui/utils" "^0.2.1"
+ tabbable "^6.0.1"
+
+"@floating-ui/utils@^0.2.1":
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2"
+ integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==
+
+"@fortawesome/fontawesome-common-types@6.5.2":
+ version "6.5.2"
+ resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz#eaf2f5699f73cef198454ebc0c414e3688898179"
+ integrity sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==
+
+"@fortawesome/fontawesome-svg-core@^6.5.2":
+ version "6.5.2"
+ resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.2.tgz#4b42de71e196039b0d5ccf88559b8044e3296c21"
+ integrity sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==
+ dependencies:
+ "@fortawesome/fontawesome-common-types" "6.5.2"
+
+"@fortawesome/free-solid-svg-icons@^6.5.2":
+ version "6.5.2"
+ resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.2.tgz#9b40b077b27400a5e9fcbf2d15b986c7be69e9ca"
+ integrity sha512-QWFZYXFE7O1Gr1dTIp+D6UcFUF0qElOnZptpi7PBUMylJh+vFmIedVe1Ir6RM1t2tEQLLSV1k7bR4o92M+uqlw==
+ dependencies:
+ "@fortawesome/fontawesome-common-types" "6.5.2"
"@fortawesome/react-fontawesome@^0.2.0":
version "0.2.0"
@@ -1773,11 +1809,6 @@
schema-utils "^3.0.0"
source-map "^0.7.3"
-"@popperjs/core@^2.11.8":
- version "2.11.8"
- resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
- integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
-
"@rollup/plugin-babel@^5.2.0":
version "5.3.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283"
@@ -1844,10 +1875,10 @@
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553"
integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==
-"@surfnet/sds@^0.0.97":
- version "0.0.97"
- resolved "https://registry.yarnpkg.com/@surfnet/sds/-/sds-0.0.97.tgz#b741fc9c950bdf60b4b6a010bf8d2d0a3862e395"
- integrity sha512-r4Mib7N+h/YGtlgaKfa4xeo+lp3NAVQe0Hc2W8laDr/MObdYLgftG3QPvtoJlIJ58Kv3XgmcEG9F0E5B67oMog==
+"@surfnet/sds@^0.0.104":
+ version "0.0.104"
+ resolved "https://registry.yarnpkg.com/@surfnet/sds/-/sds-0.0.104.tgz#ce8e7a82a10d32e1e81e8a25b1f88b9e569b67df"
+ integrity sha512-/fFRYddNPkSCDlByaN2Jp/gqFXdtcgjET0Ua5OeoeRAxU79SXGvTX5/4pa+mOVxC+tm3kyOOlRuGBXYGV0fAuQ==
"@surma/rollup-plugin-off-main-thread@^2.2.3":
version "2.2.3"
@@ -2091,10 +2122,10 @@
resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz#dcef10a69d357fe9d43ac4ff2eca6b85dbf466af"
integrity sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==
-"@types/dompurify@^3.0.3":
- version "3.0.3"
- resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.0.3.tgz#d34ba1cf4f8b8f2cbfe5d3118dc3b7d81858fa42"
- integrity sha512-odiGr/9/qMqjcBOe5UhcNLOFHSYmKFOyr+bJ/Xu3Qp4k1pNPAlNLUVNNLcLfjQI7+W7ObX58EdD3H+3p3voOvA==
+"@types/dompurify@^3.0.5":
+ version "3.0.5"
+ resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.0.5.tgz#02069a2fcb89a163bacf1a788f73cb415dd75cb7"
+ integrity sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==
dependencies:
"@types/trusted-types" "*"
@@ -2155,6 +2186,13 @@
resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35"
integrity sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==
+"@types/http-proxy@^1.17.10":
+ version "1.17.14"
+ resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.14.tgz#57f8ccaa1c1c3780644f8a94f9c6b5000b5e2eec"
+ integrity sha512-SSrD0c1OQzlFX7pGu1eXxSEjemej64aaNPRhhVYUGqXh0BtldAAx37MG8btcumvpgKyZp1F5Gn3JkktdxiFv6w==
+ dependencies:
+ "@types/node" "*"
+
"@types/http-proxy@^1.17.8":
version "1.17.10"
resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.10.tgz#e576c8e4a0cc5c6a138819025a88e167ebb38d6c"
@@ -3050,13 +3088,13 @@ bluebird@^3.5.5:
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
-body-parser@1.20.1:
- version "1.20.1"
- resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668"
- integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==
+body-parser@1.20.2:
+ version "1.20.2"
+ resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd"
+ integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==
dependencies:
bytes "3.1.2"
- content-type "~1.0.4"
+ content-type "~1.0.5"
debug "2.6.9"
depd "2.0.0"
destroy "1.2.0"
@@ -3064,7 +3102,7 @@ body-parser@1.20.1:
iconv-lite "0.4.24"
on-finished "2.4.1"
qs "6.11.0"
- raw-body "2.5.1"
+ raw-body "2.5.2"
type-is "~1.6.18"
unpipe "1.0.0"
@@ -3191,9 +3229,9 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001464:
- version "1.0.30001547"
- resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001547.tgz"
- integrity sha512-W7CrtIModMAxobGhz8iXmDfuJiiKg1WADMO/9x7/CLNin5cpSbuBjooyoIUVB5eyCc36QuTVlkVa1iB2S5+/eA==
+ version "1.0.30001605"
+ resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001605.tgz"
+ integrity sha512-nXwGlFWo34uliI9z3n6Qc0wZaf7zaZWA1CPZ169La5mV3I/gem7bst0vr5XQH5TJXZIMfDeZyOrZnSlVzKxxHQ==
case-sensitive-paths-webpack-plugin@^2.4.0:
version "2.4.0"
@@ -3267,7 +3305,7 @@ cjs-module-lexer@^1.0.0:
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40"
integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==
-classnames@^2.2.6, classnames@^2.3.0:
+classnames@^2.3.0:
version "2.3.2"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
@@ -3288,10 +3326,10 @@ cliui@^7.0.2:
strip-ansi "^6.0.0"
wrap-ansi "^7.0.0"
-clsx@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b"
- integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==
+clsx@^2.0.0, clsx@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.0.tgz#e851283bcb5c80ee7608db18487433f7b23f77cb"
+ integrity sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==
co@^4.6.0:
version "4.6.0"
@@ -3430,7 +3468,7 @@ content-disposition@0.5.4:
dependencies:
safe-buffer "5.2.1"
-content-type@~1.0.4:
+content-type@~1.0.4, content-type@~1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
@@ -3445,10 +3483,10 @@ cookie-signature@1.0.6:
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
-cookie@0.5.0:
- version "0.5.0"
- resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
- integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
+cookie@0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
+ integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
copy-to-clipboard@^3.3.1:
version "3.3.3"
@@ -3469,10 +3507,10 @@ core-js-pure@^3.23.3:
resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.30.1.tgz#7d93dc89e7d47b8ef05d7e79f507b0e99ea77eec"
integrity sha512-nXBEVpmUnNRhz83cHd9JRQC52cTMcuXAmR56+9dSMpRdpeA4I1PX6yjmhd71Eyc/wXNsdBdUDIj1QTIeZpU5Tg==
-core-js@^3.19.2, core-js@^3.35.0:
- version "3.35.0"
- resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.35.0.tgz#58e651688484f83c34196ca13f099574ee53d6b4"
- integrity sha512-ntakECeqg81KqMueeGJ79Q5ZgQNR+6eaE8sxGCx62zMbAIj65q+uYvatToew3m6eAGdU4gNZwpZ34NMe4GYswg==
+core-js@^3.19.2, core-js@^3.36.1:
+ version "3.36.1"
+ resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.36.1.tgz#c97a7160ebd00b2de19e62f4bbd3406ab720e578"
+ integrity sha512-BTvUrwxVBezj5SZ3f10ImnX2oRByMxql3EimVqMysepbC9EeMUOpLwdy6Eoili2x6E4kf+ZUB5k/+Jv55alPfA==
core-util-is@~1.0.0:
version "1.0.3"
@@ -3707,10 +3745,10 @@ cssstyle@^2.3.0:
dependencies:
cssom "~0.3.6"
-cssstyle@^3.0.0:
- version "3.0.0"
- resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-3.0.0.tgz#17ca9c87d26eac764bb8cfd00583cff21ce0277a"
- integrity sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==
+cssstyle@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-4.0.1.tgz#ef29c598a1e90125c870525490ea4f354db0660a"
+ integrity sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==
dependencies:
rrweb-cssom "^0.6.0"
@@ -3812,12 +3850,10 @@ data-urls@^5.0.0:
whatwg-mimetype "^4.0.0"
whatwg-url "^14.0.0"
-date-fns@^2.30.0:
- version "2.30.0"
- resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0"
- integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==
- dependencies:
- "@babel/runtime" "^7.21.0"
+date-fns@^3.3.1:
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.3.1.tgz#7581daca0892d139736697717a168afbb908cfed"
+ integrity sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==
debug@2.6.9, debug@^2.6.0:
version "2.6.9"
@@ -4006,13 +4042,6 @@ dom-converter@^0.2.0:
dependencies:
utila "~0.4"
-dom-helpers@^3.4.0:
- version "3.4.0"
- resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8"
- integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==
- dependencies:
- "@babel/runtime" "^7.1.2"
-
dom-helpers@^5.0.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
@@ -4062,10 +4091,10 @@ domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1:
dependencies:
domelementtype "^2.2.0"
-dompurify@^3.0.5, dompurify@^3.0.6:
- version "3.0.6"
- resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.6.tgz#925ebd576d54a9531b5d76f0a5bef32548351dae"
- integrity sha512-ilkD8YEnnGh1zJ240uJsW7AzE+2qpbOUYjacomn3AvJ6J4JhKGSZ2nh4wUIXPZrEPppaCLx5jFe8T89Rk8tQ7w==
+dompurify@^3.0.11:
+ version "3.0.11"
+ resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.11.tgz#c163f5816eaac6aeef35dae2b77fca0504564efe"
+ integrity sha512-Fan4uMuyB26gFV3ovPoEoQbxRRPfTu3CvImyZnhGq5fsIEO+gEFLp45ISFt+kQBWsK5ulDdT0oV28jS1UrwQLg==
domutils@^1.7.0:
version "1.7.0"
@@ -4655,16 +4684,16 @@ expect@^27.5.1:
jest-message-util "^27.5.1"
express@^4.17.3:
- version "4.18.2"
- resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59"
- integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==
+ version "4.19.2"
+ resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465"
+ integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==
dependencies:
accepts "~1.3.8"
array-flatten "1.1.1"
- body-parser "1.20.1"
+ body-parser "1.20.2"
content-disposition "0.5.4"
content-type "~1.0.4"
- cookie "0.5.0"
+ cookie "0.6.0"
cookie-signature "1.0.6"
debug "2.6.9"
depd "2.0.0"
@@ -4696,7 +4725,7 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
-fast-equals@^5.0.0:
+fast-equals@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-5.0.1.tgz#a4eefe3c5d1c0d021aeed0bc10ba5e0c12ee405d"
integrity sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==
@@ -4874,9 +4903,9 @@ flux@^4.0.1:
fbjs "^3.0.1"
follow-redirects@^1.0.0:
- version "1.15.2"
- resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
- integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
+ version "1.15.6"
+ resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b"
+ integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==
for-each@^0.3.3:
version "0.3.3"
@@ -5348,7 +5377,7 @@ http-proxy-agent@^7.0.0:
agent-base "^7.1.0"
debug "^4.3.4"
-http-proxy-middleware@^2.0.3, http-proxy-middleware@^2.0.6:
+http-proxy-middleware@^2.0.3:
version "2.0.6"
resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz#e1a4dd6979572c7ab5a4e4b55095d1f32a74963f"
integrity sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==
@@ -5359,6 +5388,18 @@ http-proxy-middleware@^2.0.3, http-proxy-middleware@^2.0.6:
is-plain-obj "^3.0.0"
micromatch "^4.0.2"
+http-proxy-middleware@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-3.0.0.tgz#550790357d6f92a9b82ab2d63e07343a791cf26b"
+ integrity sha512-36AV1fIaI2cWRzHo+rbcxhe3M3jUDCNzc4D5zRl57sEWRAxdXYtw7FSQKYY6PDKssiAKjLYypbssHk+xs/kMXw==
+ dependencies:
+ "@types/http-proxy" "^1.17.10"
+ debug "^4.3.4"
+ http-proxy "^1.18.1"
+ is-glob "^4.0.1"
+ is-plain-obj "^3.0.0"
+ micromatch "^4.0.5"
+
http-proxy@^1.18.1:
version "1.18.1"
resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
@@ -5389,10 +5430,10 @@ human-signals@^2.1.0:
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
-i18n-js@^4.3.2:
- version "4.3.2"
- resolved "https://registry.yarnpkg.com/i18n-js/-/i18n-js-4.3.2.tgz#ec5391f23c76f5374b53645c83d272914eb81291"
- integrity sha512-n8gbEbQEueym2/q2yrZk5/xKWjFcKtg3/Escw4JHSVWa8qtKqP8j7se3UjkRbHlO/REqFA0V/MG1q8tEfyHeOA==
+i18n-js@^4.4.3:
+ version "4.4.3"
+ resolved "https://registry.yarnpkg.com/i18n-js/-/i18n-js-4.4.3.tgz#09744ddd377261f614502cc5622ce6981026ea4a"
+ integrity sha512-QIIyvJ+wOKdigL4BlgwiFFrpoXeGdlC8EYgori64YSWm1mnhNYYjIfRu5wETFrmiNP2fyD6xIjVG8dlzaiQr/A==
dependencies:
bignumber.js "*"
lodash "*"
@@ -5434,10 +5475,10 @@ ignore@^5.2.0:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
-immer@^10.0.3:
- version "10.0.3"
- resolved "https://registry.yarnpkg.com/immer/-/immer-10.0.3.tgz#a8de42065e964aa3edf6afc282dfc7f7f34ae3c9"
- integrity sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==
+immer@^10.0.4:
+ version "10.0.4"
+ resolved "https://registry.yarnpkg.com/immer/-/immer-10.0.4.tgz#09af41477236b99449f9d705369a4daaf780362b"
+ integrity sha512-cuBuGK40P/sk5IzWa9QPUaAdvPHjkk1c+xYsd9oZw+YQQEV+10G0P5uMpGctZZKnyQ+ibRO08bD25nWLmYi2pw==
immer@^9.0.16, immer@^9.0.7:
version "9.0.21"
@@ -5771,14 +5812,14 @@ isexe@^2.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
-isomorphic-dompurify@^1.13.0:
- version "1.13.0"
- resolved "https://registry.yarnpkg.com/isomorphic-dompurify/-/isomorphic-dompurify-1.13.0.tgz#d801fec787e6834b4964517cc8bd5442a5366138"
- integrity sha512-9qOYGngy9ZR9JB/iLmr7SViPSZ7uWGvepdnLaXYznbTxvJOCuONneKajJ54f+IRQpvL8608ylUy9EK1iPtL3Ag==
+isomorphic-dompurify@^2.6.0:
+ version "2.6.0"
+ resolved "https://registry.yarnpkg.com/isomorphic-dompurify/-/isomorphic-dompurify-2.6.0.tgz#7ded39d76f743253f82af623111c7d77d0a8f88b"
+ integrity sha512-hTH3xazYEhs+cJu2uLaw/mPPvTefW6ljyRt2JiQ3OBoQ7+3YpgZOLmeBrDrGS/tnDQx1BuwwZcl6wEsYIVK4uQ==
dependencies:
- "@types/dompurify" "^3.0.3"
- dompurify "^3.0.6"
- jsdom "^23.0.0"
+ "@types/dompurify" "^3.0.5"
+ dompurify "^3.0.11"
+ jsdom "^24.0.0"
istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0:
version "3.2.0"
@@ -6382,12 +6423,12 @@ jsdom@^16.6.0:
ws "^7.4.6"
xml-name-validator "^3.0.0"
-jsdom@^23.0.0:
- version "23.0.1"
- resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-23.0.1.tgz#ede7ff76e89ca035b11178d200710d8982ebfee0"
- integrity sha512-2i27vgvlUsGEBO9+/kJQRbtqtm+191b5zAZrU/UezVmnC2dlDAFLgDYJvAEi94T4kjsRKkezEtLQTgsNEsW2lQ==
+jsdom@^24.0.0:
+ version "24.0.0"
+ resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-24.0.0.tgz#e2dc04e4c79da368481659818ee2b0cd7c39007c"
+ integrity sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==
dependencies:
- cssstyle "^3.0.0"
+ cssstyle "^4.0.1"
data-urls "^5.0.0"
decimal.js "^10.4.3"
form-data "^4.0.0"
@@ -6406,7 +6447,7 @@ jsdom@^23.0.0:
whatwg-encoding "^3.1.1"
whatwg-mimetype "^4.0.0"
whatwg-url "^14.0.0"
- ws "^8.14.2"
+ ws "^8.16.0"
xml-name-validator "^5.0.0"
jsesc@^2.5.1:
@@ -6649,7 +6690,7 @@ lodash.uniq@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==
-lodash@*, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0:
+lodash@*, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -6797,10 +6838,10 @@ minimalistic-assert@^1.0.0:
resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
-minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^5.0.1, minimatch@^9.0.0:
- version "9.0.0"
- resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.0.tgz#bfc8e88a1c40ffd40c172ddac3decb8451503b56"
- integrity sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==
+minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^5.0.1, minimatch@^9.0.4:
+ version "9.0.4"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51"
+ integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==
dependencies:
brace-expansion "^2.0.1"
@@ -6816,10 +6857,10 @@ mkdirp@^0.5.1, mkdirp@~0.5.1:
dependencies:
minimist "^1.2.6"
-moment-timezone@^0.5.34:
- version "0.5.43"
- resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.43.tgz#3dd7f3d0c67f78c23cd1906b9b2137a09b3c4790"
- integrity sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==
+moment-timezone@^0.5.45:
+ version "0.5.45"
+ resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.45.tgz#cb685acd56bac10e69d93c536366eb65aa6bcf5c"
+ integrity sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==
dependencies:
moment "^2.29.4"
@@ -8013,10 +8054,10 @@ range-parser@^1.2.1, range-parser@~1.2.1:
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
-raw-body@2.5.1:
- version "2.5.1"
- resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"
- integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==
+raw-body@2.5.2:
+ version "2.5.2"
+ resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a"
+ integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==
dependencies:
bytes "3.1.2"
http-errors "2.0.0"
@@ -8053,17 +8094,16 @@ react-copy-to-clipboard@^5.0.1:
copy-to-clipboard "^3.3.1"
prop-types "^15.8.1"
-react-datepicker@^4.25.0:
- version "4.25.0"
- resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-4.25.0.tgz#86b3ee8ac764bad1650046d0cf9280837bf6d845"
- integrity sha512-zB7CSi44SJ0sqo8hUQ3BF1saE/knn7u25qEMTO1CQGofY1VAKahO8k9drZtp0cfW1DMfoYLR3uSY1/uMvbEzbg==
+react-datepicker@^6.6.0:
+ version "6.6.0"
+ resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-6.6.0.tgz#0128547211c8fece08fef0b5406efffff2d36f1f"
+ integrity sha512-ERC0/Q4pPC9bNIcGUpdCbHc+oCxhkU3WI3UOGHkyJ3A9fqALCYpEmLc5S5xvAd7DuCDdbsyW97oRPM6pWWwjww==
dependencies:
- "@popperjs/core" "^2.11.8"
- classnames "^2.2.6"
- date-fns "^2.30.0"
+ "@floating-ui/react" "^0.26.2"
+ clsx "^2.1.0"
+ date-fns "^3.3.1"
prop-types "^15.7.2"
react-onclickoutside "^6.13.0"
- react-popper "^2.3.0"
react-dev-utils@^12.0.1:
version "12.0.1"
@@ -8108,11 +8148,6 @@ react-error-overlay@^6.0.11:
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
-react-fast-compare@^3.0.1:
- version "3.2.1"
- resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.1.tgz#53933d9e14f364281d6cba24bfed7a4afb808b5f"
- integrity sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg==
-
react-image-crop@^10.1.8:
version "10.1.8"
resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-10.1.8.tgz#6f7b33d069f6cfb887e66faee16a9fb2e6d31137"
@@ -8170,14 +8205,6 @@ react-onclickoutside@^6.13.0:
resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.13.0.tgz#e165ea4e5157f3da94f4376a3ab3e22a565f4ffc"
integrity sha512-ty8So6tcUpIb+ZE+1HAhbLROvAIJYyJe/1vRrrcmW+jLsaM+/powDRqxzo6hSh9CuRZGSL1Q8mvcF5WRD93a0A==
-react-popper@^2.3.0:
- version "2.3.0"
- resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.3.0.tgz#17891c620e1320dce318bad9fede46a5f71c70ba"
- integrity sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==
- dependencies:
- react-fast-compare "^3.0.1"
- warning "^4.0.2"
-
react-refresh@^0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046"
@@ -8281,13 +8308,14 @@ react-select@^5.8.0:
react-transition-group "^4.3.0"
use-isomorphic-layout-effect "^1.1.2"
-react-smooth@^2.0.5:
- version "2.0.5"
- resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-2.0.5.tgz#d153b7dffc7143d0c99e82db1532f8cf93f20ecd"
- integrity sha512-BMP2Ad42tD60h0JW6BFaib+RJuV5dsXJK9Baxiv/HlNFjvRLqA9xrNKxVWnUIZPQfzUwGXIlU/dSYLU+54YGQA==
+react-smooth@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-4.0.0.tgz#69e560ab69b69a066187d70cb92c1a664f7f046a"
+ integrity sha512-2NMXOBY1uVUQx1jBeENGA497HK20y6CPGYL1ZnJLeoQ8rrc3UfmOM82sRxtzpcoCkUMy4CS0RGylfuVhuFjBgg==
dependencies:
- fast-equals "^5.0.0"
- react-transition-group "2.9.0"
+ fast-equals "^5.0.1"
+ prop-types "^15.8.1"
+ react-transition-group "^4.4.5"
react-textarea-autosize@^8.3.2:
version "8.4.1"
@@ -8298,25 +8326,15 @@ react-textarea-autosize@^8.3.2:
use-composed-ref "^1.3.0"
use-latest "^1.2.1"
-react-tooltip@5.21.7:
- version "5.21.7"
- resolved "https://registry.yarnpkg.com/react-tooltip/-/react-tooltip-5.21.7.tgz#37802a9e6395248c76a9b059c0dbdba297054dea"
- integrity sha512-wixHG2I2umJIL4iHG1ewIkkiqyJuXTHJwZWry8jFKonkQQ9xhwPgqGXqE8NGyJGN7n0Did1HjBnaCW5NsGr7zA==
+react-tooltip@5.26.3:
+ version "5.26.3"
+ resolved "https://registry.yarnpkg.com/react-tooltip/-/react-tooltip-5.26.3.tgz#bcb9a53e15bdbf9ae007ddf8bf413a317a637054"
+ integrity sha512-MpYAws8CEHUd/RC4GaDCdoceph/T4KHM5vS5Dbk8FOmLMvvIht2ymP2htWdrke7K6lqPO8rz8+bnwWUIXeDlzg==
dependencies:
- "@floating-ui/dom" "^1.0.0"
+ "@floating-ui/dom" "^1.6.1"
classnames "^2.3.0"
-react-transition-group@2.9.0:
- version "2.9.0"
- resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"
- integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==
- dependencies:
- dom-helpers "^3.4.0"
- loose-envify "^1.4.0"
- prop-types "^15.6.2"
- react-lifecycles-compat "^3.0.4"
-
-react-transition-group@^4.3.0:
+react-transition-group@^4.3.0, react-transition-group@^4.4.5:
version "4.4.5"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1"
integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==
@@ -8376,16 +8394,16 @@ recharts-scale@^0.4.4:
dependencies:
decimal.js-light "^2.4.1"
-recharts@^2.10.3:
- version "2.10.3"
- resolved "https://registry.yarnpkg.com/recharts/-/recharts-2.10.3.tgz#a5dbe219354d744701e8bbd116fe42393af92f6b"
- integrity sha512-G4J96fKTZdfFQd6aQnZjo2nVNdXhp+uuLb00+cBTGLo85pChvm1+E67K3wBOHDE/77spcYb2Cy9gYWVqiZvQCg==
+recharts@^2.12.4:
+ version "2.12.4"
+ resolved "https://registry.yarnpkg.com/recharts/-/recharts-2.12.4.tgz#e560a57cd44ab554c99a0d93bdd58d059b309a2e"
+ integrity sha512-dM4skmk4fDKEDjL9MNunxv6zcTxePGVEzRnLDXALRpfJ85JoQ0P0APJ/CoJlmnQI0gPjBlOkjzrwrfQrRST3KA==
dependencies:
clsx "^2.0.0"
eventemitter3 "^4.0.1"
- lodash "^4.17.19"
+ lodash "^4.17.21"
react-is "^16.10.2"
- react-smooth "^2.0.5"
+ react-smooth "^4.0.0"
recharts-scale "^0.4.4"
tiny-invariant "^1.3.1"
victory-vendor "^36.6.8"
@@ -8641,10 +8659,10 @@ sass-loader@^12.3.0:
klona "^2.0.4"
neo-async "^2.6.2"
-sass@^1.69.6:
- version "1.69.6"
- resolved "https://registry.yarnpkg.com/sass/-/sass-1.69.6.tgz#88ae1f93facc46d2da9b0bdd652d65068bcfa397"
- integrity sha512-qbRr3k9JGHWXCvZU77SD2OTwUlC+gNT+61JOLcmLm+XqH4h/5D+p4IIsxvpkB89S9AwJOyb5+rWNpIucaFxSFQ==
+sass@^1.74.1:
+ version "1.74.1"
+ resolved "https://registry.yarnpkg.com/sass/-/sass-1.74.1.tgz#686fc227d3707dd25cb2925e1db8e4562be29319"
+ integrity sha512-w0Z9p/rWZWelb88ISOLyvqTWGmtmu2QJICqDBGyNnfG4OUnPX9BBjjYIXUpXCMOOg5MQWNpqzt876la1fsTvUA==
dependencies:
chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0"
@@ -8768,10 +8786,10 @@ serialize-javascript@^4.0.0:
dependencies:
randombytes "^2.1.0"
-serialize-javascript@^6.0.0, serialize-javascript@^6.0.1:
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.1.tgz#b206efb27c3da0b0ab6b52f48d170b7996458e5c"
- integrity sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==
+serialize-javascript@^6.0.0, serialize-javascript@^6.0.1, serialize-javascript@^6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2"
+ integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==
dependencies:
randombytes "^2.1.0"
@@ -8866,10 +8884,10 @@ slash@^4.0.0:
resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7"
integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
-socket.io-client@^4.7.2:
- version "4.7.2"
- resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.2.tgz#f2f13f68058bd4e40f94f2a1541f275157ff2c08"
- integrity sha512-vtA0uD4ibrYD793SOIAwlo8cj6haOeMHrGvwPxJsxH7CeIksqJ+3Zc06RvWTIFgiSqx4A3sOnTXpfAEE2Zyz6w==
+socket.io-client@^4.7.5:
+ version "4.7.5"
+ resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.5.tgz#919be76916989758bdc20eec63f7ee0ae45c05b7"
+ integrity sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==
dependencies:
"@socket.io/component-emitter" "~3.1.0"
debug "~4.3.2"
@@ -9272,6 +9290,11 @@ symbol-tree@^3.2.4:
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
+tabbable@^6.0.1:
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97"
+ integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
+
tailwindcss@^3.0.2:
version "3.3.1"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.1.tgz#b6662fab6a9b704779e48d083a9fef5a81d2b81e"
@@ -9787,13 +9810,6 @@ walker@^1.0.7:
dependencies:
makeerror "1.0.12"
-warning@^4.0.2:
- version "4.0.3"
- resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
- integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
- dependencies:
- loose-envify "^1.0.0"
-
watchpack@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
@@ -9835,9 +9851,9 @@ webidl-conversions@^7.0.0:
integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
webpack-dev-middleware@^5.3.1:
- version "5.3.3"
- resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz#efae67c2793908e7311f1d9b06f2a08dcc97e51f"
- integrity sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==
+ version "5.3.4"
+ resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz#eb7b39281cbce10e104eb2b8bf2b63fce49a3517"
+ integrity sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==
dependencies:
colorette "^2.0.10"
memfs "^3.4.3"
@@ -10272,10 +10288,10 @@ ws@^8.13.0:
resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0"
integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==
-ws@^8.14.2:
- version "8.14.2"
- resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f"
- integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==
+ws@^8.16.0:
+ version "8.16.0"
+ resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4"
+ integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==
ws@~8.11.0:
version "8.11.0"
diff --git a/misc/sbs-db.sql b/misc/sbs-db.sql
new file mode 100644
index 000000000..e4a74e733
--- /dev/null
+++ b/misc/sbs-db.sql
@@ -0,0 +1,1158 @@
+-- Dump of empty SBS database, alembic revision b133d5e0e198 (head)
+
+/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
+/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
+/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
+/*!50503 SET NAMES utf8mb4 */;
+/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
+/*!40103 SET TIME_ZONE='+00:00' */;
+/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
+/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
+/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
+/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
+DROP TABLE IF EXISTS `alembic_version`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `alembic_version` (
+ `version_num` varchar(32) NOT NULL,
+ PRIMARY KEY (`version_num`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `alembic_version` WRITE;
+/*!40000 ALTER TABLE `alembic_version` DISABLE KEYS */;
+INSERT INTO `alembic_version` VALUES ('b133d5e0e198');
+/*!40000 ALTER TABLE `alembic_version` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `api_keys`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `api_keys` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `hashed_secret` varchar(255) NOT NULL,
+ `organisation_id` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ `description` text NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `users_unique_hashed_secret` (`hashed_secret`),
+ KEY `organisation_id` (`organisation_id`),
+ CONSTRAINT `api_keys_ibfk_1` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `api_keys` WRITE;
+/*!40000 ALTER TABLE `api_keys` DISABLE KEYS */;
+/*!40000 ALTER TABLE `api_keys` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `audit_logs`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `audit_logs` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `user_id` int(11) DEFAULT NULL,
+ `subject_id` int(11) DEFAULT NULL,
+ `target_type` varchar(255) DEFAULT NULL,
+ `target_id` int(11) DEFAULT NULL,
+ `action` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `state_before` text,
+ `state_after` text,
+ `parent_name` varchar(100) DEFAULT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `target_name` varchar(255) DEFAULT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `audit_logs` WRITE;
+/*!40000 ALTER TABLE `audit_logs` DISABLE KEYS */;
+/*!40000 ALTER TABLE `audit_logs` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `aups`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `aups` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `au_version` varchar(255) NOT NULL,
+ `user_id` int(11) NOT NULL,
+ `agreed_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `user_id` (`user_id`),
+ CONSTRAINT `aups_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `aups` WRITE;
+/*!40000 ALTER TABLE `aups` DISABLE KEYS */;
+/*!40000 ALTER TABLE `aups` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `automatic_connection_allowed_organisations_services`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `automatic_connection_allowed_organisations_services` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `service_id` int(11) NOT NULL,
+ `organisation_id` int(11) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `service_id` (`service_id`),
+ KEY `organisation_id` (`organisation_id`),
+ CONSTRAINT `automatic_connection_allowed_organisations_services_ibfk_1` FOREIGN KEY (`service_id`) REFERENCES `services` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `automatic_connection_allowed_organisations_services_ibfk_2` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `automatic_connection_allowed_organisations_services` WRITE;
+/*!40000 ALTER TABLE `automatic_connection_allowed_organisations_services` DISABLE KEYS */;
+/*!40000 ALTER TABLE `automatic_connection_allowed_organisations_services` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `collaboration_memberships`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `collaboration_memberships` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `user_id` int(11) NOT NULL,
+ `collaboration_id` int(11) NOT NULL,
+ `role` varchar(255) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ `invitation_id` int(11) DEFAULT NULL,
+ `status` varchar(255) NOT NULL DEFAULT 'active',
+ `expiry_date` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `unique_members` (`user_id`,`collaboration_id`),
+ KEY `collaboration_id` (`collaboration_id`),
+ KEY `col_membership_invitation` (`invitation_id`),
+ CONSTRAINT `col_membership_invitation` FOREIGN KEY (`invitation_id`) REFERENCES `invitations` (`id`),
+ CONSTRAINT `collaboration_memberships_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `collaboration_memberships_ibfk_2` FOREIGN KEY (`collaboration_id`) REFERENCES `collaborations` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `collaboration_memberships` WRITE;
+/*!40000 ALTER TABLE `collaboration_memberships` DISABLE KEYS */;
+/*!40000 ALTER TABLE `collaboration_memberships` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `collaboration_memberships_groups`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `collaboration_memberships_groups` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `collaboration_membership_id` int(11) NOT NULL,
+ `group_id` int(11) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `collaboration_membership_id` (`collaboration_membership_id`),
+ KEY `group_id` (`group_id`),
+ CONSTRAINT `collaboration_memberships_groups_ibfk_1` FOREIGN KEY (`collaboration_membership_id`) REFERENCES `collaboration_memberships` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `collaboration_memberships_groups_ibfk_2` FOREIGN KEY (`group_id`) REFERENCES `groups` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `collaboration_memberships_groups` WRITE;
+/*!40000 ALTER TABLE `collaboration_memberships_groups` DISABLE KEYS */;
+/*!40000 ALTER TABLE `collaboration_memberships_groups` ENABLE KEYS */;
+UNLOCK TABLES;
+/*!50003 SET @saved_cs_client = @@character_set_client */ ;
+/*!50003 SET @saved_cs_results = @@character_set_results */ ;
+/*!50003 SET @saved_col_connection = @@collation_connection */ ;
+/*!50003 SET character_set_client = utf8mb4 */ ;
+/*!50003 SET character_set_results = utf8mb4 */ ;
+/*!50003 SET collation_connection = utf8mb4_general_ci */ ;
+/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
+/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION' */ ;
+DELIMITER ;;
+/*!50003 CREATE*/ /*!50017 DEFINER=`sbs`@`localhost`*/ /*!50003 TRIGGER collaboration_memberships_groups_collaboration_id BEFORE INSERT ON collaboration_memberships_groups FOR EACH ROW BEGIN IF (SELECT cm.collaboration_id FROM collaboration_memberships cm WHERE cm.id = NEW.collaboration_membership_id) <> (SELECT g.collaboration_id FROM `groups` g WHERE g.id = NEW.group_id) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'The collaboration ID must be equal for collaboration_memberships_groups'; END IF ; END */;;
+DELIMITER ;
+/*!50003 SET sql_mode = @saved_sql_mode */ ;
+/*!50003 SET character_set_client = @saved_cs_client */ ;
+/*!50003 SET character_set_results = @saved_cs_results */ ;
+/*!50003 SET collation_connection = @saved_col_connection */ ;
+DROP TABLE IF EXISTS `collaboration_requests`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `collaboration_requests` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `name` varchar(255) NOT NULL,
+ `short_name` varchar(255) NOT NULL,
+ `description` text,
+ `message` text,
+ `accepted_user_policy` varchar(255) DEFAULT NULL,
+ `organisation_id` int(11) NOT NULL,
+ `requester_id` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ `website_url` varchar(512) DEFAULT NULL,
+ `logo` mediumtext,
+ `status` varchar(255) NOT NULL,
+ `rejection_reason` text,
+ `uuid4` varchar(255) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `collaboration_requests_uuid4` (`uuid4`),
+ KEY `organisation_id` (`organisation_id`),
+ KEY `requester_id` (`requester_id`),
+ CONSTRAINT `collaboration_requests_ibfk_1` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `collaboration_requests_ibfk_2` FOREIGN KEY (`requester_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `collaboration_requests` WRITE;
+/*!40000 ALTER TABLE `collaboration_requests` DISABLE KEYS */;
+/*!40000 ALTER TABLE `collaboration_requests` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `collaboration_requests_units`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `collaboration_requests_units` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `collaboration_request_id` int(11) NOT NULL,
+ `unit_id` int(11) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `collaboration_request_id` (`collaboration_request_id`),
+ KEY `unit_id` (`unit_id`),
+ CONSTRAINT `collaboration_requests_units_ibfk_1` FOREIGN KEY (`collaboration_request_id`) REFERENCES `collaboration_requests` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `collaboration_requests_units_ibfk_2` FOREIGN KEY (`unit_id`) REFERENCES `units` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `collaboration_requests_units` WRITE;
+/*!40000 ALTER TABLE `collaboration_requests_units` DISABLE KEYS */;
+/*!40000 ALTER TABLE `collaboration_requests_units` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `collaboration_tags`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `collaboration_tags` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `collaboration_id` int(11) NOT NULL,
+ `tag_id` int(11) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `collaboration_tags_unique` (`collaboration_id`,`tag_id`),
+ KEY `tag_id` (`tag_id`),
+ CONSTRAINT `collaboration_tags_ibfk_1` FOREIGN KEY (`collaboration_id`) REFERENCES `collaborations` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `collaboration_tags_ibfk_2` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `collaboration_tags` WRITE;
+/*!40000 ALTER TABLE `collaboration_tags` DISABLE KEYS */;
+/*!40000 ALTER TABLE `collaboration_tags` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `collaboration_units`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `collaboration_units` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `collaboration_id` int(11) NOT NULL,
+ `unit_id` int(11) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `collaboration_id` (`collaboration_id`),
+ KEY `unit_id` (`unit_id`),
+ CONSTRAINT `collaboration_units_ibfk_1` FOREIGN KEY (`collaboration_id`) REFERENCES `collaborations` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `collaboration_units_ibfk_2` FOREIGN KEY (`unit_id`) REFERENCES `units` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `collaboration_units` WRITE;
+/*!40000 ALTER TABLE `collaboration_units` DISABLE KEYS */;
+/*!40000 ALTER TABLE `collaboration_units` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `collaborations`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `collaborations` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `identifier` varchar(255) NOT NULL,
+ `name` varchar(255) NOT NULL,
+ `description` text NOT NULL,
+ `accepted_user_policy` mediumtext,
+ `organisation_id` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ `short_name` varchar(255) NOT NULL,
+ `global_urn` text NOT NULL,
+ `disable_join_requests` tinyint(1) DEFAULT '0',
+ `disclose_member_information` tinyint(1) DEFAULT '0',
+ `disclose_email_information` tinyint(1) DEFAULT '0',
+ `logo` mediumtext,
+ `website_url` varchar(512) DEFAULT NULL,
+ `uuid4` varchar(255) NOT NULL,
+ `status` varchar(255) NOT NULL DEFAULT 'active',
+ `last_activity_date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `expiry_date` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `collaborations_unique_name` (`name`,`organisation_id`),
+ UNIQUE KEY `collaborations_unique_short_name` (`short_name`,`organisation_id`),
+ UNIQUE KEY `collaborations_uuid4` (`uuid4`),
+ UNIQUE KEY `collaborations_unique_identifier` (`identifier`),
+ KEY `organisation_id` (`organisation_id`),
+ FULLTEXT KEY `ft_collaborations_search` (`name`,`description`),
+ CONSTRAINT `collaborations_ibfk_1` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `collaborations` WRITE;
+/*!40000 ALTER TABLE `collaborations` DISABLE KEYS */;
+/*!40000 ALTER TABLE `collaborations` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `groups`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `groups` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `name` varchar(255) NOT NULL,
+ `short_name` varchar(255) NOT NULL,
+ `global_urn` text NOT NULL,
+ `description` text,
+ `auto_provision_members` tinyint(1) DEFAULT NULL,
+ `collaboration_id` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ `identifier` varchar(255) NOT NULL,
+ `service_group_id` int(11) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `group_short_name` (`short_name`,`collaboration_id`),
+ UNIQUE KEY `groups_unique_identifier` (`identifier`),
+ UNIQUE KEY `groups_unique_name_service` (`name`,`collaboration_id`,`service_group_id`),
+ KEY `collaboration_id` (`collaboration_id`),
+ KEY `groups_ibfk_2` (`service_group_id`),
+ CONSTRAINT `groups_ibfk_1` FOREIGN KEY (`collaboration_id`) REFERENCES `collaborations` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `groups_ibfk_2` FOREIGN KEY (`service_group_id`) REFERENCES `service_groups` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `groups` WRITE;
+/*!40000 ALTER TABLE `groups` DISABLE KEYS */;
+/*!40000 ALTER TABLE `groups` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `groups_invitations`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `groups_invitations` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `group_id` int(11) NOT NULL,
+ `invitation_id` int(11) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `group_id` (`group_id`),
+ KEY `invitation_id` (`invitation_id`),
+ CONSTRAINT `groups_invitations_ibfk_1` FOREIGN KEY (`group_id`) REFERENCES `groups` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `groups_invitations_ibfk_2` FOREIGN KEY (`invitation_id`) REFERENCES `invitations` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `groups_invitations` WRITE;
+/*!40000 ALTER TABLE `groups_invitations` DISABLE KEYS */;
+/*!40000 ALTER TABLE `groups_invitations` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `invitations`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `invitations` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `hash` varchar(255) NOT NULL,
+ `message` text,
+ `invitee_email` varchar(255) NOT NULL,
+ `collaboration_id` int(11) NOT NULL,
+ `user_id` int(11) NOT NULL,
+ `intended_role` varchar(255) NOT NULL,
+ `expiry_date` datetime DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `membership_expiry_date` datetime DEFAULT NULL,
+ `external_identifier` varchar(255) DEFAULT NULL,
+ `status` varchar(255) NOT NULL,
+ `reminder_send` tinyint(1) DEFAULT '0',
+ PRIMARY KEY (`id`),
+ KEY `collaboration_id` (`collaboration_id`),
+ KEY `user_id` (`user_id`),
+ CONSTRAINT `invitations_ibfk_1` FOREIGN KEY (`collaboration_id`) REFERENCES `collaborations` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `invitations_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `invitations` WRITE;
+/*!40000 ALTER TABLE `invitations` DISABLE KEYS */;
+/*!40000 ALTER TABLE `invitations` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `ip_networks`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `ip_networks` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `network_value` text NOT NULL,
+ `service_id` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `service_id` (`service_id`),
+ CONSTRAINT `ip_networks_ibfk_1` FOREIGN KEY (`service_id`) REFERENCES `services` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `ip_networks` WRITE;
+/*!40000 ALTER TABLE `ip_networks` DISABLE KEYS */;
+/*!40000 ALTER TABLE `ip_networks` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `join_requests`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `join_requests` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `reference` text,
+ `message` text,
+ `user_id` int(11) NOT NULL,
+ `collaboration_id` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `hash` varchar(512) DEFAULT NULL,
+ `status` varchar(255) NOT NULL,
+ `rejection_reason` text,
+ PRIMARY KEY (`id`),
+ KEY `user_id` (`user_id`),
+ KEY `collaboration_id` (`collaboration_id`),
+ CONSTRAINT `join_requests_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `join_requests_ibfk_2` FOREIGN KEY (`collaboration_id`) REFERENCES `collaborations` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `join_requests` WRITE;
+/*!40000 ALTER TABLE `join_requests` DISABLE KEYS */;
+/*!40000 ALTER TABLE `join_requests` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `organisation_aups`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `organisation_aups` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `aup_url` varchar(255) NOT NULL,
+ `user_id` int(11) NOT NULL,
+ `organisation_id` int(11) NOT NULL,
+ `agreed_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `user_id` (`user_id`),
+ KEY `organisation_id` (`organisation_id`),
+ CONSTRAINT `organisation_aups_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `organisation_aups_ibfk_2` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `organisation_aups` WRITE;
+/*!40000 ALTER TABLE `organisation_aups` DISABLE KEYS */;
+/*!40000 ALTER TABLE `organisation_aups` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `organisation_invitations`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `organisation_invitations` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `hash` varchar(255) NOT NULL,
+ `message` text,
+ `invitee_email` varchar(255) NOT NULL,
+ `organisation_id` int(11) NOT NULL,
+ `user_id` int(11) NOT NULL,
+ `expiry_date` datetime DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `intended_role` varchar(255) NOT NULL,
+ `reminder_send` tinyint(1) DEFAULT '0',
+ PRIMARY KEY (`id`),
+ KEY `organisation_id` (`organisation_id`),
+ KEY `user_id` (`user_id`),
+ CONSTRAINT `organisation_invitations_ibfk_1` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `organisation_invitations_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `organisation_invitations` WRITE;
+/*!40000 ALTER TABLE `organisation_invitations` DISABLE KEYS */;
+/*!40000 ALTER TABLE `organisation_invitations` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `organisation_membership_units`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `organisation_membership_units` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `organisation_membership_id` int(11) NOT NULL,
+ `unit_id` int(11) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `organisation_membership_id` (`organisation_membership_id`),
+ KEY `unit_id` (`unit_id`),
+ CONSTRAINT `organisation_membership_units_ibfk_1` FOREIGN KEY (`organisation_membership_id`) REFERENCES `organisation_memberships` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `organisation_membership_units_ibfk_2` FOREIGN KEY (`unit_id`) REFERENCES `units` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `organisation_membership_units` WRITE;
+/*!40000 ALTER TABLE `organisation_membership_units` DISABLE KEYS */;
+/*!40000 ALTER TABLE `organisation_membership_units` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `organisation_memberships`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `organisation_memberships` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `user_id` int(11) NOT NULL,
+ `organisation_id` int(11) NOT NULL,
+ `role` varchar(255) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `unique_members` (`user_id`,`organisation_id`),
+ KEY `organisation_id` (`organisation_id`),
+ CONSTRAINT `organisation_memberships_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `organisation_memberships_ibfk_2` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `organisation_memberships` WRITE;
+/*!40000 ALTER TABLE `organisation_memberships` DISABLE KEYS */;
+/*!40000 ALTER TABLE `organisation_memberships` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `organisations`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `organisations` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `name` varchar(255) NOT NULL,
+ `description` text,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ `short_name` varchar(255) NOT NULL,
+ `collaboration_creation_allowed` tinyint(1) DEFAULT '0',
+ `identifier` varchar(255) NOT NULL,
+ `logo` mediumtext,
+ `category` varchar(255) DEFAULT NULL,
+ `on_boarding_msg` mediumtext,
+ `services_restricted` tinyint(1) DEFAULT '0',
+ `uuid4` varchar(255) NOT NULL,
+ `service_connection_requires_approval` tinyint(1) DEFAULT '0',
+ `accepted_user_policy` varchar(255) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `organisations_unique_name` (`name`),
+ UNIQUE KEY `organisations_uuid4` (`uuid4`),
+ UNIQUE KEY `organisations_unique_short_name` (`short_name`),
+ FULLTEXT KEY `ft_organisations_search` (`name`,`description`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `organisations` WRITE;
+/*!40000 ALTER TABLE `organisations` DISABLE KEYS */;
+/*!40000 ALTER TABLE `organisations` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `organisations_services`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `organisations_services` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `organisation_id` int(11) NOT NULL,
+ `service_id` int(11) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `organisation_id` (`organisation_id`),
+ KEY `service_id` (`service_id`),
+ CONSTRAINT `organisations_services_ibfk_1` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `organisations_services_ibfk_2` FOREIGN KEY (`service_id`) REFERENCES `services` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `organisations_services` WRITE;
+/*!40000 ALTER TABLE `organisations_services` DISABLE KEYS */;
+/*!40000 ALTER TABLE `organisations_services` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `pam_sso_sessions`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `pam_sso_sessions` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `session_id` varchar(255) NOT NULL,
+ `attribute` varchar(255) DEFAULT NULL,
+ `user_id` int(11) DEFAULT NULL,
+ `service_id` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `pin` char(4) DEFAULT NULL,
+ `pin_shown` tinyint(1) DEFAULT '0',
+ PRIMARY KEY (`id`),
+ KEY `user_id` (`user_id`),
+ KEY `service_id` (`service_id`),
+ CONSTRAINT `pam_sso_sessions_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `pam_sso_sessions_ibfk_2` FOREIGN KEY (`service_id`) REFERENCES `services` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `pam_sso_sessions` WRITE;
+/*!40000 ALTER TABLE `pam_sso_sessions` DISABLE KEYS */;
+/*!40000 ALTER TABLE `pam_sso_sessions` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `schac_home_organisations`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `schac_home_organisations` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `name` varchar(255) NOT NULL,
+ `organisation_id` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `schac_home_organisation_name_unique` (`name`),
+ KEY `organisation_id` (`organisation_id`),
+ CONSTRAINT `schac_home_organisations_ibfk_1` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `schac_home_organisations` WRITE;
+/*!40000 ALTER TABLE `schac_home_organisations` DISABLE KEYS */;
+/*!40000 ALTER TABLE `schac_home_organisations` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `service_aups`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `service_aups` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `aup_url` varchar(255) NOT NULL,
+ `user_id` int(11) NOT NULL,
+ `service_id` int(11) NOT NULL,
+ `agreed_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `user_id` (`user_id`),
+ KEY `service_id` (`service_id`),
+ CONSTRAINT `service_aups_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `service_aups_ibfk_2` FOREIGN KEY (`service_id`) REFERENCES `services` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `service_aups` WRITE;
+/*!40000 ALTER TABLE `service_aups` DISABLE KEYS */;
+/*!40000 ALTER TABLE `service_aups` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `service_connection_requests`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `service_connection_requests` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `message` text,
+ `requester_id` int(11) NOT NULL,
+ `service_id` int(11) NOT NULL,
+ `collaboration_id` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ `hash` varchar(512) DEFAULT NULL,
+ `pending_organisation_approval` tinyint(1) DEFAULT '0',
+ `status` varchar(255) NOT NULL,
+ `rejection_reason` text,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `service_connection_requests_unique_hash` (`hash`),
+ KEY `requester_id` (`requester_id`),
+ KEY `service_id` (`service_id`),
+ KEY `collaboration_id` (`collaboration_id`),
+ CONSTRAINT `service_connection_requests_ibfk_1` FOREIGN KEY (`requester_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `service_connection_requests_ibfk_2` FOREIGN KEY (`service_id`) REFERENCES `services` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `service_connection_requests_ibfk_3` FOREIGN KEY (`collaboration_id`) REFERENCES `collaborations` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `service_connection_requests` WRITE;
+/*!40000 ALTER TABLE `service_connection_requests` DISABLE KEYS */;
+/*!40000 ALTER TABLE `service_connection_requests` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `service_groups`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `service_groups` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `name` varchar(255) NOT NULL,
+ `short_name` varchar(255) NOT NULL,
+ `description` text,
+ `auto_provision_members` tinyint(1) DEFAULT NULL,
+ `service_id` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `service_groups_unique_name` (`name`,`service_id`),
+ UNIQUE KEY `service_groups_unique_short_name` (`short_name`,`service_id`),
+ KEY `service_id` (`service_id`),
+ CONSTRAINT `service_groups_ibfk_1` FOREIGN KEY (`service_id`) REFERENCES `services` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `service_groups` WRITE;
+/*!40000 ALTER TABLE `service_groups` DISABLE KEYS */;
+/*!40000 ALTER TABLE `service_groups` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `service_invitations`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `service_invitations` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `hash` varchar(255) NOT NULL,
+ `message` text,
+ `invitee_email` varchar(255) NOT NULL,
+ `intended_role` varchar(255) NOT NULL,
+ `service_id` int(11) NOT NULL,
+ `user_id` int(11) NOT NULL,
+ `expiry_date` datetime DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `reminder_send` tinyint(1) DEFAULT '0',
+ PRIMARY KEY (`id`),
+ KEY `service_id` (`service_id`),
+ KEY `user_id` (`user_id`),
+ CONSTRAINT `service_invitations_ibfk_1` FOREIGN KEY (`service_id`) REFERENCES `services` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `service_invitations_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `service_invitations` WRITE;
+/*!40000 ALTER TABLE `service_invitations` DISABLE KEYS */;
+/*!40000 ALTER TABLE `service_invitations` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `service_memberships`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `service_memberships` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `user_id` int(11) NOT NULL,
+ `service_id` int(11) NOT NULL,
+ `role` varchar(255) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `unique_members` (`user_id`,`service_id`),
+ KEY `service_id` (`service_id`),
+ CONSTRAINT `service_memberships_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `service_memberships_ibfk_2` FOREIGN KEY (`service_id`) REFERENCES `services` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `service_memberships` WRITE;
+/*!40000 ALTER TABLE `service_memberships` DISABLE KEYS */;
+/*!40000 ALTER TABLE `service_memberships` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `service_requests`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `service_requests` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `name` varchar(255) NOT NULL,
+ `abbreviation` varchar(255) NOT NULL,
+ `description` text,
+ `logo` mediumtext,
+ `providing_organisation` varchar(255) NOT NULL,
+ `uri` varchar(255) DEFAULT NULL,
+ `uri_info` varchar(255) DEFAULT NULL,
+ `contact_email` varchar(255) DEFAULT NULL,
+ `support_email` varchar(255) DEFAULT NULL,
+ `security_email` varchar(255) DEFAULT NULL,
+ `privacy_policy` varchar(255) DEFAULT NULL,
+ `accepted_user_policy` varchar(255) DEFAULT NULL,
+ `connection_type` varchar(255) DEFAULT NULL,
+ `redirect_urls` text,
+ `saml_metadata` text,
+ `saml_metadata_url` varchar(255) DEFAULT NULL,
+ `comments` text,
+ `requester_id` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `status` varchar(255) DEFAULT NULL,
+ `uuid4` varchar(255) NOT NULL,
+ `rejection_reason` text,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `service_requests_uuid4` (`uuid4`),
+ KEY `requester_id` (`requester_id`),
+ CONSTRAINT `service_requests_ibfk_1` FOREIGN KEY (`requester_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `service_requests` WRITE;
+/*!40000 ALTER TABLE `service_requests` DISABLE KEYS */;
+/*!40000 ALTER TABLE `service_requests` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `service_tokens`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `service_tokens` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `hashed_token` varchar(512) NOT NULL,
+ `description` text NOT NULL,
+ `service_id` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ `token_type` varchar(255) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `service_id` (`service_id`),
+ CONSTRAINT `service_tokens_ibfk_1` FOREIGN KEY (`service_id`) REFERENCES `services` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `service_tokens` WRITE;
+/*!40000 ALTER TABLE `service_tokens` DISABLE KEYS */;
+/*!40000 ALTER TABLE `service_tokens` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `services`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `services` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `entity_id` varchar(255) NOT NULL,
+ `name` varchar(255) NOT NULL,
+ `description` text,
+ `address` text,
+ `identity_type` varchar(255) DEFAULT NULL,
+ `uri` varchar(255) DEFAULT NULL,
+ `accepted_user_policy` varchar(255) DEFAULT NULL,
+ `contact_email` varchar(255) DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ `automatic_connection_allowed` tinyint(1) DEFAULT '1',
+ `allow_restricted_orgs` tinyint(1) DEFAULT NULL,
+ `logo` mediumtext,
+ `access_allowed_for_all` tinyint(1) DEFAULT '0',
+ `uuid4` varchar(255) NOT NULL,
+ `non_member_users_access_allowed` tinyint(1) DEFAULT '0',
+ `abbreviation` varchar(255) NOT NULL,
+ `privacy_policy` varchar(255) DEFAULT NULL,
+ `ldap_password` varchar(255) DEFAULT NULL,
+ `support_email` varchar(255) DEFAULT NULL,
+ `security_email` varchar(255) DEFAULT NULL,
+ `token_enabled` tinyint(1) DEFAULT '0',
+ `token_validity_days` int(11) DEFAULT '1',
+ `pam_web_sso_enabled` tinyint(1) DEFAULT '0',
+ `uri_info` varchar(255) DEFAULT NULL,
+ `scim_enabled` tinyint(1) DEFAULT '0',
+ `scim_url` varchar(255) DEFAULT NULL,
+ `scim_bearer_token` mediumtext,
+ `sweep_scim_enabled` tinyint(1) DEFAULT '0',
+ `sweep_scim_daily_rate` int(11) DEFAULT '1',
+ `sweep_scim_last_run` datetime DEFAULT NULL,
+ `sweep_remove_orphans` tinyint(1) DEFAULT '0',
+ `scim_client_enabled` tinyint(1) DEFAULT '0',
+ `ldap_enabled` tinyint(1) DEFAULT '1',
+ `connection_setting` varchar(255) DEFAULT NULL,
+ `override_access_allowed_all_connections` tinyint(1) DEFAULT '0',
+ `ldap_identifier` varchar(255) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `services_unique_entity_id` (`entity_id`),
+ UNIQUE KEY `services_uuid4` (`uuid4`),
+ UNIQUE KEY `services_unique_abbreviation` (`abbreviation`),
+ FULLTEXT KEY `ft_services_search` (`name`,`entity_id`,`description`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `services` WRITE;
+/*!40000 ALTER TABLE `services` DISABLE KEYS */;
+/*!40000 ALTER TABLE `services` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `services_collaborations`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `services_collaborations` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `service_id` int(11) NOT NULL,
+ `collaboration_id` int(11) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `service_id` (`service_id`),
+ KEY `collaboration_id` (`collaboration_id`),
+ CONSTRAINT `services_collaborations_ibfk_1` FOREIGN KEY (`service_id`) REFERENCES `services` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `services_collaborations_ibfk_2` FOREIGN KEY (`collaboration_id`) REFERENCES `collaborations` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `services_collaborations` WRITE;
+/*!40000 ALTER TABLE `services_collaborations` DISABLE KEYS */;
+/*!40000 ALTER TABLE `services_collaborations` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `services_organisations`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `services_organisations` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `service_id` int(11) NOT NULL,
+ `organisation_id` int(11) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `service_id` (`service_id`),
+ KEY `organisation_id` (`organisation_id`),
+ CONSTRAINT `services_organisations_ibfk_1` FOREIGN KEY (`service_id`) REFERENCES `services` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `services_organisations_ibfk_2` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `services_organisations` WRITE;
+/*!40000 ALTER TABLE `services_organisations` DISABLE KEYS */;
+/*!40000 ALTER TABLE `services_organisations` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `ssh_keys`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `ssh_keys` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `ssh_value` text NOT NULL,
+ `user_id` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `name` varchar(255) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `user_id` (`user_id`),
+ CONSTRAINT `ssh_keys_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `ssh_keys` WRITE;
+/*!40000 ALTER TABLE `ssh_keys` DISABLE KEYS */;
+/*!40000 ALTER TABLE `ssh_keys` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `suspend_notifications`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `suspend_notifications` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `user_id` int(11) NOT NULL,
+ `sent_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `is_suspension` tinyint(1) DEFAULT '0',
+ `is_warning` tinyint(1) DEFAULT '0',
+ PRIMARY KEY (`id`),
+ KEY `user_id` (`user_id`),
+ CONSTRAINT `suspend_notifications_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `suspend_notifications` WRITE;
+/*!40000 ALTER TABLE `suspend_notifications` DISABLE KEYS */;
+/*!40000 ALTER TABLE `suspend_notifications` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `tags`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `tags` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `tag_value` text NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `tags` WRITE;
+/*!40000 ALTER TABLE `tags` DISABLE KEYS */;
+/*!40000 ALTER TABLE `tags` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `units`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `units` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `name` varchar(255) NOT NULL,
+ `organisation_id` int(11) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `organisation_units_unique` (`organisation_id`,`name`),
+ CONSTRAINT `units_ibfk_1` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `units` WRITE;
+/*!40000 ALTER TABLE `units` DISABLE KEYS */;
+/*!40000 ALTER TABLE `units` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `units_organisation_invitations`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `units_organisation_invitations` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `organisation_invitation_id` int(11) NOT NULL,
+ `unit_id` int(11) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `organisation_invitation_id` (`organisation_invitation_id`),
+ KEY `unit_id` (`unit_id`),
+ CONSTRAINT `units_organisation_invitations_ibfk_1` FOREIGN KEY (`organisation_invitation_id`) REFERENCES `organisation_invitations` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `units_organisation_invitations_ibfk_2` FOREIGN KEY (`unit_id`) REFERENCES `units` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `units_organisation_invitations` WRITE;
+/*!40000 ALTER TABLE `units_organisation_invitations` DISABLE KEYS */;
+/*!40000 ALTER TABLE `units_organisation_invitations` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `user_ip_networks`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `user_ip_networks` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `network_value` text NOT NULL,
+ `user_id` int(11) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `user_id` (`user_id`),
+ CONSTRAINT `user_ip_networks_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `user_ip_networks` WRITE;
+/*!40000 ALTER TABLE `user_ip_networks` DISABLE KEYS */;
+/*!40000 ALTER TABLE `user_ip_networks` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `user_logins`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `user_logins` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `login_type` varchar(255) NOT NULL,
+ `succeeded` tinyint(1) NOT NULL,
+ `user_id` int(11) DEFAULT NULL,
+ `user_uid` varchar(512) DEFAULT NULL,
+ `service_id` int(11) DEFAULT NULL,
+ `service_entity_id` varchar(512) DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `status` varchar(255) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `user_id` (`user_id`),
+ KEY `service_id` (`service_id`),
+ CONSTRAINT `user_logins_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL,
+ CONSTRAINT `user_logins_ibfk_2` FOREIGN KEY (`service_id`) REFERENCES `services` (`id`) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `user_logins` WRITE;
+/*!40000 ALTER TABLE `user_logins` DISABLE KEYS */;
+/*!40000 ALTER TABLE `user_logins` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `user_mails`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `user_mails` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `user_id` int(11) DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `recipient` mediumtext,
+ `name` varchar(255) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `user_id` (`user_id`),
+ CONSTRAINT `user_mails_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `user_mails` WRITE;
+/*!40000 ALTER TABLE `user_mails` DISABLE KEYS */;
+/*!40000 ALTER TABLE `user_mails` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `user_names_history`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `user_names_history` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `username` varchar(255) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `user_names_history_username` (`username`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `user_names_history` WRITE;
+/*!40000 ALTER TABLE `user_names_history` DISABLE KEYS */;
+/*!40000 ALTER TABLE `user_names_history` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `user_tokens`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `user_tokens` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `name` varchar(255) NOT NULL,
+ `description` text,
+ `hashed_token` varchar(255) NOT NULL,
+ `user_id` int(11) NOT NULL,
+ `service_id` int(11) NOT NULL,
+ `last_used_date` datetime DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `user_id` (`user_id`),
+ KEY `service_id` (`service_id`),
+ CONSTRAINT `user_tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `user_tokens_ibfk_2` FOREIGN KEY (`service_id`) REFERENCES `services` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `user_tokens` WRITE;
+/*!40000 ALTER TABLE `user_tokens` DISABLE KEYS */;
+/*!40000 ALTER TABLE `user_tokens` ENABLE KEYS */;
+UNLOCK TABLES;
+DROP TABLE IF EXISTS `users`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `users` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `uid` varchar(255) NOT NULL,
+ `name` varchar(255) DEFAULT NULL,
+ `email` varchar(255) DEFAULT NULL,
+ `nick_name` varchar(255) DEFAULT NULL,
+ `edu_members` text,
+ `affiliation` text,
+ `schac_home_organisation` varchar(255) DEFAULT NULL,
+ `family_name` varchar(255) DEFAULT NULL,
+ `given_name` varchar(255) DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `created_by` varchar(255) NOT NULL,
+ `updated_by` varchar(255) NOT NULL,
+ `scoped_affiliation` text,
+ `entitlement` text,
+ `address` varchar(255) DEFAULT NULL,
+ `username` varchar(255) DEFAULT NULL,
+ `confirmed_super_user` tinyint(1) DEFAULT '0',
+ `application_uid` varchar(255) DEFAULT NULL,
+ `eduperson_principal_name` varchar(255) DEFAULT NULL,
+ `last_login_date` datetime DEFAULT NULL,
+ `last_accessed_date` datetime DEFAULT NULL,
+ `suspended` tinyint(1) DEFAULT '0',
+ `second_factor_auth` varchar(255) DEFAULT NULL,
+ `mfa_reset_token` varchar(512) DEFAULT NULL,
+ `second_fa_uuid` varchar(255) DEFAULT NULL,
+ `home_organisation_uid` varchar(512) DEFAULT NULL,
+ `ssid_required` tinyint(1) DEFAULT '0',
+ `pam_last_login_date` datetime DEFAULT NULL,
+ `external_id` varchar(255) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `users_unique_uid` (`uid`),
+ UNIQUE KEY `users_unique_external_id` (`external_id`),
+ UNIQUE KEY `users_username` (`username`),
+ FULLTEXT KEY `ft_users_search` (`name`,`email`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+LOCK TABLES `users` WRITE;
+/*!40000 ALTER TABLE `users` DISABLE KEYS */;
+/*!40000 ALTER TABLE `users` ENABLE KEYS */;
+UNLOCK TABLES;
+/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
+
+/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
+/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
+/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
+/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
+/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
+/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
+/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
+
diff --git a/server/__main__.py b/server/__main__.py
index 708ed6df7..e0aebc5e1 100644
--- a/server/__main__.py
+++ b/server/__main__.py
@@ -3,11 +3,9 @@
# see https://github.com/gevent/gevent/issues/1016#issuecomment-328529454
import eventlet
-from server.api.service_request import service_request_api
-from server.api.unit import unit_api
-
-eventlet.monkey_patch()
+eventlet.monkey_patch(thread=False)
+from server.mail import MailMan
import logging
from sqlalchemy.orm import sessionmaker
import os
@@ -18,7 +16,6 @@
from server.api.mock_user import mock_user_api
import yaml
from flask import Flask, jsonify, request as current_request
-from flask_mail import Mail
from flask_migrate import Migrate
from flask_socketio import SocketIO
from munch import munchify
@@ -55,10 +52,12 @@
from server.api.service_group import service_group_api
from server.api.service_invitation import service_invitations_api
from server.api.service_membership import service_membership_api
+from server.api.service_request import service_request_api
from server.api.service_token import service_token_api
from server.api.system import system_api
from server.api.tag import tag_api
from server.api.token import token_api
+from server.api.unit import unit_api
from server.api.user import user_api
from server.api.user_login import user_login_api
from server.api.user_saml import user_saml_api
@@ -144,6 +143,9 @@ def page_not_found(_):
app.register_error_handler(404, page_not_found)
app.config["SQLALCHEMY_DATABASE_URI"] = config.database.uri
+if 'SBS_DB_URI_OVERRIDE' in os.environ:
+ # used for pytest fixture: override database uri to use a separate database for each worker
+ app.config["SQLALCHEMY_DATABASE_URI"] = os.environ['SBS_DB_URI_OVERRIDE']
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SQLALCHEMY_ECHO"] = False # Set to True for query debugging
# app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"pool_size": 25, "max_overflow": 15}
@@ -151,6 +153,7 @@ def page_not_found(_):
app.config["TESTING"] = test
app.config["MAIL_SERVER"] = config.mail.host
app.config["MAIL_PORT"] = int(config.mail.port)
+app.config["MAIL_BACKEND"] = "locmem" if is_test else "smtp"
app.config["OPEN_MAIL_IN_BROWSER"] = os.environ.get("OPEN_MAIL_IN_BROWSER", 0)
app.config["LOCAL"] = is_local
app.config["SESSION_COOKIE_SECURE"] = not is_test and not is_local
@@ -162,7 +165,7 @@ def page_not_found(_):
"invitation_role": invitation_role,
})
-app.mail = Mail(app)
+app.mail = MailMan(app)
app.json = DynamicExtendedJSONProvider(app)
diff --git a/server/api/audit_log.py b/server/api/audit_log.py
index dd7979305..b5781b601 100644
--- a/server/api/audit_log.py
+++ b/server/api/audit_log.py
@@ -4,9 +4,10 @@
from server.api.base import json_endpoint, query_param
from server.auth.security import current_user_id, confirm_allow_impersonation, confirm_write_access, \
- is_organisation_admin_or_manager, is_collaboration_admin, access_allowed_to_collaboration_as_org_member
+ is_organisation_admin_or_manager, is_collaboration_admin, has_org_manager_unit_access, is_application_admin, \
+ is_organisation_admin
from server.db.audit_mixin import AuditLog
-from server.db.domain import User, Organisation, Collaboration, Service
+from server.db.domain import User, Organisation, Collaboration, Service, Group, ServiceConnectionRequest
audit_log_api = Blueprint("audit_log_api", __name__, url_prefix="/api/audit_logs")
@@ -14,6 +15,7 @@
"organisations": Organisation,
"collaborations": Collaboration,
"services": Service,
+ "groups": Group
}
@@ -80,16 +82,45 @@ def override_func():
return is_organisation_admin_or_manager(query_id)
if collection_name == "collaborations":
co_admin = is_collaboration_admin(user_id=user_id, collaboration_id=query_id)
- return co_admin or access_allowed_to_collaboration_as_org_member(query_id)
+ if co_admin:
+ return True
+ collaboration = Collaboration.query.filter(Collaboration.id == query_id).one()
+ return has_org_manager_unit_access(user_id, collaboration)
return False
confirm_write_access(override_func=override_func)
- audit_logs = AuditLog.query \
- .filter(or_(and_(AuditLog.parent_id == query_id, AuditLog.parent_name == collection_name),
- and_(AuditLog.target_id == query_id, AuditLog.target_type == collection_name))) \
- .order_by(desc(AuditLog.created_at)) \
- .all()
+ conditions = [
+ and_(AuditLog.parent_id == query_id, AuditLog.parent_name == collection_name),
+ and_(AuditLog.target_id == query_id, AuditLog.target_type == collection_name)
+ ]
+ if collection_name == "collaborations":
+ groups = Group.query.options(load_only(Group.id)) \
+ .filter(Group.collaboration_id == query_id) \
+ .all()
+ group_identifiers = [group.id for group in groups]
+ conditions.append(and_(AuditLog.parent_id.in_(group_identifiers), AuditLog.parent_name == "groups"))
+
+ if collection_name == "services":
+ requests = ServiceConnectionRequest.query.options(load_only(ServiceConnectionRequest.id)) \
+ .filter(ServiceConnectionRequest.service_id == query_id) \
+ .all()
+ req_identifiers = [req.id for req in requests]
+ conditions.append(AuditLog.target_id.in_(req_identifiers))
+
+ query = AuditLog.query.filter(or_(*conditions))
+ audit_logs = query.order_by(desc(AuditLog.created_at)).all()
+
+ def access_collaboration_allowed(audit_log):
+ if audit_log.target_type != "collaborations":
+ return True
+ if audit_log.parent_name == "organisations" and is_organisation_admin(audit_log.parent_id):
+ return True
+ co = Collaboration.query.filter(Collaboration.id == audit_log.target_id).first()
+ return co and has_org_manager_unit_access(current_user_id(), co)
+
+ if collection_name == "organisations" and not is_application_admin():
+ audit_logs = list(filter(access_collaboration_allowed, audit_logs))
res = _add_references(audit_logs)
diff --git a/server/api/base.py b/server/api/base.py
index ac2dbe51b..5c01164a7 100644
--- a/server/api/base.py
+++ b/server/api/base.py
@@ -26,10 +26,6 @@
base_api = Blueprint("base_api", __name__, url_prefix="/")
-STATUS_OPEN = "open"
-STATUS_DENIED = "denied"
-STATUS_APPROVED = "approved"
-
_audit_trail_methods = ["PUT", "POST", "DELETE"]
@@ -137,7 +133,7 @@ def _audit_trail():
def send_error_mail(tb):
mail_conf = current_app.app_config.mail
- if mail_conf.send_exceptions and not os.environ.get("TESTING"):
+ if mail_conf.send_exceptions:
if "user" in session and "id" in session["user"]:
user_id = db.session.get(User, current_user_id()).email
elif request_context.get("is_authorized_api_call"):
diff --git a/server/api/collaboration.py b/server/api/collaboration.py
index d8f3a17f1..16e8e11be 100644
--- a/server/api/collaboration.py
+++ b/server/api/collaboration.py
@@ -1,8 +1,8 @@
import base64
-import urllib.request
import uuid
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
+import requests
from flasgger import swag_from
from flask import Blueprint, jsonify, request as current_request, current_app, g as request_context
from munch import munchify
@@ -13,14 +13,16 @@
from server.api.base import json_endpoint, query_param, replace_full_text_search_boolean_mode_chars, emit_socket
from server.api.exceptions import APIBadRequest
+from server.api.invitation import email_re, invitations_by_email
from server.api.service_group import create_service_groups
from server.api.unit import validate_units
from server.auth.secrets import generate_token
from server.auth.security import confirm_collaboration_admin, current_user_id, confirm_collaboration_member, \
confirm_authorized_api_call, \
confirm_allow_impersonation, confirm_organisation_admin_or_manager, confirm_external_api_call, \
- is_organisation_admin_or_manager, is_application_admin, confirm_service_admin, \
- confirm_organisation_api_collaboration, confirm_write_access, has_org_manager_unit_access, is_admin_user
+ is_organisation_admin_or_manager, is_application_admin, confirm_organisation_api_collaboration, \
+ confirm_write_access, has_org_manager_unit_access, is_admin_user, \
+ confirm_service_manager
from server.db.activity import update_last_activity_date
from server.db.db import db
from server.db.defaults import (default_expiry_date, full_text_search_autocomplete_limit, cleanse_short_name,
@@ -32,6 +34,7 @@
from server.db.models import update, save, delete, flatten, unique_model_objects
from server.mail import mail_collaboration_invitation
from server.scim.events import broadcast_collaboration_changed, broadcast_collaboration_deleted
+from server.tools import dt_now
collaboration_api = Blueprint("collaboration_api", __name__, url_prefix="/api/collaborations")
@@ -91,7 +94,7 @@ def _get_collaboration_membership(co_identifier, user_uid) -> CollaborationMembe
@collaboration_api.route("/admins/
", strict_slashes=False)
@json_endpoint
def collaboration_admins(service_id):
- confirm_service_admin(service_id)
+ confirm_service_manager(service_id)
service = db.session.get(Service, service_id)
collaborations = service.collaborations + flatten([o.collaborations for o in service.organisations])
return {c.name: c.admin_emails() for c in unique_model_objects(collaborations)}, 200
@@ -116,7 +119,8 @@ def collaboration_by_identifier():
collaboration = Collaboration.query \
.options(selectinload(Collaboration.groups)) \
- .options(selectinload(Collaboration.collaboration_memberships)) \
+ .options(selectinload(Collaboration.services)) \
+ .options(selectinload(Collaboration.organisation).selectinload(Organisation.services)) \
.filter(Collaboration.identifier == identifier) \
.one()
@@ -241,12 +245,8 @@ def _do_short_name_exists(name, organisation_id, existing_collaboration=""):
@json_endpoint
def may_request_collaboration():
user = db.session.get(User, current_user_id())
- sho = user.schac_home_organisation
- if not sho:
- return False, 200
- return Organisation.query \
- .join(Organisation.schac_home_organisations) \
- .filter(SchacHomeOrganisation.name == sho).count() > 0, 200
+ organisations = SchacHomeOrganisation.organisations_by_user_schac_home(user)
+ return len(organisations) > 0, 200
@collaboration_api.route("/all", strict_slashes=False)
@@ -426,7 +426,12 @@ def collaboration_invites():
membership_expiry_date = data.get("membership_expiry_date")
if membership_expiry_date:
- membership_expiry_date = datetime.fromtimestamp(data.get("membership_expiry_date"))
+ membership_expiry_date = datetime.fromtimestamp(data.get("membership_expiry_date"), tz=timezone.utc)
+
+ duplicate_invitations = [i.invitee_email for i in invitations_by_email(collaboration_id, administrators)]
+ if duplicate_invitations:
+ raise BadRequest(f"Duplicate email invitations: {duplicate_invitations}")
+
for administrator in administrators:
invitation = Invitation(hash=generate_token(), message=message, invitee_email=administrator,
collaboration=collaboration, user=user, status="open",
@@ -458,7 +463,7 @@ def unsuspend():
collaboration_id = data["collaboration_id"]
confirm_collaboration_admin(collaboration_id)
collaboration = db.session.get(Collaboration, collaboration_id)
- collaboration.last_activity_date = datetime.now()
+ collaboration.last_activity_date = dt_now()
collaboration.status = STATUS_ACTIVE
db.session.merge(collaboration)
db.session.commit()
@@ -472,7 +477,7 @@ def activate():
collaboration_id = data["collaboration_id"]
confirm_collaboration_admin(collaboration_id)
collaboration = db.session.get(Collaboration, collaboration_id)
- collaboration.last_activity_date = datetime.now()
+ collaboration.last_activity_date = dt_now()
collaboration.expiry_date = None
collaboration.status = STATUS_ACTIVE
db.session.merge(collaboration)
@@ -549,9 +554,9 @@ def save_collaboration_api():
logo = data.get("logo")
if logo and logo.startswith("http") and bool(uri_re.match(logo)):
try:
- res = urllib.request.urlopen(logo, timeout=10)
- if res.status == 200:
- data["logo"] = transform_image(res.read())
+ res = requests.get(logo, stream=True)
+ if res.status_code == 200:
+ data["logo"] = transform_image(res.raw.read())
except Exception as e:
raise APIBadRequest(f"Invalid Logo: {str(e)}")
elif logo:
@@ -610,6 +615,9 @@ def do_save_collaboration(data, organisation, user, current_user_admin=True, sav
_validate_collaboration(data, organisation)
administrators = data.get("administrators", [])
+ invalid_emails = [email for email in administrators if not bool(email_re.match(email))]
+ if invalid_emails:
+ raise BadRequest(f"Invalid emails {invalid_emails}")
message = data.get("message", None)
tags = data.get("tags", None)
@@ -661,10 +669,10 @@ def _validate_collaboration(data, organisation, new_collaboration=True):
expiry_date = data.get("expiry_date")
if expiry_date:
past_dates_allowed = current_app.app_config.feature.past_dates_allowed
- dt = datetime.utcfromtimestamp(int(expiry_date)) + timedelta(hours=4)
- if not past_dates_allowed and dt < datetime.now():
+ dt = datetime.fromtimestamp(int(expiry_date), timezone.utc) + timedelta(hours=4)
+ if not past_dates_allowed and dt < dt_now():
raise APIBadRequest(f"It is not allowed to set the expiry date ({dt}) in the past")
- data["expiry_date"] = datetime(year=dt.year, month=dt.month, day=dt.day, hour=0, minute=0, second=0)
+ data["expiry_date"] = dt.replace(hour=0, minute=0, second=0, microsecond=0)
else:
data["expiry_date"] = None
# Check if the status needs updating
@@ -672,7 +680,7 @@ def _validate_collaboration(data, organisation, new_collaboration=True):
data["status"] = STATUS_ACTIVE
else:
collaboration = db.session.get(Collaboration, data["id"])
- if collaboration.status == STATUS_EXPIRED and (not expiry_date or data["expiry_date"] > datetime.now()):
+ if collaboration.status == STATUS_EXPIRED and (not expiry_date or data["expiry_date"] > dt_now()):
data["status"] = STATUS_ACTIVE
if collaboration.status == STATUS_SUSPENDED:
data["status"] = STATUS_ACTIVE
@@ -689,7 +697,7 @@ def _validate_collaboration(data, organisation, new_collaboration=True):
validate_units(data, organisation)
_assign_global_urn(data["organisation_id"], data)
- data["last_activity_date"] = datetime.now()
+ data["last_activity_date"] = dt_now()
def _assign_global_urn(organisation_id, data):
diff --git a/server/api/collaboration_membership.py b/server/api/collaboration_membership.py
index d886ec412..fe4786300 100644
--- a/server/api/collaboration_membership.py
+++ b/server/api/collaboration_membership.py
@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timezone
from flask import Blueprint, request as current_request, jsonify
@@ -57,7 +57,7 @@ def update_collaboration_membership_expiry_date():
membership_id = client_data["membership_id"]
membership_expiry_date = client_data.get("expiry_date")
if membership_expiry_date:
- membership_expiry_date = datetime.fromtimestamp(client_data["expiry_date"])
+ membership_expiry_date = datetime.fromtimestamp(client_data["expiry_date"], tz=timezone.utc)
confirm_collaboration_admin(collaboration_id)
diff --git a/server/api/collaboration_request.py b/server/api/collaboration_request.py
index 91c817ef5..3b8a68739 100644
--- a/server/api/collaboration_request.py
+++ b/server/api/collaboration_request.py
@@ -6,14 +6,14 @@
from sqlalchemy.orm import contains_eager
from werkzeug.exceptions import BadRequest
-from server.api.base import json_endpoint, STATUS_OPEN, STATUS_APPROVED, STATUS_DENIED, emit_socket
+from server.api.base import json_endpoint, emit_socket
from server.api.collaboration import assign_global_urn_to_collaboration, do_save_collaboration
from server.api.unit import validate_units
from server.auth.security import current_user_id, current_user_name, \
- confirm_organisation_admin_or_manager
-from server.db.defaults import cleanse_short_name, STATUS_ACTIVE
-from server.db.domain import User, Organisation, CollaborationRequest, Collaboration, CollaborationMembership, db, \
- SchacHomeOrganisation
+ confirm_organisation_admin_or_manager, confirm_write_access
+from server.db.defaults import cleanse_short_name, STATUS_ACTIVE, STATUS_OPEN, STATUS_DENIED, STATUS_APPROVED
+from server.db.domain import User, CollaborationRequest, Collaboration, CollaborationMembership, db, \
+ SchacHomeOrganisation, OrganisationMembership
from server.db.logo_mixin import logo_from_cache
from server.db.models import save, delete
from server.mail import mail_collaboration_request, mail_accepted_declined_collaboration_request, \
@@ -23,18 +23,36 @@
collaboration_request_api = Blueprint("collaboration_request_api", __name__, url_prefix="/api/collaboration_requests")
+def membership_allowed(membership: OrganisationMembership, co_units) -> bool:
+ if membership.role == "admin" or not membership.units:
+ return True
+ manager_unit_identifiers = [unit.id for unit in membership.units]
+ return bool([identifier for identifier in manager_unit_identifiers if identifier in co_units])
+
+
+def current_member_unit_allowed(organisation_id, units):
+ user_id = current_user_id()
+ membership = OrganisationMembership.query \
+ .filter(OrganisationMembership.user_id == user_id) \
+ .filter(OrganisationMembership.organisation_id == organisation_id) \
+ .one()
+ return membership_allowed(membership, [unit.id for unit in units])
+
+
@collaboration_request_api.route("/", methods=["GET"], strict_slashes=False)
@json_endpoint
def collaboration_request_by_id(collaboration_request_id):
- res = CollaborationRequest.query \
+ collaboration_request = CollaborationRequest.query \
.join(CollaborationRequest.organisation) \
.join(CollaborationRequest.requester) \
.options(contains_eager(CollaborationRequest.organisation)) \
.options(contains_eager(CollaborationRequest.requester)) \
.filter(CollaborationRequest.id == collaboration_request_id) \
.one()
- confirm_organisation_admin_or_manager(res.organisation_id)
- return res, 200
+
+ organisation_id = collaboration_request.organisation_id
+ confirm_write_access(organisation_id, collaboration_request.units, override_func=current_member_unit_allowed)
+ return collaboration_request, 200
@collaboration_request_api.route("/", methods=["POST"], strict_slashes=False)
@@ -42,14 +60,11 @@ def collaboration_request_by_id(collaboration_request_id):
def request_collaboration():
data = current_request.get_json()
user = db.session.get(User, current_user_id())
- organisation = Organisation.query \
- .join(Organisation.schac_home_organisations) \
- .filter(SchacHomeOrganisation.name == user.schac_home_organisation) \
- .first()
- if not organisation:
+ organisations = SchacHomeOrganisation.organisations_by_user_schac_home(user)
+ if not organisations:
raise BadRequest(f"There is no organisation with a schac_home_organisation that equals the "
f"schac_home_organisation {user.schac_home_organisation} of User {user.email}")
-
+ organisation = organisations[0]
data["requester_id"] = user.id
cleanse_short_name(data)
@@ -58,7 +73,10 @@ def request_collaboration():
auto_create = organisation.collaboration_creation_allowed
entitlement = current_app.app_config.collaboration_creation_allowed_entitlement
auto_aff = user.entitlement and entitlement in user.entitlement
- recipients = list(map(lambda membership: membership.user.email, organisation.organisation_memberships))
+ co_units = [int(unit["id"]) for unit in data.get("units", [])]
+
+ allowed_members = [m for m in organisation.organisation_memberships if membership_allowed(m, co_units)]
+ recipients = [member.user.email for member in allowed_members]
emit_socket(f"organisation_{organisation.id}", include_current_user_id=True)
@@ -99,7 +117,8 @@ def request_collaboration():
@json_endpoint
def delete_request_collaboration(collaboration_request_id):
collaboration_request = db.session.get(CollaborationRequest, collaboration_request_id)
- confirm_organisation_admin_or_manager(collaboration_request.organisation_id)
+ organisation_id = collaboration_request.organisation_id
+ confirm_write_access(organisation_id, collaboration_request.units, override_func=current_member_unit_allowed)
if collaboration_request.status == STATUS_OPEN:
raise BadRequest("Collaboration request with status 'open' can not be deleted")
@@ -113,7 +132,9 @@ def delete_request_collaboration(collaboration_request_id):
@json_endpoint
def approve_request(collaboration_request_id):
collaboration_request = db.session.get(CollaborationRequest, collaboration_request_id)
- confirm_organisation_admin_or_manager(collaboration_request.organisation_id)
+ organisation_id = collaboration_request.organisation_id
+ confirm_write_access(organisation_id, collaboration_request.units, override_func=current_member_unit_allowed)
+
client_data = current_request.get_json()
attributes = ["name", "short_name", "description", "organisation_id", "accepted_user_policy", "logo",
"website_url", "logo"]
diff --git a/server/api/collaborations_services.py b/server/api/collaborations_services.py
index e125a32e7..5ee15fdc9 100644
--- a/server/api/collaborations_services.py
+++ b/server/api/collaborations_services.py
@@ -4,9 +4,9 @@
from server.api.base import json_endpoint, emit_socket
from server.api.service_group import create_service_groups
-from server.auth.security import confirm_collaboration_admin, confirm_external_api_call, confirm_service_admin
-from server.db.db import db
+from server.auth.security import confirm_collaboration_admin, confirm_external_api_call, confirm_service_manager
from server.db.activity import update_last_activity_date
+from server.db.db import db
from server.db.domain import Service, Collaboration, Organisation
from server.schemas import json_schema_validator
from server.scim.events import broadcast_service_added, broadcast_service_deleted
@@ -24,15 +24,18 @@ def connect_service_collaboration(service_id, collaboration_id, force=False):
org_automatic_allowed = organisation in service.automatic_connection_allowed_organisations
if service.override_access_allowed_all_connections:
- raise BadRequest("Connection not allowed")
+ raise BadRequest(f"Connection not allowed "
+ f"(service {service_id}, org {organisation.id}, co {collaboration_id})")
if not org_allowed and not org_automatic_allowed \
and not service.automatic_connection_allowed and not service.access_allowed_for_all:
- raise BadRequest("not_allowed_organisation")
+ raise BadRequest("not_allowed_organisation "
+ f"(service {service_id}, org {organisation.id}, co {collaboration_id})")
allowed_to_connect = service.automatic_connection_allowed or org_automatic_allowed
if not force and not allowed_to_connect:
- raise BadRequest("automatic_connection_not_allowed")
+ raise BadRequest(f"automatic_connection_not_allowed "
+ f"(service {service_id}, org {organisation.id}, co {collaboration_id})")
if organisation.services_restricted and not service.allow_restricted_orgs:
raise BadRequest(f"Organisation {collaboration.organisation.name} can only be linked to SURF services")
@@ -165,7 +168,7 @@ def delete_collaborations_services(collaboration_id, service_id):
try:
confirm_collaboration_admin(collaboration_id)
except Forbidden:
- confirm_service_admin(service_id)
+ confirm_service_manager(service_id)
collaboration = db.session.get(Collaboration, collaboration_id)
diff --git a/server/api/invitation.py b/server/api/invitation.py
index ff8825f11..cd5f84169 100644
--- a/server/api/invitation.py
+++ b/server/api/invitation.py
@@ -5,21 +5,23 @@
from flasgger import swag_from
from flask import Blueprint, request as current_request, current_app, g as request_context, jsonify
-from sqlalchemy import or_
-from sqlalchemy.orm import joinedload
+from sqlalchemy import or_, func
+from sqlalchemy.orm import joinedload, load_only
from werkzeug.exceptions import Conflict, Forbidden, BadRequest
-from server.api.base import json_endpoint, query_param, emit_socket, STATUS_OPEN
+from server.api.base import json_endpoint, query_param, emit_socket
from server.api.service_aups import add_user_aups
from server.auth.secrets import generate_token
from server.auth.security import confirm_collaboration_admin, current_user_id, confirm_external_api_call, \
confirm_organisation_api_collaboration
from server.db.activity import update_last_activity_date
-from server.db.defaults import default_expiry_date
-from server.db.domain import Invitation, CollaborationMembership, Collaboration, db, User, JoinRequest, Group
+from server.db.defaults import default_expiry_date, STATUS_OPEN
+from server.db.domain import Invitation, CollaborationMembership, Collaboration, db, User, JoinRequest, Group, \
+ OrganisationAup
from server.db.models import delete
from server.mail import mail_collaboration_invitation
from server.scim.events import broadcast_collaboration_changed
+from server.tools import dt_now
CREATED_BY_SYSTEM = "system"
@@ -45,7 +47,7 @@ def do_resend(invitation_id):
.one()
confirm_collaboration_admin(invitation.collaboration_id)
invitation.expiry_date = default_expiry_date() if invitation.is_expired() else invitation.expiry_date
- invitation.created_at = datetime.date.today() if invitation.is_expired() else invitation.created_at
+ invitation.created_at = dt_now() if invitation.is_expired() else invitation.created_at
db.session.merge(invitation)
mail_collaboration_invitation({
"salutation": "Dear",
@@ -57,7 +59,7 @@ def do_resend(invitation_id):
def parse_date(val, default_date=None):
- return datetime.datetime.fromtimestamp(val / 1e3) if val and (
+ return datetime.datetime.fromtimestamp(val / 1e3, tz=datetime.timezone.utc) if val and (
isinstance(val, float) or isinstance(val, int)) else default_date
@@ -97,7 +99,16 @@ def invitation_to_dict(invitation, include_expiry_date=False):
return res
-@invitations_api.route("/find_by_hash", strict_slashes=False)
+def add_organisation_aups(collaboration: Collaboration, user: User):
+ organisation = collaboration.organisation
+ org_identifiers = [aup.organisation_id for aup in user.organisation_aups]
+ if organisation.accepted_user_policy and organisation.id not in org_identifiers:
+ organisation_aup = OrganisationAup(aup_url=organisation.accepted_user_policy, user=user,
+ organisation=organisation)
+ db.session.merge(organisation_aup)
+
+
+@invitations_api.route("/find_by_hash", methods=["GET"], strict_slashes=False)
@json_endpoint
def invitations_by_hash():
hash_value = query_param("hash")
@@ -112,6 +123,7 @@ def invitations_by_hash():
invitation.collaboration.services
invitation.collaboration.organisation
invitation.collaboration.organisation.services
+
for member in invitation.collaboration.collaboration_memberships:
member.user
@@ -120,7 +132,26 @@ def invitations_by_hash():
invitation_json = jsonify(invitation).json
service_emails = invitation.collaboration.service_emails()
- return {"invitation": invitation_json, "service_emails": service_emails}, 200
+ admin_emails = invitation.collaboration.organisation.admin_emails()
+ return {"invitation": invitation_json, "service_emails": service_emails, "admin_emails": admin_emails}, 200
+
+
+@invitations_api.route("/exists_email", methods=["POST"], strict_slashes=False)
+@json_endpoint
+def invitation_exists_by_email():
+ data = current_request.get_json()
+ collaboration_id = int(data["collaboration_id"])
+ invitations = invitations_by_email(collaboration_id, data["emails"])
+ return [i.invitee_email for i in invitations], 200
+
+
+def invitations_by_email(collaboration_id, emails):
+ invitations = Invitation.query.options(load_only(Invitation.invitee_email)) \
+ .filter(func.lower(Invitation.invitee_email).in_([e.lower() for e in emails])) \
+ .filter(Invitation.collaboration_id == collaboration_id) \
+ .filter(Invitation.status == STATUS_OPEN) \
+ .all()
+ return invitations
@invitations_api.route("/v1/collaboration_invites", methods=["PUT"], strict_slashes=False)
@@ -161,6 +192,11 @@ def collaboration_invites_api():
expiry_date = parse_date(data.get("invitation_expiry_date"), default_expiry_date())
membership_expiry_date = parse_date(data.get("membership_expiry_date"))
invites = list(filter(lambda recipient: bool(email_re.match(recipient)), data["invites"]))
+
+ duplicate_invitations = [i.invitee_email for i in invitations_by_email(collaboration.id, invites)]
+ if duplicate_invitations:
+ raise BadRequest(f"Duplicate email invitations: {duplicate_invitations}")
+
invites_results = []
group_ids = data.get("groups", [])
@@ -210,7 +246,7 @@ def invitations_accept():
if invitation.status != "open":
raise Conflict(f"The invitation has status {invitation.status}")
- if invitation.expiry_date and invitation.expiry_date < datetime.datetime.now():
+ if invitation.expiry_date and invitation.expiry_date < dt_now():
if invitation.created_by == "system":
invitation.status = "expired"
db.session.merge(invitation)
@@ -248,7 +284,8 @@ def invitations_accept():
db.session.commit()
- add_user_aups(collaboration, user_id)
+ user = add_user_aups(collaboration, user_id)
+ add_organisation_aups(collaboration, user)
# Any outstanding join request for this user and this collaboration can be deleted now
JoinRequest.query.filter(JoinRequest.user_id == user_id, JoinRequest.collaboration_id == collaboration.id).delete()
@@ -317,6 +354,18 @@ def delete_invitation(invitation_id):
return delete(Invitation, invitation_id)
+@invitations_api.route("/delete_by_hash/", methods=["DELETE"], strict_slashes=False)
+@json_endpoint
+def delete_by_hash(hash):
+ invitation = Invitation.query.filter(Invitation.hash == hash).one()
+ collaboration = invitation.collaboration
+
+ emit_socket(f"collaboration_{collaboration.id}", include_current_user_id=True)
+
+ db.session.delete(invitation)
+ return {}, 204
+
+
@invitations_api.route("/v1/", strict_slashes=False)
@swag_from("../swagger/public/paths/get_invitation_by_identifier.yml")
@json_endpoint
@@ -338,6 +387,16 @@ def external_invitation(external_identifier):
return res, 200
+@invitations_api.route("/v1/", methods=["DELETE"], strict_slashes=False)
+@swag_from("../swagger/public/paths/delete_invitation_by_identifier.yml")
+@json_endpoint
+def delete_external_invitation(external_identifier):
+ confirm_external_api_call()
+ invitation = Invitation.query.filter(Invitation.external_identifier == external_identifier).one()
+ db.session.delete(invitation)
+ return None, 204
+
+
@invitations_api.route("/v1/invitations/", strict_slashes=False)
@swag_from("../swagger/public/paths/get_open_invitations.yml")
@json_endpoint
diff --git a/server/api/join_request.py b/server/api/join_request.py
index d42591a0a..3365be6c6 100644
--- a/server/api/join_request.py
+++ b/server/api/join_request.py
@@ -2,8 +2,8 @@
from sqlalchemy.orm import contains_eager
from werkzeug.exceptions import Conflict, BadRequest
-from server.api.base import json_endpoint, STATUS_DENIED, STATUS_APPROVED, emit_socket
-from server.api.collaboration_request import STATUS_OPEN
+from server.api.base import json_endpoint, emit_socket
+from server.db.defaults import STATUS_OPEN, STATUS_DENIED, STATUS_APPROVED
from server.api.service_aups import add_user_aups
from server.auth.secrets import generate_token
from server.auth.security import confirm_collaboration_admin, current_user_id, current_user, \
@@ -71,7 +71,6 @@ def new_join_request():
db.session.delete(jr)
join_request = JoinRequest(message=client_data["motivation"],
- reference=client_data["reference"] if "reference" in client_data else None,
user_id=user_id,
status=STATUS_OPEN,
collaboration_id=collaboration.id,
diff --git a/server/api/mfa.py b/server/api/mfa.py
index 504bac5ed..5ff2591ac 100644
--- a/server/api/mfa.py
+++ b/server/api/mfa.py
@@ -1,5 +1,4 @@
import base64
-import datetime
from io import BytesIO
import pyotp
@@ -18,6 +17,7 @@
from server.db.domain import User
from server.logger.context_logger import ctx_logger
from server.mail import mail_reset_token
+from server.tools import dt_now
mfa_api = Blueprint("mfa_api", __name__, url_prefix="/api/mfa")
@@ -50,7 +50,7 @@ def _do_verify_2fa(user: User, secret):
if totp.verify(totp_value, valid_window=1) or _totp_backdoor(user):
if not user.second_factor_auth:
user.second_factor_auth = secret
- user.last_login_date = datetime.datetime.now()
+ user.last_login_date = dt_now()
user = db.session.merge(user)
db.session.commit()
store_user_in_session(user, True, user.has_agreed_with_aup())
diff --git a/server/api/mock_scim.py b/server/api/mock_scim.py
index 2976d59b2..c323c356b 100644
--- a/server/api/mock_scim.py
+++ b/server/api/mock_scim.py
@@ -9,6 +9,7 @@
from server.api.base import query_param, json_endpoint
from server.auth.security import confirm_write_access
+from server.auth.tokens import decrypt_scim_bearer_token
from server.db.domain import Service
scim_mock_api = Blueprint("scim_mock_api", __name__, url_prefix="/api/scim_mock")
@@ -23,7 +24,8 @@ def _check_authorization_header(service_id):
if not authorization_header or not authorization_header.lower().startswith("bearer"):
raise Unauthorized(description="Invalid bearer token")
service = Service.query.filter(Service.id == service_id).one()
- if service.scim_bearer_token != authorization_header[len('bearer '):]:
+ plain_bearer_token = decrypt_scim_bearer_token(service)
+ if plain_bearer_token != authorization_header[len('bearer '):]:
raise Unauthorized(description="Invalid bearer token")
diff --git a/server/api/mock_user.py b/server/api/mock_user.py
index 73287e22b..1e59a39a7 100644
--- a/server/api/mock_user.py
+++ b/server/api/mock_user.py
@@ -1,4 +1,3 @@
-import datetime
import os
import uuid
@@ -12,6 +11,7 @@
from server.auth.user_claims import add_user_claims
from server.db.db import db
from server.db.domain import User
+from server.tools import dt_now
mock_user_api = Blueprint("mock_user_api", __name__, url_prefix="/api/mock")
@@ -26,7 +26,7 @@ def login_user():
sub = data["sub"] # oidc sub maps to sbs uid - see user_claims
user = User.query.filter(User.uid == sub).first() or User(created_by="system", updated_by="system",
external_id=str(uuid.uuid4()))
- user.last_login_date = datetime.datetime.now()
+ user.last_login_date = dt_now()
add_user_claims(data, sub, user)
db.session.merge(user)
diff --git a/server/api/organisation.py b/server/api/organisation.py
index d8b69dafc..82ca3c65a 100644
--- a/server/api/organisation.py
+++ b/server/api/organisation.py
@@ -7,15 +7,16 @@
from sqlalchemy import text, func, bindparam, String
from sqlalchemy.orm import load_only
from sqlalchemy.orm import selectinload
-from werkzeug.exceptions import Forbidden
+from werkzeug.exceptions import Forbidden, BadRequest
from server.api.base import emit_socket, organisation_by_user_schac_home
from server.api.base import json_endpoint, query_param, replace_full_text_search_boolean_mode_chars
+from server.api.organisation_invitation import organisation_invitations_by_email
from server.api.unit import validate_units
from server.auth.secrets import generate_token
from server.auth.security import confirm_write_access, current_user_id, is_application_admin, \
- confirm_organisation_admin, is_service_admin, confirm_external_api_call, confirm_read_access, \
- confirm_organisation_admin_or_manager, is_organisation_admin
+ confirm_organisation_admin, confirm_external_api_call, confirm_read_access, \
+ confirm_organisation_admin_or_manager, is_organisation_admin, is_service_admin_or_manager
from server.cron.idp_metadata_parser import idp_display_name
from server.db.db import db
from server.db.defaults import default_expiry_date, cleanse_short_name
@@ -127,7 +128,7 @@ def organisation_all():
@organisation_api.route("/search", strict_slashes=False)
@json_endpoint
def organisation_search():
- confirm_write_access(override_func=is_service_admin)
+ confirm_write_access(override_func=is_service_admin_or_manager)
res = []
q = query_param("q")
@@ -322,6 +323,11 @@ def organisation_invites():
valid_units = validate_units(data, organisation)
+ duplicate_invitations = [i.invitee_email for i in
+ organisation_invitations_by_email(administrators, organisation_id)]
+ if duplicate_invitations:
+ raise BadRequest(f"Duplicate email invitations: {duplicate_invitations}")
+
for administrator in administrators:
invitation = OrganisationInvitation(hash=generate_token(),
intended_role=intended_role,
diff --git a/server/api/organisation_invitation.py b/server/api/organisation_invitation.py
index 608c8a5b0..fde24db5d 100644
--- a/server/api/organisation_invitation.py
+++ b/server/api/organisation_invitation.py
@@ -1,7 +1,6 @@
-import datetime
-
from flask import Blueprint, request as current_request, current_app
-from sqlalchemy.orm import joinedload
+from sqlalchemy import func
+from sqlalchemy.orm import joinedload, load_only
from werkzeug.exceptions import Conflict
from server.api.base import json_endpoint, query_param, emit_socket
@@ -10,6 +9,7 @@
from server.db.domain import OrganisationInvitation, Organisation, OrganisationMembership, db
from server.db.models import delete
from server.mail import mail_organisation_invitation
+from server.tools import dt_now
organisation_invitations_api = Blueprint("organisation_invitations_api", __name__,
url_prefix="/api/organisation_invitations")
@@ -29,14 +29,14 @@ def do_resend(organisation_invitation_id):
.one()
confirm_organisation_admin(organisation_invitation.organisation_id)
organisation_invitation.expiry_date = default_expiry_date()
- organisation_invitation.created_at = datetime.date.today(),
+ organisation_invitation.created_at = dt_now()
organisation_invitation = db.session.merge(organisation_invitation)
mail_organisation_invitation({
"salutation": "Dear",
"invitation": organisation_invitation,
"base_url": current_app.app_config.base_url,
"recipient": organisation_invitation.invitee_email
- }, organisation_invitation.organisation, [organisation_invitation.invitee_email])
+ }, organisation_invitation.organisation, [organisation_invitation.invitee_email], reminder=True)
@organisation_invitations_api.route("/find_by_hash", strict_slashes=False)
@@ -61,7 +61,7 @@ def organisation_invitations_accept():
.filter(OrganisationInvitation.hash == current_request.get_json()["hash"]) \
.one()
- if organisation_invitation.expiry_date and organisation_invitation.expiry_date < datetime.datetime.now():
+ if organisation_invitation.expiry_date and organisation_invitation.expiry_date < dt_now():
delete(OrganisationInvitation, organisation_invitation.id)
raise Conflict(f"The invitation has expired at {organisation_invitation.expiry_date}")
@@ -128,3 +128,20 @@ def delete_organisation_invitation(id):
confirm_organisation_admin(organisation_invitation.organisation_id)
return delete(OrganisationInvitation, id)
+
+
+@organisation_invitations_api.route("/exists_email", methods=["POST"], strict_slashes=False)
+@json_endpoint
+def invitation_exists_by_email():
+ data = current_request.get_json()
+ organisation_id = int(data["organisation_id"])
+ invitations = organisation_invitations_by_email(data["emails"], organisation_id)
+ return [i.invitee_email for i in invitations], 200
+
+
+def organisation_invitations_by_email(emails, organisation_id):
+ invitations = OrganisationInvitation.query.options(load_only(OrganisationInvitation.invitee_email)) \
+ .filter(func.lower(OrganisationInvitation.invitee_email).in_([e.lower() for e in emails])) \
+ .filter(OrganisationInvitation.organisation_id == organisation_id) \
+ .all()
+ return invitations
diff --git a/server/api/pam_websso.py b/server/api/pam_websso.py
index b0a1e55f0..26e1cd11e 100644
--- a/server/api/pam_websso.py
+++ b/server/api/pam_websso.py
@@ -2,7 +2,7 @@
import random
import string
import uuid
-from datetime import datetime, timedelta
+from datetime import timedelta
import qrcode
from flasgger import swag_from
@@ -18,16 +18,17 @@
from server.db.domain import User, PamSSOSession, Service, CollaborationMembership
from server.db.models import log_user_login, flatten
from server.logger.context_logger import ctx_logger
+from server.tools import dt_now
pam_websso_api = Blueprint("pam_weblogin_api", __name__, url_prefix="/pam-weblogin")
-def _get_pam_sso_session(session_id):
+def _get_pam_sso_session(session_id) -> PamSSOSession:
pam_sso_session = PamSSOSession.query.filter(PamSSOSession.session_id == session_id).first()
if not pam_sso_session:
raise NotFound(f"No PamSSOSession with session_id {session_id} found")
timeout = current_app.app_config.pam_web_sso.session_timeout_seconds
- seconds_ago = datetime.now() - timedelta(hours=0, minutes=0, seconds=timeout)
+ seconds_ago = dt_now() - timedelta(hours=0, minutes=0, seconds=timeout)
if pam_sso_session.created_at < seconds_ago:
db.session.delete(pam_sso_session)
raise NotFound(f"PamSSOSession with session_id {session_id} is expired")
@@ -65,7 +66,8 @@ def include_service(s: Service, m: CollaborationMembership):
@json_endpoint
def find_by_session_id(service_shortname, session_id):
pam_sso_session = _get_pam_sso_session(session_id)
-
+ if pam_sso_session.pin_shown:
+ raise Forbidden("PIN already shown")
if pam_sso_session.service.abbreviation.lower() != service_shortname.lower():
raise Forbidden(f"Short name {service_shortname} is not correct")
@@ -76,6 +78,9 @@ def find_by_session_id(service_shortname, session_id):
db.session.add(pam_sso_session)
res["validation"] = _validate_pam_sso_session(pam_sso_session, None, False, True)
res["pin"] = pam_sso_session.pin
+ # Ensure the link can't be used anymore, but we can't delete it as we need to verify the entered pin
+ pam_sso_session.pin_shown = True
+ db.session.merge(pam_sso_session)
return res, 200
@@ -117,7 +122,7 @@ def start():
session.modified = False
pam_last_login_date = user.pam_last_login_date
- seconds_ago = datetime.now() - timedelta(hours=0, minutes=0, seconds=cache_duration)
+ seconds_ago = dt_now() - timedelta(hours=0, minutes=0, seconds=cache_duration)
if pam_last_login_date and pam_last_login_date > seconds_ago:
log_user_login(PAM_WEB_LOGIN, True, user, user.uid, service, service.entity_id, status="Cached login")
@@ -180,13 +185,13 @@ def check_pin():
success = validation["result"] == "SUCCESS"
if success:
db.session.delete(pam_sso_session)
- user.pam_last_login_date = datetime.now()
+ user.pam_last_login_date = dt_now()
db.session.merge(user)
# We also update the activity date of linked collaboration
collaborations = [cm.collaboration for cm in user.collaboration_memberships if
service in cm.collaboration.services]
for collaboration in collaborations:
- collaboration.last_activity_date = datetime.now()
+ collaboration.last_activity_date = dt_now()
db.session.merge(collaboration)
db.session.commit()
diff --git a/server/api/plsc.py b/server/api/plsc.py
index b03188fc8..0ce08840c 100644
--- a/server/api/plsc.py
+++ b/server/api/plsc.py
@@ -38,7 +38,8 @@ def internal_sync():
services = [{"id": row[0], "name": row[1], "entity_id": row[2], "contact_email": row[3],
"logo": logo_url("services", row[4]), "ldap_password": row[5],
"accepted_user_policy": row[6], "support_email": row[7], "security_email": row[8],
- "privacy_policy": row[9], "ldap_enabled": row[10], "ldap_identifier": row[11], "abbreviation": row[12]}
+ "privacy_policy": row[9], "ldap_enabled": row[10], "ldap_identifier": row[11],
+ "abbreviation": row[12]}
for row in rs]
for service in services:
if not service["contact_email"]:
@@ -55,7 +56,7 @@ def internal_sync():
organisation_memberships = [{"role": row[0], "user_id": row[1], "organisation_id": row[2]} for row in rs]
rs = conn.execute(text("SELECT cm.id, cm.role, cm.user_id, collaboration_id, cm.status FROM "
"collaboration_memberships cm "
- "INNER JOIN users u ON u.id = cm.user_id where u.suspended = 0"))
+ "INNER JOIN users u ON u.id = cm.user_id"))
collaboration_memberships = [
{"id": row[0], "role": row[1], "user_id": row[2], "collaboration_id": row[3], "status": row[4]} for row
in rs]
@@ -79,6 +80,14 @@ def internal_sync():
rs = conn.execute(text("SELECT ct.collaboration_id, t.tag_value FROM collaboration_tags ct "
"INNER JOIN tags t ON t.id = ct.tag_id"))
tags = [{"collaboration_id": row[0], "tag_value": row[1]} for row in rs]
+
+ rs = conn.execute(text("SELECT name, organisation_id from units"))
+ units = [{"name": row[0], "organisation_id": row[1]} for row in rs]
+
+ rs = conn.execute(text("SELECT cu.collaboration_id, u.name from collaboration_units cu "
+ "inner join units u on u.id = cu.unit_id"))
+ collaboration_units = [{"collaboration_id": row[0], "name": row[1]} for row in rs]
+
for coll in collaborations:
collaboration_id = coll["id"]
coll["groups"] = _find_by_id(groups, "collaboration_id", collaboration_id)
@@ -95,6 +104,8 @@ def internal_sync():
coll["collaboration_memberships"] = _find_by_id(collaboration_memberships, "collaboration_id",
collaboration_id)
coll["tags"] = [tag["tag_value"] for tag in tags if tag["collaboration_id"] == collaboration_id]
+ coll["units"] = [u["name"] for u in collaboration_units if u["collaboration_id"] == collaboration_id]
+
rs = conn.execute(text("SELECT id, name, identifier, short_name, uuid4 FROM organisations"))
for row in rs:
organisation_id = row[0]
@@ -105,16 +116,18 @@ def internal_sync():
"schac_home_organisations": _find_by_id(schac_home_organisations, "organisation_id", organisation_id),
"organisation_memberships": _find_by_id(organisation_memberships, "organisation_id", organisation_id),
"collaborations": _find_by_id(collaborations, "organisation_id", organisation_id),
+ "units": [u["name"] for u in units if u["organisation_id"] == organisation_id],
"services": _identifiers_only(
_find_by_identifiers(services, "id", [si["service_id"] for si in service_identifiers]))
})
result["services"] = services
rs = conn.execute(text("SELECT id, uid, name, given_name, family_name, email, scoped_affiliation, "
- "eduperson_principal_name, username, last_login_date FROM users"))
+ "eduperson_principal_name, username, last_login_date, suspended FROM users"))
for row in rs:
user_row = {"id": row[0], "uid": row[1], "name": row[2], "given_name": row[3], "family_name": row[4],
"email": row[5], "scoped_affiliation": row[6], "eduperson_principal_name": row[7],
- "username": row[8], "last_login_date": str(row[9])}
+ "username": row[8], "last_login_date": str(row[9]),
+ "status": "suspended" if row[10] else "active"}
rs_ssh_keys = conn.execute(text(f"SELECT ssh_value FROM ssh_keys WHERE user_id = {row[0]}"))
user_row["ssh_keys"] = [r[0] for r in rs_ssh_keys]
user_ip_networks = conn.execute(text(f"SELECT network_value FROM user_ip_networks "
diff --git a/server/api/scim.py b/server/api/scim.py
index 5c2eee594..aa92a81cb 100644
--- a/server/api/scim.py
+++ b/server/api/scim.py
@@ -1,19 +1,22 @@
import re
+import traceback
import urllib.parse
from typing import Union
import requests
+from cryptography.exceptions import InvalidTag
from flasgger import swag_from
from flask import Blueprint, Response
from sqlalchemy import func
from werkzeug.exceptions import Unauthorized, BadRequest
-from server.api.base import json_endpoint, query_param
+from server.api.base import json_endpoint, query_param, send_error_mail
from server.auth.security import confirm_write_access, is_service_admin
from server.auth.tokens import validate_service_token
from server.db.db import db
from server.db.defaults import SERVICE_TOKEN_SCIM
from server.db.domain import User, Collaboration, Group, Service
+from server.logger.context_logger import ctx_logger
from server.scim import SCIM_URL_PREFIX, EXTERNAL_ID_POST_FIX
from server.scim.group_template import find_groups_template, find_group_by_id_template
from server.scim.repo import all_scim_users_by_service, all_scim_groups_by_service
@@ -139,24 +142,38 @@ def service_group_by_identifier(group_external_id: str):
@swag_from("../swagger/public/paths/sweep.yml")
@json_endpoint
def sweep():
+ logger = ctx_logger("scim_sweep")
+
try:
service = validate_service_token("scim_enabled", SERVICE_TOKEN_SCIM)
except Unauthorized:
service_id = query_param("service_id")
confirm_write_access(service_id, override_func=is_service_admin)
service = db.session.get(Service, service_id)
+
try:
results = perform_sweep(service)
results["scim_url"] = service.scim_url
return results, 201
- except BadRequest as error:
- return {"error": f"Error from remote scim server: {error.description}",
+ except BadRequest as bad_request:
+ logger.warn(f"Error from remote SCIM server for {service.entity_id}: {bad_request.description}")
+ return {"error": f"Error from remote scim server: {bad_request.description}",
+ "scim_url": service.scim_url}, 400
+ except requests.RequestException as request_exception:
+ logger.warn(f"Could not connect to remote SCIM server {service.scim_url} for {service.entity_id}:"
+ f" {type(request_exception).__name__}")
+ return {"error": f"Could not connect to remote SCIM server ({type(request_exception).__name__})"
+ f"{': ' + request_exception.response.text if request_exception.response else ''}",
"scim_url": service.scim_url}, 400
- except requests.RequestException as e:
- return {"error": f"Could not connect to remote SCIM server ({type(e).__name__})"
- f"{': ' + e.response.text if e.response else ''}",
+ except InvalidTag:
+ exc = traceback.format_exc()
+ logger.error(f"Could not decrypt SCIM bearer secret for service {service.entity_id}\n{exc}")
+ send_error_mail(tb=f"Could not decrypt SCIM bearer secret for service {service.entity_id}\n\n{exc}")
+ return {"error": "Could not decrypt SCIM bearer secret",
"scim_url": service.scim_url}, 400
except Exception:
+ logger.error(f"Unknown error while connecting to remote SCIM server {service.scim_url} for "
+ f"{service.entity_id}: {traceback.format_exc()}")
return {"error": "Unknown error while connecting to remote SCIM server",
"scim_url": service.scim_url}, 500
diff --git a/server/api/service.py b/server/api/service.py
index 9befd49ff..a564cd0ce 100644
--- a/server/api/service.py
+++ b/server/api/service.py
@@ -8,10 +8,12 @@
from server.api.base import json_endpoint, query_param, emit_socket
from server.api.ipaddress import validate_ip_networks
+from server.api.service_invitation import service_invitations_by_email
from server.auth.secrets import generate_token, generate_ldap_password_with_hash
from server.auth.security import confirm_write_access, current_user_id, confirm_read_access, is_collaboration_admin, \
- is_organisation_admin_or_manager, is_application_admin, is_service_admin, confirm_service_admin, \
- confirm_external_api_call
+ is_organisation_admin_or_manager, is_application_admin, confirm_service_admin, \
+ confirm_external_api_call, is_service_admin_or_manager
+from server.auth.tokens import encrypt_scim_bearer_token, decrypt_scim_bearer_token
from server.db.db import db
from server.db.defaults import STATUS_ACTIVE, cleanse_short_name, default_expiry_date, valid_uri_attributes, \
service_token_options, generate_short_name
@@ -93,8 +95,8 @@ def member_access_to_service(service_id):
def user_service(service_id, view_only=True):
- # Every service may be seen by organisation admin, service admin, manager or coll admin
- if is_service_admin(service_id) or is_application_admin():
+ # Every service may be seen by organisation admin, service admin, service manager or coll admin
+ if is_service_admin_or_manager(service_id) or is_application_admin():
return True
if view_only and (is_collaboration_admin() or is_organisation_admin_or_manager()):
@@ -105,7 +107,7 @@ def user_service(service_id, view_only=True):
def _do_get_services(restrict_for_current_user=False, include_counts=False):
def override_func():
- return is_collaboration_admin() or _is_org_member() or is_service_admin()
+ return is_collaboration_admin() or _is_org_member() or is_service_admin_or_manager()
confirm_read_access(override_func=override_func)
query = Service.query \
@@ -254,7 +256,7 @@ def service_by_uuid4():
if service.contact_email:
service_emails[service.id] = [service.contact_email]
else:
- service_emails[service.id] = [membership.user.email for membership in service.service_memberships]
+ service_emails[service.id] = [m.user.email for m in service.service_memberships if m.role == "admin"]
collaborations = []
for cm in user.collaboration_memberships:
@@ -280,7 +282,7 @@ def service_by_id(service_id):
.options(selectinload(Service.service_memberships).selectinload(ServiceMembership.user))
api_call = request_context.is_authorized_api_call
- add_admin_info = not api_call and (is_application_admin() or is_service_admin(service_id))
+ add_admin_info = not api_call and (is_application_admin() or is_service_admin_or_manager(service_id))
if add_admin_info:
query = query \
.options(selectinload(Service.collaborations).selectinload(Collaboration.organisation)) \
@@ -395,6 +397,7 @@ def save_service():
"invitation": invitation,
"base_url": current_app.app_config.base_url,
"wiki_link": current_app.app_config.wiki_link,
+ "intended_role": "admin",
"recipient": administrator
}, service, [administrator])
@@ -408,12 +411,12 @@ def save_service():
def toggle_access_property(service_id):
json_dict = current_request.get_json()
attribute = list(json_dict.keys())[0]
- if attribute not in ["reset", "allow_restricted_orgs", "non_member_users_access_allowed", "access_allowed_for_all",
+ if attribute not in ["reset", "non_member_users_access_allowed", "access_allowed_for_all",
"automatic_connection_allowed", "connection_setting",
"override_access_allowed_all_connections"]:
raise BadRequest(f"attribute {attribute} not allowed")
enabled = json_dict.get(attribute)
- if attribute in ["allow_restricted_orgs"] or (attribute in ["non_member_users_access_allowed"] and enabled):
+ if attribute in ["non_member_users_access_allowed"] and enabled:
confirm_write_access()
else:
confirm_service_admin(service_id)
@@ -436,14 +439,14 @@ def toggle_access_property(service_id):
if attribute == "access_allowed_for_all":
service.override_access_allowed_all_connections = False
if enabled:
- # For all organisations that are not connected we need to make a connection
+ # For all organisations that are not connected we need to make a connection (with surf-only check)
allowed_org_identifiers = [org.id for org in service.allowed_organisations]
automatic_allowed_org_identifiers = [org.id for org in
service.automatic_connection_allowed_organisations]
query = Organisation.query \
.filter(Organisation.id.notin_(allowed_org_identifiers + automatic_allowed_org_identifiers))
if not service.allow_restricted_orgs:
- query.filter(Organisation.services_restricted == False) # noqa: E712
+ query = query.filter(Organisation.services_restricted == False) # noqa: E712
not_connected_organisations = query.all()
if service.automatic_connection_allowed:
filtered_organisations = [org for org in not_connected_organisations if
@@ -478,6 +481,8 @@ def toggle_access_property(service_id):
service.automatic_connection_allowed_organisations = []
if attribute == "non_member_users_access_allowed":
service.connection_setting = None
+ if not enabled:
+ service.override_access_allowed_all_connections = False
db.session.merge(service)
emit_socket(f"service_{service_id}")
@@ -495,22 +500,34 @@ def service_invites():
administrators = data.get("administrators", [])
message = data.get("message", None)
- intended_role = "admin"
+ intended_role = data.get("intended_role", "manager")
+ if intended_role not in ["admin", "manager"]:
+ raise BadRequest("Invalid intended role")
service = db.session.get(Service, service_id)
user = db.session.get(User, current_user_id())
+ duplicate_invitations = [i.invitee_email for i in service_invitations_by_email(administrators, service_id)]
+ if duplicate_invitations:
+ raise BadRequest(f"Duplicate email invitations: {duplicate_invitations}")
+
for administrator in administrators:
- invitation = ServiceInvitation(hash=generate_token(), message=message, invitee_email=administrator,
- service=service, user=user, created_by=user.uid,
- intended_role=intended_role, expiry_date=default_expiry_date(json_dict=data))
+ invitation = ServiceInvitation(hash=generate_token(),
+ message=message,
+ invitee_email=administrator,
+ service=service,
+ user=user,
+ intended_role=intended_role,
+ created_by=user.uid,
+ expiry_date=default_expiry_date(json_dict=data))
invitation = db.session.merge(invitation)
mail_service_invitation({
"salutation": "Dear",
"invitation": invitation,
"base_url": current_app.app_config.base_url,
"wiki_link": current_app.app_config.wiki_link,
- "recipient": administrator
+ "recipient": administrator,
+ "intended_role": intended_role
}, service, [administrator])
emit_socket(f"service_{service_id}", include_current_user_id=True)
@@ -546,11 +563,9 @@ def update_service():
for attr in [fb for fb in forbidden if fb in data]:
data[attr] = getattr(service, attr)
- if "sweep_scim_last_run" in data:
- del data["sweep_scim_last_run"]
-
- if "ldap_password" in data:
- del data["ldap_password"]
+ for attr in ["sweep_scim_last_run", "ldap_password", "scim_bearer_token"]:
+ if attr in data:
+ del data[attr]
if not data.get("ldap_enabled"):
data["ldap_password"] = None
@@ -562,8 +577,17 @@ def update_service():
.filter(ServiceToken.token_type == token_type) \
.delete()
+ scim_url_changed = data.get("scim_url", None) != service.scim_url and bool(service.scim_bearer_token)
+ # Before we update we need to get the unencrypted bearer_token
+ if scim_url_changed:
+ plain_bearer_token = decrypt_scim_bearer_token(service)
+
res = update(Service, custom_json=data, allow_child_cascades=False, allowed_child_collections=["ip_networks"])
service = res[0]
+ if scim_url_changed and service.scim_enabled:
+ service.scim_bearer_token = plain_bearer_token
+ encrypt_scim_bearer_token(service)
+
service.ip_networks
emit_socket(f"service_{service_id}")
@@ -617,3 +641,14 @@ def reset_ldap_password(service_id):
service.ldap_password = hashed
db.session.merge(service)
return {"ldap_password": password}, 200
+
+
+@service_api.route("/reset_scim_bearer_token/", methods=["PUT"], strict_slashes=False)
+@json_endpoint
+def reset_scim_bearer_token(service_id):
+ confirm_service_admin(service_id)
+ service = db.session.get(Service, service_id)
+ # Ensure we only change the scim_bearer_token
+ service.scim_bearer_token = current_request.get_json()["scim_bearer_token"]
+ encrypt_scim_bearer_token(service)
+ return {}, 201
diff --git a/server/api/service_aups.py b/server/api/service_aups.py
index 40eb09293..0ed116d52 100644
--- a/server/api/service_aups.py
+++ b/server/api/service_aups.py
@@ -13,6 +13,7 @@ def add_user_aups(collaboration, user_id):
for service in services:
if not has_agreed_with(user, service):
db.session.merge(ServiceAup(aup_url=service.accepted_user_policy, user_id=user_id, service_id=service.id))
+ return user
def has_agreed_with(user: User, service: Service):
diff --git a/server/api/service_connection_request.py b/server/api/service_connection_request.py
index 68f9b6c0e..42f981768 100644
--- a/server/api/service_connection_request.py
+++ b/server/api/service_connection_request.py
@@ -3,10 +3,11 @@
from werkzeug.exceptions import BadRequest, Forbidden
from server.api.base import json_endpoint, emit_socket
+from server.db.defaults import STATUS_DENIED, STATUS_APPROVED, STATUS_OPEN
from server.api.collaborations_services import connect_service_collaboration
from server.auth.secrets import generate_token
from server.auth.security import confirm_collaboration_admin, current_user_id, confirm_write_access, \
- is_service_admin, is_application_admin, is_organisation_admin
+ is_application_admin, is_organisation_admin, is_service_admin_or_manager, confirm_service_manager
from server.db.activity import update_last_activity_date
from server.db.domain import ServiceConnectionRequest, Service, Collaboration, db, User
from server.db.models import delete
@@ -16,22 +17,6 @@
url_prefix="/api/service_connection_requests")
-def _service_connection_request_query():
- return ServiceConnectionRequest.query \
- .join(ServiceConnectionRequest.service) \
- .join(ServiceConnectionRequest.collaboration) \
- .join(ServiceConnectionRequest.requester) \
- .options(contains_eager(ServiceConnectionRequest.service)) \
- .options(contains_eager(ServiceConnectionRequest.collaboration)) \
- .options(contains_eager(ServiceConnectionRequest.requester))
-
-
-def _service_connection_request_by_hash(hash_value):
- return _service_connection_request_query() \
- .filter(ServiceConnectionRequest.hash == hash_value) \
- .one()
-
-
def _service_connection_request_pending_organisation_approval(service_connection_request: ServiceConnectionRequest):
service = service_connection_request.service
collaboration = service_connection_request.collaboration
@@ -76,7 +61,8 @@ def _do_send_mail(collaboration, service, service_connection_request, user, pend
def _do_service_connection_request(approved):
- id = int(current_request.get_json().get("id"))
+ json_data = current_request.get_json()
+ id = int(json_data.get("id"))
service_connection_request = ServiceConnectionRequest.query.filter(ServiceConnectionRequest.id == id).one()
pending_on_org = service_connection_request.pending_organisation_approval
@@ -89,11 +75,20 @@ def _do_service_connection_request(approved):
service = service_connection_request.service
collaboration = service_connection_request.collaboration
- if not (is_service_admin(service.id) or is_application_admin() or is_organisation_admin(organisation.id)):
+ if not (is_service_admin_or_manager(service.id) or is_application_admin() or is_organisation_admin(
+ organisation.id)):
raise Forbidden(f"Not allowed to approve / decline service_connection_request for service {service.entity_id}")
if approved:
+ service_connection_request.status = STATUS_APPROVED
+ db.session.merge(service_connection_request)
+
connect_service_collaboration(service.id, collaboration.id, force=True)
+ else:
+ service_connection_request.status = STATUS_DENIED
+ rejection_reason = json_data["rejection_reason"]
+ service_connection_request.rejection_reason = rejection_reason
+ db.session.merge(service_connection_request)
user = User.query.filter(User.id == current_user_id()).one()
requester = service_connection_request.requester
@@ -106,7 +101,6 @@ def _do_service_connection_request(approved):
emails = [requester.email] if requester.email else [current_app.app_config.mail.beheer_email]
mail_accepted_declined_service_connection_request(context, service.name, collaboration.name, approved,
emails)
- db.session.delete(service_connection_request)
emit_socket(f"service_{service.id}", include_current_user_id=True)
emit_socket(f"collaboration_{collaboration.id}", include_current_user_id=True)
@@ -128,11 +122,14 @@ def service_request_connections_by_service(service_id):
@json_endpoint
def delete_service_request_connection(service_connection_request_id):
service_connection_request = db.session.get(ServiceConnectionRequest, service_connection_request_id)
-
- confirm_collaboration_admin(service_connection_request.collaboration_id)
-
service = service_connection_request.service
+ # Also service admins are allowed to delete service_connection_requests
+ try:
+ confirm_collaboration_admin(service_connection_request.collaboration_id)
+ except Forbidden:
+ confirm_service_manager(service.id)
+
emit_socket(f"service_{service.id}")
emit_socket(f"collaboration_{service_connection_request.collaboration_id}", include_current_user_id=True)
@@ -164,6 +161,7 @@ def request_new_service_connection(collaboration, message, service, user):
existing_request = ServiceConnectionRequest.query \
.filter(ServiceConnectionRequest.collaboration_id == collaboration.id) \
.filter(ServiceConnectionRequest.service_id == service.id) \
+ .filter(ServiceConnectionRequest.status == STATUS_OPEN) \
.all()
if existing_request:
raise BadRequest(f"outstanding_service_connection_request: {service.name} and {collaboration.name}")
@@ -202,7 +200,7 @@ def deny_service_connection_request():
@service_connection_request_api.route("/all/", methods=["GET"], strict_slashes=False)
@json_endpoint
def all_service_request_connections_by_service(service_id):
- confirm_write_access(service_id, override_func=is_service_admin)
+ confirm_write_access(service_id, override_func=is_service_admin_or_manager)
return ServiceConnectionRequest.query \
.join(ServiceConnectionRequest.collaboration) \
.join(ServiceConnectionRequest.requester) \
diff --git a/server/api/service_group.py b/server/api/service_group.py
index a3355cf39..5ab5d4a92 100644
--- a/server/api/service_group.py
+++ b/server/api/service_group.py
@@ -17,7 +17,7 @@
def create_service_group(service: Service, collaboration: Collaboration, service_group: ServiceGroup):
data = {
"name": service_group.name,
- "description": f"Provisioned by service {service.name} - {service_group.description}",
+ "description": service_group.description,
"short_name": f"{service.abbreviation}-{service_group.short_name}",
"collaboration_id": collaboration.id,
"auto_provision_members": service_group.auto_provision_members,
@@ -109,7 +109,7 @@ def update_service_group():
"id": group.id,
"name": service_group.name,
"short_name": short_name,
- "description": f"Provisioned by service {service.name} - {service_group.description}",
+ "description": service_group.description,
"auto_provision_members": service_group.auto_provision_members,
"global_urn": f"{collaboration.organisation.short_name}:{collaboration.short_name}:{short_name}",
"identifier": group.identifier,
diff --git a/server/api/service_invitation.py b/server/api/service_invitation.py
index 9d8cb0275..e54daddf1 100644
--- a/server/api/service_invitation.py
+++ b/server/api/service_invitation.py
@@ -1,7 +1,6 @@
-import datetime
-
from flask import Blueprint, request as current_request, current_app
-from sqlalchemy.orm import joinedload
+from sqlalchemy import func
+from sqlalchemy.orm import joinedload, load_only
from werkzeug.exceptions import Conflict
from server.api.base import json_endpoint, query_param, emit_socket
@@ -10,6 +9,7 @@
from server.db.domain import ServiceInvitation, Service, ServiceMembership, db
from server.db.models import delete
from server.mail import mail_service_invitation
+from server.tools import dt_now
service_invitations_api = Blueprint("service_invitations_api", __name__,
url_prefix="/api/service_invitations")
@@ -29,14 +29,15 @@ def do_resend(service_invitation_id):
.one()
confirm_service_admin(service_invitation.service_id)
service_invitation.expiry_date = default_expiry_date()
- service_invitation.created_at = datetime.date.today(),
+ service_invitation.created_at = dt_now()
service_invitation = db.session.merge(service_invitation)
mail_service_invitation({
"salutation": "Dear",
"invitation": service_invitation,
"base_url": current_app.app_config.base_url,
+ "intended_role": service_invitation.intended_role,
"recipient": service_invitation.invitee_email
- }, service_invitation.service, [service_invitation.invitee_email])
+ }, service_invitation.service, [service_invitation.invitee_email], reminder=True)
@service_invitations_api.route("/find_by_hash", strict_slashes=False)
@@ -60,7 +61,7 @@ def service_invitations_accept():
.filter(ServiceInvitation.hash == current_request.get_json()["hash"]) \
.one()
- if service_invitation.expiry_date and service_invitation.expiry_date < datetime.datetime.now():
+ if service_invitation.expiry_date and service_invitation.expiry_date < dt_now():
delete(ServiceInvitation, service_invitation.id)
raise Conflict(f"The invitation has expired at {service_invitation.expiry_date}")
@@ -126,3 +127,20 @@ def delete_service_invitation(id):
emit_socket(f"service_{service_invitation.service_id}")
return delete(ServiceInvitation, id)
+
+
+@service_invitations_api.route("/exists_email", methods=["POST"], strict_slashes=False)
+@json_endpoint
+def invitation_exists_by_email():
+ data = current_request.get_json()
+ service_id = int(data["service_id"])
+ invitations = service_invitations_by_email(data["emails"], service_id)
+ return [i.invitee_email for i in invitations], 200
+
+
+def service_invitations_by_email(emails, service_id):
+ invitations = ServiceInvitation.query.options(load_only(ServiceInvitation.invitee_email)) \
+ .filter(func.lower(ServiceInvitation.invitee_email).in_([e.lower() for e in emails])) \
+ .filter(ServiceInvitation.service_id == service_id) \
+ .all()
+ return invitations
diff --git a/server/api/service_membership.py b/server/api/service_membership.py
index 1e74ac0a5..b239f5348 100644
--- a/server/api/service_membership.py
+++ b/server/api/service_membership.py
@@ -43,3 +43,25 @@ def create_service_membership_role():
db.session.merge(service_membership)
return service_membership, 201
+
+
+@service_membership_api.route("/", methods=["PUT"], strict_slashes=False)
+@json_endpoint
+def update_service_membership_role():
+ client_data = current_request.get_json()
+ service_id = client_data["serviceId"]
+ user_id = client_data["userId"]
+ role = client_data["role"]
+
+ confirm_service_admin(service_id)
+
+ service_membership = ServiceMembership.query \
+ .filter(ServiceMembership.service_id == service_id) \
+ .filter(ServiceMembership.user_id == user_id) \
+ .one()
+ service_membership.role = role
+
+ emit_socket(f"service_{service_id}", include_current_user_id=True)
+
+ db.session.merge(service_membership)
+ return service_membership, 201
diff --git a/server/api/service_request.py b/server/api/service_request.py
index d5f4f3ce0..99ec21386 100644
--- a/server/api/service_request.py
+++ b/server/api/service_request.py
@@ -6,11 +6,11 @@
from sqlalchemy.orm import contains_eager
from werkzeug.exceptions import BadRequest
-from server.api.base import json_endpoint, STATUS_OPEN, STATUS_APPROVED, STATUS_DENIED, emit_socket
+from server.api.base import json_endpoint, emit_socket
from server.api.service import URI_ATTRIBUTES
from server.auth.security import current_user_id, current_user_name, \
confirm_write_access
-from server.db.defaults import cleanse_short_name, valid_uri_attributes
+from server.db.defaults import cleanse_short_name, valid_uri_attributes, STATUS_OPEN, STATUS_DENIED, STATUS_APPROVED
from server.db.domain import User, Service, ServiceMembership, db, \
ServiceRequest
from server.db.logo_mixin import logo_from_cache
diff --git a/server/api/system.py b/server/api/system.py
index 33604e538..b237cd69c 100644
--- a/server/api/system.py
+++ b/server/api/system.py
@@ -6,7 +6,7 @@
from werkzeug.exceptions import BadRequest
from server.api.base import json_endpoint
-from server.auth.security import confirm_write_access, current_user_id
+from server.auth.security import confirm_write_access, current_user_id, confirm_stats_access
from server.cron.scim_sweep_services import scim_sweep_services
from server.db.audit_mixin import metadata
from server.db.db import db
@@ -103,6 +103,26 @@ def do_cleanup_non_open_requests():
return cleanup_non_open_requests(current_app), 201
+@system_api.route("/invitation_reminders", strict_slashes=False, methods=["PUT"])
+@json_endpoint
+def do_invitation_reminders():
+ confirm_write_access()
+
+ from server.cron.invitation_reminders import invitation_reminders
+
+ return invitation_reminders(current_app), 201
+
+
+@system_api.route("/open_requests", strict_slashes=False, methods=["GET"])
+@json_endpoint
+def do_open_requests():
+ confirm_write_access()
+
+ from server.cron.open_requests import open_requests
+
+ return open_requests(current_app), 200
+
+
@system_api.route("/db_stats", strict_slashes=False, methods=["GET"])
@json_endpoint
def do_db_stats():
@@ -263,7 +283,7 @@ def sweep():
@system_api.route("/statistics", strict_slashes=False, methods=["GET"])
@json_endpoint
def statistics():
- confirm_write_access()
+ confirm_stats_access()
def group_by_month(cls):
month = extract("month", cls.created_at)
diff --git a/server/api/token.py b/server/api/token.py
index 60aaefb5b..ea8a620f6 100644
--- a/server/api/token.py
+++ b/server/api/token.py
@@ -1,5 +1,6 @@
import datetime
+from flasgger import swag_from
from flask import Blueprint, request as current_request, current_app
from server.api.base import json_endpoint
@@ -11,11 +12,13 @@
from server.db.activity import update_last_activity_date
from server.db.domain import UserToken
from server.db.models import log_user_login
+from server.tools import dt_now
token_api = Blueprint("token_api", __name__, url_prefix="/api/tokens")
@token_api.route("/introspect", methods=["POST"], strict_slashes=False)
+@swag_from("../swagger/public/paths/token_introspect.yml")
@json_endpoint
def introspect():
service = validate_service_token("token_enabled", SERVICE_TOKEN_INTROSPECTION)
@@ -26,7 +29,7 @@ def introspect():
if not user_token or user_token.service_id != service.id:
res = {"status": "token-unknown", "active": False}
- current_time = datetime.datetime.utcnow()
+ current_time = dt_now()
expiry_date = current_time - datetime.timedelta(days=service.token_validity_days)
if res["active"] and user_token.created_at < expiry_date:
res = {"status": "token-expired", "active": False}
diff --git a/server/api/user.py b/server/api/user.py
index 7a23fe86a..e4739c89c 100644
--- a/server/api/user.py
+++ b/server/api/user.py
@@ -1,7 +1,5 @@
-import datetime
import itertools
import json
-import os
import subprocess
import tempfile
import unicodedata
@@ -31,11 +29,12 @@
from server.db.defaults import full_text_search_autocomplete_limit, SBS_LOGIN
from server.db.domain import User, OrganisationMembership, CollaborationMembership, JoinRequest, CollaborationRequest, \
UserNameHistory, SshKey, ServiceMembership, ServiceAup, UserIpNetwork, \
- ServiceRequest
+ ServiceRequest, ServiceConnectionRequest
from server.db.models import log_user_login
from server.logger.context_logger import ctx_logger
from server.mail import mail_error, mail_account_deletion
from server.scim.events import broadcast_user_deleted, broadcast_user_changed
+from server.tools import dt_now
user_api = Blueprint("user_api", __name__, url_prefix="/api/users")
@@ -96,7 +95,10 @@ def _user_query():
.subqueryload(ServiceMembership.service)) \
.options(joinedload(User.join_requests)
.subqueryload(JoinRequest.collaboration)) \
+ .options(joinedload(User.service_connection_requests)
+ .subqueryload(ServiceConnectionRequest.service)) \
.options(joinedload(User.aups)) \
+ .options(joinedload(User.organisation_aups)) \
.options(joinedload(User.service_requests)) \
.options(joinedload(User.collaboration_requests)
.subqueryload(CollaborationRequest.organisation))
@@ -303,7 +305,7 @@ def resume_session():
add_user_claims(user_info_json, uid, user)
# last_login_date is set later in this method
- user.last_accessed_date = datetime.datetime.now()
+ user.last_accessed_date = dt_now()
logger.info(f"Provisioning new user {user.uid}")
else:
logger.info(f"Updating user {user.uid} with new claims / updated at")
@@ -368,7 +370,7 @@ def resume_session():
no_mfa_required = not oidc_config.second_factor_authentication_required
second_factor_confirmed = (no_mfa_required or not fallback_required) and not user.ssid_required
if second_factor_confirmed:
- user.last_login_date = datetime.datetime.now()
+ user.last_login_date = dt_now()
return redirect_to_client(cfg, second_factor_confirmed, user)
@@ -451,7 +453,7 @@ def acs():
if second_factor_confirmed:
user.ssid_required = False
- user.last_login_date = datetime.datetime.now()
+ user.last_login_date = dt_now()
else:
return redirect(
location=f"{cfg.base_url}/error?reason=ssid_failed&code={status.get('code')}&msg={status.get('msg')}")
@@ -552,7 +554,7 @@ def activate():
user = db.session.get(User, int(body["user_id"]))
user.suspended = False
- user.last_login_date = datetime.datetime.now()
+ user.last_login_date = dt_now()
user.suspend_notifications = []
db.session.merge(user)
return {}, 201
@@ -727,10 +729,10 @@ def error():
user_id = user.get("id")
if not user_id:
return {}, 201
- js_dump = json.dumps(request_json, default=str)
+ js_dump = json.dumps(request_json, indent=4, default=str)
ctx_logger("user").exception(js_dump)
mail_conf = current_app.app_config.mail
- if mail_conf.send_js_exceptions and not os.environ.get("TESTING"):
+ if mail_conf.send_js_exceptions:
user_id = user.get("email") or user.get("name") or user_id
mail_error(mail_conf.environment, user_id, mail_conf.send_exceptions_recipients, js_dump)
diff --git a/server/api/user_saml.py b/server/api/user_saml.py
index f36a97d18..8ea31efd5 100644
--- a/server/api/user_saml.py
+++ b/server/api/user_saml.py
@@ -1,5 +1,4 @@
import uuid
-from datetime import datetime
from urllib.parse import urlencode
from flask import Blueprint, current_app, request as current_request
@@ -17,8 +16,10 @@
from server.db.domain import User, Service
from server.db.models import log_user_login
from server.logger.context_logger import ctx_logger
+from server import tools
import urllib.parse
+
user_saml_api = Blueprint("user_saml_api", __name__, url_prefix="/api/users")
USER_UNKNOWN = 1
@@ -201,7 +202,7 @@ def _do_attributes(user, uid, service, service_entity_id, not_authorized_func, a
logger.debug(f"Returning interrupt for user {uid} and service_entity_id {service_entity_id} to accept AUP")
return not_authorized_func(service, AUP_NOT_AGREED)
- now = datetime.now()
+ now = tools.dt_now()
for coll in connected_collaborations:
coll.last_activity_date = now
db.session.merge(coll)
diff --git a/server/api/user_token.py b/server/api/user_token.py
index 15b879d41..8b0a7809c 100644
--- a/server/api/user_token.py
+++ b/server/api/user_token.py
@@ -1,5 +1,3 @@
-import datetime
-
from flask import Blueprint, jsonify, request as current_request, session
from werkzeug.exceptions import Forbidden
@@ -10,6 +8,7 @@
from server.db.db import db
from server.db.domain import UserToken, User, Service
from server.db.models import save, delete
+from server.tools import dt_now
user_token_api = Blueprint("user_token_api", __name__, url_prefix="/api/user_tokens")
@@ -90,7 +89,7 @@ def update_token():
def renew_lease():
data = _sanitize_and_verify(current_request.get_json(), hash_token=False)
user_token = db.session.get(UserToken, data["id"])
- user_token.created_at = datetime.datetime.utcnow()
+ user_token.created_at = dt_now()
db.session.merge(user_token)
return {}, 201
diff --git a/server/auth/mfa.py b/server/auth/mfa.py
index 9d72493bf..cb98f0833 100644
--- a/server/auth/mfa.py
+++ b/server/auth/mfa.py
@@ -1,5 +1,5 @@
import json
-from datetime import datetime, timedelta
+from datetime import timedelta
import jwt
import requests
@@ -11,6 +11,8 @@
from server.db.db import db
from server.db.domain import Organisation, SchacHomeOrganisation
from server.logger.context_logger import ctx_logger
+from server.tools import dt_now
+
ACR_VALUES = "https://refeds.org/profile/mfa"
@@ -88,7 +90,8 @@ def eligible_users_to_reset_token(user):
user_info = [{"name": u["user"].name, "email": u["user"].email, "unit": u["unit"]} for u in user_information]
if not user_info:
- user_info.append({"name": "SRAM support", "email": current_app.app_config.mail.info_email, "unit": "admin"})
+ # Empty strings will be translated client side
+ user_info.append({"name": "", "email": current_app.app_config.mail.info_email, "unit": ""})
return user_info
@@ -119,7 +122,7 @@ def schac_match(configured_schac_home, schac_home):
def has_valid_mfa(user):
last_login_date = user.last_login_date
login_sso_cutoff = timedelta(hours=0, minutes=int(current_app.app_config.mfa_sso_time_in_minutes))
- valid_mfa_sso = last_login_date and datetime.now() - user.last_login_date < login_sso_cutoff
+ valid_mfa_sso = last_login_date and dt_now() - user.last_login_date < login_sso_cutoff
logger = ctx_logger("user_api")
logger.debug(f"has_valid_mfa: {valid_mfa_sso} (user={user}, last_login={last_login_date}")
diff --git a/server/auth/rate_limit.py b/server/auth/rate_limit.py
index 4de6d8b6b..aea25c741 100644
--- a/server/auth/rate_limit.py
+++ b/server/auth/rate_limit.py
@@ -5,6 +5,7 @@
from server.db.db import db
from server.db.domain import User
+from server.tools import dt_now
def check_rate_limit(user):
@@ -20,16 +21,16 @@ def rate_limit_reached(user: User):
redis = current_app.redis_client
key = str(user.id)
value = redis.get(key)
- rate_limit_info = json.loads(value) if value else {"date": datetime.now().isoformat(), "count": 0}
+ rate_limit_info = json.loads(value) if value else {"date": dt_now().isoformat(), "count": 0}
first_guess = datetime.fromisoformat(rate_limit_info["date"])
- seconds_ago = datetime.now() - timedelta(hours=0, minutes=0, seconds=30)
+ seconds_ago = dt_now() - timedelta(hours=0, minutes=0, seconds=30)
count = rate_limit_info["count"]
rate_limit = current_app.app_config.rate_limit_totp_guesses_per_30_seconds
max_reached = count >= rate_limit and first_guess >= seconds_ago
if not max_reached:
# Need to reset the first_guess if it is more then 30 seconds ago, otherwise the user can still brute force
in_30_seconds_window = first_guess > seconds_ago
- new_date = first_guess.isoformat() if in_30_seconds_window else datetime.now().isoformat()
+ new_date = first_guess.isoformat() if in_30_seconds_window else dt_now().isoformat()
new_count = count + 1 if in_30_seconds_window else 0
redis.set(key, json.dumps({"date": new_date, "count": new_count}))
return max_reached
diff --git a/server/auth/secrets.py b/server/auth/secrets.py
index c6e3cb5fa..59ae3603c 100644
--- a/server/auth/secrets.py
+++ b/server/auth/secrets.py
@@ -1,8 +1,13 @@
+import base64
import hashlib
+import json
+import secrets
import string
from secrets import token_urlsafe, SystemRandom
import bcrypt
+from cryptography.exceptions import InvalidTag
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from werkzeug.exceptions import SecurityError
SYSTEM_RANDOM = SystemRandom()
@@ -32,3 +37,26 @@ def generate_ldap_password_with_hash():
password = start + "".join(SYSTEM_RANDOM.sample(population=ldap_characters, k=31))
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
return hashed.decode("utf-8"), password
+
+
+def encrypt_secret(encryption_key: str, plain_secret: str, context: dict) -> str:
+ nonce = secrets.token_urlsafe()
+ context["plain_secret"] = plain_secret
+ aes_gcm = AESGCM(base64.b64decode(encryption_key))
+ data = json.dumps(context).encode()
+ encrypted_context = aes_gcm.encrypt(nonce.encode(), data, None)
+ return f"{nonce}:{base64.b64encode(encrypted_context).decode()}"
+
+
+def decrypt_secret(encryption_key: str, encrypted_value: str, context: dict) -> str:
+ index = encrypted_value.index(':')
+ nonce = encrypted_value[0:index]
+ encrypted_context = encrypted_value[index + 1:]
+ data = base64.b64decode(encrypted_context)
+ aes_gcm = AESGCM(base64.b64decode(encryption_key))
+ decrypted = aes_gcm.decrypt(nonce.encode(), data, None).decode()
+ original_context = json.loads(decrypted)
+ for key, value in context.items():
+ if value != original_context[key]:
+ raise InvalidTag(f"Invalid value(={original_context[key]}) for {key}, expected {value}")
+ return original_context["plain_secret"]
diff --git a/server/auth/security.py b/server/auth/security.py
index 9c2b102f9..074e4b2d8 100644
--- a/server/auth/security.py
+++ b/server/auth/security.py
@@ -4,7 +4,7 @@
from server.db.db import db
from server.db.domain import (CollaborationMembership, OrganisationMembership, Collaboration, User,
- ServiceMembership, Organisation)
+ ServiceMembership)
CSRF_TOKEN = "CSRFToken"
@@ -130,6 +130,10 @@ def confirm_ipaddress_access(*args, override_func=None):
return confirm_scope_access(*args, override_func=override_func, scope="ipaddress")
+def confirm_stats_access(*args, override_func=None):
+ return confirm_scope_access(*args, override_func=override_func, scope="stats")
+
+
def is_current_user_organisation_admin_or_manager(collaboration_id):
return is_organisation_admin_or_manager(db.session.get(Collaboration, collaboration_id).organisation_id)
@@ -154,17 +158,6 @@ def is_organisation_admin_or_manager(organisation_id=None):
return _has_organisation_role(organisation_id, ["admin", "manager"])
-def access_allowed_to_collaboration_as_org_member(collaboration_id):
- user_id = current_user_id()
- query = OrganisationMembership.query \
- .options(load_only(OrganisationMembership.user_id)) \
- .join(OrganisationMembership.organisation) \
- .join(Organisation.collaborations) \
- .filter(Collaboration.id == collaboration_id) \
- .filter(OrganisationMembership.user_id == user_id)
- return query.count() > 0
-
-
def _has_organisation_role(organisation_id, roles):
user_id = current_user_id()
query = OrganisationMembership.query \
@@ -226,6 +219,17 @@ def override_func():
def is_service_admin(service_id=None):
+ user_id = current_user_id()
+ query = ServiceMembership.query \
+ .options(load_only(ServiceMembership.user_id)) \
+ .filter(ServiceMembership.user_id == user_id) \
+ .filter(ServiceMembership.role == "admin")
+ if service_id:
+ query = query.filter(ServiceMembership.service_id == service_id)
+ return query.count() > 0
+
+
+def is_service_admin_or_manager(service_id=None):
user_id = current_user_id()
query = ServiceMembership.query \
.options(load_only(ServiceMembership.user_id)) \
@@ -240,3 +244,10 @@ def override_func():
return is_service_admin(service_id)
confirm_write_access(override_func=override_func)
+
+
+def confirm_service_manager(service_id=None):
+ def override_func():
+ return is_service_admin_or_manager(service_id)
+
+ confirm_write_access(override_func=override_func)
diff --git a/server/auth/tokens.py b/server/auth/tokens.py
index c49fdde0a..b4dcca824 100644
--- a/server/auth/tokens.py
+++ b/server/auth/tokens.py
@@ -1,11 +1,23 @@
-from flask import request as current_request, g as request_context
-from werkzeug.exceptions import Unauthorized
+from flask import request as current_request, g as request_context, current_app
+from werkzeug.exceptions import Unauthorized, BadRequest
-from server.auth.secrets import secure_hash
+from server.auth.secrets import secure_hash, encrypt_secret, decrypt_secret
+from server.db.db import db
from server.db.domain import Service, ServiceToken
from server.logger.context_logger import ctx_logger
+def _service_context(service: Service):
+ return {"scim_url": service.scim_url, "identifier": service.id, "table_name": "services"}
+
+
+def _get_encryption_key(service: Service):
+ if not service.scim_bearer_token or not service.scim_url:
+ raise BadRequest("encrypt_scim_bearer_token requires scim_bearer_token and scim_url")
+ encryption_key = current_app.app_config.encryption_key
+ return encryption_key
+
+
def get_authorization_header(is_external_api_url, ignore_missing_auth_header=False):
authorization_header = current_request.headers.get("Authorization")
is_authorized_api_key = authorization_header and authorization_header.lower().startswith("bearer")
@@ -30,3 +42,15 @@ def validate_service_token(attr_enabled, token_type) -> Service:
raise Unauthorized()
request_context.service_token = f"Service token {service.name}"
return service
+
+
+def encrypt_scim_bearer_token(service: Service):
+ encryption_key = _get_encryption_key(service)
+ encrypted_bearer_token = encrypt_secret(encryption_key, service.scim_bearer_token, _service_context(service))
+ service.scim_bearer_token = encrypted_bearer_token
+ db.session.merge(service)
+
+
+def decrypt_scim_bearer_token(service: Service):
+ encryption_key = _get_encryption_key(service)
+ return decrypt_secret(encryption_key, service.scim_bearer_token, _service_context(service))
diff --git a/server/auth/user_claims.py b/server/auth/user_claims.py
index 45e05b460..35f720ced 100644
--- a/server/auth/user_claims.py
+++ b/server/auth/user_claims.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-import datetime
import os
import random
import re
@@ -15,6 +14,7 @@
from server.db.domain import User, UserNameHistory, Collaboration
from server.logger.context_logger import ctx_logger
from server.mail import mail_error
+from server.tools import dt_now
claim_attribute_mapping_value = [
{"sub": "uid"},
@@ -90,7 +90,7 @@ def add_user_claims(user_info_json, uid, user):
# return all active (non-expired/suspended) collaboration from the list
def _active_collaborations(collaborations: Iterator[Collaboration]) -> Iterator[Collaboration]:
- now = datetime.datetime.utcnow()
+ now = dt_now()
for collaboration in collaborations:
not_expired = not collaboration.expiry_date or collaboration.expiry_date > now
if not_expired and collaboration.status != STATUS_SUSPENDED:
@@ -142,7 +142,7 @@ def co_tags(connected_collaborations: list[Collaboration]) -> set[str]:
def collaboration_memberships_for_service(user, service):
memberships = []
- now = datetime.datetime.utcnow()
+ now = dt_now()
if user and service:
for cm in user.collaboration_memberships:
co_expired = cm.collaboration.expiry_date and cm.collaboration.expiry_date < now
diff --git a/server/config/test_config.yml b/server/config/test_config.yml
index e6bd4f535..e46d446de 100644
--- a/server/config/test_config.yml
+++ b/server/config/test_config.yml
@@ -2,6 +2,10 @@ database:
uri: "mysql+mysqldb://sbs:sbs@127.0.0.1/sbs_test?charset=utf8mb4"
secret_key: secret
+# Must be a base64 encoded key of 128, 192, or 256 bits.
+# Hint: base64.b64encode(AESGCM.generate_key(bit_length=256)).decode() or
+# base64.b64encode(os.urandom(256 // 8)).decode()
+encryption_key: 3Kw2sDznh4jSZsShUcsxgfeOkaaKE8TC24OWJ1KWeDs=
# Lifetime of session in minutes (one day is 60 * 24)
permanent_session_lifetime: 1440
@@ -14,7 +18,7 @@ socket_url: "127.0.0.1:8080/"
api_users:
- name: "sysadmin"
password: "secret"
- scopes: [ "read", "write", "system", "ipaddress" ]
+ scopes: [ "read", "write", "system", "ipaddress", "stats" ]
- name: "sysread"
password: "secret"
scopes: [ read ]
@@ -73,6 +77,7 @@ mail:
suppress_sending_mails: False
info_email: sram-support@surf.nl
beheer_email: sram-beheer@surf.nl
+ ticket_email: sram-support@surf.nl
eduteams_email: support+sram@eduteams.org
# Do we mail a summary of new Organizations and Services to the beheer_email?
audit_trail_notifications_enabled: True
@@ -119,6 +124,7 @@ retention:
remove_suspended_users_period_days: 90
reminder_expiry_period_days: 7
cron_hour_of_day: 7
+ admin_notification_mail: True
metadata:
idp_url: "file://data/idps-metadata.xml"
@@ -179,6 +185,13 @@ membership_expiration:
# How many days before actual expiration do we mail the co admin and member
expired_warning_mail_days_threshold: 14
+invitation_reminders:
+ # Do we daily check for invitations that need a reminder?
+ enabled: True
+ cron_hour_of_day: 10
+ # How long before expiration of an invitation do we remind the user?
+ invitation_reminders_threshold: 5
+
orphan_users:
# Do we daily check for users that are orphans?
enabled: True
@@ -186,6 +199,11 @@ orphan_users:
# How long after created do we delete orphan users
delete_days_threshold: -1
+open_requests:
+ # Do we weekly check for all open requests?
+ enabled: True
+ cron_day_of_week: 1
+
scim_sweep:
# Do we enable scim sweeps?
enabled: False
diff --git a/server/cron/cleanup_non_open_requests.py b/server/cron/cleanup_non_open_requests.py
index be46f9439..b878c46f0 100644
--- a/server/cron/cleanup_non_open_requests.py
+++ b/server/cron/cleanup_non_open_requests.py
@@ -8,6 +8,7 @@
from server.cron.shared import obtain_lock
from server.db.domain import CollaborationRequest, JoinRequest
from server.db.models import delete
+from server.tools import dt_now
cleanup_non_open_requests_lock_name = "cleanup_non_open_requests_lock_name"
@@ -21,7 +22,7 @@ def _do_cleanup_non_open_requests(app):
with app.app_context():
cfq = app.app_config.user_requests_retention
- current_time = datetime.datetime.utcnow()
+ current_time = dt_now()
retention_date = current_time - datetime.timedelta(days=cfq.outstanding_join_request_days_threshold)
start = int(time.time() * 1000.0)
diff --git a/server/cron/collaboration_expiration.py b/server/cron/collaboration_expiration.py
index 7b63cb365..21b259703 100644
--- a/server/cron/collaboration_expiration.py
+++ b/server/cron/collaboration_expiration.py
@@ -9,6 +9,7 @@
from server.db.defaults import STATUS_EXPIRED, STATUS_ACTIVE
from server.db.domain import Collaboration
from server.mail import mail_collaboration_expires_notification
+from server.tools import dt_now
collaboration_expiration_lock_name = "collaboration_expiration_lock_name"
@@ -23,7 +24,7 @@ def _do_expire_collaboration(app):
with app.app_context():
cfq = app.app_config.collaboration_expiration
- now = datetime.datetime.utcnow()
+ now = dt_now()
start = int(time.time() * 1000.0)
logger = logging.getLogger("scheduler")
diff --git a/server/cron/collaboration_inactivity_suspension.py b/server/cron/collaboration_inactivity_suspension.py
index d1cf6708f..25617ed13 100644
--- a/server/cron/collaboration_inactivity_suspension.py
+++ b/server/cron/collaboration_inactivity_suspension.py
@@ -9,6 +9,7 @@
from server.db.defaults import STATUS_ACTIVE, STATUS_SUSPENDED
from server.db.domain import Collaboration
from server.mail import mail_collaboration_suspension_notification
+from server.tools import dt_now
collaboration_inactivity_suspension_lock_name = "collaboration_inactivity_suspension_lock_name"
@@ -23,7 +24,7 @@ def _do_suspend_collaboration(app):
with app.app_context():
cfq = app.app_config.collaboration_suspension
- now = datetime.datetime.utcnow()
+ now = dt_now()
start = int(time.time() * 1000.0)
logger = logging.getLogger("scheduler")
diff --git a/server/cron/invitation_reminders.py b/server/cron/invitation_reminders.py
new file mode 100644
index 000000000..d5f56df73
--- /dev/null
+++ b/server/cron/invitation_reminders.py
@@ -0,0 +1,104 @@
+import datetime
+import logging
+import time
+
+from server.cron.shared import obtain_lock
+from server.db.db import db
+from server.db.defaults import STATUS_OPEN
+from server.db.domain import Invitation, OrganisationInvitation, ServiceInvitation
+from server.mail import mail_service_invitation, mail_collaboration_invitation, \
+ mail_organisation_invitation
+from server.tools import dt_now
+
+invitation_reminders_lock_name = "invitation_reminders_lock_name"
+
+
+def _result_container():
+ return {
+ "invitations": [],
+ "organisation_invitations": [],
+ "service_invitations": []
+ }
+
+
+def _do_invitation_reminders(app):
+ with app.app_context():
+ cfq = app.app_config.invitation_reminders
+
+ now = dt_now()
+
+ start = int(time.time() * 1000.0)
+ logger = logging.getLogger("scheduler")
+ logger.info("Start running invitation_reminders job")
+
+ reminder_date = now + datetime.timedelta(days=cfq.invitation_reminders_threshold)
+ results = _result_container()
+
+ invitations = Invitation.query \
+ .filter(Invitation.status == STATUS_OPEN) \
+ .filter(Invitation.expiry_date < reminder_date) \
+ .filter(Invitation.reminder_send == False) \
+ .all() # noqa: E712
+
+ for invitation in invitations:
+ invitation.reminder_send = True
+ db.session.merge(invitation)
+
+ results["invitations"].append(invitation.invitee_email)
+
+ mail_collaboration_invitation({
+ "salutation": "Dear",
+ "invitation": invitation,
+ "base_url": app.app_config.base_url,
+ "wiki_link": app.app_config.wiki_link,
+ "recipient": invitation.invitee_email
+ }, invitation.collaboration, [invitation.invitee_email], reminder=True,
+ preview=False, working_outside_of_request_context=True)
+
+ organisation_invitations = OrganisationInvitation.query \
+ .filter(OrganisationInvitation.expiry_date < reminder_date) \
+ .filter(OrganisationInvitation.reminder_send == False) \
+ .all() # noqa: E712
+ for invitation in organisation_invitations:
+ invitation.reminder_send = True
+ db.session.merge(invitation)
+
+ results["organisation_invitations"].append(invitation.invitee_email)
+
+ mail_organisation_invitation({
+ "salutation": "Dear",
+ "invitation": invitation,
+ "base_url": app.app_config.base_url,
+ "recipient": invitation.invitee_email
+ }, invitation.organisation, [invitation.invitee_email], reminder=True,
+ working_outside_of_request_context=True)
+
+ service_invitations = ServiceInvitation.query \
+ .filter(ServiceInvitation.expiry_date < reminder_date) \
+ .filter(ServiceInvitation.reminder_send == False) \
+ .all() # noqa: E712
+ for invitation in service_invitations:
+ invitation.reminder_send = True
+ db.session.merge(invitation)
+
+ results["service_invitations"].append(invitation.invitee_email)
+
+ mail_service_invitation({
+ "salutation": "Dear",
+ "invitation": invitation,
+ "base_url": app.app_config.base_url,
+ "intended_role": invitation.intended_role,
+ "recipient": invitation.invitee_email
+ }, invitation.service, [invitation.invitee_email], reminder=True,
+ working_outside_of_request_context=True)
+
+ db.session.commit()
+
+ end = int(time.time() * 1000.0)
+ logger.info(f"Finished running invitation_reminders job in {end - start} ms")
+
+ return results
+
+
+def invitation_reminders(app):
+ return obtain_lock(app, invitation_reminders_lock_name, _do_invitation_reminders, _result_container)
diff --git a/server/cron/membership_expiration.py b/server/cron/membership_expiration.py
index b8c773aa9..9b7a8177b 100644
--- a/server/cron/membership_expiration.py
+++ b/server/cron/membership_expiration.py
@@ -9,6 +9,7 @@
from server.db.defaults import STATUS_EXPIRED, STATUS_ACTIVE
from server.db.domain import CollaborationMembership
from server.mail import mail_membership_expires_notification
+from server.tools import dt_now
membership_expiration_lock_name = "membership_expiration_lock_name"
@@ -23,7 +24,7 @@ def _do_expire_memberships(app):
with app.app_context():
cfq = app.app_config.membership_expiration
- now = datetime.datetime.utcnow()
+ now = dt_now()
start = int(time.time() * 1000.0)
logger = logging.getLogger("scheduler")
diff --git a/server/cron/open_requests.py b/server/cron/open_requests.py
new file mode 100644
index 000000000..363ee7221
--- /dev/null
+++ b/server/cron/open_requests.py
@@ -0,0 +1,113 @@
+import logging
+import time
+
+from server.cron.shared import obtain_lock
+from server.db.defaults import STATUS_OPEN
+from server.db.domain import CollaborationRequest, User, \
+ JoinRequest, ServiceConnectionRequest, ServiceRequest
+from server.mail import mail_open_requests
+
+open_requests_lock_name = "open_requests_lock_name"
+
+
+def _result_container():
+ return {
+ "collaboration_requests": [],
+ "join_requests": [],
+ "service_connection_requests": [],
+ "service_requests": []
+ }
+
+
+def _recipients_to_json(recipients: dict):
+ recipients_json = {}
+
+ def open_request_summary(collection_name, requests):
+ if collection_name == "collaboration_requests":
+ return [{"name": cr.name, "requester": cr.requester.email} for cr in requests]
+ if collection_name == "join_requests":
+ return [{"name": jr.collaboration.name, "requester": jr.user.email} for jr in requests]
+ if collection_name == "service_connection_requests":
+ return [
+ {"service": scr.service.name, "organisation": scr.collaboration.name, "requester": scr.requester.email}
+ for scr in requests]
+ if collection_name == "service_requests":
+ return [{"name": sr.name, "requester": sr.requester.email} for sr in requests]
+
+ for key, val in recipients.items():
+ recipients_json[key] = {k: open_request_summary(k, v) for k, v in val.items()}
+ return recipients_json
+
+
+def _add_open_request_to_recipient(user: User, recipients: dict, collection_name, open_request):
+ recipient = recipients.get(user.email)
+ if not recipient:
+ recipient = _result_container()
+ recipients[user.email] = recipient
+ recipient[collection_name].append(open_request)
+
+
+def _do_open_requests(app):
+ with app.app_context():
+ start = int(time.time() * 1000.0)
+ logger = logging.getLogger("scheduler")
+ logger.info("Start running open_requests job")
+
+ # We track per recipient all open requests collections, see _result_container
+ recipients = {}
+
+ collaboration_requests = CollaborationRequest.query \
+ .filter(CollaborationRequest.status == STATUS_OPEN) \
+ .all()
+ for cr in collaboration_requests:
+ org_admins = [member for member in cr.organisation.organisation_memberships if member.role == "admin"]
+ for org_admin in org_admins:
+ _add_open_request_to_recipient(org_admin.user, recipients, "collaboration_requests", cr)
+
+ join_requests = JoinRequest.query \
+ .filter(JoinRequest.status == STATUS_OPEN) \
+ .all()
+ for jr in join_requests:
+ co_admins = [member for member in jr.collaboration.collaboration_memberships if member.role == "admin"]
+ for co_admin in co_admins:
+ _add_open_request_to_recipient(co_admin.user, recipients, "join_requests", jr)
+
+ service_connection_requests = ServiceConnectionRequest.query \
+ .filter(ServiceConnectionRequest.status == STATUS_OPEN) \
+ .filter(ServiceConnectionRequest.pending_organisation_approval == False) \
+ .all() # noqa: E712
+ for scr in service_connection_requests:
+ service_admins = [member for member in scr.service.service_memberships if member.role == "admin"]
+ for sa in service_admins:
+ _add_open_request_to_recipient(sa.user, recipients, "service_connection_requests", scr)
+
+ service_connection_requests = ServiceConnectionRequest.query \
+ .filter(ServiceConnectionRequest.status == STATUS_OPEN) \
+ .filter(ServiceConnectionRequest.pending_organisation_approval == True) \
+ .all() # noqa: E712
+ for scr in service_connection_requests:
+ org_admins = [m for m in scr.collaboration.organisation.organisation_memberships if m.role == "admin"]
+ for admin in org_admins:
+ _add_open_request_to_recipient(admin.user, recipients, "service_connection_requests", scr)
+
+ config = app.app_config
+ admin_users = [u.uid for u in config.admin_users]
+ platform_admins = User.query.filter(User.uid.in_(admin_users)).all()
+ service_requests = ServiceRequest.query \
+ .filter(ServiceRequest.status == STATUS_OPEN) \
+ .all()
+ for sr in service_requests:
+ for platform_admin in platform_admins:
+ _add_open_request_to_recipient(platform_admin, recipients, "service_requests", sr)
+
+ for recipient, context in recipients.items():
+ mail_open_requests(recipient, context)
+
+ end = int(time.time() * 1000.0)
+ logger.info(f"Finished running open_requests job in {end - start} ms")
+
+ return _recipients_to_json(recipients)
+
+
+def open_requests(app):
+ return obtain_lock(app, open_requests_lock_name, _do_open_requests, _result_container)
diff --git a/server/cron/orphan_users.py b/server/cron/orphan_users.py
index 20fbf4dcc..0b766587e 100644
--- a/server/cron/orphan_users.py
+++ b/server/cron/orphan_users.py
@@ -9,6 +9,7 @@
from server.db.db import db
from server.db.domain import User
from server.mail import mail_membership_orphan_users_deleted
+from server.tools import dt_now
orphan_users_lock_name = "orphan_users_lock_name"
@@ -21,7 +22,7 @@ def _do_orphan_users(app):
with app.app_context():
cfq = app.app_config.orphan_users
- now = datetime.datetime.utcnow()
+ now = dt_now()
start = int(time.time() * 1000.0)
logger = logging.getLogger("scheduler")
diff --git a/server/cron/outstanding_requests.py b/server/cron/outstanding_requests.py
index 080266946..6a3d6752d 100644
--- a/server/cron/outstanding_requests.py
+++ b/server/cron/outstanding_requests.py
@@ -8,6 +8,7 @@
from server.cron.shared import obtain_lock
from server.db.domain import CollaborationRequest, JoinRequest
from server.mail import mail_outstanding_requests
+from server.tools import dt_now
outstanding_requests_lock_name = "outstanding_requests_lock_name"
@@ -21,7 +22,7 @@ def _do_outstanding_requests(app):
with app.app_context():
cfq = app.app_config.platform_admin_notifications
- current_time = datetime.datetime.utcnow()
+ current_time = dt_now()
retention_date = current_time - datetime.timedelta(days=cfq.outstanding_join_request_days_threshold)
start = int(time.time() * 1000.0)
diff --git a/server/cron/schedule.py b/server/cron/schedule.py
index 7c685654d..a72960c30 100644
--- a/server/cron/schedule.py
+++ b/server/cron/schedule.py
@@ -3,6 +3,7 @@
from apscheduler.schedulers.background import BackgroundScheduler
+from server.cron.invitation_reminders import invitation_reminders
from server.cron.cleanup_non_open_requests import cleanup_non_open_requests
from server.cron.collaboration_expiration import expire_collaborations
from server.cron.collaboration_inactivity_suspension import suspend_collaborations
@@ -10,37 +11,42 @@
from server.cron.membership_expiration import expire_memberships
from server.cron.orphan_users import delete_orphan_users
from server.cron.outstanding_requests import outstanding_requests
+from server.cron.open_requests import open_requests
from server.cron.scim_sweep_services import scim_sweep_services
from server.cron.user_suspending import suspend_users
def start_scheduling(app):
scheduler = BackgroundScheduler()
- cfq = app.app_config
- retention = cfq.retention
+ cfg = app.app_config
+ retention = cfg.retention
options = {"trigger": "cron", "kwargs": {"app": app}, "day": "*", "timezone": 'UTC',
"misfire_grace_time": 60 * 60 * 12, "coalesce": True}
scheduler.add_job(func=suspend_users, hour=retention.cron_hour_of_day, **options)
scheduler.add_job(func=parse_idp_metadata, hour=retention.cron_hour_of_day, **options)
- if cfq.scim_sweep.enabled:
- sweep_services_options = {**options, **{"hour": "*", "minute": cfq.scim_sweep.cron_minutes_expression}}
+ if cfg.scim_sweep.enabled:
+ sweep_services_options = {**options, **{"hour": "*", "minute": cfg.scim_sweep.cron_minutes_expression}}
scheduler.add_job(func=scim_sweep_services, **sweep_services_options)
- if cfq.platform_admin_notifications.enabled:
- scheduler.add_job(func=outstanding_requests, hour=cfq.platform_admin_notifications.cron_hour_of_day, **options)
- if cfq.collaboration_expiration.enabled:
- scheduler.add_job(func=expire_collaborations, hour=cfq.collaboration_expiration.cron_hour_of_day, **options)
- if cfq.collaboration_suspension.enabled:
- scheduler.add_job(func=suspend_collaborations, hour=cfq.collaboration_suspension.cron_hour_of_day, **options)
- if cfq.membership_expiration.enabled:
- scheduler.add_job(func=expire_memberships, hour=cfq.membership_expiration.cron_hour_of_day, **options)
- if cfq.user_requests_retention.enabled:
- scheduler.add_job(func=cleanup_non_open_requests, hour=cfq.user_requests_retention.cron_hour_of_day, **options)
- if cfq.orphan_users.enabled:
- scheduler.add_job(func=delete_orphan_users, hour=cfq.orphan_users.cron_hour_of_day, **options)
-
- if cfq.metadata.get("parse_at_startup", False):
+ if cfg.platform_admin_notifications.enabled:
+ scheduler.add_job(func=outstanding_requests, hour=cfg.platform_admin_notifications.cron_hour_of_day, **options)
+ if cfg.collaboration_expiration.enabled:
+ scheduler.add_job(func=expire_collaborations, hour=cfg.collaboration_expiration.cron_hour_of_day, **options)
+ if cfg.collaboration_suspension.enabled:
+ scheduler.add_job(func=suspend_collaborations, hour=cfg.collaboration_suspension.cron_hour_of_day, **options)
+ if cfg.membership_expiration.enabled:
+ scheduler.add_job(func=expire_memberships, hour=cfg.membership_expiration.cron_hour_of_day, **options)
+ if cfg.user_requests_retention.enabled:
+ scheduler.add_job(func=cleanup_non_open_requests, hour=cfg.user_requests_retention.cron_hour_of_day, **options)
+ if cfg.orphan_users.enabled:
+ scheduler.add_job(func=delete_orphan_users, hour=cfg.orphan_users.cron_hour_of_day, **options)
+ if cfg.invitation_reminders.enabled:
+ scheduler.add_job(func=invitation_reminders, hour=cfg.invitation_reminders.cron_hour_of_day, **options)
+ if cfg.open_requests.enabled:
+ scheduler.add_job(func=open_requests, day_of_week=cfg.open_requests.cron_day_of_week, **options)
+
+ if cfg.metadata.get("parse_at_startup", False):
threading.Thread(target=parse_idp_metadata, args=(app,)).start()
# Shut down the scheduler when exiting the app
diff --git a/server/cron/scim_sweep_services.py b/server/cron/scim_sweep_services.py
index b41ce7a49..16fdba32d 100644
--- a/server/cron/scim_sweep_services.py
+++ b/server/cron/scim_sweep_services.py
@@ -6,6 +6,7 @@
from server.db.db import db
from server.db.domain import Service
from server.scim.sweep import perform_sweep
+from server.tools import dt_now
scim_sweep_services_lock_name = "scim_sweep_services_lock_name"
@@ -25,7 +26,7 @@ def _do_scim_sweep_services(app):
.filter(Service.sweep_scim_enabled == True) \
.all() # noqa: E712
- now = datetime.datetime.utcnow()
+ now = dt_now()
def service_needs_sweeping(service: Service):
if service.sweep_scim_last_run is None:
diff --git a/server/cron/user_suspending.py b/server/cron/user_suspending.py
index b76ec1b5c..8c722b31a 100644
--- a/server/cron/user_suspending.py
+++ b/server/cron/user_suspending.py
@@ -7,14 +7,16 @@
from server.cron.shared import obtain_lock
from server.db.db import db
from server.db.domain import User, SuspendNotification, UserNameHistory
-from server.mail import mail_suspend_notification, mail_suspended_account_deletion, format_date_time
+from server.mail import (mail_suspend_notification, mail_suspended_account_deletion, format_date_time,
+ mail_suspended_account_admin_notification)
+from server.tools import dt_today, dt_now
suspend_users_lock_name = "suspend_users_lock"
def create_suspend_notification(user, retention, app, is_warning, is_suspension):
suspend_notification = SuspendNotification(user=user,
- sent_at=datetime.datetime.utcnow(),
+ sent_at=dt_now(),
is_warning=is_warning,
is_suspension=is_suspension)
user.suspend_notifications.append(suspend_notification)
@@ -23,12 +25,12 @@ def create_suspend_notification(user, retention, app, is_warning, is_suspension)
logger = logging.getLogger("scheduler")
logger.info(f"Sending suspend notification (warning: {is_warning}, is_suspension={is_suspension}) to "
f"user {user.email} because last_login_date is {user.last_login_date}")
- current_time = datetime.datetime.utcnow()
+ current_time = dt_today()
suspension_date = current_time + datetime.timedelta(days=retention.reminder_suspend_period_days)
deletion_days = (
- retention.remove_suspended_users_period_days
- + retention.reminder_suspend_period_days
- + retention.reminder_expiry_period_days
+ retention.remove_suspended_users_period_days
+ + retention.reminder_suspend_period_days
+ + retention.reminder_expiry_period_days
)
if is_warning and not is_suspension:
deletion_days -= retention.reminder_suspend_period_days
@@ -39,11 +41,12 @@ def create_suspend_notification(user, retention, app, is_warning, is_suspension)
mail_suspend_notification({"salutation": f"Hi {user.given_name}",
"base_url": app.app_config.base_url,
"retention": retention,
- "days_ago": (datetime.datetime.utcnow() - user.last_login_date).days,
+ "days_ago": (dt_today() - user.last_login_date).days,
"suspend_notification": suspend_notification,
"suspension_date": format_date_time(suspension_date),
"deletion_date": format_date_time(deletion_date),
- "user": user
+ "user": user,
+ "support_address": app.app_config.mail.info_email,
},
[user.email], is_warning, is_suspension)
@@ -65,20 +68,25 @@ def _do_suspend_users(app):
logger = logging.getLogger("scheduler")
logger.info("Start running suspend_users job")
- current_time = datetime.datetime.utcnow()
+ current_time = dt_today()
+ # users who have been inactive since this date will be suspended
suspension_date = current_time - datetime.timedelta(days=retention.allowed_inactive_period_days)
- warning_date = suspension_date + datetime.timedelta(days=retention.reminder_suspend_period_days)
+ # users who have been inactive since this date will get a first suspension warning
+ suspension_warning_date = suspension_date + datetime.timedelta(days=retention.reminder_suspend_period_days)
+ # suspension warnings that have been sent before this date are considered "old" and can be acted upon
warning_timeout = current_time - datetime.timedelta(days=retention.reminder_suspend_period_days)
results = _result_container()
# note: we handle the following progression here:
- # - at allowed_inactive_period_days-reminder_suspend_period_days after the last login,
+ # - at warning_date = allowed_inactive_period_days-reminder_suspend_period_days after the last login,
# a user is warned about pending suspension
- # - at allowed_inactive_period_days after the last login, a user's account is suspended
+ # - at reminder_suspend_period_days after the warning _and_ allowed_inactive_period_days after the last login,
+ # a user's account is suspended
# - at remove_suspended_users_period_days-reminder_expiry_period_days after suspension, a
# user is warned about pending deletion
- # - at remove_suspended_users_period_days after suspension, a user is deleted
+ # - at reminder_expiry_period_days after the reminder _and_ remove_suspended_users_period_days after suspension,
+ # a user is deleted
# first, we handle suspension and warning about suspension
# concretely:
@@ -89,7 +97,7 @@ def _do_suspend_users(app):
excluded_user_accounts = [user.uid for user in app.app_config.excluded_user_accounts]
users = User.query \
- .filter(User.last_login_date < warning_date, User.suspended == False) \
+ .filter(User.last_login_date < suspension_warning_date, User.suspended == False) \
.filter(User.uid.not_in(excluded_user_accounts)) \
.all() # noqa: E712
for user in users:
@@ -99,13 +107,14 @@ def _do_suspend_users(app):
SuspendNotification.is_suspension.is_(True)
).order_by(desc(SuspendNotification.sent_at)).first()
- if last_suspend_notification is None or last_suspend_notification.sent_at < warning_date:
+ if last_suspend_notification is None or last_suspend_notification.sent_at < suspension_warning_date:
# no recent reminder, sent one first
+ logger.info(f"Sending suspension reminder to user {user.uid} ({user.email})")
create_suspend_notification(user, retention, app, True, True)
results["warning_suspend_notifications"].append(user.email)
- elif user.last_login_date < suspension_date and last_suspend_notification.sent_at < warning_timeout:
+ elif user.last_login_date < suspension_date < last_suspend_notification.sent_at < warning_timeout:
# user has gotten reminder, and we've waited long enough
- logger.info(f"Suspending user {user.email} because of inactivity")
+ logger.info(f"Suspending user {user.uid} ({user.email}) because of inactivity")
user.suspended = True
create_suspend_notification(user, retention, app, False, True)
results["suspended_notifications"].append(user.email)
@@ -120,20 +129,24 @@ def _do_suspend_users(app):
# - if deletion reminder was reminder_expiry_period_days ago, and last login was
# (allowed_inactive_period_days+remove_suspended_users_period_days) days ago, delete user
+ # users who have been inactive since this date can be deleted
deletion_date = (
- current_time
- - datetime.timedelta(days=retention.allowed_inactive_period_days)
- - datetime.timedelta(days=retention.remove_suspended_users_period_days)
+ current_time
+ - datetime.timedelta(days=retention.allowed_inactive_period_days)
+ - datetime.timedelta(days=retention.remove_suspended_users_period_days)
)
- warning_date = deletion_date + datetime.timedelta(days=retention.reminder_expiry_period_days)
+ # users who have been inactive since this date will get a first deletion warning
+ deletion_warning_date = deletion_date + datetime.timedelta(days=retention.reminder_expiry_period_days)
+ # users who have been suspended since this date can be warned about deletion
suspension_timeout = (
- current_time
- - datetime.timedelta(days=retention.remove_suspended_users_period_days)
- + datetime.timedelta(days=retention.reminder_expiry_period_days)
+ current_time
+ - datetime.timedelta(days=retention.remove_suspended_users_period_days)
+ + datetime.timedelta(days=retention.reminder_expiry_period_days)
)
+ # deletion warnings that have been sent before this date are considered "old" and can be acted upon
warning_timeout = current_time - datetime.timedelta(days=retention.reminder_expiry_period_days)
suspended_users = User.query \
- .filter(User.last_login_date < warning_date, User.suspended == True) \
+ .filter(User.last_login_date < deletion_warning_date, User.suspended == True) \
.filter(User.uid.not_in(excluded_user_accounts)) \
.all() # noqa: E712
deleted_user_uids = []
@@ -149,14 +162,20 @@ def _do_suspend_users(app):
SuspendNotification.is_suspension.is_(False)
).order_by(desc(SuspendNotification.sent_at)).first() # noqa: E712
- if last_suspend_notification is not None and \
- last_suspend_notification.sent_at < suspension_timeout and \
- (last_delete_warning is None or last_delete_warning.sent_at < suspension_date):
+ if last_suspend_notification is None:
+ raise Exception(f"User {user.uid} ({user.uid}) is suspended but has no suspension notification."
+ "This should not happen.")
+ if last_suspend_notification.sent_at > suspension_timeout:
+ continue # user suspended too recently, don't delete yet
+
+ if last_delete_warning is None or last_delete_warning.sent_at < suspension_date:
+ # no recent deletion warning, sent one first
+ logger.info(f"Sending deletion reminder to user {user.uid} ({user.email})")
create_suspend_notification(user, retention, app, True, False)
results["warning_deleted_notifications"].append(user.email)
- elif user.last_login_date < deletion_date and \
- last_delete_warning is not None and last_delete_warning.sent_at < warning_timeout:
+ elif user.last_login_date < deletion_date < last_delete_warning.sent_at < warning_timeout:
# don't send mail to user; they have already received 3 emails, and this one is not actionable.
+ logger.info(f"Deleting user {user.uid} ({user.email}) because of inactivity")
results["deleted_notifications"].append(user.email)
deleted_user_uids.append(user.uid)
if user.username:
@@ -172,6 +191,14 @@ def _do_suspend_users(app):
if deleted_user_uids:
mail_suspended_account_deletion(deleted_user_uids)
+ if retention.admin_notification_mail and any(results.values()):
+ mail_suspended_account_admin_notification(results, [
+ suspension_warning_date,
+ suspension_date,
+ deletion_warning_date,
+ deletion_date
+ ])
+
end = int(time.time() * 1000.0)
logger.info(f"Finished running suspend_users job in {end - start} ms")
diff --git a/server/db/activity.py b/server/db/activity.py
index 4aec1bc59..cbf3a0a6c 100644
--- a/server/db/activity.py
+++ b/server/db/activity.py
@@ -1,10 +1,9 @@
-from datetime import datetime
-
from server.db.db import db
from server.db.domain import Collaboration
+from server.tools import dt_now
def update_last_activity_date(collaboration_id):
collaboration = db.session.get(Collaboration, collaboration_id)
- collaboration.last_activity_date = datetime.now()
+ collaboration.last_activity_date = dt_now()
db.session.merge(collaboration)
diff --git a/server/db/audit_mixin.py b/server/db/audit_mixin.py
index 2e2398b42..e2fe11d6c 100644
--- a/server/db/audit_mixin.py
+++ b/server/db/audit_mixin.py
@@ -1,13 +1,14 @@
import os
from flask import session, current_app
-
from sqlalchemy import MetaData
from sqlalchemy import event, inspect
from sqlalchemy.orm import class_mapper
from sqlalchemy.orm import declarative_base
+from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm.attributes import get_history
+from server.db.datetime import TZDateTime
from server.db.db import db
from server.db.json_serialize_base import JsonSerializableBase
@@ -39,7 +40,7 @@ class AuditLog(JsonSerializableBase, db.Model):
action = db.Column("action", db.Integer())
state_before = db.Column("state_before", db.Text())
state_after = db.Column("state_after", db.Text())
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
def __init__(self, current_user_id, subject_id, target_type, target_id, target_name, parent_id, parent_name, action,
@@ -55,21 +56,6 @@ def __init__(self, current_user_id, subject_id, target_type, target_id, target_n
self.state_before = state_before
self.state_after = state_after
- def save(self, connection):
- connection.execute(
- self.__table__.insert(),
- dict(user_id=self.user_id,
- subject_id=self.subject_id,
- target_type=self.target_type,
- target_id=self.target_id,
- target_name=self.target_name,
- parent_id=self.parent_id,
- parent_name=self.parent_name,
- action=self.action,
- state_before=self.state_before,
- state_after=self.state_after)
- )
-
def find_subject(mapper, target):
if target.__tablename__ == "users":
@@ -142,10 +128,10 @@ def create_audit(connection, subject_id, target, parent_id, parent_name, action,
kwargs.get("state_before"),
kwargs.get("state_after")
)
- if connection is None:
- db.session.merge(audit)
- else:
- audit.save(connection)
+ scoped_session = sessionmaker(db.engine)
+ with scoped_session.begin() as sc:
+ sc.merge(audit)
+ sc.commit()
@classmethod
def __declare_last__(cls):
@@ -206,7 +192,7 @@ def audit_update(mapper, connection, target):
if isinstance(state_before_list, list):
state_before[attr.key] = state_before_list.pop()
state_after[attr.key] = getattr(target, attr.key)
- # connection, subject_id, target_type, target_id, parent_id, parent_name, action
+ # connection, subject_id, target, parent_id, parent_name, action
pi = parent_info(target)
if state_before and state_after:
before_response = current_app.json.response(state_before).data.decode("ascii", "ignore")
diff --git a/server/db/datetime.py b/server/db/datetime.py
new file mode 100644
index 000000000..cd7c3b525
--- /dev/null
+++ b/server/db/datetime.py
@@ -0,0 +1,33 @@
+import datetime
+
+import sqlalchemy
+
+
+# make sure all database columns can be used as timezone aware datetime
+# adapted from https://docs.sqlalchemy.org/en/20/core/custom_types.html#store-timezone-aware-timestamps-as-timezone-naive-utc
+class TZDateTime(sqlalchemy.TypeDecorator):
+ impl = sqlalchemy.DateTime
+ cache_ok = True
+
+ def process_bind_param(self, value, dialect):
+ if value is None:
+ return None
+ elif isinstance(value, datetime.datetime):
+ if value.tzinfo is None:
+ raise ValueError(f"Datetime '{value}' must be timezone aware")
+ else:
+ return value.astimezone(datetime.timezone.utc).replace(tzinfo=None)
+ # note: datetime.date is also an instance of datetime.datetime, so ordering matters here
+ elif isinstance(value, datetime.date):
+ # convert to datetime, setting time to 0
+ return datetime.datetime(value.year, value.month, value.day, tzinfo=datetime.timezone.utc)
+ else:
+ raise TypeError(f"Unknown type '{type(value)}' for datetime")
+
+ def process_result_value(self, value, dialect):
+ if value is None:
+ return None
+
+ if value.tzinfo is None:
+ # database returned non-timezoned datetime, so assume UTC
+ return value.replace(tzinfo=datetime.timezone.utc)
diff --git a/server/db/db.py b/server/db/db.py
index ea8ed39c6..c7b91025c 100644
--- a/server/db/db.py
+++ b/server/db/db.py
@@ -7,8 +7,9 @@
def db_migrations(sqlalchemy_database_uri):
- migrations_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "../migrations/")
from alembic.config import Config
+
+ migrations_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "../migrations/")
config = Config(migrations_dir + "alembic.ini")
config.set_main_option("sqlalchemy.url", sqlalchemy_database_uri)
config.set_main_option("script_location", migrations_dir)
diff --git a/server/db/defaults.py b/server/db/defaults.py
index 039ade5f8..611b2056f 100644
--- a/server/db/defaults.py
+++ b/server/db/defaults.py
@@ -2,17 +2,23 @@
import re
import string
from collections.abc import Iterable
-from datetime import datetime, date, time, timedelta
+from datetime import datetime, timedelta, timezone
from typing import Optional
from werkzeug.exceptions import BadRequest
+from server.tools import dt_now
+
full_text_search_autocomplete_limit = 16
STATUS_ACTIVE = "active"
STATUS_EXPIRED = "expired"
STATUS_SUSPENDED = "suspended"
+STATUS_OPEN = "open"
+STATUS_DENIED = "denied"
+STATUS_APPROVED = "approved"
+
SERVICE_TOKEN_INTROSPECTION = "introspection"
SERVICE_TOKEN_PAM = "pam"
SERVICE_TOKEN_SCIM = "scim"
@@ -31,12 +37,12 @@
def default_expiry_date(json_dict=None):
if json_dict is not None and "expiry_date" in json_dict:
ms = int(json_dict["expiry_date"])
- dt = datetime.utcfromtimestamp(ms)
- return datetime(year=dt.year, month=dt.month, day=dt.day, hour=0, minute=0, second=0)
- return datetime.combine(date.today(), time()) + timedelta(days=15)
+ dt = datetime.fromtimestamp(ms, timezone.utc)
+ return datetime(year=dt.year, month=dt.month, day=dt.day, hour=0, minute=0, second=0, tzinfo=timezone.utc)
+ return dt_now() + timedelta(days=15)
-def calculate_expiry_period(invitation, today=datetime.today()):
+def calculate_expiry_period(invitation, today=dt_now()):
if (isinstance(invitation, Iterable) and "expiry_date" not in invitation) or not invitation.expiry_date:
return "15 days"
diff = invitation.expiry_date - today
@@ -65,7 +71,7 @@ def generate_short_name(cls, name, attr="short_name"):
unique_short_name = cls.query.filter_by(**filters).count() == 0
if unique_short_name:
return generated_short_name
- generated_short_name = f"{generated_short_name[:16 - len(str(counter))]}{counter}"
+ generated_short_name = f"{short_name[:16 - len(str(counter))]}{counter}"
counter = counter + 1
# Very unlikely Fallback
return "".join(random.sample(string.ascii_lowercase, k=16))
@@ -115,3 +121,7 @@ def valid_tag_label(tag_value: Optional[str]) -> bool:
if tag_value is not None and len(tag_value) <= 32 and tag_re.fullmatch(tag_value):
return True
return False
+
+
+def split_list_semantically(arr):
+ return f"{', '.join(arr[:-1])} and {arr[-1]}" if len(arr) > 1 else ",".join(arr)
diff --git a/server/db/domain.py b/server/db/domain.py
index 80fbb03d8..9f1a6a668 100644
--- a/server/db/domain.py
+++ b/server/db/domain.py
@@ -1,4 +1,3 @@
-import datetime
from uuid import uuid4
from flask import current_app
@@ -6,10 +5,12 @@
from sqlalchemy.orm import column_property
from server.db.audit_mixin import Base, metadata
+from server.db.datetime import TZDateTime
from server.db.db import db
-from server.db.defaults import STATUS_ACTIVE
+from server.db.defaults import STATUS_ACTIVE, STATUS_OPEN
from server.db.logo_mixin import LogoMixin
from server.db.secret_mixin import SecretMixin
+from server.tools import dt_now
def gen_uuid4():
@@ -41,10 +42,10 @@ class User(Base, db.Model):
user_ip_networks = db.relationship("UserIpNetwork", cascade="all, delete-orphan", passive_deletes=True,
lazy="selectin")
created_by = db.Column("created_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
- updated_at = db.Column("updated_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ updated_at = db.Column("updated_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
organisation_memberships = db.relationship("OrganisationMembership", back_populates="user",
cascade="all, delete, delete-orphan", passive_deletes=True)
@@ -58,16 +59,20 @@ class User(Base, db.Model):
cascade="all, delete, delete-orphan", passive_deletes=True)
join_requests = db.relationship("JoinRequest", back_populates="user",
cascade="all, delete, delete-orphan", passive_deletes=True)
+ service_connection_requests = db.relationship("ServiceConnectionRequest", back_populates="requester",
+ cascade="all, delete-orphan", passive_deletes=True)
aups = db.relationship("Aup", back_populates="user", cascade="all, delete-orphan", passive_deletes=True)
service_aups = db.relationship("ServiceAup", back_populates="user", cascade="all, delete-orphan",
passive_deletes=True)
+ organisation_aups = db.relationship("OrganisationAup", back_populates="user", cascade="all, delete-orphan",
+ passive_deletes=True)
user_tokens = db.relationship("UserToken", back_populates="user", cascade="all, delete-orphan",
passive_deletes=True)
eduperson_principal_name = db.Column("eduperson_principal_name", db.String(length=255), nullable=True)
application_uid = db.Column("application_uid", db.String(length=255), nullable=True)
- last_accessed_date = db.Column("last_accessed_date", db.DateTime(timezone=True), nullable=False)
- last_login_date = db.Column("last_login_date", db.DateTime(timezone=True), nullable=False)
- pam_last_login_date = db.Column("pam_last_login_date", db.DateTime(timezone=True), nullable=False)
+ last_accessed_date = db.Column("last_accessed_date", TZDateTime(), nullable=False)
+ last_login_date = db.Column("last_login_date", TZDateTime(), nullable=False)
+ pam_last_login_date = db.Column("pam_last_login_date", TZDateTime(), nullable=False)
suspended = db.Column("suspended", db.Boolean(), nullable=True, default=False)
suspend_notifications = db.relationship("SuspendNotification", back_populates="user", cascade="all, delete-orphan",
passive_deletes=True)
@@ -180,7 +185,7 @@ class CollaborationMembership(Base, db.Model):
id = db.Column("id", db.Integer(), primary_key=True, nullable=False, autoincrement=True)
role = db.Column("role", db.String(length=255), nullable=False)
status = db.Column("status", db.String(length=255), nullable=False, default=STATUS_ACTIVE)
- expiry_date = db.Column("expiry_date", db.DateTime(timezone=True), nullable=True)
+ expiry_date = db.Column("expiry_date", TZDateTime(), nullable=True)
user_id = db.Column(db.Integer(), db.ForeignKey("users.id"))
user = db.relationship("User", back_populates="collaboration_memberships")
invitation_id = db.Column(db.Integer(), db.ForeignKey("invitations.id"))
@@ -193,14 +198,14 @@ class CollaborationMembership(Base, db.Model):
lazy="select")
created_by = db.Column("created_by", db.String(length=512), nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
def is_expired(self):
- return self.expiry_date and datetime.datetime.utcnow() > self.expiry_date
+ return self.expiry_date and dt_now() > self.expiry_date
def is_active(self):
- now = datetime.datetime.utcnow()
+ now = dt_now()
not_expired = not self.expiry_date or self.expiry_date > now
co_not_expired = not self.collaboration.expiry_date or self.collaboration.expiry_date > now
return not_expired and co_not_expired and not self.user.suspended
@@ -248,10 +253,11 @@ class Invitation(Base, db.Model):
back_populates="invitations")
intended_role = db.Column("intended_role", db.String(length=255), nullable=True)
external_identifier = db.Column("external_identifier", db.String(length=255), nullable=True)
- expiry_date = db.Column("expiry_date", db.DateTime(timezone=True), nullable=True)
- membership_expiry_date = db.Column("membership_expiry_date", db.DateTime(timezone=True), nullable=True)
+ expiry_date = db.Column("expiry_date", TZDateTime(), nullable=True)
+ membership_expiry_date = db.Column("membership_expiry_date", TZDateTime(), nullable=True)
+ reminder_send = db.Column("reminder_send", db.Boolean(), nullable=True, default=False)
created_by = db.Column("created_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
@staticmethod
@@ -260,7 +266,7 @@ def validate_role(role):
raise ValueError(f"{role} is not valid. Valid roles are admin and member")
def is_expired(self):
- return self.expiry_date and datetime.datetime.utcnow() > self.expiry_date
+ return self.expiry_date and dt_now() > self.expiry_date
services_collaborations_association = db.Table(
@@ -295,16 +301,16 @@ class Collaboration(Base, db.Model, LogoMixin):
global_urn = db.Column("global_urn", db.Text, nullable=True)
accepted_user_policy = db.Column("accepted_user_policy", db.Text(), nullable=True)
status = db.Column("status", db.String(length=255), nullable=False, default=STATUS_ACTIVE)
- last_activity_date = db.Column("last_activity_date", db.DateTime(timezone=True), nullable=False,
+ last_activity_date = db.Column("last_activity_date", TZDateTime(), nullable=False,
server_default=db.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"))
- expiry_date = db.Column("expiry_date", db.DateTime(timezone=True), nullable=True)
+ expiry_date = db.Column("expiry_date", TZDateTime(), nullable=True)
organisation_id = db.Column(db.Integer(), db.ForeignKey("organisations.id"))
organisation = db.relationship("Organisation", back_populates="collaborations")
created_by = db.Column("created_by", db.String(length=512), nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
- updated_at = db.Column("updated_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ updated_at = db.Column("updated_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
services = db.relationship("Service", secondary=services_collaborations_association, lazy="select",
back_populates="collaborations")
@@ -354,7 +360,7 @@ def service_emails(self):
if service.contact_email:
res[service.id] = [service.contact_email]
else:
- res[service.id] = [membership.user.email for membership in service.service_memberships]
+ res[service.id] = [m.user.email for m in service.service_memberships if m.role == "admin"]
return res
def is_allowed_unit_organisation_membership(self, organisation_membership):
@@ -380,7 +386,7 @@ class OrganisationMembership(Base, db.Model):
back_populates="organisation_memberships")
created_by = db.Column("created_by", db.String(length=512), nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
def allowed_attr_view(self):
@@ -427,6 +433,7 @@ class Organisation(Base, db.Model, LogoMixin):
logo = db.Column("logo", db.Text(), nullable=True)
uuid4 = db.Column("uuid4", db.String(length=255), nullable=False, default=gen_uuid4)
on_boarding_msg = db.Column("on_boarding_msg", db.Text(), nullable=True)
+ accepted_user_policy = db.Column("accepted_user_policy", db.Text(), nullable=True)
schac_home_organisations = db.relationship("SchacHomeOrganisation", cascade="all, delete-orphan",
passive_deletes=True, lazy="selectin")
units = db.relationship("Unit", cascade="all, delete-orphan", passive_deletes=True, lazy="selectin")
@@ -434,7 +441,7 @@ class Organisation(Base, db.Model, LogoMixin):
service_connection_requires_approval = db.Column("service_connection_requires_approval", db.Boolean(),
nullable=True, default=False)
created_by = db.Column("created_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
collaboration_creation_allowed = db.Column("collaboration_creation_allowed", db.Boolean(), nullable=True,
@@ -454,6 +461,8 @@ class Organisation(Base, db.Model, LogoMixin):
api_keys = db.relationship("ApiKey", back_populates="organisation",
cascade="delete, delete-orphan",
passive_deletes=True)
+ organisation_aups = db.relationship("OrganisationAup", back_populates="organisation", cascade="all, delete-orphan",
+ passive_deletes=True)
collaborations_count = column_property(select(func.count(Collaboration.id))
.where(Collaboration.organisation_id == id)
.correlate_except(Collaboration)
@@ -466,6 +475,9 @@ class Organisation(Base, db.Model, LogoMixin):
def is_member(self, user_id):
return len(list(filter(lambda membership: membership.user_id == user_id, self.organisation_memberships))) > 0
+ def admin_emails(self):
+ return [membership.user.email for membership in self.organisation_memberships]
+
class ServiceMembership(Base, db.Model):
__tablename__ = "service_memberships"
@@ -481,7 +493,7 @@ class ServiceMembership(Base, db.Model):
service = db.relationship("Service", back_populates="service_memberships")
created_by = db.Column("created_by", db.String(length=512), nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
@@ -495,12 +507,12 @@ class ServiceToken(Base, db.Model, SecretMixin):
service_id = db.Column(db.Integer(), db.ForeignKey("services.id"))
service = db.relationship("Service", back_populates="service_tokens")
created_by = db.Column("created_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
-class Service(Base, db.Model, LogoMixin):
+class Service(Base, db.Model, LogoMixin, SecretMixin):
__tablename__ = "services"
metadata = metadata
id = db.Column("id", db.Integer(), primary_key=True, nullable=False, autoincrement=True)
@@ -522,18 +534,13 @@ class Service(Base, db.Model, LogoMixin):
support_email = db.Column("support_email", db.String(length=255), nullable=True)
security_email = db.Column("security_email", db.String(length=255), nullable=True)
override_access_allowed_all_connections = db.Column("override_access_allowed_all_connections", db.Boolean(),
- nullable=True, default=True)
- automatic_connection_allowed = db.Column("automatic_connection_allowed", db.Boolean(), nullable=True, default=True)
+ nullable=True, default=False)
+ automatic_connection_allowed = db.Column("automatic_connection_allowed", db.Boolean(), nullable=True, default=False)
access_allowed_for_all = db.Column("access_allowed_for_all", db.Boolean(), nullable=True, default=False)
allow_restricted_orgs = db.Column("allow_restricted_orgs", db.Boolean(), nullable=True, default=False)
non_member_users_access_allowed = db.Column("non_member_users_access_allowed", db.Boolean(), nullable=True,
default=False)
connection_setting = db.Column("connection_setting", db.String(length=255), nullable=True)
- research_scholarship_compliant = db.Column("research_scholarship_compliant", db.Boolean(),
- nullable=True,
- default=False)
- code_of_conduct_compliant = db.Column("code_of_conduct_compliant", db.Boolean(), nullable=True, default=False)
- sirtfi_compliant = db.Column("sirtfi_compliant", db.Boolean(), nullable=True, default=False)
token_enabled = db.Column("token_enabled", db.Boolean(), nullable=True, default=False)
token_validity_days = db.Column("token_validity_days", db.Integer(), nullable=True, default=0)
pam_web_sso_enabled = db.Column("pam_web_sso_enabled", db.Boolean(), nullable=True, default=False)
@@ -570,10 +577,10 @@ class Service(Base, db.Model, LogoMixin):
sweep_scim_enabled = db.Column("sweep_scim_enabled", db.Boolean(), nullable=True, default=False)
sweep_remove_orphans = db.Column("sweep_remove_orphans", db.Boolean(), nullable=True, default=False)
sweep_scim_daily_rate = db.Column("sweep_scim_daily_rate", db.Integer(), nullable=True, default=0)
- sweep_scim_last_run = db.Column("sweep_scim_last_run", db.DateTime(timezone=True), nullable=True)
+ sweep_scim_last_run = db.Column("sweep_scim_last_run", TZDateTime(), nullable=True)
created_by = db.Column("created_by", db.String(length=512), nullable=True)
updated_by = db.Column("updated_by", db.String(length=512), nullable=True)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
def is_member(self, user_id):
@@ -597,11 +604,6 @@ class ServiceRequest(Base, db.Model, LogoMixin):
security_email = db.Column("security_email", db.String(length=255), nullable=True)
privacy_policy = db.Column("privacy_policy", db.String(length=255), nullable=False)
accepted_user_policy = db.Column("accepted_user_policy", db.String(length=255), nullable=True)
- code_of_conduct_compliant = db.Column("code_of_conduct_compliant", db.Boolean(), nullable=True, default=False)
- sirtfi_compliant = db.Column("sirtfi_compliant", db.Boolean(), nullable=True, default=False)
- research_scholarship_compliant = db.Column("research_scholarship_compliant", db.Boolean(),
- nullable=True,
- default=False)
status = db.Column("status", db.String(length=255), nullable=False)
comments = db.Column("comments", db.Text(), nullable=True)
connection_type = db.Column("connection_type", db.String(length=255), nullable=True)
@@ -611,7 +613,7 @@ class ServiceRequest(Base, db.Model, LogoMixin):
requester_id = db.Column(db.Integer(), db.ForeignKey("users.id"))
requester = db.relationship("User", back_populates="service_requests")
rejection_reason = db.Column("rejection_reason", db.Text(), nullable=True)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
@@ -627,9 +629,10 @@ class ServiceInvitation(Base, db.Model):
user_id = db.Column(db.Integer(), db.ForeignKey("users.id"))
user = db.relationship("User")
intended_role = db.Column("intended_role", db.String(length=255), nullable=True)
- expiry_date = db.Column("expiry_date", db.DateTime(timezone=True), nullable=True)
+ expiry_date = db.Column("expiry_date", TZDateTime(), nullable=True)
+ reminder_send = db.Column("reminder_send", db.Boolean(), nullable=True, default=False)
created_by = db.Column("created_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
@@ -655,9 +658,9 @@ class Group(Base, db.Model):
service_group = db.relationship("ServiceGroup", back_populates="groups")
created_by = db.Column("created_by", db.String(length=512), nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
- updated_at = db.Column("updated_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ updated_at = db.Column("updated_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
def is_member(self, user_id):
@@ -669,7 +672,6 @@ class JoinRequest(Base, db.Model):
metadata = metadata
id = db.Column("id", db.Integer(), primary_key=True, nullable=False, autoincrement=True)
message = db.Column("message", db.Text(), nullable=True)
- reference = db.Column("reference", db.Text(), nullable=True)
rejection_reason = db.Column("rejection_reason", db.Text(), nullable=True)
status = db.Column("status", db.String(length=255), nullable=False)
user_id = db.Column(db.Integer(), db.ForeignKey("users.id"))
@@ -677,7 +679,7 @@ class JoinRequest(Base, db.Model):
collaboration_id = db.Column(db.Integer(), db.ForeignKey("collaborations.id"))
collaboration = db.relationship("Collaboration", back_populates="join_requests")
hash = db.Column("hash", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
@@ -695,9 +697,10 @@ class OrganisationInvitation(Base, db.Model):
units = db.relationship("Unit", secondary=units_organisation_invitations_association, lazy="select",
back_populates="organisation_invitations")
intended_role = db.Column("intended_role", db.String(length=255), nullable=True)
- expiry_date = db.Column("expiry_date", db.DateTime(timezone=True), nullable=True)
+ expiry_date = db.Column("expiry_date", TZDateTime(), nullable=True)
+ reminder_send = db.Column("reminder_send", db.Boolean(), nullable=True, default=False)
created_by = db.Column("created_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
@staticmethod
@@ -715,7 +718,7 @@ class ApiKey(Base, db.Model, SecretMixin):
organisation_id = db.Column(db.Integer(), db.ForeignKey("organisations.id"))
organisation = db.relationship("Organisation", back_populates="api_keys")
created_by = db.Column("created_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
@@ -727,7 +730,7 @@ class Aup(Base, db.Model):
au_version = db.Column("au_version", db.String(length=255), nullable=False)
user_id = db.Column(db.Integer(), db.ForeignKey("users.id"))
user = db.relationship("User", back_populates="aups")
- agreed_at = db.Column("agreed_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ agreed_at = db.Column("agreed_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
@@ -737,7 +740,7 @@ class SuspendNotification(Base, db.Model):
id = db.Column("id", db.Integer(), primary_key=True, nullable=False, autoincrement=True)
user_id = db.Column(db.Integer(), db.ForeignKey("users.id"))
user = db.relationship("User", back_populates="suspend_notifications")
- sent_at = db.Column("sent_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ sent_at = db.Column("sent_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
is_suspension = db.Column("is_suspension", db.Boolean(), nullable=True, default=False)
is_warning = db.Column("is_warning", db.Boolean(), nullable=True, default=False)
@@ -765,7 +768,7 @@ class CollaborationRequest(Base, db.Model, LogoMixin):
back_populates="collaboration_requests")
created_by = db.Column("created_by", db.String(length=512), nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
@@ -780,12 +783,14 @@ class ServiceConnectionRequest(Base, db.Model):
service_id = db.Column(db.Integer(), db.ForeignKey("services.id"))
pending_organisation_approval = db.Column("pending_organisation_approval", db.Boolean(), nullable=True,
default=False)
+ status = db.Column("status", db.String(length=255), nullable=False, default=STATUS_OPEN)
+ rejection_reason = db.Column("rejection_reason", db.Text(), nullable=True)
service = db.relationship("Service", back_populates="service_connection_requests")
collaboration_id = db.Column(db.Integer(), db.ForeignKey("collaborations.id"))
collaboration = db.relationship("Collaboration", back_populates="service_connection_requests")
created_by = db.Column("created_by", db.String(length=512), nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
@@ -797,7 +802,7 @@ class IpNetwork(Base, db.Model):
service_id = db.Column(db.Integer(), db.ForeignKey("services.id"))
service = db.relationship("Service", back_populates="ip_networks")
created_by = db.Column("created_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
@@ -810,7 +815,7 @@ class SchacHomeOrganisation(Base, db.Model):
organisation_id = db.Column(db.Integer(), db.ForeignKey("organisations.id"))
organisation = db.relationship("Organisation", back_populates="schac_home_organisations")
created_by = db.Column("created_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
@@ -831,7 +836,7 @@ class SshKey(Base, db.Model):
ssh_value = db.Column("ssh_value", db.Text(), nullable=False)
user_id = db.Column(db.Integer(), db.ForeignKey("users.id"))
user = db.relationship("User", back_populates="ssh_keys")
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
@@ -840,7 +845,7 @@ class UserNameHistory(Base, db.Model):
metadata = metadata
id = db.Column("id", db.Integer(), primary_key=True, nullable=False, autoincrement=True)
username = db.Column("username", db.String(length=255), nullable=True)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
@@ -852,7 +857,7 @@ class UserMail(Base, db.Model):
recipient = db.Column("recipient", db.String(length=255), nullable=True)
user_id = db.Column(db.Integer(), db.ForeignKey("users.id"))
user = db.relationship("User", back_populates="user_mails")
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
audit_log_exclude = True
@@ -871,7 +876,7 @@ class ServiceGroup(Base, db.Model):
passive_deletes=True)
created_by = db.Column("created_by", db.String(length=512), nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
@@ -884,7 +889,7 @@ class ServiceAup(Base, db.Model):
user = db.relationship("User", back_populates="service_aups")
service_id = db.Column(db.Integer(), db.ForeignKey("services.id"))
service = db.relationship("Service", back_populates="service_aups")
- agreed_at = db.Column("agreed_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ agreed_at = db.Column("agreed_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
@@ -899,8 +904,8 @@ class UserToken(Base, db.Model, SecretMixin):
user = db.relationship("User", back_populates="user_tokens")
service_id = db.Column(db.Integer(), db.ForeignKey("services.id"))
service = db.relationship("Service", back_populates="user_tokens")
- last_used_date = db.Column("last_used_date", db.DateTime(timezone=True), nullable=True)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ last_used_date = db.Column("last_used_date", TZDateTime(), nullable=True)
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
@@ -912,7 +917,7 @@ class UserIpNetwork(Base, db.Model):
user_id = db.Column(db.Integer(), db.ForeignKey("users.id"))
user = db.relationship("User", back_populates="user_ip_networks")
created_by = db.Column("created_by", db.String(length=512), nullable=False)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
updated_by = db.Column("updated_by", db.String(length=512), nullable=False)
@@ -924,11 +929,12 @@ class PamSSOSession(Base, db.Model):
session_id = db.Column("session_id", db.String(length=255), nullable=True)
attribute = db.Column("attribute", db.String(length=255), nullable=True)
pin = db.Column("pin", db.String(length=255), nullable=True)
+ pin_shown = db.Column("pin_shown", db.Boolean(), nullable=False, default=False)
user_id = db.Column(db.Integer(), db.ForeignKey("users.id"), nullable=True)
user = db.relationship("User")
service_id = db.Column(db.Integer(), db.ForeignKey("services.id"))
service = db.relationship("Service")
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
audit_log_exclude = True
@@ -947,7 +953,20 @@ class UserLogin(Base, db.Model):
service_id = db.Column(db.Integer(), db.ForeignKey("services.id", ondelete="SET NULL"), nullable=True)
service = db.relationship("Service")
service_entity_id = db.Column("service_entity_id", db.String(length=512), nullable=True)
- created_at = db.Column("created_at", db.DateTime(timezone=True), server_default=db.text("CURRENT_TIMESTAMP"),
+ created_at = db.Column("created_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
nullable=False)
audit_log_exclude = True
+
+
+class OrganisationAup(Base, db.Model):
+ __tablename__ = "organisation_aups"
+ metadata = metadata
+ id = db.Column("id", db.Integer(), primary_key=True, nullable=False, autoincrement=True)
+ aup_url = db.Column("aup_url", db.String(length=255), nullable=False)
+ user_id = db.Column(db.Integer(), db.ForeignKey("users.id"))
+ user = db.relationship("User", back_populates="organisation_aups")
+ organisation_id = db.Column(db.Integer(), db.ForeignKey("organisations.id"))
+ organisation = db.relationship("Organisation", back_populates="organisation_aups")
+ agreed_at = db.Column("agreed_at", TZDateTime(), server_default=db.text("CURRENT_TIMESTAMP"),
+ nullable=False)
diff --git a/server/db/logo_mixin.py b/server/db/logo_mixin.py
index 9f88ab034..ff8955642 100644
--- a/server/db/logo_mixin.py
+++ b/server/db/logo_mixin.py
@@ -6,7 +6,7 @@
uuid4_reg_exp = re.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$")
-login_mixins_classes = ["collaborations", "collaboration_requests", "organisations", "services"]
+login_mixins_classes = ["collaborations", "collaboration_requests", "organisations", "services", "service_requests"]
def _redis_key(object_type, sid):
diff --git a/server/db/models.py b/server/db/models.py
index 178a6c8b4..788b0298c 100644
--- a/server/db/models.py
+++ b/server/db/models.py
@@ -132,7 +132,7 @@ def parse_date_fields(json_dict):
if date_field in json_dict:
val = json_dict[date_field]
if isinstance(val, float) or isinstance(val, int):
- json_dict[date_field] = datetime.datetime.fromtimestamp(val / 1e3)
+ json_dict[date_field] = datetime.datetime.fromtimestamp(val / 1e3, tz=datetime.timezone.utc)
for rel in flatten(filter(lambda i: isinstance(i, list), json_dict.values())):
parse_date_fields(rel)
diff --git a/server/db/redis.py b/server/db/redis.py
index f86fd2dde..80a5d420c 100644
--- a/server/db/redis.py
+++ b/server/db/redis.py
@@ -1,13 +1,4 @@
-from eventlet.green import ssl
import redis
-import eventlet
-import socket
-eventlet.monkey_patch()
-
-
-# work around eventlet bug https://github.com/eventlet/eventlet/issues/692
-# safe to remove once that is fixed
-ssl.timeout_exc = socket.timeout
def init_redis(app_conf):
diff --git a/server/db/secret_mixin.py b/server/db/secret_mixin.py
index 51d955b37..2cc555456 100644
--- a/server/db/secret_mixin.py
+++ b/server/db/secret_mixin.py
@@ -1,6 +1,6 @@
class SecretMixin(object):
def __getattribute__(self, name):
- if name == "hashed_token" or name == "hashed_secret":
+ if name == "hashed_token" or name == "hashed_secret" or name == "scim_bearer_token":
return None
return object.__getattribute__(self, name)
diff --git a/server/mail.py b/server/mail.py
index a2220d08f..1ce968c38 100644
--- a/server/mail.py
+++ b/server/mail.py
@@ -2,16 +2,18 @@
import logging
import os
import uuid
+from email.mime.image import MIMEImage
from threading import Thread
-
+from flask_mailman import Mail
import requests
from flask import current_app, render_template
-from flask_mail import Message
+from flask_mailman import EmailMultiAlternatives
from server.auth.security import current_user_id
from server.db.db import db
-from server.db.defaults import calculate_expiry_period
+from server.db.defaults import calculate_expiry_period, split_list_semantically
from server.db.domain import User, UserMail
+from server.db.models import flatten
from server.logger.context_logger import ctx_logger
from server.mail_types.mail_types import COLLABORATION_REQUEST_MAIL, \
COLLABORATION_JOIN_REQUEST_MAIL, AUTOMATIC_COLLABORATION_JOIN_REQUEST_MAIL, ORGANISATION_INVITATION_MAIL, \
@@ -22,25 +24,43 @@
COLLABORATION_EXPIRED_NOTIFICATION_MAIL, COLLABORATION_SUSPENDED_NOTIFICATION_MAIL, \
COLLABORATION_SUSPENSION_WARNING_MAIL, MEMBERSHIP_EXPIRED_NOTIFICATION_MAIL, MEMBERSHIP_EXPIRES_WARNING_MAIL, \
SERVICE_INVITATION_MAIL, ACCEPTED_SERVICE_REQUEST_MAIL, DENIED_SERVICE_REQUEST_MAIL
+from server.tools import dt_now
+
+
+# Backward compatibility Flask-Mail context manager for testing
+class MailMan(Mail):
+
+ def __init__(self, app=None):
+ super().__init__(app)
+ def record_messages(self):
+ return self
-def _send_async_email(ctx, msg, mail):
+ def __enter__(self):
+ self.state.outbox = []
+ return self.state.outbox
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.state.outbox.clear()
+
+
+def _send_async_email(ctx, msg):
with ctx:
attempts = 1
try:
- mail.send(msg)
+ msg.send()
except Exception as e:
logger = logging.getLogger("mail")
logger.error("Error in sending mail", exc_info=1)
attempts = attempts + 1
if attempts < 5:
- _send_async_email(ctx, msg, mail)
+ _send_async_email(ctx, msg)
else:
logger.info(f"After attempts mailing {msg.body} failed")
raise e
-def _open_mail_in_browser(msg):
+def _open_mail_in_browser(msg_html):
import tempfile
import webbrowser
@@ -48,7 +68,7 @@ def _open_mail_in_browser(msg):
path = tmp.name + ".html"
f = open(path, "w")
- f.write(msg.html)
+ f.write(msg_html)
f.close()
webbrowser.open("file://" + path)
@@ -70,11 +90,11 @@ def _user_attributes(user: User):
def _now_strf_time():
- return datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
+ return dt_now().strftime('%Y-%m-%d %H:%M')
def _do_send_mail(subject, recipients, template, context, preview, working_outside_of_request_context=False, cc=None,
- attachment_url=None):
+ attachment_url=None, bulk_headers=True):
recipients = recipients if isinstance(recipients, list) else list(
map(lambda x: x.strip(), recipients.split(",")))
@@ -85,45 +105,49 @@ def _do_send_mail(subject, recipients, template, context, preview, working_outsi
context = {**context, **{"environment": environment}}
msg_html = render_template(f"{template}.html", **context)
msg_body = render_template(f"{template}.txt", **context)
- msg = Message(subject=subject,
- sender=(mail_ctx.get("sender_name", "SURF"), mail_ctx.get("sender_email", "no-reply@surf.nl")),
- recipients=recipients,
- cc=cc,
- extra_headers={
- "Auto-submitted": "auto-generated",
- "X-Auto-Response-Suppress": "yes",
- "Precedence": "bulk"
- })
+ message_id = f"<{str(uuid.uuid4())}@{os.uname()[1]}.internal.sram.surf.nl>".replace("-", ".")
+ extra_headers = {
+ "Auto-submitted": "auto-generated",
+ "X-Auto-Response-Suppress": "yes",
+ "Precedence": "bulk",
+ "message-id": message_id
+ } if bulk_headers else {}
+ msg = EmailMultiAlternatives(subject=subject,
+ body=msg_body,
+ from_email=(mail_ctx.get("sender_name", "SURF"),
+ mail_ctx.get("sender_email", "no-reply@surf.nl")),
+ to=recipients,
+ cc=cc,
+ headers=extra_headers)
+ msg.attach_alternative(msg_html, "text/html")
+ msg.html = msg_html
+ msg.mixed_subtype = 'related'
if attachment_url and not os.environ.get("TESTING"):
- image = attachment_url[attachment_url.rindex('/') + 1:]
- file_name = f"{image}.jpeg"
data = requests.get(attachment_url).content
- msg.attach(file_name, "image/jpeg", data, "attachment", headers=[["Content-ID", ""], ])
- msg.html = msg_html
- msg.body = msg_body
- msg.msgId = f"<{str(uuid.uuid4())}@{os.uname()[1]}.internal.sram.surf.nl>".replace("-", ".")
+ logo = MIMEImage(data, "jpeg")
+ logo.add_header("Content-ID", "")
+ msg.attach(logo)
logger = logging.getLogger("mail") if working_outside_of_request_context else ctx_logger("user")
- logger.debug(f"Sending mail message to {','.join(recipients)} with Message-id {msg.msgId}")
+ logger.debug(f"Sending mail message to {','.join(recipients)} with Message-id {message_id}")
suppress_mail = "suppress_sending_mails" in mail_ctx and mail_ctx.suppress_sending_mails
open_mail_in_browser = current_app.config["OPEN_MAIL_IN_BROWSER"]
if not preview and not suppress_mail and not open_mail_in_browser:
- mail = current_app.mail
if "TESTING" in os.environ:
- mail.send(msg)
+ msg.send()
else:
ctx = current_app.app_context()
- thr = Thread(target=_send_async_email, args=[ctx, msg, mail])
+ thr = Thread(target=_send_async_email, args=[ctx, msg])
thr.start()
if suppress_mail and not preview:
- logger.info(f"Sending mail {msg.html}")
+ logger.info(f"Sending mail {msg_html}")
if open_mail_in_browser and not preview:
- _open_mail_in_browser(msg)
- return msg.html
+ _open_mail_in_browser(msg_html)
+ return msg_html
def _store_mail(user, mail_type, recipients):
@@ -186,51 +210,58 @@ def mail_automatic_collaboration_request(context, collaboration, organisation, r
)
-def mail_organisation_invitation(context, organisation, recipients, preview=False):
+def mail_organisation_invitation(context, organisation, recipients, reminder=False, preview=False,
+ working_outside_of_request_context=False):
if not preview:
_store_mail(None, ORGANISATION_INVITATION_MAIL, recipients)
context = {**context, **{"expiry_period": calculate_expiry_period(context["invitation"])},
- "organisation": organisation}
+ "organisation": organisation, "reminder": reminder}
+ reminder_part = "Reminder - " if reminder else ""
return _do_send_mail(
- subject=f"Invitation to join organisation {organisation.name}",
+ subject=f"{reminder_part}Invitation to join organisation {organisation.name}",
recipients=recipients,
template="organisation_invitation",
context=context,
- preview=preview
+ preview=preview,
+ working_outside_of_request_context=working_outside_of_request_context
)
-def mail_collaboration_invitation(context, collaboration, recipients, preview=False):
+def mail_collaboration_invitation(context, collaboration, recipients, reminder=False, preview=False,
+ working_outside_of_request_context=False):
if not preview:
_store_mail(None, COLLABORATION_INVITATION_MAIL, recipients)
invitation = context["invitation"]
message = invitation.message.replace("\n", "
") if invitation.message else None
context = {**context, "expiry_period": calculate_expiry_period(invitation),
- "collaboration": collaboration, "message": message}
-
+ "collaboration": collaboration, "message": message, "reminder": reminder}
+ reminder_part = "Reminder - " if reminder else ""
return _do_send_mail(
- subject=f"Invitation to join collaboration {collaboration.name}",
+ subject=f"{reminder_part}Invitation to join collaboration {collaboration.name}",
recipients=recipients,
template="collaboration_invitation",
context=context,
preview=preview,
- working_outside_of_request_context=False,
+ working_outside_of_request_context=working_outside_of_request_context,
cc=None,
attachment_url=collaboration.organisation.logo
)
-def mail_service_invitation(context, service, recipients, preview=False):
+def mail_service_invitation(context, service, recipients, reminder=False, preview=False,
+ working_outside_of_request_context=False):
if not preview:
_store_mail(None, SERVICE_INVITATION_MAIL, recipients)
context = {**context, **{"expiry_period": calculate_expiry_period(context["invitation"])},
- "service": service}
+ "service": service, "reminder": reminder}
+ reminder_part = "Reminder - " if reminder else ""
return _do_send_mail(
- subject=f"Invitation to become service admin for {service.name}",
+ subject=f"{reminder_part}Invitation to become service {context['intended_role']} for {service.name}",
recipients=recipients,
template="service_invitation",
context=context,
- preview=preview
+ preview=preview,
+ working_outside_of_request_context=working_outside_of_request_context
)
@@ -309,7 +340,8 @@ def mail_accepted_declined_service_connection_request(context, service_name, col
def mail_suspend_notification(context, recipients, is_warning, is_suspension):
- _store_mail(context["user"], SUSPEND_NOTIFICATION_MAIL, recipients)
+ user = context["user"]
+ _store_mail(user, SUSPEND_NOTIFICATION_MAIL, recipients)
if is_suspension and is_warning:
template = "suspend_suspend_warning_notification"
elif is_suspension and not is_warning:
@@ -318,6 +350,11 @@ def mail_suspend_notification(context, recipients, is_warning, is_suspension):
template = "suspend_delete_warning_notification"
else:
raise Exception("We don't send mails on account deletion")
+ collaborations = [m.collaboration for m in user.collaboration_memberships]
+ services = flatten([co.services for co in collaborations])
+ affs = (user.affiliation if user.affiliation else []) + (user.scoped_affiliation if user.scoped_affiliation else [])
+ context = {**context, "affiliations": split_list_semantically(affs),
+ "collaborations": collaborations, "services": services}
return _do_send_mail(
subject="SURF SRAM: suspend notification",
recipients=recipients,
@@ -352,9 +389,10 @@ def mail_service_request(service_request, context):
mail_cfg = current_app.app_config.mail
return _do_send_mail(
subject=f"Request for new service {service_request.name}",
- recipients=[mail_cfg.beheer_email],
+ recipients=[mail_cfg.ticket_email],
template="service_request",
context=context,
+ bulk_headers=False,
preview=False
)
@@ -447,6 +485,26 @@ def mail_suspended_account_deletion(uids: list[str]):
)
+def mail_suspended_account_admin_notification(results: dict[str, list[str]], dates: list[datetime.date]):
+ mail_cfg = current_app.app_config.mail
+ recipients = [mail_cfg.beheer_email]
+ _do_send_mail(
+ subject=f"Results of inactive account check for environment {mail_cfg.environment}",
+ recipients=recipients,
+ template="admin_suspended_user_account_actions",
+ context={"environment": mail_cfg.environment,
+ "date": _now_strf_time(),
+ "warning_suspend": results["warning_suspend_notifications"],
+ "suspend": results["suspended_notifications"],
+ "warning_delete": results["warning_deleted_notifications"],
+ "delete": results["deleted_notifications"],
+ "dates": [d.strftime("%Y-%m-%d") for d in dates[0:4]]},
+
+ preview=False,
+ working_outside_of_request_context=True
+ )
+
+
def mail_reset_token(admin_email, user, message):
_store_mail(user, RESET_MFA_TOKEN_MAIL, admin_email)
_do_send_mail(
@@ -454,6 +512,7 @@ def mail_reset_token(admin_email, user, message):
recipients=[admin_email],
template="user_reset_mfa_token",
context={"user": user, "message": message},
+ bulk_headers=False,
preview=False
)
@@ -489,7 +548,7 @@ def mail_collaboration_suspension_notification(collaboration, is_warning):
subject = f"Collaboration {collaboration.name} will be suspended in {threshold} days"
else:
subject = f"Collaboration {collaboration.name} has been suspended"
- now = datetime.datetime.utcnow()
+ now = dt_now()
suspension_date = format_date_time(now + datetime.timedelta(days=cfq.inactivity_warning_mail_days_threshold))
_do_send_mail(
subject=subject,
@@ -537,3 +596,15 @@ def mail_membership_orphan_users_deleted(user_uids):
preview=False,
working_outside_of_request_context=True
)
+
+
+def mail_open_requests(recipient, context):
+ base_url = current_app.app_config.base_url
+ new_context = {**context, "base_url": base_url}
+ return _do_send_mail(
+ subject="SRAM Open requests",
+ recipients=[recipient],
+ template="open_requests_overview",
+ context=new_context,
+ preview=False,
+ working_outside_of_request_context=True)
diff --git a/server/migrations/alembic.ini b/server/migrations/alembic.ini
index 650bf853a..22bb24e88 100644
--- a/server/migrations/alembic.ini
+++ b/server/migrations/alembic.ini
@@ -35,7 +35,7 @@ script_location = migrations
# are written from script.py.mako
# output_encoding = utf-8
-sqlalchemy.url = mysql+mysqldb://sbs:sbs@localhost/sbs
+sqlalchemy.url = mysql+mysqldb://sbs:sbs@127.0.0.1/sbs
# Logging configuration
@@ -71,4 +71,4 @@ formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
-datefmt = %H:%M:%S
+datefmt = %H:%M:%S
\ No newline at end of file
diff --git a/server/migrations/versions/41611d873d8f_added_accepted_user_policy_to_.py b/server/migrations/versions/41611d873d8f_added_accepted_user_policy_to_.py
new file mode 100644
index 000000000..758e76e1c
--- /dev/null
+++ b/server/migrations/versions/41611d873d8f_added_accepted_user_policy_to_.py
@@ -0,0 +1,24 @@
+"""Added accepted_user_policy to organisations
+
+Revision ID: 41611d873d8f
+Revises: 5aab84f28e1e
+Create Date: 2024-01-22 11:28:09.526909
+
+"""
+from alembic import op
+from sqlalchemy import text
+
+# revision identifiers, used by Alembic.
+revision = '41611d873d8f'
+down_revision = '5aab84f28e1e'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ conn = op.get_bind()
+ conn.execute(text("ALTER TABLE organisations ADD COLUMN accepted_user_policy varchar(255) DEFAULT NULL"))
+
+
+def downgrade():
+ pass
diff --git a/server/migrations/versions/527f14734c00_remove_provisioned_by_service_from_.py b/server/migrations/versions/527f14734c00_remove_provisioned_by_service_from_.py
new file mode 100644
index 000000000..635f61fda
--- /dev/null
+++ b/server/migrations/versions/527f14734c00_remove_provisioned_by_service_from_.py
@@ -0,0 +1,34 @@
+"""Remove Provisioned by service from description
+
+Revision ID: 527f14734c00
+Revises: 8d674e53b5ba
+Create Date: 2024-01-22 09:26:57.645360
+
+"""
+from alembic import op
+from sqlalchemy import text
+
+# revision identifiers, used by Alembic.
+revision = '527f14734c00'
+down_revision = '8d674e53b5ba'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ conn = op.get_bind()
+
+ provisioned = "Provisioned by service %"
+ result = conn.execute(text(f"SELECT id, description from `groups` where description like '{provisioned}'"))
+ for row in result:
+ identifier = row[0]
+ description = row[1]
+ index = description.find("- ")
+ if index > 0:
+ new_description = description[index + 2:]
+ conn.execute(text("UPDATE `groups` SET `description` = :descr WHERE id = :id"),
+ {"id": identifier, "descr": new_description})
+
+
+def downgrade():
+ pass
diff --git a/server/migrations/versions/5aab84f28e1e_added_pin_show_to_pamssosession.py b/server/migrations/versions/5aab84f28e1e_added_pin_show_to_pamssosession.py
new file mode 100644
index 000000000..ea68bc1e3
--- /dev/null
+++ b/server/migrations/versions/5aab84f28e1e_added_pin_show_to_pamssosession.py
@@ -0,0 +1,24 @@
+"""Added pin_show to PamSSOSession
+
+Revision ID: 5aab84f28e1e
+Revises: 527f14734c00
+Create Date: 2024-01-22 10:16:16.371781
+
+"""
+from alembic import op
+from sqlalchemy import text
+
+# revision identifiers, used by Alembic.
+revision = '5aab84f28e1e'
+down_revision = '527f14734c00'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ conn = op.get_bind()
+ conn.execute(text("ALTER TABLE pam_sso_sessions ADD COLUMN pin_shown tinyint(1) default 0"))
+
+
+def downgrade():
+ pass
diff --git a/server/migrations/versions/7ff5f119c762_user_accepted_policies_per_organisation.py b/server/migrations/versions/7ff5f119c762_user_accepted_policies_per_organisation.py
new file mode 100644
index 000000000..5164e1112
--- /dev/null
+++ b/server/migrations/versions/7ff5f119c762_user_accepted_policies_per_organisation.py
@@ -0,0 +1,33 @@
+"""User accepted policies per organisation
+
+Revision ID: 7ff5f119c762
+Revises: 41611d873d8f
+Create Date: 2024-01-22 11:32:14.334806
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = '7ff5f119c762'
+down_revision = '41611d873d8f'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table("organisation_aups",
+ sa.Column("id", sa.Integer(), primary_key=True, nullable=False,
+ autoincrement=True),
+ sa.Column("aup_url", sa.String(length=255), nullable=False),
+ sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id",
+ ondelete="cascade"), nullable=False),
+ sa.Column("organisation_id", sa.Integer(),
+ sa.ForeignKey("organisations.id", ondelete="cascade"), nullable=False),
+ sa.Column("agreed_at", sa.DateTime(timezone=True),
+ server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False)
+ )
+
+
+def downgrade():
+ pass
diff --git a/server/migrations/versions/811a3753d09f_invitation_reminder_send.py b/server/migrations/versions/811a3753d09f_invitation_reminder_send.py
new file mode 100644
index 000000000..ac08de2c7
--- /dev/null
+++ b/server/migrations/versions/811a3753d09f_invitation_reminder_send.py
@@ -0,0 +1,26 @@
+"""Invitation reminder send
+
+Revision ID: 811a3753d09f
+Revises: e7e7dd87f94c
+Create Date: 2024-03-01 12:01:46.376594
+
+"""
+from alembic import op
+from sqlalchemy import text
+
+# revision identifiers, used by Alembic.
+revision = '811a3753d09f'
+down_revision = 'e7e7dd87f94c'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ conn = op.get_bind()
+ conn.execute(text("ALTER TABLE invitations ADD COLUMN reminder_send tinyint(1) default 0"))
+ conn.execute(text("ALTER TABLE organisation_invitations ADD COLUMN reminder_send tinyint(1) default 0"))
+ conn.execute(text("ALTER TABLE service_invitations ADD COLUMN reminder_send tinyint(1) default 0"))
+
+
+def downgrade():
+ pass
diff --git a/server/migrations/versions/b133d5e0e198_remove_compliancy_columns_in_services.py b/server/migrations/versions/b133d5e0e198_remove_compliancy_columns_in_services.py
new file mode 100644
index 000000000..7cdf01987
--- /dev/null
+++ b/server/migrations/versions/b133d5e0e198_remove_compliancy_columns_in_services.py
@@ -0,0 +1,29 @@
+"""Remove compliancy columns in services
+
+Revision ID: b133d5e0e198
+Revises: 811a3753d09f
+Create Date: 2024-04-03 11:32:33.606028
+
+"""
+from alembic import op
+from sqlalchemy import text
+
+# revision identifiers, used by Alembic.
+revision = 'b133d5e0e198'
+down_revision = '811a3753d09f'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ conn = op.get_bind()
+ conn.execute(text("ALTER TABLE `services` DROP COLUMN research_scholarship_compliant"))
+ conn.execute(text("ALTER TABLE `services` DROP COLUMN code_of_conduct_compliant"))
+ conn.execute(text("ALTER TABLE `services` DROP COLUMN sirtfi_compliant"))
+ conn.execute(text("ALTER TABLE `service_requests` DROP COLUMN research_scholarship_compliant"))
+ conn.execute(text("ALTER TABLE `service_requests` DROP COLUMN code_of_conduct_compliant"))
+ conn.execute(text("ALTER TABLE `service_requests` DROP COLUMN sirtfi_compliant"))
+
+
+def downgrade():
+ pass
diff --git a/server/migrations/versions/bfff5ff0d8e6_service_connection_requests_rejection_.py b/server/migrations/versions/bfff5ff0d8e6_service_connection_requests_rejection_.py
new file mode 100644
index 000000000..96c957e64
--- /dev/null
+++ b/server/migrations/versions/bfff5ff0d8e6_service_connection_requests_rejection_.py
@@ -0,0 +1,24 @@
+"""Service connection requests rejection reason
+
+Revision ID: bfff5ff0d8e6
+Revises: fd61c7076492
+Create Date: 2024-02-27 11:35:02.410585
+
+"""
+from alembic import op
+from sqlalchemy import text
+
+# revision identifiers, used by Alembic.
+revision = 'bfff5ff0d8e6'
+down_revision = 'fd61c7076492'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ conn = op.get_bind()
+ conn.execute(text("ALTER TABLE service_connection_requests ADD COLUMN rejection_reason text"))
+
+
+def downgrade():
+ pass
diff --git a/server/migrations/versions/e7e7dd87f94c_scim_token_encryption.py b/server/migrations/versions/e7e7dd87f94c_scim_token_encryption.py
new file mode 100644
index 000000000..d8a3d7792
--- /dev/null
+++ b/server/migrations/versions/e7e7dd87f94c_scim_token_encryption.py
@@ -0,0 +1,59 @@
+"""SCIM_token encryption
+
+Revision ID: e7e7dd87f94c
+Revises: bfff5ff0d8e6
+Create Date: 2024-02-29 09:07:10.144305
+
+"""
+import base64
+import json
+import os
+import secrets
+
+import yaml
+from alembic import op
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from munch import munchify
+from sqlalchemy import text
+
+# revision identifiers, used by Alembic.
+revision = 'e7e7dd87f94c'
+down_revision = 'bfff5ff0d8e6'
+branch_labels = None
+depends_on = None
+
+
+def encrypt_secret(encryption_key: str, plain_secret: str, context: dict) -> str:
+ nonce = secrets.token_urlsafe()
+ context["plain_secret"] = plain_secret
+ aes_gcm = AESGCM(base64.b64decode(encryption_key))
+ data = json.dumps(context).encode()
+ encrypted_context = aes_gcm.encrypt(nonce.encode(), data, None)
+ return f"{nonce}:{base64.b64encode(encrypted_context).decode()}"
+
+
+def upgrade():
+ config_file_location = os.environ.get("CONFIG", "config/config.yml")
+ file = f"{os.path.dirname(os.path.realpath(__file__))}/../../{config_file_location}"
+ with open(file) as f:
+ config = munchify(yaml.load(f.read(), Loader=yaml.FullLoader))
+ encryption_key = config.encryption_key
+ conn = op.get_bind()
+ result = conn.execute(text("SELECT id, scim_url, scim_bearer_token FROM services WHERE scim_url IS NOT NULL "
+ "AND scim_bearer_token IS NOT NULL"))
+ for row in result:
+ identifier = row[0]
+ scim_url = row[1]
+ scim_bearer_token = row[2]
+ context = {
+ "scim_url": scim_url,
+ "identifier": identifier,
+ "table_name": "services"
+ }
+ secret = encrypt_secret(encryption_key, scim_bearer_token, context)
+ conn.execute(text("UPDATE services SET scim_bearer_token = :scim_bearer_token WHERE id = :id"),
+ dict(scim_bearer_token=secret, id=identifier))
+
+
+def downgrade():
+ pass
diff --git a/server/migrations/versions/fd61c7076492_service_connection_requests_have_a_.py b/server/migrations/versions/fd61c7076492_service_connection_requests_have_a_.py
new file mode 100644
index 000000000..5938304c0
--- /dev/null
+++ b/server/migrations/versions/fd61c7076492_service_connection_requests_have_a_.py
@@ -0,0 +1,26 @@
+"""Service connection requests have a status
+
+Revision ID: fd61c7076492
+Revises: 7ff5f119c762
+Create Date: 2024-02-27 10:53:00.679801
+
+"""
+from alembic import op
+from sqlalchemy import text
+
+# revision identifiers, used by Alembic.
+revision = 'fd61c7076492'
+down_revision = '7ff5f119c762'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ conn = op.get_bind()
+ conn.execute(text("ALTER TABLE service_connection_requests ADD COLUMN status varchar(255)"))
+ conn.execute(text("UPDATE service_connection_requests SET status = 'open'"))
+ conn.execute(text("ALTER TABLE service_connection_requests CHANGE status status VARCHAR(255) NOT NULL"))
+
+
+def downgrade():
+ pass
diff --git a/server/requirements/base.txt b/server/requirements/base.txt
index d840303ee..4e1e975fb 100644
--- a/server/requirements/base.txt
+++ b/server/requirements/base.txt
@@ -1,35 +1,35 @@
APScheduler==3.10.4
-mysqlclient==2.2.1
+mysqlclient==2.2.4
PyYAML==6.0.1
-SQLAlchemy==2.0.18
-cryptography==41.0.7
+SQLAlchemy==2.0.29
+cryptography==42.0.5
+eventlet==0.36.1
flask-jsonschema-validator==0.0.4
-flask-mail==0.9.1
-flask==3.0.0
-flask_migrate==4.0.5
+flask-mailman==1.0.0
+flask==3.0.2
+flask_migrate==4.0.7
Flask-SQLAlchemy==3.1.1
-jinja2==3.1.2
+jinja2==3.1.3
munch==4.0.0
-paho-mqtt==1.6.1
+paho-mqtt==2.0.0
python2-secrets==1.0.5
-pytz==2023.3.post1
+pytz==2024.1
requests==2.31.0
-uWSGI==2.0.23
-urllib3==2.1.0
+uWSGI==2.0.24
+urllib3==2.2.1
websockets==12.0
-redis==5.0.1
+redis==5.0.3
pyotp==2.9.0
qrcode==7.4.2
-Pillow==10.1.0
+Pillow==10.3.0
PyJWT==2.8.0
Authlib==1.3.0
passlib==1.7.4
-werkzeug==3.0.1
+werkzeug==3.0.2
Flask-Executor==1.0.0
-eventlet==0.33.3
Flask-SocketIO==5.3.6
Flask-Cors==4.0.0
-dnspython==2.4.2
+dnspython==2.6.1
python3-saml==1.16.0
bcrypt==4.1.2
git+https://github.com/SURFscz/flasgger@surf/main#egg=flasgger
diff --git a/server/requirements/test.txt b/server/requirements/test.txt
index cf77f4c17..f562d9bc3 100644
--- a/server/requirements/test.txt
+++ b/server/requirements/test.txt
@@ -1,13 +1,14 @@
-r base.txt
blinker==1.7.0
-flake8==6.1.0
+flake8==7.0.0
flask-testing==0.8.1
flask-webtest==0.1.4
hbmqtt2==0.9.7
-pytest-cov==4.1.0
-pytest==7.4.4
-responses==0.24.1
-alembic==1.13.0
-freezegun==1.3.1
-coverage==7.4.0
+pytest==8.1.1
+pytest-cov==5.0.0
+pytest-xdist==3.5.0
+responses==0.25.0
+alembic==1.13.1
+freezegun==1.4.0
+coverage==7.4.4
mock==5.1.0
diff --git a/server/scim/scim.py b/server/scim/scim.py
index a4822a933..4f6fe94c1 100644
--- a/server/scim/scim.py
+++ b/server/scim/scim.py
@@ -8,6 +8,7 @@
import requests
from server.api.base import application_base_url
+from server.auth.tokens import decrypt_scim_bearer_token
from server.db.db import db
from server.db.domain import Service, User, Group, Collaboration, Organisation
from server.db.models import flatten, unique_model_objects
@@ -24,7 +25,7 @@ def apply_change(f):
@wraps(f)
def wrapper(*args, **kwargs):
try:
- # We dont want to sleep in the sync_executor or in the testing mode
+ # We don't want to sleep in the sync_executor or in the testing mode
if not os.environ.get("TESTING", False) and args[len(args) - 1] == SYNC_MODE:
sleep(1)
res = f(*args, **kwargs)
@@ -63,7 +64,8 @@ def validate_response(response, service, outside_user_context=False, extra_loggi
# Get the headers with the bearer authentication
def scim_headers(service: Service, is_delete=False):
- headers = {"Authorization": f"Bearer {service.scim_bearer_token}",
+ plain_bearer_token = decrypt_scim_bearer_token(service)
+ headers = {"Authorization": f"Bearer {plain_bearer_token}",
"X-Service": str(service.id)}
if not is_delete:
headers["Accept"] = "application/scim+json"
diff --git a/server/scim/sweep.py b/server/scim/sweep.py
index 0fffb0b4c..e79146f21 100644
--- a/server/scim/sweep.py
+++ b/server/scim/sweep.py
@@ -177,8 +177,7 @@ def perform_sweep(service: Service):
scim_dict = create_user_template(user)
url = f"{service.scim_url}/{SCIM_USERS}"
scim_dict_cleansed = replace_none_values(scim_dict)
- response = requests.post(url, json=scim_dict_cleansed, headers=scim_headers(service),
- timeout=10)
+ response = requests.post(url, json=scim_dict_cleansed, headers=scim_headers(service), timeout=10)
if validate_response(response, service, outside_user_context=True, extra_logging="SCIM user create"):
# Add the new remote user to the remote_users_by_external_id for membership lookup
response_json = response.json()
diff --git a/server/swagger/conf.py b/server/swagger/conf.py
index 9d9ff4ff2..77c721f47 100644
--- a/server/swagger/conf.py
+++ b/server/swagger/conf.py
@@ -19,7 +19,12 @@
{
"name": "PAM web login",
"description": "All endpoints for external services using the PAM web login"
- }
+ },
+ {
+ "name": "User token introspection",
+ "description": "All endpoints for external services using an Introspect token"
+ },
+
],
"securityDefinitions": {
"Organisation": {
@@ -29,8 +34,9 @@
},
"Service": {
"type": "apiKey", "name": "Authorization", "in": "header",
- "description": "Authorization header using the bearer scheme with PAM web login and SCIM client. "
- "Example: \"Authorization: Bearer {scim_token}\""
+ "description": "Authorization header using the bearer scheme with PAM web login, "
+ "SCIM client and User introspection. "
+ "Example: \"Authorization: Bearer {token}\""
}
}
}
diff --git a/server/swagger/public/paths/delete_invitation_by_identifier.yml b/server/swagger/public/paths/delete_invitation_by_identifier.yml
new file mode 100644
index 000000000..64d275f98
--- /dev/null
+++ b/server/swagger/public/paths/delete_invitation_by_identifier.yml
@@ -0,0 +1,46 @@
+summary: "Delete invitation."
+description: "Deletes a single invitation."
+
+tags:
+ - "Organisation"
+
+consumes:
+ - "application/json"
+
+produces:
+ - "application/json"
+
+security:
+ - Organisation: [ 'Authorization' ]
+
+parameters:
+ - name: Authorization
+ in: header
+ description: Bearer API key
+ required: true
+ schema:
+ type: string
+ example: Bearer Am4Hp7GBO2lMseskWHRmEtE3DWD-VxZZ3qwMkNPv6qZ8
+ - name: external_identifier
+ in: path
+ description: Unique identifier of the invitation
+ required: true
+ schema:
+ type: string
+ format: uuid
+ example: "301ee8e6-b5d1-40b5-a27e-47611f803371"
+
+responses:
+ 204:
+ description: Ok
+ schema:
+ $ref: '/swagger/schemas/InvitationDetail.yaml'
+ 401:
+ schema:
+ $ref: '/swagger/components/responses/Unauthorized.yaml'
+ 403:
+ schema:
+ $ref: '/swagger/components/responses/Forbidden.yaml'
+ 404:
+ schema:
+ $ref: '/swagger/components/responses/NotFound.yaml'
diff --git a/server/swagger/public/paths/token_introspect.yml b/server/swagger/public/paths/token_introspect.yml
new file mode 100644
index 000000000..1d5c05e33
--- /dev/null
+++ b/server/swagger/public/paths/token_introspect.yml
@@ -0,0 +1,35 @@
+summary: "Token introspect."
+description: "User information retrieved by token."
+
+tags:
+ - "User token introspection"
+
+consumes:
+ - "application/x-www-form-urlencoded"
+
+produces:
+ - "application/json"
+
+security:
+ - Service: [ 'Authorization' ]
+
+parameters:
+ - name: Authorization
+ in: header
+ description: Bearer service token
+ required: true
+ schema:
+ type: string
+ example: Bearer Am4Hp7GBO2lMseskWHRmEtE3DWD-VxZZ3qwMkNPv6qZ8
+ - name: token
+ in: formData
+ description: Hashed user token
+ required: true
+ type: string
+ example: 'Aanl9A87WkaHKahT-7xvV7P6isFDfJQXjuEo9PE7tDbA'
+
+responses:
+ 200:
+ description: Userinfo
+ schema:
+ $ref: '/swagger/schemas/IntrospectUserResult.yaml'
diff --git a/server/swagger/public/schemas/IntrospectUserResult.yaml b/server/swagger/public/schemas/IntrospectUserResult.yaml
new file mode 100644
index 000000000..f44a9257f
--- /dev/null
+++ b/server/swagger/public/schemas/IntrospectUserResult.yaml
@@ -0,0 +1,71 @@
+---
+type: object
+properties:
+ active:
+ type: boolean
+ example: true
+ status:
+ type: string
+ enum:
+ - "token-unknown"
+ - "token-expired"
+ - "user-suspended"
+ - "token-not-connected"
+ - "token-valid"
+ client_id:
+ type: string
+ example: "https://service.cloud.com"
+ sub:
+ type: string
+ example: "7e28ebe36633f958e75a15a803aa6f4a7f0ab8ac@acc.sram.eduteams.org"
+ username:
+ type: string
+ example: "jdoe11"
+ iat:
+ type: number
+ example: 1709043044
+ exp:
+ type: number
+ example: 1709043044
+ aud:
+ type: string
+ example: "https://service.cloud.com"
+ iss:
+ type: string
+ example: "https://test.sram.surf.nl/"
+ user:
+ type: object
+ properties:
+ name:
+ type: string
+ example: "John Doe"
+ given_name:
+ type: string
+ example: "John"
+ family_name:
+ type: string
+ example: "Doe"
+ email:
+ type: string
+ example: "rdoe@uniharderwijk.nl"
+ entitlement:
+ type: string
+ example: "employee"
+ sub:
+ type: string
+ example: "7e28ebe36633f958e75a15a803aa6f4a7f0ab8ac@acc.sram.eduteams.org"
+ voperson_external_id:
+ type: string
+ example: "jdoe@example.com"
+ voperson_external_affiliation:
+ type: string
+ example: "student"
+ uid:
+ type: string
+ example: "7e28ebe36633f958e75a15a803aa6f4a7f0ab8ac@acc.sram.eduteams.org"
+ eduperson_entitlement:
+ type: array
+ description: "All the public SSH keys"
+ items:
+ type: string
+ example: "ssh-rsa AAAAB3NzaC1yc2EAAA..."
diff --git a/server/swagger/public/schemas/InvitationBulk.yaml b/server/swagger/public/schemas/InvitationBulk.yaml
index 3989dcf35..e1a7485a4 100644
--- a/server/swagger/public/schemas/InvitationBulk.yaml
+++ b/server/swagger/public/schemas/InvitationBulk.yaml
@@ -33,7 +33,7 @@ properties:
example: "rdoe@uniharderwijk.nl"
groups:
type: array
- description: "All the group identifiers (or short_names) where the user will be a member of after accepting the invitation"
+ description: "All the group identifiers where the user will be a member of after accepting the invitation"
items:
type: string
- example: "ia_researching"
\ No newline at end of file
+ example: "301ee8e6-b5d1-40b5-a27e-47611f803371"
\ No newline at end of file
diff --git a/server/swagger/public/schemas/Service.yaml b/server/swagger/public/schemas/Service.yaml
index aeb6910cf..6fe9a1d51 100644
--- a/server/swagger/public/schemas/Service.yaml
+++ b/server/swagger/public/schemas/Service.yaml
@@ -41,10 +41,6 @@ properties:
type: boolean
description: "If true all collaborations can connect to this service"
example: true
- code_of_conduct_compliant:
- type: boolean
- description: "Compliant with the code of conduct"
- example: true
non_member_users_access_allowed:
type: boolean
description: "If true then non collaboration members can access this service"
@@ -53,14 +49,6 @@ properties:
type: boolean
description: "If true in combination access_allowed_for_all equals true then connections need to be requested"
example: true
- research_scholarship_compliant:
- type: boolean
- description: "Compliant with the research scholarship"
- example: true
- sirtfi_compliant:
- type: boolean
- description: "Compliant with sirtfi"
- example: true
token_enabled:
type: boolean
description: "If true then tokens can be created for users"
diff --git a/server/templates/admin_suspended_user_account_actions.html b/server/templates/admin_suspended_user_account_actions.html
new file mode 100644
index 000000000..9be8bcab8
--- /dev/null
+++ b/server/templates/admin_suspended_user_account_actions.html
@@ -0,0 +1,67 @@
+{% extends "mail_layout.html" %}
+{% block title %}SRAM account deletion notification for {{ environment }}{% endblock %}
+{% block content %}
+
+ Hi,
+ Below follow the results for the check for inactive user accounts on {{ environment }}.
+
+ Current limits are:
+
+
+ Warning |
+ {{ dates[0] }} |
+
+
+ Suspension |
+ {{ dates[1] }} |
+
+
+ Last warning |
+ {{ dates[2] }} |
+
+
+ Deletion |
+ {{ dates[3] }} |
+
+
+
+ {% if warning_suspend|length -%}
+ The following {{ warning_suspend|length }} accounts have received a suspension warning:
+
+ {% for mail in warning_suspend %}
+ - {{ mail }}
+ {% endfor %}
+
+ {% endif %}
+
+ {% if suspend|length -%}
+ The following {{ suspend|length }} accounts have been suspended:
+
+ {% for mail in suspend %}
+ - {{ mail }}
+ {% endfor %}
+
+ {% endif %}
+
+
+ {% if warning_delete|length %}
+ The following {{ warning_delete|length }} accounts have received a deletion warning:
+
+ {% for mail in warning_delete %}
+ - {{ mail }}
+ {% endfor %}
+
+ {% endif %}
+
+ {% if delete|length %}
+ The following {{ delete|length }} accounts have been deleted:
+
+ {% for mail in delete %}
+ - {{ mail }}
+ {% endfor %}
+
+ {% endif %}
+
+ This email is automatically sent for environment {{ environment }}.
+
+{% endblock %}
diff --git a/server/templates/admin_suspended_user_account_actions.txt b/server/templates/admin_suspended_user_account_actions.txt
new file mode 100644
index 000000000..985dd0ff9
--- /dev/null
+++ b/server/templates/admin_suspended_user_account_actions.txt
@@ -0,0 +1,44 @@
+{% extends "mail_layout.txt" %}
+{% block title %}SRAM inactive account check for {{ environment }}{% endblock %}
+{% block content %}
+
+Hi,
+Below follow the results for the check for inactive user accounts on {{ environment }}.
+
+Current limits are:
+ - warning: inactive since {{ dates[0] }}
+ - suspension: inactive since {{ dates[1] }}
+ - last warning: inactive since {{ dates[2] }}
+ - deletion: inactive since {{ dates[3] }}
+
+{% if warning_suspend|length -%}
+The following {{ warning_suspend|length }} accounts have received a suspension warning:
+{%- for email in warning_suspend %}
+ - {{ email }}
+{%- endfor -%}
+{% endif %}
+
+{% if suspend|length -%}
+The following {{ suspend|length }} accounts have been suspended:
+{%- for email in suspend %}
+ - {{ email }}
+{%- endfor -%}
+{% endif %}
+
+{% if warning_delete|length -%}
+The following {{ warning_delete|length }} accounts have received a deletion warning:
+{%- for email in warning_delete %}
+ - {{ email }}
+{%- endfor -%}
+{% endif %}
+
+{% if delete|length -%}
+The following {{ delete|length }} accounts have been deleted:
+{%- for email in delete %}
+ - {{ email }}
+{%- endfor -%}
+{% endif %}
+
+This email is automatically sent for environment {{ environment }}.
+
+{% endblock %}
diff --git a/server/templates/collaboration_invitation.html b/server/templates/collaboration_invitation.html
index 723c33e5c..756bdd5ea 100644
--- a/server/templates/collaboration_invitation.html
+++ b/server/templates/collaboration_invitation.html
@@ -4,12 +4,22 @@
+ {% if reminder is not defined or reminder == False %}
{% if invitation.intended_role == "member" %}
You have been invited by {{ invitation.user.name }} to join a collaboration page.
{% endif %}
{% if invitation.intended_role == "admin" %}
You have been invited by {{ invitation.user.name }} to become an admin in collaboration {{ collaboration.name }}.
{% endif %}
+ {% endif %}
+ {% if reminder == True %}
+ {% if invitation.intended_role == "member" %}
+ Reminder: you have been invited by {{ invitation.user.name }} to join a collaboration page.
+ {% endif %}
+ {% if invitation.intended_role == "admin" %}
+ Reminder: you have been invited by {{ invitation.user.name }} to become an admin in collaboration {{ collaboration.name }}.
+ {% endif %}
+ {% endif %}
diff --git a/server/templates/collaboration_join_request.html b/server/templates/collaboration_join_request.html
index 3d52abfbc..fa3ca40ce 100644
--- a/server/templates/collaboration_join_request.html
+++ b/server/templates/collaboration_join_request.html
@@ -21,13 +21,4 @@
{{ join_request.message }}
-
- {% if join_request.reference is defined and join_request.reference != None and join_request.reference|length %}
-
The user has provided the following references within the collaboration.
-
{{ join_request.reference }}
- {% else %}
-
The user has not provided any references within the collaboration.
- {% endif %}
-
-
{% endblock %}
diff --git a/server/templates/collaboration_join_request.txt b/server/templates/collaboration_join_request.txt
index af04be77e..5f8ea9c29 100644
--- a/server/templates/collaboration_join_request.txt
+++ b/server/templates/collaboration_join_request.txt
@@ -7,12 +7,6 @@
The user {{ user.name }} has requested access to the collaboration {{ collaboration.name }}.
The motivation for this request:
{{ join_request.message }}
-{% if join_request.reference is defined and join_request.reference != None and join_request.reference|length %}
-The user has provided the following references within the collaboration.
-{{ join_request.reference }}
-{% else %}
-The user has not provided any references within the collaboration.
-{% endif %}
Login to process this request:
{{base_url}}/collaborations/{{collaboration.id}}/joinrequests
diff --git a/server/templates/open_requests_overview.html b/server/templates/open_requests_overview.html
new file mode 100644
index 000000000..af61d0c68
--- /dev/null
+++ b/server/templates/open_requests_overview.html
@@ -0,0 +1,64 @@
+{% extends "mail_layout.html" %}
+{% block title %}SRAM open requests for {{ environment }}{% endblock %}
+{% block content %}
+
+
+
+
Hi,
+
Below are all the open requests from SRAM users that need your attention:
+
+
+ {% if collaboration_requests|length -%}
+
The following collaboration requests are outstanding:
+
+ {% endif %}
+
+ {% if join_requests|length -%}
+
The following join requests are outstanding:
+
+ {% endif %}
+
+ {% if service_connection_requests|length -%}
+
The following service connection requests are outstanding:
+
+ {% for scr in service_connection_requests %}
+ {% if scr.pending_organisation_approval == True %}
+ -
+ {{ scr.collaboration.name }} requested by {{ scr.requester.email }}
+
+ {% endif %}
+ {% if scr.pending_organisation_approval == False %}
+ -
+ {{ scr.collaboration.name }} requested by {{ scr.requester.email }}
+
+ {% endif %}
+ {% endfor %}
+
+ {% endif %}
+
+ {% if service_requests|length -%}
+
The following service requests are outstanding:
+
+ {% for sr in service_requests %}
+ -
+ {{ sr.name }} requested by {{ sr.requester.email }}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/server/templates/open_requests_overview.txt b/server/templates/open_requests_overview.txt
new file mode 100644
index 000000000..e116a848b
--- /dev/null
+++ b/server/templates/open_requests_overview.txt
@@ -0,0 +1,43 @@
+{% extends "mail_layout.txt" %}
+{% block title %}SRAM inactive account check for {{ environment }}{% endblock %}
+{% block content %}
+
+ Hi,
+ Below are all the open requests that need your attention on {{ environment }}.
+
+
+ {% if collaboration_requests|length -%}
+ The following {{ collaboration_requests|length }} collaboration requests are outstanding:
+ {% for cr in collaboration_requests %}
+ {{ cr.name }}: {{ base_url }}/organisations/{{ cr.organisation_id }}/collaboration_requests
+ {% endfor %}
+
+ {% endif %}
+
+ {% if join_requests|length -%}
+ The following {{ join_requests|length }} join requests are outstanding:
+ {% for jr in join_requests %}
+ {{ jr.collaboration.name }}: {{ base_url }}/collaborations/{{ jr.collaboration_id }}/joinrequests
+ {% endfor %}
+ {% endif %}
+
+ {% if service_connection_requests|length -%}
+ The following {{ service_connection_requests|length }} service connection requests are outstanding:
+ {% for scr in service_connection_requests %}
+ {% if scr.pending_organisation_approval == True %}
+ {{ scr.collaboration.name }}: {{ base_url }}/organisations/{{ scr.collaboration.organisation_id }}/serviceConnectionRequests
+ {% endif %}
+ {% if scr.pending_organisation_approval == False %}
+ {{ scr.collaboration.name }}: {{ base_url }}/services/{{ scr.service_id }}/serviceConnectionRequests
+ {% endif %}
+ {% endfor %}
+ {% endif %}
+
+ {% if service_requests|length -%}
+ The following {{ service_requests|length }} service requests are outstanding:
+ {% for sr in service_requests %}
+ {{ sr.name }}: {{ base_url }}/home/service_requests
+ {% endfor %}
+ {% endif %}
+
+{% endblock %}
diff --git a/server/templates/organisation_invitation.html b/server/templates/organisation_invitation.html
index bd9eea2a4..67bb3cf18 100644
--- a/server/templates/organisation_invitation.html
+++ b/server/templates/organisation_invitation.html
@@ -4,7 +4,12 @@
+ {% if reminder is not defined or reminder == False %}
You have been invited by {{ invitation.user.name }} to become {{ invitation.intended_role }} in organisation '{{ invitation.organisation.name }}'.
+ {% endif %}
+ {% if reminder == True %}
+ Reminder: you have been invited by {{ invitation.user.name }} to become {{ invitation.intended_role }} in organisation '{{ invitation.organisation.name }}'.
+ {% endif %}
diff --git a/server/templates/personal_information.html b/server/templates/personal_information.html
new file mode 100644
index 000000000..2404ea9fb
--- /dev/null
+++ b/server/templates/personal_information.html
@@ -0,0 +1,26 @@
+
+ We have stored the following personal information about you:
+
+ - Name: {{ user.name }}
+ - Email: {{ user.email }}
+ {% if affiliations|length -%}
+ - Affiliations: {{ affiliations }}
+ {% endif %}
+
+ {% if collaborations|length -%}
+ You are a member of the following collaborations:
+
+ {% for co in collaborations %}
+ - {{ co.name }}
+ {% endfor %}
+
+ {% endif %}
+
+ {% if services|length -%}
+ You have access to the following services:
+
+ {% for service in services %}
+ - {{ service.name }}
+ {% endfor %}
+
+ {% endif %}
diff --git a/server/templates/service_invitation.html b/server/templates/service_invitation.html
index b35a06957..6a2a830ed 100644
--- a/server/templates/service_invitation.html
+++ b/server/templates/service_invitation.html
@@ -2,14 +2,24 @@
- {{ invitation.user.name }} invited you to become an admin for service {{ invitation.service.name }}.
+ {% if reminder is not defined or reminder == False %}
+ {{ invitation.user.name }} invited you to become an {{ invitation.intended_role }} for service {{ invitation.service.name }}.
+ {% endif %}
+ {% if reminder == True %}
+ Reminder: {{ invitation.user.name }} invited you to become an {{ invitation.intended_role }} for service {{ invitation.service.name }}.
+ {% endif %}
+ {% if invitation.intended_role == "admin" %}
As a service admin you can manage service properties, manage which organisations can use this service, and invite other service admins for this service.
+ {% endif %}
+ {% if invitation.intended_role == "manager" %}
+ As a service manager you can approve or deny connection requests and disconnect collaborations from the Service.
+ {% endif %}
- Become admin of {{ invitation.service.name }}
+ Become {{ invitation.intended_role }} of {{ invitation.service.name }}
{% if invitation.message is defined and invitation.message != None and invitation.message|length %}
diff --git a/server/templates/suspend_delete_warning_notification.html b/server/templates/suspend_delete_warning_notification.html
index b1ec12a40..84c9dea08 100644
--- a/server/templates/suspend_delete_warning_notification.html
+++ b/server/templates/suspend_delete_warning_notification.html
@@ -17,6 +17,8 @@
your profile will be deleted on {{ deletion_date }}.
This means non-web services might not work anymore.
+ {% include "personal_information.html" %}
+
If you want to keep using the services available through SURF Research Access Management,
simply login using the link below or by going to {{ base_url }}/login.
@@ -28,7 +30,7 @@
style="border-radius: 4px;color: white;background-color: #0077c8;text-decoration: none;display: inline-block;margin: 15px 0;cursor:pointer;padding: 14px 26px;font-size: 18px;">
Login to prevent suspension
-
If you have any questions, please let us know by replying to this email.
+
If you have any questions, please let us know by sending a mail to {{ support_address }}.
Kind regards,
SURF Research Access Management support
diff --git a/server/templates/suspend_delete_warning_notification.txt b/server/templates/suspend_delete_warning_notification.txt
index 8c88487ac..54687b239 100644
--- a/server/templates/suspend_delete_warning_notification.txt
+++ b/server/templates/suspend_delete_warning_notification.txt
@@ -11,7 +11,8 @@ If you want to keep using the services available through SURF Research Access Ma
simply login using the link below or by going to {{ base_url }}/login.
If you want to delete your profile right away, please go to {{ base_url }}/profile?delete=true.
-If you have any questions, please let us know by replying to this email.
+If you have any questions, please let us know by sending a mail to {{ support_address }}.
+
Kind regards,
SURF Research Access Management support
{% endblock %}
diff --git a/server/templates/suspend_suspend_notification.html b/server/templates/suspend_suspend_notification.html
index 871f649cd..266f59a1c 100644
--- a/server/templates/suspend_suspend_notification.html
+++ b/server/templates/suspend_suspend_notification.html
@@ -18,6 +18,8 @@
This means non-web services might not work anymore. On {{ deletion_date }}, your profile will be
deleted.
+ {% include "personal_information.html" %}
+
If you want to keep using the services available through SURF Research Access Management,
simply login using the link below or by going to {{ base_url }}/login.
@@ -29,7 +31,7 @@
style="border-radius: 4px;color: white;background-color: #0077c8;text-decoration: none;display: inline-block;margin: 15px 0;cursor:pointer;padding: 14px 26px;font-size: 18px;">
Login to prevent suspension
-
If you have any questions, please let us know by replying to this email.
+
If you have any questions, please let us know by sending a mail to {{ support_address }}.
Kind regards,
SURF Research Access Management support
diff --git a/server/templates/suspend_suspend_notification.txt b/server/templates/suspend_suspend_notification.txt
index 77f0ad25c..f1fc862a3 100644
--- a/server/templates/suspend_suspend_notification.txt
+++ b/server/templates/suspend_suspend_notification.txt
@@ -11,7 +11,8 @@ If you want to keep using the services available through SURF Research Access Ma
simply login using the link below or by going to {{ base_url }}/login.
If you want to delete your profile right away, please go to {{ base_url }}/profile?delete=true.
-If you have any questions, please let us know by replying to this email.
+If you have any questions, please let us know by sending a mail to {{ support_address }}.
+
Kind regards,
SURF Research Access Management support
{% endblock %}
diff --git a/server/templates/suspend_suspend_warning_notification.html b/server/templates/suspend_suspend_warning_notification.html
index 363aecb6e..53e9dfabe 100644
--- a/server/templates/suspend_suspend_warning_notification.html
+++ b/server/templates/suspend_suspend_warning_notification.html
@@ -18,6 +18,8 @@
This means non-web services might not work anymore. On {{ deletion_date }}, your profile will be
deleted.
+ {% include "personal_information.html" %}
+
If you want to keep using the services available through SURF Research Access Management,
simply login using the link below or by going to {{ base_url }}/login.
@@ -29,7 +31,7 @@
style="border-radius: 4px;color: white;background-color: #0077c8;text-decoration: none;display: inline-block;margin: 15px 0;cursor:pointer;padding: 14px 26px;font-size: 18px;">
Login to prevent suspension
-
If you have any questions, please let us know by replying to this email.
+
If you have any questions, please let us know by sending a mail to {{ support_address }}.
Kind regards,
SURF Research Access Management support
diff --git a/server/templates/suspend_suspend_warning_notification.txt b/server/templates/suspend_suspend_warning_notification.txt
index b7b45e60e..5593bcc2e 100644
--- a/server/templates/suspend_suspend_warning_notification.txt
+++ b/server/templates/suspend_suspend_warning_notification.txt
@@ -11,7 +11,8 @@ If you want to keep using the services available through SURF Research Access Ma
simply login using the link below or by going to {{ base_url }}/login.
If you want to delete your profile right away, please go to {{ base_url }}/profile?delete=true.
-If you have any questions, please let us know by replying to this email.
+If you have any questions, please let us know by sending a mail to {{ support_address }}.
+
Kind regards,
SURF Research Access Management support
{% endblock %}
diff --git a/server/test/abstract_test.py b/server/test/abstract_test.py
index c6c19ef96..5a297db7c 100644
--- a/server/test/abstract_test.py
+++ b/server/test/abstract_test.py
@@ -16,7 +16,7 @@
from flask_testing import TestCase
from onelogin.saml2.utils import OneLogin_Saml2_Utils
from sqlalchemy import text
-from sqlalchemy.orm import sessionmaker
+from sqlalchemy.orm import sessionmaker, load_only
from server.auth.mfa import ACR_VALUES
from server.auth.secrets import secure_hash
@@ -26,6 +26,7 @@
PamSSOSession, Group, CollaborationMembership
from server.test.seed import seed, user_sarah_name
from server.tools import read_file
+from server.tools import dt_now
# See api_users in config/test_config.yml
BASIC_AUTH_HEADER = {"Authorization": f"Basic {b64encode(b'sysadmin:secret').decode('ascii')}"}
@@ -97,7 +98,7 @@ def change_collaboration(user_name, do_change):
@staticmethod
def expire_collaborations(user_name):
def do_change(collaboration):
- collaboration.expiry_date = datetime.datetime.utcnow() - datetime.timedelta(days=50)
+ collaboration.expiry_date = dt_now() - datetime.timedelta(days=50)
return AbstractTest.change_collaboration(user_name, do_change)
@@ -175,7 +176,7 @@ def expire_all_collaboration_memberships(self, user_name):
self.expire_collaboration_memberships(user.collaboration_memberships)
def expire_collaboration_memberships(self, collaboration_memberships):
- past = datetime.datetime.now() - datetime.timedelta(days=5)
+ past = dt_now() - datetime.timedelta(days=5)
for cm in collaboration_memberships:
cm.expiry_date = past
cm.status = STATUS_EXPIRED
@@ -200,14 +201,14 @@ def add_service_aup_to_user(user_uid, service_entity_id):
@staticmethod
def expire_user_token(raw_token):
user_token = UserToken.query.filter(UserToken.hashed_token == secure_hash(raw_token)).first()
- user_token.created_at = datetime.datetime.utcnow() - datetime.timedelta(days=500)
+ user_token.created_at = dt_now() - datetime.timedelta(days=500)
db.session.merge(user_token)
db.session.commit()
@staticmethod
def expire_invitation(hash):
invitation = Invitation.query.filter(Invitation.hash == hash).first()
- invitation.expiry_date = datetime.datetime.utcnow() - datetime.timedelta(days=500)
+ invitation.expiry_date = dt_now() - datetime.timedelta(days=500)
invitation.created_by = "not_system"
db.session.merge(invitation)
db.session.commit()
@@ -215,7 +216,7 @@ def expire_invitation(hash):
@staticmethod
def login_user_2fa(user_uid):
user = User.query.filter(User.uid == user_uid).one()
- user.last_login_date = datetime.datetime.now()
+ user.last_login_date = dt_now()
return AbstractTest._merge_user(user)
@staticmethod
@@ -260,7 +261,7 @@ def mark_user_ssid_required(self, name=user_sarah_name, home_organisation_uid=No
@staticmethod
def expire_pam_session(session_id):
pam_websso = PamSSOSession.query.filter(PamSSOSession.session_id == session_id).first()
- pam_websso.created_at = datetime.datetime.utcnow() - datetime.timedelta(days=500)
+ pam_websso.created_at = dt_now() - datetime.timedelta(days=500)
db.session.merge(pam_websso)
db.session.commit()
@@ -295,3 +296,10 @@ def find_collaboration_membership(collaboration_identifier, user_uid):
.filter(Collaboration.identifier == collaboration_identifier) \
.filter(User.uid == user_uid) \
.first()
+
+ def add_bearer_token_to_services(self):
+ services = Service.query.options(load_only(Service.id)).filter(Service.scim_enabled == True).all() # noqa: E712
+ service_identifiers = [s.id for s in services]
+ for identifier in service_identifiers:
+ self.put(f"/api/services/reset_scim_bearer_token/{identifier}",
+ {"scim_bearer_token": "secret"})
diff --git a/server/test/api/test_audit_log.py b/server/test/api/test_audit_log.py
index 6382ccd96..157c39a3a 100644
--- a/server/test/api/test_audit_log.py
+++ b/server/test/api/test_audit_log.py
@@ -1,13 +1,14 @@
-from datetime import datetime
-
from flask import jsonify
from server.db.audit_mixin import ACTION_DELETE, ACTION_CREATE, ACTION_UPDATE, AuditLog
from server.db.domain import User, Collaboration, Service, Organisation, Group
+from server.tools import dt_now
+
from server.test.abstract_test import AbstractTest
from server.test.seed import service_cloud_name, co_ai_computing_name, \
- service_mail_name, invitation_hash_curious, unihard_invitation_hash, unihard_name, group_science_name, user_sarah_name, \
- user_james_name
+ service_mail_name, invitation_hash_curious, unihard_invitation_hash, unihard_name, group_science_name, \
+ user_sarah_name, \
+ user_james_name, co_teachers_name
class TestAuditLog(AbstractTest):
@@ -139,7 +140,21 @@ def test_activity(self):
def test_no_last_activity_date_only_audit_logs(self):
collaboration = self.find_entity_by_name(Collaboration, co_ai_computing_name)
- collaboration.last_activity_date = datetime.now()
+ collaboration.last_activity_date = dt_now()
self.save_entity(collaboration)
audit_logs = AuditLog.query.all()
self.assertEqual(0, len(audit_logs))
+
+ def test_manager_no_access_based_on_unit(self):
+ self.login("urn:harry")
+ collaboration = self.find_entity_by_name(Collaboration, co_teachers_name)
+ self.get(f"/api/audit_logs/info/{collaboration.id}/collaborations", response_status_code=403)
+
+ def test_filter_collaborations_audit_logs_based_on_units(self):
+ collaboration = self.find_entity_by_name(Collaboration, co_teachers_name)
+ collaboration.name = "changed"
+ self.save_entity(collaboration)
+ self.login("urn:harry")
+ res = self.get(f"/api/audit_logs/info/{collaboration.organisation_id}/organisations")
+ collaboration_audit_logs = [log for log in res["audit_logs"] if log["target_type"] == "collaborations"]
+ self.assertEqual(0, len(collaboration_audit_logs))
diff --git a/server/test/api/test_base.py b/server/test/api/test_base.py
index 038f7777d..ae07f7871 100644
--- a/server/test/api/test_base.py
+++ b/server/test/api/test_base.py
@@ -13,7 +13,6 @@ class TestBase(AbstractTest):
def test_send_error_mail_with_external_api_user(self):
try:
- del os.environ["TESTING"]
self.app.app_config.mail.send_exceptions = True
with self.app.app_context():
mail = self.app.mail
@@ -25,12 +24,10 @@ def test_send_error_mail_with_external_api_user(self):
self.assertTrue("api_user" in html)
finally:
- os.environ["TESTING"] = "1"
self.app.app_config.mail.send_exceptions = False
def test_send_error_mail_with_no_user(self):
try:
- del os.environ["TESTING"]
self.app.app_config.mail.send_exceptions = True
with self.app.app_context():
mail = self.app.mail
@@ -41,7 +38,6 @@ def test_send_error_mail_with_no_user(self):
self.assertTrue("unknown" in html)
finally:
- os.environ["TESTING"] = "1"
self.app.app_config.mail.send_exceptions = False
def test_health(self):
diff --git a/server/test/api/test_collaboration.py b/server/test/api/test_collaboration.py
index 0911d4975..59815e660 100644
--- a/server/test/api/test_collaboration.py
+++ b/server/test/api/test_collaboration.py
@@ -1,8 +1,9 @@
import base64
import datetime
import json
+import os
import time
-
+import responses
from sqlalchemy import text
from server.api.collaboration import generate_short_name
@@ -11,6 +12,8 @@
from server.db.domain import Collaboration, Organisation, Invitation, CollaborationMembership, User, Group, \
ServiceGroup, Tag, Service
from server.db.models import flatten
+from server.tools import dt_now
+
from server.test.abstract_test import AbstractTest, API_AUTH_HEADER
from server.test.seed import (co_ai_computing_uuid, co_ai_computing_name, co_research_name, user_john_name,
co_ai_computing_short_name, co_teachers_name, read_image, co_research_uuid,
@@ -261,8 +264,8 @@ def test_collaboration_update_organisation(self):
self.put("/api/collaborations", body=collaboration)
collaboration = self.find_entity_by_name(Collaboration, co_ai_computing_name)
- self.assertEqual("uva:ai_computing", collaboration.global_urn)
- self.assertListEqual(["uva:ai_computing:ai_dev", "uva:ai_computing:ai_res"],
+ self.assertEqual("ufra:ai_computing", collaboration.global_urn)
+ self.assertListEqual(["ufra:ai_computing:ai_dev", "ufra:ai_computing:ai_res"],
sorted(group.global_urn for group in collaboration.groups))
self.assertEqual(pre_uuid4, collaboration.uuid4)
@@ -492,6 +495,15 @@ def test_collaboration_invites_no_intended_role(self):
invitation = Invitation.query.filter(Invitation.invitee_email == "new@example.org").first()
self.assertEqual("member", invitation.intended_role)
+ def test_collaboration_duplicate_invites(self):
+ self.login("urn:john")
+ collaboration_id = self.find_entity_by_name(Collaboration, co_ai_computing_name).id
+ existing_invitee_mail = "curious@ex.org"
+ res = self.put("/api/collaborations/invites",
+ body={"collaboration_id": collaboration_id, "administrators": [existing_invitee_mail],
+ "intended_role": "admin"}, response_status_code=400)
+ self.assertTrue(existing_invitee_mail in res["message"])
+
def test_collaboration_invites_preview(self):
self.login("urn:john")
collaboration_id = self._find_by_identifier()["id"]
@@ -595,7 +607,7 @@ def test_api_call(self):
self.assertIsNone(collaboration.accepted_user_policy)
self.assertIsNotNone(collaboration.logo)
self.assertEqual(2, len(collaboration.tags))
- one_day_ago = datetime.datetime.now() - datetime.timedelta(days=1)
+ one_day_ago = dt_now() - datetime.timedelta(days=1)
self.assertTrue(collaboration.last_activity_date > one_day_ago)
count = Invitation.query.filter(Invitation.collaboration_id == collaboration.id).count()
@@ -664,25 +676,36 @@ def test_api_call_invalid_json(self):
content_type="application/json")
self.assertEqual(400, response.status_code)
+ @responses.activate
def test_api_call_with_logo_url(self):
- response = self.client.post("/api/collaborations/v1",
- headers={"Authorization": f"Bearer {unihard_secret}"},
- data=json.dumps({
- "name": "new_collaboration",
- "description": "new_collaboration",
- "administrators": ["the@ex.org", "that@ex.org"],
- "short_name": "new_short_name",
- "disable_join_requests": True,
- "disclose_member_information": True,
- "disclose_email_information": True,
- "logo": "https://static.surfconext.nl/media/idp/eduid.png"
- }),
- content_type="application/json")
- self.assertEqual(201, response.status_code)
+ file = f"{os.path.dirname(os.path.realpath(__file__))}/../images/eduid.png"
+ with open(file, "rb") as f:
+ body = f.read()
+ logo_url = "http://localhost:8081/eduid.png"
+ responses.add(method=responses.GET,
+ url=logo_url,
+ body=body,
+ status=200,
+ content_type="image/jpeg",
+ stream=True)
+ response = self.client.post("/api/collaborations/v1",
+ headers={"Authorization": f"Bearer {unihard_secret}"},
+ data=json.dumps({
+ "name": "new_collaboration",
+ "description": "new_collaboration",
+ "administrators": ["the@ex.org", "that@ex.org"],
+ "short_name": "new_short_name",
+ "disable_join_requests": True,
+ "disclose_member_information": True,
+ "disclose_email_information": True,
+ "logo": logo_url
+ }),
+ content_type="application/json")
+ self.assertEqual(201, response.status_code)
- collaboration = self.find_entity_by_name(Collaboration, "new_collaboration")
- raw_logo = collaboration.raw_logo()
- self.assertFalse(raw_logo.startswith("http"))
+ collaboration = self.find_entity_by_name(Collaboration, "new_collaboration")
+ raw_logo = collaboration.raw_logo()
+ self.assertFalse(raw_logo.startswith("http"))
def test_api_call_without_logo(self):
response = self.client.post("/api/collaborations/v1",
@@ -885,7 +908,7 @@ def test_collaboration_update_expiration_date_with_suspended(self):
def test_unsuspend(self):
coll = self.find_entity_by_name(Collaboration, co_ai_computing_name)
- coll.last_activity_date = datetime.datetime.now() - datetime.timedelta(days=365)
+ coll.last_activity_date = dt_now() - datetime.timedelta(days=365)
coll.status = STATUS_SUSPENDED
db.session.merge(coll)
db.session.commit()
@@ -893,11 +916,11 @@ def test_unsuspend(self):
self.put("/api/collaborations/unsuspend", body={"collaboration_id": coll.id})
coll = self.find_entity_by_name(Collaboration, co_ai_computing_name)
self.assertEqual(STATUS_ACTIVE, coll.status)
- self.assertTrue(coll.last_activity_date > datetime.datetime.now() - datetime.timedelta(hours=1))
+ self.assertGreater(coll.last_activity_date, dt_now() - datetime.timedelta(hours=1))
def test_activate(self):
coll = self.find_entity_by_name(Collaboration, co_ai_computing_name)
- coll.expiry_date = datetime.datetime.now() - datetime.timedelta(days=365)
+ coll.expiry_date = dt_now() - datetime.timedelta(days=365)
coll.status = STATUS_EXPIRED
db.session.merge(coll)
db.session.commit()
@@ -906,7 +929,7 @@ def test_activate(self):
coll = self.find_entity_by_name(Collaboration, co_ai_computing_name)
self.assertEqual(STATUS_ACTIVE, coll.status)
self.assertIsNone(coll.expiry_date)
- self.assertTrue(coll.last_activity_date > datetime.datetime.now() - datetime.timedelta(hours=1))
+ self.assertGreater(coll.last_activity_date, dt_now() - datetime.timedelta(hours=1))
def test_id_by_identifier(self):
res = self.get("/api/collaborations/id_by_identifier",
@@ -959,7 +982,7 @@ def test_collaboration_admins(self):
self.login("urn:service_admin")
service = self.find_entity_by_name(Service, service_storage_name)
res = self.get(f"/api/collaborations/admins/{service.id}")
- self.assertDictEqual({f"{co_research_name}": ["sarah@uva.org"]}, res)
+ self.assertDictEqual({f"{co_research_name}": ["sarah@uni-franeker.nl"]}, res)
def test_delete_membership_api(self):
self.assertIsNotNone(self.find_collaboration_membership(co_ai_computing_uuid, 'urn:jane'))
@@ -1064,3 +1087,23 @@ def test_hint_short_name(self):
def test_empty_hint_short_name(self):
res = self.post("/api/collaborations/hint_short_name", body={"name": "*&^%$$@"}, response_status_code=200)
self.assertEqual("short_name", res["short_name"])
+
+ def test_api_call_invalid_emails(self):
+ response = self.client.post("/api/collaborations/v1",
+ headers={"Authorization": f"Bearer {unihard_secret}"},
+ data=json.dumps({
+ "name": "new_collaboration",
+ "description": "new_collaboration",
+ "accepted_user_policy": "https://aup.org",
+ "administrators": ["nope", "nada"],
+ "administrator": "urn:sarah",
+ "short_name": "new_short_name",
+ "disable_join_requests": True,
+ "disclose_member_information": True,
+ "disclose_email_information": True,
+ "logo": read_image("robot.png")
+ }),
+ content_type="application/json")
+ self.assertEqual(400, response.status_code)
+ response_json = response.json
+ self.assertTrue("Invalid emails" in response_json["message"])
diff --git a/server/test/api/test_collaboration_request.py b/server/test/api/test_collaboration_request.py
index 431ed5ecb..728efee14 100644
--- a/server/test/api/test_collaboration_request.py
+++ b/server/test/api/test_collaboration_request.py
@@ -47,7 +47,8 @@ def test_request_collaboration_collaboration_creation_allowed(self):
self.assertEqual("new_collaboratio", collaboration.short_name)
mail_msg = outbox[0]
organisation = self.find_entity_by_name(Organisation, unihard_name)
- self.assertEqual(f"New collaboration {collaboration.name} created in {organisation.name} (local)", mail_msg.subject)
+ self.assertEqual(f"New collaboration {collaboration.name} created in {organisation.name} (local)",
+ mail_msg.subject)
self.assertTrue("automatically approve collaboration requests" in mail_msg.html)
def test_request_collaboration_collaboration_creation_allowed_entitlement(self):
@@ -69,7 +70,8 @@ def test_request_collaboration_collaboration_creation_allowed_entitlement(self):
self.assertEqual("new_collaboratio", collaboration.short_name)
mail_msg = outbox[0]
organisation = self.find_entity_by_name(Organisation, unifra_name)
- self.assertEqual(f"New collaboration {collaboration.name} created in {organisation.name} (local)", mail_msg.subject)
+ self.assertEqual(f"New collaboration {collaboration.name} created in {organisation.name} (local)",
+ mail_msg.subject)
def test_request_collaboration_no_schachome(self):
self.login("urn:user_suspend_warning", schac_home_organisation=None)
@@ -106,13 +108,13 @@ def test_request_collaboration_approve_logo_url(self):
self.login("urn:john")
self.put(f"/api/collaboration_requests/approve/{collaboration_request_id}",
- body={
- "name": res["name"],
- "logo": res["logo"],
- "description": "new_collaboration",
- "short_name": res["short_name"],
- "organisation_id": res["organisation_id"]
- }, with_basic_auth=False)
+ body={
+ "name": res["name"],
+ "logo": res["logo"],
+ "description": "new_collaboration",
+ "short_name": res["short_name"],
+ "organisation_id": res["organisation_id"]
+ }, with_basic_auth=False)
collaboration_request = self.find_entity_by_name(CollaborationRequest, collaboration_request_name)
raw_logo = collaboration_request.raw_logo()
self.assertFalse(raw_logo.startswith("http"))
@@ -134,7 +136,7 @@ def test_request_collaboration_deny(self):
def test_delete(self):
collaboration_request = self.find_entity_by_name(CollaborationRequest, collaboration_request_name)
- self.login("urn:harry")
+ self.login("urn:john")
self.delete("/api/collaboration_requests", primary_key=collaboration_request.id, with_basic_auth=False,
response_status_code=400)
@@ -142,7 +144,7 @@ def test_delete_with_status_open(self):
pre_count = CollaborationRequest.query.count()
collaboration_request = self.find_entity_by_name(CollaborationRequest, collaboration_request_name)
collaboration_request_id = collaboration_request.id
- self.login("urn:harry")
+ self.login("urn:john")
self.put(f"/api/collaboration_requests/approve/{collaboration_request_id}",
body={
"name": collaboration_request.name,
@@ -152,3 +154,52 @@ def test_delete_with_status_open(self):
}, with_basic_auth=False)
self.delete("/api/collaboration_requests", primary_key=collaboration_request_id, with_basic_auth=False)
self.assertEqual(pre_count - 1, CollaborationRequest.query.count())
+
+ def test_request_collaboration_limited_recipients(self):
+ organisation = self.find_entity_by_name(Organisation, unihard_name)
+ organisation.collaboration_creation_allowed = False
+ self.save_entity(organisation)
+ self.login("urn:james", schac_home_organisation_unihar)
+ data = {
+ "name": "New Collaboration",
+ "short_name": "new_collaboration_short",
+ "message": "pretty please",
+ "organisation_id": organisation.id
+ }
+ with self.app.mail.record_messages() as outbox:
+ self.post("/api/collaboration_requests", body=data, with_basic_auth=False)
+ mail_msg = outbox[0]
+ # harry is not a recipient because he has a unit
+ self.assertEqual(["john@example.org", "mary@example.org"], sorted(mail_msg.to))
+
+ def test_collaboration_request_approve_not_allowed(self):
+ collaboration_request = self.find_entity_by_name(CollaborationRequest, collaboration_request_name)
+ self.login("urn:harry")
+ self.put(f"/api/collaboration_requests/approve/{collaboration_request.id}",
+ body={}, with_basic_auth=False, response_status_code=403)
+
+ def test_collaboration_request_by_id_not_allowed(self):
+ collaboration_request = self.find_entity_by_name(CollaborationRequest, collaboration_request_name)
+ collaboration_request_id = collaboration_request.id
+ self.login("urn:harry")
+ self.get(f"/api/collaboration_requests/{collaboration_request_id}",
+ with_basic_auth=False, response_status_code=403)
+
+ def test_delete_not_allowed(self):
+ collaboration_request = self.find_entity_by_name(CollaborationRequest, collaboration_request_name)
+ self.login("urn:harry")
+ self.delete("/api/collaboration_requests", primary_key=collaboration_request.id, with_basic_auth=False,
+ response_status_code=403)
+
+ def test_request_collaboration_with_subdomain(self):
+ organisation = self.find_entity_by_name(Organisation, unifra_name)
+ self.login("urn:mary")
+ data = {
+ "name": "New Collaboration",
+ "short_name": "new_collaboration_short",
+ "message": "pretty please",
+ "organisation_id": organisation.id
+ }
+ res = self.post("/api/collaboration_requests", body=data, with_basic_auth=False)
+ collaboration_request = db.session.get(CollaborationRequest, res["id"])
+ self.assertEqual("urn:mary", collaboration_request.requester.uid)
diff --git a/server/test/api/test_collaborations_services.py b/server/test/api/test_collaborations_services.py
index 922697f85..5aa421888 100644
--- a/server/test/api/test_collaborations_services.py
+++ b/server/test/api/test_collaborations_services.py
@@ -80,7 +80,7 @@ def test_add_collaborations_no_automatic_connection_allowed(self):
}, response_status_code=400)
self.assertTrue(res["error"])
- self.assertTrue("Connection not allowed" in res["message"])
+ self.assertTrue("automatic_connection_not_allowed" in res["message"])
# (3) add a Service set to allow connection from this Org only (ok)
def test_add_collaborations_automatic_connection_allowed_organisations(self):
diff --git a/server/test/api/test_dynamic_extended_json_encoder.py b/server/test/api/test_dynamic_extended_json_encoder.py
index b5e45ee11..10af7beac 100644
--- a/server/test/api/test_dynamic_extended_json_encoder.py
+++ b/server/test/api/test_dynamic_extended_json_encoder.py
@@ -1,17 +1,17 @@
import time
import uuid
-from datetime import date
from flask import jsonify
from server.test.abstract_test import AbstractTest
+from server.tools import dt_today
class TestDynamicExtendedJSONEncoder(AbstractTest):
def test_encoding(self):
_uuid = uuid.uuid4()
- today = date.today()
+ today = dt_today()
obj = {"1": _uuid, "2": today, "3": "default", "4": (1, 2)}
res = jsonify(obj).json
diff --git a/server/test/api/test_invitation.py b/server/test/api/test_invitation.py
index f0fd3579f..b7c54cd70 100644
--- a/server/test/api/test_invitation.py
+++ b/server/test/api/test_invitation.py
@@ -2,14 +2,16 @@
import time
import uuid
-from server.api.base import STATUS_OPEN
+from server.db.defaults import STATUS_OPEN
from server.db.db import db
from server.db.domain import Invitation, CollaborationMembership, User, Collaboration, Organisation, ServiceAup, \
JoinRequest
from server.test.abstract_test import AbstractTest
-from server.test.seed import invitation_hash_no_way, co_ai_computing_name, invitation_hash_curious, invitation_hash_uva, \
+from server.test.seed import invitation_hash_no_way, co_ai_computing_name, invitation_hash_curious, \
+ invitation_hash_ufra, \
co_research_name, unihard_secret, unihard_name, co_ai_computing_short_name, co_ai_computing_join_request_peter_hash, \
co_ai_computing_uuid, group_ai_researchers_short_name, group_ai_dev_identifier
+from server.tools import dt_now
class TestInvitation(AbstractTest):
@@ -48,7 +50,7 @@ def test_collaboration_expired_invitation(self):
def test_external_collaboration_expired_invitation(self):
invitation = self._get_invitation_curious()
- invitation.expiry_date = datetime.datetime.utcnow() - datetime.timedelta(days=500)
+ invitation.expiry_date = dt_now() - datetime.timedelta(days=500)
invitation.external_identifier = str(uuid.uuid4())
db.session.merge(invitation)
db.session.commit()
@@ -72,7 +74,7 @@ def test_external_collaboration_accepted(self):
def test_accept_with_authorisation_group_invitations(self):
self.login("urn:jane")
- self.put("/api/invitations/accept", body={"hash": invitation_hash_uva}, with_basic_auth=False)
+ self.put("/api/invitations/accept", body={"hash": invitation_hash_ufra}, with_basic_auth=False)
collaboration_membership = CollaborationMembership.query \
.join(CollaborationMembership.user) \
@@ -120,7 +122,7 @@ def test_resend(self):
self.put("/api/invitations/resend", body={"id": invitation_id})
self.assertEqual(1, len(outbox))
mail_msg = outbox[0]
- self.assertListEqual(["curious@ex.org"], mail_msg.recipients)
+ self.assertListEqual(["curious@ex.org"], mail_msg.to)
self.assertTrue(self.app.app_config.base_url + "/invitations/accept/" in mail_msg.html)
def test_resend_bulk(self):
@@ -155,24 +157,28 @@ def test_collaboration_invites_api_identifier(self):
self.assertListEqual(["q@demo.com"], [inv["email"] for inv in res])
def test_collaboration_invites_api_bad_request(self):
- self.put("/api/invitations/v1/collaboration_invites",
- body={
- "invites": ["q@demo.com"]
- },
- headers={"Authorization": f"Bearer {unihard_secret}"},
- response_status_code=400,
- with_basic_auth=False)
+ res = self.put("/api/invitations/v1/collaboration_invites", body={"invites": ["q@demo.com"]},
+ headers={"Authorization": f"Bearer {unihard_secret}"}, response_status_code=400,
+ with_basic_auth=False)
+ self.assertTrue("Exactly one of short_name and collaboration_identifier is required" in res["message"])
def test_collaboration_invites_api_bad_request_2(self):
- self.put("/api/invitations/v1/collaboration_invites",
- body={
- "collaboration_identifier": co_ai_computing_uuid,
- "short_name": co_ai_computing_short_name,
- "invites": ["q@demo.com"]
- },
- headers={"Authorization": f"Bearer {unihard_secret}"},
- response_status_code=400,
- with_basic_auth=False)
+ res = self.put("/api/invitations/v1/collaboration_invites",
+ body={"collaboration_identifier": co_ai_computing_uuid, "short_name": co_ai_computing_short_name,
+ "invites": ["q@demo.com"]}, headers={"Authorization": f"Bearer {unihard_secret}"},
+ response_status_code=400, with_basic_auth=False)
+ self.assertTrue("Exactly one of short_name and collaboration_identifier is required" in res["message"])
+
+ def test_collaboration_invites_api_duplicate_mails(self):
+ mail = "curious@ex.org"
+ res = self.put("/api/invitations/v1/collaboration_invites",
+ body={
+ "short_name": co_ai_computing_short_name,
+ "invites": [mail]
+ },
+ headers={"Authorization": f"Bearer {unihard_secret}"},
+ with_basic_auth=False, response_status_code=400)
+ self.assertTrue(mail in res["message"])
def _do_test_collaboration_invites_api(self):
mail = self.app.mail
@@ -217,7 +223,7 @@ def test_collaboration_invite_wrong_collaboration(self):
def test_collaboration_external_identifier(self):
invitation = self._get_invitation_curious()
- invitation.expiry_date = datetime.datetime.utcnow() - datetime.timedelta(days=500)
+ invitation.expiry_date = dt_now() - datetime.timedelta(days=500)
invitation.external_identifier = str(uuid.uuid4())
db.session.merge(invitation)
db.session.commit()
@@ -277,10 +283,12 @@ def test_external_invitation_invalid_group(self):
self.assertTrue("Invalid group identifier: nope" in res["message"])
def test_accept_with_existing_join_request(self):
- self.assertEqual(1, JoinRequest.query.filter(JoinRequest.hash == co_ai_computing_join_request_peter_hash).count())
+ self.assertEqual(1,
+ JoinRequest.query.filter(JoinRequest.hash == co_ai_computing_join_request_peter_hash).count())
self.login("urn:peter")
self.put("/api/invitations/accept", body={"hash": invitation_hash_curious}, with_basic_auth=False)
- self.assertEqual(0, JoinRequest.query.filter(JoinRequest.hash == co_ai_computing_join_request_peter_hash).count())
+ self.assertEqual(0,
+ JoinRequest.query.filter(JoinRequest.hash == co_ai_computing_join_request_peter_hash).count())
def test_open_invites_api(self):
collaboration = self.find_entity_by_name(Collaboration, co_ai_computing_name)
@@ -289,3 +297,41 @@ def test_open_invites_api(self):
with_basic_auth=False)
self.assertEqual(1, len(res))
self.assertEqual(STATUS_OPEN, res[0]["status"])
+
+ def test_delete_external_invitation(self):
+ res = self.put("/api/invitations/v1/collaboration_invites",
+ body={
+ "short_name": co_ai_computing_short_name,
+ "intended_role": "bogus",
+ "invitation_expiry_date": (int(time.time()) * 1000) + 60 * 60 * 25 * 15,
+ "invites": ["joe@test.com"],
+ "groups": [group_ai_researchers_short_name, group_ai_dev_identifier]
+ },
+ headers={"Authorization": f"Bearer {unihard_secret}"},
+ with_basic_auth=False)
+ invitation_id = res[0]["invitation_id"]
+ invitation = Invitation.query.filter(Invitation.external_identifier == invitation_id).one()
+ self.assertIsNotNone(invitation)
+
+ self.delete(f"/api/invitations/v1/{invitation_id}",
+ headers={"Authorization": f"Bearer {unihard_secret}"},
+ with_basic_auth=False)
+ invitation = Invitation.query.filter(Invitation.external_identifier == invitation_id).first()
+ self.assertIsNone(invitation)
+
+ def test_invitation_exists_by_email(self):
+ invitation = Invitation.query.filter(Invitation.invitee_email == "curious@ex.org").one()
+ collaboration_id = invitation.collaboration_id
+ res = self.post("/api/invitations/exists_email",
+ body={"emails": ["CURIOUS@ex.org"], "collaboration_id": collaboration_id},
+ response_status_code=200)
+ self.assertEqual(["curious@ex.org"], res)
+ res = self.post("/api/invitations/exists_email",
+ body={"emails": ["nope@ex.org"], "collaboration_id": collaboration_id},
+ response_status_code=200)
+ self.assertEqual(0, len(res))
+
+ def test_delete_by_hash(self):
+ self.delete(f"/api/invitations/delete_by_hash/{invitation_hash_no_way}")
+ self.get("/api/invitations/find_by_hash", query_data={"hash": invitation_hash_no_way},
+ response_status_code=404)
diff --git a/server/test/api/test_join_request.py b/server/test/api/test_join_request.py
index f44353f55..f13e13030 100644
--- a/server/test/api/test_join_request.py
+++ b/server/test/api/test_join_request.py
@@ -2,7 +2,7 @@
from sqlalchemy.orm import joinedload
-from server.api.base import STATUS_APPROVED, STATUS_DENIED
+from server.db.defaults import STATUS_DENIED, STATUS_APPROVED
from server.db.db import db
from server.db.domain import JoinRequest, User, Collaboration
from server.test.abstract_test import AbstractTest
@@ -31,7 +31,7 @@ def test_new_join_request(self):
self.assertEqual(1, len(outbox))
mail_msg = outbox[0]
- self.assertListEqual(["boss@example.org"], mail_msg.recipients)
+ self.assertListEqual(["boss@example.org"], mail_msg.to)
self.assertTrue(f"{self.app.app_config.base_url}/collaborations/{collaboration_id}/joinrequests" in mail_msg.html)
def test_new_join_request_already_member(self):
@@ -92,7 +92,7 @@ def test_accept_join_request(self):
self.put("/api/join_requests/accept", body={"hash": join_request_hash})
self.assertEqual(1, len(outbox))
mail_msg = outbox[0]
- self.assertListEqual(["peter@example.org"], mail_msg.recipients)
+ self.assertListEqual(["peter@example.org"], mail_msg.to)
self.assertTrue("accepted" in mail_msg.html)
join_request = db.session.get(JoinRequest, join_request_id)
self.assertEqual(STATUS_APPROVED, join_request.status)
@@ -114,7 +114,7 @@ def test_decline_join_request(self):
"rejection_reason": rejection_reason})
self.assertEqual(1, len(outbox))
mail_msg = outbox[0]
- self.assertListEqual(["peter@example.org"], mail_msg.recipients)
+ self.assertListEqual(["peter@example.org"], mail_msg.to)
self.assertTrue("declined" in mail_msg.html)
self.assertTrue(rejection_reason in mail_msg.html)
diff --git a/server/test/api/test_mock_scim.py b/server/test/api/test_mock_scim.py
index 60e313a57..6fa0abd7f 100644
--- a/server/test/api/test_mock_scim.py
+++ b/server/test/api/test_mock_scim.py
@@ -13,10 +13,13 @@ class TestMockScim(AbstractTest):
# Very lengthy flow test, but we need the ordering right
def test_mock_scim_flow(self):
self.delete("/api/scim_mock/clear")
-
+ cloud_service_id = self.find_entity_by_name(Service, service_cloud_name).id
+ self.put(f"/api/services/reset_scim_bearer_token/{cloud_service_id}",
+ {"scim_bearer_token": "secret"})
+ # Prevent sqlalchemy.orm.exc.DetachedInstanceError
cloud_service = self.find_entity_by_name(Service, service_cloud_name)
- cloud_service_id = cloud_service.id
- headers = {"X-Service": str(cloud_service.id), "Authorization": f"bearer {cloud_service.scim_bearer_token}"}
+
+ headers = {"X-Service": str(cloud_service.id), "Authorization": "bearer secret"}
sarah = self.find_entity_by_name(User, user_sarah_name)
body = create_user_template(sarah)
@@ -116,6 +119,8 @@ def test_mock_scim_flow(self):
def test_mock_scim_authorization(self):
cloud_service_id = self.find_entity_by_name(Service, service_cloud_name).id
+ self.put(f"/api/services/reset_scim_bearer_token/{cloud_service_id}",
+ {"scim_bearer_token": "secret"})
self.post("/api/scim_mock/Users",
body={},
diff --git a/server/test/api/test_organisation.py b/server/test/api/test_organisation.py
index 3b0d58ab2..301a2b91a 100644
--- a/server/test/api/test_organisation.py
+++ b/server/test/api/test_organisation.py
@@ -1,7 +1,8 @@
from server.db.db import db
from server.db.domain import Organisation, OrganisationInvitation, User, JoinRequest
from server.test.abstract_test import AbstractTest, API_AUTH_HEADER
-from server.test.seed import (unihard_name, unifra_name, schac_home_organisation_unihar, schac_home_organisation_example,
+from server.test.seed import (unihard_name, unifra_name, schac_home_organisation_unihar,
+ schac_home_organisation_example,
read_image, unihard_secret, user_jane_name, unihard_short_name, unihard_unit_support_name)
@@ -34,7 +35,7 @@ def test_organisations_all(self):
organisations = self.get("/api/organisations/all",
headers=API_AUTH_HEADER,
with_basic_auth=False)
- self.assertEqual(3, len(organisations))
+ self.assertEqual(4, len(organisations))
organisation = organisations[0]
self.assertEqual(3, organisation["collaborations_count"])
@@ -159,7 +160,7 @@ def test_organisation_crud(self):
with_basic_auth=False)
self.assertIsNotNone(organisation["id"])
self.assertEqual("new_organisation", organisation["name"])
- self.assertEqual(4, Organisation.query.count())
+ self.assertEqual(5, Organisation.query.count())
new_organisation = self.find_entity_by_name(Organisation, "new_organisation")
self.assertEqual(2, len(new_organisation.schac_home_organisations))
@@ -169,7 +170,7 @@ def test_organisation_crud(self):
self.assertEqual("changed", organisation["name"])
self.delete("/api/organisations", primary_key=organisation["id"])
- self.assertEqual(3, Organisation.query.count())
+ self.assertEqual(4, Organisation.query.count())
def test_organisation_update_short_name(self):
self.mark_organisation_service_restricted(unihard_name)
@@ -199,7 +200,7 @@ def test_organisation_update_schac_home(self):
organisation["schac_home_organisations"] = [
{"name": "rug.nl"}, # new
- {"name": orig_sho} # identical to before
+ {"name": orig_sho} # identical to before
]
self.put("/api/organisations", body=organisation)
@@ -262,7 +263,8 @@ def test_organisation_short_name_exists(self):
self.assertEqual(False, res)
def test_organisation_schac_home_exists(self):
- res = self.get("/api/organisations/schac_home_exists", query_data={"schac_home": schac_home_organisation_unihar})
+ res = self.get("/api/organisations/schac_home_exists",
+ query_data={"schac_home": schac_home_organisation_unihar})
self.assertEqual(schac_home_organisation_unihar, res)
uuc_id = self.find_entity_by_name(Organisation, unihard_name).id
@@ -285,12 +287,12 @@ def test_organisation_schac_home_exists(self):
def test_my_organisations_lite_super_user(self):
self.login("urn:john")
res = self.get("/api/organisations/mine_lite")
- self.assertEqual(3, len(res))
+ self.assertEqual(4, len(res))
def test_my_organisations_lite_admin(self):
self.login("urn:mary")
res = self.get("/api/organisations/mine_lite")
- self.assertEqual(1, len(res))
+ self.assertEqual(2, len(res))
def test_my_organisations_lite_no_admin(self):
self.login("urn:james")
@@ -331,7 +333,8 @@ def test_organisation_invites(self):
post_count = OrganisationInvitation.query.count()
self.assertEqual(2, len(outbox))
self.assertTrue(
- f"You have been invited by John Doe to become manager in organisation '{unihard_name}'" in outbox[0].html)
+ f"You have been invited by John Doe to become manager in organisation '{unihard_name}'" in outbox[
+ 0].html)
self.assertEqual(pre_count + 2, post_count)
def test_organisation_invites_with_bogus_intended_role(self):
@@ -350,6 +353,15 @@ def test_organisation_invites_with_bogus_intended_role(self):
self.assertEqual("manager", invitation.intended_role)
self.assertEqual(2, len(invitation.units))
+ def test_organisation_duplicate_invites(self):
+ self.login("urn:john")
+ organisation_id = self.find_entity_by_name(Organisation, unihard_name).id
+ email = "roger@example.org"
+ res = self.put("/api/organisations/invites",
+ body={"organisation_id": organisation_id, "administrators": [email], "message": "Please join"},
+ response_status_code=400)
+ self.assertTrue(email in res["message"])
+
def test_organisation_save_with_invites(self):
pre_count = OrganisationInvitation.query.count()
self.login("urn:john")
diff --git a/server/test/api/test_organisation_invitation.py b/server/test/api/test_organisation_invitation.py
index f0384826e..18c9cec13 100644
--- a/server/test/api/test_organisation_invitation.py
+++ b/server/test/api/test_organisation_invitation.py
@@ -74,7 +74,7 @@ def test_resend(self):
self.put("/api/organisation_invitations/resend", body={"id": invitation_id, "message": "changed"})
self.assertEqual(1, len(outbox))
mail_msg = outbox[0]
- self.assertListEqual(["roger@example.org"], mail_msg.recipients)
+ self.assertListEqual(["roger@example.org"], mail_msg.to)
def test_resend_bulk(self):
identifiers = []
@@ -90,3 +90,19 @@ def test_resend_not_found(self):
def test_delete_not_found(self):
self.delete("/api/organisation_invitations", primary_key="nope", response_status_code=404)
+
+ def test_invitation_exists_by_email(self):
+ inv = OrganisationInvitation.query.filter(OrganisationInvitation.invitee_email == "roger@example.org").one()
+ organisation_id = inv.organisation_id
+ res = self.post("/api/organisation_invitations/exists_email",
+ body={"emails": ["roger@EXAMPLE.ORG"], "organisation_id": organisation_id},
+ response_status_code=200)
+ self.assertEqual(["roger@example.org"], res)
+ res = self.post("/api/organisation_invitations/exists_email",
+ body={"emails": ["nope@ex.org"], "organisation_id": organisation_id},
+ response_status_code=200)
+ self.assertEqual(0, len(res))
+ res = self.post("/api/organisation_invitations/exists_email",
+ body={"emails": ["roger@example.org"], "organisation_id": "9999"},
+ response_status_code=200)
+ self.assertEqual(0, len(res))
diff --git a/server/test/api/test_pam_websso.py b/server/test/api/test_pam_websso.py
index f545ec03f..4a8cdadd9 100644
--- a/server/test/api/test_pam_websso.py
+++ b/server/test/api/test_pam_websso.py
@@ -1,4 +1,4 @@
-from datetime import datetime, timedelta
+from datetime import timedelta
import requests
@@ -7,6 +7,7 @@
from server.test.abstract_test import AbstractTest
from server.test.seed import pam_session_id, service_storage_name, service_storage_token, \
pam_invalid_service_session_id, user_roger_name
+from server.tools import dt_now
class TestPamWebSSO(AbstractTest):
@@ -28,6 +29,8 @@ def test_get_with_pin(self):
self.assertEqual(service_storage_name, res["service"]["name"])
self.assertEqual("1234", res["pin"])
self.assertEqual("SUCCESS", res["validation"]["result"])
+ # Second time is not allowed
+ self.get(f"/pam-weblogin/storage/{pam_session_id}", with_basic_auth=False, response_status_code=403)
def test_get_session_different_user(self):
self.login("urn:sarah")
@@ -94,7 +97,7 @@ def test_start_denied(self):
def test_start_cached_login(self):
self.login_user_2fa("urn:roger")
roger = self.find_entity_by_name(User, user_roger_name)
- roger.pam_last_login_date = datetime.now()
+ roger.pam_last_login_date = dt_now()
db.session.merge(roger)
db.session.commit()
@@ -105,7 +108,7 @@ def test_start_cached_login(self):
with_basic_auth=False,
headers={"Authorization": f"bearer {service_storage_token}"})
- self.assertEqual(res["cached"], True)
+ self.assertTrue(res["cached"])
def test_check_pin_fail(self):
with requests.Session():
@@ -169,7 +172,7 @@ def test_check_pin_wrong_pin(self):
def test_check_pin_time_out(self):
self.login("urn:peter")
pam_sso_session = PamSSOSession.query.filter(PamSSOSession.session_id == pam_session_id).one()
- pam_sso_session.created_at = datetime.utcnow() - timedelta(days=500)
+ pam_sso_session.created_at = dt_now() - timedelta(days=500)
db.session.merge(pam_sso_session)
db.session.commit()
diff --git a/server/test/api/test_plsc.py b/server/test/api/test_plsc.py
index b2b01b64f..2a8d1ce85 100644
--- a/server/test/api/test_plsc.py
+++ b/server/test/api/test_plsc.py
@@ -1,5 +1,6 @@
from base64 import b64encode
+from server.db.domain import User
from server.db.models import flatten
from server.test.abstract_test import AbstractTest
from server.test.seed import user_sarah_name, service_wiki_entity_id, unihard_name, co_ai_computing_name, \
@@ -20,8 +21,19 @@ def test_syncing_fetch(self):
res = self.get("/api/plsc/syncing")
self.assert_sync_result(res)
+ def test_suspended_user(self):
+ sarah = self.find_entity_by_name(User, user_sarah_name)
+ sarah.suspended = True
+ self.save_entity(sarah)
+
+ res = self.get("/api/plsc/syncing")
+ users = res["users"]
+ suspended_user_names = sorted([user["name"] for user in users if user["status"] == "suspended"])
+ self.assertListEqual([user_sarah_name, "user_deletion_warning", "user_gets_deleted"], suspended_user_names)
+ self.assertEqual(14, len([user["name"] for user in users if user["status"] == "active"]))
+
def assert_sync_result(self, res):
- self.assertEqual(3, len(res["organisations"]))
+ self.assertEqual(4, len(res["organisations"]))
logo = res["organisations"][0]["logo"]
self.assertTrue(logo.startswith("http://localhost:8080/api/images/organisations/"))
res_image = self.client.get(logo.replace("http://localhost:8080", ""))
@@ -29,7 +41,7 @@ def assert_sync_result(self, res):
users_ = res["users"]
self.assertEqual(17, len(users_))
sarah = next(u for u in users_ if u["name"] == user_sarah_name)
- self.assertEqual("sarah@uva.org", sarah["email"])
+ self.assertEqual("sarah@uni-franeker.nl", sarah["email"])
self.assertEqual("sarah", sarah["username"])
self.assertEqual("some-lame-key", sarah["ssh_keys"][0])
self.assertEqual(2, len(sarah["user_ip_networks"]))
@@ -42,7 +54,7 @@ def assert_sync_result(self, res):
user_gets_deleted = next(u for u in users_ if u["name"] == "user_gets_deleted")
self.assertIsNotNone(user_gets_deleted["last_login_date"])
services_ = res["services"]
- self.assertEqual(11, len(services_))
+ self.assertEqual(12, len(services_))
wiki = next(s for s in services_ if s["entity_id"] == service_wiki_entity_id)
self.assertEqual(wiki["contact_email"], "help@wiki.com")
self.assertEqual(wiki["name"], "Wiki")
@@ -73,6 +85,11 @@ def assert_sync_result(self, res):
group_membership = ai_researchers["collaboration_memberships"][0]
self.assertIsNotNone(group_membership["user_id"])
+ org_harderwijk = [org for org in res["organisations"] if org["name"] == unihard_name][0]
+ self.assertListEqual(["Research", "Support"], sorted(org_harderwijk["units"]))
+ co_ai_computing = [co for co in org_harderwijk["collaborations"] if co["name"] == co_ai_computing_name][0]
+ self.assertListEqual(["Support"], co_ai_computing["units"])
+
def test_ip_ranges_fetch(self):
res = self.get("/api/plsc/ip_ranges")
self.assertTrue("service_ipranges" in res)
diff --git a/server/test/api/test_scim.py b/server/test/api/test_scim.py
index c089f39ab..c9db4631f 100644
--- a/server/test/api/test_scim.py
+++ b/server/test/api/test_scim.py
@@ -108,6 +108,9 @@ def test_users_filter_not_implemented(self):
@responses.activate
def test_sweep(self):
+ service_id = self.find_entity_by_name(Service, service_network_name).id
+ self.put(f"/api/services/reset_scim_bearer_token/{service_id}",
+ {"scim_bearer_token": "secret"})
with responses.RequestsMock(assert_all_requests_are_fired=True) as rsps:
remote_groups = json.loads(read_file("test/scim/sweep/remote_groups_unchanged.json"))
remote_users = json.loads(read_file("test/scim/sweep/remote_users_unchanged.json"))
@@ -127,26 +130,29 @@ def test_sweep(self):
@responses.activate
def test_sweep_error(self):
+ service_id = self.find_entity_by_name(Service, service_network_name).id
+ self.put(f"/api/services/reset_scim_bearer_token/{service_id}",
+ {"scim_bearer_token": "secret"})
# test error response from remote SCIM server
with responses.RequestsMock(assert_all_requests_are_fired=True) as rsps:
rsps.add(responses.GET, "http://localhost:8080/api/scim_mock/Groups", json={"error": True},
status=400)
res = self.put("/api/scim/v2/sweep", headers={"Authorization": f"bearer {service_network_token}"},
- with_basic_auth=False, response_status_code=400)
+ with_basic_auth=False, response_status_code=400)
self.assertTrue("error" in res)
self.assertTrue("Invalid response from remote SCIM server (got HTTP status 400)" in res["error"])
# test HTTP error from remote SCIM server
with mock.patch("requests.get", side_effect=requests.Timeout('Connection timed out')):
res = self.put("/api/scim/v2/sweep", headers={"Authorization": f"bearer {service_network_token}"},
- with_basic_auth=False, response_status_code=400)
+ with_basic_auth=False, response_status_code=400)
self.assertTrue("error" in res)
self.assertEqual(res["error"], "Could not connect to remote SCIM server (Timeout)")
# test other errors during SCIM sweep
with mock.patch("requests.get", side_effect=Exception("Weird error")):
res = self.put("/api/scim/v2/sweep", headers={"Authorization": f"bearer {service_network_token}"},
- with_basic_auth=False, response_status_code=500)
+ with_basic_auth=False, response_status_code=500)
self.assertTrue("error" in res)
self.assertEqual(res["error"], "Unknown error while connecting to remote SCIM server")
diff --git a/server/test/api/test_service.py b/server/test/api/test_service.py
index 84e76e98f..821f11664 100644
--- a/server/test/api/test_service.py
+++ b/server/test/api/test_service.py
@@ -10,7 +10,7 @@
from server.test.seed import service_mail_name, service_network_entity_id, unihard_name, \
service_network_name, service_scheduler_name, service_wiki_name, service_storage_name, \
service_cloud_name, service_storage_entity_id, service_ssh_name, unifra_name, unihard_secret, \
- user_jane_name, user_roger_name, service_sram_demo_sp
+ user_jane_name, user_roger_name, service_sram_demo_sp, umcpekela_name
class TestService(AbstractTest):
@@ -116,6 +116,7 @@ def test_service_invites(self):
"service_id": service_id,
"administrators": ["new@example.org", "pop@example.org"],
"message": "Please join",
+ "intended_role": "admin",
"expiry_date": int(time.time())
})
post_count = ServiceInvitation.query.count()
@@ -125,6 +126,14 @@ def test_service_invites(self):
self.assertEqual("admin", invitation.intended_role)
self.assertIsNotNone(invitation.expiry_date)
+ def test_service_invites_duplicate_mail(self):
+ self.login("urn:john")
+ service_id = self.find_entity_by_name(Service, service_cloud_name).id
+ mail = "admin@cloud.org"
+ res = self.put("/api/services/invites", body={"service_id": service_id, "administrators": [mail]},
+ response_status_code=400)
+ self.assertTrue(mail in res["message"])
+
def test_service_update(self):
service = self._find_by_name(service_cloud_name)
service["name"] = "changed"
@@ -192,7 +201,7 @@ def test_toggle_access_allowed_for_all_no_automatic_connection_allowed(self):
service = self.find_entity_by_name(Service, service_cloud_name)
self.assertTrue(service.access_allowed_for_all)
self.assertFalse(service.non_member_users_access_allowed)
- self.assertEqual(3, len(service.allowed_organisations))
+ self.assertEqual(4, len(service.allowed_organisations))
def test_toggle_allow_restricted(self):
service = self.find_entity_by_name(Service, service_cloud_name)
@@ -201,10 +210,11 @@ def test_toggle_allow_restricted(self):
self.login("urn:john")
self.put(f"/api/services/toggle_access_property/{service.id}",
body={"allow_restricted_orgs": True},
- with_basic_auth=False)
+ with_basic_auth=False,
+ response_status_code=400)
service = self.find_entity_by_name(Service, service_cloud_name)
- self.assertTrue(service.allow_restricted_orgs)
+ self.assertFalse(service.allow_restricted_orgs)
def test_toggle_non_member_users_access_allowed(self):
service = self.find_entity_by_name(Service, service_cloud_name)
@@ -409,7 +419,7 @@ def test_services_all_include_counts(self):
def test_services_mine(self):
self.login("urn:service_admin")
services = self.get("/api/services/mine", with_basic_auth=False)
- self.assertEqual(4, len(services))
+ self.assertEqual(5, len(services))
service_storage = self.find_by_name(services, service_storage_name)
self.assertEqual(0, service_storage["organisations_count"])
@@ -557,3 +567,42 @@ def test_hint_short_name(self):
def test_empty_hint_short_name(self):
res = self.post("/api/services/hint_short_name", body={"name": "*&^%$$@"}, response_status_code=200)
self.assertEqual("short_name", res["short_name"])
+
+ def test_toggle_access_allowed_for_services_restricted(self):
+ service = self.find_entity_by_name(Service, service_cloud_name)
+ self.assertFalse(service.access_allowed_for_all)
+ self.assertFalse(service.allow_restricted_orgs)
+ self.assertEqual(2, len(service.allowed_organisations))
+
+ service.automatic_connection_allowed = False
+ self.save_entity(service)
+
+ organisation = self.find_entity_by_name(Organisation, umcpekela_name)
+ organisation.services_restricted = True
+ self.save_entity(organisation)
+
+ self.login("urn:james")
+ self.put(f"/api/services/toggle_access_property/{service.id}",
+ body={"access_allowed_for_all": True},
+ with_basic_auth=False)
+
+ service = self.find_entity_by_name(Service, service_cloud_name)
+ self.assertTrue(service.access_allowed_for_all)
+ self.assertFalse(service.non_member_users_access_allowed)
+ self.assertEqual(3, len(service.allowed_organisations))
+
+ def test_service_update_scim_url(self):
+ service = self._find_by_name(service_cloud_name)
+ self.put(f"/api/services/reset_scim_bearer_token/{service['id']}",
+ {"scim_bearer_token": "secret"})
+ rows = db.session.execute(text(f"SELECT scim_bearer_token FROM services where id = {service['id']}"))
+ scim_bearer_token = next(rows)[0]
+ self.assertIsNotNone(scim_bearer_token)
+
+ service["scim_url"] = "https://changed.com"
+
+ self.login("urn:john")
+ service = self.put("/api/services", body=service, with_basic_auth=False)
+ rows = db.session.execute(text(f"SELECT scim_bearer_token FROM services where id = {service['id']}"))
+ new_scim_bearer_token = next(rows)[0]
+ self.assertNotEqual(scim_bearer_token, new_scim_bearer_token)
diff --git a/server/test/api/test_service_connection_request.py b/server/test/api/test_service_connection_request.py
index 76f834011..34bf417fa 100644
--- a/server/test/api/test_service_connection_request.py
+++ b/server/test/api/test_service_connection_request.py
@@ -4,7 +4,8 @@
from server.db.domain import Collaboration, Service, ServiceConnectionRequest
from server.test.abstract_test import AbstractTest
from server.test.seed import service_connection_request_ssh_hash, co_research_name, service_wiki_name, \
- co_ai_computing_name, service_ssh_name, service_storage_name, service_cloud_name
+ co_ai_computing_name, service_ssh_name, service_storage_name, service_cloud_name, \
+ co_robotics_disabled_join_request_name, service_connection_request_storage_hash
class TestServiceConnectionRequest(AbstractTest):
@@ -45,7 +46,7 @@ def test_service_connection_request(self):
mail_msg = outbox[0]
self.assertEqual("Request for new service Wiki connection to collaboration AI computing (local)",
mail_msg.subject)
- self.assertEqual(["service_admin@ucc.org"], mail_msg.recipients)
+ self.assertEqual(["service_admin@ucc.org"], mail_msg.to)
def test_service_connection_request_with_no_admins(self):
collaboration = self.find_entity_by_name(Collaboration, co_ai_computing_name)
@@ -64,7 +65,7 @@ def test_service_connection_request_with_no_admins(self):
with self.app.mail.record_messages() as outbox:
self.post("/api/service_connection_requests", body=data, with_basic_auth=False)
mail_msg = outbox[0]
- self.assertEqual(["john@example.org"], mail_msg.recipients)
+ self.assertEqual(["john@example.org"], mail_msg.to)
self.assertEqual(0, len(mail_msg.cc))
def test_service_connection_request_by_admin_email_admin(self):
@@ -80,7 +81,7 @@ def test_service_connection_request_by_admin_email_admin(self):
with self.app.mail.record_messages() as outbox:
self.post("/api/service_connection_requests", body=data, with_basic_auth=False)
mail_msg = outbox[0]
- self.assertEqual(["james@example.org"], mail_msg.recipients)
+ self.assertListEqual(["betty@uuc.org", "james@example.org"], sorted(mail_msg.to))
def test_service_connection_request_by_member(self):
collaboration = self.find_entity_by_name(Collaboration, co_ai_computing_name)
@@ -182,7 +183,7 @@ def test_approve_service_connection_request_with_no_email_requester(self):
self.put("/api/service_connection_requests/approve", body={"id": request_id})
mail_msg = outbox[0]
- self.assertListEqual(["sram-beheer@surf.nl"], mail_msg.recipients)
+ self.assertListEqual(["sram-beheer@surf.nl"], mail_msg.to)
def test_deny_service_connection_request(self):
pre_services_count = len(self.find_entity_by_name(Collaboration, co_research_name).services)
@@ -192,12 +193,13 @@ def test_deny_service_connection_request(self):
# CO admin not allowed to deny
self.login("urn:sarah")
self.put("/api/service_connection_requests/deny",
- body={"id": request_id},
+ body={"id": request_id, "rejection_reason": "Because..."},
response_status_code=403)
# Service admin is allowed to deny
self.login("urn:betty")
- self.put("/api/service_connection_requests/deny", body={"id": request_id})
+ self.put("/api/service_connection_requests/deny",
+ body={"id": request_id, "rejection_reason": "Because..."})
post_services_count = len(self.find_entity_by_name(Collaboration, co_research_name).services)
self.assertEqual(pre_services_count, post_services_count)
@@ -235,3 +237,27 @@ def test_service_approve_with_service_groups(self):
groups = collaboration.groups
group = list(filter(lambda group: group.global_urn == "uniharderwijk:ai_computing:wiki-wiki2", groups))[0]
self.assertEqual(1, len(group.invitations))
+
+ def test_delete_service_request_connection_by_service_manager(self):
+ request = ServiceConnectionRequest.query \
+ .filter(ServiceConnectionRequest.hash == service_connection_request_storage_hash).one()
+
+ self.login("urn:service_admin")
+ self.delete("/api/service_connection_requests", request.id, with_basic_auth=False)
+
+ def test_service_connection_request_pending_organisation(self):
+ collaboration = self.find_entity_by_name(Collaboration, co_robotics_disabled_join_request_name)
+ service = self.find_entity_by_name(Service, service_ssh_name)
+
+ self.login("urn:jane")
+ data = {
+ "collaboration_id": collaboration.id,
+ "service_id": service.id,
+ "message": "Pretty please"
+ }
+ with self.app.mail.record_messages() as outbox:
+ self.post("/api/service_connection_requests", body=data, with_basic_auth=False)
+
+ mail_msg = outbox[0]
+ # Admin of the organisation
+ self.assertEqual(["jdoe@example"], mail_msg.to)
diff --git a/server/test/api/test_service_group.py b/server/test/api/test_service_group.py
index 3aa59f84c..557b65b1a 100644
--- a/server/test/api/test_service_group.py
+++ b/server/test/api/test_service_group.py
@@ -2,8 +2,7 @@
from server.db.domain import Service, ServiceGroup, Collaboration
from server.test.abstract_test import AbstractTest
-from server.test.seed import service_mail_name, service_group_mail_name, service_cloud_name, service_group_wiki_name1, \
- co_research_name
+from server.test.seed import service_mail_name, service_group_mail_name, service_cloud_name, co_research_name
class TestServiceGroup(AbstractTest):
@@ -59,7 +58,7 @@ def test_save_service_group(self):
service_group = self.find_entity_by_name(ServiceGroup, service_group_name)
groups = service_group.groups
self.assertEqual(1, len(groups))
- self.assertEqual("uva:research:cloud-new_auth_service", groups[0].global_urn)
+ self.assertEqual("ufra:research:cloud-new_auth_service", groups[0].global_urn)
collaboration = self.find_entity_by_name(Collaboration, co_research_name)
group = list(filter(lambda g: g.short_name == "cloud-new_auth_service", collaboration.groups))[0]
@@ -73,20 +72,24 @@ def test_update_service_group(self):
self.assertEqual(0, len(service_group.groups[0].collaboration_memberships))
service_group_json = jsonify(service_group).json
+ service_group_json["description"] = "this is a new description"
service_group_json["short_name"] = "new_short_name"
service_group_json["auto_provision_members"] = True
self.put("/api/servicegroups/", body=service_group_json, with_basic_auth=False)
service_group = self.find_entity_by_name(ServiceGroup, service_group_mail_name)
+ self.assertEqual("this is a new description", service_group.description)
self.assertEqual("new_short_name", service_group.short_name)
groups = service_group.groups
self.assertEqual(1, len(groups))
- self.assertEqual("uva:research:mail-new_short_name", groups[0].global_urn)
+ self.assertEqual("this is a new description", groups[0].description)
+ self.assertEqual("ufra:research:mail-new_short_name", groups[0].global_urn)
+ self.assertTrue(groups[0].auto_provision_members)
self.assertEqual(4, len(groups[0].collaboration_memberships))
def test_delete_service_group(self):
self.login("urn:service_admin")
- service_group_id = self.find_entity_by_name(ServiceGroup, service_group_wiki_name1).id
+ service_group_id = self.find_entity_by_name(ServiceGroup, service_group_mail_name).id
self.delete("/api/servicegroups", primary_key=service_group_id, with_basic_auth=False)
self.delete("/api/servicegroups", primary_key=service_group_id, response_status_code=404)
diff --git a/server/test/api/test_service_invitation.py b/server/test/api/test_service_invitation.py
index 65ef17b69..cbbad964d 100644
--- a/server/test/api/test_service_invitation.py
+++ b/server/test/api/test_service_invitation.py
@@ -65,7 +65,7 @@ def test_resend(self):
self.put("/api/service_invitations/resend", body={"id": invitation_id, "message": "changed"})
self.assertEqual(1, len(outbox))
mail_msg = outbox[0]
- self.assertListEqual(["admin@cloud.org"], mail_msg.recipients)
+ self.assertListEqual(["admin@cloud.org"], mail_msg.to)
def test_resend_bulk(self):
identifiers = []
@@ -81,3 +81,14 @@ def test_resend_not_found(self):
def test_delete_not_found(self):
self.delete("/api/service_invitations", primary_key="nope", response_status_code=404)
+
+ def test_invitation_exists_by_email(self):
+ inv = ServiceInvitation.query.filter(ServiceInvitation.invitee_email == "admin@cloud.org").one()
+ service_id = inv.service_id
+ res = self.post("/api/service_invitations/exists_email",
+ body={"emails": ["ADMIN@CLOUD.ORG", "nice@nope.com"], "service_id": service_id},
+ response_status_code=200)
+ self.assertEqual(["admin@cloud.org"], res)
+ res = self.post("/api/service_invitations/exists_email",
+ body={"emails": ["nope@ex.org"], "service_id": service_id}, response_status_code=200)
+ self.assertEqual(0, len(res))
diff --git a/server/test/api/test_service_membership.py b/server/test/api/test_service_membership.py
index f11111709..ed8f46950 100644
--- a/server/test/api/test_service_membership.py
+++ b/server/test/api/test_service_membership.py
@@ -1,6 +1,6 @@
from server.db.domain import Service, User, ServiceMembership
from server.test.abstract_test import AbstractTest
-from server.test.seed import service_cloud_name, user_james_name
+from server.test.seed import service_cloud_name, user_james_name, service_wiki_name
class TestServiceMembership(AbstractTest):
@@ -10,13 +10,13 @@ def test_delete_service_membership(self):
service = self.find_entity_by_name(Service, service_cloud_name)
user = self.find_entity_by_name(User, user_james_name)
- self.assertEqual(1, len(service.service_memberships))
+ self.assertEqual(2, len(service.service_memberships))
self.login("urn:james")
self.delete("/api/service_memberships", primary_key=f"{service.id}/{user.id}", )
service = self.find_entity_by_name(Service, service_cloud_name)
- self.assertEqual(0, len(service.service_memberships))
+ self.assertEqual(1, len(service.service_memberships))
def test_delete_service_membership_not_allowed(self):
service = self.find_entity_by_name(Service, service_cloud_name)
@@ -41,3 +41,23 @@ def test_create_service_membership(self):
.filter(ServiceMembership.service_id == service_id) \
.one()
self.assertEqual("admin", service_membership.role)
+
+ def test_update_service_membership(self):
+ self.login("urn:john")
+ service_id = self.find_entity_by_name(Service, service_wiki_name).id
+ user_id = User.query.filter(User.uid == "urn:service_admin").one().id
+
+ res = self.put("/api/service_memberships",
+ body={"serviceId": service_id, "userId": user_id, "role": "manager"},
+ with_basic_auth=False)
+ self.assertEqual(service_id, res["service_id"])
+ self.assertEqual(user_id, res["user_id"])
+ self.assertEqual("manager", res["role"])
+
+ service_membership = ServiceMembership \
+ .query \
+ .join(ServiceMembership.user) \
+ .filter(User.uid == "urn:service_admin") \
+ .filter(ServiceMembership.service_id == service_id) \
+ .one()
+ self.assertEqual("manager", service_membership.role)
diff --git a/server/test/api/test_service_request.py b/server/test/api/test_service_request.py
index 536bdba59..dc01d880a 100644
--- a/server/test/api/test_service_request.py
+++ b/server/test/api/test_service_request.py
@@ -1,7 +1,7 @@
from server.db.db import db
from server.db.domain import ServiceRequest, ServiceMembership, Service
from server.test.abstract_test import AbstractTest
-from server.test.seed import service_request_gpt_name
+from server.test.seed import service_request_gpt_name, service_request_gpt_uuid4
class TestServiceRequest(AbstractTest):
@@ -40,7 +40,9 @@ def test_request_service_approve(self):
"comment": "pretty please",
"providing_organisation": "cloudy",
"entity_id": "https://entity_id.com",
- "privacy_policy": "https://privacy_policy.org"
+ "privacy_policy": "https://privacy_policy.org",
+ "logo": f"https://sbs/api/images/service_requests/{service_request_gpt_uuid4}",
+ "id": "invalid_id"
}
with self.app.mail.record_messages() as outbox:
self.login("urn:john")
diff --git a/server/test/api/test_service_token.py b/server/test/api/test_service_token.py
index 957608bf8..4ab4137cb 100644
--- a/server/test/api/test_service_token.py
+++ b/server/test/api/test_service_token.py
@@ -1,7 +1,7 @@
from server.db.defaults import SERVICE_TOKEN_SCIM, SERVICE_TOKEN_INTROSPECTION, SERVICE_TOKEN_PAM
from server.db.domain import ServiceToken, Service
from server.test.abstract_test import AbstractTest
-from server.test.seed import service_network_name, service_mail_name
+from server.test.seed import service_network_name, service_mail_name, service_storage_name
class TestServiceToken(AbstractTest):
@@ -32,6 +32,23 @@ def test_service_token_flow(self):
post_count = ServiceToken.query.count()
self.assertEqual(pre_count, post_count)
+ def test_service_token_flow_autoenable(self):
+ secret = self.get("/api/service_tokens")["value"]
+
+ service = self.find_entity_by_name(Service, service_storage_name)
+ self.assertFalse(service.token_enabled)
+ self.assertEqual(0, service.token_validity_days)
+
+ self.login("urn:john")
+ body = {"service_id": service.id, "hashed_token": secret, "description": "Test",
+ "token_type": SERVICE_TOKEN_INTROSPECTION}
+ self.post("/api/service_tokens", body=body, with_basic_auth=False)
+
+ # service should have been updated
+ service = self.find_entity_by_name(Service, service_storage_name)
+ self.assertTrue(service.token_enabled)
+ self.assertEqual(1, service.token_validity_days)
+
def test_service_token_tampering(self):
service = self.find_entity_by_name(Service, service_network_name)
self.login("urn:john")
diff --git a/server/test/api/test_system.py b/server/test/api/test_system.py
index 274b598fc..ff1cb3e1c 100644
--- a/server/test/api/test_system.py
+++ b/server/test/api/test_system.py
@@ -1,9 +1,9 @@
from flask import current_app
from sqlalchemy import text
-from server.api.base import STATUS_DENIED, STATUS_APPROVED
from server.cron.schedule import start_scheduling
from server.db.db import db
+from server.db.defaults import STATUS_DENIED, STATUS_APPROVED
from server.db.domain import User
from server.test.abstract_test import AbstractTest
from server.test.seed import unihard_invitation_hash
@@ -21,8 +21,14 @@ def test_schedule(self):
def test_db_stats(self):
res = self.get("/api/system/db_stats")
- self.assertDictEqual({"count": 17, "name": "users"}, res[0])
- self.assertDictEqual({"count": 14, "name": "organisations_services"}, res[2])
+ self.assertEqual(45, len(res))
+ # convert list to proper dict:
+ stats = {r["name"]: r["count"] for r in res}
+ self.assertEqual(len(res), len(stats.keys()))
+ self.assertEqual(17, stats["users"])
+ self.assertEqual(14, stats["organisations_services"])
+ self.assertEqual(2, stats["units"])
+ self.assertEqual(17, stats["aups"])
def test_db_seed(self):
self.get("/api/system/seed", response_status_code=201)
@@ -70,7 +76,7 @@ def test_scheduled_jobs(self):
start_scheduling(self.app)
jobs = self.get("/api/system/scheduled_jobs")
- self.assertEqual(8, len(jobs))
+ self.assertEqual(10, len(jobs))
def test_scheduled_jobs_async(self):
try:
@@ -123,7 +129,7 @@ def test_db_clean_slate_forbidden(self):
def test_validations(self):
res = self.get("/api/system/validations", response_status_code=200)
self.assertEqual(2, len(res["organisation_invitations"]))
- self.assertEqual(5, len(res['services']))
+ self.assertEqual(1, len(res['services']))
self.assertEqual(1, len(res["organisations"]))
def test_composition(self):
@@ -138,3 +144,21 @@ def test_composition_forbidden(self):
def test_statistics(self):
res = self.get("/api/system/statistics")
self.assertEqual(8, len(res))
+
+ def test_parse_metadata(self):
+ res = self.get("/api/system/parse_metadata")
+ self.assertIn("entity_ids", res)
+ self.assertEqual(2, len(res["entity_ids"]))
+ self.assertIn("schac_home_organizations", res)
+ self.assertEqual(4, len(res["schac_home_organizations"]))
+
+ def test_sweep(self):
+ res = self.get("/api/system/sweep")
+ self.assertEqual(1, len(res))
+ self.assertIn("services", res)
+ self.assertEqual(1, len(res["services"]))
+ self.assertEqual("Network Services", res["services"][0]["name"])
+
+ def test_open_requests(self):
+ res = self.get("/api/system/open_requests")
+ self.assertEqual(6, len(res))
diff --git a/server/test/api/test_user.py b/server/test/api/test_user.py
index 2d6e2d894..511ae7d0a 100644
--- a/server/test/api/test_user.py
+++ b/server/test/api/test_user.py
@@ -10,10 +10,11 @@
from server.auth.security import CSRF_TOKEN
from server.db.db import db
from server.db.domain import Organisation, Collaboration, User, Aup
+from server.tools import dt_now, read_file
+
from server.test.abstract_test import AbstractTest
from server.test.seed import (unihard_name, co_ai_computing_name, user_roger_name, user_john_name, user_james_name,
co_research_name, co_ai_computing_uuid, user_sarah_name)
-from server.tools import read_file
class TestUser(AbstractTest):
@@ -103,7 +104,7 @@ def do_test_activate(self, login_urn, object_dict):
user = User.query.filter(User.name == "user_deletion_warning").one()
self.assertEqual(False, user.suspended)
retention = current_app.app_config.retention
- retention_date = datetime.datetime.now() - datetime.timedelta(days=retention.allowed_inactive_period_days + 1)
+ retention_date = dt_now() - datetime.timedelta(days=retention.allowed_inactive_period_days + 1)
self.assertTrue(user.last_login_date > retention_date)
def test_search(self):
@@ -128,7 +129,7 @@ def test_search(self):
res = self.get("/api/users/search", query_data={"q": "*",
"collaboration_admins": True})
- self.assertEqual(2, len(res))
+ self.assertEqual(3, len(res))
res = self.get("/api/users/search", query_data={"q": "*",
"organisation_admins": True})
@@ -149,7 +150,7 @@ def test_other(self):
res = self.get("/api/users/other", query_data={"uid": "urn:mary"})
self.assertEqual("Mary Doe", res["name"])
self.assertEqual(0, len(res["collaboration_memberships"]))
- self.assertEqual(1, len(res["organisation_memberships"]))
+ self.assertEqual(2, len(res["organisation_memberships"]))
self.assertEqual("Research", res["organisation_memberships"][0]["organisation"]["category"])
def test_find_by_id(self):
@@ -300,24 +301,19 @@ def test_delete_other(self):
self.assertEqual(0, count)
def test_error(self):
- self.post("/api/users/error", body={"error": "403"}, with_basic_auth=False, response_status_code=401)
+ self.post("/api/users/error", body={"status": 403}, with_basic_auth=False, response_status_code=401)
- def test_csrf(self):
- try:
- del os.environ["TESTING"]
- self.login("urn:john")
- self.post("/api/organisations",
- body={"name": "new_organisation",
- "schac_home_organisations": [],
- "short_name": "https://ti1"},
- response_status_code=401,
- with_basic_auth=False)
- finally:
- os.environ["TESTING"] = "1"
+ self.post("/api/users/error", body={"status": 403}, with_basic_auth=True)
+
+ self.login("urn:betty")
+ res = self.post("/api/users/error", body={"status": 429}, with_basic_auth=False)
+ self.assertDictEqual({}, res)
+
+ res = self.post("/api/users/error", body={"status": 500}, with_basic_auth=False)
+ self.assertDictEqual({}, res)
def test_error_mail(self):
try:
- del os.environ["TESTING"]
mail = self.app.mail
with mail.record_messages() as outbox:
self.app.app_config.mail.send_js_exceptions = True
@@ -332,13 +328,12 @@ def test_error_mail(self):
self.assertTrue("weird" in mail_msg.html)
self.assertTrue("An error occurred in local" in mail_msg.html)
finally:
- os.environ["TESTING"] = "1"
self.app.app_config.mail.send_js_exceptions = False
def test_update_date_bug(self):
roger = self.find_entity_by_name(User, user_roger_name)
roger_id = roger.id
- now = datetime.datetime.now()
+ now = dt_now()
roger.last_login_date = now
roger.last_accessed_date = now
@@ -490,7 +485,8 @@ def test_update_with_user_ip_networks(self):
self.assertEqual(2, len(sarah.user_ip_networks))
def test_login_with_ssid_required(self):
- self.mark_user_ssid_required(name=user_sarah_name, home_organisation_uid="admin", schac_home_organisation="ssid.org")
+ self.mark_user_ssid_required(name=user_sarah_name, home_organisation_uid="admin",
+ schac_home_organisation="ssid.org")
self.login("urn:sarah", schac_home_organisation="ssid.org")
@@ -509,7 +505,8 @@ def test_login_with_ssid_required_missing_attributes(self):
self.assertFalse(user["second_factor_confirmed"])
def test_acs(self):
- self.mark_user_ssid_required(name=user_sarah_name, home_organisation_uid="admin", schac_home_organisation="ssid.org")
+ self.mark_user_ssid_required(name=user_sarah_name, home_organisation_uid="admin",
+ schac_home_organisation="ssid.org")
self.login("urn:sarah", schac_home_organisation="ssid.org")
# Commented out by oharsta because of Fatal Python error: Segmentation fault
@@ -542,7 +539,8 @@ def test_acs_error_no_user(self):
# self.assertEqual(self.app.app_config.base_url + path, res.location)
def test_acs_error_saml_error(self):
- self.mark_user_ssid_required(name=user_sarah_name, home_organisation_uid="admin", schac_home_organisation="ssid.org")
+ self.mark_user_ssid_required(name=user_sarah_name, home_organisation_uid="admin",
+ schac_home_organisation="ssid.org")
self.login("urn:sarah", schac_home_organisation="ssid.org")
# Commented out by oharsta because of Fatal Python error: Segmentation fault
@@ -568,7 +566,7 @@ def test_invalid_user_login(self):
self.assertTrue("http://localhost:3000/missing-attributes" in res.headers.get('Location'))
self.assertEqual(1, len(outbox))
mail_msg = outbox[0]
- self.assertListEqual(["sram-support@surf.nl"], mail_msg.recipients)
+ self.assertListEqual(["sram-support@surf.nl"], mail_msg.to)
self.assertTrue("subby" in mail_msg.html)
finally:
@@ -584,7 +582,7 @@ def test_invalid_user_login_name(self):
self.assertTrue("http://localhost:3000/missing-attributes" in res.headers.get('Location'))
self.assertEqual(1, len(outbox))
mail_msg = outbox[0]
- self.assertListEqual(["sram-support@surf.nl"], mail_msg.recipients)
+ self.assertListEqual(["sram-support@surf.nl"], mail_msg.to)
self.assertTrue("subby" in mail_msg.html)
self.assertTrue("jdoe" in mail_msg.html)
diff --git a/server/test/api/test_user_saml.py b/server/test/api/test_user_saml.py
index 1bc78959e..dbed1f05e 100644
--- a/server/test/api/test_user_saml.py
+++ b/server/test/api/test_user_saml.py
@@ -110,7 +110,7 @@ def test_proxy_authz_no_aup(self):
network_service = Service.query.filter(Service.entity_id == service_network_entity_id).one()
parameters = urlencode({"service_id": network_service.uuid4, "service_name": network_service.name})
- self.assertEqual(res["status"]["redirect_url"], f"{self.app.app_config.base_url}/service-aup?{parameters}")
+ self.assertEqual(f"{self.app.app_config.base_url}/service-aup?{parameters}", res["status"]["redirect_url"], )
def test_proxy_authz_no_user(self):
res = self.post("/api/users/proxy_authz", body={"user_id": "urn:nope", "service_id": service_mail_entity_id,
@@ -184,7 +184,7 @@ def test_proxy_authz_mfa_sbs_totp_sso(self):
"homeorganization": "example.com",
"user_email": "sarah@ex.com", "user_name": "sarah p"})
status_ = res["status"]
- self.assertEqual(status_["result"], "authorized")
+ self.assertEqual("authorized", status_["result"])
sarah = self.find_entity_by_name(User, user_sarah_name)
self.assertFalse(sarah.ssid_required)
@@ -215,7 +215,7 @@ def test_proxy_authz_mfa_sbs_ssid_sso(self):
"homeorganization": "ssid.org",
"user_email": "sarah@ex.com", "user_name": "sarah p"})
sarah = self.find_entity_by_name(User, user_sarah_name)
- self.assertEqual(res["status"]["result"], "authorized")
+ self.assertEqual("authorized", res["status"]["result"])
self.assertFalse(sarah.ssid_required)
def test_proxy_authz_mfa_sbs_idp(self):
@@ -256,7 +256,7 @@ def test_proxy_authz_mfa_service_totp_sso(self):
"homeorganization": "example.com",
"user_email": "sarah@ex.com", "user_name": "sarah p"})
sarah = self.find_entity_by_name(User, user_sarah_name)
- self.assertEqual(res["status"]["result"], "authorized")
+ self.assertEqual("authorized", res["status"]["result"], )
self.assertFalse(sarah.ssid_required)
def test_proxy_authz_mfa_service_ssid(self):
@@ -291,7 +291,7 @@ def test_proxy_authz_mfa_service_ssid_sso(self):
"homeorganization": "ssid.org"})
sarah = self.find_entity_by_name(User, user_sarah_name)
- self.assertEqual(res["status"]["result"], "authorized")
+ self.assertEqual("authorized", res["status"]["result"])
self.assertFalse(sarah.ssid_required)
def test_proxy_authz_mfa_service_idp(self):
diff --git a/server/test/api/test_user_token.py b/server/test/api/test_user_token.py
index 1e59f464d..346ad8f03 100644
--- a/server/test/api/test_user_token.py
+++ b/server/test/api/test_user_token.py
@@ -5,6 +5,7 @@
from server.test.abstract_test import AbstractTest
from server.test.seed import (user_sarah_name, service_wiki_name, service_mail_name, service_cloud_name,
user_sarah_user_token_network, user_john_name, service_network_name)
+from server.tools import dt_now
class TestUserToken(AbstractTest):
@@ -101,12 +102,12 @@ def test_create_user_token_service_not_token_enabled(self):
self.post("/api/user_tokens", body=user_token, response_status_code=403)
def test_create_user_token_service_not_allowed(self):
- betty_id = self.find_entity_by_name(User, "betty").id
- self.login("urn:betty")
+ mary_id = self.find_entity_by_name(User, "Mary Doe").id
+ self.login("urn:mary")
hashed_token = self._get_token()
cloud = self.find_entity_by_name(Service, service_cloud_name)
- user_token = {"name": "token", "hashed_token": hashed_token, "user_id": betty_id, "service_id": cloud.id}
+ user_token = {"name": "token", "hashed_token": hashed_token, "user_id": mary_id, "service_id": cloud.id}
self.post("/api/user_tokens", body=user_token, response_status_code=403)
def test_delete_user_token(self):
@@ -130,6 +131,6 @@ def test_renew_lease(self):
user_tokens_updated = self.get("/api/user_tokens")
created_at = int(user_tokens_updated[0]["created_at"])
- one_day_ago = int((datetime.datetime.utcnow() - datetime.timedelta(days=1)).timestamp())
+ one_day_ago = int((dt_now() - datetime.timedelta(days=1)).timestamp())
self.assertTrue(created_at > one_day_ago)
self.assertIsNone(user_tokens[0].get("hashed_token"))
diff --git a/server/test/auth/test_mfa.py b/server/test/auth/test_mfa.py
index c7bc832c6..184b96f3f 100644
--- a/server/test/auth/test_mfa.py
+++ b/server/test/auth/test_mfa.py
@@ -35,7 +35,7 @@ def test_eligible_users_to_reset_token_coll_members(self):
user = User.query.filter(User.uid == "urn:roger").one()
res = eligible_users_to_reset_token(user)
self.assertEqual(1, len(res))
- self.assertEqual("sarah@uva.org", res[0]["email"])
+ self.assertEqual("sarah@uni-franeker.nl", res[0]["email"])
self.assertEqual("Research", res[0]["unit"])
def test_eligible_users_to_reset_token_coll_org_members(self):
diff --git a/server/test/auth/test_secrets.py b/server/test/auth/test_secrets.py
index 32ea119c3..9ac169a9b 100644
--- a/server/test/auth/test_secrets.py
+++ b/server/test/auth/test_secrets.py
@@ -1,6 +1,9 @@
+import base64
+import os
from unittest import TestCase
-from server.auth.secrets import secure_hash, generate_token, generate_ldap_password_with_hash
+from server.auth.secrets import secure_hash, generate_token, generate_ldap_password_with_hash, decrypt_secret, \
+ encrypt_secret
class TestSecret(TestCase):
@@ -14,3 +17,17 @@ def test_secure_hash(self):
def test_ldap_password(self):
_, password = generate_ldap_password_with_hash()
self.assertEqual(32, len(password))
+
+ def test_encrypt_decrypt_secret(self):
+ encryption_key = base64.b64encode(os.urandom(256 // 8)).decode()
+ context = {
+ "database_name": "sbs_test",
+ "table_name": "services",
+ "identifier": 999,
+ "scim_url": "https://scim.url"
+ }
+ plain_secret = "https://top_secret.com?query=params"
+ encrypted_value = encrypt_secret(encryption_key, plain_secret, context)
+
+ decrypted_secret = decrypt_secret(encryption_key, encrypted_value, context)
+ self.assertEqual(plain_secret, decrypted_secret)
diff --git a/server/test/auth/test_user_claims.py b/server/test/auth/test_user_claims.py
index cce3ec591..6ac7ce831 100644
--- a/server/test/auth/test_user_claims.py
+++ b/server/test/auth/test_user_claims.py
@@ -4,7 +4,7 @@
from munch import munchify
-from server.auth.user_claims import add_user_claims, generate_unique_username, user_memberships
+from server.auth.user_claims import add_user_claims, generate_unique_username, user_memberships, valid_user_attributes
from server.db.db import db
from server.db.domain import User, UserNameHistory
from server.test.abstract_test import AbstractTest
@@ -130,9 +130,17 @@ def test_cleared_attributes(self):
}, "urn:john", user)
self.assertEqual(1, len(outbox))
mail_msg = outbox[0]
- self.assertListEqual(["sram-support@surf.nl"], mail_msg.recipients)
+ self.assertListEqual(["sram-support@surf.nl"], mail_msg.to)
self.assertTrue("scoped_affiliation" in mail_msg.html)
self.assertTrue("email" in mail_msg.html)
finally:
os.environ["TESTING"] = "1"
+
+ def test_valid_user_attributes(self):
+ attributes = {
+ "given_name": " John ",
+ "family_name": " Doe "
+ }
+ self.assertFalse(valid_user_attributes(attributes))
+ self.assertEqual("John Doe", attributes.get("name"))
diff --git a/server/test/conftest.py b/server/test/conftest.py
new file mode 100644
index 000000000..e05de9680
--- /dev/null
+++ b/server/test/conftest.py
@@ -0,0 +1,45 @@
+# make sure we monkey_patch right at the start, before other imports
+# see https://github.com/miguelgrinberg/Flask-SocketIO/issues/806
+# and https://github.com/eventlet/eventlet/issues/896
+
+import os
+import re
+
+import sqlalchemy
+import pytest
+import yaml
+from munch import munchify
+
+from server.db.db import db_migrations
+from server.tools import read_file
+
+
+def pytest_sessionstart():
+ import eventlet
+
+ eventlet.monkey_patch(thread=False)
+
+
+@pytest.fixture(autouse=True, scope='session')
+def use_random_db(request, worker_id):
+ if worker_id == "master":
+ return
+
+ # make sure we use a separate DB for each worker
+ # and make sure the database is created and migrated
+
+ config_file = os.environ.get("CONFIG", "config/test_config.yml")
+ config = munchify(yaml.load(read_file(config_file), Loader=yaml.FullLoader))
+ database_uri_root = re.sub(r"/sbs_test\b", "/", config.database.uri)
+ database_uri = re.sub(r"/sbs_test\b", f"/sbs_{worker_id}", config.database.uri)
+
+ engine = sqlalchemy.create_engine(database_uri_root)
+ with engine.connect() as conn:
+ conn.execute(sqlalchemy.text(f"CREATE DATABASE IF NOT EXISTS sbs_{worker_id}"))
+
+ # not sure why, but this is not picked up from AbstractTest.setUpClass
+ # and we need it in some of the migrations
+ os.environ["CONFIG"] = os.environ.get("CONFIG", "config/test_config.yml")
+
+ os.environ['SBS_DB_URI_OVERRIDE'] = database_uri
+ db_migrations(database_uri)
diff --git a/server/test/cron/test_cleanup_non_open_requests.py b/server/test/cron/test_cleanup_non_open_requests.py
index 7ad033fd5..96c2b1a1a 100644
--- a/server/test/cron/test_cleanup_non_open_requests.py
+++ b/server/test/cron/test_cleanup_non_open_requests.py
@@ -1,6 +1,6 @@
from sqlalchemy import text
-from server.api.base import STATUS_DENIED, STATUS_APPROVED
+from server.db.defaults import STATUS_DENIED, STATUS_APPROVED
from server.cron.cleanup_non_open_requests import cleanup_non_open_requests
from server.db.db import db
from server.test.abstract_test import AbstractTest
diff --git a/server/test/cron/test_collaboration_expiration.py b/server/test/cron/test_collaboration_expiration.py
index af3da983d..714430138 100644
--- a/server/test/cron/test_collaboration_expiration.py
+++ b/server/test/cron/test_collaboration_expiration.py
@@ -7,12 +7,13 @@
from server.db.domain import Collaboration
from server.test.abstract_test import AbstractTest
from server.test.seed import co_ai_computing_name, co_research_name, co_teachers_name
+from server.tools import dt_now
class TestCollaborationExpiration(AbstractTest):
def _setup_data(self):
- now = datetime.datetime.utcnow()
+ now = dt_now()
cfq = self.app.app_config.collaboration_expiration
# Will cause expiration warning mail
threshold_upper = datetime.timedelta(days=cfq.expired_warning_mail_days_threshold)
diff --git a/server/test/cron/test_collaboration_inactivity_suspension.py b/server/test/cron/test_collaboration_inactivity_suspension.py
index 416cd9e9a..6a2bd8d7a 100644
--- a/server/test/cron/test_collaboration_inactivity_suspension.py
+++ b/server/test/cron/test_collaboration_inactivity_suspension.py
@@ -7,12 +7,13 @@
from server.db.domain import Collaboration
from server.test.abstract_test import AbstractTest
from server.test.seed import co_ai_computing_name, co_research_name, co_teachers_name
+from server.tools import dt_now
class TestCollaborationInactivitySuspension(AbstractTest):
def _setup_data(self):
- now = datetime.datetime.utcnow()
+ now = dt_now()
cfq = self.app.app_config.collaboration_suspension
threshold_for_warning = cfq.collaboration_inactivity_days_threshold - cfq.inactivity_warning_mail_days_threshold
threshold_upper = datetime.timedelta(days=threshold_for_warning)
diff --git a/server/test/cron/test_idp_metadata_parser.py b/server/test/cron/test_idp_metadata_parser.py
index 97a290781..a5ad928d2 100644
--- a/server/test/cron/test_idp_metadata_parser.py
+++ b/server/test/cron/test_idp_metadata_parser.py
@@ -1,11 +1,14 @@
-from server.cron.idp_metadata_parser import idp_display_name, idp_schac_home_by_entity_id
+from server.cron.idp_metadata_parser import idp_display_name, idp_schac_home_by_entity_id, parse_idp_metadata
from server.test.abstract_test import AbstractTest
+from server.cron import idp_metadata_parser
class TestIdpMetadataParser(AbstractTest):
- def test_schedule(self):
+ def test_idp_displayname(self):
+ idp_metadata_parser.idp_metadata = None
+
display_name_nl = idp_display_name("uni-franeker.nl", "nl")
self.assertEqual("Universiteit van Franeker", display_name_nl)
@@ -21,6 +24,9 @@ def test_schedule(self):
display_none = idp_display_name("nope", lang="en", use_default=False)
self.assertIsNone(display_none)
+ def test_idp_schachome(self):
+ idp_metadata_parser.idp_metadata = None
+
schac_home = idp_schac_home_by_entity_id("https://idp.uni-franeker.nl/")
self.assertEqual("uni-franeker.nl", schac_home)
@@ -30,6 +36,9 @@ def test_schedule(self):
schac_home = idp_schac_home_by_entity_id(None)
self.assertIsNone(schac_home)
- from server.cron.idp_metadata_parser import idp_metadata
- self.assertEqual(len(idp_metadata["schac_home_organizations"]), 4)
- self.assertEqual(len(idp_metadata["entity_ids"]), 2)
+ def test_idp_metadata(self):
+ idp_metadata_parser.idp_metadata = None
+ parse_idp_metadata(self.app)
+
+ self.assertEqual(len(idp_metadata_parser.idp_metadata["schac_home_organizations"]), 4)
+ self.assertEqual(len(idp_metadata_parser.idp_metadata["entity_ids"]), 2)
diff --git a/server/test/cron/test_invitation_reminders.py b/server/test/cron/test_invitation_reminders.py
new file mode 100644
index 000000000..bc685c633
--- /dev/null
+++ b/server/test/cron/test_invitation_reminders.py
@@ -0,0 +1,57 @@
+import datetime
+
+from server.cron.invitation_reminders import invitation_reminders
+from server.db.db import db
+from server.db.defaults import STATUS_OPEN
+from server.db.domain import Invitation, ServiceInvitation, \
+ OrganisationInvitation
+from server.test.abstract_test import AbstractTest
+from server.tools import dt_now
+
+
+class TestInvitationReminders(AbstractTest):
+
+ def _setup_data(self):
+ now = dt_now()
+ cfq = self.app.app_config.invitation_reminders
+ # Will cause reminders mail
+ expiry_date = now + datetime.timedelta(days=cfq.invitation_reminders_threshold - 2)
+ for invitation in Invitation.query.all():
+ invitation.expiry_date = expiry_date
+ invitation.status = STATUS_OPEN
+ invitation.reminder_send = False
+ db.session.merge(invitation)
+ for invitation in ServiceInvitation.query.all():
+ invitation.expiry_date = expiry_date
+ invitation.reminder_send = False
+ db.session.merge(invitation)
+ for invitation in OrganisationInvitation.query.all():
+ invitation.expiry_date = expiry_date
+ invitation.reminder_send = False
+ db.session.merge(invitation)
+ db.session.commit()
+
+ def test_schedule(self):
+ self._setup_data()
+
+ mail = self.app.mail
+ with mail.record_messages() as outbox:
+ results = invitation_reminders(self.app)
+ self.assertEqual(4, len(results["invitations"]))
+ self.assertEqual(2, len(results["organisation_invitations"]))
+ self.assertEqual(2, len(results["service_invitations"]))
+ self.assertEqual(8, len(outbox))
+
+ results = invitation_reminders(self.app)
+ self.assertEqual(0, len(results["invitations"]))
+ self.assertEqual(0, len(results["organisation_invitations"]))
+ self.assertEqual(0, len(results["service_invitations"]))
+
+ def test_system_invitation_reminders(self):
+ self._setup_data()
+
+ results = self.put("/api/system/invitation_reminders")
+
+ self.assertEqual(4, len(results["invitations"]))
+ self.assertEqual(2, len(results["organisation_invitations"]))
+ self.assertEqual(2, len(results["service_invitations"]))
diff --git a/server/test/cron/test_membership_expiration.py b/server/test/cron/test_membership_expiration.py
index caf7ec075..68b4b5a87 100644
--- a/server/test/cron/test_membership_expiration.py
+++ b/server/test/cron/test_membership_expiration.py
@@ -8,12 +8,13 @@
from server.test.abstract_test import AbstractTest
from server.test.seed import co_ai_computing_name, user_sarah_name, user_jane_name, \
user_boss_name
+from server.tools import dt_now
class TestMembershipExpiration(AbstractTest):
def _setup_data(self):
- now = datetime.datetime.utcnow()
+ now = dt_now()
cfq = self.app.app_config.membership_expiration
# Will cause expiration warning mail
threshold_upper = datetime.timedelta(days=cfq.expired_warning_mail_days_threshold)
diff --git a/server/test/cron/test_open_requests.py b/server/test/cron/test_open_requests.py
new file mode 100644
index 000000000..ba3fbe113
--- /dev/null
+++ b/server/test/cron/test_open_requests.py
@@ -0,0 +1,12 @@
+from server.cron.open_requests import open_requests
+from server.test.abstract_test import AbstractTest
+
+
+class TestOpenRequest(AbstractTest):
+
+ def test_open_requests(self):
+ mail = self.app.mail
+ with mail.record_messages() as outbox:
+ res = open_requests(self.app)
+ self.assertEqual(6, len(res))
+ self.assertEqual(6, len(outbox))
diff --git a/server/test/cron/test_schedule.py b/server/test/cron/test_schedule.py
index 34b820a86..f49451257 100644
--- a/server/test/cron/test_schedule.py
+++ b/server/test/cron/test_schedule.py
@@ -6,8 +6,10 @@
class TestSchedule(AbstractTest):
def test_start_scheduling(self):
+ self.app.app_config.scim_sweep.enabled = True
+
scheduler = start_scheduling(self.app)
jobs = scheduler.get_jobs()
self.assertTrue(scheduler.running)
- self.assertEqual(8, len(jobs))
+ self.assertEqual(11, len(jobs))
diff --git a/server/test/cron/test_scim_sweep_services.py b/server/test/cron/test_scim_sweep_services.py
index 8a0e2015e..407e672b4 100644
--- a/server/test/cron/test_scim_sweep_services.py
+++ b/server/test/cron/test_scim_sweep_services.py
@@ -1,4 +1,5 @@
import json
+from time import sleep
import responses
@@ -11,27 +12,50 @@
class TestScimSweepServices(AbstractTest):
+ def setUp(self):
+ super(TestScimSweepServices, self).setUp()
+ self.add_bearer_token_to_services()
+
@responses.activate
def test_schedule_sweep(self):
+ # wait to make sure time has passed since initialization;
+ # otherwise time checks in scim run check will fail
+ sleep(5)
+
remote_groups = json.loads(read_file("test/scim/sweep/remote_groups_unchanged.json"))
remote_users = json.loads(read_file("test/scim/sweep/remote_users_unchanged.json"))
with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
rsps.add(responses.GET, "http://localhost:8080/api/scim_mock/Users", json=remote_users, status=200)
rsps.add(responses.GET, "http://localhost:8080/api/scim_mock/Groups", json=remote_groups, status=200)
sweep_result = scim_sweep_services(self.app)
- # This is a temporarily fix for wonkey test results (OH 19-12-2022)
- if len(sweep_result["services"]) > 0:
- self.assertEqual(service_network_name, sweep_result["services"][0]["name"])
- sync_results = sweep_result["services"][0]["sync_results"]
- self.assertEqual(0, len(sync_results["groups"]["created"]))
- self.assertEqual(0, len(sync_results["users"]["created"]))
-
- sweep_result = scim_sweep_services(self.app)
- self.assertEqual(0, len(sweep_result["services"]))
-
- service = self.find_entity_by_name(Service, service_network_name)
- service.sweep_scim_last_run = None
- self.save_entity(service)
- sweep_result = scim_sweep_services(self.app)
- if len(sweep_result["services"]) > 0:
- self.assertEqual(service_network_name, sweep_result["services"][0]["name"])
+ self.assertEqual(1, len(sweep_result["services"]))
+
+ self.assertEqual(service_network_name, sweep_result["services"][0]["name"])
+ sync_results = sweep_result["services"][0]["sync_results"]
+ self.assertEqual(0, len(sync_results["groups"]["created"]))
+ self.assertEqual(0, len(sync_results["users"]["created"]))
+
+ sweep_result = scim_sweep_services(self.app)
+ self.assertEqual(0, len(sweep_result["services"]))
+
+ service = self.find_entity_by_name(Service, service_network_name)
+ service.sweep_scim_last_run = None
+ self.save_entity(service)
+
+ sweep_result = scim_sweep_services(self.app)
+ self.assertEqual(service_network_name, sweep_result["services"][0]["name"])
+
+ def test_schedule_sweep_fail(self):
+ # wait to make sure time has passed since initialization;
+ # otherwise time checks in scim run check will fail
+ sleep(5)
+
+ with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+ rsps.add(responses.GET, "http://localhost:8080/api/scim_mock/Users", status=503,
+ body="Server unavailable")
+ rsps.add(responses.GET, "http://localhost:8080/api/scim_mock/Groups", status=503,
+ body="Server unavailable")
+ sweep_result = scim_sweep_services(self.app)
+ sync_results = sweep_result["services"][0]["sync_results"]
+ self.assertEqual("400 Bad Request: Invalid response from remote SCIM server (got HTTP status 503)",
+ sync_results)
diff --git a/server/test/cron/test_user_suspending.py b/server/test/cron/test_user_suspending.py
index 62681dae9..0c8f9aed5 100644
--- a/server/test/cron/test_user_suspending.py
+++ b/server/test/cron/test_user_suspending.py
@@ -1,4 +1,4 @@
-from datetime import datetime, timedelta
+from datetime import timedelta
from freezegun import freeze_time
from sqlalchemy import text
@@ -7,6 +7,7 @@
from server.cron.user_suspending import suspend_users, suspend_users_lock_name
from server.db.domain import User, UserNameHistory
from server.test.abstract_test import AbstractTest
+from server.tools import dt_now
class TestUserSuspending(AbstractTest):
@@ -24,11 +25,27 @@ def test_schedule_lock(self):
def test_schedule(self):
mail = self.app.mail
- results = suspend_users(self.app)
- self.assertListEqual(["user_suspend_warning@example.org"], results["warning_suspend_notifications"])
- self.assertListEqual(["user_gets_suspended@example.org"], results["suspended_notifications"])
- self.assertListEqual(["user_deletion_warning@example.org"], results["warning_deleted_notifications"])
- self.assertListEqual(["user_gets_deleted@example.org"], results["deleted_notifications"])
+
+ with mail.record_messages() as outbox:
+ results = suspend_users(self.app)
+ self.assertListEqual(["user_suspend_warning@example.org"], results["warning_suspend_notifications"])
+ self.assertListEqual(["user_gets_suspended@example.org"], results["suspended_notifications"])
+ self.assertListEqual(["user_deletion_warning@example.org"], results["warning_deleted_notifications"])
+ self.assertListEqual(["user_gets_deleted@example.org"], results["deleted_notifications"])
+ self.assertEqual(5, len(outbox))
+
+ # check recepients of mails
+ self.assertListEqual(["user_suspend_warning@example.org", "user_gets_suspended@example.org",
+ "user_deletion_warning@example.org", "support+sram@eduteams.org", "sram-beheer@surf.nl"],
+ [m.to[0] for m in outbox])
+
+ # find results mail
+ messages = [m for m in outbox if "Results of inactive account check" in m.subject]
+ self.assertEqual(1, len(messages))
+ self.assertIn("suspension warning:\n - user_suspend_warning@example.org", messages[0].body)
+ self.assertIn("have been suspended:\n - user_gets_suspended@example.org", messages[0].body)
+ self.assertIn("deletion warning:\n - user_deletion_warning@example.org", messages[0].body)
+ self.assertIn("have been deleted:\n - user_gets_deleted@example.org", messages[0].body)
user_suspend_warning = self.find_entity_by_name(User, "user_suspend_warning")
self.assertEqual(False, user_suspend_warning.suspended)
@@ -72,16 +89,57 @@ def test_schedule(self):
# now fast-forward time
retention = self.app.app_config.retention
- newdate = (
- datetime.utcnow()
- + timedelta(retention.reminder_suspend_period_days)
- + timedelta(retention.remove_suspended_users_period_days)
- )
+ newdate = (dt_now()
+ + timedelta(retention.reminder_suspend_period_days)
+ + timedelta(retention.remove_suspended_users_period_days))
+ with freeze_time(newdate):
+ with mail.record_messages() as outbox:
+ results = suspend_users(self.app)
+ self.assertListEqual([], results["warning_suspend_notifications"])
+ self.assertListEqual(["user_suspend_warning@example.org"], results["suspended_notifications"])
+ self.assertListEqual(["user_gets_suspended@example.org"], results["warning_deleted_notifications"])
+ self.assertListEqual(["user_deletion_warning@example.org"], results["deleted_notifications"])
+
+ self.assertEqual(4, len(outbox))
+ to = set([m.to[0] for m in outbox])
+ self.assertSetEqual({"user_suspend_warning@example.org", "user_gets_suspended@example.org",
+ "sram-beheer@surf.nl", "support+sram@eduteams.org"}, to)
+
+ def test_schedule_changed_config(self):
+ mail = self.app.mail
+
+ # run suspend cron job
+ results = suspend_users(self.app)
+ self.assertListEqual(["user_suspend_warning@example.org"], results["warning_suspend_notifications"])
+ self.assertListEqual(["user_gets_suspended@example.org"], results["suspended_notifications"])
+ self.assertListEqual(["user_deletion_warning@example.org"], results["warning_deleted_notifications"])
+ self.assertListEqual(["user_gets_deleted@example.org"], results["deleted_notifications"])
+
+ # now we change the config
+ # this causes the last active date of all suspended users to shift past the deletion date
+ self.app.app_config.retention.allowed_inactive_period_days -= (
+ self.app.app_config.retention.remove_suspended_users_period_days
+ + 1)
+ # now run suspend cron job again; nothing should change!
+ with mail.record_messages() as outbox:
+ results = suspend_users(self.app)
+ self.assertListEqual([], results["warning_suspend_notifications"])
+ self.assertListEqual([], results["suspended_notifications"])
+ self.assertListEqual([], results["warning_deleted_notifications"])
+ self.assertListEqual([], results["deleted_notifications"])
+ self.assertEqual(0, len(outbox))
+
+ # now fast-forward time past the waiting window
+ retention = self.app.app_config.retention
+ newdate = (dt_now()
+ + timedelta(retention.reminder_suspend_period_days)
+ + timedelta(retention.remove_suspended_users_period_days))
with freeze_time(newdate):
with mail.record_messages() as outbox:
+ # now users should be suspended/reminede again because their notifications are older than the threshold
results = suspend_users(self.app)
self.assertListEqual([], results["warning_suspend_notifications"])
self.assertListEqual(["user_suspend_warning@example.org"], results["suspended_notifications"])
self.assertListEqual(["user_gets_suspended@example.org"], results["warning_deleted_notifications"])
self.assertListEqual(["user_deletion_warning@example.org"], results["deleted_notifications"])
- self.assertEqual(3, len(outbox))
+ self.assertEqual(4, len(outbox))
diff --git a/server/test/db/test_datetime.py b/server/test/db/test_datetime.py
new file mode 100644
index 000000000..06617e417
--- /dev/null
+++ b/server/test/db/test_datetime.py
@@ -0,0 +1,65 @@
+import datetime
+
+from sqlalchemy.exc import StatementError
+
+from server.db.db import db
+from server.db.domain import User
+from server.test.abstract_test import AbstractTest
+
+base_user = dict(
+ uid="urn:test",
+ name="Test User",
+ email="test@foo",
+ schac_home_organisation="test.foo",
+ username="test",
+ external_id="test",
+ created_by="testcase",
+ updated_by="testcase"
+)
+
+
+class TestDatetime(AbstractTest):
+
+ def test_datetime_none(self):
+ test_user = User(**base_user, last_login_date=None)
+ db.session.add(test_user)
+ db.session.commit()
+
+ test_user_readback = User.query.filter_by(uid=base_user["uid"]).first()
+ self.assertIsNone(test_user_readback.last_login_date)
+
+ def test_datetime_date(self):
+ dt = datetime.datetime(2019, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)
+ date = dt.date()
+ test_user = User(**base_user, last_login_date=date)
+ db.session.add(test_user)
+ db.session.commit()
+
+ test_user_readback = User.query.filter_by(uid=base_user["uid"]).first()
+ self.assertEqual(dt, test_user_readback.last_login_date)
+
+ def test_datetime_other_tz(self):
+ dt = datetime.datetime(2019, 1, 1, 0, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=1)))
+ test_user = User(**base_user, last_login_date=dt)
+ db.session.add(test_user)
+ db.session.commit()
+
+ test_user_readback = User.query.filter_by(uid=base_user["uid"]).first()
+ # new tz should be UTC, but actual times should be equal
+ self.assertEqual(datetime.timezone.utc, test_user_readback.last_login_date.tzinfo)
+ self.assertEqual(dt, test_user_readback.last_login_date)
+
+ def test_datetime_no_timezone(self):
+ invalid_dt = datetime.datetime(2019, 1, 1, 0, 0, 0)
+ test_user = User(**base_user, last_login_date=invalid_dt)
+ with self.assertRaises(StatementError) as exc:
+ db.session.add(test_user)
+ db.session.commit()
+ self.assertIn("must be timezone aware", str(exc.exception))
+
+ def test_datetime_invalid(self):
+ test_user = User(**base_user, last_login_date=42)
+ with self.assertRaises(StatementError) as exc:
+ db.session.add(test_user)
+ db.session.commit()
+ self.assertIn("Unknown type '
' for datetime", str(exc.exception))
diff --git a/server/test/db/test_defaults.py b/server/test/db/test_defaults.py
index 1df3ee281..14a83153c 100644
--- a/server/test/db/test_defaults.py
+++ b/server/test/db/test_defaults.py
@@ -1,39 +1,41 @@
import datetime
import time
-from unittest import TestCase
from munch import munchify
from werkzeug.exceptions import BadRequest
-from server.db.defaults import default_expiry_date, calculate_expiry_period, cleanse_short_name, valid_uri_attributes, \
- uri_re
-from server.db.domain import Invitation
+from server.tools import dt_now
+from server.db.db import db
+from server.db.defaults import (default_expiry_date, calculate_expiry_period, cleanse_short_name, valid_uri_attributes,
+ uri_re, generate_short_name)
+from server.db.domain import Invitation, Service
+from server.test.abstract_test import AbstractTest
-class TestDefaults(TestCase):
+class TestDefaults(AbstractTest):
def test_default_expiry_date(self):
default_date = default_expiry_date()
- res = default_date - datetime.datetime.today()
+ res = default_date - dt_now()
self.assertEqual(14, res.days)
def test_expiry_date(self):
date = default_expiry_date({"expiry_date": time.time()})
- res = date - datetime.datetime.today()
+ res = date - dt_now()
self.assertEqual(-1, res.days)
def test_calculate_expiry_period_days(self):
period = calculate_expiry_period(
- munchify({"expiry_date": datetime.datetime.today() + datetime.timedelta(days=15)}))
+ munchify({"expiry_date": dt_now() + datetime.timedelta(days=15)}))
self.assertTrue(period.endswith("days"))
def test_calculate_expiry_period_hours(self):
- today = datetime.datetime.today()
+ today = dt_now()
period = calculate_expiry_period(munchify({"expiry_date": today + datetime.timedelta(hours=6)}), today=today)
self.assertTrue(period.endswith("hours"))
def test_calculate_expiry_period_today(self):
- today = datetime.datetime.today()
+ today = dt_now()
period = calculate_expiry_period(munchify({"expiry_date": today}), today=today)
self.assertEqual("0 minutes", period)
@@ -45,23 +47,23 @@ def test_calculate_expiry_period_default(self):
self.assertEqual("15 days", period)
def test_calculate_expiry_period_diff(self):
- today = datetime.datetime.today()
+ today = dt_now()
period = calculate_expiry_period(munchify({"expiry_date": today + datetime.timedelta(minutes=15)}), today=today)
self.assertEqual("15 minutes", period)
def test_calculate_expiry_period_db_object(self):
- invitation = Invitation(expiry_date=datetime.datetime.today() + datetime.timedelta(minutes=15))
+ invitation = Invitation(expiry_date=dt_now() + datetime.timedelta(minutes=15))
period = calculate_expiry_period(invitation)
self.assertTrue(period.endswith("minutes"))
def test_calculate_expiry_period_with_today_hour(self):
- today = datetime.datetime.today()
+ today = dt_now()
invitation = Invitation(expiry_date=today + datetime.timedelta(hours=1))
period = calculate_expiry_period(invitation, today=today)
self.assertEqual("1 hour", period)
def test_calculate_expiry_period_with_today_day(self):
- today = datetime.datetime.today()
+ today = dt_now()
invitation = Invitation(expiry_date=today + datetime.timedelta(days=1))
period = calculate_expiry_period(invitation, today=today)
self.assertEqual("1 day", period)
@@ -85,6 +87,21 @@ def _test_cleansing(short_name, expected):
_test_cleansing("check", "check")
_test_cleansing("1222323", "")
+ def test_generate_short_name_fallback(self):
+ # first generate service with abbreviation entity1..entity999
+ db.session.merge(Service(entity_id="entity", name="entity", created_by="test", updated_by="test",
+ abbreviation="entity"))
+ for i in range(1, 1000):
+ db.session.merge(Service(entity_id=f"entity{i}", name=f"entity{i}", created_by="test", updated_by="test",
+ abbreviation=f"entity{i}"))
+ db.session.commit()
+
+ # adding a new entity should now result in a random abbreviation
+ abbr = generate_short_name(Service, "entity", "abbreviation")
+ # we expect a random string of 16 characters
+ self.assertNotIn("entity", abbr)
+ self.assertEqual(16, len(abbr))
+
def test_valid_uri_attributes(self):
self.assertTrue(valid_uri_attributes({"url": "https://sram.org"}, ["url"]))
diff --git a/server/test/db/test_domain.py b/server/test/db/test_domain.py
index b876d88e2..ce0a283f4 100644
--- a/server/test/db/test_domain.py
+++ b/server/test/db/test_domain.py
@@ -1,16 +1,19 @@
from sqlalchemy.exc import IntegrityError
from server.db.db import db
-from server.db.domain import Collaboration, CollaborationMembership, Invitation, OrganisationInvitation
+from server.db.domain import Collaboration, CollaborationMembership, Invitation, OrganisationInvitation, User
from server.test.abstract_test import AbstractTest
-from server.test.seed import co_ai_computing_name
+from server.test.seed import co_ai_computing_name, user_boss_name
class TestModels(AbstractTest):
def test_collaboration(self):
collaboration = self.find_entity_by_name(Collaboration, co_ai_computing_name)
- self.assertEqual(False, collaboration.is_admin(999))
+ self.assertFalse(collaboration.is_admin(999))
+
+ admin = self.find_entity_by_name(User, user_boss_name)
+ self.assertTrue(collaboration.is_admin(admin.id))
def test_invitation_role(self):
Invitation.validate_role("admin")
diff --git a/server/test/db/test_image.py b/server/test/db/test_image.py
index e07ec036d..b47899be2 100644
--- a/server/test/db/test_image.py
+++ b/server/test/db/test_image.py
@@ -16,7 +16,8 @@ def test_transform_image(self):
self.do_assert_transform("horizontal.png")
def test_transform_image_from_url(self):
- res = urllib.request.urlopen("https://static.surfconext.nl/media/idp/eduid.png")
+ file = f"{os.path.dirname(os.path.realpath(__file__))}/../images/eduid.png"
+ res = urllib.request.urlopen(f"file://{file}")
image_pil = Image.open(BytesIO(base64.b64decode(transform_image(res.read()).encode())))
self.assertEqual(480, image_pil.width)
self.assertEqual(348, image_pil.height)
diff --git a/server/test/images/eduid.png b/server/test/images/eduid.png
new file mode 100644
index 000000000..9dd445271
Binary files /dev/null and b/server/test/images/eduid.png differ
diff --git a/server/test/images/testbeeld.png b/server/test/images/testbeeld.png
new file mode 100644
index 000000000..5372ae77d
Binary files /dev/null and b/server/test/images/testbeeld.png differ
diff --git a/server/test/images/testbeeld.svg b/server/test/images/testbeeld.svg
new file mode 100644
index 000000000..7e869921b
--- /dev/null
+++ b/server/test/images/testbeeld.svg
@@ -0,0 +1,2495 @@
+
+
+
diff --git a/server/test/scim/sweep/remote_users_changes.json b/server/test/scim/sweep/remote_users_changes.json
index b9dbc7ab7..d28187ef1 100644
--- a/server/test/scim/sweep/remote_users_changes.json
+++ b/server/test/scim/sweep/remote_users_changes.json
@@ -100,7 +100,7 @@
"emails": [
{
"primary": true,
- "value": "sarah@uva.org"
+ "value": "sarah@uni-franeker.nl"
}
],
"externalId": "8297d8a5-a2a4-4208-9fb6-100a5865f022@sram.surf.nl",
diff --git a/server/test/scim/sweep/remote_users_unchanged.json b/server/test/scim/sweep/remote_users_unchanged.json
index 3f1b17b1c..342cbde0e 100644
--- a/server/test/scim/sweep/remote_users_unchanged.json
+++ b/server/test/scim/sweep/remote_users_unchanged.json
@@ -100,7 +100,7 @@
"emails": [
{
"primary": true,
- "value": "sarah@uva.org"
+ "value": "sarah@uni-franeker.nl"
}
],
"externalId": "8297d8a5-a2a4-4208-9fb6-100a5865f022@sram.surf.nl",
diff --git a/server/test/scim/test_events.py b/server/test/scim/test_events.py
index eed7b78e2..667423db7 100644
--- a/server/test/scim/test_events.py
+++ b/server/test/scim/test_events.py
@@ -26,6 +26,10 @@ def tearDownClass(cls):
super(TestEvents, cls).tearDownClass()
os.environ["SCIM_DISABLED"] = "1"
+ def setUp(self):
+ super(TestEvents, self).setUp()
+ self.add_bearer_token_to_services()
+
@responses.activate
def test_apply_user_change_create(self):
sarah = self.find_entity_by_name(User, user_sarah_name)
diff --git a/server/test/scim/test_group_template.py b/server/test/scim/test_group_template.py
index 3bd6d1a75..c6a4e954d 100644
--- a/server/test/scim/test_group_template.py
+++ b/server/test/scim/test_group_template.py
@@ -1,15 +1,15 @@
-import datetime
import uuid
from server.db.domain import Group
from server.scim.group_template import find_group_by_id_template
from server.test.abstract_test import AbstractTest
+from server.tools import dt_now
class TestGroupTemplate(AbstractTest):
def test_find_group_by_id_template(self):
- now = datetime.datetime.now()
+ now = dt_now()
group = Group(identifier=uuid.uuid4(), created_at=now, updated_at=now)
result = find_group_by_id_template(group)
diff --git a/server/test/scim/test_scim.py b/server/test/scim/test_scim.py
index 9c5482e8b..fda64add7 100644
--- a/server/test/scim/test_scim.py
+++ b/server/test/scim/test_scim.py
@@ -14,11 +14,16 @@ class TestScim(AbstractTest):
@responses.activate
def test_membership_user_scim_identifiers_provisioning_error(self):
service = self.find_entity_by_name(Service, service_cloud_name)
+ self.put(f"/api/services/reset_scim_bearer_token/{service.id}",
+ {"scim_bearer_token": "secret"})
+
no_user_found = json.loads(read_file("test/scim/no_user_found.json"))
collaboration = self.find_entity_by_name(Collaboration, co_research_name)
with responses.RequestsMock(assert_all_requests_are_fired=True) as rsps:
rsps.add(responses.GET, "http://localhost:8080/api/scim_mock/Users", json=no_user_found, status=200)
# We mock that all member provisioning give an error response
rsps.add(responses.POST, "http://localhost:8080/api/scim_mock/Users", status=400)
+ # Need to reload to prevent DetachedInstanceError
+ service = self.find_entity_by_name(Service, service_cloud_name)
identifiers = membership_user_scim_objects(service, collaboration)
self.assertListEqual([], identifiers)
diff --git a/server/test/scim/test_sweep.py b/server/test/scim/test_sweep.py
index 9ac6f65b2..dfe05e6b5 100644
--- a/server/test/scim/test_sweep.py
+++ b/server/test/scim/test_sweep.py
@@ -17,6 +17,10 @@
class TestSweep(AbstractTest):
+ def setUp(self):
+ super(TestSweep, self).setUp()
+ self.add_bearer_token_to_services()
+
@responses.activate
def test_sweep_no_changes(self):
service = self.find_entity_by_name(Service, service_network_name)
diff --git a/server/test/scim/test_user_template.py b/server/test/scim/test_user_template.py
index 585ffe7e7..5a0513d27 100644
--- a/server/test/scim/test_user_template.py
+++ b/server/test/scim/test_user_template.py
@@ -1,15 +1,15 @@
-import datetime
import uuid
from unittest import TestCase
from server.db.domain import User
from server.scim.user_template import find_user_by_id_template
+from server.tools import dt_now
class TestUserTemplate(TestCase):
def test_find_user_by_id_template(self):
- now = datetime.datetime.now()
+ now = dt_now()
user = User(external_id=uuid.uuid4(), name="John Doe", email="jdoe@domain.com", updated_at=now, created_at=now)
result = find_user_by_id_template(user)
diff --git a/server/test/seed.py b/server/test/seed.py
index 5a91268f4..30bc8ad65 100644
--- a/server/test/seed.py
+++ b/server/test/seed.py
@@ -5,16 +5,18 @@
from sqlalchemy import text
-from server.api.collaboration_request import STATUS_OPEN
-from server.auth.secrets import secure_hash, generate_token
+from server.auth.secrets import secure_hash, generate_token, encrypt_secret
+from server.auth.tokens import _service_context
from server.db.audit_mixin import metadata
-from server.db.defaults import default_expiry_date, SERVICE_TOKEN_INTROSPECTION, SERVICE_TOKEN_SCIM, SERVICE_TOKEN_PAM
+from server.db.defaults import (default_expiry_date, SERVICE_TOKEN_INTROSPECTION, SERVICE_TOKEN_SCIM, SERVICE_TOKEN_PAM,
+ STATUS_OPEN)
from server.db.domain import (User, Organisation, OrganisationMembership, Service, Collaboration,
CollaborationMembership, JoinRequest, Invitation, Group, OrganisationInvitation, ApiKey,
CollaborationRequest, ServiceConnectionRequest, SuspendNotification, Aup,
SchacHomeOrganisation, SshKey, ServiceGroup, ServiceInvitation, ServiceMembership,
ServiceAup, UserToken, UserIpNetwork, Tag, PamSSOSession, IpNetwork, ServiceToken,
ServiceRequest, Unit)
+from server.tools import dt_now, dt_today
# users
user_boss_name = "The Boss"
@@ -46,14 +48,13 @@
unifra_secret = generate_token()
unifra_hashed_secret = secure_hash(unifra_secret)
-umcpekela_name = "Universitair Medisch Centrum Noord-Pekela"
+umcpekela_name = "Universitair Medisch Centrum Zuid-Pekela"
# collaborations
co_ai_computing_name = "AI computing"
co_ai_computing_short_name = "ai_computing"
co_ai_computing_uuid = "a71a2b01-4642-4e1a-b3ac-0a06b2bf66f2"
co_ai_computing_join_request_peter_hash = generate_token()
-co_ai_computing_join_request_john_reference = "Dr. Johnson"
co_teachers_name = "Teachers"
@@ -97,11 +98,12 @@
service_group_wiki_name1 = "service_group_wiki_name_1"
service_group_wiki_name2 = "service_group_wiki_name_2"
-service_connection_request_network_hash = generate_token()
+service_connection_request_storage_hash = generate_token()
service_connection_request_ssh_hash = generate_token()
service_connection_request_wireless_hash = generate_token()
service_request_gpt_name = "GPT"
+service_request_gpt_uuid4 = str(uuid.uuid4())
service_invitation_cloud_hash = generate_token()
service_invitation_wiki_expired_hash = generate_token()
@@ -109,7 +111,7 @@
# tokens
invitation_hash_curious = generate_token()
invitation_hash_no_way = generate_token()
-invitation_hash_uva = generate_token()
+invitation_hash_ufra = generate_token()
service_cloud_token = generate_token()
service_network_token = generate_token()
@@ -151,7 +153,6 @@ def persist_instance(db, *objs):
def clean_db(db):
tables = reversed(metadata.sorted_tables)
- # tables = reversed(metadata.sort_tables_and_constraints)
for table in tables:
db.session.execute(table.delete())
db.session.execute(text("DELETE FROM audit_logs"))
@@ -169,7 +170,8 @@ def seed(db, app_config, skip_seed=False):
peter = User(uid="urn:peter", name="Peter Doe", email="peter@example.org", username="peter",
external_id="b7fdbc01-5b5a-4028-b90a-5409f380e603")
mary = User(uid="urn:mary", name="Mary Doe", email="mary@example.org", username="mdoe",
- schac_home_organisation=schac_home_organisation_example, external_id="bb3d4bd4-2848-4cf3-b30b-fd84186c0c52")
+ schac_home_organisation=f"student.{schac_home_organisation_example}",
+ external_id="bb3d4bd4-2848-4cf3-b30b-fd84186c0c52")
admin = User(uid="urn:admin", name=user_boss_name, email="boss@example.org", username="admin",
external_id="e906cf88-cdb3-480d-8bb3-ce53bdcda4e7")
roger = User(uid="urn:roger", name=user_roger_name, email="roger@example.org",
@@ -180,7 +182,8 @@ def seed(db, app_config, skip_seed=False):
james = User(uid="urn:james", name=user_james_name, email="james@example.org", username="james",
schac_home_organisation=schac_home_organisation_unihar, given_name="James",
external_id="100ae6f1-930f-459c-bf1a-f28facfe5834")
- sarah = User(uid="urn:sarah", name=user_sarah_name, email="sarah@uva.org", application_uid="sarah_application_uid",
+ sarah = User(uid="urn:sarah", name=user_sarah_name, email="sarah@uni-franeker.nl",
+ application_uid="sarah_application_uid",
username="sarah", external_id="8297d8a5-a2a4-4208-9fb6-100a5865f022")
betty = User(uid="urn:betty", name="betty", email="betty@uuc.org", username="betty",
external_id="bbd8123c-b0f9-4e3d-b3ff-288aa1c1edd6", mfa_reset_token="1234567890")
@@ -193,10 +196,11 @@ def seed(db, app_config, skip_seed=False):
service_admin = User(uid="urn:service_admin", name="Service Admin", email="service_admin@ucc.org",
username="service_admin", schac_home_organisation="service.admin.com",
external_id="c5ed5e18-b6aa-48f2-8849-a68a8cfe39a8")
+
# User seed for suspend testing
retention = app_config.retention
- current_time = datetime.datetime.utcnow()
- retention_date = current_time - datetime.timedelta(days=retention.allowed_inactive_period_days + 1)
+ retention_today = dt_today().replace(hour=20)
+ retention_date = retention_today - datetime.timedelta(days=retention.allowed_inactive_period_days + 1)
retention_warning_date = retention_date + datetime.timedelta(days=retention.reminder_suspend_period_days)
user_suspend_warning = User(uid="urn:user_suspend_warning", name="user_suspend_warning",
@@ -225,25 +229,28 @@ def seed(db, app_config, skip_seed=False):
paul, hannibal, service_admin)
# old suspension warning, should not affect new suspension warnings
- warning_date_old = current_time - datetime.timedelta(retention.allowed_inactive_period_days + 1)
+ warning_date_old = retention_today - datetime.timedelta(retention.allowed_inactive_period_days + 1)
notification_gets_suspended_old = SuspendNotification(user=user_suspend_warning, sent_at=warning_date_old,
is_suspension=True, is_warning=True)
- warning_date = datetime.datetime.utcnow() - datetime.timedelta(days=retention.reminder_suspend_period_days + 1)
+ warning_date = dt_now() - datetime.timedelta(days=retention.reminder_suspend_period_days + 1)
notification_gets_suspended = SuspendNotification(user=user_gets_suspended, sent_at=warning_date,
is_suspension=True, is_warning=True)
- warning_date = datetime.datetime.utcnow() - datetime.timedelta(days=retention.remove_suspended_users_period_days) \
+ warning_date = dt_now() - datetime.timedelta(days=retention.remove_suspended_users_period_days) \
+ datetime.timedelta(days=retention.reminder_expiry_period_days - 1)
- notification_suspension_warning = SuspendNotification(user=user_deletion_warning, sent_at=warning_date,
- is_suspension=True, is_warning=False)
+ notification_deletion_warning = SuspendNotification(user=user_deletion_warning, sent_at=warning_date,
+ is_suspension=True, is_warning=False)
- deletion_date = current_time - datetime.timedelta(retention.remove_suspended_users_period_days + 1)
- notification_gets_deleted = SuspendNotification(user=user_gets_deleted, sent_at=deletion_date,
- is_suspension=False, is_warning=True)
+ suspension_date = retention_today - datetime.timedelta(days=retention.remove_suspended_users_period_days + 1)
+ deletion_date = retention_today - datetime.timedelta(days=retention.reminder_expiry_period_days + 1)
+ notification_gets_deleted_1 = SuspendNotification(user=user_gets_deleted, sent_at=suspension_date,
+ is_suspension=True, is_warning=False)
+ notification_gets_deleted_2 = SuspendNotification(user=user_gets_deleted, sent_at=deletion_date,
+ is_suspension=False, is_warning=True)
persist_instance(db, notification_gets_suspended_old, notification_gets_suspended,
- notification_suspension_warning, notification_gets_deleted)
+ notification_deletion_warning, notification_gets_deleted_1, notification_gets_deleted_2)
ssh_key_john = SshKey(user=john, ssh_value="ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC/nvjea1zJJNCnyUfT6HLcHD"
"hwCMp7uqr4BzxhDAjBnjWcgW4hZJvtLTqCLspS6mogCq2d0/31DU4DnGb2MO28"
@@ -266,43 +273,49 @@ def seed(db, app_config, skip_seed=False):
sarah_other_user_ip_network = UserIpNetwork(network_value="255.0.0.9/24", user=sarah)
persist_instance(db, sarah_user_ip_network, sarah_other_user_ip_network)
- uuc = Organisation(name=unihard_name, short_name=unihard_short_name, identifier="95306a5f-0a16-4461-b358-8442e09dab20",
+ uuc = Organisation(name=unihard_name, short_name=unihard_short_name,
+ identifier="95306a5f-0a16-4461-b358-8442e09dab20",
description="Unincorporated Urban Community", logo=read_image("uni-harderwijk.png"),
created_by="urn:admin", updated_by="urnadmin", category="Research",
+ accepted_user_policy="https://uni-harderwijk/aup/v1",
on_boarding_msg="We are using **SRAM** to provide access to the following research tools:"
"\n- Wiki\n- Cloud\n- Awesome things...\n\nIf you want to join one of our "
"collaborations, please send a mail to [support@uuc.nl](mailto:support@uuc.nl)."
"\n
\nHappy researching,\n\n*UUC support*",
collaboration_creation_allowed=True)
- uva = Organisation(name=unifra_name, description=unifra_name,
- identifier="7c60a022-ab09-438c-8603-c361bc1a088d", created_by="urn:admin",
- updated_by="urn:admin", short_name="uva", logo=read_image("uni-franeker.png"),
- category="University", service_connection_requires_approval=True)
- tue = Organisation(name=umcpekela_name, description=umcpekela_name,
- identifier="65fadfcb-71fd-4962-8428-0ecd15970f8d",
- created_by="urn:admin", updated_by="urn:admin", short_name="tue",
- logo=read_image("umc-pekela.png"), category="UMC")
- persist_instance(db, uuc, uva, tue)
+ ufra = Organisation(name=unifra_name, description=unifra_name,
+ identifier="7c60a022-ab09-438c-8603-c361bc1a088d", created_by="urn:admin",
+ updated_by="urn:admin", short_name="ufra", logo=read_image("uni-franeker.png"),
+ category="University", service_connection_requires_approval=True,
+ accepted_user_policy="https://uni-franeker/aup/v1", )
+ pekela = Organisation(name=umcpekela_name, description=umcpekela_name,
+ identifier="65fadfcb-71fd-4962-8428-0ecd15970f8d",
+ created_by="urn:admin", updated_by="urn:admin", short_name="pekela",
+ logo=read_image("umc-pekela.png"), category="UMC")
+ tst = Organisation(name="Test Organisation", description="Organisation for unit testing",
+ identifier="9ba681d0-d70d-11ee-a264-001c4288d429",
+ created_by="urn:admin", updated_by="urn:admin", short_name="test",
+ logo=read_image("testbeeld.png"), category="Overig")
+ persist_instance(db, uuc, ufra, pekela, tst)
uuc_unit_research = Unit(name=unihard_unit_research_name, organisation=uuc)
uuc_unit_support = Unit(name=unihard_unit_support_name, organisation=uuc)
persist_instance(db, uuc_unit_research, uuc_unit_support)
- shouuc = SchacHomeOrganisation(name=schac_home_organisation_unihar, organisation=uuc, created_by="urn:admin",
- updated_by="urn:admin")
- shouva = SchacHomeOrganisation(name=schac_home_organisation_example, organisation=uva, created_by="urn:admin",
- updated_by="urn:admin")
- persist_instance(db, shouuc, shouva)
+ sho_uuc = SchacHomeOrganisation(name=schac_home_organisation_unihar, organisation=uuc, created_by="urn:admin",
+ updated_by="urn:admin")
+ sho_ufra = SchacHomeOrganisation(name=schac_home_organisation_example, organisation=ufra, created_by="urn:admin",
+ updated_by="urn:admin")
+ persist_instance(db, sho_uuc, sho_ufra)
api_key_uuc = ApiKey(hashed_secret=unihard_hashed_secret, organisation=uuc, description="API access",
created_by="urn:admin", updated_by="urn:admin")
- api_key_uva = ApiKey(hashed_secret=unifra_hashed_secret, organisation=uva, description="API access",
- created_by="urn:admin", updated_by="urn:admin")
- persist_instance(db, api_key_uuc, api_key_uva)
+ api_key_ufra = ApiKey(hashed_secret=unifra_hashed_secret, organisation=ufra, description="API access",
+ created_by="urn:admin", updated_by="urn:admin")
+ persist_instance(db, api_key_uuc, api_key_ufra)
organisation_invitation_roger = OrganisationInvitation(message="Please join", hash=unihard_invitation_hash,
- expiry_date=datetime.date.today() + datetime.timedelta(
- days=14),
+ expiry_date=dt_today() + datetime.timedelta(days=14),
invitee_email="roger@example.org", organisation=uuc,
units=[uuc_unit_research],
intended_role="admin",
@@ -311,89 +324,94 @@ def seed(db, app_config, skip_seed=False):
"really, really, really \n really, "
"really, really \n want to...",
hash=unihard_invitation_expired_hash,
- expiry_date=datetime.date.today() - datetime.timedelta(
- days=21),
+ expiry_date=dt_today() - datetime.timedelta(days=21),
intended_role="admin",
invitee_email="pass@example.org", organisation=uuc, user=john)
persist_instance(db, organisation_invitation_roger, organisation_invitation_pass)
- organisation_membership_john = OrganisationMembership(role="admin", user=john, organisation=uuc)
- organisation_membership_mary = OrganisationMembership(role="admin", user=mary, organisation=uuc)
+ organisation_membership_john_uuc = OrganisationMembership(role="admin", user=john, organisation=uuc)
+ organisation_membership_mary_uuc = OrganisationMembership(role="admin", user=mary, organisation=uuc)
+ organisation_membership_mary_pek = OrganisationMembership(role="admin", user=mary, organisation=pekela)
organisation_membership_harry = OrganisationMembership(role="manager", user=harry, organisation=uuc,
units=[uuc_unit_support])
- organisation_membership_jane = OrganisationMembership(role="admin", user=jane, organisation=uva)
+ organisation_membership_jane = OrganisationMembership(role="admin", user=jane, organisation=ufra)
organisation_membership_paul_uuc = OrganisationMembership(role="manager", user=paul, organisation=uuc,
units=[uuc_unit_research])
- organisation_membership_paul_uva = OrganisationMembership(role="manager", user=paul, organisation=uva)
- persist_instance(db, organisation_membership_john, organisation_membership_mary, organisation_membership_harry,
- organisation_membership_jane, organisation_membership_paul_uuc, organisation_membership_paul_uva)
+ organisation_membership_paul_ufra = OrganisationMembership(role="manager", user=paul, organisation=ufra)
+ persist_instance(db, organisation_membership_john_uuc, organisation_membership_mary_uuc, organisation_membership_mary_pek,
+ organisation_membership_harry, organisation_membership_jane, organisation_membership_paul_uuc,
+ organisation_membership_paul_ufra)
mail = Service(entity_id=service_mail_entity_id, name=service_mail_name, contact_email=john.email,
override_access_allowed_all_connections=False, automatic_connection_allowed=True,
logo=read_image("email.png"),
- accepted_user_policy="https://google.nl", allowed_organisations=[uuc, uva], abbreviation="mail",
+ accepted_user_policy="https://google.nl", allowed_organisations=[uuc, ufra], abbreviation="mail",
privacy_policy="https://privacy.org", security_email="sec@org.nl")
wireless = Service(entity_id="https://wireless", name=service_wireless_name, description="Network Wireless Service",
override_access_allowed_all_connections=False, automatic_connection_allowed=True,
contact_email=john.email,
logo=read_image("wireless.png"), accepted_user_policy="https://google.nl", abbreviation="wire",
- allowed_organisations=[uuc, uva], uri="https://wireless", non_member_users_access_allowed=True,
+ allowed_organisations=[uuc, ufra], uri="https://wireless", non_member_users_access_allowed=True,
privacy_policy="https://privacy.org", security_email="sec@org.nl")
# ldap_password is 'changethispassword'
cloud = Service(entity_id=service_cloud_entity_id, name=service_cloud_name, description="SARA Cloud Service",
override_access_allowed_all_connections=False, automatic_connection_allowed=True,
logo=read_image("cloud.png"),
- allowed_organisations=[uuc, uva], abbreviation="cloud",
+ allowed_organisations=[uuc, ufra], abbreviation="cloud",
token_enabled=True, token_validity_days=1, security_email="sec@org.nl", scim_client_enabled=True,
- scim_enabled=True, scim_url="http://localhost:8080/api/scim_mock", scim_bearer_token="secret")
- storage = Service(entity_id=service_storage_entity_id, name=service_storage_name, allowed_organisations=[uuc, uva],
+ scim_enabled=True, scim_url="http://localhost:8080/api/scim_mock")
+ storage = Service(entity_id=service_storage_entity_id, name=service_storage_name, allowed_organisations=[uuc, ufra],
description="SURF Storage Service", logo=read_image("storage.png"), abbreviation="storage",
override_access_allowed_all_connections=False, automatic_connection_allowed=True,
allow_restricted_orgs=True,
uri="https://storage.net", support_email="support@storage.net",
pam_web_sso_enabled=True, security_email="sec@org.nl",
accepted_user_policy="https://google.nl", privacy_policy="https://privacy.org",
- scim_enabled=True, scim_url="http://localhost:8080/api/scim_mock", scim_bearer_token="secret")
+ scim_enabled=True, scim_url="http://localhost:8080/api/scim_mock",
+ token_enabled=False, token_validity_days=0)
wiki = Service(entity_id=service_wiki_entity_id, name=service_wiki_name, description="No more wiki's please",
uri="https://servicedesk.surf.nl/wiki/",
override_access_allowed_all_connections=False, automatic_connection_allowed=False,
logo=read_image("wiki.png"),
- allowed_organisations=[uuc, uva], contact_email="help@wiki.com", abbreviation="wiki",
+ allowed_organisations=[uuc, ufra], contact_email="help@wiki.com", abbreviation="wiki",
accepted_user_policy="https://google.nl", privacy_policy="https://privacy.org",
- automatic_connection_allowed_organisations=[uva], ldap_enabled=True,
+ automatic_connection_allowed_organisations=[ufra], ldap_enabled=True,
ldap_password="$2b$12$GLjC5hK59aeDcEe.tHHJMO.SQQjFgIIpZ7VaKTIsBn05z/gE7JQny",
token_enabled=True, scim_client_enabled=True, token_validity_days=365, security_email="sec@org.nl")
- sweep_scim_last_run = current_time - datetime.timedelta(days=1)
+ sweep_scim_last_run = dt_now() - datetime.timedelta(days=1)
network = Service(entity_id=service_network_entity_id, name=service_network_name,
description="Network enabling service SSH access", address="Some address",
uri="https://uri.net", identity_type="SSH KEY", accepted_user_policy="https://aup.org",
- contact_email="help@network.com", logo=read_image("network.png"),
- automatic_connection_allowed=False, abbreviation="network",
+ logo=read_image("network.png"), automatic_connection_allowed=False, abbreviation="network",
allowed_organisations=[uuc], privacy_policy="https://privacy.org",
token_enabled=True, token_validity_days=365, security_email="sec@org.nl",
- scim_enabled=True, scim_url="http://localhost:8080/api/scim_mock", scim_bearer_token="secret",
+ scim_enabled=True, scim_url="http://localhost:8080/api/scim_mock",
sweep_scim_last_run=sweep_scim_last_run, sweep_scim_daily_rate=1, sweep_scim_enabled=True,
sweep_remove_orphans=True, scim_client_enabled=True)
- service_ssh_uva = Service(entity_id="service_ssh_uva", name=service_ssh_name,
- description="Uva SSH access",
+ service_ssh = Service(entity_id="service_ssh_ufra", name=service_ssh_name,
+ description="Franeker SSH access",
uri="https://uri.com/ssh", identity_type="SSH KEY", accepted_user_policy="https://ssh",
contact_email="help@ssh.com", logo=read_image("ssh.png"),
automatic_connection_allowed=False,
access_allowed_for_all=True, abbreviation="service_ssh",
- research_scholarship_compliant=True,
- code_of_conduct_compliant=True, sirtfi_compliant=True,
privacy_policy="https://privacy.org", security_email="sec@org.nl")
- service_ssh_uva.ldap_identifier = service_ssh_uva.entity_id
+ service_ssh.ldap_identifier = service_ssh.entity_id
uuc_scheduler = Service(entity_id=service_scheduler_entity_id, name=service_scheduler_name,
accepted_user_policy="https://google.nl", abbreviation="uuc_scheduler",
description="UUC Scheduler Service", logo=read_image("scheduler.png"),
contact_email="help@uuc_scheduler.example.com",
- automatic_connection_allowed_organisations=[uva],
+ automatic_connection_allowed_organisations=[ufra],
override_access_allowed_all_connections=False, automatic_connection_allowed=False,
allowed_organisations=[uuc],
privacy_policy="https://privacy.org", security_email="sec@org.nl", ldap_enabled=False)
+ service_empty = Service(entity_id="urn:x-test:empty", name="Test service",
+ accepted_user_policy="https://google.nl", abbreviation="empty",
+ description="Test Service for Unit tests", logo=read_image("testbeeld.png"),
+ contact_email="help@example.com", automatic_connection_allowed=False,
+ privacy_policy="https://privacy.org", security_email="sec@org.nl", ldap_enabled=False)
+
demo_sp = Service(entity_id="https://demo-sp.sram.surf.nl/saml/module.php/saml/sp/metadata.php/test",
name="SRAM Demo SP", abbreviation="sram_demosp", description="Generic SRAM demo sp",
logo=read_image("test.png"), uri="https://demo-sp.sram.surf.nl/",
@@ -401,8 +419,7 @@ def seed(db, app_config, skip_seed=False):
contact_email="sram-beheer@surf.nl", security_email="sram-beheer@surf.nl",
override_access_allowed_all_connections=True, automatic_connection_allowed=True,
allow_restricted_orgs=True,
- access_allowed_for_all=True, sirtfi_compliant=True, research_scholarship_compliant=True,
- code_of_conduct_compliant=True, ldap_enabled=False)
+ access_allowed_for_all=True, ldap_enabled=False)
demo_rp = Service(entity_id="APP-18DE6298-7BDD-4CFA-9399-E1CC62E8DE05",
name=service_sram_demo_sp, abbreviation="sram_demorp", description="Generic SRAM demo rp",
@@ -412,27 +429,36 @@ def seed(db, app_config, skip_seed=False):
override_access_allowed_all_connections=False, automatic_connection_allowed=True,
allow_restricted_orgs=True,
access_allowed_for_all=True,
- sirtfi_compliant=True, research_scholarship_compliant=True, code_of_conduct_compliant=True,
ldap_enabled=False)
+ persist_instance(db, mail, wireless, cloud, storage, wiki, network, service_ssh, uuc_scheduler,
+ service_empty, demo_sp, demo_rp)
+
service_monitor = Service(entity_id="https://ldap-monitor.example.org", name="LDAP/SCIM Monitor Service",
description="Used for monitoring LDAP and SCIM. NIET AANKOMEN.",
override_access_allowed_all_connections=False, automatic_connection_allowed=True,
logo=read_image("ldap.png"),
- allowed_organisations=[uuc, uva], abbreviation="ldap_mon",
+ allowed_organisations=[uuc, ufra], abbreviation="ldap_mon",
privacy_policy="https://privacy.org", accepted_user_policy="https://example.nl/aup",
contact_email="admin@exmaple.nl", security_email="sec@example.nl",
ldap_password="$2b$12$GLjC5hK59aeDcEe.tHHJMO.SQQjFgIIpZ7VaKTIsBn05z/gE7JQny",
- ldap_enabled=True,
- scim_enabled=True, scim_url="https://scim-monitor.sram.surf.nl/scim/tst",
- scim_bearer_token="server_token", scim_client_enabled=True)
+ ldap_enabled=True, scim_enabled=True)
service_monitor.ldap_identifier = service_monitor.entity_id
service_token_monitor_scim = ServiceToken(hashed_token=secure_hash("Axyz_geheim"), description="Monitor token",
service=service_monitor, token_type=SERVICE_TOKEN_SCIM)
- persist_instance(db, mail, wireless, cloud, storage, wiki, network, service_ssh_uva, uuc_scheduler, demo_sp,
- demo_rp, service_monitor, service_token_monitor_scim)
+ persist_instance(db, service_monitor, service_token_monitor_scim)
+
+ # set (encrypted) SCIM Bearer token for this service
+ # can't do this directly, because the service id is needed for the token encryption
+ service_monitor = Service.query.filter(Service.entity_id == service_monitor.entity_id).first()
+ encrypted_bearer_token = encrypt_secret(app_config.encryption_key, "server_token", _service_context(service_monitor))
+ service_monitor.scim_bearer_token = encrypted_bearer_token
+ service_monitor.scim_url = "https://scim-monitor.sram.surf.nl/scim/tst",
+ service_monitor.scim_client_enabled = True
+
+ persist_instance(db, service_monitor)
service_group_mail = ServiceGroup(name=service_group_mail_name,
short_name="mail",
@@ -478,27 +504,33 @@ def seed(db, app_config, skip_seed=False):
service_token_storage_scim, service_token_wiki_introspection, service_token_wiki_scim)
service_invitation_cloud = ServiceInvitation(message="Please join", hash=service_invitation_cloud_hash,
- expiry_date=datetime.date.today() + datetime.timedelta(days=14),
+ expiry_date=dt_today() + datetime.timedelta(days=14),
invitee_email="admin@cloud.org", service=cloud,
intended_role="admin",
user=john)
service_invitation_wiki_expired = ServiceInvitation(message="Please join",
hash=service_invitation_wiki_expired_hash,
- expiry_date=datetime.date.today() - datetime.timedelta(
- days=21),
+ expiry_date=dt_today() - datetime.timedelta(days=21),
intended_role="admin",
invitee_email="pass@wiki.org", service=wiki, user=john)
persist_instance(db, service_invitation_cloud, service_invitation_wiki_expired)
service_membership_james = ServiceMembership(role="admin", user=james, service=cloud)
+ cloud_manager = ServiceMembership(role="manager", user=betty, service=cloud)
service_membership_service_admin_1 = ServiceMembership(role="admin", user=service_admin, service=storage)
service_membership_service_admin_2 = ServiceMembership(role="admin", user=service_admin, service=network)
service_membership_wiki = ServiceMembership(role="admin", user=service_admin, service=wiki)
service_membership_mail = ServiceMembership(role="admin", user=service_admin, service=mail)
- service_membership_betty = ServiceMembership(role="admin", user=betty, service=service_ssh_uva)
- persist_instance(db, service_membership_james, service_membership_service_admin_1,
+ service_membership_ssh = ServiceMembership(role="admin", user=betty, service=service_ssh)
+ service_membership_scheduler = ServiceMembership(role="admin", user=betty, service=uuc_scheduler)
+ service_membership_wireless = ServiceMembership(role="admin", user=betty, service=wireless)
+ service_membership_demosp = ServiceMembership(role="admin", user=betty, service=demo_sp)
+ service_membership_demorp = ServiceMembership(role="admin", user=betty, service=demo_rp)
+ service_membership_monitor = ServiceMembership(role="admin", user=service_admin, service=service_monitor)
+ persist_instance(db, service_membership_james, cloud_manager, service_membership_service_admin_1,
service_membership_service_admin_2, service_membership_wiki, service_membership_mail,
- service_membership_betty)
+ service_membership_ssh, service_membership_wireless, service_membership_scheduler,
+ service_membership_demosp, service_membership_demorp, service_membership_monitor)
service_iprange_cloud_v4 = IpNetwork(network_value="82.217.86.55/24", service=cloud)
service_iprange_cloud_v6 = IpNetwork(network_value="2001:1c02:2b2f:be00:1cf0:fd5a:a548:1a16/128", service=cloud)
@@ -511,9 +543,9 @@ def seed(db, app_config, skip_seed=False):
uuc.services.append(wiki)
tag_uuc = Tag(tag_value="tag_uuc")
- tag_uva = Tag(tag_value="tag_uva")
+ tag_ufra = Tag(tag_value="tag_ufra")
tag_orphan = Tag(tag_value="tag_orphan")
- persist_instance(db, tag_uuc, tag_uva, tag_orphan)
+ persist_instance(db, tag_uuc, tag_ufra, tag_orphan)
ai_computing = Collaboration(name=co_ai_computing_name,
identifier=co_ai_computing_uuid,
@@ -529,17 +561,17 @@ def seed(db, app_config, skip_seed=False):
accepted_user_policy="https://www.google.nl",
disclose_email_information=True,
disclose_member_information=True)
- uva_research = Collaboration(name=co_research_name,
- short_name="research",
- global_urn="uva:research",
- identifier=co_research_uuid,
- tags=[tag_uva],
- website_url="https://www.google.nl",
- description="University of Amsterdam Research - Urban Crowd Control",
- logo=read_image("research.png"),
- organisation=uva, services=[cloud, storage, wiki],
- join_requests=[], invitations=[],
- disclose_member_information=True)
+ ufra_research = Collaboration(name=co_research_name,
+ short_name="research",
+ global_urn="ufra:research",
+ identifier=co_research_uuid,
+ tags=[tag_ufra],
+ website_url="https://www.google.nl",
+ description="University of Amsterdam Research - Urban Crowd Control",
+ logo=read_image("research.png"),
+ organisation=ufra, services=[cloud, storage, wiki],
+ join_requests=[], invitations=[],
+ disclose_member_information=True)
uuc_teachers = Collaboration(name=co_teachers_name,
identifier="033cbc91-45ed-4020-bf86-3cc323e1f1cf",
global_urn=f"ucc:{co_teachers_name}",
@@ -565,27 +597,27 @@ def seed(db, app_config, skip_seed=False):
monitoring_co_2 = Collaboration(name="Monitoring CO numero 2",
identifier="4c1095e5-ae60-4d6d-8bfe-f711d0f81942",
uuid4="716065e3-5154-4883-b1a6-06d6e32f11e9",
- global_urn="tue:monitoring2",
+ global_urn="pekela:monitoring2",
website_url="https://www.google.nl",
description="CO voor monitoring. NIET AANKOMEN.",
logo=read_image("monitor2.png"),
- organisation=tue, services=[service_monitor],
+ organisation=pekela, services=[service_monitor],
join_requests=[], invitations=[],
short_name="monitor2",
- accepted_user_policy="https://www.tue.example.nl/monitor")
+ accepted_user_policy="https://www.example.nl/monitor_aup.txt")
- uu_disabled_join_request = Collaboration(name=co_robotics_disabled_join_request_name,
+ ai_disabled_join_request = Collaboration(name=co_robotics_disabled_join_request_name,
short_name="ai_short",
- global_urn="uva:ai_short",
+ global_urn="ufra:ai_short",
website_url="https://www.google.nl",
logo=read_image("robot.png"),
identifier="568eed02-0e46-48ab-83fe-116d2a8a58c5",
description="Artificiation AI",
disable_join_requests=True,
- organisation=uva,
+ organisation=ufra,
services=[],
join_requests=[], invitations=[])
- persist_instance(db, ai_computing, uva_research, uu_disabled_join_request, uuc_teachers,
+ persist_instance(db, ai_computing, ufra_research, ai_disabled_join_request, uuc_teachers,
monitoring_co_1, monitoring_co_2)
john_ai_computing = CollaborationMembership(role="member", user=john, collaboration=ai_computing)
@@ -596,22 +628,26 @@ def seed(db, app_config, skip_seed=False):
betty_uuc_teachers = CollaborationMembership(role="member", user=betty, collaboration=uuc_teachers)
betty_uuc_ai_computing = CollaborationMembership(role="member", user=betty, collaboration=ai_computing)
- roger_uva_research = CollaborationMembership(role="member", user=roger, collaboration=uva_research)
- peter_uva_research = CollaborationMembership(role="member", user=peter, collaboration=uva_research)
- sarah_uva_research = CollaborationMembership(role="admin", user=sarah, collaboration=uva_research)
- user_two_suspend_uva_research = CollaborationMembership(role="member", user=user_deletion_warning,
- collaboration=uva_research)
+ roger_ufra_research = CollaborationMembership(role="member", user=roger, collaboration=ufra_research)
+ peter_ufra_research = CollaborationMembership(role="member", user=peter, collaboration=ufra_research)
+ sarah_ufra_research = CollaborationMembership(role="admin", user=sarah, collaboration=ufra_research)
+ user_two_suspend_ufra_research = CollaborationMembership(role="member", user=user_deletion_warning,
+ collaboration=ufra_research)
paul_monitoring_co_1 = CollaborationMembership(role="member", user=paul, collaboration=monitoring_co_1)
betty_monitoring_co_1 = CollaborationMembership(role="member", user=betty, collaboration=monitoring_co_1)
betty_monitoring_co_2 = CollaborationMembership(role="member", user=betty, collaboration=monitoring_co_2)
harry_monitoring_co_2 = CollaborationMembership(role="member", user=harry, collaboration=monitoring_co_2)
- persist_instance(db, john_ai_computing, admin_ai_computing, roger_uva_research, peter_uva_research,
- sarah_uva_research,
- jane_ai_computing, sarah_ai_computing, user_two_suspend_uva_research, betty_uuc_teachers,
+ paul_ai_disabled_join_request = CollaborationMembership(role="admin", user=paul, collaboration=ai_disabled_join_request)
+ harry_ai_disabled_join_request = CollaborationMembership(role="member", user=harry, collaboration=ai_disabled_join_request)
+
+ persist_instance(db, john_ai_computing, admin_ai_computing, roger_ufra_research, peter_ufra_research,
+ sarah_ufra_research,
+ jane_ai_computing, sarah_ai_computing, user_two_suspend_ufra_research, betty_uuc_teachers,
betty_uuc_ai_computing,
- paul_monitoring_co_1, betty_monitoring_co_1, betty_monitoring_co_2, harry_monitoring_co_2)
+ paul_monitoring_co_1, betty_monitoring_co_1, betty_monitoring_co_2, harry_monitoring_co_2,
+ paul_ai_disabled_join_request, harry_ai_disabled_join_request)
admin_service_aups = [ServiceAup(user=admin, service=service, aup_url=service.accepted_user_policy) for service in
ai_computing.services]
@@ -636,12 +672,12 @@ def seed(db, app_config, skip_seed=False):
collaboration_memberships=[john_ai_computing])
group_science = Group(name=group_science_name,
short_name="science",
- global_urn="uva:research:science",
+ global_urn="ufra:research:science",
identifier=group_science_identifier,
auto_provision_members=True,
description="Science",
- collaboration=uva_research,
- collaboration_memberships=[roger_uva_research])
+ collaboration=ufra_research,
+ collaboration_memberships=[roger_ufra_research])
group_service_mail = Group(name=service_group_mail_name,
short_name="mail-mail",
@@ -649,21 +685,21 @@ def seed(db, app_config, skip_seed=False):
identifier="9946ca40-2a53-40a8-bc63-fb0758e716e3",
auto_provision_members=False,
description="Provisioned by service Mail Services - Mail group",
- collaboration=uva_research,
+ collaboration=ufra_research,
collaboration_memberships=[],
service_group=service_group_mail)
persist_instance(db, group_researchers, group_developers, group_science, group_service_mail)
- join_request_john = JoinRequest(message="Please...", reference=co_ai_computing_join_request_john_reference, user=john,
+ join_request_john = JoinRequest(message="Please...", user=john,
collaboration=ai_computing, hash=generate_token(), status="open")
join_request_peter = JoinRequest(message="Please...", user=peter, collaboration=ai_computing,
hash=co_ai_computing_join_request_peter_hash, status="open")
join_request_mary = JoinRequest(message="Please...", user=mary, collaboration=ai_computing, hash=generate_token(),
status="open")
- join_request_uva_research = JoinRequest(message="Please...", user=james, collaboration=uva_research,
- hash=generate_token(), status="open")
+ join_request_ufra_research = JoinRequest(message="Please...", user=james, collaboration=ufra_research,
+ hash=generate_token(), status="open")
- persist_instance(db, join_request_john, join_request_peter, join_request_mary, join_request_uva_research)
+ persist_instance(db, join_request_john, join_request_peter, join_request_mary, join_request_ufra_research)
invitation = Invitation(hash=invitation_hash_curious, invitee_email="curious@ex.org", collaboration=ai_computing,
expiry_date=default_expiry_date(), user=admin, message="Please join...",
@@ -671,15 +707,15 @@ def seed(db, app_config, skip_seed=False):
invitation_accepted = Invitation(hash=generate_token(), invitee_email="some@ex.org", collaboration=ai_computing,
expiry_date=default_expiry_date(), user=admin, message="Please join...",
status="accepted", intended_role="admin")
- invitation_uva = Invitation(hash=invitation_hash_uva, invitee_email="uva@ex.org", collaboration=uva_research,
- expiry_date=default_expiry_date(), user=admin, message="Please join...",
- intended_role="member", groups=[group_science], status="open")
+ invitation_ufra = Invitation(hash=invitation_hash_ufra, invitee_email="ufra@ex.org", collaboration=ufra_research,
+ expiry_date=default_expiry_date(), user=admin, message="Please join...",
+ intended_role="member", groups=[group_science], status="open")
invitation_noway = Invitation(hash=invitation_hash_no_way, invitee_email="noway@ex.org", collaboration=ai_computing,
- expiry_date=datetime.date.today() - datetime.timedelta(days=21), user=admin,
+ expiry_date=dt_today() - datetime.timedelta(days=21), user=admin,
intended_role="member", status="expired",
message="Let me please join as I really, really, really \n really, "
"really, really \n want to...")
- persist_instance(db, invitation, invitation_accepted, invitation_uva, invitation_noway)
+ persist_instance(db, invitation, invitation_accepted, invitation_ufra, invitation_noway)
collaboration_request_1 = CollaborationRequest(name=collaboration_request_name, short_name="new_collaboration",
website_url="https://google.com", logo=read_image("request.png"),
@@ -691,19 +727,22 @@ def seed(db, app_config, skip_seed=False):
requester=peter, description="please")
persist_instance(db, collaboration_request_1, collaboration_request_2)
- service_connection_request_network = ServiceConnectionRequest(message="AI computing needs storage",
- hash=service_connection_request_network_hash,
+ service_connection_request_storage = ServiceConnectionRequest(message="AI computing needs storage",
+ hash=service_connection_request_storage_hash,
requester=admin, collaboration=ai_computing,
pending_organisation_approval=False,
+ status=STATUS_OPEN,
service=storage)
- service_connection_request_wiki = ServiceConnectionRequest(message="UVA research needs ssh",
+ service_connection_request_wiki = ServiceConnectionRequest(message="UFra research needs ssh",
hash=service_connection_request_ssh_hash,
- requester=sarah, collaboration=uva_research,
+ requester=sarah, collaboration=ufra_research,
pending_organisation_approval=True,
- service=service_ssh_uva)
- persist_instance(db, service_connection_request_network, service_connection_request_wiki)
+ status=STATUS_OPEN,
+ service=service_ssh)
+ persist_instance(db, service_connection_request_storage, service_connection_request_wiki)
- user_token_sarah = UserToken(name="token", description="some", hashed_token=secure_hash(user_sarah_user_token_network),
+ user_token_sarah = UserToken(name="token", description="some",
+ hashed_token=secure_hash(user_sarah_user_token_network),
user=sarah, service=network)
user_token_betty_for_wiki = UserToken(name="token", description="some",
hashed_token=secure_hash(user_betty_user_token_wiki),
@@ -718,13 +757,12 @@ def seed(db, app_config, skip_seed=False):
service_request_gpt = ServiceRequest(name=service_request_gpt_name, abbreviation="gpt",
description="We need more AI", logo=read_image("computing.png"),
- uuid4=str(uuid.uuid4()), providing_organisation="Cloudy",
+ uuid4=service_request_gpt_uuid4, providing_organisation="Cloudy",
uri_info="https://login.org", uri="https://website.org",
contact_email="contact@gpt.org", support_email="support@gpt.org",
security_email="security@gpt.org", privacy_policy="https://privacy_policy.org",
accepted_user_policy="https://accepted_user_policy.org",
- code_of_conduct_compliant=True, sirtfi_compliant=True,
- research_scholarship_compliant=True, status="open", comments="Please",
+ status="open", comments="Please",
connection_type="openIDConnect",
redirect_urls="https://redirect.org, https://redirect.alternative.org",
requester=sarah)
diff --git a/server/test/test_mail.py b/server/test/test_mail.py
index 43d32c2f2..f054f3c37 100644
--- a/server/test/test_mail.py
+++ b/server/test/test_mail.py
@@ -1,5 +1,4 @@
import json
-import os
from server.auth.security import CSRF_TOKEN
from server.db.domain import Collaboration, User
@@ -24,13 +23,12 @@ def test_send_join_request_mail(self):
mail_collaboration_join_request(context, collaboration, ["test@example.com"])
self.assertEqual(1, len(outbox))
mail_msg = outbox[0]
- self.assertListEqual(["test@example.com"], mail_msg.recipients)
- self.assertEqual("SURF_ResearchAccessManagement ", mail_msg.sender)
+ self.assertListEqual(["test@example.com"], mail_msg.to)
+ self.assertEqual("SURF_ResearchAccessManagement ", mail_msg.from_email)
self.assertTrue(f"http://localhost:300/collaborations/{collaboration.id}/joinrequests" in mail_msg.html)
def test_send_error_mail(self):
try:
- del os.environ["TESTING"]
self.app.app_config.mail.send_exceptions = True
mail = self.app.mail
with mail.record_messages() as outbox:
@@ -52,12 +50,10 @@ def test_send_error_mail(self):
html = outbox[0].html
self.assertTrue("An error occurred in local" in html)
finally:
- os.environ["TESTING"] = "1"
self.app.app_config.mail.send_exceptions = False
def test_no_error_mail_for_api(self):
try:
- del os.environ["TESTING"]
self.app.app_config.mail.send_exceptions = True
mail = self.app.mail
with mail.record_messages() as outbox:
@@ -67,17 +63,15 @@ def test_no_error_mail_for_api(self):
content_type="application/json")
self.assertEqual(0, len(outbox))
finally:
- os.environ["TESTING"] = "1"
self.app.app_config.mail.send_exceptions = False
def test_send_audit_trail_mail(self):
try:
- del os.environ["TESTING"]
self.app.app_config.mail.audit_trail_notifications_enabled = True
mail = self.app.mail
+ self.login("urn:john")
+ me = self.get("/api/users/me")
with mail.record_messages() as outbox:
- self.login("urn:john")
- me = self.get("/api/users/me")
self.post("/api/organisations",
body={"name": "new_organisation",
"short_name": "https://ti1"},
@@ -88,6 +82,4 @@ def test_send_audit_trail_mail(self):
self.assertTrue("User John Doe has created a(n) Organisation"
" on environment local" in mail_msg.html)
finally:
- os.environ["TESTING"] = "1"
- self.app.app_config.mail.send_exceptions = False
self.app.app_config.mail.audit_trail_notifications_enabled = False
diff --git a/server/tools.py b/server/tools.py
index b8371e6b5..7a360d5b3 100644
--- a/server/tools.py
+++ b/server/tools.py
@@ -1,5 +1,6 @@
import logging
import os
+from datetime import datetime, timezone
from os.path import exists
from werkzeug.exceptions import BadRequest
@@ -14,4 +15,12 @@ def read_file(file_name: str) -> str:
with open(file) as f:
return f.read()
else:
- raise BadRequest()
+ raise BadRequest(f"Can't read file: {file_name}")
+
+
+def dt_now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def dt_today() -> datetime:
+ return dt_now().replace(hour=0, minute=0, second=0, microsecond=0)