Skip to content

fix(acl): preserve user permissions across namespaces - #9812

Open
gooohgb wants to merge 2 commits into
dgraph-io:mainfrom
gooohgb:fix-acl-cache-namespace-collision
Open

fix(acl): preserve user permissions across namespaces#9812
gooohgb wants to merge 2 commits into
dgraph-io:mainfrom
gooohgb:fix-acl-cache-namespace-collision

Conversation

@gooohgb

@gooohgb gooohgb commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Description

AclCache.Update clears cached permissions belonging to the namespace being
refreshed. However, when writing the new userPredPerms, it replaced the
user'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 RefreshACLs iterates over a Go map of namespaces, the last namespace
loaded 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:

  • Loading the same user ID in both namespace orders.
  • Refreshing permissions in one namespace without affecting another.
  • Clearing permissions in one namespace while preserving another namespace.

This addresses the same-user collision not covered by the different-user
multi-namespace scenario added in #8418.

Checklist

  • The PR title follows the Conventional Commits syntax.
  • Regression tests for the bug have been added.
  • Code compiles correctly and linting via trunk passes locally.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

@gooohgb
gooohgb requested a review from a team as a code owner August 20, 2026 10:24
Comment thread worker/acl_cache.go
AclCachePtr.userPredPerms[userID] = perms
}
for predicate, permission := range newPerms {
perms[predicate] = permission

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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) vs mapIterStart
  • this branch: mapassign_faststr (this line) vs mapIterStart

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.

Comment thread worker/acl_cache.go
// 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]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 RefreshACLs calls Update once per namespace, so a full refresh is O(namespaces x total entries).
  • authorizePreds now iterates a user's predicates across all namespaces to build allowedPreds, and expand(_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.

Comment thread worker/acl_cache.go
for predicate, permission := range newPerms {
perms[predicate] = permission
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@gooohgb

gooohgb commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants