Skip to content

Commit 8fdf2e6

Browse files
Add ForbiddenError
1 parent ab605fe commit 8fdf2e6

File tree

4 files changed

+73
-27
lines changed

4 files changed

+73
-27
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.0.2] - 2023-06-16 :corn:
9+
- Raises a more specific exception `ForbiddenError` when the user of an
10+
operation is authenticated properly, but authorization fails.
11+
This enables better handling of authorization error, differentiating when the
12+
user context is missing or invalid, and when the context is valid but the
13+
user has no rights to do a certain operation. See [#371](https://github.com/Neoteroi/BlackSheep/issues/371).
14+
815
## [1.0.1] - 2023-03-20 :sun_with_face:
916
- Improves the automatic rotation of `JWKS`: when validating `JWTs`, `JWKS` are
1017
refreshed automatically if an unknown `kid` is encountered, and `JWKS` were

guardpost/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.0.1"
1+
__version__ = "1.0.2"

guardpost/authorization.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ def _is_async_handler(handler_type: Type[Requirement]) -> bool:
4444

4545

4646
class UnauthorizedError(AuthorizationError):
47+
"""
48+
Error class used for all situations in which a user initiating an operation is not
49+
authorized to complete the operation.
50+
"""
51+
4752
def __init__(
4853
self,
4954
forced_failure: Optional[str],
@@ -85,6 +90,14 @@ def _get_message(forced_failure, failed_requirements):
8590
return "Unauthorized"
8691

8792

93+
class ForbiddenError(UnauthorizedError):
94+
"""
95+
A specific kind of authorization error, used to indicate that the application
96+
understands a request but refuses to authorize it. In other words, the user context
97+
is valid but the user is not authorized to perform a certain operation.
98+
"""
99+
100+
88101
class AuthorizationContext:
89102
__slots__ = ("identity", "requirements", "_succeeded", "_failed_forced")
90103

@@ -228,6 +241,10 @@ async def _handle_with_policy(self, policy: Policy, identity: Identity, scope: A
228241
requirement.handle(context) # type: ignore
229242

230243
if not context.has_succeeded:
244+
if identity and identity.is_authenticated():
245+
raise ForbiddenError(
246+
context.forced_failure, context.pending_requirements
247+
)
231248
raise UnauthorizedError(
232249
context.forced_failure, context.pending_requirements
233250
)

tests/test_authorization.py

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from guardpost.authorization import (
99
AuthorizationContext,
1010
AuthorizationStrategy,
11+
ForbiddenError,
1112
Policy,
1213
PolicyNotFoundError,
1314
Requirement,
@@ -164,32 +165,6 @@ def request_identity_getter(request):
164165
return request.user
165166

166167

167-
@pytest.mark.asyncio
168-
async def test_authorization_identity_getter():
169-
class UserNameRequirement(Requirement):
170-
def __init__(self, expected_name: str):
171-
self.expected_name = expected_name
172-
173-
async def handle(self, context: AuthorizationContext):
174-
assert context.identity is not None
175-
176-
if context.identity.has_claim_value("name", self.expected_name):
177-
context.succeed(self)
178-
179-
auth = get_strategy(
180-
[Policy("user", UserNameRequirement("Tybek"))], request_identity_getter
181-
)
182-
183-
@auth(policy="user")
184-
async def some_method(request: Request):
185-
assert request is not None
186-
return True
187-
188-
value = await some_method(Request(User({"name": "Tybek"})))
189-
190-
assert value is True
191-
192-
193168
@pytest.mark.asyncio
194169
async def test_claims_requirement():
195170
auth = get_strategy(
@@ -422,3 +397,50 @@ async def some_method():
422397

423398
with raises(TypeError, match="Missing identity getter function."):
424399
await some_method()
400+
401+
402+
class UserNameRequirement(Requirement):
403+
def __init__(self, expected_name: str):
404+
self.expected_name = expected_name
405+
406+
async def handle(self, context: AuthorizationContext):
407+
assert context.identity is not None
408+
409+
if context.identity.has_claim_value("name", self.expected_name):
410+
context.succeed(self)
411+
412+
413+
@pytest.mark.asyncio
414+
async def test_authorization_identity_getter():
415+
auth = get_strategy(
416+
[Policy("user", UserNameRequirement("Tybek"))], request_identity_getter
417+
)
418+
419+
@auth(policy="user")
420+
async def some_method(request: Request):
421+
assert request is not None
422+
return True
423+
424+
value = await some_method(Request(User({"name": "Tybek"})))
425+
426+
assert value is True
427+
428+
429+
@pytest.mark.asyncio
430+
async def test_authorization_identity_getter_forbidden():
431+
auth = get_strategy(
432+
[Policy("user", UserNameRequirement("Tybek"))], request_identity_getter
433+
)
434+
435+
@auth(policy="user")
436+
async def some_method(request: Request):
437+
assert request is not None
438+
return True
439+
440+
with pytest.raises(UnauthorizedError):
441+
await some_method(
442+
Request(User({"some_prop": "Example"}, authentication_mode=None))
443+
)
444+
445+
with pytest.raises(ForbiddenError):
446+
await some_method(Request(User({"name": "Foo"}, authentication_mode="cookie")))

0 commit comments

Comments
 (0)