diff --git a/backend/build.gradle b/backend/build.gradle index 156829bf4..c7792db77 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -172,6 +172,9 @@ dependencies { compile 'com.opencsv:opencsv:4.4' testCompile 'org.skyscreamer:jsonassert:1.5.0' + + // Envers for persistent entities versioning + compile 'org.hibernate:hibernate-envers' } def generatedSrcDir = new File(buildDir, 'generated/src/main/java') diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/filters/MetadataFilter.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/filters/MetadataFilter.java index 8a42cd1f7..5ee500437 100644 --- a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/filters/MetadataFilter.java +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/filters/MetadataFilter.java @@ -10,6 +10,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; +import org.hibernate.envers.Audited; import javax.persistence.Column; import javax.persistence.Entity; @@ -34,6 +35,7 @@ @JsonSubTypes.Type(value=SignatureValidationFilter.class, name="SignatureValidation"), @JsonSubTypes.Type(value=RequiredValidUntilFilter.class, name="RequiredValidUntil"), @JsonSubTypes.Type(value=NameIdFormatFilter.class, name="NameIDFormat")}) +@Audited public class MetadataFilter extends AbstractAuditable { @JsonProperty("@type") diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/resolvers/DynamicHttpMetadataResolver.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/resolvers/DynamicHttpMetadataResolver.java index b98d4188b..e8deb0e3e 100644 --- a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/resolvers/DynamicHttpMetadataResolver.java +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/resolvers/DynamicHttpMetadataResolver.java @@ -4,6 +4,7 @@ import lombok.Getter; import lombok.Setter; import lombok.ToString; +import org.hibernate.envers.Audited; import javax.persistence.CascadeType; import javax.persistence.ElementCollection; diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/resolvers/FileBackedHttpMetadataResolver.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/resolvers/FileBackedHttpMetadataResolver.java index b6d303fb3..4ffadae52 100644 --- a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/resolvers/FileBackedHttpMetadataResolver.java +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/resolvers/FileBackedHttpMetadataResolver.java @@ -1,9 +1,12 @@ package edu.internet2.tier.shibboleth.admin.ui.domain.resolvers; +import edu.internet2.tier.shibboleth.admin.ui.domain.AbstractAuditable; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; import lombok.ToString; +import org.hibernate.envers.AuditOverride; +import org.hibernate.envers.Audited; import javax.persistence.Embedded; import javax.persistence.Entity; @@ -13,6 +16,8 @@ @Getter @Setter @ToString +@Audited +@AuditOverride(forClass = AbstractAuditable.class) public class FileBackedHttpMetadataResolver extends MetadataResolver { public FileBackedHttpMetadataResolver() { type = "FileBackedHttpMetadataResolver"; diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/resolvers/MetadataResolver.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/resolvers/MetadataResolver.java index 8f2fbfd60..ec639ba38 100644 --- a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/resolvers/MetadataResolver.java +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/resolvers/MetadataResolver.java @@ -11,6 +11,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; +import org.hibernate.envers.Audited; import javax.persistence.CascadeType; import javax.persistence.Column; @@ -37,6 +38,7 @@ @JsonSubTypes.Type(value = DynamicHttpMetadataResolver.class, name = "DynamicHttpMetadataResolver"), @JsonSubTypes.Type(value = FilesystemMetadataResolver.class, name = "FilesystemMetadataResolver"), @JsonSubTypes.Type(value = ResourceBackedMetadataResolver.class, name = "ResourceBackedMetadataResolver")}) +@Audited public class MetadataResolver extends AbstractAuditable { @JsonProperty("@type") diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/envers/PrincipalAwareRevisionEntity.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/envers/PrincipalAwareRevisionEntity.java new file mode 100644 index 000000000..8ee27218f --- /dev/null +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/envers/PrincipalAwareRevisionEntity.java @@ -0,0 +1,20 @@ +package edu.internet2.tier.shibboleth.admin.ui.envers; + +import lombok.Getter; +import lombok.Setter; +import org.hibernate.envers.DefaultRevisionEntity; +import org.hibernate.envers.RevisionEntity; + +import javax.persistence.Entity; + +/** + * Extension of the default envers revision entity to track authenticated principals + */ +@Entity +@RevisionEntity(PrincipalEnhancingRevisionListener.class) +@Getter +@Setter +public class PrincipalAwareRevisionEntity extends DefaultRevisionEntity { + + private String principalUserName; +} diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/envers/PrincipalEnhancingRevisionListener.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/envers/PrincipalEnhancingRevisionListener.java new file mode 100644 index 000000000..12af196ed --- /dev/null +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/envers/PrincipalEnhancingRevisionListener.java @@ -0,0 +1,20 @@ +package edu.internet2.tier.shibboleth.admin.ui.envers; + +import org.hibernate.envers.RevisionListener; + +import static edu.internet2.tier.shibboleth.admin.ui.security.springsecurity.PrincipalAccessor.currentPrincipalIfLoggedIn; + +/** + * Implementation of envers revision listener to enhance revision entity with authenticated principal username. + */ +public class PrincipalEnhancingRevisionListener implements RevisionListener { + + private static final String ANONYMOUS = "anonymous"; + + @Override + public void newRevision(Object revisionEntity) { + PrincipalAwareRevisionEntity rev = (PrincipalAwareRevisionEntity) revisionEntity; + String user = currentPrincipalIfLoggedIn().orElse(ANONYMOUS); + rev.setPrincipalUserName(user); + } +} diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/springsecurity/PrincipalAccessor.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/springsecurity/PrincipalAccessor.java new file mode 100644 index 000000000..4b9642f96 --- /dev/null +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/springsecurity/PrincipalAccessor.java @@ -0,0 +1,21 @@ +package edu.internet2.tier.shibboleth.admin.ui.security.springsecurity; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +public final class PrincipalAccessor { + + //Non-instantiable utility class + private PrincipalAccessor() { + } + + public static Optional currentPrincipalIfLoggedIn() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + return Optional.empty(); + } + return Optional.of(authentication.getName()); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index b0f63bd34..d35c7c099 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -44,6 +44,9 @@ spring.jpa.properties.hibernate.format_sql=false spring.jpa.hibernate.use-new-id-generator-mappings=true +#Envers versioning +spring.jpa.properties.org.hibernate.envers.store_data_at_delete=true + # Set the following property to periodically write out the generated metadata files. There is no default value; the following is just an example # shibui.metadata-dir=/opt/shibboleth-idp/metadata/generated shibui.logout-url=/dashboard diff --git a/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/repository/envers/MetadataResolverEntityBasicEnversVersioningTests.groovy b/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/repository/envers/MetadataResolverEntityBasicEnversVersioningTests.groovy new file mode 100644 index 000000000..c4f70dbcd --- /dev/null +++ b/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/repository/envers/MetadataResolverEntityBasicEnversVersioningTests.groovy @@ -0,0 +1,106 @@ +package edu.internet2.tier.shibboleth.admin.ui.repository.envers + +import edu.internet2.tier.shibboleth.admin.ui.configuration.CoreShibUiConfiguration +import edu.internet2.tier.shibboleth.admin.ui.configuration.InternationalizationConfiguration +import edu.internet2.tier.shibboleth.admin.ui.configuration.SearchConfiguration +import edu.internet2.tier.shibboleth.admin.ui.configuration.TestConfiguration +import edu.internet2.tier.shibboleth.admin.ui.domain.filters.EntityAttributesFilter +import edu.internet2.tier.shibboleth.admin.ui.domain.filters.EntityAttributesFilterTarget +import edu.internet2.tier.shibboleth.admin.ui.domain.resolvers.MetadataResolver +import edu.internet2.tier.shibboleth.admin.ui.repository.MetadataResolverRepository +import org.hibernate.envers.AuditReaderFactory +import org.hibernate.envers.query.AuditQuery +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.DefaultTransactionDefinition +import spock.lang.Specification + +import javax.persistence.EntityManager + +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_REQUIRES_NEW + +/** + * Testing metadata resolvers basic versioning by envers is functioning. + */ +@DataJpaTest +@ContextConfiguration(classes = [CoreShibUiConfiguration, InternationalizationConfiguration, TestConfiguration, SearchConfiguration]) +@EnableJpaRepositories(basePackages = ["edu.internet2.tier.shibboleth.admin.ui"]) +@EntityScan("edu.internet2.tier.shibboleth.admin.ui") +class MetadataResolverEntityBasicEnversVersioningTests extends Specification { + + @Autowired + MetadataResolverRepository metadataResolverRepository + + @Autowired + EntityManager entityManager + + @Autowired + PlatformTransactionManager txMgr + + def "test basic audit and version data is created when persisting base metadata resolver with envers enabled"() { + when: + MetadataResolver mdr = doInExplicitTransaction { + metadataResolverRepository.save(create {new MetadataResolver()}) + } + def metadataResolverHistory = resolverHistory() + + then: + metadataResolverHistory.size() == 1 + + when: + def rev = metadataResolverHistory[0] + + then: + rev[1].principalUserName == 'anonymous' + + when: + mdr.name = 'Updated' + doInExplicitTransaction { + metadataResolverRepository.save(mdr) + } + metadataResolverHistory = resolverHistory() + + then: + metadataResolverHistory.size == 2 + } + + private resolverHistory() { + def auditReader = AuditReaderFactory.get(entityManager) + AuditQuery auditQuery = auditReader + .createQuery() + .forRevisionsOfEntity(MetadataResolver, false, true) + auditQuery.resultList + + } + + private static create(Closure concreteResolverSupplier) { + MetadataResolver resolver = concreteResolverSupplier() + resolver.with { + it.name = "testme" + it.metadataFilters.add(new EntityAttributesFilter().with { + it.entityAttributesFilterTarget = new EntityAttributesFilterTarget().with { + it.entityAttributesFilterTargetType = EntityAttributesFilterTarget.EntityAttributesFilterTargetType.ENTITY + it.value = ["hola"] + return it + } + return it + }) + } + resolver + } + + //This explicit low level transaction dance is required in order to verify history/version data that envers + //writes out only after the explicit transaction is committed, therefore making it impossible to verify within the main tx + //boundary of the test method which commits tx only after an execution of the test method. This let's us explicitly + //start/commit transaction making envers data written out and verifiable + private doInExplicitTransaction(Closure uow) { + def txStatus = txMgr.getTransaction(new DefaultTransactionDefinition(PROPAGATION_REQUIRES_NEW)) + def entity = uow() + txMgr.commit(txStatus) + entity + } +}