Get Started
Securing a Real World App with Oso Cloud

Securing a Real World App with Oso Cloud

In this tutorial, we'll cover the three main pieces involved in adding authorization to a real world app with Oso Cloud: enforcement, modeling, and data management. This guide is for you if you've gone through the quickstart and want to see how Oso Cloud fits into a more realistic app.

The app we'll use in this guide is GitCloud, a GitHub/GitLab-like app that comprises a primary Python service that managing accounts, organizations, and repositories and a secondary Node.js service that manages GitCloud Actions, a CI/CD offering. (Note: no actual CIs or CDs were harmed in the making of this example app — the computer only pretends to go brrrr.)

First, we'll use Oso Cloud's enforcement APIs to answer authorization queries like "is User:alice allowed to "view" the Repo:foo repository?" and to efficiently authorize collections of resources. Then, we'll learn how to model an authorization policy for Oso Cloud using the common authorization building blocks and patterns in the Modeling Building Blocks guide. Finally, we'll see how to keep authorization data up to date in Oso Cloud via its data management APIs.

Initial setup

To get our app's Python & Node.js services to start talking to Oso Cloud, we're going to install the respective client library for each language.

# In the Python project
pip install oso-cloud

# In the Node.js project
yarn add oso-cloud

The clients are stateless, so we can initialize a global singleton in each service to avoid repeating the API key everywhere:

# In the Python service
oso = Oso(api_key=getenv("OSO_AUTH"))
// In the Node.js service
const oso = new Oso("https://cloud.osohq.com/", process.env["OSO_AUTH"]);

In the Python service, we'll go a step further and create a pair of helpers for calling Oso Cloud's authorize and list enforcement APIs. This will avoid some repetitiveness across the various controllers we have to update:

def authorize(action: str, resource: Any) -> bool:
    if current_user is None:
        raise NotFound
    return oso.authorize(current_user, action, resource)

def authorized_resources(action: str, resource_type: str) -> List[str]:
    if current_user is None:
        return []
    return oso.list(current_user, action, resource_type)

With setup complete, it's time to start adding authorization checks to our app.

Enforcement

We're going to start by enforcing authorization across both services. Enforcement is what the app does with the authorization decision it gets back from Oso Cloud. For example, enforcement might be returning a 403 Forbidden response instead of a 200 OK or only displaying an authorized subset of resources to the user. We're doing this first so that when we start modeling we'll be able to play around with the application to see everything working. Additionally, writing the enforcement checks first will help us understand the set of permissions we need to declare for each of our app's resources.

Oso Cloud's default (empty) policy denies everything. As you add enforcement logic to an application, it's a good idea to flip between "allow everything" and "deny everything" policies to test that your authorization API calls are hooked up correctly. Here's how to push an "allow everything" policy to Oso Cloud via the CLI:

echo "allow(_, _, _);" > policy.polar
oso-cloud policy policy.polar

And an empty policy that will result in every authorization request being denied:

echo "" > policy.polar
oso-cloud policy policy.polar

With the "allow everything" policy, every request for an access controlled resource should have a 2XX status (200, 201, etc). With the "deny everything" policy, every request should have a 4XX status (403, 404, etc).

Authorizing an action on a specific resource

Most common operations in GitCloud boil down to a user trying to perform an action on a specific resource. For example, viewing a repository, canceling a GitCloud Action run, updating an organization, etc. For all of these scenarios, we add enforcement by calling Oso Cloud's authorize() API, which checks if an actor is allowed to perform an action on a specific resource.

For a concrete example, here's the updated handler for viewing a specific repository:

def show(repo_id: int):
    repo = session.get(Repo, repo_id)
    if not authorize("view", repo):
        raise NotFound
    return repo.repr()

We ask Oso Cloud if the current user is allowed to "view" the repo in question. We'll use the same pattern — fetch the resource, then use Oso Cloud's authorize API to check if the current user can perform a certain action on it — for most of the endpoints across both services.

Note that we chose to raise a NotFound error instead of Forbidden. This ensures that someone who doesn't have access to the repo cannot tell if it exists.

Authorizing an action on a collection of resources

The second most common operation in GitCloud is the "index" endpoint that displays a collection of resources, such as organizations that a user belongs to or issues that have been opened on a particular repository. The naïve way to add enforcement to an index endpoint is to loop over the collection of resources loaded from the database and hit Oso Cloud's authorize() API for each one. But Oso Cloud has a super power that can wrap those N authorization checks into a single efficient query: the list() API. While authorize() poses the question "can actor perform action on resource?", list() asks "for which resources of type resource_type can actor perform action?"

