more optional validations

This commit is contained in:
gowthaman.b
2023-11-11 13:10:25 +05:30
parent ea14212337
commit 31388bae59
9 changed files with 441 additions and 68 deletions

View File

@@ -1,22 +1,55 @@
package com.restapi
import com.restapi.config.AppConfig.Companion.appConfig
import com.restapi.domain.EntityModel
import io.javalin.http.Context
import io.javalin.http.Handler
import io.javalin.http.HttpStatus
import io.javalin.security.AccessManager
import io.javalin.security.RouteRole
import org.slf4j.LoggerFactory
import com.restapi.domain.Session.currentRoles
import com.restapi.domain.Session.database
class AppAccessManager : AccessManager {
private val logger = LoggerFactory.getLogger("Access")
private fun loadEntityActionRole(entity: String?, action: String?): List<String> {
if (entity == null || action == null) return emptyList()
return database.find(EntityModel::class.java)
.where()
.eq("name", entity)
.findOne()?.actions
?.filter { it.equals(action, ignoreCase = true) }
?.map { "role_${entity}_$it" } ?: emptyList()
}
override fun manage(handler: Handler, ctx: Context, routeRoles: Set<RouteRole>) {
logger.warn("access {}, {}", ctx.pathParamMap(), routeRoles)
val pathParamMap = ctx.pathParamMap()
logger.warn("access {}, {}", pathParamMap, routeRoles)
val regex = Regex("^[a-zA-Z0-9\\-_\\.]+$")
if(ctx.pathParamMap().values.count { !regex.matches(it) } > 0){
ctx.status(HttpStatus.FORBIDDEN).result("invalid request")
if (pathParamMap.values.count { !regex.matches(it) } > 0) {
ctx.status(HttpStatus.FORBIDDEN).result("invalid request")
} else {
handler.handle(ctx)
val entity = pathParamMap["entity"]
val action = pathParamMap["action"]
val allowedRoles = routeRoles.map { it as Role }.flatMap {
when (it) {
Role.DbOps -> listOf("ROLE_DB_OPS")
Role.Entity -> loadEntityActionRole(entity, action)
is Role.Standard -> listOf("ROLE_${entity}_${it.action}")
}.map(String::uppercase)
}
val isAllowed = currentRoles().count { allowedRoles.contains(it) } > 0
if (isAllowed || !appConfig.enforceRoleRestriction() || allowedRoles.isEmpty()) {
//if role is allowed, or enforcement is turned off or no roles are explicitly allowed
handler.handle(ctx)
} else {
ctx.status(HttpStatus.UNAUTHORIZED).result("unauthorized request")
}
}
}
}

View File

@@ -8,11 +8,9 @@ import com.restapi.config.Auth.getAuthEndpoint
import com.restapi.config.Auth.parseAuthToken
import com.restapi.controllers.Entities
import com.restapi.domain.DataNotFoundException
import com.restapi.domain.Session.database
import com.restapi.domain.Session.objectMapper
import com.restapi.domain.Session.redis
import com.restapi.domain.Session.setAuthorizedUser
import io.ebean.CallableSql
import io.ebean.DuplicateKeyException
import io.javalin.Javalin
import io.javalin.apibuilder.ApiBuilder.*
@@ -20,7 +18,6 @@ import io.javalin.http.*
import io.javalin.http.util.NaiveRateLimit
import io.javalin.http.util.RateLimitUtil
import io.javalin.json.JavalinJackson
import io.javalin.security.AccessManager
import io.javalin.security.RouteRole
import org.slf4j.LoggerFactory
import java.net.URI
@@ -99,11 +96,15 @@ fun main(args: Array<String>) {
}
before("/api/*") { ctx ->
//validate, auth token
NaiveRateLimit.requestPerTimeUnit(ctx, appConfig.rateLimit().getOrDefault(30), TimeUnit.MINUTES) // throws if rate limit is exceeded
NaiveRateLimit.requestPerTimeUnit(
ctx,
appConfig.rateLimit().getOrDefault(30),
TimeUnit.MINUTES
)
val authToken = ctx.header("Authorization")?.replace("Bearer ", "")
val authToken = ctx.header("Authorization")
?.replace("Bearer ", "")
?.replace("Bearer: ", "")
?.trim() ?: throw UnauthorizedResponse()
@@ -111,21 +112,29 @@ fun main(args: Array<String>) {
setAuthorizedUser(parseAuthToken(authToken = authToken))
}
val adminRole = Role.Standard(Action.ADMIN)
val viewRole = Role.Standard(Action.VIEW)
val createRole = Role.Standard(Action.CREATE)
val updateRole = Role.Standard(Action.UPDATE)
val approveOrRejectRole = Role.Standard(Action.APPROVE)
path("/api") {
post("/execute/{name}", Entities::executeStoredProcedure, Roles(Role.DbOps))
post("/execute/{name}", Entities::executeStoredProcedure, Roles(adminRole, Role.DbOps))
post("/script/{name}", Entities::executeScript, Roles(adminRole, Role.DbOps))
get("/{entity}/{id}", Entities::view, Roles(Role.Standard(Action.VIEW)))
post("/{entity}/query/{id}", Entities::sqlQueryId, Roles(Role.Standard(Action.VIEW)))
post("/{entity}/query", Entities::sqlQueryRaw, Roles(Role.Standard(Action.VIEW)))
post("/{entity}", Entities::create, Roles(Role.Standard(Action.CREATE)))
get("/{entity}/{id}", Entities::view, Roles(adminRole, viewRole))
post("/{entity}/query/{id}", Entities::sqlQueryById, Roles(adminRole, viewRole))
post("/{entity}/query", Entities::sqlQueryRaw, Roles(adminRole, viewRole))
post("/{entity}", Entities::create, Roles(adminRole, createRole))
put("/{entity}/approve/{id}", Entities::approve, Roles(Role.Standard(Action.APPROVE)))
put("/{entity}/reject/{id}", Entities::reject, Roles(Role.Standard(Action.APPROVE)))
put("/{entity}/{action}/{id}", Entities::action, Roles(Role.Entity))
put("/{entity}/approve/{id}", Entities::approve, Roles(adminRole, approveOrRejectRole))
put("/{entity}/reject/{id}", Entities::reject, Roles(adminRole, approveOrRejectRole))
put("/{entity}/{action}/{id}", Entities::action, Roles(adminRole, Role.Entity))
put("/{entity}/{id}", Entities::update, Roles(Role.Standard(Action.UPDATE)))
patch("/{entity}/{id}", Entities::patch, Roles(Role.Standard(Action.UPDATE)))
delete("/{entity}/{id}", Entities::delete, Roles(Role.Standard(Action.DELETE)))
put("/{entity}/{id}", Entities::update, Roles(adminRole, updateRole))
patch("/{entity}/{id}", Entities::patch, Roles(adminRole, updateRole))
delete("/{entity}/{id}", Entities::delete, Roles(adminRole, Role.Standard(Action.DELETE)))
}
@@ -163,7 +172,7 @@ fun main(args: Array<String>) {
enum class Action {
CREATE, VIEW, UPDATE, DELETE, APPROVE
CREATE, VIEW, UPDATE, DELETE, APPROVE, ADMIN
}
sealed class Role {

View File

@@ -66,6 +66,13 @@ interface AppConfig {
@Key("app.network.rate_limit")
fun rateLimit(): Optional<Int>
@Key("app.security.enforce_role_restriction")
@Default("true")
fun enforceRoleRestriction(): Boolean
@Key("app.scripts.path")
fun scriptsPath(): String
companion object {
val appConfig: AppConfig = ConfigFactory.builder().build().create(AppConfig::class.java)
}

View File

@@ -1,16 +1,23 @@
package com.restapi.controllers
import com.restapi.domain.ApprovalStatus
import com.restapi.domain.DataModel
import com.restapi.domain.EntityModel
import com.restapi.domain.Session
import com.restapi.domain.Session.database
import com.restapi.domain.Session.findByEntityAndId
import com.restapi.integ.Scripting
import io.ebean.CallableSql
import io.ebean.RawSqlBuilder
import io.javalin.http.BadRequestResponse
import io.javalin.http.Context
import io.javalin.http.NotFoundResponse
import io.javalin.http.bodyAsClass
import org.slf4j.LoggerFactory
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
data class PatchValue(val key: String, val value: Any)
data class Query(
@@ -46,6 +53,14 @@ object Entities {
fun approve(ctx: Context) {}
fun reject(ctx: Context) {}
fun executeScript(ctx: Context) {
val name = ctx.pathParam("name")
val params = ctx.bodyAsClass<Map<String, Any>>()
ctx.json(
Scripting.execute(name, params)
)
}
fun executeStoredProcedure(ctx: Context) {
val name = ctx.pathParam("name")
val params = ctx.bodyAsClass<Map<String, Any>>()
@@ -58,7 +73,12 @@ object Entities {
cs.setParameter(index + 1, entry.value)
}
cs.setParameter(params.entries.size + 1, Session.currentTenant())
database.execute(cs)
val done = database.execute(cs)
ctx.json(
mapOf(
"done" to done
)
)
}
fun sqlQueryRaw(ctx: Context) {
@@ -77,7 +97,7 @@ object Entities {
}
fun sqlQueryId(ctx: Context) {
fun sqlQueryById(ctx: Context) {
val sql = ctx.bodyAsClass<Query>()
val query = database.findByEntityAndId(ctx.pathParam("entity"), ctx.pathParam("id"))
@@ -104,16 +124,112 @@ object Entities {
}
fun create(ctx: Context) {
val entity = ctx.pathParam("entity")
val seqCreated = Session.creatSeq(entity)
logger.debug("sequence created for $entity? = $seqCreated")
database.save(
ctx.bodyAsClass<DataModel>().apply {
this.entityName = entity
if (this.uniqueIdentifier.isEmpty()) {
this.uniqueIdentifier = Session.nextUniqId(entity)
}
//may be approval flow is configured?
val setupEntity = database.find(EntityModel::class.java)
.where()
.eq("name", entity)
.findOne()
Session.creatSeq(entity)
val dataModel = ctx.bodyAsClass<DataModel>().apply {
this.entityName = entity
if (this.uniqueIdentifier.isEmpty()) {
this.uniqueIdentifier = Session.nextUniqId(entity)
}
)
}
database.save(
dataModel.apply {
if (setupEntity != null) {
val allowedFields = setupEntity.allowedFields.map { it.lowercase() }
if (allowedFields.isNotEmpty()) {
val moreFields =
dataModel.data.keys.map { it.lowercase() }.filter { !allowedFields.contains(it) }
if (moreFields.isNotEmpty()) {
logger.warn("Data Keys = ${dataModel.data.keys} is more than $allowedFields, extra fields = $moreFields")
throw BadRequestResponse("data contains more fields than allowed")
}
setupEntity.allowedFieldTypes.forEach { (key, expectedType) ->
val valueFromUser = dataModel.data[key] ?: return@forEach
val isDate = expectedType.equals("date", ignoreCase = true)
val isDateTime = expectedType.equals("datetime", ignoreCase = true)
val isTime = expectedType.equals("time", ignoreCase = true)
if (isDate || isDateTime || isTime) {
//this should be a string of a particular format
if (valueFromUser !is String) {
throw BadRequestResponse("field $key, is of type ${valueFromUser.javaClass.simpleName} expected $expectedType")
} else {
val dtPattern = Regex("^\\d{4}-\\d{2}-\\d{2}$")
val dtmPattern = Regex("^\\d{4}-\\d{2}-\\d{2}\\s\\d{2}:\\d{2}$")
val timePattern = Regex("^\\d{2}:\\d{2}$")
if (isDate
&& !dtPattern.matches(valueFromUser)
&& !isValidDate(valueFromUser)
) {
throw BadRequestResponse("field $key, is of type ${valueFromUser.javaClass.simpleName} expected $expectedType")
}
if (isDateTime
&& !dtmPattern.matches(valueFromUser)
&& !isValidDateTime(valueFromUser)
) {
throw BadRequestResponse("field $key, is of type ${valueFromUser.javaClass.simpleName} expected $expectedType")
}
if (isTime
&& !timePattern.matches(valueFromUser)
&& !isValidTime(valueFromUser)
) {
throw BadRequestResponse("field $key, is of type ${valueFromUser.javaClass.simpleName} expected $expectedType")
}
}
}
if (valueFromUser.javaClass.simpleName != expectedType) {
throw BadRequestResponse("field $key, is of type ${valueFromUser.javaClass.simpleName} expected $expectedType")
}
}
}
if (setupEntity.approvalLevels > 0) {
this.approvalStatus = ApprovalStatus.PENDING
this.requiredApprovalLevels = setupEntity.approvalLevels
}
}
})
}
private fun isValidDate(f: String) = try {
LocalDate.parse(f, DateTimeFormatter.ofPattern("yyyy-MM-dd"))
true
} catch (e: Exception) {
false
}
private fun isValidDateTime(f: String) = try {
LocalDateTime.parse(f, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
true
} catch (e: Exception) {
false
}
private fun isValidTime(f: String) = try {
LocalTime.parse(f, DateTimeFormatter.ofPattern("HH:mm"))
true
} catch (e: Exception) {
false
}
}

View File

@@ -47,6 +47,7 @@ object Session {
fun currentUser() = currentUser.get().userName
fun currentTenant() = currentUser.get().tenant
fun currentRoles() = currentUser.get().roles
fun Database.findByEntityAndId(entity: String, id: String): DataModel {
return find(DataModel::class.java)

View File

@@ -20,6 +20,10 @@ import javax.persistence.*
data class Comments(val text: String = "", val by: String = "", val at: LocalDateTime = LocalDateTime.now())
enum class ApprovalStatus {
PENDING, APPROVED, REJECTED
}
@MappedSuperclass
abstract class BaseModel : Model() {
@Id
@@ -51,6 +55,13 @@ abstract class BaseModel : Model() {
var deletedBy: String? = null
var currentApprovalLevel: Int = 0
var requiredApprovalLevels: Int = 0
@Enumerated(EnumType.STRING)
var approvalStatus: ApprovalStatus = ApprovalStatus.PENDING
@DbArray
var tags: MutableList<String> = arrayListOf()
@@ -70,11 +81,15 @@ open class TenantModel : BaseModel() {
}
enum class AuditType {
CREATE, UPDATE, DELETE, VIEW
CREATE, UPDATE, DELETE, VIEW, APPROVE, REJECT
}
@Entity
@Index(columnNames = ["audit_type", "entity", "unique_identifier", "tenant_id", "created_by"])
open class AuditLog : BaseModel() {
@Enumerated(EnumType.STRING)
var auditType: AuditType = AuditType.CREATE
var entity: String = ""
var uniqueIdentifier: String = ""
@@ -108,6 +123,10 @@ open class EntityModel : BaseModel() {
@DbArray
var allowedFields: List<String> = emptyList()
//enforce field types, if this is present, only fields that are present is validated
@DbJsonB
var allowedFieldTypes: Map<String, String> = hashMapOf()
//when an entity is saved/updated audit logs will be populated, when this is empty, all fields are logged
@DbArray
var auditLogFields: List<String> = emptyList()
@@ -116,7 +135,7 @@ open class EntityModel : BaseModel() {
var preferences: MutableMap<String, Any> = hashMapOf()
//if '0' then its auto saved, no approval steps are required, for further steps,
//a user needs to have ROLE_ENTITY_APPROVE_LEVEL1, ROLE_ENTITY_APPROVE_LEVEL2 roles
//a user needs to have ROLE_ENTITY_APPROVE_LEVEL1, ROLE_ENTITY_APPROVE_LEVEL2 roles for further approvals
var approvalLevels: Int = 0
}

View File

@@ -1,43 +1,17 @@
package com.restapi.integ
import com.restapi.config.AppConfig.Companion.appConfig
import java.io.File
import javax.script.Invocable
import javax.script.ScriptEngineManager
object Scripting {
const val a = "1"
@JvmStatic
fun main(args: Array<String>) {
k()
}
fun k(){
fun execute(name: String, params: Map<String, Any>): Any {
val engine = ScriptEngineManager().getEngineByExtension("kts")!!
val res1 = engine.eval("""
fun fn(x: Int) = x + 2
val obj = object {
fun fn1(x: Int) = x + 3
}
obj""".trimIndent())
println(res1)
val invocator = engine as? Invocable
println(invocator)
try {
println(invocator!!.invokeFunction("fn1", 3))
} catch (e: NoSuchMethodException) {
println(e)
}
val res2 = invocator!!.invokeFunction("fn", 3)
println(res2)
val res3 = invocator.invokeMethod(res1, "fn1", 3)
println(res3)
engine.eval(File(appConfig.scriptsPath(), "$name.kts").reader())
val invocator = engine as Invocable
return invocator.invokeFunction("execute", params)
}
}