Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: skillLab integration #52

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .github/workflows/aam-backend-service-build-and-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ on:
push:
tags:
- "*"
branches:
- main
paths:
- '.github/workflows/aam-backend-service-build-and-publish.yml'
- 'application/aam-backend-service/**'
Expand Down Expand Up @@ -148,7 +146,6 @@ jobs:
with:
platforms: ${{ matrix.platform }}
context: ./application/aam-backend-service
target: build
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha
Expand Down
2 changes: 1 addition & 1 deletion application/aam-backend-service/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") // needed in some tests

runtimeOnly("org.postgresql:postgresql:42.7.4")
runtimeOnly("com.h2database:h2")

annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")

Expand Down Expand Up @@ -92,6 +91,7 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.springframework.security:spring-security-test")
testImplementation(kotlin("test"))
}

jacoco {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import java.util.*
Expand All @@ -13,11 +14,11 @@ import java.util.*
@ConfigurationPropertiesScan
@EnableScheduling
@EnableJpaRepositories
@EnableMethodSecurity
class Application

fun main(args: Array<String>) {
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))

runApplication<Application>(*args)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ class DefaultCouchDbClient(
) : CouchDbClient {

private val logger = LoggerFactory.getLogger(javaClass)



enum class DefaultCouchDbClientErrorCode : AamErrorCode {
INVALID_RESPONSE,
PARSING_ERROR,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import org.springframework.data.repository.CrudRepository
import org.springframework.stereotype.Repository
import java.util.*

@Entity
/**
* Store latest sync sequence for each database for change detection
*/
@Entity(name = "couchdb_sync_entry")
sleidig marked this conversation as resolved.
Show resolved Hide resolved
data class SyncEntry(
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import com.aamdigital.aambackendservice.reporting.domain.event.ReportCalculation
import com.aamdigital.aambackendservice.reporting.reportcalculation.core.ReportCalculationRequest
import com.aamdigital.aambackendservice.reporting.reportcalculation.di.ReportCalculationQueueConfiguration.Companion.REPORT_CALCULATION_EVENT_QUEUE
import com.aamdigital.aambackendservice.reporting.reportcalculation.usecase.DefaultReportCalculationUseCase
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.databind.ObjectMapper
import io.micrometer.observation.Observation
import io.micrometer.observation.ObservationRegistry
import org.slf4j.LoggerFactory
Expand All @@ -28,7 +28,8 @@ import org.springframework.stereotype.Component
)
class ReportCalculationEventListener(
val observationRegistry: ObservationRegistry,
val reportCalculationUseCase: DefaultReportCalculationUseCase
val reportCalculationUseCase: DefaultReportCalculationUseCase,
val objectMapper: ObjectMapper,
) {

private val logger = LoggerFactory.getLogger(javaClass)
Expand Down Expand Up @@ -63,7 +64,7 @@ class ReportCalculationEventListener(
response.cause
)

is UseCaseOutcome.Success -> logger.trace(jacksonObjectMapper().writeValueAsString(response))
is UseCaseOutcome.Success -> logger.trace(objectMapper.writeValueAsString(response))
}

}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.aamdigital.aambackendservice.rest

import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter


@Configuration
class ObjectMapperConfiguration {

@Bean
@Primary
fun objectMapper(): ObjectMapper {
val mapper = Jackson2ObjectMapperBuilder()
mapper.featuresToEnable(
DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT,
DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE
)
return mapper.build()
}

@Bean
fun mappingJackson2HttpMessageConverter(): MappingJackson2HttpMessageConverter {
val builder = Jackson2ObjectMapperBuilder()
builder.featuresToEnable(
DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT,
DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE
)
return MappingJackson2HttpMessageConverter(builder.build())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.aamdigital.aambackendservice.security

import org.springframework.core.convert.converter.Converter
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
import org.springframework.stereotype.Component

@Component
class AamAuthenticationConverter : Converter<Jwt, AbstractAuthenticationToken> {
override fun convert(source: Jwt): AbstractAuthenticationToken {
return JwtAuthenticationToken(source, getClientAuthorities(source))
}

private fun getClientAuthorities(jwt: Jwt): Collection<GrantedAuthority> {
val realmAccessClaim = jwt.getClaimAsMap("realm_access") ?: return emptyList()

val roles: List<String> = if (realmAccessClaim.containsKey("roles")) {
when (val rolesClaim = realmAccessClaim["roles"]) {
is List<*> -> rolesClaim.filterIsInstance<String>()
else -> emptyList()
}
} else {
emptyList()
}

return roles
.filter { it.startsWith("aam_") }
.map {
SimpleGrantedAuthority("ROLE_$it")
}
Comment on lines +30 to +33
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a bit lost here, looking at this as a new dev. Do we have any description about how the roles are used and mapped for us?

Is the "aam_" added internally or already do be defined in the Keycloak roles?
How to use the @PreAuthorize("hasAuthority('ROLE_aam_skill_admin')") in the following files: which parts are hard prefixes and which ones the name of the role in Keycloak? (Maybe we could have one annotation to check exactly against a keycloak role name to make this easier to understand?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"aam_skill_admin" is the name of the role in the token and in Keycloak. The "ROLE_" prefix is an spring boot convention, not really necessary.

}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.aamdigital.aambackendservice.security

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
Expand All @@ -16,7 +16,11 @@ import org.springframework.security.web.SecurityFilterChain
class SecurityConfiguration {

@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
fun filterChain(
http: HttpSecurity,
aamAuthenticationConverter: AamAuthenticationConverter,
objectMapper: ObjectMapper,
): SecurityFilterChain {
http {
authorizeRequests {
authorize(HttpMethod.GET, "/", permitAll)
Expand All @@ -40,22 +44,20 @@ class SecurityConfiguration {
authenticationEntryPoint =
AamAuthenticationEntryPoint(
parentEntryPoint = BearerTokenAuthenticationEntryPoint(),
objectMapper = jacksonObjectMapper()
objectMapper = objectMapper
)
}
oauth2ResourceServer {
jwt {
jwtAuthenticationConverter = aamAuthenticationConverter
authenticationEntryPoint =
AamAuthenticationEntryPoint(
parentEntryPoint = BearerTokenAuthenticationEntryPoint(),
objectMapper = jacksonObjectMapper()
objectMapper = objectMapper
)
}
}
}
return http.build()
}


}

Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.aamdigital.aambackendservice.skill.controller

import com.aamdigital.aambackendservice.error.HttpErrorDto
import com.aamdigital.aambackendservice.skill.core.FetchUserProfileUpdatesRequest
import com.aamdigital.aambackendservice.skill.core.FetchUserProfileUpdatesUseCase
import com.aamdigital.aambackendservice.skill.repository.SkillLabUserProfileSyncRepository
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.time.Instant
import java.time.ZoneOffset
import kotlin.jvm.optionals.getOrElse

data class SkillDto(
val projectId: String,
val latestSync: String,
)

enum class SyncModeDto {
DELTA,
FULL,
}

@RestController
@RequestMapping("/v1/skill")
@ConditionalOnProperty(
prefix = "features.skill-api",
name = ["mode"],
havingValue = "skilllab",
matchIfMissing = false
)
class SkillAdminController(
private val skillLabFetchUserProfileUpdatesUseCase: FetchUserProfileUpdatesUseCase,
private val skillLabUserProfileSyncRepository: SkillLabUserProfileSyncRepository,
) {

private val logger = LoggerFactory.getLogger(javaClass)

@GetMapping("/sync")
@PreAuthorize("hasAuthority('ROLE_skill_admin')")
fun fetchSyncStatus(): ResponseEntity<List<SkillDto>> {
val result = skillLabUserProfileSyncRepository.findAll().mapNotNull {
SkillDto(
projectId = it.projectId,
latestSync = it.latestSync.toString()
)
}

return ResponseEntity.ok().body(result)
}

/**
* Fetch data from external system and sync data.
* For details of parameters like syncMode, see docs/api-specs/skill-api-v1.yaml
*/
@PostMapping("/sync/{projectId}")
@PreAuthorize("hasAuthority('ROLE_skill_admin')")
fun triggerSync(
@PathVariable projectId: String,
syncMode: SyncModeDto = SyncModeDto.DELTA,
sleidig marked this conversation as resolved.
Show resolved Hide resolved
updatedFrom: String? = null,
): ResponseEntity<Any> {

val result = skillLabUserProfileSyncRepository.findByProjectId(projectId).getOrElse {
return ResponseEntity.notFound().build()
}

when (syncMode) {
SyncModeDto.DELTA -> if (!updatedFrom.isNullOrBlank()) {
result.latestSync = Instant.parse(updatedFrom).atOffset(ZoneOffset.UTC)
skillLabUserProfileSyncRepository.save(result)
}

SyncModeDto.FULL -> skillLabUserProfileSyncRepository.delete(result)
}

try {
skillLabFetchUserProfileUpdatesUseCase.run(
request = FetchUserProfileUpdatesRequest(
projectId = projectId
)
)
} catch (ex: Exception) {
sleidig marked this conversation as resolved.
Show resolved Hide resolved
logger.error(
"[${this.javaClass.name}] An error occurred: {}",
ex.localizedMessage,
ex
)
return ResponseEntity.internalServerError().body(
HttpErrorDto(
errorCode = "INTERNAL_SERVER_ERROR",
errorMessage = ex.localizedMessage,
)
)
}


return ResponseEntity.noContent().build()
}
}
Loading
Loading