Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [3.8, 3.9, "3.10", "3.11"]
python-version: [3.9, "3.10", "3.11", "3.12", "3.13"]

steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v4
with:
fetch-depth: 9
submodules: false
Expand All @@ -33,7 +33,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

- uses: actions/cache@v1
- uses: actions/cache@v4
id: depcache
with:
path: deps
Expand Down Expand Up @@ -69,7 +69,7 @@ jobs:
for f in ./examples/*.py; do echo "Processing $f file..." && python $f; done

- name: Upload pytest test results
uses: actions/upload-artifact@master
uses: actions/upload-artifact@v4
with:
name: pytest-results-${{ matrix.python-version }}
path: junit/pytest-results-${{ matrix.python-version }}.xml
Expand All @@ -81,34 +81,34 @@ jobs:

- name: Install distribution dependencies
run: pip install --upgrade build
if: matrix.python-version == 3.10
if: matrix.python-version == 3.12

- name: Create distribution package
run: python -m build
if: matrix.python-version == 3.10
if: matrix.python-version == 3.12

- name: Upload distribution package
uses: actions/upload-artifact@master
uses: actions/upload-artifact@v4
with:
name: dist
path: dist
if: matrix.python-version == 3.10
if: matrix.python-version == 3.12

publish:
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'release'
steps:
- name: Download a distribution artifact
uses: actions/download-artifact@v2
uses: actions/download-artifact@v4
with:
name: dist
path: dist

- name: Use Python 3.11
uses: actions/setup-python@v1
- name: Use Python 3.12
uses: actions/setup-python@v3
with:
python-version: '3.11'
python-version: "3.12"

- name: Install dependencies
run: |
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,37 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.3] - 2025-10-04 :trident:

- Add a `roles` property to the `Identity` object.
- Add a `RolesRequirement` class to authorize by **sufficient roles**
(any one is enough).
- Add support for validating JWTs signed using symmetric encryption
(`SymmetricJWTValidator` and `AsymmetricJWTValidator`).
- Add support to call the `authorize` method with an optional set of roles,
treated as sufficient roles to succeed authorization.
- Add Python `3.12` and `3.13` to the build matrix.
- Remove Python `3.8` from the build matrix.
- Improve `pyproject.toml`.
- Workflow maintenance.

## [1.0.2] - 2023-06-16 :corn:

- Raises a more specific exception `ForbiddenError` when the user of an
operation is authenticated properly, but authorization fails.
This enables better handling of authorization error, differentiating when the
user context is missing or invalid, and when the context is valid but the
user has no rights to do a certain operation. See [#371](https://github.com/Neoteroi/BlackSheep/issues/371).

## [1.0.1] - 2023-03-20 :sun_with_face:

- Improves the automatic rotation of `JWKS`: when validating `JWTs`, `JWKS` are
refreshed automatically if an unknown `kid` is encountered, and `JWKS` were
last fetched more than `refresh_time` seconds ago (by default 120 seconds).
- Corrects an inconsistency in how `claims` are read in the `User` class.

## [1.0.0] - 2023-01-07 :star:

- Adds built-in support for dependency injection, using the new `ContainerProtocol`
in `rodi` v2.
- Removes the synchronous code API, maintaining only the asynchronous code API
Expand All @@ -29,24 +46,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Corrects `Identity.__getitem__` to raise `KeyError` if a claim is missing.

## [0.1.0] - 2022-11-06 :snake:

- Workflow maintenance.

## [0.0.9] - 2021-11-14 :swan:

- Adds `sub`, `access_token`, and `refresh_token` properties to the `Identity`.
class
- Adds `py.typed` file.

## [0.0.8] - 2021-10-31 :shield:

- Adds classes to handle `JWT`s validation, but only for `RSA` keys.
- Fixes issue (wrong arrangement in test) #5.
- Includes `Python 3.10` in the CI/CD matrix.
- Enforces `black` and `isort` in the CI pipeline.

## [0.0.7] - 2021-01-31 :grapes:

- Corrects a bug in the `Policy` class (#2).
- Changes the type annotation of `Identity` claims (#3).

## [0.0.6] - 2020-12-12 :octocat:

- Completely migrates to GitHub Workflows.
- Improves build to test Python 3.6 and 3.9.
- Adds a changelog.
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2019 Roberto Prevato
Copyright (c) 2019-present Roberto Prevato roberto.prevato@gmail.com

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
2 changes: 1 addition & 1 deletion guardpost/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.0.2"
__version__ = "1.0.3"
9 changes: 9 additions & 0 deletions guardpost/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ def __init__(
def sub(self) -> Optional[str]:
return self.get("sub")

@property
def roles(self) -> Optional[str]:
return self.get("roles")

def is_authenticated(self) -> bool:
return bool(self.authentication_mode)

Expand All @@ -43,6 +47,11 @@ def has_claim(self, name: str) -> bool:
def has_claim_value(self, name: str, value: str) -> bool:
return self.claims.get(name) == value

def has_role(self, name: str) -> bool:
if not self.roles:
return False
return name in self.roles


class User(Identity):
@property
Expand Down
91 changes: 73 additions & 18 deletions guardpost/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,29 @@ async def handle(self, context: "AuthorizationContext"):
"""Handles this requirement for a given context."""


class RolesRequirement(Requirement):
"""
Requires an identity with certain roles.
Supports defining sufficient roles (any one is enough).
"""

__slots__ = ("_roles",)

def __init__(self, roles: Optional[Sequence[str]] = None):
self._roles = list(roles) if roles else None

def handle(self, context: "AuthorizationContext"):
identity = context.identity

if not identity:
context.fail("Missing identity")
return

if self._roles:
if any(identity.has_role(name) for name in self._roles):
context.succeed(self)


RequirementConfType = Union[Requirement, Type[Requirement]]


Expand Down Expand Up @@ -208,46 +231,78 @@ def with_default_policy(self, policy: Policy) -> "AuthorizationStrategy":
return self

async def authorize(
self, policy_name: Optional[str], identity: Identity, scope: Any = None
self,
policy_name: Optional[str],
identity: Identity,
scope: Any = None,
roles: Optional[Sequence[str]] = None,
):
if policy_name:
policy = self.get_policy(policy_name)

if not policy:
raise PolicyNotFoundError(policy_name)

await self._handle_with_policy(policy, identity, scope)
await self._handle_with_policy(policy, identity, scope, roles)
else:
if self.default_policy:
await self._handle_with_policy(self.default_policy, identity, scope)
await self._handle_with_policy(
self.default_policy, identity, scope, roles
)
return

if roles:
# This code is only executed if the user specified roles without
# specifying an authorization policy.
await self._handle_with_roles(identity, roles)
return

if not identity:
raise UnauthorizedError("Missing identity", [])
if not identity.is_authenticated():
raise UnauthorizedError("The resource requires authentication", [])

def _get_requirements(self, policy: Policy, scope: Any) -> Iterable[Requirement]:
def _get_requirements(
self, policy: Policy, scope: Any, roles: Optional[Sequence[str]] = None
) -> Iterable[Requirement]:
if roles:
yield RolesRequirement(roles=roles)
yield from self._get_instances(policy.requirements, scope)

async def _handle_with_policy(self, policy: Policy, identity: Identity, scope: Any):
async def _handle_with_policy(
self,
policy: Policy,
identity: Identity,
scope: Any,
roles: Optional[Sequence[str]] = None,
):
with AuthorizationContext(
identity, list(self._get_requirements(policy, scope))
identity, list(self._get_requirements(policy, scope, roles))
) as context:
for requirement in context.requirements:
if _is_async_handler(type(requirement)): # type: ignore
await requirement.handle(context)
else:
requirement.handle(context) # type: ignore

if not context.has_succeeded:
if identity and identity.is_authenticated():
raise ForbiddenError(
context.forced_failure, context.pending_requirements
)
raise UnauthorizedError(
await self._handle_context(identity, context)

async def _handle_with_roles(
self, identity: Identity, roles: Optional[Sequence[str]] = None
):
# This method is to be used only when the user specified roles without a policy
with AuthorizationContext(identity, [RolesRequirement(roles=roles)]) as context:
await self._handle_context(identity, context)

async def _handle_context(self, identity: Identity, context: AuthorizationContext):
for requirement in context.requirements:
if _is_async_handler(type(requirement)): # type: ignore
await requirement.handle(context)
else:
requirement.handle(context) # type: ignore

if not context.has_succeeded:
if identity and identity.is_authenticated():
raise ForbiddenError(
context.forced_failure, context.pending_requirements
)
raise UnauthorizedError(
context.forced_failure, context.pending_requirements
)

async def _handle_with_identity_getter(
self, policy_name: Optional[str], *args, **kwargs
Expand Down
Loading