Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package dev.kviklet.kviklet.controller

import dev.kviklet.kviklet.security.EnterpriseOnly
import dev.kviklet.kviklet.service.RoleSyncConfigService
import dev.kviklet.kviklet.service.dto.RoleSyncConfig
import dev.kviklet.kviklet.service.dto.RoleSyncMapping
import dev.kviklet.kviklet.service.dto.SyncMode
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.http.ResponseEntity
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

data class RoleSyncConfigResponse(
val enabled: Boolean,
val syncMode: SyncMode,
val groupsAttribute: String,
val mappings: List<RoleSyncMappingResponse>,
) {
companion object {
fun fromDto(config: RoleSyncConfig): RoleSyncConfigResponse = RoleSyncConfigResponse(
enabled = config.enabled,
syncMode = config.syncMode,
groupsAttribute = config.groupsAttribute,
mappings = config.mappings.map { RoleSyncMappingResponse.fromDto(it) },
)
}
}

data class RoleSyncMappingResponse(
val id: String,
val idpGroupName: String,
val roleId: String,
val roleName: String,
) {
companion object {
fun fromDto(mapping: RoleSyncMapping): RoleSyncMappingResponse = RoleSyncMappingResponse(
id = mapping.id ?: "",
idpGroupName = mapping.idpGroupName,
roleId = mapping.roleId,
roleName = mapping.roleName,
)
}
}

data class UpdateRoleSyncConfigRequest(
val enabled: Boolean? = null,
val syncMode: SyncMode? = null,
val groupsAttribute: String? = null,
)

data class AddRoleSyncMappingRequest(val idpGroupName: String, val roleId: String)

@RestController
@Validated
@RequestMapping("/config/role-sync")
@Tag(
name = "Role Sync Config",
description = "Configure role synchronization from identity providers.",
)
class RoleSyncConfigController(private val roleSyncConfigService: RoleSyncConfigService) {
@GetMapping("/")
@EnterpriseOnly(feature = "Role Synchronization")
fun getConfig(): ResponseEntity<RoleSyncConfigResponse> {
val config = roleSyncConfigService.getConfig()
return ResponseEntity.ok(RoleSyncConfigResponse.fromDto(config))
}

@PutMapping("/")
@EnterpriseOnly(feature = "Role Synchronization")
fun updateConfig(@RequestBody request: UpdateRoleSyncConfigRequest): ResponseEntity<RoleSyncConfigResponse> {
val config = roleSyncConfigService.updateConfig(
enabled = request.enabled,
syncMode = request.syncMode,
groupsAttribute = request.groupsAttribute,
)
return ResponseEntity.ok(RoleSyncConfigResponse.fromDto(config))
}

@PostMapping("/mappings")
@EnterpriseOnly(feature = "Role Synchronization")
fun addMapping(@RequestBody request: AddRoleSyncMappingRequest): ResponseEntity<RoleSyncMappingResponse> {
val mapping = roleSyncConfigService.addMapping(
idpGroupName = request.idpGroupName,
roleId = request.roleId,
)
return ResponseEntity.ok(RoleSyncMappingResponse.fromDto(mapping))
}

@DeleteMapping("/mappings/{id}")
@EnterpriseOnly(feature = "Role Synchronization")
fun deleteMapping(@PathVariable id: String): ResponseEntity<Unit> {
roleSyncConfigService.deleteMapping(id)
return ResponseEntity.noContent().build()
}
}
177 changes: 177 additions & 0 deletions backend/src/main/kotlin/dev/kviklet/kviklet/db/RoleSyncConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package dev.kviklet.kviklet.db

import dev.kviklet.kviklet.db.util.BaseEntity
import dev.kviklet.kviklet.service.dto.RoleSyncConfig
import dev.kviklet.kviklet.service.dto.RoleSyncMapping
import dev.kviklet.kviklet.service.dto.SyncMode
import jakarta.persistence.CascadeType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import jakarta.persistence.Table
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Service

@Entity
@Table(name = "role_sync_config")
class RoleSyncConfigEntity : BaseEntity {

@Column(nullable = false)
var enabled: Boolean = false

@Column(name = "sync_mode", nullable = false)
@Enumerated(EnumType.STRING)
var syncMode: SyncMode = SyncMode.FULL_SYNC

@Column(name = "groups_attribute", nullable = false)
var groupsAttribute: String = "groups"

@OneToMany(mappedBy = "config", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var mappings: MutableSet<RoleSyncMappingEntity> = mutableSetOf()

constructor(
id: String? = null,
enabled: Boolean = false,
syncMode: SyncMode = SyncMode.FULL_SYNC,
groupsAttribute: String = "groups",
) : this() {
this.id = id
this.enabled = enabled
this.syncMode = syncMode
this.groupsAttribute = groupsAttribute
}

constructor()

fun toDto() = RoleSyncConfig(
enabled = enabled,
syncMode = syncMode,
groupsAttribute = groupsAttribute,
mappings = mappings.map { it.toDto() },
)
}

@Entity
@Table(name = "role_sync_mapping")
class RoleSyncMappingEntity : BaseEntity {

@Column(name = "idp_group_name", nullable = false, unique = true)
lateinit var idpGroupName: String

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "role_id", nullable = false)
lateinit var role: RoleEntity

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "config_id")
var config: RoleSyncConfigEntity? = null

constructor(
id: String? = null,
idpGroupName: String,
role: RoleEntity,
config: RoleSyncConfigEntity? = null,
) : this() {
this.id = id
this.idpGroupName = idpGroupName
this.role = role
this.config = config
}

constructor()

fun toDto() = RoleSyncMapping(
id = id,
idpGroupName = idpGroupName,
roleId = role.id!!,
roleName = role.name,
)
}

interface RoleSyncConfigRepository : JpaRepository<RoleSyncConfigEntity, String>

interface RoleSyncMappingRepository : JpaRepository<RoleSyncMappingEntity, String>

@Service
class RoleSyncConfigAdapter(
private val configRepository: RoleSyncConfigRepository,
private val mappingRepository: RoleSyncMappingRepository,
private val roleRepository: RoleRepository,
) {
companion object {
const val DEFAULT_CONFIG_ID = "default"
}

fun getConfig(): RoleSyncConfig {
val config = configRepository.findById(DEFAULT_CONFIG_ID).orElseGet {
configRepository.save(
RoleSyncConfigEntity(
id = DEFAULT_CONFIG_ID,
enabled = false,
syncMode = SyncMode.FULL_SYNC,
groupsAttribute = "groups",
),
)
}
return config.toDto()
}

fun updateConfig(
enabled: Boolean? = null,
syncMode: SyncMode? = null,
groupsAttribute: String? = null,
): RoleSyncConfig {
val config = configRepository.findById(DEFAULT_CONFIG_ID).orElseGet {
RoleSyncConfigEntity(id = DEFAULT_CONFIG_ID)
}

enabled?.let { config.enabled = it }
syncMode?.let { config.syncMode = it }
groupsAttribute?.let { config.groupsAttribute = it }

return configRepository.save(config).toDto()
}

fun addMapping(idpGroupName: String, roleId: String): RoleSyncMapping {
val config = configRepository.findById(DEFAULT_CONFIG_ID).orElseThrow {
IllegalStateException("Role sync config not found")
}

val role = roleRepository.findById(roleId).orElseThrow {
IllegalArgumentException("Role with id $roleId not found")
}

val mapping = RoleSyncMappingEntity(
idpGroupName = idpGroupName,
role = role,
config = config,
)

return mappingRepository.save(mapping).toDto()
}

fun deleteMapping(id: String) {
mappingRepository.deleteById(id)
}

fun deleteAllMappings() {
mappingRepository.deleteAll()
}

fun getMappings(): List<RoleSyncMapping> {
val config = configRepository.findById(DEFAULT_CONFIG_ID).orElse(null) ?: return emptyList()
return config.mappings.map { it.toDto() }
}

fun getMappingsByGroupNames(groupNames: List<String>): List<RoleSyncMapping> {
val config = configRepository.findById(DEFAULT_CONFIG_ID).orElse(null) ?: return emptyList()
return config.mappings
.filter { groupNames.contains(it.idpGroupName) }
.map { it.toDto() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import dev.kviklet.kviklet.db.User
import dev.kviklet.kviklet.db.UserAdapter
import dev.kviklet.kviklet.service.LicenseRestrictionException
import dev.kviklet.kviklet.service.LicenseService
import dev.kviklet.kviklet.service.RoleSyncService
import dev.kviklet.kviklet.service.dto.Role
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
Expand All @@ -27,6 +28,7 @@ class UserAuthService(
private val userAdapter: UserAdapter,
private val roleAdapter: RoleAdapter,
private val licenseService: LicenseService,
private val roleSyncService: RoleSyncService,
) {
/**
* Find existing user or create new one during authentication.
Expand All @@ -35,6 +37,7 @@ class UserAuthService(
* @param idpIdentifier The identity provider-specific identifier
* @param email User's email from the identity provider
* @param fullName User's display name from the identity provider
* @param idpGroups List of group names from the identity provider for role sync
* @param requireLicense If true, throws if no valid license (for SAML)
* @return The user (existing or newly created)
*/
Expand All @@ -43,6 +46,7 @@ class UserAuthService(
idpIdentifier: IdpIdentifier,
email: String,
fullName: String?,
idpGroups: List<String> = emptyList(),
requireLicense: Boolean = false,
): User {
// 1. Check license upfront if required (SAML only)
Expand Down Expand Up @@ -83,9 +87,9 @@ class UserAuthService(
// 5. Update user with current IdP identifier, clear others consistently
user = updateUserIdentifier(user, idpIdentifier, email, fullName)

// 6. [Future] Role sync integration point
// val resolvedRoles = roleSyncService.resolveRoles(idpGroups, user.roles, isNewUser)
// user = user.copy(roles = resolvedRoles)
// 6. Role sync - resolve roles based on IdP groups
val resolvedRoles = roleSyncService.resolveRoles(idpGroups, user.roles, isNewUser)
user = user.copy(roles = resolvedRoles)

return userAdapter.createOrUpdateUser(user)
}
Expand Down
Loading
Loading