For a deeper dive into the list() endpoint, head over to the data filtering guide.

Oso Cloud's list() API looks similar to authorize() except that it takes a resource type, e.g., the "Org" type instead of a concrete Org{id: 1} instance. As an example of adding enforcement for a collection of resources, here's the updated index handler for organizations:

def index():
    authorized_ids = authorized_resources("view", "Org")
    orgs = session.query(Org).filter(In(authorized_ids))
    return jsonify([o.repr() for o in orgs])

We ask Oso Cloud which organizations the current user can "view" and then use the returned collection of IDs to load organization objects from the service database. This pattern — fetching a collection of authorized IDs from Oso Cloud's list() API and then loading those resources from the database — applies to any endpoint that deals with a collection of resources, such as the various index endpoints across both services.

Conditional UI elements

The final common enforcement pattern that we'll cover in this tutorial is conditionally displaying UI elements based on whether the current user is authorized to perform a particular action. For example, graying out (or hiding completely) a button if the user isn't allowed to press it. This is often seen as better UX than letting the user click the button only to encounter a blaring red failure notification.

GitCloud has only recently soft-launched its self-hosted public beta, so it doesn't yet ship with admin tooling out of the box. But imagine for a moment that when an admin navigates to a user's profile they should be presented with a series of crescendoing buttons: Impersonate, Lock Account, and Ban Hammer. To efficiently fetch the set of actions that the current user is allowed to perform on another user account, we can use Oso Cloud's actions() API as follows:

def user_profile_actions(user_id: int):
    user_profile = session.get(User, user_id)
    return oso.actions(current_user, user_profile)

The above call to the actions() API will return a list of actions that the current user is allowed to perform on the user_profile account. For an admin, the returned list would look something like ["impersonate", "lock", "ban"], and for a regular user it'd be []. We could hit this handler from our front end to conditionally show or hide the relevant buttons in the UI.

Modeling

Now that we have enforcement set up across our services, it's time to write our policy and push it to Oso Cloud. If we were following best practices and deploying GitCloud to production, we'd keep the policy file in version control and gate changes to the deployed production policy behind a rigorous, automated CI/CD process including a final manual approval step before policy changes go live. For GitCloud's current soft-launched, self-hosted public beta, however, we're going to push policy changes via Oso Cloud's CLI.

Before we have anything to push, we need to figure out what our policy should look like. We're going to use Oso Cloud's Modeling Building Blocks, a collection of common authorization schemes (and compositions thereof). And by "use" we of course mean "copy the relevant policy examples and adapt them to our needs."

Standard RBAC

The core of our policy will be bog-standard RBAC, where a user can perform an action on a resource if they have a role for that resource. We'll copy the example in the Roles section of the Modeling Building Blocks guide and adapt it to our needs:

resource Repo {
  roles = ["reader"];
  permissions = ["view"];

  "view" if "reader";
}

The above policy says that actors with the "reader" role on a particular repository can "view" that repository. As we add additional roles and permissions governing access to repositories, we'll add them to the resource block and declare new RBAC rules in the <permission> if <role>; form. That's all it takes to write a standard RBAC policy in Polar, and we'll employ the exact same pattern to fill out the base policy for the other access-controlled types in our app: organizations, issues, actions, and users.

Resource Hierarchies / Multitenancy

There are numerous instances of hierarchically related resources in GitCloud's domain model, such as an organization having many repositories and a repository having many issues and actions. In all of these cases, it's reasonable to have access flow from parent resources to child resources. If a user has the "member" role on the ACME organization, they should have the same access to one of ACME's repositories as a user with the "reader" role on that specific repository. The naïve way to accomplish this would be to grant each "member" an explicit "reader" role on every single repository in the ACME org. Instead of managing all of those extra roles, we can define a relationship between the Org and Repo types and declare that an actor with the "member" role on a repository's parent organization should be able to do anything a "reader" can do on the repository. Once again, we'll copy the example code from the Resource Hierarchies section of the Modeling Building Blocks guide and adapt it to our needs:

resource Org {
  roles = ["member"];
}

resource Repo {
  roles = ["reader"];
  permissions = ["view"];
  relations = { parent: Org };

  "view" if "reader";

  "reader" if "member" on "parent";
}

Sharing

We want to allow users to invite others to organizations and repositories and then manage the roles of users who have access to a particular organization or repository.

Consulting the trusty Modeling Building Blocks guide, this is the Sharing pattern. As previously done for RBAC and Resource Hierarchies, we're going to copy and adapt the example code:

