-
Name: ListUsers and StreamedListUsers APIs
-
Start Date: 2023-12-14
-
Authors: @jon-whit, @willvedd, @miparnisari
-
Status: Approved
-
RFC Pull Request: #15
-
Relevant Issues:
- openfga/roadmap#16 (main roadmap issue)
- openfga/openfga#406
- openfga/openfga#1215
-
Supersedes: N/A
- Summary
- Definitions
- Motivation
- What it is
- API Semantics
- API and Server Configuration Changes
- How it Works
- Algorithm
- Concurrency Control
- Out of Scope
- Pruning/Directing Expansion with Graph Edges
- Migration
- Drawbacks
- Alternatives
- Prior Art
- Open Questions
The ListUsers
API will provide an API that answers the question, who are all the users that have a relationship with an object?
More specifically, given an object, the ListUsers
API will return all of the concrete/terminal user subjects that have a relationship with that object.
-
You may see references to strings formatted as
object#relation@user
in this document. This is short-hand notation for representing an OpenFGA Relationship Tuple. For example,group:eng#member@user:jon
ordocument:1#viewer@group:eng#member
etc.. You may also see strings formatted asobjectType#relation
. This is short-hand notation for representing a specific relation defined on some object type. For example,document#viewer
represents the viewer relationship defined on the document object type. -
Expansion - refers to the process of iteratively traversing the graph of relationships that are realized through both an OpenFGA model AND the relationship tuples that exist in the store.
-
Forward expansion - refers to expansion of the OpenFGA relationship graph in a forward form. That is, starting at an object and relation, walk the graph of relationships in a directed way towards a user of some specific type by following paths that would lead through some target relation.
-
Reverse expansion - refers to expansion of the OpenFGA relationship grpah in a backwards form. That is, starting at a user of a specific type, walk the graph of relationships in a directed way towards an object of some specific type by following paths that would lead through some target relation.
-
Concrete/terminal objects - a concrete or terminal object refers to a singular or specific resource of a given object type and id. A concrete object cannot be expanded any further (see Expansion above). It can be conceptualized as a leaf-node in a graph. Concrete objects differ from usersets, because usersets refer to a collection of zero or more concrete objects. For example
user:jon
is a concrete object whilegroup:eng#member
is a userset which may expand to multiple concrete objects such asuser:jon
,user:andres
, etc.. -
Userset - A userset refers to a set of zero or more concrete objects that are the subjects that a relationship expands to. See our Concepts page on Users for more info.
Developers using OpenFGA want to look up all of the users that have a particular relationship with an object. Currently, the Read API is inadequate for listing users because it can only display users who have direct access to an object but not with users that have derived access through expansion.
- UIs - Display the users that a resource has been shared with. Ex: the "Share" dialog in Google Docs.
- Notifications - Notify all users who relate to a specific object. Ex: send and email to editors of a document when it has been updated.
Two new core APIs are added to the OpenFGA API and implementations of these new APIs in the OpenFGA server.
Given an object
, relation
, and one or more user/subject provided filters, return the concrete/terminal users or sets of users matching at least one of the user filters that have that relationship with the object.
More formally, given the following model and relationship tuples:
model
schema 1.1
type user
type cat
type group
relations
define member: [user, group#member]
type document
relations
define viewer: [cat, user, group#member]
object | relation | user | condition_name | context |
---|---|---|---|---|
document:1 | viewer | user:anne | null | null |
document:1 | viewer | group:eng#member | null | null |
group:eng | member | group:fga#member | null | null |
group:fga | member | user:jon | null | null |
ListUsers and StreamedListUsers would behave as follows:
ℹ️ For the sake of brevity the examples below focus on the ListUsers unary RPC variant. The difference with the StreamedListUsers API is the streaming semantics of the response. Instead of returning an array, the streaming variant returns each result one at a time as they are "discovered".
Example 1
user
object types are both directly and indirectly related to document#viewer
relationships. The indirect relationships come through the direct relationships involving group#member
(e.g. users can directly view a document or if the user is part of a group that can view the document)
ListUsers({
object: "document:1",
relation: "viewer",
user_filters: [{type: "user"}]
}) --> {
"users": [
{
"type": "user",
"id": "anne"
},
{
"type": "user",
"id": "jon"
}
]
}
Example 2
Looking at the type restrictions defined in the model, group
objects are not directly related whatsoever to document#viewer
relationships. So this example returns no results.
ListUsers({
object: "document:1",
relation: "viewer",
user_filters: [{type: "group"}]
}) --> {
"users": []
}
Example 3
You can see in the model that group#member
relationships are directly related to document#viewer
. So this example returns the group:eng#member
direct relationship and the group:fga#member
relationship indirectly through group:eng#member
.
ListUsers({
object: "document:1",
relation: "viewer",
user_filters: [
{
type: "group",
relation: "member"
}
]
}) -->
{
"users": [
{
"type": "group",
"id": "eng",
"relation": "member"
},
{
"type": "group",
"id": "fga",
"relation": "member"
}
]
}
Unless otherwise noted, the intent is for the ListUsers
and StreamedListUsers
APIs to behave similarly with the ListObjects
and StreamedListObjects
API. This is to encourage more uniformity in the API experience. The API and server configuration should reflect similarities, the error propagation strategy should strive to be the same, and any limits and/or deadline behaviors should strive to be identical unless there is a compelling reason to have an exception. We may find such compelling reasons as we dig into the implementation details further, but it's not obvious why/if it would have to differ at this time.
-
Introduce the new protobuf API definitions.
ℹ️ The various protobuf annotations have been omitted for brevity, but assume they are identical to those existing annotations for the same or similar fields used in other endpoints.
- ListUsers
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) message ListUsersRequest { string store_id = 1; string authorization_model_id = 2; string object = 3; string relation = 4; repeated ListUsersFilter user_filters = 5; repeated TupleKey contextual_tuples = 6; google.protobuf.Struct context = 7; } // ListUsersResponse represents a unary response with // all user result(s) up to the maximum limit. message ListUsersResponse { repeated User users = 1; } message User { oneof user { Object object = 1; UsersetUser userset = 2; Wildcard wildcard = 3; } } message UsersetUser { string type = 1; string id = 2; string relation = 3; } message ListUsersFilter { string type = 1; string relation = 2; }
The
user_filters
defines an array ofListUsersFilter
(e.g. type restriction definitions) that will be used to control the expansion. As ListUsers expansion occurs, if we find a user/subject that meets one of the user_filters type restriction(s), then we include the result in the response and stop further expansion on that subproblem. Once we've found a result meeting the filter criteria we don't explore that subproblem any further, even if it would lead to more results matching the other filters in theuser_filters
(see Example 5 below for further details).Initially
user_filters
will be limited to 1 filter (array of size 1), but we've planned on allowing the client to filter by multiple user filters in the future (as the community sees the need).- StreamedListUsers
rpc StreamedListUsers(StreamedListUsersRequest) returns (stream StreamedListUsersResponse) message StreamedListUsersRequest { string store_id = 1; string authorization_model_id = 2; string object = 3; string relation = 4; repeated ListUsersFilter user_filters = 5; repeated TupleKey contextual_tuples = 6; google.protobuf.Struct context = 7; } // StreamedListUsersResponse represents a single streaming user result // returned from the streaming endpoint. message StreamedListUsersResponse { User user = 1; }
-
New server configurations (flags/config/env):
-
--listUsers-max-results (uint32)
- limits the maximum size of the results returned by ListUsers (if 0 all results are returned up to the deadline, otherwise the response is limited to this size) -
--listUsers-deadline (duration)
- the timeout deadline for serving ListUsers or StreamedListUsers requests (default 3s) -
--max-concurrent-reads-for-list-objects (uint32)
- the maximum allowed number of concurrent datastore reads in a single ListUsers or StreamedListUsers query
-
The ListUsers
API is a unary RPC while the StreamedListUsers
is a server-streaming RPC. Because of these semantic differences we have different behaviors around limits and deadlines.
The server flag --listUsers-deadline
(mentioned above) sets an overall upper limit on the amount of time (e.g. the deadline) for serving a ListUsers
or StreamedListUsers
request. It controls how long time can be spent expanding results (e.g. finding relationships in the graph). If there are very few results and the deadline is generous, then the request will respond earlier than the deadline period. However, if there are many results and returning all of them would take longer then the deadline, then only the subset that can be returned in the deadline period are returned.
The server flag --listUsers-max-results
(mentioned above) will limit the size of the set of results return from the unary ListUsers
endpoint. It sets a hard upper limit on how many results can be returned. If the actual result set is less than this limit then the subset should be promptly returned so as to avoid keeping the client waiting longer than necessary. If the actual result set is at least as large as the max results limit but the results haven't been computed before the --listUsers-deadline
period, then only the results which have been computed up to that deadline will be returned.
ListUsers and StreamedListUsers should strive to implement error handling semantics inline with the way ListObjects and StreamedListObjects do. Namely, the API should strive to fulfill the request with its limits as much as possible. For the unary ListUsers endpoint, if and only if it cannot fulfill the requested --listUsers-max-results
and at least one error occurred, then an error should be surfaced. For the StreamedListUsers endpoint, as errors are encountered they should be yielded over the stream.
type user
type employee
type document
relations
define viewer: [user:*, employee:*]
object | relation | user |
---|---|---|
document:1 | viewer | user:* |
document:1 | viewer | employee:* |
Example 1:
ListUsers({
object: "document:1",
relation: "viewer",
user_filters: [
{
type: "user",
}
]
}) --> {
"users": [
{
"type": "user",
"wildcard": {}
}
]
}
In example 1, there are two tuples establishing a typed wildcard, one for user
and employee
, both with document:1#viewer
. But while expanding the ListUsers request we only return the user
typed public wildcard because it is the only tuple that matches the filters in the user_filters
.
Example 2:
ListUsers({
object: "document:1",
relation: "viewer",
user_filters: [
{
type: "user",
}
{
type: "employee",
}
]
}) --> {
"users": [
{
"type": "user",
"wildcard": {}
},
{
"type": "employee",
"wildcard": {}
}
]
}
However, in example 2, both user
and employee
typed wildcard are returned because those user types were specified in the user_filters
input field.
ListUsers
and StreamedListUsers
is a filtered form of recursive Expand. We don’t want to return all users of any type, we only want to return all the concrete user objects of a specified type.
The implementation of ListUsers and StreamedListUsers will behave a lot like ListObjects and StreamedListObjects, but instead of starting at a user and expanding backwards (e.g. reverse expansion) we will start at a relationship with an object and recursively expand (forward expansion) any usersets we find along the way that would lead to a concrete/terminal object matching one of the specific user filters.
ℹ️ Most of the implementation details we spent time tuning with
ListObjects
apply to this problem as well. Namely, bounding the number of concurrent evaluation paths, bounding the number of concurrent database queries that can be inflight per request, constraining the response results and/or the streaming deadline, etc.. We should be able to move more quickly on the implementation phase as a result of the prior work and education from ListObjects.
Here’s are a few examples to demonstrate the algorithm:
model
schema 1.1
type user
type document
relations
define viewer: [user]
object | relation | user |
---|---|---|
document:1 | viewer | user:jon |
document:1 | viewer | user:andres |
ListUsers({
object: "document:1",
relation: "viewer",
user_filters: [{type: "user"}]
}) --> {
"users": [
{
"type": "user",
"id": "jon"
},
{
"type": "user",
"id": "andres"
}
]
}
In this case the algorithm simply must expand all direct relationships between document:1#viewer
and user
objects. Since these are directly related to one another, then we must only do a simple reverse database lookup.
model
schema 1.1
type user
type group
relations
define member: [user, group#member]
type document
relations
define viewer: [group#member]
object | relation | user |
---|---|---|
document:1 | viewer | group:eng#member |
group:eng | member | group:fga#member |
group:fga | member | user:andres |
group:fga | member | group:fga-core#member |
group:fga-core | member | user:jon |
ListUsers({
object: "document:1",
relation: "viewer",
user_filters: [{type: "user"}]
}) --> {
"users": [
{
"type": "user",
"id": "jon"
},
{
"type": "user",
"id": "andres
}
]
}
This example requires recursive expansion.
ℹ️ The usage of
expand
from here forward refers to an internal expand function, and it should not be confused with the public Expand API, but it operates somewhat similarly.
-
Expand the set of subjects/users that relate to
document:1#viewer
.expand(document:1#viewer)
--> ["group:eng#member"] -
Expand the new set of subjects/users that make up
group:eng#member
set.expand(group:eng#member)
--> ["group:fga#member"] -
Expand the set of subjects/users that make up the
group:fga#member
set.expand(group:fga#member)
--> ["user:andres", "group:fga-core#member"]- We find a terminal/concrete object of
user:andres
, which matches theuser
in the user filter, and so we add it to the list of items to include in the response.
- We find a terminal/concrete object of
-
Continue expanding the residual set of subjects/users in
group:fga-core#member
expand(group:fga-core#member)
--> ["user:jon"]- We find a terminal/concrete object of
user:jon
, which matches theuser
in the user filter, so add it to the list of items to include in the response
- We find a terminal/concrete object of
-
No further subjects to expand, and so we're done. Return the items we accumulated from the steps above.
Visually, the overall recursive call tree looks like the following:
expand(document:1#viewer) --> ["group:eng#member"]
|-> expand(group:eng#member) --> ["group:fga#member"]
|---> expand(group:fga#member) --> ["user:andres", "group:fga-core#member"]
yield "user:andres"
|-----> expand(group:fga-core#member) --> ["user:jon"]
yield "user:jon"
return ["user:andres", "user:jon"]
See Typed Public Wildcard section for more context.
model
schema 1.1
type user
type document
relations
define viewer: [user:*]
object | relation | user |
---|---|---|
document:1 | viewer | user:* |
ListUsers({
object: "document:1",
relation: "viewer",
user_filters: [
{
type: "user"
}
]
}) --> {
"users": [
{
"type": "user",
"wildcard": {}
}
]
}
-
Expand the set of subjects/users that relate to
document:1#viewer
.expand(document:1#viewer)
--> ["user:*"]- We find a terminal/concrete wildcard of
user:*
, which matches theuser:*
in the user filter, and so we add it to the list of items to include in the response.
- We find a terminal/concrete wildcard of
-
No further users to expand, and so we're done. Return the items we accumulated from the steps above.
Visually, the overall recursive call tree looks like the following:
expand(document:1#viewer) --> ["user:*"]
yield "user:*"
return ["user:*"]
model
schema 1.1
type user
type person
type document
relations
define editor: [user, person]
define viewer: editor
object | relation | user |
---|---|---|
document:1 | editor | user:jon |
document:1 | editor | person:bob |
ListUsers({
object: "document:1",
relation: "viewer",
user_filters: [{type: "user"}]
}) --> {
"users": [
{
"type": "user",
"id": "jon"
}
]
}
This example demonstrates a simple rewritten relation involved in the expansion. Instead of expanding document:1#viewer
we immediately rewrite that to document:1#editor
and expand that. Namely,
-
Rewrite document#viewer to document#editor through computed_userset.
expand(document:1#viewer)
--rewritten-->expand(document:1#editor)
-
Expand the new (rewritten) relationship.
expand(document:1#editor)
--> ["user:jon", "person:bob"]- We find terminal/concrete objects including
user:jon
andperson:bob
.user:jon
matches theuser
in the user filter, so add it to the list of items to include in the response, but we filter out/omitperson:bob
.
- We find terminal/concrete objects including
-
No further subjects to expand, and so we're done. Return the items we accumulated from the steps above.
Visually, the overall recursive call tree looks like the following:
expand(document:1#viewer) (rewritten)
|-> expand(document:1#editor) --> ["user:jon", "person:bob"]
filter(["user:jon", "person:bob"])
yield "user:jon"
return ["user:jon"]
type user
type folder
relations
define viewer: [user]
type document
relations
define parent: [folder]
define viewer: viewer from parent
object | relation | user |
---|---|---|
document:1 | parent | folder:x |
folder:x | viewer | user:jon |
ListUsers({
object: "document:1",
relation: "viewer",
user_filters: [{type: "user"}]
}) --> {
"users": [
{
"type": "user",
"id": "jon"
}
]
}
-
Rewrite document#viewer through tuple_to_userset (TTU).
expand(document:1#viewer)
--rewritten-->expandTTU(document:1#viewer)
-
Lookup the
document:1#parent
tupleset relationship.storage#Read(document:1#parent)
--> ["folder:x"] -
Expand the computed relation
viewer
of the TTU for each of the tupleset relationships found above. In this case we have to expandfolder:x#viewer
.expand(folder:x#viewer)
--> ["user:jon"]- We find terminal/concrete object
user:jon
, which matches theuser
in the user filter, so add it to the list of items to include in the response.
- We find terminal/concrete object
-
No further subjects to expand, and so we're done. Return the items we accumulated from the steps above.
Visually, the overall recursive call tree looks like the following:
expand(document:1#viewer)
|-> expandTTU(document:1#viewer)
|---> storage#Read(document:1#parent) --> ["folder:x"]
|---> expand(folder:x#viewer) --> ["user:jon"]
yield "user:jon"
return ["user:jon"]
This example is a unique one in that it demonstrates a corner case to the algorithm. In the API and Server Configuration Changes section earlier we said the following:
As ListUsers expansion occurs, if we find a user/subject that meets one of the `user_filters`` type restriction(s), then we include the result in the response and stop further expansion on that subproblem.
This examples demonstrates one corner case where we must continue expansion, but only if the subproblem would potentially expand to the same target type and relation of that which was found.
Consider the following model and tuples:
model
schema 1.1
type user
type group
relations
define member: [user, group#member]
type document
relations
define viewer: [group#member]
object | relation | user |
---|---|---|
document:1 | viewer | group:eng#member |
group:eng | member | group:fga#member |
ListUsers({
object: "document:1",
relation: "viewer",
user_filters: [
{
type: "group",
relation: "member"
}
]
}) --> {
"users": [
{
"type": "group",
"id": "eng",
"relation": "member"
},
{
"type": "group",
"id": "fga",
"relation": "member"
}
]
}
This example deviates from many of the examples above in that we expand all relationships for a specific object and relation (e.g. document:1#viewer
) that are related to a given set of users or subject set (e.g. group#member
).
-
Expand the set of subjects/users that relate to
document:1#viewer
.expand(document:1#viewer)
--> ["group:eng#member"]- We find
group:eng#member
, which matches thegroup#member
in the user filter, so addgroup:eng
to the list of items in the response.
- We find
-
Even though we found
group:eng#member
and it matched thegroup#member
user filter, we continue expanding the set of subjects/users that relate togroup:eng#member
becausegroup#member
is related togroup#member
and thus there may be more nested groups matching the specific filter.expand(group:eng#member)
--> ["group:fga#member"]- We find
group:fga#member
, which matches thegroup#member
in the user filter, so addgroup:fga
to the list of items in the response.
- We find
-
Again, we continue expansion because we may find more specific nested groups that relate to
group:fga#member
.expand(group:fga#member)
--> [] -
No further subjects to expand, and so we're done. Return the items we accumulated from the steps above.
Visually, the overall recursive call tree looks like the following:
expand(document:1#viewer) --> ["group:eng#member"]
yield group:eng
|-> expand(group:eng#member) --> ["group:fga#member"]
yield group:fga
|---> expand(group:fga#member) --> []
return ["group:eng", "group:fga"]
For relationships that involve an intersection (e.g. a and b
) or exclusion (e.g. a but not b
) we'll apply the same algorithmic approach we do in ListObjects. Namely, given the set a and b
we'll compute a
and then call Check
for each of the results to resolve the set intersection through Check resolution. Likewise, for a but not b
we'll compute a
and then call Check
for each of the results to resolve the set difference.
This choice is an algorithmic choice that exploits the fact that a and b
and a but not b
can be no larger than the max(size(a), size(b))
, so instead of computing the results for both sets, holding the results temporarily in memory and then resolving the overlap, we simply compute the first set and then use Check resolution to resolve the residual.
Similar to the mitigations we've implemented in Check and ListObjects, a single ListUsers subproblem could fan-out to hundreds or more of repetitive expansions. Consequently, we must limit the breadth of the number of expansions that can be dispatched at any level as well as limit the total depth of expansion.
For widely nested sets of relationship, consider the following model and relationship tuples:
model
schema 1.1
type user
type group
relations
define member: [user, group#member]
object | relation | user |
---|---|---|
group:1 | member | group:2#member |
group:1 | member | group:3#member |
group:1 | member | ... |
group:1 | member | group:N#member |
group:N | member | user:jon |
If a developer were to call
ListUsers({
object: "group:1",
relation: "member",
user_filters: [{type: "user"}]
})
then, for large N
, this would cause a high degree of expansive breadth and saturate the server CPU and memory.
Similarly, for recursively expanding deeply nested sets, consider the same model above but the following tuples:
object | relation | user |
---|---|---|
group:1 | member | group:2#member |
group:2 | member | group:3#member |
... | member | ... |
group:N | member | user:jon |
If a developer were to call
ListUsers({
object: "group:1",
relation: "member",
user_filters: [{type: "user"}]
})
then, for large N
, this would cause a high degree of depth and saturate the server CPU and memory.
ℹ️ Failure to prune expansion could lead to more excessive server exhaustion and be a reliability and performance concern.
A naive implementation would blindly expand subject/user sets without using any of the type restriction information included in the model (e.g. neglecting concrete edges in the graph). A more optimized approach to the iterative expansion algorithm described above would use the type restriction information to prune the expansion space. For example, consider the following model and relationship tuples:
model
schema 1.1
type person
type user
type group
relations
define member: [person, group#member]
type document
relations
define viewer: [group#member]
object | relation | user |
---|---|---|
document:1 | viewer | group:1#member |
document:1 | viewer | group:2#member |
document:1 | viewer | ... |
document:1 | viewer | group:N#member |
group:N | member | person:bob |
If a developer were to call
ListUsers({
object: "document:1",
relation: "viewer",
user_filters: [{type: "user"}]
})
then a naive implementation would start expanding all group#member
relationships only to find that user
objects aren't related to any group members. This is because the type restrictions would not allow such relationships to exist in the first place. In this case, if N
is large, this is a server concern. Consequently, we should avoiding expanding edges that would not lead to a terminal object that matches one of the targets in the provided user_filters
list. We can do so by using the relationship edges information we have available in the OpenFGA typesystem.
The following API features were considered been deemed out-of-scope for the initial development of ListUsers
.
Resolved paths answer the question of how a user is related to an object. The following example showcases the potential behavior of resolved paths in ListUsers
and how the API behaves when multiple target user filters are provided.
This behavior enables experiences similar to that you get in Google Docs when you open the Share dialog.
We'll use the following model and tuples for this example:
type user
type organization
relations
define member: [group#member]
type group
relations
define member: [user, group#member]
type folder
relations
define viewer: [user]
type document
relations
define parent: [folder]
define owner: [user]
define editor: [user, group#member] or owner
define viewer: [user, group#member] or editor or viewer from parent
object | relation | user |
---|---|---|
document:example | owner | user:maria |
document:example | editor | user:will |
document:example | parent | folder:x |
folder:x | viewer | user:andres |
document:example | viewer | group:engineering#member |
group:engineering | member | user:will |
document:example | viewer | user:* |
The dialog display has two unique sections "People with access" and "General access", and these different sections distinguish between access that has been shared with specific individual end-users and/or groups of end-users versus access that has been broadly shared to anyone in the org (e.g. a wildcard scoped to an org context).
To handle this Google Doc Share Dialog use case we'd call ListUsers
twice, one for each section of the dialog.
To render the "People with access" section we'd make the following query:
ListUsers({
object: "document:example",
relation: "viewer",
user_filters: [
{
type: "user"
},
{
type: "group",
relation: "member"
}
]
}) --> {
"users": [
{
user: {
"type": "user",
"id": "maria"
},
resolved_paths: [
{
"object": "document:example",
"relation": "owner"
}
]
},
{
user: {
"type": "user",
"id": "will"
},
resolved_paths: [
{
"object": "document:example",
"relation": "editor"
}
]
},
{
user: {
"type": "user",
"id": "andres"
},
resolved_paths: [
{
"object": "folder:x",
"relation": "viewer"
}
]
},
{
user: {
"type": "group",
"id": "eng",
"relation": "member"
},
resolved_paths: [
{
"object": "document:example",
"relation": "viewer"
}
]
},
{
user: {
"type": "user",
"wildcard": {}
},
resolved_paths: [
{
"object": "document:example",
"relatoin": "viewer"
}
]
}
],
}
Two things are important to point out about this response:
-
We didn't return
user:will
twice, because once we find the groupgroup:engineering#member
, which this user is apart of, we stop expanding that group because it matches one of the provideduser_filters
inputs, namely thegroup#member
filter. So in this case for this user we only return the computed relation that stems from thedocument:example#editor
resolution path. -
Notice the usage of the relation 'viewer' in the request. This relation is the greatest common denominator of the relations included in the list of permissions in the UI dialog next to each user/subject's name. Here's how to interpret the response and how the Google Docs app would use the response to build the UI:
-
Since
user:maria
is the owner we find resolution paths stemming fromdocument:example#editor
anddocument:example#viewer
thus leading todocument:example#viewer
. The Google Doc Share Dialog chooses to prioritize the highest permission in the UI dropdown boxes, so in this case the UI shows theowner
relation next touser:maria
-
Likewise, since
user:will
is assigneddocument:example#editor
which has a computed relationship todocument:example#viewer
. So in this case the UI shows theeditor
relation next touser:will
-
user:andres
can view the parent folderfolder:x
, and thus we find a resolution path todocument:example#viewer
throughfolder:x#viewer
. The Google Doc Share Dialog chooses to render the inherited permissiondocument#viewer
based on this resolution path. -
The set of users/subjects included in
group:engineering
have editor acces, and thus we find a resolution path stemming fromdocument:example#viewer
that leads todocument:example#viewer
. The Google Doc Share Dialog chooses to render group memberships such as this as the Google Group and the highest permission that set of subjects has, which isviewer
in this case.
-
-
The
user
type wildcard is returned as well, which lends nicely to the Google Doc Share example, which has a visually unique treatment of the wildcard. The client can take that wildcard response and render it specific to their needs.
This response indicates to the Google Docs apps that viewer permissions to document:example
have been explicitly granted to any object of type user
, and then the app can use this to implement a link-based sharing mechanism.
The ability to specify multiple target relations was considered. This would have enabled developers to list users of more than one type that have a specific relation with an object. Supporting multiple target relations would have introduced performance concerns. Further, all existing endpoints operate on a single-relation basis, which would have made this behavior inconsistent.
- No migrations or API breaking changes should be necessary for this work. We're extending the API surface, not changing it.
-
Increases API surface to maintain
-
Another costly graph traversal query - when we added ListObjects and StreamedListObjects we had to work through some reliability improvements to ensure these new APIs didn't exhaust the database connection pool. These same considerations will be very relevant to this work as well.
- The client calls Check for every user in the system for the given
object
andrelation
This is an option, but it is not one that is conducive to performance and/or handling more queries at scale. If many OpenFGA clients used Check for this pattern, then the server would have to be scaled up pretty high during more normal operation to account for this burst load. Additionally, it is also more challenging for a client to orchestrate this many Checks since we do not have a BatchCheck mechanism at this time.
- Naive server implementation which queries all subjects/users in the system that match the target subject/user filter and for each of them call Check on the provided
object
andrelation
(e.g. server-side BatchCheck naive implementation).
A preliminary implementation spike revealed that this approach would not cater to various of the use cases we explored including the Google Share Dialog that is discussed in more depth above in Resolved Paths. For this reason and for performance concerns (e.g. the volume of Checks that would have to occur) we do not feel this is a viable approach.
- Encourage clients to implement Expand recursively themselves
There’s nothing stopping clients from implementing ListUsers today using the existing Expand
API, and we won’t necessarily discourage it even after we implement ListUsers. However, we want to build ListUsers to provide this API more natively in the API offering and thus reduce duplication across the community and the burden on the client. We want to provide developers in the community with a simple to use API that doesn’t require reimplementing this logic anywhere it is needed. I anticipate the community will build API endpoints providing recursive Expand for this use case, and so we may as well offer it natively in the API.
The following code implements a POC implementation of ListUsers
. The code is not quite complete when it comes to intersection and exclusion or the typed public wildcard behavior described above, but it demonstrates the main algorithmic composition. Intersection and exclusion support will behave similarly to how intersection and exclusion behave in ListObjects. That is, a set of users that are contained under an intersection or exclusion will require additional Checks to resolve the set intersection or exclusion directly.
-
What uses cases would this help you solve? See: use cases
-
What parts of the design do you expect to be resolved before this gets merged?
-
What related issues do you consider out of scope for this RFC that could be addressed in the future independently of the solution that comes out of this RFC?