Skip to content

Commit d6dbc47

Browse files
committed
Takes org list for promotion, filters orgs updated/output by this subset. Other refactoring
1 parent e865044 commit d6dbc47

File tree

6 files changed

+143
-57
lines changed

6 files changed

+143
-57
lines changed

.gitignore

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
# Token file used in local testing
2-
token.txt
3-
unmanaged_orgs.txt
1+
# Token file, orgs list and security managers list
2+
*.txt
43

54
# CSV files
65
*.csv

README.md

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ The scripts will give you a list of all organizations in the enterprise as a CSV
77
> [!NOTE]
88
> This is an _unofficial_ tool created by Field Security Specialists, and is not officially supported by GitHub.
99
10-
:information_source: This uses the [security manager role](https://docs.github.com/en/organizations/managing-peoples-access-to-your-organization-with-roles/managing-security-managers-in-your-organization) and parts of the GraphQL API that is available in GitHub.com (free/pro/teams and enterprise), as well as GitHub Enterprise Server versions 3.5 and higher.
10+
> [!NOTE]
11+
> This uses the [security manager role](https://docs.github.com/en/organizations/managing-peoples-access-to-your-organization-with-roles/managing-security-managers-in-your-organization) and parts of the GraphQL API that is available in GitHub.com (free/pro/teams and enterprise), as well as GitHub Enterprise Server versions 3.5 and higher.
1112
1213
## Scripts
1314

@@ -30,19 +31,26 @@ The scripts will give you a list of all organizations in the enterprise as a CSV
3031
pip install -r requirements.txt
3132
```
3233

33-
1. Choose inputs as arguments to the script as follows:
34+
1. Choose inputs as arguments to the scripts as follows:
3435

35-
- the server URL (for GHES, EMU, or data residency) in `--github-url`
36+
- The server URL (for GHES, EMU, or data residency) in `--github-url`.
3637
- For GHEC this is not required.
37-
- call the script with the correct GitHub PAT
38-
- place it in `GITHUB_TOKEN` in your environment, or
39-
- create a file and save your token there to read it, and call the script with the `--token-file` argument
40-
- use the enterprise slug as the first argument in the promote/demote scripts
41-
- this is string URL version of the enterprise identity. It's available in the enterprise admin url (for cloud and server), e.g. `https://github.com/enterprises/ENTERPRISE-SLUG-HERE`.
42-
- for the security manager team script:
43-
- use the list of orgs output by `org-admin-promote.py` in `--unmanaged-orgs`
44-
- put the name of the security manager team and the team members to add in `--team-name` and `--team-members`.
45-
- If you are using GHES 3.15 or below, please use the `--legacy` flag to use the legacy security managers API.
38+
- Call the scripts with the correct GitHub PAT:
39+
- Place it in `GITHUB_TOKEN` in your environment, or
40+
- create a file and save your token there to read it, and call the script with the `--token-file` argument.
41+
- See progress with the `--progress` flag.
42+
- Promote/demote scripts:
43+
- Limit the promotion to a subset of organization slugs/names using the `--orgs` or `--orgs-file` arguments.
44+
- For `--orgs/-o`, list them space separated after the argument.
45+
- For `--orgs-file/-f`, put a new-line separated list of organizations in a file and provide the path.
46+
- Use the enterprise slug as the first argument:
47+
- This is string URL version of the enterprise identity. It's available in the enterprise admin url (for cloud and server), e.g. `https://github.com/enterprises/ENTERPRISE-SLUG-HERE`.
48+
- By default, a list of all of the organizations in scope, and the unmanaged set, will be output to `all_orgs.csv` and `unmanaged_orgs.txt` respectively.
49+
- You can use the `--orgs-csv` and `--unmanaged-orgs` arguments to place these elsewhere.
50+
- Security manager team script:
51+
- Put the name of the security manager team and the team members to add in `--team-name` and `--team-members`.
52+
- If you are using GHES 3.15 or below, use the `--legacy` flag to use the legacy security managers API.
53+
- Use the list of orgs output by `org-admin-promote.py` in `--unmanaged-orgs`, if you changed the output path.
4654
4755
1. Run them in the following order:
4856

manage-sec-team.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ def add_args(parser) -> None:
5757
action="store_true",
5858
help="Use legacy API endpoints to manage the security managers",
5959
)
60+
parser.add_argument(
61+
"--progress",
62+
action="store_true",
63+
help="Show progress information",
64+
)
6065
parser.add_argument(
6166
"--debug",
6267
"-d",
@@ -71,6 +76,7 @@ def make_security_managers_team(
7176
api_url: str,
7277
headers: dict[str, str],
7378
legacy=False,
79+
progress=False,
7480
) -> None:
7581
"""Create or update the security managers team in the specified organization."""
7682
security_manager_role_id: str | None = None
@@ -105,7 +111,8 @@ def make_security_managers_team(
105111

106112
# Create the team if it doesn't exist
107113
if sec_team_name not in teams_list:
108-
LOG.info("Creating team {}".format(sec_team_name))
114+
if progress:
115+
LOG.info("Creating team {}".format(sec_team_name))
109116
try:
110117
teams.create_team(api_url, headers, org_name, sec_team_name)
111118
except Exception as e:
@@ -130,11 +137,12 @@ def make_security_managers_team(
130137
security_manager_role_id,
131138
legacy=legacy,
132139
)
133-
LOG.info(
134-
"✓ Team {} updated as a security manager for {}".format(
135-
sec_team_name, org_name
140+
if progress:
141+
LOG.info(
142+
"✓ Team {} updated as a security manager for {}".format(
143+
sec_team_name, org_name
144+
)
136145
)
137-
)
138146
else:
139147
LOG.debug(
140148
"✓ Team {} already has the security manager role for {}".format(
@@ -153,14 +161,16 @@ def add_security_managers_to_team(
153161
sec_team_members: list[str],
154162
api_url: str,
155163
headers: dict[str, str],
164+
progress: bool = False
156165
) -> None:
157166
"""Add security managers to the specified team in the organization."""
158167
# Get the list of org members, adding the missing ones to the org
159168
org_members = organizations.list_org_users(api_url, headers, org_name)
160169
org_members_list = [member["login"] for member in org_members]
161170
for username in sec_team_members:
162171
if username not in org_members_list:
163-
LOG.info("Adding {} to {}".format(username, org_name))
172+
if progress:
173+
LOG.info("Adding {} to {}".format(username, org_name))
164174
try:
165175
organizations.add_org_user(api_url, headers, org_name, username)
166176
except Exception as e:
@@ -232,8 +242,12 @@ def main() -> None:
232242

233243
sec_team_members = []
234244
if args.sec_team_members_file:
235-
with open(args.sec_team_members_file, "r") as f:
236-
sec_team_members = [line.strip() for line in f if line.strip()]
245+
sec_team_members = util.read_lines(args.sec_team_members_file)
246+
247+
if not sec_team_members:
248+
LOG.error("⨯ No security team members found in file")
249+
return
250+
237251
elif args.sec_team_members:
238252
sec_team_members = args.sec_team_members
239253
else:
@@ -254,10 +268,10 @@ def main() -> None:
254268
org_name = org["login"]
255269

256270
make_security_managers_team(
257-
org_name, args.sec_team_name, api_url, headers, legacy=args.legacy
271+
org_name, args.sec_team_name, api_url, headers, legacy=args.legacy, progress=args.progress
258272
)
259273
add_security_managers_to_team(
260-
org_name, args.sec_team_name, sec_team_members, api_url, headers
274+
org_name, args.sec_team_name, sec_team_members, api_url, headers, progress=args.progress
261275
)
262276

263277

org-admin-demote.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,17 @@ def add_args(parser: ArgumentParser) -> None:
4040
required=False,
4141
help="File containing a GitHub Personal Access Token with admin:enterprise and read:org scope (or use GITHUB_TOKEN)",
4242
)
43-
4443
parser.add_argument(
4544
"--unmanaged-orgs",
4645
default="unmanaged_orgs.txt",
4746
help="Path to newline-delimited list of organization IDs to demote from (default: unmanaged_orgs.txt)",
4847
)
48+
parser.add_argument(
49+
"--progress",
50+
"-p",
51+
action="store_true",
52+
help="Show progress during demotion",
53+
)
4954
parser.add_argument(
5055
"--debug",
5156
"-d",
@@ -54,24 +59,19 @@ def add_args(parser: ArgumentParser) -> None:
5459
)
5560

5661

57-
def read_unmanaged_org_ids(path: str) -> List[str]:
58-
"""Return a list of non-empty lines from the unmanaged orgs file."""
59-
with open(path, "r", encoding="utf-8") as f:
60-
return [line.strip() for line in f if line.strip()]
61-
62-
6362
def demote_admin(
64-
api_url: str, headers: dict[str, str], enterprise_id: str, org_ids: Iterable[str]
63+
api_url: str, headers: dict[str, str], enterprise_id: str, org_ids: Iterable[str], progress: bool = False
6564
) -> None:
6665
"""Demote the enterprise admin from each organization ID provided."""
6766
org_ids_list = list(org_ids)
6867
LOG.info("Total count of orgs to demote admin from: {}".format(len(org_ids_list)))
6968
for i, org_id in enumerate(org_ids_list):
70-
LOG.info(
71-
"Removing from organization: {} [{}/{}]".format(
72-
org_id, i + 1, len(org_ids_list)
69+
if progress:
70+
LOG.info(
71+
"Removing from organization: {} [{}/{}]".format(
72+
org_id, i + 1, len(org_ids_list)
73+
)
7374
)
74-
)
7575
enterprises.promote_admin(
7676
api_url, headers, enterprise_id, org_id, "UNAFFILIATED"
7777
)
@@ -101,8 +101,13 @@ def main() -> None:
101101
api_url, args.enterprise_slug, headers
102102
)
103103

104-
unmanaged_orgs = read_unmanaged_org_ids(args.unmanaged_orgs)
105-
demote_admin(api_url, headers, enterprise_id, unmanaged_orgs)
104+
unmanaged_orgs = util.read_lines(args.unmanaged_orgs)
105+
106+
if not unmanaged_orgs:
107+
LOG.error("⨯ No unmanaged organizations found to demote admin from")
108+
return
109+
110+
demote_admin(api_url, headers, enterprise_id, unmanaged_orgs, args.progress)
106111

107112

108113
if __name__ == "__main__": # pragma: no cover

org-admin-promote.py

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ def add_args(parser: ArgumentParser) -> None:
3232
"enterprise_slug",
3333
help="Enterprise slug (after /enterprises/ in URL)",
3434
)
35+
parser.add_argument(
36+
"--orgs",
37+
"-o",
38+
nargs="*",
39+
required=False,
40+
help="List of organization slugs to promote on (default: all organizations)",
41+
)
42+
parser.add_argument(
43+
"--orgs-file",
44+
"-f",
45+
required=False,
46+
help="Path to file containing organization slugs to promote - line separated (default: all organizations)",
47+
)
3548
parser.add_argument(
3649
"--github-url",
3750
required=False,
@@ -52,6 +65,12 @@ def add_args(parser: ArgumentParser) -> None:
5265
default="all_orgs.csv",
5366
help="Output CSV file listing all organizations in the enterprise (default: all_orgs.csv)",
5467
)
68+
parser.add_argument(
69+
"--progress",
70+
"-p",
71+
action="store_true",
72+
help="Show progress during promotion",
73+
)
5574
parser.add_argument(
5675
"--debug",
5776
"-d",
@@ -70,10 +89,17 @@ def write_unmanaged_orgs(path: str, unmanaged_org_ids: List[str]) -> None:
7089

7190

7291
def promote_all(
73-
api_url: str, headers: dict[str, str], enterprise_slug: str, unmanaged_out: str
92+
api_url: str,
93+
headers: dict[str, str],
94+
enterprise_slug: str,
95+
orgs_subset: set[str] | None,
96+
unmanaged_out: str,
97+
progress: bool = False,
7498
) -> List[str] | None:
7599
"""
76100
Promote the enterprise admin to owner on all unmanaged organizations.
101+
102+
If a subset of organizations is provided, only attempt to promote on those; otherwise, try on all organizations.
77103
"""
78104
total_org_count = organizations.get_total_count(api_url, enterprise_slug, headers)
79105
if total_org_count == 0:
@@ -85,31 +111,32 @@ def promote_all(
85111
"⨯ Total count of organizations returned by the query is different from the expected count"
86112
)
87113
return None
88-
LOG.info(
89-
"Total count of organizations returned by the query is: {}".format(
90-
total_org_count
91-
)
92-
)
114+
LOG.info("Total organizations: {}".format(total_org_count))
115+
116+
if orgs_subset is not None:
117+
orgs = [org for org in orgs if org["node"]["login"] in orgs_subset]
118+
119+
LOG.info("Organizations in scope: {}".format(len(orgs)))
120+
93121
enterprise_id = enterprises.get_enterprise_id(api_url, enterprise_slug, headers)
94122
unmanaged_orgs = [
95123
org["node"]["id"] for org in orgs if not org["node"]["viewerCanAdminister"]
96124
]
97-
LOG.info(
98-
"Total count of unmanaged organizations to be promoted on: {}".format(
99-
len(unmanaged_orgs)
100-
)
101-
)
125+
if not unmanaged_orgs:
126+
LOG.info("No organizations to promote on")
127+
return []
128+
129+
LOG.info("Unmanaged organizations to promote on: {}".format(len(unmanaged_orgs)))
102130
for i, org_id in enumerate(unmanaged_orgs):
103-
LOG.info(
104-
"Promoting to owner on organization: {} [{}/{}]".format(
105-
org_id, i + 1, len(unmanaged_orgs)
131+
if progress:
132+
LOG.info(
133+
"Promoting to owner on organization: {} [{}/{}]".format(
134+
org_id, i + 1, len(unmanaged_orgs)
135+
)
106136
)
107-
)
108137
enterprises.promote_admin(api_url, headers, enterprise_id, org_id, "OWNER")
109138
write_unmanaged_orgs(unmanaged_out, unmanaged_orgs)
110-
LOG.info(
111-
"Total count of newly managed organizations is: {}".format(len(unmanaged_orgs))
112-
)
139+
LOG.info("Promoted on organizations: {}".format(len(unmanaged_orgs)))
113140
return unmanaged_orgs
114141

115142

@@ -132,12 +159,21 @@ def main() -> None:
132159
"Authorization": f"token {github_pat}",
133160
}
134161

162+
orgs_subset_list: list[str] | None = (
163+
args.orgs or util.read_lines(args.orgs_file) or None
164+
)
165+
orgs_subset: set[str] | None = (
166+
set(orgs_subset_list) if orgs_subset_list is not None else None
167+
)
168+
135169
if (
136170
promote_all(
137171
api_url,
138172
headers,
139173
args.enterprise_slug,
174+
orgs_subset,
140175
args.unmanaged_orgs,
176+
args.progress
141177
)
142178
is None
143179
):
@@ -146,6 +182,11 @@ def main() -> None:
146182

147183
# Refresh and write all orgs CSV after promotions
148184
orgs = organizations.list_orgs(api_url, args.enterprise_slug, headers)
185+
186+
# Filter by the list of orgs, if provided
187+
if orgs_subset is not None:
188+
orgs = [org for org in orgs if org["node"]["login"] in orgs_subset]
189+
149190
organizations.write_orgs_to_csv(orgs, args.orgs_csv)
150191

151192

src/util.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
import os
66
from urllib.parse import urlparse
7+
import logging
8+
9+
10+
LOG = logging.getLogger(__name__)
711

812

913
def read_token(token_file: str | None) -> str | None:
@@ -27,6 +31,21 @@ def read_token(token_file: str | None) -> str | None:
2731
return token
2832

2933

34+
def read_lines(input_path: str | None) -> list[str] | None:
35+
"""
36+
Read a file and return a list of lines.
37+
"""
38+
if not input_path:
39+
return None
40+
41+
try:
42+
with open(input_path, "r", encoding="utf-8") as f:
43+
return [line.strip() for line in f.readlines() if line.strip()]
44+
except (FileNotFoundError, IsADirectoryError) as err:
45+
LOG.error(f"⨯ File error: {input_path}: {err}")
46+
raise err
47+
48+
3049
def add_request_headers(headers: dict[str, str]) -> dict[str, str]:
3150
"""
3251
Add required headers to the request headers.

0 commit comments

Comments
 (0)