Skip to content

Commit

Permalink
refactor: simplify, create and delete integration tests
Browse files Browse the repository at this point in the history
  • Loading branch information
cif committed Oct 6, 2024
1 parent 414e4bc commit 7f77e9d
Show file tree
Hide file tree
Showing 11 changed files with 166 additions and 47 deletions.
41 changes: 19 additions & 22 deletions src/main/kotlin/com/stabledata/dao/AccessDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,26 @@ package com.stabledata.dao

import com.stabledata.endpoint.io.AccessRequest
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.*

data class AccessRecord(
val id: String,
val teamId: String,
val type: String,
val type: String?,
val role: String,
val operation: String?,
val path: String?
val path: String
)

object AccessTable: Table("stable.access") {
val accessId = uuid("id")
val teamId = varchar("team_id", 255)
val kind = varchar("type", 5).check {
val teamId = text("team_id")
val kind = text("type").check {
it inList listOf("grant", "deny")
}
val role = varchar("role", 255)
val operation = varchar("operation", 255).nullable()
val path = varchar("path", 255).nullable()

init {
check("either_operation_or_path") {
(operation.isNotNull() and path.isNull()) or (operation.isNull() and path.isNotNull())
}
}
val role = text("role")
val path = text("path")

fun insertFromRequest(type: String, team: String, record: AccessRequest) {
AccessTable.insert { row ->
Expand All @@ -37,32 +30,34 @@ object AccessTable: Table("stable.access") {
row[teamId] = team
row[role] = record.role
row[path] = record.path
row[operation] = record.operation


}
}

fun deleteRulesForOperationAndRole(team: String, checkRole: String, checkPath: String) {
AccessTable.deleteWhere {
(teamId eq team) and
(role eq checkRole) and
(path eq checkPath)
}
}

fun findMatchingRules(operationOrPath: String, team: String, checkRole: String): Pair<List<AccessRecord>, List<AccessRecord>> {
fun findMatchingRules(team: String, checkRole: String, checkPath: String): Pair<List<AccessRecord>, List<AccessRecord>> {
val rules = transaction {
// Later: make paths matchable in parts
AccessTable
.select {
(teamId eq team) and
(role eq checkRole) and
(
(operation eq operationOrPath) or
(path eq operationOrPath)
)
(path eq checkPath)

}
.map {
AccessRecord(
id = it[accessId].toString(),
teamId = it[teamId],
type = it[kind],
role = it[role],
operation = it[operation],
path = it[path]
)
}
Expand All @@ -73,4 +68,6 @@ object AccessTable: Table("stable.access") {

return Pair(allowingRules, blockingRules)
}


}
10 changes: 5 additions & 5 deletions src/main/kotlin/com/stabledata/endpoint/AccessCreateRoute.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,34 +22,34 @@ fun Application.configureAccessCreateRoute() {
authenticate(JWT_NAME) {
post("access/grant") {
val (access, user, envelope, logEntry) = contextualize(
"access/create"
"access/manage"
) { postData ->
AccessRequest.fromJSON(postData)
} ?: return@post

// slightly borrowed, but not crazy use case issues in logs anyway
logEntry.path("grant")

logger.debug { "Create collection requested by ${user.id} with event id ${envelope.eventId}" }
logger.debug { "Grant access to roles requested by ${user.id} with event id ${envelope.eventId}" }

try {
val finalLogEntry = logEntry.build()

transaction {
AccessTable.insertFromRequest("grant", user.team, access)
LogsTable.insertLogEntry(finalLogEntry)
Ably.publish(user.team, "collection/create", finalLogEntry)
Ably.publish(user.team, "access/manage", finalLogEntry)
}

logger.debug {"Collection access control record for path: ${access.path} or operation: ${access.operation}" }
logger.debug {"Access control record created for role ${access.role} on path ${access.path}" }

return@post call.respond(
HttpStatusCode.Created,
finalLogEntry
)

} catch (e: ExposedSQLException) {
logger.error { "Create access record failed: ${e.localizedMessage}" }
logger.error { "Grant access record failed: ${e.localizedMessage}" }
return@post call.respond(HttpStatusCode.InternalServerError, e.localizedMessage)
}
}
Expand Down
57 changes: 57 additions & 0 deletions src/main/kotlin/com/stabledata/endpoint/AccessDeleteRoute.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.stabledata.endpoint

import com.stabledata.Ably
import com.stabledata.dao.AccessTable
import com.stabledata.dao.LogsTable
import com.stabledata.endpoint.io.AccessRequest
import com.stabledata.plugins.JWT_NAME
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.jetbrains.exposed.exceptions.ExposedSQLException
import org.jetbrains.exposed.sql.transactions.transaction

fun Application.configureAccessDeleteRoute() {

val logger = KotlinLogging.logger {}

routing {
authenticate(JWT_NAME) {
post("access/delete") {
val (access, user, envelope, logEntry) = contextualize(
"access/manage"
) { postData ->
AccessRequest.fromJSON(postData)
} ?: return@post

// slightly borrowed, but not crazy use case issues in logs anyway
logEntry.path("delete")

logger.debug { "All access records removal requested by ${user.id} with event id ${envelope.eventId}" }

try {
val finalLogEntry = logEntry.build()
transaction {
AccessTable.deleteRulesForOperationAndRole(user.team, access.role, access.path)
LogsTable.insertLogEntry(finalLogEntry)
Ably.publish(user.team, "access/delete", finalLogEntry)
}

logger.debug {"Access control record deleted for role ${access.role} on path ${access.path}" }

return@post call.respond(
HttpStatusCode.Created,
finalLogEntry
)

} catch (e: ExposedSQLException) {
logger.error { "Delete access record failed: ${e.localizedMessage}" }
return@post call.respond(HttpStatusCode.InternalServerError, e.localizedMessage)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import io.ktor.server.application.*
fun Application.configureApplicationRouting() {
// access controls
configureAccessCreateRoute()
configureAccessDeleteRoute()

// schema
configureCreateCollectionRoute()
Expand Down
4 changes: 2 additions & 2 deletions src/main/kotlin/com/stabledata/endpoint/io/AccessRequest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import kotlinx.serialization.json.Json
@Serializable
data class AccessRequest (
val id: String,
val type: String?,
val role: String,
val operation: String?,
val path: String?
val path: String
) {
companion object {
fun fromJSON (json: String): AccessRequest {
Expand Down
4 changes: 2 additions & 2 deletions src/main/kotlin/com/stabledata/plugins/Auth.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,9 @@ suspend fun PipelineContext<Unit, ApplicationCall>.permissions(
userCredentials.role

val (allowingRules, blockingRules) = AccessTable.findMatchingRules(
operation,
userCredentials.team,
roleToCheck
checkRole = roleToCheck,
checkPath = operation
)

var hasPermission = false
Expand Down
13 changes: 4 additions & 9 deletions src/main/resources/db/migration/V1__Migration_20240906154254.sql
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,9 @@ CREATE TABLE stable.fields (

CREATE TABLE stable.access (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
team_id VARCHAR(255) NOT NULL,
type VARCHAR(5) CHECK (type IN ('grant', 'deny')) NOT NULL,
role VARCHAR(255) NOT NULL,
operation VARCHAR(255),
path VARCHAR(255),
CONSTRAINT either_operation_or_path CHECK (
(operation IS NOT NULL AND path IS NULL) OR
(path IS NOT NULL AND operation IS NULL)
)
team_id text NOT NULL,
type text CHECK (type IN ('grant', 'deny')) NOT NULL,
role text NOT NULL,
path text NOT NULL
);

28 changes: 25 additions & 3 deletions src/main/resources/openapi/doc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,36 @@ paths:

/access/grant:
post:
summary: Grant or Deny Access
description: Grant a specific role access to an operation or resource.
summary: Grant or deny role access
description: Grant a specific user role access to admin operations or resources.
requestBody:
required: true
content:
application/json:
schema:
$ref: './schemas/access/create.json'
$ref: './schemas/access/manage.json'
responses:
201:
description: Access granted successfully
content:
application/json:
schema:
$ref: './schemas/event.json'
401:
description: Unauthorized
403:
description: Forbidden

/access/delete:
post:
summary: Removes all access records
description: Deletes any records matching the request against role, path or resource
requestBody:
required: true
content:
application/json:
schema:
$ref: './schemas/access/manage.json'
responses:
201:
description: Access record saved successfully
Expand Down
21 changes: 21 additions & 0 deletions src/main/resources/openapi/schemas/access/manage.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"description": "Unique identifier access control record"
},
"role": {
"type": "string",
"description": "Role associated with this access entry"
},
"path": {
"type": "string",
"description": "Path to the resource (or operation) for which access is granted or denied"
}
},
"required": ["id", "role", "path"],
"additionalProperties": false
}
28 changes: 27 additions & 1 deletion src/test/kotlin/com/stabledata/AccessIntegrationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import kotlin.test.assertEquals
class AccessIntegrationTest: WordSpec({
"access controls test" should {
val ruleCreationEventId = uuidString()
val ruleDeleteEventId = uuidString()
val ruleId = uuidString()
val tokenForCustomRole = generateTokenForTesting("test.role")
val adminToken = generateTokenForTesting(Roles.Admin)
Expand Down Expand Up @@ -60,7 +61,7 @@ class AccessIntegrationTest: WordSpec({
{
"id": "$ruleId",
"role": "test.role",
"operation": "collection/create"
"path": "collection/create"
}
""".trimIndent()
)
Expand Down Expand Up @@ -114,5 +115,30 @@ class AccessIntegrationTest: WordSpec({
}


"should delete the access role it previously created" {
testApplication {
application {
module()
}
val response = client.post("/access/delete") {
headers {
append(HttpHeaders.Authorization, "Bearer $adminToken")
append(StableEventIdHeader, ruleDeleteEventId)
}
contentType(ContentType.Application.Json)
setBody("""
{
"id": "$ruleId",
"role": "test.role",
"path": "collection/create"
}
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, response.status)
}
}


}
})
6 changes: 3 additions & 3 deletions src/test/kotlin/com/stabledata/ValidatorTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,16 @@ class ValidatorTest {
}

@Test
fun `validates access creates correctly` () {
fun `validates access schemas correctly` () {
val validJSON = """
{
"id": "${uuidString()}",
"role": "test.role",
"operation": "collection/create"
"path": "collection/create"
}
""".trimIndent()

val (isValid, errors) = validateJSONUsingSchema("access/create.json", validJSON)
val (isValid, errors) = validateJSONUsingSchema("access/manage.json", validJSON)
assert(isValid)
assert(errors.isEmpty())
}
Expand Down

0 comments on commit 7f77e9d

Please sign in to comment.