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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ GITHUB_APP_CLIENT_ID="<client ID of the github OAuth app>"
GITHUB_APP_CLIENT_SECRET="<client secret of the github OAuth app>"
# Token for background jobs (a personal access token with read access to the repos)
GITHUB_TOKEN="<github token>"
GITHUB_ORG="<org>"
GITHUB_TEAM="<team slug>"
# Database config
DB_NAME=
DB_USER=
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ A deployment orchestrator for GitHub-hosted projects.

<img src="https://upload.wikimedia.org/wikipedia/commons/2/2e/Jean-Luc_Picard_2.jpg?utm_source=commons.wikimedia.org&utm_campaign=index&utm_content=original" alt="drawing" width="500"/>

## Login

Users log in with GitHub, and only active members of one GitHub team are let in. Set the team with `GITHUB_ORG` and `GITHUB_TEAM` (the team's slug from its URL, not its display name). If either is missing, nobody can log in.

Membership is checked once at login, so someone removed from the team keeps access until their session ends.

## Database setup & migrations

`make create_db && make upgrade_db` should be all that's needed to get started. Those two commands will create the database if it does not exist and run the migrations.
Expand Down
4 changes: 4 additions & 0 deletions helm/makeitso/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ spec:
secretKeyRef:
name: {{ .Values.app.githubAppSecretName | quote }}
key: ghWebhookSecret
- name: GITHUB_ORG
value: {{ .Values.app.githubOrg | quote }}
- name: GITHUB_TEAM
value: {{ .Values.app.githubTeam | quote }}
readinessProbe:
httpGet:
path: /healthz
Expand Down
2 changes: 2 additions & 0 deletions helm/makeitso/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ app:
env: prod

githubAppSecretName: makeitso-github-app-secret
githubOrg: bcgov
githubTeam: cas-developers

# Runs deploys
deployWorker:
Expand Down
24 changes: 23 additions & 1 deletion src/makeitso/auth/routes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from authlib.integrations.base_client import OAuthError
from flask import Blueprint, current_app, redirect, render_template, session, url_for
from flask import Blueprint, current_app, flash, redirect, render_template, session, url_for

from makeitso.extensions import oauth

Expand All @@ -24,11 +24,33 @@ def authorize():
response.raise_for_status()

user_info = response.json()

org = current_app.config["GITHUB_ORG"]
team = current_app.config["GITHUB_TEAM"]
if not _is_team_member(org, team, user_info["login"]):
session.clear()
flash(f"You need to be a member of the {org}/{team} GitHub team to log in", "danger")
return redirect("/")

session["user"] = user_info

return redirect("/")


def _is_team_member(org: str | None, team: str | None, username: str) -> bool:
# Fail closed if the team isn't configured
if not org or not team:
return False

response = oauth.github.get(f"orgs/{org}/teams/{team}/memberships/{username}")
# 404 means not a member, or the token can't see the team
if response.status_code != 200:
return False

# Pending invites don't count
return response.json().get("state") == "active"


@bp.route("/login")
def login():
scheme = "https" if current_app.config["SESSION_COOKIE_SECURE"] else "http"
Expand Down
3 changes: 3 additions & 0 deletions src/makeitso/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ class Config:
# Used by background jobs, which have no logged-in user
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
GITHUB_WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET")
# Only members of this team can log in
GITHUB_ORG = os.environ.get("GITHUB_ORG")
GITHUB_TEAM = os.environ.get("GITHUB_TEAM")

# Where deploys check out code; Helm mounts /workspace
DEPLOY_WORKSPACE = os.environ.get("DEPLOY_WORKSPACE", "/tmp/makeitso-workspace")
Expand Down
3 changes: 2 additions & 1 deletion src/makeitso/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@
authorize_url="https://github.com/login/oauth/authorize",
authorize_params=None,
api_base_url="https://api.github.com/",
client_kwargs={"scope": "openid email profile"},
# read:org is needed to check team membership at login
client_kwargs={"scope": "read:user read:org"},
)
4 changes: 3 additions & 1 deletion src/makeitso/static/css/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
font-display: swap;
}

