Authentication and Authorization in Microservices: Implementing Complex Authorization with Oso Cloud

microservices security

Explore how microservices can delegate complex authorization decisions to a dedicated service like Oso Cloud. This article, part of a series, details strategies for managing distributed authorization data, contrasting traditional in-service logic with a centralized policy-driven approach using Oso's Polar language and its integration into a microservice architecture.

This article is the fifth in a series exploring authentication and authorization in a microservice architecture. It builds upon previous discussions on implementing JWT-based authorization and using fetch and replicate strategies for distributed authorization data.

In earlier parts, strategies for a service to obtain authorization data from other services were discussed:

  • Provide: Required authorization data is included in the access token.
  • Fetch: The service retrieves authorization data directly from another service, e.g., via its REST API.
  • Replicate: The service maintains a local replica of authorization data, synchronized using events (e.g., the CQRS pattern).

While these strategies address authorization with distributed data, their implementation can burden service developers. For instance, the CQRS pattern necessitates additional code for event publishing, consumption, and replica maintenance. As services and authorization relationships scale, complexity and maintenance costs can rise rapidly. This article explores an alternative: delegating authorization decisions to a dedicated authorization service.

Motivation: Why Use an Authorization Service?

Two primary issues arise when each service implements its own authorization logic.

First, passing authorization data between services can become costly and complex. For example, a Customer Service might need to replicate its data into a Security System Service for authorization decisions. This not only complicates both services but also creates another point where data must be shared and synchronized as new services are introduced.

Second, implementing authorization logic within services is inherently difficult and error-prone. Authorization code often involves intricate conditional logic and database queries joining multiple tables. The complexity of this code mirrors that of the underlying authorization rules, making development and maintenance increasingly challenging as rules become more sophisticated.

Let’s examine how authorization-as-a-service can mitigate these problems.

An Overview of Authorization-as-a-Service

