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: 1 addition & 1 deletion template/README.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ https://github.com/organizations/{% endraw %}{{ repo_org_name }}{% raw %}/settin

You'll need to create a Personal Access Token (PAT) to allow Pulumi to manage the GitHub Organization.

Token permissions needed: All repositories, Administration: Read & write, Environments: Read & write, Contents: read & write. Organization: Members Read&Write{% endraw %}{% if use_repo_secret_for_github_iac_tokens %}{% raw %}
Token permissions needed: All repositories, Administration: Read & write, Environments: Read & write, Contents: read & write. Organization: Members Read&Write, Custom organization roles: Read & write (only needed if org_role_assignments are configured){% endraw %}{% if use_repo_secret_for_github_iac_tokens %}{% raw %}
In the repository settings, create both a Repository Secret and a Dependabot Secret with the name `IAC_GITHUB_API_TOKENS`. The value of the secret should be formatted like this: `{"org_tokens": {"MyGithubOrganizationName": {"deploy_token": "my-deploy-token"}}}`{% endraw %}{% else %}{% raw %}
After the initial deployment which creates the secret, go in and use the Manual Secrets permission set to update the secret with the real token, then you can create repos{% endraw %}{% endif %}{% raw %}{% endraw %}{% endif %}{% raw %}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
from .repo import GithubRepo
from .repo import GithubRepoConfig
from .repo import create_repos
from .teams import ALL_REPOSITORY_TRIAGE_ROLE
from .teams import GithubOrgAdminAsTeamMemberError
from .teams import GithubOrganizationRoleNotFoundError
from .teams import GithubOrgMembers
from .teams import GithubOrgRoleUserNotInOrgMembersError
from .teams import GithubTeamConfig
from .teams import GithubTeamMemberNotInOrgMembersError
from .teams import RepositoryName
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from .repo import create_repos
from .teams import GithubOrgMembers
from .teams import GithubTeamConfig
from .teams import create_org_role_assignments
from .teams import create_teams

logger = logging.getLogger(__name__)
Expand All @@ -46,7 +47,7 @@ def build_org_github_resources( # noqa: PLR0913 # this is a lot of arguments, b
tokens_created: bool,
) -> None:
if not USE_REPO_SECRET_FOR_GITHUB_IAC_TOKENS:
# Token permissions needed: All repositories, Administration: Read & write, Environments: Read & write, Contents: read & write. Organization: Members Read&Write
# Token permissions needed: All repositories, Administration: Read & write, Environments: Read & write, Contents: read & write. Organization: Members Read&Write, Custom organization roles: Read & write (only needed if org_role_assignments are configured)
# After the initial deployment which creates the secret, go in and use the Manual Secrets permission set to update the secret with the real token, then you can create repos
_ = secretsmanager.Secret(
append_resource_suffix("github-deploy-access-token"),
Expand Down Expand Up @@ -82,6 +83,7 @@ def build_org_github_resources( # noqa: PLR0913 # this is a lot of arguments, b
org_name=org_name,
root_team_push_repos=root_team_push_repos,
)
create_org_role_assignments(org_members=org_members, provider=provider)
create_repository_collaborators(configs=collaborator_configs, provider=provider)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,32 @@

from ephemeral_pulumi_deploy import append_resource_suffix
from pulumi import ComponentResource
from pulumi import InvokeOptions
from pulumi import ResourceOptions
from pulumi_github import OrganizationRoleUser
from pulumi_github import Provider
from pulumi_github import Team
from pulumi_github import TeamMembers
from pulumi_github import TeamMembersMemberArgs
from pulumi_github import TeamRepository
from pulumi_github import get_organization_roles
from pydantic import BaseModel
from pydantic import Field

type RepositoryName = str
type GithubRepositoryPermission = Literal["pull", "triage", "push", "maintain", "admin"]
type OrganizationRoleName = str

# Predefined organization role, available on every GitHub plan, granting triage on every repo in the org, including future ones.
ALL_REPOSITORY_TRIAGE_ROLE = "all_repo_triage"


class GithubOrgMembers(BaseModel):
org_admins: list[str] = Field(default_factory=list)
everyone: list[str] = Field(default_factory=list)
org_role_assignments: dict[OrganizationRoleName, list[str]] = Field(
default_factory=dict[OrganizationRoleName, list[str]]
)


def ensure_full_repo_name(repo_name: str, *, org_name: str) -> str:
Expand Down Expand Up @@ -118,6 +128,20 @@ def __init__(self, *, username: str, team_name: str):
)


