diff --git a/template/README.md.jinja b/template/README.md.jinja index 685ba91d..02fc7976 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -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 %} diff --git a/template/src/aws_central_infrastructure/{% if manage_github_repos %}github_repos{% endif %}/lib/__init__.py b/template/src/aws_central_infrastructure/{% if manage_github_repos %}github_repos{% endif %}/lib/__init__.py index eedeb0db..86ad03b9 100644 --- a/template/src/aws_central_infrastructure/{% if manage_github_repos %}github_repos{% endif %}/lib/__init__.py +++ b/template/src/aws_central_infrastructure/{% if manage_github_repos %}github_repos{% endif %}/lib/__init__.py @@ -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 diff --git a/template/src/aws_central_infrastructure/{% if manage_github_repos %}github_repos{% endif %}/lib/program.py b/template/src/aws_central_infrastructure/{% if manage_github_repos %}github_repos{% endif %}/lib/program.py index 3ef914a5..9130a380 100644 --- a/template/src/aws_central_infrastructure/{% if manage_github_repos %}github_repos{% endif %}/lib/program.py +++ b/template/src/aws_central_infrastructure/{% if manage_github_repos %}github_repos{% endif %}/lib/program.py @@ -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__) @@ -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"), @@ -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) diff --git a/template/src/aws_central_infrastructure/{% if manage_github_repos %}github_repos{% endif %}/lib/teams.py b/template/src/aws_central_infrastructure/{% if manage_github_repos %}github_repos{% endif %}/lib/teams.py index d6627e6e..0bcfd6fa 100644 --- a/template/src/aws_central_infrastructure/{% if manage_github_repos %}github_repos{% endif %}/lib/teams.py +++ b/template/src/aws_central_infrastructure/{% if manage_github_repos %}github_repos{% endif %}/lib/teams.py @@ -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: @@ -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: @@ -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], @@ -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] = [] @@ -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), + ) diff --git a/template/tests/unit/{% if manage_github_repos %}github_repos{% endif %}/test_teams.py b/template/tests/unit/{% if manage_github_repos %}github_repos{% endif %}/test_teams.py index 32e0c506..7a637280 100644 --- a/template/tests/unit/{% if manage_github_repos %}github_repos{% endif %}/test_teams.py +++ b/template/tests/unit/{% if manage_github_repos %}github_repos{% endif %}/test_teams.py @@ -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 @@ -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): @@ -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)