resource Repo {
  roles = [
    "reader",
    "admin",
  ];
  permissions = [
    "view",
    "create_role_assignment",
    "delete_role_assignment",
  ];
  relations = { parent: Org };

  "view" if "reader";

  "create_role_assignment" if "admin";
  "delete_role_assignment" if "admin";

  "reader" if "member" on "parent";
}

Ownership

The final pattern we'll implement for GitCloud is ownership. There are a few instances of this pattern across the GitCloud app, but here we'll focus on closing issues. A user should be able to close any issues they create. Looking at the Ownership pattern in the Modeling Building Blocks guide, the easiest way to represent ownership is by creating a new role, so let's do just that:

resource Issue {
  roles = ["creator"];
  permissions = ["view", "close"];
  relations = { parent: Repo };

  "close" if "creator";

  "view" if "reader" on "parent";
}

Pushing the Policy to Oso Cloud

Finally, we're ready to push our initial policy, saved as policy.polar, with the Oso Cloud CLI:

oso-cloud policy policy.polar

There are plenty of other patterns that apply to GitCloud. In fact, all of the patterns in the Modeling Building Blocks guide use GitCloud as an example. If you're looking for a challenge or to improve your authorization chops, try cloning the GitCloud repo and implementing a new pattern from the Modeling Building Blocks guide.

Data Management

The final step of implementing authorization with Oso Cloud is to start pushing authorization data into Oso Cloud's optimized data store. In the modeling step, we defined the abstract authorization policy for GitCloud, and now we need to start pushing concrete role assignments (User:alice has the "member" role on Org:acme) and relationships (Org:acme is the "parent" of Repo:foo) to Oso Cloud so that we can ask it questions like "is User:alice allowed to "view" the Repo:foo repository?".

Any time we create a new resource in GitCloud, such as a new repository or issue, we have to tell Oso Cloud about role assignments and relationships pertaining to the new resource. For example, when a user creates a new issue, we need to tell Oso Cloud that the user has the "creator" role on that issue and that there exists a "parent" relationship between the issue and its parent repository. In Oso Cloud, role assignments are persisted as has_role facts of the form has_role(actor, role, resource), e.g., has_role(User:alice, "member", Org:acme). Similarly, relationships are represented as has_relation facts of the form has_relation(related_resource, relation, resource), e.g., has_relation(Org:acme, "parent", Repo:foo).

To persist the pair of new facts to Oso Cloud when creating a new issue, we'll use the Python client's bulk_tell() API to send both facts in the same request:

def create(org_id: int, repo_id: int):
    repo = session.get(Repo, repo_id)
    if not authorize("create_issues", repo):
        raise Forbidden
    issue = Issue(title=payload["title"])
    session.save(issue)
    oso.bulk_tell(
        [
            ["has_role", current_user, "creator", issue],
            ["has_relation", issue, "parent", repo],
        ]
    )
    return issue.repr(), 201

If we only needed to create a single fact, we could use the tell() API.

There are also places where we'll need to update or delete facts from Oso Cloud in order to keep GitCloud's authorization data up-to-date. For example, when a user's role for an organization is revoked, we need to relay that deletion to Oso Cloud:

def delete_org_role(org_id: int):
    org = session.get(Org, org_id)
    if not authorize("delete_role_assignment", org):
        raise Forbidden
    user = session.get(User, payload["user_id"])
    oso.delete("has_role", user, payload["role"], org)
    return {}, 204

We're using the delete() API because the user only has a single role to clean up, but if we needed to delete multiple facts at once the bulk_delete() API is at our disposal.

Summary

In this tutorial, we covered the three main pieces involved in adding authorization to a real world app with Oso Cloud: enforcement, modeling, and data management. We used Oso Cloud's authorize() and list() APIs to answer authorization questions posed about specific resources and collections of resources, respectively. We learned how to lean on the Modeling Building Blocks to help us model common authorization patterns in Oso Cloud. And we saw how to keep authorization data up to date in Oso Cloud via the tell() and delete() data management APIs.

For next steps, if you're interested in securing your own app with Oso Cloud, head over to the Add Oso Cloud to Your App guide. If you want to play around with GitCloud, clone the repo and try implementing some new authorization patterns from the Modeling Building Blocks reference.

Talk to an Oso Engineer

Our team is happy to help you get started with Oso. If you'd like to learn more about using Oso in your app or have any questions about this guide, schedule a 1x1 with an Oso engineer.

Get started with Oso Cloud →