more optional validations
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user