From 7f77e9d6bacfce95a6525ff7722e97b76d546d1e Mon Sep 17 00:00:00 2001 From: cif Date: Sun, 6 Oct 2024 22:50:21 +0100 Subject: [PATCH] refactor: simplify, create and delete integration tests --- .../kotlin/com/stabledata/dao/AccessDao.kt | 41 +++++++------ .../stabledata/endpoint/AccessCreateRoute.kt | 10 ++-- .../stabledata/endpoint/AccessDeleteRoute.kt | 57 +++++++++++++++++++ .../stabledata/endpoint/ApplicationRouting.kt | 1 + .../stabledata/endpoint/io/AccessRequest.kt | 4 +- .../kotlin/com/stabledata/plugins/Auth.kt | 4 +- .../V1__Migration_20240906154254.sql | 13 ++--- src/main/resources/openapi/doc.yaml | 28 ++++++++- .../openapi/schemas/access/manage.json | 21 +++++++ .../com/stabledata/AccessIntegrationTest.kt | 28 ++++++++- .../kotlin/com/stabledata/ValidatorTest.kt | 6 +- 11 files changed, 166 insertions(+), 47 deletions(-) create mode 100644 src/main/kotlin/com/stabledata/endpoint/AccessDeleteRoute.kt create mode 100644 src/main/resources/openapi/schemas/access/manage.json diff --git a/src/main/kotlin/com/stabledata/dao/AccessDao.kt b/src/main/kotlin/com/stabledata/dao/AccessDao.kt index bb04576..16c92d1 100644 --- a/src/main/kotlin/com/stabledata/dao/AccessDao.kt +++ b/src/main/kotlin/com/stabledata/dao/AccessDao.kt @@ -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 -> @@ -37,24 +30,27 @@ 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> { + fun findMatchingRules(team: String, checkRole: String, checkPath: String): Pair, List> { 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( @@ -62,7 +58,6 @@ object AccessTable: Table("stable.access") { teamId = it[teamId], type = it[kind], role = it[role], - operation = it[operation], path = it[path] ) } @@ -73,4 +68,6 @@ object AccessTable: Table("stable.access") { return Pair(allowingRules, blockingRules) } + + } \ No newline at end of file diff --git a/src/main/kotlin/com/stabledata/endpoint/AccessCreateRoute.kt b/src/main/kotlin/com/stabledata/endpoint/AccessCreateRoute.kt index fad99b1..c2f4267 100644 --- a/src/main/kotlin/com/stabledata/endpoint/AccessCreateRoute.kt +++ b/src/main/kotlin/com/stabledata/endpoint/AccessCreateRoute.kt @@ -22,7 +22,7 @@ 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 @@ -30,7 +30,7 @@ fun Application.configureAccessCreateRoute() { // 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() @@ -38,10 +38,10 @@ fun Application.configureAccessCreateRoute() { 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, @@ -49,7 +49,7 @@ fun Application.configureAccessCreateRoute() { ) } 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) } } diff --git a/src/main/kotlin/com/stabledata/endpoint/AccessDeleteRoute.kt b/src/main/kotlin/com/stabledata/endpoint/AccessDeleteRoute.kt new file mode 100644 index 0000000..a52146b --- /dev/null +++ b/src/main/kotlin/com/stabledata/endpoint/AccessDeleteRoute.kt @@ -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) + } + } + } + } +} diff --git a/src/main/kotlin/com/stabledata/endpoint/ApplicationRouting.kt b/src/main/kotlin/com/stabledata/endpoint/ApplicationRouting.kt index 0630e47..fbc05e6 100644 --- a/src/main/kotlin/com/stabledata/endpoint/ApplicationRouting.kt +++ b/src/main/kotlin/com/stabledata/endpoint/ApplicationRouting.kt @@ -4,6 +4,7 @@ import io.ktor.server.application.* fun Application.configureApplicationRouting() { // access controls configureAccessCreateRoute() + configureAccessDeleteRoute() // schema configureCreateCollectionRoute() diff --git a/src/main/kotlin/com/stabledata/endpoint/io/AccessRequest.kt b/src/main/kotlin/com/stabledata/endpoint/io/AccessRequest.kt index 81c4919..31aecf3 100644 --- a/src/main/kotlin/com/stabledata/endpoint/io/AccessRequest.kt +++ b/src/main/kotlin/com/stabledata/endpoint/io/AccessRequest.kt @@ -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 { diff --git a/src/main/kotlin/com/stabledata/plugins/Auth.kt b/src/main/kotlin/com/stabledata/plugins/Auth.kt index 43254ab..3acabf0 100644 --- a/src/main/kotlin/com/stabledata/plugins/Auth.kt +++ b/src/main/kotlin/com/stabledata/plugins/Auth.kt @@ -94,9 +94,9 @@ suspend fun PipelineContext.permissions( userCredentials.role val (allowingRules, blockingRules) = AccessTable.findMatchingRules( - operation, userCredentials.team, - roleToCheck + checkRole = roleToCheck, + checkPath = operation ) var hasPermission = false diff --git a/src/main/resources/db/migration/V1__Migration_20240906154254.sql b/src/main/resources/db/migration/V1__Migration_20240906154254.sql index 5b8aa92..5ec1bde 100644 --- a/src/main/resources/db/migration/V1__Migration_20240906154254.sql +++ b/src/main/resources/db/migration/V1__Migration_20240906154254.sql @@ -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 ); diff --git a/src/main/resources/openapi/doc.yaml b/src/main/resources/openapi/doc.yaml index 21fe610..0ad16ca 100644 --- a/src/main/resources/openapi/doc.yaml +++ b/src/main/resources/openapi/doc.yaml @@ -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 diff --git a/src/main/resources/openapi/schemas/access/manage.json b/src/main/resources/openapi/schemas/access/manage.json new file mode 100644 index 0000000..53a1a27 --- /dev/null +++ b/src/main/resources/openapi/schemas/access/manage.json @@ -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 +} \ No newline at end of file diff --git a/src/test/kotlin/com/stabledata/AccessIntegrationTest.kt b/src/test/kotlin/com/stabledata/AccessIntegrationTest.kt index ab2d47d..b595082 100644 --- a/src/test/kotlin/com/stabledata/AccessIntegrationTest.kt +++ b/src/test/kotlin/com/stabledata/AccessIntegrationTest.kt @@ -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) @@ -60,7 +61,7 @@ class AccessIntegrationTest: WordSpec({ { "id": "$ruleId", "role": "test.role", - "operation": "collection/create" + "path": "collection/create" } """.trimIndent() ) @@ -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) + } + } + + } }) \ No newline at end of file diff --git a/src/test/kotlin/com/stabledata/ValidatorTest.kt b/src/test/kotlin/com/stabledata/ValidatorTest.kt index d45fda7..6b56835 100644 --- a/src/test/kotlin/com/stabledata/ValidatorTest.kt +++ b/src/test/kotlin/com/stabledata/ValidatorTest.kt @@ -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()) }