Securing your application đď
Need to secure your FastAPI application and keep attackers and bots out of your API? Follow this guide!
Background: Scopes and Rolesď
In the world of OAuth2, a scope is a unit of permission that authorises a client to take some action on a userâs behalf. For example, a scope might be something like proposal:create. You can think of scopes as roughly analogous to the permissions that you grant when installing apps on your phone. As developers, youâll need to work with your stakeholders to define permissions for the various actions clients will be allowed to take using your service. Scopes are specific to your service, defined by you, and typically granted to clients by users themselves.
Tip
You define your own scopes, but theyâll need to be registered with Entra ID on your behalf by the OrbIT team before clients can request and use them. See step 2
In contrast to scopes, the roles used in role-based access control are pre-defined globally, and managed as Entra groups. Where scopes grant specific permissions to a client, roles are statements from the powers-that-be about what sort of user is making the request. Roles will usually be defined around job function, or the type of work people will be doing at the SKA Observatory. One user may have multiple roles simultaneously. For example, both a software engineer and a database admin.
Tip
Roles are already predefined. This library will help you control access to your application using roles, but it doesnât enable creating new roles or assigning users to roles.
In order to secure your API, youâll need to evaluate both scopes and roles â and potentially other considerations specific to the business logic of your own service â when deciding whether to reject or accept an incoming request. The tools in Auth Helpers are designed to make it easier to do that in a convenient, repeatable way.
Step 1: Define your applicationâs scopesď
Thereâs plenty of advice on the internet about how to define scopes: I found this document to be helpful. As a starting point, Iâd suggest following a simple {noun}:{verb} pattern. Think about the different entities in your service and what actions clients will will need to take with them, then think about which combinations of these would ever be useful in isolation.
You want to make your scopes as small and granular as reasonable â but no smaller. In other words, scopes should define the smallest sensible unit of permission for your application. For example, in most cases, clients that are allowed to create an entity can probably also update that entity to make changes to it. It might make more sense to define a write scope instead of separate create and update scopes. Resist the temptation to mechanically define a zillion different tiny CRUD scopes that will be overwhelming to our users.
In my experience, Iâve found it hard to define all my scopes up front in the abstract. If youâre like me, you might find it easier to skip ahead to Steps 3 and 4, implement the permissions first, and then come back to formalise them based on what youâve learned. Ultimately, scopes are just some strings you make up, so you can experiment with them during development and figure out what makes sense in the context of your app.
Step 2: Register your application with Entra IDď
Youâll need to file a request with the OrbIT team and have them create a registration for you in Entra ID. At time of writing, the IT portal doesnât have all the necessary fields available to request registration for a service app. Do the best you can: explain in the free text fields that you want to register what Microsoft calls a âWeb APIâ and that it needs to expose a few scopes. At this stage, you might need a bit of back-and-forth discussion with the OrbIT team to settle on the correct setup for your app. Join the #help-aaa-questions channel to ask for help if you need it.
In addition to the scope itself, Entra ID will let you provide a display name and a description for each scope that helps users make an informed decision when granting consent to a client.
For example, you might ask to register some scopes that look like:
Scope |
Display name |
Description |
|---|---|---|
|
Submit proposals |
Allows formally submitting proposals for review. This is the final step in the proposal preparation workflow. |
|
Execute activities on SKA Low |
Execute activities on SKA Low: This permission allows instructing hardware for the SKA Low telescope. |
Once your app is successfully registered, youâll get one or more API IDs (likely one for use in production and one for non-prod environments). These are UUIDs that identify your application in Entra ID. Youâll need to save them somewhere and make them available as configuration data to your application. They are not confidential information, you donât have to protect them or keep them hidden in Vault. You might put them in Helm charts or read them from environment variables set in the deployment pipeline.
Step 3: Import some tools and do a bit of setupď
Start off by importing the tools that weâll be using:
from ska_aaa_authhelpers import (
AuditLogFilter,
AuthContext,
AuthFailError,
Requires,
Role,
watchdog,
)
# You probably already have this somewhere:
from ska_ser_logging import configure_logging
Configure audit loggingď
Wherever your app sets up logging, add the AuditLogFilter
configure_logging(level="WARNING", tags_filter=AuditLogFilter)
This will automatically annotate log entries with a user_id and a trace to help us tie together log entries for auditing purposes.
Unleash the watchdogď
When creating your FastAPI app instance, add the watchdog() lifespan:
app = FastAPI(lifespan=watchdog())
This runs once when your app starts up and double-checks that all your routes are secured. Now, if you try to run your app, you should see the watchdog barking about security holes, and preventing it from startingâŚ
ska_aaa_authhelpers.watchdog.SecurityHoleError: Route...does not have a Requires() dependency that defines scopes and roles to control access.
Step 4: Add Requires() to all your routesď
The Requires()utility leverages FastAPIâs native dependency-injection system to create a Security dependency that gets called on every request in order to automatically enforce authorisation restrictions. It will return an HTTP 403 error unless the request is accompanied by a signed access token valid for a certain audience, plus particular scopes and roles. If youâve registered your app in step 2, youâll already know what value you need for the audience but if not, thatâs fine and you can use a dummy value to get started developing and testing.
Example: AuthContext passed into your viewď
If youâre experienced with FastAPI and its use of Depends() then this should look pretty familiar to you:
# You get this value by registering in step 2:
MY_API = "api://3688e6c2-87c0-4584-a674-c11e63e9b442"
@app.get("/hello")
async def say_hello(
auth: Annotated[
AuthContext,
Requires(
audience=MY_API,
roles={Role.SW_ENGINEER},
scopes={"hello:listen"},
),
],
):
return {"msg": f"Hello, user: {auth.user_id}"}
On every request, FastAPI will automatically attempt to satisfy the dependency by providing an AuthContext object that meets your requirements and passing it as a parameter for use in your view function.
Example: Using Requires() without accessing AuthContextď
For some simple cases, you might not need to use the auth object in your view function, but you can still use Requires() to secure your routes by passing it as one of the dependencies directly in the FastAPI path operation decorator. For example:
@app.get("/goodbye", dependencies=[Requires(
audience=MY_API,
roles={Role.SW_ENGINEER},
scopes={"hello:listen"},
)])
async def say_goodbye():
return {"msg": "Goodbye"}
Example: DRY out your audience with functools.partialď
The audience field is always going to be the same for a single deployment of your app, so if you have many views, passing the same value can quickly get repetitive. Iâve found using Pythonâs functools.partial to set it one time is a nice trick for removing duplication from your code:
import functools
# The name 'Permissions' is arbitrary here, pick whatever name you like.
Permissions = functools.partial(Requires, audience=MY_API)
@app.get("/hi")
async def say_hi(
auth: Annotated[
AuthContext,
Permissions(
roles={Role.SW_ENGINEER},
scopes={"hello:listen",},
),
],
):
return {"msg": f"Hi, user: {auth.user_id}"}
Going further, if you have a bunch of views that all require the same roles and scopes, you can simply pass the exact same object to multiple views.
operators_only = Requires(
audience=MY_API,
scopes={"telescope:operate"},
roles={Role.LOW_OPERATOR, Role.MID_OPERATOR}
)
app.get('/array/')
async def get_arrays(auth: Annotated[AuthContext, operators_only]):
return {"arrays": ["array1", "array2"]}
app.get('/calibration/')
async def get_calibration(auth: Annotated[AuthContext, operators_only]):
return {"calibration": "good"}
For some applications, you might be done here! If your app doesnât have any authorisation requirements more granular than controlling access to entire views based on scopes and roles, then youâre all set. You can move on to testing your application.