Authentication and authorization
Context and requirements
- authentication (
authn
) is the process of figuring out a user’s identity. - authorization (
authz
) is the process of figuring out whether a user can do something.
This design project started as a result of a feature request coming from SNCF users
and stakeholders. After some interviews, we believe the overall needs to be as follows:
- controlling access to features
- some users are supposed to only view results of operational studies
- some users only get access to part of the app
- not everyone can have access to the admin panel
- it could be nice to be able to roll experimental features out incrementaly
- controlling access to data
- some infrastructures shall only be changed by automated import jobs
- users might want to control who can mess with what they’re currently working on
- rolling stock, infrastructure and timetable data may be confidential
Overall architecture
flowchart LR
subgraph gateway
auth([authentication])
end
subgraph editoast
subgraph authorization
roles([role check])
permissions([permission check])
end
end
subgraph decisions
permit
deny
end
request --> auth --> roles --> permissions
auth --> deny
roles --> deny
permissions --> permit & deny
Authentication
The app’s backend is not responsible for authenticating the user: it gets all required information
from gateway
, the authenticating reverse proxy which stands between it and the front-end.
- at application start-up, the front-end redirects to the login page if the user is not logged in
- if the user is already authenticated, the gateway returns user metadata
- otherwise, the gateway initiates the authentication process, usually with OIDC.
The implementation was designed to allow new backends to be added easily.
- once the user is authenticated, all requests to the backend can expect the following headers to be set:
x-remote-user-identity
contain a unique identifier for this identity. It can be thought of as an opaque provider_id/user_id
tuple.x-remote-user-name
contain a username
When editoast receives a request, it has to match the remote user ID with a
database user, creating it as needed.
create table authn_subject(
id bigserial generated always as identity primary key,
);
create table authn_user(
id bigint primary key references auth_subject on delete cascade,
identity_id text not null,
name text,
);
create table authn_group(
id bigint primary key references auth_subject on delete cascade,
name text not null,
);
-- add a trigger so that when a group is deleted, the associated authn_subject is deleted too
-- add a trigger so that when a user is deleted, the associated authn_subject is deleted too
create table authn_group_membership(
user bigint references auth_user on delete cascade not null,
group bigint references auth_group on delete cascade not null,
unique (user, group),
);
Group and role management API
Users cannot be directly created. The authenticating reverse proxy is in charge of user management.
- role management is protected by the
role:admin
role. - groups management is subject to permissions.
GET /authn/me
GET /authn/user/{user_id}
{
"id": 42,
"name": "Foo Bar",
"groups": [
{"id": 1, "name": "A"},
{"id": 2, "name": "B"}
],
"app_roles": ["ops"],
"builtin_roles": ["infra:read"]
}
Builtin roles are deduced from app roles, and thus cannot be directly edited.
Add roles to a user or group
This endpoint can only be called if the user has the role:admin
builtin role.
POST /authn/user/{user_id}/roles/add
POST /authn/group/{group_id}/roles/add
Takes a list of app roles:
Remove roles from a user or group
This endpoint can only be called if the user has the role:admin
builtin role.
POST /authn/user/{user_id}/roles/remove
Takes a list of app roles to remove:
Create a group
This endpoint can only be called if the user has the group:create
builtin role.
When a user creates a group, it becomes its owner.
POST /authn/group
{
"name": "Foo"
"app_roles": ["ops"],
}
Returns the group ID.
Add users to a group
Can only be called if the user has Writer
access to the group.
POST /authn/group/{group_id}/add
Takes a list of user IDs
Remove users from a group
Can only be called if the user has Writer
access to the group.
POST /authn/group/{group_id}/remove
Takes a list of user IDs
Delete a group
Can only be called if the user has Owner
access to the group.
DELETE /authn/group/{group_id}
Authorization
As shown in the overall architecture section, to determine if a subject is
allowed to conduct an action on a ressource, two checks are performed:
- We check that the roles of the subject allows the action.
- We check that the subject has the minimum privileges on the ressource(s) that are required to perform the action.
Roles
Subject can have any number of roles. Roles allow access to features. Roles do not give rights on specific objects.
Both the frontend and backend require some roles to be set to allow access to parts of the app.
In the frontend, roles guard features, in the backend, roles guard endpoints or group of endpoints.
There are two types of roles:
- Builtin roles are bundled with OSRD. Only builtin roles can be required by endpoints. These roles cannot directly be assigned to users.
- Application roles can be assigned to users. These roles are defined in a configuration file that editoast reads at startup.
Here is an example of what builtin roles might look like:
role:admin
allows assigning roles to users and groupsgroup:create
allows creating user groupsinfra:read
allows access to the map viewer moduleinfra:write
implies infra:read
. it allows access to the infrastructure editor.rolling-stock:read
rolling-stock:write
implies rolling-stock:read
. Allows access to the rolling stock editor.timetable:read
timetable:write
implies timetable:read
operational-studies:read
allows read only access to operational studies. it implies infra:read
, timetable:read
and rolling-stock:read
operational-studies:write
allows write access to operational studies. it implies operational-studies:read
and timetable:write
stdcm
implies infra:read
, timetable:read
and rolling-stock:read
. it allows access to the short term path request module.admin
gives access to the admin panel, and implies all other roles
Given these builtin roles, application roles may look like:
operational-studies-customer
implies operational-studies:read
operational-studies-analyst
implies operational-studies:write
stdcm-customer
implies stdcm
ops
implies admin
Roles are hierarchical. This is a necessity to ensure that, for example, if we are to introduce a new action related to scenarios, each subject with the role “exploitation studies” gets that new role automatically.
We’d otherwise need to edit the appropriate existing roles.
Their hierarchy could ressemble:
%%{init: {"flowchart": {"defaultRenderer": "elk"}} }%%
flowchart TD
subgraph application roles
operational-studies-analyst
operational-studies-customer
end
subgraph builtin roles
rolling-stock:read
rolling-stock:write
infra:read
infra:write
timetable:read
timetable:write
operational-studies:read
operational-studies:write
end
operational-studies-analyst --> operational-studies:write
operational-studies-customer --> operational-studies:read
infra:write --> infra:read
rolling-stock:write --> rolling-stock:read
operational-studies:read --> infra:read & timetable:read & rolling-stock:read
operational-studies:write --> operational-studies:read & timetable:write
timetable:write --> timetable:read
classDef app fill:#333,color:white,font-style:italic
classDef builtin fill:#992233,color:white,font-style:bold
class stdcm,exploitation,infra,project,study,scenario app
class infra_read,infra_edit,infra_delete,project_create,study_delete,scenario_create,scenario_update builtin
Permissions
Permission checks are done by the backend, even though the frontend may use the effective privilege level
of a user to decide whether to allow modifying / changing permissions for a given object.
Permissions are checked per resource, after checking roles.
A single request may involve multiple resources, and as such involve multiple permission checks.
Permission checks are performed as follows:
- for each request, before any resource is accessed, compute which resources need access and required privilege levels
- figure out, for the request’s user, its effective privilege level for every involved resource
- if the user’s privilege level does not meet expectations, raise an error before any change is made
enum EffectivePrivLvl {
Owner, // all operations allowed, including granting access and deleting the resource
Writer, // can change the resource
Creator, // can create new subresources
Reader, // can read the resource
MinimalMetadata, // is indirectly aware that the resource exists
}
trait Resource {
#[must_use]
fn get_privlvl(resource_pk: u64, user: &UserIdentity) -> EffectivePrivLvl;
}
The backend may therefore perform one or more privilege check per request:
- pathfinding:
Reader
on the infrastructure
- displaying a timetable:
Reader
on each rolling stock
- batch train creation:
- conflict detection:
Reader
on the infrastructureReader
on the timetableReader
on every involved rolling stock
- simulation results:
Reader
on the infrastructureReader
on the rolling stock
A grant is a right, given to a user or group on a specific resource.
Users get privileges through grants. There are two types of grants:
- explicit grants are explicitly attached to resources
- implicit grants automatically propagate explicit grants for objects which belong to a hierarchy:
- if a subject owns a project, it also owns all studies and scenarios
- if a subject can read a scenario, it knows the parent study and project exist
Explicit grants
- can be edited from the frontend
- any user holding grants over a resource can add new ones
- when a resource is created,
Owner
is granted to the current user - not all objects type can have explicit grants: train schedule inherit their timetable’s grants
-- this type is the same as EffectivePrivLvl, except that MinimalMetadata is absent,
-- as it cannot be granted directly. mere knowledge that an object exist can only be
-- granted using implicit grants.
create type grant_privlvl as enum ('Owner', 'Writer', 'Creator', 'Reader');
-- this table is a template, which other grant tables are
-- designed to be created from. it must be kept empty.
create table authz_template_grant(
-- if subject is null, this grant applies to any subject
subject bigint references authn_subject on delete cascade,
grant grant_privlvl not null,
granted_by bigint references authn_user on delete set null,
granted_at timestamp not null default CURRENT_TIMESTAMP,
);
-- these indices speed up cascade deletes
create index on authz_template_grant(subject);
create index on authz_template_grant(granted_by);
-- create a new grant table for infrastructures
create table authz_grant_EXAMPLE (
like authz_template_grant including all,
resource bigint references EXAMPLE on delete cascade not null,
unique nulls not distinct (resource, subject),
);
-- raise an error if grants are inserted into the template
create function authz_grant_insert_error() RETURNS trigger AS $err$
BEGIN
RAISE EXCEPTION 'authz_grant is a template, which other grant '
'tables are designed to inherit from. it must be kept empty.';
END;
$err$ LANGUAGE plpgsql;
create trigger before insert on authz_template_grant execute function authz_grant_insert_error();
Implicit grants
Implicit grants only apply to the operational studies module, not timetables, infrastructures and rolling stocks.
Implicit grants propagate explicit grants to related objects. There are two types of implicit grants:
- explicit grants propagate downwards within hierarchies:
Owner
, Reader
, Writer
propagate as is, Creator
is reduced to Reader
MinimalMetadata
propagates up within project hierarchies, so that read access to a study or scenario allows having the name and description of the parent project
The following objects have implicit grants:
project
gets MinimalMetadata
if the user has any right on a child study or scenariostudy
gets:MinimalMetadata
if the user has any right on a child scenarioOwner
, Reader
, Writer
if the user has such right on the parent study. Creator
is reduced to Reader
.
scenario
gets Owner
, Reader
, Writer
if the user has such right on the parent study or project. Creator
is reduced to Reader
.train-schedule
s have the same grants as their timetable
Get the privilege level of the current user
GET /authz/{resource_type}/{resource_id}/privlvl
Get all grants for a resource
GET /authz/{resource_type}/{resource_id}/grants
[
{
"subject": {"kind": "group", "id": 42, "name": "Bar"},
"implicit_grant": "Owner",
"implicit_grant_source": "project"
},
{
"subject": {"kind": "user", "id": 42, "name": "Foo"},
"grant": "Writer"
},
{
"subject": {"kind": "user", "id": 42, "name": "Foo"},
"grant": "Writer",
"implicit_grant": "MinimalMetadata",
"implicit_grant_source": "project"
}
]
Implicit grants cannot be edited, and are only displayed to inform the end user.
Add a new grant
POST /authz/{resource_type}/{resource_id}/grants
{
"subject_id": 42,
"grant": "Writer"
}
Change a grant
PATCH /authz/{resource_type}/{resource_id}/grants/{grant_id}
Revoke a grant
DELETE /authz/{resource_type}/{resource_id}/grants/{grant_id}
Implementation plan
Phase 1: ground work
Back-end:
- pass the proper headers from the reverse proxy to editoast
- implement the authn / authz model into the database
- get / create users on the fly using reverse proxy headers
- implement the role parsing and book-keeping (it can be parsed on startup and leaked into a static lifetime)
- implement a proof of concept for roles using
role:admin
and role management - implement a proof of concept for permissions by implementing group management
- implement a middleware within editoast which:
- attaches a UserInfo object to each request
- ensures that role / permission checks were performed. Implement two modules: log on missing check, abort on missing check.
- injects which checks were performed into response headers so it can be tested
- introduce the concept of rolling stock collections to enable easier rolling stock permission checking
- write a migration guide to help OSRD developpers navigate the authorization APIs
Front-end:
- take into account builtin roles to decide which features to unlock
- design, validate and build a permission editor
- prepare graceful handling of 403s
Phase 2: migration
Back-end:
- incrementally migrate all endpoints, using the middleware to find missing checks
- switch the default action on missing permission check to abort
Front-end:
- add the permission editor to all relevant objects
- handle 403s, especially on scenarios, where read access on the timetable, infra, rolling stock collections and electrical profile is required
Design decisions
Simultaneous RBAC and ABAC
RBAC: role based access control (users have roles, actions require roles)
ABAC: attribute based access control (resources have attributes, user + actions require attributes). ACLs are a kind of ABAC.
After staring at what users asked for and established authorization models allow,
we figured out that while no one model is a good fit on its own:
- just RBAC would not allow fine grained, per object access control
- just ABAC would not allow guarding off access to entire features
We decided that each authorization model could be used where it shows its strength:
- RBAC is used to authorize access to frontend features and backend endpoints
- ABAC is used to authorize actions on specific objects
We found no success in our attempts to find a unifying model.
Not using any policy language
At first, we assumed that using a policy language would assist with correctly implementing authorization. After further consideration, we concluded that:
- no user asked for policy flexibility nor policy as code, and there does not seem to be any obvious use case not already covered by RBAC + ABAC
- the main policy language considered, cedar, makes it very awkward to implement single pass RBAC + ABAC
- the primary benefit of policy languages, policy flexibility, is still very much constrained by the data the policy engine is fed: for OSRD,
feeding all grants, all users, all groups and all roles to the policy engine is not practical. we thus need filtering and careful modeling,
which almost guarantees changes will be required if a new authz rule type were to be requested by a customer. Worse yet, these changes seem
to require more effort than adapting the authz system if there were not policy language at all.
- as policy languages only deal with evaluating the policy, one can be introduced later if so desired
We felt like this feature would be hard to implement, and be likely to introduce confidentiality and performance issues:
- these objects may not be part of any operational studies, or multiple operational studies
- implicit grants are hard to implement, and risk introducing vulnerabilities
- infra, timetable and rolling stock are likely to be confidential
Instead, we plan to:
- delay implementing this feature until we figure out if the lack thereof is an UX issue
- if deemed required, implement it by checking, within the permission editor, whether all
users having access to a scenario can access associated data, and suggesting associated
permission changes
All resource types share the same permission management endpoints
We considered two patterns for permission management endpoints:
- a single set of endpoints for all resource types:
/authz/{resource_type}/{resource_id}/grants/...
- separate set of endpoints per resource type:
/v2/infra/{infra_id}/grants/...
We found that:
- having separate set of endpoints per resource types brought extra back-end and front-end complexity
- the only constraint of unified permission management endpoints is that all resource types need globaly unique IDs
- the globaly unique ID constraint is less costly than the extra complexity of separate endpoints
Dynamically enforce permission checks
Ideally, there would be static checks enforcing permission checks.
However, we found no completly fool proof way to statically do so.
Instead, we decided that all permission checks will be registered
with a middleware, which will either log or raise an error when a
handler performs no check.
- during local development, the middleware logs missing permission checks as errors
- during continuous integration checks and production deployments, the middleware aborts on missing checks
1 - Editoast internal authorization API
This design document is not intended to describe the exact editoast authorization API.
The actual implementation may slightly differ. If major limitations were uncovered, please
update this document.
Context and requirements
The following invariants were deemed worth validating:
- (high priority) role and privilege checks were performed
- (low priority) privilege checks are performed before changes are made / data is returned
- (low priority) access patterns match privilege checks
Other design criterias have an impact:
- (high priority) misuse potential
- (high priority) usage complexity and developer experience
- (medium priority) ease of migration
- (low priority) static checks are prefered
Data model
Builtin roles
First, we define an enum for all our builtin roles:
#[derive(Roles, EnumSetType, Copy)]
enum BuiltinRole {
#[role(tag = "infra:read")]
InfraRead,
#[role(tag = "infra:write", implies = [InfraRead])]
InfraWrite,
#[role(tag = "rolling-stock:read")]
RollingStockRead,
#[role(tag = "rolling-stock:write", implies = [RollingStockRead])]
RollingStockWrite,
#[role(tag = "timetable:read")]
TimetableRead,
#[role(tag = "timetable:write", implies = [TimetableRead])]
TimetableWrite,
#[role(tag = "operational-studies:read", implies = [TimetableRead, InfraRead, RollingStockRead])]
OperationalStudiesRead,
#[role(tag = "operational-studies:write", implies = [OperationalStudiesRead, TimetableWrite])]
OperationalStudiesWrite,
}
which could expand to:
#[derive(EnumSetType, Copy)]
enum BuiltinRole {
InfraRead,
InfraWrite,
RollingStockRead,
RollingStockWrite,
TimetableRead,
TimetableWrite,
OperationalStudiesRead,
OperationalStudiesWrite,
}
const ROLES: phf::Map<&'static str, BuiltinRole> = phf::phf_map! {
"infra:read" => Self::InfraRead,
"infra:write" => Self::InfraWrite,
"rolling-stock:read" => Self::RollingStockRead,
"rolling-stock:write" => Self::RollingStockWrite,
"timetable:read" => Self::TimetableRead,
"timetable:write" => Self::TimetableWrite,
"operational-studies:read" => Self::OperationalStudiesRead,
"operational-studies:write" => Self::OperationalStudiesWrite,
};
impl BuiltinRole {
fn parse_tag(tag: &str) -> Option<BuiltinRole> {
ROLES.get(tag)
}
fn tag(&self) -> &'static str {
match self {
Self::InfraRead => "infra:read",
Self::InfraWrite => "infra:write",
Self::RollingStockRead => "rolling-stock:read",
Self::RollingStockWrite => "rolling-stock:write",
Self::TimetableRead => "timetable:read",
Self::TimetableWrite => "timetable:write",
Self::OperationalStudiesRead => "operational-studies:read",
Self::OperationalStudiesWrite => "operational-studies:write",
}
}
fn implies(&self) -> &[Self] {
match self {
Self::InfraRead => &[Self::InfraRead],
Self::InfraWrite => &[Self::InfraRead, Self::InfraWrite],
Self::RollingStockRead => &[Self::RollingStockRead],
Self::RollingStockWrite => &[Self::RollingStockRead, Self::RollingStockWrite],
Self::TimetableRead => &[Self::TimetableRead],
Self::TimetableWrite => &[Self::TimetableRead, Self::TimetableWrite],
Self::OperationalStudiesRead => &[Self::TimetableRead, Self::InfraRead, Self::RollingStockRead],
Self::OperationalStudiesWrite => &[Self::OperationalStudiesRead, Self::TimetableWrite],
}
}
}
Application roles
Application roles are loaded from a yaml file at application startup:
application_roles:
ops:
name: "DevOps"
description: "Software engineers in charge of operating and maintaining the app"
implies: [admin]
stdcm-customer:
name: "STDCM customer"
implies: [stdcm]
operational-studies-customer:
name: "Operational studies customer"
implies: [operational-studies:read]
operational-studies-analyse:
name: "Operational studies analyse"
implies: [operational-studies:write]
Once loaded into editoast, app roles are resolved to a set of user roles:
type UserRoles = EnumSet<BuiltinRole>;
struct AppRoleResolver(HashMap<String, UserRoles>);
/// The API does not allow querying app roles, as it should have no impact on authorization:
/// only the final resolved set of builtin roles matters.
impl AppRoleResolver {
fn load_from_config(&path: Path) -> Result<Self, E>;
fn resolve(&self, app_role_tag: &str) -> Result<UserRoles, E>;
}
Resources and grants
TODO: decide where to process implicit grants: database or editoast?
enum ResourceType {
Group,
Project,
Study,
Scenario,
Timetable,
Infra,
RollingStockCollection,
}
struct Grant {
grant_id: u64,
subject: SubjectId,
privlvl: GrantPrivLvl,
granted_by: UserId,
granted_at: Timestamp,
}
async fn all_grants(conn, resource_type: ResourceType, resource_id: u64) -> Vec<Grant>;
async fn applicable_grants(conn, resource_type: ResourceType, resource_id: u64, subject_ids: Vec<SubjectId>) -> Vec<Grant>;
async fn revoke_grant(conn, resource_type: ResourceType, grant_id: u64);
async fn update_grant(conn, resource_type: ResourceType, grant_id: u64, privlvl: GrantPrivLvl);
Low level authorization API
struct PrivCheck {
resource_type: ResourceType,
resource_id: u64,
minimum_privlvl: EffectivePrivLvl,
}
/// The authorizer is injected into each request by a middleware.
/// The middleware finds the user ID associated with the request.
/// At the end of each request, it ensures roles and privileges were checked.
struct Authorizer {
user_id: u64,
checked_roles: Option<UserRoles>,
checked_privs: Option<Vec<PrivCheck>>,
};
impl FromRequest for Authorizer {}
impl Authorizer {
async fn check_roles(
conn: &mut DatabaseConnection,
required_roles: &[BuiltinRole],
) -> Result<bool, Error>;
async fn check_privs(
conn: &mut DatabaseConnection,
required_privs: &[PrivCheck],
) -> Result<bool, Error>;
}
This API is then used as follows:
#[post("/project/{project_id}/study/{study_id}/scenario")]
async fn create_scenario(
path: Path<(i64, i64)>,
authz: Authorizer,
db_pool: web::Data<DatabasePool>,
Json(form): Json<ScenarioCreateForm>,
) -> Result<Response, Error> {
let conn, db_pool.get().await;
let (project_id, study_id) = path.into_inner();
// validate that study.scenario == scenario
authz.check_roles(&mut conn, &[BuiltinRoles::OperationalStudiesWrite]).await?;
authz.check_privs(&mut conn, &[(Study, study_id, Creator).into()]).await?;
// create the object
// ...
Ok(...)
}
High level authorization API
🤔 Proposal: fully dynamic checks
This proposal suggests dynamically enforcing all authorization invariants:
- role and privilege checks were performed: The authorizer records all checks, and panics / logs an error if no check is made
- privilege checks are performed before changes are made / data is returned: checked database accesses
(the default) cannot be made before commiting authorization checks. No more authorization check can be made after commiting.
- access patterns match privilege checks: Check database access functions ensure a prior check was
made using the Authorizer’s check log.
Each database access method thus gets two variants:
a checked variant (the default), which takes the Authorizer as a parameter. This variants panics if:
- a resource is accessed before authorization checks are commited
- a resource is accessed without a prior authorizer check.
an unchecked variant. its use should be limited to:
- fetching data for authorization checks
- updating modification dates
#[post("/project/{project_id}/study/{study_id}/scenario")]
async fn create_scenario(
path: Path<(i64, i64)>,
authz: Authorizer,
db_pool: web::Data<DatabasePool>,
Json(form): Json<ScenarioCreateForm>,
) -> Result<Response, Error> {
let conn, db_pool.get().await;
let (project_id, study_id) = path.into_inner();
// Check if the project and the study exist
let (mut project, mut study) =
check_project_study_conn(&mut conn, project_id, study_id).await?;
authz.check_roles(&mut conn, &[BuiltinRoles::OperationalStudiesWrite])?;
authz.check_privs(&mut conn, &[(Study, study_id, Creator).into()])?;
// all checks done, checked database accesses allowed
authz.commit();
// ...
// create the scenario
let scenario: Scenario = data.into_scenario(study_id, timetable_id);
let scenario = scenario.create(db_pool.clone(), &authz).await?;
// Update study last_modification field
study.update_last_modified(conn).await?;
// Update project last_modification field
project.update_last_modified(conn).await?;
// ...
Ok(...)
}
Bonus proposal: require roles using macros
TODO: check if this is worth keeping
Then, we annotate each endpoint that require role restrictions with requires_roles
:
#[post("/scenario")]
#[requires_roles(BuiltinRoles::OperationalStudiesWrite)]
async fn create_scenario(
user: web::Header<GwUserId>,
db_pool: web::Data<DatabasePool>
) -> Result<Response, Error> {
todo!()
}
which may expand to something similar to:
async fn create_scenario(
user: web::Header<GwUserId>,
db_pool: web::Data<DatabasePool>
) -> Result<Response, Error> {
{
let conn = &mut db_pool.get().await?;
let required_roles = [BuiltinRoles::OperationalStudiesWrite];
if !editoast_models::check_roles(conn, &user_id, &required_roles).await? {
return Err(403);
}
}
async move {
todo!()
}.await
}
🤔 Proposal: Static access control
This proposal aims at improving the Authorizer
descibed above by building on it a safety layer that encodes granted permissions into the type system.
This way, if access patterns do not match the privilege checks performed beforehand, the program will fail to compile and precisely pinpoint the privilege override as a type error.
To summarize, the Authorizer
allows us to:
- Pre-fetch the user of the request and its characteristics as a middleware
- Check their roles
- Maintain a log of authorization requests on specific ressources, and check if they hold
- Guarantees that no authorization will be granted passed a certain point (
commit
function) - At the end of an endpoint, checks that permissions were granted or
panic!
s otherwise
While all these checks are performed at runtime, those can be tested rather trivially in unit tests.
However, the Authorizer
cannot check that the endpoints actually respect the permission level they asked for when they access the DB. For example, an endpoint might ask for Read
privileges on a Timetable
, only to delete it afterwards. This is trivial to check if the privilege override happens in the same function, but it can be much more vicious if that happens conditionally, in another function, deep down the call stack. For the same reasons, refactoring code subject to authorizations becomes much more risky and error prone.
Hence, for both development and review experience, to ease writing and refactoring authorizing code, to be confident our system works, and for general peace of mind, we need a way to ensure that an endpoint won’t go beyond the privilege level it required for all of its code paths.
We can do that either statically or dynamically.
Dynamic access pattern checks
Let’s say we keep the Authorizer
as the high-level API for authorization.
It holds a log of grants. Therefore, any DB operation that needs to be authorized must, in addition to the conn
, take an Arc<Authorizer>
parameter and let the operation check that it’s indeed authorized. For example, every retrieve(conn, authorizer, id)
operation would ask the authorizer the permission before querying the DB.
This approach works and has the benefit of being easy to understand, but does not provide any guarantee that the access paterns match the granted authorizations and that privilege override cannot happen.
A way to ensure that would be to thoroughly test each endpoint and ensure that the DB accesses panic
in expected situations. Doing so manually is extremely tedious and fragile in the long run, so let’s focus on automated tests.
To make sure that, at any moment, each endpoint doesn’t override its privileges, we’d need a test for each releveant privilege level and for each code path accessing ressources. Admittedly this would be great, but:
- it heavily depends on test coverage (which we don’t have) to make sure no code path is left out, i.e. that no test is missing
- it’s unrealistic given the current state of things and how fast editoast changes
- tests would be extremely repetitive, and mistakes will happen
- the test suite of an endpoint now not only depends on what it should do, but also on how it should do it: i.e. to know how to test your endpoint, you need to know precisely what DB operations will be performed, under what conditions, on all code paths, and replicate that
- when refactoring code subject to authorization that’s shared across several endpoints, the tests of each of these endpoints would need to be examined to ensure no check goes missing
- unless we postpone the creation of these tests and accept a lower level of confidence in our system, even temporarily(TM), the authz migration would be slowed down significantly
Or we could just accept the risk.
Or we could statically ensure that no endpoint override its requested privileges, using the typesystem, and be sure that such issues can (almost) never arise.
Static checks
The idea is to provide an high-level API for authorization, on top of the Authorizer
. It encodes granted privileges into the typesystem. For example,
for a request GET /timetable/42
, the endpoint will ask from the Authorizer
an Authz<Timetable, Read>
object:
let timetable_authz: Authz<Timetable, Read> = authorizer.authorize(&[42])?;
The authorizer does two things here:
- Checks that the privilege level of the user allows them to
Read
on the timetable ID#42. - Builds an
Authz
object that stores the ID#42 for later checks, which encodes in the type system that we have a Read
authorization on some Timetable
ressources.
Then, after we authorizer.commit();
, we can use the Authz
to effectively request the timetable:
let timetable: Timetable = timetable_authz.retrieve(conn, 42)?;
The Authz
checks that the ID#42 is indeed authorized before forwarding the call the modelv2::Retrieve::retrieve
function that performs the query.
However, if by mistake we wrote:
let timetable = timetable_authz.delete(conn, 42)?;
we’d get a compilation error such as Trait AuthorizedDelete is not implemented for Authz<Timetable, Read>
, effectively preventing a privilege override statically.
On a more realistic example:
impl Scenario {
fn remove(
self,
conn: &mut DatabaseConnection,
scenario_authz: Authz<Self, Delete>,
study_authz: Authz<Study, Update>,
) -> Result<(), Error> {
// open transaction
scenario_authz.delete(conn, self.id)?;
let cs = Study::changeset().last_update(Datetime::now());
study_authz.update(conn, self.study_id, cs)?;
Ok(())
}
}
This approach brings several advantages:
- correctness: the compiler will prevent any privilege override for us
- readability: if a function requires some form of authorization, it will show in its prototype
- ease of writing: we can’t write DB operations that ultimately wouldn’t be authorized, avoiding a potential full rewrite once we notice the problem (and linting is on our side to show problems early)
- more declarative: if you want to read an object, you ask for a
Read
permission, the system is then responsible for checking the privilege level and map that to a set of allowed permissions. This way we abstract a little over the hierarchy of privileges a ressource can have. - ease of refactoring: thanks rustc ;)
- flexibility: since the
Authz
has a reference to the Authorizer
, the API mixes well with more dynamic contexts (should we need that in the future) - migration
- shouldn’t be too complex or costly since the
Authz
wraps the ModelV2
traits - will require changes in the same areas that would be impacted by a dynamic checker, no more, no less (even in the dynamic context mentioned above we still need to pass the
Arc<Authorizer>
down the call stack)
- contamination: admittedly, this API is slightly more contaminating than just passing an
Arc<Authorizer>
everywhere. However, this issue is mitigated on several fronts:- most endpoints in editoast either access the DB in the endpoint function itself, or in at most one or two function calls deep. So the contamination likely won’t spread far and the migration shouldn’t take much more time.
- if we notice that a DB call deep down the call stack requires an
Authz<T, _>
that we need to forward through many calls, it’s probably pathological of a bad architecture
The following sections explore how to use this API:
- to define authorized ressources
- implement the effective privilege level logic
- to deal with complex ressources (here
Study
) which need custom authorization rules and that are not atomic (the budgets follow different rules than the rest of the metadata) - to implement an endpoint that require different permissions (
create_scenario
)
Actions
We define all actions our Authz
is able to expose at both type-level and at runtime (classic CRUD + Append for exploitation studies).
mod action {
struct Create;
struct Read;
struct Update;
struct Delete;
struct Append;
enum Cruda {
Create,
Read,
Update,
Delete,
Append,
}
trait AuthorizedAction {
fn as_cruda() -> Cruda;
}
impl AuthorizedAction for Create;
impl AuthorizedAction for Read;
impl AuthorizedAction for Update;
impl AuthorizedAction for Delete;
impl AuthorizedAction for Append;
}
The motivation behind this is that at usage, we don’t usually care about the privilege of a user over a ressource. We only care, if we’re about to read a ressource, whether the user has a privilege level high enough to do so.
The proposed paradigm here is to ask the permission to to an action over a ressource, and let the ressource definition module decide (using its own effective privilege hierarchy) whether the action is authorized or not.
Standard and custom effective privileges
We need to define the effective privilege level for each ressource. For most
ressources, a classic Reader < Writer < Owner
is enough. So we expose that by default, leaving the choice to each ressource to provide their own.
We also define an enum providing the origin of a privilege, which is a useful
information for permission sharing.
// built-in the authorization system
#[derive(PartialOrd, PartialEq)]
enum StandardPrivilegeLevel {
Read,
Write,
Own,
}
enum StandardPrivilegeLevelOrigin {
/// It's an explicit privilege
User,
/// The implicit privilege comes from a group the user belongs to
Group,
/// The implicit privilege is granted publicly (authz_grant_xyz.subject IS NULL)
Public,
}
trait PrivilegeLevel: PartialOrd + PartialEq {
type Origin;
}
impl PrivilegeLevel for StandardPrivilegeLevel {
type Origin = StandardPrivilegeLevelOrigin;
}
Grant definition
Then we need to associate to each grant in DB its effective privilege level and origin.
// struct AuthzGrantInfra is a struct that models the table authz_grant_infra
impl EffectiveGrant for AuthzGrantInfra {
type EffectivePrivilegeLevel = StandardPrivilegeLevel;
async fn fetch_grants(
conn: &mut DbConnection,
subject: &Subject,
keys: &[i64],
) -> GrantMap<Self::EffectivePrivilegeLevel>? {
crate::tables::authz_grants_infra.filter(...
}
}
where GrantMap<PrivilegeLevel>
is an internal representation of a collection of grants (implicit and explicit) with some privilege level hierarchy (custom or not).
Ressource definition
Each ressource is then associated to a model and a grant type. We also declare which actions are allowed based on how we want the model to be used given the effective privilege of the ressource in DB.
The RessourceType
is necessary for the dynamic context of the underlying Authorizer
.
impl Ressource for Infra {
type Grant = AuthzGrantInfra;
const TYPE: RessourceType = RessourceType::Infra;
/// Returns None is the action is prohibited
fn minimum_privilege_required(action: Cruda) -> Option<Self::Grant::EffectivePrivilegeLevel> {
use Cruda::*;
use StandardPrivilegeLevel as lvl;
Some(match action {
Read => lvl::Read,
Create | Update | Append => lvl::Write,
Delete => lvl::Own,
})
}
}
And that’s it!
The rest of the mechanics are located within the authorization system.
A more involved example: Studies
//////// Privilege levels
enum StudyPrivilegeLevel {
ReadMetadata, // a scenario of the study has been shared
Read,
Append, // can only create scenarios
Write,
Own,
}
enum StudyPrivilegeLevelOrigin {
User,
Group,
Project, // the implicit privilege comes from the user's grants on the study's project
Public,
}
impl PrivilegeLevel for StudyPrivilegeLevel {
type Origin = StudyPrivilegeLevelOrigin;
}
///////// Effective grant retrieval
impl EffectiveGrant for AuthzGrantStudy {
type EffectivePrivilegeLevel = StudyrivilegeLevel;
async fn fetch_grants(
conn: &mut DbConnection,
subject: &Subject,
keys: &[i64],
) -> GrantMap<Self::EffectivePrivilegeLevel>? {
// We implement here the logic of implicit privileges where an owner
// of a project is also owner of all its studies
crate::tables::authz_grants_study
.filter(...)
.inner_join(crate::tables::study.on(...))
.inner_join(crate::tables::project.on(...))
.inner_join(crate::tables::authz_grants_project.on(...))
}
}
//////// Authorized ressources
/// Budgets of the study (can be read and updated by owners)
struct StudyBudgets { ... }
impl Ressource for StudyBudgets {
type Grant = AuthzGrantStudy;
const TYPE: RessourceType = RessourceType::Study;
fn minimum_privilege_required(action: Cruda) -> Option<StudyPrivilegeLevel> {
use Cruda::*;
use StudyPrivilegeLevel as lvl;
Some(match action {
Read | Update => lvl::Own,
_ => return None,
})
}
}
/// Non-sensitive metadata available to users with privilege level MinimalMetadata (can only be read)
struct StudyMetadata { ... }
impl Ressource for StudyMetadata {
type Grant = AuthzGrantStudy;
const TYPE: RessourceType = RessourceType::Study;
fn minimum_privilege_required(action: Cruda) -> Option<StudyPrivilegeLevel> {
use Cruda::*;
use StudyPrivilegeLevel as lvl;
Some(match action {
Read => lvl::ReadMetadata,
_ => return None,
})
}
}
/// A full study (can be created, read, updated, appended and deleted)
struct Study { ... }
impl Ressource for Study {
type Grant = AuthzGrantStudy;
const TYPE: RessourceType = RessourceType::Study;
fn minimum_privilege_required(action: Cruda) -> Option<StudyPrivilegeLevel> {
use Cruda::*;
use StudyPrivilegeLevel as lvl;
Some(match action {
Read => lvl::Read,
Append => lvl::Append,
Create => lvl::Create,
Update => lvl::Write,
Delete => lvl::Own,
})
}
}
Concrete endpoint definition
#[post("/scenario")]
async fn create_scenario(
authorizer: Arc<Authorizer>,
conn: DatabaseConnection,
db_pool: web::Data<DatabasePool>,
Json(form): Json<ScenarioCreateForm>,
path: Path<(i64, i64)>,
authz: Authorizer,
) -> Result<Response, Error> {
let conn, db_pool.get().await;
let (project_id, study_id) = path.into_inner();
let ScenarioCreateForm { infra_id, timetable_id, .. } = &form;
authorizer.authorize_roles(&mut conn, &[BuiltinRoles::OperationalStudiesWrite]).await?;
let _ = authorizer.authorize::<Timetable, Read>(&mut conn, &[timetable_id]).await?;
let _ = authorizer.authorize::<Infra, Read>(&mut conn, &[infra_id]).await?;
let study_authz: Authz<Study, Append> = authorizer.authorize(&mut conn, &[study_id]).await?;
authorizer.commit();
let response = conn.transaction(move |conn| async {
let scenario: Scenario = study_authz.append(&mut conn, form.into()).await?;
scenario.into_response()
}).await?;
Ok(Json(response))
}