Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Directory group error handling plus #347

Merged
merged 8 commits into from
Apr 3, 2024
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,16 @@
`JSON` object posted in the `body`. This is pattern is preferred over using a `fetchJSON` request with
`params` posted in the request header.

* `DefaultRoleService` has improved error handling for failed directory group lookups.
* `LdapService` bulk lookup methods now provide a `strict` option to throw if a query fails rather than
quietly returning an empty result.

### 💥 Breaking Changes

* Requires `hoist-react >= 63.0` for client-side support of the new `track` and `submitError` endpoints.
* Implementations of `DefaultRoleService.doLoadUsersForDirectoryGroups` will need to handle a new
`strictMode` flag provided as a second argument.



## 18.5.1 - 2024-03-08
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ class RoleAdminController extends BaseController {
}

def usersForDirectoryGroup(String name) {
renderJSON(data: roleService.loadUsersForDirectoryGroups(singleton(name))[name])
renderJSON(data: roleService.loadUsersForDirectoryGroups(singleton(name), true)[name])
}


Expand Down
107 changes: 65 additions & 42 deletions grails-app/services/io/xh/hoist/ldap/LdapService.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -51,83 +51,106 @@ class LdapService extends BaseService {


LdapPerson lookupUser(String sName) {
searchOne("(sAMAccountName=$sName) ", LdapPerson)
searchOne("(sAMAccountName=$sName) ", LdapPerson, true)
}

List<LdapPerson> lookupGroupMembers(String dn) {
// See class-level comment regarding this AD-specific query
searchMany("(|(member0f=$dn) (memberOf:1.2.840.113556.1.4.1941:=$dn))", LdapPerson)
lookupGroupInternal(dn, true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be lookupGroupMembersInternal?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, thank you

}

Map<String, List<LdapPerson>> lookupGroupMembers(Set<String> dns) {
dns.collectEntries { dn -> [dn, task { lookupGroupMembers(dn) }] }
.collectEntries { [it.key, it.value.get()] }
List<LdapGroup> findGroups(String sNamePart) {
searchMany("(sAMAccountName=*$sNamePart*)", LdapGroup, true)
}

Map<String, LdapGroup> lookupGroups(Set<String> dns) {
dns.collectEntries { dn -> [dn, task { lookupGroup(dn) }] }
/**
* Lookup a number of groups in parallel.
* @param dns - set of distinguished names.
* @param strictMode - if true, this method will throw if any lookups fail,
* otherwise, failed lookups will be logged, and resolved as null.
*/
Map<String, LdapGroup> lookupGroups(Set<String> dns, boolean strictMode = false) {
dns.collectEntries { dn -> [dn, task { lookupGroupInternal(dn, strictMode) }] }
.collectEntries { [it.key, it.value.get()] }
}

List<LdapGroup> findGroups(String sNamePart) {
searchMany("(sAMAccountName=*$sNamePart*)", LdapGroup)
/**
* Lookup group members for a number of groups in parallel.
* @param dns - set of distinguished names.
* @param strictMode - if true, this method will throw if any lookups fail,
* otherwise, failed lookups will be logged, and resolved as an empty list.
*/
Map<String, List<LdapPerson>> lookupGroupMembers(Set<String> dns, boolean strictMode = false) {
dns.collectEntries { dn -> [dn, task { lookupGroupMembersInternal(dn, strictMode) }] }
.collectEntries { [it.key, it.value.get()]}
}


//----------------------
// Implementation
//----------------------
private LdapGroup lookupGroup(String dn) {
searchOne("(distinguishedName=$dn)", LdapGroup)
private LdapGroup lookupGroupInternal(String dn, boolean strictMode) {
searchOne("(distinguishedName=$dn)", LdapGroup, strictMode)
}

private <T extends LdapObject> T searchOne(String baseFilter, Class<T> objType) {
private List<LdapPerson> lookupGroupMembersInternal(String dn, boolean strictMode) {
// See class-level comment regarding this AD-specific query
searchMany("(|(member0f=$dn) (memberOf:1.2.840.113556.1.4.1941:=$dn))", LdapPerson, strictMode)
}

private <T extends LdapObject> T searchOne(String baseFilter, Class<T> objType, boolean strictMode) {
for (server in config.servers) {
List<T> matches = doQuery(server, baseFilter, objType)
List<T> matches = doQuery(server, baseFilter, objType, strictMode)
if (matches) return matches.first()
}
return null
}

private <T extends LdapObject> List<T> searchMany(String baseFilter, Class<T> objType) {
private <T extends LdapObject> List<T> searchMany(String baseFilter, Class<T> objType, boolean strictMode) {
List<T> ret = []
for (server in config.servers) {
List<T> matches = doQuery(server, baseFilter, objType)
List<T> matches = doQuery(server, baseFilter, objType, strictMode)
if (matches) ret.addAll(matches)
}
return ret
}

private <T extends LdapObject> List<T> doQuery(Map server, String baseFilter, Class<T> objType) {
private <T extends LdapObject> List<T> doQuery(Map server, String baseFilter, Class<T> objType, boolean strictMode) {
if (!enabled) throw new RuntimeException('LdapService is not enabled.')

boolean isPerson = objType == LdapPerson
String host = server.host,
filter = "(&(objectCategory=${isPerson ? 'Person' : 'Group'})$baseFilter)"

cache.getOrCreate(server.toString() + filter) {
withDebug(["Querying LDAP", [host: host, filter: filter]]) {
LdapNetworkConnection conn
try {

String baseDn = isPerson ? server.baseUserDn : server.baseGroupDn,
username = configService.getString('xhLdapUsername'),
password = configService.getPwd('xhLdapPassword')
String[] keys = objType.keys.toArray() as String[]

conn = new LdapNetworkConnection(host)
conn.timeOut = config.timeoutMs as Long
conn.bind(username, password)
conn.search(baseDn, filter, SearchScope.SUBTREE, keys)
.collect { objType.create(it.attributes as Collection<Attribute>) }
} catch (Exception e) {
logError("Failure querying", [host: host, filter: filter], e)
return null
} finally {
conn?.unBind()
conn?.close()
}
} as List<T>
filter = "(&(objectCategory=${isPerson ? 'Person' : 'Group'})$baseFilter)",
key = server.toString() + filter

List<T> ret = cache.get(key)
if (ret) return ret

withDebug(["Querying LDAP", [host: host, filter: filter]]) {
LdapNetworkConnection conn
try {

String baseDn = isPerson ? server.baseUserDn : server.baseGroupDn,
username = configService.getString('xhLdapUsername'),
password = configService.getPwd('xhLdapPassword')
String[] keys = objType.keys.toArray() as String[]

conn = new LdapNetworkConnection(host)
conn.timeOut = config.timeoutMs as Long
conn.bind(username, password)
ret = conn.search(baseDn, filter, SearchScope.SUBTREE, keys)
.collect { objType.create(it.attributes as Collection<Attribute>) }
cache.put(key, ret)
} catch (Exception e) {
if (strictMode) throw e
logError("Failure querying", [host: host, filter: filter], e)
ret = null
} finally {
conn?.unBind()
conn?.close()
}
}
return ret

}

private Map getConfig() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,15 @@ class DefaultRoleAdminService extends BaseService {
if (defaultRoleService.directoryGroupsSupported) {
Set<String> groups = roles.collectMany(new HashSet()) { it.directoryGroups }
if (groups) {
Map<String, Object> groupsLookup = defaultRoleService.loadUsersForDirectoryGroups(groups)
usersForGroups = groupsLookup.findAll { it.value instanceof Set }
errorsForGroups = groupsLookup.findAll { !(it.value instanceof Set) }
try {
Map<String, Object> groupsLookup = defaultRoleService.loadUsersForDirectoryGroups(groups, true)
usersForGroups = groupsLookup.findAll { it.value instanceof Set }
errorsForGroups = groupsLookup.findAll { !(it.value instanceof Set) }
} catch (Throwable e) {
def errMsg = 'Error resolving Directory Groups'
logError(errMsg, e)
errorsForGroups = groups.collectEntries {[it, errMsg] }
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ class DefaultRoleUpdateService extends BaseService {
roleToDelete.delete(flush: true)

trackService.track(msg: "Deleted role: '$id'", category: 'Audit')
defaultRoleService.clearCaches()
defaultRoleService.refreshRoleAssignments()
}


Expand Down Expand Up @@ -99,7 +99,7 @@ class DefaultRoleUpdateService extends BaseService {
if (role) {
role.addToMembers(type: USER, name: user.username, createdBy: 'defaultRoleUpdateService')
role.save(flush: true)
defaultRoleService.clearCaches()
defaultRoleService.refreshRoleAssignments()
} else {
logWarn("Failed to find role $roleName to assign to $user", "role will not be assigned")
}
Expand Down Expand Up @@ -163,7 +163,7 @@ class DefaultRoleUpdateService extends BaseService {
)
}

defaultRoleService.clearCaches()
defaultRoleService.refreshRoleAssignments()
return role
}

Expand Down
59 changes: 36 additions & 23 deletions src/main/groovy/io/xh/hoist/role/provided/DefaultRoleService.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import static io.xh.hoist.util.InstanceConfigUtils.getInstanceConfig
* Role.
*
* Applications wishing to extend this feature should override doLoadUsersForDirectoryGroups().
* If doLoadUsersForDirectoryGroups() throws an exception, this service will use the last successful
* lookup result and log an error. Clearing this service's caches will also clear the cached lookup.
*
* Certain aspects of this service and its Admin Console UI are soft-configurable via a JSON
* `xhRoleModuleConfig`. This service will create this config entry if not found on startup.
Expand All @@ -81,6 +83,7 @@ class DefaultRoleService extends BaseRoleService {
private Timer timer
protected Map<String, Set<String>> _allRoleAssignments = emptyMap()
protected ConcurrentMap<String, Set<String>> _roleAssignmentsByUser = new ConcurrentHashMap<>()
protected Map<String, Object> _usersForDirectoryGroups = emptyMap()

static clearCachesConfigs = ['xhRoleModuleConfig']

Expand All @@ -89,7 +92,7 @@ class DefaultRoleService extends BaseRoleService {

timer = createTimer(
interval: { config.refreshIntervalSecs as int * DateTimeUtils.SECONDS },
runFn: this.&refreshCaches,
runFn: this.&refreshRoleAssignments,
runImmediatelyAndBlock: true
)
}
Expand Down Expand Up @@ -162,12 +165,16 @@ class DefaultRoleService extends BaseRoleService {
* enabled LdapService in the application. Override this method to customize directory-based
* lookup to attach to different, or additional external datasources.
*
* If strictMode is true, implementations should throw on any partial failures. Otherwise, they
* should log, and make a best-faith effort to return whatever groups they can load.
*
* Method Map of directory group names to either:
* a) Set<String> of assigned users
* OR
* b) String describing lookup error.
*/
protected Map<String, Object> doLoadUsersForDirectoryGroups(Set<String> groups) {
protected Map<String, Object> doLoadUsersForDirectoryGroups(Set<String> groups, boolean strictMode) {
if (!groups) return emptyMap()
if (!ldapService.enabled) {
return groups.collectEntries{[it, 'LdapService not enabled in this application']}
}
Expand All @@ -177,18 +184,18 @@ class DefaultRoleService extends BaseRoleService {

// 1) Determine valid groups
ldapService
.lookupGroups(groups)
.lookupGroups(groups, strictMode)
.each {name, group ->
if (group) {
foundGroups << name
} else {
ret[name] = 'No AD group found'
ret[name] = 'Directory Group not found'
}
}

// 2) Search for members of valid groups
ldapService
.lookupGroupMembers(foundGroups)
.lookupGroupMembers(foundGroups, strictMode)
.each {name, members ->
ret[name] = members.collect(new HashSet()) { it.samaccountname.toLowerCase()}
}
Expand Down Expand Up @@ -268,19 +275,11 @@ class DefaultRoleService extends BaseRoleService {
//---------------------------
// Implementation/Framework
//---------------------------
final Map<String, Object> loadUsersForDirectoryGroups(Set<String> directoryGroups) {
if (!directoryGroups) return emptyMap()
// Wrapped here to avoid failing implementations from ever bringing down app.
try {
return doLoadUsersForDirectoryGroups(directoryGroups)
} catch (Throwable e) {
def errMsg = 'Error resolving Directory Groups'
logError(errMsg, e)
return directoryGroups.collectEntries {[it, errMsg] }
}
final Map<String, Object> loadUsersForDirectoryGroups(Set<String> directoryGroups, boolean strictMode) {
doLoadUsersForDirectoryGroups(directoryGroups, strictMode)
}

protected void refreshCaches() {
void refreshRoleAssignments() {
withDebug('Refreshing role caches') {
_allRoleAssignments = unmodifiableMap(generateRoleAssignments())
_roleAssignmentsByUser = new ConcurrentHashMap()
Expand All @@ -290,16 +289,27 @@ class DefaultRoleService extends BaseRoleService {
@ReadOnly
protected Map<String, Set<String>> generateRoleAssignments() {
List<Role> roles = Role.list()
Map<String, Object> usersForDirectoryGroups = [:]

if (directoryGroupsSupported) {
Set<String> groups = roles.collectMany(new HashSet()) { it.directoryGroups }
loadUsersForDirectoryGroups(groups).each { k, v ->
if (v instanceof Set) {
usersForDirectoryGroups[k] = v
} else {
logError("Error resolving users for directory group", k, v)

// Error handling on resolution. Can be complex (e.g. parallel LDAP calls) so be robust.
// If we don't have results, take any results we can get, but
// if we do have results, never replace them with non-complete/imperfect set.
boolean strictMode = _usersForDirectoryGroups as boolean
try {
Map<String, Object> usersForDirectoryGroups = [:]
loadUsersForDirectoryGroups(groups, strictMode).each { k, v ->
if (v instanceof Set) {
usersForDirectoryGroups[k] = v
} else {
logError("Error resolving users for directory group", k, v)
}
}
_usersForDirectoryGroups = usersForDirectoryGroups
} catch (Throwable e) {
// Leave existing _usersForDirectoryGroups cache in place, log error, and continue.
logError("Error resolving users for directory groups", e)
}
}

Expand All @@ -313,7 +323,7 @@ class DefaultRoleService extends BaseRoleService {
if (directoryGroupsSupported) groups.addAll(effRole.directoryGroups)
}
groups.each { group ->
usersForDirectoryGroups[group]?.each { users << it.toLowerCase() }
_usersForDirectoryGroups[group]?.each { users << it.toLowerCase() }
}

logTrace("Generated assignments for ${role.name}", "${users.size()} effective users")
Expand Down Expand Up @@ -347,6 +357,9 @@ class DefaultRoleService extends BaseRoleService {
}

void clearCaches() {
_allRoleAssignments = emptyMap()
_roleAssignmentsByUser = new ConcurrentHashMap()
_usersForDirectoryGroups = emptyMap()
timer.forceRun()
super.clearCaches()
}
Expand Down
Loading