Introduction

Authzen is a framework for easily integrating authorization into backend services.

Policy based authorization is great but can be really complex to integrate into an application. This project exists to help remove a lot of the up front cost that's required to get authorization working in backend rust services.

Authzen can mostly be thought of as a "frontend" gluing together multiple different backends. It orchestrates the enforcement of authorization policies and then the performance of those authorized actions. The enforced policies live external to authzen and are managed by an "authorization engine" such as Open Policy Agent. Authzen wraps around that authorization engine, and when an action needs to be authorized, authzen relays the policy query to the authorization engine. Then if the action is allowed, authzen can either stop there or perform the action, depending on where the "action" takes place.

For example, say we want to authorize whether a user can tag another user in a post. The authorization engine is running in a separate process and can be reached by a network connection; when provided a query of the format (subject, action, object, input, context) the authorization engine will return some form of a binary decision indicating whether the user (subject) can create (action) a PostTag (object as well as input). Assuming PostTags live in a database that the backend service has a connection to, and that the backend uses a database interface supported by authzen (diesel, etc.), the order of operations looks something like this:

  • backend application calls PostTag::try_create, providing the PostTag to be created and a relevant context object containing all the http clients/connections needed to reach the authorization engine, database, etc.
    • note that try_create is a method provided by authzen while PostTag is a user defined type
  • authzen relays the policy query to the authorization engine specified in the context above
    • if the policy query is rejected or fails, try_create returns an error
  • authzen then inserts the PostTag into the database using adaptor code provided by authzen
    • for example, if PostTag has a "connected" struct DbPostTag which implements diesel::Insertable, then authzen automatically derives the adaptor code to insert the PostTag inside of PostTag::try_create
    • if the insertion of the PostTag into the database fails, try_create returns an error
  • authzen then places the inserted PostTag into a "transaction cache" which stores all of the objects inserted/updated/deleted as a part of this database transaction as a json blob

The transaction cache step is the crucial motivation for authzen: if your authorization engine requires up to date information to make accurate and unambiguous policy decisions, then changes to your database over the course of a transaction must be visible to your authorization engine. Because the component of the authorization engine which retrieves data information typically runs in a different process with different database connections from the transaction, it may not be able to see the changes from the transaction! The transaction cache is a workaround for this problem, which when integrated into your authorization engine, allows it to fetch the most up-to-date versions of your data.

Design

The design philosophy of authzen was heavily influenced by hexagonal architecture. Particularly, authzen is designed with the goal of supporting not only swappable data sources but also swappable authorization engines. The core exports of authzen land squarely in the Interactors category of the above article: utilities which facilitate interacting with the underlying authorization engine and data sources while not exposing their internals. Applications should be able to use a call to PostTag::try_create in their business logic and not need to change that code if they want to swap out where PostTags are stored or which authorization engine authorizes their creation.

Example

There is a fully running example in the repository. It shows an example of a backend service which manages accounts, shopping items and shopping carts. The example provides example policies for which accounts can create items, which carts an account can add items to (only their own), and which carts an account can view the contents of (only their own).

Authorization Primitives

