mirror of
https://github.com/NLnetLabs/krill.git
synced 2026-08-23 16:12:33 +02:00
347 lines
17 KiB
Markdown
347 lines
17 KiB
Markdown
# Multi-User: Authorization
|
|
|
|
> Authorization is the act of granting an authenticated party permission to do something.<br/>
|
|
> _Source: https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-vs-authorization_
|
|
|
|
**Contents:**
|
|
|
|
<!--ts-->
|
|
* [Where is authorization applied?](#where-is-authorization-applied)
|
|
* [When is authorization applied?](#when-is-authorization-applied)
|
|
* [Resource terminology](#resource-terminology)
|
|
* [Permissions](#permissions)
|
|
* [Tight integration with <a href="http://www.osohq.com/" rel="nofollow">Oso</a>](#tight-integration-with-oso)
|
|
* [The aa! macro](#the-aa-macro)
|
|
* [The Oso policy](#the-oso-policy)
|
|
* [Putting it all together](#putting-it-all-together)
|
|
|
|
<!-- Added by: ximon, at: do 8 apr 2021 10:49:52 CEST -->
|
|
|
|
<!--te-->
|
|
|
|
## Where is authorization applied?
|
|
|
|
The `Authorizer` is responsible for authorization but delegates the responsibility to two other components:
|
|
|
|
- An instance of `AuthPolicy` which it owns.
|
|
- An instance of `Actor` which it created and to which it gave a reference to the `AuthPolicy`.
|
|
|
|
## When is authorization applied?
|
|
|
|
When a Krill `Actor` needs to perform some action on some resource, we ask the `Actor` if it
|
|
`is_allowed(permission, resource)`. The `Actor` then asks the `AuthPolicy` to decide if the requested action is allowed
|
|
by the actor on the resource.
|
|
|
|
Most calls to `Actor::is_allowed()` happen in the Krill REST API handler functions. In some cases the API call cannot be
|
|
rejected or permitted outright but instead the values included in the response are filtered based on the rights of the
|
|
caller. In such cases calls to `Actor::is_allowed()` occur deeper within Krill, e.g. when listing the CAs which exist a
|
|
user may have the right to list CAs but may not be permitted to know about some CAs.
|
|
|
|
## Resource terminology
|
|
|
|
*Resource* in this context is **NOT** a resource in the RPKI sense. Rather the term resource comes from the
|
|
[Oso](http://www.osohq.com/) policy engine which powers Krills authorization decision making process and is anything
|
|
that can be acted upon by an actor.
|
|
|
|
_**TODO:** Rename all such authorization related uses of 'resource' to 'target' instead?_
|
|
|
|
A resource is currently either a CA `Handle` or `NoResource`. The latter applies to actions such as logging in or
|
|
listing all CAs, which do not relate to a single specific resource. In Oso policy language `NoResource` is mapped to Oso
|
|
`nil`.
|
|
|
|
## Permissions
|
|
|
|
Permissions in Krill are defined as a variants of a `Permission` enum. Every action that must be secured behind a
|
|
permission check invokes `Actor::is_allowed(permission, resource)` with the required permission for that action.
|
|
|
|
Authorization rules are written in terms of needing a specific permission. An alternative would have been to grant
|
|
access to Krill based on the requested HTTP REST API relative path. However, that would then require that writers of
|
|
custom authorization policy rules be aware of and tie their policy definition to the implementation details of the REST
|
|
API. Furthermore there may be subsets of the REST API which do not make logical sense to split access to, e.g. ROAs can
|
|
be updated via the API, it does not make sense to permit ROA creation but not deletion or vice versa. Therefore we
|
|
define higher level Krill "permissions" and authorization policy is based around these, not around the REST API relative
|
|
paths and HTTP methods used to invoke them.
|
|
|
|
_**NOTE:** The set of Permission variants, the locations at which permission checks are applied, and which permissions
|
|
are required in those locations for those actions to be performed need to be reviewed._
|
|
|
|
## Tight integration with [Oso](http://www.osohq.com/)
|
|
|
|
The core of Oso is written in Rust and can thus be tightly integrated with Krill.
|
|
|
|
On startup the `Authorizer` loads embedded policy "scripts" into an instance of Oso and registers the Krill `Actor`,
|
|
`Handle` and `Permission` Rust types with Oso so that they can be referred to directly in Oso policy syntax.
|
|
|
|
Optionally, user defined Oso policy files can be loaded from disk at runtime on Krill startup providing the ability to
|
|
write custom authorization rules to match a customers specific needs.
|
|
|
|
If needed this tight integration can be used to expose useful business logic via custom functions that can be invoked
|
|
directly from the Oso policy language, e.g. one might think of something like `CA::is_owner(actor)` (not currently
|
|
implemented, instead the policy language currently works with CA handles rather than CA "objects", but Oso makes it
|
|
possible).
|
|
|
|
## The aa! macro
|
|
|
|
As Krill does not use a REST API framework but handles the HTTP requests itself there is no middleware interface that
|
|
we can plug the authorization step into. I considered defining a function level attribute such as `#[authz(...)]` which
|
|
could be used to "annotate" the REST API handler functions with the details of what authorization should be required to
|
|
invoke the function, however with Rust that would require a procedural macro which would in turn require a separate Rust
|
|
crate. It would also make the check more magic and less obvious, and without more flexibility would also not be usable
|
|
within a handler function thereby requiring the function to be split up if separate rules need to be applied to
|
|
different flows within it.
|
|
|
|
Instead, for the case where REST API handler functions all have to do almost but not quite exactly the same thing, the
|
|
authorization check has been extracted to a Rust declarative macro (which does not require a separate crate). With more
|
|
changes to the Krill REST API handling code it might be possible to use a normal function instead of a macro, but for
|
|
now REST API handler functions invoke this macro via `aa!(...)`. AA here denotes that the macro checks that the given
|
|
actor is both authenticated and authorized.
|
|
|
|
|
|
The `aa!` macro can be described like so:
|
|
|
|
```rust
|
|
aa!($req, $permission, $resource, $action)
|
|
```
|
|
|
|
This should be read as: invoke the given action only if the actor identified by the given HTTP request has the specified
|
|
permission on the specified "resource".
|
|
|
|
There are two additional combinatorial variants of the macro invocation syntax:
|
|
|
|
- `aa!(no_warn ...` - this is only used by the handler function for
|
|
`GET /api/authorized` to suppress logging of auth failures which would otherwise be caused by every initial load of
|
|
the Lagosta web user interface.
|
|
- `aa!($req, $permission, $action)` - this is used in cases where the permission check is not related to a specific
|
|
"resource", e.g. `LOGIN`.
|
|
|
|
`$action` is a Rust code block containing any code that should be executed, e.g.:
|
|
|
|
```rust
|
|
aa!(req, LOGIN, { /* do something here only if the aa check succeeds */ })
|
|
```
|
|
|
|
If the `aa!` checks fail the `$action` is not invoked, instead a nice human readable error message is returned to the
|
|
client.
|
|
|
|
This slightly strange construction was originally created to work on but not consume the request object that is passed
|
|
through the "pipe" of REST API handler function invocations as it used to exist in the code:
|
|
|
|
```rust
|
|
let res = api(req)
|
|
.or_else(health)
|
|
.or_else(metrics)
|
|
...
|
|
```
|
|
|
|
_(a similar construction still exists but was unpacked to not use `.or_else()` due to problems with explosive async
|
|
recursive compilation)_
|
|
|
|
## The Oso policy
|
|
|
|
Useful reading:
|
|
- https://docs.osohq.com/rust/learn/polar-foundations.html
|
|
- https://docs.osohq.com/rust/getting-started/policies.html
|
|
- https://docs.osohq.com/rust/reference/polar/classes.html
|
|
- https://docs.rs/oso/0.11.3/oso/index.html
|
|
- [defaults/rules.polar](../../../defaults/rules.polar)
|
|
- [defaults/roles.polar](../../../defaults/roles.polar)
|
|
|
|
`Actor::is_allowed()` defers to [`Oso::is_allowed()`](https://docs.rs/oso/0.11.3/oso/struct.Oso.html#method.is_allowed).
|
|
Oso will evaluate whether the given `Actor`, `Permission` and resource combination yields true or false according to the
|
|
policy. A resource is either a CA `Handle` or `NoResource`, the latter being treatable as `nil` in Oso Polar syntax.
|
|
|
|
While the policy is static, the data it uses is dynamic. Via the `Actor` type it has access to all of the logged-in
|
|
users metadata. When using the OpenID Connect provider this metadata is controlled by the provider and thus can be
|
|
updated without restarting Krill. Note however that Krill will not see any changes to the metadata until the user logs
|
|
in again via the OpenID Connect provider.
|
|
|
|
Thus the same static policy can return true for Joe having read-access to CA `some_ca` but can return false for the same
|
|
check for Alice.
|
|
|
|
The rule enforcement process starts with lines starting of the form `allow(...)` in `rules.polar`. Oso will follow
|
|
`allow(...)` rules to other rules defined in the `.polar` files, but always starts at the most specific `allow(...)`
|
|
match that it can find. It will evaluate all matching `allow(...)` root rules until a rule results in `true` or all
|
|
rules are exhausted, e.g.:
|
|
|
|
```rust
|
|
allow(actor: Actor, action: Permission, _resource: Option) if
|
|
_resource = nil and
|
|
not disallow(actor, action, _resource) and
|
|
actor_has_role(actor, role) and
|
|
role_allow(role, action);
|
|
```
|
|
|
|
_(the `_resource = nil` bit is a workaround for [Oso bug #788](https://github.com/osohq/oso/issues/788) which is already
|
|
closed and so hopefully will be fixed in a new release of Oso soon)_
|
|
|
|
This rule says allow the requested action if it is not expressly disallowed and the actor has the role required by the
|
|
specified action. If we look at a role definition in `roles.polar`:
|
|
|
|
```rust
|
|
role_allow("admin", _action: Permission);
|
|
```
|
|
|
|
Here we see an extremely simple rule that says any action (of type `Permission`) can be used with role name `admin`.
|
|
This will feed back into the `allow(...)` rule above so that `actor_has_role(actor, "admin")` will be evaluated. If we
|
|
look at that rule back in `rules.polar`:
|
|
|
|
```rust
|
|
actor_has_role(actor: Actor, role) if role in actor.attr("role");
|
|
```
|
|
|
|
This says that the actor has the role if the actors attributes include an attribute called `role` which has the given
|
|
role value.
|
|
|
|
Note that only the `allow` rule name has special meaning to Oso, there is nothing special about the names `role_allow`
|
|
or `actor_has_role`, these could be named anything we like.
|
|
|
|
Finally, if we follow `ACTOR_DEF_ADMIN_TOKEN` from `constants.rs` we see an example of an `ActorDef` which
|
|
would yield an `Actor` with a `role` attribute with value `admin`. According to the rules we just looked at, that actor
|
|
would have all Permissions in Krill:
|
|
|
|
```rust
|
|
// --- constants.rs ---
|
|
pub const ACTOR_DEF_ADMIN_TOKEN: ActorDef = ActorDef::system("admin-token", "admin");
|
|
|
|
// --- actor.rs ---
|
|
impl ActorDef {
|
|
pub const fn system(name: &'static str, role: &'static str) -> ActorDef {
|
|
ActorDef {
|
|
name: ActorName::AsStaticStr(name),
|
|
attributes: Attributes::RoleOnly(role),
|
|
is_user: false,
|
|
new_auth: None,
|
|
auth_error: None,
|
|
}
|
|
}
|
|
```
|
|
|
|
The `Actor` object exposed to Oso Polar syntax is actually a proxy type defined in `policy.rs` like this: _(with some parts stripped out to keep it simpler here)_
|
|
|
|
```rust
|
|
impl PolarClass for Actor {
|
|
fn get_polar_class() -> oso::Class {
|
|
Self::get_polar_class_builder()
|
|
.add_method("attr", Actor::attribute)
|
|
.build()
|
|
}
|
|
|
|
fn get_polar_class_builder() -> oso::ClassBuilder<Self> {
|
|
oso::Class::builder()
|
|
}
|
|
}
|
|
```
|
|
|
|
Notice the `attr` "method" definition. It is this that allows Oso Polar syntax to invoke `actor.attr("role")` and reach in and retrieve an actual attribute value from the `Actor` instance.
|
|
|
|
## Putting it all together
|
|
|
|
Let's look at a complete example with a simplified version of a real code path from `http/server.rs`:
|
|
|
|
```rust
|
|
async fn map_requests(req: hyper::Request<hyper::Body>, state: State) -> Result<hyper::Response<hyper::Body>, Error> {
|
|
let req = Request::new(req, state).await; // 1
|
|
|
|
let new_auth = req.actor().new_auth(); // 2
|
|
|
|
let logger = ApiCallLogger::new(&req); // 3
|
|
|
|
let mut res = api(req).await; // 4
|
|
if let Err(req) = res {
|
|
res = auth(req).await; // 5
|
|
}
|
|
// ...
|
|
|
|
let res = add_new_auth_to_response(res, new_auth); // 6
|
|
|
|
logger.log(res.as_ref()); // 7
|
|
|
|
res.map(|res| res.response()) // 8
|
|
}
|
|
|
|
// An example of a resource-less restriction
|
|
async fn api(req: Request) -> RoutingResult {
|
|
if !req.path().full().starts_with("/api/v1") {
|
|
Err(req) // 9
|
|
} else {
|
|
let mut path = req.path().clone();
|
|
path.next();
|
|
|
|
match path.next() {
|
|
Some("authorized") => api_authorized(req).await, // 10
|
|
restricted_endpoint => {
|
|
aa!(req, Permission::LOGIN, { // 11
|
|
match restricted_endpoint {
|
|
Some("cas") => api_cas(req, &mut path).await,
|
|
// ...
|
|
_ => render_unknown_method(),
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// An example of no_warn
|
|
async fn api_authorized(req: Request) -> RoutingResult {
|
|
aa!(no_warn // 12
|
|
req,
|
|
Permission::LOGIN,
|
|
match *req.method() {
|
|
Method::GET => render_ok(),
|
|
_ => render_unknown_method(),F
|
|
}
|
|
)
|
|
}
|
|
|
|
// An example of a resource specific restriction
|
|
async fn api_ca_delete(req: Request, handle: Handle) -> RoutingResult {
|
|
let actor = req.actor();
|
|
aa!(
|
|
req,
|
|
Permission::CA_DELETE,
|
|
handle.clone(), // 13
|
|
render_json_res(req.state().read().await.ca_delete(&handle, &actor).await)
|
|
)
|
|
}
|
|
|
|
// An example of resource specific restriction deeper within Krill
|
|
async fn api_cas_list(req: Request) -> RoutingResult {
|
|
aa!(req, Permission::CA_LIST, { // 14
|
|
let actor = req.actor();
|
|
render_json_res(req.state().read().await.ca_list(&actor))
|
|
})
|
|
}
|
|
|
|
// --- defined in daemon/ca/manager.rs ---
|
|
pub fn ca_list(&self, actor: &Actor) -> KrillResult<CertAuthList> {
|
|
Ok(CertAuthList::new(
|
|
self.ca_store
|
|
.list()?
|
|
.into_iter()
|
|
.filter(|handle| {matches!(actor.is_allowed(Permission::CA_READ, handle.clone()), Ok(true))) // 15
|
|
.map(CertAuthSummary::new)
|
|
.collect(),
|
|
))
|
|
}
|
|
|
|
|
|
```
|
|
|
|
| # | Description
|
|
|---|------------
|
|
| 1 | Construct a Krill `Request` object from the Hyper request object. This object has access to `KrillServer` (via `State`) and internally calls `Authorizer::actor_from_request()` which in turn invokes `AuthProvider::authenticate()` so that there is an actor associated with the request. In case of authentication failure the actor is [`ACTOR_DEF_ANON`](./architecture.md) as some endpoints can be used without authentication.
|
|
| 2 | Extract any "new auth" that was created as part of invoking `AuthProvider::authenticate()`. This is a new token that the client should use in subsequent requests instead of the one it has now. New auth can be generated for example if a token was due to expire and was refreshed.
|
|
| 3 | Create a logger for, at trace level, logging more details about the request and the response. This logger honours the "benign" flag which the `aa!` macro sets on the response if `no_warn` is specified.
|
|
| 4 | Invoke the chain of handler functions. If the request is not for the handler it passes the `Request` through to the next handler via the `Err` return value. The first link in the chain checks for requests to `/api/`.
|
|
| 5 | This next handler is worth calling out here. It handles requests to `/auth/` which are the endpoints used by Lagosta to interact with the `AuthProvider` interface, i.e. `get_login_url()`, `login()` and `logout()`.
|
|
| 6 | If "new auth" is available, piggy back it on the HTTP response (whether success or failure) as an `Authorize` header. The client should extract the bearer token and use it on subsequent requests to the REST API.
|
|
| 7 | Log based on current log level: at INFO or above a summary of the request and response is logged here; at DEBUG and lower the detailed request was logged at step 3 and the detailed response is logged here. <br/><br/> _**TODO:** At the time I couldn't find a way to make `reqwest` or `fern` cause such logging,, but I recently came across [reqwest::ClientBuilder::connection_verbose()](https://docs.rs/reqwest/0.10.8/reqwest/struct.ClientBuilder.html#method.connection_verbose) which perhaps is the missing piece of this puzzle._
|
|
| 8 | Pass the constructed HTTP response back to the caller.
|
|
| 9 | Chain to the next handler by returning an Err if we are not the handler for this request.
|
|
| 10 | Handle the `/api/v1/authorized` endpoint separately (see 12 below).
|
|
| 11 | Don't proceed to handle the following endpoints unless the user has `LOGIN` permission.
|
|
| 12 | Check also for the `LOGIN` permission when handling `/api/v1/authorize`, but via `no_warn` suppress failure logging.
|
|
| 13 | To delete a CA the user must have both `CA_DELETE` permission AND access to the particular CA handle.
|
|
| 14 | To list CAs the user must have `CA_LIST` permission. However, notice that the `actor` is passed in to `ca_list()`...
|
|
| 15 | ... Looking closely at `ca_list()` we see that it further requires `CA_READ` permission on each individual CA handle in order to include that CA in the list result. |