Aserto on Aserto: an OPA authorization policy for Aserto tenants
Using your system to build your system, or more colloquially, “eating your own dogfood”, is an age-old practice for validating that a system is capable of supporting a serious use-case, and that its designers would trust their system enough to bet their entire endeavor on it.
So it's not exactly shocking that we’ve built Aserto’s authorization system using Aserto :) We call this “Aserto on Aserto”, or AonA for short.
Using a general-purpose policy language like Rego provides us many design choices: we start with a set of permissions, assign those permissions to a set of roles, and author policies that allow assigning permissions either globally as well as for specific resources (which in our world are Aserto tenants).
Permissions
Aserto’s back-end services are all written in Go and their APIs are all defined using protobuf messages. Each of these APIs has an associated permission that is named after its corresponding message identifier.
We define these permissions in a data file - perms/data.json
- and they are mapped into the policy as data.perms.perms
. A few permissions are listed below:
{
"perms": {
"aserto.common.info.v1.Config.Get": {
"description": "aserto.common.info.v1.Config.Get"
},
"aserto.common.info.v1.Info.Info": {
"description": "aserto.common.info.v1.Info.Info"
},
"aserto.tenant.account.v1.Account.GetAccount": {
"description": "aserto.tenant.account.v1.Account.GetAccount"
},
"aserto.tenant.account.v1.Account.ListInvites": {
"description": "aserto.tenant.account.v1.Account.ListInvites"
},
"aserto.tenant.account.v1.Account.UpdateAccount": {
"description": "aserto.tenant.account.v1.Account.UpdateAccount"
},
"aserto.tenant.connection.v1.Connection.CreateConnection": {
"description": "aserto.tenant.connection.v1.Connection.CreateConnection"
},
...
}
}
Roles
We define five roles in our system:
- tenant_viewer: read-only access to tenant data
- tenant_member: read-write access to tenant data
- tenant_admin: all of tenant_member, plus the ability to administer other users
- tenant_owner: all of tenant_admin, plus the ability to invite users, and the restriction that there must always be at least one owner for the tenant
- sys-admin: access to all APIs - whether they are tenant-scoped or system APIs
These roles are defined in a data file - roles/data.json
- and they are mapped into the system as data.roles.roles
. Each role contains a map of the default values for the associated permissions.
Here is an example of a few default permissions for the tenant_viewer role:
"tenant_viewer": {
"description": "tenant viewer",
"perms": {
"aserto.common.info.v1.Config.Get": {
"allowed": false
},
"aserto.common.info.v1.Info.Info": {
"allowed": true
},
"aserto.tenant.account.v1.Account.GetAccount": {
"allowed": true
},
"aserto.tenant.account.v1.Account.ListInvites": {
"allowed": true
},
"aserto.tenant.account.v1.Account.UpdateAccount": {
"allowed": false
},
"aserto.tenant.connection.v1.Connection.CreateConnection": {
"allowed": false
},
...
}
}
Policies
Finally, each permission has a policy file under the aserto
policy root, which defines a package named after the permission. This is where most of the action happens. Here are some examples of policies, starting from the simple and getting into the more sophisticated.
Information APIs
The simplest policies are for APIs that provide general information, and are therefore anonymous (don’t require a user context and don’t have any access control). The policy for the Info
API simply returns a true
value for the allowed
decision, with no conditions:
package aserto.common.info.v1.Info.Info
default allowed = true
Global roles
Most of our policies are written to look up the user’s role(s) under the user’s attribute set. In Rego, the expression
some i
user.attributes.roles[i]
will evaluate the decision over all the possible roles. For example, if the user’s attributes include both the tenant_viewer and sys-admin roles, the policy will evaluate the expression over both.
We then index into the data we mapped from roles/data.json
using the user’s roles, index further into the specific permission that we are looking for, and evaluate the decision to the value of the allowed
attribute that is defined in the data file.
For example, here is the policy for the DeleteTenant
operation. In the roles/data.json
file, only the sys-admin role defines the allowed
attribute to true
; the rest of the roles default this to false
.
package aserto.tenant.system.v1.System.DeleteTenant
import input.user
import input.policy.path
default allowed = false
# global role
allowed {
not user.enabled != true
some i
data.roles.roles[user.attributes.roles[i]].perms[path].allowed
}
Let's take the policy step by step:
- First, we import the
input.user
andinput.policy.path
fields of the input payload, to make them available asuser
andpath
, respectively.user
contains all the user attributes, including the roles the user is a member of, available asuser.attributes.roles[]
.path
is set to the policy we are evaluating (in this case,aserto.tenant.system.v1.System.DeleteTenant
). - Then we makes sure that the
user.enabled
flag isn’t set tofalse
(if it is, theallowed
decision evaluates tofalse
) data.roles.roles[user.attributes.roles[i]]
evaluates to therole
object for each role the user belongs to, and.perms[path]
will index into theperms
associated with that role, and fish out the object corresponding to our permission (aserto.tenant.system.v1.System.DeleteTenant
).- Finally, we return the value of the
allowed
attribute and make that the result of theallowed
decision.
In this way, we can use this general policy form for every policy that needs to evaluate a global (user-scoped) role, merely changing the permission that we are indexing (in this case, aserto.tenant.system.v1.System.DeleteTenant
). Should we want to assign this permission to other roles, we can easily do so by modifying the roles/data.json
file.
Tenant-specific roles
Most operations that users perform in Aserto are done in the context of a tenant. For example, you can create connections to identity providers or source-code control systems in Aserto if you are in the tenant_member role (or higher).
In the CreateConnection
policy below, you’ll see that we have a rule for the allowed decision that is based on the global role that the user is in (user.attributes.roles
), just like in the DeleteTenant
policy. But in this policy, we have an additional rule for the tenant context. In addition to input.user
and input.policy.path
, we “fish out” two other input values:
input.resource[“Aserto-Tenant-Id”]
: the ID of the Aserto tenant we’re trying to make a decision for, which is given to us as part of the resource context, which we bind to thet
variableuser.applications[t]
: the value of the user’s “application block” for that tenant ID. The application block is a map of data (like roles and permissions) stored for a user, usually keyed on some form of identifier; in Aserto’s case, we key on the Aserto tenant ID.
The second allowed
block will then perform the exact same lookup in the roles data for the CreateConnection
permission, based on the role that this user has been granted on this specific tenant.
package aserto.tenant.connection.v1.Connection.CreateConnection
import input.user
import input.policy.path
default allowed = false
# global role
allowed {
not user.enabled != true
some i
data.roles.roles[user.attributes.roles[i]].perms[path].allowed
}
# tenant context role
allowed {
not user.enabled != true
t = input.resource["Aserto-Tenant-Id"]
a = user.applications[t]
some i
data.roles.roles[a.roles[i]].perms[path].allowed
}
In this way, it’s possible for users to have different roles (and different permissions) for different tenants, and for the same set of policies to allow the concept of a “global role” regardless of tenant.
Policies based on more than one identity
We’d like to allow members of a tenant to see the attributes of other users in the same tenant: for example, in the Aserto console, a tenant_viewer should be able to see the name and email address of all the members and owners of that tenant. This is what the GetUser
policy accomplishes.
We start with the same allowed
block for the global role, and then follow with two more specific rules.
In the first one, we look up the current user (user.id
) and the target user (input.resource[“id”]
), and allow the operation if the two values are identical. This allows a user to retrieve their own user information using the GetUser
API.
package aserto.authorizer.directory.v1.Directory.GetUser
import input.user
import input.policy.path
default allowed = false
# global role
allowed {
not user.enabled != true
some i
data.roles.roles[user.attributes.roles[i]].perms[path].allowed
}
# allow reading your own user
allowed {
not user.enabled != true
targetID = input.resource["id"]
user.id == targetID
}
# allow reading co-members of tenants
allowed {
not user.enabled != true
targetID = input.resource["id"]
targetUser = dir.user(targetID)
some i, j
user.applications[i]
targetUser.applications[j]
i == j
}
The final allowed block will map the caller and target user, and look up every tenant ID that the caller and target user are in. If the two users share at least one tenant, this rule will evaluate the allowed
decision to true
.
Driving UI behavior using the visible
and enabled
decisions
Some policies in Aserto have two additional decisions - visible
and enabled
- that help the Aserto console UI render its state based on the permissions that a user has. For example, a tenant_owner can invite other members to a tenant, but a tenant_viewer or tenant_member cannot. Therefore, we’d like the UI to render the “Invite member” button as disabled for users that are in these roles.
The following policy for the InviteUser
API does exactly that: it defines a visible
decision that is always true
(so the button is always visible), but defines the enabled
decision to evaluate to true
if and only if there is a role that allows it, or if the user has the tenant_owner role on this particular tenant. The Aserto console uses the output of the enabled
decision to control the enabled state for the "Invite user" button, so only users that are allowed to execute the InviteUser
operation can click the button. This way, we eliminate the suboptimal user experience of rendering UI elements that will always return a “not allowed” error for the current user.
package aserto.tenant.profile.v1.Profile.InviteUser
import input.user
import input.policy.path
default allowed = false
default visible = true
default enabled = false
# global role
allowed {
not user.enabled != true
some i
data.roles.roles[user.attributes.roles[i]].perms[path].allowed
}
# tenant context role
allowed {
not user.enabled != true
t = input.resource["Aserto-Tenant-Id"]
a = user.applications[t]
some i
data.roles.roles[a.roles[i]].perms[path].allowed
}
enabled {
t = input.resource["Aserto-Tenant-Id"]
a = user.applications[t]
some i
a.roles[i] == "tenant_owner"
}
Summary
We’ve seen a few examples of policies that enforce global roles, per-tenant roles, operations that compare values between the logged-in user and a target user, and how to drive the conditional rendering of UI based on the same policy that enforces access to APIs.
To review the full Aserto-on-Aserto (AonA) policy, check out the public github repo for it.
Happy hacking!
Related Content
Composing OPA solutions
Building awesome apps with OPA just got easier.
Sep 29th, 2021
Introducing the Open Policy Registry (OPCR) project
A Docker-inspired workflow for OPA policies, now available at openpolicyregistry.io!
Oct 12th, 2021
Handling data in OPA policies
Passing data into the decision engine is a critical design choice for a robust authorization system. Here are four common patterns, each with their own tradeoffs.
Oct 27th, 2021