Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
aef6a57
docs: add AAF dev-aaf deployment steps to README
amandazhuyilan Jun 19, 2026
b35ad64
feat(config): recognise dev-aaf environment (maps to dev-aaf portal URL)
amandazhuyilan Jun 19, 2026
c334709
docs: correct dev-aaf backend deploy (stock dev image, ENVIRONMENT=de…
amandazhuyilan Jun 20, 2026
ab00610
Merge pull request #288 from AustralianBioCommons/main
amandazhuyilan Jul 31, 2026
978a640
feat(auth0): read AAF core attribute claims in UserInfo
amandazhuyilan Jul 31, 2026
59ec0f2
feat: represent AAF users in aai-backend database (AAI-871) (#293)
marius-mather Aug 12, 2026
269c562
feat: check AAF logins and link to existing account if needed (AAI-87…
marius-mather Aug 25, 2026
2f9d77c
fix: need to redirect back to Auth0 with a signed token, not just ret…
marius-mather Sep 4, 2026
420ca7c
feat: build and deploy aaf-dev image
amandazhuyilan Sep 11, 2026
50a7204
ci: build and deploy aaf-dev image
amandazhuyilan Sep 11, 2026
ba9ef96
fix: update aaf-dev deploy to use the right role
amandazhuyilan Sep 11, 2026
861fd84
feat: hard-delete users (AAF environment only) (AAF-883) (#296)
marius-mather Sep 11, 2026
db7d11d
chore: replace httpx with httpx2 (#297)
marius-mather Sep 11, 2026
9dab0dc
feat: allow manually triggering user sync from Auth0
marius-mather Sep 11, 2026
c961774
fix: email_verified can be missing in Auth0 responses, when not relevant
marius-mather Sep 11, 2026
54c31ec
fix: use httpx in aaf router
amandazhuyilan Sep 13, 2026
23e2980
feat: update user admin to show account type etc.
marius-mather Sep 14, 2026
4e4ad76
Merge branch 'aaf-dev' of github.com:AustralianBioCommons/aai-backend…
marius-mather Sep 14, 2026
9e4f61a
feat: AAF registration endpoint (AAI-881) (#299)
marius-mather Sep 16, 2026
f132517
fix: AAF registration needs to set aaf_registration_complete flag
marius-mather Sep 17, 2026
aa4b58f
fix: email_verified is optional in Auth0 user info
marius-mather Sep 17, 2026
a24d3d6
test: update unit tests to check for aaf_registration_complete
marius-mather Sep 17, 2026
23b20e3
feat: return /continue redirect_url from register-aaf
amandazhuyilan Sep 21, 2026
42d4735
Merge remote-tracking branch 'origin/main' into aaf-dev
amandazhuyilan Sep 21, 2026
d81ee30
fix: type errors
amandazhuyilan Sep 22, 2026
cec72a0
fix: lint
amandazhuyilan Sep 22, 2026
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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ ADMIN_ROLES='["Admin", "GalaxyAdmin"]'
ENABLE_ADMIN_DASHBOARD=False
# AAI Portal URL for admin links in emails
AAI_PORTAL_URL=https://aaiportal.example.com
# AAF login proxy
AAI_LOGIN_PROXY_URL=https://aafproxy.example.com
# URL of Galaxy instance, for making calls to Galaxy API
GALAXY_URL=https://galaxy.example.com
GALAXY_API_KEY=api-key
Expand Down
162 changes: 162 additions & 0 deletions .github/workflows/build-and-deploy-aaf-dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
name: build-and-deploy-aaf-dev
# Builds a `dev-aaf`-tagged image from the aaf-dev branch and deploys it to the
# dev-aaf backend (biocloud-dev-aaf tenant), so AAF work can be tested without
# merging into main. Mirrors build-and-deploy-dev.yml but targets dev-aaf.
#
# Deploy mechanism (assume-invoke-role, cross-account): the CI push role
# (AWS_ROLE_ECR_PUSH, in 331315009666) assumes the dev-aaf invoke role
# (AWS_ROLE_BACKEND_DEPLOY_DEV_AAF = aai-backend-dev-aaf-deploy-invoke-role in
# 498096047392), which invokes AaiBackendDevAafDeploymentFunction.
on:
push:
branches: [aaf-dev]

permissions:
contents: read
id-token: write

env:
AWS_REGION: ${{ secrets.AWS_REGION }}
IMAGE_REPO: ${{ secrets.AWS_ECR_IMAGE_REPO }}

jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Validate required secrets
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_ECR_IMAGE_REPO: ${{ secrets.AWS_ECR_IMAGE_REPO }}
AWS_ROLE_ECR_PUSH: ${{ secrets.AWS_ROLE_ECR_PUSH }}
AWS_ROLE_BACKEND_DEPLOY_DEV_AAF: ${{ secrets.AWS_ROLE_BACKEND_DEPLOY_DEV_AAF }}
run: |
set -euo pipefail
if [ -z "${AWS_REGION}" ]; then
echo "Missing required secret AWS_REGION" >&2
exit 1
fi
if [ -z "${AWS_ECR_IMAGE_REPO}" ]; then
echo "Missing required secret AWS_ECR_IMAGE_REPO" >&2
exit 1
fi
if [ -z "${AWS_ROLE_ECR_PUSH}" ]; then
echo "Missing required secret AWS_ROLE_ECR_PUSH" >&2
exit 1
fi
if [ -z "${AWS_ROLE_BACKEND_DEPLOY_DEV_AAF}" ]; then
echo "Missing required secret AWS_ROLE_BACKEND_DEPLOY_DEV_AAF" >&2
exit 1
fi

- uses: docker/setup-buildx-action@v4

- name: Stamp dev version
run: |
set -euo pipefail
SHORT_SHA=$(git rev-parse --short HEAD)
node <<'JS'
const fs = require('fs');
const path = 'pyproject.toml';
const shortSha = (process.env.SHORT_SHA || '').toLowerCase().slice(0, 7);
const lines = fs.readFileSync(path, 'utf8').split('\n');
let updated = false;
let newVersion = null;
const result = lines.map((line) => {
if (line.startsWith('version = ')) {
const match = line.match(/version = \"(.+)\"/);
if (!match) {
return line;
}
const baseRaw = match[1];
const cleanBase = baseRaw
.replace(/\.dev\d+(?:\+.+)?$/i, '')
.replace(/\+.+$/i, '')
.replace(/-dev_[0-9a-f]+$/i, '');
newVersion = `${cleanBase}.dev0+g${shortSha}`;
updated = true;
return `version = "${newVersion}"`;
}
return line;
});
if (!updated || !newVersion) {
throw new Error('Failed to compute new version');
}
fs.writeFileSync(path, result.join('\n'));
console.log('Stamped version to', newVersion);
JS
env:
SHORT_SHA: ${{ github.sha }}

- name: Sync uv lockfile
run: |
set -euo pipefail
python -m pip install uv==0.11.15
uv lock

# Authenticate to AWS (push role, in 331315009666)
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_ROLE_ECR_PUSH }}
aws-region: ${{ env.AWS_REGION }}

- uses: aws-actions/amazon-ecr-login@v2

- name: Build & Push (dev-aaf only)
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: ${{ env.IMAGE_REPO }}:dev-aaf
provenance: false
sbom: false
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Configure AWS credentials for dev-aaf deploy
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_ROLE_BACKEND_DEPLOY_DEV_AAF }}
role-session-name: backend-aaf-dev-deploy
aws-region: ${{ env.AWS_REGION }}
role-chaining: true
role-skip-session-tagging: true

- name: Deploy dev-aaf backend via Lambda
env:
DEPLOY_FUNCTION_NAME: AaiBackendDevAafDeploymentFunction
IMAGE_TAG: dev-aaf
run: |
set -euo pipefail

export AWS_MAX_ATTEMPTS=1

PAYLOAD=$(jq -n --arg tag "${IMAGE_TAG}" '{tag: $tag}')
RESPONSE_FILE=$(mktemp)

INVOKE_METADATA=$(aws lambda invoke \
--function-name "${DEPLOY_FUNCTION_NAME}" \
--payload "${PAYLOAD}" \
--cli-binary-format raw-in-base64-out \
--cli-read-timeout 0 \
"${RESPONSE_FILE}")

echo "${INVOKE_METADATA}"

FUNCTION_ERROR=$(echo "${INVOKE_METADATA}" | jq -r '.FunctionError // empty')
if [ -n "${FUNCTION_ERROR}" ]; then
echo "Deployment lambda reported an error: ${FUNCTION_ERROR}" >&2
cat "${RESPONSE_FILE}" >&2 || true
exit 1
fi

cat "${RESPONSE_FILE}"

STATUS=$(jq -r '.status // empty' "${RESPONSE_FILE}")
if [ "${STATUS}" != "SUCCESS" ]; then
echo "Deployment lambda returned unexpected status: ${STATUS}" >&2
exit 1
fi
2 changes: 0 additions & 2 deletions .github/workflows/check-migrations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ on:
branches:
- main
pull_request:
branches:
- main

jobs:

Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ on:
branches:
- main
pull_request:
branches:
- main

jobs:
test:
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,42 @@ When the database models are changed, the database schema diagram in [`db_diagra
## Documents to be updated
Please update the following documents if there are changes to the database schema:
- [AAI User Database Technical Design Document](https://docs.google.com/document/d/1xECcTqXH9ykXBCEESBSg43SOMncXT6Zayi5FwqvCT4Y/edit?tab=t.0#heading=h.sj9060dgy5fu)

## AAF integration — `dev-aaf` deployment

The `aaf-dev` branch runs against the **`biocloud-dev-aaf`** Auth0 tenant
(`dev-aaf` environment), isolated from `dev-bc`. Deploys are **manual** for now.

Run locally against the tenant:

```bash
uv venv && uv sync --extra dev
# .env pointed at the new tenant:
# AUTH0_DOMAIN=biocloud-dev-aaf.au.auth0.com
# AUTH0_ISSUER=https://biocloud-dev-aaf.au.auth0.com/
# AUTH0_AUDIENCE=https://dev-aaf.api.aai.test.biocommons.org.au
# AUTH0_MANAGEMENT_ID / AUTH0_MANAGEMENT_SECRET=<M2M in biocloud-dev-aaf>
# AUTH0_DB_CONNECTION=Username-Password-Authentication
uv run uvicorn main:app --reload --port 8000
```

Hosted dev-aaf backend (deployed via CDK from `aai-infrastructure`):

The dev-aaf ECS service currently runs the **stock `aai-backend:dev` image** with
`ENVIRONMENT=dev`. The published `:dev` image doesn't accept `dev-aaf` as an
environment, so the CDK passes `ENVIRONMENT=dev`; auth still targets the dev-aaf
tenant via the `dev-aaf/backend/secrets` values. The service is created by
`cdk deploy -c env=dev-aaf AaiBackendDevAaf` in `aai-infrastructure` — no image
build needed to stand it up.

To ship **backend AAF code changes**, you need a `dev-aaf`-aware image in the
**shared ECR (account `331315009666`)**. A manual `docker push` is **not** possible
— that repo's push role is GitHub-OIDC-only (no human/SSO principal can assume it).
So build it through CI on the `aaf-dev` branch (this branch's `config.py` already
adds `dev-aaf` to the accepted environments). Once the `:dev-aaf` image exists, set
`backend.image_tag: 'dev-aaf'` and drop `backend.app_environment` in
`config/environments/dev-aaf.yaml`, then redeploy `AaiBackendDevAaf`.

> Side effect of `ENVIRONMENT=dev`: admin email links default to `dev.portal…`.
> Set `AAI_PORTAL_URL=https://dev-aaf.portal.aai.test.biocommons.org.au` in the
> backend secret if that matters.
1 change: 1 addition & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ script_location = migrations

# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
path_separator = os
prepend_sys_path = .

# timezone to use when rendering the date within the migration file
Expand Down
66 changes: 66 additions & 0 deletions auth/account_permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from enum import StrEnum
from logging import getLogger
from typing import Annotated

from fastapi import HTTPException
from fastapi.params import Depends
from starlette import status

from auth.user_permissions import get_db_user
from db.models import BiocommonsUser

logger = getLogger("uvicorn.error")


class AccountActions(StrEnum):
CHANGE_EMAIL = 'change_email'
CHANGE_PASSWORD = 'change_password'
CHANGE_USERNAME = 'change_username'
CHANGE_NAME = 'change_name'


def account_action_allowed(action: AccountActions, user: BiocommonsUser) -> bool:
"""
Check if a user can perform an action, based on account type.
"""
# All actions currently allowed for Auth0 users
if user.account_type == "auth0":
return True
elif user.account_type == "aaf":
match action:
case AccountActions.CHANGE_EMAIL:
return False
case AccountActions.CHANGE_USERNAME:
# TODO: username change code currently assumes the
# Auth0 DB connection, so disable for AAF for now
return False
case AccountActions.CHANGE_PASSWORD:
return False
case AccountActions.CHANGE_NAME:
# TODO: Assuming we allow users to set their name and don't
# auto-update from AAF
return True
logger.warning(f"Unexpected account type ({user.account_type})/action ({action}. Disallowing by default.")
return False


def require_account_permission(action: AccountActions) -> Depends:
"""
FastAPI dependency that checks if the user can perform an action, based on account type.
"""
def require_account_action(
user: Annotated[BiocommonsUser | None, Depends(get_db_user)],
) -> BiocommonsUser:
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User account not found.",
)
if not account_action_allowed(action, user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to perform this action.",
)
return user

return Depends(require_account_action)
4 changes: 2 additions & 2 deletions auth/management.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Annotated

import httpx
import httpx2
from cachetools import TTLCache
from fastapi import Depends

Expand All @@ -24,7 +24,7 @@ def get_management_token(settings: Annotated[Settings, Depends(get_settings)]):
"client_secret": settings.auth0_management_secret,
"audience": f"https://{settings.auth0_domain}/api/v2/",
}
response = httpx.post(url, json=payload)
response = httpx2.post(url, json=payload)
response.raise_for_status()
data = response.json()
token = data["access_token"]
Expand Down
27 changes: 24 additions & 3 deletions auth/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import json
import logging
import weakref
from datetime import UTC, datetime, timedelta

import httpx
import httpx2
import jwt
from cachetools import TTLCache
from fastapi import HTTPException
Expand Down Expand Up @@ -105,7 +106,7 @@ async def _fetch_rsa_keys(auth0_domain: str) -> dict:

try:
metadata_url = f"https://{auth0_domain}/.well-known/openid-configuration"
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
metadata_response = await client.get(metadata_url)
metadata_response.raise_for_status()
metadata = metadata_response.json()
Expand All @@ -117,7 +118,7 @@ async def _fetch_rsa_keys(auth0_domain: str) -> dict:
except KeyError as exc:
logger.error(f"OIDC metadata from {metadata_url} did not include jwks_uri")
raise InvalidTokenError("Failed to fetch JWKS") from exc
except (httpx.HTTPError, ValueError) as exc:
except (httpx2.HTTPError, ValueError) as exc:
logger.error(
f"Failed to fetch OIDC metadata or JWKS for domain {auth0_domain}: {exc}"
)
Expand Down Expand Up @@ -168,3 +169,23 @@ def verify_action_token(token: str, settings: Settings) -> dict:
except InvalidTokenError:
raise HTTPException(status_code=401, detail="invalid session_token")
return payload


def create_action_token(payload: dict, settings: Settings, expires_in_seconds: int = 300) -> dict:
"""
Create a signed JWT that can be passed back to Auth0 actions
"""
required_fields = ["sub", "iss", "state"]
for field in required_fields:
if field not in payload:
raise ValueError( f"Missing required field {field} in action token")
now = datetime.now(tz=UTC)
exp = (now + timedelta(seconds=expires_in_seconds)).timestamp()
payload = {**payload, "exp": int(exp), "iat": int(now.timestamp())}
secret = settings.auth0_management_secret
signed_payload = jwt.encode(
payload,
key=secret,
algorithm="HS256",
)
return signed_payload
Loading
Loading