diff --git a/security-annotations/build.gradle.kts b/security-annotations/build.gradle.kts index 0761fe29b1..9d099785c7 100644 --- a/security-annotations/build.gradle.kts +++ b/security-annotations/build.gradle.kts @@ -3,4 +3,5 @@ plugins { } dependencies { compileOnly("io.micronaut:micronaut-core-processor") + compileOnly(mnData.micronaut.data.model) } diff --git a/security-annotations/src/main/java/io/micronaut/security/annotation/CreatedBy.java b/security-annotations/src/main/java/io/micronaut/security/annotation/CreatedBy.java new file mode 100644 index 0000000000..1cde27bee2 --- /dev/null +++ b/security-annotations/src/main/java/io/micronaut/security/annotation/CreatedBy.java @@ -0,0 +1,38 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.annotation; + +import io.micronaut.data.annotation.AutoPopulated; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * An annotation for use with Micronaut Data entities that will cause the annotated field to be automatically + * populated on save with the identity of the currently authenticated user. + * + * @author Jeremy Grelle + * @since 4.5.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.FIELD}) +@Documented +@AutoPopulated(updateable = false) +public @interface CreatedBy { +} diff --git a/security-annotations/src/main/java/io/micronaut/security/annotation/UpdatedBy.java b/security-annotations/src/main/java/io/micronaut/security/annotation/UpdatedBy.java new file mode 100644 index 0000000000..62aab30a70 --- /dev/null +++ b/security-annotations/src/main/java/io/micronaut/security/annotation/UpdatedBy.java @@ -0,0 +1,38 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.annotation; + +import io.micronaut.data.annotation.AutoPopulated; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * An annotation for use with Micronaut Data entities that will cause the annotated field to be automatically + * populated on both save and update with the identity of the currently authenticated user. + * + * @author Jeremy Grelle + * @since 4.5.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.FIELD}) +@Documented +@AutoPopulated +public @interface UpdatedBy { +} diff --git a/security/build.gradle.kts b/security/build.gradle.kts index 5275c743c8..6ff5f0643d 100644 --- a/security/build.gradle.kts +++ b/security/build.gradle.kts @@ -14,11 +14,14 @@ dependencies { api(projects.micronautSecurityAnnotations) implementation(mnValidation.micronaut.validation) implementation(mnReactor.micronaut.reactor) - + compileOnly(mnData.micronaut.data.runtime) compileOnly(mn.micronaut.http.server) compileOnly(mn.micronaut.management) compileOnly(mn.jackson.databind) - + testCompileOnly(mnData.micronaut.data.processor) + testImplementation(mnSql.h2) + testImplementation(mnSql.micronaut.jdbc.hikari) + testImplementation(mnData.micronaut.data.jdbc) testImplementation(mnSerde.micronaut.serde.jackson) testImplementation(mnReactor.micronaut.reactor) testImplementation(mn.micronaut.management) diff --git a/security/src/main/java/io/micronaut/security/audit/UserAuditingEntityEventListener.java b/security/src/main/java/io/micronaut/security/audit/UserAuditingEntityEventListener.java new file mode 100644 index 0000000000..2c4036a481 --- /dev/null +++ b/security/src/main/java/io/micronaut/security/audit/UserAuditingEntityEventListener.java @@ -0,0 +1,129 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.audit; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.AnnotationMetadata; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.beans.BeanProperty; +import io.micronaut.core.convert.ConversionService; +import io.micronaut.core.convert.exceptions.ConversionErrorException; +import io.micronaut.data.annotation.AutoPopulated; +import io.micronaut.data.annotation.event.PrePersist; +import io.micronaut.data.annotation.event.PreUpdate; +import io.micronaut.data.event.EntityEventContext; +import io.micronaut.data.model.runtime.RuntimePersistentProperty; +import io.micronaut.data.runtime.event.listeners.AutoPopulatedEntityEventListener; +import io.micronaut.security.annotation.CreatedBy; +import io.micronaut.security.annotation.UpdatedBy; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.utils.SecurityService; +import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.annotation.Annotation; +import java.util.*; +import java.util.function.Predicate; + +/** + * An event listener that handles auto-population of entity fields annotated with {@link CreatedBy} or + * {@link UpdatedBy} by mapping them from the current {@link Authentication}. + * + * @author Jeremy Grelle + * @since 4.5.0 + */ +@Requires(classes = { AutoPopulatedEntityEventListener.class, EntityEventContext.class }) +@Singleton +final class UserAuditingEntityEventListener extends AutoPopulatedEntityEventListener { + private static final Logger LOG = LoggerFactory.getLogger(UserAuditingEntityEventListener.class); + + private final SecurityService securityService; + + private final ConversionService conversionService; + + UserAuditingEntityEventListener(SecurityService securityService, ConversionService conversionService) { + this.securityService = securityService; + this.conversionService = conversionService; + } + + @Override + public boolean prePersist(@NonNull EntityEventContext context) { + return populate(context, PrePersist.class); + } + + @Override + public boolean preUpdate(@NonNull EntityEventContext context) { + return populate(context, PreUpdate.class); + } + + @Override + protected @NonNull List> getEventTypes() { + return Arrays.asList(PrePersist.class, PreUpdate.class); + } + + @Override + protected @NonNull Predicate> getPropertyPredicate() { + return property -> { + final AnnotationMetadata annotationMetadata = property.getAnnotationMetadata(); + return annotationMetadata.hasAnnotation(CreatedBy.class) || annotationMetadata.hasAnnotation(UpdatedBy.class); + }; + } + + private boolean populate(@NonNull EntityEventContext context, + @NonNull Class listenerAnnotation) { + try { + securityService.getAuthentication().ifPresent(authentication -> { + Map, Object> valueForType = new HashMap<>(); + for (RuntimePersistentProperty persistentProperty : getApplicableProperties(context.getPersistentEntity())) { + if (shouldSetProperty(persistentProperty, listenerAnnotation)) { + final BeanProperty beanProperty = persistentProperty.getProperty(); + Object value = valueForType.computeIfAbsent(beanProperty.getType(), type -> convert(authentication, beanProperty)); + if (value != null) { + context.setProperty(beanProperty, value); + } + } + } + }); + return true; + } catch (ConversionErrorException e) { + return false; + } + } + + @Nullable + private Object convert(@NonNull Authentication authentication, @NonNull BeanProperty beanProperty) throws ConversionErrorException { + try { + return conversionService.convertRequired(authentication, beanProperty.getType()); + } catch (ConversionErrorException e) { + if (LOG.isErrorEnabled()) { + LOG.error("Cannot convert from {} to {} for bean property {}", authentication.getClass().getSimpleName(), beanProperty.getType(), beanProperty.getName(), e); + } + throw e; + } + } + + private boolean shouldSetProperty(@NonNull RuntimePersistentProperty persistentProperty, Class listenerAnnotation) { + if (listenerAnnotation == PrePersist.class) { + return true; + } + if (listenerAnnotation == PreUpdate.class) { + return persistentProperty.getAnnotationMetadata().booleanValue(AutoPopulated.class, AutoPopulated.UPDATEABLE).orElse(true); + } + return false; + } +} diff --git a/security/src/main/java/io/micronaut/security/audit/package-info.java b/security/src/main/java/io/micronaut/security/audit/package-info.java new file mode 100644 index 0000000000..ed831961c0 --- /dev/null +++ b/security/src/main/java/io/micronaut/security/audit/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Contains classes specific to adding security auditing integration with Micronaut Data. + * + * @author Jeremy Grelle + * @since 4.5.0 + */ +package io.micronaut.security.audit; diff --git a/security/src/main/java/io/micronaut/security/converters/PrincipalToStringConverter.java b/security/src/main/java/io/micronaut/security/converters/PrincipalToStringConverter.java new file mode 100644 index 0000000000..16bd6899a4 --- /dev/null +++ b/security/src/main/java/io/micronaut/security/converters/PrincipalToStringConverter.java @@ -0,0 +1,54 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.converters; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.convert.ConversionContext; +import io.micronaut.core.convert.TypeConverter; +import io.micronaut.security.annotation.CreatedBy; +import io.micronaut.security.annotation.UpdatedBy; +import io.micronaut.security.authentication.Authentication; + +import java.security.Principal; +import java.util.Optional; + +/** + * A {@link Principal} to {@code String} converter. + * + * This is intended as the default implementation for conversion of the current {@link Authentication} to {@code String} + * entity fields annotated with either {@link CreatedBy} or {@link UpdatedBy}, + * and simply converts to {@link Principal#getName()}. + * This implementation may be replaced for custom mapping of a unique {@link String} identifier, or additional converters + * may be provided for mapping to more complex types. + * + * @author Jeremy Grelle + * @since 4.5.0 + */ +@Internal +final class PrincipalToStringConverter implements TypeConverter { + + /** + * + * @param principal The source principal + * @param targetType The target type being converted to + * @param context The {@link ConversionContext} + * @return The converted type or empty if the conversion is not possible + */ + @Override + public Optional convert(Principal principal, Class targetType, ConversionContext context) { + return Optional.ofNullable(principal.getName()); + } +} diff --git a/security/src/main/java/io/micronaut/security/converters/SecurityTypeConvertersRegistrar.java b/security/src/main/java/io/micronaut/security/converters/SecurityTypeConvertersRegistrar.java new file mode 100644 index 0000000000..88ab85c94f --- /dev/null +++ b/security/src/main/java/io/micronaut/security/converters/SecurityTypeConvertersRegistrar.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.converters; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.convert.MutableConversionService; +import io.micronaut.core.convert.TypeConverterRegistrar; + +import java.security.Principal; + +/** + * Registers security {@link io.micronaut.core.convert.TypeConverter}s. + * @author Sergio del Amo + * @since 4.5.0 + */ +@Internal +public final class SecurityTypeConvertersRegistrar implements TypeConverterRegistrar { + @Override + public void register(MutableConversionService conversionService) { + conversionService.addConverter(Principal.class, String.class, new PrincipalToStringConverter()); + } +} diff --git a/security/src/main/java/io/micronaut/security/converters/package-info.java b/security/src/main/java/io/micronaut/security/converters/package-info.java new file mode 100644 index 0000000000..6141db6b7b --- /dev/null +++ b/security/src/main/java/io/micronaut/security/converters/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Security {@link io.micronaut.core.convert.TypeConverter}s. + */ +package io.micronaut.security.converters; \ No newline at end of file diff --git a/security/src/main/resources/META-INF/services/io.micronaut.core.convert.TypeConverterRegistrar b/security/src/main/resources/META-INF/services/io.micronaut.core.convert.TypeConverterRegistrar new file mode 100644 index 0000000000..a80f4d6ecd --- /dev/null +++ b/security/src/main/resources/META-INF/services/io.micronaut.core.convert.TypeConverterRegistrar @@ -0,0 +1 @@ +io.micronaut.security.converters.SecurityTypeConvertersRegistrar \ No newline at end of file diff --git a/security/src/test/groovy/io/micronaut/security/audit/AuditingCustomIdentitySpec.groovy b/security/src/test/groovy/io/micronaut/security/audit/AuditingCustomIdentitySpec.groovy new file mode 100644 index 0000000000..7eb903f521 --- /dev/null +++ b/security/src/test/groovy/io/micronaut/security/audit/AuditingCustomIdentitySpec.groovy @@ -0,0 +1,167 @@ +package io.micronaut.security.audit + +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Requires +import io.micronaut.core.async.publisher.Publishers +import io.micronaut.core.convert.ConversionContext +import io.micronaut.core.convert.TypeConverter +import io.micronaut.data.jdbc.annotation.JdbcRepository +import io.micronaut.data.model.query.builder.sql.Dialect +import io.micronaut.data.repository.CrudRepository +import io.micronaut.http.HttpMethod +import io.micronaut.http.HttpRequest +import io.micronaut.http.annotation.Body +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Post +import io.micronaut.http.annotation.Put +import io.micronaut.http.client.BlockingHttpClient +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.security.annotation.Secured +import io.micronaut.security.authentication.Authentication +import io.micronaut.security.filters.AuthenticationFetcher +import io.micronaut.security.rules.SecurityRule +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import jakarta.inject.Singleton +import org.reactivestreams.Publisher +import spock.lang.Specification + +import java.security.Principal + +@Property(name = "datasources.default.dialect", value = "H2") +@Property(name = "datasources.default.schema-generate", value = "CREATE_DROP") +@Property(name = "datasources.default.url", value = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE") +@Property(name = "datasources.default.username", value = "sa") +@Property(name = "datasources.default.driver-class-name", value = "org.h2.Driver") +@Property(name = "spec.name", value = "AuditingCustomIdentitySpec") +@MicronautTest(transactional = false) +class AuditingCustomIdentitySpec extends Specification { + + @Inject + MessageRepository messageRepository + + @Inject + @Client("/") + HttpClient httpClient + + void "createdBy and updatedBy are populated automatically with a custom converter"() { + given: + BlockingHttpClient client = httpClient.toBlocking() + + when: + Message message = client.retrieve(HttpRequest.POST("/messages", [title: 'FooBar']), Message.class) + + then: + noExceptionThrown() + messageRepository.count() == old(messageRepository.count()) + 1 + message.title == "FooBar" + message.creator == "SHERLOCK" + message.lastModifiedBy == "SHERLOCK" + + when: + message.title = "FooBaz" + Message updatedMessage = client.retrieve(HttpRequest.PUT("/messages", message), Message.class) + + then: + noExceptionThrown() + messageRepository.count() == old(messageRepository.count()) + updatedMessage.title == "FooBaz" + updatedMessage.creator == "SHERLOCK" + updatedMessage.lastModifiedBy == "WATSON" + } + + void "updatedBy is still auto populated on update with a custom converter if createdBy is null"() { + given: + BlockingHttpClient client = httpClient.toBlocking() + + when: + Message message = client.retrieve(HttpRequest.POST("/messages/unsecured", [title: 'FooBar']), Message.class) + + then: + noExceptionThrown() + messageRepository.count() == old(messageRepository.count()) + 1 + message.title == "FooBar" + !message.creator + !message.lastModifiedBy + + when: + message.title = "FooBaz" + Message updatedMessage = client.retrieve(HttpRequest.PUT("/messages", message), Message.class) + + then: + noExceptionThrown() + messageRepository.count() == old(messageRepository.count()) + updatedMessage.title == "FooBaz" + !updatedMessage.creator + updatedMessage.lastModifiedBy == "WATSON" + } + + @Requires(property = "spec.name", value = "AuditingCustomIdentitySpec") + @Singleton + static class CustomPrincipalToStringConverter implements TypeConverter { + @Override + Optional convert(Principal principal, Class targetType, ConversionContext context) { + Optional.ofNullable(principal.getName()).map(identity -> identity.toUpperCase()) + } + } + + @Requires(property = "spec.name", value = "AuditingCustomIdentitySpec") + @Controller("/messages") + static class MessageController { + private final MessageRepository messageRepository + + MessageController(MessageRepository messageRepository) { + this.messageRepository = messageRepository + } + + @Secured(SecurityRule.IS_AUTHENTICATED) + @Post + Message save(@Body Message body) { + messageRepository.save(body) + } + + @Secured(SecurityRule.IS_AUTHENTICATED) + @Put + Message update(@Body Message body) { + messageRepository.update(body) + } + + @Secured(SecurityRule.IS_ANONYMOUS) + @Post("/unsecured") + Message saveUnsecured(@Body Message body) { + messageRepository.save(body) + } + + @Secured(SecurityRule.IS_ANONYMOUS) + @Put("/unsecured") + Message updateUnsecured(@Body Message body) { + messageRepository.update(body) + } + } + + @Requires(property = "spec.name", value = "AuditingCustomIdentitySpec") + @Singleton + static class FooAuthenticationFetcher implements AuthenticationFetcher { + + @Override + Publisher fetchAuthentication(HttpRequest request) { + if (request.uri.toString().endsWith("/unsecured")) { + return Publishers.empty() + } + + if (request.method == HttpMethod.POST) { + return Publishers.just(Authentication.build("sherlock")) + } else if (request.method == HttpMethod.PUT) { + return Publishers.just(Authentication.build("watson")) + } + + return Publishers.empty() + } + } + + @Requires(property = "spec.name", value = "AuditingCustomIdentitySpec") + @JdbcRepository(dialect = Dialect.H2) + static interface MessageRepository extends CrudRepository { + } +} diff --git a/security/src/test/groovy/io/micronaut/security/audit/AuditingEmptyConversionDoesNotOverrideSpec.groovy b/security/src/test/groovy/io/micronaut/security/audit/AuditingEmptyConversionDoesNotOverrideSpec.groovy new file mode 100644 index 0000000000..69b4a784ef --- /dev/null +++ b/security/src/test/groovy/io/micronaut/security/audit/AuditingEmptyConversionDoesNotOverrideSpec.groovy @@ -0,0 +1,119 @@ +package io.micronaut.security.audit + +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Requires +import io.micronaut.core.async.publisher.Publishers +import io.micronaut.core.convert.ConversionContext +import io.micronaut.core.convert.TypeConverter +import io.micronaut.data.annotation.Query +import io.micronaut.data.jdbc.annotation.JdbcRepository +import io.micronaut.data.model.query.builder.sql.Dialect +import io.micronaut.data.repository.CrudRepository +import io.micronaut.http.HttpMethod +import io.micronaut.http.HttpRequest +import io.micronaut.http.annotation.Body +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Put +import io.micronaut.http.client.BlockingHttpClient +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.security.annotation.Secured +import io.micronaut.security.authentication.Authentication +import io.micronaut.security.filters.AuthenticationFetcher +import io.micronaut.security.rules.SecurityRule +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import jakarta.inject.Singleton +import org.reactivestreams.Publisher +import spock.lang.Specification + +import java.security.Principal + +@Property(name = "datasources.default.dialect", value = "H2") +@Property(name = "datasources.default.schema-generate", value = "CREATE_DROP") +@Property(name = "datasources.default.url", value = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE") +@Property(name = "datasources.default.username", value = "sa") +@Property(name = "datasources.default.driver-class-name", value = "org.h2.Driver") +@Property(name = "spec.name", value = "AuditingFailingConverterSpec") +@MicronautTest(transactional = false) +class AuditingEmptyConversionDoesNotOverrideSpec extends Specification { + @Inject + MessageRepository messageRepository + + @Inject + @Client("/") + HttpClient httpClient + + void "empty conversion does not override fields annotated with @CreatedBy or @UpdatedBy"() { + given: + BlockingHttpClient client = httpClient.toBlocking() + + when: + Message message = messageRepository.save(new Message(id: null, title: "FooBar", creator: "moriarty", lastModifiedBy: "moriarty")) + + then: + message.creator == "moriarty" + message.lastModifiedBy == "moriarty" + + Message updatedMessage = client.retrieve(HttpRequest.PUT("/messages", new Message(id: message.id, title: "FooBaz")), Message.class) + + then: + noExceptionThrown() + messageRepository.count() == old(messageRepository.count()) + updatedMessage.title == "FooBaz" + updatedMessage.creator == "moriarty" + updatedMessage.lastModifiedBy == "moriarty" + } + + @Requires(property = "spec.name", value = "AuditingFailingConverterSpec") + @Singleton + static class CustomPrincipalToStringConverter implements TypeConverter { + @Override + Optional convert(Principal principal, Class targetType, ConversionContext context) { + Optional.empty() + } + } + + @Requires(property = "spec.name", value = "AuditingFailingConverterSpec") + @Controller("/messages") + static class MessageController { + private final MessageRepository messageRepository + + MessageController(MessageRepository messageRepository) { + this.messageRepository = messageRepository + } + + @Secured(SecurityRule.IS_AUTHENTICATED) + @Put + Message update(@Body Message body) { + if (body.id == null) { + return messageRepository.update(body) + } else { + messageRepository.update(body.id, body.title) + return messageRepository.findById(body.id).orElse(null) + } + } + + } + + @Requires(property = "spec.name", value = "AuditingFailingConverterSpec") + @Singleton + static class FooAuthenticationFetcher implements AuthenticationFetcher { + @Override + Publisher fetchAuthentication(HttpRequest request) { + if (request.method == HttpMethod.PUT) { + return Publishers.just(Authentication.build("sherlock")) + } + + return Publishers.empty() + } + } + + @Requires(property = "spec.name", value = "AuditingFailingConverterSpec") + @JdbcRepository(dialect = Dialect.H2) + static interface MessageRepository extends CrudRepository { + + @Query("UPDATE message SET title = :title WHERE id = :id") + void update(Long id, String title) + } +} diff --git a/security/src/test/groovy/io/micronaut/security/audit/AuditingSpec.groovy b/security/src/test/groovy/io/micronaut/security/audit/AuditingSpec.groovy new file mode 100644 index 0000000000..8fb61dba24 --- /dev/null +++ b/security/src/test/groovy/io/micronaut/security/audit/AuditingSpec.groovy @@ -0,0 +1,179 @@ +package io.micronaut.security.audit + +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Requires +import io.micronaut.core.async.publisher.Publishers +import io.micronaut.data.jdbc.annotation.JdbcRepository +import io.micronaut.data.model.query.builder.sql.Dialect +import io.micronaut.data.repository.CrudRepository +import io.micronaut.http.HttpMethod +import io.micronaut.http.HttpRequest +import io.micronaut.http.annotation.Body +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Post +import io.micronaut.http.annotation.Put +import io.micronaut.http.client.BlockingHttpClient +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.security.annotation.Secured +import io.micronaut.security.authentication.Authentication +import io.micronaut.security.filters.AuthenticationFetcher +import io.micronaut.security.rules.SecurityRule +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import jakarta.inject.Singleton +import org.reactivestreams.Publisher +import spock.lang.Specification + +@Property(name = "datasources.default.dialect", value = "H2") +@Property(name = "datasources.default.schema-generate", value = "CREATE_DROP") +@Property(name = "datasources.default.url", value = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE") +@Property(name = "datasources.default.username", value = "sa") +@Property(name = "datasources.default.driver-class-name", value = "org.h2.Driver") +@Property(name = "spec.name", value = "AuditingSpec") +@MicronautTest(transactional = false) +class AuditingSpec extends Specification { + @Inject + MessageRepository messageRepository + + @Inject + @Client("/") + HttpClient httpClient + + void "createdBy and updatedBy are populated automatically"() { + given: + BlockingHttpClient client = httpClient.toBlocking() + + when: + Message message = client.retrieve(HttpRequest.POST("/messages", [title: 'FooBar']), Message.class) + + then: + noExceptionThrown() + messageRepository.count() == old(messageRepository.count()) + 1 + message.title == "FooBar" + message.creator == "sherlock" + message.lastModifiedBy == "sherlock" + + when: + message.title = "FooBaz" + Message updatedMessage = client.retrieve(HttpRequest.PUT("/messages", message), Message.class) + + then: + noExceptionThrown() + messageRepository.count() == old(messageRepository.count()) + updatedMessage.title == "FooBaz" + updatedMessage.creator == "sherlock" + updatedMessage.lastModifiedBy == "watson" + } + + void "createdBy and updatedBy are not populated when not authenticated"() { + given: + BlockingHttpClient client = httpClient.toBlocking() + + when: + Message message = client.retrieve(HttpRequest.POST("/messages/unsecured", [title: 'FooBar']), Message.class) + + then: + noExceptionThrown() + messageRepository.count() == old(messageRepository.count()) + 1 + message.title == "FooBar" + !message.creator + !message.lastModifiedBy + + when: + message.title = "FooBaz" + Message updatedMessage = client.retrieve(HttpRequest.PUT("/messages/unsecured", message), Message.class) + + then: + noExceptionThrown() + messageRepository.count() == old(messageRepository.count()) + updatedMessage.title == "FooBaz" + !updatedMessage.creator + !updatedMessage.lastModifiedBy + } + + void "updatedBy is still auto populated on update if createdBy is null"() { + given: + BlockingHttpClient client = httpClient.toBlocking() + + when: + Message message = client.retrieve(HttpRequest.POST("/messages/unsecured", [title: 'FooBar']), Message.class) + + then: + noExceptionThrown() + messageRepository.count() == old(messageRepository.count()) + 1 + message.title == "FooBar" + !message.creator + !message.lastModifiedBy + + when: + message.title = "FooBaz" + Message updatedMessage = client.retrieve(HttpRequest.PUT("/messages", message), Message.class) + + then: + noExceptionThrown() + messageRepository.count() == old(messageRepository.count()) + updatedMessage.title == "FooBaz" + !updatedMessage.creator + updatedMessage.lastModifiedBy == "watson" + } + + @Requires(property = "spec.name", value = "AuditingSpec") + @Controller("/messages") + static class MessageController { + private final MessageRepository messageRepository + + MessageController(MessageRepository messageRepository) { + this.messageRepository = messageRepository + } + + @Secured(SecurityRule.IS_AUTHENTICATED) + @Post + Message save(@Body Message body) { + messageRepository.save(body) + } + + @Secured(SecurityRule.IS_AUTHENTICATED) + @Put + Message update(@Body Message body) { + messageRepository.update(body) + } + + @Secured(SecurityRule.IS_ANONYMOUS) + @Post("/unsecured") + Message saveUnsecured(@Body Message body) { + messageRepository.save(body) + } + + @Secured(SecurityRule.IS_ANONYMOUS) + @Put("/unsecured") + Message updateUnsecured(@Body Message body) { + messageRepository.update(body) + } + } + + @Requires(property = "spec.name", value = "AuditingSpec") + @Singleton + static class FooAuthenticationFetcher implements AuthenticationFetcher { + + @Override + Publisher fetchAuthentication(HttpRequest request) { + if (request.uri.toString().endsWith("/unsecured")) { + return Publishers.empty() + } + + if (request.method == HttpMethod.POST) { + return Publishers.just(Authentication.build("sherlock")) + } else if (request.method == HttpMethod.PUT) { + return Publishers.just(Authentication.build("watson")) + } + + return Publishers.empty() + } + } + + @Requires(property = "spec.name", value = "AuditingSpec") + @JdbcRepository(dialect = Dialect.H2) + static interface MessageRepository extends CrudRepository { + } +} diff --git a/security/src/test/groovy/io/micronaut/security/audit/CreatedByUpdatedByPlusNullableSpec.groovy b/security/src/test/groovy/io/micronaut/security/audit/CreatedByUpdatedByPlusNullableSpec.groovy new file mode 100644 index 0000000000..0725b2a8c0 --- /dev/null +++ b/security/src/test/groovy/io/micronaut/security/audit/CreatedByUpdatedByPlusNullableSpec.groovy @@ -0,0 +1,131 @@ +package io.micronaut.security.audit + +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Requires +import io.micronaut.core.annotation.Nullable +import io.micronaut.core.async.publisher.Publishers +import io.micronaut.data.annotation.GeneratedValue +import io.micronaut.data.annotation.Id +import io.micronaut.data.annotation.MappedEntity +import io.micronaut.data.jdbc.annotation.JdbcRepository +import io.micronaut.data.model.query.builder.sql.Dialect +import io.micronaut.data.repository.CrudRepository +import io.micronaut.http.HttpRequest +import io.micronaut.http.annotation.Body +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Post +import io.micronaut.http.client.BlockingHttpClient +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.security.annotation.CreatedBy +import io.micronaut.security.annotation.Secured +import io.micronaut.security.annotation.UpdatedBy +import io.micronaut.security.authentication.Authentication +import io.micronaut.security.filters.AuthenticationFetcher +import io.micronaut.security.rules.SecurityRule +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import jakarta.inject.Singleton +import jakarta.validation.constraints.NotBlank +import org.reactivestreams.Publisher +import spock.lang.Specification + +@Property(name = "datasources.default.dialect", value = "H2") +@Property(name = "datasources.default.schema-generate", value = "CREATE_DROP") +@Property(name = "datasources.default.url", value = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE") +@Property(name = "datasources.default.username", value = "sa") +@Property(name = "datasources.default.driver-class-name", value = "org.h2.Driver") +@Property(name = "spec.name", value = "CreatedByUpdatedByPlusNullableSpec") +@MicronautTest(transactional = false) +class CreatedByUpdatedByPlusNullableSpec extends Specification { + @Inject + MessageRepository messageRepository + + @Inject + @Client("/") + HttpClient httpClient + + void "@CreatedBy @UpdatedBy in combination with @Nullable"() { + given: + String username = 'sherlock' + String title = "FooBar" + BlockingHttpClient client = httpClient.toBlocking() + + when: + HttpRequest request = HttpRequest.POST("/messages/authenticated", new Message(title: title)) + Message message = client.retrieve(request, Message) + + then: + noExceptionThrown() + message + username == message.creator + username == message.lastModifiedBy + + when: + request = HttpRequest.POST("/messages/anonymous", new Message(title: title)) + message = client.retrieve(request, Message) + + then: + noExceptionThrown() + message + title == message.title + !message.creator + !message.lastModifiedBy + } + + @Requires(property = "spec.name", value = "CreatedByUpdatedByPlusNullableSpec") + @Controller("/messages") + @Secured(SecurityRule.IS_ANONYMOUS) + static class MessageController { + private final MessageRepository messageRepository + + MessageController(MessageRepository messageRepository) { + this.messageRepository = messageRepository + } + + @Post("/anonymous") + Message anonymous(@Body Message body) { + messageRepository.save(body) + } + + @Post("/authenticated") + Message authenticated(@Body Message body) { + messageRepository.save(body) + } + } + + @Requires(property = "spec.name", value = "CreatedByUpdatedByPlusNullableSpec") + @Singleton + static class FooAuthenticationFetcher implements AuthenticationFetcher { + @Override + Publisher fetchAuthentication(HttpRequest request) { + request.path.contains("authenticated") + ? Publishers.just(Authentication.build("sherlock")) + : Publishers.empty() + } + } + + @Requires(property = "spec.name", value = "CreatedByUpdatedByPlusNullableSpec") + @JdbcRepository(dialect = Dialect.H2) + static interface MessageRepository extends CrudRepository { + } + + @MappedEntity("message") + static class Message { + @Id + @GeneratedValue + @Nullable + Long id + + @NotBlank + String title + + @CreatedBy + @Nullable + String creator + + @UpdatedBy + @Nullable + String lastModifiedBy + } +} diff --git a/security/src/test/groovy/io/micronaut/security/audit/CreatedByUpdatedByTypeNoConverterSpec.groovy b/security/src/test/groovy/io/micronaut/security/audit/CreatedByUpdatedByTypeNoConverterSpec.groovy new file mode 100644 index 0000000000..61c39caf90 --- /dev/null +++ b/security/src/test/groovy/io/micronaut/security/audit/CreatedByUpdatedByTypeNoConverterSpec.groovy @@ -0,0 +1,112 @@ +package io.micronaut.security.audit + +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Requires +import io.micronaut.core.annotation.Nullable +import io.micronaut.core.async.publisher.Publishers +import io.micronaut.data.annotation.GeneratedValue +import io.micronaut.data.annotation.Id +import io.micronaut.data.annotation.MappedEntity +import io.micronaut.data.jdbc.annotation.JdbcRepository +import io.micronaut.data.model.query.builder.sql.Dialect +import io.micronaut.data.repository.CrudRepository +import io.micronaut.http.HttpRequest +import io.micronaut.http.annotation.Body +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Post +import io.micronaut.http.client.BlockingHttpClient +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.security.annotation.CreatedBy +import io.micronaut.security.annotation.Secured +import io.micronaut.security.annotation.UpdatedBy +import io.micronaut.security.authentication.Authentication +import io.micronaut.security.filters.AuthenticationFetcher +import io.micronaut.security.rules.SecurityRule +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import jakarta.inject.Singleton +import jakarta.validation.constraints.NotBlank +import org.reactivestreams.Publisher +import spock.lang.Specification + +@Property(name = "datasources.default.dialect", value = "H2") +@Property(name = "datasources.default.schema-generate", value = "CREATE_DROP") +@Property(name = "datasources.default.url", value = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE") +@Property(name = "datasources.default.username", value = "sa") +@Property(name = "datasources.default.driver-class-name", value = "org.h2.Driver") +@Property(name = "spec.name", value = "CreatedByUpdatedByTypeNoConverterSpec") +@MicronautTest(transactional = false) +class CreatedByUpdatedByTypeNoConverterSpec extends Specification { + @Inject + MessageRepository messageRepository + + @Inject + @Client("/") + HttpClient httpClient + + void "@CreatedBy @UpdatedBy in combination with @Nullable"() { + given: + String title = "FooBar" + BlockingHttpClient client = httpClient.toBlocking() + HttpRequest request = HttpRequest.POST("/messages/authenticated", new Message(title: title)) + + when: + client.retrieve(request, Message) + + then: + noExceptionThrown() + messageRepository.count() == old(messageRepository.count()) + } + + @Requires(property = "spec.name", value = "CreatedByUpdatedByTypeNoConverterSpec") + @Controller("/messages") + @Secured(SecurityRule.IS_ANONYMOUS) + static class MessageController { + private final MessageRepository messageRepository + + MessageController(MessageRepository messageRepository) { + this.messageRepository = messageRepository + } + + @Post("/authenticated") + Message authenticated(@Body Message body) { + messageRepository.save(body) + } + } + + @Requires(property = "spec.name", value = "CreatedByUpdatedByTypeNoConverterSpec") + @Singleton + static class FooAuthenticationFetcher implements AuthenticationFetcher { + @Override + Publisher fetchAuthentication(HttpRequest request) { + request.path.contains("authenticated") + ? Publishers.just(Authentication.build("sherlock")) + : Publishers.empty() + } + } + + @Requires(property = "spec.name", value = "CreatedByUpdatedByTypeNoConverterSpec") + @JdbcRepository(dialect = Dialect.H2) + static interface MessageRepository extends CrudRepository { + } + + @MappedEntity("message") + static class Message { + @Id + @GeneratedValue + @Nullable + Long id + + @NotBlank + String title + + @CreatedBy + @Nullable + Long creator + + @UpdatedBy + @Nullable + Long lastModifiedBy + } +} diff --git a/security/src/test/groovy/io/micronaut/security/audit/Message.groovy b/security/src/test/groovy/io/micronaut/security/audit/Message.groovy new file mode 100644 index 0000000000..f06e4c3fd3 --- /dev/null +++ b/security/src/test/groovy/io/micronaut/security/audit/Message.groovy @@ -0,0 +1,28 @@ +package io.micronaut.security.audit + +import io.micronaut.core.annotation.Nullable +import io.micronaut.data.annotation.GeneratedValue +import io.micronaut.data.annotation.Id +import io.micronaut.data.annotation.MappedEntity +import io.micronaut.security.annotation.CreatedBy +import io.micronaut.security.annotation.UpdatedBy +import jakarta.validation.constraints.NotBlank + +@MappedEntity +class Message { + @Id + @GeneratedValue + @Nullable + Long id + + @NotBlank + String title + + @CreatedBy + @Nullable + String creator + + @UpdatedBy + @Nullable + String lastModifiedBy +} diff --git a/src/main/docs/guide/dataAuditing.adoc b/src/main/docs/guide/dataAuditing.adoc new file mode 100644 index 0000000000..fd73642c8c --- /dev/null +++ b/src/main/docs/guide/dataAuditing.adoc @@ -0,0 +1 @@ +Micronaut Security provides integration with https://micronaut-projects.github.io/micronaut-data/latest/guide/[Micronaut Data] for automatically capturing the identity of the user that created or updated a particular entity. diff --git a/src/main/docs/guide/dataAuditing/annotations.adoc b/src/main/docs/guide/dataAuditing/annotations.adoc new file mode 100644 index 0000000000..f089423778 --- /dev/null +++ b/src/main/docs/guide/dataAuditing/annotations.adoc @@ -0,0 +1,6 @@ +The annotations ann:security.annotation.CreatedBy[] and ann:security.annotation.UpdatedBy[] annotations are provided for application to your Micronaut Data entities. The annotated fields will be automatically populated with the currently authenticated user's identity. For example: + +snippet::io.micronaut.security.audit.docs.createdby.Book[tags="clazz"] +<1> The class is mapped and persisted by Micronaut Data +<2> The creator field will be populated on `save()` +<3> The editor field will be populated on both `save()` and `update()` diff --git a/src/main/docs/guide/dataAuditing/identityMapping.adoc b/src/main/docs/guide/dataAuditing/identityMapping.adoc new file mode 100644 index 0000000000..235f220d23 --- /dev/null +++ b/src/main/docs/guide/dataAuditing/identityMapping.adoc @@ -0,0 +1,7 @@ +A api:security.audit.PrincipalToStringConverter[] is provided to map the current api:security.Authentication[] object to the annotated String fields. The default implementation maps the value of `Principal.getName()` to the fields. To customize this mapping, you can provide your own `TypeConverter` implementation that replaces `PrincipalToStringConverter`. For example: + +snippet::io.micronaut.security.audit.docs.customconverter.AuthenticationToStringConverter[tags="clazz"] +<1> Conversion between `Authentication` and `String` is implemented +<2> The implementation maps a custom attribute to the auto-populated identity + +The type conversion mechanism could also be used to map `Authentication` to more complex field types other than String, such as a custom domain-specific `User` object. diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index 9f0faa79c9..611729400a 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -75,6 +75,10 @@ retrievingAuthenticatedUser: title: Retrieve the authenticated user customAuthenticatedUser: Custom Binding securityService: User outside of a controller +dataAuditing: + title: Data Change Auditing + annotations: Auditing Annotations + identityMapping: Custom Authentication Mapping securityEvents: Security Events oauth: title: OAuth 2.0 diff --git a/test-suite-groovy/build.gradle b/test-suite-groovy/build.gradle index be0139fa30..58be41cb69 100644 --- a/test-suite-groovy/build.gradle +++ b/test-suite-groovy/build.gradle @@ -23,7 +23,14 @@ dependencies { testImplementation(mnReactor.micronaut.reactor) testCompileOnly(projects.micronautSecurityAnnotations) testCompileOnly(mnSerde.micronaut.serde.processor) - testImplementation(mnSerde.micronaut.serde.jackson)} + testImplementation(mnSerde.micronaut.serde.jackson) + + testCompileOnly(mnData.micronaut.data.processor) + testImplementation(mnData.micronaut.data.jdbc) + testImplementation(mnSql.h2) + testImplementation(mnSql.micronaut.jdbc.hikari) +} + tasks.named('test') { useJUnitPlatform() diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/security/audit/docs/createdby/Book.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/security/audit/docs/createdby/Book.groovy new file mode 100644 index 0000000000..d7aec63e1a --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/security/audit/docs/createdby/Book.groovy @@ -0,0 +1,31 @@ +package io.micronaut.security.audit.docs.createdby +//tag::clazz[] +import io.micronaut.core.annotation.Nullable +import io.micronaut.data.annotation.GeneratedValue +import io.micronaut.data.annotation.Id +import io.micronaut.data.annotation.MappedEntity +import io.micronaut.security.annotation.CreatedBy +import io.micronaut.security.annotation.UpdatedBy +import jakarta.validation.constraints.NotBlank + +@MappedEntity //1 +class Book { + + @Id + @GeneratedValue + @Nullable + Long id + + @NotBlank + String title + + @NotBlank + String author + + @CreatedBy //2 + String creator + + @UpdatedBy //3 + String editor +} +//end::clazz[] diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/security/audit/docs/createdby/UserAuditingSpec.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/security/audit/docs/createdby/UserAuditingSpec.groovy new file mode 100644 index 0000000000..a34d3519af --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/security/audit/docs/createdby/UserAuditingSpec.groovy @@ -0,0 +1,74 @@ +package io.micronaut.security.audit.docs.createdby + +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Replaces +import io.micronaut.context.annotation.Requires +import io.micronaut.data.jdbc.annotation.JdbcRepository +import io.micronaut.data.model.query.builder.sql.Dialect +import io.micronaut.data.repository.CrudRepository +import io.micronaut.security.authentication.Authentication +import io.micronaut.security.utils.DefaultSecurityService +import io.micronaut.security.utils.SecurityService +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import jakarta.inject.Singleton +import spock.lang.Specification + +@Property(name = "datasources.default.dialect", value = "H2") +@Property(name = "datasources.default.schema-generate", value = "CREATE_DROP") +@Property(name = "datasources.default.url", value = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE") +@Property(name = "datasources.default.username", value = "sa") +@Property(name = "datasources.default.driver-class-name", value = "org.h2.Driver") +@Property(name = "spec.name", value = "UserAuditingSpec") +@MicronautTest(transactional = false) +class UserAuditingSpec extends Specification { + + @Inject + BookRepository bookRepository + + def "createdBy and updatedBy are populated on save"() { + given: + Book book = new Book() + book.title = "Tropic of Cancer" + book.author = "Henry Miller" + + when: + book = bookRepository.save(book) + + then: + book.id + book.creator == "sherlock" + book.editor == "sherlock" + } + + @Requires(property = "spec.name", value = "UserAuditingSpec") + @Replaces(DefaultSecurityService.class) + @Singleton + static class MockSecurityService implements SecurityService { + + @Override + Optional username() { + Optional.of("sherlock") + } + + @Override + Optional getAuthentication() { + Optional.of(Authentication.build(username().orElseThrow())) + } + + @Override + boolean isAuthenticated() { + return true + } + + @Override + boolean hasRole(String role) { + return false + } + } + + @Requires(property = "spec.name", value = "UserAuditingSpec") + @JdbcRepository(dialect = Dialect.H2) + static interface BookRepository extends CrudRepository { + } +} diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/security/audit/docs/customconverter/AuthenticationToStringConverter.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/security/audit/docs/customconverter/AuthenticationToStringConverter.groovy new file mode 100644 index 0000000000..2811f5e341 --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/security/audit/docs/customconverter/AuthenticationToStringConverter.groovy @@ -0,0 +1,20 @@ +package io.micronaut.security.audit.docs.customconverter + +//tag::clazz[] +import io.micronaut.context.annotation.Requires +import io.micronaut.core.convert.ConversionContext +import io.micronaut.core.convert.TypeConverter +import io.micronaut.security.authentication.Authentication +import jakarta.inject.Singleton + +//end::clazz[] +@Requires(property = "spec.name", value = "CustomPrincipalConverterSpec") +//tag::clazz[] +@Singleton +class AuthenticationToStringConverter implements TypeConverter { // <1> + @Override + Optional convert(Authentication authentication, Class targetType, ConversionContext context) { + Optional.ofNullable(authentication.attributes.get("CUSTOM_ID_ATTR")).map(Object::toString) // <2> + } +} +//end::clazz[] diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/security/audit/docs/customconverter/CustomPrincipalConverterSpec.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/security/audit/docs/customconverter/CustomPrincipalConverterSpec.groovy new file mode 100644 index 0000000000..114b1a9864 --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/security/audit/docs/customconverter/CustomPrincipalConverterSpec.groovy @@ -0,0 +1,77 @@ +package io.micronaut.security.audit.docs.customconverter + +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Replaces +import io.micronaut.context.annotation.Requires +import io.micronaut.data.jdbc.annotation.JdbcRepository +import io.micronaut.data.model.query.builder.sql.Dialect +import io.micronaut.data.repository.CrudRepository +import io.micronaut.security.audit.docs.createdby.Book +import io.micronaut.security.authentication.Authentication +import io.micronaut.security.utils.DefaultSecurityService +import io.micronaut.security.utils.SecurityService +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import jakarta.inject.Singleton +import spock.lang.Specification + +@Property(name = "datasources.default.dialect", value = "H2") +@Property(name = "datasources.default.schema-generate", value = "CREATE_DROP") +@Property(name = "datasources.default.url", value = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE") +@Property(name = "datasources.default.username", value = "sa") +@Property(name = "datasources.default.driver-class-name", value = "org.h2.Driver") +@Property(name = "spec.name", value = "CustomPrincipalConverterSpec") +@MicronautTest(transactional = false) +class CustomPrincipalConverterSpec extends Specification { + + @Inject + BookRepository bookRepository + + def "createdBy and updatedBy are populated on save"() { + given: + Book book = new Book() + book.title = "Tropic of Cancer" + book.author = "Henry Miller" + + when: + book = bookRepository.save(book) + + then: + book.id + book.creator == "my_unique_identifier" + book.editor == "my_unique_identifier" + } + + @Requires(property = "spec.name", value = "CustomPrincipalConverterSpec") + @Replaces(DefaultSecurityService.class) + @Singleton + static class MockSecurityService implements SecurityService { + + @Override + Optional username() { + Optional.of("sherlock") + } + + @Override + Optional getAuthentication() { + Optional.of(Authentication.build(username().orElseThrow(), [ + "CUSTOM_ID_ATTR" : "my_unique_identifier" + ])) + } + + @Override + boolean isAuthenticated() { + return true + } + + @Override + boolean hasRole(String role) { + return false + } + } + + @Requires(property = "spec.name", value = "CustomPrincipalConverterSpec") + @JdbcRepository(dialect = Dialect.H2) + static interface BookRepository extends CrudRepository { + } +} diff --git a/test-suite-kotlin/build.gradle b/test-suite-kotlin/build.gradle index e21c437d58..4c9ccfc60e 100644 --- a/test-suite-kotlin/build.gradle +++ b/test-suite-kotlin/build.gradle @@ -26,6 +26,11 @@ dependencies { testImplementation(mn.jackson.databind) kaptTest(mnSerde.micronaut.serde.processor) testImplementation(mnSerde.micronaut.serde.jackson) + + kaptTest(mnData.micronaut.data.processor) + testImplementation(mnData.micronaut.data.jdbc) + testImplementation(mnSql.h2) + testImplementation(mnSql.micronaut.jdbc.hikari) } tasks.named('test') { diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/security/audit/docs/createdby/Book.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/audit/docs/createdby/Book.kt new file mode 100644 index 0000000000..9fdf96ebd3 --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/audit/docs/createdby/Book.kt @@ -0,0 +1,21 @@ +package io.micronaut.security.audit.docs.createdby +//tag::clazz[] +import io.micronaut.data.annotation.GeneratedValue +import io.micronaut.data.annotation.Id +import io.micronaut.data.annotation.MappedEntity +import io.micronaut.security.annotation.CreatedBy +import io.micronaut.security.annotation.UpdatedBy + +@MappedEntity //1 +data class Book( + @field:Id + @field:GeneratedValue(GeneratedValue.Type.AUTO) + var id: Long? = null, + var title: String, + var author: String, + @field:CreatedBy //2 + var creator: String? = null, + @UpdatedBy //3 + var editor: String? = null +) +//end::clazz[] diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/security/audit/docs/createdby/UserAuditingTest.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/audit/docs/createdby/UserAuditingTest.kt new file mode 100644 index 0000000000..04da36eb69 --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/audit/docs/createdby/UserAuditingTest.kt @@ -0,0 +1,53 @@ +package io.micronaut.security.audit.docs.createdby + +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Replaces +import io.micronaut.context.annotation.Requires +import io.micronaut.data.jdbc.annotation.JdbcRepository +import io.micronaut.data.model.query.builder.sql.Dialect +import io.micronaut.data.repository.CrudRepository +import io.micronaut.security.authentication.Authentication +import io.micronaut.security.utils.DefaultSecurityService +import io.micronaut.security.utils.SecurityService +import io.micronaut.test.extensions.junit5.annotation.MicronautTest +import jakarta.inject.Inject +import jakarta.inject.Singleton +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import java.util.* + +@Property(name = "datasources.default.dialect", value = "H2") +@Property(name = "datasources.default.schema-generate", value = "CREATE_DROP") +@Property(name = "datasources.default.url", value = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE") +@Property(name = "datasources.default.username", value = "sa") +@Property(name = "datasources.default.driver-class-name", value = "org.h2.Driver") +@Property(name = "spec.name", value = "UserAuditingTest") +@MicronautTest(transactional = false) +class UserAuditingTest { + + @Inject + lateinit var bookRepository: BookRepository + + @Test + fun testCreatedByUpdatedByPopulatedOnSave() { + var book = Book(null, "Tropic of Cancer", "Henry Miller", null, null) + book = bookRepository.save(book) + Assertions.assertNotNull(book.id) + Assertions.assertEquals("sherlock", book.creator) + Assertions.assertEquals("sherlock", book.editor) + } + + @Requires(property = "spec.name", value = "UserAuditingTest") + @Replaces(DefaultSecurityService::class) + @Singleton + internal class MockSecurityService : SecurityService { + override fun username(): Optional = Optional.of("sherlock") + override fun getAuthentication(): Optional =Optional.of(Authentication.build(username().orElseThrow())) + override fun isAuthenticated(): Boolean = true + override fun hasRole(role: String): Boolean = false + } + + @Requires(property = "spec.name", value = "UserAuditingTest") + @JdbcRepository(dialect = Dialect.H2) + interface BookRepository : CrudRepository +} diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/security/audit/docs/customconverter/AuthenticationToStringConverter.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/audit/docs/customconverter/AuthenticationToStringConverter.kt new file mode 100644 index 0000000000..8aa922b87a --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/audit/docs/customconverter/AuthenticationToStringConverter.kt @@ -0,0 +1,24 @@ +package io.micronaut.security.audit.docs.customconverter + +//tag::clazz[] +import io.micronaut.context.annotation.Requires +import io.micronaut.core.convert.ConversionContext +import io.micronaut.core.convert.TypeConverter +import io.micronaut.security.authentication.Authentication +import jakarta.inject.Singleton +import java.util.* + +//end::clazz[] +@Requires(property = "spec.name", value = "CustomPrincipalConverterTest") +//tag::clazz[] +@Singleton +class AuthenticationToStringConverter : TypeConverter { // <1> + override fun convert( + authentication: Authentication, + targetType: Class, + context: ConversionContext + ): Optional { + return Optional.ofNullable(authentication.getAttributes()["CUSTOM_ID_ATTR"]).map { obj -> obj.toString() } // <3> + } +} +//end::clazz[] diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/security/audit/docs/customconverter/CustomPrincipalConverterTest.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/audit/docs/customconverter/CustomPrincipalConverterTest.kt new file mode 100644 index 0000000000..254f3c75c2 --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/audit/docs/customconverter/CustomPrincipalConverterTest.kt @@ -0,0 +1,59 @@ +package io.micronaut.security.audit.docs.customconverter + +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Replaces +import io.micronaut.context.annotation.Requires +import io.micronaut.data.jdbc.annotation.JdbcRepository +import io.micronaut.data.model.query.builder.sql.Dialect +import io.micronaut.data.repository.CrudRepository +import io.micronaut.security.audit.docs.createdby.Book +import io.micronaut.security.audit.docs.createdby.UserAuditingTest +import io.micronaut.security.authentication.Authentication +import io.micronaut.security.utils.DefaultSecurityService +import io.micronaut.security.utils.SecurityService +import io.micronaut.test.extensions.junit5.annotation.MicronautTest +import jakarta.inject.Inject +import jakarta.inject.Singleton +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import java.util.* + +@Property(name = "datasources.default.dialect", value = "H2") +@Property(name = "datasources.default.schema-generate", value = "CREATE_DROP") +@Property(name = "datasources.default.url", value = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE") +@Property(name = "datasources.default.username", value = "sa") +@Property(name = "datasources.default.driver-class-name", value = "org.h2.Driver") +@Property(name = "spec.name", value = "CustomPrincipalConverterTest") +@MicronautTest(transactional = false) +class CustomPrincipalConverterTest { + + @Inject + lateinit var bookRepository: BookRepository + + @Test + fun testCreatedByUpdatedByPopulatedOnSave() { + var book = Book(null, "Tropic of Cancer", "Henry Miller", null, null) + book = bookRepository.save(book) + Assertions.assertNotNull(book.id) + Assertions.assertEquals("my_unique_identifier", book.creator) + Assertions.assertEquals("my_unique_identifier", book.editor) + } + + @Requires(property = "spec.name", value = "CustomPrincipalConverterTest") + @Replaces( + DefaultSecurityService::class + ) + @Singleton + class MockSecurityService : SecurityService { + override fun username(): Optional = Optional.of("sherlock") + + override fun getAuthentication(): Optional = + Optional.of(Authentication.build(username().orElseThrow(), mapOf("CUSTOM_ID_ATTR" to "my_unique_identifier"))) + override fun isAuthenticated(): Boolean = true + override fun hasRole(role: String): Boolean = false + } + + @Requires(property = "spec.name", value = "CustomPrincipalConverterTest") + @JdbcRepository(dialect = Dialect.H2) + interface BookRepository : CrudRepository +} diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/security/oauth2/docs/github/GithubAuthenticationMapper.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/oauth2/docs/github/GithubAuthenticationMapper.kt index 29efab3aef..6e6c09253f 100644 --- a/test-suite-kotlin/src/test/kotlin/io/micronaut/security/oauth2/docs/github/GithubAuthenticationMapper.kt +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/oauth2/docs/github/GithubAuthenticationMapper.kt @@ -1,6 +1,7 @@ package io.micronaut.security.oauth2.docs.github //tag::clazz[] +import io.micronaut.context.annotation.Requires import io.micronaut.security.authentication.AuthenticationResponse import io.micronaut.security.oauth2.endpoint.authorization.state.State import io.micronaut.security.oauth2.endpoint.token.response.OauthAuthenticationMapper @@ -11,6 +12,9 @@ import org.reactivestreams.Publisher import reactor.core.publisher.Flux @Named("github") // <1> +//end::clazz[] +@Requires(property = "docs.classes") +//tag::clazz[] @Singleton internal class GithubAuthenticationMapper(private val apiClient: GithubApiClient) // <2> : OauthAuthenticationMapper { diff --git a/test-suite/build.gradle b/test-suite/build.gradle index f3b470e795..4063cf8951 100644 --- a/test-suite/build.gradle +++ b/test-suite/build.gradle @@ -23,6 +23,11 @@ dependencies { testAnnotationProcessor(mnSerde.micronaut.serde.processor) testCompileOnly(mn.jackson.databind) testImplementation(mnSerde.micronaut.serde.jackson) + + testAnnotationProcessor(mnData.micronaut.data.processor) + testImplementation(mnData.micronaut.data.jdbc) + testImplementation(mnSql.h2) + testImplementation(mnSql.micronaut.jdbc.hikari) } tasks.named('test') { diff --git a/test-suite/src/test/java/io/micronaut/security/audit/docs/createdby/Book.java b/test-suite/src/test/java/io/micronaut/security/audit/docs/createdby/Book.java new file mode 100644 index 0000000000..083d7ca645 --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/security/audit/docs/createdby/Book.java @@ -0,0 +1,32 @@ +package io.micronaut.security.audit.docs.createdby; +//tag::clazz[] +import io.micronaut.core.annotation.Nullable; +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; +import io.micronaut.security.annotation.CreatedBy; +import io.micronaut.security.annotation.UpdatedBy; +import jakarta.validation.constraints.NotBlank; + +@MappedEntity //1 +public record Book( + @Id + @GeneratedValue + @Nullable + Long id, + + @NotBlank + String title, + + @NotBlank + String author, + + @Nullable + @CreatedBy //2 + String creator, + + @Nullable + @UpdatedBy //3 + String editor) { +} +//end::clazz[] diff --git a/test-suite/src/test/java/io/micronaut/security/audit/docs/createdby/UserAuditingTest.java b/test-suite/src/test/java/io/micronaut/security/audit/docs/createdby/UserAuditingTest.java new file mode 100644 index 0000000000..f40a9b1fa9 --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/security/audit/docs/createdby/UserAuditingTest.java @@ -0,0 +1,69 @@ +package io.micronaut.security.audit.docs.createdby; + +import io.micronaut.context.annotation.Property; +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.annotation.Requires; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.utils.DefaultSecurityService; +import io.micronaut.security.utils.SecurityService; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +@Property(name = "datasources.default.dialect", value = "H2") +@Property(name = "datasources.default.schema-generate", value = "CREATE_DROP") +@Property(name = "datasources.default.url", value = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE") +@Property(name = "datasources.default.username", value = "sa") +@Property(name = "datasources.default.driver-class-name", value = "org.h2.Driver") +@Property(name = "spec.name", value = "UserAuditingTest") +@MicronautTest(transactional = false) +public class UserAuditingTest { + + @Test + void testCreatedByUpdatedByPopulatedOnSave(BookRepository bookRepository) { + Book book = new Book(null, "Tropic of Cancer", "Henry Miller", null, null); + book = bookRepository.save(book); + Assertions.assertNotNull(book.id()); + Assertions.assertEquals("sherlock", book.creator()); + Assertions.assertEquals("sherlock", book.editor()); + } + + @Requires(property = "spec.name", value = "UserAuditingTest") + @Replaces(DefaultSecurityService.class) + @Singleton + static class MockSecurityService implements SecurityService { + + @Override + public Optional username() { + return Optional.of("sherlock"); + } + + @Override + public Optional getAuthentication() { + return Optional.of(Authentication.build(username().orElseThrow())); + } + + @Override + public boolean isAuthenticated() { + return true; + } + + @Override + public boolean hasRole(String role) { + return false; + } + } + + @Requires(property = "spec.name", value = "UserAuditingTest") + @JdbcRepository(dialect = Dialect.H2) + interface BookRepository extends CrudRepository { + } + +} diff --git a/test-suite/src/test/java/io/micronaut/security/audit/docs/customconverter/AuthenticationToStringConverter.java b/test-suite/src/test/java/io/micronaut/security/audit/docs/customconverter/AuthenticationToStringConverter.java new file mode 100644 index 0000000000..0396de0e0f --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/security/audit/docs/customconverter/AuthenticationToStringConverter.java @@ -0,0 +1,22 @@ +package io.micronaut.security.audit.docs.customconverter; + +//tag::clazz[] +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.convert.ConversionContext; +import io.micronaut.core.convert.TypeConverter; +import io.micronaut.security.authentication.Authentication; +import jakarta.inject.Singleton; + +import java.util.Optional; + +//end::clazz[] +@Requires(property = "spec.name", value = "CustomPrincipalConverterTest") +//tag::clazz[] +@Singleton +public class AuthenticationToStringConverter implements TypeConverter { // <1> + @Override + public Optional convert(Authentication authentication, Class targetType, ConversionContext context) { + return Optional.ofNullable(authentication.getAttributes().get("CUSTOM_ID_ATTR")).map(Object::toString); // <2> + } +} +//end::clazz[] diff --git a/test-suite/src/test/java/io/micronaut/security/audit/docs/customconverter/CustomPrincipalConverterTest.java b/test-suite/src/test/java/io/micronaut/security/audit/docs/customconverter/CustomPrincipalConverterTest.java new file mode 100644 index 0000000000..50d0349adc --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/security/audit/docs/customconverter/CustomPrincipalConverterTest.java @@ -0,0 +1,78 @@ +package io.micronaut.security.audit.docs.customconverter; + +import io.micronaut.context.annotation.Property; +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.annotation.Requires; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; +import io.micronaut.security.audit.docs.createdby.Book; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.utils.DefaultSecurityService; +import io.micronaut.security.utils.SecurityService; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Property(name = "datasources.default.dialect", value = "H2") +@Property(name = "datasources.default.schema-generate", value = "CREATE_DROP") +@Property(name = "datasources.default.url", value = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE") +@Property(name = "datasources.default.username", value = "sa") +@Property(name = "datasources.default.driver-class-name", value = "org.h2.Driver") +@Property(name = "spec.name", value = "CustomPrincipalConverterTest") +@MicronautTest(transactional = false) +public class CustomPrincipalConverterTest { + + @Inject + private BookRepository bookRepository; + + @Test + void testCreatedByUpdatedByPopulatedOnSave() { + Book book = new Book(null, "Tropic of Cancer", "Henry Miller", null, null); + + book = bookRepository.save(book); + + Assertions.assertNotNull(book.id()); + Assertions.assertEquals("my_unique_identifier", book.creator()); + Assertions.assertEquals("my_unique_identifier", book.editor()); + } + + @Requires(property = "spec.name", value = "CustomPrincipalConverterTest") + @Replaces(DefaultSecurityService.class) + @Singleton + public static class MockSecurityService implements SecurityService { + @Override + public Optional username() { + return Optional.of("sherlock"); + } + + @Override + public Optional getAuthentication() { + Map attrs = new HashMap<>(); + attrs.put("CUSTOM_ID_ATTR", "my_unique_identifier"); + return Optional.of(Authentication.build(username().orElseThrow(), attrs)); + } + + @Override + public boolean isAuthenticated() { + return true; + } + + @Override + public boolean hasRole(String role) { + return false; + } + + } + + @Requires(property = "spec.name", value = "CustomPrincipalConverterTest") + @JdbcRepository(dialect = Dialect.H2) + interface BookRepository extends CrudRepository { + } +} diff --git a/test-suite/src/test/java/io/micronaut/security/oauth2/docs/github/GithubAuthenticationMapper.java b/test-suite/src/test/java/io/micronaut/security/oauth2/docs/github/GithubAuthenticationMapper.java index 4c27af3bf7..3c1895748b 100644 --- a/test-suite/src/test/java/io/micronaut/security/oauth2/docs/github/GithubAuthenticationMapper.java +++ b/test-suite/src/test/java/io/micronaut/security/oauth2/docs/github/GithubAuthenticationMapper.java @@ -2,6 +2,7 @@ //tag::clazz[] +import io.micronaut.context.annotation.Requires; import io.micronaut.core.annotation.Nullable; import io.micronaut.security.authentication.AuthenticationResponse; import io.micronaut.security.oauth2.endpoint.authorization.state.State; @@ -9,12 +10,16 @@ import io.micronaut.security.oauth2.endpoint.token.response.TokenResponse; import jakarta.inject.Named; import jakarta.inject.Singleton; -import java.util.Collections; -import java.util.List; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; +import java.util.Collections; +import java.util.List; + @Named("github") // <1> +//end::clazz[] +@Requires(property = "docs.classes") +//tag::clazz[] @Singleton class GithubAuthenticationMapper implements OauthAuthenticationMapper {