Authzen provides the following core abstractions to be used when describing a policy and its components

  • ActionType: denote the type of an action, will be used to identify the action in authorization engines
  • ObjectType: denote the type and originating service of an object, will be used to identify the object in authorization engines
  • AuthzObject:
    • derive macro used to implement ObjectType for a wrapper struct which should contain a representation of the object which can be persisted to a specific data source
    • for example, if you have a struct DbFoo which can be persisted to a database, then AuthzObject should be derived on some other struct pub struct Foo<'a>(pub Cow<'a, DbFoo>);. The use of a newtype with Cow is actually necessary to derive AuthzObject (the compiler will let you know if you forget), because there are certain cases where we want to construct an ObjectType with a reference and not an owned value
  • ActionError: an error type encapsulating the different ways an action authorization+performance can fail
  • Event: collection of all identifying information which will be used as input for an authorization decision; it is generic over the following parameters
    • Subject: who is performing the action; can be any type
    • Action: what the action is; must implement ActionType
    • Object:
      • the object being acted upon; must implement ObjectType, which should typically be derived using AuthzObject
      • see here for an example usage
      • note that this parameter only represents the information about the object which can be derived from ObjectType, i.e. object type and object service
    • Input:
      • the actual data representing the object being acted upon, this can take many different forms and is dependent on which data source(s) this object lives in
      • for example, if trying to create a Foo, an expected input could be a vec of Foos which the authorization engine can then use to determine if they the action is acceptable or not
      • as another example, if trying to read a Foo, an expected could be a vec of Foo ids
    • Context: any additional information which may be needed by the authorization engine to make an unambiguous decision; typically the type of the Context provided should be the same across all events since the policy enforcer (the server/application) shouldn't need to know what context a specific action requires, that is up to the authorization engine
  • Try* traits:
    • this is a class of traits which are automatically derived for valid ObjectType types (see the section on StorageAction for more details)
    • * here can be replaced with the name of an action, for example TryCreate, TryDelete, TryRead, and TryUpdate
    • each Try* trait contains two methods: can_* and try_*, the former only authorizes an action, while the latter both authorizes and then, if allowed, performs an action
      • these two methods are the primary export of authzen, meaning that they are the points of authorization enforcement and provide considerable value and code
    • the Try* traits are generated using the action macro
  • action: given an action name (and optionally an action type string if one wants to explicitly set it), will produce:
    • a type which implements ActionType; it is generic over the object type it is acting upon
    • the Try* traits mentioned above and implementations of them for any type O implementing ObjectType for which the action implements StorageAction<O>

Data Sources

A data source is an abstraction representing the place where objects which require authorization to act upon are stored. A storage action is a representation of an ActionType in the context of a specific storage client. For example, the create action has an implementation as a storage action for any type which implements DbInsert -- its storage client is an async diesel connection. Essentially storage actions are a way to abstract over the actual performance of an action using a storage client.

Why do these abstractions exist? Because then we can call methods like try_create for an object rather than having to call can_create and then perform the subsequent action after it has been authorized. Wrapping the authorization and performance of an action is particularly useful when the data source where the objects are stored is transactional in nature, see the section on transaction caches for why that is the case.

diesel

Integration with diesel as a storage client is fully supported and comes with some features. The base export is DbEntity, which is automatically implemented for any type which implements diesel::associations::HasTable. Of note, this includes all types which implement diesel::Insertable. Types which implement DbEntity can then implement the following operation traits:

  • DbGet provides utility methods for retrieving records
    • get: given a collection of ids for this DbEntity, retrieve the corresponding records
    • get_one: given an id for this DbEntity, retrieve the corresponding record
    • get_by_column: given a column belonging to this DbEntity's table and a collection of values which match the sql type of that column, get all corresponding records
    • get_page: given a Page, return a collection of records that match the specified page; currently Page only supports (index, count) matching but a goal is to also support cursor based pagination in the future
    • get_pages: given a collection of pages, return records matching any of the pages; note that this method only makes one database query :)
  • DbInsert
    • DbInsert::Post: the data type which will actually be used to insert the record -- for any type T which implements Insertable, T will automatically implement DbInsert<Post = T>
    • DbInsert::PostHelper: the data type which will be passed to insert; this defaults to DbInsert::Post, however if you have a data type which you want to use to represent database records but which cannot directly implement Insertable, PostHelper can be set to that type and then at the time of insert it will be converted to the DbInsert::Post type
    • insert: given a collection of DbInsert::PostHelper types, insert them into the database; note that if this type implementing DbInsert also implements Audit, then audit records will be automatically inserted for all records inserted as well
  • DbUpdate
    • DbUpdate::Patch: the data type which will actually be used to update the record -- for any type T which implements Changeset, T will automatically implement DbUpdate<Patch = T>
    • DbUpdate::PatchHelper: the data type which will be passed to insert; this defaults to DbUpdate::Patch, however if you have a data type which you want to use to represent database records but which cannot directly implement Insertable, PatchHelper can be set to that type and then at the time of insert it will be converted to the DbUpdate::Patch type

Example

The PostHelper/PatchHelper terminology in DbInsert/DbUpdate can be a little confusing without an example. The major win from this design is the ability to represent discriminated unions in tables easily and safely. As an example, let's take a case where an item table can either be an inventory item or a general item. General items have no count, while inventory items do have a count of how many are owned and how many are needed. A typical representation of this table with a diesel model would just use an option for the two counts, and we will use that as a "raw" model, but the type we'd rather work with in service code is one which makes the distinction between the two item types with an enum.

