Skip to content

Commit

Permalink
Hoist role support (#309)
Browse files Browse the repository at this point in the history
  • Loading branch information
lbwexler authored Dec 26, 2023
1 parent 4dda421 commit 52a9871
Show file tree
Hide file tree
Showing 19 changed files with 813 additions and 95 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## 18.0-SNAPSHOT

### 🎁 New Features

* New support for Role Management.
* Hoist now supports an out-of-the-box, database-driven system for maintaining a hierarchical set
of Roles associating and associating them with individual users.
* New system supports app and plug-in specific integrations to AD and other enterprise systems.
* Administration of the new system provided by a new admin UI in hoist-react v60 and above.

### ⚙️ Technical

* Add `xh/echoHeaders` utility endpoint. Useful for verifying headers (e.g. `jespa_connection_id`)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package io.xh.hoist.admin

import io.xh.hoist.BaseController
import io.xh.hoist.role.Role
import io.xh.hoist.role.RoleAdminService
import io.xh.hoist.security.Access

@Access(['HOIST_ROLE_MANAGER'])
class RoleAdminController extends BaseController {
RoleAdminService roleAdminService

@Access(['HOIST_ADMIN_READER'])
def list() {
List<Map> roles = roleAdminService.list()
renderJSON(data:roles)
}

def create() {
ensureAuthUserCanEdit()
Map roleSpec = parseRequestJSON()
Role role = roleAdminService.create(roleSpec)
renderJSON(data:role)
}

def update() {
ensureAuthUserCanEdit()
Map roleSpec = parseRequestJSON()
Role role = roleAdminService.update(roleSpec)
renderJSON(data:role)
}

def delete(String id) {
ensureAuthUserCanEdit()
roleAdminService.delete(id)
renderJSON(success:true)
}


//-----------------------
// Implementation
//-----------------------
private void ensureAuthUserCanEdit() {
if (!authUser.hasRole('HOIST_ROLE_MANAGER')) {
throw new RuntimeException("$authUsername is not a 'HOIST_ROLE_MANAGER'")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ package io.xh.hoist.admin

import io.xh.hoist.BaseController
import io.xh.hoist.security.Access
import io.xh.hoist.user.BaseRoleService
import io.xh.hoist.role.BaseRoleService
import io.xh.hoist.user.BaseUserService
import static io.xh.hoist.util.Utils.parseBooleanStrict

Expand Down
150 changes: 150 additions & 0 deletions grails-app/domain/io/xh/hoist/role/Role.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package io.xh.hoist.role

import io.xh.hoist.json.JSONFormat

/**
* Backing domain class for Hoist's built-in role management. Methods on this class are used
* internally by Hoist and should not be called directly by application code.
*/
class Role implements JSONFormat {
String name
String category
String notes
Date lastUpdated
String lastUpdatedBy
static hasMany = [members: RoleMember]

static mapping = {
table 'xh_role'
id name: 'name', generator: 'assigned', type: 'string'
cache true
members cascade: 'all-delete-orphan', fetch: 'join', cache: true
}

static constraints = {
category nullable: true, maxSize: 30, blank: false
notes nullable: true, maxSize: 1200
lastUpdatedBy maxSize: 50
}

Map formatForJSON() {
[
name: name,
category: category,
notes: notes,
lastUpdated: lastUpdated,
lastUpdatedBy: lastUpdatedBy
]
}

List<String> getUsers() {
members.findAll { it.type == RoleMember.Type.USER }.collect { it.name }
}

List<String> getDirectoryGroups() {
members.findAll { it.type == RoleMember.Type.DIRECTORY_GROUP }.collect { it.name }
}

List<String> getRoles() {
members.findAll { it.type == RoleMember.Type.ROLE }.collect { it.name }
}

Map<RoleMember.Type, List<EffectiveMember>> resolveEffectiveMembers() {
Map<RoleMember.Type, List<EffectiveMember>> ret = [:]
List<EffectiveMember> effectiveRoles = listEffectiveRoles()

ret.put(RoleMember.Type.USER, listEffectiveUsers(effectiveRoles))
ret.put(RoleMember.Type.DIRECTORY_GROUP, listEffectiveDirectoryGroups(effectiveRoles))
ret.put(RoleMember.Type.ROLE, effectiveRoles)

return ret
}

/**
* Use BFS to find all roles for which this role is an effective member, with source association
*/
List<EffectiveMember> listInheritedRoles() {
Set<String> visitedRoles = [name]
Queue<Role> rolesToVisit = [this] as Queue
List<Role> allRoles = list()
Map<String, EffectiveMember> ret = [:].withDefault { new EffectiveMember([name: it])}

while (!rolesToVisit.isEmpty()) {
Role role = rolesToVisit.poll()
allRoles
.findAll { it.roles.contains(role.name) }
.each { inheritedRole ->
ret[inheritedRole.name].sourceRoles << role.name
if (!visitedRoles.contains(inheritedRole.name)) {
visitedRoles.add(inheritedRole.name)
rolesToVisit.offer(inheritedRole)
}
}
}

ret.values() as List<EffectiveMember>
}

//------------------------
// Implementation
//------------------------

/**
* List users, each with a list of role-names justifying why they inherit this role
*/
private List<EffectiveMember> listEffectiveUsers(List<EffectiveMember> effectiveRoles) {
collectEffectiveMembers(effectiveRoles) { it.users }
}

/**
* List directory groups, each with a list of role-names justifying why they inherit this role
*/
private List<EffectiveMember> listEffectiveDirectoryGroups(List<EffectiveMember> effectiveRoles) {
collectEffectiveMembers(effectiveRoles) { it.directoryGroups }
}

/**
* List effective members of this role with source associations
*/
private List<EffectiveMember> listEffectiveRoles() {
Set<String> visitedRoles = [name]
Queue<Role> rolesToVisit = new LinkedList<Role>()
rolesToVisit.offer(this)
Map<String, EffectiveMember> ret = [:].withDefault { new EffectiveMember([name: it])}

while (!rolesToVisit.isEmpty()) {
Role role = rolesToVisit.poll()
role.roles.each { memberName ->
ret[memberName].sourceRoles << role.name
if (!visitedRoles.contains(memberName)) {
visitedRoles.add(memberName)
rolesToVisit.offer(get(memberName))
}
}
}

ret.values() as List<EffectiveMember>
}

/**
* Implementation for `listEffectiveUsers` and `listEffectiveDirectoryGroups`
*/
private List<EffectiveMember> collectEffectiveMembers(
List<EffectiveMember> sourceRoles,
Closure<List<String>> memberNamesFn
) {
Map<String, EffectiveMember> ret = [:].withDefault { new EffectiveMember([name: it])}

memberNamesFn(this).each { memberName ->
ret[memberName].sourceRoles << name
}
sourceRoles.each { sourceRole ->
String sourceRoleName = sourceRole.name
memberNamesFn(get(sourceRoleName)).each { memberName ->
ret[memberName].sourceRoles << sourceRoleName
}
}

ret.values() as List<EffectiveMember>
}
}
32 changes: 32 additions & 0 deletions grails-app/domain/io/xh/hoist/role/RoleMember.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package io.xh.hoist.role

import io.xh.hoist.json.JSONFormat

class RoleMember implements JSONFormat {
enum Type {
USER,
DIRECTORY_GROUP,
ROLE
}

Type type
String name
static belongsTo = [role: Role]

Date dateCreated
String createdBy

static mapping = {
table 'xh_role_member'
cache true
}

Map formatForJSON() {
return [
type: type.name(),
name: name,
dateCreated: dateCreated,
createdBy: createdBy
]
}
}
47 changes: 45 additions & 2 deletions grails-app/init/io/xh/hoist/BootStrap.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package io.xh.hoist

import grails.util.Holders
import io.xh.hoist.role.Role
import io.xh.hoist.util.Utils

import java.time.ZoneId
Expand All @@ -18,12 +19,14 @@ class BootStrap {

def logLevelService,
configService,
prefService
prefService,
roleAdminService

def init = {servletContext ->
logStartupMsg()
ensureRequiredConfigsCreated()
ensureRequiredPrefsCreated()
ensureRequiredRolesCreated()

ensureExpectedServerTimeZone()

Expand All @@ -40,7 +43,6 @@ class BootStrap {
//------------------------
private void logStartupMsg() {
def hoist = Holders.currentPluginManager().getGrailsPlugin('hoist-core')

log.info("""
\n
__ __ ______ __ ______ ______
Expand Down Expand Up @@ -256,6 +258,18 @@ class BootStrap {
groupName: 'xh.io',
note: 'Email address to which status monitor alerts should be sent. Value "none" disables emailed alerts.'
],
xhRoleModuleConfig: [
valueType: 'json',
defaultValue: [
enabled: true,
assignDirectoryGroups: true,
assignUsers: true,
refreshIntervalSecs: 30,
],
clientVisible: true,
groupName: 'xh.io',
note: 'Configures built-in role management.'
],
xhWebSocketConfig: [
valueType: 'json',
defaultValue: [
Expand Down Expand Up @@ -309,6 +323,35 @@ class BootStrap {
])
}

private void ensureRequiredRolesCreated() {
if (!configService.getMap('xhRoleModuleConfig').enabled || Role.count()) return
roleAdminService.ensureRequiredRolesCreated([
[
name: 'HOIST_ADMIN',
category: 'Hoist',
notes: 'Hoist Admins have full access to all Hoist Admin tools and functionality.'
],
[
name: 'HOIST_ADMIN_READER',
category: 'Hoist',
notes: 'Hoist Admin Readers have read-only access to all Hoist Admin tools and functionality.',
roles: ['HOIST_ADMIN']
],
[
name: 'HOIST_IMPERSONATOR',
category: 'Hoist',
notes: 'Hoist Impersonators can impersonate other users.',
roles: ['HOIST_ADMIN']
],
[
name: 'HOIST_ROLE_MANAGER',
category: 'Hoist',
notes: 'Hoist Role Managers can manage roles and their memberships.',
roles: ['HOIST_ADMIN']
]
])
}

/**
* Validates that the JVM TimeZone matches the value specified by the `xhExpectedServerTimeZone`
* application config. This is intended to ensure that the JVM is running in the expected Zone,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ class AlertBannerService extends BaseService {
private Map cachedBanner = emptyAlert

void init() {
super.init()
createTimer(
runFn: this.&refreshCachedBanner,
interval: 2 * MINUTES,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ class ClientErrorService extends BaseService implements EventPublisher {
private int getAlertInterval() {configService.getMap('xhClientErrorConfig').intervalMins * MINUTES}

void init() {
super.init()
createTimer(
interval: { alertInterval },
delay: 15 * SECONDS
Expand Down
3 changes: 2 additions & 1 deletion grails-app/services/io/xh/hoist/config/ConfigService.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ class ConfigService extends BaseService implements EventPublisher {
/**
* Check a list of core configurations required for Hoist/application operation - ensuring that these configs are
* present and that their valueTypes and clientVisible flags are are as expected. Will create missing configs with
* supplied default values if not found. Called for xh.io configs by Hoist Core Bootstrap.
* supplied default values if not found.
*
* @param reqConfigs - map of configName to map of [valueType, defaultValue, clientVisible, groupName]
*/
@Transactional
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ class EnvironmentService extends BaseService {

void init() {
_appTimeZone = calcAppTimeZone()
super.init();
}

/**
Expand Down
2 changes: 0 additions & 2 deletions grails-app/services/io/xh/hoist/pref/PrefService.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,6 @@ class PrefService extends BaseService {
* these prefs are present and that their values and local flags are as expected. Will create
* missing prefs with supplied default values if not found.
*
* Called for xh.io prefs by Hoist Core Bootstrap.
*
* @param requiredPrefs - map of prefName to map of [type, defaultValue, local, note]
*/
@Transactional
Expand Down
Loading

0 comments on commit 52a9871

Please sign in to comment.