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
18 changes: 10 additions & 8 deletions .github/scripts/fetch_auth0_test_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@

def main() -> int:
url = f"https://{os.environ['AUTH0_DOMAIN']}/oauth/token"
payload = json.dumps(
{
"client_id": os.environ["AUTH0_CLIENT_ID"],
"client_secret": os.environ["AUTH0_CLIENT_SECRET"],
"audience": os.environ["AUTH0_AUDIENCE"],
"grant_type": "client_credentials",
}
).encode("utf-8")
token_request = {
"client_id": os.environ["AUTH0_CLIENT_ID"],
"client_secret": os.environ["AUTH0_CLIENT_SECRET"],
"audience": os.environ["AUTH0_AUDIENCE"],
"grant_type": "client_credentials",
}
scope = os.environ.get("AUTH0_TEST_TOKEN_SCOPES")
if scope:
token_request["scope"] = scope
payload = json.dumps(token_request).encode("utf-8")

request = urllib.request.Request(
url,
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy-staged.yml
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ jobs:
AUTH0_AUDIENCE: ${{ secrets.AUTH0_AUDIENCE_NO_DOMAIN }}
AUTH0_CLIENT_ID: ${{ secrets.AUTH0_TEST_TOKEN_CLIENT_ID }}
AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_TEST_TOKEN_CLIENT_SECRET }}
AUTH0_TEST_TOKEN_SCOPES: ${{ secrets.AUTH0_TEST_TOKEN_SCOPES }}
run: python .github/scripts/fetch_auth0_test_token.py

- name: Run deployed integration tests
Expand Down
2 changes: 2 additions & 0 deletions changelog.d/calculate-analytics-requests-endpoint.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Added an authenticated, scoped calculate analytics endpoint for value-free
request and unique variable-key queries.
6 changes: 5 additions & 1 deletion config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ auth:
address: Auth0 domain (without https:// or trailing slash)
audience: Auth0 audience/API identifier
test_token: JWT token used only for pre-deployment GitHub Actions tests
test_token_scopes: Space-delimited OAuth scopes for the static test token

ai:
enabled: Whether AI features are enabled (true/false) (these features are only used in the alpha-mode AI explainer endpoint)
Expand Down Expand Up @@ -308,6 +309,7 @@ AUTH0_AUDIENCE_NO_DOMAIN=https://your-api-identifier
When Auth0 is enabled, the following endpoints require valid JWT tokens:
- `/<country_id>/calculate` - Main calculation endpoint
- `/<country_id>/ai-analysis` - AI analysis endpoint (remains in alpha)
- `/analytics/calculate/requests` - Calculate analytics endpoint; additionally requires the `read:calculate-analytics` scope

The following endpoints remain unprotected:
- `/` - Home endpoint
Expand Down Expand Up @@ -341,6 +343,7 @@ AUTH__ENABLED=true # Enable Auth0 authentication
AUTH0_ADDRESS_NO_DOMAIN=${{ secrets.AUTH0_ADDRESS_NO_DOMAIN }}
AUTH0_AUDIENCE_NO_DOMAIN=${{ secrets.AUTH0_AUDIENCE_NO_DOMAIN }}
AUTH0_TEST_TOKEN_NO_DOMAIN=${{ secrets.AUTH0_TEST_TOKEN_NO_DOMAIN }} # Used for local testing purposes
AUTH0_TEST_TOKEN_SCOPES=read:calculate-analytics # Used for scoped local testing

# Analytics configuration (opt-in)
ANALYTICS__ENABLED=true # Enable user analytics
Expand Down Expand Up @@ -390,7 +393,8 @@ auth:
auth0:
address: ${AUTH0_DOMAIN}
audience: ${AUTH0_AUDIENCE}
test_bearer_token: ${AUTH0_TOKEN}
test_token: ${AUTH0_TOKEN}
test_token_scopes: ${AUTH0_TOKEN_SCOPES}

ai:
enabled: true
Expand Down
2 changes: 2 additions & 0 deletions config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ auth:
audience: "" # Override with AUTH0_AUDIENCE_NO_DOMAIN
# Test JWT token used only for GitHub Actions tests pre-deployment
test_token: "" # Override with AUTH0_TEST_TOKEN_NO_DOMAIN
# Space-delimited OAuth scopes for the static test token
test_token_scopes: "" # Override with AUTH0_TEST_TOKEN_SCOPES

# AI services configuration
ai:
Expand Down
1 change: 1 addition & 0 deletions config/production.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ auth:
address: ${AUTH0_ADDRESS_NO_DOMAIN} # From env var
audience: ${AUTH0_AUDIENCE_NO_DOMAIN} # From env var
test_token: "" # Used only for executing pre-deploy integration tests
test_token_scopes: "" # Used only for executing pre-deploy integration tests

ai:
enabled: true
Expand Down
1 change: 1 addition & 0 deletions config/test_with_auth.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ auth:
address: ${AUTH0_ADDRESS_NO_DOMAIN} # From env var
audience: ${AUTH0_AUDIENCE_NO_DOMAIN} # From env var
test_token: ${AUTH0_TEST_TOKEN_NO_DOMAIN} # From env var
test_token_scopes: ${AUTH0_TEST_TOKEN_SCOPES} # From env var

ai:
enabled: false
10 changes: 9 additions & 1 deletion policyengine_household_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@
)