#![allow(unused)]
fn main() {
use authzen::data_sources::diesel::prelude::*;
use diesel::prelude::*;
use uuid::Uuid;

#[derive(Clone, Debug)]
pub struct DbItem {
    pub id: Uuid,
    pub item_type: ItemType,
}

#[derive(Clone, Debug)]
pub enum ItemType {
    General,
    Inventory {
        owned: i32,
        needed: i32,
    },
}

/// raw diesel model
/// note that this type automatically implements DbInsert<Post<'v> = _DbItem, PostHelper<'v> = _DbItem>
#[derive(Clone, Debug, Identifiable, Insertable, Queryable)]
#[diesel(table_name = item)]
pub struct _DbItem {
    pub id: Uuid,
    pub is_inventory_type: bool,
    pub owned: Option<i32>,
    pub needed: Option<i32>,
}

/// for DbItem to implement DbInsert<Post<'v> = _DbItem, PostHelper<'v> = DbItem>, DbItem must implement Into<_DbItem>
impl From<DbItem> for _DbItem {
    fn from(value: DbItem) -> Self {
        let (is_inventory_type, owned, needed) = match value.item_type {
            ItemType::General => (false, None, None),
            ItemType::Inventory { owned, needed } => (true, Some(owned), Some(needed)),
        };
        Self { id: value.id, is_inventory_type, owned, needed }
    }
}

/// to be able to call <DbItem as DbInsert>::insert, _DbItem must implement TryInto<DbItem>
impl TryFrom<_DbItem> for DbItem {
    type Error = anyhow::Error;
    fn try_from(value: _DbItem) -> Result<Self, Self::Error> {
      let item_type = match (value.is_inventory_type, value.owned, value.needed) {
          (false, None, None) => ItemType::General,
          (true, Some(owned), Some(needed)) => ItemType::InventoryType { owned, needed },
          (is_inventory_type, owned, needed) => return Err(anyhow::Error::msg(format!(
            "unexpected inventory type found in database record: is_inventory_type = {is_inventory_type}, owned = {owned:#?}, needed = {needed:#?}",
          ))),
      };
      Ok(Self { id: value.id, item_type })
    }
}


impl DbInsert for DbItem {
    type Post<'v> = _DbItem;
}

/// service code
///
/// the Db trait here is imported in the authzen diesel prelude
/// it is a wrapper type for various types which allow us to
/// asynchronously get a diesel connection, i.e. it's implemented
/// for diesel_async::AsyncPgConnection as well as various connection pools
pub fn insert_an_item<D: Db>(db: &D, db_item: DbItem) -> Result<DbItem, DbEntityError<anyhow::Error>> {
    DbItem::insert_one(db, db_item).await
}
}

Adding in updates would look like this:

#![allow(unused)]
fn main() {
#[derive(Clone, Debug)]
pub struct DbItemPatch {
    pub id: Uuid,
    pub item_type: Option<ItemTypePatch>,
}

#[derive(Clone, Debug)]
pub enum ItemTypePatch {
    General,
    Inventory {
        owned: Option<i32>,
        needed: Option<i32>,
    },
}

#[derive(AsChangeset, Clone, Debug, Changeset, Identifiable, IncludesChanges)]
pub struct _DbItemPatch {
    pub id: Uuid,
    pub is_inventory_type: Option<bool>,
    pub owned: Option<Option<i32>>,
    pub needed: Option<Option<i32>>,
}

/// for DbItem to implement DbUpdate<Patch<'v> = _DbItemPatch, PatchHelper<'v> = DbItemPatch>,
/// DbItemPatch must implement Into<_DbItemPatch>
impl From<DbItemPatch> for _DbItemPatch {
    fn from(value: DbItemPatch) -> Self {
        let (is_inventory_type, owned, needed) = match value.item_type {
            None => (None, None, None),
            Some(ItemTypePatch::General) => (Some(false), Some(None), Some(None)),
            Some(ItemTypePatch::InventoryType { owned, needed }) => (Some(true), Some(owned), Some(needed)),
        };
        Self { id: value.id, is_inventory_type, owned, needed }
    }
}

impl DbUpdate for DbItem {
    type Patch<'v> = _DbItemPatch;
    type PatchHelper<'v> = DbItemPatch;
}

/// service code
pub fn update_an_item<D: Db>(db: &D, db_item_patch: DbItemPatch) -> Result<DbItem, DbEntityError<anyhow::Error>> {
    DbItem::update_one(db, db_item_patch).await
}
}

