fix(acl): preserve user permissions across namespaces - #9812
Conversation
| AclCachePtr.userPredPerms[userID] = perms | ||
| } | ||
| for predicate, permission := range newPerms { | ||
| perms[predicate] = permission |
There was a problem hiding this comment.
The merge itself is correct, and this is pre-existing rather than something you introduced. But the new write loop widens it, so it's worth knowing about.
GetUserPredPerms (line 53) returns the live inner map and then drops the lock. edgraph.authorizePreds then ranges it twice with no lock held (edgraph/access.go L650-L655), while the SubscribeForAclUpdates goroutine calls Update and mutates those same inner maps. I ran a probe under -race against both commits:
- base:
mapdelete(the clear loop above) vsmapIterStart - this branch:
mapassign_faststr(this line) vsmapIterStart
Delete-vs-iterate was already there. Insert-vs-iterate is the nastier form, because a grow/rehash mid-iteration is what trips Go's unrecoverable concurrent map read and map write throw, which takes the Alpha down. This change also makes the maps genuinely larger (the bug was collapsing each user down to a single namespace), so the iteration window widens along with it.
Cheap to close while you're in here:
func (cache *AclCache) GetUserPredPerms(userId string) map[string]int32 {
cache.RLock()
defer cache.RUnlock()
perms := make(map[string]int32, len(cache.userPredPerms[userId]))
for pred, perm := range cache.userPredPerms[userId] {
perms[pred] = perm
}
return perms
}While you're there, authorizePreds calls GetUserPredPerms twice per query, once for the len() in the make and again for the range. One call would do.
| // User IDs are namespace-local, so the same ID can exist in multiple namespaces. Merge the | ||
| // namespaced predicates instead of replacing permissions collected from another namespace. | ||
| for userID, newPerms := range userPredPerms { | ||
| perms, found := AclCachePtr.userPredPerms[userID] |
There was a problem hiding this comment.
Non-blocking design note, since what you have is correct.
The collision is possible at all because userPredPerms is keyed by bare user ID while every value key is namespace-qualified. Keying the outer map by x.NamespaceAttr(ns, userID) would remove it at the source, and take two costs with it that this change slightly worsens:
- The clear loop (L138-L144) walks every user's full predicate map on each refresh, and
RefreshACLscallsUpdateonce per namespace, so a full refresh is O(namespaces x total entries). authorizePredsnow iterates a user's predicates across all namespaces to buildallowedPreds, andexpand(_all_)builds a hash map over that slice on every query. For a user ID present in many namespaces (common in multi-tenant setups) that grows linearly with namespace count.
authorizePreds already has ns := userData.namespace in hand, so GetUserPredPerms(ns, userId) should be a fairly contained change. Happy to take it as a follow-up if you'd rather keep this PR tight.
| for predicate, permission := range newPerms { | ||
| perms[predicate] = permission | ||
| } | ||
| } |
There was a problem hiding this comment.
Low priority, and pre-existing: once a user's last permission in their only namespace is cleared, the entry sticks around forever with an empty inner map. The merge branch doesn't prune it either. One line at the end of the loop:
if len(perms) == 0 {
delete(AclCachePtr.userPredPerms, userID)
}Callers behave the same either way, since allowedPreds ends up []string{} whether the lookup returns nil or an empty map.
|
thank you for identifying the concurrency issue. GetUserPredPerms now copies the user’s permission map under RLock and returns an independent snapshot instead of exposing the cache’s live inner map. authorizePreds obtains that snapshot once and uses it throughout the authorization check. I also added the suggested cleanup for empty user entries and regression coverage confirming that cache updates and caller-side mutations cannot change a previously returned snapshot. The focused race test, the full worker package, and the full edgraph package all pass. I left the namespace-qualified outer-key redesign for a separate follow-up, as suggested. |
Description
AclCache.Updateclears cached permissions belonging to the namespace beingrefreshed. However, when writing the new
userPredPerms, it replaced theuser's entire predicate-permission map.
User IDs are namespace-local, so the same user ID can exist in multiple
namespaces. Refreshing ACLs for one namespace could therefore discard that
user's permissions from every other namespace.
Because
RefreshACLsiterates over a Go map of namespaces, the last namespaceloaded could differ between Alpha instances and restarts. This could leave
different Alpha instances with different allowed-predicate sets and affect
predicate discovery paths such as
expand(_all_).This change preserves the existing per-user map and merges the newly refreshed
namespaced predicates into it after removing only the permissions belonging to
the namespace being refreshed.
Regression tests cover:
This addresses the same-user collision not covered by the different-user
multi-namespace scenario added in #8418.
Checklist
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.