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 PostTag
s 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 thePostTag
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 whilePostTag
is a user defined type
- note that
- 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
- if the policy query is rejected or fails,
- authzen then inserts the
PostTag
into the database using adaptor code provided by authzen- for example, if
PostTag
has a "connected" structDbPostTag
which implementsdiesel::Insertable
, then authzen automatically derives the adaptor code to insert thePostTag
inside ofPostTag::try_create
- if the insertion of the
PostTag
into the database fails,try_create
returns an error
- for example, if
- 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 PostTag
s 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, thenAuthzObject
should be derived on some other structpub struct Foo<'a>(pub Cow<'a, DbFoo>);
. The use of a newtype with Cow is actually necessary to deriveAuthzObject
(the compiler will let you know if you forget), because there are certain cases where we want to construct anObjectType
with a reference and not an owned value
- derive macro used to implement
- 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
- the object being acted upon; must implement
- 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 ofFoo
s 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 ofFoo
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_*
andtry_*
, 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
- this is a class of traits which are automatically derived for valid
- 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 typeO
implementingObjectType
for which the action implementsStorageAction<O>
- a type which implements
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 :)
- get: given a collection of ids for this
- DbInsert
- DbInsert::Post:
the data type which will actually be used to insert the record -- for any type
T
which implementsInsertable
,T
will automatically implementDbInsert<Post = T>
- DbInsert::PostHelper:
the data type which will be passed to
insert
; this defaults toDbInsert::Post
, however if you have a data type which you want to use to represent database records but which cannot directly implementInsertable
,PostHelper
can be set to that type and then at the time of insert it will be converted to theDbInsert::Post
type - insert: given a collection of
DbInsert::PostHelper
types, insert them into the database; note that if this type implementingDbInsert
also implements Audit, then audit records will be automatically inserted for all records inserted as well
- DbInsert::Post:
the data type which will actually be used to insert the record -- for any type
- DbUpdate
- DbUpdate::Patch:
the data type which will actually be used to update the record -- for any type
T
which implementsChangeset
,T
will automatically implementDbUpdate<Patch = T>
- DbUpdate::PatchHelper:
the data type which will be passed to
insert
; this defaults toDbUpdate::Patch
, however if you have a data type which you want to use to represent database records but which cannot directly implementInsertable
,PatchHelper
can be set to that type and then at the time of insert it will be converted to theDbUpdate::Patch
type
- DbUpdate::Patch:
the data type which will actually be used to update the record -- for any 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:
- Authorize then create an object
Foo { id: "1", approved: true }
. - 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.