Access Control
One of the most powerful features of Exograph is its ability to specify access control rules for Postgres types. By co-locating type definitions with access control rules, you can define a model that is both self-documenting and self-enforcing. You can examine the exo file and immediately understand the access control rules for each type.
Exograph supports access control for the Postgres module using the @access
annotation on types. Queries and mutations associated with each type inherit the access control rules of the type. For example, if a type defines access control to limit querying only to "admin" users, all queries for that type will only be accessible to "admin" users.
The access annotation specifies rules as varied as allowing everyone to access a query, only to those with specific roles, based on a particular field's value of the accessed object, users from specific IP addresses, users with valid captcha, date and time, or any combination. This is possible because the context of a request is a flexible concept, and you can model it from various sources.
The @access
annotation takes a boolean expression evaluated in the context of the request and the objects being accessed.
@access(expression)
If needed, you can specify separate rules for queries and mutations (and even specific queries and mutations). We will examine that in the Separating queries and mutations access control section.
A quick example
The typical access control rules look as follows:
@postgres
module BlogModule {
@access(
query = AuthContext.role == "admin" || self.published,
mutation = AuthContext.role == "admin"
)
type Blog {
@pk id: Int = autoIncrement()
title: String
content: String
published: Boolean
publishedOn: LocalDateTime
}
}
Here, we have defined a Blog
type along with access control rules for queries and mutations associated with the type.
Access control expressions for queries act as a gate and filter for existing data. The expressions carry additional semantics for mutations; we will examine them in the Effect on Mutation APIs section.
Consider the above definition along with the following query and mutation:
query {
blogs(where: { publishedOn: { gt: "2022-08-18" } }) {
id
title
content
}
}
mutation {
createBlog(data: { title: "Hello", content: "World" }) {
id
}
}
If you run the above query and mutation, you will get the following result:
Operation | Invoking user | Result |
---|---|---|
Query | Admin | All blogs |
Non-admin | All published blogs | |
Mutation | Admin | Success |
Non-admin | Authorization error |
When an admin user executes the query, the access control expression AuthContext.role == "admin" || self.published
evaluates to true
. Thus, Exograph will not apply any further filtering. Specifically, it will return all blogs, including those not published, as long as they were published after August 18, 2022. Similarly, if an admin user executes the createBlog
mutation, the access control expression evaluates to true
, and the mutation will be allowed.
If a non-admin user makes a query, the access control expression evaluates to a self.published
residue. Exograph will apply that residue to get only published blogs, as if the user had also specified published: {eq: true}
in the where clause, effectively making the where clause as where: {and: [publishedOn: {gt: "2022-08-18"}, {published: {eq: true}]}
. If a non-admin user makes a mutation, the access control expression evaluates to false
, and the mutation will return an authorization error.
Access control will be in effect for all queries and mutations associated with the type--even the nested ones. For example, if we have modeled a User
type with a blogs
field of the Set<Blog>
type, accessing the blogs
field will be subject to the same access control rules as the Blog
type. In other words, no matter how you access a particular type, Exograph prevents unintended access.
We will first examine the primitive elements that form the access control expression. Then, we will discuss combining these elements to form access expressions.
Primitive Elements
An access control expression can use context objects, literals, and the special self
object to refer to the accessed object. It can then combine these using relational and logical operations. So, let's dive a bit deeper into these elements.
Context Objects
Context objects model certain aspects of the incoming request, such as the user's role, IP address, or other information. Let's consider an exo file with the following contexts:
context AuthContext {
@jwt role: String
}
context IPContext {
@clientIp ip: String
}
context CaptchaContext {
@query("verifyCaptcha") valid: Boolean
}
Now, you can use AuthContext.role
to refer to the user's role, IPContext.ip
to refer to the user's IP address, and CaptchaContext.valid
to refer to a captcha's validity.
Literals
Literals specify a value directly in expressions such as true
, false
, 1
, and "hello"
.
The self
object
Sometimes, an access control expression must refer to the accessed object. For example, you may want to allow access to a blog only if the blog is published. In this case, you can use the special self
. For example, in the example above, you can use self.published
to refer to the blog's published
field. This is, of course, valid only while defining access control for the Blog
type.
Using these elements, you can express rules to control access to the object.
Relational and Logical Operations
You can combine these elements using relational and logical operations. Let's take a look at these operations.
Relational Operations
To compare numeric values, you can use the relational operators: ==
, !=
, <
, <=
, >
, and >=
. For example, you can use AuthContext.id == 100
to check if the user's id is 100 and self.price > 100
to check if the price of the accessed object is greater than 100.
You may use ==
and !=
to compare boolean values. For example, you can use CaptchaContext.isValid == true
to check captcha validity. However, usually, you will use the value as is or its negation. For example, you may write the earlier check simply as CaptchaContext.isValid
. Similarly, you can use !CaptchaContext.isValid
to check if the captcha is invalid.
You can use ==
and !=
to compare strings. For example, you can use AuthContext.role == "admin"
to check if the user's role is "admin".
You can use in
to check if the value is in a set of values. This works for any type. For example, you can use AuthContext.role in ["admin", "manager"]
to check if the user's role is either "admin" or "manager".
Logical Operations
You can combine expressions with the logical operators &&
, ||
, and !
. For example, you can use AuthContext.role == "admin" || AuthContext.role == "manager"
to check if the user's role is either "admin" or "manager". Similarly, you can useEnvContext.isDevelopment && CaptchaContext.isValid
to ascertain that the captcha has been validated and that the app is in development mode.
You may use parentheses to group expressions. For example, you can use (AuthContext.role == "admin" || AuthContext.role == "manager") && CaptchaContext.isValid
to check if the user's role is either "admin" or "manager" and that the captcha is valid.
Examples
Equipped with the above elements, you can now form more access control expressions. Let's take a look at some examples.
Using literals
The simplest access control expression is literal. For example, the following expression will allow access to all users:
@access(true)
While the following expression will deny access to all users:
@access(false)
The default access control expression is false
. This secure-by-default approach forces you to think about access control for each type explicitly.
When you use access(false)
for a mutation, it has the effect of removing that API. For more details, please see the Effect on Mutation APIs section.
Using context objects
You can use context objects to express rules based on the user's role. For example, the following expression will allow access to all users with the "admin" role:
context AuthContext {
role: String
}
@access(AuthContext.role == "admin")
If the context contained roles
instead of a single role
, you could use the in
operator to check if the user has a particular role:
context AuthContext {
roles: Array<String>
}
@access("admin" in AuthContext.roles)