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

1415: Revoke existing cards via user import endpoint #1674

Merged
merged 7 commits into from
Oct 28, 2024
9 changes: 7 additions & 2 deletions administration/src/errors/DefaultErrorMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,14 @@ const defaultErrorMap = (extensions?: ErrorExtensions): GraphQLErrorMessage => {
return {
title: 'Diese Rolle kann nicht zugewiesen werden.',
}
case GraphQlExceptionCode.InvalidUserEntitlements:
case GraphQlExceptionCode.UserEntitlementNotFound:
return {
title: 'Sie sind scheinbar nicht berechtigt einen KoblenzPass zu erstellen. Bitte prüfen Sie Ihre Eingaben',
title:
'Wir konnten Ihre Angaben nicht im System finden. Bitte überprüfen Sie Ihre Angaben und versuchen Sie es erneut.',
}
case GraphQlExceptionCode.UserEntitlementExpired:
return {
title: 'Sie sind nicht mehr berechtigt einen KoblenzPass zu erstellen.',
}
case GraphQlExceptionCode.MailNotSent:
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ import app.ehrenamtskarte.backend.exception.service.UnauthorizedException
import app.ehrenamtskarte.backend.exception.webservice.exceptions.InvalidCardHashException
import app.ehrenamtskarte.backend.exception.webservice.exceptions.InvalidInputException
import app.ehrenamtskarte.backend.exception.webservice.exceptions.InvalidQrCodeSize
import app.ehrenamtskarte.backend.exception.webservice.exceptions.InvalidUserEntitlementsException
import app.ehrenamtskarte.backend.exception.webservice.exceptions.RegionNotActivatedForCardConfirmationMailException
import app.ehrenamtskarte.backend.exception.webservice.exceptions.RegionNotFoundException
import app.ehrenamtskarte.backend.exception.webservice.exceptions.UserEntitlementExpiredException
import app.ehrenamtskarte.backend.exception.webservice.exceptions.UserEntitlementNotFoundException
import app.ehrenamtskarte.backend.mail.Mailer
import app.ehrenamtskarte.backend.matomo.Matomo
import app.ehrenamtskarte.backend.regions.database.repos.RegionsRepository
Expand Down Expand Up @@ -145,9 +146,12 @@ class CardMutationService {
)
val userHash = Argon2IdHasher.hashKoblenzUserData(user)

val userEntitlements = transaction { UserEntitlementsRepository.findUserEntitlements(userHash.toByteArray()) }
if (userEntitlements == null || userEntitlements.revoked || userEntitlements.endDate.isBefore(LocalDate.now())) {
throw InvalidUserEntitlementsException()
val userEntitlements = transaction { UserEntitlementsRepository.findByUserHash(userHash.toByteArray()) }
if (userEntitlements == null) {
throw UserEntitlementNotFoundException()
}
if (userEntitlements.revoked || userEntitlements.endDate.isBefore(LocalDate.now())) {
throw UserEntitlementExpiredException()
}

val updatedCardInfo = enrichCardInfo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,29 +55,29 @@ class WebService {
val healthHandler = HealthHandler(config)
val userImportHandler = UserImportHandler(config)

app.post("/") { ctx ->
app.post("/") { context ->
if (!production) {
ctx.header("Access-Control-Allow-Headers: Authorization")
ctx.header("Access-Control-Allow-Origin: *")
context.header("Access-Control-Allow-Headers: Authorization")
context.header("Access-Control-Allow-Origin: *")
}
graphQLHandler.handle(ctx, applicationData)
graphQLHandler.handle(context, applicationData)
}

app.get(mapStyleHandler.getPath()) { ctx ->
app.get(mapStyleHandler.getPath()) { context ->
if (!production) {
ctx.header("Access-Control-Allow-Headers: Authorization")
ctx.header("Access-Control-Allow-Origin: *")
context.header("Access-Control-Allow-Headers: Authorization")
context.header("Access-Control-Allow-Origin: *")
}
mapStyleHandler.handle(ctx)
mapStyleHandler.handle(context)
}

app.get(applicationHandler.getPath()) { ctx ->
applicationHandler.handle(ctx)
app.get(applicationHandler.getPath()) { context ->
applicationHandler.handle(context)
}

app.get("/health") { ctx -> healthHandler.handle(ctx) }
app.get("/health") { context -> healthHandler.handle(context) }

app.post("/users/import") { ctx -> userImportHandler.handle(ctx) }
app.post("/users/import") { context -> userImportHandler.handle(context) }

app.start(host, port)
println("Server is running at http://$host:$port")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ package app.ehrenamtskarte.backend.exception.webservice.exceptions
import app.ehrenamtskarte.backend.exception.GraphQLBaseException
import app.ehrenamtskarte.backend.exception.webservice.schema.GraphQLExceptionCode

class InvalidUserEntitlementsException : GraphQLBaseException(GraphQLExceptionCode.INVALID_USER_ENTITLEMENTS)
class UserEntitlementExpiredException : GraphQLBaseException(GraphQLExceptionCode.USER_ENTITLEMENT_EXPIRED)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package app.ehrenamtskarte.backend.exception.webservice.exceptions

import app.ehrenamtskarte.backend.exception.GraphQLBaseException
import app.ehrenamtskarte.backend.exception.webservice.schema.GraphQLExceptionCode

class UserEntitlementNotFoundException : GraphQLBaseException(GraphQLExceptionCode.USER_ENTITLEMENT_NOT_FOUND)
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ enum class GraphQLExceptionCode {
INVALID_PASSWORD_RESET_LINK,
INVALID_QR_CODE_SIZE,
INVALID_ROLE,
INVALID_USER_ENTITLEMENTS,
MAIL_NOT_SENT,
PASSWORD_RESET_KEY_EXPIRED,
REGION_NOT_FOUND,
REGION_NOT_ACTIVATED_FOR_APPLICATION,
REGION_NOT_ACTIVATED_CARD_CONFIRMATION_MAIL
REGION_NOT_ACTIVATED_CARD_CONFIRMATION_MAIL,
USER_ENTITLEMENT_NOT_FOUND,
USER_ENTITLEMENT_EXPIRED
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,34 @@
package app.ehrenamtskarte.backend.userdata.database

import org.jetbrains.exposed.sql.booleanLiteral
import org.jetbrains.exposed.sql.intLiteral
import org.jetbrains.exposed.sql.javatime.dateLiteral
import org.jetbrains.exposed.sql.javatime.timestampLiteral
import org.jetbrains.exposed.sql.upsert
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.update
import java.time.Instant
import java.time.LocalDate

object UserEntitlementsRepository {

fun insertOrUpdateUserData(userHash: ByteArray, startDate: LocalDate, endDate: LocalDate, revoked: Boolean, regionId: Int) {
UserEntitlements.upsert(
UserEntitlements.userHash,
onUpdate = listOf(
UserEntitlements.startDate to dateLiteral(startDate),
UserEntitlements.endDate to dateLiteral(endDate),
UserEntitlements.revoked to booleanLiteral(revoked),
UserEntitlements.regionId to intLiteral(regionId),
UserEntitlements.lastUpdated to timestampLiteral(Instant.now())
)
) {
fun insert(userHash: ByteArray, startDate: LocalDate, endDate: LocalDate, revoked: Boolean, regionId: Int) {
UserEntitlements.insert {
it[UserEntitlements.userHash] = userHash
it[UserEntitlements.startDate] = startDate
it[UserEntitlements.endDate] = endDate
it[UserEntitlements.revoked] = revoked
it[UserEntitlements.regionId] = regionId
it[lastUpdated] = Instant.now()
}
}

fun findUserEntitlements(userHash: ByteArray): UserEntitlementsEntity? {
fun update(userHash: ByteArray, startDate: LocalDate, endDate: LocalDate, revoked: Boolean, regionId: Int) {
UserEntitlements.update({ UserEntitlements.userHash eq userHash }) {
it[UserEntitlements.startDate] = startDate
it[UserEntitlements.endDate] = endDate
it[UserEntitlements.revoked] = revoked
it[UserEntitlements.regionId] = regionId
it[lastUpdated] = Instant.now()
}
}

fun findByUserHash(userHash: ByteArray): UserEntitlementsEntity? {
return UserEntitlementsEntity.find { UserEntitlements.userHash eq userHash }.firstOrNull()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@ import app.ehrenamtskarte.backend.auth.database.ApiTokenEntity
import app.ehrenamtskarte.backend.auth.database.PasswordCrypto
import app.ehrenamtskarte.backend.auth.database.repos.ApiTokensRepository
import app.ehrenamtskarte.backend.cards.Argon2IdHasher
import app.ehrenamtskarte.backend.cards.database.repos.CardRepository
import app.ehrenamtskarte.backend.config.BackendConfiguration
import app.ehrenamtskarte.backend.exception.service.ForbiddenException
import app.ehrenamtskarte.backend.exception.service.ProjectNotFoundException
import app.ehrenamtskarte.backend.exception.service.UnauthorizedException
import app.ehrenamtskarte.backend.projects.database.ProjectEntity
import app.ehrenamtskarte.backend.projects.database.Projects
import app.ehrenamtskarte.backend.regions.database.Regions
import app.ehrenamtskarte.backend.regions.database.repos.RegionsRepository
import app.ehrenamtskarte.backend.userdata.database.UserEntitlementsRepository
import app.ehrenamtskarte.backend.userdata.exception.UserImportException
import io.javalin.http.Context
import org.apache.commons.csv.CSVFormat
import org.apache.commons.csv.CSVParser
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.Logger
import org.slf4j.LoggerFactory
Expand All @@ -31,9 +31,9 @@ class UserImportHandler(
) {
private val logger: Logger = LoggerFactory.getLogger(UserImportHandler::class.java)

fun handle(ctx: Context) {
fun handle(context: Context) {
try {
val apiToken = authenticate(ctx)
val apiToken = authenticate(context)

val project = transaction { ProjectEntity.find { Projects.id eq apiToken.projectId }.single() }
val projectConfig = backendConfiguration.getProjectConfig(project.project)
Expand All @@ -42,7 +42,7 @@ class UserImportHandler(
throw UserImportException("User import is not enabled in the project")
}

val files = ctx.uploadedFiles("file")
val files = context.uploadedFiles("file")
when {
files.isEmpty() -> throw UserImportException("No file uploaded")
files.size > 1 -> throw UserImportException("Multiple files uploaded")
Expand All @@ -51,26 +51,27 @@ class UserImportHandler(

BufferedReader(InputStreamReader(file.content())).use { reader ->
getCSVParser(reader).use { csvParser ->
importData(csvParser, project.id.value)
validateHeaders(csvParser.headerNames)
importData(csvParser, project.project)
}
}
ctx.status(200).json(mapOf("message" to "Import successfully completed"))
context.status(200).json(mapOf("message" to "Import successfully completed"))
} catch (exception: UserImportException) {
ctx.status(400).json(mapOf("message" to exception.message))
context.status(400).json(mapOf("message" to exception.message))
} catch (exception: UnauthorizedException) {
ctx.status(401).json(mapOf("message" to exception.message))
context.status(401).json(mapOf("message" to exception.message))
} catch (exception: ForbiddenException) {
ctx.status(403).json(mapOf("message" to exception.message))
context.status(403).json(mapOf("message" to exception.message))
} catch (exception: ProjectNotFoundException) {
ctx.status(404).json(mapOf("message" to exception.message))
context.status(404).json(mapOf("message" to exception.message))
} catch (exception: Exception) {
logger.error("Failed to perform user import", exception)
ctx.status(500).json(mapOf("message" to "Internal error occurred"))
context.status(500).json(mapOf("message" to "Internal error occurred"))
}
}

private fun authenticate(ctx: Context): ApiTokenEntity {
val authHeader = ctx.header("Authorization")?.takeIf { it.startsWith("Bearer ") }
private fun authenticate(context: Context): ApiTokenEntity {
val authHeader = context.header("Authorization")?.takeIf { it.startsWith("Bearer ") }
?: throw UnauthorizedException()
val tokenHash = PasswordCrypto.hashWithSHA256(authHeader.substring(7).toByteArray())

Expand All @@ -91,23 +92,23 @@ class UserImportHandler(
)
}

private fun importData(csvParser: CSVParser, projectId: Int) {
val headers = csvParser.headerMap.keys
private fun validateHeaders(headers: List<String>) {
val requiredColumns = setOf("regionKey", "userHash", "startDate", "endDate", "revoked")
if (!headers.containsAll(requiredColumns)) {
throw UserImportException("Missing required columns: ${requiredColumns - headers}")
throw UserImportException("Missing required columns: ${requiredColumns - headers.toSet()}")
}
}

private fun importData(csvParser: CSVParser, project: String) {
transaction {
val regionsByProject = Regions.select { Regions.projectId eq projectId }
.associate { it[Regions.regionIdentifier] to it[Regions.id].value }
val regionsByProject = RegionsRepository.findAllInProject(project)

for (entry in csvParser) {
if (entry.toMap().size != csvParser.headerMap.size) {
throw UserImportException(entry.recordNumber, "Missing data")
}

val regionId = regionsByProject[entry.get("regionKey")]
val region = regionsByProject.singleOrNull { it.regionIdentifier == entry.get("regionKey") }
?: throw UserImportException(entry.recordNumber, "Specified region not found for the current project")

val userHash = entry.get("userHash")
Expand All @@ -121,16 +122,9 @@ class UserImportHandler(
throw UserImportException(entry.recordNumber, "Start date cannot be after end date")
}

val revoked = entry.get("revoked").toBooleanStrictOrNull()
?: throw UserImportException(entry.recordNumber, "Revoked must be a boolean value")
val revoked = parseRevoked(entry.get("revoked"), entry.recordNumber)

UserEntitlementsRepository.insertOrUpdateUserData(
userHash.toByteArray(),
startDate,
endDate,
revoked,
regionId
)
upsertUserEntitlement(userHash, startDate, endDate, revoked, region.id.value)
}
}
}
Expand All @@ -142,4 +136,21 @@ class UserImportHandler(
throw UserImportException(lineNumber, "Failed to parse date [$dateString]. Expected format: dd.MM.yyyy")
}
}

private fun parseRevoked(revokedString: String, lineNumber: Long): Boolean {
return revokedString.toBooleanStrictOrNull()
?: throw UserImportException(lineNumber, "Revoked must be a boolean value")
}

private fun upsertUserEntitlement(userHash: String, startDate: LocalDate, endDate: LocalDate, revoked: Boolean, regionId: Int) {
val userEntitlement = UserEntitlementsRepository.findByUserHash(userHash.toByteArray())
if (userEntitlement == null) {
UserEntitlementsRepository.insert(userHash.toByteArray(), startDate, endDate, revoked, regionId)
} else {
UserEntitlementsRepository.update(userHash.toByteArray(), startDate, endDate, revoked, regionId)
if (revoked) {
CardRepository.revokeByEntitlementId(userEntitlement.id.value)
}
}
}
}
Loading