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

Improve Directory Group lookup error handling #339

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,19 @@
`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` now optionally throws if a query fails rather than returning an empty result. This is not
expected to affect most applications, but may require some to adjust their error handling.


### 💥 Breaking Changes

* Requires `hoist-react >= 63.0` for client-side support of the new `track` and `submitError` endpoints.
*
### 🐞 Bug Fixes

* Fixed bug in `DefaultRoleService.doLoadUsersForDirectoryGroups` where LDAP members with `null`
samAccountNames were not being filtered out, causing `NullPointerExceptions`.


## 18.5.1 - 2024-03-08
Expand Down
3 changes: 0 additions & 3 deletions grails-app/services/io/xh/hoist/ldap/LdapService.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,6 @@ class LdapService extends BaseService {
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()
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)
usersForGroups = groupsLookup.findAll { it.value instanceof Set }
errorsForGroups = groupsLookup.findAll { !(it.value instanceof Set) }
Copy link
Member

Choose a reason for hiding this comment

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

To confirm - we might still get errorsForGroups populated as expected with partial lookup failures, but now only in the case of 'No AD group found' - is that correct?

Checking that a search returnign no result does not throw- that would be reasonable but didn't want to assume.

(Would suggest changing to "Directory Group not found" FWIW)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Correct - will double-check this now to make sure. And sounds good re: change to error text

Copy link
Contributor Author

@ghsolomon ghsolomon Apr 2, 2024

Choose a reason for hiding this comment

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

Confirmed! Built a snapshot from this branch and tested in a client app to confirm.

} 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
48 changes: 28 additions & 20 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 @@ -168,6 +171,7 @@ class DefaultRoleService extends BaseRoleService {
* b) String describing lookup error.
*/
protected Map<String, Object> doLoadUsersForDirectoryGroups(Set<String> groups) {
if (!groups) return emptyMap()
if (!ldapService.enabled) {
return groups.collectEntries{[it, 'LdapService not enabled in this application']}
}
Expand All @@ -182,15 +186,17 @@ class DefaultRoleService extends BaseRoleService {
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)
.each {name, members ->
ret[name] = members.collect(new HashSet()) { it.samaccountname.toLowerCase()}
ret[name] = members.collect(new HashSet()) { it.samaccountname?.toLowerCase() }
// Exclude members without a samaccountname (e.g. email-only contacts within a DL)
ret[name].remove(null)
}

return ret
Expand Down Expand Up @@ -269,18 +275,10 @@ 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] }
}
doLoadUsersForDirectoryGroups(directoryGroups)
}

protected void refreshCaches() {
void refreshRoleAssignments() {
withDebug('Refreshing role caches') {
_allRoleAssignments = unmodifiableMap(generateRoleAssignments())
_roleAssignmentsByUser = new ConcurrentHashMap()
Expand All @@ -290,16 +288,23 @@ 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)
// Wrapped here to avoid failing implementations from ever bringing down app.
try {
Map<String, Object> usersForDirectoryGroups = [:]
loadUsersForDirectoryGroups(groups).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)
Copy link
Member

Choose a reason for hiding this comment

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

Worth a comment to clarify that leaving the previous _usersForDirectoryGroups in place (and then re-processing it below) is a deliberate choice?

}
}

Expand All @@ -313,7 +318,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 +352,9 @@ class DefaultRoleService extends BaseRoleService {
}

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