Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85a586cf6c | |||
| 2beca6bbe0 | |||
| 8f2a381f65 | |||
| 74b527de1d | |||
| 1fe063add2 | |||
| 642bfb875e |
52
README.md
52
README.md
@@ -1,8 +1,48 @@
|
|||||||
# access-control
|
# Access Kontrol
|
||||||
Helpers to create Access Control
|
Helpers to create a simple Access Control in kotlin
|
||||||
|
|
||||||
[](https://github.com/flecomte/access-control/actions/workflows/tests.yml)
|
[](https://github.com/flecomte/access-kontrol/actions/workflows/tests.yml)
|
||||||
[](https://sonarcloud.io/dashboard?id=flecomte_access-control)
|
[](https://sonarcloud.io/dashboard?id=flecomte_access-kontrol)
|
||||||
|
|
||||||
[](https://sonarcloud.io/dashboard?id=flecomte_access-control)
|
[](https://sonarcloud.io/dashboard?id=flecomte_access-kontrol)
|
||||||
[](https://sonarcloud.io/dashboard?id=flecomte_access-control)
|
[](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
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -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
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
rootProject.name = "access-control"
|
rootProject.name = "access-kontrol"
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user