Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🔏 Requires valid access tokens for API calls #173

Merged
merged 5 commits into from
Oct 25, 2023
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
1 change: 0 additions & 1 deletion .env.development

This file was deleted.

13 changes: 13 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Postgres URL for Chowda DB.
# Format: postgresql://{user}:{password}@{host}:{port}/{database}
DB_URL=''

# Env vars beginning with "AUTH0_" are used for authentication and authorization with Auth0.
# These values can be obtained by logging into our Auth0 account.
AUTH0_DOMAIN=''
AUTH0_CLIENT_ID=''
AUTH0_CLIENT_SECRET=''
AUTH0_API_AUDIENCE=''

# Secret value for securing session data using SessionMiddleware
CHOWDA_SECRET=''
1 change: 0 additions & 1 deletion .env.test

This file was deleted.

5 changes: 1 addition & 4 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ name: 🧪 Integration Tests

on: [push, pull_request, workflow_dispatch]

env:
DB_URL: postgresql://postgres:postgres@postgres:5432/chowda-test

jobs:
tests:
name: ⚗️ Application Tests
Expand All @@ -13,7 +10,7 @@ jobs:
with:
pdm_args: -G test,ci
pytest_args: -n auto --nbmake -ra -s
pg_db: chowda-test
pg_db: chowda

lint:
name: 👕 Lint
Expand Down
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,9 @@ ci.toml

# Metaflow generated files
metaflow.s3.*
.metaflow
.metaflow

.cache_ggshield

