GitCloud: 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 (opens in a new tab), 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 projectpip install oso-cloud# In the Node.js projectyarn 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, 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.polaroso-cloud policy authorization.polar
And an empty policy that will result in every authorization request being denied:
echo "" > authorization.polaroso-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 list 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 guides of the Modeling guides and adapt it to our needs:
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-Specific Roles
guide and adapt it to our needs:
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.
This is the Sharing pattern. As previously done for RBAC and Resource-Specific Roles, we're going to copy and adapt the example code:
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 guide in the Modeling Patterns guide, the easiest way to represent ownership is with a role on a resource, so let's do just that:
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 modeling guides use GitCloud as an example. If you're looking for a challenge or to improve your authorization chops, try cloning the GitCloud repo (opens in a new tab) and implementing a new pattern from the Modeling guides.
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 resdef 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 guides 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 Enforcement guide. If you want to play around with GitCloud, clone the repo (opens in a new tab) and try implementing some new authorization patterns from the Modeling Patterns reference.
Talk to an Oso engineer
If you'd like to learn more about using Oso Cloud in your app or have any questions about this guide, connect with us on Slack. We're happy to help.