If you also want to create a record in a separate table item_audit any time a new record is inserted to or updated in the item table, this can be achieved automatically any time DbInsert::insert or DbUpdate::update are called by deriving Audit on _DbItem. This example assumes the table item_audit is defined like this

create table if not exists item_audit (
    id                 uuid     primary key,
    item_id            uuid     not null,
    is_inventory_type  boolean  not null,
    owned              int,
    needed             int,
    foreign key (item_id) references item (id)
);

Note that the placement of item_id as the second column in the table is required, otherwise, there is a chance that the diesel table model will still compile but with the ids swapped for example if the id and item_id columns are swapped in the sql table definition.

#![allow(unused)]
fn main() {
#[derive(Audit, Clone, Debug, Identifiable, Insertable, Queryable)]
#[audit(foreign_key = item_id)]
#[diesel(table_name = item)]
pub struct _DbItem {
    pub id: Uuid,
    pub is_inventory_type: bool,
    pub owned: Option<i32>,
    pub needed: Option<i32>,
}
}

The inclusion of #[audit(foreign_key = item_id)] is only necessary if the audit foreign key back to the original table does not follow the naming scheme {original_table_name}_id. So the above example could be reduced as below since the foreign key's name is item_id which follows the expected audit foreign key naming scheme.

#![allow(unused)]
fn main() {
#[derive(Audit, Clone, Debug, Identifiable, Insertable, Queryable)]
#[diesel(table_name = item)]
pub struct _DbItem {
    pub id: Uuid,
    pub is_inventory_type: bool,
    pub owned: Option<i32>,
    pub needed: Option<i32>,
}
}

Soft deletes are also supported out of the box:

  • queries used in any of the DbGet methods will omit records for which the soft delete column is not null
  • deletions will update the soft deleted column to the current timestamp rather than deleting the record from the database
#![allow(unused)]
fn main() {
#[derive(Audit, Clone, Debug, Identifiable, Insertable, Queryable, SoftDelete)]
#[audit(foreign_key = item_id)]
#[diesel(table_name = item)]
#[soft_delete(db_entity = DbAccount, deleted_at = deleted_at)]
pub struct _DbAccount {
    pub id: Uuid,
    pub deleted_at: Option<chrono::NaiveDateTime>,
    pub is_inventory_type: bool,
    pub owned: Option<i32>,
    pub needed: Option<i32>,
}
}

Note that updates can be still made on records which have already been soft deleted (not sure yet if this behavior is desirable; at the very least, it gives the ability to un-delete easily).

Authorization Engines

An Authorization Engine is an abstraction over a policy decision point. It's main priority is to provide binary decisions on whether actions are allowed and, in the future, to support partial evaluation of policies which can then be adapted to queries on different data sources (OPA and Oso both support partial evaluation).

Open Policy Agent

Open Policy Agent is supported for use as an authorization engine with authzen. You can either use the provided client which depends on the following assumptions about the structure of your rego policies:

  • input is expected to have the structure:
{
  "subject": {
    "value": {
      "token": "<subject-jwt>"
    }
  },
  "action": "<action-type>",
  "object": "<object-type>",
  "input": # json blob,
  "context": # json blob,
  "transaction_id": # string or null,
}
  • the output has structure {"response":bool} where the details you choose to use about the subject live inside the encoded jwt token like so
token := io.jwt.decode_verify(
  input.subject.value.token,
  {"alg": "<jwt-alg>", "cert": "<jwt-cert>"},
)
is_verified := token[0]
subject := token[2].state

Policy Information Point and Transaction Cache

If your policies are not governing live data, there's no need for either a policy information point nor a transaction cache.

Otherwise, it's highly suggested! Taking the leap to implementing a policy information point can seem like the design is getting out of hand but in reality it's the final link to make your authorization system as flexible as needed! Integrating a transaction cache into your policy information point will also ensure that the information used by OPA will be fresh and valid within transactions as well as outside of them.

Authzen provides an easy way to run a policy information point server with transaction cache integration through the use of the server macro. Using a trait based handler system, the server fetches objects based off of your own custom defined PIP query type. For an example of this in action, see the main.rs in the example and check out the example context and query definitions as well as the custom data type handler implementations.

Rego Template

