Skip to content

Commit

Permalink
[SDESK-7484] Create async Content API Subscriber Token and Auth (#2825)
Browse files Browse the repository at this point in the history
* Add HATEOAS links for async REST API to eve homepoint

* Add cors headers to Resource REST API

* Implement `content_api` token resource and service

SDESK-7484

* Implement async TokenAuth for `ContentApi`

SDESK-7484

* Small fixes

SDESK-7484

* Fix black

* Add mock sync auth to avoid breaking the app

SDESK-7484

* Fix content_api.items which depends on legacy auth

SDESK-7484

* Move related links generation to ResourceModel

Move related links generation from REST endpoints to ResourceModel for better encapsulation and reusability.
Cache relationships at class creation time to avoid potential performance issues because of the extraction of annotations.
Add utilities for consistent URL generation and annotation handling across class hierarchies.

* Fix `mypy` complains. Some type errors ignored

Added some TODO-ASYNC comments to some ignored type errors for now

* Fix flake8 complains

SDESK-7484

* Split `resource_endpoints_test.py` as it is getting too big

SDESK-7484

* Fix broken tests

SDESK-7484

* Rename from `nose-tests.yml` to `tests.yml` simply

We no longer use `nose` so there is not reason to have the file name with it

* Fix content_api tests

SDESK-7484

* Add tests for related items in hateoas

SDESK-7484

---------

Co-authored-by: Mark Pittaway <[email protected]>
  • Loading branch information
eos87 and MarkLark86 authored Feb 10, 2025
1 parent c39fc7c commit 5bf9eff
Show file tree
Hide file tree
Showing 30 changed files with 731 additions and 274 deletions.
File renamed without changes.
6 changes: 4 additions & 2 deletions content_api/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from eve.io.mongo.mongo import MongoJSONEncoder

from superdesk.flask import Config
from content_api.tokens import SubscriberTokenAuth
from content_api.tokens.auth import LegacyTokenAuth
from superdesk.datalayer import SuperdeskDataLayer
from superdesk.factory.elastic_apm import setup_apm
from superdesk.validator import SuperdeskValidator
Expand Down Expand Up @@ -63,7 +63,7 @@ def get_app(config=None):
media_storage = get_media_storage_class(app_config)

app = SuperdeskEve(
auth=SubscriberTokenAuth,
auth=LegacyTokenAuth,
settings=app_config,
data=SuperdeskDataLayer,
media=media_storage,
Expand All @@ -81,6 +81,8 @@ def get_app(config=None):
except AttributeError:
pass

app.async_app.start()

return app


Expand Down
3 changes: 3 additions & 0 deletions content_api/app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@

MODULES = [
"content_api.items.module",
"superdesk.publish.subscriber_token",
]

ASYNC_AUTH_CLASS = "content_api.tokens.auth:SubscriberTokenAuth"

CONTENTAPI_DOMAIN = {}

# NOTE: no trailing slash for the CONTENTAPI_URL setting!
Expand Down
1 change: 0 additions & 1 deletion content_api/tests/search_service_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
from werkzeug.datastructures import MultiDict

from superdesk.flask import Flask
from content_api.tests import ApiTestCase


class SearchServiceTestCase(IsolatedAsyncioTestCase):
Expand Down
50 changes: 3 additions & 47 deletions content_api/tokens/__init__.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,4 @@
# -*- coding: utf-8; -*-
#
# This file is part of Superdesk.
#
# Copyright 2013, 2014, 2015 Sourcefabric z.u. and contributors.
#
# For the full copyright and license information, please see the
# AUTHORS and LICENSE files distributed with this source code, or
# at https://www.sourcefabric.org/superdesk/license
from .resource import CompanyTokenResource
from .service import CompanyTokenService

import superdesk

from eve.auth import TokenAuth

from superdesk.core import get_current_app
from superdesk.flask import g
from superdesk.utc import utcnow
from superdesk.publish.subscriber_token import SubscriberTokenResource, SubscriberTokenService

from content_api.tokens.resource import CompanyTokenResource # noqa
from content_api.tokens.service import CompanyTokenService # noqa


TOKEN_RESOURCE = "subscriber_token"


class AuthSubscriberTokenResource(SubscriberTokenResource):
item_methods = []
resource_methods = []


class SubscriberTokenAuth(TokenAuth):
def check_auth(self, token, allowed_roles, resource, method):
"""Try to find auth token and if valid put subscriber id into ``g.user``."""
app = get_current_app()
data = app.data.mongo.find_one(TOKEN_RESOURCE, req=None, _id=token)
if not data:
return False
now = utcnow()
if data.get("expiry") and data.get("expiry") < now:
app.data.mongo.remove(TOKEN_RESOURCE, {"_id": token})
return False
g.user = str(data.get("subscriber"))
return g.user


def init_app(app) -> None:
superdesk.register_resource(TOKEN_RESOURCE, AuthSubscriberTokenResource, SubscriberTokenService, _app=app)
__all__ = ["CompanyTokenResource", "CompanyTokenService"]
90 changes: 90 additions & 0 deletions content_api/tokens/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from eve.auth import TokenAuth

from superdesk.utc import utcnow
from superdesk.errors import SuperdeskApiError
from superdesk.core.types.web import AuthRule, Request
from superdesk.core.auth.user_auth import UserAuthProtocol
from superdesk.core.auth.rules import endpoint_intrinsic_auth_rule
from superdesk.publish.subscriber_token import SubscriberTokenService, SubscriberToken


# TODO-ASYNC: Needed to avoid the content_api items endpoint from crashing
# as it relies on the `user` stored in the `g` object. Once items are migrated
# we should remove this
class LegacyTokenAuth(TokenAuth):
def check_auth(self, token, allowed_roles, resource, method):
"""Try to find auth token and if valid put subscriber id into ``g.user``."""
from superdesk.flask import g

data = SubscriberTokenService().mongo.find_one(token)
if not data:
return False
now = utcnow()
if data.get("expiry") and data.get("expiry") < now:
SubscriberTokenService().mongo.delete_one({"_id": token})
return False
g.user = str(data.get("subscriber"))
return g.user


class SubscriberTokenAuth(UserAuthProtocol):
def get_default_auth_rules(self) -> list[AuthRule]:
return [endpoint_intrinsic_auth_rule]

def get_token_from_request(self, request: Request) -> str | None:
"""
Extracts the token from `Authorization` header. Code taken partly
from eve.Auth module
"""

auth = (request.get_header("Authorization") or "").strip()
if len(auth):
if auth.lower().startswith(("token", "bearer", "basic")):
return auth.split(" ")[1] if " " in auth else None
return auth

return None

async def authenticate(self, request: Request) -> None:
"""
Tries to find the auth token in the request and if valid put subscriber id into ``g.user``.
"""
token_service = SubscriberTokenService()
token_missing_exception = SuperdeskApiError.forbiddenError(message="Authorization token missing.")
token_id = self.get_token_from_request(request)

if token_id is None:
raise token_missing_exception

token = await token_service.find_by_id(token_id)
if token is None:
await self.stop_session(request)
raise token_missing_exception

await self.check_token_validity(token)
await self.start_session(request, token)

async def check_token_validity(self, token: SubscriberToken) -> None:
"""
Checks if the token is valid and if it has expired.
"""

if token.expiry and token.expiry < utcnow():
await SubscriberTokenService().delete(token)
raise SuperdeskApiError.forbiddenError(message="Authorization token expired.")

async def start_session(self, request: Request, token: SubscriberToken) -> None: # type: ignore[override]
"""
Puts the subscriber id into ``g.user``.
"""
request.storage.request.set("user", str(token.subscriber))

async def stop_session(self, request: Request) -> None:
"""
Removes the subscriber id from ``g.user``.
"""
request.storage.request.set("user", None)

def get_current_user(self, request: Request) -> str | None:
"""Overrides as it is needed."""
return None
2 changes: 1 addition & 1 deletion superdesk/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
# AUTHORS and LICENSE files distributed with this source code, or
# at https://www.sourcefabric.org/superdesk/license

from typing import Dict, List, Optional, Any, cast
import importlib
from typing import Dict, List, Optional, Any, cast

from superdesk.core.types import WSGIApp

Expand Down
6 changes: 3 additions & 3 deletions superdesk/core/auth/token_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,13 @@ async def start_session(self, request: Request, user: dict[str, Any], **kwargs)
async def continue_session(self, request: Request, user: dict[str, Any], **kwargs) -> None:
auth_token = request.storage.session.get("session_token")

if isinstance(auth_token, str):
auth_token = json.loads(auth_token)

if not auth_token:
await self.stop_session(request)
raise SuperdeskApiError.unauthorizedError()

if isinstance(auth_token, str):
auth_token = json.loads(auth_token)

user_service = get_resource_service("users")
request.storage.request.set("user", user)
request.storage.request.set("role", user_service.get_role(user))
Expand Down
66 changes: 52 additions & 14 deletions superdesk/core/resources/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@
Any,
ClassVar,
cast,
get_origin,
get_args,
)
from typing_extensions import dataclass_transform, Self
from dataclasses import field as dataclass_field
from copy import deepcopy
from inspect import get_annotations
from datetime import datetime
from dataclasses import field as dataclass_field
from typing_extensions import dataclass_transform, Self

from pydantic import (
ConfigDict,
Expand All @@ -32,13 +33,13 @@
SerializerFunctionWrapHandler,
RootModel,
)
from pydantic_core import InitErrorDetails, PydanticCustomError, from_json
from pydantic.dataclasses import dataclass as pydataclass
from pydantic_core import InitErrorDetails, PydanticCustomError, from_json

from superdesk.core.types import BaseModel
from superdesk.core.utils import generate_guid, GUID_NEWSML

from .utils import get_model_aliased_fields
from .utils import get_model_aliased_fields, get_model_annotations, gen_url_for_related_resource
from .fields import ObjectId


Expand Down Expand Up @@ -131,6 +132,9 @@ class ResourceModel(BaseModel):
model_config = default_model_config
model_resource_name: ClassVar[str] # Automatically set when registered

# class variable to store the relations
_related_field_definitions: ClassVar[dict[str, str]] = {} # field_name -> resource_name

@computed_field(alias="_type") # type: ignore[misc]
@property
def type(self) -> str:
Expand All @@ -148,6 +152,29 @@ def type(self) -> str:
#: Datetime the document was last updated
updated: Annotated[Optional[datetime], Field(alias="_updated")] = None

def __init_subclass__(cls) -> None:
"""
Examines the model class annotations for Annotated fields that contain AsyncValidator metadata.
For each field with a resource name specified in its validator, stores the relation in
_related_field_definitions.
This is done at class creation time to:
1. Cache the relationships to avoid repeated annotation inspection
2. Allow lazy generation of related links when needed
3. We use __init_subclass__ as ResourceModel is designed to be subclassed, not instantiated directly
"""
from .validators import AsyncValidator

super().__init_subclass__()

cls._related_field_definitions = {}
for field_name, annotation in get_model_annotations(cls).items():
if get_origin(annotation) is Annotated:
relations = [meta for meta in get_args(annotation) if isinstance(meta, AsyncValidator)]
for rel in relations:
if rel.resource_name:
cls._related_field_definitions[field_name] = rel.resource_name

async def validate_async(self):
await _run_async_validators_from_model_class(self, self)

Expand Down Expand Up @@ -229,6 +256,25 @@ async def get_user_email(user_id: ObjectId) -> str | None:
app = get_current_async_app()
return app.resources.get_resource_service(cls.model_resource_name)

@classmethod
def get_related_links(cls, item: dict[str, Any]) -> dict[str, Any]:
"""Generate related links for a dictionary representation of the model.
This allows generating links without needing to create a model instance,
useful when working with raw data from the database.
:param item: Dictionary containing the raw data
:return: Dictionary of related links
"""
links = {}
for field_name, resource_name in cls._related_field_definitions.items():
if field_value := item.get(field_name):
links[field_name] = {
"title": field_name.title(),
"href": gen_url_for_related_resource(resource_name, str(field_value)),
}
return links


async def _run_async_validators_from_model_class(
model_instance: Any, root_item: ResourceModel, field_name_stack: Optional[List[str]] = None
Expand All @@ -238,15 +284,7 @@ async def _run_async_validators_from_model_class(
if field_name_stack is None:
field_name_stack = []

model_class = model_instance.__class__

try:
annotations = {}
for base_class in reversed(model_class.__mro__):
if base_class != ResourceModel and issubclass(base_class, ResourceModel):
annotations.update(get_annotations(base_class))
except (TypeError, AttributeError):
annotations = get_annotations(model_class)
annotations = get_model_annotations(model_instance.__class__)

for field_name, annotation in annotations.items():
value = getattr(model_instance, field_name)
Expand Down
Loading

0 comments on commit 5bf9eff

Please sign in to comment.