At first glance, authorization-as-a-service may seem similar to what OAuth 2.0 provides. However, OAuth 2.0's "authorization server" issues tokens containing scopes, not fine-grained authorization decisions. It's the resource server (e.g., RealGuardIO application's backend services) that uses these tokens to determine API endpoint access. OAuth does not dictate whether a user can perform a specific action on a specific resource within your system.

Modern authorization services bridge this gap by collaborating with resource servers. A resource server still validates access tokens but delegates fine-grained authorization decisions to the authorization service, rather than relying solely on coarse-grained scopes. The authorization service evaluates policy rules and data (about users, roles, relationships, and resources) to determine if a specific action is permitted. This approach delivers policy-driven, fine-grained control over application behavior, ensuring consistency across all services.

Key features of an authorization service include:

  • A centralized component managing authorization policies and evaluating decisions across multiple applications or microservices.
  • A declarative policy language (e.g., Polar, Rego, Cedar) for defining authorization rules, removing the need to embed them in application code.
  • A mechanism for services to supply "facts" (e.g., role assignments, relationships, attributes) describing the system's current state.
  • A decision API (e.g., isAllowed(user, action, resource)) that services call to check permissions.
  • Consistent and explainable enforcement, ensuring all services adhere to the same policies and data, making authorization decisions auditable and observable.

Several products implement this authorization-as-a-service model, such as Open Policy Agent (OPA) with its Rego language, AWS Verified Permissions using Cedar, and Oso Cloud with its Polar language. This article focuses on Oso Cloud.

A Quick Overview of Oso

Oso is an authorization-as-a-service platform designed to define and enforce access control rules externally to application code. It centralizes authorization policies and data in Oso Cloud, providing APIs for data management and permission checks. Oso also supports a self-hosted mode for environments unable to rely on a cloud service.

Oso policies are written in the Polar language, a declarative, logic-based language tailored for fine-grained authorization. Polar supports Role-Based Access Control (RBAC), Relationship-Based Access Control (ReBAC), and Attribute-Based Access Control (ABAC). Policies concisely express who can perform which actions on which resources.

The following diagram illustrates a high-level view of an application using Oso Cloud:

At a high level:

  • The application uploads its authorization policy to Oso Cloud.
  • It sends facts describing the system's current state, such as role assignments and relationships.
  • When a service requires an authorization decision, it queries Oso Cloud to verify if a user is permitted to perform an action on a resource.

Applications can also request Oso to generate SQL query fragments that enforce policies directly within their databases, an approach to be detailed in a future article.

A Simple Example: Managing Customer Employees

To understand Oso, consider a simple example from the RealGuardIO application. The Customer Service implements createCustomerEmployee(), which creates a new employee for a customer organization. The authorization rule is: A user can create an employee for a customer if and only if they have the ADMIN role within that same customer.

Let’s compare a traditional Java implementation with the Oso version.

Implementing Authorization Checks in Java

Traditionally, a service would query its database to retrieve the logged-in user’s roles for the specified customer and then check for the required role. This logic, typically written in Java, often mixes with business logic, hindering understanding, testing, and maintenance.

For example, an excerpt of the createCustomerEmployee() operation in the Customer Service:

public class CustomerService {

    public CustomerEmployee createCustomerEmployee(Long customerId, PersonDetails personDetails) {
        Customer customer = customerRepository.findRequiredById(customerId);
        customerActionAuthorizer.verifyCanDo(customerId, RolesAndPermissions.CREATE_CUSTOMER_EMPLOYEE);
        // ...
    }
}

This calls authorization logic with the customerID and createCustomerEmployee permission. The authorization logic:

public class LocalCustomerActionAuthorizer {
    public void verifyCanDo(long customerId, String permission) {
        Set<String> requiredRoles = RolesAndPermissions.rolesForPermission(permission);
        String userId = userNameSupplier.getCurrentUserEmail();
        Set<String> currentUserRolesAtCustomer = customerEmployeeRepository.findRolesInCustomer(customerId, userId);

        if (Collections.disjoint(currentUserRolesAtCustomer, requiredRoles)) {
            throw ...;
        }
    }
}

This imperative Java logic performs these steps:

  1. Identifies roles required for the permission.
  2. Finds roles for the logged-in user at the customer.
  3. Throws an exception if the user lacks the required role.

The finder method used to retrieve user roles:

public interface CustomerEmployeeRepository {
    @Query(
        """
        SELECT mr.name
        FROM MemberRole mr, CustomerEmployee ce
        WHERE mr.member.id = ce.memberId
        AND mr.member.emailAddress.email = :employeeUserId
        AND ce.customerId = :customerId
        """
    )
    Set<String> findRolesInCustomer(@Param("customerId") Long customerId, @Param("employeeUserId") String employeeUserId);
}

This query joins multiple tables. Even for a simple policy, the code is non-trivial, mixing authorization with business logic, relying on repository queries, and potentially becoming difficult to test or change as policies evolve.

Implementing Authorization Checks with Oso

With Oso, authorization rules are declaratively written in Polar and evaluated by Oso Cloud. A simple policy for this rule might be:

actor CustomerEmployee {}

resource Customer {
  roles = ["COMPANY_ROLE_ADMIN", ...];
  permissions = ["createCustomerEmployee", ...];

  "createCustomerEmployee" if "COMPANY_ROLE_ADMIN";
}

This policy defines which customer employees can create others. actor CustomerEmployee {} declares CustomerEmployee as an actor type. The resource Customer { … } block defines roles and permissions for that organization. Here, COMPANY_ROLE_ADMIN grants createCustomerEmployee permission, restricting creation to customer organization administrators.

For authorization decisions, Oso requires "facts" about who holds which roles. For instance, the fact has_role(CustomerEmployee{"bob"}, "COMPANY_ROLE_ADMIN", Customer{"acme"}); indicates that Bob, a CustomerEmployee, has the COMPANY_ROLE_ADMIN role for Customer{"acme"}.

Once Oso has necessary facts (e.g., employee-to-customer mappings and roles), the application delegates decisions to Oso Cloud. For example, the query has_permission(CustomerEmployee{"bob"}, "createCustomerEmployee", Customer{"acme"}) asks if Bob can create an employee for Acme. Oso evaluates this against the policy and facts. Since Bob has the COMPANY_ROLE_ADMIN role for Acme, the rule applies, and Oso returns true.

This approach separates authorization logic from application code, enhancing development and maintenance. In an Oso-based implementation, customerActionAuthorizer.verifyCanDo() simply delegates the decision to Oso. The only complexity is that services like Customer Service must populate Oso with facts, typically by publishing events representing role and relationship changes.

This was a straightforward, single-service authorization rule. RealGuardIO also handles more complex scenarios where authorization decisions span multiple services. Let’s explore one such example.

A More Complicated Example: Managing Security Systems

The authorization policy for managing a security system (arming, disarming, etc.) exemplifies a policy spanning multiple services. The Security System Service must verify user authorization using data owned by the Customer Service.

As previously described, a customer employee can disarm a security system at a location if any of the following are true:

  • The employee is assigned the SECURITY_SYSTEM_DISARMER role in the security system’s location.
  • The employee is assigned the SECURITY_SYSTEM_DISARMER role in the company that owns the location.
  • The employee belongs to a team assigned the SECURITY_SYSTEM_DISARMER role in the security system’s location.

Let’s first compare traditional Java implementation with the Oso version for this policy.

Traditional Java Implementation

As discussed in Part 4 of this series, the Security System Service can obtain authorization data from the Customer Service via two methods:

  • Fetch: The Security System Service directly calls the Customer Service (e.g., via a REST API) to look up user roles for the relevant customer or location for each authorized request. Simple, but adds latency and runtime coupling.
  • Replicate: The Security System Service maintains a local copy of authorization data by subscribing to events published by the Customer Service (the CQRS pattern). Avoids runtime coupling but requires additional infrastructure for publishing, consuming, and updating replicated data.

Both approaches work, but as authorization logic grows in complexity (involving multiple relationships like teams, locations, and customers), they introduce significant implementation and maintenance overhead.

Let's now consider the Oso Cloud approach.

Oso Implementation

We start with a Polar policy capturing the first two cases:

  • Employee assigned SECURITY_SYSTEM_DISARMER role in the security system’s location.
  • Employee assigned SECURITY_SYSTEM_DISARMER role in the company owning the location.

In these cases, the action is on a SecuritySystem resource, but authorization depends on roles assigned at related Location or its Customer resources. Oso expresses this through role inheritance based on resource relationships.

Here’s the policy:

resource Customer {
  roles = ["SECURITY_SYSTEM_DISARMER", ...];
  // ...
}

resource Location {
  relations = { customer: Customer };
  roles = ["SECURITY_SYSTEM_DISARMER", ...];

  "SECURITY_SYSTEM_DISARMER" if "SECURITY_SYSTEM_DISARMER" on "customer";
}

resource SecuritySystem {
  relations = { location: Location };
  permissions = ["disarm", ...];

  "disarm" if "SECURITY_SYSTEM_DISARMER" on "location";
}

These policy elements define how the SECURITY_SYSTEM_DISARMER role and disarm permission are inherited:

  • The Customer resource defines the SECURITY_SYSTEM_DISARMER role.
  • The Location resource, related to a Customer via the customer relation, also defines this role. The rule "SECURITY_SYSTEM_DISARMER" if "SECURITY_SYSTEM_DISARMER" on "customer"; means anyone with the SECURITY_SYSTEM_DISARMER role on a Customer automatically has it on all its Locations.
  • The SecuritySystem resource defines a location relation and the rule "disarm" if "SECURITY_SYSTEM_DISARMER" on "location";, granting disarm permission to anyone with that role at the security system’s Location.

Together, these rules implement the desired behavior.

Next, we extend this policy for the third case: a CustomerEmployee belonging to a Team assigned the SECURITY_SYSTEM_DISARMER role on a Location. This involves an actor-resource relationship, differing from purely resource-to-resource inheritance.

First, represent teams and members. The Team resource defines a members relation linking each team to one or more CustomerEmployees:

resource Team {
  relations = { members: CustomerEmployee };
}

This establishes team membership. With this, a Polar rule defines how a customer employee inherits a role from their team:

has_role(u: CustomerEmployee, role: String, loc: Location) if
  team matches Team and
  has_relation(team, "members", u) and
  has_role(team, role, loc);

This rule states that CustomerEmployee u has a given role on Location loc if there's a Team that includes u (has_relation(team, "members", u)) and that same team has the role on loc (has_role(team, role, loc)). In essence, roles assigned to teams are inherited by all members.

Polar Resource Blocks are Shorthand for Explicit Rules

Initially, the has_role(…​) if …​ rule for teams seemed distinct from resource definitions. However, Resource definitions are indeed shorthand for more explicit Polar rules.

For example, the Location resource:

resource Location {
  relations = { customer: Customer };
  roles = ["SECURITY_SYSTEM_DISARMER", ...];

  "SECURITY_SYSTEM_DISARMER" if "SECURITY_SYSTEM_DISARMER" on "customer";
}

is shorthand for the following explicit Polar rule:

has_role(u, "SECURITY_SYSTEM_DISARMER", loc: Location) if
  has_relation(loc, "customer", cust) and
  has_role(u, "SECURITY_SYSTEM_DISARMER", cust);

And the SecuritySystem resource:

resource SecuritySystem {
  relations = { location: Location };
  permissions = ["disarm", ...];

  "disarm" if "SECURITY_SYSTEM_DISARMER" on "location";
}

is shorthand for this explicit Polar rule:

has_permission(u, "disarm", ss: SecuritySystem) if
  has_relation(ss, "location", loc) and
  has_role(u, "SECURITY_SYSTEM_DISARMER", loc);

An Oso policy is fundamentally a set of rules like has_role(…​) if …​ and has_permission(…​) if …​. The body of each rule defines conditions for success, typically expressed as other rules, comparisons, or recursively evaluated function calls. In contrast, "facts" (e.g., has_role(…​)) are unconditional truths representing the system's state, without an if clause. Rules describe how to derive new truths from these facts.

Let’s examine how Oso uses these rules and facts for permission evaluation.

How Oso Evaluates Queries

Oso’s policy evaluation relies on unification, a concept from logic programming. Unification identifies variable bindings that make logical expressions identical.

When a query like has_permission(CustomerEmployee{"alice"}, "disarm", SecuritySystem{"ss1"}) is evaluated, Oso attempts to unify it with patterns in the policy’s rules, such as has_permission(u, "disarm", ss: SecuritySystem) if …​. If the query matches the rule head, Oso substitutes concrete values (e.g., u = CustomerEmployee{"alice"}, ss = SecuritySystem{"ss1"}) and recursively evaluates the rule body conditions: has_relation(ss, "location", loc) and has_role(u, "SECURITY_SYSTEM_DISARMER", loc). Each recursive step involves further unification against other rules or facts.

For example, has_relation(SecuritySystem{"ss1"}, "location", loc) would match a fact specifying the security system’s location, like has_relation(SecuritySystem{"ss1"}, "location", Location{"loc1"}). Oso then needs to prove has_role(CustomerEmployee{"alice"}, "SECURITY_SYSTEM_DISARMER", Location{"loc1"}). This could match an existing fact (if the user has a role at that location) or be resolved through role inheritance rules from teams or customers. Through this chain of unifications, Oso constructs a logical proof of permission or determines its absence.

Now, let's see how Oso Cloud integrates into an application like RealGuardIO, demonstrating the flow of facts from backend services to Oso to support authorization.

Integrating Oso Cloud into the RealGuardIO Application

RealGuardIO backend services interact with Oso Cloud in two ways:

  1. Services owning data corresponding to policy facts must populate Oso.
  2. Services requiring authorization call Oso for decisions.

The following diagram illustrates this architecture:

Let's first explore how Oso is populated with facts, then how authorization decisions are made.

Responsibilities of Data-Owning Services

Oso Cloud integrates into RealGuardIO using a variation of the CQRS pattern. Services owning data corresponding to facts (e.g., Customer Service) publish events. An Oso Integration Service subscribes to these events and sends the facts to Oso.

For example, the assignLocationRole() operation, which assigns a CustomerEmployee a role at a Location, publishes a CustomerEmployeeAssignedLocationRole event:

  • The Customer Service handles the request by assigning the CustomerEmployee the specified role and location.
  • The Customer Service publishes a CustomerEmployeeAssignedLocationRole event.
  • The Oso Integration Service handles the event by creating the fact has_role(CustomerEmployee{"mary"}, "SECURITY_SYSTEM_DISARMER", Location{"loc3"}) in Oso.

Here’s an excerpt:

public class CustomerService {

    private final CustomerEventPublisher customerEventPublisher;

    @Transactional
    public CustomerEmployeeLocationRole assignLocationRole(Long customerId, Long customerEmployeeId, Long locationId, String roleName) {
        // ...
        customerEventPublisher.publish(customer,
            new CustomerEmployeeAssignedLocationRole(userName, locationId, roleName));
        // ...
    }
}

The CustomerEventPublisher uses the Eventuate Platform, supporting the Transactional Outbox pattern. This ensures that role assignment and event publication are atomic within a single database transaction, preventing inconsistencies between Customer Service and Oso.

The Oso Integration Service populates Oso with facts using the Oso Java SDK, which invokes the Oso REST API. For instance, this call creates a fact stating a CustomerEmployee has the COMPANY_ROLE_ADMIN role at a Company:

import com.osohq.oso_cloud.Oso;

Oso oso = new Oso(apiKey, URI.create(osoUrl));

oso.insert(new Fact(
        "has_role",
        new Value("CustomerEmployee", userId),
        new Value("COMPANY_ROLE_ADMIN"),
        new Value("Customer", customerId)
));

Now, let's look at how services use Oso for authorization decisions.

Responsibilities of Services that Authorize Requests

Services needing to authorize requests delegate decisions to Oso Cloud rather than implementing them locally. Upon receiving a request, the service calls Oso to check if the user can perform the requested action on the specified resource. This replaces complex, hand-coded authorization logic with a simple API call.

For example, both Customer Service and Security System Service use the Oso Java SDK to call the POST /authorize REST endpoint (Oso’s equivalent of isAllowed(user, action, resource)). Both services pass the logged-in user's identity as the actor. The Customer Service passes customerId as the resource, while the Security System Service passes securitySystemId.

Here’s how the Security System Service checks authorization to disarm a security system:

boolean canDisarm = oso.authorize(
              new Value("CustomerEmployee", userId),
              "disarm",
              new Value("SecuritySystem", securitySystemId)
      );

Summary

An authorization service centrally manages declarative authorization policies and offers an API for making authorization decisions. Services provide this authorization service with facts (e.g., role assignments, relationships, attributes) describing the system's current state.

Oso is an authorization-as-a-service platform available as a cloud service or for self-hosted deployment. Its policies are written in Polar, a declarative, logic-based language for fine-grained authorization rules. Oso makes decisions using facts stored in Oso Cloud, application databases, or a combination.

Using an authorization service like Oso simplifies authorization implementation within microservices, eliminating the need for complex, hand-coded logic, such as Java conditional statements and multi-table SQL joins. In the RealGuardIO application, data-owning services (like Customer Service) update Oso using the event-based CQRS pattern.