class GithubOrgRoleUserNotInOrgMembersError(Exception):
def __init__(self, *, username: str, role_name: str):
super().__init__(
f"User '{username}' assigned the organization role '{role_name}' is not listed in org_members.everyone or org_members.org_admins. Organization roles can only be granted to members of the organization."
)


class GithubOrganizationRoleNotFoundError(Exception):
def __init__(self, *, role_name: str, available_role_names: list[str]):
super().__init__(
f"Organization role '{role_name}' was not found in this GitHub organization. Available roles: {available_role_names}"
)


def _validate_team_members_in_org(configs: list[GithubTeamConfig], org_members: GithubOrgMembers) -> None:
all_org_usernames = set(org_members.org_admins) | set(org_members.everyone)
for config in configs:
Expand All @@ -126,6 +150,20 @@ def _validate_team_members_in_org(configs: list[GithubTeamConfig], org_members:
raise GithubTeamMemberNotInOrgMembersError(username=member, team_name=config.name)


def _role_id_for(*, role_name: str, role_ids: dict[OrganizationRoleName, int]) -> int:
if role_name not in role_ids:
raise GithubOrganizationRoleNotFoundError(role_name=role_name, available_role_names=sorted(role_ids.keys()))
return role_ids[role_name]


def _validate_org_role_users_in_org(org_members: GithubOrgMembers) -> None:
all_org_usernames = set(org_members.org_admins) | set(org_members.everyone)
for role_name, usernames in org_members.org_role_assignments.items():
for username in usernames:
if username not in all_org_usernames:
raise GithubOrgRoleUserNotInOrgMembersError(username=username, role_name=role_name)


def fully_configure_teams(
*,
configs: list[GithubTeamConfig],
Expand All @@ -134,6 +172,7 @@ def fully_configure_teams(
root_team_push_repos: list[str] | None = None,
) -> None:
_validate_team_members_in_org(configs, org_members)
_validate_org_role_users_in_org(org_members)

for config in configs:
admins_to_convert: list[str] = []
Expand Down Expand Up @@ -177,3 +216,26 @@ def create_teams( # noqa: PLR0913 # this is a lot of arguments, but they're all
if config.parent_team is None and config is not root_team:
config.parent_team = root_team
_ = GithubTeam(config=config, org_name=org_name, provider=provider)


def create_org_role_assignments(*, org_members: GithubOrgMembers, provider: Provider) -> None:
"""Grant organization-wide roles to individual members.

The numeric role IDs differ between organizations, so they are looked up by name at deploy time rather
than hardcoded. This requires the Custom organization roles permission: read on the preview token, and read &
write on the deploy token in order to assign them. It is distinct from the Members permission.
Pre-defined roles such as all_repo_triage exist on every GitHub plan, custom roles need Enterprise Cloud.
"""
if len(org_members.org_role_assignments) == 0:
return
available_roles = get_organization_roles(opts=InvokeOptions(provider=provider))
role_ids = {role.name: role.role_id for role in available_roles.roles}
for role_name, usernames in org_members.org_role_assignments.items():
role_id = _role_id_for(role_name=role_name, role_ids=role_ids)
for username in usernames:
_ = OrganizationRoleUser(
append_resource_suffix(f"{role_name.replace(' ', '-').lower()}-{username}", max_length=200),
login=username,
role_id=role_id,
opts=ResourceOptions(provider=provider),
)
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
from random import randint
from uuid import uuid4

import pytest

# separate internal imports from external imports with this comment, because otherwise ruff in the copier template doesn't recognize them as internal and reformats them
from aws_central_infrastructure.github_repos.lib import GithubOrgAdminAsTeamMemberError
from aws_central_infrastructure.github_repos.lib import GithubOrganizationRoleNotFoundError
from aws_central_infrastructure.github_repos.lib import GithubOrgMembers
from aws_central_infrastructure.github_repos.lib import GithubOrgRoleUserNotInOrgMembersError
from aws_central_infrastructure.github_repos.lib import GithubTeamConfig
from aws_central_infrastructure.github_repos.lib import GithubTeamMemberNotInOrgMembersError
from aws_central_infrastructure.github_repos.lib import fully_configure_teams
from aws_central_infrastructure.github_repos.lib.teams import _role_id_for
from aws_central_infrastructure.github_repos.lib.teams import ensure_full_repo_name
from aws_central_infrastructure.github_repos.teams import define_team_configs


Expand Down Expand Up @@ -60,6 +65,45 @@ def test_all_team_members_are_in_org_members(self):
fully_configure_teams(configs=team_configs, org_members=org_members, root_team=root_team)


class TestEnsureFullRepoName:
def test_Given_bare_repo_name__When_ensure_full_repo_name__Then_prefixes_passed_org(self):
org_name = str(uuid4())
repo_name = str(uuid4())

result = ensure_full_repo_name(repo_name, org_name=org_name)

assert result == f"{org_name}/{repo_name}"

def test_Given_already_qualified_repo_name__When_ensure_full_repo_name__Then_unchanged(self):
org_name = str(uuid4())
already_qualified = f"{uuid4()}/{uuid4()}"

result = ensure_full_repo_name(already_qualified, org_name=org_name)

assert result == already_qualified


class TestRootTeamPushRepos:
def test_Given_push_repos_provided__When_fully_configure_teams__Then_granted_push_on_root_team(self):
root_team = GithubTeamConfig(name=str(uuid4()), description=str(uuid4()))
push_repo = str(uuid4())
org_members = GithubOrgMembers()

fully_configure_teams(
org_members=org_members, configs=[], root_team=root_team, root_team_push_repos=[push_repo]
)

assert root_team.repo_permissions == {push_repo: "push"}

def test_Given_no_push_repos__When_fully_configure_teams__Then_root_team_has_no_grants(self):
root_team = GithubTeamConfig(name=str(uuid4()), description=str(uuid4()))
org_members = GithubOrgMembers()

fully_configure_teams(org_members=org_members, configs=[], root_team=root_team, root_team_push_repos=None)

assert root_team.repo_permissions == {}


class TestTeamMemberInOrgValidation:
@pytest.mark.parametrize("role_field", ["members", "maintainers"])
def test_Given_user_not_in_org__When_fully_configure_teams__Then_error(self, role_field: str):
Expand All @@ -85,3 +129,44 @@ def test_Given_user_in_org__When_fully_configure_teams__Then_no_error(self, role
)

fully_configure_teams(org_members=org_members, configs=[team], root_team=root_team)


class TestOrgRoleAssignmentValidation:
@pytest.mark.parametrize("org_field", ["everyone", "org_admins"])
def test_Given_assigned_user_in_org__When_fully_configure_teams__Then_no_error(self, org_field: str):
root_team = GithubTeamConfig(name=str(uuid4()), description=str(uuid4()))
username = str(uuid4())
role_name = str(uuid4())
org_members = GithubOrgMembers.model_validate(
{org_field: [username], "org_role_assignments": {role_name: [username]}}
)

fully_configure_teams(org_members=org_members, configs=[], root_team=root_team)

def test_Given_assigned_user_not_in_org__When_fully_configure_teams__Then_error(self):
root_team = GithubTeamConfig(name=str(uuid4()), description=str(uuid4()))
user_not_in_org = str(uuid4())
role_name = str(uuid4())
org_members = GithubOrgMembers(everyone=[str(uuid4())], org_role_assignments={role_name: [user_not_in_org]})

with pytest.raises(GithubOrgRoleUserNotInOrgMembersError, match=rf"{user_not_in_org}.*{role_name}"):
fully_configure_teams(org_members=org_members, configs=[], root_team=root_team)


class TestRoleIdLookup:
def test_Given_role_present__When_role_id_for__Then_returns_its_id(self):
role_name = str(uuid4())
role_id = randint(1, 100000)
role_ids = {str(uuid4()): randint(1, 100000), role_name: role_id}

result = _role_id_for(role_name=role_name, role_ids=role_ids)

assert result == role_id

def test_Given_role_absent__When_role_id_for__Then_error_names_role_and_available(self):
missing_role_name = str(uuid4())
present_role_name = str(uuid4())
role_ids = {present_role_name: randint(1, 100000)}

with pytest.raises(GithubOrganizationRoleNotFoundError, match=rf"{missing_role_name}.*{present_role_name}"):
_ = _role_id_for(role_name=missing_role_name, role_ids=role_ids)