Skip to content

Commit

Permalink
#1313 Allowing all users to edit dashboards
Browse files Browse the repository at this point in the history
  • Loading branch information
dcoraboeuf committed Jul 1, 2024
1 parent d6d257a commit e94518a
Show file tree
Hide file tree
Showing 17 changed files with 301 additions and 58 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ class CascSecuritySettingsIT : AbstractCascTestSupport() {
fun `Security settings`() {
withSettings<SecuritySettings> {
withNoGrantViewToAll {
casc("""
casc(
"""
ontrack:
config:
settings:
security:
grantProjectViewToAll: true
grantProjectParticipationToAll: true
""".trimIndent())
""".trimIndent()
)

// Checks the new settings
val settings = cachedSettingsService.getCachedSettings(SecuritySettings::class.java)
Expand All @@ -36,6 +38,32 @@ class CascSecuritySettingsIT : AbstractCascTestSupport() {
}
}

@Test
fun `Disabling dashboard rights`() {
withCleanSettings<SecuritySettings> {

val defaults = cachedSettingsService.getCachedSettings(SecuritySettings::class.java)
assertEquals(true, defaults.grantDashboardEditionToAll)
assertEquals(true, defaults.grantDashboardSharingToAll)

casc(
"""
ontrack:
config:
settings:
security:
grantDashboardEditionToAll: false
grantDashboardSharingToAll: false
""".trimIndent()
)

// Checks the new settings
val settings = cachedSettingsService.getCachedSettings(SecuritySettings::class.java)
assertEquals(false, settings.grantDashboardEditionToAll)
assertEquals(false, settings.grantDashboardSharingToAll)
}
}

@Test
fun `Rendering the settings`() {
asAdmin {
Expand All @@ -52,6 +80,8 @@ class CascSecuritySettingsIT : AbstractCascTestSupport() {
"grantProjectViewToAll" to true,
"grantProjectParticipationToAll" to true,
"builtInAuthenticationEnabled" to true,
"grantDashboardEditionToAll" to true,
"grantDashboardSharingToAll" to true,
).asJson(),
json
)
Expand All @@ -72,13 +102,15 @@ class CascSecuritySettingsIT : AbstractCascTestSupport() {
builtInAuthenticationEnabled = true,
)
)
casc("""
casc(
"""
ontrack:
config:
settings:
security:
builtInAuthenticationEnabled: false
""".trimIndent())
""".trimIndent()
)

// Checks the new settings
val settings = cachedSettingsService.getCachedSettings(SecuritySettings::class.java)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ class MockSecurityService : SecurityService {
override val autoProjectFunctions: Set<KClass<out ProjectFunction>>
get() = error("Not available in mock")

override val autoGlobalFunctions: Set<KClass<out GlobalFunction>>
get() = error("Not available in mock")

override val currentAccount: OntrackAuthenticatedUser?
get() = error("Not available in mock")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package net.nemerosa.ontrack.model.dashboards

/**
* Right to manage dashboards globally.
*/
interface DashboardGlobal: DashboardEdition, DashboardSharing
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,53 @@ import java.io.Serializable
import kotlin.reflect.KClass

data class Authorisations(
private val projectFunctions: Set<KClass<out ProjectFunction>> = emptySet(),
private val globalRole: GlobalRole? = null,
private val projectRoleAssociations: Set<ProjectRoleAssociation> = emptySet()
private val projectFunctions: Set<KClass<out ProjectFunction>> = emptySet(),
private val globalFunctions: Set<KClass<out GlobalFunction>> = emptySet(),
private val globalRole: GlobalRole? = null,
private val projectRoleAssociations: Set<ProjectRoleAssociation> = emptySet()
) : AuthorisationsCheck, Serializable {

companion object {
@JvmStatic
fun none() = Authorisations(emptySet(), null, emptySet())
fun none() = Authorisations(
projectFunctions = emptySet(),
globalFunctions = emptySet(),
globalRole = null,
projectRoleAssociations = emptySet(),
)
}

override fun isGranted(fn: Class<out GlobalFunction>) = globalRole != null && globalRole.isGlobalFunctionGranted(fn)
override fun isGranted(fn: Class<out GlobalFunction>) =
globalFunctions.map { it.java }.any { fn.isAssignableFrom(it) } ||
(globalRole != null && globalRole.isGlobalFunctionGranted(fn))

override fun isGranted(projectId: Int, fn: Class<out ProjectFunction>) =
(globalRole != null && globalRole.isProjectFunctionGranted(fn))
|| projectFunctions.map { it.java }.any { fn.isAssignableFrom(it) }
|| projectRoleAssociations.any { pa -> pa.projectId == projectId && pa.isGranted(fn) }
(globalRole != null && globalRole.isProjectFunctionGranted(fn))
|| projectFunctions.map { it.java }.any { fn.isAssignableFrom(it) }
|| projectRoleAssociations.any { pa -> pa.projectId == projectId && pa.isGranted(fn) }

fun withProjectFunctions(projectFunctions: Set<KClass<out ProjectFunction>>) =
Authorisations(projectFunctions, globalRole, projectRoleAssociations)
Authorisations(projectFunctions, globalFunctions, globalRole, projectRoleAssociations)

fun withGlobalRole(globalRole: GlobalRole?) = Authorisations(projectFunctions, globalRole, projectRoleAssociations)
fun withGlobalFunctions(globalFunctions: Set<KClass<out GlobalFunction>>) =
Authorisations(projectFunctions, globalFunctions, globalRole, projectRoleAssociations)

fun withGlobalRole(globalRole: GlobalRole?) =
Authorisations(projectFunctions, globalFunctions, globalRole, projectRoleAssociations)

fun withProjectRoles(projectRoleAssociations: Collection<ProjectRoleAssociation>) =
Authorisations(projectFunctions, globalRole, this.projectRoleAssociations + projectRoleAssociations)
Authorisations(
projectFunctions,
globalFunctions,
globalRole,
this.projectRoleAssociations + projectRoleAssociations
)

fun withProjectRole(projectRoleAssociation: ProjectRoleAssociation) =
Authorisations(projectFunctions, globalRole, this.projectRoleAssociations + projectRoleAssociation)
Authorisations(
projectFunctions,
globalFunctions,
globalRole,
this.projectRoleAssociations + projectRoleAssociation
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package net.nemerosa.ontrack.model.security

import net.nemerosa.ontrack.model.dashboards.DashboardEdition
import net.nemerosa.ontrack.model.dashboards.DashboardGlobal
import net.nemerosa.ontrack.model.dashboards.DashboardSharing
import net.nemerosa.ontrack.model.labels.LabelManagement
import net.nemerosa.ontrack.model.labels.ProjectLabelManagement
Expand Down Expand Up @@ -97,6 +98,7 @@ interface RolesService {
ValidationStampBulkUpdate::class.java,
DashboardEdition::class.java,
DashboardSharing::class.java,
DashboardGlobal::class.java,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ interface SecurityService {
*/
val autoProjectFunctions: Set<KClass<out ProjectFunction>>

/**
* List of [global functions][GlobalFunction] which are automatically assigned to authenticated users.
*/
val autoGlobalFunctions: Set<KClass<out GlobalFunction>>

/**
* Returns the current logged account or `null` if none is logged.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty
import net.nemerosa.ontrack.model.annotations.APIDescription
import net.nemerosa.ontrack.model.form.Form
import net.nemerosa.ontrack.model.form.YesNo
import net.nemerosa.ontrack.model.form.yesNoField

/**
* General security settings.
Expand All @@ -21,10 +22,16 @@ class SecuritySettings(
val isGrantProjectParticipationToAll: Boolean,
@APIDescription("Enabling the built-in authentication")
val builtInAuthenticationEnabled: Boolean = DEFAULT_BUILTIN_AUTHENTICATION_ENABLED,
@APIDescription("Grants dashboard creation rights to all")
val grantDashboardEditionToAll: Boolean = DEFAULT_GRANT_DASHBOARD_EDITION,
@APIDescription("Grants dashboard sharing rights to all")
val grantDashboardSharingToAll: Boolean = DEFAULT_GRANT_DASHBOARD_SHARING,
) {

companion object {
const val DEFAULT_BUILTIN_AUTHENTICATION_ENABLED = true
const val DEFAULT_GRANT_DASHBOARD_EDITION = true
const val DEFAULT_GRANT_DASHBOARD_SHARING = true
}

fun form(): Form =
Expand All @@ -48,4 +55,12 @@ class SecuritySettings(
.help("Enabling the built-in authentication")
.value(builtInAuthenticationEnabled)
)
.yesNoField(
SecuritySettings::grantDashboardEditionToAll,
grantDashboardEditionToAll
)
.yesNoField(
SecuritySettings::grantDashboardSharingToAll,
grantDashboardSharingToAll
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,26 +105,45 @@ class DashboardServiceImpl(
return dashboard
}

/**
* Deletion of an existing dashboard.
*
* A built-in dashboard can never be deleted.
*
* A shared dashboard can be deleted if:
*
* * the user is granted the [DashboardGlobal] function
* * or the user owns this dashboard
*
* A private dashboard can be deleted if:
*
* * the user owns this dashboard
*/
override fun deleteDashboard(uuid: String) {
val existing = dashboardStorageService.findDashboardByUuid(uuid)
?: throw DashboardUuidNotFoundException(uuid)

when (existing.userScope) {
val account = securityService.currentAccount?.account
val ownsDashboard = account != null && dashboardStorageService.ownDashboard(uuid, account.id)

val okToDelete: Boolean = when (existing.userScope) {
DashboardContextUserScope.BUILT_IN -> throw DashboardCannotDeleteBuiltInException()
DashboardContextUserScope.SHARED -> securityService.checkGlobalFunction(DashboardSharing::class.java)
DashboardContextUserScope.PRIVATE -> securityService.checkGlobalFunction(DashboardEdition::class.java)
DashboardContextUserScope.SHARED -> ownsDashboard || securityService.isGlobalFunctionGranted<DashboardGlobal>()
DashboardContextUserScope.PRIVATE -> ownsDashboard
}

val account = securityService.currentAccount?.account
if (account != null) {
val prefs = preferencesService.getPreferences(account)
if (prefs.dashboardUuid == uuid) {
prefs.dashboardUuid = null
preferencesService.setPreferences(account, prefs)
if (okToDelete) {

if (account != null) {
val prefs = preferencesService.getPreferences(account)
if (prefs.dashboardUuid == uuid) {
prefs.dashboardUuid = null
preferencesService.setPreferences(account, prefs)
}
}
}

dashboardStorageService.deleteDashboard(uuid)
dashboardStorageService.deleteDashboard(uuid)
}
}

override fun selectDashboard(uuid: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class DashboardStorageServiceImpl(
store = STORE,
type = StoredDashboard::class,
size = MAX_DASHBOARDS,
query = "CAST(data->>'userId' as int) = :userId",
query = "CAST(data->>'userId' as int) = :userId AND data->'dashboard'->>'userScope' = 'PRIVATE'",
queryVariables = mapOf("userId" to id.value)
).map { it.dashboard }

Expand All @@ -30,20 +30,18 @@ class DashboardStorageServiceImpl(
store = STORE,
type = StoredDashboard::class,
size = MAX_DASHBOARDS,
query = "data->>'userId' IS NULL",
query = "data->'dashboard'->>'userScope' = 'SHARED'",
).map { it.dashboard }

override fun saveDashboard(dashboard: Dashboard): Dashboard {
val userId = if (dashboard.userScope == DashboardContextUserScope.PRIVATE) {
securityService.currentAccount?.id()
} else {
null
if (dashboard.userScope == DashboardContextUserScope.BUILT_IN) {
error("Cannot save built-in dashboards")
}
storageService.store(
STORE,
dashboard.uuid,
StoredDashboard(
userId = userId,
userId = securityService.currentAccount?.id(),
dashboard = dashboard,
)
)
Expand All @@ -59,6 +57,12 @@ class DashboardStorageServiceImpl(
storageService.delete(STORE, uuid)
}

/**
* Stored dashboard
*
* @property userId Creator of the dashboard (null for backward compatibility)
* @property dashboard Dashboard definition
*/
private data class StoredDashboard(
val userId: Int?,
val dashboard: Dashboard,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package net.nemerosa.ontrack.service.security

import net.nemerosa.ontrack.common.getOrNull
import net.nemerosa.ontrack.model.Ack
import net.nemerosa.ontrack.model.exceptions.AccountDefaultAdminCannotDeleteException
import net.nemerosa.ontrack.model.exceptions.AccountDefaultAdminCannotUpdateNameException
Expand All @@ -18,6 +17,7 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.*
import java.util.stream.Collectors
import kotlin.jvm.optionals.getOrNull

@Service
@Transactional
Expand All @@ -39,6 +39,7 @@ class AccountServiceImpl(
// Direct account authorisations
val authorisations = Authorisations()
.withProjectFunctions(securityService.autoProjectFunctions)
.withGlobalFunctions(securityService.autoGlobalFunctions)
.withGlobalRole(roleRepository.findGlobalRoleByAccount(raw.accountId).getOrNull()
?.let { id: String -> rolesService.getGlobalRole(id).getOrNull() })
.withProjectRoles(roleRepository.findProjectRoleAssociationsByAccount(raw.accountId) { project: Int, roleId: String ->
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package net.nemerosa.ontrack.service.security

import net.nemerosa.ontrack.model.dashboards.DashboardEdition
import net.nemerosa.ontrack.model.dashboards.DashboardSharing
import net.nemerosa.ontrack.model.labels.LabelManagement
import net.nemerosa.ontrack.model.labels.ProjectLabelManagement
import net.nemerosa.ontrack.model.security.*
Expand Down Expand Up @@ -285,7 +286,8 @@ class RolesServiceImpl(
registerGlobalRole(Roles.GLOBAL_PARTICIPANT, "Participant",
"This role grants a read-only access to all projects and the right to comment on validation runs.",
RolesService.readOnlyGlobalFunctions
+ DashboardEdition::class.java,
+ DashboardEdition::class.java
+ DashboardSharing::class.java,
RolesService.readOnlyProjectFunctions
+ ValidationRunStatusChange::class.java
+ ValidationRunStatusCommentEditOwn::class.java
Expand Down
Loading

0 comments on commit e94518a

Please sign in to comment.