6 Commits
0.1.0 ... main

7 changed files with 106 additions and 37 deletions

View File

@@ -1,8 +1,48 @@
# access-control # Access Kontrol
Helpers to create Access Control Helpers to create a simple Access Control in kotlin
[![Tests](https://github.com/flecomte/access-control/actions/workflows/tests.yml/badge.svg)](https://github.com/flecomte/access-control/actions/workflows/tests.yml) [![Tests](https://github.com/flecomte/access-kontrol/actions/workflows/tests.yml/badge.svg)](https://github.com/flecomte/access-kontrol/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) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=flecomte_access-kontrol&metric=coverage)](https://sonarcloud.io/dashboard?id=flecomte_access-kontrol)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=flecomte_access-control&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=flecomte_access-control) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=flecomte_access-kontrol&metric=alert_status)](https://sonarcloud.io/dashboard?id=flecomte_access-kontrol)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=flecomte_access-control&metric=ncloc)](https://sonarcloud.io/dashboard?id=flecomte_access-control) [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=flecomte_access-kontrol&metric=ncloc)](https://sonarcloud.io/dashboard?id=flecomte_access-kontrol)
## Example
Define AC
```kotlin
class AccessControlSample : AccessKontrol() {
/** The user can view the object if it is connected and if it is the creator */
fun canView(myObject: MyObject, user: User?): AccessResponse {
return if (user != null && myObject.createdBy == user) {
granted(message = "OK") // the message if optional on granted
} else {
denied(message = "You must be the creator", code = "creator.ko")
}
}
fun canView(myObjects: List<MyObject>, user: User?): AccessResponses {
return canAll(myObjects) { canView(it, user) }
}
}
```
Usage
```kotlin
AccessControlSample().canView(MyObject(), User()).let { response ->
response.message // "OK"
response.decision == AccessDecision.GRANTED // true
}
try {
AccessControlSample().canView(MyObject(), User()).assert() // throw exception if no access
} catch (e: AccessDeniedException) {
e.getFirstMessage() // the access denied message: "You must be the creator"
e.first.code // the access denied code: "creator.ko"
}
AccessControlSample().canView(MyObject(), User()).toBoolean() // return true if access is granted
```

View File

@@ -44,8 +44,8 @@ val sourcesJar by tasks.registering(Jar::class) {
publishing { publishing {
repositories { repositories {
maven { maven {
name = "access-control" name = "access-kontrol"
url = uri("https://maven.pkg.github.com/flecomte/access-control") url = uri("https://maven.pkg.github.com/flecomte/access-kontrol")
credentials { credentials {
username = System.getenv("GITHUB_ACTOR") username = System.getenv("GITHUB_ACTOR")
password = System.getenv("GITHUB_TOKEN") password = System.getenv("GITHUB_TOKEN")
@@ -54,7 +54,7 @@ publishing {
} }
publications { publications {
create<MavenPublication>("access-control") { create<MavenPublication>("access-kontrol") {
from(components["java"]) from(components["java"])
artifact(sourcesJar) artifact(sourcesJar)
} }

View File

@@ -1,7 +1,7 @@
kotlin.code.style=official kotlin.code.style=official
systemProp.sonar.host.url=https://sonarcloud.io systemProp.sonar.host.url=https://sonarcloud.io
systemProp.sonar.projectKey=flecomte_access-control systemProp.sonar.projectKey=flecomte_access-kontrol
systemProp.sonar.projectName=AccessControl systemProp.sonar.projectName=AccessKontrol
systemProp.sonar.organization=flecomte systemProp.sonar.organization=flecomte
systemProp.sonar.java.coveragePlugin=jacoco systemProp.sonar.java.coveragePlugin=jacoco
systemProp.sonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/test/jacocoTestReport.xml systemProp.sonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/test/jacocoTestReport.xml

View File

@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

@@ -1 +1 @@
rootProject.name = "access-control" rootProject.name = "access-kontrol"

View File

@@ -1,6 +1,6 @@
package io.github.flecomte package io.github.flecomte
/** Responses of AccessControl */ /** Responses of AccessKontrol */
enum class AccessDecision { enum class AccessDecision {
GRANTED, GRANTED,
DENIED; DENIED;
@@ -14,7 +14,7 @@ enum class AccessDecision {
} }
} }
abstract class AccessControl { abstract class AccessKontrol {
/** /**
* A Shortcut for return a GrantedResponse * A Shortcut for return a GrantedResponse
*/ */
@@ -27,9 +27,13 @@ abstract class AccessControl {
/** /**
* A helper to convert a list of subject into one response * A helper to convert a list of subject into one response
*
* @throws [NoDecision] if the list of responses is empty
*/ */
protected fun <S : List<T>, T> canAll(items: S, action: (T) -> AccessResponse): AccessResponses = items protected fun <S : List<T>, T> canAll(items: S, action: (T) -> AccessResponse): AccessResponses = items
.map { action(it) }.let { responses -> .ifEmpty { throw NoDecision() }
.map { action(it) }
.let { responses ->
if (responses.any { it is DeniedResponse }) { if (responses.any { it is DeniedResponse }) {
DeniedResponses(responses) DeniedResponses(responses)
} else { } else {
@@ -39,9 +43,9 @@ abstract class AccessControl {
} }
/** /**
* Throw an Exception if AccessControl return a DENIED response * Throw an Exception if AccessKontrol return a DENIED response
*/ */
fun <T : AccessControl> T.assert(action: T.() -> AccessResponse) { fun <T : AccessKontrol> T.assert(action: T.() -> AccessResponse) {
action().assert() action().assert()
} }
@@ -50,9 +54,12 @@ typealias AccessResponses = List<AccessResponse>
/** /**
* Check all responses and return DENIED if one is DENIED * Check all responses and return DENIED if one is DENIED
* *
* If the list of responses is empty, return GRANTED * @throws [NoDecision] if the list of responses is empty
*/ */
fun AccessResponses.getFirstDecisionResponse(): AccessResponse = this.firstOrNull { it.decision == AccessDecision.DENIED } ?: this.first { it.decision == AccessDecision.GRANTED } fun AccessResponses.getFirstDecisionResponse(): AccessResponse {
ifEmpty { throw NoDecision() }
return firstOrNull { it.decision == AccessDecision.DENIED } ?: first()
}
/** /**
* Throw an Exception if one response is DENIED * Throw an Exception if one response is DENIED
@@ -62,8 +69,8 @@ fun AccessResponses.assert() {
throw AccessDeniedException(this) throw AccessDeniedException(this)
} }
} }
val AccessResponses.grantedResponses get(): AccessResponses = this.filterIsInstance<GrantedResponse>() val AccessResponses.grantedResponses get(): List<GrantedResponse> = this.filterIsInstance<GrantedResponse>()
val AccessResponses.deniedResponses get(): AccessResponses = this.filterIsInstance<DeniedResponse>() val AccessResponses.deniedResponses get(): List<DeniedResponse> = this.filterIsInstance<DeniedResponse>()
/** /**
* Convert responses as boolean * Convert responses as boolean
@@ -78,6 +85,9 @@ class AccessDeniedException(val accessResponses: AccessResponses) : Throwable(ac
*/ */
fun first(): AccessResponse = accessResponses.deniedResponses.first() fun first(): AccessResponse = accessResponses.deniedResponses.first()
val deniedResponses: List<DeniedResponse>
get() = this.accessResponses.deniedResponses
/** /**
* Check if the error code is present into the responses * Check if the error code is present into the responses
*/ */
@@ -97,27 +107,27 @@ class AccessDeniedException(val accessResponses: AccessResponses) : Throwable(ac
*/ */
fun getMessages(): List<String> = accessResponses fun getMessages(): List<String> = accessResponses
.deniedResponses .deniedResponses
.map { it.message!! } .map { it.message }
/** /**
* Get the first message * Get the first message
*/ */
fun getFirstMessage(): String? = accessResponses fun getFirstMessage(): String = accessResponses
.deniedResponses .deniedResponses
.first() .first()
.message .message
} }
/** /**
* The response that all AccessControl method return * The response that all AccessKontrol method return
* @see GrantedResponse * @see GrantedResponse
* @see DeniedResponse * @see DeniedResponse
*/ */
sealed class AccessResponse( sealed class AccessResponse(
val decision: AccessDecision, val decision: AccessDecision,
val accessControl: AccessControl, val accessControl: AccessKontrol,
val message: String?, open val message: String?,
val code: String? open val code: String?
) { ) {
/** /**
* Convert response as boolean * Convert response as boolean
@@ -135,15 +145,15 @@ sealed class AccessResponse(
} }
open class GrantedResponse( open class GrantedResponse(
accessControl: AccessControl, accessControl: AccessKontrol,
message: String? = null, message: String? = null,
code: String? = null code: String? = null
) : AccessResponse(AccessDecision.GRANTED, accessControl, message, code) ) : AccessResponse(AccessDecision.GRANTED, accessControl, message, code)
open class DeniedResponse( open class DeniedResponse(
accessControl: AccessControl, accessControl: AccessKontrol,
message: String, override val message: String,
code: String override val code: String
) : AccessResponse(AccessDecision.DENIED, accessControl, message, code) ) : AccessResponse(AccessDecision.DENIED, accessControl, message, code)
class GrantedResponses( class GrantedResponses(
@@ -159,7 +169,9 @@ class DeniedResponses(
accessResponses: List<AccessResponse> accessResponses: List<AccessResponse>
) : AccessResponses by accessResponses, ) : AccessResponses by accessResponses,
DeniedResponse( DeniedResponse(
accessResponses.deniedResponses.first().accessControl, accessResponses.deniedResponses.firstOrNull()?.accessControl ?: error("DeniedResponses cannot be empty"),
accessResponses.deniedResponses.firstOrNull()?.message ?: error("DeniedResponses cannot be empty"), accessResponses.deniedResponses.first().message,
accessResponses.deniedResponses.firstOrNull()?.code ?: error("DeniedResponses cannot be empty") accessResponses.deniedResponses.first().code
) )
class NoDecision : RuntimeException("No decision has been taken")

View File

@@ -13,7 +13,7 @@ data class MyObject(
val title: String val title: String
) )
class AccessControlSample : AccessControl() { class AccessControlSample : AccessKontrol() {
fun canView(myObject: MyObject, user: User?): AccessResponse { fun canView(myObject: MyObject, user: User?): AccessResponse {
return if (myObject.title == "granted" && user != null) { return if (myObject.title == "granted" && user != null) {
granted("ok") granted("ok")
@@ -29,7 +29,7 @@ class AccessControlSample : AccessControl() {
} }
} }
class AccessControlTest { class AccessKontrolTest {
@Test @Test
fun `test granted`() { fun `test granted`() {
AccessControlSample().run { AccessControlSample().run {
@@ -59,6 +59,14 @@ class AccessControlTest {
} }
} }
@Test
fun `test empty canAllGranted`() {
assertThrows(NoDecision::class.java) {
AccessControlSample()
.canView(listOf(), User(""))
}
}
@Test @Test
fun `test CanAllDenied`() { fun `test CanAllDenied`() {
AccessControlSample().run { AccessControlSample().run {
@@ -98,6 +106,7 @@ class AccessControlTest {
assertEquals(null, getErrorCode("notExists")?.code) assertEquals(null, getErrorCode("notExists")?.code)
assertEquals("KO2", getMessages().last()) assertEquals("KO2", getMessages().last())
assertEquals("KO", getFirstMessage()) assertEquals("KO", getFirstMessage())
assertEquals("ko", deniedResponses.firstOrNull()?.code)
} }
} }
@@ -128,6 +137,14 @@ class AccessControlTest {
} }
} }
@Test
fun `test getFirstDecisionResponse on empty`() {
assertThrows(NoDecision::class.java) {
listOf<AccessResponse>()
.getFirstDecisionResponse()
}
}
@Test @Test
fun `GrantedResponse instantiation test`() { fun `GrantedResponse instantiation test`() {
assertEquals(AccessDecision.GRANTED, GrantedResponse(AccessControlSample()).decision) assertEquals(AccessDecision.GRANTED, GrantedResponse(AccessControlSample()).decision)