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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ __pycache__
.venv
.vscode
*.log
*.pem
backend/data
backend/staticfiles
build
Expand Down
39 changes: 20 additions & 19 deletions .github/ansible/production/nest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,29 +28,30 @@
shell:
cmd: sed -i 's/\bnest-frontend\b/production-nest-frontend/' ~/frontend/Makefile

- name: Copy .env.backend
- name: Copy secrets
copy:
src: '{{ github_workspace }}/.env.backend'
src: '{{ github_workspace }}/{{ item }}'
dest: ~/
mode: '0400'
loop:
- .env.backend
- .env.cache
- .env.db
- .env.frontend
- .github.pem

- name: Copy .env.cache
copy:
src: '{{ github_workspace }}/.env.cache'
dest: ~/
mode: '0400'

- name: Copy .env.db
copy:
src: '{{ github_workspace }}/.env.db'
dest: ~/
mode: '0400'

- name: Copy .env.frontend
copy:
src: '{{ github_workspace }}/.env.frontend'
dest: ~/
mode: '0400'
- name: Clean up secrets
delegate_to: localhost
file:
path: '{{ github_workspace }}/{{ item }}'
state: absent
loop:
- .env.backend
- .env.cache
- .env.db
- .env.frontend
- .github.pem
run_once: true

- name: Copy crontab
copy:
Expand Down
37 changes: 18 additions & 19 deletions .github/ansible/staging/nest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,29 +34,28 @@
state: directory
mode: '0755'

- name: Copy .env.backend
- name: Copy secrets
copy:
src: '{{ github_workspace }}/.env.backend'
src: '{{ github_workspace }}/{{ item }}'
dest: ~/
mode: '0400'
loop:
- .env.backend
- .env.cache
- .env.db
- .env.frontend

- name: Copy .env.cache
copy:
src: '{{ github_workspace }}/.env.cache'
dest: ~/
mode: '0400'

- name: Copy .env.db
copy:
src: '{{ github_workspace }}/.env.db'
dest: ~/
mode: '0400'

- name: Copy .env.frontend
copy:
src: '{{ github_workspace }}/.env.frontend'
dest: ~/
mode: '0400'
- name: Clean up secrets
delegate_to: localhost
file:
path: '{{ github_workspace }}/{{ item }}'
state: absent
loop:
- .env.backend
- .env.cache
- .env.db
- .env.frontend
run_once: true

- name: Copy crontab
copy:
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/run-ci-cd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,6 @@ jobs:
echo "DJANGO_SETTINGS_MODULE=${{ secrets.DJANGO_SETTINGS_MODULE }}" >> .env.backend
echo "DJANGO_SLACK_BOT_TOKEN=${{ secrets.DJANGO_SLACK_BOT_TOKEN }}" >> .env.backend
echo "DJANGO_SLACK_SIGNING_SECRET=${{ secrets.DJANGO_SLACK_SIGNING_SECRET }}" >> .env.backend
echo "GITHUB_TOKEN=${{ secrets.NEST_GITHUB_TOKEN }}" >> .env.backend
echo "SLACK_BOT_TOKEN_T04T40NHX=${{ secrets.SLACK_BOT_TOKEN_T04T40NHX }}" >> .env.backend