# Internal imports
from .decorators.auth import create_auth_decorator
from .decorators.auth import ANALYTICS_READ_SCOPE, create_auth_decorator
from policyengine_household_api.decorators.analytics import (
log_analytics_if_enabled,
)

# Endpoints
from .endpoints import (
get_home,
get_calculate_analytics_requests,
get_calculate,
generate_ai_explainer,
)
Expand Down Expand Up @@ -78,6 +79,13 @@ def calculate(country_id):
return get_calculate(country_id)


@app.route("/analytics/calculate/requests", methods=["GET"])
@require_auth_if_enabled([ANALYTICS_READ_SCOPE])
@limiter.limit("60 per minute")
def calculate_analytics_requests():
return get_calculate_analytics_requests()


@app.route("/<country_id>/ai-analysis", methods=["POST"])
@require_auth_if_enabled()
def ai_analysis(country_id: str):
Expand Down
16 changes: 12 additions & 4 deletions policyengine_household_api/decorators/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
from authlib.integrations.flask_oauth2 import ResourceProtector
from authlib.oauth2.rfc6750 import BearerTokenValidator
from ..auth.validation import Auth0JWTBearerTokenValidator
from ..utils.config_loader import get_config, get_config_value
from ..utils.config_loader import get_config_value

ANALYTICS_READ_SCOPE = "read:calculate-analytics"


class StaticBearerToken:
Expand All @@ -33,15 +35,16 @@ def get_scope(self) -> str:
class StaticBearerTokenValidator(BearerTokenValidator):
"""Accept a single configured bearer token for test environments."""

def __init__(self, expected_token: str):
def __init__(self, expected_token: str, scopes: str | None = ""):
super().__init__()
self.expected_token = expected_token
self.scopes = scopes or ""

def authenticate_token(
self, token_string: Optional[str]
) -> Optional[StaticBearerToken]:
if token_string == self.expected_token:
return StaticBearerToken(token_string)
return StaticBearerToken(token_string, scope=self.scopes)
return None


Expand Down Expand Up @@ -98,6 +101,9 @@ def _setup_authentication(self) -> None:
self._auth_enabled = get_config_value("auth.enabled", False)
app_environment = get_config_value("app.environment", "")
auth0_test_token = get_config_value("auth.auth0.test_token", "")
auth0_test_token_scopes = get_config_value(
"auth.auth0.test_token_scopes", ""
)

# Get Auth0 configuration values
auth0_address = get_config_value("auth.auth0.address", "")
Expand All @@ -108,7 +114,9 @@ def _setup_authentication(self) -> None:
if app_environment == "test_with_auth" and auth0_test_token:
resource_protector = ResourceProtector()
resource_protector.register_token_validator(
StaticBearerTokenValidator(auth0_test_token)
StaticBearerTokenValidator(
auth0_test_token, auth0_test_token_scopes
)
)
self._decorator = resource_protector
elif auth0_address and auth0_audience:
Expand Down
11 changes: 8 additions & 3 deletions policyengine_household_api/endpoints/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
from .home import get_home
from .household import get_calculate
from .household_explainer import generate_ai_explainer
from .analytics import (
get_calculate_analytics_requests as get_calculate_analytics_requests,
)
from .home import get_home as get_home
from .household import get_calculate as get_calculate
from .household_explainer import (
generate_ai_explainer as generate_ai_explainer,
)
Loading
Loading