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

feat(list-users): ListUsers with Excluded Users #1604

Merged
merged 25 commits into from May 20, 2024

Conversation

jon-whit
Copy link
Member

@jon-whit jon-whit commented May 6, 2024

Description

This PR introduces the concept of excluded users, which represent the list of users that do not apply to an exclusion clause. This occurs in models that have chained exclusion where the intermediate exclusion includes a typed public wildcard.

Example:

model
  schema 1.1

type user

type document
  relations
    define viewer: [user, user:*] but not blocked
    define blocked: [user, user:*] but not unblocked
    define unblocked: [user, user:*]
"document:1", "viewer", "user:jon"
"document:1", "blocked", "user:*"
"document:1", "unblocked", "user:jon"

In this example, the "unblocked" status of user:jon was being lost when evaluating the outer exclusion because the exception to the exclusion was not being communicated. This is considered an "excluded user". The solution here is to keep track of the excluded users so that the outer negation context can un-exclude the proper subjects. We also return the excluded users along with the response for the client to handle appropriately.

Consider the same model above but the following tuples:

"document:1", "viewer", "user:*"
"document:1", "blocked", "user:*"
"document:1", "unblocked", "user:jon"

ListUsers(document:1#viewer, user_filters=[{"type": "user"}]) will/should propagate up the user:jon subject from the inner most exclusion. That is, intermediate expansions should always yield a result, but that result may indicate the subject does or does not have the relationship for that intermediate relationships. In this case, that means the following:

ListUsers(document:1#viewer, user_filters=[{"type": "user"}]) --> [{user: "user:jon"}]
--> expandExclusion(document:1#viewer, filter={type: "user"}) --> [{user: "user:jon"}]
-----> base --> [{user: "user:*"}]
-----> sub  --> [{user: "user:*", noRelationship: false}, {user: "user:jon", noRelationship: true}]
--------> expandExclusion(document:1#blocked, filter={type: "user"}) --> [{user: "user:*", noRelationship: false}, {user: "user:jon", noRelationship: true}]
-----------> base --> [{user: "user:*}]
-----------> sub  --> [{user: "user:jon"}]

References

Related API PR: openfga/api#144

Review Checklist

  • I have clicked on "allow edits by maintainers".
  • I have added documentation for new/changed functionality in this PR or in a PR to openfga.dev [Provide a link to any relevant PRs in the references section above]
  • The correct base branch is being used, if not main
  • I have added tests to validate that the change in functionality is working as expected

@jon-whit jon-whit requested a review from a team as a code owner May 6, 2024 21:18
Copy link

Minder Vulnerability Report ✅

Minder analyzed this PR and found no vulnerable dependencies.

Vulnerability scan of c38697b1:

  • 🐞 vulnerable packages: 0
  • 🛠 fixes available for: 0

Copy link

Minder analyzed this PR with Trusty and found no dependencies scored lower than your profile threshold.

@jon-whit jon-whit changed the base branch from main to list-users May 6, 2024 21:18
@jon-whit
Copy link
Member Author

jon-whit commented May 6, 2024

Supersedes #1590

Copy link
Contributor

@willvedd willvedd left a comment

Choose a reason for hiding this comment

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

Still need to look over more but some immediate comments.

Copy link
Member

@miparnisari miparnisari left a comment

Choose a reason for hiding this comment

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

I wrote two tests that I've corroborated with Check API manually (not sure why we're not doing that within runListUsersTestCases) and that are failing with this PR:

{
			name: "intersection",
			req: &openfgav1.ListUsersRequest{
				Object:   &openfgav1.Object{Type: "document", Id: "1"},
				Relation: "viewer",
				UserFilters: []*openfgav1.UserTypeFilter{
					{
						Type: "user",
					},
				},
			},
			model: `model
			schema 1.1
			type user
			type document
				relations
					define b: [user:*] but not b1
					define b1: [user]
					define a: [user:*] but not a1
					define a1: [user]
					define viewer: a and b`,

			tuples: []*openfgav1.TupleKey{
				tuple.NewTupleKey("document:1", "b", "user:*"),
				tuple.NewTupleKey("document:1", "b1", "user:will"),
				// b -> user:* but not user:will
				tuple.NewTupleKey("document:1", "a", "user:*"),
				tuple.NewTupleKey("document:1", "a1", "user:maria"),
				// a -> user:* but not user:maria
				// therefore:
				// a and -b -> (user:* but not user:maria) and (user:* but not user:will)
				//          -> user:* but not (user:maria, user:will)
			},
			expectedUsers: []string{"user:*"},
			butNot:        []string{"user:maria", "user:will"},
		},
		{
			name: "union",
			req: &openfgav1.ListUsersRequest{
				Object:   &openfgav1.Object{Type: "document", Id: "1"},
				Relation: "viewer",
				UserFilters: []*openfgav1.UserTypeFilter{
					{
						Type: "user",
					},
				},
			},
			model: `model
			schema 1.1
			type user
			type document
				relations
					define b: [user:*] but not b1
					define b1: [user]
					define a: [user:*] but not a1
					define a1: [user]
					define viewer: a or b`,

			tuples: []*openfgav1.TupleKey{
				tuple.NewTupleKey("document:1", "b", "user:*"),
				tuple.NewTupleKey("document:1", "b1", "user:will"),
				// b -> user:* but not user:will
				tuple.NewTupleKey("document:1", "a", "user:*"),
				tuple.NewTupleKey("document:1", "a1", "user:maria"),
				// a -> user:* but not user:maria
				// therefore:
				// a or -b -> (user:* but not user:maria) or (user:* but not user:will)
				//          -> user:*, user:maria, user:will
			},
			expectedUsers: []string{"user:*", "user:maria", "user:will"},
		},

I'm not very sure on the changes you made but it seems to be that we need changes in union and intersection handlers too.

More specifically I think we need a helper method that recursively computes the transformations of intersection, union and exclusion.

E.g.

(user:* but not user:maria) or (user:* but not user:will) -> user:*, user:maria, user:will

(user:* but not user:maria) and (user:* but not user:will) -> user:* but not (user:maria, user:will)

I'm pretty sure this is https://en.wikipedia.org/wiki/De_Morgan%27s_laws 🤔

@miparnisari
Copy link
Member

miparnisari commented May 10, 2024

Are we going to address the case of excluded usersets in a different PR?

{
			name: "usersets",
			model: `model
			schema 1.1

			type user
			type group
				relations
					define member: [user]

			type document
			  relations
				define blocked: [group#member]
				define viewer: [group#member] but not blocked
			`,
			tuples: []*openfgav1.TupleKey{
				tuple.NewTupleKey("document:1", "viewer", "group:eng#member"),
				tuple.NewTupleKey("document:1", "blocked", "group:fga#member"),
			},
			req: &openfgav1.ListUsersRequest{
				Object:      &openfgav1.Object{Type: "document", Id: "1"},
				Relation:    "viewer",
				UserFilters: []*openfgav1.UserTypeFilter{{Type: "group", Relation: "member"}},
			},
			expectedUsers: []string{"group:eng#member"},
			butNot:        []string{"group:fga#member"},
		},

if !userIsSubtracted && !wildcardSubtracted {
switch {
case baseWildcardExists:
if !wildcardSubtracted {
Copy link
Member Author

@jon-whit jon-whit May 15, 2024

Choose a reason for hiding this comment

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

This conditional behavior is not quite correct and has deviated from the prior commit. Previously we were only producing the wildcard subject if baseWildcardExists && !wildcardSubtracted. See

user: tuple.StringToUserProto(wildcardKey),

And we'd only produce an explicit (non-wildcard) subject if baseWildcardExists && !userIsSubtracted && !wildcardSubtracted. See

if baseWildcardExists {
if !userIsSubtracted && !wildcardSubtracted {
trySendResult(ctx, foundUser{
user: tuple.StringToUserProto(userKey),
}, foundUsersChan)
}

Consider the following scenario:

type user

type document
  relations
    define restricted: [user, user:*]
    define viewer: [user, user:*] but not restricted

with tuples:

  • document:1#viewer@user:*
  • document:1#viewer@user:jon
  • document:1#restricted@user:jon

When ranging over the baseFoundUsersMap we'll find user:jon and yield it here, because there is not a subtracted wildcard. The only reason a test related to this scenario is passing against this commit is because of other code in the callstack above this function, which isn't obvious and is actually misleading when reading the code. See

if userIsExcluded {
continue
}

But this logic itself is not quite correct, becase we're producing on the foundUsersChan superfluously only to be filtered out above upstream since it is not applicable (the subject user:jon is not just excluded but is explicitly subtracted). The rewrite handler should only produce accurate "found user candidates" with respect to the found users channel.

Copy link
Contributor

Choose a reason for hiding this comment

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

Are you ok with proving your above theory with a unit test? Then we can address appropriately in code.

Copy link
Member Author

Choose a reason for hiding this comment

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

@willvedd
Copy link
Contributor

This is one test case I'm still pondering though, could see it going either way:

		{
			name: "chained_negation_14",
			tuples: []*openfgav1.TupleKey{
				tuple.NewTupleKey("document:1", "viewer", "user:*"),
				tuple.NewTupleKey("document:1", "blocked", "user:*"),
				tuple.NewTupleKey("document:1", "blocked", "user:maria"),
				tuple.NewTupleKey("document:1", "unblocked", "user:*"),
			},
			expectedUsers: []string{"user:*"}, // should `user:maria` be included?
		},

@jon-whit
Copy link
Member Author

jon-whit commented May 20, 2024

This is one test case I'm still pondering though, could see it going either way:

		{
			name: "chained_negation_14",
			tuples: []*openfgav1.TupleKey{
				tuple.NewTupleKey("document:1", "viewer", "user:*"),
				tuple.NewTupleKey("document:1", "blocked", "user:*"),
				tuple.NewTupleKey("document:1", "blocked", "user:maria"),
				tuple.NewTupleKey("document:1", "unblocked", "user:*"),
			},
			expectedUsers: []string{"user:*"}, // should `user:maria` be included?
		},

For consistency, user:maria should be included. This is for the same reason that we return the following:

  tuple.NewTupleKey("document:1", "viewer", "user:*"),
  tuple.NewTupleKey("document:1", "blocked", "user:*"),
  tuple.NewTupleKey("document:1", "unblocked", "user:maria"),

  expectedUsers: []string{"user:maria"}

Maria is explicitly blocked, but then subsequently unblocked, so she explicitly does not have the blocked relationshiop. For any subject that shows up in the base operand, if the subtracted operand contains a wildcard subject of that same type, then every base subject of the exclusion should have a relationship status NO_RELATIONSHIP. If this exclusion is then included as an operand of another chained exclusion, then those subjects will propagate up appropriately.

ds := memory.New()
t.Cleanup(ds.Close)

t.Run("avoid_producing_explicitly_negated_subjects", func(t *testing.T) {
Copy link
Member Author

Choose a reason for hiding this comment

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

This fails right now and demonstrates we're producing more subjects than we should from the exclusion handler. For example, we should only produce user:* with the tuples and model provided, but we end up producing 3-4 subjects (user:* and multiple copies of user:jon).

Screenshot 2024-05-20 at 9 14 58 AM

}
}
}
case subtractWildcardExists:
Copy link
Member Author

Choose a reason for hiding this comment

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

This case can be collapsed along with the case below

case userIsSubtracted, subtractWildcardExists:
    trySendResult(ctx, foundUser{
        user:               tuple.StringToUserProto(userKey),
        relationshipStatus: subtractedUser.relationshipStatus.InvertRelationshipStatus(),
    }, foundUsersChan)

Copy link
Contributor

@willvedd willvedd left a comment

Choose a reason for hiding this comment

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

🙏

@jon-whit jon-whit merged commit c170551 into list-users May 20, 2024
5 of 7 checks passed
@jon-whit jon-whit deleted the listUsers-excludedUsers branch May 20, 2024 21:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants