From 350f8ad1fa8f34fb1ebdd86cc82b1f90f7d12ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Ayerdis=20Espinoza?= Date: Sun, 26 Jul 2026 17:34:11 -0600 Subject: [PATCH 1/6] refactor: replace validate_lola_access with lola_access_error (Response | None) and add new decorators plus drop _get_url_pk --- testbed/core/views/decorators.py | 196 +++++++++++++++++-------------- 1 file changed, 111 insertions(+), 85 deletions(-) diff --git a/testbed/core/views/decorators.py b/testbed/core/views/decorators.py index fbb591c..236a813 100644 --- a/testbed/core/views/decorators.py +++ b/testbed/core/views/decorators.py @@ -1,7 +1,13 @@ """ Shared decorators and helpers for LOLA views. -- validate_lola_access: OAuth scope + token-to-actor binding gate for LOLA-protected endpoints +Access-control decorators: +- actor_required: resolve the URL to an Actor (404 if missing) and inject it +- lola_scope_required: strict LOLA gate - portability scope is mandatory +- lola_scope_optional: dual-mode LOLA gate - public allowed, binding still enforced + +Supporting helpers: +- lola_access_error: the gate logic behind the two decorators (Response | None) - build_auth_context: standardized auth context dict passed to JSON-LD builders - activitypub_content: sets ActivityPub content-type + CORS headers """ @@ -11,135 +17,106 @@ from django.core.exceptions import ObjectDoesNotExist -from ..utils.errors import build_actor_mismatch_error, build_insufficient_scope_error +from ..models import Actor +from ..utils.errors import ( + build_actor_mismatch_error, + build_actor_not_found_error, + build_insufficient_scope_error, +) logger = logging.getLogger(__name__) -def validate_lola_access(request, required_scope=True): +def lola_access_error(request, required_scope, url_pk): """ - Scope gate and actor binding check for LOLA-protected endpoints. + Evaluate the LOLA access gate for an actor-scoped request. + + Returns the error Response that should be sent, or None if access is allowed (no error -> None). Two-layer check, each regulated by a different condition: Layer 1 - Scope presence (controlled by `required_scope`): - Strict endpoints (required_scope=True) MUST carry a token with the activitypub_account_portability scope. - Returns 403 insufficient_scope otherwise. + Strict endpoints (required_scope=True) MUST carry a token with + the activitypub_account_portability scope. Returns 403 insufficient_scope otherwise. Dual-mode endpoints (required_scope=False) skip this layer so - unauthenticated/public traffic falls through to their public response. + unauthenticated/public traffic falls through (returns None). Layer 2 - Actor binding enforcement (LOLA Section 5 MUST): - Runs whenever a portability token is present (request.has_portability_scope) -- regardless of required_scope. - A dual-mode endpoint stays publicly readable, but the moment a portability token is supplied it must be bound - to the Actor whose is in the URL, or the request is rejected with 403 actor_mismatch. + Runs whenever a portability token is present (request.has_portability_scope), + regardless of required_scope. The token MUST be bound to the Actor whose + is in the URL, or the request is rejected with 403 actor_mismatch. Binding is persisted at token issuance by ActivityPubOAuth2Validator._save_bearer_token (the write side). This gate is the read/enforcement side. The check covers both OptionalOAuth2Authentication paths (Authorization header - and the demo-only session token) because both set request.auth to the same AccessToken instance, so - request.auth.actor_binding is available whenever request.has_portability_scope is True. + and the demo-only session token) because both set request.auth to the same AccessToken instance. - Mode summary: - required_scope=True (strict): no token -> 403 insufficient_scope; - token bound to other actor -> 403 actor_mismatch. - required_scope=False (dual-mode): no token -> public access (valid); - token bound to other actor -> 403 actor_mismatch. + Fail-closed: once a portability scope is claimed, access is allowed only if a + binding can actually be verified. A missing URL pk, a missing token object, a + missing binding row, or a binding to a different actor all return actor_mismatch. Args: - request: HTTP request with OAuth authentication attributes set by - OptionalOAuth2Authentication. request.auth is the AccessToken. - required_scope: Whether the LOLA portability scope is required (default: True). - Pass False for dual-mode endpoints that also serve public traffic but - must still reject mis-bound portability tokens. + request: DRF request; OptionalOAuth2Authentication has set + request.has_portability_scope and request.auth. + required_scope: True for strict endpoints, False for dual-mode endpoints. + url_pk: the actor pk from the URL (the value the token must be bound to). Returns: - dict: {'valid': True} on success, or - {'valid': False, 'error_response': Response} on any failure. + Response on denial, or None when access is allowed. """ - has_scope = bool(getattr(request, "has_portability_scope", False)) # Layer 1: scope presence (strict endpoints only) if required_scope and not has_scope: logger.warning("LOLA access denied: insufficient_scope for %s", request.path) - return { - "valid": False, - "error_response": build_insufficient_scope_error( - required_scope="activitypub_account_portability", - endpoint_path=request.path, - request=request, - ), - } - - # No portability token + return build_insufficient_scope_error( + required_scope="activitypub_account_portability", + endpoint_path=request.path, + request=request, + ) + + # No portability token: nothing to bind. Strict endpoints already returned above + # dual-mode endpoints fall through to their public response. if not has_scope: - return {"valid": True} + return None # Layer 2: actor binding. Reached whenever a portability token is present, so dual-mode # endpoints cannot leak another actor's augmented data to a token bound to a different actor. - url_pk = _get_url_pk(request) if url_pk is None: - # Fail closed. All LOLA actor-scoped endpoints carry in the URL, so this should not occur in normal operation. + # Fail closed. All LOLA actor-scoped endpoints carry in the URL, so this should not occur in normal operation. logger.warning( "LOLA access denied: actor binding check invoked without URL pk path=%s", request.path, ) - return { - "valid": False, - "error_response": build_actor_mismatch_error(request=request), - } + return build_actor_mismatch_error(request=request) token = getattr(request, "auth", None) if token is None: # In normal flows has_portability_scope is derived from the token, so this state should not occur; # if it does the binding is unverifiable -> fail closed rather than grant on an unverifiable claim. logger.warning( - "LOLA access denied: portability scope claimed without a token object " - "path=%s", + "LOLA access denied: portability scope claimed without a token object path=%s", request.path, ) - return { - "valid": False, - "error_response": build_actor_mismatch_error(request=request), - } + return build_actor_mismatch_error(request=request) - mismatch = _check_actor_binding(request, token, url_pk) - if mismatch is not None: - return mismatch + error = _check_actor_binding(request, token, url_pk) + if error is not None: + return error logger.info( - "LOLA access granted: scope=activitypub_account_portability " - "endpoint=%s actor_pk=%s", + "LOLA access granted: scope=activitypub_account_portability endpoint=%s actor_pk=%s", request.path, url_pk, ) - - return {"valid": True} - - -def _get_url_pk(request): - """ - Extract the actor primary key from the URL resolver kwargs. - - All LOLA-protected actor endpoints use the URL pattern - /api/actors//... so the actor pk is always available as - request.resolver_match.kwargs["pk"] when this decorator is called - from an actor-scoped view. - - Returns None if the pk is not present (e.g. unexpected endpoint shape). - The caller treats None as a fail-closed signal. - """ - resolver_match = getattr(request, "resolver_match", None) - if resolver_match is None: - return None - return resolver_match.kwargs.get("pk") + return None def _check_actor_binding(request, token, url_pk): """ Compare the token's bound Actor against the actor pk in the URL. - Returns a validation failure dict (with error_response) on mismatch or - missing binding, or None when the binding is valid. + Returns an actor_mismatch Response on a missing binding row or a binding to a + different actor, or None when the binding is valid. Failure modes: - Missing binding row (ObjectDoesNotExist on token.actor_binding): @@ -151,15 +128,11 @@ def _check_actor_binding(request, token, url_pk): binding = token.actor_binding # OneToOne reverse accessor except ObjectDoesNotExist: logger.warning( - "LOLA access denied: portability token has no actor_binding " - "token_id=%s path=%s", + "LOLA access denied: portability token has no actor_binding token_id=%s path=%s", getattr(token, "pk", None), request.path, ) - return { - "valid": False, - "error_response": build_actor_mismatch_error(request=request), - } + return build_actor_mismatch_error(request=request) if binding.actor_id != int(url_pk): logger.warning( @@ -170,14 +143,67 @@ def _check_actor_binding(request, token, url_pk): url_pk, request.path, ) - return { - "valid": False, - "error_response": build_actor_mismatch_error(request=request), - } + return build_actor_mismatch_error(request=request) return None +def _apply_lola_gate(view_func, required_scope): + """ + Wrap `view_func` so lola_access_error runs before it, short-circuiting with the error Response on denial. + Shared implementation behind lola_scope_required (required_scope=True) and lola_scope_optional (required_scope=False). + The actor pk is read from the view's URL kwargs. + """ + @wraps(view_func) + def wrapper(request, *args, **kwargs): + error = lola_access_error(request, required_scope, kwargs.get("pk")) + if error is not None: + return error + return view_func(request, *args, **kwargs) + + return wrapper + + +def lola_scope_required(view_func): + """ + Strict LOLA gate. The activitypub_account_portability scope is REQUIRED (no token -> 403 insufficient_scope), + and any token present MUST be bound to the URL actor (else 403 actor_mismatch). + Use on endpoints that expose only scope-gated data (followers, content, liked, blocked). + """ + return _apply_lola_gate(view_func, required_scope=True) + + +def lola_scope_optional(view_func): + """ + Dual-mode LOLA gate. Public access is allowed (no token -> public response), but any portability token present + MUST be bound to the URL actor (else 403 actor_mismatch). + Use on endpoints that serve public traffic and augment it for the bound actor (actor-detail, outbox, following). + """ + return _apply_lola_gate(view_func, required_scope=False) + + +def actor_required(view_func): + """ + Resolve the Actor named by the URL and inject it into the view as the + `actor` keyword argument; return 404 actor_not_found if no such actor exists. + + Stack this ABOVE the LOLA gate decorators (lola_scope_required / lola_scope_optional) so the existence check (404) + runs before the auth check (403), preserving each endpoint's 404-before-403 precedence. + """ + + @wraps(view_func) + def wrapper(request, *args, **kwargs): + pk = kwargs.get("pk") + try: + actor = Actor.objects.get(pk=pk) + except Actor.DoesNotExist: + return build_actor_not_found_error(pk, request) + kwargs["actor"] = actor + return view_func(request, *args, **kwargs) + + return wrapper + + def build_auth_context(request): """ Build the standardized authentication context dict passed to all JSON-LD builders. From 84c3141bf6d356a024bad3f9010d79b9faa75cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Ayerdis=20Espinoza?= Date: Sun, 26 Jul 2026 23:54:39 -0600 Subject: [PATCH 2/6] refactor: Applying @actor_required + LOLA gate decorators to the actor-scoped views. Dropping inline lookups and validation boilerplate --- testbed/core/views/api.py | 184 +++++++++++++------------------------- 1 file changed, 61 insertions(+), 123 deletions(-) diff --git a/testbed/core/views/api.py b/testbed/core/views/api.py index babb76d..760bf3b 100644 --- a/testbed/core/views/api.py +++ b/testbed/core/views/api.py @@ -2,17 +2,33 @@ LOLA API views Contains: -- actor_detail: ActivityPub Actor with conditional LOLA migration.* properties -- portability_outbox_detail: Outbox with LOLA content filtering -- following_collection: public Following OrderedCollection -- followers_collection: LOLA-gated Followers OrderedCollection -- content_collection: LOLA-gated raw Notes (no Activity wrappers) -- liked_collection: LOLA-gated liked objects with migration metadata -- blocked_collection: LOLA-gated block list (FEP-c648) -- oauth_authorization_server_metadata: RFC8414 discovery endpoint - -All LOLA-gated endpoints call validate_lola_access() from decorators.py. -All endpoints use build_auth_context() to produce a consistent dict for JSON-LD builders. +- actor_detail [dual-mode]: ActivityPub Actor with conditional LOLA migration.* properties +- portability_outbox_detail [dual-mode]: Outbox with LOLA content filtering +- following_collection [dual-mode]: Following OrderedCollection +- followers_collection [strict]: LOLA-gated Followers OrderedCollection +- content_collection [strict]: LOLA-gated raw Notes (no Activity wrappers) +- liked_collection [strict]: LOLA-gated liked objects with migration metadata +- blocked_collection [strict]: LOLA-gated block list (FEP-c648) +- oauth_authorization_server_metadata [public]: RFC8414 discovery endpoint (no actor) + +Access model (actor-scoped views): +Below the DRF chain (@api_view / @authentication_classes / @activitypub_content), +every actor-scoped view stacks two decorators from decorators.py: + +- @actor_required resolves -> Actor (404 actor_not_found if missing) and injects it as the `actor` argument. +- @lola_scope_required OR @lola_scope_optional is the LOLA gate. + It runs AFTER @actor_required, so the 404 existence check always precedes the 403 auth check. + +The two gate differ only in whether the portability scope is mandatory: +- @lola_scope_required (STRICT) - followers, content, liked, blocked. + No token -> 403 insufficient_scope; a token bound to a different actor -> 403 actor_mismatch. +- @lola_scope_optional (DUAL-MODE) - actor-detail, outbox, following. + Public access stays open (no token -> plain public response), but a token bound to a different + actor -> 403 actor_mismatch, so it is never served this actor's augmented/private data. + The dedicated .../migration/{outbox,following} routes reuse these same views and inherit the gate. + +Each view below therefore assumes `actor` exists and the caller is authorized for it, and documents only +what is endpoint-specific. All views build their payload via json_ld_builders, passing the dict from build_auth_context(request). """ import logging @@ -32,17 +48,20 @@ ) from ..json_ld_utils import build_actor_id, build_note_id from ..models import ( - Actor, Blocked, Followers, Following, LikeActivity, Note, - PortabilityOutbox, ) from ..oauth.authentication import OptionalOAuth2Authentication -from ..utils.errors import build_actor_not_found_error -from .decorators import activitypub_content, build_auth_context, validate_lola_access +from .decorators import ( + actor_required, + activitypub_content, + build_auth_context, + lola_scope_optional, + lola_scope_required, +) logger = logging.getLogger(__name__) @@ -50,24 +69,9 @@ @api_view(["GET"]) @authentication_classes([OptionalOAuth2Authentication]) @activitypub_content -def actor_detail(request, pk): - """ - Returns basic ActivityPub data for unauthenticated requests, - and enhanced LOLA data for authenticated requests with portability scope. - """ - try: - actor = Actor.objects.get(pk=pk) - except Actor.DoesNotExist: - return build_actor_not_found_error(pk, request) - - # Dual-mode gate: public access is allowed (required_scope=False), but a portability token - # must be bound to this actor before its scope-gated discovery surface is exposed (LOLA Section 5). - # A token bound to another actor is rejected with 403 actor_mismatch - # instead of leaking actor 's migration object / collection URLs. - validation_result = validate_lola_access(request, required_scope=False) - if not validation_result["valid"]: - return validation_result["error_response"] - +@actor_required +@lola_scope_optional +def actor_detail(request, pk, actor): # Build standardized authentication context auth_context = build_auth_context(request) @@ -79,19 +83,10 @@ def actor_detail(request, pk): @api_view(["GET"]) @authentication_classes([OptionalOAuth2Authentication]) @activitypub_content -def portability_outbox_detail(request, pk): - """ - Returns public activities for unauthenticated requests, - and all activities for authenticated requests with portability scope. - """ - try: - outbox = PortabilityOutbox.objects.get(actor_id=pk) - except PortabilityOutbox.DoesNotExist: - return build_actor_not_found_error(pk, request) - - validation_result = validate_lola_access(request, required_scope=False) - if not validation_result["valid"]: - return validation_result["error_response"] +@actor_required +@lola_scope_optional +def portability_outbox_detail(request, pk, actor): + outbox = actor.portability_outbox # Build standardized authentication context auth_context = build_auth_context(request) @@ -104,24 +99,15 @@ def portability_outbox_detail(request, pk): @api_view(["GET"]) @authentication_classes([OptionalOAuth2Authentication]) @activitypub_content -def following_collection(request, pk): +@actor_required +@lola_scope_optional +def following_collection(request, pk, actor): """ Returns who an actor is currently following in ActivityPub OrderedCollection format. Per LOLA spec: "The Following collection as per https://www.w3.org/TR/activitypub/#following SHOULD be provided on the Actor object when accessed with the account migration authorization token." - - Note: While the collection URL only appears in LOLA-authenticated Actor objects, - the collection itself follows standard ActivityPub public access patterns. + Also serves the advertised .../migration/following/ route. """ - try: - actor = Actor.objects.get(pk=pk) - except Actor.DoesNotExist: - return build_actor_not_found_error(pk, request) - - validation_result = validate_lola_access(request, required_scope=False) - if not validation_result["valid"]: - return validation_result["error_response"] - # Get all active following relationships for this actor following_qs = Following.objects.filter( actor=actor, status=Following.STATUS_ACTIVE @@ -149,22 +135,9 @@ def following_collection(request, pk): @api_view(["GET"]) @authentication_classes([OptionalOAuth2Authentication]) @activitypub_content -def followers_collection(request, pk): - """ - Returns who is currently following an actor in ActivityPub OrderedCollection format. - This is privacy-sensitive data that requires LOLA scope authentication. - Per LOLA implementation: Followers collection requires account migration authorization token. - """ - try: - actor = Actor.objects.get(pk=pk) - except Actor.DoesNotExist: - return build_actor_not_found_error(pk, request) - - # Apply centralized LOLA validation - validation_result = validate_lola_access(request, required_scope=True) - if not validation_result["valid"]: - return validation_result["error_response"] - +@actor_required +@lola_scope_required +def followers_collection(request, pk, actor): # Get all active follower relationships for this actor followers_qs = Followers.objects.filter( actor=actor, status=Followers.STATUS_ACTIVE @@ -192,26 +165,13 @@ def followers_collection(request, pk): @api_view(["GET"]) @authentication_classes([OptionalOAuth2Authentication]) @activitypub_content -def content_collection(request, pk): +@actor_required +@lola_scope_required +def content_collection(request, pk, actor): """ - Returns raw authored objects (Notes) without Activity wrappers per LOLA specification. - Applies visibility gating: unauthenticated requests receive public-only content; LOLA-authenticated - requests receive all content including non-public objects. - - Spec: "MUST provide raw authored objects (no wrapper Activities) for fidelity." - - Auth: Requires activitypub_account_portability scope. + The actor's raw authored objects (Notes) without Activity wrappers, for migration fidelity. + Spec: "MUST provide raw authored objects (no wrapper Activities) for fidelity. """ - try: - actor = Actor.objects.get(pk=pk) - except Actor.DoesNotExist: - return build_actor_not_found_error(pk, request) - - # Apply centralized LOLA validation - validation_result = validate_lola_access(request, required_scope=True) - if not validation_result["valid"]: - return validation_result["error_response"] - # Apply content filtering based on authentication and scope notes_qs = Note.objects.filter(actor=actor).order_by("-published") @@ -236,24 +196,13 @@ def content_collection(request, pk): @api_view(["GET"]) @authentication_classes([OptionalOAuth2Authentication]) @activitypub_content -def liked_collection(request, pk): +@actor_required +@lola_scope_required +def liked_collection(request, pk, actor): """ Returns objects that an actor has liked with migration-ready metadata per LOLA specification. Applies field projection to minimize payload size while retaining sufficient migration context. - - This endpoint requires LOLA scope authentication and applies field projection - to minimize payload size while providing sufficient metadata for migration. """ - try: - actor = Actor.objects.get(pk=pk) - except Actor.DoesNotExist: - return build_actor_not_found_error(pk, request) - - # Apply centralized LOLA validation - validation_result = validate_lola_access(request, required_scope=True) - if not validation_result["valid"]: - return validation_result["error_response"] - # Get all LikeActivity objects for this actor in reverse chronological order likes_qs = LikeActivity.objects.filter(actor=actor).order_by("-timestamp") @@ -325,31 +274,20 @@ def liked_collection(request, pk): @api_view(["GET"]) @authentication_classes([OptionalOAuth2Authentication]) @activitypub_content -def blocked_collection(request, pk): +@actor_required +@lola_scope_required +def blocked_collection(request, pk, actor): """ - LOLA Blocked collection endpoint. + LOLA Blocked collection endpoint (FEP-c648). - Returns actors that have been blocked by an actor in ActivityPub OrderedCollection format. + The actor's block list as an ActivityPub OrderedCollection. Per LOLA spec: "If the source server does blocking, the personal block list SHOULD be fetchable at the URL advertised on the Actor object, as per https://codeberg.org/fediverse/fep/src/branch/main/fep/c648/fep-c648.md" - This is highly privacy-sensitive data that requires LOLA scope authentication. - Block lists are critical user safety data that must never be exposed without proper authorization. - Security Note: This endpoint implements the strongest privacy protection in the entire LOLA specification, as block lists reveal who users consider threats, harassers, or sources of harm. Unauthorized access could compromise user safety. """ - try: - actor = Actor.objects.get(pk=pk) - except Actor.DoesNotExist: - return build_actor_not_found_error(pk, request) - - # Apply centralized LOLA validation - MANDATORY for blocked collection - validation_result = validate_lola_access(request, required_scope=True) - if not validation_result["valid"]: - return validation_result["error_response"] - # Get all active blocking relationships for this actor blocked_qs = Blocked.objects.filter( actor=actor, status=Blocked.STATUS_ACTIVE From 95e66ec261ed8efccb8ffb5f1057c7ff93564098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Ayerdis=20Espinoza?= Date: Sun, 26 Jul 2026 23:56:41 -0600 Subject: [PATCH 3/6] refactor: Updating re-exports on views/__init__.py --- testbed/core/views/__init__.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/testbed/core/views/__init__.py b/testbed/core/views/__init__.py index 60a6b73..0933b76 100644 --- a/testbed/core/views/__init__.py +++ b/testbed/core/views/__init__.py @@ -1,4 +1,10 @@ -from .decorators import activitypub_content, build_auth_context, validate_lola_access +from .decorators import ( + actor_required, + activitypub_content, + build_auth_context, + lola_scope_optional, + lola_scope_required, +) from .api import ( actor_detail, @@ -21,7 +27,9 @@ ) __all__ = [ - "validate_lola_access", + "actor_required", + "lola_scope_required", + "lola_scope_optional", "build_auth_context", "activitypub_content", "actor_detail", From 91a51546a41cf981954df0cc19301c77638d2521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Ayerdis=20Espinoza?= Date: Sun, 26 Jul 2026 23:59:57 -0600 Subject: [PATCH 4/6] docstring: Replacing validate_lola_access references --- testbed/core/oauth/authentication.py | 4 ++-- testbed/core/tests/conftest.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/testbed/core/oauth/authentication.py b/testbed/core/oauth/authentication.py index 5ae17ba..626a301 100644 --- a/testbed/core/oauth/authentication.py +++ b/testbed/core/oauth/authentication.py @@ -47,8 +47,8 @@ def authenticate(self, request): Both enabled paths produce the same (user, token) shape and set request.auth to the AccessToken instance. The actor binding check in - validate_lola_access() operates on request.auth and therefore covers - every path uniformly. + lola_access_error() (behind the @lola_scope_* gate decorators) operates on + request.auth and therefore covers every path uniformly. If authentication succeeds, checks if the token has the portability scope. If authentication fails, allows the request to continue as unauthenticated. diff --git a/testbed/core/tests/conftest.py b/testbed/core/tests/conftest.py index 1d7e535..0b90cb6 100644 --- a/testbed/core/tests/conftest.py +++ b/testbed/core/tests/conftest.py @@ -31,7 +31,8 @@ def create_isolated_actor(username_prefix, role=None): When `user` is given, the token is issued for that user so token.user matches actor.user; otherwise the factory creates a fresh token user. Only the token<->actor binding is what -validate_lola_access checks, so both shapes satisfy the gate. +lola_access_error() (behind the @lola_scope_* gate decorators) checks, so both shapes +satisfy the gate. """ def bind_portability_token(actor, user=None): if user is not None: From 132c4302ee8b16d228a627a4eeb14f52919bec9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Ayerdis=20Espinoza?= Date: Mon, 27 Jul 2026 00:03:55 -0600 Subject: [PATCH 5/6] tests: Migrating token-binding unit tests to lola_access_error and simpliflying _make_lola_request --- .../core/tests/test_token_actor_binding.py | 75 ++++++++----------- 1 file changed, 32 insertions(+), 43 deletions(-) diff --git a/testbed/core/tests/test_token_actor_binding.py b/testbed/core/tests/test_token_actor_binding.py index 300817e..7cb5475 100644 --- a/testbed/core/tests/test_token_actor_binding.py +++ b/testbed/core/tests/test_token_actor_binding.py @@ -16,7 +16,7 @@ ) from testbed.core.models import Actor, TokenActorBinding from testbed.core.oauth.validators import ActivityPubOAuth2Validator -from testbed.core.views.decorators import validate_lola_access +from testbed.core.views.decorators import lola_access_error # Model @@ -86,15 +86,16 @@ def test_validator_skips_binding_for_non_portability_token(): # Decorator -def _make_lola_request(actor, token): - """Build a fake request with portability scope targeting the given actor pk.""" - rf = RequestFactory() - request = rf.get(f"/api/actors/{actor.pk}/followers/") +def _make_lola_request(token): + """ + Build a fake request that claims portability scope and carries `token` as request.auth. + The actor pk the token must be bound to is passed to lola_access_error directly as url_pk, + so no request.resolver_match is needed. Pass token=None to model the "scope claimed but no token object" state. + """ + request = RequestFactory().get("/api/actors/1/followers/") request.is_oauth_authenticated = True request.has_portability_scope = True request.auth = token - request.resolver_match = MagicMock() - request.resolver_match.kwargs = {"pk": actor.pk} return request @@ -102,10 +103,9 @@ def _make_lola_request(actor, token): def test_same_actor_access_succeeds(): """Token bound to actor A may access actor A.""" binding = TokenActorBindingFactory() - request = _make_lola_request(binding.actor, binding.token) + request = _make_lola_request(binding.token) - result = validate_lola_access(request) - assert result["valid"] is True + assert lola_access_error(request, required_scope=True, url_pk=binding.actor.pk) is None @pytest.mark.django_db @@ -118,18 +118,13 @@ def test_cross_actor_access_denied(): user=user_b, username=f"{user_b.username}_src", role=Actor.ROLE_SOURCE ) - rf = RequestFactory() - request = rf.get(f"/api/actors/{actor_b.pk}/followers/") - request.is_oauth_authenticated = True - request.has_portability_scope = True - request.auth = binding.token - request.resolver_match = MagicMock() - request.resolver_match.kwargs = {"pk": actor_b.pk} + # Token bound to actor A, but the URL pk is actor B. + request = _make_lola_request(binding.token) - result = validate_lola_access(request) - assert result["valid"] is False - assert result["error_response"].status_code == 403 - assert result["error_response"].data["error_code"] == "actor_mismatch" + error = lola_access_error(request, required_scope=True, url_pk=actor_b.pk) + assert error is not None + assert error.status_code == 403 + assert error.data["error_code"] == "actor_mismatch" @pytest.mark.django_db @@ -142,12 +137,12 @@ def test_unbound_token_denied(): token = AccessTokenFactory(user=user, lola_scope=True) # Intentionally: no TokenActorBinding created. - request = _make_lola_request(actor, token) + request = _make_lola_request(token) - result = validate_lola_access(request) - assert result["valid"] is False - assert result["error_response"].status_code == 403 - assert result["error_response"].data["error_code"] == "actor_mismatch" + error = lola_access_error(request, required_scope=True, url_pk=actor.pk) + assert error is not None + assert error.status_code == 403 + assert error.data["error_code"] == "actor_mismatch" @pytest.mark.django_db @@ -155,18 +150,13 @@ def test_missing_url_pk_fails_closed(): """A LOLA request without a URL pk is rejected (fail closed, no silent skip).""" binding = TokenActorBindingFactory() - rf = RequestFactory() - request = rf.get("/api/unexpected/") - request.is_oauth_authenticated = True - request.has_portability_scope = True - request.auth = binding.token - request.resolver_match = MagicMock() - request.resolver_match.kwargs = {} # no "pk" key + # url_pk=None models an actor-scoped gate invoked without a pk in the URL + request = _make_lola_request(binding.token) - result = validate_lola_access(request) - assert result["valid"] is False - assert result["error_response"].status_code == 403 - assert result["error_response"].data["error_code"] == "actor_mismatch" + error = lola_access_error(request, required_scope=True, url_pk=None) + assert error is not None + assert error.status_code == 403 + assert error.data["error_code"] == "actor_mismatch" # Integration @@ -245,12 +235,11 @@ def test_dual_mode_cross_actor_denied(route_name): @pytest.mark.django_db def test_scope_claimed_without_token_fails_closed(): binding = TokenActorBindingFactory() - request = _make_lola_request(binding.actor, binding.token) # Anomalous state: the scope flag is set but no token object is present, so # no binding can be verified. The gate must deny, not grant. - request.auth = None + request = _make_lola_request(token=None) - result = validate_lola_access(request) - assert result["valid"] is False - assert result["error_response"].status_code == 403 - assert result["error_response"].data["error_code"] == "actor_mismatch" + error = lola_access_error(request, required_scope=True, url_pk=binding.actor.pk) + assert error is not None + assert error.status_code == 403 + assert error.data["error_code"] == "actor_mismatch" From 13fd5b88a51f99f4484d0b6fec551a8fb13bbb12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Ayerdis=20Espinoza?= Date: Mon, 27 Jul 2026 00:05:31 -0600 Subject: [PATCH 6/6] docs: Updating lola-authentication.md --- docs/lola-authentication.md | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/lola-authentication.md b/docs/lola-authentication.md index ad426dd..578b055 100644 --- a/docs/lola-authentication.md +++ b/docs/lola-authentication.md @@ -412,19 +412,23 @@ The binding is persisted inside the same `transaction.atomic()` block DOT ### Enforcement at Request Time -**Location**: `testbed/core/views/decorators.py` - `validate_lola_access` +**Location**: `testbed/core/views/decorators.py` — the `@lola_scope_required` / +`@lola_scope_optional` gate decorators (stacked under `@actor_required`), backed +by `lola_access_error(request, required_scope, url_pk)`. -Every LOLA actor-scoped endpoint calls `validate_lola_access(request, ...)`, -which runs two layers governed by *different* conditions: +Every LOLA actor-scoped view is decorated with `@actor_required` above a gate +decorator. The gate runs `lola_access_error(...)` and short-circuits with the +error `Response` it returns (or `None` to let the request through). It runs two +layers governed by *different* conditions: -- **Layer 1 - Scope presence (controlled by `required_scope`).** Strict - endpoints (`required_scope=True`) reject requests without +- **Layer 1 - Scope presence (the choice of gate decorator).** Strict endpoints + (`@lola_scope_required`) reject requests without `activitypub_account_portability` scope with 403 `insufficient_scope`. - Dual-mode endpoints (`required_scope=False`) skip this layer so public + Dual-mode endpoints (`@lola_scope_optional`) skip this layer so public traffic passes through to their public response. - **Layer 2 - Actor binding check (runs whenever a portability token is - present, regardless of `required_scope`).** Reads the requested actor pk from - `request.resolver_match.kwargs["pk"]` and compares it to + present, regardless of the decorator).** Compares the requested actor pk (the + view's URL ``, passed into the gate) to `request.auth.actor_binding.actor_id`. Mismatches, missing binding rows, a missing URL pk, and a claimed scope with no token object all return 403 `actor_mismatch` (fail closed). @@ -439,7 +443,7 @@ Token-to-actor binding is enforced on **every** actor-scoped LOLA endpoint that can expose scope-gated data — both the strict collections and the dual-mode endpoints. -**Strict endpoints** — `validate_lola_access(request, required_scope=True)`. +**Strict endpoints** — `@lola_scope_required`. No portability scope ⇒ 403 `insufficient_scope`; wrong actor ⇒ 403 `actor_mismatch`: @@ -448,7 +452,7 @@ No portability scope ⇒ 403 `insufficient_scope`; wrong actor ⇒ 403 - `GET /api/actors//liked/` - `GET /api/actors//blocked/` (and `…/migration/blocked/`) -**Dual-mode endpoints** — `validate_lola_access(request, required_scope=False)`. +**Dual-mode endpoints** — `@lola_scope_optional`. No token ⇒ public response (200); a portability token bound to a *different* actor ⇒ 403 `actor_mismatch`: @@ -506,7 +510,7 @@ Two core ActivityPub endpoints have been enhanced with LOLA authentication suppo | ✅ Portability token bound to **this** actor | Enhanced Actor with LOLA discovery fields | | ⛔ Portability token bound to a **different** actor | `403 actor_mismatch` (dual-mode binding) | -This is a dual-mode endpoint: it calls `validate_lola_access(request, required_scope=False)`, so anonymous access stays a normal public response, but a portability token must be bound to `` before the LOLA fields are exposed (see [Endpoints Enforcing Binding](#endpoints-enforcing-binding)). +This is a dual-mode endpoint (`@lola_scope_optional`), so anonymous access stays a normal public response, but a portability token must be bound to `` before the LOLA fields are exposed (see [Endpoints Enforcing Binding](#endpoints-enforcing-binding)). #### LOLA Fields Added (when authenticated with portability scope) @@ -598,7 +602,7 @@ This is a dual-mode endpoint: it calls `validate_lola_access(request, required_s | ✅ Portability token bound to **this** actor | ALL activities (public + private) | | ⛔ Portability token bound to a **different** actor | `403 actor_mismatch` (dual-mode binding) | -Like actor-detail, this is a dual-mode endpoint (`validate_lola_access(request, required_scope=False)`): a mis-bound portability token is rejected rather than served another actor's private activities. The same applies to the advertised `…/migration/outbox/` route (same view). +Like actor-detail, this is a dual-mode endpoint (`@lola_scope_optional`): a mis-bound portability token is rejected rather than served another actor's private activities. The same applies to the advertised `…/migration/outbox/` route (same view). #### Response Structure