.env.*
!.env.sample
14 changes: 0 additions & 14 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,6 @@
"jinja": false,
"justMyCode": false
},
{
"name": "Chowda: tests",
"type": "python",
"request": "launch",
"cwd": "${workspaceFolder}",
"module": "pytest",
"args": [
"-v",
"-n",
"auto"
],
"jinja": false,
"justMyCode": false
},
{
"name": "Metaflow: Run",
"type": "python",
Expand Down
9 changes: 6 additions & 3 deletions chowda/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Main Chowda application"""

from fastapi import FastAPI
from fastapi import Depends, FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.middleware import Middleware
from starlette.middleware.sessions import SessionMiddleware
Expand All @@ -13,6 +13,7 @@
from chowda.admin import Admin
from chowda.api import api
from chowda.auth import OAuthProvider
from chowda.auth.utils import get_admin_user, verified_access_token
from chowda.config import SECRET, STATIC_DIR, TEMPLATES_DIR
from chowda.db import engine
from chowda.models import (
Expand Down Expand Up @@ -53,8 +54,10 @@
)
app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static')

app.include_router(api, prefix='/api')
app.include_router(dashboard, prefix='/dashboard')
app.include_router(api, prefix='/api', dependencies=[Depends(verified_access_token)])
app.include_router(
dashboard, prefix='/dashboard', dependencies=[Depends(get_admin_user)]
)


# Create admin
Expand Down
4 changes: 2 additions & 2 deletions chowda/auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from starlette_admin.auth import AdminUser, AuthMiddleware, AuthProvider

from chowda.config import (
API_AUDIENCE,
AUTH0_API_AUDIENCE,
AUTH0_CLIENT_ID,
AUTH0_CLIENT_SECRET,
AUTH0_DOMAIN,
Expand All @@ -25,7 +25,7 @@
'scope': 'openid profile email',
},
server_metadata_url=f'https://{AUTH0_DOMAIN}/.well-known/openid-configuration',
authorize_params={'audience': API_AUDIENCE},
authorize_params={'audience': AUTH0_API_AUDIENCE},
)


Expand Down
132 changes: 114 additions & 18 deletions chowda/auth/utils.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
from typing import Annotated
from typing import Annotated, List, Set

from fastapi import Depends, HTTPException, Request, status
from pydantic import BaseModel, Field

from chowda.config import API_AUDIENCE
from chowda.config import AUTH0_API_AUDIENCE, AUTH0_JWKS_URL

unauthorized_redirect = HTTPException(
status_code=status.HTTP_303_SEE_OTHER,
detail='Not Authorized',
headers={'Location': '/admin'},
)

unauthorized = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Not Authorized',
)


class UserToken(BaseModel):
class OAuthAccessToken(BaseModel):
"""OAuth Authorization token"""

sub: str
permissions: List[str] = []


class OAuthUser(BaseModel):
"""ID Token model for authorization."""

name: str
email: str | None = None
roles: set[str] = Field(set(), alias=f'{API_AUDIENCE}/roles')
roles: set[str] = Field(set(), alias=f'{AUTH0_API_AUDIENCE}/roles')

@property
def is_admin(self) -> bool:
Expand All @@ -24,29 +42,107 @@ def is_clammer(self) -> bool:
return 'clammer' in self.roles


unauthorized = HTTPException(
status_code=status.HTTP_303_SEE_OTHER,
detail='Not Authorized',
headers={'Location': '/admin'},
)


def get_user(request: Request) -> UserToken:
def get_oauth_user(request: Request) -> OAuthUser:
"""Get the user token from the session."""
user = request.session.get('user', None)
if not user:
request.session['error'] = 'Not Logged In'
raise unauthorized
return UserToken(**user)
raise unauthorized_redirect
return OAuthUser(**user)


def admin_user(
request: Request, user: Annotated[UserToken, Depends(get_user)]
) -> UserToken:
def get_admin_user(
request: Request, user: Annotated[OAuthUser, Depends(get_oauth_user)]
) -> OAuthUser:
"""Check if the user has the admin role using FastAPI Depends.
If not, sets a session error and raises an HTTPException."""
if not user.is_admin:
request.session['error'] = 'Not Authorized'
raise unauthorized
raise unauthorized_redirect

return user


def unverified_access_token(request: Request) -> str:
"""Extract and return the unverified access token from the Authorization header.
Raises an HTTPUnauthorizedException if the header is missing or malformed."""
auth_header = request.headers.get('Authorization', None)

if not auth_header:
raise HTTPException(status_code=401, detail="Missing Authorization header")
if not auth_header.startswith('Bearer '):
raise HTTPException(
status_code=401,
detail="Bearer token malformed or missing in Authorization header",
)

# Return the access token from the Authorization header,
# i.e. the string without the "Bearer " prefix
return auth_header.replace('Bearer ', '')


def jwt_signing_key(
unverified_access_token: Annotated[str, Depends(unverified_access_token)]
) -> str:
"""Get the JWT signing key from the JWKS URL."""
from jwt import PyJWKClient

jwks_client = PyJWKClient(AUTH0_JWKS_URL)
try:
signing_key = jwks_client.get_signing_key_from_jwt(unverified_access_token)
return signing_key.key

except Exception as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)
) from exc


def verified_access_token(
request: Request,
unverified_access_token: Annotated[str, Depends(unverified_access_token)],
jwt_signing_key: Annotated[str, Depends(jwt_signing_key)],
) -> OAuthAccessToken:
"""Decodes and verifies an access token. If any exceptions occur, an
HTTPUnauthorizedException is raised from the original exception."""
from jwt import decode

try:
decoded_token = decode(
unverified_access_token,
jwt_signing_key,
algorithms=['RS256', 'HS256'],
audience='https://chowda.wgbh-mla.org/api',
)
return OAuthAccessToken(**decoded_token)

except Exception as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)
) from exc


def permissions(permissions: str | List[str] | Set[str]) -> None:
"""Dependency function to check if token has required permissions.

Args:
permissions (str, List, Set): Required permissions. Can be a str, list, or set.
Examples:
@app.get('/users/', dependencies=[Depends(permissions('read:users'))])
"""
if isinstance(permissions, (str, list)):
permissions: set = {permissions}

def _permissions(
token: Annotated[OAuthAccessToken, Depends(verified_access_token)],
) -> None:
"""Verify token has all required permissions, or raise a 403 Forbidden exception
with the missing permissions in the detail message."""
missing_permissions = permissions - set(token.permissions)
if missing_permissions:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Missing required permissions: {missing_permissions}',
)

return _permissions
6 changes: 4 additions & 2 deletions chowda/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
AUTH0_CLIENT_ID = environ.get('AUTH0_CLIENT_ID')
AUTH0_CLIENT_SECRET = environ.get('AUTH0_CLIENT_SECRET')
AUTH0_DOMAIN = environ.get('AUTH0_DOMAIN')
AUTH0_JWKS_URL = f'https://{AUTH0_DOMAIN}/.well-known/jwks.json'
AUTH0_API_AUDIENCE = environ.get(
'AUTH0_API_AUDIENCE', 'https://chowda.wgbh-mla.org/api'
)

SECRET = environ.get('CHOWDA_SECRET')

API_AUDIENCE = environ.get('API_AUDIENCE', 'https://chowda.wgbh-mla.org/api')
6 changes: 2 additions & 4 deletions chowda/routers/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
from fastapi import APIRouter, Depends, Request, status
from fastapi import APIRouter, Request, status
from metaflow.integrations import ArgoEvent
from starlette.responses import RedirectResponse, Response

from chowda.auth.utils import admin_user

dashboard = APIRouter(dependencies=[Depends(admin_user)])
dashboard = APIRouter()


@dashboard.post('/sync')
Expand Down
42 changes: 30 additions & 12 deletions chowda/routers/events.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,54 @@
from json import loads
from json import JSONDecodeError, loads

from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException, status
from metaflow import Run, namespace
from sqlmodel import Session

from chowda.auth.utils import permissions
from chowda.db import engine
from chowda.models import MetaflowRun

events = APIRouter()


@events.post('/')
def event(event: dict):
@events.post('/', dependencies=[Depends(permissions('create:event'))])
async def event(event: dict):
"""Receive an event from Argo Events."""
print('Chowda event received', event)
if not event.get('body'):
raise HTTPException(400, 'No body')
body = loads(event['body'])
if body['name'] == 'pipeline':
body = event.get('body')
if not body:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
'Argo Event must include a `body` key as a string',
)
try:
body = loads(event['body'])
except JSONDecodeError as e:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, 'Argo Event body must be valid JSON'
) from e
name = body.get('name')
if not name:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Argo Event body string must include a `name` key",
)
if name == 'pipeline':
print('new pipeline event!')
# FIXME: Ideally, we would add the run to the database here,
# but the run_id won't be minted until metaflow gets this event.
# For now, we can continue creating the db row inside the running flow,
# then update metaflow status events, as they come in.
return
return None
if body['name'].startswith('metaflow.Pipeline'):
print('Found event!', body['name'])
payload = body['payload']
with Session(engine) as db:
row = db.get(MetaflowRun, payload['run_id'])
if not row:
raise HTTPException(404, 'MetaflowRun row not found!')
raise HTTPException(
status.HTTP_404_NOT_FOUND, 'MetaflowRun row not found!'
)
namespace(None)
run = Run(f"{payload['flow_name']}/{payload['run_id']}")
row.finished = run.finished
Expand All @@ -41,5 +59,5 @@ def event(event: dict):
db.add(row)
db.commit()
print('Successfully updated MetaflowRun row!', row)
return
raise HTTPException(400, 'Unknown event')
return None
return 'Event successfully processed, but did not match known event'
Loading