Relationship-based authorization
Answer "may this user edit this document" from a model of who relates to what — nested groups and inherited access included.
Roles answer what may this person do anywhere in the tenant. Some questions are about one object: may Anne edit this document, may Bob see this folder because his team was given it. Base316 answers those from an authorization model and the relationships your application records, using OpenFGA underneath.
You never run or reach OpenFGA yourself. Every environment has its own model and its own relationships: nothing written in Sandbox can answer a Production check, and no other workspace can see yours.
All of /v1/fga needs a secret key, including checks. A check reveals who
can see what; with a publishable key, any visitor to your site could ask. Call
it from your server, which knows who is signed in.
1. Write a model
A model declares types, the relations on them, and how relations imply each other. It is written in OpenFGA's modelling language:
model
schema 1.1
type user
type group
relations
define member: [user, group#member]
type folder
relations
define viewer: [user, group#member]
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[user, group#member]— who can be given the relation directly: a person, or everyone who is a member of a group. Groups can contain groups.or owner— owners are editors without being written as editors.viewer from parent— viewers of the parent folder view the document.
Save it in the dashboard under Settings → Fine-grained access, which validates as you go and shows each problem with its line and column, or through the API:
import { Base316 } from "@base316/sdk";
const base316 = new Base316();
await base316.fga.writeModel({ dsl: MODEL });writeModel also takes { model } in OpenFGA's JSON form. A model that does
not validate is refused with INVALID_REQUEST, and error.message names the
first problem by line. Saving replaces the model; relationships you already
wrote are kept.
2. Record relationships
A relationship is a tuple — user has relation to object:
await base316.fga.write({
writes: [
{ user: "user:anne", relation: "member", object: "group:eng" },
{ user: "group:eng#member", relation: "editor", object: "document:roadmap" },
{ user: "folder:plans", relation: "parent", object: "document:roadmap" },
],
});- A call holds up to 100 tuples across
writesanddeletes, and is applied atomically. - Writes and deletes are idempotent: writing a tuple that exists, or deleting one that does not, succeeds. Retrying after a timeout is safe.
- Use your own ids.
user:anneis whatever identifies Anne in your system.
3. Ask
const { allowed } = await base316.fga.check({
user: "user:anne",
relation: "viewer",
object: "document:roadmap",
});Anne is a member of eng, eng edits the roadmap, and editors are viewers — so
this is allowed without anybody writing "Anne may view the roadmap".
listObjects answers the other way round — every document Anne can view — and
listUsers lists who holds a relation:
const { objects } = await base316.fga.listObjects({
user: "user:anne",
relation: "viewer",
type: "document",
});
const { users } = await base316.fga.listUsers({
object: "document:roadmap",
relation: "editor",
userFilters: [{ type: "user" }],
});The tester under Settings → Fine-grained access asks all three questions — a check, list objects and list users — against the environment's saved model, so you can try a model out before writing any code.
Contextual tuples
A relationship that holds only for this request — the group a session token claims, say — can ride on the check without being stored:
await base316.fga.check({
user: "user:anne",
relation: "viewer",
object: "document:roadmap",
contextualTuples: [{ user: "user:anne", relation: "member", object: "group:auditors" }],
});Up to 50 per call.
Plan, permission and relationship in one call
access.check can ask all three at once. It is allowed only when every part
passes:
const decision = await base316.access.check({
externalId: accountId, // the billing account: is "reports" on its plan?
feature: "reports",
externalUserId: "anne", // the person
relation: "editor", // ...and may they edit this report?
object: `report:${reportId}`,
});
if (!decision.allowed) {
// "RELATION_NOT_GRANTED" when only the relationship failed.
throw new Forbidden(decision.reason);
}The relationship is checked for user:<externalUserId> (or user:<userId>),
exactly as sent. Pass subject to check for something else, such as
group:eng#member. A relationship alone needs no billing account.
Errors
| Code | When |
|---|---|
INVALID_REQUEST | A model that does not validate, a type or relation the model does not define |
INVALID_REQUEST | param: "model" — no model has been written for this environment yet |
SECRET_KEY_REQUIRED | Any /v1/fga call with a publishable key |
SERVICE_UNAVAILABLE | The authorization service did not answer; retry with backoff |
Personal data
Tuples hold whatever ids you write into them. When you erase a user, delete
their tuples too — read them with fga.tuples({ user: "user:anne", object: "document:" }) for each type, then fga.write({ deletes }). Base316's own
erasure does not reach into your relationships, because it cannot know which of
your ids is that person.

