andrewmusselman commented on code in PR #1207:
URL: 
https://github.com/apache/tooling-trusted-releases/pull/1207#discussion_r3171424382


##########
atr/blueprints/api_auth.py:
##########
@@ -0,0 +1,232 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Explicit authentication level decorators for API endpoints.
+
+Every route decorated with :func:`atr.blueprints.api.typed` must also be
+decorated with exactly one of the auth level decorators in this module.
+The :func:`atr.blueprints.api.typed` decorator enforces this at import
+time; forgetting the decorator raises :class:`TypeError` and the server
+will not start.
+
+Usage::
+
+    @api.typed
+    @api.auth.bearer
+    @quart_schema.validate_response(models.api.FooResults, 200)
+    async def foo(...):
+        ...
+
+Decorator order: ``@api.typed`` on the outside, then ``@api.auth.<level>``,
+then any ``@quart_schema`` or ``@rate_limiter`` decorators closer to the
+function.
+
+The levels are:
+
+- ``public``     No authentication. Route may be called by anyone.
+- ``bearer``     ATR-issued JWT in an ``Authorization: Bearer ...`` header.
+- ``body_oidc``  Trusted Publisher OIDC token carried in the request body.
+- ``pat``        Personal Access Token in the request body, exchanged for a 
JWT.
+
+For ``body_oidc`` routes, the verified OIDC payload and the resolved
+ASF UID (if any) are exposed via :func:`trusted_publisher_context` /
+``quart.g.tp_payload`` / ``quart.g.tp_asf_uid`` after the decorator runs.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+import functools
+from typing import TYPE_CHECKING, Final, Literal
+
+import quart
+import quart_schema
+import werkzeug.exceptions as exceptions
+
+import atr.jwtoken as jwtoken
+
+if TYPE_CHECKING:
+    from collections.abc import Awaitable, Callable, Coroutine
+    from typing import Any
+
+    import atr.models.github as github
+
+
+AuthLevel = Literal["public", "bearer", "body_oidc", "pat"]
+
+# Public name so external tests and the typed() enforcer agree.
+AUTH_LEVEL_ATTR: Final[str] = "_api_auth_level"
+
+VALID_LEVELS: Final[frozenset[AuthLevel]] = frozenset({"public", "bearer", 
"body_oidc", "pat"})
+
+
+def bearer[**P, R](
+    func: Callable[P, Coroutine[Any, Any, R]],
+) -> Callable[P, Awaitable[R]]:
+    """Require an ATR-issued Bearer JWT in the ``Authorization`` header.
+
+    Folds in two concerns that previously had to be declared separately
+    on every bearer endpoint:
+
+    1. ``@jwtoken.require`` — validates the JWT and populates
+       ``quart.g.jwt_claims`` so handlers can read the caller's identity
+       via ``_jwt_asf_uid()``.
+    2. ``@quart_schema.security_scheme([{"BearerAuth": []}])`` — advertises
+       the scheme in the generated OpenAPI document.
+
+    The auth-level marker is applied to the outermost wrapper so
+    :func:`atr.blueprints.api.typed` can read it back.
+    """
+    wrapped = jwtoken.require(func)
+    wrapped = quart_schema.security_scheme([{"BearerAuth": []}])(wrapped)
+    _mark("bearer", wrapped)
+    return wrapped
+
+
[email protected](frozen=True)
+class TrustedPublisherContext:
+    """Verified state exposed to ``body_oidc`` handlers after the decorator 
runs.
+
+    Handlers retrieve this via :func:`trusted_publisher_context`, or 
equivalently
+    via ``quart.g.tp_context``. The fields are:
+
+    - ``payload``  The verified :class:`github.TrustedPublisherPayload`.
+    - ``asf_uid``  The ASF user the GitHub actor maps to, or ``None`` when the
+                   token was issued to the trusted role (i.e. an ATR-driven
+                   workflow, not a project's TP workflow).
+    - ``publisher`` The publisher string from the body (currently only
+                   ``"github"`` is supported).
+    """
+
+    payload: github.TrustedPublisherPayload
+    asf_uid: str | None
+    publisher: str
+
+
+_TP_CONTEXT_ATTR: Final[str] = "tp_context"
+
+
+def body_oidc[F: Callable[..., Awaitable[Any]]](func: F) -> F:
+    """Validate a Trusted Publisher OIDC token carried in the request body.
+
+    The handler must accept a ``data`` keyword argument whose value is a
+    pydantic model with ``publisher: str`` and ``jwt: str`` fields. This
+    decorator runs *after* ``@quart_schema.validate_request``, so ``data``
+    is already a validated model by the time this wrapper executes.
+
+    On success, a :class:`TrustedPublisherContext` is placed on
+    ``quart.g.tp_context`` and the handler runs normally. Handlers read
+    the context via :func:`trusted_publisher_context`.
+
+    On failure (bad signature, wrong audience, unsupported publisher,
+    unknown actor) a 401 ``ASFQuartException`` is raised before the
+    handler body executes, so handlers never see unauthenticated calls.
+
+    Phase-specific checks (e.g. "this release is in the COMPOSE phase")
+    remain the handler's responsibility — the decorator is deliberately
+    narrow.
+    """
+
+    @functools.wraps(func)
+    async def wrapper(*args: Any, **kwargs: Any) -> Any:
+        data = kwargs.get("data")
+        if data is None:
+            # If @quart_schema.validate_request wasn't applied the handler
+            # is misconfigured; we can't authenticate a body we don't have.
+            raise exceptions.BadRequest("Trusted Publisher auth requires a 
validated request body")
+        publisher = getattr(data, "publisher", None)
+        jwt = getattr(data, "jwt", None)
+        if (not isinstance(publisher, str)) or (not isinstance(jwt, str)):
+            raise exceptions.BadRequest(
+                "Trusted Publisher auth requires 'publisher' and 'jwt' string 
fields in the request body"
+            )
+
+        # Import lazily to avoid a top-level cycle: atr.db.interaction
+        # depends on a lot of things, some of which depend on the blueprints.
+        import atr.db.interaction as interaction
+
+        payload, asf_uid = await interaction.validate_trusted_jwt(publisher, 
jwt)

Review Comment:
   You're right, the 401 claim was aspirational. I made the docstring true: 
`body_oidc` now catches `interaction.InteractionError` (unsupported publisher), 
`pyjwt.InvalidTokenError` (bad signature, expired, malformed JWT), and 
`pydantic.ValidationError` (payload doesn't match `TrustedPublisherPayload` 
schema) from `validate_trusted_jwt(...)`, and converts each to 
`ASFQuartException(errorcode=401)`. Pre-existing `ASFQuartException` is 
re-raised unchanged so 502s for genuine upstream failures (TLS errors fetching 
JWKS, etc.) keep their status. Body validation errors (missing data, missing 
publisher/jwt fields) also map to 401. Two affected unit tests updated.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to