Skip to main content

Ory Permission Language specification

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 used to represent permission already exist, such as Rego or Casbin. Most permissions are defined by developers who are likely familiar with Web technologies like JavaScript or Typescript. There is a need for a developer-friendly configuration language for permissions that has a learning curve small enough so that most developers can understand and use it with minimal effort. To fulfill this need, we defined the 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.

Notation

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)

Lowercase 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.

Configuration text representation

The configuration is encoded in UTF-8.

Lexical elements

Comments

  1. Line comments start with the character sequence // and stop at the end of the line.
  2. General comments start with the character sequence /* and stop with the first subsequent character sequence */.
  3. Documentation comments start with the character sequence /** and stop with the first subsequent character sequence */.

Identifiers

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

String literals represent string constants as sequences of characters.

string_lit    = single_quoted | double_quoted .
single_quoted = "'" identifier "'" .
double_quoted = '"' identifier '"' .

Keywords

The configuration language has the following keywords:

  • class
  • implements
  • related
  • permits
  • this
  • ctx
  • id
  • imports
  • exports
  • as

Builtin Types

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

Operators

The following character sequences represent boolean operators:

OperatorSignatureSemantic
&&x && ytrue iff. both x and y are true
\|\|x \|\| ytrue iff. either x or y are true

The following character sequences represent miscellaneous operators:

OperatorExampleSemantic
(, )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.xProperty traversal token
:relation: typeRelation/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-languageImport glob
\|(User \| Group)[]Type union

Statements

Type declaration

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 {}

Relation declaration

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[]
}
}

Permission definition

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" }
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" ")" .

Implementation notes

IncludeCheck and TransitiveCheck translate to Zanzibar concepts as follows:

Keto ConfigZanzibar 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" } }

Type checking

The following type checks are performed once the config is fully parsed:

  • Given a TypeName as X (e.g., in RelationDecls), we check that there exists a class declaration for X.
  • Given a SubjectSetType as SubjectSet<T, R>, we check that R is a relation defined for T.
  • Given an IncludesCheck as this.related.R.includes(ctx.subject), we check that
    • R is a relation defined for the current namespace.
  • Given a TransitiveCheck as this.related.R.transitive(x = x.permits.P(ctx.subject)), we check that
    • R is a relation defined for the current namespace and that
    • P is a permission defined for all types referenced by R.
  • Given a TransitiveCheck as this.related.R.transitive(x = x.related.S.includes(ctx.subject)), we check that
    • R is a relation defined for the current namespace and that
    • S is a relation defined for all types referenced by R.

Examples

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)),
}
}