:root {
/* Also on [data-bs-theme=light], where Bootstrap resets its colours to the defaults */
:root,
[data-bs-theme="light"] {
--bs-body-font-family: "BC Sans", "Noto Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
--bc-primary-blue: #003366;
--bc-bg-blue: #38598a;
Expand Down
24 changes: 17 additions & 7 deletions src/makeitso/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,23 @@
{% block navbar_actions %}{% endblock %}
{% if session.user %}
<a class="btn btn-sm btn-primary border-light d-inline-flex align-items-center gap-2" href="{{ url_for('monitor.index') }}">{{ render_icon("activity") }} Jobs</a>
<div class="navbar-brand me-0">
<img src="{{ session.user.avatar_url }}" alt="User Avatar" class="rounded-circle" width="30" height="30">
{{ session.user.login }}@github
<form method="post" action="{{ url_for('auth.logout') }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-primary border-light ms-3 d-inline-flex align-items-center gap-2">{{ render_icon("box-arrow-right") }} Logout</button>
</form>
<div class="dropdown">
<button type="button" class="btn p-0 border-0" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Account menu">
<img src="{{ session.user.avatar_url }}" alt="{{ session.user.login }}" class="rounded-circle" width="32" height="32">
</button>
<div class="dropdown-menu dropdown-menu-end text-nowrap p-3" data-bs-theme="light">
<div class="d-flex align-items-center gap-3 mb-3">
<img src="{{ session.user.avatar_url }}" alt="" class="rounded-circle" width="48" height="48">
<div>
{% if session.user.name %}<div class="fw-bold text-primary">{{ session.user.name }}</div>{% endif %}
<a href="{{ session.user.html_url }}" target="_blank" rel="noopener">@{{ session.user.login }}</a>
</div>
</div>
<form method="post" action="{{ url_for('auth.logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-primary w-100 d-inline-flex align-items-center justify-content-center gap-2">{{ render_icon("box-arrow-right") }} Logout</button>
</form>
</div>
</div>
{# The landing page has its own big login button #}
{% elif request.endpoint != "main.index" %}
Expand Down
108 changes: 108 additions & 0 deletions tests/auth/test_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
from unittest.mock import MagicMock, patch
from urllib.parse import parse_qs, urlparse

import pytest
from authlib.integrations.base_client import OAuthError

USER = {"login": "octocat", "avatar_url": ""}


def _response(status_code, json=None):
response = MagicMock(status_code=status_code)
response.json.return_value = json or {}
return response


class TestLogin:
def test_asks_github_for_org_access(self, client):
response = client.get("/login")

assert response.status_code == 302
location = urlparse(response.location)
assert location.netloc == "github.com"
assert parse_qs(location.query)["scope"] == ["read:user read:org"]


class TestLogout:
def test_clears_session(self, client, login):
login()

response = client.post("/logout")

assert response.status_code == 302
assert response.location == "/"
with client.session_transaction() as session:
assert "user" not in session
assert "token" not in session


class TestAuthorize:
@pytest.fixture(autouse=True)
def team(self, app, monkeypatch):
monkeypatch.setitem(app.config, "GITHUB_ORG", "bcgov")
monkeypatch.setitem(app.config, "GITHUB_TEAM", "makeitso-users")

# Fakes the GitHub OAuth client: GET /user, then the team membership call
@pytest.fixture
def github(self):
with patch("makeitso.auth.routes.oauth") as oauth:
oauth.github.authorize_access_token.return_value = {"access_token": "t"}
yield oauth.github

@staticmethod
def _authorize(client, github, membership):
github.get.side_effect = [_response(200, USER), membership]
return client.get("/authorize")

def test_member_logs_in(self, client, github):
response = self._authorize(client, github, _response(200, {"state": "active"}))

assert response.status_code == 302
assert response.location == "/"
github.get.assert_called_with("orgs/bcgov/teams/makeitso-users/memberships/octocat")
with client.session_transaction() as session:
assert session["user"] == USER

@pytest.mark.parametrize(
"membership",
[_response(404), _response(200, {"state": "pending"})],
ids=["not-a-member", "pending-invite"],
)
def test_non_member_is_sent_back_with_a_message(self, client, github, membership):
response = self._authorize(client, github, membership)

assert response.status_code == 302
assert response.location == "/"
with client.session_transaction() as session:
assert "user" not in session
assert "token" not in session

def test_non_member_sees_message_on_landing_page(self, client, github):
github.get.side_effect = [_response(200, USER), _response(404)]

response = client.get("/authorize", follow_redirects=True)

assert response.request.path == "/"
assert b"member of the bcgov/makeitso-users GitHub team" in response.data
assert b"Log in with GitHub" in response.data

def test_oauth_error_shows_error_page(self, client, github):
github.authorize_access_token.side_effect = OAuthError(description="bad code")

response = client.get("/authorize")

assert b"bad code" in response.data
with client.session_transaction() as session:
assert "user" not in session

def test_denied_when_team_not_configured(self, app, client, github, monkeypatch):
monkeypatch.setitem(app.config, "GITHUB_TEAM", None)
github.get.return_value = _response(200, USER)

response = client.get("/authorize")

assert response.status_code == 302
with client.session_transaction() as session:
assert "user" not in session
# Never asks GitHub about membership
assert github.get.call_count == 1
25 changes: 25 additions & 0 deletions tests/main/test_routes.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
from tests.factories import StackFactory


class TestNavbar:
def test_logged_in_shows_account_menu(self, client):
with client.session_transaction() as session:
session["user"] = {
"login": "octocat",
"name": "Mona Lisa",
"avatar_url": "https://avatars.example/octocat",
"html_url": "https://github.com/octocat",
}

response = client.get("/")

assert b'src="https://avatars.example/octocat"' in response.data
assert b"Mona Lisa" in response.data
assert b'href="https://github.com/octocat"' in response.data
assert b"@octocat" in response.data
assert b'action="/logout"' in response.data

def test_logged_out_has_no_account_menu(self, client):
response = client.get("/")

assert b"Account menu" not in response.data
assert b'action="/logout"' not in response.data


class TestHealthz:
def test_ok(self, client):
response = client.get("/healthz")
Expand Down
Loading