If you want to use a working rego policy template out of the box, check out the rego package entry in the example. For interaction with your policy information point, util.rego in the example provides a useful function data.util.fetch which when provided input with structure

{
  "service": "<object's-service-name>",
  "type": "<object-type>",
  # query fields here
}

Transaction Caches

Transaction caches are transient json blob storages (i.e. every object inserted only lives for a short bit before being removed) which contain objects which have been mutated in the course of a transaction (only objects which we are concerned with authorizing). They are essential in ensuring that an authorization engine has accurate information in the case where it would not be able to view data which is specific to an ongoing transaction.

For example, say we have the following architecture:

  • a backend api using authzen for authorization enforcement
  • a postgres database
  • OPA as the authorization engine
  • a policy information point which is essentially another api which OPA talks to in order to retrieve information about objects it is trying to make policy decisions on
  • a transaction cache

Then let's look at the following operations taking place in the backend api wrapped in a database transaction:

  1. Authorize then create an object Foo { id: "1", approved: true }.
  2. Authorize then create two child objects [Bar { id: "1", foo_id: "1" }, Bar { id: "1", foo_id: "2" }].

Say our policies living in OPA look something like this:

import future.keywords.every

allow {
  input.action == "create"
  input.object.type == "foo"
}

allow {
  input.action == "create"
  input.object.type == "bar"
  every post in input.input {
    allow_create_bar[post.id]
  }
}

allow_create_bar[id] {
  post := input.input[_]
  id := post.id

  # retrieve the Foos these Bars belong to
  foos := http.send({
    "headers": {
		  "accept": "application/json",
		  "content-type": "application/json",
		  "x-transaction-id": input.transaction_id,
    },
		"method": "POST",
		"url": "http://localhost:9191", # policy information point url
		"body": {
      "service": "my_service",
      "type": "foo",
      "ids": {id | id := input.input[_].foo_id},
    },
  }).body

  # policy will automatically fail if the parent foo does not exist
  foo := foos[post.foo_id]

  foo.approved == true
}

Without a transaction cache to store transaction specific changes, the policy information point would have no clue that Foo { id: "1" } exists in the database and therefore this whole operation would fail. If we integrate the transaction cache into our policy information point to pull objects matching the given query (in this case, {"service":"my_service","type":"foo","ids":["1"]}) from both the database and the transaction cache, then the correct information will be returned for Foo with id 1 and the policy will correctly return that the action is acceptable.

Integration of a transaction cache into a policy information point is very straightforward using authzen, see section on policy information points.

mongodb

Contexts

Authzen contexts are data types which hold all of the necessary http clients/connections to the underlying authorization engines, data data sources and transaction caches. In rust, they are any type which implements AuthorizationContext. AuthorizationContext can be derived on structs like so:

#![allow(unused)]
fn main() {
#[derive(Clone, Copy, authzen::Context, authzen::data_sources::diesel::Db)]
pub struct Context<D> {
    #[subject]
    pub session: uuid::Uuid,
    #[db]
    #[data_source]
    pub db: D,
    #[authz_engine]
    pub opa_client: authzen::authz_engines::opa::OPAClient,
    #[transaction_cache]
    pub mongodb_client: authzen::transaction_caches::mongodb::MongodbTxCollection,
}
}

or if you want to do so in a generic way you could define context like this

#![allow(unused)]
fn main() {
#[derive(Clone, Copy, Context, Derivative, Db)]
#[derivative(Debug)]
pub struct Context<D, S, C, M> {
    #[subject]
    pub session: S,
    #[db]
    #[derivative(Debug = "ignore")]
    #[data_source]
    pub db: D,
    #[authz_engine]
    #[derivative(Debug = "ignore")]
    pub opa_client: C,
    #[transaction_cache]
    #[derivative(Debug = "ignore")]
    pub mongodb_client: M,
}
pub type Ctx<'a, D> = Context<D, &'a AccountSession, &'a OPAClient, &'a MongodbTxCollection>;
pub type CtxOptSession<'a, D> = Context<D, Option<&'a AccountSession>, &'a OPAClient, &'a MongodbTxCollection>;
}

Policy Information Points

A policy information point is a common component of many authorization systems, it basically returns information about objects required for the authorization engine to make unambiguous decisisons. Authzen provides utilities that make it simple to implement a policy information point which will integrate best with your authorization engine. More documentation for this section will come soon, but check out the example of implementing one in the examples.

Self Hosted