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
5 changes: 3 additions & 2 deletions administration/src/errors/DefaultErrorMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,10 @@ 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.MailNotSent:
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ 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.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 +145,9 @@ class CardMutationService {
)
val userHash = Argon2IdHasher.hashKoblenzUserData(user)

val userEntitlements = transaction { UserEntitlementsRepository.findUserEntitlements(userHash.toByteArray()) }
val userEntitlements = transaction { UserEntitlementsRepository.findByUserHash(userHash.toByteArray()) }
if (userEntitlements == null || userEntitlements.revoked || userEntitlements.endDate.isBefore(LocalDate.now())) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we have to throw different exceptions here. At least we should create a TODO and ticket if it will not be done here.
We have to distinguish between the input data does not match the entitlement hash then we throw UserEntitlementNotFoundException()

If revoked or expired i would throw
UserEntitlementExpiredException or sth similar with a separate message

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will need to associate a new exception with some error message.
Do you think Sie sind nicht mehr berechtigt, einen KoblenzPass zu erstellen is fine?

throw InvalidUserEntitlementsException()
throw UserEntitlementNotFoundException()
}

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 UserEntitlementNotFoundException : GraphQLBaseException(GraphQLExceptionCode.USER_ENTITLEMENT_NOT_FOUND)
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ 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
}
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