Enforcing fine-grained permissions is a critical building block of mature technology solutions that protect privacy and identity in the information age. Several proprietary languages to represent permission already exist, namely the Authzed Schema Language and Auth0 FGA. Most permissions are defined by normal developers who typically are most familiar with Web technologies like JavaScript or Typescript. We see the need for a developer-friendly configuration language for permissions that has such a small learning curve that most developers can understand and use it with close to no effort. We therefore chose to define our permissions configuration language as a subset of the most common general-purpose programming language: JavaScript/TypeScript.
The Ory Permission Language is a syntactical subset of TypeScript. Along with
type definitions for the syntax elements of the language (such as Namespace
or
Context
), users can get context help from their IDE while writing the
configuration.
The syntax is specified using the Extended Backus-Naur Form (EBNF):
Production = production_name "=" [ Expression ] "." .
Expression = Alternative { "|" Alternative } .
Alternative = Term { Term } .
Term = production_name | token [ "…" token ] | Group | Option | Repetition .
Group = "(" Expression ")" .
Option = "[" Expression "]" .
Repetition = "{" Expression "}" .
Productions are expressions constructed from terms and the following operators, in increasing precedence:
| alternation
() grouping
[] option (0 or 1 times)
{} repetition (0 to n times)
Lower-case production names are used to identify lexical tokens. Non-terminals
are in CamelCase. Lexical tokens are enclosed in double quotes ""
or single
quotes ''
.
The form a … b
represents the set of characters from a through b as
alternatives. The horizontal ellipsis …
is also used elsewhere in the spec to
informally denote various enumerations or code snippets that are not further
specified.
The configuration is encoded in UTF-8.
- Line comments start with the character sequence
//
and stop at the end of the line. - General comments start with the character sequence
/*
and stop with the first subsequent character sequence*/
. - Documentation comments start with the character sequence
/**
and stop with the first subsequent character sequence*/
.
Identifiers name program entities such as variables and types. An identifier is a sequence of one or more letters and digits. The first character in an identifier must be a letter.
identifier = letter { letter | digit } .
digit = "0" … "9" .
letter = "A" … "Z" | "a" … "z" | "_" .
String literals represent string constants as sequences of characters.
string_lit = single_quoted | double_quoted .
single_quoted = "'" identifier "'" .
double_quoted = '"' identifier '"' .
The configuration language has the following keywords:
class
implements
related
permits
this
ctx
id
imports
exports
as
The following types are built in:
Context
Namespace
Namespace[]
boolean
string
SubjectSet<T, R>
In TypeScript, they would be defined as follows:
type Context = { subject: never }
interface Namespace {
related?: { [relation: string]: Namespace[] }
permits?: { [method: string]: (ctx: Context) => boolean }
}
interface Array<Namespace> {
includes(element: Namespace): boolean
traverse(iteratorfn: (element: Namespace) => boolean): boolean
}
type SubjectSet<A extends Namespace, R extends keyof A["related"]> =
A["related"][R] extends Array<infer T> ? T : never
The following character sequences represent boolean operators:
Operator | Signature | Semantic |
---|---|---|
&& |
x && y |
true iff. both x and y are true |
|| |
x || y |
true iff. either x or y are true |
! |
! x |
true iff. x is false |
The following character sequences represent miscellaneous operators:
Operator | Example | Semantic |
---|---|---|
( , ) |
x() |
call function x |
[] |
T[] |
T is an array type |
< , > |
SubjectSet<Group, "members"> |
Type reference to the "members" relation of the "Group" namespace |
{ , } |
class x {} |
Scope delimiters |
=> |
list.transitive(el => f(el)) |
Lambda definition token. |
. |
this.x |
Property traversal token |
: |
relation: type |
Relation/type separator token |
= |
permits = {...} |
Assignment token |
, |
{x: 1, y: 2} |
Property separator |
' |
'string' |
Single quoted string literal |
" |
"string" |
Double quoted string literal |
* |
import * from @ory/permission-language |
Import glob |
| |
(User | Group)[] |
Type union |
The top level of the configuration consists of a list of class
declarations
for each namespace. Each class
consists of relation declarations and
permission declarations.
Config = [ ClassDecl ] .
ClassDecl = "class" identifier "implements" "Namespace" "{" ClassSpec "}" .
ClassSpec = [ RelationDecls ] | [ PermissionDefns] .
The following example declares the type User.
class User implements Namespace {}
The related
section of type declarations defines relations. Unlike regular
TypeScript, RelationName
must be a unique identifier used as the relation
strings in the tuples. The TypeName
must be the name of another type
that is
defined above or below (in TypeScript: a class that implements Namespace
).
Type unions (|
) can be used to denote that a relation can have subjects of
multiple types, e.g., viewers: (User | SubjectSet<Group, "members">)[]
,
meaning that the subject of the "viewer" relation can be either a "User", or a
subject set "Group#members".
RelationDecls = "related" "=" "{" { RelationName ":" ArrayType } "}" .
RelationName = identifier .
ArrayType = RelationType | ( "(" RelationType { "|" RelationType } ")" ) "[]" .
RelationType = SubjectType | SubjectSetType .
SubjectType = TypeName .
SubjectSetType = "SubjectSet" "<" TypeName, string_lit ">" .
TypeName = identifier .
Note that all relations are defined as array types T[]
because there are
naturally only many-to-many relations in Keto.
The following declares a type Document with three relations: owners and viewers, both of which have users as subjects. Additionally, the relation parent has type Document.
class User {}
class Document {
related = {
parents: Document[]
owners: User[]
viewers: User[]
}
}
Permissions are defined as functions within class declarations that take a
parameter ctx
of type Context
and evaluate to a boolean true
or false
.
The type annotations for ctx
and the return value are optional.
PermissionDefns = "permits" "=" "{" Permission [ "," Permission ] "}" .
Permission = PermissionSign "=>" PermissionBody .
PermissionSign = PermissionName ":" "(" "ctx" [ ":" "Context" ] ")" [ ":" "boolean" ] .
PermissionName = identifier .
The ctx
object is a fixed parameter that contains the subject
for which the
permission check should be conducted:
ctx = { subject: "some_user_id" }
The context will contain more fields in the future, e.g., the IP range or geolocation, the time of day, or the security level of the device making the request.
PermissionBody = ( "(" PermissionBody ")" ) | ( "!" PermissionBody ) | ( PermissionCheck | { Operator PermissionBody } ) .
Operator = "||" | "&&" .
PermissionCheck = TransitiveCheck | IncludesCheck .
The body of a permission check is either one of:
-
a
IncludesCheck
, a check that something is in a set, e.g.,this.related.viewers.includes(ctx.subject)
:IncludesCheck = Var "." "related" "." RelationName "." "includes" "(" "ctx" "." "subject" ")" . Var = identifier .
-
a
TranstitiveCheck
, a call to a permission on a relation, e.g.,this.related.parents.transitive(p => p.permits.view(ctx))
:TransitiveCheck = "this" "." "related" "." RelationName "." "transitive" "(" Var "=>" ( PermissionCall | IncludesCheck ) ")" . PermissionCall = Var "." "permits" "." PermissionName "(" "ctx" ")" .
IncludeCheck
and TransitiveCheck
translate to Zanzibar concepts as follows:
Keto Config | Zanzibar AST |
---|---|
this.related.R.includes(ctx.subject) |
computed_userset { relation: "R" } } |
this.related.R.transitive(x = x.permits.P(ctx.subject)) |
tuple_to_userset { tupleset { relation: "R" } computed_userset { relation: "P" } } |
The following type checks are performed once the config is fully parsed:
- Given a
TypeName
asX
(e.g., inRelationDecls
), we check that there exists a class declaration forX
. - Given a
SubjectSetType
asSubjectSet<T, R>
, we check thatR
is a relation defined forT
. - Given an
IncludesCheck
asthis.related.R.includes(ctx.subject)
, we check thatR
is a relation defined for the current namespace.
- Given a
TransitiveCheck
asthis.related.R.transitive(x = x.permits.P(ctx.subject))
, we check thatR
is a relation defined for the current namespace and thatP
is a permission defined for all types referenced byR
.
- Given a
TransitiveCheck
asthis.related.R.transitive(x = x.related.S.includes(ctx.subject))
, we check thatR
is a relation defined for the current namespace and thatS
is a relation defined for all types referenced byR
.
The config can be type-checked in strict
mode by TypeScript with the
noLib option (preventing the
standard globals), and the
strictPropertyInitialization
option (allowing uninitialized properties).
class User implements Namespace {
related: {
manager: User[]
}
}
class Group implements Namespace {
related: {
members: (User | Group)[]
}
}
class Folder implements Namespace {
related: {
parents: File[]
viewers: (User | SubjectSet<Group, "members">)[]
}
permits = {
view: (ctx: Context): boolean => this.related.viewers.includes(ctx.subject),
}
}
class File implements Namespace {
related: {
parents: (File | Folder)[]
viewers: (User | SubjectSet<Group, "members">)[]
owners: (User | SubjectSet<Group, "members">)[]
siblings: File[]
}
permits = {
view: (ctx: Context): boolean =>
this.related.parents.traverse((p) =>
p.related.viewers.includes(ctx.subject),
) ||
this.related.parents.traverse((p) => p.permits.view(ctx)) ||
this.related.viewers.includes(ctx.subject) ||
this.related.owners.includes(ctx.subject),
edit: (ctx: Context) => this.related.owners.includes(ctx.subject),
rename: (ctx: Context) =>
this.related.siblings.traverse((s) => s.permits.edit(ctx)),
}
}