- Name: Add Type Restrictions to the JSON Syntax
- Start Date: 2022-08-31
- Author(s): rhamzeh
- Status: Approved
- RFC Pull Request: #7
- Relevant Issues:
- Supersedes: N/A
Allow users to indicate in their store's authorization model what types of objects can have a particular relation to an object type.
- OpenFGA JSON Syntax
- OpenFGA DSL
- ListObjects Endpoint
- What is a user?
- Floating User ID: A user identifier without a type (e.g.
anne
,4
or4179af14-f0c0-4930-88fd-5570c7bf6f59
) - Reverse Expansion: Normally expand takes an object and relation and returns the first leaves of users who are related. Reverse expand would be the opposite, taking a user and a relation and returning the objects which are related.
- Representing public access:
*
In order to implement some optimizations to the ListObjects endpoint mentioned ListObjects RFC, we should be able to traverse the relationship graph in reverse. We not only need to understand how objects are related to other objects, but also what type of objects can be related.
At the moment, our authorization model does not allow users to indicate what the type they expect on a relation to be, leaving it ambiguous and potentially a cause of errors, especially when someone is reading the authorization model later without much context and where the original authors are not available. It is hard for someone to grok from first glance that a repository owner should be an organization or a user. Adding type restrictions allows developers to have an easier time in interpreting the model.
- Using the extra type information we get from this change, we will be able to more easily traverse the graph in reverse, thus allowing us to provide a solution for reverse expansion and optimizing the ListObjects endpoint.
- Users reading an authorization model will be able to better interpret its meaning (for example by understanding that repository owners must be either users or group members).
This RFC proposes an update to the OpenFGA JSON syntax requiring users to indicate all the types of users that could be directly related to an object of a certain type through a particular relation.
For example, our current syntax allows expressing the following:
- documents have parents
- repositories have owners
But cannot express restrictions such as:
- parents of a document have to be objects of type folder
- owners of a repository have to be of type user or a userset of group members
We introduce a metadata
entry in the type definition would look like:
type Metadata = {
relations?: Record<string, RelationMetadata>;
};
type RelationMetadata = {
directly_related_user_types?: DirectlyRelatedUserType[];
};
type DirectlyRelatedUserType = {
type: string;
relation?: string;
wildcard?: Record<string, never>;
};
The following is an example of the changes we are proposing, an explanation of the additions will follow:
{
"type_definitions": [
{
"type": "user",
"relations": {}
},
{
"type": "employee",
"relations": {}
},
{
"type": "group",
"relations": {
"parent": {
"this": {}
},
"member": {
"this": {}
},
"guest": {
"this": {}
}
},
"metadata": {
"relations": {
"parent": {
"directly_related_user_types": [
{
// Groups can be parents of other groups
"type": "group"
}
]
},
"member": {
"directly_related_user_types": [
{
// An object of type employee can be a member of the group
"type": "employee"
},
{
// A set of objects who are related to a group as members can be made members of a group (e.g. members of the admin group are members of the security group)
"type": "group",
"relation": "member"
}
]
},
"guest": {
"directly_related_user_types": [
{
// An object of type user can be a guest of the group
"type": "user"
},
{
// A group can be made public (have all objects of type user be guests of it)
"type": "user",
"wildcard": {}
},
{
// An object of type employee can be a guest of the group
"type": "employee"
},
{
// A set of objects who are related to a group as members can be made guests of a group (e.g. members of the admin group are guests of the employee group)
"type": "group",
"relation": "member"
}
]
}
}
}
}
]
}
In the type metadata, add a directly_related_user_types
array to each relation to indicate what types of objects can be directly related to the relation. It will be an array of objects, each object must be composed of:
- a type: indicates that objects of this type can be related
- a type and an optional relation: indicates that sets of objects who are related to that type as that relation can be related
- a type and an empty wildcard object: indicates that the special set of all objects of these types can be related
In the above example:
- having the following
"parent": { "directly_related_user_types": [{ "type": "group" }] }
in thegroup
type definition indicates that only objects of typegroup
can be directly related to agroup
asparent
- having the following
"member": { "directly_related_user_types": [{ "type": "user" }, { "type": "group", "relation": "member" }] }
in thegroup
type definition indicates that only objects of typeuser
or usersets of typegroup
and relationmember
can be directly related to agroup
asmember
- having the following
"guest": { "directly_related_user_types": [{ "type": "user" }, { "type": "user", "wildcard": {} }, { "type": "employee" }] }
in thegroup
type definition indicates that theuser:*
syntax is allowed and that when present, it means all objects of typeuser
can be directly related to agroup
asguest
This will affect only relations that are directly related (as in they are considered "assignable" and have the direct relationship keyword ("this") in their relation definition).
For relation definitions that:
- have the direct relationship keyword (this):
directly_related_user_types
must be present and MUST be an non-empty array containing at least one valid type restriction - do not have the direct relationship keyword:
directly_related_user_types
can optionally be present, but if it is, it MUST be an empty array
Note: This syntax does not offer a way to enforce restrictions based on what the userset of group members resolves to (for example, group member can be a user, an employee or another userset). Later on, tooling can help visually indicate this to the user inline.
To prevent breaking changes, this will be done by appending a new field called metadata
-> relations
to each type definition. Each relation will be a key under this that is a map that contains a field called directly_related_user_types
that contains what type or type and relation combination could be directly related to it and another called allow_public
that defines whether the *
is valid.
As part of adding type restriction, we decided to change how the concept of everyone (previously represented as *
) to allow it to also be typed.
- In schema version 1.1,
*
is no longer supported (along with floating user_ids). If there are existing relationship tuples in the system that are either*
or a floating user ID they will be ignored when evaluating using a 1.1 schema version and writes including them to a 1.1 schema version will fail validation - To represent all objects of a certain type, the following syntax is introduced:
${type}:*
. For example,employee:*
in the user field of a relationship tuple means all objects of typeemployee
. Note that this syntax can only be used in the user field and not in the object field. Note thatemployee:*
will match any object of typeemployee
, including those not in existing relationship tuples. In order to prevent confusion, we chose to disallow objects of formatemployee:*
on v1.0 models going forward, as that will be interpreted as an object of typeemployee
and id*
on the previous version and every object of typeemployee
on the v1.1 model
In order to make sure we can easily parse the model across updates (and this will be especially true when breaking changes are introduced), we are proposing to add a version to the model.
This version will be called the schema_version
in order not be confused with an update to the authorization model itself (frequently referenced as a new authorization model version). It will be of the form "x.y", where both x
and y
are non-negative integers. x
will start from 1
instead of 0
, so the initial version of the authorization model will be 1.0
and this RFC once implemented will introduce 1.1
.
This field is optional, and when missing will be interpreted as being 1.0
. The schema version can only be one of the existing versions ("1.0" and "1.1" at the time of this RFC).
{
"schema_version": "1.1",
"type_definitions": [ ... ]
}
You can see some examples of how authorization models will need to change with this new proposed extension in this PR.
Entitlements
{
+ "schema_version": "1.1",
"type_definitions": [
+ { "type": "user" },
{
"type": "plan",
"relations": {
"subscriber": {
"this": {}
},
"subscriber_member": {
"tupleToUserset": {
"tupleset": {
"object": "",
"relation": "subscriber"
},
"computedUserset": {
"object": "",
"relation": "member"
}
}
}
+ },
+ "metadata": {
+ "relations": {
+ "subscriber": {
+ "directly_related_user_types": [{
+ "type": "organization"
+ }],
+ },
+ "subscriber_member": {
+ "directly_related_user_types": []
+ }
+ }
}
},
{
"type": "organization",
"relations": {
"member": {
"this": {}
}
+ },
+ "metadata": {
+ "relations": {
+ "member": {
+ "directly_related_user_types": [{
+ "type": "user"
+ }]
+ }
}
},
{
"type": "feature",
"relations": {
"access": {
"tupleToUserset": {
"tupleset": {
"object": "",
"relation": "associated_plan"
},
"computedUserset": {
"object": "",
"relation": "subscriber_member"
}
}
},
"associated_plan": {
"this": {}
}
+ },
+ "metadata": {
+ "relations": {
+ "associated_plan": {
+ "directly_related_user_types": [{
+ "type": "plan"
+ }]
+ },
+ "access": {
+ "directly_related_user_types": []
+ }
+ }
}
}
]
}
Expenses
{
+ "schema_version": "1.1",
"type_definitions": [
+ { "type": "user" },
{
"type": "report",
"relations": {
"approver": {
"tupleToUserset": {
"tupleset": {
"object": "",
"relation": "submitter"
},
"computedUserset": {
"object": "",
"relation": "manager"
}
}
},
"submitter": {
"this": {}
}
+ },
+ "metadata": {
+ "relations": {
+ "approver": {
+ "directly_related_user_types": []
+ },
+ "submitter": {
+ "directly_related_user_types": [{
+ "type": "user"
+ }]
+ }
+ }
}
},
{
"type": "employee",
"relations": {
"manager": {
"union": {
"child": [
{
"this": {}
},
{
"tupleToUserset": {
"tupleset": {
"object": "",
"relation": "manager"
},
"computedUserset": {
"object": "",
"relation": "manager"
}
}
}
]
}
}
+ },
+ "metadata": {
+ "relations": {
+ "manager": {
+ "directly_related_user_types": [{
+ "type": "user"
+ }]
+ }
+ }
}
}
]
}
When writing new models in the new syntax, we need to validate:
- That the directly related user types array exists, and
- is empty when the relationship definition does not allow this (direct relationships)
- is non-empty when the relationship definition does allow this (direct relationships)
- When directly related user types are passed, we need to check that:
- the type is an existing type in the system
- the relation is either empty or exists on the type
- no duplicates are present
{ "schema_version": "1.1",
"type_definitions": [
{ "type": "user", "relations": {} },
{ "type": "group",
"relations": {
"relation-1": { "this": {} },
"relation-2": { "this": {} },
"relation-3": { "this": {} },
"relation-4": { "this": {} },
"relation-5": { "this": {} },
"relation-6": { "computedUserset": { "object": "", "relation": "relation-1"} },
"relation-7": { "computedUserset": { "object": "", "relation": "relation-1"} },
"relation-8": { "this": {} },
"relation-9": { "this": {} },
"relation-10": { "this": {} },
},
"metadata": {
"relations": {
"relation-1": { "directly_related_user_types": [{ "type": "user" }] }, // valid, user exists as a type
"relation-2": { "directly_related_user_types": [{ "type": "group", "relation": "relation-1" }] }, // valid, group exists as a type, and relation-1 exists on that type
"relation-3": { "directly_related_user_types": [] }, // invalid, relation-3 allows direct relationships, but no directly related user types are set
"relation-4": { "directly_related_user_types": [{ "type": "group", "relation": "relation-0" }] }, // invalid, group exists, but relation-0 does not exist on that type
"relation-5": { "directly_related_user_types": [{ "type": "user" }, { "type": "user" }] }, // invalid, duplicate found
"relation-6": { "directly_related_user_types": [{ "type": "user" }] }, // invalid, relation-6 does not allow direct relationships (no `this` in the relation definition)
"relation-7": { "directly_related_user_types": [] }, // valid, relation-7 does not allow direct relationships, so no elements are expected
"relation-8": { "directly_related_user_types": [{ "type": "group", "wildcard": {} }] }, // valid
"relation-9": { "directly_related_user_types": [{ "wildcard": {} }] }, // invalid, each directly_related_user_type must have a relation
"relation-10": { "directly_related_user_types": [{ "type": "group", "relation": "relation-1", "wildcard": {} }] }, // invalid, a directly_related_user_type cannot have both a relation and a wildcard
}
}
}
] }
We'll use the following example authorization model:
{ "schema_version": "1.1",
"type_definitions": [
{ "type": "user", "relations": {} },
{ "type": "group",
"relations": {
"parent": { "this": {} },
"member": { "this": {} },
},
"metadata": {
"relations": {
"parent": { "directly_related_user_types": [{ "type": "group" }] },
"member": { "directly_related_user_types": [{ "type": "user", "wildcard": {} }, { "type": "group", "relation": "member" }, { "type": "employee" }] }
}
}
},
] }
On write, we need to validate that:
-
If the user is an object:
- The type of the user should be in the list of directly related user types on the relation of the type of the object
write(user=user:1, member, group:1)
; valid, theuser
type is in the list of directly related user types for themember
relation of thegroup
typewrite(user=group:2, parent, group:1)
; valid, thegroup
type is in the list of directly related user types for theparent
relation of thegroup
typewrite(user=group:2, member, group:1)
; invalid, thegroup
type is not in the list of directly related user types for themember
relation of thegroup
typewrite(user=user:1, parent, group:1)
; invalid, theuser
type is not in the list of directly related user types for theparent
relation of thegroup
type
-
If the user is a userset:
write(user=group:2#member, member, group:1)
; valid, the(type=group, relation=member)
is in the list of directly related user types for themember
relation of thegroup
typewrite(user=group:2#member, parent, group:1)
; invalid, the(type=group, relation=member)
is not in the list of directly related user types for theparent
relation of thegroup
typewrite(user=group:2#parent, member, group:1)
; invalid, the(type=group, relation=parent)
is not in the list of directly related user types for themember
relation of thegroup
typewrite(user=group:2#parent, parent, group:1)
; invalid, the(type=group, relation=parent)
is not in the list of directly related user types for theparent
relation of thegroup
type
-
If the user is a typed wildcard
${type}:*
:write(user=user:*, member, group:1)
; valid, it will mean all objects that are of typeuser
are related togroup:1
asmember
write(user=*, member, group:1)
; invalid, v1.1 schema version dropped support for the*
user syntaxwrite(user=user*, can_view, group:1)
; invalid, thecan_view
relation on thegroup
type does not allow direct relationship tuples (nothis
in the relation definition).write(user=group:*, parent, group:1)
; invalid,(type=group, wildcard={})
is not in thedirectly_related_user_types
arraywrite(user=group:1#parent, parent, group:1)
; invalid, the type restrictions on theparent
relation on thegroup
type does not allow(type=group, relation=parent)
Note: Any tuples that are considered invalid on write according to a certain model should also be ignored when evaluating the graph based on that model even if they already exist in the database.
ListObjects will be the first of the Relationship Query endpoints to take advantage of this new functionality. Our hope is that we can start building a more performant ListObjects endpoint using the new type restrictions.
Once ListObjects has been implemented and tested, Expand will need to be updated to ignore tuples that do not match the directly related user types in the authorization model.
Check will be the final phase and should be undertaken only once we are completely confident of how ListObjects and Expand are performing with the new functionality. Check is the core of OpenFGA, and we should be diligent in making sure it does not break and that any changes are properly communicated.
When the time comes, check will be updated to ignore relationship tuples in the database that do not match the directly related user types in the authorization model.
Existing models will not be migrated - new models will be required to use types (with an optional grace period). Authorization models that do not make use of types will not be able to use the optimized ListObjects endpoint and will fallback to the existing brute force implementation.
Due to the users in the tuples now required to have types in order to enforce the restrictions, existing tuples with users as floating user identifiers with no types (e.g. anne
) will no longer be valid when types are added. These tuples will not be removed from the system, and will still be valid while the legacy authorization model is supported, but will be ignored during evaluation on newer authorization models with type restrictions in place.
This will need to be communicated to developers so that they can migrate accordingly by:
- Introducing a
user
type to the model - Reading all exiting relationship tuples to find those with a floatig user id (user that has only an identifier and no type)
- Writing a copy of that tuple but with the
user
type - Migrating their app code to perform checks using that type
One option for developers administrating an OpenFGA installation could be a script to check the DB (look for tuples in the DB that do not have user_type
), and print the offending store IDs.
Automatic unassisted tuple migration will not be feasible because it is not possible to know what type end users will want to use for each offending tuple.
This change will affect repositories across the board. It will entail changes to the protobufs, the openfga core, the DSL, the syntax transformer, the SDKs, the FGA Playground, the sample stores and the documentation.
For the scope of this RFC, we are proposing that the initial phase be backwards compatible and not a breaking change. This will allow users on previous versions to keep using them during a grace period while keeping changes to the public surface of the API and SDKs to a minimum.
As a lot of developers will now have to include a user type and it may not have any relations on it. An update to the DSL needs to happen to support types with no relations: (completed via openfga/frontend-utils#47)
type user
An update to the DSL needs to be drafted to support the inclusion of the type restrictions, this should come in a later RFC.
Syntax transformer will need to be updated to support both the new JSON syntax and the new DSL it maps to, as well as all the necessary validations. (Completed via syntax-transformer v0.0.8)
This RFC is draftedThe api protobufs are updated to allow types with no relationsThe openfga/api#27 PR introduces the new fields into the proto-filesThe DSL & openfga/syntax-transformer are updated to allow empty user type(completed via openfga/frontend-utils#47)openfga/openfga.dev is updated to use the user type across the board- openfga/openfga
Validation is added to prevent writing models with invalid type restrictions (e.g. restricting to a type that does not exist)Validation is added to prevent writing tuples that do not match the type restrictions- ListObjects implementation is updated to take the type restrictions into consideration
An RFC for the updated DSL that supports type restrictions is draftedopenfga/syntax-transformer is updated with support for the new DSL and JSON syntaxPlayground is updated with the latest syntax-transformer changesopenfga/sdk-generator is updated to reflect the changes in the proto files- openfga/sample-stores is updated with the type restrictions
- openfga/openfga.dev is updated to include type restrictions in the documentation
Expand and Check implementations are updated to take the type restrictions into consideration
- Floating user_ids with no type can no longer be supported (as adding type restrictions on direct relations requires that objects have a type)
- The
*
syntax with no type is no longer be supported - The JSON syntax now contains duplicate fields for each relation (the additional one being in metadata containing directly related user types)
{ "type_definitions": [
{ "type": "user", "relations": {} },
{ "type": "group",
"relations": {
"parent": { "this": {} },
"member": { "this": {} },
},
"metadata": {
"relations": {
"parent": { "directly_related_user_types": ["group"] },
"member": { "directly_related_user_types": ["user"] }
}
}
},
] }
Allowing users to set the types that a relation can resolve to, can help us build tooling for them that would ensure that their userset rewrites are valid.
For example, consider the following model (in psuedocode). If someone writing the authorization model tries to set this as parent, we can raise an error because we know that parent resolves to a folder, while editor needs to resolve to a user. Which would improve the developer experience and help us guide the users to resolve issues in their models.
{ "type_definitions": [
{ "type": "user", "relations": {} },
{ "type": "folder" },
{ "type": "document",
"relations": {
"parent": {
"directly_related_user_types": ["folder"],
"this": {}
},
"editor": {
"directly_related_user_types": ["user"],
"computedUserset": {
"object": "",
"relation": "parent" // we can detect an error, because parent resolves to folder while editor expects user
}
},
}
},
] }
This alternative would have been our choice had we been optimizing for developer experience instead of for resolving the ReverseExpand/ListObjects use-case. However, because it does not help us narrow down the address space when traversing the graph in reverse, it was deemed insufficient to meet our needs.
It would have been cleaner to introduce the directly related user types into the relation object like so:
{ "type_definitions": [
{ "type": "user", "relations": {} },
{ "type": "group",
"relations": {
"parent": { "directly_related_user_types": [{ "type": "group" }], "this": {} },
"member": { "directly_related_user_types": [{ "type": "user" }, { "type": "group", "relation": "member" }], "this": {} },
}
},
] }
However, that is a breaking change due to how the relations are currently defined in the protobuf files as a map of usersets.
If we do introduce it, it should probably be bundled with other cleanup and breaking changes at a later point in time.
Drop support for *
, introduce support for an alternative syntax, require specifying the type for which wildcards are valid
- To represent everyone in the subject field of a relationship tuple:
user=${type}:*
(note: it must include the type) - To add everyone to the type restrictions of a certain relation, add
{ "type": "employee", "wildcard": {} }
(other alternatives considered were:{ "type": "employee", "wildcard": true }
)
benefits: ability to properly represent everyone of a particular type
cons: breaking changes, *
is not supported, and employee:*
will be interpreted differently
Drop support for *
, introduce support for an alternative syntax, introduce allowPublic
on a type metadata
- To represent everyone in the subject field of a relationship tuple:
user=${type}:*
(note: it must include the type) allowPublic
on a type metadata means all type restrictions with no relations are now accepted
*
is kept, but will be interpreted as all the types that are allowed in type restrictions
benefits: no breaking changes, possible to seemlessly upgrade from a 1.0 to a 1.1 model cons: inability to properly represent everyone of a particular type
We chose the first option in order to give developers the choice to better represent their models, and be more explicit in what they want to allow.
Other projects that have a Zanzibar-like API have adopted similar patterns:
- SpiceDB - Representing users as subjects
- Permify - Modeling
- Ory Keto - Type declaration