diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index eb1bf2f..866f09c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,6 +53,19 @@ jobs: gradle-version: '7.1' arguments: test + - name: Coverage + uses: eskatos/gradle-command-action@v1 + with: + gradle-version: '7.1' + arguments: jacocoTestReport + + - name: Cache SonarCloud packages + uses: actions/cache@v1 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Push analyse to sonarqube uses: eskatos/gradle-command-action@v1 env: diff --git a/README.md b/README.md new file mode 100644 index 0000000..c08ed9b --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# access-control +Helpers to create Access Control + +[![Tests](https://github.com/flecomte/access-control/actions/workflows/tests.yml/badge.svg)](https://github.com/flecomte/access-control/actions/workflows/tests.yml) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=flecomte_access-control&metric=coverage)](https://sonarcloud.io/dashboard?id=flecomte_access-control) + +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=flecomte_access-control&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=flecomte_access-control) +[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=flecomte_access-control&metric=ncloc)](https://sonarcloud.io/dashboard?id=flecomte_access-control) diff --git a/build.gradle.kts b/build.gradle.kts index 8446cd7..1a54510 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,7 @@ plugins { `maven-publish` kotlin("jvm") version "+" id("net.nemerosa.versioning") version "+" + id("org.jlleitschuh.gradle.ktlint") version "+" id("org.sonarqube") version "+" } @@ -15,6 +16,14 @@ version = versioning.info.run { } } +tasks.withType { + kotlinOptions { + jvmTarget = "11" + sourceCompatibility = "11" + targetCompatibility = "11" + } +} + tasks.jacocoTestReport { dependsOn(tasks.test) reports { @@ -54,10 +63,18 @@ publishing { } } else { org.slf4j.LoggerFactory.getLogger("gradle") - .error("The git is DIRTY (${versioning.info.full})") + .error("The git is DIRTY (${versioning.info.full})") } } repositories { mavenCentral() -} \ No newline at end of file +} + +tasks.test { + useJUnitPlatform() +} + +dependencies { + testImplementation("org.junit.jupiter:junit-jupiter:5.7.+") +} diff --git a/gradle.properties b/gradle.properties index 6a1db77..497f67c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ kotlin.code.style=official systemProp.sonar.host.url=https://sonarcloud.io -systemProp.sonar.projectKey=AccessControl +systemProp.sonar.projectKey=flecomte_access-control systemProp.sonar.projectName=AccessControl systemProp.sonar.organization=flecomte systemProp.sonar.java.coveragePlugin=jacoco diff --git a/settings.gradle.kts b/settings.gradle.kts index 737e57a..11b020f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1 @@ rootProject.name = "access-control" - diff --git a/src/main/kotlin/AccessControl.kt b/src/main/kotlin/io/github/flecomte/AccessControl.kt similarity index 57% rename from src/main/kotlin/AccessControl.kt rename to src/main/kotlin/io/github/flecomte/AccessControl.kt index aa2c58d..2041a15 100644 --- a/src/main/kotlin/AccessControl.kt +++ b/src/main/kotlin/io/github/flecomte/AccessControl.kt @@ -1,4 +1,4 @@ -package fr.dcproject.common.security +package io.github.flecomte /** Responses of AccessControl */ enum class AccessDecision { @@ -25,19 +25,17 @@ abstract class AccessControl { */ protected fun denied(message: String, code: String): DeniedResponse = DeniedResponse(this, message, code) - /** - * Check all responses and return DENIED if one is DENIED - * - * If the list of responses is empty, return GRANTED - */ - private fun AccessResponses.getOneResponse(): AccessResponse = this.firstOrNull { it.decision == AccessDecision.DENIED } ?: granted() - /** * A helper to convert a list of subject into one response */ - protected fun , T> canAll(items: S, action: (T) -> AccessResponse): AccessResponse = items - .map { action(it) } - .getOneResponse() + protected fun , T> canAll(items: S, action: (T) -> AccessResponse): AccessResponses = items + .map { action(it) }.let { responses -> + if (responses.any { it is DeniedResponse }) { + DeniedResponses(responses) + } else { + GrantedResponses(responses) + } + } } /** @@ -47,49 +45,65 @@ fun T.assert(action: T.() -> AccessResponse) { action().assert() } +typealias AccessResponses = List + /** * Check all responses and return DENIED if one is DENIED * * If the list of responses is empty, return GRANTED */ -fun AccessResponses.getOneResponse(): AccessResponse = this.firstOrNull { it.decision == AccessDecision.DENIED } ?: GrantedResponse(first().accessControl) +fun AccessResponses.getFirstDecisionResponse(): AccessResponse = this.firstOrNull { it.decision == AccessDecision.DENIED } ?: this.first { it.decision == AccessDecision.GRANTED } /** * Throw an Exception if one response is DENIED */ -fun AccessResponses.assert() = this.getOneResponse().assert() +fun AccessResponses.assert() { + if (!toBoolean()) { + throw AccessDeniedException(this) + } +} +val AccessResponses.grantedResponses get(): AccessResponses = this.filterIsInstance() +val AccessResponses.deniedResponses get(): AccessResponses = this.filterIsInstance() -class AccessDeniedException(private val accessResponses: AccessResponses) : Throwable(accessResponses.first().message) { +/** + * Convert responses as boolean + */ +fun AccessResponses.toBoolean(): Boolean = deniedResponses.isEmpty() + +class AccessDeniedException(val accessResponses: AccessResponses) : Throwable(accessResponses.deniedResponses.first().message) { constructor(accessResponse: AccessResponse) : this(listOf(accessResponse)) /** * Get first response */ - fun first(): AccessResponse = accessResponses.first() + fun first(): AccessResponse = accessResponses.deniedResponses.first() /** * Check if the error code is present into the responses */ fun hasErrorCode(code: String): Boolean = accessResponses - .filter { it.decision == AccessDecision.DENIED } + .deniedResponses .any { it.code == code } /** * Find and return the response than match with the error code */ fun getErrorCode(code: String): AccessResponse? = accessResponses - .firstOrNull { it.decision == AccessDecision.DENIED && it.code == code } + .deniedResponses + .firstOrNull { it.code == code } /** * Get a list of messages of all responses */ fun getMessages(): List = accessResponses - .mapNotNull { it.message } + .deniedResponses + .map { it.message!! } /** * Get the first message */ fun getFirstMessage(): String? = accessResponses + .deniedResponses .first() .message } @@ -120,16 +134,32 @@ sealed class AccessResponse( } } -class GrantedResponse( +open class GrantedResponse( accessControl: AccessControl, message: String? = null, code: String? = null ) : AccessResponse(AccessDecision.GRANTED, accessControl, message, code) -class DeniedResponse( +open class DeniedResponse( accessControl: AccessControl, message: String, code: String ) : AccessResponse(AccessDecision.DENIED, accessControl, message, code) -typealias AccessResponses = List +class GrantedResponses( + accessResponses: List +) : AccessResponses by accessResponses, + GrantedResponse( + accessResponses.grantedResponses.first().accessControl, + accessResponses.grantedResponses.first().message, + accessResponses.grantedResponses.first().code + ) + +class DeniedResponses( + accessResponses: List +) : AccessResponses by accessResponses, + DeniedResponse( + accessResponses.deniedResponses.first().accessControl, + accessResponses.deniedResponses.firstOrNull()?.message ?: error("DeniedResponses cannot be empty"), + accessResponses.deniedResponses.firstOrNull()?.code ?: error("DeniedResponses cannot be empty") + ) diff --git a/src/test/kotlin/io/github/flecomte/AccessControlTest.kt b/src/test/kotlin/io/github/flecomte/AccessControlTest.kt new file mode 100644 index 0000000..2b58624 --- /dev/null +++ b/src/test/kotlin/io/github/flecomte/AccessControlTest.kt @@ -0,0 +1,144 @@ +package io.github.flecomte + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +data class User( + val name: String +) +data class MyObject( + val title: String +) + +class AccessControlSample : AccessControl() { + fun canView(myObject: MyObject, user: User?): AccessResponse { + return if (myObject.title == "granted" && user != null) { + granted("ok") + } else if (myObject.title == "wrong2") { + denied("KO2", "ko2") + } else { + denied("KO", "ko") + } + } + + fun canView(myObjects: List, user: User?): AccessResponses { + return canAll(myObjects) { canView(it, user) } + } +} + +class AccessControlTest { + @Test + fun `test granted`() { + AccessControlSample().run { + assertTrue(canView(MyObject("granted"), User("")).toBoolean()) + } + } + + @Test + fun `test denied`() { + AccessControlSample().run { + assertFalse(canView(MyObject("wrong"), User("")).toBoolean()) + } + } + + @Test + fun `test canAllGranted`() { + AccessControlSample().run { + assertTrue( + canView( + listOf( + MyObject("granted"), + MyObject("granted") + ), + User("") + ).first().toBoolean() + ) + } + } + + @Test + fun `test CanAllDenied`() { + AccessControlSample().run { + assertFalse( + canView( + listOf( + MyObject("granted"), + MyObject("wrong") + ), + User("") + ).toBoolean() + ) + } + } + + @Test + fun `test Assert on fail`() { + assertThrows(AccessDeniedException::class.java) { + AccessControlSample().canView(MyObject("denied"), User("")).assert() + } + } + + @Test + fun `test Assert on success`() { + AccessControlSample().assert { canView(MyObject("granted"), User("")) } + } + + @Test + fun `Exception tests`() { + assertThrows(AccessDeniedException::class.java) { + AccessControlSample().canView(listOf(MyObject("wrong"), MyObject("granted"), MyObject("wrong2")), User("")).assert() + }.run { + assertEquals("ko", first().code) + assertTrue(hasErrorCode("ko")) + assertFalse(hasErrorCode("notExists")) + assertEquals("ko", getErrorCode("ko")?.code) + assertEquals(null, getErrorCode("notExists")?.code) + assertEquals("KO2", getMessages().last()) + assertEquals("KO", getFirstMessage()) + } + } + + @Test + fun `Assert success`() { + AccessControlSample() + .canView(listOf(MyObject("granted"), MyObject("granted")), User("")) + .assert() + } + + @Test + fun `test getFirstDecisionResponse`() { + AccessControlSample() + .canView(listOf(MyObject("granted"), MyObject("granted")), User("")) + .getFirstDecisionResponse() + .run { + assertTrue(decision.toBoolean()) + } + } + + @Test + fun `test getFirstDecisionResponse on denied`() { + AccessControlSample() + .canView(listOf(MyObject("granted"), MyObject("denied")), User("")) + .getFirstDecisionResponse() + .run { + assertFalse(decision.toBoolean()) + } + } + + @Test + fun `GrantedResponse instantiation test`() { + assertEquals(AccessDecision.GRANTED, GrantedResponse(AccessControlSample()).decision) + } + + @Test + fun `DeniedResponses must be throw exception if have no denied responses`() { + assertThrows(Exception::class.java) { + DeniedResponses( + listOf(GrantedResponse(AccessControlSample())), + ) + } + } +}