docs: rewrite OPL as practical guide#2588
Conversation
There was a problem hiding this comment.
Pull request overview
This PR rewrites the Ory Permission Language (OPL) reference page from a formal specification into a practical how-to guide, introducing core modeling concepts (namespaces, relations, subject-set references, and permits) via short examples.
Changes:
- Replaced the EBNF/spec-style content with a step-by-step, example-driven guide.
- Added focused examples for unions,
SubjectSet<...>references,includes,traverse, boolean operators, and permission composition. - Consolidated into a single “complete example” schema showing direct, group-based, and inherited access patterns.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| restricted: (ctx: Context) => | ||
| this.related.allowlist.includes(ctx.subject) && | ||
| !this.related.blocklist.includes(ctx.subject), | ||
| ``` |
| edit: (ctx: Context) => this.related.owners.includes(ctx.subject), | ||
| admin: (ctx: Context) => this.permits.edit(ctx) && this.related.admins.includes(ctx.subject), |
| The `related` block defines who can be associated with an object. Each entry names a relation and declares what subject types it | ||
| holds. |
There was a problem hiding this comment.
I think this can be confusing, as it is not necessarily clear what objects and subjects are here. I would either add something to namespaces section, or define object and subject here as the direction of relations.
Something along the lines:
| The `related` block defines who can be associated with an object. Each entry names a relation and declares what subject types it | |
| holds. | |
| The `related` block defines the relations between namespaces (classes). Each relation has a direction: `subject` is in `relation` of `object`. The relations defined in an OPL class (object) declare the allowed subjects. Generally, relations should represent the "real-world" relations between objects and subjects. |
Then add something after the example saying which relations this defines and what the objects/subjects are.
| identifier = letter { letter | digit } . | ||
| digit = "0" … "9" . | ||
| letter = "A" … "Z" | "a" … "z" | "_" . | ||
| class File implements Namespace { |
| ClassDecl = "class" identifier "implements" "Namespace" "{" ClassSpec "}" . | ||
| ClassSpec = [ RelationDecls ] | [ PermissionDefns] . | ||
| ``` | ||
| ### Subject-set references |
There was a problem hiding this comment.
I propose to completely omit this section, as subject sets are not really useful currently. There is no specific reason to use them, right?
There was a problem hiding this comment.
good point. Based on how we implement things, currently subjectsets are faster than traverse calls. In a place that traverse would cause 100 direct check calls, SubjectSet<> would make 1 call and get the answer.
But that's an implementation details, and technically, we can make optimizations to make them equally performant.
I think that's the only difference. Otherwise, Traverse is more future-proof as we can easily update/change the relationships form .members.includes(.) to .admin.includes(.).
There was a problem hiding this comment.
Okay, see this answer: https://github.com/orgs/authzed/discussions/2851#discussioncomment-15608216
This is also one difference, though i don't think anyone would care about it.
There was a problem hiding this comment.
I see, but the use-case is very narrow.
IMO the only relevant use-case is where the relation is dynamic, so the user can choose whether they want to grant permissions to all group members, only group admins, ... That requires the relation to not be in the OPL though, or allow narrowing it down to multiple relations. The use-case is not supported really rn.
| ``` | ||
|
|
||
| Note that all relations are defined as array types `T[]` because there are naturally only many-to-many relations in Keto. | ||
| A subject can now be either a `User` directly, or any member of the `Group:engineering`. |
There was a problem hiding this comment.
Not clear to me what this means.
|
|
||
| ```ts | ||
| class User {} | ||
| The `permits` block defines boolean functions the engine evaluates during a check. |
There was a problem hiding this comment.
| The `permits` block defines boolean functions the engine evaluates during a check. | |
| The `permits` block defines permissions. These are evaluated during a permission check request. Permissions are written as functions that return a boolean representing whether a permission is granted or not. While relations follow the real-world, permissions define application-specific rules based on relations. |
| ``` | ||
|
|
||
| The `ctx` object is a fixed parameter that contains the `subject` for which the permission check should be conducted: | ||
| `this.related.x.traverse(g => ...)` iterates over the objects in relation `x` and evaluates the inner expression for each one. |
There was a problem hiding this comment.
| `this.related.x.traverse(g => ...)` iterates over the objects in relation `x` and evaluates the inner expression for each one. | |
| `this.related.x.traverse(g => ...)` iterates over the subjects in relation `x` and evaluates the inner expression for each one. |
| edit: (ctx: Context) => this.related.owners.includes(ctx.subject), | ||
| admin: (ctx: Context) => this.permits.edit(ctx) && this.related.admins.includes(ctx.subject), |
There was a problem hiding this comment.
I think the example is not the most natural one
| edit: (ctx: Context) => this.related.owners.includes(ctx.subject), | |
| admin: (ctx: Context) => this.permits.edit(ctx) && this.related.admins.includes(ctx.subject), | |
| isAdmin: (ctx: Context) => this.related.admins.includes(ctx.subject), | |
| edit: (ctx: Context) => this.permits.isAdmin(ctx) ||this.related.owners.includes(ctx.subject), |
| related: { | ||
| members: (User | Group)[] | ||
| members: (User | SubjectSet<Group, "members">)[] | ||
| } |
There was a problem hiding this comment.
While the subject set works, I don't think it is the correct way of doing this. Having the parents explicitly here makes it a lot clearer and allows the relation to be reused in different permissions if one wants to add them (think admins of groups, ...)
| related: { | |
| members: (User | Group)[] | |
| members: (User | SubjectSet<Group, "members">)[] | |
| } | |
| related: { | |
| members: User[] | |
| parents: Group[] | |
| } | |
| permits = { | |
| isMember: (ctx: Context) => this.related.members.includes(ctx.subject) || this.related.parents.traverse((parent) => parent.permits.isMember(ctx)) |
There was a problem hiding this comment.
I admit that it is not so nice in the other namespaces without the planned type assertions, but I think it is the better way of writing permissions.
| This schema models: | ||
|
|
||
| - Direct access via `viewers` and `owners` | ||
| - Group-based access via `SubjectSet<Group, "members">` | ||
| - Inherited access from parent folders via `traverse` |
There was a problem hiding this comment.
Consider adding some of these details as comments, so it is more clear what you refer to. Consider adding more comments to the full example in general.
This PR rewrites OPL into a practical how-to guide covering namespaces, relations, subject-set references, and permits, each section with examples