io.github.graphglue.definition.NodeDefinitionCollection.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of graphglue-core Show documentation
Show all versions of graphglue-core Show documentation
A library to develop annotation-based code-first GraphQL servers using GraphQL Kotlin, Spring Boot and Neo4j - excluding Spring GraphQL server dependencies
The newest version!
package io.github.graphglue.definition
import io.github.graphglue.GraphglueCoreConfigurationProperties
import io.github.graphglue.authorization.AllowRuleGenerator
import io.github.graphglue.authorization.DisallowRuleGenerator
import io.github.graphglue.authorization.MergedAuthorization
import io.github.graphglue.authorization.Permission
import io.github.graphglue.data.execution.CypherConditionGenerator
import io.github.graphglue.graphql.extensions.getSimpleName
import io.github.graphglue.model.Authorization
import io.github.graphglue.model.Node
import io.github.graphglue.model.Rule
import io.github.graphglue.util.iterateGraph
import org.neo4j.cypherdsl.core.*
import org.springframework.beans.factory.BeanFactory
import kotlin.reflect.KClass
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.full.isSuperclassOf
/**
* Stores a collection of [NodeDefinition]s
* Also handles generation and storage of authorization conditions
*
* @param backingCollection the provided list of [NodeDefinition]s
* @param beanFactory used to get condition defining beans for authorization
* @param configurationProperties configuration properties for Graphglue core
*/
class NodeDefinitionCollection(
backingCollection: Map, NodeDefinition>,
private val beanFactory: BeanFactory,
private val configurationProperties: GraphglueCoreConfigurationProperties
) : Collection by backingCollection.values {
/**
* Defensive copy of provided `backingCollection`, used to store [NodeDefinition]s
*/
private val backingCollection = HashMap(backingCollection)
/**
* [NodeDefinition]s by GraphQL name lookup
*/
private val definitionsByGraphQLName = backingCollection.mapKeys { it.key.getSimpleName() }
/**
* Lookup from set of all subtype GraphQL names to common parent type [NodeDefinition]
* Can be used to generate Cypher queries and conditions more efficiently by not having to check for
* multiple different labels, but only for a common label
* Keys contain only GraphQL names of [NodeDefinition]s which are object types in the schema (no interfaces)
*/
private val supertypeNodeDefinitionLookup = generateSupertypeNodeDefinitionLookup()
/**
* Set of all known authorization names
* Used to detect misspelled authorization names
*/
private val allAuthorizationNames = backingCollection.values.flatMap { it.authorizations.keys }.toSet()
/**
* Lookup for disallow rules by authorization name
*/
private val authorizationDisallowRules = generateAuthorizationDisallowRules()
/**
* Associates each [NodeDefinition] with all sub-NodeDefinitions and super-NodeDefinitions
*/
private val nodeDefinitionHierarchyLookup = generateNodeDefinitionHierarchyLookup()
/**
* For each authorization name and [NodeDefinition], the list of allow rules to check
*/
private val authorizationAllowRules = generateAuthorizationAllowRules()
/**
* For each authorization name, all [NodeDefinition]s that are always allowed
*/
private val authorizationAllAllowedNodeDefinitions = generateAuthorizationAllAllowedNodeDefinitions()
/**
* Generates the supertype node definition lookup
*
* @return the generated supertype node definition lookup
*/
private fun generateSupertypeNodeDefinitionLookup(): Map, NodeDefinition> {
val supertypeNodeDefinitionLookup = mutableMapOf, NodeDefinition>()
for ((nodeClass, nodeDefinition) in backingCollection) {
val subTypes = backingCollection.keys.filter { it.isSubclassOf(nodeClass) }.filter { !it.isAbstract }
.map { it.getSimpleName() }.toSet()
supertypeNodeDefinitionLookup[subTypes] = nodeDefinition
}
return supertypeNodeDefinitionLookup
}
/**
* Associates each authorization name with a set of all disallow rules (by [NodeDefinition]
*
* @return the generated authorization name and [NodeDefinition] to disallow [Rule]s lookup
*/
private fun generateAuthorizationDisallowRules(): Map>> {
return allAuthorizationNames.associateWith { name ->
associateWith {
it.authorizations[name]?.disallow?.toSet() ?: emptySet()
}.filterValues { it.isNotEmpty() }
}
}
/**
* Associates each [NodeDefinition] with all sub-NodeDefinitions and super-NodeDefinitions
*
* @return the generated [NodeDefinition] to all [NodeDefinition]s in hierarchy lookup
*/
private fun generateNodeDefinitionHierarchyLookup(): Map> {
return associateWith { nodeDefinition ->
filter {
it.nodeType.isSubclassOf(nodeDefinition.nodeType) || it.nodeType.isSuperclassOf(nodeDefinition.nodeType)
}.toSet()
}
}
/**
* Associates each authorization name and [NodeDefinition] with a set of [NodeAllowRule]s used to generate
* the allow condition
*
* @return the lookup from authorization name and [NodeDefinition] to the generated set of [NodeAllowRule]s
*/
private fun generateAuthorizationAllowRules(): Map>> {
return allAuthorizationNames.associateWith { name ->
associateWith { nodeDefinition ->
generateAuthorizationNodeAllowRules(nodeDefinition, name)
}
}
}
/**
* Generates the set of [NodeAllowRule] for a specific authorization name and [NodeDefinition]
* Also see [generateAuthorizationAllowRules]
*
* @param nodeDefinition the [NodeDefinition] for which to generate the allow rules
* @param name the name of the authorization
* @return the generated set of [NodeAllowRule]s used to generate the allow condition
*/
private fun generateAuthorizationNodeAllowRules(
nodeDefinition: NodeDefinition,
name: String
): Set {
val tempAllowRules = mutableSetOf()
val allowAllNodeDefinitions = mutableSetOf()
nodeDefinitionHierarchyLookup[nodeDefinition]!!.iterateGraph { toCheck ->
val authorization = toCheck.authorizations[name]
if (authorization?.allowAll == true) {
allowAllNodeDefinitions += toCheck
}
authorization?.allow?.forEach {
tempAllowRules += NodeAllowRule(
setOf(toCheck),
it
)
}
getAllowFromRelatedNodeDefinitions(authorization, toCheck)
}
return generateFinalAllowRules(tempAllowRules, allowAllNodeDefinitions)
}
/**
* Gets [NodeDefinition] based on [Authorization.allowFromRelated] of a [MergedAuthorization]
*
* @param authorization defines the relationships
* @param nodeDefinition definition for node annotated with a part of [authorization]
* @return all [NodeDefinition]s from which allow is inherited
*/
private fun getAllowFromRelatedNodeDefinitions(
authorization: MergedAuthorization?,
nodeDefinition: NodeDefinition
): List {
return authorization?.allowFromRelated?.mapNotNull {
val relationshipDefinition = nodeDefinition.relationshipDefinitionsByProperty[it]
if (relationshipDefinition == null && !nodeDefinition.nodeType.isAbstract) {
throw IllegalStateException("Cannot find relationship defined by property $it on $nodeDefinition")
}
relationshipDefinition
}
?.flatMap {
val relatedNodeDefinition = getNodeDefinition(it.nodeKClass)
nodeDefinitionHierarchyLookup[relatedNodeDefinition]!!
} ?: emptyList()
}
/**
* Takes a temporary set of [NodeAllowRule] and a set of allow all [NodeDefinition]s and creates the
* final set of [NodeAllowRule]s
*
* @param tempAllowRules already existing [NodeAllowRule]s, merged by [NodeAllowRule.allowRule]
* @param allowAllNodeDefinitions used to create a new [NodeAllowRule] without a [NodeAllowRule.allowRule]
* @return the transformed [tempAllowRules] and a new [NodeAllowRule] generated based on [allowAllNodeDefinitions]
* (if necessary)
*/
private fun generateFinalAllowRules(
tempAllowRules: Set,
allowAllNodeDefinitions: Set
): Set {
val allowRules = tempAllowRules.groupBy { it.allowRule }.values.map { rules ->
rules.first().copy(nodeDefinitions = rules.flatMap { it.nodeDefinitions }.toSet())
}.toMutableSet()
if (allowAllNodeDefinitions.isNotEmpty()) {
allowRules += NodeAllowRule(
allowAllNodeDefinitions
)
}
return allowRules
}
/**
* Generates a set of always allowed [NodeDefinition]s for each authorization name
*
* @return the lookup from authorization name to always allowed [NodeDefinition]s
*/
private fun generateAuthorizationAllAllowedNodeDefinitions(): Map> {
return allAuthorizationNames.associateWith { name ->
filter { it.mergedAuthorizations[name]?.allowAll ?: false }.toSet()
}
}
/**
* Gets the list of [NodeDefinition]s associated with names
* If a common supertype is found (and the provided names include all subtypes),
* returns that supertype
* Otherwise the provied list is mapped to [NodeDefinition]s
*
* @param names the list of GraphQL names
* @return the found [NodeDefinition]s
*/
fun getNodeDefinitionsFromGraphQLNames(names: List): List {
return supertypeNodeDefinitionLookup[names.toSet()]?.let { listOf(it) }
?: names.map { definitionsByGraphQLName[it]!! }
}
/**
* Gets a [NodeDefinition] by defining class
*
* @param nodeType the defining class
* @return the found [NodeDefinition]
* @throws Exception if the provided type does not define a [NodeDefinition]
*/
fun getNodeDefinition(nodeType: KClass): NodeDefinition {
return backingCollection[nodeType]!!
}
/**
* Gets a [NodeDefinition] by defining class
*
* @param T the defining type
* @return the found [NodeDefinition]
* @throws Exception if the provided type does not define a [NodeDefinition]
*/
inline fun getNodeDefinition(): NodeDefinition {
return getNodeDefinition(T::class)
}
/**
* Generates the authorization condition
*
* @param nodeDefinition the type to generate the authorization condition for
* @param permission context for condition creation
* @return a condition generator which generates the authorization condition
*/
fun generateAuthorizationCondition(
nodeDefinition: NodeDefinition, permission: Permission
): CypherConditionGenerator {
return generateAuthorizationCondition(
nodeDefinition, permission, false
)
}
/**
* Generates the authorization condition for the remote side of a relationship
* It is assumed that allow is present on the parent side!
*
* @param relationshipDefinition defines the relation to generate the condition for
* @param permission context for condition creation
* @return a condition generator which generates the authorization condition when provided the remote side node
*/
fun generateRelationshipAuthorizationCondition(
relationshipDefinition: RelationshipDefinition, permission: Permission
): CypherConditionGenerator {
val nodeDefinition = getNodeDefinition(relationshipDefinition.nodeKClass)
return generateAuthorizationCondition(
nodeDefinition, permission, checkIfRelationIsAllowed(relationshipDefinition, permission.name)
)
}
/**
* Generates the authorization condition for a specific type
*
* @param nodeDefinition the type to generate the authorization condition for
* @param permission context for condition creation
* @param isAllowed if `true`, allow is assumed to be present and only disallow conditions are checked
* @return a condition generator which generates the authorization condition
*/
private fun generateAuthorizationCondition(
nodeDefinition: NodeDefinition, permission: Permission, isAllowed: Boolean
): CypherConditionGenerator {
return if (isAllowed || nodeDefinition in authorizationAllAllowedNodeDefinitions[permission.name]!!) {
CypherConditionGenerator {
generateDisallowRule(permission, nodeDefinition, it)
}
} else {
val allowRules = authorizationAllowRules[permission.name]!![nodeDefinition]!!
if (allowRules.isEmpty()) {
CypherConditionGenerator { Cypher.isFalse() }
} else {
CypherConditionGenerator { node ->
generateFullAuthorizationCondition(allowRules, node, permission)
}
}
}
}
/**
* Generates the full authorization condition.
* This includes both the [allowRules] and all disallow rules.
*
* @param allowRules the set of [NodeAllowRule]s to check
* @param node the [Node] to apply the condition to
* @param permission context for condition generation
* @return the generated condition
*/
private fun generateFullAuthorizationCondition(
allowRules: Set,
node: org.neo4j.cypherdsl.core.Node,
permission: Permission
): Condition {
val procedure = "io.github.graphglue.authorizationPath"
val pathName = Cypher.name("a__1")
val nodeName = Cypher.name("a__0")
val endNode = Cypher.anyNode(nodeName)
val statement = if (configurationProperties.useNeo4jPlugin) {
if (node.symbolicName.isEmpty) {
val namedNode = node.named(Cypher.name("a__3"))
Cypher.match(namedNode).call(procedure).withArgs(namedNode.requiredSymbolicName, Cypher.anonParameter(permission.name))
} else {
Cypher.call(procedure).withArgs(node.requiredSymbolicName, Cypher.anonParameter(permission.name))
}.yield(Cypher.name("path").`as`(pathName), Cypher.name("node").`as`(nodeName))
} else {
val relationshipStart = node.relationshipTo(endNode).min(0)
.withProperties(mapOf(permission.name to Cypher.literalTrue()))
val namedRelationship = Cypher.path(pathName).definedBy(relationshipStart)
Cypher.match(namedRelationship)
}
val nodeInPath = Cypher.name("a__2")
val nodeDisallowRule = generateDisallowRule(permission, getNodeDefinition(), Cypher.anyNode(nodeInPath))
val disallowRule = Cypher.all(nodeInPath).`in`(Cypher.nodes(pathName)).where(nodeDisallowRule)
val allowExistRules = allowRules.fold(Cypher.noCondition()) { oldCondition, rule ->
val nodeDefinitionsCondition =
rule.nodeDefinitions.fold(Cypher.noCondition()) { oldNodeCondition, definition ->
oldNodeCondition.or(endNode.hasLabels(definition.primaryLabel))
}
val condition = if (rule.allowRule != null) {
val ruleGenerator = beanFactory.getBean(rule.allowRule.beanRef, AllowRuleGenerator::class.java)
ruleGenerator.generateRule(endNode, rule.allowRule, permission)
} else {
Cypher.noCondition()
}
val conditionToApply = nodeDefinitionsCondition.and(condition)
oldCondition.or(conditionToApply)
}
return Cypher.exists(
statement.where(disallowRule.and(allowExistRules)).returning(Cypher.literalTrue()).build()
)
}
/**
* Checks if a relation is allowed.
* A relation is allowed if allow on the parent side of the relation implies allow on the remote
* side of the relation.
* Can be used to improve authorization checking when fetching nested data structures.
* Allow is implied if the inverse relation (if existing) is allowed from related or if
* this is a one-side and allows from the related side (and has no other allow rules, neither other allow from
* related nor allow rules).
*
* @param relationshipDefinition defines the relation to check
* @param authorizationName name of the authorization, used to obtain the [MergedAuthorization]
* @return `true` iff allow for the remote nodes is implied
*/
private fun checkIfRelationIsAllowed(
relationshipDefinition: RelationshipDefinition, authorizationName: String
): Boolean {
val nodeDefinition = getNodeDefinition(relationshipDefinition.nodeKClass)
val nodeAuthorization = getMergedAuthorization(authorizationName, nodeDefinition)
val inverseRelationshipDefinition = nodeDefinition.getRelationshipDefinitionByInverse(relationshipDefinition)
return if (inverseRelationshipDefinition?.property?.name in nodeAuthorization.allowFromRelated) {
true
} else if (relationshipDefinition is OneRelationshipDefinition && relationshipDefinition.parentKClass.isFinal) {
val parentNodeDefinition = getNodeDefinition(relationshipDefinition.parentKClass)
val parentAuthorization = getMergedAuthorization(authorizationName, parentNodeDefinition)
parentAuthorization.allow.isEmpty() && !parentAuthorization.allowAll
&& parentAuthorization.allowFromRelated.size == 1
&& relationshipDefinition.property.name in parentAuthorization.allowFromRelated
} else {
false
}
}
/**
* Creates a [Condition] to check for disallowed nodes that must be `true`
*
* @param permission the [Permission] to check for
* @param nodeDefinition definition of the node
* @param node Cypher DSL node to apply the condition to
*/
private fun generateDisallowRule(
permission: Permission,
nodeDefinition: NodeDefinition,
node: org.neo4j.cypherdsl.core.Node
): Condition {
val disallowRules = authorizationDisallowRules[permission.name]!!
val nodeDefinitions = nodeDefinitionHierarchyLookup[nodeDefinition]!! intersect disallowRules.keys
val rules = nodeDefinitions.map { it to disallowRules[it]!! }
return if (rules.isEmpty()) {
Cypher.isTrue()
} else {
rules.fold(Cypher.noCondition()) { oldCondition, (definition, nodeDisallowRules) ->
val disallowCondition = nodeDisallowRules.fold(Cypher.noCondition()) { oldDisallowCondition, rule ->
val ruleGenerator = beanFactory.getBean(rule.beanRef, DisallowRuleGenerator::class.java)
oldDisallowCondition.and(
ruleGenerator.generateRule(
node, rule, permission
).not()
)
}
oldCondition.and(node.hasLabels(definition.primaryLabel).not().or(disallowCondition))
}
}
}
/**
* If present, returns `nodeDefinition.mergedAuthorizations[name]`
* If not, checks if the name is known, it returns an empty [Authorization]
* Otherwise throws an exception, has this hints at a misspelled name
*
* @param name the name of the authorization
* @param nodeDefinition the [NodeDefinition] to get the authorization from
* @return the found [Authorization] or a default empty one
* @throws IllegalArgumentException if the name is completely unknown
*/
private fun getMergedAuthorization(name: String, nodeDefinition: NodeDefinition): MergedAuthorization {
return nodeDefinition.mergedAuthorizations[name] ?: if (name in allAuthorizationNames) {
MergedAuthorization(name, emptySet(), emptySet(), emptySet(), false)
} else {
throw IllegalArgumentException("Potentially wrong permission name: $name")
}
}
}
/**
* Mapping from a [NodeDefinition] to a [AllowRuleGenerator]
*
* @param nodeDefinitions the set of associated [NodeDefinition]s
* @param allowRule the associated [Rule], if not present, no rule is used to generate a [Condition]
*/
private data class NodeAllowRule(
val nodeDefinitions: Set,
val allowRule: Rule? = null
)