Modular Models
Modular models allows splitting your authorization module across multiple files and modules, improving upon some of the challenges that may be faced when operating an authorization model within a company, such as:
- A model can grow large and difficult to understand
- As more teams begin to contribute to a model, the ownership boundaries may not be clear and code review processes might not scale
With modular models, a single model can be split across multiple files in a project. It can be organized in a way that makes sense for the project or teams collaborating on it, and it enables ownership for reviews to be expressed using a feature such as GitHub's, GitLab's or Gitea's code owners.
Key Concepts
fga.mod
The fga.mod
is the project file for modular models. This file specifies the schema version for the final combined model and lists the individual files that make up the modular model.
Property | Description |
---|---|
schema | The schema version to be used for the combined model |
contents | The individual files that make up the modular model |
Modules
Modules are declared using the module
keyword in the DSL, and a module can be written across multiple files. A single file cannot have more than one module.
Currently, modules are stored as metadata but are not used by OpenFGA. Module metadata will be used in upcoming features, such as applying authorization to writing and reading/querying tuples.
Type Extensions
As teams implement features, they might find that core types they are dependent upon might not contain all the relations they need. However, it might not make sense for these relations to be owned by the owner of that type if they aren't needed across the system.
To solve this, individual types can be extended within other modules to implement the relations needed.
In order to allow this, there are certain requirements for type extension:
- The extended type must exist
- A single type can only be extended once per file
- The relations added must not already exist, or be part of another type extension
Example
In this example we'll look at how an authorization model for a SaaS company with an issue tracking and wiki software could implement modular models.
Core
It's likely there will be a core set of types owned by a team that manages the overall identity for the company, this would provide the basics such as users, organizations and groups that can be used by each respective product area.
module core
type user
type organization
relations
define member: [user]
define admin: [user]
type group
relations
define member: [user]
Issue tracking
In the issue tracking software we'd likely separate out the project and issue related types into separate files, we'll also extend the organization
type here so that we can add a relation specific to the issue tracking feature, which is the ability to authorize who can create a project.
module issue-tracker
extend type organization
relations
define can_create_project: admin
type project
relations
define organization: [organization]
define viewer: member from organization
module issue-tracker
type ticket
relations
define project: [project]
define owner: [user]
Wiki
Our wiki model we'll handle in one file for now until it grows some more. Again, we'll also extend the organization
type here so that we can add a relation to track who can create a space.
module wiki
extend type organization
relations
define can_create_space: admin
type space
relations
define organization: [organization]
define can_view_pages: member from organization
type page
relations
define space: [space]
define owner: [user]
fga.mod
In order to deploy this model we'll need to create our fga.mod
manifest file, in here we'll set our schema version and list the individual module files that make up our model.
schema: '1.2'
contents:
- core.fga
- issue-tracker/projects.fga
- issue-tracker/tickets.fga
- wiki.fga
Putting it all together
Now that we have our individual parts of the modular model, we're able to write this model to OpenFGA and then run tests against it.
In order to write our model we need to use the CLI and run:
fga model write --store-id=$FGA_STORE_ID --file fga.mod
We can then write tuples and query this model as you would expect with a singular file authorization model.
- Node.js
- Go
- .NET
- Python
- Java
- CLI
- curl
await fgaClient.write({
writes: [
{"user":"user:anne","relation":"admin","object":"organization:acme"},
{"user":"organization:acme","relation":"organization","object":"space:acme"},
{"user":"organization:acme","relation":"organization","object":"project:acme"}
],
}, {
authorization_model_id: "01HVMMBCMGZNT3SED4Z17ECXCA"
});
options := ClientWriteOptions{
AuthorizationModelId: PtrString("01HVMMBCMGZNT3SED4Z17ECXCA"),
}
body := ClientWriteRequest{
Writes: []ClientTupleKey{
{
User: "user:anne",
Relation: "admin",
Object: "organization:acme",
}, {
User: "organization:acme",
Relation: "organization",
Object: "space:acme",
}, {
User: "organization:acme",
Relation: "organization",
Object: "project:acme",
},
},
}
data, err := fgaClient.Write(context.Background()).
Body(body).
Options(options).
Execute()
if err != nil {
// .. Handle error
}
_ = data // use the response
var options = new ClientWriteOptions {
AuthorizationModelId = "01HVMMBCMGZNT3SED4Z17ECXCA",
};
var body = new ClientWriteRequest() {
Writes = new List<ClientTupleKey>() {
new() {
User = "user:anne",
Relation = "admin",
Object = "organization:acme"
},
new() {
User = "organization:acme",
Relation = "organization",
Object = "space:acme"
},
new() {
User = "organization:acme",
Relation = "organization",
Object = "project:acme"
}
},
};
var response = await fgaClient.Write(body, options);
options = {
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA"
}
body = ClientWriteRequest(
writes=[
ClientTuple(
user="user:anne",
relation="admin",
object="organization:acme",
),
ClientTuple(
user="organization:acme",
relation="organization",
object="space:acme",
),
ClientTuple(
user="organization:acme",
relation="organization",
object="project:acme",
),
],
)
response = await fga_client.write(body, options)
var options = new ClientWriteOptions()
.authorizationModelId("01HVMMBCMGZNT3SED4Z17ECXCA");
var body = new ClientWriteRequest()
.writes(List.of(
new ClientTupleKey()
.user("user:anne")
.relation("admin")
._object("organization:acme"),
new ClientTupleKey()
.user("organization:acme")
.relation("organization")
._object("space:acme"),
new ClientTupleKey()
.user("organization:acme")
.relation("organization")
._object("project:acme")
));
var response = fgaClient.write(body, options).get();
fga tuple write --store-id=${FGA_STORE_ID} --model-id=01HVMMBCMGZNT3SED4Z17ECXCA user:anne admin organization:acme
fga tuple write --store-id=${FGA_STORE_ID} --model-id=01HVMMBCMGZNT3SED4Z17ECXCA organization:acme organization space:acme
fga tuple write --store-id=${FGA_STORE_ID} --model-id=01HVMMBCMGZNT3SED4Z17ECXCA organization:acme organization project:acme
curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/write \
-H "Authorization: Bearer $FGA_API_TOKEN" \ # Not needed if service does not require authorization
-H "content-type: application/json" \
-d '{"writes": { "tuple_keys" : [{"user":"user:anne","relation":"admin","object":"organization:acme"},{"user":"organization:acme","relation":"organization","object":"space:acme"},{"user":"organization:acme","relation":"organization","object":"project:acme"}] }, "authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA"}'
- Node.js
- Go
- .NET
- Python
- Java
- CLI
- curl
- Pseudocode
Initialize the SDK
// ApiTokenIssuer, ApiAudience, ClientId and ClientSecret are optional.
// import the SDK
const { OpenFgaClient } = require('@openfga/sdk');
// Initialize the SDK with no auth - see "How to setup SDK client" for more options
const fgaClient = new OpenFgaClient({
apiUrl: process.env.FGA_API_URL, // required, e.g. https://api.fga.example
storeId: process.env.FGA_STORE_ID,
authorizationModelId: process.env.FGA_MODEL_ID, // Optional, can be overridden per request
});
// Run a check
const { allowed } = await fgaClient.check({
user: 'user:anne',
relation: 'can_create_space',
object: 'organization:acme',
}, {
authorization_model_id: '01HVMMBCMGZNT3SED4Z17ECXCA',
});
// allowed = true
Initialize the SDK
// ApiTokenIssuer, ApiAudience, ClientId and ClientSecret are optional.
import (
"os"
. "github.com/openfga/go-sdk"
. "github.com/openfga/go-sdk/client"
)
func main() {
// Initialize the SDK with no auth - see "How to setup SDK client" for more options
fgaClient, err := NewSdkClient(&ClientConfiguration{
ApiUrl: os.Getenv("FGA_API_URL"), // required, e.g. https://api.fga.example
StoreId: os.Getenv("FGA_STORE_ID"), // optional, not needed for `CreateStore` and `ListStores`, required before calling for all other methods
AuthorizationModelId: os.Getenv("FGA_MODEL_ID"), // Optional, can be overridden per request
})
if err != nil {
// .. Handle error
}
}
options := ClientCheckOptions{
AuthorizationModelId: PtrString("01HVMMBCMGZNT3SED4Z17ECXCA"),
}
body := ClientCheckRequest{
User: "user:anne",
Relation: "can_create_space",
Object: "organization:acme",
}
data, err := fgaClient.Check(context.Background()).
Body(body).
Options(options).
Execute()
// data = { allowed: true }
Initialize the SDK
// ApiTokenIssuer, ApiAudience, ClientId and ClientSecret are optional.
// import the SDK
using OpenFga.Sdk.Client;
using OpenFga.Sdk.Client.Model;
using OpenFga.Sdk.Model;
using Environment = System.Environment;
namespace Example;
class Example {
public static async Task Main() {
// Initialize the SDK with no auth - see "How to setup SDK client" for more options
var configuration = new ClientConfiguration() {
ApiUrl = Environment.GetEnvironmentVariable("FGA_API_URL"), ?? "http://localhost:8080", // required, e.g. https://api.fga.example
StoreId = Environment.GetEnvironmentVariable("FGA_STORE_ID"), // optional, not needed for `CreateStore` and `ListStores`, required before calling for all other methods
AuthorizationModelId = Environment.GetEnvironmentVariable("FGA_MODEL_ID"), // Optional, can be overridden per request
};
var fgaClient = new OpenFgaClient(configuration);
}
}
var options = new ClientCheckOptions {
AuthorizationModelId = "01HVMMBCMGZNT3SED4Z17ECXCA",
};
var body = new ClientCheckRequest {
User = "user:anne",
Relation = "can_create_space",
Object = "organization:acme",
};
var response = await fgaClient.Check(body, options);
// response.Allowed = true
Initialize the SDK
# ApiTokenIssuer, ApiAudience, ClientId and ClientSecret are optional.
import asyncio
import os
import json
from openfga_sdk.client import ClientConfiguration, OpenFgaClient
async def main():
configuration = ClientConfiguration(
api_url = os.environ.get('FGA_API_URL'), # required, e.g. https://api.fga.example
store_id = os.environ.get('FGA_STORE_ID'), # optional, not needed for `CreateStore` and `ListStores`, required before calling for all other methods
authorization_model_id = os.environ.get('FGA_MODEL_ID'), # Optional, can be overridden per request
)
# Enter a context with an instance of the OpenFgaClient
async with OpenFgaClient(configuration) as fga_client:
api_response = await fga_client.read_authorization_models()
await fga_client.close()
asyncio.run(main())
options = {
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA"
}
body = ClientCheckRequest(
user="user:anne",
relation="can_create_space",
object="organization:acme",
)
response = await fga_client.check(body, options)
# response.allowed = true
Initialize the SDK
// ApiTokenIssuer, ApiAudience, ClientId and ClientSecret are optional.
import dev.openfga.sdk.api.client.OpenFgaClient;
import dev.openfga.sdk.api.configuration.ClientConfiguration;
public class Example {
public static void main(String[] args) throws Exception {
var config = new ClientConfiguration()
.apiUrl(System.getenv("FGA_API_URL")) // If not specified, will default to "https://localhost:8080"
.storeId(System.getenv("FGA_STORE_ID")) // Not required when calling createStore() or listStores()
.authorizationModelId(System.getenv("FGA_AUTHORIZATION_MODEL_ID")); // Optional, can be overridden per request
var fgaClient = new OpenFgaClient(config);
}
}
var options = new ClientCheckOptions()
.authorizationModelId("01HVMMBCMGZNT3SED4Z17ECXCA");
var body = new ClientCheckRequest()
.user("user:anne")
.relation("can_create_space")
._object("organization:acme");
var response = fgaClient.check(body, options).get();
// response.getAllowed() = true
Set FGA_API_URL according to the service you are using (e.g. https://api.fga.example)
Set FGA_API_URL according to the service you are using (e.g. https://api.fga.example)
fga query check --store-id=$FGA_STORE_ID --model-id=01HVMMBCMGZNT3SED4Z17ECXCA user:anne can_create_space organization:acme
# Response: {"allowed":true}
Set FGA_API_URL according to the service you are using (e.g. https://api.fga.example)
Set FGA_API_URL according to the service you are using (e.g. https://api.fga.example)
curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/check \
-H "Authorization: Bearer $FGA_API_TOKEN" \ # Not needed if service does not require authorization
-H "content-type: application/json" \
-d '{"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA", "tuple_key":{"user":"user:anne","relation":"can_create_space","object":"organization:acme"}}'
# Response: {"allowed":true}
check(
user = "user:anne", // check if the user `user:anne`
relation = "can_create_space", // has an `can_create_space` relation
object = "organization:acme", // with the object `organization:acme`
authorization_id = "01HVMMBCMGZNT3SED4Z17ECXCA"
);
Reply: true
Viewing the model
When viewing the combined model DSL via the CLI with fga model get --store-id=$FGA_STORE_ID
, the DSL will be annotated with comments defining the source module and file for types, relations and conditions.
For example, if we look specifically at the organization
type we can see that the type is defined in the core.fga
file as part of the core
module, and then the can_create_project
relation is defined in issue-tracker/projects.fga
as part of the issuer-tracker
module and the can_create_space
relation is defined in the wiki.fga
file as part of the wiki
module.
type organization # module: core, file: core.fga
relations
define admin: [user]
define member: [user] or admin
define can_create_project: admin # extended by: module: issue-tracker, file: issue-tracker/projects.fga
define can_create_space: admin # extended by: module: wiki, file: wiki.fga