Aserto on Aserto: an OPA authorization policy for Aserto tenants

Sep 25th, 2021

Omri Gazitt avatar

Omri Gazitt

Open Policy Agent  |  

Engineering

Rego language listing

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 and input.policy.path fields of the input payload, to make them available as user and path, respectively. user contains all the user attributes, including the roles the user is a member of, available as user.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 to false (if it is, the allowed decision evaluates to false)
  • data.roles.roles[user.attributes.roles[i]] evaluates to the role object for each role the user belongs to, and .perms[path] will index into the perms 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 the allowed 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 the t variable
  • user.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!

Omri Gazitt avatar

Omri Gazitt

CEO, Aserto