Skip to content

Roles and Generations

BrunoRosendo edited this page Sep 13, 2023 · 6 revisions

Roles and generations play a crucial role in the revamp of our website. With our student branch being dynamic and having multiple roles that change on a yearly basis, roles and generations serve as the mechanism we have developed to effectively manage permissions for each role and keep track of who holds them now and each year. To achieve this, every role is linked to a specific generation or school year, and this information is maintained over time to create a yearbook of NIAEFEUP's members!

Contents

Understanding the Model

In this section, we will delve into the conceptual model of the role system and its implementation within our application. The account and activity blocks are incomplete to simplify the diagram.

roles-conceptual-model

The role system consists of the following components:

  • Permissions: These are represented by a 64-bit number (Long) that contains information regarding available permissions, with one bit allocated for each permission.
  • Role: A representation of a role within the system (e.g., President), which is associated with a specific generation and a set of permissions.
  • PerActivityRole: As a part of a role, this component represents a set of permissions applicable exclusively to a single activity, such as a project or event.
  • Generation: Identified by a school year, this component encompasses all the roles utilized during that academic year.

Permissions

Permissions are represented by a 64-bit number (Long) that contains information regarding available permissions, with one bit allocated for each permission. Consequently, we can accommodate a maximum of 64 permissions, which is generally more than enough. However, it's worth noting that if the need arises, we can seamlessly transition to a larger data type for representation.

This is the approach adopted for storing permissions both in the database and their representation in the API. Nonetheless, to streamline operations, the application converts these into a set of discrete permissions, each represented by an integer. This transformation is facilitated through the implementation of an Attribute Converter:

@Converter
class PermissionsConverter : AttributeConverter<Permissions, Long> {
    override fun convertToDatabaseColumn(attribute: Permissions) = attribute.toLong()
    override fun convertToEntityAttribute(dbData: Long) = Permissions.fromLong(dbData)
}

The current implementation of the extension functions is available within the Permissions class, which can be located as follows:

class Permissions(
    permissions: Collection<Permission> = emptyList()
) : MutableSet<Permission> by TreeSet(permissions) {

    companion object {
        fun fromLong(encoded: Long): Permissions {
            val decodedPermissions = Permission.values()
                .filter { perm ->
                    // To decide if the permission is present in the `encoded` Long,
                    // we need to see if the bits for those permissions are set (equal to 1)
                    // `encoded ushr n and 1L` returns the value of the `n`-th bit of `encoded`
                    val encodedBit = encoded ushr perm.bit and 1L
                    return@filter encodedBit == 1L
                }

            return Permissions(decodedPermissions)
        }
    }

    // To encode the permissions into a Long, we need to set the
    // corresponding bit to 1, if the permission is present
    // `1L shl n` returns a number whose binary representation has
    // a single 1 in the `n`-th position
    fun toLong() = fold(0L) { acc, perm ->
        acc or (1L shl perm.bit)
    }

    ...
}

In the provided example, the by keyword is utilized to delegate the implementation of MutableSet to the TreeSet class. This approach differs from direct inheritance from TreeSet, as it ensures that permissions remains a mutable set.

Lastly, the Permission enum serves as a comprehensive catalog of all the permissions accessible on the website, with each permission corresponding to its specific bit position. Presented below is a compact initial list, which will be expanded upon release:

enum class Permission(val bit: Int) {
    CREATE_ACCOUNT(0), VIEW_ACCOUNT(1), EDIT_ACCOUNT(2), DELETE_ACCOUNT(3),
    CREATE_ACTIVITY(4), VIEW_ACTIVITY(5), EDIT_ACTIVITY(6), DELETE_ACTIVITY(7),
    EDIT_SETTINGS(8), SUPERUSER(9)
}

Roles

Roles symbolize positions held within NIAEFEUP, such as President or Recruit. Each role is linked to a particular generation and a set of permissions. In the following example, we will examine the existing model implementation and clarify the less evident fields:

@Entity
class Role(
    @JsonProperty(required = true)
    var name: String,

    @JsonProperty(required = true)
    @field:Convert(converter = PermissionsConverter::class)
    var permissions: Permissions,

    @JsonProperty(required = true)
    var isSection: Boolean,

    @ManyToMany(mappedBy = "roles")
    @JsonIgnore
    @OnDelete(action = OnDeleteAction.CASCADE) // Remove relationship, since this is the non-owner side
    val accounts: MutableList<@Valid Account> = mutableListOf(),

    @Id @GeneratedValue
    val id: Long? = null
) {
    @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, mappedBy = "role")
    @JsonManagedReference
    val associatedActivities: MutableList<@Valid PerActivityRole> = mutableListOf()

    @JoinColumn
    @ManyToOne(fetch = FetchType.LAZY)
    @JsonBackReference
    lateinit var generation: Generation
}
  • The permissions field is subject to conversion through the PermissionsConverter, as detailed previously.
  • The isSection field serves to indicate whether the role should be presented as a section on the members' page.
  • If any of the annotations are confusing, you can refer to the Models wiki page for clarification.

Generations

Generations are simple objects identified by a school year and hold all of the roles utilized during that academic year. One should always take into consideration the current generation when handling access control or modifying roles and permissions.

This is the current implementation of the model:

@Entity
class Generation(
    @JsonProperty(required = true)
    @Column(unique = true)
    @field:SchoolYear
    var schoolYear: String,

    @Id @GeneratedValue
    val id: Long? = null
) {
    @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, mappedBy = "generation")
    @JsonManagedReference
    @field:NoDuplicateRoles
    val roles: MutableList<@Valid Role> = mutableListOf()
}

Handling Roles