# Cache
Expand Down Expand Up @@ -652,6 +651,8 @@ jobs:
echo "DJANGO_DB_PASSWORD=${{ secrets.DJANGO_DB_PASSWORD }}" >> .env.backend
echo "DJANGO_DB_PORT=${{ secrets.DJANGO_DB_PORT }}" >> .env.backend
echo "DJANGO_DB_USER=${{ secrets.DJANGO_DB_USER }}" >> .env.backend
echo "DJANGO_GITHUB_APP_ID=${{ secrets.DJANGO_GITHUB_APP_ID }}" >> .env.backend
echo "DJANGO_GITHUB_APP_INSTALLATION_ID=${{ secrets.DJANGO_GITHUB_APP_INSTALLATION_ID }}" >> .env.backend
echo "DJANGO_OPEN_AI_SECRET_KEY=${{ secrets.DJANGO_OPEN_AI_SECRET_KEY }}" >> .env.backend
echo "DJANGO_REDIS_HOST=${{ secrets.DJANGO_REDIS_HOST }}" >> .env.backend
echo "DJANGO_REDIS_PASSWORD=${{ secrets.DJANGO_REDIS_PASSWORD }}" >> .env.backend
Expand All @@ -661,7 +662,6 @@ jobs:
echo "DJANGO_SETTINGS_MODULE=${{ secrets.DJANGO_SETTINGS_MODULE }}" >> .env.backend
echo "DJANGO_SLACK_BOT_TOKEN=${{ secrets.DJANGO_SLACK_BOT_TOKEN }}" >> .env.backend
echo "DJANGO_SLACK_SIGNING_SECRET=${{ secrets.DJANGO_SLACK_SIGNING_SECRET }}" >> .env.backend
echo "GITHUB_TOKEN=${{ secrets.NEST_GITHUB_TOKEN }}" >> .env.backend
echo "SLACK_BOT_TOKEN_T04T40NHX=${{ secrets.SLACK_BOT_TOKEN_T04T40NHX }}" >> .env.backend

# Cache
Expand All @@ -684,6 +684,10 @@ jobs:
echo "NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }}" >> .env.frontend
echo "NEXTAUTH_URL=${{ secrets.VITE_API_URL }}" >> .env.frontend

# GitHub App private key
echo "${{ secrets.NEST_GITHUB_APP_PRIVATE_KEY }}" > .github.pem
chmod 600 .github.pem
Comment thread
arkid15r marked this conversation as resolved.

- name: Run Nest deploy
working-directory: .github/ansible
run: ansible-playbook -i inventory.yaml production/nest.yaml -e "github_workspace=$GITHUB_WORKSPACE"
Expand Down
93 changes: 93 additions & 0 deletions backend/apps/github/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""GitHub App authentication module."""

import logging
import os
from pathlib import Path

from django.conf import settings
from github import Auth, Github
from github.GithubException import BadCredentialsException

from apps.github.constants import GITHUB_ITEMS_PER_PAGE

logger = logging.getLogger(__name__)


class GitHubAppAuth:
"""GitHub App authentication handler."""

def __init__(self):
"""Initialize GitHub App authentication."""
self.app_id = settings.GITHUB_APP_ID
self.app_installation_id = settings.GITHUB_APP_INSTALLATION_ID
self.private_key = self._load_private_key()

self.pat_token = os.getenv("GITHUB_TOKEN")

if not self._is_app_configured() and not self.pat_token:
error_message = (
"GitHub App configuration is incomplete. "
"Please set GITHUB_APP_ID and GITHUB_APP_INSTALLATION_ID, "
"ensure backend/.github.pem file exists, "
"or provide GITHUB_TOKEN for PAT authentication."
)
raise ValueError(error_message)

def _is_app_configured(self) -> bool:
"""Check if GitHub App is properly configured."""
return all((self.app_id, self.private_key, self.app_installation_id))

def _load_private_key(self):
"""Load the GitHub App private key from a local file."""
try:
with (Path(settings.BASE_DIR) / ".github.pem").open("r") as key_file:
return key_file.read().strip()
except (FileNotFoundError, PermissionError):
return None

def get_github_client(self, per_page: int | None = None) -> Github:
"""Get authenticated GitHub client.

Args:
per_page: Number of items per page for pagination.

Returns:
Authenticated GitHub client instance.

Raises:
BadCredentialsException: If authentication fails.

"""
per_page = per_page or GITHUB_ITEMS_PER_PAGE

if self._is_app_configured():
logger.warning("Using GitHub App authentication")
return Github(
auth=Auth.AppInstallationAuth(
app_auth=Auth.AppAuth(
app_id=self.app_id,
private_key=self.private_key,
),
installation_id=int(self.app_installation_id),
),
per_page=per_page,
)

if self.pat_token:
logger.warning("Using GitHub PAT token")
return Github(self.pat_token, per_page=per_page)

raise BadCredentialsException(401, "Invalid GitHub credentials", None)


def get_github_client(per_page: int | None = None) -> Github:
"""Get authenticated GitHub client.

