• Tutorials
  • End-to-End Example

End-to-End Example

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 manages accounts, organizations, and repositories and a secondary Node.js service that manages GitCloud Jobs, 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 "read" the Repository: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 Patterns 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 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
from oso_cloud import Oso
oso = Oso(api_key=getenv("OSO_AUTH"))
// In the Node.js service
const { Oso } = require("oso-cloud");
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 few helpers for calling Oso Cloud's authorize, list, and actions enforcement APIs. This will avoid some repetitiveness across the various controllers we have to update:

def authorize(action: str, resource: Any) -> bool:
    actor = current_user()
    resource = object_to_oso_value(resource)
    return oso.authorize(actor, action, resource)
 
def list_resources(action: str, resource_type: str) -> List[str]:
    if g.current_user is None:
        return []
    return oso.list(
        current_user(),
        action,
        resource_type
    )
 
def actions(resource: Any) -> List[str]:
    if g.current_user is None:
        return []
    actor = current_user()
    resource = object_to_oso_value(resource)
    return oso.actions(actor, resource)

We'll talk about how to use these helpers in a bit.

We'll also define a few helpers for converting our domain objects into the oso_cloud.Value objects that Oso Cloud accepts:

def current_user() -> oso_cloud.Value:
    if g.current_user is None:
        raise Unauthorized
    return object_to_oso_value(g.current_user)
 
def object_to_oso_value(obj: Any) -> oso_cloud.Value:
    if isinstance(obj, dict):
        return {"type": obj["type"], "id": str(obj["id"])} # make sure ids are stringified
    elif isinstance(obj, User):
        return {"type": "User", "id": str(obj.username)}
    else:
        return {"type": obj.__class__.__name__, "id": str(obj.id)}

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(_, _, _);" > authorization.polar
oso-cloud policy authorization.polar

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

echo "" > authorization.polar
oso-cloud policy authorization.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 Jobs 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 organization:

def show(org_id):
    if not authorize("read", {"type": "Organization", "id": org_id}):
        raise NotFound
    org = g.session.get_or_404(Organization, id=org_id)
    return org.as_json()

We ask Oso Cloud if the current user is allowed to "read" the organization in question. We'll use the same pattern— use Oso Cloud's authorize() API to check if the current user can perform a certain action on a resource, then load it from the database— 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 repository 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 "Organization" type instead of a concrete {"type": "Organization", "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 = list_resources("read", "Organization")
    orgs = (
        g.session.query(Organization)
        .filter(Organization.id.in_(authorized_ids))
        .order_by(Organization.id)
    )
    return jsonify([o.as_json() for o in orgs])

We ask Oso Cloud which organizations the current user can "read" 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 typically better UX than letting the user click the button only to encounter a blaring red failure notification.

For example, the GitCloud UI includes a page that lets repository administrators add and remove users and assign roles. Users who aren't authorized to see this page shouldn't see any links to it.

To efficiently fetch the set of actions that the current user is allowed to perform on a repository, we can use Oso Cloud's actions() API. Here's the updated handler for viewing a specific repository:

def show(org_id, repo_id):
    if not authorize("read", {"type": "Repository", "id": repo_id}):
        raise NotFound
    repo = g.session.get_or_404(Repository, id=repo_id, org_id=org_id)
    json = repo.as_json()
    json["permissions"] = actions(repo)
    return json

The above call to the actions() API will return a list of actions that the current user is allowed to perform on the repo repository. We include these in the JSON response, so that our frontend can check for the "manage_members" permission and show or hide the link to the admin page as necessary.

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 Patterns, 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 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 Patterns guide and adapt it to our needs:

authorization.polar
actor User {}
 
resource Repository {
  roles = ["reader"];
  permissions = ["read"];
 
  "read" if "reader";
}

The above policy says that actors with the "reader" role on a particular repository can "read" 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, jobs, 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 jobs. 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 Organization and Repository 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 Patterns guide and adapt it to our needs:

authorization.polar
actor User {}
 
resource Organization {
  roles = ["member"];
  permissions = ["read", "create_repositories"];
 
  "create_repositories" if "member";
  "read" if "member";
}
 
resource Repository {
  roles = ["reader"];
  permissions = ["read"];
  relations = { organization: Organization };
 
  "read" if "reader";
 
  "reader" if "member" on "organization";
}

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 Patterns guide, this is the Sharing pattern. As previously done for RBAC and Resource Hierarchies, we're going to copy and adapt the example code:

authorization.polar
actor User {}
 
resource Organization {
  roles = ["member"];
  permissions = ["read", "create_repositories"];
 
  "create_repositories" if "member";
  "read" if "member";
}
 
resource Repository {
  roles = [
    "reader",
    "admin",
  ];
  permissions = [
    "read",
    "manage_members",
  ];
  relations = { organization: Organization };
 
  "read" if "reader";
 
  "manage_members" if "admin";
 
  "reader" if "member" on "organization";
}

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 Patterns guide, the easiest way to represent ownership is with a role on a resource, so let's do just that:

authorization.polar
actor User {}
 
resource Organization {
  roles = ["member"];
  permissions = ["read", "create_repositories"];
 
  "create_repositories" if "member";
  "read" if "member";
}
 
resource Repository {
  roles = [
    "reader",
    "admin",
  ];
  permissions = [
    "read",
    "manage_members",
  ];
  relations = { organization: Organization };
 
  "read" if "reader";
 
  "manage_members" if "admin";
 
  "reader" if "member" on "organization";
}
 
resource Issue {
  roles = ["creator"];
  permissions = ["read", "close"];
  relations = { repository: Repository };
 
  "close" if "creator";
 
  "read" if "reader" on "repository";
}

Pushing the policy to Oso Cloud

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

oso-cloud policy authorization.polar

There are plenty of other patterns that apply to GitCloud. In fact, all of the patterns in the Modeling Patterns 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 Patterns 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 "admin" role on Repository:foo) and relationships (Organization:acme is the "organization" of Repository:foo) to Oso Cloud so that we can ask it questions like "is User:alice allowed to "read" Repository:foo?".