The RoleController offers a multitude of operations for managing roles, with the underlying functionality being implemented in the RoleService. In addition to the standard CRUD (Create, Read, Update, Delete) operations for roles, there exist endpoints for granting and revoking permissions, allocating and withdrawing activity permissions, and adding or removing a user from a specified role. Presented below is an excerpt from the service, but you are encouraged to explore all the operations within the code.

fun grantPermissionToRole(roleId: Long, permissions: Permissions) {
    val role = getRoleById(roleId)
    role.permissions.addAll(permissions)
    roleRepository.save(role)
}

fun updateRole(roleId: Long, dto: UpdateRoleDto): Role {
    val role = getRoleById(roleId)

    dto.update(role)
    // Re-triggers the @NoDuplicateRoles validator
    if (validator.validateProperty(role.generation, "roles").isNotEmpty()) {
        throw IllegalArgumentException(ErrorMessages.roleAlreadyExists(role.name, role.generation.schoolYear))
    }

    return roleRepository.save(role)
}

Handling Generations

Much like the roles, the GenerationController offers a wide range of operations for managing generations, with the underlying logic implemented in the GenerationService. These operations can be somewhat intricate because they allow for the cascading creation and removal of roles within the generation.

To gain a better understanding of the intended implementation, a series of flowcharts were created prior to feature implementation. You can review them here or view the image below. Please note that these were initial sketches, and not all operations may align precisely with the current implementation:

generations

Generations are a great example of how repositories can be used to save some work when fetching data from the database:

@Repository
interface GenerationRepository : CrudRepository<Generation, Long> {
    fun findBySchoolYear(schoolYear: String): Generation?

    fun findFirstByOrderBySchoolYearDesc(): Generation?

    @Query("SELECT schoolYear FROM Generation ORDER BY schoolYear DESC")
    fun findAllSchoolYearOrdered(): List<String>
}

Another noteworthy aspect is the customized format used when returning generations to our API. In such instances, we transform them into a list of sections, which are essentially roles with the isSection flag set to true. These sections include user information, along with any additional roles they may possess.

To achieve this, we introduced the alias GetGenerationDto. You can locate the buildGetGenerationDto method in src/dto/generations/GetGenerationDto.kt, responsible for converting the generation model into this specific format.

typealias GetGenerationDto = List<GenerationSectionDto>

data class GenerationUserDto(
    @JsonUnwrapped
    val account: Account,
    val roles: List<String>
)

data class GenerationSectionDto(
    val section: String,
    val accounts: List<GenerationUserDto>
)

Additional methods are more straightforward and are available in the GenerationService. Below, you can find a few examples:

fun createNewGeneration(dto: GenerationDto): Generation {
    dto.schoolYear = inferSchoolYearIfNotSpecified(dto)
    val generation = dto.create()
    assignRolesAndActivities(generation, dto)
    return repository.save(generation)
}

fun getGenerationByIdOrInferLatest(id: Long?): Generation {
    return if (id != null) {
        repository.findById(id).orElseThrow {
            NoSuchElementException(ErrorMessages.generationNotFound(id))
        }
    } else {
        repository.findFirstByOrderBySchoolYearDesc()
            ?: throw IllegalArgumentException(ErrorMessages.noGenerations)
    }
}

Access Control

The final phase of implementing the role system involves securing the endpoints with the necessary permissions. This development is currently in progress within #147. It closely aligns with Spring Security's functionalities, which are elaborated upon in Authentication. The process essentially consists of the following steps:

  • Adding toString() methods to the Role and PerActivityRole classes to enable Spring Security to store them in its context. (Refer to getAuthorities).
// Role.kt
override fun toString(): String {
    val permissionsPayload = permissions.joinToString(separator = " ") { it.name }
    if (associatedActivities.isEmpty()) {
        return permissionsPayload
    }
    return permissionsPayload + " " + associatedActivities.joinToString(separator = " ").trimEnd()
}

// PerActivityRole.kt
override fun toString(): String {
    val activityTitle = activity.title.filter { it.isLetterOrDigit() }.uppercase(Locale.getDefault())
    val permissionNames = permissions.joinToString(separator = "-") { it.name }
    return "$activityTitle:$permissionNames"
}
  • Implement methods within the AuthService to validate both regular and activity permissions.
fun hasPermission(permission: String): Boolean {
    val authentication = SecurityContextHolder.getContext().authentication
    return authentication.authorities.any {
        it.toString() == permission
    }
}

fun hasActivityPermission(activityId: Long, permission: String): Boolean {
    val authentication = SecurityContextHolder.getContext().authentication

    val activity = activityService.getActivityById(activityId)
    val name = activity.title.filter { it.isLetterOrDigit() }.uppercase(Locale.getDefault())

    return authentication.authorities.any {
        val payload = it.toString().split(":")
        payload.size == 2 && payload[0] == name && payload[1].split("-").any { p -> p == permission }
    }
}
  • Apply annotations such as @PreAuthorize to the endpoints, invoking the appropriate authentication service methods, as shown below:
@PreAuthorize("@authService.hasPermission(#permission)") // #permissions refers to the path variable
@GetMapping("/hasPermission/{permission}")
fun protectedPermission(@PathVariable permission: String): Map<String, String> {
    return mapOf("message" to "You have permission to access this endpoint!")
}

@PreAuthorize("@authService.hasActivityPermission(#activityId, #permission)")
@GetMapping("/hasPermission/{activityId}/{permission}")
fun protectedPerActivityPermission(
    @PathVariable activityId: Long,
    @PathVariable permission: String
): Map<String, String> {
    return mapOf("message" to "You have permission to access this endpoint!")
}

Please ensure to verify the current implementation, as the one described at the time of writing may undergo changes.