Crates.io | authzen |
lib.rs | authzen |
version | 0.1.0-alpha.0 |
source | src |
created_at | 2023-03-03 00:48:36.374645 |
updated_at | 2023-03-03 00:48:36.374645 |
description | A framework for easily integrating authorization into backend services. |
homepage | https://github.com/tlowerison/authzen |
repository | https://github.com/tlowerison/authzen |
max_upload_size | |
id | 799285 |
size | 34,907 |
A framework for easily integrating authorization into backend services. The design philosophy of authzen was heavily influenced by hexagonal architecture and aims to provide authorization primitives with the support for many different "backends".
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. The goals of this project include:
Authzen provides primitives for combining the enforcement of authorization policies and the actions those policies govern.
For example, in an endpoint which creates a Foo
for a user but needs to be certain the user is authorized to create the
Foo provided, using authzen this would look something like
#[derive(Clone, Debug, diesel::Insertable, serde::Deserialize, serde::Serialize)]
#[diesel(table_name = foo)] // `foo` is an in-scope struct produced by the diesel::table macro somewhere
pub struct DbFoo {
pub id: uuid::Uuid,
pub bar: String,
pub baz: Option<String>,
}
#[derive(authzen::AuthzObject, Clone, Debug, serde::Deserialize, serde::Serialize)]
#[authzen(service = "my_backend_service_name", ty = "foo")]
pub struct Foo<'a>(pub std::borrow::Cow<'a, DbFoo>);
pub async fn create_foo<D: authzen::storage_backends::diesel::connection::Db>(ctx: Ctx<'_, D>, foos: Vec<Foo>) -> Result<(), anyhow::Error> {
use authzen::actions::TryCreate;
let db_foos = Foo::try_create(ctx, foos).await?;
// ...
Ok(())
}
The method try_create
combines both the authorization enforcement with the actual creation of the Foo.
If you need to authorize the action separately from the performance of the action, which can happens often, you can instead call
pub async fn create_foo<D: authzen::storage_backends::diesel::connection::Db>(ctx: Ctx<'_, D>, foos: Vec<Foo>) -> Result<(), anyhow::Error> {
use authzen::actions::TryCreate;
use authzen::storage_backends::diesel::operations::DbInsert;
Foo::can_create(ctx, &foos).await?;
// ...
let db_foos = DbFoo::insert(ctx, foos).await?; // note, DbFoo automatically implements the trait DbInsert, giving it the method `DbInsert::insert`
// ...
Ok(())
}
There is a working example in the examples directory which uses postgres as a database, diesel as its rust-sql interface (aka its storage client), Open Policy Agent as its policy decision point (in authzen, this is referred to as a decision maker), and the Mongodb container as its transaction cache.
It's highly recommended to give this a look to get an idea of what authzen can do and how to use it.
The main components of the authzen framework are:
Each component is discussed in its own section.
authzen provides the following core abstractions to be used when describing a policy and its components
ActionType
ObjectType
, which should typically be derived using AuthzObjectObjectType
, i.e. object type and object serviceFoo
, an expected input could be a vec of Foo
s which the decision maker can then use to determine if they the action is acceptable or notFoo
, an expected could be a vec of Foo
idsObjectType
for a wrapper struct which should contain
a representation of the object which can be persisted to a specific storage backendDbFoo
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 valueTry*
traits:
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
TryUpdateTry*
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
Try*
traits are generated using the action macroActionType
; it is generic over the object type it is acting uponTry*
traits mentioned above and implementations of them for any type O
implementing ObjectType
for which the action implements StorageAction<O>
A storage client 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 storage backend where the objects are stored is transactional in nature, see the section on transaction caches for why that is the case.
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:
Then let's look at the following operations taking place in the backend api wrapped in a database transaction:
Foo { id: "1", approved: true }
.[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.
A policy information point is a common component of many authorization schemes, it basically returns information about objects required for the authorization engine to make unambiguous decisisons. Realizing that you need to implement one of these can make you feel like it's all gone too far, maybe I should just go back to simple RBAC. Authzen makes it really simple to implement one however! More documentation for this section will come soon, but check out the example of implementing one in the examples.