In most cases, when we create a new resource in GitCloud, such as a new repository or organization, we have to tell Oso Cloud about role assignments and relationships pertaining to the new resource. For example, when a user creates a new repository, we need to tell Oso Cloud that the user has the "admin" role on that repository and that there exists an "organization" relationship between the repository and its parent organization. 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, "admin", Repository:foo). Similarly, relationships are represented as has_relation facts of the form has_relation(related_resource, relation, resource), e.g., has_relation(Repository:foo, "organization", Organization:acme).

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

def create(org_id):
    org_value = {"type": "Organization", "id": str(org_id)}
    if not authorize("read", org_value):
        raise NotFound
    if not authorize("create_repositories", org_value):
        raise Forbidden("you do not have permission to create repositories")
 
    payload = request.get_json(force=True)
 
    repo = Repository(name=payload["name"], org_id=org_id)
    g.session.add(repo)
    g.session.commit()
 
    repo_value = {"type": "Repository", "id": str(repo.id)}
    oso.bulk_tell([
      { "name": "has_relation", "args": [repo_value, "organization", org_value] },
      { "name": "has_role",     "args": [current_user(), "admin", repo_value] }
    ])
    return repo.as_json(), 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 is removed from an organization, we need to delete any role assignments in Oso Cloud. We could list and delete facts one-by-one, but instead we'll use Oso Cloud's bulk() API, which supports deleting many facts at once using wildcard arguments:

def org_delete(org_id):
    payload = request.get_json(force=True)
    org_value = {"type": "Organization", "id": str(org_id)}
    permissions = actions(org_value)
    if not "read" in permissions:
        raise NotFound
    elif not "manage_members" in permissions:
        raise Forbidden
 
    org = g.session.get_or_404(Organization, id=org_id)
 
    user = {"type": "User", "id": payload["username"]}
    oso.bulk(delete=[{"name": "has_role", "args": [user, None, org_value]})
 
    return {}, 204

In the bulk() API, the None argument acts as a wildcard, instructing Oso Cloud to delete any roles the user may have on the given organization.

We're using the bulk() API because the user may have several roles to clean up, but if we only needed to delete a single fact, we could use the delete() API instead. Additionally, if you don't need to use wildcards, you can delete many concrete facts at once with the bulk_delete() API.

It's also worth noting that the bulk() API can perform updates in addition to deletes. This is extra handy when writing syncing code.

Using context facts

What if we don't want to store every piece of authorization-relevant data in Oso Cloud? For example, the main GitCloud service is the only service that knows about issues, and there are many more issues than repositories or organizations. Do we have to store issue-related facts in Oso Cloud?

In cases like this, we can take advantage of context facts, which are considered only for the duration of a single request. Rather than using the tell() and delete() APIs to sync data about issues to Oso Cloud, we can pass context facts into any of the authorization APIs, including authorize() and list().

Let's update our authorize() helper function to fetch authorization-relevant data about issues from our database and pass it along as context facts:

def authorize(action: str, resource: Any) -> bool:
    actor = current_user()
    resource = object_to_oso_value(resource)
    context_facts = []
    if resource["type"] == "Issue":
        context_facts = get_facts_for_issue(resource["id"])
    res = oso.authorize(actor, action, resource, context_facts)
    return res
 
def get_facts_for_issue(issue_id):
    issue = g.session.get_or_404(Issue, id=issue_id)
 
    issue_value = {"type": "Issue", "id": str(issue.id)}
 
    has_parent = {
        "name": "has_relation",
        "args": [
          issue_value,
          "repository",
          {"type": "Repository", "id": str(issue.repo_id)}
        ],
    }
    creator = {
        "name": "has_role",
        "args": [
            {"type": "User", "id": str(issue.creator_id)},
            "creator",
            issue_value,
        ],
    }
    facts = [has_parent, creator]
 
    return facts

Now we can call authorize("close", issue) in a handler to ensure that the current user is authorized to "close" a given issue. Our helper will pass along two critical has_role and has_relation facts so that Oso Cloud can make the correct authorization decision.

Context facts are powerful, but they're better suited for certain situations than others. Be sure to read more about context facts before using them in your application.

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 Patterns 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(), delete(), and bulk() 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 Patterns reference.

Talk to an Oso Engineer

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

Get started with Oso Cloud →