Authorization
This guide covers implementing authorization in your SRAM-authenticated FastAPI application.
Authentication vs Authorization
Authentication answers "Who are you?" - it verifies user identity through SRAM's OIDC flow.
Authorization answers "What can you do?" - it determines what resources an authenticated user can access based on their attributes.
SRAM User Attributes
SRAM provides two primary attributes for authorization decisions:
eduperson_entitlement
A list of URIs representing specific permissions or capabilities granted to the user. Common patterns:
urn:mace:surf.nl:sram:group:my-collaboration
urn:example:admin
urn:example:researcher
Use entitlements for fine-grained access control to specific features or resources.
voperson_external_affiliation
A list of affiliation strings in the format role@organization. Examples:
staff@tudelft.nl
employee@example.org
student@university.edu
Use affiliations for role-based access control at the organizational level.
Authorization Dependencies
The sram_fastapi.auth module provides two dependency factories for route-level authorization.
require_entitlement
Restricts access to users with specific entitlements.
from fastapi import Depends
from sram_fastapi.auth import require_entitlement
# Require any one of the specified entitlements (OR logic)
@app.get("/admin")
async def admin_page(
user = Depends(require_entitlement("urn:example:admin", "urn:example:superuser"))
):
return {"message": "Welcome, admin!"}
# Require all specified entitlements (AND logic)
@app.get("/super-admin")
async def super_admin_page(
user = Depends(require_entitlement(
"urn:example:admin",
"urn:example:billing",
require_all=True
))
):
return {"message": "Welcome, super admin!"}
require_affiliation
Restricts access based on user affiliations. Supports wildcard matching.
from fastapi import Depends
from sram_fastapi.auth import require_affiliation
# Require staff at any organization
@app.get("/staff-only")
async def staff_page(
user = Depends(require_affiliation("staff@"))
):
return {"message": "Welcome, staff member!"}
# Require specific organization affiliation
@app.get("/tudelft-only")
async def tudelft_page(
user = Depends(require_affiliation("staff@tudelft.nl", "employee@tudelft.nl"))
):
return {"message": "Welcome, TU Delft affiliate!"}
# Require multiple affiliations (AND logic)
@app.get("/staff-and-researcher")
async def staff_researcher_page(
user = Depends(require_affiliation(
"staff@",
"researcher@",
require_all=True
))
):
return {"message": "Welcome, staff researcher!"}
Wildcard Matching
The require_affiliation dependency supports two wildcard patterns:
role@- Matches any organization with the specified role (e.g.,staff@matchesstaff@tudelft.nl,staff@example.org)@organization- Matches any role at the specified organization (e.g.,@tudelft.nlmatchesstaff@tudelft.nl,student@tudelft.nl)
require_group
Restricts access to members of a named group inside a collaboration, addressed by the group's short name rather than by a full entitlement URI.
An entitlement names one specific collaboration: urn:mace:surf.nl:sram:group:tudelft:sramdemo:editors grants nothing to a member of another collaboration, even one holding the equivalent group there. require_entitlement therefore only works for an application serving a single, known collaboration. require_group matches on the last segment, so the same rule holds for every collaboration the application serves.
from fastapi import Depends
from sram_fastapi.auth import User, require_group
@app.get("/reports")
async def reports(user: User = Depends(require_group("editor"))):
return {"message": "Members of the editor group can see this"}
Features are mapped to group short names in configuration, so a deployment can point a feature at whatever its groups are called:
# .env
SRAM_FEATURE_GROUPS=editor=tudelft:sramdemo/sramdemo-editors,reviewer=tudelft:sramdemo/sramdemo-reviewers
Each entry names the collaboration the group belongs to and the group's short name as it appears in the entitlement. The feature is granted to members of that group in that collaboration, and the same short name in any other collaboration grants nothing.
An entry may also name only a short name:
SRAM_FEATURE_GROUPS=editor=sramdemo-editors
That form is granted only where SRAM confirms the group's origin. The application asks the organisation API which collaborations hold a service group of that name — a group carrying service_group_id, meaning SRAM provisioned it for a service rather than a collaboration admin creating it — and grants the feature in those collaborations alone. The answer is cached for five minutes.
The distinction matters because a short name is chosen by whoever creates the group. Without verification, an admin of any collaboration connected to this service could create a group under the configured name and hand the feature to their members. Verification is refused, not assumed: with no organisation API token, or when SRAM cannot be reached, an unbound feature grants nothing and the reason is logged.
Verification covers the organisation the API token belongs to. A collaboration in another organisation is invisible to it, so a feature that must work there is bound explicitly.
The check fails closed. Requiring a feature that configuration does not define denies every user and logs the missing mapping, rather than falling back to the feature name as a group. A typo therefore locks people out, which is visible, instead of granting access through a group nobody meant to name.
Like the other dependencies, require_group takes several names and a require_all flag:
Depends(require_group("editor", "curator")) # either group
Depends(require_group("editor", "curator", require_all=True)) # both groups
To find which collaborations granted a feature, rather than only whether one did, use groups_of(user), which returns the collaboration URN and short name of every group the user belongs to.
Where the groups come from
The short names have to exist in each collaboration for any of this to work. SRAM provides that through service groups: groups defined once on the service, which SRAM provisions into every collaboration that connects the service.
This division of labour is what makes an application able to depend on a short name:
| Action | Service admin | Collaboration admin |
|---|---|---|
| Define the group and its short name | Yes, on the service in SRAM | No |
| Have it appear in a collaboration | Automatic when the service is connected | No |
| Add and remove members | No | Yes |
| Turn on auto provisioning, so every member joins | No | Yes |
| Rename or delete it | Yes, on the service | No |
A collaboration admin cannot remove a group that a connected service provisioned, so the application's authorization cannot be broken from inside a collaboration. What each collaboration decides for itself is who is in the group.
A collaboration admin can also create ordinary groups. Those work identically for authorization, but nothing guarantees the short name, so an application should rely on service groups for its own features.
Why two applications can both define a group called "editors"
An organisation may connect several applications to the same collaboration, and nothing stops two of them from naming a service group editors. Matching on a bare short name would then let a member of one application's editors group pass the other application's check.
SRAM prevents this when it provisions the group. The group it creates in the collaboration is named after both the service and the service group:
short_name = <service abbreviation>-<service group short name>
platform id = <organisation>:<collaboration>:<short_name>
Two applications defining editors therefore produce appa-editors and appb-editors as separate groups in the same collaboration, with separate memberships and separate entitlements. The prefix is applied by SRAM, not by the application, and a service group's short name cannot be changed from inside a collaboration, so the namespace holds.
This is why a feature maps to the prefixed name. Configuring editor=editors would match nothing, because no entitlement ends in :editors once SRAM has prefixed it; and stripping prefixes before matching would reintroduce exactly the collision the prefix exists to prevent.
A second boundary limits the damage of any name collision that does occur. SRAM releases only the memberships that concern the application being logged into: "Applications only receive attributes which concern the collaboration(s) to which the application is connected." A group named after this application in a collaboration it is not connected to never appears in a claim it receives.
This leaves one case the application has to handle itself, and it is the reason every feature has to name its collaboration.
The prefix looks like it should be enough. A name of the form <abbreviation>-<group> is created by SRAM, and in a collaboration where the corresponding service group exists, the name is occupied and a collaboration admin cannot take it. But that holds only for names that correspond to a service group that really exists. If a deployment configures sramdemo-editors while the service group is called editor, or before it is created at all, the name is unclaimed everywhere, and an admin of any collaboration connected to this service can create an ordinary group under it. The prefix is a naming convention, not proof of origin, and comparing strings cannot tell the two apart.
Establishing origin needs data the claim does not carry: the group's service_group_id, which the organisation API returns on every group of every collaboration in the organisation, and which marks the ones SRAM provisioned for a service. The application reads it, so an unbound feature is granted where SRAM says a service group of that name exists, and refused everywhere else. Service abbreviations are unique, so a service group's short name identifies the service that owns it.
Two limits follow from where that data comes from. It needs the organisation API token, so a deployment without one can only use bound features. And it sees one organisation, so a collaboration elsewhere is never verified and has to be named explicitly.
Handling Authorization Errors
When authorization fails, an AuthorizationError exception is raised. This exception contains context about what was required vs what the user has.
In API Routes
For JSON APIs, the default behavior returns a 403 Forbidden response:
{
"detail": "Access denied: missing required entitlements"
}
In Web Applications
For HTML applications, register an exception handler to render a user-friendly error page:
from fastapi import Request
from sram_fastapi.auth import AuthorizationError
@app.exception_handler(AuthorizationError)
async def authorization_error_handler(request: Request, exc: AuthorizationError):
return templates.TemplateResponse(
request=request,
name="forbidden.html",
status_code=403,
context={
"required": exc.required,
"actual": exc.actual,
"check_type": exc.check_type,
},
)
AuthorizationError Attributes
| Attribute | Type | Description |
|---|---|---|
required |
list[str] |
The entitlements or affiliations that were required |
actual |
list[str] |
The entitlements or affiliations the user actually has |
check_type |
str |
Either "entitlement" or "affiliation" |
require_all |
bool |
Whether all requirements needed to match (True) or just one (False) |
Complete Example
from typing import Annotated
from fastapi import Depends, FastAPI, Request
from fastapi.templating import Jinja2Templates
from sram_fastapi.auth import (
AuthorizationError,
User,
get_current_user,
require_affiliation,
require_entitlement,
)
app = FastAPI()
templates = Jinja2Templates(directory="templates")
# Exception handler for authorization errors
@app.exception_handler(AuthorizationError)
async def authz_error_handler(request: Request, exc: AuthorizationError):
return templates.TemplateResponse(
request=request,
name="forbidden.html",
status_code=403,
context={
"required": exc.required,
"actual": exc.actual,
"check_type": exc.check_type,
},
)
# Authenticated route (no additional authorization)
@app.get("/")
async def home(user: Annotated[User, Depends(get_current_user)]):
return {"user": user.name}
# Staff-only route
@app.get("/staff")
async def staff_area(
user: Annotated[User, Depends(require_affiliation("staff@"))]
):
return {"message": f"Welcome staff member {user.name}"}
# Admin route requiring specific entitlement
@app.get("/admin")
async def admin_area(
user: Annotated[User, Depends(require_entitlement("urn:example:admin"))]
):
return {"message": f"Welcome admin {user.name}"}
Best Practices
- Use entitlements for feature access - Grant specific capabilities through SRAM collaboration entitlements
- Use affiliations for organizational access - Restrict access based on institutional roles
- Prefer specific requirements - Use exact entitlement URNs rather than patterns where possible
- Handle errors gracefully - Provide clear feedback about what access is required
- Log authorization decisions - Track access attempts for security auditing
- Test authorization logic - Write unit tests for your authorization requirements