Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ FLASK_TRINO_SF_PRIVATE_KEY=trino_private_key
# FLASK_DISABLE_AUTH_FOR_TESTS=1


# C-IDP Creds
FLASK_SSO_CLIENT_ID=client-id
FLASK_SSO_CLIENT_SECRET=client-secret
FLASK_SSO_PROVIDER=oidc-provider-url
14 changes: 7 additions & 7 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,33 +26,33 @@ tasks:
format-python:
desc: "Format python code"
cmds:
- docker exec assetsubuntucom-app-1 yarn format-python
- docker compose run --rm --no-deps --entrypoint "" app yarn format-python

lint:
desc: "Run code linters"
cmds:
- docker exec assetsubuntucom-app-1 yarn lint
- docker compose run --rm --no-deps --entrypoint "" app yarn lint

test-python:
desc: "Run tests"
cmds:
- docker exec assetsubuntucom-app-1 yarn test-python
- docker compose run --rm --no-deps --entrypoint "" app yarn test-python

test-e2e:
desc: "Run end-to-end tests"
desc: "Run playwright e2e tests. This requires the app server to be running, so make sure to run `task` first."
cmds:
- docker exec assetsubuntucom-app-1 yarn playwright install
- docker exec assetsubuntucom-app-1 yarn playwright install --with-deps
- docker exec assetsubuntucom-app-1 yarn test-e2e

build:
desc: "Build the project"
cmds:
- docker exec assetsubuntucom-app-1 yarn build
- docker compose run --rm --no-deps --entrypoint "" app yarn build

watch:
desc: "Watch the project"
cmds:
- docker exec assetsubuntucom-app-1 yarn watch
- docker compose run --rm --no-deps --entrypoint "" app yarn watch

test:
desc: "Run all tests"
Expand Down
5 changes: 4 additions & 1 deletion charm/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,7 @@ config:
default: false
trino-sf:
type: secret
description: Trino credentials, must contain (project-id, private-key-id, client-email, client-id, private-key)
description: Trino credentials, must contain (project-id, private-key-id, client-email, client-id, private-key)
sso:
type: secret
description: SSO credentials, must contain (client-id, client-secret, provider)
7 changes: 5 additions & 2 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,20 @@ services:
- path: .env.local
required: false
ports:
- "${PORT}:80"
- "${PORT}:${PORT}"
volumes:
- .:/srv
- /srv/.venv
- /srv/node_modules
command: ["yarn", "start"]
entrypoint: []
depends_on:
postgres:
condition: service_healthy
swift:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/manager"]
test: ["CMD", "curl", "-f", "http://localhost:${PORT}/manager"]
interval: 5s
timeout: 5s
retries: 30
Expand Down
4 changes: 3 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Pillow==10.4.0
psycopg2-binary==2.9.9
python-keystoneclient==5.4.0
python-swiftclient==4.6.0
gunicorn<22.0.0
requests==2.32.3
scour==0.38.2
sh==2.0.7
Expand All @@ -22,4 +23,5 @@ google-auth==2.40.3
cryptography==46.0.1
setuptools==79.0.1
black==25.1.0
flake8==7.1.1
flake8==7.1.1
Authlib==1.6.6
101 changes: 80 additions & 21 deletions webapp/sso.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,103 @@
import functools
import os
from urllib.parse import urljoin, urlparse

import flask
from django_openid_auth.teams import TeamsRequest, TeamsResponse
from flask_openid import OpenID
from authlib.integrations.flask_client import OAuth
import requests

from webapp.views import get_users_data

SSO_LOGIN_URL = "https://login.ubuntu.com"
SSO_TEAM = "canonical-content-people"
LAUNCHPAD_API_URL = "https://api.launchpad.net/1.0"


def is_safe_url(target):
if not target:
return False

# Security: Prevent "///" or "\\\" bypasses
# that some browsers interpret as absolute URLs
if target.startswith(("\\", "//")):
return False

ref_url = urlparse(flask.request.host_url)
test_url = urlparse(urljoin(flask.request.host_url, target))

return (
test_url.scheme in ("http", "https")
and ref_url.netloc == test_url.netloc
)


def _safe_redirect():
# .get() returns None if missing, which is_safe_url handles
target = flask.request.args.get("next")

if not is_safe_url(target):
return flask.redirect("/manager")

return flask.redirect(target)


def init_sso(app):
open_id = OpenID(
store_factory=lambda: None,
safe_roots=[],
extension_responses=[TeamsResponse],

oauth = OAuth(app)

oauth.register(
"canonical",
client_id=os.getenv("SSO_CLIENT_ID"),
client_secret=os.getenv("SSO_CLIENT_SECRET"),
server_metadata_url=os.getenv("SSO_PROVIDER"),
client_kwargs={
"token_endpoint_auth_method": "client_secret_post",
"scope": "openid profile email",
},
)

@app.route("/login", methods=["GET", "POST"])
@open_id.loginhandler
@app.route("/login")
def login():
if "openid" in flask.session:
return flask.redirect(open_id.get_next_url())
return _safe_redirect()

redirect_uri = flask.url_for("oauth_callback", _external=True)
return oauth.canonical.authorize_redirect(redirect_uri)

teams_request = TeamsRequest(query_membership=[SSO_TEAM])
return open_id.try_login(
SSO_LOGIN_URL, ask_for=["email"], extensions=[teams_request]
@app.route("/auth/callback")
def oauth_callback():
token = oauth.canonical.authorize_access_token()
users, status_code = get_users_data(token["userinfo"]["name"])

if status_code != 200 or not users:
flask.abort(
403, description="Failed to fetch user data from directory."
)

response = requests.get(
f"{LAUNCHPAD_API_URL}/~{users[0]['launchpadId']}/super_teams",
)

@open_id.after_login
def after_login(resp):
if SSO_TEAM not in resp.extensions["lp"].is_member:
flask.abort(403)
if response.status_code != 200:
flask.abort(
403, description="Failed to fetch Launchpad team memberships."
)

memberships = response.json().get("entries", [])
if SSO_TEAM not in [team["name"] for team in memberships]:
flask.abort(
403,
description=(
"Please make sure you are a member of the "
"canonical-content-people team on Launchpad."
),
)

flask.session["openid"] = {
"identity_url": resp.identity_url,
"email": resp.email,
"identity_url": token["userinfo"]["iss"],
"email": token["userinfo"]["email"],
"fullname": token["userinfo"]["name"],
}

return flask.redirect(open_id.get_next_url())
return _safe_redirect()


def login_required(func):
Expand Down
33 changes: 21 additions & 12 deletions webapp/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,20 +474,21 @@ def delete_redirect(redirect_path):
return jsonify({}), 204


def get_users(username: str):
def get_users_data(username: str):
query = """
query($name: String!) {
employees(filter: { contains: { name: $name }}) {
id
firstName
surname
email
team
department
jobTitle
query($name: String!) {
employees(filter: { contains: { name: $name }}) {
id
firstName
surname
email
team
department
jobTitle
launchpadId
}
}
}
"""
"""

headers = {
"Authorization": "token "
Expand All @@ -506,6 +507,14 @@ def get_users(username: str):

if response.status_code == 200:
users = response.json().get("data", {}).get("employees", [])
return users, 200

return None, response.status_code


def get_users(username: str):
users, status_code = get_users_data(username)
if status_code == 200:
return jsonify(list(users))
return jsonify({"error": "Failed to fetch users"}), 500

Expand Down
Loading