Args:
per_page: Number of items per page for pagination.

Returns:
Authenticated GitHub client instance.

"""
return GitHubAppAuth().get_github_client(per_page=per_page)
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
"""A command to update OWASP entities related repositories data."""

import logging
import os

import github
from django.core.management.base import BaseCommand
from github.GithubException import UnknownObjectException

from apps.github.auth import get_github_client
from apps.github.common import sync_repository
from apps.github.constants import GITHUB_ITEMS_PER_PAGE
from apps.github.utils import get_repository_path
from apps.owasp.models.project import Project

Expand Down Expand Up @@ -39,7 +37,7 @@ def handle(self, *args, **options) -> None:
"""
active_projects = Project.active_projects.order_by("-created_at")
active_projects_count = active_projects.count()
gh = github.Github(os.getenv("GITHUB_TOKEN"), per_page=GITHUB_ITEMS_PER_PAGE)
gh = get_github_client()

offset = options["offset"]
projects = []
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""A command to get GitHub App installation ID."""

import logging
import os
import sys
from pathlib import Path

from django.conf import settings
from django.core.management.base import BaseCommand
from github import Auth, GithubIntegration

logger = logging.getLogger(__name__)


class Command(BaseCommand):
help = "Get GitHub App installation ID for the configured app."

def add_arguments(self, parser):
"""Add command-line arguments to the parser.

Args:
parser (argparse.ArgumentParser): The argument parser instance.

"""
parser.add_argument(
"--app-id",
type=int,
help="GitHub App ID (overrides GITHUB_APP_ID environment variable)",
)
parser.add_argument(
"--private-key-file",
type=str,
help="Path to private key file (overrides default backend/.github.pem)",
)

def handle(self, *args, **options):
"""Handle the command execution.

Args:
*args: Variable length argument list.
**options: Arbitrary keyword arguments containing command options.

"""
# Get app ID from arguments or environment
app_id = options.get("app_id") or os.getenv("GITHUB_APP_ID")
if not app_id:
self.stderr.write(
self.style.ERROR(
"GitHub App ID is required. "
"Provide --app-id argument or set GITHUB_APP_ID environment variable."
)
)
sys.exit(1)

# Get private key from file
private_key_file = (
options.get("private_key_file") or Path(settings.BASE_DIR) / ".github.pem"
)
if not Path(private_key_file).exists():
self.stderr.write(
self.style.ERROR(
f"Private key file not found: {private_key_file}. "
"Please ensure the file exists and contains your GitHub App private key."
)
)
sys.exit(1)

try:
with Path(private_key_file).open("r") as key_file:
private_key = key_file.read().strip()
if not private_key:
self.stderr.write(
self.style.ERROR(f"Private key file is empty: {private_key_file}")
)
sys.exit(1)
except (FileNotFoundError, PermissionError) as e:
self.stderr.write(self.style.ERROR(f"Failed to read private key file: {e}"))
sys.exit(1)

try:
# Create GitHub App authentication
app_auth = Auth.AppAuth(app_id=int(app_id), private_key=private_key)

# Create GitHub Integration instance
gi = GithubIntegration(auth=app_auth)

# Get all installations
installations = list(gi.get_installations())

if not installations:
self.stdout.write(
self.style.WARNING(f"No installations found for GitHub App ID: {app_id}")
)
return

self.stdout.write(
self.style.SUCCESS(
f"Found {len(installations)} installation(s) for GitHub App ID: {app_id}"
)
)

for installation in installations:
self.stdout.write(f"Installation ID: {installation.id}")
if hasattr(installation, "account") and installation.account:
account_type = installation.account.type
account_name = getattr(installation.account, "login", "N/A")
self.stdout.write(f" Account: {account_name} ({account_type})")
self.stdout.write("")

except Exception as e:
self.stderr.write(self.style.ERROR(f"Failed to get installations: {e}"))
logger.exception("Error getting GitHub App installations")
sys.exit(1)
Loading